在做裸机开发时,你可能遇到过这样的情况:主循环里既要跑业务逻辑,又要轮询串口和 ADC 的状态,结果一忙起来就丢数据,按键响应也时灵时不灵。 这时候你需要的不是盲目提高主频,而是让外设自己”跑”起来——用中断处理异步事件,用 DMA 搬运批量数据。
嵌入式系统设计的第一原则:让 CPU 只做它该做的事——运算与决策,数据搬运尽量交给 DMA。
1. 为什么要用中断和 DMA?
要选对 IO 方式,先得搞清楚 CPU 和外设之间是怎么交换数据的。 简单来说有三种方式:轮询、中断、DMA,它们在 CPU 占用和实时性上差别很大。
1.1 轮询、中断与 DMA
三种数据交换方式各有特点:
- 轮询(Polling):CPU 不断查询外设状态标志,实现最简单,但等待期间 CPU 什么也干不了
- 中断(Interrupt):外设就绪后主动通知 CPU,实时性好,但每个字节都要进出一次中断
- DMA:外设与内存之间直接成块搬运,整块数据传完才中断 CPU 一次
如果用轮询发送 100 个字节,CPU 就要原地等待约 104ms; 而用 DMA,配置好之后 CPU 可以继续执行其他任务,整块发完才收到一次中断。
1.2 常见性能瓶颈
根据实际项目经验,裸机程序的 IO 瓶颈通常出现在以下几个方面:
| 瓶颈类型 | 典型场景 | 优化方向 |
|---|---|---|
| 主循环轮询 | 循环里反复查询串口标志位 | 改用中断或 DMA |
| 中断里做耗时操作 | 在 ISR 里解析协议、打印日志 | 只置标志位,处理放主循环 |
| 优先级配置错误 | 高频中断长时间抢占导致丢数据 | 合理分配抢占优先级 |
| 数据搬运占用 CPU | 内存到外设逐字节复制 | DMA 零拷贝搬运 |
2. 实战一:外部中断 EXTI 配置
按键、外部脉冲这类异步小事件,最适合用外部中断处理。以 PA0 接按键为例,配置为下降沿触发后, 按下瞬间 CPU 会自动暂停当前任务、跳转到中断服务函数,无需主循环轮询。
配置分三步:开启 GPIO 时钟并设置中断模式 → 在 NVIC 中配置优先级 → 编写中断服务函数。
// 配置 PA0 为下降沿外部中断
void EXTI0_Init(void)
{
GPIO_InitTypeDef gpio = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
gpio.Pin = GPIO_PIN_0;
gpio.Mode = GPIO_MODE_IT_FALLING;
gpio.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &gpio);
HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}
// 回调中只置标志位,耗时处理放主循环
volatile uint8_t key_pressed = 0;
void EXTI0_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_0) {
key_pressed = 1;
}
}
中断服务函数要尽量短:这里只把 key_pressed 置 1,消抖和动作响应都放在主循环里完成。 在中断里调用 printf、长延时或复杂协议解析,是新手最常见的性能陷阱。

3. 实战二:DMA 串口零拷贝收发
串口接收协议帧是 DMA 最典型的用武之地。难点在于数据长度不固定,解决方案是 DMA 接收 + 串口空闲中断(IDLE):当总线持续一个字节时间没有新数据,硬件触发空闲中断, 此时用缓冲区总长度减去 DMA 剩余计数,就得到了这一帧的实际长度。
uint8_t rx_buf[256];
volatile uint16_t rx_len = 0;
// DMA + IDLE 空闲中断实现不定长接收
void UART_DMA_Init(void)
{
__HAL_RCC_DMA1_CLK_ENABLE();
hdma_usart1_rx.Instance = DMA1_Stream5;
hdma_usart1_rx.Init.Channel = DMA_CHANNEL_4;
hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE;
hdma_usart1_rx.Init.Mode = DMA_NORMAL;
HAL_DMA_Init(&hdma_usart1_rx);
__HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx);
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
HAL_UART_Receive_DMA(&huart1, rx_buf, sizeof(rx_buf));
}
// 空闲中断:一帧结束,算出实际长度
void USART1_IRQHandler(void)
{
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
rx_len = sizeof(rx_buf)
- __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
// 主循环中解析 rx_buf[0 .. rx_len)
HAL_UART_Receive_DMA(&huart1, rx_buf,
sizeof(rx_buf));
}
}
DMA 配置三要素:外设地址、内存地址、传输数据量。ADC 连续采样用循环模式,协议帧收发用正常模式。
4. 调试与测量工具
中断和 DMA 出问题时往往”看不见摸不着”,趁手的工具能省下大量时间:
- ST-Link + STM32CubeIDE:在线单步调试,查看外设寄存器与 NVIC 状态
- ITM / SWV:通过调试接口打印日志,不占用串口
- 逻辑分析仪:抓取 GPIO、串口波形,直接测量中断响应延迟
- GPIO 翻转打点:进入中断拉高、处理完拉低,配合示波器量化耗时
排查 DMA 问题优先看三处:时钟是否使能、数据流与通道是否和数据手册对应、缓冲区地址与数据宽度是否匹配。
5. 总结
中断与 DMA 是把 CPU 从 IO 中解放出来的两大法宝。中断适合按键、外部信号这类异步小事件; DMA 适合串口、ADC、SPI 的批量数据搬运,二者配合才能让主循环专注于业务逻辑。
记住三条实战原则:中断服务函数只置标志位,越短越好; DMA 的正常模式与循环模式按场景选择;带 D-Cache 的型号要注意缓冲区的缓存一致性。
如果这篇文章对你有帮助,请我喝杯茶吧