
DMA是个好东西但也是个容易让人卡壳的东西。很多人学STM32学到定时器、串口、ADC都挺顺一到DMA就懵了——寄存器不少通道还分优先级传输模式还有好几种稍不注意数据就乱了。我当初学DMA也是啃了好几遍参考手册又做了两三个实验才真正摸透。这篇笔记就把DMA的基本原理和几个典型实验整理到一起按我自己的理解来讲希望对正在学这块的朋友有点帮助。1. DMA到底是什么——先搞清楚原理再动手1.1 为什么需要DMACPU的负担问题先想一个最简单的场景你有一个串口每隔一小段时间就要往外发一批数据比如发一帧几十字节的传感器报文。如果不使用DMA你每次发送一个字节都要CPU参与——往发送数据寄存器里写一个字节等发送完成标志置位然后再写下一个字节。这中间CPU啥也干不了就一直在那里“喂”数据。假如波特率是115200大概每字节要花将近87微秒几十个字节就是几毫秒。几毫秒看似不长但如果你的主程序里还有显示刷新、按键扫描、控制算法要跑这些时间就都会被挤占。更麻烦的是如果传输过程中来了一个高优先级的中断串口发送的节奏就可能被打乱丢数据的风险很高。这种“CPU全程陪着”的方式本质上是在浪费主控最宝贵的时间资源。DMADirect Memory Access直接存储器访问就是来解决这个问题的。它相当于一个专职的数据搬运工可以在没有CPU参与的情况下直接把数据从一处搬到另一处。CPU只需要在最开始告诉它“从哪里搬、搬到哪、搬多少、搬完怎么办”然后就撒手不管了。搬数据的过程完全由DMA控制器自己完成搬完了再通知你一声。1.2 DMA的工作原理传输过程的拆解DMA传输的核心可以拆成几个要素源地址、目的地址、传输方向、传输宽度、传输数量、触发方式。一次完整的DMA传输就是根据这些参数按字节或半字或全字为单位把数据从源地址搬到目的地址搬完指定数量后产生一次传输完成事件。有个容易被忽略的点DMA传输本质上不是“凭空发生的”它需要一个触发源。STM32上每个DMA通道都有多个可选的触发请求比如定时器更新事件、ADC转换完成事件、串口接收/发送数据寄存器空或满事件等。只有当这些外设发出请求信号时DMA通道才会搬一次数据。也就是说DMA的工作节奏是被外设事件驱动的搬多少数据、什么时候搬都跟触发源的节奏有关。还有一种特殊模式叫存储器到存储器传输这种情况下不是靠外设触发而是由DMA控制器自己启动模拟一种“软件触发”的效果。这种模式常用于内存数据拷贝比如把一个大数组从一个缓冲区搬到另一个缓冲区。1.3 DMA的关键要素方向、数量、宽度配置DMA时几个东西必须想清楚传输方向。三种方向必须区分清楚外设到内存、内存到外设、内存到内存。其中“外设”指的是片上外设的数据寄存器地址不是外设寄存器本身“内存”指的是Flash或SRAM的地址空间。工程上最常用的就是串口接收外设到内存和串口发送内存到外设。传输数据的数量。这个参数是DMA搬运的“总件数”不是字节数。到底一件代表多少个字节要看数据宽度。如果外设寄存器和内存都配成了8位宽度那么一件就是一个字节如果都配成16位一件就是两个字节。实际配置时外设侧的宽度通常由外设寄存器本身决定比如串口数据寄存器是8位的ADC规则数据寄存器可以是16位的内存侧的宽度一般要和源/目的寄存器宽度保持一致否则数据会错位。数据宽度。宽度不一致时DMA会按某种规则自动对齐但那种对齐逻辑容易让人搞混我自己的经验是尽量让外设寄存器和内存的宽度一致省得给自己挖坑。比如串口就是8位对8位ADC一般16位对16位。优先级。多个DMA通道同时请求时优先级高的先被响应。如果两个通道优先级相同则看通道号小的优先。这个理解起来不难但工程里容易犯的错是把实时性要求高的放在低优先级通道上导致偶尔丢数据。2. STM32的DMA硬件结构——通道怎么选、模式怎么定2.1 控制器与通道分配STM32F1系列有两个DMA控制器DMA1有7个通道DMA2有5个通道。DMA1主要服务低速外设串口、定时器、I2C、SPI等DMA2主要服务高速外设ADC、SDIO、定时器等。不同型号可能略有差异比如有些F1系列没有DMA2所以用之前先确认你的芯片型号。关键问题是通道和外设怎么对应。每个DMA通道不是所有外设都能用的它有自己固定的映射表。比如USART1_TX在DMA1的通道4USART1_RX在DMA1的通道5USART3_TX在DMA1的通道2USART3_RX在DMA1的通道3。你需要查阅参考手册里DMA请求映射表确定你的外设应该挂在哪个通道上。我见过不少人在这一步配错程序怎么调都不通最后查了半天发现是通道挂错了。2.2 DMA与中断的配合DMA搬数据搬完了怎么通知CPU两种途径查询标志位或者用DMA中断。实际工程中几乎没有用查询方式的因为那样你又得一直去Check了等于白瞎了DMA节省CPU的意义。正确做法是开DMA中断在传输完成中断里做数据处理或状态置位。DMA中断有三种标志位可以配置传输完成TC、半传输HT、传输错误TE。半传输中断特别有用比如你要发送一帧比较大的数据希望在发送完一半的时候做点处理就可以开HT中断。但在大多数简单场景下只开TC中断就够了开太多中断反而增加复杂度。2.3 正常模式与循环模式DMA有两种传输模式正常模式Normal和循环模式Circular。正常模式就是搬完指定数量的数据就停需要重新配置传输数量才能再次启动循环模式是搬完自动重新装载传输数量继续搬运适合那种连续不间断的数据流比如ADC连续采样或者串口不定长接收的缓冲区。选择哪种模式有个很实际的影响正常模式下传输完成中断里你要手动重新设置传输数量CNDTR寄存器否则DMA没法再次触发循环模式则不用管数量它会自动重装但你得小心缓冲区被反复覆盖——如果不及时取走数据旧数据就被新数据覆盖了。我做串口DMA接收时常用循环模式加空闲中断这个后面实验部分会细讲那是这块最实用的一个玩法。3. 实验一内存到内存的DMA传输——先跑通最小模型3.1 实验目标与硬件准备这个实验的目的很单纯用DMA把数组A的数据拷贝到数组B拷贝完成后在串口打印结果验证。硬件上只需要一块能跑串口printf的板子即可不需要任何外部接线。这个实验的价值在于帮你理解DMA传输的完整流程因为不涉及外设触发逻辑最纯粹。我用的是一块F103的开发板串口1做调试输出。软件部分用标准外设库来写虽然现在HAL库用得多但标准库把所有寄存器操作摆在明面上对理解原理更有帮助。3.2 代码实现步骤与要点内存到内存的DMA传输配置比外设到内存简单一点因为不需要外设触发源只要往DMA_CCR寄存器的EN位置1传输就开始。配置要点如下先定义源数组和目的数组。源数组内容随意比如几十个递增数目的数组初始化为全0用来观察搬运结果。配置DMA通道时关键寄存器参数有这么几个DAR目的地址放目的数组的首地址PAR源地址放源数组的首地址CNDTR传输数量放数组元素个数CCR控制寄存器配置方向内存到内存、数据宽度、优先级等几个细节要注意。内存到内存传输只能由DMA2的通道使用DMA1不支持这种模式这是硬件限制。另外内存到内存传输不支持循环模式只能正常模式。还要注意地址递增开关源地址递增需要开启因为要连续读源数组目的地址递增需要开启因为要连续写目的数组外设地址递增内存到内存模式下“外设”这个概念被复用为第二个内存区同样要开启数据宽度配置上源和目的都配成16位或都配成8位都可以只要两边一致。我用的是16位宽度搬运30个半字正好60字节。配置完成后开启DMA的传输完成中断在中断里翻转一个标志位主循环检测到标志位后打印两个数组的内容对比验证结果正确。3.3 结果验证与心得体会实测下来打印的结果和我预期一致目的数组B和源数组A每项都相等。这时候基本就说明DMA配置逻辑是通的。有个我当初踩过的坑目的地址写成了数组名而不是数组名的取地址看起来差不多但在某些编译器上下文里行为有差异。写DMA配置代码时建议统一用强制转换成unsigned long类型的写法比如(unsigned long)src_buf保证地址值不会被指针运算的语义干扰。另一个心得内存到内存模式虽然简单但实际工程里直接用它来拷贝大量数据的场景并不多因为SRAM到SRAM的拷贝用memcpy就够了没必要动用DMA。这个实验真正的价值在于让你没有外设干扰地看清DMA控制器的工作机制所以别觉得它“没用”它是最基础的认知铺垫。4. 实验二串口发送加DMA——把CPU从字节搬运中解放出来4.1 配置思路与代码结构跑通内存到内存后进入真正常用的场景串口发送用DMA。这个实验的目标很简单把一串字符串通过串口1的TX DMA通道发出去发送期间CPU去跑一个LED翻转循环用逻辑分析仪或肉眼观察证明“CPU没在管发送这件事”。硬件上依然是最小系统即可不需要额外接线前提是你的板子上有串口转USB芯片能通过串口助手查看输出。串口发送DMA配置比内存到内存多了一个步骤要把串口的发送请求映射到DMA通道上。F103上USART1_TX对应DMA1通道4这个不能搞错。整体流程分三步第一步初始化串口。串口配置为115200、8N1使能串口发送。注意要配置成DMA请求有效状态标准库里的实现会调用USART_DMACmd()函数告诉串口“发送请求可以走DMA通道”。第二步初始化DMA通道。方向是内存到外设源地址为待发送数组首地址目的地址为USART1-DR寄存器的地址传输数量为数组长度地址递增上源地址递增、目的地址不变。第三步启动DMA。设置传输完成后产生中断在中断里关掉DMA通道防止下一次传输还没配置好就触发。一个容易被忽略的坑发送的数据格式。串口数据寄存器是8位的所以内存侧的宽度应该配成8位数组类型用uint8_t。如果你用了一个uint16_t数组然后配16位宽度串口发送的时候会高低字节错乱发出去的完全是乱码。上电后实测效果字符串正常打印。我在主循环里加了一个延时翻转LED的代码可以看到发送期间LED照常闪烁没有任何卡顿说明CPU确实被解放了。4.2 为什么串口发送用DMA收益很大有人可能会想发送几百字节就要配一堆寄存器麻烦死了直接printf不就行了吗我们算一笔账115200波特率下每毫秒大约能发11.5字节50字节串的发送时间约4.3毫秒。如果你是阻塞式发送这4.3毫秒CPU完全空转但如果你用DMACPU只花几微秒配置和启动传输剩下的时间全都能干别的。在很多通信协议里上位机对下位机有严格的时序要求比如要求下位机在几毫秒内响应某个请求。这时候如果CPU被串口发送占用了大半就很难保证及时响应其他事件。用DMA之后主循环和中断响应的时间余量大了很多系统的实时性明显变好。实际项目中只要能发、值得发我都会给串口配上DMA发送尤其是上报数据帧比较长的情况。4.3 串口发送DMA的中断处理细节串口发送DMA中断里应该做什么这里有个原则能少做就少做。发送完成中断一进来就关掉对应DMA通道清除标志位如果有需要再置一个事件标志或信号量给主循环。不要在中断里做耗时的事情比如打印日志、大数组拷贝这些都会拖慢中断响应甚至造成嵌套中断问题。关通道这个操作要解释一下DMA传输完成后CNDTR寄存器变为0如果下一次传输前不重新设置这个值即使触发请求来了DMA也不会动作。所以在传输完成中断里先关闭DMA通道清除EN位再设置新的CNDTR再重新开启这是标准做法。如果你用的HAL库内部封装已经帮你处理了大部分但原理还是要清楚一旦出问题排查时不懂这个机制就无从下手。5. 实验三串口接收加DMA与空闲中断——不定长数据的经典解法5.1 为什么接收比发送难串口接收用DMA比发送复杂最核心的问题在于你不知道数据什么时候来、总共来多少。DMA正常情况下搬完预设数量才会触发完成中断但串口数据长度是不定的——你预设接收100字节别人发一条指令可能只有20字节剩下的80字节永远不会到完成中断永远不会触发。这个问题怎么解决方案有好几种这里我推荐一种工程上最常用、也最好理解的DMA循环模式加串口空闲中断。所谓空闲中断就是串口在接收到一帧数据后检测到总线空闲即超过一个字节时间没有新数据到来时触发的硬件中断。这个中断的触发时机刚好标志着一次传输结束。巧妙的地方在于DMA用循环模式可以持续接收数据数据不断往下写而空闲中断告诉你“当前这一帧数据收完了”这时你只需要根据DMA当前写到哪个位置、上次读到哪里就能计算出这一帧的长度然后去缓冲区里提取数据即可。整个过程CPU几乎不被占用完美适配不定长度数据的接收场景。顺着这个方法思路可以引出一个更完整的工程方案用空闲中断 DMA循环模式实现一个接收环形缓冲区。接收缓冲区开几十字节的数组搭配环形读写指针主循环随时可以从缓冲区里取出完整指令帧来处理。这样做的好处是接收和解析彻底分离。5.2 代码实现与关键参数计算接收方向的配置有几个关键点DMA方向选外设到内存。外设地址填USART1-DR内存地址填接收缓冲区首地址。缓冲区大小我开了64字节对应预设接收最大长度64。数据宽度8位因为串口数据是8位的。接收缓冲区数组初始化全0。DMA_CHx的CNDTR寄存器设置成64开启循环模式。这个模式下传输数量会从64递减递减到0以后重新load 64相当于DMA自己转圈持续接收。DMA接收通道配好后开启串口空闲中断。空闲中断的触发条件我已经从时序上验证过它只能在帧间隔超过一个完整字节时间后触发对高速连续数据不会误判这个特性非常可靠。触发空闲中断后在中断里做两件事读取DMA当前剩余未传输数量记为remaining清空闲中断标志位。已接收数据的长度为“缓冲区容量减去remaining值”。这里可以用一个简单口算来检验缓冲区64字节DMA初始CNDTR64一个字节没收到时remaining64已接收长度0收到10字节后remaining54已接收长度10逻辑完全对得上。把接收到的数据取走后要及时更新“已处理的位置”记录。因为DMA在循环模式会从头开始覆盖如果主循环来不及取走数据就可能被覆盖丢帧。对这个问题的处理方式我在环形缓冲区里使用读指针来防冲突主循环每次只取走读指针到DMA写位置之间的数据然后移动读指针和DMA的写指针互相追赶但互锁。多说一句环形缓冲区和循环DMA配合时读指针的移动必须小心建议用volatile修饰防止编译器优化掉边界判断导致越界读取。5.3 实验验证与常见坑验证方式串口助手给板子发不定长字符串板子在空闲中断中捕获数据帧提取后原样回传上位机显示回传内容。实测发几字节、几十字节、上百字节都没问题数据完整。这个实验最常见的一个坑是串口空闲标志没清干净导致中断反复进入主程序像是被什么卡住了一样。事实上空闲中断的标志处理和普通串口中断不太一样清标志顺序不对的话后面几个字节的空闲触发会异常导致重入中断。正确写法是先读一下串口状态寄存器再清除标志或者按参考手册要求先读SR再读DR。这个细节很刁钻标准库例程里不一定讲清楚我当初排查了很久才确认是清标志顺序的问题。另外一个坑就是DMA接收缓冲区的CNDTR值。这个值每收一字节就减1如果你在主循环里也去读它又不注意时序可能读到的是中间态。严谨做法是在空闲中断里读取CNDTR因为此时DMA刚刚停止一个传输周期值不会被改变数据最可靠。主循环里读到的值只能做参考尽量别当成精确判断依据。6. 常见问题速查与避坑经验DMA这块的问题说来说去就那么几个我整理成一个表格方便大家排查时对号入座。现象可能原因排查思路DMA传输不启动标志位一直为0通道映射错误或触发源没使能对照参考手册检查DMA请求映射表和外设的DMA请求使能位数据错位或乱码源/目的数据宽度不一致统一外设侧与内存侧数据宽度串口用8位ADC用16位传输完成后无法再次启动CNDTR寄存器没有重新装载先关通道EN位再写CNDTR再开通道接收不定长数据时丢帧缓冲区被覆盖使用循环模式按DMA写位置及时取走数据考虑环形缓冲区空闲中断反复触发空闲标志清除顺序不对先读SR再读DR或使用库函数清除标志确保标志真正清零DMA中断里卡死在中断里做了耗时操作或嵌套冲突中断里只做置标志和必要清标志耗时操作移到主循环数据一直发不出去内存地址当作外设地址方向配反仔细核对PAR和DAR的地址与传输方向的关系多个通道同时用低优先级的数据丢了优先级配置不合理把实时性要求高的通道优先级调高保留一个低优先级给非关键数据这些坑里有几个值得展开说。地址差一的问题经常出现。有一次我用DMA发送一个字符串总是最后一个字节发不出去或者多出一个乱码字节。排查发现是传输数量比字符串长度多配了1CNDTR写的是“数组长度1”。原因是我把字符串结尾的\0也算进去了。字符串数组的长度包含了结尾符但发送时不需要发它所以CNDTR要写strlen而不是sizeof。类似这种边界问题配置DMA时多留个心眼动手前先把传输数量用笔算一遍。优先级问题也值得多提一句。F103的DMA1通道之间如果同时请求高优先级确实会被先响应但这不代表低优先级就永远执行不了。DMA的仲裁机制是同优先级按通道号排队它是轮询的低优先级只是更容易被抢占不会饿死。只要数据量不过分大一般不会出问题。真正要担心的是多个DMA通道争用同一总线时的带宽问题这个在F103这种入门级芯片上不是主要矛盾别过度设计。我再补充一个调试DMA的实用技巧仿真器里看DMA通道的寄存器。调试时要留意几个关键值如果CNDTR一直在变说明DMA在正常工作如果CNDTR不变而外设有请求信号说明触发源配置有问题如果CNDTR变为0后再也不动要么是传输完成了没有重新配置要么是中断里关掉了没重新打开。通过观察这几个寄存器值大部分问题都能定位。还有一个很多人问的问题DMA和Cache的关系。简单说一下F103没有Cache所以不需要考虑一致性问题。但如果你以后用H7系列这类带Cache的高性能芯片DMA和Cache之间就有缓存一致性问题要处理那属于另一个深水区。现阶段先把F103玩明白原理通了后面进阶会顺畅很多。7. 个人经验总结——DMA怎么学才不走弯路我自己的体会是学DMA不能光看手册也不能一上来就抄别人的库函数。最有效的方法是先做一个内存到内存的极简实验纯粹看DMA怎么把数据搬过去然后加一个串口发送体会外设触发和中断完成的配合最后做串口接收加空闲中断把循环模式和缓冲区管理串起来。这条路线每步都建立在前面一步的认知上不容易脱节。如果你用的是HAL库封装程度很高配置起来确实省心。但我还是建议至少在标准库或寄存器层面做过一次实验否则底层机制一旦出问题HAL库给你省的那些时间都会在调试阶段加倍还回去。这不是说HAL不好而是说理解原理之后再享受封装的便利才踏实。DMA用熟了之后你自然会想到很多应用ADC连续采样直接进内存多个传感器数据按固定周期批量采集显示屏刷屏时整块数据走DMASD卡读写时数据缓冲区和外设之间直接搬移。这些场景的共同点都一样把CPU从重复、大量、节奏固定的事务中解放出来让它去处理真正需要复杂逻辑的事情。最后提一个小技巧如果你调试DMA传输发现结果不对先把中断关掉改成查询方式跑一遍。传输逻辑通了再打开中断并检查中断处理代码。这样可以把“传输本身”和“中断处理”两个变量分开排查定位问题会快很多。最后更新2025年某开发板上的F103。