1. 背景
在数据采集项目中,经常需要 ADC 按固定周期采集多个模拟通道,并通过 DMA 自动把转换结果搬运到内存,以减少 CPU 占用。
AS32X601 当前 DMA 驱动没有提供可直接使用的循环模式,因此 DMA 完成一次传输后,需要由软件重新装载 DMA 参数。
对于 ADC 多通道扫描,可以采用下面的方案:
- ADC 的规则序列配置多个通道。
- 每轮 ADC 扫描完成后,结果依次保存在 RDR[0]、RDR[1]…… 中。
- DMA 使用单次模式,把这些结果搬入一个采样帧。
- DMA 完成中断中重新设置源地址、目的地址和传输数量。
- 使用双缓冲保存数据,避免 CPU 处理数据时被下一轮 DMA 覆盖。
本文以 ADC1 采集 4 个通道为例,介绍具体实现。流程图如下
2. ADC 多通道数据排列
假设 ADC 规则序列配置如下:
规则序号 | ADC 通道 | 引脚 |
RSEQ0 | ADC1_CH2 | PA0 |
RSEQ1 | ADC1_CH3 | PA1 |
RSEQ2 | ADC1_CH4 | PA2 |
RSEQ3 | ADC1_CH5 | PA3 |
一次规则序列转换结束后,结果分别存放在:
ADC1->RDR[0]
ADC1->RDR[1]
ADC1->RDR[2]
ADC1->RDR[3]
因此,一轮 ADC 扫描可以看作一个采样帧:
一帧数据 = CH2 + CH3 + CH4 + CH5
DMA 每次搬运 4 个数据,即可得到一帧完整的多通道采样结果。
3. 数据结构设计
先定义通道数量和双缓冲区:
#define ADC_CHANNEL_COUNT 4U volatile uint16_t g_adc_buffer[2][ADC_CHANNEL_COUNT]; volatile uint8_t g_adc_dma_index = 0U; volatile uint8_t g_adc_ready_index = 0U; volatile uint8_t g_adc_frame_ready = 0U;两个缓冲区交替工作:
- DMA 正在写缓冲区 0 时,CPU 可以处理缓冲区 1。
- DMA 正在写缓冲区 1 时,CPU可以处理缓冲区 0。
这种方式通常称为乒乓缓冲或双缓冲。
4. ADC 多通道初始化
下面以 PA0~PA3 对应的 ADC1 通道为例:
void User_ADC1_Init(void) { ADC_InitTypeDef adc; ADC_ChannelTypeDef channel; GPIO_InitTypeDef gpio; ADC1_CLK_ENABLE(); GPIOA_CLK_ENABLE(); /* PA0、PA1、PA2、PA3配置为模拟输入 */ gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; gpio.GPIO_Mode = GPIO_Mode_AN; gpio.GPIO_IType = GPIO_IN_FLOATING; gpio.GPIO_OStrength = GPIO_OStrength_4_5mA; GPIO_Init(GPIOA, &gpio); ADC_StructInit(&adc); adc.ADC_ClockDivide = ADC_CLK_DIVIDE_4; adc.ADC_Resolution = ADC_RESOLUTION_12BIT; adc.ADC_Alignment = ADC_DATA_ALIGN_RIGHT; adc.ADC_Symbol = ADC_DATA_SELDO_UNSIGNED; /* * 若由软件启动连续转换,选择 INTERNAL。 * 若由 TIM0 周期触发,则改为对应的外部触发源。 */ adc.ADC_RegularTrigger = ADC_TRIGGER_INTERNAL; adc.ADC_RegularEnable = ENABLE; adc.ADC_InjectEnable = DISABLE; adc.ADC_DmaEnable = ENABLE; /* 多通道规则序列必须开启扫描模式 */ adc.ADC_ScanModeEn = ENABLE; /* * 软件连续采集时可设置为 ENABLE。 * 如果使用定时器周期触发,一般设置为 DISABLE。 */ adc.ADC_ContinuousModeEn = ENABLE; adc.ADC_RegularDiscontinuousModeEn = DISABLE; adc.ADC_InjectDiscontinuousModeEn = DISABLE; adc.ADC_InjectAutoModeEn = DISABLE; adc.ADC_IntervalModeEn = DISABLE; adc.ADC_RegularSequenceLength = ADC_CHANNEL_COUNT; adc.ADC_InjectSequenceLength = 0U; adc.ADC_PowerEn = ENABLE; adc.ADC_PowerRegain = ENABLE; ADC_Init(ADC1, &adc); channel.ADC_SampleTime = ADC_SPT_CLK_640; channel.ADC_InterruptEn = DISABLE; channel.ADC_Channel = ADC_CH_2; ADC_ConfigChannel(ADC1, ADC_RSEQ_0, &channel); channel.ADC_Channel = ADC_CH_3; ADC_ConfigChannel(ADC1, ADC_RSEQ_1, &channel); channel.ADC_Channel = ADC_CH_4; ADC_ConfigChannel(ADC1, ADC_RSEQ_2, &channel); channel.ADC_Channel = ADC_CH_5; ADC_ConfigChannel(ADC1, ADC_RSEQ_3, &channel); }这里最重要的是:
adc.ADC_ScanModeEn = ENABLE;
adc.ADC_RegularSequenceLength = ADC_CHANNEL_COUNT;
规则序列长度必须与实际配置的通道数量一致。
5. DMA 单次模式初始化
定义 ADC 使用的 DMA 通道:
#define ADC_DMA_CHANNEL DMA0_Channel1
DMA 的源地址为 ADC1->RDR[0],目的地址为当前 DMA 缓冲区。
void User_ADC1_DMA_Init(void) { DMA_InitTypeDef dma; PLIC_InitTypeDef plic; DMA0_CLK_ENABLE(); DMA_Cmd(ADC_DMA_CHANNEL, DISABLE); DMA_DeInit(ADC_DMA_CHANNEL); DMA_ClearFlag(DMA0, DMA_FLAG_FTF1); DMA_ClearFlag(DMA0, DMA_FLAG_ERR1); DMA_StructInit(&dma); dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->RDR[0]; dma.DMA_MemoryBaseAddr = (uint32_t)&g_adc_buffer[0][0]; dma.DMA_DIR = DMA_DIR_PeripheralSRC; /* 一次只搬运一帧,即4个通道 */ dma.DMA_BufferSize = ADC_CHANNEL_COUNT; /* * RDR[0]~RDR[3]地址连续,因此外设地址递增。 */ dma.DMA_PeripheralInc = DMA_PeripheralInc_Enable; /* 目标数组地址递增 */ dma.DMA_MemoryInc = DMA_MemoryInc_Enable; /* * ADC的RDR寄存器是32位寄存器, * 内存仅保存12位转换结果,因此使用16位数组。 */ dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Word; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Priority = DMA_Priority_VeryHigh; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(ADC_DMA_CHANNEL, &dma); /* 将DMA通道连接到ADC1请求 */ DMA_PeriphIF(ADC_DMA_CHANNEL, ADC1_IF); DMA_ITConfig(ADC_DMA_CHANNEL, DMA_FTF_IT, ENABLE); DMA_ITConfig(ADC_DMA_CHANNEL, DMA_ERR_IT, ENABLE); plic.PLIC_IRQChannel = DMA0_IRQn; plic.PLIC_IRQPriority = 2U; plic.PLIC_IRQChannelCmd = ENABLE; PLIC_Init(&plic); }为什么 DMA 每次只搬运一个采样帧?
如果外设地址递增,DMA 会依次读取:
RDR[0] → RDR[1] → RDR[2] → RDR[3]
但是 DMA 不会自动把外设地址恢复为 RDR[0]。如果直接把传输数量设置为多帧数据量,那么完成第一帧后,源地址还会继续递增,最终访问到 ADC 结果寄存器区域以外的地址。
因此,正确做法是:
• 每次DMA搬运4个通道
• 产生完成中断
• 重新装载RDR[0]和下一块缓冲区地址
• 开始下一轮搬运
6. DMA 重新装载
DMA 重新装载时应先关闭通道,然后依次设置计数器、外设地址和内存地址:
static void User_ADC1_DMA_Arm(uint8_t buffer_index) { DMA_Cmd(ADC_DMA_CHANNEL, DISABLE); DMA_ClearFlag(DMA0, DMA_FLAG_FTF1); DMA_ClearFlag(DMA0, DMA_FLAG_ERR1); ADC_DMA_CHANNEL->CHCNT = ADC_CHANNEL_COUNT; ADC_DMA_CHANNEL->CHPADDR = (uint32_t)&ADC1->RDR[0]; ADC_DMA_CHANNEL->CHMADDR = (uint32_t)&g_adc_buffer[buffer_index][0]; DMA_Cmd(ADC_DMA_CHANNEL, ENABLE); }这里需要同时恢复三个关键参数:
CHCNT /* 本次传输数量 */
CHPADDR /* 外设起始地址 */
CHMADDR /* 内存起始地址 */
只恢复 CHCNT 是不够的,因为上一轮传输后,DMA 的源地址和目的地址通常已经发生变化。
7. 启动 ADC 和 DMA
void User_ADC1_Start(void) { g_adc_dma_index = 0U; g_adc_frame_ready = 0U; User_ADC1_DMA_Arm(g_adc_dma_index); /* * AS32X601带Cache。 * DMA启动前应根据芯片Cache接口处理缓冲区一致性。 */ ClearCache(); ADC1->CTRL0 |= ADC_CTRL0_TRIGEN | ADC_CTRL0_DMAEN; ADC_SoftRegularConvert(ADC1); }如果采用 TIM0 外部触发,则应先准备好 DMA,再启动定时器:
• 初始化ADC
• 初始化DMA
• 装载并使能DMA
• 使能ADC外部触发
• 启动TIM0
这个顺序可以避免第一次 ADC 转换发生时 DMA 尚未准备好。
8. DMA 完成中断
void DMA0_IRQ_Handler(void) { uint8_t completed_index; if (DMA_GetITStatus(DMA0, DMA_IT_FTF1) != RESET) { DMA_Cmd(ADC_DMA_CHANNEL, DISABLE); DMA_ClearITPendingBit(DMA0, DMA_IT_FTF1); completed_index = g_adc_dma_index; /* 切换到另一个DMA缓冲区 */ g_adc_dma_index ^= 1U; /* * 先重新装载DMA,尽量缩短DMA未就绪的时间。 */ User_ADC1_DMA_Arm(g_adc_dma_index); /* * 根据具体Cache接口,使CPU能够看到DMA写入的数据。 * 如果芯片只提供整个Cache维护接口,可使用ClearCache(); * 若提供按地址失效接口,优先只处理完成缓冲区。 */ ClearCache(); g_adc_ready_index = completed_index; g_adc_frame_ready = 1U; } if (DMA_GetITStatus(DMA0, DMA_IT_ERR1) != RESET) { DMA_Cmd(ADC_DMA_CHANNEL, DISABLE); DMA_ClearITPendingBit(DMA0, DMA_IT_ERR1); /* * 根据应用需求记录错误, * 然后重新装载DMA或停止ADC。 */ } }中断函数中不要进行串口打印、滤波或浮点运算。中断只负责:
- 清除中断标志。
- 切换 DMA 缓冲区。
- 重新装载 DMA。
- 通知主循环处理已经完成的缓冲区。
9. 主循环处理数据
int main(void) { uint8_t index; Systemclock_Init(); delay_init(160); User_Print_Init(921600); User_ADC1_Init(); User_ADC1_DMA_Init(); User_ADC1_Start(); while (1) { if (g_adc_frame_ready != 0U) { /* * 简单应用中可短暂关中断保护这两个共享变量。 * 更高采样率场景建议使用帧计数或消息队列。 */ index = g_adc_ready_index; g_adc_frame_ready = 0U; printf("CH2=%u, CH3=%u, CH4=%u, CH5=%u\r\n", (unsigned int)g_adc_buffer[index][0], (unsigned int)g_adc_buffer[index][1], (unsigned int)g_adc_buffer[index][2], (unsigned int)g_adc_buffer[index][3]); } } }缓冲区中的通道顺序与规则序列一致:
buffer[x][0] = RSEQ0,也就是ADC_CH2
buffer[x][1] = RSEQ1,也就是ADC_CH3
buffer[x][2] = RSEQ2,也就是ADC_CH4
buffer[x][3] = RSEQ3,也就是ADC_CH5
10. 使用单次 DMA 时的关键问题
10.1 必须重新装载 DMA 地址
DMA 完成后,不能只重新使能 DMA。因为:
- CHCNT 已经减到 0。
- 外设地址已经从 RDR[0] 移动到最后一个结果寄存器之后。
- 内存地址也已经移动到缓冲区末尾。
• 下一轮开始前必须重新设置:
ADC_DMA_CHANNEL->CHCNT;
ADC_DMA_CHANNEL->CHPADDR;
ADC_DMA_CHANNEL->CHMADDR;
10.2 注意下一次 ADC 触发间隔
从 DMA 完成到重新使能 DMA 之间存在一个很短的中断处理窗口。如果下一个 ADC 触发在这个窗口内到来,可能出现:
- ADC DMA 请求丢失;
- 通道数据错位;
- 一帧数据不完整;
- DMA 溢出或 ADC overrun。
因此必须保证:
ADC触发周期 > 一帧转换时间 + DMA完成中断重新装载时间
如果采样频率很高,可以考虑:
- 降低 ADC 触发频率;
- 提高 DMA 中断优先级;
- 缩短中断函数;
- 使用 ADC 序列完成信号控制下一次触发;
- 在重新装载 DMA 期间暂时屏蔽 ADC 触发;
10.3 软件连续转换不一定适合高速多通道采集
如果配置:
ADC_ContinuousModeEn = ENABLE;
ADC 可能在 DMA 完成中断尚未重新装载时就开始下一轮转换。对于采样时序要求较高的应用,建议使用 TIM0 周期触发:
ADC_ContinuousModeEn = DISABLE;
ADC_RegularTrigger = ADC_TRIGGER_EXTERNAL_x;
这样可以获得稳定的采样周期,并为 DMA 重装留下确定的时间。
10.4 注意 Cache 一致性
AS32X601 的 CPU 带有 Cache,而 DMA 直接访问物理内存。DMA 已经写完数据,并不代表 CPU 立即能够从 Cache 中读取到最新值。
因此需要根据平台提供的 Cache API:
- DMA 写内存、CPU 读内存:读取前失效对应 Cache。
- CPU 写内存、DMA 读内存:启动 DMA 前清理对应 Cache。
- DMA 缓冲区最好按 Cache Line 对齐。
- 如果支持非缓存区,DMA 缓冲区可放入非缓存内存段。
不要仅依靠 volatile。volatile 只能限制编译器优化,不能解决 CPU Cache 与 DMA 之间的数据一致性问题。
10.5 不要在 DMA 中断中打印
串口打印耗时通常远大于 DMA 重装操作。如果在中断中调用 printf(),很容易错过下一次 ADC 触发。
DMA 中断应尽可能短,打印和数据处理放到主循环或任务中完成。
11. 如果需要采集多帧数据
假设需要一次采集 1024 帧,每帧包含 4 个通道,可以定义:
#define ADC_CHANNEL_COUNT 4U #define ADC_FRAME_COUNT 1024U volatile uint16_t g_adc_data[ADC_FRAME_COUNT][ADC_CHANNEL_COUNT]; volatile uint32_t g_adc_frame_count = 0U;DMA 每次仍然只传输一帧,但目的地址指向不同的数组行:
static void ADC_DMA_ArmFrame(uint32_t frame) { DMA_Cmd(ADC_DMA_CHANNEL, DISABLE); ADC_DMA_CHANNEL->CHCNT = ADC_CHANNEL_COUNT; ADC_DMA_CHANNEL->CHPADDR = (uint32_t)&ADC1->RDR[0]; ADC_DMA_CHANNEL->CHMADDR = (uint32_t)&g_adc_data[frame][0]; DMA_Cmd(ADC_DMA_CHANNEL, ENABLE); }完成中断中推进帧号:
void DMA0_IRQ_Handler(void) { if (DMA_GetITStatus(DMA0, DMA_IT_FTF1) != RESET) { DMA_Cmd(ADC_DMA_CHANNEL, DISABLE); DMA_ClearITPendingBit(DMA0, DMA_IT_FTF1); ++g_adc_frame_count; if (g_adc_frame_count < ADC_FRAME_COUNT) { ADC_DMA_ArmFrame(g_adc_frame_count); } else { ADC1->CTRL0 &= ~(ADC_CTRL0_TRIGEN | ADC_CTRL0_DMAEN); ClearCache(); g_adc_frame_ready = 1U; } } }这样最终得到的数据排列为:
g_adc_data[0][0~3] 第0帧的4个通道
g_adc_data[1][0~3] 第1帧的4个通道
...
g_adc_data[1023][0~3] 第1023帧的4个通道
12. 总结
AS32X601 的 DMA 没有可直接使用的循环模式时,仍然可以完成 ADC 多通道连续采集。核心方法是:
• ADC扫描多个规则通道
• DMA单次搬运一帧数据
• DMA完成中断
• 恢复CHCNT、CHPADDR和CHMADDR
• 切换缓冲区
• 继续下一轮采集
实现时需要特别注意以下几点:
- ADC 必须开启扫描模式。
- 规则序列长度必须等于通道数量。
- DMA 外设地址从 RDR[0] 开始,并开启外设地址递增。
- DMA 每次只搬运一轮规则序列。
- DMA 完成后必须恢复计数、源地址和目的地址。
- 高采样率场景建议使用 TIM0 等定时器触发 ADC。
- 中断中只进行 DMA 重装和状态更新。
- 必须正确处理 CPU Cache 与 DMA 的一致性。
- 使用双缓冲可以减少数据覆盖风险。
通过软件重装 DMA,可以在不依赖硬件循环模式的情况下,实现稳定、低 CPU 占用的 ADC 多通道数据采集。