第四章:实时操作系统(RTOS)在助听器中的应用

各位同学,今天我们来聊聊RTOS在助听器里的那些事儿。说实话,我刚入行那会儿,助听器还是跑裸机的——一个超级循环,轮询各个模块。后来随着功能越来越多,裸机实在扛不住了。你想想看,又要处理音频流,又要跑降噪算法,还得管理蓝牙连接,一个循环里稍微卡顿一下,用户那边就是「嘶嘶」的噪音。

所以,RTOS几乎是必然的选择。我个人习惯用FreeRTOS,轻量、稳定,而且社区资源丰富。今天我就把我在助听器项目中用FreeRTOS的经验,掰开了揉碎了讲给你听。

4.1 任务调度:别让音频任务饿死

FreeRTOS的任务调度,说白了就是决定「哪个任务先跑」。助听器里任务优先级怎么设?我有个原则:音频处理任务最高,按键响应次之,LED闪烁这种最低。

为什么?因为音频是实时流。你晚处理1毫秒,用户听到的就是破音。我曾经在一个项目里,把音频任务优先级设得不够高,结果蓝牙数据过来时把音频任务抢占了,用户反馈「声音断断续续」。查了两天才找到原因——嗯,从那以后我再也不敢乱设优先级了。

核心要点:助听器任务优先级排序

  • 最高优先级:音频采集与播放(硬实时)
  • 高优先级:降噪算法、反馈抑制
  • 中优先级:蓝牙通信、按键扫描
  • 低优先级:电量显示、日志输出

代码示例,创建一个音频处理任务:

// 音频处理任务,优先级设为最高
void vAudioTask(void *pvParameters) {
    for(;;) {
        // 等待音频数据就绪
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        
        // 处理音频帧(必须在1ms内完成)
        processAudioFrame();
        
        // 输出处理后的音频
        outputAudio();
    }
}

// 创建任务
xTaskCreate(
    vAudioTask,          // 任务函数
    "AudioTask",         // 任务名
    512,                 // 栈深度(字节)
    NULL,                // 参数
    configMAX_PRIORITIES - 1,  // 最高优先级
    NULL                 // 任务句柄
);

我的经验:任务栈大小别抠门。我见过同事把栈设成256字节,结果任务一跑就溢出,系统随机崩溃。助听器里音频任务至少512字节起步,保险点给1024。

4.2 中断管理:别在中断里干重活

中断是RTOS的命脉。助听器里中断特别多——音频数据就绪中断、按键中断、蓝牙中断。新手最容易犯的错误:在中断服务函数里做大量计算。

我曾经接手过一个项目,前任工程师在ADC中断里直接跑了FFT。结果呢?中断占用时间太长,其他中断被阻塞,系统响应变得一塌糊涂。用户按一下音量键,要等半秒才有反应。

正确的做法是什么?中断里只做两件事:

  1. 读取数据(比如从ADC寄存器拿音频样本)
  2. 通知任务来处理(用信号量或任务通知)

看这个例子:

// 音频中断服务函数
void ADC_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
    // 1. 读取音频数据
    uint16_t sample = ADC->DR;
    g_audioBuffer[g_bufferIndex++] = sample;
    
    // 2. 缓冲区满了,通知音频任务
    if(g_bufferIndex >= AUDIO_FRAME_SIZE) {
        g_bufferIndex = 0;
        vTaskNotifyGiveFromISR(
            xAudioTaskHandle,
            &xHigherPriorityTaskWoken
        );
    }
    
    // 3. 如果需要上下文切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

避坑指南:我曾经在中断里用了printf打印调试信息,结果系统直接死机。记住:中断里绝对不能用阻塞型函数!printf、malloc、甚至一些RTOS的API都不能用。只能用FromISR结尾的函数。

4.3 内存管理:助听器里每一字节都珍贵

助听器的RAM通常只有几十KB。你想想看,又要存音频缓冲区,又要跑算法,还要给RTOS的任务栈留空间。内存管理稍有不慎,系统就崩了。

FreeRTOS提供了5种内存管理方案。我个人在助听器项目里只用heap_4.c——它支持碎片合并,而且不会释放内存(助听器里也不需要动态释放)。

方案 特点 助听器适用性
heap_1 最简单,不支持释放 适合极简场景
heap_2 支持释放,但会碎片 不推荐,碎片问题严重
heap_3 包装了malloc/free 依赖C库,不推荐
heap_4 支持碎片合并 强烈推荐
heap_5 支持多块内存区域 特殊场景使用

内存分配的一个坑:不要在音频任务里频繁malloc/free。我见过一个项目,每次处理音频帧都动态分配缓冲区,跑了几个小时内存就耗尽了。正确的做法是:启动时一次性分配好所有缓冲区,运行期间不再动态申请。

// 静态分配音频缓冲区
static int16_t g_audioBuffer[AUDIO_FRAME_SIZE * 2];
static float g_filterCoeffs[FILTER_TAPS];

// 只在初始化时分配一次
void audioInit(void) {
    // 所有内存都在这里分配好
    g_audioTaskStack = pvPortMalloc(1024);
    configASSERT(g_audioTaskStack != NULL);
}

我的习惯:在FreeRTOSConfig.h里把configTOTAL_HEAP_SIZE设成实际RAM的70%左右。留30%给中断栈、硬件外设等。别贪心,留点余量。

4.4 低功耗模式:让助听器撑一整天

助听器是电池供电的,用户可不想半天充一次电。FreeRTOS的Tickless模式就是为此设计的——没有任务运行时,让CPU进入休眠。

实现起来其实不复杂。核心思路是:

  1. 空闲任务钩子函数里调用WFI指令
  2. 配置一个低功耗定时器,在下一个任务到期时唤醒
  3. 进入休眠前保存上下文,唤醒后恢复

看配置:

// FreeRTOSConfig.h
#define configUSE_TICKLESS_IDLE         1
#define configEXPECTED_IDLE_TIME_BEFORE_SLEEP 2  // 至少2个tick空闲才休眠

// 低功耗定时器配置
void vPortSetupTimerInterrupt(void) {
    // 配置一个16位定时器,用于Tickless模式
    // 频率设为32768Hz,这样功耗最低
    LPTIM->ARR = 32768 / configTICK_RATE_HZ;
    LPTIM->CR = LPTIM_CR_ENABLE;
}

实际效果:我在一个项目中开启Tickless模式后,待机功耗从2mA降到了0.3mA。用户反馈电池续航从8小时提升到了20小时。嗯,这个优化值得做。

但要注意一个坑:低功耗定时器的精度。我曾经用了一个精度不高的RC振荡器,结果休眠时间不准,任务唤醒延迟了几毫秒。音频处理就出问题了。后来换成晶振,问题解决。

4.5 任务间通信:别让数据打架

助听器里多个任务需要交换数据。音频任务要把处理后的数据发给输出任务,蓝牙任务要把音量指令发给音频任务。怎么通信?

FreeRTOS提供了几种方式:队列、信号量、任务通知、事件组。我个人最常用的是队列和任务通知。

队列适合传递数据块。比如音频帧从采集任务传到处理任务:

// 创建队列,存放音频帧
QueueHandle_t xAudioQueue = xQueueCreate(4, sizeof(AudioFrame_t));

// 采集任务发送
void vAudioCaptureTask(void *pvParams) {
    AudioFrame_t frame;
    for(;;) {
        captureAudio(&frame);
        // 发送到队列,超时10ms
        xQueueSend(xAudioQueue, &frame, pdMS_TO_TICKS(10));
    }
}

// 处理任务接收
void vAudioProcessTask(void *pvParams) {
    AudioFrame_t frame;
    for(;;) {
        // 阻塞等待音频数据
        xQueueReceive(xAudioQueue, &frame, portMAX_DELAY);
        processAudio(&frame);
    }
}

任务通知更快,适合传递信号。比如按键按下通知音量调整:

// 按键中断中通知
void EXTI_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    vTaskNotifyGiveFromISR(xVolumeTaskHandle, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 音量任务等待通知
void vVolumeTask(void *pvParams) {
    for(;;) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        adjustVolume();  // 调整音量
    }
}

避坑指南:我曾经在队列里传了指针,结果两个任务同时访问同一块内存,数据全乱了。记住:队列传值不传址!要么传结构体副本,要么用内存池管理共享内存。

还有一个经验:队列长度别设太大。音频队列我一般设4个缓冲区,够用就行。设大了浪费内存,设小了容易丢数据。4个是经验值,你可以根据音频帧大小和任务处理时间调整。

小结

好了,这一章的内容就这些。总结一下我的核心观点:

  • 任务优先级:音频最高,别让实时任务被抢
  • 中断处理:只做最少的事,用通知唤醒任务
  • 内存管理:用heap_4,启动时一次性分配
  • 低功耗:Tickless模式是续航利器
  • 任务通信:队列传数据,通知传信号

下一章我们会讲音频信号的前端处理——ADC配置、增益控制、抗混叠滤波。这些是算法的基础,也是我踩坑最多的地方。到时候再跟你细聊。

公众号:蓝海资料掘金营,微信deep3321