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实现不是可重入的。在中断里调用,轻则栈溢出,重则死机。记住:中断服务函数里尽量只做标记、发信号量、唤醒任务,不要做复杂操作。

解决栈溢出,我总结了三个步骤:

  1. 先定位是哪个任务:开启configCHECK_FOR_STACK_OVERFLOW,看钩子函数打印的任务名。
  2. 估算栈使用量:在任务函数里调用uxTaskGetStackHighWaterMark(),可以知道任务运行过程中栈最多用了多少。比如:
UBaseType_t uxHighWaterMark;
uxHighWaterMark = uxTaskGetStackHighWaterMark(xAudioTaskHandle);
printf("Audio Task stack remaining: %d words\n", uxHighWaterMark);

如果剩余量小于总栈大小的20%,我建议你加大栈空间,或者优化代码减少局部变量。

  1. 优化代码减少栈使用:把大的局部变量改成静态变量或者动态分配。比如音频处理用的临时缓冲区,可以声明成全局数组,或者用pvPortMalloc从堆里分配。
小技巧:我习惯在任务创建时把栈大小设成估计值的1.5倍。比如你觉得需要1000 word,就设成1500 word。多出来的空间就当是安全余量。等产品稳定了,再通过uxTaskGetStackHighWaterMark精确调整,把多余的内存省下来给别的任务用。

最后说一句,栈溢出这个问题,在开发阶段一定要暴露出来。别等到量产了,用户用着用着死机,那才叫麻烦。我现在的习惯是:所有产品在出厂前,都会跑72小时的压力测试,同时开启栈溢出检测。只要触发过一次,就必须找到根因才能放行。

嗯,关于FreeRTOS的任务调度和栈管理,今天就聊这么多。下一节我们讲WiFi协议栈的移植,那又是另一番天地了。