
1. 项目概述从“点灯”到“读数据”的进阶玩过STM32的朋友第一步基本都是“点灯”。当GPIO控制LED闪烁已经烂熟于心后下一个让你感觉“有点东西”的里程碑往往就是ADC模数转换器。毕竟从数字世界一脚踏入模拟世界去读取温度、电压、光照这些连续变化的物理量那种感觉是完全不同的。而当你开始用轮询或中断方式读取ADC发现数据一多就手忙脚乱甚至影响主程序流畅度时DMA直接存储器访问就成了你技术栈里必须点亮的那颗星。这个项目就是一次典型的嵌入式开发能力跃迁基于STM32F103C8T6俗称“蓝桥杯”或“最小系统板”实现ADC的DMA连续采集与读取。它解决的痛点非常明确在需要高速、连续、不占用CPU资源的场景下比如音频采样、电机电流监控、多通道传感器巡检如何优雅且高效地获取模拟量数据。如果你还在为“ADC采样时我的屏幕怎么卡了”或者“多通道切换采样数据对不上号”而烦恼那么深入理解并实践ADCDMA的组合就是你的解药。我将以最常用的STM32标准外设库虽然HAL库是趋势但标准库对理解底层更有帮助为框架带你从原理到配置从代码到调试完整走一遍这个流程。你会发现一旦打通了DMA这个“任督二脉”你对STM32资源调度的理解会上一个全新的台阶。2. 核心思路与方案选型为什么是ADCDMA在动手写代码之前我们先得把“为什么”搞清楚。嵌入式开发里选型就是做权衡理解背后的权衡才能写出稳健的代码。2.1 三种ADC数据读取方式对比STM32的ADC转换完成后数据会放在一个固定的数据寄存器ADC_DR里。我们怎么把这个数据搬到自己定义的数组里呢通常有三种方式轮询Polling主程序里不断地去查询ADC的状态标志位一旦发现转换完成标志置位就立刻读取数据。这是最简单粗暴的方式。问题CPU被“绑死”在等待ADC转换这件事上期间不能做任何其他事效率极低。只适用于对实时性要求极低、偶尔采一次样的场景。中断Interrupt配置ADC转换完成中断。当一次转换结束时硬件自动触发中断在中断服务函数里读取数据。优点CPU在ADC转换期间可以处理其他任务。缺点每次转换都要进入一次中断。如果采样率很高比如10kHz那么每秒就会有1万次中断。频繁的中断响应和上下文切换会消耗大量CPU资源并可能影响其他对时序敏感的任务如PWM生成、通信协议处理。DMADirect Memory Access配置一个DMA通道将其源头Source指向ADC的数据寄存器ADC_DR目标Destination指向我们定义在内存中的数组。然后设置好传输的数据量比如一个数组的长度。一旦ADC转换完成硬件会自动触发DMA请求DMA控制器便在不经过CPU的情况下直接把数据从ADC_DR“搬”到内存数组里。优点CPU完全被解放只在DMA传输完成或半传输时才需要被通知一下通过中断去处理已经攒好的一批数据。这是真正意义上的“后台自动搬运”效率最高对主程序影响最小。注意DMA不是万能的它主要解决的是“数据搬运”这个耗时、重复的苦力活。对于需要复杂计算或逻辑判断的数据处理CPU仍然不可替代。ADCDMA的组合相当于雇了一个专门的搬运工DMA把原料ADC数据从仓库门口ADC_DR搬到车间内存然后车间主任CPU可以等原料积累到一定数量再统一进行精加工算法处理。2.2 关键外设与时钟树考量对于STM32F103C8T6我们需要关注以下核心外设ADC1我们通常使用ADC1它功能完整支持多个通道。STM32F103的ADC是12位精度的参考电压Vref通常接3.3VVDDA所以理论上的电压分辨率是 3.3V / 4096 ≈ 0.8mV。DMA1STM32F103有DMA1和DMA2DMA1用于处理外设到内存、内存到外设或内存到内存的传输。ADC1对应的DMA通道是DMA1_Channel1。这个映射关系是芯片设计时固定的必须查数据手册确认不能随意更改。GPIOAADC的输入通道大多复用在GPIOA上例如通道0在PA0通道1在PA1以此类推。需要将对应的GPIO引脚配置为模拟输入模式。时钟配置是稳定工作的基石STM32F103的ADC时钟ADCCLK最大不能超过14MHz它由PCLK2APB2总线时钟经过分频得到。在常见的72MHz系统时钟下PCLK2也是72MHz。因此我们必须为ADC时钟设置分频器比如6分频得到12MHz或者8分频得到9MHz以确保其在安全范围内。时钟配置不当是导致ADC采样值跳动大、不准的常见原因之一。3. 详细配置与代码实现解析理论清晰后我们进入实战环节。我将以“连续采集ADC1的通道0PA0和通道1PA1使用DMA循环模式将数据存入一个双缓冲数组”为例拆解每一个步骤。3.1 工程准备与引脚配置首先创建一个标准库工程并初始化系统时钟通常设置为72MHz。然后配置GPIO// 1. 开启GPIOA和ADC1的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); // 2. 配置PA0和PA1为模拟输入 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; // 模拟输入模式关键 GPIO_Init(GPIOA, GPIO_InitStructure);这里的关键是GPIO_Mode_AIN。模拟输入模式下引脚内部的上拉、下拉电阻和数字输入部分都被断开确保外部模拟电压能无干扰地进入ADC。3.2 ADC模块的深度配置ADC的配置参数较多需要仔细设置// 1. 复位并初始化ADC1 ADC_DeInit(ADC1); ADC_InitTypeDef ADC_InitStructure; ADC_InitStructure.ADC_Mode ADC_Mode_Independent; // 独立模式单个ADC工作 ADC_InitStructure.ADC_ScanConvMode ENABLE; // 扫描模式使能多通道必须 ADC_InitStructure.ADC_ContinuousConvMode ENABLE; // 连续转换模式使能 ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; // 软件触发不用外部信号 ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; // 数据右对齐方便读取 ADC_InitStructure.ADC_NbrOfChannel 2; // 规则通道序列的长度我们用了2个通道 ADC_Init(ADC1, ADC_InitStructure); // 2. 配置ADC时钟ADCCLK对PCLK2进行8分频72MHz/89MHz RCC_ADCCLKConfig(RCC_PCLK2_Div8); // 3. 配置规则通道的转换顺序和采样时间 // 规则通道组可以按顺序排列多个通道ADC会按这个顺序依次转换 // 参数ADCx, 通道号, 序列中的排名(1~16), 采样时间 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); // PA0序列1 ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); // PA1序列2 // 4. 使能ADC的DMA请求 // 当规则通道组转换完成时ADC会发出一个DMA请求信号 ADC_DMACmd(ADC1, ENABLE); // 5. 使能ADC ADC_Cmd(ADC1, ENABLE); // 6. ADC校准非常重要能减少零点误差和增益误差 ADC_ResetCalibration(ADC1); // 重置校准寄存器 while(ADC_GetResetCalibrationStatus(ADC1)); // 等待重置完成 ADC_StartCalibration(ADC1); // 开始校准 while(ADC_GetCalibrationStatus(ADC1)); // 等待校准完成关键点解析扫描模式ScanConvMode必须使能。它告诉ADC规则通道序列里有多个通道需要依次转换。连续转换模式ContinuousConvMode使能后ADC在完成一轮序列转换后会自动立刻开始下一轮形成连续采集。如果不使能则需要每次用软件触发一次。采样时间SampleTime这里设置为55.5个ADC时钟周期。采样时间越长ADC内部的采样电容充电越充分对抗输入信号源内阻的能力越强结果越稳定但转换速度会变慢。对于一般传感器信号55.5个周期是兼顾速度和精度的常用值。如果信号源阻抗很大如10kΩ可能需要更长的采样时间。校准Calibration这是很多新手会忽略但至关重要的一步。ADC内部电路存在偏差上电后必须进行一次校准将校准值存入内部后续转换时会自动补偿。不校准可能导致结果存在固定的偏移。3.3 DMA控制器的核心配置DMA的配置是连接ADC和内存的桥梁// 1. 开启DMA1时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 2. 定义存储ADC值的数组双缓冲思想 #define ADC_BUFFER_SIZE 100 // 每个通道的采样深度 uint16_t ADC_ConvertedValue[ADC_BUFFER_SIZE * 2]; // 长度通道数*深度用于存储交错的数据 // 3. 配置DMA DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel1); // DMA1, 通道1对应ADC1 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(ADC1-DR); // 外设地址ADC1数据寄存器 DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)ADC_ConvertedValue; // 内存地址我们的数组 DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; // 传输方向从外设ADC到内存 DMA_InitStructure.DMA_BufferSize ADC_BUFFER_SIZE * 2; // 传输总量总数据个数 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; // 外设地址固定不递增 DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; // 内存地址递增依次存放 DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; // 外设数据宽度16位ADC是12位存在16位寄存器低12位 DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; // 内存数据宽度16位 DMA_InitStructure.DMA_Mode DMA_Mode_Circular; // 模式循环模式传输完自动从头开始 DMA_InitStructure.DMA_Priority DMA_Priority_High; // 优先级高 DMA_InitStructure.DMA_M2M DMA_M2M_Disable; // 禁止内存到内存模式我们是用外设触发 DMA_Init(DMA1_Channel1, DMA_InitStructure); // 4. 使能DMA通道 DMA_Cmd(DMA1_Channel1, ENABLE);关键点解析数据交错存储当ADC以扫描模式转换多个通道时转换结果会依次放入ADC_DR。DMA也会按这个顺序依次搬运到内存。所以我们的数组ADC_ConvertedValue中的数据排列将是[Ch0_Sample1, Ch1_Sample1, Ch0_Sample2, Ch1_Sample2, ...]。理解这个顺序对后续数据处理至关重要。循环模式Circular Mode这是实现“连续不断”采集的关键。当DMA传输完预设的数据量BufferSize后它会自动将传输计数器重置并从头开始下一轮传输覆盖旧数据。这形成了一个环形的缓冲区。结合ADC的连续转换模式就实现了“永动机”式的采集。外设地址不递增因为ADC每次转换完数据都放在同一个寄存器ADC1-DR里所以源地址是固定的。3.4 启动采集与数据处理配置完成后只需要一个软件触发整个系统就开始自动运行// 启动ADC的软件触发开始第一次转换。之后由于是连续模式ADC会一直转换下去。 ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 主循环中我们可以安全地处理数据而无需担心采集被打断 while (1) { // 示例计算通道0最近100个采样的平均值 uint32_t sum_ch0 0; for (int i 0; i ADC_BUFFER_SIZE; i) { // 注意索引通道0的数据存放在数组的偶数位置0, 2, 4... sum_ch0 ADC_ConvertedValue[i * 2]; } uint16_t avg_ch0 sum_ch0 / ADC_BUFFER_SIZE; // 将平均值转换为电压值 (假设Vref3.3V) float voltage_ch0 (avg_ch0 * 3.3f) / 4096.0f; // 这里可以发送到串口、刷新屏幕、或者进行其他控制逻辑 // ... Delay_ms(100); // 主循环延迟模拟其他任务处理 }实操心得在主循环里处理数据时你可能会担心DMA正在写入的数组和你正在读取的数组是同一块内存会不会冲突这里有一个高级技巧使用DMA的“双缓冲”或“半传输完成中断”。你可以将数组大小设为实际需要的两倍然后在DMA传输到一半半传输完成中断和全部完成传输完成中断时切换读写指针。这样CPU永远在处理“旧”的、完整的一块数据而DMA在写“新”的另一块数据实现了无锁的线程安全数据交换。这是实现高性能实时系统的常用模式。4. 调试技巧与常见问题排查即使代码逻辑正确在实际硬件调试中也可能遇到各种问题。下面是我踩过坑后总结的排查清单。4.1 ADC采样值不稳定跳动大这是最常见的问题现象是输入一个固定电压读出的ADC值在几十个数字甚至上百个数字的范围内跳动。检查1电源与参考电压用万用表测量开发板的3.3VVDDA/Vref和GND是否稳定。如果电源纹波大ADC结果必然跳动。可以尝试在VDDA和GND之间并联一个10uF的电解电容和一个0.1uF的瓷片电容进行滤波。检查2输入信号源信号源本身是否稳定如果使用电位器分压电位器质量差或接触不良也会导致跳动。可以尝试直接连接一个稳定的电压源如稳压芯片的输出进行测试。检查3采样时间采样时间是否足够如果信号源内阻较大如光敏电阻、热敏电阻ADC内部的采样电容来不及充到稳定电压结果就会不准。解决方法增加ADC_SampleTime例如从ADC_SampleTime_55Cycles5增加到ADC_SampleTime_239Cycles5。代价是降低最大采样率。检查4数字噪声干扰ADC对噪声非常敏感。确保模拟部分ADC输入走线远离数字部分如时钟线、高速数据线。如果可能将不用的ADC通道接地或接Vref避免浮空引入噪声。检查5软件滤波硬件无法完全解决时必须在软件上做文章。均值滤波如上面代码示例是最简单有效的。对于周期性干扰可以使用滑动平均滤波。对于快速变化的信号可以使用卡尔曼滤波或一阶低通数字滤波。4.2 DMA传输不启动或数据不对现象是数组里的数据全是0或者一直是某个固定值没有变化。检查1DMA和外设时钟确认RCC_AHBPeriphClockCmd(DMA1)和RCC_APB2PeriphClockCmd(ADC1)确实被使能。这是最基础的错误。检查2DMA通道映射确认使用的是DMA1_Channel1来服务ADC1。这个映射是固定的用错通道DMA不会响应ADC的请求。检查3ADC的DMA请求使能确认执行了ADC_DMACmd(ADC1, ENABLE);。没有这行代码ADC转换完成不会产生DMA请求信号。检查4ADC是否真正启动确认执行了ADC_SoftwareStartConvCmd(ADC1, ENABLE);或者使用了其他触发方式。可以尝试在启动后读取ADC1-DR寄存器或者检查ADC状态寄存器看转换是否真的在进行。检查5缓冲区溢出与指针在循环模式下DMA会不断覆盖数组。如果你在主循环里通过串口打印整个数组打印速度可能跟不上DMA覆盖的速度导致看到的数据是错乱的。调试建议可以暂时改为非循环模式DMA_Mode_Normal在DMA传输完成中断里停止ADC然后检查数组数据。这样能确保你看到的是“静止”的一帧完整数据。4.3 多通道数据顺序错乱现象是通道0和通道1的数据混在了一起。根本原因没有理解数据在内存中的交错存储格式。如前所述对于两个通道的连续扫描数据排列是 [C0, C1, C0, C1, ...]。解决方案在读取数据时必须按照这个规律进行索引。// 正确索引方式 uint16_t ch0_value ADC_ConvertedValue[buffer_index * 2]; // 偶数索引是通道0 uint16_t ch1_value ADC_ConvertedValue[buffer_index * 2 1]; // 奇数索引是通道1进阶方案如果觉得交错处理麻烦可以在DMA传输完成中断中对数据进行“解交错”处理将不同通道的数据分别存入不同的数组方便主循环使用。4.4 计算实际采样率了解你的系统能跑多快很重要。采样率决定了你能捕获多高频率的信号根据奈奎斯特采样定理至少需要信号频率的2倍。计算公式总转换时间T_conv 采样时间T_sampling 12.5个ADC时钟周期单个通道的转换时间 T_conv多个通道扫描一轮的时间 通道数 * T_conv采样率 1 / (通道数 * T_conv)举例ADC时钟9MHz采样时间55.5周期2个通道。T_sampling 55.5 / 9MHz ≈ 6.17usT_conv 6.17us (12.5 / 9MHz) ≈ 6.17us 1.39us ≈ 7.56us扫描一轮时间 2 * 7.56us 15.12us采样率每个通道 1 / 15.12us ≈ 66.1kHz这意味着在DMA循环模式下每个通道每秒能获得约6.6万个采样点。这个速度对于音频~44.1kHz采集已经绰绰有余但对于高频信号分析则可能不够。如果需要更高速度可以尝试提高ADC时钟但不超过14MHz或减少采样时间牺牲精度。配置完成后我习惯用一个简单的流程来验证整个链路是否通畅将PA0接GNDPA1接3.3V。上电后通过调试器或串口观察ADC_ConvertedValue数组。你应该能看到所有偶数索引位置的值接近0所有奇数索引位置的值接近4095。如果结果如此那么恭喜你从模拟信号到内存数组的“高速公路”已经全线贯通。这条路的打通意味着你的STM32具备了实时、高效处理外部模拟世界信息的能力为更复杂的项目如数字示波器、数据记录仪、闭环控制系统奠定了坚实的地基。