本文是一篇系统的 DMA(Direct Memory Access,直接存储器访问)学习笔记:前半部分讲透 DMA 是什么、为什么需要它、STM32 DMA 控制器的架构(通道/仲裁/三要素/模式/中断),后半部分用 CubeMX + HAL 完成四个实战(内存搬运 → 串口 DMA 发送 → 串口 DMA + 空闲中断不定长接收 → ADC 多通道 DMA),最后附 HAL 速查表、FAQ 和面试考点。

本文是本系列 ADC 篇、定时器篇的姊妹篇,三篇联合阅读效果更佳。

适合:单片机/嵌入式入门同学,以及需要快速复习 DMA 应付笔试面试的同学。

阅读路线

  • 赶时间直接上手 → 看 第四章 四个实战

  • 面试/考试抱佛脚 → 看 第六、七章 速查表和 FAQ

  • 想真正理解 → 从头读,重点吃透第二、三章。


一、DMA 是什么:CPU 雇来的"专职搬运工"

先想一个场景:串口以 1 Mbps 波特率疯狂接收数据,每个字节 10µs 就到一颗。如果让 CPU 逐字节去中断里读 DR 再写进内存,CPU 基本别干别的了;主程序一卡顿,数据立刻溢出丢失。

DMA(Direct Memory Access,直接存储器访问)就是来解决这类问题的:它是一块独立于 CPU 的硬件,专门负责"把数据从 A 搬到 B"——整个过程不经过 CPU 的寄存器、不消耗 CPU 指令周期

DMA 能搬三种方向的货:

方向 典型场景
外设 → 内存 串口接收、ADC 采样、SPI 收数
内存 → 外设 串口发送、SPI 发显示数据、DAC 输出波形
内存 ↔ 内存 大块数组复制(可以当"硬件 memcpy"用)

💡 一句话理解:CPU 是大脑,DMA 是传送带。 大脑负责"决定搬什么、搬到哪、搬多少",传送带负责执行,搬完了再通知大脑验收。

用 DMA 的三大好处:

  1. 解放 CPU:搬运期间 CPU 跑自己的主程序;

  2. 高速不漏:硬件逐字节搬,1Mbps 数据流、1Msps 采样都稳收;

  3. 省电省时:搬完一次统一中断一次即可,不用每字节打断 CPU。


二、STM32 DMA 架构详解

2.1 控制器与通道

STM32F103 有 DMA1(7 个通道),大容量型号另有 DMA2(5 个通道)。每个通道(Channel)可以理解成一条独立的传送带,可以独立配置源、目的、数量、宽度、优先级。

看图记住四个角色:

  1. 外设请求线:外设(串口、ADC、SPI…)想搬数据时,通过专用请求线"举手"申请;

  2. 仲裁器:多个通道同时举手时,按优先级决定谁先搬(见 2.3);

  3. 通道引擎:真正干活的单元,每个通道有"三件宝"——CPAR(外设地址)、CMAR(内存地址)、CNDTR(剩余传输数量,每传一个自动减 1);

  4. 中断输出:每个通道独立产生 HT(半满)/ TC(全满)/ TE(错误)中断,提醒 CPU 来验收。

2.2 通道请求映射:硬件连线定死的

哪个外设的请求接到 DMA1 的哪个通道,是芯片内部物理连线固定的,软件不能随意改。常用映射(F103):

通道 常见外设请求
通道1 ADC1(独占!ADC 走 DMA 就用它)
通道2 SPI1_RX、USART3_TX
通道3 SPI1_TX、USART3_RX
通道4 USART1_TX、SPI2_RX
通道5 USART1_RX、SPI2_TX
通道6 USART2_RX、I2C1_TX
通道7 USART2_TX、I2C1_RX

⚠️ 不用背表!CubeMX 里从某个外设的 DMA Settings 界面点 Add,该外设所有可用的 DMA 通道会自动列出来,照着选即可。

注意:F103 的 ADC2/ADC3 没有独立 DMA 请求(想走 DMA 得借双 ADC 模式经 ADC1 的通道);TIM2/3/4 的更新/捕获事件也都有固定的 DMA 通道(可以用来做定时器触发 DMA 的高级玩法)。

2.3 仲裁与优先级:谁前先搬?

两级裁决:

  1. 软件优先级(每个通道可配 4 档):很高 Very High > 高 High > 中 Medium > 低 Low;

  2. 硬件通道号:同一软件优先级内,通道号小的优先

举例:CH4(很高) vs CH2(高)同时请求 → CH2 先;CH4(很高) vs CH5(很高)→ CH4 先(4 < 5)。裁决是"逐次"进行的,低优先级不会被永久饿死。

📌 实战建议:把"绝不能丢数据、实时性最强"的通道(比如高速 ADC、高速串口 RX)设成很高/高优先级;把"慢也无所谓"的(内存拷贝、日志发送)设成中低优先级。


三、一次 DMA 传输的完整流程

3.1 配置 DMA = 回答三个问题

  1. 源地址:从哪搬?(外设寄存器如 USART1->DR,或内存数组起始地址)

  2. 目的地址:搬到哪?(方向:外设→内存、内存→外设、内存→内存)

  3. 数据个数(NDTR):搬多少个?每传 1 个 CNDTR 自动减 1,减到 0 传输完成。

3.2 数据宽度与地址自增(两大易错点)

  • 数据宽度(PSIZE/MSIZE):可选 8 / 16 / 32 位,两端可以不同,硬件自动打包/拆包;

  • 地址自增:内存侧一般开 MINC(搬完一个自动指向下一个);外设侧一般关 PINC(始终指向同一个 DR);

  • 实战守则:尽量让"外设宽度 = 内存宽度"(串口 uint8_t、ADC uint16_t),否则极易数据错位、全是乱码。

3.3 两种工作模式:Normal vs Circular

Normal 普通模式 Circular 循环模式
行为 CNDTR 减到 0 → 传输停止、通道关闭 减到 0 → 自动重装,循环往复
重启 需软件重新配置启动 不用管,永不停止
典型用途 一次性任务(发一包数据、搬一块数组) 连续采集/接收(ADC 多通道、串口监听)

3.4 DMA 中断:HT / TC / TE

每个通道能产生三种中断(在 NVIC 里给对应 DMA 通道勾中断使能):

  • HT(Half Transfer)半满中断:搬完一半;

  • TC(Transfer Complete)全满中断:搬完全部;

  • TE(Transfer Error)错误中断:总线错误(极少用,调试配置时有用)。

HT+TC 组合在循环模式下有个绝活——乒乓/双半缓冲:前半灌满时中断处理前半(DMA 继续灌后半),全满时处理后半(DMA 回卷灌前半),采集永不停歇,处理永远不慌。


四、上手实战(STM32CubeMX + HAL)

四个实战从易到难,通用配置流程一张图:

4.1 实战一:内存到内存搬运(理解 DMA 最快的例子)

目标:把 src[] 数组内容用 DMA 复制到 dst[],相当于"硬件版 memcpy"。这是唯一不需要外设参与的 DMA 用法,最适合验证 DMA 通路。

CubeMX 配置:DMA1 → Add → 选 MEMTOMEM(内存到内存),任意通道;Normal 模式;两边数据宽度都选 Byte(或一致即可);内存地址 Increment 勾上。

用户代码:

uint8_t src[] = "DMA memory-to-memory demo!";
uint8_t dst[64] = {0};
​
HAL_DMA_Start(&hdma_memtomem_dma1_channel1,
              (uint32_t)src, (uint32_t)dst, sizeof(src));
/* 阻塞等待传输完成 */
HAL_DMA_PollForTransfer(&hdma_memtomem_dma1_channel1,
                        HAL_DMA_FULL_TRANSFER, 100);
​
if (memcmp(src, dst, sizeof(src)) == 0)
    printf("DMA memcpy 成功!dst = %s\r\n", dst);
else
    printf("数据不一致,检查配置\r\n");

要点解析

  • mem2mem 模式下"外设地址"填的是源内存地址

  • 这是唯一能直观看到"DMA 完成"的最小例子,跑通它说明 DMA 硬件、时钟、NVIC 一切正常。

4.2 实战二:串口 DMA 发送(CPU 下令即走)

目标:用 DMA 发送一大段数据(如日志、传感器数据帧),发送期间 CPU 完全不参与。

CubeMX 配置:USART1 开异步 115200 → DMA Settings → Add → USART1_TX(自动选中 DMA1 通道4,方向 M2P,Normal,优先级按需勾选)。

用户代码:

uint8_t tx_buf[] = "Hello, this is a DMA UART TX demo!\r\n";
​
HAL_UART_Transmit_DMA(&huart1, tx_buf, sizeof(tx_buf) - 1);
​
/* 发送完成回调:N 字节全部搬完自动进这里 */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        // 可以在这里:发下一帧、置发送完成标志、关串口省电…
    }
}

要点解析

  • 调一句 HAL_UART_Transmit_DMA 就完事,HAL 自动配置 DMA 源/目的/数量并启动外设请求;

  • ⚠️ 细坑:DMA 的 TC 只代表"字节全塞进 DR 了",最后一个字节的最后一位还在串口的移位寄存器里"跑"!断电、切方向、改复用前要等 UART 的 TC 标志__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC))才算真正发完,否则会丢尾巴。

4.3 实战三:串口 DMA + 空闲中断,不定长接收(明星方案)

这是串口接收的终极方案,每个嵌入式工程师必须会:

  • 问题:串口 DMA 接收需要预先告诉它"收多少个字节",但现实中数据包长短不一

  • 解法:DMA(循环或普通) + 串口空闲中断 IDLE。IDLE 的触发条件是"RX 线连续约 1 字节时间没有新数据"——正好当成"这一帧收完了"的信号。

CubeMX 配置:USART1 → DMA Settings → Add → USART1_RX(Normal 或 Circular 均可,推荐 Normal + 手动重启,更好理解);NVIC 把 USART1 全局中断勾上(注意是串口中断,不是 DMA 中断——我们用串口的 IDLE 标志)。

用户代码(经典手写版,全系列通用):

#define BUF_SIZE 100
uint8_t rx_buf[BUF_SIZE];
volatile uint16_t rx_len = 0;      // 实际收到长度
​
/* 初始化:使能 IDLE 中断 + 启动 DMA 接收 */
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE);
​
/* 串口中断服务函数(USER CODE 区追加 IDLE 检测) */
void USART1_IRQHandler(void)
{
    HAL_UART_IRQHandler(&huart1);
    /* USER CODE BEGIN USART1_IRQn 1 */
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))   // 总线空闲了 → 一包收完
    {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);             // 必须清标志!
        HAL_UART_DMAStop(&huart1);                      // 停DMA
        rx_len = BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 实际长度
​
        /* 处理 rx_buf[0 .. rx_len):回显示例 */
        HAL_UART_Transmit_DMA(&huart1, rx_buf, rx_len);
​
        HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE); // 重启,等下一包
    }
    /* USER CODE END USART1_IRQn 1 */
}

要点解析

  • 算长度公式是灵魂:已收长度 = 缓冲区总长 − DMA 还剩没搬的个数(__HAL_DMA_GET_COUNTER

  • 中断里三件事(停 DMA → 算长度处理 → 清标志重启)要快进快出,别在里面干耗时操作;

  • 新版 HAL 库(CubeF1 1.8+,F4/H7 等)还提供更省事的一键 API:HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, BUF_SIZE) + HAL_UARTEx_RxEventCallback(huart, Size),回调里 Size 就是收到的长度,原理一模一样。

4.4 实战四:ADC + DMA 多通道采集(呼应本系列 ADC 篇)

CubeMX 配置:ADC1 开扫描+连续模式(多通道)→ DMA Settings → Add ADC1(Circular 循环,HalfWord 宽度)→ NVIC 勾 DMA1_Channel1 中断。

用户代码:

uint16_t adc_buf[200];     // 乒乓缓冲:前100/后100
​
HAL_ADCEx_Calibration_Start(&hadc1);
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 200);
​
/* 前一半灌满(buf[0:100))时处理前一半 */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    // 处理 adc_buf[0..99]
}
/* 全满时处理后一半 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    // 处理 adc_buf[100..199]
}

要点解析

  • ADC 的 DMA 必须用 Circular + 循环模式,让采集永不停歇;

  • 数据宽度必须选 HalfWord(ADC 的 DR 是 16 位)——选错会出现"数据全是 0 或错位";

  • 乒乓缓冲让 CPU 有"半个缓冲的时间窗"处理数据,这是信号采集/音频流的标配写法。


五、HAL DMA API 速查表

功能 函数 / 宏 说明
内存搬运 HAL_DMA_Start(&hdma, src, dst, Len) mem2mem 专用
阻塞等待 HAL_DMA_PollForTransfer(&hdma, level, timeout) 等半满/全满
串口 DMA 发送 HAL_UART_Transmit_DMA(&huart, pData, Size)
串口 DMA 接收 HAL_UART_Receive_DMA(&huart, pData, Size) 配 IDLE👑
串口一键不定长收 HAL_UARTEx_ReceiveToIdle_DMA(...) 新版 HAL
ADC DMA HAL_ADC_Start_DMA(&hadc, buf, Len)
SPI DMA HAL_SPI_Transmit_DMA / Receive_DMA
查剩余数量 __HAL_DMA_GET_COUNTER(&hdma) 算实际收到长度必备
清标志 __HAL_DMA_CLEAR_FLAG(&hdma, FLAG)
回调 HAL_DMA_XferCpltCallback 各外设有自己的简化回调

六、常见问题 FAQ

Q1:DMA 启动了,数据一动不动? A:按顺序查:方向配反没有(M2P/P2M)?内存地址自增勾了没?数据宽度两边匹不匹配?外设的 DMA 请求使能了没(HAL 封装的一般自己来,裸写寄存器时容易漏)?NDTR/数量是不是填成了 0?

Q2:串口 DMA 发送,最后一字节丢了? A:见 4.2 节的坑——DMA 是把字节塞进 DR,不等于串口物理发完。断电/切换前等 UART 的 TC 标志位

Q3:循环模式下,缓冲里的数据好像被"踩"了? A:处理速度跟不上采集速度,DMA 回卷把你还没处理完的旧数据覆盖了。加大缓冲、改用乒乓缓冲提高时间窗、或者提升处理效率(别在 DMA 中断里做耗时处理)。

Q4:串口 DMA 接收,偶尔收到乱码/错位? A:大概率是数据宽度没配成 Byte(8 位),或者你用了循环模式但每次空闲中断没正确处理回卷——循环 + IDLE 要自己算环形边界,新手建议用 Normal + 手动重启,代码更直白。

Q5:ADC+DMA 读数全是 0 或全是 65535? A:数据宽度!ADC 的 DR 是 16 位,必须按 HalfWord 配置;数组也一定要是 uint16_t。另一个常见原因是 MX_DMA_Init 和 MX_ADC_Init 的顺序,DMA 要先初始化。

Q6:F7/H7 上 DMA 明明搬完了,CPU 读到的却是旧数据? A:这是 Cortex-M7 的 D-Cache 一致性问题(F1/F4 没有这烦恼):DMA 写的是物理内存,CPU 读的是缓存。要么调用 SCB_InvalidateDCache_by_Addr() 让缓存失效,要么用 MPU 把 DMA 缓冲区设成"No Cache"区。

Q7:多个外设想用同一个通道? A:一个通道同一时刻只能服务一个请求。想用就分时复用(用前先禁用、重新配置源/目的/数量再启动),或者干脆分开用不占通道(比如串口接收用 DMA,发送状态机也不错)。

Q8:mem2mem 有"专属通道"吗? A:没有,任意通道都行。CubeMX 里选 MEMTOMEM 即可。


七、知识点速查表(背完敢去面试)

  • DMA = CPU 的专职搬运工,搬运过程 0 CPU 占用

  • 三方向:外设↔内存、内存↔内存

  • STM32F1:DMA1 7 通道 / DMA2 5 通道(大容量);

  • 通道三要素:源地址、目的地址、数据个数 CNDTR(每传 1 个 −1)

  • 通道映射是硬件固定连线,CubeMX 自动列出可用通道;F103 的 ADC2/3 无独立 DMA

  • 仲裁两级:软件 4 档优先级 → 同级拼通道号(小号优先)

  • 数据宽度 8/16/32 位两端可不同,硬件自动打包;实战尽量两边一致

  • 内存侧开自增 MINC,外设侧关自增 PINC

  • 模式:Normal = 一次就停 / Circular = 环形循环;连续采集必选 Circular

  • 中断三剑客:HT 半满、TC 全满、TE 错误;HT+TC 组合 = 乒乓/双半缓冲

  • 串口三件套:TX DMA 发送 / RX DMA + IDLE 空闲中断不定长收

  • 算收到长度:len = 缓冲总长 − __HAL_DMA_GET_COUNTER

  • 经典坑:DMA 的 TC ≠ 串口物理发完(要等 UART TC);F7/H7 要管 D-Cache


八、进阶阅读方向

  • 定时器触发 DMA 给 DAC 喂任意波形(正弦/任意信号发生器);

  • DMA 双缓冲 + CMSIS-DSP FFT,做实时频谱分析(与本系列定时器/ADC 篇联动);

  • 环形缓冲区(Ring Buffer)+ DMA + IDLE,工业级串口数据流架构;

  • F4/H7 的 DMA 控制器增强:FIFO、突发传输(Burst)、双缓冲模式(DBM);

  • SDIO + DMA 驱动 SD 卡高速读写,SPI + DMA 驱动 TFT 全屏刷新。

参考资料

  1. ST《STM32F10x 参考手册 RM0008》第 12 章:DMA controller (DMA)

  2. 【CSDN】STM32 串口通信 DMA 接收 + 空闲中断 IDLE 详解:STM32串口通信DMA接收 + 空闲中断IDLE详解_stm32 uart dma idle-CSDN博客

  3. 【CSDN】STM32 HAL库串口 DMA 空闲中断的正确使用方式:STM32-HAL库串口DMA空闲中断的正确使用方式+解析SBUS信号_stm32 hal usart2 dma-CSDN博客

  4. 【CSDN】DMA + 串口空闲中断 + 环形缓冲区:STM32 串口 (DMA + 空闲中断 + 环形缓冲区)_串口dma 空闲中断-CSDN博客

  5. 本系列姊妹篇:《ADC 学习笔记》《STM32 定时器学习笔记》


📝 这篇笔记希望帮你建立"DMA = 传送带"的整体画面:先懂它为什么存在、再懂它怎么搬、最后把串口和 ADC 两个最常用的实战跑通。文中如有疏漏,欢迎评论区指正,一起进步!

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐