裸机工程解剖: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++;
}
写中断服务函数,有两条铁律:
- 快进快出:中断里只做最必要的事,比如存数据、设标志位。千万别在中断里做复杂计算或打印。
- 清标志位:进中断后第一件事就是判断中断源,处理完后必须清标志。否则会一直进中断,系统直接卡死。
我曾经踩过的坑:有一次做串口接收,中断里直接调用了 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 循环、中断、全局变量之间的关系串起来:
这张图把整个裸机程序的运行逻辑说清楚了:
- 中断负责“写”——把硬件数据存进全局变量,置位标志。
- 主循环负责“读”——轮询标志位,取走数据,处理业务。
- 全局变量是桥梁——两边都认识它,但操作要小心。
6. 避坑指南:我踩过的几个雷
做裸机开发这些年,有些坑真的是反复踩。分享几个典型的:
坑1:中断里调用 printf
我曾经在串口中断里直接 printf 打印收到的数据。结果 printf 底层又触发了串口中断,直接死锁。解决方案:中断里只把数据放进环形缓冲区,主循环里再统一打印。
坑2:忘记加 volatile
有一次调试一个按键中断,主循环里判断按键标志位,怎么都判断不到。查了半天,发现是编译器把变量优化掉了。加上 volatile 后立刻正常。嗯,这个教训让我记住了——凡是中断和主循环共享的变量,一律 volatile。
坑3:中断里做延时
有人想在中断里做个简单的延时消抖,直接调了 HAL_Delay。结果 HAL_Delay 依赖 SysTick 中断,而 SysTick 中断优先级可能比当前中断低,直接卡死。记住:中断里绝对不能用延时函数。
7. 小结:裸机工程的“三驾马车”
总结一下,裸机工程的核心就三个东西:
- main 循环:轮询所有任务,不能阻塞,每个任务都要快。
- 中断服务函数:处理紧急事件,快进快出,只做数据搬运。
- 全局变量:中断和主循环的通信桥梁,记得加 volatile。
搞懂这三者的协作关系,你就掌握了裸机开发的底层逻辑。后面我们讲 RTOS 移植时,你会发现——RTOS 本质上就是把 main 循环的轮询逻辑,交给了调度器来管理。但中断和全局变量的处理思路,其实是一脉相承的。