仿真器里跑 RTOS,十个里有八个是死在中断配置上,剩下两个是死在内存上。这话不是开玩笑,我把 Proteus 8.9 里的 STM32F103C8T6 和 FreeRTOS 折腾了一整晚,任务就是起不来,LED 死活不闪,串口一个字符都不吐,最后查出来的原因让我只想抽自己——当然,也让我彻底把 Cortex-M3 上跑 FreeRTOS 的注意点全捋明白了。这篇文章就把这次踩坑全过程写清楚,给正准备在 Proteus 里搞 STM32 仿真、又把 FreeRTOS 搬进去的朋友一条直达的路。
先说清楚这个项目要解决什么问题:在不需要真实硬件的情况下,用 Proteus 仿真 STM32F103C8T6 最小系统板,并在 Keil 里移植 FreeRTOS,跑起两个任务。目标是任务能调度、LED 按节奏翻转、串口能周期性打印。这事放到真实板子上十分钟搞定,但放到 Proteus 里情况就复杂了——仿真器的时钟模型、中断模型、内存模型都和真实芯片有细微差别,而这些差别恰恰是 FreeRTOS 这种对时序极度敏感的系统最容易翻车的地方。所以这篇文章的核心不是“怎么点灯”,而是“为什么我的 FreeRTOS 任务跑不起来”,以及如何一步步把这个问题从现象定位到根因。
无论你是刚接触 RTOS 的学生,还是想用仿真环境快速验证方案的在职工程师,这篇文章都值得花十分钟看完。我会把整个排查过程按思路、配置、故障、调试四条线拆开,该给参数给参数,该给代码给代码,绝不绕弯子。
1. 整体设计思路拆解
1.1 为什么用 Proteus 仿真 STM32F103C8T6 跑 FreeRTOS
先说个最直接的理由:不是每个人都有现成的开发板和 ST-Link。用 Proteus 仿真一方面能省下硬件成本,另一方面特别适合教学——学生不需要在实体板上飞线、接错烧毁,老师在讲 RTOS 调度原理时也能直接看着仿真实时状态讲,比 PPT 上画一百张状态机图都管用。
我自己用 Proteus 仿真的主要原因则是快速验证:写一个新模块的框架、验证 FreeRTOS 任务划分是否合理、测试某个外设驱动的大致逻辑,不一定非要上板子。只要电路模型选对、时钟配置正确,Proteus 跑 STM32F103C8T6 的仿真结果在“行为层面”是有参考价值的,尤其是在验证任务调度顺序、信号量互斥逻辑这种纯软件层面的事情上。
但这里必须提前打个预防针:Proteus 仿真和真实芯片之间有一个“等速”问题。真实 STM32F103C8T6 最高 72MHz,而 Proteus 的 VSM 模型是软件模拟,实际运行速度远低于真实芯片。这意味着你在仿真里看到的时序表现、任务切换细节,和硬件上是有偏差的。所以我的定位很明确:仿真器用来看“逻辑对不对”,不用来看“时间准不准”。
1.2 工具链选型:Proteus 8.9 + Keil uVision5 + FreeRTOS 版本
这次用的三个核心工具版本,我直接列出来,因为版本匹配直接决定你能不能少踩两个小时坑:
- Proteus 8.9 Professional,VSM 内置了 STM32F103C8T6(LQFP48)仿真模型,不需要额外装第三方库。
- Keil uVision5(MDK-ARM),配合 ARM Compiler 5 使用,因为我用的是标准外设库,不是 HAL 库。
- FreeRTOS 官方 V10.x 内核源码,移植层选用 portable/RVDS/ARM_CM3 目录(对应 Keil/ARMCC 编译器),堆管理用 heap_4.c。
为什么选择标准库而不是 HAL 库?原因只有一个:在 Proteus 仿真环境下,标准库的代码更直接、层级更少,出问题的时候更容易定位。要知道 FreeRTOS 本身就是直接在寄存器层操作 SysTick、NVIC、PendSV 的,标准库恰好也是这个风格,配合起来没有 HAL 库那种“中间层绑架”的麻烦。当然,你说用 HAL 库行不行?行,但必须额外处理 HAL_InitTick 和 FreeRTOS 抢占 SysTick 的问题,这一步不处理好就是大坑,后面我会单独讲。
1.3 仿真环境下 FreeRTOS 移植的特殊性
有人会问:FreeRTOS 移植到 STM32 不是有现成例程吗,直接抄不就完事了?答案是:直接抄真不一定能跑,因为移植要分三层看——CPU 移植层、系统时钟配置层、应用层。Proteus 仿真的特殊性恰恰集中在第二层。
FreeRTOS 在 Cortex-M3 上的移植层做的核心事情有三个:用 SVC 异常启动第一个任务、用 PendSV 异常做上下文切换、用 SysTick 作为系统时基。这三块都是源码写死的,一般不用动。但 CPU 的主频、SysTick 的重装载值、NVIC 的中断优先级分组这三项是和工程配置强相关的。你的 Keil 工程里 RCC 把 SYSCLK 配成 72MHz,Proteus 模型里却只有一个 8MHz 的外部晶振,甚至你在 Proteus 里压根没放晶振,那 FreeRTOS 用configCPU_CLOCK_HZ算出来的 tick 周期就是错的。这就是仿真的特殊之处:硬件上你把晶振焊上去就完事了,仿真里你得明确告诉 Proteus“这颗芯片外部挂了多大的晶振”。
记住这句话:Proteus 里的 STM32 仿真模型不会自动感知你的 Keil 工程配置,它只按自己模型参数和 hex 文件里的初始化代码来模拟。你代码里写的时钟配置和 Proteus 模型里设置的晶振频率必须匹配,否则你连 1ms 延时都是错的,更别提 FreeRTOS 的任务切换了。
2. 工程搭建与仿真电路实操
2.1 Proteus 电路搭建要点
在 Proteus 8.9 中新建工程后,从元件库搜索 STM32F103C8T6,拖到原理图上。这玩意儿是 LQFP48 封装,引脚密密麻麻的,但仿真里不用管封装细节,只需要关心我们必须连接的引脚。
我搭电路时按下面这个清单来,缺一不可:
- 电源和地:Proteus 的 STM32 模型默认会接收 VDD/GND,但为了保证仿真实实在在,我还是习惯在原理图上把 VDD 和 VDDA 接 +3.3V,VSS 和 VSSA 接地。这样后续如果想加虚拟电压表测功耗也方便。
- 复位电路:NRST 引脚接一个 10kΩ 上拉电阻到 3.3V,再接一个 100nF 电容到地。这是标准做法,防止仿真器里出现复位异常。
- BOOT0 引脚:必须通过电阻接地。BOOT0 拉高会导致芯片从系统存储器启动,那你的程序根本跑不起来。很多人在 Proteus 里漏掉 BOOT0,结果程序加载进去没反应,百思不得其解。
- 外部晶振(HSE):在 OSC_IN 和 OSC_OUT 之间放一个 8MHz 晶振,两个引脚各接一个 20pF 电容到地。这个晶振很重要,因为 Keil 工程默认是用 HSE 经 PLL 倍频到 72MHz 的;如果你在 Proteus 里不放晶振,就相当于真实板子没焊晶振,SystemInit 初始化时钟时会卡在等待 HSE 就绪的死循环里——程序整个锁死,任务当然跑不起来。
- 调试/指示电路:在 PB0 引脚接一个 LED(串联 330Ω 限流电阻到地),用来观察任务运行状态。再多接一个虚拟串口(Virtual Serial Terminal)到 USART1 的 TX/RX 引脚(PA9/PA10),用于打印调试信息。
电路搭好后有个小细节:Proteus 原理图里的 MCU 型号双击打开属性面板,在 Program File 选项卡可以加载编译好的 hex 文件。这个面板里还有 Clock Frequency 参数,默认是 8MHz,指的就是 OSC_IN 引脚上的外部晶振频率。你把它设成 8M,和你电路里放的 8MHz 晶振一致,不要乱改。
2.2 Keil 工程创建与 Hex 生成
Keil 工程结构上,我一般分四组文件:
- CMSIS 组:core_cm3.h、system_stm32f10x.c,用于提供 Cortex-M3 内核访问接口和系统时钟初始化。
- 标准外设库组:stm32f10x_gpio.c、stm32f10x_rcc.c、stm32f10x_usart.c 等,用哪个外设加哪个。
- FreeRTOS 内核组:tasks.c、queue.c、list.c、port.c(portable 下的 ARM_CM3)、heap_4.c。
- 用户代码组:main.c、FreeRTOSConfig.h、stm32f10x_it.c 等。
生成 hex 文件这一步要格外注意:在 Options for Target 对话框 -> Output 选项卡,必须勾选 Create HEX File。如果不勾选,Keil 只生成 axf/elf 文件,Proteus 无法直接加载。另外在 C/C++ 选项卡里,Compiler 我用的是 ARM Compiler 5(AC5),因为 FreeRTOS 的 portable/RVDS 层是给 ARMCC 用的,AC6 虽然也能编,但有些老版本的 port.c 在 AC6 下会有兼容警告,没必要给自己添堵。
编译无误后,从 Build Output 窗口记下这行信息:Program Size: Code=xxx RO-data=xx RW-data=xx ZI-data=xx。ZI-data 就是零初始化数据(含堆栈、全局变量、FreeRTOS 的堆)的大小,对 STM32F103C8T6 来说 RAM 只有 20KB,如果 ZI-data 算下来接近 18KB 以上,后面任务分配栈时就会很容易溢出,这也是一些“奇怪现象”的根源。先记下这个数字,后面排错用得上。
2.3 FreeRTOS 移植步骤与关键配置
FreeRTOS 源码可以从官网或 GitHub 下载,我用的版本是 V10.4.x,移植文件结构如下:
FreeRTOS/ ├── tasks.c ├── queue.c ├── list.c ├── portable/RVDS/ARM_CM3/port.c ├── portable/MemMang/heap_4.c └── include/(一堆头文件)把以上文件加入 Keil 工程之后,最核心的是 FreeRTOSConfig.h 配置头文件。这个文件决定了 FreeRTOS 怎么跑。我给出一份经过验证的、能在 Proteus 仿真环境下稳定运行的配置:
#define configUSE_PREEMPTION 1 #define configUSE_PORT_OPTIMISED_TASK_SELECTION 1 #define configCPU_CLOCK_HZ 72000000UL #define configTICK_RATE_HZ 1000 #define configMAX_PRIORITIES 5 #define configMINIMAL_STACK_SIZE 128 #define configTOTAL_HEAP_SIZE 8 * 1024 #define configUSE_IDLE_HOOK 1 #define configUSE_TICK_HOOK 0 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configKERNEL_INTERRUPT_PRIORITY 255 #define configMAX_SYSCALL_INTERRUPT_PRIORITY 191这个配置里有两个数字(255 和 191)我要专门解释一下。Cortex-M3 的 NVIC 中断优先级寄存器只使用了高 4 位,所以实际优先级数值范围按优先级分组可以是 0-15。FreeRTOS 要求内核自身使用的 PendSV 和 SysTick 必须配置为最低优先级,也就是数值最大,因此configKERNEL_INTERRUPT_PRIORITY取 255(向全 8 位寄存器写入 255,取高 4 位就是 0xF,即 15,最低优先级)。而configMAX_SYSCALL_INTERRUPT_PRIORITY取 191 是为了保证只有优先级数值小于等于 191(即实际优先级高于至少 1 级)的中断才能调用 FreeRTOS API,防止在临界区内被更高优先级中断打断造成死锁。这两个值如果你在别处看到的例程数值不一样,别慌,只要满足“内核中断优先级为最低、可调用系统 API 的中断优先级高于内核优先级”这个原则就行。
任务创建部分,我先用两个任务做最小验证:
static void vTaskLed(void *pvParameters) { GPIO_SetBits(GPIOB, GPIO_Pin_0); vTaskDelay(500); GPIO_ResetBits(GPIOB, GPIO_Pin_0); vTaskDelay(500); } static void vTaskPrint(void *pvParameters) { const char *msg = "FreeRTOS run on Proteus\r\n"; while (1) { UART_SendString(USART1, msg); vTaskDelay(1000); } }注意我故意不在 vTaskLed 里写 while(1),就是为了测试任务创建后是否会因异常退出。正常写法应该是 while(1) 包裹循环体,但作为问题排查,可以先暴露问题。
主函数调用:
int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4); SystemInit(); GPIO_Config(); USART1_Config(); xTaskCreate(vTaskLed, "LED", 128, NULL, 1, NULL); xTaskCreate(vTaskPrint, "PRINT", 128, NULL, 2, NULL); vTaskStartScheduler(); while (1); }优先级实验配置里有一个“陷阱”先留在这里:LED 任务优先级为 1,打印任务优先级为 2。在不做任何延时的情况下,高优先级任务会独占 CPU——这也是判断调度器是否正常工作的一个关键观察点。
2.4 在 Proteus 里正确装载 Hex 文件
在 Proteus 原理图中双击 STM32F103C8T6 模型,在 Program Files 一栏选择 Keil 刚生成的 hex 文件,然后点击开始仿真。这里面有几个容易踩的坑:
- 如果点了仿真但 MCU 没反应,先检查 Program File 加载后有没有报错,h 文件路径不要有中文。
- Proteus 8.9 加载 hex 后默认是全速运行,不是单步调试。想单步得用调试工具,但 VSM 对 STM32 的调试支持一般,不如 Keil 的 Debugger 直观。所以我更推荐直接全速跑,靠输出现象判断。
- 如果程序里用了串口打印,记得在原理图上放一个 Virtual Terminal 或者 COMPIM 虚拟串口,波特率要和你代码里设置一致。Virtual Terminal 不需要配置波特率,它直接显示 TX 引脚输出的字符,非常好用。
3. FreeRTOS 任务“跑不起来”深层原因排查
3.1 症状归类:死机、只运行一个任务、切换异常
FreeRTOS 任务跑不起来的现象其实分好几类,对应原因完全不同。我踩坑当晚看到的表现,按严重程度可以分成这样几种:
- 死机型:程序烧进去后什么都没发生,LED 不亮不闪,串口无输出,在 Proteus 里看 PC 指针停在某个地址不动。这种情况十有八九是单片机根本没启动,或者启动后死在时钟初始化死循环里。
- 单任务型:只有一个高优先级任务一遍遍刷串口或者跑死循环,低优先级任务完全得不到执行。这种情况往往是优先级配置或者调度器没有真正开抢占,也可能是你先让高优先级任务用
while(1)裸跑而没有调用阻塞延时。 - 切换异常型:任务能启动,但切换节奏完全不对,比如 LED 应该 500ms 翻转一次,实际 5 秒才翻一次。这种情况多是时基不准,即
configCPU_CLOCK_HZ或 Systick 重装载值计算有误。
明确自己属于哪一类,排查方向就清晰了。我当时遇到的是“死机型”,所以优先检查了启动流程和时钟。
3.2 中断优先级:PendSV/SysTick/SVC 的配置坑
这是 FreeRTOS 移植到 Cortex-M3 最核心、也是最容易翻车的一点。要理解它,你得先接受一个事实:FreeRTOS 靠的不是什么魔法,而是三个异常——SVC、PendSV、SysTick。
- SVC 在
vTaskStartScheduler()启动时被触发,作用是把 CPU 控制权从启动代码切到第一个任务。 - PendSV 是用来做上下文切换的“替补”,它永远在空闲时执行任务切换。
- SysTick 产生系统 tick,每触发一次就检查是否有更高优先级任务需要抢占。
这三个异常有个共同要求:优先级必须是最低(数值最大)。为什么?因为如果 SysTick 和 PendSV 的优先级设得比某些外设中断高,那么这些外设中断就会被 RTOS 的时基和调度动作打断,破坏实时性,甚至导致死锁。
在 STM32 标准库下,初始化时设置中断优先级分组的地方通常长这样:
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);然后在 FreeRTOS 的vPortStartFirstTask和xPortStartScheduler内部会调用:
portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI; portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;这行汇编级别的操作直接把 PendSV 和 SysTick 优先级写到 NVIC 的系统异常优先级寄存器里,设置为最低。这部分一般不会出问题,因为你只要没有在别处把二者优先级改掉,FreeRTOS 自己会做好。
真正坑的地方在于:你的工程里可能在别处把 SysTick 的优先级改成最高了。常见来源有两个,一个是你自己写延时函数时调用了SysTick_Config()并手动NVIC_SetPriority(SysTick_IRQn, 0);另一个是 HAL 库的HAL_InitTick()。如果你把 SysTick 优先级设成了 0(最高),FreeRTOS 的时基中断就会不停地打断其他所有中断,甚至打断临界区,系统不是死锁就是调度乱套。我当时犯的错就是这个——为了跑一个 delay 函数,把 SysTick_Config 先配了一次,然后把优先级设为最高,再启动 FreeRTOS,结果第一个任务还没来得及切换就被 SysTick 刷屏,最后直接进 HardFault。
排查方法很简单:在 Keil 里搜索SysTick_Config和NVIC_SetPriority,把一切涉及 SysTick 和 PendSV 的优先级设置都展开来核对。FreeRTOS 启动后,SysTick 的优先级寄存器的值应该是 0xF0(高 4 位为 15),PendSV 同理。
3.3 SysTick 冲突:HAL_InitTick 与内核时基
如果你用的是 STM32 HAL 库,那么这里有一个经典的“隐形杀手”:HAL_InitTick()。它在HAL_Init()里被调用,会配置 SysTick 作为 HAL 的时基,默认 1ms 触发一次。而 FreeRTOS 也需要 SysTick 作为时基源,两个模块同时抢一个外设,结果只有一个:一个配置覆盖另一个,系统要么死循环,要么 tick 完全不按预期。
真实硬件上,很多人建议把 HAL 时基改成 TIM6 或者 TIM7,从而解放 SysTick 给 FreeRTOS 用。在 Proteus 仿真里,我这个工程用的是标准库,没有 HAL 的干扰,所以这个问题没遇到。但我必须提醒你:如果你是从 CubeMX 生成的 HAL 工程开始移植 FreeRTOS,而不是用标准库从零搭,那你几乎一定会碰到这个问题。
解决方法有两条路:一是把HAL_RCC_...初始化的 HAL 时基改用定时器,二是在main()里先调用HAL_Init()之后、启动 FreeRTOS 之前,通过HAL_InitTick(0)停掉 HAL 的 Systick 占用。但说句掏心窝的话,如果你要在 Proteus 里仿真 FreeRTOS,标准库是更省心的选择,不用和 HAL 周旋。
3.4 时钟频率与 CPU 主频配置差异
FreeRTOS 的vTaskDelay(500)到底等待多久,取决于configTICK_RATE_HZ和configCPU_CLOCK_HZ是否匹配真实运行频率。而 Proteus 仿真时钟问题的坑位就在这里。
STM32F103C8T6 的启动文件(startup_stm32f10x_md.s)会调用SystemInit(),这个函数在标准库的 system_stm32f10x.c 里。它会对 RCC 寄存器做初始化,把 HSE 作为时钟源,经过 PLL 倍频到 72MHz。而 Proteus 仿真模型在向你提供一个 8MHz 的外部晶振引脚模型时,需要你在原理图上真的放一个晶振,并且把属性栏的 Clock Frequency 设为和你的外部晶振一致。
如果 Proteus 模型里没有晶振或者频率和代码里假设的不一致,就会发生一个“隐藏故障”:SystemInit()在等待 HSE 就绪时,如果 HSE 始终没有就绪,代码就卡在while(!(RCC->CR & RCC_CR_HSERDY))的死循环里。界面看上起像程序没反应,甚至连仿真都动不了,实际上 CPU 正蹲在时钟初始化里一脸无辜。
我当时犯的错是图省事,在 Proteus 里没放晶振,心想仿真模型应该自己内部有时钟。结果程序加载后直接没反应。后来老老实实放了一个 8M 晶振,才真正跑起来。
另外,还有一个容易误导人的参数:Proteus MCU 属性栏里的 Clock Frequency 有时候也会被误填为 72MHz。如果外部实际晶振是 8MHz,而你在属性栏填 72MHz,就可能造成时钟比例错误。建议就填外部晶振的实际频率,代码里的 PLL 倍频由SystemInit()自己处理。
3.5 RAM/栈/堆溢出:C8 只有 20K
STM32F103C8T6“最小系统板”在网上被用来跑各种工程,但它的 SRAM 只有 20KB,Flash 只有 64KB。Proteus 的模型同样遵循这个规格。跑 FreeRTOS 时,每个任务都要有自己的栈,任务栈和内核堆都要从这 20KB 里划拨。你要是按开发板上那种豪放的风格给每个任务分配 2KB、4KB 栈,再一来四个任务,直接 16KB 没了,再算上中断栈、全局变量,20KB 瞬间见底。
FreeRTOS 的 heap 分配方式用的是 heap_4.c,它负责的是一个静态数组ucHeap[configTOTAL_HEAP_SIZE],这个数组是静态分配的,会在ZI-data里占空间。我在上文中配的是 8KB 堆,这在 STM32F103C8 上属于中等偏大,如果任务建得多,你很可能就得把它降下来。
栈溢出带来的问题非常隐蔽:任务可能在创建时还能运行,但稍一递归函数或者稍微用多一点局部变量,栈指针就冲出了 FreeRTOS 分配的内存块,覆盖了相邻任务的控制块或者内核数据,然后系统随机崩溃——表现就是任务时好时坏、有时候跑起来了下一秒死掉,排查起来极其痛苦。好在你可以在 FreeRTOSConfig.h 里开启栈溢出检测:
#define configCHECK_FOR_STACK_OVERFLOW 2然后在代码里把钩子函数实现:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { for (;;); }当任务栈溢出时,程序会跳进这个钩子,方便你确认问题是不是出在栈大小上。在 Proteus 里,如果程序跳进这个函数死循环,仿真画面就会出现“CPU 停在某个地址不动”的现象。判断方法是在 Keil 的调试里看 PC 指针指向的是不是这个钩子函数。
另外还有一个非常实用的判断方法:看 Keil 编译输出的 ZI-data 大小,然后结合configTOTAL_HEAP_SIZE计算总 RAM 占用。假设 ZI-data = 12KB(其中包含 8KB 的 FreeRTOS 堆和 2KB 左右全局变量),那么可供用户任务栈分配的剩余空间只有大概 8KB。如果每个任务栈 128 字(word,即 512 字节),创建 8 个任务也就 4KB,还算能接受。但如果你把每个任务栈开成 512 字(2KB),稍微几个任务就崩,所以要精打细算。
3.6 Proteus 对 Cortex-M3 仿真的特殊限制
这里单独说下 Proteus 8.9 在仿真 Cortex-M3 时的几个“脾气”,像极了老式调试器的怪癖:
第一,Proteus 的 VSM 仿真核心对 Cortex-M3 的支持是组件化模拟,不代表每个内部外设都 100% 精确。比如你跑一个复杂的 DSP 运算或者靠精确时间控制的协议,Proteus 可能表现不佳。对 FreeRTOS 来说,SysTick 和 NVIC 这两个关键外设的模型经过考验还是比较准的,所以 RTOS 的核心调度逻辑仿真没有问题。
第二,Proteus 仿真速度受代码量影响。代码里如果有大量浮点运算或者频繁中断,仿真器会明显变慢,看起来像是程序卡住了。要注意区分“仿真器慢”和“程序死掉”。一个技巧是在仿真运行时暂停(Pause),打开调试信息看 Simulation Log,里面会显示执行了多少指令周期,如果在不断增长说明程序还活着,只是慢。
第三,Proteus 的 STM32F103C8T6 模型默认的 Flash 大小是 64KB,Hex 文件超过 64KB 会加载失败或仿真死异常。好在 FreeRTOS 几个任务编译出来最多十几 KB,不用担心。
4. 调试技巧与常见问题速查
4.1 快速定位“是跑起来了还是死了”
在 FreeRTOS 工程里,我最常用的“体检法”是三步走:
第一步,裸机测试。先把 FreeRTOS 全部注释掉,main 里只做一个 GPIO 翻转延时循环,烧进去看 LED 是否闪烁。如果裸机都不亮,说明是电路、时钟或者 GPIO 配置的问题,别急着找 RTOS 的茬。
第二步,点亮一个“调试灯”。在vTaskStartScheduler()之前点亮另一个 LED,如果这个 LED 常亮,说明程序至少跑到了启动调度器这一行,之前没说的事故都排除。
第三步,在任务入口放一个调试点。比如在vTaskLed入口把 LED 点亮,如果 LED 亮了但后面不再变化,说明任务创建成功了,但调度器可能没有正常切换;如果 LED 一直不亮,说明任务压根没被调度到,或者调度器启动失败。
这三步能帮你快速缩小问题范围,原理很简单:每个阶段用 LED 把“程序执行到了”这个信号暴露出来。
4.2 用串口虚拟终端确认任务调度结果
GPIO 只能告诉你任务有没有被调度,要看到 FreeRTOS 的调度细节,最好的办法还是用串口打印。在 Proteus 原理图上放置 Virtual Terminal,把它的 RXD 引脚连到 STM32F103C8T6 的 PA9(USART1_TX),程序里发送调试字符串。
为了观察任务优先级抢占的效果,我把两个任务这样写:
static void vTaskHigh(void *pvParameters) { while (1) { printf("H"); } } static void vTaskLow(void *pvParameters) { while (1) { printf("L"); vTaskDelay(10); } }高优先级任务跑出来应该是一连串的 “HHHHH...”,低优先级任务要等高优先级任务进入阻塞才能运行。但注意不要把 printf 直接放进任务里大量打印,因为串口本身是慢速外设,打印太频繁会压缩任务调度的表现。实际测试时,我喜欢在任务里加一个运行计数器和 vTaskDelay,每 1000ms 打印一次:
static void vTaskPrint(void *pvParameters) { uint32_t count = 0; while (1) { printf("TaskPrint count=%lu\r\n", count++); vTaskDelay(1000); } }如果串口每隔大约 1000ms 打印一次,且计数递增,说明 SysTick 时基和任务调度都正常了。
4.3 常见问题速查表
下面这个表是我这次排错过程中整理出来的,很多问题不止出现一次,写成速查表方便以后对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 仿真开始后 LED 不亮、串口无输出 | 晶振未接或频率不匹配 | 检查 Proteus 是否放了 8MHz 晶振,属性频率是否一致 |
| 程序启动后卡在时钟初始化 | HSE 未就绪 | 确认启动文件里的 SystemInit 没有死循环 |
| vTaskStartScheduler 之后无反应 | 没有空闲任务或系统堆不足 | 检查 configMINIMAL_STACK_SIZE 和堆大小 |
| 只有一个高优先级任务在跑 | 低优先级任务被饿死 | 检查 vTaskDelay/yield 是否调用 |
| 任务切换时好时坏、随机卡死 | 栈溢出或 RAM 超出 20KB | 开启栈溢出检测,检查 ZI-data 和 map 文件 |
| 串口打印内容乱码 | 波特率不匹配 | 代码里波特率和虚拟终端设置要一致 |
| 仿真速度极慢 | 大量浮点运算或频繁打印 | 降低打印频率,减少无谓运算 |
4.4 调试 PB 值判断(个人经验补充)
这里额外分享一个我后来常用的调试技巧:在 Keil 的 Debug 模式下配合 Proteus 做联调,并不是必须的。你可以直接在 Proteus 里全速跑,然后用一个简单的方法估算任务是否在运行:观察 Simulation Log 中 “System Clock Frequency” 是否稳定输出,以及仿真计时器 Real Time 和 Wall Clock 的比值是否异常。当任务跑起来时,仿真器执行的指令量呈阶梯式增长,这一特征在 log 里非常明显。
另外,如果你在 Proteus 里看到仿真一直卡在某个地址,先别急着改代码。暂停仿真,打开“Debug > List File”窗口,找到当前 PC 对应的函数名,大概率可以定位到是 HardFault_Handler、vApplicationStackOverflowHook 还是 SystemInit 内部。很多次我以为是 FreeRTOS 的问题,结果发现是硬错误异常,真正的原因是个数组越界。
5. 最后的经验总结:这几点能让你少走弯路
Proteus 仿真 STM32F103C8T6 跑 FreeRTOS 这件事,本身不算复杂,但坑全都埋在“仿真环境与真实芯片的差异”上。最后把我这次踩坑后固化下来的几个习惯分享给你。
第一个习惯是,先最小化验证调度器,再往上加业务。我第一次移植 FreeRTOS 就直接把电平采集、OLED 显示、串口协议全堆上去,结果出了问题根本没法定位。后来改成只创建两个点灯任务,确认调度 OK 之后再逐步加模块,效率反而最高。
第二个习惯是,在 Proteus 仿真里不要用裸延时函数。裸延时函数通常用 SysTick 计数实现,跟 FreeRTOS 抢 SysTick 是原罪之一。入了 FreeRTOS 之后,所有需要延时的场景一律用vTaskDelay或xTaskDelayUntil,这样既遵循 RTOS 的调度规则,也能避免时基冲突。
第三个习惯是,把 FreeRTOSConfig.h 里的栈溢出检测常开。这个开关在调试阶段升级到configCHECK_FOR_STACK_OVERFLOW 2,发布到硬件前再关掉。仿真阶段最好一直开着,它帮你节省的时间绝对比那一点点性能开销值钱得多。
最后说一句:如果你也在 Proteus 里遇到过任务跑不起来的类似问题,别急着怀疑仿真器不可靠。大多数情况下,问题出在中断优先级、时钟配置和内存分配这三件事上。把这三件事逐项核对一遍,你的 FreeRTOS 任务就都跑起来了。仿真器是一面不错的镜子,它把你在真实板子上也可能踩的坑,提前照了出来。