☰
STM32F103 DMA寄存器配置实战:从内存搬运到USART与ADC应用
2026/10/19 6:37:11 网站建设 项目流程

1. 为什么第八章要专门谈DMA:先理解搬运工的价值

写到这里,前面七章已经过了一遍RCC、GPIO、NVIC/EXTI、定时器、USART、ADC这些STM32F103的“四肢五官”。到了这一章,我想认真聊聊DMA,全称Direct Memory Access,直接存储器访问。它是让单片机从“什么都亲力亲为”变为“学会委托办事”的关键一步。

很多跟我聊过的新手朋友,学到这里都会有个疑问:前面用串口、ADC这些外设时,轮询或者中断不都能用吗?为什么还要多引入一个DMA,不是给自己添麻烦吗?

我的回答通常是:先做一个小实验。你写一个循环,让一个IO口每秒翻转一次,同时用一个USART以115200波特率向上位机发送几百字节的数据。如果这些发送全靠在中断里一个一个字节往DR寄存器里填,你会发现主循环的翻转频率很不稳定,时不时被串口中断打断,时序抖动明显。数据量一旦上去,CPU就一直在“伺候”发送这件事,真正想做的运算反而排不上号。

DMA解决的正是这个问题:它像一台自动分拣流水线,你在寄存器的设定下定好“从哪搬、搬到哪、搬多少、一次搬几个字节”,然后告诉DMA控制器“开始”,之后的搬运过程就不再需要CPU逐个字节参与。搬完了或者搬了一半,它会通过事件标志或中断通知你一声。CPU可以专心去跑算法、刷屏、做状态机,而不必惦记数据搬运这件机械劳动。

这里有个容易误解的地方:DMA不是独立于外设之外的一种通信方式,它是连接存储器与不同外设之间的一条高速专用通道,本身不产生数据,只为数据“跑腿”。比如USART接收,外设收到字节后,你可以选择让CPU去读DR寄存器,也可以让DMA自动把DR里的数据搬到内存数组中;ADC转换完成的数据,可以自动积累到缓冲区,不必每次转换完都进中断。

从整个学习路径看,为什么这件事放在第八章而不是第一章,我也觉得安排合理。前七章你起码已经熟悉了串口收发、ADC采集、定时器PWM之类的基础外设,心里有“这套设备工作起来大概是什么节奏”的参照。DMA本身不能脱离外设单独工作,它必须依附于某个触发源。如果你还不了解外设的工作流程,直接学DMA会非常抽象,因为你不知道“请求信号”从哪来的。先有了外设基础,再学DMA,脑子里就能很自然地挂上钩:“原来我在用串口时手动读的那个动作,现在可以交给DMA了。”

在讲寄存器配置之前,我建议你先建立一个直觉模型:DMA搬运数据的过程,与人去自助快递柜取件的过程非常相似。你不用亲自跑一趟,快递柜本身能帮你把包裹送到指定位置,你只需要在App上设定好“从哪个柜子取、送到哪个地址、总共几件”。DMA的CCR寄存器就是那张App设定单,CPAR是“源柜子编号”,CMAR是“目的地地址”,CNDTR是“件数”,一旦触发条件满足(比如串口收到一个字节,或者定时器溢出一次),它就会自动执行“取一件、放一件、数减一”的流程,直到取完规定件数。

2. 抛开HAL库,直接从寄存器认识DMA的控制核心

STM32F103的DMA控制器在芯片手册里有一个单独的章节。它有两个控制器,DMA1和DMA2,各自带着若干通道,但我实际经历的大多数项目只用了DMA1。DMA1有7个通道,DMA2有5个通道,每个通道对应一组寄存器,组里主要的控制寄存器是CCR、CNDTR、CPAR、CMAR以及每个通道共用的状态寄存器ISR和清除标志寄存器IFCR。

如果只看数据手册,DMA_CCR这个寄存器非常吓人,它有16位,每个字段都有精确的位含义。但拆开了看,真正需要程序员关心的核心字段其实不多。

先说DMA_CCR,这是DMA通道的“总控开关”,也是大多数人第一次配置时容易卡住的地方。它里面的MEM2MEM、PL、MSIZE、PSIZE、MINC、PINC、DIR和EN这几个位,在不同的项目里起到的作用各不相同。我按照实务中的使用优先级给你拆一遍。

内存到内存模式(MEM2MEM)是配置时首先需要想清楚的一点。如果你希望搬运的数据来源和目的地都在存储器区,比如把SRAM中的数组A拷贝到数组B,就必须置位这个位。反过来,如果是外设到内存或者内存到外设的方向,也就是我们平时最常遇到的DMA应用,那么MEM2MEM位必须保持置0。原因在于,MEM2MEM模式不依赖外部触发,DMA配置完成后会持续请求总线进行搬运,直到CNDTR减到0;而外设相关传输必须等待外设的请求信号。

通道优先级(PL位)是资源分配策略的问题。DMA1和DMA2的每个通道在同一时刻只能有一个请求真正占用总线。如果你有多个通道同时在跑,硬件会先看优先级,再看看是哪个通道先发出请求。PL是两位,00是低优先级,01是中优先级,10是高优先级,11是最高优先级。我的体会是,除非有明确时序要求,别把所有通道都配成最高,否则高优先级形同虚设。

数据宽度的选择指向一个常见误区。MSIZE和PSIZE分别对应内存地址和外设地址的数据宽度,可选00(8位)、01(16位)、10(32位)。很多时候我们处理的原始数据就是一个字节或者一个16位的半字,于是有人把这两个位都配成00,也不管另一端外设寄存器是几个字节,结果数据高低位全乱了。外设端PSIZE要和对应外设寄存器的单位一致。比如ADC1的DR寄存器是32位宽的,那么PSIZE应当配成10也就是32位;而若要从USART的DR寄存器(8位有效)读数据,PSIZE配成00即可。内存端的MSIZE则根据你安排在内存里的缓冲是u8、u16还是u32类型来定。这两者并不强制相等,硬件会自动按宽度拆分或拼接,但我建议初学阶段尽量保持两边一致,尤其做串口单字节收发时两边都用8位就非常清晰。

增量模式MINC和PINC代表地址自动递增的开关。这块我见过不少理解偏差。MINC置1时,每搬运一个数据单元,内存地址自动前进一个MSIZE单位;PINC同理,控制外设地址是否自增。对外设寄存器地址通常是无意义的,比如ADC1->DR是固定的寄存器,你不能让它第二个样本从别处读,所以PINC几乎总是置0。但有一种场景例外,比如你要使用DMA分别读取多个GPIOC的数据,这种情况下外设地址也需要递增。对内存地址来说,数组存储往往依赖MINC让数据依次落位,如果你漏了MINC,搬运的结果是每个数据都写到缓冲区的头地址上,最后一个字节覆盖了前面的,缓冲区里只有一个有效数据。

方向DIR位很简单,00是从外设读(外设到内存),01是从内存读(内存到外设)。但这里新手常犯的错误是把“从外设读”理解成“从内存读”,因为自然语言里“读”的主语太模糊了。我建议你换一种记忆方式:把DIR想成箭头方向,0表示箭头从外设指向内存,1表示箭头从内存指向外设,这样就不容易搞混。

DMA_CPAR和DMA_CMAR更直白,它们分别是外设基地址寄存器和内存基地址寄存器。外设地址就是你要绑定的那一套外设寄存器地址,比如串口发送数据寄存器USART1->DR,或者ADC1数据寄存器ADC1->DR。CMAR填的是你的内存缓冲区首地址,比如一个u8类型数组的地址。

CNDTR是当前剩余传输数据个数寄存器,也就是“件数”计数器。配置前写入要传输的总数据个数,DMA每完成一次搬运动作,硬件自动减1。当它减到0时,如果使能了传输完成中断,就会触发中断事件,同时通道的使能位EN会被硬件自动清掉。这里我要特意强调一个非常容易踩的坑,CNDTR在DMA传输开始后会随搬运自动递减,但如果你中途读这个寄存器想了解进度,它确实是准的;而如果你想在同一个通道上重新发起一次新的传输,必须先把EN位清0,再往CNDTR里写入新数目,然后重新置位EN。手册在这一块写得很隐晦,很多人只是把CNDTR重写一遍却没重新关闭通道,结果是DMA傻在原地不工作。

DMA_ISR是一个状态标志寄存器,每个通道占用四位:TEIF(传输错误)、HTIF(半传输完成)、TCIF(传输完成)、GIF(全局中断)。这四个标志对应到七个通道,分布在不同bit位置上。我配置完DMA之后,最常见的例行检查就是读ISR对应通道的TCIF位,看它是否置1,以此判断一轮传输是否结束。CFTR则是对应的清除寄存器,注意,它跟中断标志那种“硬件自动清”不一样,它必须写入1来清除相应标志,方便你为下一轮传输做准备。

3. 寄存器版DMA配置实战:从内存到内存的最简模型

纸上谈兵不如直接跑一次。我先带你做一个跟外设无关的DMA传输实验,把一块缓冲区的数据拷贝到另一块缓冲区。这个实验难度低、见效快,还能让你清楚看到寄存器每一步设置的用意。等模型跑通了,再回去接串口、接ADC时就会有种“一切尽在掌握”的感觉。

选定通道,我用DMA1的通道1来演示。为什么要用通道1?这里其实没有严格要求,但为了后面接外设方便,我建议你从一开始就建立一个固定的通道分配习惯。比如USART1_TX常用的是DMA1通道4,ADC1常用的是DMA1通道1。你先用通道1做内存拷贝自然没问题,后面要是做ADC采样,1号通道直接就能复用,省得改配置。

初始化时钟这一类操作不再赘述,RCC里记得把DMA1时钟打开,APB1外设时钟使能寄存器RCC->AHBENR的bit0置1即可。很多朋友在这里犯迷糊,DMA挂的是AHB总线,而不是APB,手册把DMA列为AHB外设,所以你打开的是AHBENR而不是APB2ENR。

接下来是设置CCR。以内存到内存搬运为例,我写下一份配置代码,这段代码只需要出现在初始化阶段:

RCC->AHBENR |= RCC_AHBENR_DMA1EN; DMA1_Channel1->CCR &= ~DMA_CCR_EN; DMA1_Channel1->CCR = DMA_CCR_MEM2MEM | DMA_CCR_PL_1 | DMA_CCR_MSIZE_0 | DMA_CCR_PSIZE_0 | DMA_CCR_MINC | DMA_CCR_DIR; DMA1_Channel1->CPAR = (uint32_t)srcBuf; DMA1_Channel1->CMAR = (uint32_t)dstBuf; DMA1_Channel1->CNDTR = BUFFER_SIZE;

逐行解释一下。第一步先确保通道未使能,这是每次修改配置之前的老规矩:先关电源再改线路。CCR里的MEM2MEM是置1状态,表示源和目的都在存储器。PL我用的是高优先级(DMA_CCR_PL_1对应PL字段的高位组合,实际你会看到PL_1为0x2000,配PL[1:0]=10)。MSIZE和PSIZE都是8位,也就是说每次搬一个字节。MINC置1,内存地址递增。DIR置1,方向设定为内存到内存。等等,DIR对MEM2MEM有意义吗?实际上在MEM2MEM模式下DIR位要求配置为1,也就是从内存读,这样才能让DMA向目标内存地址写入。这也从侧面印证了上文中我用箭头记法:1表示从内存到外设,这里把另一块内存当作外设看,所以箭头是1。

然后依次填入CPAR源地址、CMAR目标地址、CNDTR传输总字节数。这三个寄存器必须在使能通道之前填好,而且CNDTR必须是最后写入,一旦写入了CNDTR,通道自动进入可运行状态。

最后启动传输只需一条指令:

DMA1_Channel1->CCR |= DMA_CCR_EN;

然后就可以循环等待传输完成标志了:

while (!(DMA1->ISR & DMA_ISR_TCIF1)); DMA1->IFCR |= DMA_IFCR_CTCIF1;

这个循环很简单,但有两个细节要留意。第一,等待的是ISR对应通道的TCIF位置1,不要等错通道。TCIF1是第1位,它对应通道1的传输完成标志。第二,清标志用IFCR寄存器写入1,不能直接写0。如果不在下一轮传输前清了它,你下一次判断ISR里TCIF1是否置1时,会误认为传输完成——因为上一次的残留标志还在。

整个流程跑完后,dstBuf里的数据应该和srcBuf完全一致。你可以用调试器查看内存,或者往上位机发一份对比。到这里,DMA的基本骨骼你已经摸清了。

4. 串口接收和ADC多通道采样,两个实战案例拆解

内存到内存的模型虽然清晰,但现实世界没有太多场合需要我们手动拷贝SRAM数据。DMA真正发挥作用的地方,是和外设配合自动完成数据流搬运。这里我拿两个最常用的场景展开:USART串口空闲接收、ADC的多通道扫描采样。

先说串口接收不定长数据。常规做法是每收到一个字节进入一次接收中断,在中断里把DR寄存器的值读到数组中,同时计数。数据速率不高时这个方法够用,但一旦外部设备连续发送一大包数据,中断会变得非常频繁,CPU被切来切去,主循环卡顿肉眼可见。用DMA后,我们可以让串口收到的每个字节自动写入内存数组,而CPU完全不用理会。

配置的思路是:USART1的接收方向(DR寄存器)作为外设源,一个uint8_t数组作为内存目标,方向为外设到内存,外设地址固定(PINC=0),内存地址递增(MINC=1),每次搬运一个字节,搬运个数等于我们期望接收的最大包长或者缓冲区的容量。

RCC->AHBENR |= RCC_AHBENR_DMA1EN; USART1->CR3 |= USART_CR3_DMAR; // 使能USART1接收方向的DMA请求 DMA1_Channel5->CCR &= ~DMA_CCR_EN; DMA1_Channel5->CCR = DMA_CCR_PSIZE_0 | DMA_CCR_MSIZE_0 | DMA_CCR_MINC | DMA_CCR_DIR; DMA1_Channel5->CPAR = (uint32_t)&USART1->DR; DMA1_Channel5->CMAR = (uint32_t)rxBuffer; DMA1_Channel5->CNDTR = RX_BUFFER_SIZE; DMA1_Channel5->CCR |= DMA_CCR_EN;

注意通道号变了,USART1_RX对应的是DMA1的通道5,这是芯片设计固定的映射关系,查手册表格即可,无法随意更改。USART的CR3里开启DMAR之后,每来一个字节,硬件会自动请求DMA搬运,DR寄存器里的值会被搬进rxBuffer,同时CNDTR自动减一。

这里就有个很实际的痛点:我们不知道一帧数据什么时候结束。DMA的TCIF只有在CNDTR减到0的时候才会触发,也就是缓冲区满了才会通知,而实际串口数据往往不到缓冲区大小就停了。这时有经验的工程师会想到串口的IDLE中断。IDLE中断是串口接收到一个字节后总线空闲超过一个字节周期时触发的,它不是DMA的事件,但配合起来天衣无缝。在IDLE中断里,你可以读一下当前DMA1_Channel5->CNDTR的值,用“初始值减去剩余值”算出实际收到的字节数。这就是“DMA+IDLE”判帧的经典做法。

我再给一个完整的判断代码段,放在USART中断处理函数里:

if (USART1->SR & USART_SR_IDLE) { USART1->SR &= ~USART_SR_IDLE; receivedLen = RX_BUFFER_SIZE - DMA1_Channel5->CNDTR; DMA1_Channel5->CCR &= ~DMA_CCR_EN; DMA1_Channel5->CNDTR = RX_BUFFER_SIZE; DMA1_Channel5->CCR |= DMA_CCR_EN; }

这段代码做的事很简单:先清IDLE标志(这里直接对SR写0清标志,不同型号可能有差异,F103是写0清SR位),然后用CNDTR的初值与剩余值做差求长度,最后重置DMA通道。重置的这个步骤极为关键,因为CNDTR已经减了很多,不清零重新装满的话,下一帧数据就会从数组中间开始写,覆盖逻辑全乱。你还要注意关闭通道的先后顺序,务必在置EN之前修改CNDTR,顺序反了有时也能跑,但往往会漏掉几个字节,因为DMA可能在修改过程中不停执行搬运。

再说ADC多通道扫描采样。它的优势非常直观:多个通道的数据,每个通道的转换结果依次写入自己的变量,全程不打断CPU。F103的ADC1可以配置为扫描模式,按顺序转换多个通道,DMA在每次数据转换完成后自动将ADC1->DR搬运到内存。

RCC->AHBENR |= RCC_AHBENR_DMA1EN; ADC1->CR2 |= ADC_CR2_DMA; // 使能ADC1 DMA请求 DMA1_Channel1->CCR &= ~DMA_CCR_EN; DMA1_Channel1->CCR = DMA_CCR_PSIZE_1 | DMA_CCR_MSIZE_1 | DMA_CCR_MINC | DMA_CCR_DIR; DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR; DMA1_Channel1->CMAR = (uint32_t)adcValues; DMA1_Channel1->CNDTR = CHANNEL_COUNT; DMA1_Channel1->CCR |= DMA_CCR_EN;

这里PSIZE是01即16位,MSIZE也是16位,因为ADC1->DR的高位是转换结果,低16位有效,我们用u16数组来承接恰好合适。方向是外设到内存,MINC置1让adcValues数组依序接收。

一个容易忽略的细节是ADC的CR2里的DMA位,它和DMA控制器之间是“门”的关系。ADC可以把转换完成信号当作请求发出,但要不要真正触发DMA搬运,取决于这一位使能。反过来,DMA通道不开启,ADC即使发出请求也无人应答。两边都对齐才会工作。

实际项目里,这个多通道ADC采集方案最大的坑在CNDTR的数值上。如果你配置了扫描模式连续转换三个通道,理论上DMA搬运三个值就可以暂停。但ADC1在连续转换模式下会不断产生新的数据请求,而CNDTR变成0之后DMA通道自动关闭,多余的数据就丢弃了。你的adcValues数组每次只会填充前三个通道,后续通道的结果丢失。这种情况下即使有DMA,ADC采回来的数据也是“第一轮转换的那三个”,不会持续更新。解决方法是在DMA传输完成中断里重新装载CNDTR并重新开启通道,或者改用ADC1的DMA循环模式——但F103本身没有循环模式的DMA,所以更常规的方案是配置成单次转换模式,每次转换一轮后DMA自动停下,你再手动触发下一轮。

5. 多次踩坑后,我总结的DMA配置检查清单和常见错误

按下“调试”按钮之后,DMA不按预期工作,这种场景我见过太多次了。甚至到现在,偶尔犯低级错误时,我也得回头一项项核对配置。我把这些年积累的高频bug整理成一张检查清单,配合我这里讲过的寄存器配置,逐一对照排查。

第一,检查通道和外设请求的对应关系是否正确。STM32F103的DMA请求映射是硬件固定的,比如USART1_TX只能用DMA1通道4,USART1_RX只能用DMA1通道5,而SPI1_RX是通道2。如果你配错了通道,哪怕CCR、CPAR全对,DMA也纹丝不动。手册里有一张DMA请求映射表,平时不觉得重要,真出问题时它才是第一个该翻的地方。

第二,检查外设端的DMA请求使能位是否打开。ADN这篇里我反复强调,USART有CR3里的DMAR和DMAT,ADC1有CR2里的DMA,SPI有CR2里的TXDMAEN和RXDMAEN。很多人把DMA控制器配置得完美无瑕,却忘了给外设开门,结果外设数据永远不产生DMA请求。DMA控制器不可能凭空开始工作,它需要外设的“召唤”。

第三,检查传输方向、地址自增、数据宽度的一致性。这个我前面说得很细,但实际操作里最容易出问题的是MSIZE和PSIZE不一致导致数据错位。比如从32位的ADC1->DR搬运到u16数组里,MSIZE还设成32位的话,DMA会一次取4字节,数组里的第一个u16会被高16位的零填掉,值变成0。这类问题在调试器里看内存时很容易发现:数据看起来是“隔一个正常一个”。

第四,检查CNDTR最大值的范围。CNDTR是16位寄存器,最大表示65535。如果你需要一次性搬运超过65535个字节,直接塞一个大于65535的值是塞不进去的,硬件会用低位截断。此时可以分段传输,搬完一段再由中断补填下一段。这个限制不少人在做大数据块搬运时才遇到,比如从外部Flash读取一整个图片文件。提前留个心,后面会省不少事。

第五,检查通道是否被硬件自动关闭。前面提到过,CNDTR减到0后EN位会自动清0,F103的DMA不是自动重载模式。因此如果它依赖循环搬运数据,就必须在传输完成中断里重新装载CNDTR并再次置位EN。有的项目直接省掉这一步,DMA只工作一次就不再响应,排查起来让人抓狂。

第六,检查内存对齐。总线要求CMAR寄存器里的地址对齐到至少半字边界。如果你的缓冲区是u32数组,那对齐没问题;但如果是结构体强转地址、或者用malloc动态分配的内存,要留意起始地址是不是4字节对齐。不对齐会出现奇怪的硬件错误中断,DMA配置完全正确也救不回来。

列完检查项,我想再单独说一个特别容易混淆的读写标志问题。状态寄存器ISR里的GIF是“全局中断标志”,它是该通道所有中断事件综合在一起的一个逻辑或。很多人为了省事,在中断处理时直接清GIF,结果把半传输、传输完成等标志一起清了,后续判断全乱。我建议平时清标志尽量精确到具体事件位,别一把梭清全局。

还有一个软件层面的设计习惯,值得你从第一天就养成:DMA的优先级不等于中断优先级。DMA优先级解决的是总线占用顺序,中断优先级解决的是CPU响应顺序。两个通道传输完成,各自触发了中断请求,CPU先响应哪个,看的是NVIC里配置的中断优先级,跟DMA通道优先级没关系。不要看到两个通道都使能了就以为中断会自然分先后,这俩维度得分开配。

配置DMA时候有一类“玄学”问题让我印象特别深:DMA偶尔丢数或者多发一个数。多数情况下是中断处理不及时导致的。比如ADC单次转换完成后,DMA搬运走最后一个数据的同时,通道自动关闭,但你还没来得及清TCIF,下一次触发又早早地来了,标志还没清干净,判断就乱套。对这种“偶发”问题,我的建议是先确保在传输完成回调里第一时间关闭通道并清标志,再处理数据,顺序尽量不要反过来。

6. 进一步想想:什么时候不适合用DMA,以及如何看手册聪明地查表

任何一个技术都是双刃剑。DMA虽然强大,但也不是所有场景都适合。我见过不少工程师把DMA当作万能药,动不动就上DMA,结果工程项目反而变得娇贵脆弱,调试起来要两边联动,相当烦人。

场景一:单次短数据的传输。比如串口一次只发两个字节的控制指令,用轮询方式和DMA的启动开销差别不大。DMA多了一道初始化、启动、等完成的流程,反而让代码更啰嗦。这时候用传统的TXE等待发送中断反而清晰。我自己的原则是:单次传输超过16字节,或者需要连续、周期性地搬运数据时,才优先考虑DMA。

场景二:内存到内存的大矩阵拷贝。DMA虽快,但它是在总线层面接管操作,会占用系统总线带宽,严重时会影响CPU取指令速度。如果你同时还有严格的实时性要求,可能反而被自己拖慢。内存拷贝这类操作,除非优化到极限,否则简单用C库函数有编译器帮你优化,未必比DMA差太多。

场景三:需要精细控制每一个字节流顺序的协议解析。比如Modbus协议的帧结构特殊,每个字节的含义不同,中间还可能穿插校验字节。这种情况下,DMA把一整帧收进缓冲区,你还是得逐字节解析,那DMA的“高效”主要体现在接收不打断CPU,而解析本身没法省。如果你的系统资源紧张到必须靠DMA才能接收数据,那就得再考虑加一块更大的缓冲区做双缓冲设计。

然后说说查手册这个话题。很多人翻STM32参考手册时,第一眼看到DMA那两页的请求映射表就头大,感觉要背下来。其实不需要背,你只要知道查表的方法就够了。你做一个功能前,先确认外设是什么,比如USART1_RX,然后再去手册里找USART1_RX对应的DMA通道号,找到后写进配置里就行。项目做多了,常用的映射自然记住,不常用的随时回来查表。真正需要仔细看的,是每个寄存器的位定义里那些位与位之间的相互作用,比如MEM2MEM和DIR的搭配、PINC和MINC在不同模式下的语义差异。

还有一点关于“通道”和“请求”的误读,我觉得值得单独澄清。有的朋友看到DMA1有7个通道,以为每个通道只能对应一个固定外设,实则不然。DMA1的每个通道可以接收到多个不同外设的请求,但同一时刻这个通道只能被一个外设占用。比如DMA1通道3既能服务SPI1_RX,也能服务USART3_TX,二者不可同时使用通道3。如果你的项目里两个外设都开着,而你把它们配到同一个通道,它们的请求会互相冲突,导致数据串扰或者完全不动。遇到多个外设同时需要DMA时,你应该按优先级合理分配通道资源。

7. 我看过的几个“DMA打死都不动”的真实排查过程

写这一小段之前,我特意回忆了一下自己经历过的几次DMA排障,拿出来给大家当“演练场”。每个案例都具备代表性,里面的错误类型覆盖了新手到中期工程师最容易犯的毛病。

第一个案例是某次串口收发实验一直失败。现象是初始化完成后,DMA从不触发,ISR里所有标志全空。检查代码时发现,USART1->CR3的DMAR位是配了,但CR3寄存器本身低位的DMAR必须在USART使能状态开启才有效。而初始化代码里,USART的UE位却是在CR3之后才置位,等于DMA使能时串口还没准备好。这类问题属于“初始化顺序”类的坑,不算DMA自身配置错,但确实容易疏忽。建议你配置外设时,先开外设时钟、配置外设参数、再开外设的各种附加功能(包括DMA请求),最后使能外设本体。

第二个案例是片内Flash写入DMA无法工作。F103的Flash接口不像SRAM那样可以随便由DMA访问,在某些型号上DMA对Flash写操作的访问会受限于总线仲裁和擦写周期,导致传输错误或者超时。这个场景需要查阅参考手册中关于Flash编程时是否支持DMA的说明。实际项目中,我通常是把来自串口的数据先搬到SRAM缓冲区,再由CPU执行Flash写,而不是图省事直接让DMA写Flash。这个思路也适用于其他有严格访问约束的存储器区域,先搞清总线属性再决定要不要用DMA,能省一大笔调试时间。

第三个案例是外部大容量存储器件的数据读取。DMA配置好之后,数据经常少读一块。排查后发现是外设片选信号与DMA请求之间的时序不匹配。比如外设芯片在回答完一次传输后,片选已经拉高,DMA却还在等下一次请求。这类问题一般通过在主循环里手动触发DMA、或者加一个一次性的软件触发请求来兜底解决。F103的DMA没有软件触发外设请求的通用机制,但如果把MEM2MEM置位,DMA会随时准备好搬运,那你就可以间接模拟软件触发,把一个无关的源缓冲区搬运到目标区来“唤醒”流程。

第四个案例更有迷惑性,现象是偶尔丢一个字节。后来用调试器反复观察,发现USART接收中断处理的优先级比外部中断高,而外部中断服务程序恰好会关闭总中断,导致未及时搬运的缓冲区数据被新数据覆盖。这里的问题根源不是DMA配置,而是中断优先级设计不合理。DMA接收时,缓冲区满之前CPU不能介入,但如果这个缓冲区本身很小,而外部中断的关闭时间又太长,数据覆盖就可能发生。解决办法是扩大DMA接收缓冲,或者降低外部中断的优先级。

这些案例看起来是DMA问题,但排查到最后往往考验的是对系统整体运行逻辑的理解。所以我想多说一句:一旦DMA不工作或工作不稳定,别只盯着DMA寄存器看,也要回头看看操作系统的中断配置、外设时钟、总线优先级,甚至电源管理相关的设置,看到整个系统的协作关系,问题自然浮出水面。我几次把源码翻来覆去看了几十遍也没找到问题,最后却在调试器的外设寄存器窗口里发现外设根本没有被正确使能,几分钟就解决了困扰几小时的故障。

8. 从“记得配置”到“善于调度”,聊聊DMA进阶的几个方向

到这里,DMA的基本框架和实战应该已经比较清楚了。但作为第八章,我想再往上提一层,讲讲你后面做工程时如何把DMA从“会用”提升到“善用”。

第一个进阶方向是多通道协同与优先级编排。当你项目中同时有ADC采样、串口收发、SPI通信等多个DMA通道时,它们都在争夺总线和中断资源。这时候你就要通盘考虑优先级:实时性要求最高的外设,分配更高的DMA通道优先级,同时中断优先级也要配合调整。比如高速数据采集的ADC通道可以设为最高,因为它一旦丢数据就无法恢复;而串口接收只要缓冲区够大,稍微晚一点搬运问题不大,可以设为较低优先级。

第二个进阶方向是双缓冲区的交替使用。很多通信类项目会有一个痛点:DMA在传输一大块数据时,CPU想处理这块数据却必须等传输完成,否则会把正在搬运的数据搞乱。双缓冲的思路是准备两个缓冲区A和B,DMA先写到A,传输完成后,CPU处理A的同时,DMA立即转向写入B。这样数据处理和数据接收可以流水线式并行,大大提升吞吐量。虽然F103的DMA本身不直接支持“自动切换缓冲区”,但你可以通过传输完成中断里的软件逻辑实现切换:中断里更新CMAR到另一个缓冲区地址,重新装载CNDTR,并再次使能通道。这样做切换时会有几个周期的空隙,但对于大多数外设的速度没问题。

第三个进阶方向是DMA中断里尽可能少做事。这一点我前面也提过,但值得再强调。DMA中断触发时,CPU正在从高负载状态下被拉出来,如果你在中断里做几毫秒的数据解析、内存拷贝,那么DMA节省下来的性能又被浪费回去了。正确做法是:中断里只做标志置位、CNDTR保存、通道复位等极其轻量的操作,实际的解析和处理放到主循环里。用变量做交接,主循环检查变量非零就开工。如果你刚开始写DMA代码,就养成这个习惯,后面代码的架构会清爽很多。

第四个进阶方向是理解F103的DMA与外设请求标志的联动机制。每一次DMA搬运完成,都会清除对应外设的请求标志吗?答案是不能一概而论。比如USART的RXNE标志在DMA读取DR后会被硬件自动清零,而ADC转换完成标志EOC却不一定会被DMA自动清。这关系到你是否需要在中断里手动清标志。我用ADC时,通常不需要额外清EOC,因为F103的DMA读取DR时会清掉EOC,但部分型号或者配置下行为不同。最稳妥的做法是实际读一下SR寄存器的值,观察哪些位在DMA搬运前后发生了变化,据此调整代码。

第五个方向是DMA的调试思维升级。我见过太多人在DMA不工作时,第一反应是加打印、加延时,这样反而更糟。我推荐的做法是:先查看ISR里的TEIF位有没有置位,如果有,说明传输错误;然后看CNDTR有没有在变化,如果没变,说明DMA根本没有启动或者请求没有到达;再看CPAR和CMAR里面的地址有没有按预期递增变化。这个排查路径配合调试器,能让你很快定位到“启动”和“执行”到底卡在哪一环。

至于手册或者参考文档怎么查,我最后再唠叨一句。F103参考手册的DMA章节内容不少,但真正的必读段落是DMA功能描述和请求映射表、每个寄存器字段的详细说明、以及ADC/USART等外设各自章节里的DMA请求点。你不需要整章通读,抓到这三个部分就够用了。碰到具体问题时,先在PDF里搜索DMA和通道号,比如搜“DMA1_Channel5”,往往能快速找到相关描述。

写到这里,第八章的内容也差不多了。从最初把DMA当成一套需要死记硬背的寄存器列表,到后面熟练运用它来接管串口接收、ADC采样甚至多通道协同,我自己也是靠一个个小实验和一次次翻车慢慢摸索过来的。始终记得一点:DMA终究是个工具,它存在的意义是让你把CPU留给真正需要智慧的工作——判断、运算、控制逻辑。当你配置DMA不再需要对着手册一个个选位,而是能闭着眼写出通道号和关键参数时,也就说明你已经真正理解了这枚芯片骨子里是如何协作运转的。下面你大可以把前面几章里那些轮询或中断版的外设例子,逐个改成DMA版本,体会一下“松绑双手”之后系统运行节奏的变化。这份手感,比任何一份寄存器表格都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询