☰
Proteus仿真STM32跑FreeRTOS:双任务调度与优先级抢占实战
2026/10/5 11:40:17 网站建设 项目流程

这个系列写到第 10 篇,终于从裸机战场切到实时操作系统了。前面几篇聊过 GPIO、定时器、串口、中断这些基础外设,玩法再花哨,本质上还是一个超大的 while(1) 轮询逻辑,或者靠中断搞定一切。但真到项目里,任务一多,裸机那套就顶不住了。这篇我就用 Proteus 8.9 搭配 STM32CubeMX 生成的 HAL 库工程,把 FreeRTOS 在仿真环境里完整跑起来,从环境搭建、任务创建、时钟配置到常见坑位,一步不落。

为什么强调是在 Proteus 里跑 FreeRTOS?因为我最近收到不少留言,说手里暂时没有开发板,或者板子在路上,但已经急着想学 RTOS 的任务调度、优先级抢占、信号量这些概念。Proteus 正好能解决这个问题,它对 STM32F103 的仿真支持比较成熟,配合 HAL 库和 FreeRTOS,逻辑层面的东西几乎都能在电脑上验证。不过仿真和真机还是有差别的,尤其是时序精度,这个我后面会专门讲。下面先说环境。

1. 建好仿真三件套:Proteus 8.9、CubeMX、Keil

1.1 版本匹配与芯片模型准备

Proteus 8.9 是目前网上比较容易找到的版本,我实际使用下来也稳定。元件库直接搜 STM32F103C8 就能找到对应的仿真模型,这个型号是 LQFP48 封装,和 C8T6 的核心完全一致,RAM 20KB,Flash 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 Resonator,PLL 倍频到 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 改成 TIM6,HAL 库的 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_PRIORITIES,CubeMX 默认是 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,异步模式,波特率设 115200,PA9 是 TX,PA10 是 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 字节。如果任务里只是翻转 GPIO,128 足够;但如果加上 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,延时改成 2000ms,Task_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,最大的优势是让你在见到寄存器之前先理解“任务”这个概念。任务不是函数调用,而是调度器管理的执行单元,它们之间的关系由优先级和延时决定,不取决于代码的书写顺序。仿真环境把这种关系可视化后,后面无论你切换到哪块开发板、哪个编译工具链,这套思维都是通用的。实际动手时先跑通这个双任务示例,再往里面加队列和信号量,体会一下任务之间如何通信,这是从单片机迈向嵌入式系统开发最扎实的一条路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询