导读『用 DMA 可以降低 CPU 占用率』几乎成了口头禅但 DMA 又不是协处理器它凭什么让 CPU 变闲本文先做一笔『周期账』量化 CPU 搬运的真实成本再从总线主设备、总线矩阵仲裁的角度讲清 DMA 的硬件原理然后给出 STM32 上串口DMA 收不定长包、ADCDMA 连续采样两个完整实操案例最后总结 8 个最常见的 DMA 翻车现场。一、先算账CPU 搬一个字节到底贵在哪看一段最朴素的串口收字节代码中断方式▍CPU 中断搬运每字节一次中断void USART1_IRQHandler(void) /* 每收到 1 个字节触发一次 */ { uint8_t b USART1-DR; /* 读数据寄存器清 RXNE */ ring_put(b); /* 写内存、更新指针 */ }每收 1 个字节CPU 要经历硬件入栈 8 字约 12 周期→ 编译器生成的 push {r4-r11, lr} → 读 DR外设总线一次访存→ 写内存 → 清标志 → 出栈返回又 12 周期。粗算每次 ISR 200 个周期并不夸张。把它乘以速率图 1 72MHz 内核上不同串口速率下『CPU 逐字节中断』与『DMA 循环搬运』的占用率对比估算115200bps 时 CPU 约 3% 被吃掉似乎不多但注意三个隐藏成本打断成本每次中断都会冲刷流水线、污染 I-Cache/D-Cache主循环里正在跑的FFT、滤波算法被反复『腰斩』实测主任务性能下降远超 3%峰值成本中断是突发的一帧数据背靠背到达时主任务可能连续几十微秒得不到执行实时性最坏情况延迟才是嵌入式的第一指标扩展成本速率上到 921600、3Mbps蓝牙、数传电台常见或者 ADC 1MSPS 连续采样CPU 方式直接 30%~100% 占满系统崩盘。而 DMA 方式下 CPU 只在『配置』和『半传输/传输完成』两个时间点介入搬运本身由 DMA 控制器在总线上独立完成。这就是『降低 CPU 占用率』的数学本质把每字节 O(1) 的 CPU 开销摊薄成每帧 O(1)。二、DMA 的硬件本质第二个『总线主设备』图 2 两种搬运方式对比CPU 方式每字节执行上百条指令DMA 方式搬运期间 CPU 全速跑主程序要理解 DMA先破除一个直觉误区内存和外设之间传数据『必须经过 CPU』是错的。在 STM32 内部CPU 内核和 DMA 控制器都是总线矩阵Bus Matrix上的主设备MasterSRAM、Flash、外设寄存器都是从设备Slave。CPU 之所以能搬数据只是因为它恰好是一个『会执行 LDR/STR 指令的主设备』而 DMA 是另一个『只会做固定搬运套路的主设备』它有自己的地址发生器、计数器与请求仲裁器。图 3 STM32 数据通路与循环模式缓冲区模型DMA 写满半区触发 HT 中断写满整区触发 TC 中断于是『DMA 为什么快』就有了硬件答案零指令开销一次搬运在 DMA 内部是『请求→仲裁→读→写→计数』的固定状态机每字节约 2 个总线周期而不是 CPU 的上百条指令并行性总线矩阵有多层DMA 搬外设→SRAM 的同时CPU 可以从另一层访问Flash 取指、访问另一块 SRAM两者真正并行冲突只付小代价当 CPU 与 DMA 抢同一条总线/同一块内存时仲裁器让一方等1~2 个周期——CPU 被『挂起』而不是被『打断』没有上下文切换、没有 Cache 污染。【一句话原理】DMA 降低 CPU 占用率不是因为它『搬得魔法』而是因为它把『每字节一次的软件循环』变成了『每字节一次的硬件总线事务』并把 CPU 的介入频率从 N 次降到 2 次HT/TC 中断。三、STM32 的 DMA 长什么样通道、寄存器与工作模式3.1 通道与请求映射F1 系列有 DMA17 通道 DMA25 通道外设到通道的映射是硬件固定的查错表是日常见表 1。F4 系列升级为 8 条 Stream × 8 个 Channel 的交叉开关每条 Stream 可用 Channel 选择寄存器连接多个外设之一灵活但要查『Stream/Channel对照表』。外设请求F1 固定通道F4 示例Stream/ChannelUSART1_RXDMA1_Channel5DMA2_Stream5 / CH4USART1_TXDMA1_Channel4DMA2_Stream7 / CH4ADC1DMA1_Channel1DMA2_Stream0 / CH0SPI1_RX / TXDMA1_Ch2 / Ch3DMA2_Stream0/2 / CH3TIM2_UPDMA1_Channel2更新事件DMA1_Stream7 / CH3表 1 常用外设的 DMA 请求映射以参考手册 DMA 章节表格为准3.2 一条通道运行时在做什么CPAR/CMAR外设地址、内存地址源或目的由方向位 DIR 决定CNDTR剩余传输计数每完成一次搬运硬件减 1减到 0 触发传输完成TCCCR方向、循环模式CIRC、外设/内存位宽PSIZE/MSIZE、地址自增PINC/MINC、内存到内存M2M、通道优先级、三个中断使能TCIE/HTIE/TEIE运行时序外设置位请求如 RXNE→ DMA 向总线矩阵申请总线 → 读外设 DR → 写内存 → CNDTR-- → 到半值触发 HT 中断 → 到 0 触发 TC 中断循环模式则重载CNDTR 继续。位宽不一致时硬件会自动做打包/解包如 PSIZE16bit、MSIZE8bit一次外设读产生两次内存写这既是便利也是 Bug 7 的温床。F4 还有 4 字 FIFO 与双缓冲M0AR/M1AR CT 位切换适合高速连续流。四、实操案例一USART1 循环 DMA IDLE 中断收不定长包这是工程里最经典的 DMA 用法DMA 负责『不停地把字节填入环形缓冲区』IDLE 中断负责『一帧结束时通知我』两者配合实现零丢包、零轮询的串口接收。▍USART1 DMA 循环接收 IDLE 判帧尾/* STM32F103, HAL 版 */ #define RX_SIZE 512 uint8_t rx_buf[RX_SIZE]; /* 全局、4 字节对齐绝不能放栈上 */ volatile uint16_t rx_len 0; /* 本帧长度ISR 写、主循环读 */ void uart_dma_init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); /* ① DMA 时钟忘开DMA 完全不动 */ hdma.Instance DMA1_Channel5; /* USART1_RX */ hdma.Init.Direction DMA_PERIPH_TO_MEMORY; hdma.Init.PeriphInc DMA_PINC_DISABLE; /* 外设地址固定 */ hdma.Init.MemInc DMA_MINC_ENABLE; /* 内存地址 */ hdma.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma.Init.Mode DMA_CIRCULAR; /* ② 循环模式 */ hdma.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma); __HAL_LINKDMA(huart1, hdmarx, hdma); HAL_NVIC_SetPriority(DMA1_Channel5_IRQn, 2); HAL_NVIC_EnableIRQ(DMA1_Channel5_IRQn); HAL_UART_Receive_DMA(huart1, rx_buf, RX_SIZE); /* ③ 启动 DMA 接收 */ __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); /* ④ 开 IDLE 中断 */ } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); /* 读 SR 读 DR 清 IDLE */ /* ⑤ CNDTR 还没写的字节数RX_SIZE - CNDTR DMA 已写入的位置 */ uint16_t pos RX_SIZE - __HAL_DMA_GET_COUNTER(hdma); rx_len pos; /* 简化处理见正文的『回卷』讨论 */ } HAL_UART_IRQHandler(huart1); /* 处理 DMA 的 TC/HT/TE 中断 */ } /* 主循环if (rx_len) { 处理 rx_buf[0..rx_len-1]; 用完后 rx_len 0; } */4.1 循环模式下的『回卷』问题循环模式下 DMA 写到缓冲区尾部会自动回到 0 继续写。若一帧数据恰好跨过尾部rx_buf[0..pos-1] 里其实是『上一帧尾巴 本帧开头』的拼接。工程上有两种稳妥做法半区法推荐只消费『刚被写满的半区』——HT 中断消费前半、TC 中断消费后半把缓冲区当双缓冲用彻底避开跨边界的帧适合定长/流式数据如 ADC、I2S软件环形法记录 last_posIDLE 里若 poslast_pos 则分两段 memcpy 拼帧适合不定长协议包。无论哪种都要保证『消费速度 ≥ 生产速度』否则 DMA 会追上来覆盖未读数据overrun。缓冲区大小的经验值≥ 2 × 最大帧长 × 安全系数。五、实操案例二ADC1 多通道连续扫描 DMA▍ADC DMA 循环采样骨架/* ADC1: 3 通道连续扫描DMA 循环搬运到 adc_buf */ #define CHN_NUM 3 uint32_t adc_buf[CHN_NUM]; /* ADC 是 12 位用 uint32 防截断 */ hadc1.Init.ScanConvMode ADC_SCAN_ENABLE; hadc1.Init.ContinuousConvMode ENABLE; /* 连续转换 */ hadc1.Init.DMAContinuousRequests ENABLE; /* DMA 请求持续 */ hdma_adc1.Init.Mode DMA_CIRCULAR; /* ...其余初始化同前通道序列 Ch0/Ch1/Ch4... */ HAL_ADC_Start_DMA(hadc1, adc_buf, CHN_NUM); void DMA1_Channel1_IRQHandler(void) /* F1: ADC1 固定走 Ch1 */ { HAL_DMA_IRQHandler(hdma_adc1); } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *h) { /* HT前半(本例即一次完整序列)已稳定可安全取走 */ }要点ADC 转换完成自动触发 DMA 请求无需 CPU多通道扫描时 DMA 按转换顺序依次填入数组天然对齐。若用 F7/H7带 D-Cache请务必看 Bug 6。六、常用 Bug 与排查Bug 1DMA 完全不动——忘了开 DMA 时钟现象HAL_UART_Receive_DMA 返回 HAL_OK但 CNDTR 永远不变。DMA 挂在 AHB 上必须 __HAL_RCC_DMA1_CLK_ENABLE()UART 本体在 APB、GPIO 在 APB2三路时钟一个都不能少。自检读 DMA1_Channel5-CNDTR 是否在递减。Bug 2通道/Stream 映射张冠李戴现象配置看着都对就是没数据。F1 的映射是硬件固定的USART1_RX 只能是 DMA1_Channel5用错通道 DMA 根本收不到请求F4 用错 Stream 或 Channel 选择位同理。解决永远以参考手册『DMA request mapping』表为准CubeMX 生成的代码可以信手写必须查表。Bug 3HT/TC 中断各触发了一次『多余』的进入现象回调函数莫名其妙多进一次或进一次后再也不进。DMA 的 HT/TC/TE 标志是『置位后需软件清除』的HAL 的 IRQHandler 会帮你清但裸机写法若只清了 TC 没清 HTHT 中断会一直挂起。裸机模板进 ISR 先读 ISR 寄存器判断是哪个标志处理完写 IFCR 对应位清掉。Bug 4循环缓冲被覆盖overrun 丢数现象偶发数据错乱重现象消失。根因是消费太慢DMA 写完半区后你在 HT 回调里做了耗时操作等回来时 DMA 已经开始写这半区了。对策回调里只做 memcpy 到第二份缓冲或置标志用半区法时确保单半区容量 × 2 的余量高速流I2S/ADC 1MSPS直接上F4 的双缓冲模式硬件切换 M0AR/M1AR。Bug 5位宽不匹配导致数据『错位』现象数组里隔一个错一个。外设 16 位如 ADC配了 MSIZEBYTEDMA 会做解包每 16 位拆成 2 字节你的 uint16_t 数组立刻错位。规则PSIZE 跟随外设 DR 宽度MSIZE 跟随内存数组元素宽度两者一致最省心。Bug 6F7/H7 上 DMA 数据『看不见』——D-Cache 一致性现象M7 内核开了 D-Cache 后CPU 读到的始终是旧数据或外设收到旧数据。根因DMA 写内存不经过 CPU CacheCPU 从 Cache 读到的是过期副本。三选一解决MPU 把 DMA 缓冲区配置为 Non-cacheable最稳推荐接收前 SCB_InvalidateDCache_by_Addr()发送前 SCB_CleanDCache_by_Addr()缓冲区 32 字节对齐且不与他人共享同一 Cache 行避免 invalidate 误伤。Bug 7运行中改配置 / 重启动 DMA 后死机DMA 通道的 EN 位必须先清零才能改配置且硬件需要若干周期真正关掉——写 0 后要轮询确认 EN0 再写其它寄存器传输错误标志 TE 未清就重开也会导致立即报错。HAL 用户请走 HAL_DMA_Abort() 而不是直接戳寄存器。Bug 8缓冲区放在栈上 / 未对齐函数内局部数组传进 HAL_UART_Receive_DMA函数返回后栈帧作废DMA 仍在往那里写——轻则数据乱、重则 HardFault写到关键栈区。同理 __attribute__((aligned(4))) 可避免非对齐访问在某些配置下产生的 BusFault。Bug根因自检手段DMA 不动DMA/外设/GPIO 时钟缺一读 CNDTR 是否递减收不到数据通道/Stream 映射错对照手册 mapping 表中断多进/不进HT/TC/TE 标志未清读 DMA_ISR 寄存器偶发错乱循环缓冲 overrun回调只做 memcpy/置标志隔项错位PSIZE/MSIZE 不匹配两者设为一致M7 数据过期D-Cache 一致性MPU Non-cacheable重配置死机EN 未确认清零HAL_DMA_Abort()随机 HardFault栈上缓冲区/未对齐改全局aligned(4)七、总结DMA 是与 CPU 并列的总线主设备搬运走硬件状态机每字节约 2 个总线周期『降低 CPU 占用率』的本质介入次数从 N 次摊薄到 2 次且不打断主任务流水线循环模式 半区/双缓冲是流式数据的标准姿势IDLE 中断补上『帧尾』语义时钟、映射、清标志、位宽、Cache、对齐——DMA 的坑集中在『配置期』运行期几乎零维护这正是硬件方案的魅力。