4、RTOS基础与任务调度:FreeRTOS移植、任务创建与管理、消息队列、信号量、软件定时器

好,咱们进入第四章。这一章可以说是整个蓝牙耳机语音助手软件的「地基」。你想想看,一个耳机里要同时跑蓝牙协议栈、音频编解码、语音唤醒、按键检测、电量管理……这么多活儿,要是没有个靠谱的调度系统,那不乱成一锅粥了?

RTOS(实时操作系统)就是干这个的。我这些年做嵌入式项目,从裸机到RTOS,最大的感受就是:裸机写逻辑,RTOS写架构。今天咱们就拿FreeRTOS开刀,把这套东西讲透。

4.1 FreeRTOS移植:别怕,其实就三步

很多新手一听到「移植」两个字就头大。其实说白了,就是把FreeRTOS的源码放到你的工程里,然后告诉它你的芯片长什么样。

我个人习惯把移植分成三步走:

  1. 拿源码:去官网或者GitHub下载最新版,别用太老的版本,坑多。
  2. 配接口:主要是三个东西——滴答定时器(SysTick)、中断优先级、以及临界区保护。
  3. 跑demo:先创建一个最简单的任务,点个灯,验证移植是否成功。

这里有个容易踩的坑——中断优先级配置。FreeRTOS要求你使用芯片的最高优先级给SysTick和PendSV中断。我曾经在一个STM32项目上忘了设置,结果任务调度死活不工作,查了两天才发现是优先级没对齐。

⚠️ 注意: 不同MCU的中断优先级位数不同。STM32是4位,但有些芯片只有3位。移植前一定要确认好 configMAX_SYSCALL_INTERRUPT_PRIORITY 这个宏的值。

代码层面,你只需要在 FreeRTOSConfig.h 里配好这几个宏:

#define configUSE_PREEMPTION          1
#define configCPU_CLOCK_HZ            ( ( unsigned long ) 72000000 )
#define configTICK_RATE_HZ            ( ( TickType_t ) 1000 )
#define configMAX_PRIORITIES          ( 5 )
#define configMINIMAL_STACK_SIZE      ( ( unsigned short ) 128 )
#define configTOTAL_HEAP_SIZE         ( ( size_t ) ( 10 * 1024 ) )

嗯,这里要注意,configTOTAL_HEAP_SIZE 别设太小。蓝牙耳机里一般要跑协议栈,堆空间至少留个10KB以上,不然跑着跑着就内存不足了。

4.2 任务创建与管理:你的第一个RTOS任务

任务,就是RTOS里的「线程」。每个任务都有自己的栈空间、优先级和状态。创建任务用 xTaskCreate(),就这么简单:

void vTaskLed(void *pvParameters) {
    while(1) {
        GPIO_ToggleBits(GPIOA, GPIO_Pin_5);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

xTaskCreate(vTaskLed, "LED", 128, NULL, 1, NULL);

你看,一个任务就是一个死循环。为什么是死循环?因为任务一旦返回,就会被系统删除。所以任务函数永远不要写 return,这是我见过新手最容易犯的错误。

任务有四种状态:运行、就绪、阻塞、挂起。我画个简单的图帮你理解:

状态 说明 常见场景
运行 正在使用CPU 当前执行的任务
就绪 能跑但没轮到 同优先级任务轮转
阻塞 等待某件事 vTaskDelay、等待队列
挂起 人为暂停 vTaskSuspend

任务优先级怎么定?我的经验是:实时性要求高的给高优先级,计算量大的给低优先级。比如蓝牙音频流必须高优先级,而LED闪烁给个最低优先级就行。

💡 小技巧: 调试时可以用 uxTaskGetNumberOfTasks() 查看当前有多少任务在跑。如果发现任务数不对,八成是某个任务创建失败了。

4.3 消息队列:任务间的「快递员」

任务之间怎么通信?最常用的就是消息队列。你可以把它想象成一个快递柜——一个任务往里放数据,另一个任务去取。

创建队列:

QueueHandle_t xQueueAudio;
xQueueAudio = xQueueCreate(10, sizeof(AudioFrame_t));

发送数据:

AudioFrame_t frame;
// 填充frame数据
xQueueSend(xQueueAudio, &frame, portMAX_DELAY);

接收数据:

AudioFrame_t rxFrame;
if(xQueueReceive(xQueueAudio, &rxFrame, pdMS_TO_TICKS(100)) == pdPASS) {
    // 处理音频帧
}

我在做蓝牙耳机项目时,就用队列把蓝牙接收到的音频数据传给解码任务。这里有个坑——队列长度要设够。蓝牙一包数据过来,如果解码任务来不及处理,队列满了就会丢包。我建议队列长度至少是最大预期数据量的两倍。

🔑 关键点: 队列传递的是数据的副本,不是指针。如果数据量大,建议传指针,但要注意内存管理,别造成野指针。

4.4 信号量:协调资源的「红绿灯」

信号量分两种:二值信号量和计数信号量。二值信号量就像一把锁,只能被一个任务持有;计数信号量则像停车场空位,可以多个任务同时访问。

二值信号量常用于同步:

SemaphoreHandle_t xSemaphore;
xSemaphore = xSemaphoreCreateBinary();

// 中断中给出信号量
xSemaphoreGiveFromISR(xSemaphore, NULL);

// 任务中等待信号量
if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
    // 收到中断通知,开始处理
}

计数信号量常用于资源管理:

xSemaphore = xSemaphoreCreateCounting(5, 5);  // 最多5个资源,初始全部可用

// 使用资源前
xSemaphoreTake(xSemaphore, portMAX_DELAY);
// 使用资源后
xSemaphoreGive(xSemaphore);

我曾经在调试一个语音助手时,发现麦克风数据偶尔会丢失。查了半天,原来是两个任务同时访问了I2S外设。加上信号量保护后,问题立刻解决。所以记住:任何共享资源,都要加锁

⚠️ 注意: 中断服务函数里只能用 GiveFromISR 系列函数,不能用普通的 Give/Take。否则会导致调度器崩溃。

4.5 软件定时器:不用硬件的「闹钟」

有时候你需要定时做某件事,比如每100ms检查一次按键状态。用硬件定时器当然可以,但资源有限。FreeRTOS提供了软件定时器,基于系统滴答时钟实现,用起来非常方便。

TimerHandle_t xTimer;
xTimer = xTimerCreate("KeyScan", pdMS_TO_TICKS(100), pdTRUE, NULL, vTimerCallback);

// 启动定时器
xTimerStart(xTimer, 0);

void vTimerCallback(TimerHandle_t xTimer) {
    // 每100ms执行一次
    uint8_t keyState = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0);
    if(keyState == 0) {
        // 按键按下,发送消息
        xQueueSend(xQueueKey, &keyState, 0);
    }
}

软件定时器的回调函数是在定时器服务任务中执行的,所以里面不能调用阻塞函数(比如 vTaskDelay)。我刚开始用的时候就在回调里加了延时,结果整个定时器服务都卡住了。

💡 小技巧: 如果定时器回调里要做耗时操作,建议只发一个消息给其他任务,让别的任务去处理。回调函数保持轻量。

4.6 实战经验总结

说了这么多,最后给你几个我在项目中沉淀下来的建议:

  • 任务栈大小要留余量:用 uxTaskGetStackHighWaterMark() 查看栈使用峰值,然后加30%的余量。
  • 优先级别超过5级:优先级太多反而增加调度开销,3-5级足够应对大多数场景。
  • 队列和信号量要命名规范:我习惯用 xQueue+功能名 和 xSem+功能名,方便调试时识别。
  • 内存分配用静态方式:FreeRTOS支持静态分配内存,在资源受限的MCU上更可控。

好了,这一章的内容就到这里。RTOS是嵌入式开发的必修课,FreeRTOS又是其中最轻量、最流行的选择。掌握了这些基础,后面咱们做语音助手集成时,你就能游刃有余了。

下一章,咱们聊聊蓝牙协议栈的集成与音频流处理。到时候见。