2026/10/5 11:40:26

Proteus仿真STM32跑FreeRTOS:双任务调度与优先级抢占实战

Proteus仿真STM32跑FreeRTOS:双任务调度与优先级抢占实战 这个系列写到第 10 篇终于从裸机战场切到实时操作系统了。前面几篇聊过 GPIO、定时器、串口、中断这些基础外设玩法再花哨本质上还是一个超大的 while(1) 轮询逻辑或者靠中断搞定一切。但真到项目里任务一多裸机那套就顶不住了。这篇我就用 Proteus 8.9 搭配 STM32CubeMX 生成的 HAL 库工程把 FreeRTOS 在仿真环境里完整跑起来从环境搭建、任务创建、时钟配置到常见坑位一步不落。为什么强调是在 Proteus 里跑 FreeRTOS因为我最近收到不少留言说手里暂时没有开发板或者板子在路上但已经急着想学 RTOS 的任务调度、优先级抢占、信号量这些概念。Proteus 正好能解决这个问题它对 STM32F103 的仿真支持比较成熟配合 HAL 库和 FreeRTOS逻辑层面的东西几乎都能在电脑上验证。不过仿真和真机还是有差别的尤其是时序精度这个我后面会专门讲。下面先说环境。1. 建好仿真三件套Proteus 8.9、CubeMX、Keil1.1 版本匹配与芯片模型准备Proteus 8.9 是目前网上比较容易找到的版本我实际使用下来也稳定。元件库直接搜 STM32F103C8 就能找到对应的仿真模型这个型号是 LQFP48 封装和 C8T6 的核心完全一致RAM 20KBFlash 64KB跑 FreeRTOS 的两个任务示例绰绰有余。这里提醒一下版本匹配的问题。Proteus 的工程文件不向下兼容高版本保存过的 .pdsprj 低版本打不开。如果你下了别人分享的原理图一定要确认对方用的版本否则加载元件时经常报库不匹配。另外Proteus 支持导入 .dexpi 和 XML 格式的元件描述文件偶尔从网上下载到自定义元件时用得上放 Libraries 目录下刷新即可。但主要元件还是直接用库里的 STM32F103C8没必要折腾。开发环境我建议用 Keil MDK版本 5.2x 以上都行。CubeMX 用 6.x 问题不大生成的工程可以直接用 Keil 打开。编译器用默认的 AC5 或者 AC6 都可以Proteus 只关心最终烧进去的 hex 文件内容编译工具链不影响仿真所以 IAR 用户也不用慌步骤基本一致。1.2 CubeMX 工程参数时基、堆和任务配置CubeMX 里建工程的第一件事是选对 MCU 型号STM32F103C8T6然后进入时钟树配置。这里有个非常关键的选择SYS 页面里的 Timebase Source默认是 SysTick但只要启用了 FreeRTOS就必须把它改成 TIM6 或者 TIM7。具体原因我在第 2 节详细讲你先记住这个操作否则后面编译烧录进 Proteus代码一跑就进 HardFault。接着在 Middleware and Software Packs 里找到 FREERTOS勾选启用Interface 选择 CMSIS_V1。我推荐 V1 的原因很简单它在仿真环境里生成的内核代码更精简行为也更接近 FreeRTOS 原生 API 的文档描述。V2 在真机上用没问题但仿真环境中排查问题会多一层封装对初学者不友好。勾选之后FreeRTOS 配置页面里会自动生成一个默认任务 defaultTask。我建议直接把默认任务删掉后面我们新建两个自己控制的任务。启用 FreeRTOS 后FreeRTOSConfig.h 里的 configTOTAL_HEAP_SIZE 默认只有 3072 字节太小了跑两个任务加队列很容易堆不足直接改成 8192。F103C8 的 20KB RAM 完全放得下不用抠门。时钟树那边HSE 选择 Crystal/Ceramic ResonatorPLL 倍频到 72MHz。接下来要保证 Proteus 芯片模型的时钟频率和这里一致后面第 3 节会说。RCC 配置里 HSE 和 LSE 都按实际情况勾选如果代码里没用到 LSE直接禁用节省开销。2. HAL 工程里跑 FreeRTOS 的移植逻辑2.1 生成代码结构哪些文件是内核、哪些是业务CubeMX 生成工程后打开 Keil 的 Project 面板会看到 Middlewares 目录下面多了一整块 FreeRTOS 源码包括 tasks.c、queue.c、list.c、timers.c、port.c 这些。首次看到这么多 C 文件不要慌它们属于内核实现一般不需要动。真正要关心的是 Core/Inc 下的 FreeRTOSConfig.h以及 Core/Src 下的 freertos.c。freertos.c 是 CubeMX 为 RTOS 业务代码开的一扇门。里面有一个 MX_FREERTOS_Init() 函数main.c 在完成外设初始化后会调用它在这个函数里CubeMX 默认会创建任务。任务的具体入口由你配置时填的名字决定比如默认的 StartDefaultTask。main.c 里 while(1) 之前有 vTaskStartScheduler()这个调用会把 CPU 控制权完全交给 FreeRTOS 调度器之后 while(1) 基本就空转了。很多人不理解为什么 main 里还有个 while(1)其实就是给调度器兜底一旦 FreeRTOS 任务崩溃退出程序不会跑飞。CubeMX 生成的 freertos.c 里写代码有讲究。任务函数加进 /* USER CODE BEGIN Application/ 和 /USER CODE END Application */ 之间创建任务的代码放在 MX_FREORTOS_Init 的 USER CODE 区域。这样下次重新生成代码这些手写内容不会丢。2.2 SysTick 冲突的根因与解法这是 FreeRTOS 移植中遇到最多的问题也是很多人在 Proteus 里跑起来没反应或者 HardFault 的直接原因。HAL 库的 HAL_Delay、HAL_GetTick 默认依赖 SysTick 中断去递增 tick 计数而 FreeRTOS 内核默认也用 SysTick 作为系统节拍源。两边一抢结果就是 SysTick 被 FreeRTOS 接管后HAL 库的 tick 计数不再更新HAL_Delay 里的 while 循环永远等不到超时直接卡死严重的时候两个都去改 SysTick 寄存器一路进 HardFault。CubeMX 生成工程时如果你在 SYS 里把 Timebase Source 改成 TIM6HAL 库的 tick 就会改为由 TIM6 中断驱动FreeRTOS 继续使用 SysTick 做系统节拍。两个中断互不干扰这就是标准解法。如果你是自己手动移植 FreeRTOS 而不是用 CubeMX也会遇到同样问题。解决办法类似FreeRTOS 的 xPortSysTickHandler 要放在 SysTick_Handler 里面HAL 的 HAL_IncTick 则挪到 TIM6 中断回调去做。CubeMX 已经把这件事做好了但理解原理后真机调试时遇到问题才能快速定位。2.3 FreeRTOSConfig.h 的三处关键调整FreeRTOSConfig.h 是整个 RTOS 的行为配置文件CubeMX 生成时已经做了默认适配但有三个地方我建议手动确认。第一个是堆大小前文提过configTOTAL_HEAP_SIZE 从 3072 改到 8192。第二个是栈溢出检测configCHECK_FOR_STACK_OVERFLOW 改成 1 或 2同时把 vApplicationStackOverflowHook 这个钩子函数实现一下里面用串口打印或者翻转一个调试引脚。Proteus 仿真时内存越界不会立刻有提示开启检测后可以快速锁定是哪个任务栈不够了。第三个是 configUSE_TIME_SLICING保持 1。这个参数控制同优先级任务之间是否能按时间片轮转学习任务调度时很关键。另外注意 configMAX_PRIORITIESCubeMX 默认是 7意味着优先级数值范围是 0 到 6数字越大优先级越高。创建任务时优先级别填超过 6否则断言直接报错。还有 configUSE_TIMERS如果你用软件定时器就置 1并且确认 configTIMER_TASK_STACK_DEPTH 够用默认 256 个字一般没问题。3. Proteus 电路搭建与固件加载3.1 芯片、时钟与 LED 电路的搭建细节新建 Proteus 工程从元件库拖出 STM32F103C8。放置后双击芯片会看到一个属性对话框里面有个 Clock Frequency 选项默认 8MHz。如果你的 CubeMX 用的是 HSE 8MHz 进 PLL 到 72MHz这里保持 8MHz。如果 CubeMX 用的是 HSI 内部时钟也要把这里改成和实际一致不然仿真的时间基准会乱掉。供电和复位方面Proteus 的 STM32 模型内部已经处理了 VDD 供电不需要像 51 那样手动接 VCC。但复位引脚 NRST 建议接一个 10k 上拉电阻到 3.3V再加一个 100nF 电容到地这样复位的时序更接近真机。LED 电路很简单我用 PB0 和 PB1 两路各接一个 LED 和一个 470Ω 限流电阻到 GND。选引脚的时候要对照数据手册STM32F103C8 的 PB0 是第 18 脚PB1 是第 19 脚Proteus 原理图里的引脚排序和封装图一致但容易看错建议反键芯片查看引脚映射再连线。晶振电路我会放个 8MHz 晶振加两个 20pF 电容接在 OSC32不OSC_IN/OSC_OUT 是主晶振接在这里更接近真实电路虽然 Proteus 不接也能跑但接上可以避免一些奇怪问题。3.2 Keil 生成 HEX 并在 Proteus 中装载Keil 默认不会生成 hex 文件需要在 Options for Target 的 Output 页面勾选 Create HEX File然后重新编译。编译成功后在工程目录的 Objects 或 Output 文件夹下会看到 .hex 文件。这里有个小提醒工程路径千万不要有中文和空格否则 Keil 偶尔会在生成 hex 时报写入失败我一般习惯把所有工程放在 D:\Projects\ 这种纯英文路径下。回到 Proteus双击 STM32 芯片在 Program File 一栏选择刚才编译出的 hex 文件点 OK。芯片模型支持直接加载 hex 到内部 Flash 仿真不需要额外配置 boot 引脚。加载完成后点左下角运行按钮程序就会开始执行。Proteus 不像真机有烧录成功的提示它只是把 hex 装载到仿真 Flash 里运行后如果 LED 没反应不要怀疑烧录失败要去检查电路连接和时钟配置。另一个容易犯的错是加载了旧 hex编译失败后 Keil 里还是上一个版本的 hex 文件Proteus 加载的是过期固件自然看不到新代码的效果。每次重新编译前习惯性看一眼 Keil 的 Build Output 窗口确认“0 Error(s)”再装进 Proteus。3.3 利用虚拟终端观察任务运行FreeRTOS 跑起来之后光看两颗 LED 的闪烁虽然直观但信息量太少了。我建议加一个串口输出用 Proteus 的虚拟终端 VIRTUAL TERMINAL 看任务执行时序。CubeMX 里打开 USART1异步模式波特率设 115200PA9 是 TXPA10 是 RX。生成代码后在 Keil 里重定向 printf 到 USART1。Proteus 里放置 VIRTUAL TERMINAL把它的 RXD 引脚接到 STM32 的 PA9。双击虚拟终端把波特率改成 115200虚拟终端的默认波特率是 9600不改的话输出全是乱码。接下来在任务里用 printf 打印任务名称和当前 tick 值终端上就能看到任务执行的先后顺序。需要提醒的是FreeRTOS 多任务下 printf 不是线程安全的多个任务同时打印会出现字符交错。演示阶段我建议只让一个任务打印或者用一个全局互斥量保护 printf这个后面实操里会再提。4. 双任务 LED 控制器完整实操4.1 手动创建任务和用 CubeMX 生成任务怎么选CubeMX 的 FreeRTOS 配置页里其实可以可视化添加任务名字、优先级、栈大小都能填生成代码时会自动封装成 osThreadCreate 调用。这种方式很适合做项目时快速搭框架。但学习阶段我更推荐直接手写 xTaskCreate因为参数含义会过一遍脑子理解更深。我的做法是保留 CubeMX 生成的空任务框架在 freertos.c 的 USER CODE 区域自己写两个任务函数然后在 MX_FREERTOS_Init 里调用 xTaskCreate 创建。这样代码简单直接每个参数都是 FreeRTOS 原生语义网上查资料也更方便。如果你用 CubeMX 的任务配置页删掉默认的 defaultTask否则它会一直占资源干扰观察。栈大小这里多说一句。xTaskCreate 的 usStackDepth 参数单位是字不是字节。128 个字等于 512 字节。如果任务里只是翻转 GPIO128 足够但如果加上 printf我建议直接给 256 个字否则栈溢出风险很大。开篇我说把 configCHECK_FOR_STACK_OVERFLOW 打开就是为这种情况准备的。4.2 完整的任务代码与关键 API 说明下面给一份可直接编译的双任务代码两个任务分别控制 PB0 和 PB1 上的 LED一个 500ms 翻转一次一个 1000ms 翻转一次。任务函数和创建代码都写在 USER CODE 区域。/* USER CODE BEGIN Application */ #include stdio.h void Task_Red(void *argument) { (void)argument; for(;;) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0); printf([%lu] Red task running\r\n, (unsigned long)HAL_GetTick()); vTaskDelay(pdMS_TO_TICKS(500)); } } void Task_Blue(void *argument) { (void)argument; for(;;) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_1); printf([%lu] Blue task running\r\n, (unsigned long)HAL_GetTick()); vTaskDelay(pdMS_TO_TICKS(1000)); } } /* USER CODE END Application */创建任务放在 MX_FREERTOS_Init 里的 USER CODE 区域/* USER CODE BEGIN RTOS_THREADS */ xTaskCreate(Task_Red, Red, 256, NULL, 1, NULL); xTaskCreate(Task_Blue, Blue, 256, NULL, 1, NULL); /* USER CODE END RTOS_THREADS */GPIO 初始化在 CubeMX 里把 PB0、PB1 配成推挽输出即可HAL_GPIO_TogglePin 不需要额外初始化参数。两个任务优先级都填 1同优先级走时间片轮转。代码里最关键的是 vTaskDelay而不是 HAL_Delay。vTaskDelay 会让当前任务进入阻塞状态调度器趁机把 CPU 让给别的任务HAL_Delay 是忙等延时会一直占着 CPU。如果在 Task_Red 里用 HAL_Delay(500)Task_Blue 在这 500ms 里根本跑不动表现出来就是一颗灯亮时另一颗纹丝不动像单任务一样。4.3 仿真结果解读时间片轮转与优先级抢占编译加载后运行应该看到 PB0 的 LED 大约 1 秒一个周期500ms 亮 500ms 灭PB1 的 LED 大约 2 秒一个周期。虚拟终端上会出现交替打印的 Red 和 Blue 字样这代表两个任务在调度器管理下轮流执行。为了验证优先级抢占我把 Task_Red 的优先级改成 2延时改成 2000msTask_Blue 保持优先级 1、延时 500ms。重新编译加载终端上 Blue 会非常密集地打印Red 每 2 秒才出现一次。原理是 FreeRTOS 优先调度高优先级任务Task_Red 只要不是在延时阻塞状态就一定会抢占 CPU它延时期间Task_Blue 才有机会运行。这就是 RTOS 在 Proteus 里学习的最大价值。裸机逻辑里你想观察这种调度行为要么靠调试器打断点要么靠逻辑分析仪而在仿真里加几行 printf 就能把调度过程看得明明白白。后面你学队列、信号量、互斥量的时候也能用同样的方式观察阻塞和唤醒的过程。5. 常见问题与排查技巧实录5.1 编译报错无法创建 obj 目录Keil 编译时有时会报这个错.\obj\freertos.hex: error: Q0147E: failed to create directory .\obj\freertos。这个报错和 FreeRTOS 没关系是工程路径问题。工程所在路径有中文、空格或层级太长时Keil 无法创建输出文件夹就会出现这种诡异情况。解决办法是按顺序试手动在工程目录下新建一个 obj 文件夹在 Options for Target 的 Output 页面点 Select Folder for Objects把路径指到已有的 Output 文件夹最后可以考虑用管理员身份运行 Keil。我自己的工程常年放在 D:\Proj\ 这种短路径下面这个报错几乎没再出现过。5.2 LED 不闪、任务不切换的排查顺序Proteus 里加载 hex 后没有任何现象我建议按这个顺序排查。先双击芯片确认 Clock Frequency 和 CubeMX 的 HSE 配置一致8MHz 对 8MHz再检查 LED 接的引脚是不是初始化代码里配置成推挽输出的那两根STM32F103C8 的引脚编号特别容易看错然后确认 Keil 编译日志里是 0 Error并且 hex 文件时间戳是刚刚生成的。如果 LED 会亮但不会闪或者一直保持一个状态大概率是任务里的延时写成了 HAL_Delay。把所有 HAL_Delay 替换成 vTaskDelay(pdMS_TO_TICKS(...)) 再试。任务不切换还有一个常见原因任务函数没有 for(;;) 死循环执行完直接返回了FreeRTOS 里任务返回等同于系统崩溃调度器就停了。5.3 HAL_Delay 卡死与 HardFault 的定位HAL_Delay 在任务里一调用就卡住或者直接进 HardFault_Handler九成是 SysTick 时基冲突。前面讲过解法CubeMX 里把 SYS 的 Timebase Source 改成 TIM6 重新生成代码即可。改完之后如果还是进 HardFault检查一下是不是优先级超过了 configMAX_PRIORITIES。xTaskCreate 的优先级参数如果大于等于 7会触发 configASSERT在仿真里表现就是程序跑飞。还有一个思路在调试器里看 HardFault_Handler 栈回溯但 Proteus 里没有实时调试器所以更实用的做法是开启栈溢出检测先把问题定位到具体任务再说。5.4 仿真速度慢、时序偏差的应对思路Proteus 仿真是解释执行速度比真机慢不少特别是任务多、printf 频繁的时候明显能感觉到拖影和卡顿。不要在仿真里期待精确时序它更擅长验证逻辑正确性。如果要测延时是否准确不如直接上真机。想要仿真快一点可以把动画速度调到最高关闭不用的波形调试视图尽量减少 printf 输出。实测下来 printf 是最大的性能瓶颈用虚拟终端显示大量文本会严重拖慢仿真速度循环里打印改成只打印次数或者状态切换事件就好很多。下面整理一个速查表方便遇到问题时快速对照。阶段现象常见原因解决要点编译无法创建 obj/hex 目录路径含中文/空格/过长工程移到纯英文短路径仿真LED 不亮时钟频率不匹配/引脚接错核对芯片 Clock Frequency、引脚号仿真任务不切换误用 HAL_Delay换成 vTaskDelay运行HAL_Delay 卡死SysTick 时基冲突SYS 时基改成 TIM6运行HardFault任务栈溢出/优先级越界开启栈溢出检测、检查优先级范围仿真速度慢动画开销大/printf 频繁加快动画速度、减少打印最后再分享一点个人的理解。Proteus 里跑 FreeRTOS最大的优势是让你在见到寄存器之前先理解“任务”这个概念。任务不是函数调用而是调度器管理的执行单元它们之间的关系由优先级和延时决定不取决于代码的书写顺序。仿真环境把这种关系可视化后后面无论你切换到哪块开发板、哪个编译工具链这套思维都是通用的。实际动手时先跑通这个双任务示例再往里面加队列和信号量体会一下任务之间如何通信这是从单片机迈向嵌入式系统开发最扎实的一条路。