3、RTOS基础与任务调度:FreeRTOS移植与配置、任务创建与优先级管理、我在项目中遇到的任务栈溢出问题
说实话,做智能音箱这种产品,没有RTOS几乎是寸步难行的。WiFi协议栈要跑,蓝牙协议栈要跑,音频编解码要跑,按键检测要跑……你想想看,如果全靠一个超级循环在那里轮询,光是处理WiFi中断就能把CPU占满,蓝牙那边早就断连了。
我个人习惯,在智能音箱这类产品上首选FreeRTOS。原因很简单:轻量、稳定、生态好。WiFi芯片厂商(像乐鑫、瑞昱)出的SDK,底层基本都绑定了FreeRTOS。你换别的OS,反而要自己适配驱动层,得不偿失。
3.1 FreeRTOS移植与配置
移植FreeRTOS,说白了就是把OS内核源码放到你的工程里,然后配好几个跟硬件相关的接口。我当年第一次移植的时候,以为要改很多代码,结果发现核心就三件事:
- 配置系统时钟节拍:FreeRTOS需要一个周期性的定时器中断来驱动任务调度。一般用SysTick(Cortex-M内核)或者硬件定时器。频率通常设为1000Hz,也就是1ms一个tick。
- 实现临界区保护:关中断/开中断的宏定义。在Cortex-M上就是
__disable_irq()和__enable_irq()。嗯,这里要注意,如果你的芯片有多个中断优先级,记得把FreeRTOS管理的最高优先级设置好,别让OS外的中断把调度器给打断了。 - 配置堆栈大小:FreeRTOS内核本身需要一块内存来管理任务控制块(TCB)和堆栈。这个在
FreeRTOSConfig.h里配configTOTAL_HEAP_SIZE。
我贴一个我常用的配置片段,你可以参考一下:
/* FreeRTOSConfig.h 关键配置 */
#define configUSE_PREEMPTION 1 // 抢占式调度
#define configCPU_CLOCK_HZ ( ( unsigned long ) 240000000 ) // 240MHz
#define configTICK_RATE_HZ ( ( TickType_t ) 1000 ) // 1ms tick
#define configMAX_PRIORITIES ( 5 ) // 优先级数量,够用就行
#define configMINIMAL_STACK_SIZE ( ( unsigned short ) 128 ) // 最小栈,单位word
#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 60 * 1024 ) ) // 60KB堆
#define configUSE_IDLE_HOOK 0
#define configUSE_TICK_HOOK 0
这里有个坑:configMINIMAL_STACK_SIZE的单位是word(4字节),不是byte。我见过有人配成128字节,结果空闲任务一启动就栈溢出。你想想看,128字节连函数调用压栈都不够用。
3.2 任务创建与优先级管理
任务创建很简单,调用xTaskCreate()就行。但优先级怎么分,这里面有门道。
我的经验是:实时性要求越高的任务,优先级越高。在智能音箱里,优先级排序一般是这样的:
| 任务名称 | 优先级 | 说明 |
|---|---|---|
| WiFi/BT协议栈任务 | 4(最高) | 处理射频中断和协议栈状态机,延迟大了会断连 |
| 音频I2S数据搬运任务 | 3 | 从DMA缓冲区取数据,丢数据就会爆音 |
| 按键/触摸检测任务 | 2 | 用户交互,响应要快但不能抢协议栈的CPU |
| LED显示/UI更新任务 | 1 | 可以慢一点,优先级最低 |
| 空闲任务 | 0 | 系统自带,跑低功耗模式 |
创建任务的代码大概长这样:
TaskHandle_t xAudioTaskHandle = NULL;
void vAudioTask(void *pvParameters)
{
// 音频处理循环
for(;;)
{
// 等待音频数据就绪的信号量
xSemaphoreTake(xAudioSemaphore, portMAX_DELAY);
// 处理音频数据
process_audio_data();
}
}
// 在main函数中创建
xTaskCreate(
vAudioTask, // 任务函数
"Audio Task", // 任务名,方便调试
1024, // 栈大小,单位word
NULL, // 参数
3, // 优先级
&xAudioTaskHandle // 任务句柄
);
这里我多说一句:优先级不要设太多。5个优先级足够用了。优先级多了,调度开销变大,而且容易出优先级反转的问题。我见过有人设了20个优先级,结果调试的时候自己都搞不清哪个任务在跑。
3.3 我在项目中遇到的任务栈溢出问题
好,终于说到这个让我头疼过的问题了。
有一次做一款带语音助手的智能音箱,功能都调通了,跑压力测试。连续播放音乐+语音唤醒,跑了大概6个小时,突然死机。串口打印最后一条日志是vApplicationStackOverflowHook被调用了。
嗯,栈溢出了。
FreeRTOS提供了栈溢出检测机制。你需要在FreeRTOSConfig.h里开启:
#define configCHECK_FOR_STACK_OVERFLOW 2
然后实现钩子函数:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
// 这里可以打印任务名,方便定位
printf("Stack Overflow! Task: %s\n", pcTaskName);
// 然后可以进入断言或者重启
configASSERT(0);
}
当时打印出来是Audio Task溢出了。我一开始不信,因为创建任务时我给了1024个word(4KB)的栈,处理音频数据应该够用啊。
后来仔细一查,发现问题出在函数调用深度上。音频处理函数里调用了FFT库,FFT库又调用了malloc分配临时缓冲区。而malloc本身在FreeRTOS里会占用任务栈空间(如果用了heap_4.c的话)。再加上中断嵌套,栈就爆了。
printf。printf的实现通常需要较大的栈空间,而且很多printf实现不是可重入的。在中断里调用,轻则栈溢出,重则死机。记住:中断服务函数里尽量只做标记、发信号量、唤醒任务,不要做复杂操作。
解决栈溢出,我总结了三个步骤:
- 先定位是哪个任务:开启
configCHECK_FOR_STACK_OVERFLOW,看钩子函数打印的任务名。 - 估算栈使用量:在任务函数里调用
uxTaskGetStackHighWaterMark(),可以知道任务运行过程中栈最多用了多少。比如:
UBaseType_t uxHighWaterMark;
uxHighWaterMark = uxTaskGetStackHighWaterMark(xAudioTaskHandle);
printf("Audio Task stack remaining: %d words\n", uxHighWaterMark);
如果剩余量小于总栈大小的20%,我建议你加大栈空间,或者优化代码减少局部变量。
- 优化代码减少栈使用:把大的局部变量改成静态变量或者动态分配。比如音频处理用的临时缓冲区,可以声明成全局数组,或者用
pvPortMalloc从堆里分配。
uxTaskGetStackHighWaterMark精确调整,把多余的内存省下来给别的任务用。
最后说一句,栈溢出这个问题,在开发阶段一定要暴露出来。别等到量产了,用户用着用着死机,那才叫麻烦。我现在的习惯是:所有产品在出厂前,都会跑72小时的压力测试,同时开启栈溢出检测。只要触发过一次,就必须找到根因才能放行。
嗯,关于FreeRTOS的任务调度和栈管理,今天就聊这么多。下一节我们讲WiFi协议栈的移植,那又是另一番天地了。