第三章 RTOS基础与任务调度:FreeRTOS移植、任务创建与删除、任务优先级与调度策略、任务间通信

各位同学,欢迎来到第三章。这一章我们聊聊RTOS,特别是FreeRTOS。说实话,在TWS耳机这种资源受限的芯片上,没有RTOS,你根本玩不转。裸机跑轮询?那延迟和功耗会让你崩溃的。

我个人习惯,拿到一个新芯片,第一件事就是把FreeRTOS跑起来。它就像地基,地基不稳,上面盖什么楼都得塌。这一章,我会把移植、任务管理、调度策略和通信机制,掰开了揉碎了讲给你听。

3.1 FreeRTOS移植:从零开始跑起来

移植FreeRTOS,说白了就是让这个“小操作系统”能认识你的芯片硬件。我刚开始做移植时,总觉得很神秘,后来发现,核心就三件事:配置时钟、实现中断、修改堆栈。

第一步:拿到源码,找到核心文件

FreeRTOS的源码结构很清晰。你只需要关注这几个文件:

  • tasks.cqueue.clist.c —— 核心代码,不用动。
  • portable 文件夹 —— 这才是你干活的地方。找到对应你MCU架构的文件夹,比如ARM Cortex-M系列。
  • FreeRTOSConfig.h —— 配置文件,重中之重。

第二步:配置FreeRTOSConfig.h

这个文件决定了你的RTOS行为。我见过很多新手,直接复制别人的配置,结果系统跑飞了都不知道为什么。记住,每个项目都要自己配。

/* FreeRTOSConfig.h 关键配置示例 */
#define configUSE_PREEMPTION            1   // 使用抢占式调度
#define configCPU_CLOCK_HZ              ( ( unsigned long ) 48000000 ) // 你的MCU主频
#define configTICK_RATE_HZ              ( ( TickType_t ) 1000 ) // 系统时钟节拍,1ms
#define configMAX_PRIORITIES            ( 5 )  // 最大优先级数,够用就行,别贪多
#define configMINIMAL_STACK_SIZE        ( ( unsigned short ) 128 ) // 最小任务栈大小
#define configTOTAL_HEAP_SIZE           ( ( size_t ) ( 8 * 1024 ) ) // 总堆大小,TWS耳机通常8-16KB
⚠️ 避坑指南: 我曾经在配置 configTICK_RATE_HZ 时,为了追求高精度设成了10000(0.1ms)。结果系统大部分时间都在处理时钟中断,任务反而跑不动了。TWS耳机里,1000Hz(1ms)是黄金平衡点。

第三步:实现滴答定时器和PendSV中断

这是移植的核心。滴答定时器(SysTick)为RTOS提供心跳,PendSV用于上下文切换。在ARM Cortex-M上,通常这样实现:

/* 滴答定时器中断服务函数 */
void SysTick_Handler(void)
{
    if (xTaskGetSchedulerState() != taskSCHEDULER_NOT_STARTED)
    {
        xPortSysTickHandler(); // FreeRTOS的滴答处理函数
    }
}

/* PendSV中断,用于任务切换 */
void PendSV_Handler(void)
{
    vPortSVCHandler(); // 调用FreeRTOS的上下文切换函数
}

嗯,这里要注意:PendSV_Handler 的优先级一定要设为最低。为什么?因为上下文切换不能被其他中断打断,否则会导致竞态条件。我见过有人把PendSV优先级设得很高,结果系统调度乱成一锅粥。

3.2 任务创建与删除:让代码“活”起来

任务,就是RTOS里的“线程”。在TWS耳机里,一个任务处理音频流,一个任务处理蓝牙协议栈,一个任务处理按键和触摸。任务创建很简单,但用好它需要经验。

任务创建:xTaskCreate

我个人习惯,每个任务都给它起个有意义的名字,方便调试。

/* 任务函数原型 */
void vAudioTask(void *pvParameters);
void vBLEStackTask(void *pvParameters);
void vUserInputTask(void *pvParameters);

/* 创建任务 */
xTaskCreate(
    vAudioTask,          // 任务函数
    "Audio Task",        // 任务名字,调试用
    256,                 // 栈深度,单位是字(4字节)
    NULL,                // 参数
    3,                   // 优先级,数值越大优先级越高
    NULL                 // 任务句柄
);

xTaskCreate(vBLEStackTask, "BLE Stack", 512, NULL, 4, NULL);
xTaskCreate(vUserInputTask, "User Input", 128, NULL, 2, NULL);
💡 我的经验: 栈大小怎么定?我一般先给个保守值(比如256字),然后运行一段时间,用 uxTaskGetStackHighWaterMark() 查看剩余栈空间。如果剩余空间小于20%,我就加大栈大小。别一开始就给太大,浪费RAM。

任务删除:vTaskDelete

任务删除很简单,但要注意:删除自己时,要传NULL。删除别人时,要传任务句柄。

/* 任务内部删除自己 */
void vSomeTask(void *pvParameters)
{
    // ... 做一些初始化工作 ...
    vTaskDelete(NULL); // 任务结束,自我了断
}

/* 外部删除任务 */
TaskHandle_t xTaskHandle = NULL;
// ... 创建任务时保存句柄 ...
vTaskDelete(xTaskHandle); // 干掉它
⚠️ 避坑指南: 我曾经在删除一个正在使用互斥锁的任务时,没有先释放锁,结果导致其他任务永久阻塞。记住:删除任务前,确保它已经释放了所有占用的资源(信号量、队列、互斥锁等)。

3.3 任务优先级与调度策略:谁先跑,谁后跑

FreeRTOS支持抢占式调度和协作式调度。在TWS耳机里,我们几乎只用抢占式。为什么?因为音频流必须实时处理,不能被低优先级的按键任务拖累。

优先级设计原则

我总结了一个“三区优先级”原则,在TWS耳机里特别好用:

优先级区间 典型任务 说明
高优先级(4-5) 音频处理、蓝牙协议栈 实时性要求极高,不能被打断
中优先级(2-3) 编解码、音量控制 有一定实时性,可以容忍少量延迟
低优先级(1) 按键扫描、LED控制、电量检测 不着急,有空再处理

调度策略:抢占式 vs 时间片

FreeRTOS默认是抢占式调度。高优先级任务就绪时,会立即抢占低优先级任务。同优先级任务之间,则通过时间片轮转。

你想想看,如果音频任务和蓝牙任务优先级相同,会发生什么?它们会轮流跑,每个任务跑一个时间片。但音频处理需要连续的数据流,时间片切换会导致音频卡顿。所以,我通常把音频任务优先级设得比蓝牙任务高一级。

🔑 核心要点: 在TWS耳机里,音频处理任务永远是“王”。它的优先级应该是最高的。任何其他任务都不能阻塞它。如果音频任务跑飞了,耳机就“哑巴”了。

3.4 任务间通信:队列、信号量与互斥锁

任务之间怎么说话?不能直接调用对方的函数,那样会破坏RTOS的独立性。FreeRTOS提供了三种“信使”:队列、信号量、互斥锁。

3.4.1 队列:数据搬运工

队列用于任务间传递数据。比如,按键任务检测到用户按下了“音量+”,就把这个事件通过队列发给音量控制任务。

/* 创建队列,最多存放5个消息,每个消息4字节 */
QueueHandle_t xVolumeQueue = xQueueCreate(5, sizeof(uint32_t));

/* 按键任务:发送数据 */
void vKeyScanTask(void *pvParameters)
{
    uint32_t ulEvent = KEY_VOLUME_UP;
    xQueueSend(xVolumeQueue, &ulEvent, portMAX_DELAY);
}

/* 音量控制任务:接收数据 */
void vVolumeControlTask(void *pvParameters)
{
    uint32_t ulReceivedEvent;
    while(1)
    {
        if(xQueueReceive(xVolumeQueue, &ulReceivedEvent, pdMS_TO_TICKS(100)) == pdPASS)
        {
            // 处理音量事件
            AdjustVolume(ulReceivedEvent);
        }
    }
}
💡 我的经验: 队列的等待时间不要设成 portMAX_DELAY,除非你确定发送方一定会发数据。否则,接收任务会永久阻塞。我一般设成 pdMS_TO_TICKS(100),超时了就做点别的事,或者打印个日志。

3.4.2 信号量:事件通知器

信号量用于同步。比如,音频解码任务等待DMA传输完成,DMA完成后通过信号量通知解码任务。

/* 创建二值信号量 */
SemaphoreHandle_t xDMASemaphore = xSemaphoreCreateBinary();

/* DMA中断服务函数 */
void DMA_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xDMASemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

/* 音频解码任务 */
void vAudioDecodeTask(void *pvParameters)
{
    while(1)
    {
        // 等待DMA传输完成
        if(xSemaphoreTake(xDMASemaphore, portMAX_DELAY) == pdPASS)
        {
            // 处理解码后的音频数据
            ProcessAudioData();
        }
    }
}
⚠️ 避坑指南: 在中断里调用 xSemaphoreGiveFromISR 时,一定要检查 xHigherPriorityTaskWoken。如果它为 pdTRUE,说明有一个更高优先级的任务被唤醒了,你需要在中断末尾调用 portYIELD_FROM_ISR 来触发一次上下文切换。我曾经忘了这个,导致高优先级任务迟迟得不到执行。

3.4.3 互斥锁:资源保护神

互斥锁用于保护共享资源。比如,两个任务都要写同一个I2C总线,如果不加锁,数据会乱掉。

/* 创建互斥锁 */
SemaphoreHandle_t xI2CMutex = xSemaphoreCreateMutex();

/* 任务A:写I2C */
void vTaskA(void *pvParameters)
{
    xSemaphoreTake(xI2CMutex, portMAX_DELAY);
    // 写I2C操作
    I2C_Write(slaveAddr, regAddr, data);
    xSemaphoreGive(xI2CMutex);
}

/* 任务B:写I2C */
void vTaskB(void *pvParameters)
{
    xSemaphoreTake(xI2CMutex, portMAX_DELAY);
    // 写I2C操作
    I2C_Write(slaveAddr, regAddr, data);
    xSemaphoreGive(xI2CMutex);
}

互斥锁有个好特性:优先级继承。如果低优先级任务持有锁,高优先级任务在等锁,系统会临时提升低优先级任务的优先级,防止“优先级反转”。

🔑 核心要点: 互斥锁和信号量不要混用。互斥锁用于保护资源,信号量用于同步事件。我见过有人用信号量保护共享资源,结果导致优先级反转,系统调度出问题。记住:保护资源用互斥锁,同步事件用信号量。

好了,这一章的内容就到这里。RTOS是TWS耳机固件的灵魂,任务调度和通信机制是它的骨架。下一章,我们会深入音频数据流处理,看看数据是怎么从蓝牙芯片流到喇叭的。到时候,你会更深刻地理解为什么任务优先级和通信机制这么重要。