裸机工程解剖:main循环、中断与全局变量的组织方式

说实话,很多初学者拿到一个裸机工程,第一反应就是——代码怎么这么多?从哪看起?

我当年刚入行时也是这样。点个灯而已,怎么又是寄存器配置、又是中断向量表、又是链接脚本的。后来被师傅带着读了几遍工程,才慢慢摸到门道。

这一章,我就带你解剖一个典型的裸机点灯+串口工程。咱们不搞虚的,直接看代码怎么组织、main循环怎么跑、中断怎么插进来、全局变量又该放哪。

核心观点:裸机工程就三件事——初始化、死循环、中断。搞懂这三块的协作方式,你就掌握了嵌入式软件的基本骨架。

1. 工程文件结构:别被文件夹吓到

一个标准的裸机工程,通常长这样:

project/
├── startup/          # 启动文件,汇编写的
│   └── startup_stm32f10x_hd.s
├── drivers/          # 外设驱动
│   ├── gpio.c / gpio.h
│   ├── usart.c / usart.h
│   └── systick.c / systick.h
├── main.c            # 主函数
├── stm32f10x_it.c    # 中断服务函数
└── stm32f10x_conf.h  # 外设配置头文件

我个人习惯把文件分成三层:

  • 芯片层:启动文件、系统时钟配置、中断向量表——这部分基本不动,除非换芯片。
  • 驱动层:GPIO、UART、Timer 这些外设的初始化与操作函数——写一次,反复用。
  • 应用层:main.c 和中断服务函数——业务逻辑在这。

你想想看,如果所有代码都塞在一个文件里,后期维护有多痛苦?我在项目里见过一个 3000 行的 main.c,改个灯闪频率要找半天……

2. main循环:嵌入式程序的“心脏”

裸机程序的核心,就是一个死循环。说白了,单片机没有操作系统来调度任务,只能靠我们自己在一个循环里轮询。

来看一个典型的 main.c:

int main(void)
{
    /* 1. 初始化硬件 */
    HAL_Init();           // 初始化HAL库
    SystemClock_Config(); // 配置系统时钟(72MHz)
    GPIO_Init();          // 初始化LED引脚
    USART_Init();         // 初始化串口

    /* 2. 初始化全局变量 */
    uint32_t tick = 0;
    uint8_t  rx_buffer[64] = {0};

    /* 3. 进入主循环 */
    while (1)
    {
        /* 任务1:LED闪烁 */
        if (HAL_GetTick() - tick > 500)
        {
            HAL_GPIO_Toggle(LED_PIN);
            tick = HAL_GetTick();
        }

        /* 任务2:串口轮询接收 */
        if (USART_DataAvailable())
        {
            uint8_t ch = USART_ReadByte();
            ProcessCommand(ch);
        }

        /* 任务3:喂看门狗(如果有) */
        IWDG_Refresh();
    }
}

这里有几个关键点:

  • 初始化顺序很重要:先时钟、再外设、最后变量。我遇到过有人把串口初始化放在时钟配置前面,结果串口怎么都调不通……
  • 主循环不能阻塞:每个任务都要快速执行,不能死等。比如 LED 闪烁用时间戳判断,而不是 delay 死等。
  • 轮询 vs 中断:主循环里做轮询,紧急的事情交给中断。这个后面细说。

小技巧:主循环里加个“空闲钩子”——当所有任务都执行完了,可以让 CPU 进入休眠模式。我在做低功耗项目时,这个技巧帮了大忙。

3. 中断服务函数:紧急事件的“快车道”

主循环是慢车道,中断就是快车道。一旦有紧急事件(比如串口收到数据、按键按下),CPU 会立刻跳转到对应的中断服务函数。

中断服务函数通常写在 stm32f10x_it.c 里:

/* 串口接收中断 */
void USART1_IRQHandler(void)
{
    if (USART_GetITStatus(USART1, USART_IT_RXNE))
    {
        uint8_t data = USART_ReceiveData(USART1);
        /* 把数据放进环形缓冲区 */
        RingBuf_Push(&rx_ring, data);
        /* 清中断标志 */
        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

/* 定时器中断(1ms一次) */
void SysTick_Handler(void)
{
    uwTick++;
}

写中断服务函数,有两条铁律:

  1. 快进快出:中断里只做最必要的事,比如存数据、设标志位。千万别在中断里做复杂计算或打印。
  2. 清标志位:进中断后第一件事就是判断中断源,处理完后必须清标志。否则会一直进中断,系统直接卡死。

我曾经踩过的坑:有一次做串口接收,中断里直接调用了 printf 打印收到的数据。结果 printf 本身也依赖串口中断,直接死锁了。从那以后,我规定中断里只做数据搬运,不做任何可能触发中断的操作。

4. 全局变量:共享数据的“双刃剑”

裸机工程里,全局变量是 main 循环和中断之间传递数据的桥梁。但用不好,就是灾难。

常见的组织方式:

/* globals.h */
extern volatile uint32_t g_sys_tick;    // 系统滴答,中断里更新
extern volatile uint8_t  g_rx_flag;     // 接收完成标志
extern          uint8_t  g_rx_buffer[]; // 接收缓冲区

/* globals.c */
volatile uint32_t g_sys_tick = 0;
volatile uint8_t  g_rx_flag  = 0;
uint8_t           g_rx_buffer[64] = {0};

这里有个细节:volatile 关键字。为什么中断里用的变量要加 volatile?

因为编译器会优化代码。比如你在主循环里判断 g_rx_flag,如果没有 volatile,编译器可能认为这个变量永远不会变,直接把判断优化掉了。结果就是——中断改了标志,主循环却永远看不到。

我建议的全局变量管理原则:

变量类型 存放位置 是否加 volatile 说明
系统滴答计数 globals.c 是 中断中递增,主循环读取
接收缓冲区 globals.c 否(数组本身) 中断写,主循环读,用环形缓冲区管理
状态标志位 globals.c 是 中断置位,主循环清零
配置参数 main.c 或单独 config.c 否 只在初始化时设置,运行时不改

个人习惯:我一般把全局变量集中放在一个 globals.h/.c 里,而不是散落在各个驱动文件中。这样找变量方便,也容易做代码审查。

5. 整体协作流程:一张图看懂

说了这么多,咱们用一张图把 main 循环、中断、全局变量之间的关系串起来:

裸机工程核心协作流程 主循环 (main loop) while(1) 轮询 LED闪烁 | 串口轮询 | 喂狗 读取全局变量 处理业务逻辑 清除标志位 中断服务函数 (ISR) 硬件触发 → 自动跳转 串口接收 | 定时器中断 写入环形缓冲区 置位标志位 清中断标志 → 退出 全局变量 (volatile) g_sys_tick | g_rx_flag | g_rx_buffer 读取变量 写入变量 数据驱动 ⚠ 关键原则: 中断只写数据/置标志,主循环读数据/清标志。两者通过 volatile 全局变量通信,绝不交叉调用。

这张图把整个裸机程序的运行逻辑说清楚了:

  • 中断负责“写”——把硬件数据存进全局变量,置位标志。
  • 主循环负责“读”——轮询标志位,取走数据,处理业务。
  • 全局变量是桥梁——两边都认识它,但操作要小心。

6. 避坑指南:我踩过的几个雷

做裸机开发这些年,有些坑真的是反复踩。分享几个典型的:

坑1:中断里调用 printf

我曾经在串口中断里直接 printf 打印收到的数据。结果 printf 底层又触发了串口中断,直接死锁。解决方案:中断里只把数据放进环形缓冲区,主循环里再统一打印。

坑2:忘记加 volatile

有一次调试一个按键中断,主循环里判断按键标志位,怎么都判断不到。查了半天,发现是编译器把变量优化掉了。加上 volatile 后立刻正常。嗯,这个教训让我记住了——凡是中断和主循环共享的变量,一律 volatile。

坑3:中断里做延时

有人想在中断里做个简单的延时消抖,直接调了 HAL_Delay。结果 HAL_Delay 依赖 SysTick 中断,而 SysTick 中断优先级可能比当前中断低,直接卡死。记住:中断里绝对不能用延时函数。

7. 小结:裸机工程的“三驾马车”

总结一下,裸机工程的核心就三个东西:

  • main 循环:轮询所有任务,不能阻塞,每个任务都要快。
  • 中断服务函数:处理紧急事件,快进快出,只做数据搬运。
  • 全局变量:中断和主循环的通信桥梁,记得加 volatile。

搞懂这三者的协作关系,你就掌握了裸机开发的底层逻辑。后面我们讲 RTOS 移植时,你会发现——RTOS 本质上就是把 main 循环的轮询逻辑,交给了调度器来管理。但中断和全局变量的处理思路,其实是一脉相承的。