4、RTOS基础与任务调度:FreeRTOS移植、任务创建与管理、消息队列、信号量、软件定时器
好,咱们进入第四章。这一章可以说是整个蓝牙耳机语音助手软件的「地基」。你想想看,一个耳机里要同时跑蓝牙协议栈、音频编解码、语音唤醒、按键检测、电量管理……这么多活儿,要是没有个靠谱的调度系统,那不乱成一锅粥了?
RTOS(实时操作系统)就是干这个的。我这些年做嵌入式项目,从裸机到RTOS,最大的感受就是:裸机写逻辑,RTOS写架构。今天咱们就拿FreeRTOS开刀,把这套东西讲透。
4.1 FreeRTOS移植:别怕,其实就三步
很多新手一听到「移植」两个字就头大。其实说白了,就是把FreeRTOS的源码放到你的工程里,然后告诉它你的芯片长什么样。
我个人习惯把移植分成三步走:
- 拿源码:去官网或者GitHub下载最新版,别用太老的版本,坑多。
- 配接口:主要是三个东西——滴答定时器(SysTick)、中断优先级、以及临界区保护。
- 跑demo:先创建一个最简单的任务,点个灯,验证移植是否成功。
这里有个容易踩的坑——中断优先级配置。FreeRTOS要求你使用芯片的最高优先级给SysTick和PendSV中断。我曾经在一个STM32项目上忘了设置,结果任务调度死活不工作,查了两天才发现是优先级没对齐。
代码层面,你只需要在 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闪烁给个最低优先级就行。
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外设。加上信号量保护后,问题立刻解决。所以记住:任何共享资源,都要加锁。
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又是其中最轻量、最流行的选择。掌握了这些基础,后面咱们做语音助手集成时,你就能游刃有余了。
下一章,咱们聊聊蓝牙协议栈的集成与音频流处理。到时候见。