去年年底接手 P5 这个项目的时候,板子上的固件还是一个大while(1)里塞了三十几个状态标志位,串口、按键、屏幕刷新、参数存储全挤在一起。那种代码跑是能跑,但只要其中一个环节耗时多一点,屏幕就开始卡,按键就开始丢,后面加一个功能就得把整个循环捋一遍。所以这次重构,我把 freeRTOS 和软件架构绑在一起做,先定分层和任务模型,再往里面填 OS 的调度、队列、信号量,最后才是具体业务。这套打法在 STM32F103C8T6 这种 64KB Flash、20KB RAM 的小容量芯片上一样跑得动,实测下来内核加应用常驻 RAM 控在 6KB 左右。
这篇文章想聊的不是"怎么把 FreeRTOS 编译通过",那个半个下午就能搞定。我想聊的是:当你已经能跑通点灯任务之后,任务怎么切、优先级怎么排、栈给多大、队列和信号量到底该用哪个、分层怎么分才不会半年后自己都看不懂。这些内容偏工程判断,文档里要么不提,要么一笔带过,但恰恰是决定项目能不能维护三年的关键。适合刚把 FreeRTOS 跑起来准备做实际项目的朋友,也适合做了几个项目但总觉得代码越写越乱的同行。
1. FreeRTOS 加软件架构,到底要解决什么问题
1.1 裸机大循环的三条死线
裸机大循环不是不能用,我在很多小项目上照样用。它有三条死线,只要不碰,就相安无事。第一条是实时性死线:任何一个函数里出现HAL_Delay(10)这种阻塞等待,整个系统的响应时间就被拉长 10ms,如果这个函数还在跟别的外设抢总线,实际延迟更大。第二条是耦合死线:所有模块共享同一个全局状态,A 模块改一个标志,B 模块的解析逻辑就得跟着改,改动量随模块数平方增长。第三条是可测性死线:你想单独测一下协议解析对不对,得先把整个 main 函数跑起来,喂串口数据,这基本没法做单元测试。
P5 这个项目三条线全踩了。设备要同时做三件事:1kHz 采一路 ADC 并做滑动滤波、100ms 上报一帧数据给上位机、屏幕 20ms 刷一次界面。用裸机做,屏幕刷新一定会打断 ADC 的定时,采样间隔就抖了。这不是水平问题,是结构问题。
1.2 P5 项目的分层切法与职责边界
我给它切了五层,从上到下是应用层、服务层、OS 抽象层、驱动层、硬件层。听起来像教科书,但每一层画在哪里是有具体判据的。
驱动层只做两件事:把寄存器操作封装成函数,以及把数据从外设搬到内存缓冲区。它不知道 RTOS 的存在,不允许在驱动里调用xQueueSend。这条约束看起来苛刻,但收益很大:驱动层的代码可以直接拿到无 OS 的项目里复用,也能在 PC 上做桩测试。
OS 抽象层(OSAL)是薄薄一层胶水,提供osal_task_create、osal_queue_send、osal_mutex_lock这类接口,里面再调 FreeRTOS 的 API。多这一层的理由是防止 FreeRTOS 的宏和类型污染业务代码,同时也留了后路——万一某个项目要用别的内核,改这一层就够了。有人觉得这是过度设计,我承认在小项目里确实是负担,但 P5 后面还要出三四个衍生型号,这层很值。
服务层放那些跨模块的能力:日志、参数存储、协议解析、故障记录。它们的共同特征是被多个应用任务调用,所以必须线程安全,内部该加互斥量就加。应用层就是各个任务函数本体,只做业务编排:取数据、判断、发消息、更新界面。
1.3 先定架构再动 OS,顺序反了会怎样
我见过最常见的翻车方式:先兴冲冲把 FreeRTOS 移植进来,然后随手xTaskCreate建了七八个任务,每个任务里再直接调驱动。跑了两周发现任务之间互相等,加锁加到死锁,最后干脆退回去用裸机,还得出一个"RTOS 不适合小芯片"的结论。
正确的顺序是反的:先把模块依赖图画出来,标清楚谁是生产者、谁是消费者、谁是被动响应,再决定这些数据流用任务边界还是用队列边界切断,最后才是建任务。任务不是越多越好,P5 最终只有 5 个任务,外加 FreeRTOS 自己的空闲任务和定时器任务,一共 7 个。任务数量少,调度开销小,调试的时候用 RTOS 感知插件看状态也一目了然。
2. 把 FreeRTOS 移植进工程:从目录到配置项
2.1 源码裁剪与目录结构
官方源码包里有portable目录,里面按编译器和内核架构分了几十种组合。Cortex-M3 只需要保留portable/RVDS/ARM_CM3(Keil)或portable/IAR/ARM_CM3(IAR),其余全删。MemMang目录下五个heap_x.c只留一个,P5 留的是heap_4.c。留错或留多会导致重复定义,链接阶段才报错,而且报错信息往往指不到根因。
我的目录是这么铺的:
P5_Firmware/ ├── app/ # 应用层:任务函数、业务编排 ├── service/ # 服务层:log、param、protocol、fault ├── osal/ # OS 抽象层 ├── driver/ # 驱动层:adc、uart、spi_lcd、flash ├── bsp/ # 板级:引脚定义、时钟配置 ├── third_party/ │ ├── FreeRTOS/ # 内核源码 │ └── lvgl/ # 图形库 └── config/ ├── FreeRTOSConfig.h └── lv_conf.h这个结构的价值在于,FreeRTOSConfig.h单独放在config目录,而不是塞在FreeRTOS/include里。升级内核版本的时候,你只需要覆盖third_party/FreeRTOS,自己的配置和业务代码一个字都不用动。踩过一次坑:以前把配置文件和内核源码放一起,升级时手抖覆盖了,一上午白干。
2.2 中断向量与三个 Handler 的归属
这是移植阶段最容易卡住的地方,尤其是从别的例程抄过来的时候。FreeRTOS 的 Cortex-M3 端口需要三个异常处理函数:vPortSVCHandler(启动第一个任务)、xPortPendSVHandler(任务切换)、xPortSysTickHandler(系统节拍)。而 STM32 的启动文件startup_stm32f103xb.s里已经定义了SVC_Handler、PendSV_Handler、SysTick_Handler这三个弱符号。
两种做法。一种是去改启动文件,把弱符号的名字改掉;另一种更干净,在FreeRTOSConfig.h里做宏映射:
#define vPortSVCHandler SVC_Handler #define xPortPendSVHandler PendSV_Handler #define xPortSysTickHandler SysTick_Handler注意:这两个方案只能二选一。同时用会报重复定义,而且报错位置在汇编文件里,不太好找。
另外别忘了 SysTick 的优先级。FreeRTOS 在xPortStartScheduler里会把 PendSV 和 SysTick 设成最低优先级(值最大),并且通过configKERNEL_INTERRUPT_PRIORITY自动配置,正常情况下不用你手动NVIC_SetPriority。但如果你的main里在调用vTaskStartScheduler之前就手动初始化过 SysTick,优先级可能被改掉,导致任务切换异常。我遇到过这个,现象是任务能跑但节拍不对,xTaskGetTickCount走得比实际慢。
2.3 FreeRTOSConfig.h 逐项拆解
这个文件是移植的核心,我按功能分组说明,参数不是抄来的,都是压着实际需求定的。
#define configUSE_PREEMPTION 1 /* 抢占式调度,实时性靠它 */ #define configUSE_TIME_SLICING 1 /* 同优先级轮转 */ #define configUSE_TICKLESS_IDLE 0 /* 低功耗模式没启用,先关掉 */ #define configCPU_CLOCK_HZ ( 72000000UL ) #define configTICK_RATE_HZ ( 1000 ) #define configMAX_PRIORITIES ( 8 ) #define configMINIMAL_STACK_SIZE ( 128 ) #define configTOTAL_HEAP_SIZE ( ( size_t ) ( 12 * 1024 ) ) #define configUSE_16_BIT_TICKS 0 #define configUSE_MUTEXES 1 #define configUSE_RECURSIVE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configQUEUE_REGISTRY_SIZE 8 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1 #define configKERNEL_INTERRUPT_PRIORITY ( 15 << ( 8 - 4 ) ) #define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5 #define configMAX_SYSCALL_INTERRUPT_PRIORITY ( 5 << ( 8 - 4 ) ) #define configASSERT( x ) if( ( x ) == 0 ) { taskDISABLE_INTERRUPTS(); for( ;; ); }逐条说几个关键的。configTICK_RATE_HZ我选 1000,也就是 1ms 一个节拍。选 100 会省一点中断开销,但vTaskDelay(1)的精度就变成 10ms,很多传感器驱动的等待时间没法表达。选 1000 的代价是每秒一千次 SysTick 中断,在 72MHz 的 M3 上每次中断进出大概 1~2 微秒,占用不到 0.2% 的 CPU,完全能接受。
configMAX_PRIORITIES设 8 而不是默认的 5,是因为我要留出清晰的优先级梯队,0 给空闲任务,1 给日志,2 给定时器任务,3 给界面,4 给通信,5 给采样,6 和 7 空着留给以后的中断转任务。优先级数字越大优先级越高,这一点和某些人的直觉相反,写代码的时候多看两眼。
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY设为 5 是个临界点。它的含义是:优先级数值大于等于 5(也就是逻辑优先级更低)的中断,才允许调用xxxFromISR系列的 API。数值小于 5 的高优先级中断,FreeRTOS 关中断的时候屏蔽不了它,所以在里面调FromISR会破坏内核数据。STM32 的中断优先级数值越小优先级越高,换算的时候容易绕晕,我的做法是在所有外设中断初始化里显式写上HAL_NVIC_SetPriority(XXX_IRQn, 5, 0),数值只用 5 到 15 这一段。
configCHECK_FOR_STACK_OVERFLOW设 2,检测方式最严格,代价是每次任务切换多检查 20 个字节,实测开销可以忽略,具体检测原理后面单独讲。
3. 任务模型设计:需求怎么翻译成任务和通信机制
3.1 任务划分的四条判据
任务划分是架构设计里最考验经验的一步。我给自己的四条判据是:时间尺度是否独立、是否有阻塞点、耦合度是否高、失效影响范围是否隔离。四条里满足两条以上,就值得单独开一个任务。
以 P5 为例。ADC 采样要 1kHz 严格周期,它在时间尺度上完全独立,必须单独任务,用vTaskDelayUntil保证周期稳定。通信任务要等串口数据、等上位机应答,有明确阻塞点,单独任务。界面任务要等 LVGL 的刷新时机,单独任务。参数存储只在按键触发时写一次 Flash,属于低频且不阻塞,直接放应用层函数里同步调用就行,不值得开任务。日志任务我犹豫过,最后留下了,因为它是唯一有"消费速度可能跟不上生产速度"特性的模块,需要独立缓冲。
这里有个反例要提醒:不要为了"看起来清晰"而把每个驱动都包成一个任务。我见过一个项目,一个串口一个任务、一个 ADC 一个任务、一个按键一个任务,最后 15 个任务抢 RAM,光栈就吃掉 10KB。任务数量应该由数据流的阻塞特性决定,不是由外设数量决定。
3.2 优先级与栈深度的估算方法
优先级分配有个朴素原则:周期越短、截止时间越紧的,优先级越高,这叫速率单调调度,在大部分嵌入式场景够用。P5 的分配是采样任务 5、通信任务 4、界面任务 3、定时器服务任务 2、日志任务 1、空闲任务 0。
| 任务 | 优先级 | 周期/触发 | 栈(字) | 栈字节数 | 主要阻塞点 |
|---|---|---|---|---|---|
| 采样任务 | 5 | 1ms 周期 | 200 | 800 | vTaskDelayUntil |
| 通信任务 | 4 | 事件驱动 | 512 | 2048 | 队列、串口中断信号量 |
| 界面任务 | 3 | 5ms 周期 | 1024 | 4096 | LVGL 内部、互斥量 |
| 定时器服务 | 2 | 内核管理 | 256 | 1024 | 定时器队列 |
| 日志任务 | 1 | 事件驱动 | 384 | 1536 | 队列 |
| 空闲任务 | 0 | 空闲时运行 | 128 | 512 | 无 |
注意栈的单位。Cortex-M3 的StackType_t是uint32_t,xTaskCreate的usStackDepth参数单位是字不是字节。写 512 代表 2048 字节。这个坑我踩过,一开始以为单位是字节,结果栈只有标称的四分之一,跑一会儿就触发栈溢出钩子。
界面任务给 1024 字是因为 LVGL 内部有递归调用和局部数组,实测高水位线用到 700 字左右,留了 30% 余量。通信任务给 512 字,是因为里面有个 256 字节的协议解析临时缓冲放在栈上,这个做法其实不好,后面改成了静态缓冲。
3.3 队列、信号量、互斥量、任务通知怎么选
这是新手最容易混的地方,我把四个机制的适用场景做成表,选型的时候直接对号入座。
| 机制 | 典型用途 | 数据传递 | 能否在 ISR 用 | 开销 | 注意事项 |
|---|---|---|---|---|---|
| 队列 Queue | 任务间传数据块、ISR 转任务 | 支持,值拷贝 | 用 FromISR 版本 | 中 | 拷贝的是值,大结构体建议传指针 |
| 二值信号量 | ISR 通知任务、事件同步 | 不传数据 | 用 FromISR 版本 | 低 | 不能累计,连续 Give 会丢 |
| 计数信号量 | 资源计数、多事件累计 | 不传数据 | 用 FromISR 版本 | 低 | 可以累计到设定上限 |
| 互斥量 Mutex | 共享资源加锁 | 不传数据 | 禁止 | 中 | 有优先级继承,别当信号量用 |
| 任务通知 | 一对一任务同步或传值 | 可传 32 位值 | 用 FromISR 版本 | 最低 | 每任务仅一组,冲突时需额外缓冲 |
| 事件组 | 多事件逻辑组合等待 | 位标志 | 用 FromISR 版本 | 中 | 适合"等三个事件都到齐" |
几个实际判断。串口中断收到一帧数据,要交给通信任务解析,用二值信号量就够了,数据本身放环形缓冲,信号量只做"有数据了"的通知。如果多个中断源都要往日志任务丢消息,每个中断给一次 Give,那必须用计数信号量,否则中间的会丢。
互斥量用在两个地方:SPI 总线被界面和 Flash 驱动共享,用xSemaphoreCreateMutex加锁;LVGL 的 API 被多个任务调用的话,也要锁。互斥量绝不能在中断里用,因为优先级继承机制需要阻塞当前任务,中断上下文没有任务可以阻塞。这条如果违反,运行时会触发configASSERT死循环,如果你把断言关了,那就是随机崩溃,非常难查。
4. 分层架构落地:驱动层、OSAL、服务层与应用层
4.1 驱动层和 OSAL 的写法
驱动层的接口我统一成"打开/读写/控制"三段式,函数名带前缀,参数用句柄。比如串口驱动:
typedef struct uart_dev *uart_handle_t; int uart_open(uart_handle_t *h, const uart_cfg_t *cfg); int uart_write(uart_handle_t h, const uint8_t *buf, uint32_t len); int uart_read_nonblock(uart_handle_t h, uint8_t *buf, uint32_t len);驱动内部收到字节后,只做一件事:把数据塞进环形缓冲,然后调用注册的回调。回调是谁?是 OSAL 层提供的一个通知函数。这样驱动层就不依赖 FreeRTOS,依赖关系反转成了"驱动调用一个函数指针,函数指针由上层注入"。
OSAL 层大概两百行代码,把用到的 FreeRTOS API 包一遍。有人问这层值不值,我的判断标准是:如果你的项目只做一个型号、生命周期两年内,就别加;如果要做多个衍生型号、或者团队里有新人需要快速上手,加上它。P5 属于后者。
4.2 服务层:日志、参数存储、协议栈
日志服务我用的是"环形缓冲加独立任务"的模式。其他任务通过log_write往缓冲里写,缓冲区满的时候直接丢弃并计数,绝不阻塞业务任务。日志任务在低优先级下慢慢往串口或者 RTT 输出。这个设计的核心是日志不能反过来影响业务,我见过因为日志串口没接、发送阻塞,把整个采样任务拖垮的案例。
参数存储用 Flash 的最后两页做双备份,写入的时候先擦 A 页写数据、校验通过后擦 B 页写索引,掉电也不会两边都坏。不要在任务里直接擦写 Flash,STM32F103 的页擦除要 20~40ms,这期间 CPU 取指如果落在同一 Bank 上会被硬件阻塞,其他任务直接失去响应。我的做法是把擦写放到一个专门的临界区里,先挂起调度器vTaskSuspendAll、操作完再xTaskResumeAll,同时保证这段时间不依赖中断响应。更稳的方案是把参数写到外部 EEPROM,只有确认没有 EEPROM 才用内部 Flash 兜底。
协议解析服务是无状态的,输入字节流、输出帧结构,方便单独测试。这里有个技巧:解析函数不要直接往队列里发,而是返回一个状态码,由调用方决定怎么处理。这样解析逻辑可以在 PC 上用 GCC 编译跑单元测试,不用上板子。
4.3 应用层与 LVGL、上位机协议的解耦
LVGL 的移植有两个必须做对的地方。第一是心跳:lv_tick_inc(1)必须每毫秒调用一次,我放在vApplicationTickHook里,这个钩子由configUSE_TICK_HOOK打开,在 SysTick 中断上下文执行,函数体里只做lv_tick_inc(1)这一件事,不做别的。第二是刷新:lv_timer_handler放在界面任务里,用vTaskDelayUntil保证 5ms 一次,返回下次需要等待的时间可以拿来动态调整延时。
LVGL 本身不是线程安全的。我的处理方式是所有lv_*调用只允许出现在界面任务里,其他任务想改界面,就把"界面请求"丢进一个队列,界面任务收到后自己调 LVGL。这比到处加互斥量可靠得多,因为互斥量只能保证不冲突,保证不了调用时序正确。如果非要在别的任务里直接调,用lv_async_call也得配合锁,我实测下来还是队列方案省心。
上位机那边,P5 配套的是一个 Qt 写的监控软件。有意思的是,Qt 上位机的架构和 MCU 侧几乎是一一对应的。MCU 侧有驱动层(UART)、协议层(帧解析)、服务层(数据缓存)、应用层(业务逻辑)、显示层(LVGL);Qt 侧就是 QSerialPort、协议解析类、数据模型、业务逻辑类、QWidget 界面。
Qt 那边的关键点是:别在 UI 线程里做耗时解析。QSerialPort::readyRead信号在 UI 线程触发,如果解析函数里有大量计算,界面就会卡。我的做法是readyRead里只把readAll()的数据丢进一个缓冲,然后用信号槽(队列连接)发给工作线程里的解析对象,解析完再发信号回 UI 线程更新。槽函数的连接类型要显式写成Qt::QueuedConnection,不然默认是直连,等于没跨线程。
帧格式我定的是:0xAA 0x55 | 长度 | 命令字 | 负载 | CRC16 低字节 | CRC16 高字节。用 CRC16 而不是简单校验和,是因为设备在电机旁边,干扰大,校验和漏检率太高。CRC 表法计算,72MHz 下算 64 字节大概几十微秒,放在通信任务里没问题。
5. 内存与堆:heap_1 到 heap_5 的选择和实测数据
5.1 五种 heap 方案的取舍
FreeRTOS 提供五个heap_x.c,很多人是照抄别人的选择,其实每种都有明确适用场景。
| 方案 | 是否可释放 | 碎片处理 | 适用场景 | P5 是否选用 |
|---|---|---|---|---|
| heap_1 | 否 | 不涉及 | 所有对象只在启动时创建 | 否 |
| heap_2 | 是 | 不合并相邻空闲块 | 已废弃,不建议新项目用 | 否 |
| heap_3 | 是 | 依赖 C 库 malloc | 有成熟 malloc 实现的平台 | 否 |
| heap_4 | 是 | 首次适配 + 相邻合并 | 通用场景,绝大多数项目 | 是 |
| heap_5 | 是 | 同 heap_4,支持多内存区 | 有外部 SRAM/SDRAM 的平台 | 否 |
heap_4 是默认答案,它把空闲块用链表串起来,分配时首次适配,释放时自动合并相邻空闲块,能有效抑制碎片。heap_1 虽然最简单最安全,但要求所有任务、队列、信号量在调度器启动前一次性建好,P5 有个按需创建的调试任务,所以不用。
heap_5 适合有片外 RAM 的场景,vPortDefineHeapRegions把内部 SRAM 和外部 SDRAM 一起交给内核管理。用的时候要注意:必须在创建任何内核对象之前调用,否则内核会先按默认区域初始化,再定义就失效了。
5.2 堆剩余量的监控与调参
configTOTAL_HEAP_SIZE我定的是 12KB,芯片总共 20KB RAM,内部 Flash 双备份、LVGL 缓冲、环形缓冲都要占,留给堆的就是这么多。调参不能靠猜,要在运行一段时间后打印两个数值:
size_t remain = xPortGetFreeHeapSize(); size_t min_ever = xPortGetMinimumEverFreeHeapSize();xPortGetMinimumEverFreeHeapSize记录的是历史最低水位,这个值比当前剩余量有用得多,因为它能告诉你运行过程中的峰值占用。我的判断标准是:历史最低水位要留 20% 以上余量。如果最低水位掉到 1KB 以下,说明配置得太紧,后面加一个队列就会分配失败。
堆分配失败的钩子也要打开,configUSE_MALLOC_FAILED_HOOK设为 1,然后实现vApplicationMallocFailedHook,里面把任务名、请求大小打印出来再死循环。这个钩子救过我一次,现象是设备偶尔重启,最后发现是某个场景下队列创建失败返回了 NULL,代码没判空直接解引用。
6. 调试与排查:栈溢出、优先级反转、死锁现场
6.1 栈溢出检测的两种模式
configCHECK_FOR_STACK_OVERFLOW设 1 的时候,内核在任务切换时检查栈指针是否越界,只能发现"已经越界"的情况,属于亡羊补牢。设 2 的时候,创建任务时整个栈填 0xA5,切换时检查栈末尾 20 个字节是否还是 0xA5 图案,能在越界前就发现,是主动防御。代价是每次切换多 20 字节比较,实测在 M3 上大概多 1~2 微秒。P5 用 2。
触发后内核会调用vApplicationStackOverflowHook,参数是出问题的任务句柄和任务名:
void vApplicationStackOverflowHook( TaskHandle_t xTask, char *pcTaskName ) { taskDISABLE_INTERRUPTS(); printf( "STACK OVERFLOW: %s\r\n", pcTaskName ); for( ;; ); }注意:钩子里不要调用任何 FreeRTOS API 里带阻塞的接口,栈已经崩了,再调这些函数就是二次伤害。最简单的做法是关中断然后死循环,用调试器看
pcTaskName。
平时想监控栈余量,用uxTaskGetStackHighWaterMark(handle),返回值是历史最小剩余量,单位是字。我在调试版本里每 5 秒把所有任务的这个值打一遍,哪个任务剩余量低于 20 字,就该加大栈了。
6.2 常见故障速查表
下面这些是我和同事在实际项目里遇到过的,按现象归类。
| 现象 | 可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 上电就进 HardFault | 栈溢出 / 向量表未重映射 | 看 LR 和 PC 寄存器 | 加大栈、检查 SCB->VTOR |
| 任务跑一会儿卡死 | 优先级反转或死锁 | RTOS 感知调试看各任务状态 | 检查互斥量获取顺序 |
vTaskDelay(1)实际延迟很长 | SysTick 优先级被改 / 有其他高优先级中断 | 测实际节拍 | 统一中断优先级数值 |
| 串口偶发丢帧 | ISR 里给了二值信号量导致合并 | 计数信号量替换 | 环形缓冲 + 计数信号量 |
| 堆分配返回 NULL | configTOTAL_HEAP_SIZE偏小 | 打印历史最低水位 | 增大堆或复用对象 |
| 界面卡顿但 CPU 不忙 | LVGL 调用分散在多任务 | 检查lv_*调用点 | 集中到界面任务 |
| 调度器启动前就崩 | 中断里调了非 FromISR 的 API | 检查启动阶段的中断 | 改成 FromISR 版本 |
优先级反转值得单独说一句。界面任务拿了 SPI 互斥量,被通信任务抢占,通信任务又要拿同一个互斥量,理论上通信任务优先级高会一直等。FreeRTOS 的互斥量有优先级继承机制,会把持有锁的任务临时提到等待者的优先级,缓解这个问题。但如果链路上有三四个互斥量嵌套,继承机制就不好使了,只能靠设计上避免嵌套加锁。我的规矩是:同一时刻每个任务最多持有一把锁,绝不在持锁状态下申请另一把。
6.3 RTOS 感知调试与内核切换流程的回看
Keil MDK 和 IAR 都支持 RTOS 感知调试,能看到当前有哪些任务、各自状态、优先级、栈用量。Keil 里需要在FreeRTOSConfig.h里打开configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS,然后在调试窗口里勾选。这个功能我在排查死锁的时候用过一次,一眼就看出两个任务都在Blocked状态,等待对方手里的信号量,比打日志快得多。
顺便回顾一下内核切换流程,理解了这个,很多现象就能自己解释。任务的切换由PendSV 异常完成,因为它是可挂起的、优先级最低的,不会打断更高优先级的中断。SysTick 中断里如果发现需要切换,就置位 PendSV 的挂起位,等所有高优先级中断处理完,PendSV 才执行,在它的处理函数里完成寄存器压栈、栈指针切换、出栈。所以任务切换永远是发生在中断退出之后,这也解释了为什么在中断里改的任务状态不会立即生效。
6.4 面试里绕不开的几个点
既然聊到内核,顺带说几个面试常问、也确实反映功底的题目。vTaskDelay和vTaskDelayUntil的区别是什么?前者是"从现在起延时 N 个节拍",每次调用会引入累计误差;后者是"延时到指定时刻",能保证周期稳定,适合周期性采样。xQueueSend和xQueueSendFromISR的差别?后者不会阻塞,靠pxHigherPriorityTaskWoken参数告知是否需要退出中断时触发切换,用的时候必须配合portYIELD_FROM_ISR,否则被唤醒的高优先级任务要等下一个节拍才运行。
还有一个高频问题:二值信号量和互斥量的区别。表面看都是 0/1,本质区别有三个:互斥量有优先级继承、互斥量有所有权概念(谁拿谁放)、互斥量不能在中断里使用。面试官问这个,其实是在确认你有没有真正踩过坑。
7. 后续可以这样扩展
P5 这套结构后面还有几件事要做。低功耗是第一个,configUSE_TICKLESS_IDLE打开后,空闲时可以让芯片进入低功耗模式,靠定时器唤醒。要注意的是打开这个功能前,必须先确认没有任务依赖绝对精确的节拍,否则唤醒后的节拍补偿会带来偏差,采样任务的时间戳就飘了。
再就是单元测试。协议解析、参数校验这些纯逻辑模块,我抽出来放到 PC 上跑,用 GCC 编译加一套简单的断言,不需要上目标板。这样改协议的时候能快速回归,比烧一次板子快得多。
最后分享一个我自己的小习惯:每次改完架构相关的代码,我都会在README里记一行"为什么这么改"。因为半年后你自己一定会忘,而这三行字能省下一小时。
8. 一个关于外包给纯软件公司的做法讨论
最近常有人问,能不能把 FreeRTOS 那一层直接外包给纯软件公司,自己团队只写业务。从经验看,可行但有两个前提。
一是外包出去的契约必须写到接口级别,OSAL 那层的函数签名、语义、错误码要全部冻结,不能允许对方"顺便优化"。二是必须有懂 RTOS 的人做验收,哪怕只招一个,因为很多问题在 PC 上测不出来,必须上板。
我见过一个反例:外包方为了交差,把configTOTAL_HEAP_SIZE直接设成 16KB,在 20KB RAM 的板子上所有功能都能跑,但他们没测长时间运行。实际上内部 Flash 模拟 EEPROM 的缓冲和图形缓冲吃掉了几 KB,最终在客户现场连续跑三天后堆分配失败重启。纯软件出身的人对这种"资源在某些路径上才被消耗"的模式不敏感,这个必须自己把关。
所以我的建议是:让外包方做操作系统抽象层之上的业务逻辑和上位机,RTOS 配置、堆栈预算、中断优先级这三样核心参数由自己团队定,写进接口文档里当成硬约束,改动必须走评审。这样分工,两边都能发挥长处,责任边界也清楚。