裸机编程回顾:超级循环、ISR与状态机

说实话,很多从学校出来的工程师,第一份工作接触的都是裸机编程。我自己也是这么过来的。那时候觉得,一个 while(1) 循环就能搞定一切,简单直接,没什么不好。

但当你开始做复杂项目——比如同时处理按键、显示、通信、传感器采集——你就会发现,裸机编程其实藏着不少坑。今天我们就来好好聊聊裸机编程的三大核心:超级循环、中断服务程序、状态机设计模式。

超级循环架构:最朴素的调度方式

超级循环,说白了就是主循环。它的结构非常简单:

void main(void)
{
    // 初始化硬件
    System_Init();
    
    while(1)
    {
        Task_KeyScan();      // 按键扫描
        Task_Display();      // 显示刷新
        Task_CommProcess();  // 通信处理
        Task_SensorRead();   // 传感器读取
    }
}

每个任务按顺序执行,周而复始。我在早期做智能家居面板时,用的就是这种架构。当时觉得挺稳的,直到遇到一个问题——按键按下时,显示会卡顿一下。

为什么会这样?因为所有任务都是串行执行的。如果 Task_Display 里有个延时操作,后面的任务就得等着。这就是超级循环最大的问题:实时性差。

超级循环的优缺点
  • 优点:代码简单、逻辑清晰、资源占用极低
  • 缺点:任务互相阻塞、实时性无法保证、扩展性差

你想想看,如果某个任务执行时间长了,整个系统就像卡住了一样。这在工业控制、汽车电子等场景下,是绝对不能接受的。

中断服务程序(ISR):打破循环的利器

为了解决实时性问题,我们引入了中断。中断允许 CPU 暂停当前任务,去处理更紧急的事件。

我记得第一次用中断时,觉得这东西太神奇了。按键按下,CPU 立刻响应,主循环完全不受影响。但后来发现,中断也不是万能的。

// 定时器中断服务程序
void TIM2_IRQHandler(void)
{
    if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
    {
        TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
        
        // 处理定时任务
        g_uiTick++;
        
        // 注意:不要在ISR里做耗时操作!
        // Delay(100);  // 这是大忌
    }
}

中断服务程序有几个铁律:

  • 快进快出:ISR 里不能做耗时操作,比如延时、打印、复杂计算
  • 尽量短小:只做必要的处理,比如设置标志位、读取数据
  • 注意优先级:高优先级中断会打断低优先级中断,可能导致嵌套
我曾经踩过的坑: 在 ISR 里调用了 printf 函数,结果系统直接死机。因为 printf 本身是阻塞的,而且可能重入。从那以后,我养成了一个习惯——ISR 里只设标志位,具体处理放到主循环里做。

中断的引入,让裸机系统有了初步的实时响应能力。但问题来了:多个中断同时触发怎么办?哪个先处理?这就引出了中断优先级的概念。

中断特性 说明 注意事项
抢占优先级 决定中断能否打断另一个中断 数值越小优先级越高
子优先级 同抢占优先级下的响应顺序 只在同组内有效
中断嵌套 高优先级打断低优先级 嵌套层数不宜过多
临界区 需要关中断保护的代码段 时间要尽量短

状态机设计模式:让逻辑变得可控

有了超级循环和中断,系统能跑起来了。但业务逻辑一复杂,代码就变得像意大利面条一样,到处都是 if-else 和标志位判断。

这时候,状态机就派上用场了。我个人习惯用状态机来处理按键、通信协议、菜单导航这类有明确状态转换的场景。

// 按键状态机
typedef enum
{
    KEY_STATE_IDLE,      // 空闲状态
    KEY_STATE_PRESS,     // 按下状态
    KEY_STATE_LONG,      // 长按状态
    KEY_STATE_RELEASE    // 释放状态
} KeyState_t;

KeyState_t g_eKeyState = KEY_STATE_IDLE;

void KeyFSM_Process(void)
{
    uint8_t ucKeyVal = GPIO_ReadInputDataBit(KEY_PORT, KEY_PIN);
    
    switch(g_eKeyState)
    {
        case KEY_STATE_IDLE:
            if(ucKeyVal == 0)  // 按键按下
            {
                g_eKeyState = KEY_STATE_PRESS;
                g_uiPressTick = 0;
            }
            break;
            
        case KEY_STATE_PRESS:
            if(ucKeyVal == 1)  // 按键释放
            {
                g_eKeyState = KEY_STATE_RELEASE;
                // 触发短按事件
                Event_Send(EVENT_KEY_SHORT);
            }
            else if(g_uiPressTick > LONG_PRESS_TIME)
            {
                g_eKeyState = KEY_STATE_LONG;
                // 触发长按事件
                Event_Send(EVENT_KEY_LONG);
            }
            break;
            
        case KEY_STATE_LONG:
            if(ucKeyVal == 1)  // 长按后释放
            {
                g_eKeyState = KEY_STATE_RELEASE;
            }
            break;
            
        case KEY_STATE_RELEASE:
            g_eKeyState = KEY_STATE_IDLE;
            break;
            
        default:
            g_eKeyState = KEY_STATE_IDLE;
            break;
    }
}

状态机的核心思想其实很简单:把系统行为抽象成有限个状态,每个状态只关心自己能做什么、能跳转到哪些状态。这样一来,代码的可读性和可维护性都大大提升。

我的经验: 状态机设计时,一定要画状态转换图。别偷懒,画出来之后,很多隐藏的逻辑漏洞一眼就能看出来。我曾经有个项目,就是因为没画图,漏了一个状态转换条件,导致产品在特定操作下死机。后来补了图,五分钟就找到了问题。

三种模式的协同工作

在实际项目中,超级循环、中断、状态机往往是配合使用的。我一般这样组织代码:

  • 中断:处理硬件事件,设置标志位或填充缓冲区
  • 主循环:轮询标志位,调用状态机处理逻辑
  • 状态机:管理业务逻辑,控制行为转换

这种架构,说白了就是「中断收数据,主循环做处理,状态机管逻辑」。它虽然不是 RTOS,但在很多简单场景下已经够用了。

下面这张图,是我自己总结的裸机编程核心架构,你可以参考一下:

裸机编程核心架构 超级循环 中断服务程序 状态机设计模式 设置标志位 调用处理 主循环任务调度 轮询标志位 · 调用状态机 · 执行任务 按键扫描 显示刷新 通信处理 传感器 核心思想:中断收数据 → 主循环做处理 → 状态机管逻辑 三者配合,实现简单高效的裸机系统

裸机编程的局限性

说了这么多裸机编程的好处,也得说说它的不足。毕竟我们这门课是要往 RTOS 方向走的。

裸机编程最大的问题,我总结为三点:

  1. 任务调度不公平:超级循环里,前面的任务占用了太多时间,后面的任务就得等着。你没法保证每个任务都能按时执行。
  2. 实时性难以保证:虽然有中断,但中断处理也是有时延的。而且多个中断同时到来时,优先级处理不当就会出问题。
  3. 代码耦合度高:所有任务都在一个循环里,改一个地方可能影响全局。项目大了之后,维护成本直线上升。
什么时候该考虑 RTOS?
  • 任务数量超过 5 个
  • 有严格的实时性要求(毫秒级响应)
  • 任务之间有复杂的同步和通信
  • 代码规模大,需要模块化设计