4. 主控模块设计:任务调度、状态机管理、系统启动流程、看门狗策略

好,咱们进入第四章。这一章我打算聊聊主控模块。

主控模块是什么?说白了,它就是整个音响系统的大脑。所有的事情——音频解码、蓝牙连接、用户交互、电源管理——都得靠它来协调。如果主控模块设计得不好,系统就会卡顿、死机,甚至黑屏。我在项目里见过太多这样的惨案了。

4.1 任务调度:别让CPU闲着,也别让它累死

车载音响系统里,任务调度是个老生常谈的话题。但说实话,很多工程师把它想简单了。

你想想看,一个典型的音响系统里,同时跑着多少任务?

  • 音频播放(实时性要求极高,丢一个buffer就卡顿)
  • 蓝牙协议栈(时不时来个中断)
  • UI渲染(用户操作时得立刻响应)
  • CAN总线通信(车机指令不能延迟)
  • 电源管理(省电策略要时刻监控)

这么多任务,怎么调度?我个人习惯用优先级抢占式调度。为什么?因为音频播放的实时性要求最高,必须给它最高的优先级。UI响应可以稍微慢一点,但也不能太慢。

核心原则:音频任务优先级 > 通信任务 > UI任务 > 后台维护任务

我举个例子。以前我做的一个项目,音频解码和蓝牙通信共用一个线程。结果呢?蓝牙连接一频繁,音频就卡顿。后来我把音频解码单独拎出来,放到一个高优先级任务里,问题就解决了。

这里给个简单的任务调度框架示例:

// 任务优先级定义
#define TASK_PRIO_AUDIO     5   // 最高
#define TASK_PRIO_BLUETOOTH 4
#define TASK_PRIO_UI        3
#define TASK_PRIO_CAN       3
#define TASK_PRIO_BACKGROUND 1  // 最低

// 任务创建
void System_TaskInit(void)
{
    // 音频解码任务
    xTaskCreate(AudioDecodeTask, "AudioDecode", 4096, NULL, TASK_PRIO_AUDIO, NULL);
    // 蓝牙协议栈任务
    xTaskCreate(BluetoothTask, "Bluetooth", 2048, NULL, TASK_PRIO_BLUETOOTH, NULL);
    // UI刷新任务
    xTaskCreate(UIRefreshTask, "UIRefresh", 2048, NULL, TASK_PRIO_UI, NULL);
    // CAN通信任务
    xTaskCreate(CANCommTask, "CANComm", 1024, NULL, TASK_PRIO_CAN, NULL);
    // 后台维护任务(比如看门狗喂狗、日志输出)
    xTaskCreate(BackgroundTask, "Background", 1024, NULL, TASK_PRIO_BACKGROUND, NULL);
}

小技巧:任务栈大小别拍脑袋定。我一般先设个较大的值(比如4096),跑一段时间后用工具查看实际使用量,再调小。这样既省内存,又不会栈溢出。

4.2 状态机管理:系统不乱跑的秘诀

状态机这东西,做嵌入式的人都不陌生。但车载音响系统的状态机,比你想的要复杂。

为什么?因为车载音响不是简单的“开机-播放-关机”。它要处理各种异常情况:蓝牙断连、USB拔出、CAN总线休眠、电源掉电……每个状态之间的转换,都得考虑清楚。

我习惯把状态机分成主状态机子状态机两层。主状态机管大方向,子状态机管细节。

主状态机一般包含这几个状态:

状态 说明 触发条件
POWER_OFF 系统关机 ACC信号消失
BOOTING 系统启动中 ACC信号到来
IDLE 空闲待机 启动完成,无音源
ACTIVE 正常工作 有音源播放
SLEEP 低功耗休眠 长时间无操作
FAULT 故障状态 检测到硬件异常

每个主状态下,还有子状态机。比如ACTIVE状态下,子状态机可能是:

  • ACTIVE_BLUETOOTH(蓝牙播放)
  • ACTIVE_USB(USB播放)
  • ACTIVE_RADIO(收音机)
  • ACTIVE_AUX(AUX输入)

状态切换的时候,一定要做状态进入动作状态退出动作。我见过有人只做进入动作,退出时忘了清理资源,结果内存泄漏。

注意:状态切换时,一定要先执行退出动作,再执行进入动作。顺序反了,可能会造成资源冲突。我曾经因为这个bug,查了整整两天。

代码实现上,我推荐用函数指针表的方式,而不是switch-case。为什么?因为状态多了,switch-case会变得又臭又长,而且不好扩展。

// 状态机结构体
typedef struct {
    uint8_t currentState;
    void (*entryFunc)(void);
    void (*exitFunc)(void);
    void (*processFunc)(void);
} StateMachine_t;

// 状态表
static StateMachine_t stateTable[] = {
    {POWER_OFF,  PowerOff_Entry,  PowerOff_Exit,  PowerOff_Process},
    {BOOTING,    Booting_Entry,   Booting_Exit,   Booting_Process},
    {IDLE,       Idle_Entry,      Idle_Exit,      Idle_Process},
    {ACTIVE,     Active_Entry,    Active_Exit,    Active_Process},
    {SLEEP,      Sleep_Entry,     Sleep_Exit,     Sleep_Process},
    {FAULT,      Fault_Entry,     Fault_Exit,     Fault_Process},
};

// 状态切换函数
void StateMachine_Transition(uint8_t newState)
{
    // 先执行当前状态的退出动作
    stateTable[currentState].exitFunc();
    // 更新状态
    currentState = newState;
    // 再执行新状态的进入动作
    stateTable[currentState].entryFunc();
}

4.3 系统启动流程:从按下ACC到播放音乐

系统启动流程,说白了就是“从按下ACC键到音响出声”这个过程。这个过程看似简单,但里面坑很多。

我把它分成几个阶段:

  1. 硬件上电阶段:ACC信号触发,电源芯片开始工作,MCU和DSP上电复位。
  2. Bootloader阶段:MCU从Flash加载固件,校验完整性,然后跳转到主程序。
  3. 硬件初始化阶段:初始化时钟、GPIO、I2C、SPI、UART等外设。
  4. 驱动加载阶段:加载音频编解码器驱动、蓝牙驱动、CAN驱动等。
  5. 服务启动阶段:启动任务调度器、状态机、看门狗等核心服务。
  6. 应用初始化阶段:加载用户配置、恢复上次播放状态、开始播放。

这里有个关键点:启动顺序不能乱。比如,你必须在驱动加载完成之后,才能启动服务。否则,服务调用驱动时,驱动还没准备好,系统就会崩溃。

我的经验:启动流程里,每一步都要加超时保护。比如硬件初始化,如果某个外设没响应,不能死等,要设置超时时间,超时后跳过或报错。否则,一个坏掉的外设就能让整个系统卡死在启动阶段。

代码示例:

void System_Startup(void)
{
    // 1. 硬件初始化
    if (HW_Init() != SUCCESS) {
        // 记录错误,进入故障状态
        System_SetFault(FAULT_HW_INIT_FAIL);
        return;
    }
    
    // 2. 驱动加载
    if (Driver_Load() != SUCCESS) {
        System_SetFault(FAULT_DRIVER_LOAD_FAIL);
        return;
    }
    
    // 3. 服务启动
    Service_Start();
    
    // 4. 应用初始化
    App_Init();
    
    // 5. 进入空闲状态
    StateMachine_Transition(IDLE);
}

4.4 看门狗策略:系统最后的防线

看门狗,说白了就是系统最后的救命稻草。如果系统死机了,看门狗能把它拉回来。

但看门狗的设计,不是简单地“定时喂狗”就完事了。我见过太多人把看门狗当成摆设——在主循环里喂狗,结果系统死机了,看门狗还在被喂,根本起不到作用。

正确的做法是:分层喂狗

我一般设计三层看门狗:

层级 喂狗位置 超时时间 作用
第一层 硬件看门狗(独立芯片) 1.6秒 防止MCU完全死机
第二层 任务调度器 500ms 检测任务是否卡死
第三层 应用层心跳 100ms 检测关键任务是否正常

第一层是硬件看门狗,由MCU的独立看门狗模块实现。这个看门狗只在主循环里喂,如果主循环卡死了,它就会复位系统。

第二层是任务调度器的看门狗。每个任务执行完后,都要报告自己的状态。如果某个任务超过500ms没有报告,调度器就认为它卡死了,然后重启这个任务。

第三层是应用层的心跳。比如音频解码任务,每100ms要发一个心跳包。如果超过100ms没收到心跳,系统就认为音频解码出了问题,尝试重新初始化。

避坑指南:我曾经在一个项目里,把硬件看门狗的喂狗操作放在了中断里。结果呢?主程序死机了,中断还在跑,看门狗一直不被触发。系统就这么“死而不僵”地运行着,直到用户发现音响没声音了。从那以后,我再也不在中断里喂狗了。

喂狗代码示例:

// 硬件看门狗喂狗(在主循环中调用)
void Watchdog_Feed(void)
{
    // 喂硬件看门狗
    HAL_IWDG_Refresh();
}

// 任务调度器喂狗(每个任务执行完后调用)
void Scheduler_Feed(uint8_t taskID)
{
    taskHeartbeat[taskID] = HAL_GetTick();
}

// 应用层心跳(关键任务定期调用)
void App_Heartbeat(uint8_t appID)
{
    appHeartbeat[appID] = HAL_GetTick();
}

// 看门狗监控任务(低优先级后台任务)
void Watchdog_MonitorTask(void *param)
{
    while(1) {
        // 检查每个任务的心跳
        for (int i = 0; i < TASK_NUM; i++) {
            if (HAL_GetTick() - taskHeartbeat[i] > TASK_TIMEOUT) {
                // 任务超时,重启任务
                Task_Restart(i);
            }
        }
        // 检查每个应用的心跳
        for (int i = 0; i < APP_NUM; i++) {
            if (HAL_GetTick() - appHeartbeat[i] > APP_TIMEOUT) {
                // 应用超时,重新初始化
                App_Restart(i);
            }
        }
        // 喂硬件看门狗
        Watchdog_Feed();
        // 延时100ms
        vTaskDelay(100);
    }
}

嗯,这一章的内容就这些。任务调度、状态机、启动流程、看门狗,这四个东西是主控模块的基石。设计好了,系统就稳如泰山;设计不好,那就是定时炸弹。

下一章,咱们聊聊音频处理模块的设计。音频这块,坑更多,到时候我慢慢讲。