1. 一个让人抓狂的现场:ADC数据每隔几个点就"跳"一下
如果你在用GD32E230做模拟量采集,大概率会碰到这样一种现象:ADC配置了DMA搬运,单看代码逻辑没有任何问题,采样率也对,通道顺序也对,但串口打印出来的波形就是不对劲——每隔几个采样点,数值会突然跳变一次,或者整段数据的顺序像是被谁"挪"了一格。你把DMA缓冲区开大一点、把采样周期拉长一点、甚至换一块板子,现象依旧时有时无。
我第一次遇到这个问题是在一个带参数存储功能的小项目上。系统需要周期性采集两路模拟量,同时还要在参数变更时把新配置写进片内FLASH。单独跑ADC+DMA,数据干净得很;单独跑FLASH擦写,也没毛病。可一旦两者在时间上挨得比较近,ADC的DMA缓冲区里就会出现错位——本该属于通道1的数据跑到了通道2的位置,或者干脆整段数据往后平移了一个位置。
这类问题最折磨人的地方在于:它不是必现的。你可能调试一整天都复现不了,交给测试跑两天突然冒出来一次。而一旦出现在量产设备上,就是数据可信度的问题——采集值错了,后面的控制逻辑、报警阈值、记录曲线全都跟着错。
这篇内容就是围绕GD32E230平台上ADC+DMA数据错位这个具体现象展开的。我会把排查的完整链路、FLASH编程为什么会干扰DMA、以及最终落地的规避策略讲清楚。适合正在用GD32E230做数据采集、并且系统里同时有片内FLASH读写需求的开发者参考。即使你用的是同系列其他型号,底层的总线仲裁和DMA优先级逻辑也是相通的。
2. 先把现象拆开:错位到底"错"在哪
2.1 错位的三种典型表现
在动手改代码之前,必须先搞清楚你遇到的是哪一种错位。不同表现对应的根因往往不一样,盲目改配置只会把水搅浑。
第一种是通道间错位。比如规则组里配置了CH0和CH1两个通道,DMA缓冲区是[ch0_0, ch1_0, ch0_1, ch1_1, ...]这样的交替结构。错位时变成[ch0_0, ch0_1, ch1_0, ch1_1, ...],也就是某一次转换的结果被重复搬运,或者某一次被跳过。这种错位通常和ADC的EOC(转换结束)标志与DMA请求的时序配合有关。
第二种是整段平移。缓冲区里的数据整体往后挪了一个或几个位置,开头多出来几个无效值,末尾丢了几个有效值。这种更像是DMA传输过程中被"打断"了一次,导致搬运指针和ADC数据寄存器之间失去了同步。
第三种是偶发单点跳变。数据顺序没错,但某个点的值明显偏离正常范围,像是采到了上一次转换的残留值。这种最隐蔽,因为单看数值你可能以为是噪声,只有把原始数据和理论值逐点比对才能发现。
我那次遇到的是第一种和第二种混合:大部分时候是通道间错位,偶尔伴随整段平移。后来定位下来,根因是同一个。
2.2 为什么"错位"比"错值"更危险
很多人对ADC问题的第一反应是"精度不够"或者"有噪声",于是去加滤波、换基准、改采样时间。但错位和错值是两码事。
错值通常是幅度上的偏差,滤波、校准、多次平均都能压下去。而错位是结构性错误——数据的顺序和通道的对应关系被破坏了。你没法用软件滤波修复它,因为滤波的前提是"每个位置的数据属于哪个通道"是确定的。一旦这个前提没了,后面所有处理都是建立在错误假设上的。
更麻烦的是,错位往往在数据量小的时候看不出来。你采10个点,可能刚好没触发;采1000个点,错位就露出来了。所以调试阶段如果只做短时测试,很容易漏掉。
2.3 复现条件的构造
要排查这类问题,第一步是稳定复现。我当时的做法是写一个循环测试:ADC+DMA连续采集,同时在固定的时间间隔触发一次FLASH页擦写,把采集到的数据通过串口原样发出来,上位机做逐点比对。
关键是要让FLASH操作和ADC采集在时间上"重叠"。如果FLASH擦写期间ADC刚好没在转换,或者DMA刚好没在搬运,就复现不了。所以测试代码里要故意让两者并发,比如在ADC连续转换模式下,主循环里穿插FLASH编程操作。
这里有个细节:GD32E230的FLASH擦写是按页进行的,擦一页的时间在毫秒级,而ADC一次转换在微秒级。也就是说,一次FLASH擦写期间,ADC可能已经完成了成百上千次转换,DMA也搬运了同样多的数据。干扰就发生在这个窗口里。
3. FLASH编程为什么会动到DMA的"奶酪"
3.1 片内FLASH和DMA共享的是同一套总线资源
这是理解整个问题的核心。GD32E230内部,CPU、DMA、FLASH控制器都挂在同一套总线矩阵上。FLASH编程(擦除和写入)不是简单的"写个寄存器就完事",它需要FLASH控制器对存储阵列执行高压操作,这个过程会占用FLASH接口的总线带宽,并且可能插入等待周期。
当DMA正在从ADC数据寄存器搬运数据到SRAM时,如果此时CPU发起了FLASH编程操作,FLASH控制器会进入忙状态。虽然DMA访问的是ADC外设和SRAM,理论上不直接经过FLASH,但问题在于:中断向量表、常量数据、以及部分代码可能位于FLASH中。FLASH忙的时候,如果DMA传输过程中触发了中断,CPU去FLASH取中断服务程序的指令,就会遇到等待,进而影响整个系统的时序。
更直接的影响是:GD32E230的DMA和FLASH控制器在总线仲裁上有优先级关系。FLASH编程期间,FLASH控制器会拉高总线占用请求,如果此时DMA也在请求总线,仲裁器可能会让FLASH先走,导致DMA的搬运被延迟。对于ADC这种"数据寄存器不等人"的外设来说,DMA延迟一个周期,数据就可能被下一次转换覆盖,或者DMA指针和ADC状态失去同步。
3.2 ADC的DMA请求是"边沿触发"还是"电平触发"
这里要区分一个容易混淆的点。ADC的DMA请求通常是在每次转换完成后产生的。GD32E230的ADC在规则组转换结束后置位EOC,同时如果DMA使能,会产生一个DMA请求。这个请求如果没被及时响应,EOC标志会保持,但ADC可能已经开始下一次转换了。
如果DMA因为FLASH编程被延迟,错过了这个请求窗口,那么下一次转换完成时又会产生新的请求。DMA控制器看到的是"有一个请求待处理",于是搬运一次。但此时ADC数据寄存器里已经是新一次转换的结果了,而DMA以为搬的是上一次的。这就造成了错位——搬走的和应该搬的对不上。
用个生活化的类比:ADC像个传送带,每转一圈放一个零件到取件口;DMA像个机械臂,看到取件口有零件就抓走放到箱子里。正常情况下机械臂抓一个、传送带转一圈,配合默契。但如果机械臂被别的事情耽搁了,传送带已经转了第二圈,取件口换成了第二个零件,机械臂才回来抓——它抓走的是第二个零件,但箱子里的位置是按第一个零件排的,顺序就乱了。
3.3 中断优先级和DMA优先级的叠加效应
还有一个容易被忽略的因素:中断优先级。FLASH编程通常是在主循环或者某个低优先级任务里发起的,但FLASH操作完成后的中断(如果使能了)可能和ADC/DMA中断产生竞争。如果FLASH相关中断的优先级高于DMA中断,那么在FLASH操作期间,DMA中断被挂起,DMA的后续配置(比如重新装载计数器)被延迟,也会导致错位。
GD32E230的NVIC支持中断优先级分组,DMA通道有各自的优先级设置。默认情况下,如果没特意配置,DMA通道优先级是"低",而FLASH操作如果通过中断通知完成,其中断优先级可能被设得较高。这种优先级倒置在并发场景下就是错位的温床。
4. 排查链路:从"怀疑DMA"到"锁定FLASH"
4.1 第一步:隔离变量,确认DMA本身没问题
遇到错位,先别急着怀疑FLASH。我的习惯是先把系统拆到最小可运行状态:只跑ADC+DMA,不跑FLASH编程,连续采集几万点,看数据是否稳定。
如果单独跑ADC+DMA就错位,那问题在ADC/DMA配置本身,比如采样时间太短、DMA缓冲区对齐问题、或者ADC时钟超频。如果单独跑没问题,一加FLASH就出错,那基本可以锁定是并发干扰。
这一步的关键是用数据说话。不要靠"我觉得",而是把原始数据打出来,逐点比对。我当时写了个简单的Python脚本,把串口收到的数据按通道拆分,和理论值做差,差值超过阈值的点标红。这样一眼就能看出错位发生的频率和模式。
4.2 第二步:在FLASH操作前后加"探针"
确认是并发干扰后,下一步是定位干扰发生的精确时刻。我在FLASH擦写函数的前后各翻转一个GPIO,用示波器抓这个GPIO和ADC的EOC信号(或者DMA的传输完成信号)。
结果很直观:FLASH擦写期间,GPIO拉高,这段时间内ADC的EOC信号出现了"堆积"——连续几次转换完成,但DMA的搬运信号没有及时跟上。等FLASH操作结束,DMA才开始补搬,但此时ADC数据寄存器里已经是最后一次转换的值了,前面几次的值已经被覆盖。
这个现象证实了:FLASH编程期间,DMA的响应被延迟了,而ADC没有停止转换,导致数据寄存器被覆盖,DMA搬到了错误的数据。
4.3 第三步:查手册确认总线仲裁和等待周期
GD32E230的用户手册里,关于FLASH编程的部分会提到"编程期间总线会被占用"或者"CPU访问FLASH会插入等待周期"。虽然手册不会直接写"这会干扰DMA",但结合总线矩阵的结构图可以推断出来。
我当时的做法是查了GD32E230的总线矩阵章节,确认了FLASH控制器和DMA控制器确实共享总线资源。同时查了FLASH编程的时间参数——页擦除典型值是几十毫秒,这个时间窗口足够ADC完成成百上千次转换。
另外,GD32E230的FLASH有预取缓冲(prefetch buffer),如果预取被FLASH编程打断,CPU取指会变慢,进而影响中断响应速度。如果DMA中断服务程序在FLASH里,中断响应变慢也会加剧错位。
4.4 第四步:用"最小干扰"实验验证假设
为了进一步确认,我做了个实验:把FLASH编程操作放到ADC停止转换的间隙里执行。具体做法是,在启动FLASH编程前先关闭ADC的连续转换模式,等FLASH操作完成后再重新使能ADC和DMA。
结果错位消失了。这反向验证了假设:只要FLASH编程和ADC+DMA不在时间上重叠,就不会错位。
但这个方案有个明显缺点:ADC采集会出现"空洞",FLASH操作期间的数据丢了。对于需要连续采集的应用,这不可接受。所以还需要更优雅的规避策略。
5. 规避策略:让FLASH和DMA"错峰出行"
5.1 策略一:DMA双缓冲+FLASH操作分时
这是我最推荐的方案。核心思路是:用DMA的双缓冲模式,让ADC始终有缓冲区可写,而CPU在另一个缓冲区满时去处理数据。FLASH编程只在两个缓冲区切换的间隙执行,并且执行前先暂停ADC的DMA请求。
具体实现上,GD32E230的DMA支持双缓冲(也叫乒乓模式)。配置两个缓冲区,DMA填满一个后自动切换到另一个,并产生传输完成中断。在中断里,如果此时需要执行FLASH编程,先关闭ADC的DMA使能(注意不是关闭ADC本身),然后执行FLASH操作,完成后重新使能DMA。
这里的关键是:关闭DMA使能后,ADC的EOC标志会继续置位,但DMA不搬运。所以FLASH操作期间的数据会丢,但不会错位。如果你能接受少量数据丢失,这个方案最简单可靠。
如果一点数据都不能丢,那就需要在FLASH操作前先停止ADC转换,操作完再重启。但这样会引入更长的采集空洞。
5.2 策略二:把FLASH操作"藏"在ADC采样间隙里
GD32E230的ADC支持注入组和规则组。规则组用于常规采集,注入组可以打断规则组。如果你用规则组+DMA做连续采集,那么FLASH操作可以安排在两次规则组转换之间的固定间隙里。
具体做法是:配置ADC为"单次转换+外部触发"模式,而不是连续转换。每次触发后转换一次,DMA搬运一次。在两次触发之间,有一段确定的时间窗口,FLASH编程就放在这个窗口里执行。
这个方案的前提是你的采样率不高,两次转换之间的间隙足够长(比如大于FLASH页擦除时间)。如果采样率很高,间隙太短,就不适用。
5.3 策略三:调整DMA和中断优先级,减少响应延迟
如果FLASH操作无法避免和ADC并发,那就尽量降低FLASH操作对DMA响应的影响。具体措施包括:
- 把DMA通道的优先级设为"非常高",确保DMA请求能被优先响应。
- 把FLASH操作相关的中断优先级设为"低",避免它抢占DMA中断。
- 如果FLASH操作是通过轮询方式等待完成(而不是中断),确保轮询循环里没有其他高优先级中断干扰。
- 把DMA中断服务程序放在SRAM里执行(通过链接脚本或
__attribute__((section))),避免FLASH忙时取指等待。
这些措施不能完全消除错位,但能显著降低发生概率。我实测下来,调整优先级后,错位频率从"每几次FLASH操作就出现"降到了"几万次操作才偶发一次"。
5.4 策略四:FLASH操作前先"冻结"DMA请求
这是最彻底的方案。在发起FLASH编程前,先清除ADC的DMA请求使能,并等待当前DMA传输完成。然后执行FLASH操作,完成后重新使能DMA请求。
GD32E230的ADC有一个ADC_CTL1寄存器里的DMA位,控制是否产生DMA请求。把这个位清零后,ADC转换完成不会再触发DMA。此时DMA控制器不会收到新请求,当前正在进行的传输完成后就停止。
操作顺序很重要:先清DMA请求使能,再等DMA的传输完成标志(或者用DMA_CHxCTL的CHXEN位确认),然后执行FLASH编程,最后重新使能DMA请求。
这个方案的代价是FLASH操作期间的数据会丢,但保证了不会错位。对于参数存储这种低频操作,丢几个采样点通常可以接受。
6. 代码层面的具体落地
6.1 ADC+DMA的基础配置要点
先贴一段GD32E230上ADC+DMA的典型配置,重点看注释里标出的关键位。
// ADC规则组通道配置 adc_channel_length_config(ADC0, ADC_REGULAR_CHANNEL, 2); // 2个通道 adc_regular_channel_config(ADC0, 0, ADC_CHANNEL_0, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 1, ADC_CHANNEL_1, ADC_SAMPLETIME_55POINT5); // DMA配置 dma_parameter_struct dma_init_struct; dma_deinit(DMA_CH0); dma_init_struct.direction = DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.memory_addr = (uint32_t)adc_buffer; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.memory_width = DMA_MEMORY_WIDTH_16BIT; dma_init_struct.number = ADC_BUFFER_SIZE; dma_init_struct.periph_addr = (uint32_t)&ADC_RDATA(ADC0); dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_16BIT; dma_init_struct.priority = DMA_PRIORITY_ULTRA_HIGH; // 关键:设为最高优先级 dma_init(DMA_CH0, &dma_init_struct); // 使能ADC的DMA请求 adc_dma_mode_enable(ADC0); // 这一位就是后面要"冻结"的开关 dma_channel_enable(DMA_CH0);注意dma_init_struct.priority这一项。默认例程里经常是DMA_PRIORITY_HIGH或者更低,但在有FLASH并发的场景下,建议直接拉到DMA_PRIORITY_ULTRA_HIGH。
6.2 FLASH操作前的DMA"冻结"函数
下面这个函数封装了"安全执行FLASH操作"的逻辑:
void flash_operation_safe(void) { // 1. 关闭ADC的DMA请求,停止新的DMA触发 adc_dma_mode_disable(ADC0); // 2. 等待当前DMA传输完成 while(dma_flag_get(DMA_CH0, DMA_FLAG_FTF) == RESET); dma_flag_clear(DMA_CH0, DMA_FLAG_FTF); // 3. 此时DMA已停止,可以安全执行FLASH操作 // ... FLASH擦写代码 ... // 4. 重新配置DMA(因为传输完成后计数器已归零) dma_memory_address_config(DMA_CH0, (uint32_t)adc_buffer); dma_transfer_number_config(DMA_CH0, ADC_BUFFER_SIZE); dma_channel_enable(DMA_CH0); // 5. 重新使能ADC的DMA请求 adc_dma_mode_enable(ADC0); }这里有个坑:DMA传输完成后,CHXEN位会自动清零,传输数量寄存器也会归零。所以重新使能前必须重新配置内存地址和传输数量,否则DMA不会工作。
另外,adc_dma_mode_disable之后,ADC的EOC标志可能还在置位状态。重新使能DMA前最好清一下EOC标志,避免残留标志触发一次错误的DMA请求。
6.3 双缓冲模式下的切换逻辑
如果用双缓冲,DMA的配置会复杂一些,但能减少数据丢失。GD32E230的DMA双缓冲通过两个内存地址寄存器和两个传输数量寄存器实现。配置时用dma_multi_data_parameter_struct结构体。
dma_multi_data_parameter_struct dma_multi_init; dma_multi_init.memory0_addr = (uint32_t)adc_buffer0; dma_multi_init.memory1_addr = (uint32_t)adc_buffer1; dma_multi_init.memory_width = DMA_MEMORY_WIDTH_16BIT; dma_multi_init.number = ADC_BUFFER_SIZE / 2; dma_multi_init.periph_addr = (uint32_t)&ADC_RDATA(ADC0); dma_multi_init.periph_width = DMA_PERIPHERAL_WIDTH_16BIT; dma_multi_init.priority = DMA_PRIORITY_ULTRA_HIGH; dma_multi_data_mode_init(DMA_CH0, &dma_multi_init);在DMA传输完成中断里,判断当前是哪个缓冲区满,然后处理数据。如果此时需要FLASH操作,就在中断里先冻结DMA,执行FLASH,再恢复。
双缓冲的好处是:即使FLASH操作导致一个缓冲区的数据不完整,另一个缓冲区仍然是干净的。你可以选择丢弃不完整的那个,用另一个继续。
7. 几个容易踩的坑和实测经验
7.1 别在FLASH操作期间关全局中断
有人为了"保护"FLASH操作,在擦写期间关掉全局中断。这在有ADC+DMA的系统里是灾难性的——DMA中断被关掉,DMA传输完成标志无法及时处理,缓冲区溢出,错位更严重。
正确的做法是只关掉会干扰FLASH操作的中断,或者用优先级机制让DMA中断能打断FLASH操作。GD32E230的FLASH编程本身不需要关中断,只要保证编程期间CPU不去访问FLASH即可(比如把执行代码放在SRAM里)。
7.2 ADC采样时间不要太短
采样时间太短会导致ADC的采样保持电容充电不充分,转换结果本身就不准。在FLASH并发场景下,如果采样时间太短,ADC转换完成得太快,DMA请求更密集,被FLASH延迟的概率也更高。
我实测下来,GD32E230在55.5个ADC时钟周期的采样时间下,配合DMA最高优先级,错位概率明显低于7.5周期的配置。当然这也要看你的信号源阻抗,阻抗高的话采样时间本来就要拉长。
7.3 DMA缓冲区别忘了对齐
GD32E230的DMA对内存地址有对齐要求。如果缓冲区是16位宽度,地址最好2字节对齐;32位宽度则4字节对齐。不对齐可能导致DMA传输异常,这种异常在FLASH并发时更容易暴露。
用__attribute__((aligned(4)))修饰缓冲区数组,确保对齐。
7.4 FLASH操作后要重新校准ADC吗
不需要。FLASH编程不影响ADC的校准值。但如果你在FLASH操作期间关闭了ADC(而不只是关闭DMA),重新使能后建议等几个转换周期再开始采集,让ADC的模拟前端稳定下来。
7.5 用DMA传输完成中断而不是轮询
轮询DMA标志会占用CPU时间,而且在FLASH操作期间轮询,CPU频繁访问FLASH取指,反而加剧总线竞争。用DMA传输完成中断,把中断服务程序放SRAM里,能减少对FLASH的访问。
8. 最后的经验:并发问题的通用排查思路
ADC+DMA被FLASH编程干扰,本质上是一个资源共享导致的时序竞争问题。这类问题在嵌入式系统里很常见,排查思路可以归纳成几条:
第一,先隔离再叠加。单独跑每个功能,确认各自没问题,再叠加起来看什么时候出错。不要一上来就怀疑最复杂的部分。
第二,用硬件信号做时间标定。GPIO翻转+示波器是最直接的手段。软件打印的时间戳精度不够,而且打印本身也会干扰时序。
第三,查手册的总线矩阵和仲裁章节。很多并发问题的答案就在手册里,只是分散在不同章节,需要自己拼起来。
第四,优先用"分时"而不是"加锁"。在资源受限的MCU上,关中断、加锁的代价往往比错峰执行更大。能让两个操作不在时间上重叠,就不要用互斥机制。
第五,接受"数据丢失"而不是"数据错位"。如果必须二选一,丢几个采样点通常比拿到错误顺序的数据要好处理。前者可以在上层做插值或者标记,后者会污染整个数据流。
我后来把这个项目的FLASH操作全部改成了"冻结DMA+分时执行"的方案,连续跑了一周的压力测试,没有再出现错位。代价是每次参数存储会丢几十个采样点,但对于那个应用来说完全可以接受。如果你的场景对数据连续性要求极高,那就得考虑用双缓冲+SRAM执行代码的组合方案,把干扰降到最低。