这个仿真系列写到第10篇了。前面9篇里,GPIO点灯、串口printf、定时器中断、ADC采样,都是用HAL库在Proteus里一步一步搭起来的,全程没碰真实开发板。到了这一篇,终于轮到FreeRTOS。说实话,Proteus上做FreeRTOS仿真比普通外设仿真要敏感得多——时钟配置、SysTick归属、堆栈参数,随便错一个,系统要么卡死在启动阶段,要么任务跑着跑着就hard fault。但好处也很明显:任务优先级的影响、时间片轮转的节奏、队列通信的数据流,这些抽象概念在仿真里变成LED闪烁和串口日志,看得见摸得着,比干读《FreeRTOS手册》强太多。
这篇东西适合三种人:一是刚接触RTOS、想先看调度效果再上手硬件的学生;二是手头没板子但想验证FreeRTOS移植思路的工程师;三是已经跟了这个系列、想把前面外设Demo升级成多任务工程的读者。我会把CubeMX配置、代码生成、Proteus图纸搭建到调试排错整个链路都过一遍,尤其后面那节排查记录,全是我自己跑仿真时踩过的坑。
1. 先说清楚:Proteus里跑FreeRTOS,到底图什么
1.1 仿真能验证的和验证不了的
很多工程师一上来就否定Proteus仿真:"这玩意根本做不了时序仿真,和真实硬件差远了。"这话对,但不全对。Proteus的STM32模型是基于指令级模拟的,确实做不到真实芯片那种精确到纳秒的外设时序,但它的优势在于:你能在5分钟内改一个任务优先级然后立刻看到行为变化,能在不烧录的情况下验证队列数据有没有按期望流转,能在一台没有开发板只有笔记本电脑的机器上完成整个RTOS入门学习。
具体到FreeRTOS场景,Proteus能验证的是这几类东西:
- 任务创建与调度的基本行为:高优先级任务是否抢占低优先级任务、同等优先级任务是否按时间片轮转;
- 阻塞与延时的逻辑:osDelay、信号量等待、队列读取这些阻塞点是否按预期挂起和恢复;
- 任务间通信的数据流:队列里放进的数据是否被正确消费,互斥锁有没有挡住共享资源冲突;
- 栈深度和堆大小够不够:在仿真环境里故意调大任务栈观察行为,比在硬件上看hard fault直观得多。
验证不了的东西也要心里有数:极端的实时性要求、中断响应时间、功耗,以及那些依赖芯片内部模拟特性的外设行为。把这几条边界想清楚,Proteus就能成为一个高效的学习验证工具,而不是一个"玩具"。
1.2 为什么这个系列坚持用HAL库
这个系列从第1篇开始就用HAL库,不是因为标准库不行,而是因为HAL库有完整的CubeMX图形化配置支持。做FreeRTOS移植,第一步永远是配置时钟树、配置外设、生成初始化代码,这些在CubeMX里就是勾几个选项的事。标准库当然可以移植FreeRTOS,但所有初始化要手写,光时钟配置就够写一屏,对学习RTOS本身没有帮助。
HAL库里还带了一个特别关键的机制——HAL_Delay和HAL_GetTick依赖一个1ms时基。默认情况下这个时基来自SysTick,而FreeRTOS调度器也需要SysTick来产生系统节拍。这两件事撞在一起,就是你后面会遇到的最大一个坑。HAL库的优势恰恰在于,它的时基源可以通过CubeMX一键切换,给FreeRTOS腾出SysTick。这个细节下一节重点拆。
2. CubeMX端配置:时间基准、堆大小与任务清单
2.1 SysTick让位:时间基准必须改到其他定时器
打开CubeMX,先按照前面9篇的套路选好STM32F103C8T6,配置好RCC。这里有个动作特别容易被忽略:在左侧引脚分类里找到SYS,把Timebase Source(时基源)从默认的SysTick改成TIM1或TIM2。我习惯用TIM1,因为它挂在APB2总线上,而且一般没有被外设占用,TIM2、TIM3留给编码器或者PWM更实用。
这个改动的原理是:HAL库需要一个1ms周期中断来驱动HAL_IncTick函数,累加出HAL_GetTick的毫秒数。SysTick刚好能干这个活,但FreeRTOS的vTaskDelay、任务切换同样依赖一个周期性中断,它也看上了SysTick。两个主人抢一个中断,结果就是系统跑一会儿就崩——这是FreeRTOS + HAL库最经典的冲突场景。把HAL的时基切到TIM1之后,SysTick_Handler中断就完全由FreeRTOS接管,CubeMX生成的代码里SysTick_Handler会自动调用xPortSysTickHandler(),两边各用各的定时器,互不干扰。
切换完之后注意一下生成的工程,CubeMX会额外生成stm32f1xx_hal_timebase_tim.c这类源文件,HAL_InitTick会被改写为用TIM1产生1ms中断。如果你在Keil的编译日志里没看到这个文件参与编译,那就说明你的时基没有真正切过来。
2.2 FreeRTOS参数里最关键的三个配置
在CubeMX左侧打开Middleware and Software Packs -> FreeRTOS,首先把Interface选成CMSIS_V2。这是ARM标准的CMSIS-RTOS v2 API,封装层更干净,osDelay、osMessageQueuePut这些函数都是v2风格。CMSIS_V1是老接口,虽然也能用,但新项目不建议再开倒车。
然后看Config parameters选项卡,重点看三个参数:
| 参数 | 推荐值 | 作用 |
|---|---|---|
| configTOTAL_HEAP_SIZE | 8192 ~ 12288 | FreeRTOS所有任务栈、队列、信号量共享的堆大小 |
| configCHECK_FOR_STACK_OVERFLOW | Enable | 上下文切换时检查任务栈是否越界,触发钩子函数 |
| configUSE_MALLOC_FAILED_HOOK | Enable | 堆耗尽时调用钩子,第一时间暴露内存不够 |
configTOTAL_HEAP_SIZE这个值,STM32F103C8T6有20KB SRAM,仿真里一般给8KB到12KB就够。你如果建了三四个任务外加一两个队列,8KB会比较紧张,给12KB比较稳。但这也不是越大越好,堆声明的是一个静态数组,直接占SRAM,给大了空闲时也白白耗着。
这几个配置对应了FreeRTOSConfig.h里的宏。CubeMX生成代码后你当然可以手改,但直接在图形界面配好,省得重新生成代码又被覆盖回去。
2.3 在CubeMX里直接定义任务还是手写
CubeMX的Tasks and Queues选项卡里可以预先定义任务和队列,生成代码时会直接生成对应的任务句柄和入口函数骨架。我推荐在CubeMX里先建好任务和队列,因为生成的骨架代码会主动挂在创建函数里,你只需要往入口函数里填业务逻辑,不会出现"忘了调用osThreadNew导致任务没创建"这种低级错误。
比如建两个任务:LED1_Task,优先级osPriorityNormal,栈大小128 words;UART_Task,优先级osPriorityBelowNormal,栈大小256 words——因为要调printf,栈需求偏大。再建一个队列myQueue,长度10,消息大小4字节,也就是放uint32_t用的。这些参数生成之后,main.c里能看到对应的句柄定义,入口函数在freertos.c里,直接往里面写字就行。
3. 代码侧:HAL库和FreeRTOS怎么协作
3.1 生成代码后的文件结构和入口
CubeMX生成的FreeRTOS代码分散在两个文件:main.c负责初始化所有外设然后调用MX_FREERTOS_Init(),freertos.c负责创建任务、队列、信号量,最后调用osKernelStart()启动调度器。main函数执行完osKernelStart()之后,程序控制权就交给调度器了,main函数后面再写任何代码都执行不到,这个要有个概念。
任务入口函数是void函数,带一个void* argument参数,函数内部几乎一定是个while(1)死循环,配合一个阻塞调用(osDelay、队列读、信号量等待)释放CPU。没有阻塞的话任务就一直占着CPU,同优先级甚至低优先级任务就没机会跑。很多初学者写RTOS任务喜欢在里面写一个for(;;)空转,也不放延时,结果另一个任务一动不动,这就是典型的调度没有被让出来。
3.2 用两个LED任务验证调度逻辑
先看最基础的验证方法。两个任务各自翻转一个LED,延时不同,如果两个LED都在按自己的节奏闪,说明调度器正常工作。
/* freertos.c 里两个任务入口 */ void LED1_Task(void *argument) { while (1) { HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin); osDelay(300); } } void LED2_Task(void *argument) { while (1) { HAL_GPIO_TogglePin(LED2_GPIO_Port, LED2_Pin); osDelay(700); } }两个任务优先级相同、都用osDelay阻塞自己,那么FreeRTOS就会按时间片轮转调度。LED1闪得快一点,LED2慢一点,两个都在动,这就能说明多任务在跑。
要观察抢占效果也很简单:建一个高优先级任务,在循环里做一堆空运算并且不延时,另一个低优先级任务闪灯。高优先级任务只要不需要等待,它就会一直霸占CPU,低优先级任务几乎抢不到执行时间,LED闪得极慢甚至不动。把高优先级任务里加一个osDelay,调度器立刻让出CPU,LED恢复正常闪烁。这个实验在真实板子上也能做,但Proteus里改优先级只需要在CubeMX下拉框里选一下,重新生成代码烧进去,对比特别直观。
3.3 队列收发:HAL与FreeRTOS共存的典型写法
任务调度跑通之后,队列通信是第二个必须练手的点。用CubeMX定义好的myQueue,代码里这样写生产者和消费者:
/* 生产者:定时往队列里塞一个递增计数 */ void Producer_Task(void *argument) { uint32_t cnt = 0; while (1) { osMessageQueuePut(myQueueHandle, &cnt, 0, 0); cnt++; osDelay(500); } } /* 消费者:阻塞等待队列数据,收到后通过串口打印 */ void Consumer_Task(void *argument) { uint32_t rx; char buf[32]; while (1) { if (osMessageQueueGet(myQueueHandle, &rx, 0, portMAX_DELAY) == osOK) { sprintf(buf, "rx: %lu\r\n", (unsigned long)rx); HAL_UART_Transmit(&huart1, (uint8_t*)buf, strlen(buf), 100); } } }注意osMessageQueueGet的最后一个参数是等待时间,portMAX_DELAY表示无限等待,任务在这里挂起,只有队列里有数据才被唤醒。这就是RTOS事件驱动模型最核心的一点:没有数据的时候任务不轮询、不空转,CPU时间留给其他任务。HAL_UART_Transmit是阻塞式的,在仿真里串口打印本身耗时,这会间接影响任务节拍,但用于观察数据流完全够用。
4. Proteus 8.9图纸搭建与hex装载
4.1 元件选型与最小系统连接
Proteus 8.9的元件库里已经带了STM32F103系列模型,搜索STM32F103C8就能找到。如果你搜不到,说明元件库不完整,需要重新安装Proteus的MCU库补丁,或者检查是不是装成了不含ARM模型的简化版。这一点很多新手卡了半天,先把这个确认了再继续。
图纸上放置STM32F103C8后,最少要接这几根线:
- VDD、VDDA接到3.3V电源网络,VSS、VSSA接到GND;
- NRST接一个10k电阻上拉到3.3V,保证上电不复位;
- BOOT0和BOOT1都接到GND,让芯片从内部Flash启动;
- 如果CubeMX里配置的是HSE外部晶振,图纸上必须在OSC_IN和OSC_OUT之间接一个8MHz晶振,两边各接一个20pF电容到地;
- 如果用的是HSI内部时钟,OSC引脚悬空就行。
4.2 时钟设置:仿真模型时钟与固件配置必须一致
这是Proteus跑FreeRTOS最容易翻车的点,单独拿出来说。STM32F103C8T6的最大主频是72MHz,CubeMX里用HSE 8MHz经过PLL倍频到72MHz,很常规的配置。但Proteus的STM32模型里有一个Clock Frequency属性,双击芯片在属性框里找到它,必须填成和固件实际运行时钟一致的值,也就是72MHz。填错会怎样?固件里osDelay(500)按72MHz的节拍来算,Proteus模型却按8MHz来模拟运行,所有时间相关的行为全部错乱,UART波特率也会不对,虚拟终端打出来全是乱码。
这里有个实操技巧:CubeMX里SYS的Debug选项如果配了Serial Wire,Proteus里也要把PA13/PA14留出来,否则仿真时芯片可能进不了正常模式。我在前几篇仿真里踩过这个坑,这次提醒一下。另外,Proteus虚拟仿真跑FreeRTOS时整体速度会比真实芯片慢,LED闪烁看起来会拖沓一点,这正常,不影响逻辑判断。
4.3 装载hex和启动仿真
Keil工程编译前,确认Output选项卡里勾选了Create HEX File,这样编译才会在.\obj\目录下生成hex文件。Keil里偶尔会报一个错误:error: q0147e: failed to create directory .\obj\freertos,这通常是因为工程名或输出路径里带了空格、中文,或者文件夹权限不对,把输出路径改短、保证全是英文就能解决。
在Proteus里双击芯片,Program File那一栏填上hex文件的完整路径,也可以点文件夹图标浏览选择。时钟频率按上一节说的填好,点左下角的播放按钮开始仿真。如果一切正常,两个LED按各自的频率闪起来,虚拟终端里能看到队列消费者打印出来的递增计数,这一套就算通了。
5. 实测中的坑:任务不跑、系统死机和栈溢出的完整排查
5.1 症状一:仿真窗口一切正常,但任务一个都不动
这个症状的出现最有迷惑性:Proteus里芯片引脚没有任何反应,也没有报错,程序就像困在某个死循环里。我遇到过两次,一次是CubeMX里配置了HSE外部晶振,但Proteus图纸上没放晶振,芯片时钟起不来,整个系统卡在SystemClock_Config的等待HSE就绪死循环里;另一次是Proteus芯片属性里Clock Frequency填的和固件不一致,FreeRTOS的系统节拍算出来的时间全不对,任务也处于一种奇怪的僵持状态。
排查顺序可以固定下来:先看仿真运行后芯片有没有进入主函数,最简单的方法是临时在系统启动完成之后、创建任务之前翻转一个GPIO接LED。LED不亮,说明卡在更早的位置,优先检查时钟配置和芯片启动条件;LED亮了但任务没动作,说明卡在任务创建或调度器启动这一步。第二步检查fill波形,如果HSE有问题,Proteus的仿真日志里通常会有振荡器相关的警告,把晶振加上再去掉,对比一下就能确认。
5.2 症状二:一调用HAL_Delay就卡死
这个坑上一节已经埋了伏笔。如果在CubeMX里没有改SYS的Timebase Source,让HAL库继续用SysTick作为时基,同时又打开了FreeRTOS,那么生成的代码里SysTick_Handler直接被FreeRTOS占用,HAL_IncTick永远得不到调用。后果就是:HAL_GetTick永远返回0,HAL_Delay内部while循环条件永远不满足,程序死在延时函数里。你的任务可能正常运行一两次,然后一碰到某个函数里的HAL_Delay就再也回不来。
这个问题的根源就是时基冲突。解决办法在CubeMX的SYS设置里,把Timebase Source从SysTick改成TIM1,重新生成代码后,你会发现stm32f1xx_it.c里的SysTick_Handler变成了调用xPortSysTickHandler(),而TIM1的中断处理函数负责调用HAL_IncTick。两个中断各司其职,HAL_Delay恢复正常。这个坑排查起来其实非常简单,但没搞清楚原理时很容易在那里瞎试。
5.3 症状三:任务跑一会儿就hard fault
任务能跑但跑一会儿就进入HardFault_Handler,最常见的元凶就是任务栈溢出。FreeRTOS给每个任务单独分配栈空间,你在CubeMX里填任务参数时的Stack Size就是它,单位是word,不是字节。如果默认填了128 words,也就是512字节,任务里又用了sprintf这类吃栈大户,栈很容易越界。
前面让在CubeMX里打开configCHECK_FOR_STACK_OVERFLOW,就是在这里发挥作用的。栈溢出检测开启后,FreeRTOS会在上下文切换时检查栈是否越界,一旦发现就调用vApplicationStackOverflowHook。自己动手实现这个钩子,把错误状态变得可视化:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { /* 死循环里闪烁错误LED,方便在仿真里观察 */ while (1) { HAL_GPIO_TogglePin(ERR_LED_GPIO_Port, ERR_LED_Pin); HAL_Delay(100); } }在仿真里看到错误LED闪烁,就知道某个任务栈不够了,把对应任务的Stack Size从128翻到256再试。除了这招,另一个更精准的手段是调用uxTaskGetStackHighWaterMark,它可以查到任务历史最低剩余栈空间:
/* 在主任务里打印每个任务的栈余量,HighWaterMark单位也是word */ printf("led1 hwm: %u\r\n", (unsigned int)uxTaskGetStackHighWaterMark(LED1_TaskHandle));把这个函数放到周期任务里周期性打印,观察每个任务栈余量有没有逼近0,就可以把一个任务的实际栈需求测出来,再反推CubeMX里该配多少。这个接口对排查"任务跑着跑着崩了"非常有用,比猜参数强多了。
5.4 在Proteus里怎么"抓"正在跑哪个任务
FreeRTOS在仿真里不像Keil的RTX调试插件那样有现成的任务列表窗口,但有几个土办法可以绕。最简单的就是在任务切换点翻转一个GPIO,用Proteus左侧工具栏里的虚拟示波器挂上去看波形。比如在LED1_Task里把PB0拉高,在LED2_Task里把PB0拉低,示波器上能看到方波的上下沿密度,就能直观看出两个任务谁占用CPU多。
再就是串口日志里故意打出任务名:每个任务进入循环体时向UART打印一行标识,把波特率调到115200接上虚拟终端,任务切换序列一目了然。这个方法只适合调试,日志本身会占用额外CPU时间、改变时序,但观察调度逻辑足够了。在仿真里养成这个习惯后,上真机也照样用。
6. 仿真和真实硬件的差距,心里要有个数
跑通了Proteus上的FreeRTOS,不代表嵌入式就入门了,但至少把RTOS最核心的调度逻辑、任务通信、内存管理这些抽象概念建立起来了。在仿真里学到的"任务要有阻塞点"“高优先级会抢占"“栈空间要留余量”,这些理念拿到真实板子上同样成立。
区别在于,真实芯片上你会遇到仿真里遇不到的问题:中断优先级分组对FreeRTOS临界区的影响、看门狗喂狗跟任务时序的纠缠、DMA中断和任务切换之间的竞态……这些问题都得靠真机调。Proteus的价值是把前面80%的逻辑性错误挡在电脑里,让你上真机时只专心对付剩下20%跟硬件强相关的问题。
根据我个人的体会,最舒服的学习路径是:Proteus里把任务调度和队列通信练熟,形成直觉,然后再买一块开发板,把同一个工程烧进去,观察真实时序和仿真有多大差异。你在仿真里用uxTaskGetStackHighWaterMark测出来的栈水位,和真机上测的基本一致,这类经验可以直接迁移。最后再分享一个小技巧,FreeRTOS的堆状态可以用xPortGetFreeHeapSize()周期打印,堆余量如果持续下降而不是稳定在一个值,说明你的系统里存在消息或内存泄漏,这个在仿真里暴露得比真机还快,值得多用。
这个系列到这篇为止,仿真环境下的基础外设和RTOS就都覆盖了。下一篇我打算把FreeRTOS和前面写的串口命令解析结合起来,做一个能在虚拟终端里动态查看任务状态的小工具,那样调试起来会更顺手。如果你正卡在某个和这篇相关的坑上,欢迎在评论里把现象发出来,我看到会回的。