简介:一套基于STM32标准库的ADC采集学习资源,面向正在入门STM32嵌入式开发、需要掌握单通道与多通道模拟信号采集的开发者。资源以Keil工程形式呈现,围绕1路、2路、多路ADC采集场景,给出标准库下的初始化配置、通道序列扫描、DMA或中断读取等工程化实现,可直接对照学习或在此基础上修改复用。压缩包共377个文件,包含66个c源码、74个h头文件以及大量编译生成的o/d/crf中间文件,另有uvprojx工程文件、map/axf/lst链接输出等,整体结构是一套完整可编译的STM32标准库例程,包大小9.56MB。目前已有382人学习下载。结合内容预览可知工程还涉及定时器、Flash、LCD等模块,适合作为综合实验模板;通过梳理ADC配置参数、采样时序与多路切换思路,可帮助读者快速从单路采集扩展到多路应用,减少重复踩坑。
1. 标准库视角下的 STM32 ADC 采集:从 1 路到多路的关键分界
玩标准库的人,早晚会卡在 ADC 多路采集上:单路轮询明明好使,把通道切换代码怼上去,读回来的数据要么永远是第一个通道的,要么多通道数据互相串。这不是代码敲错,而是对 STM32 ADC 的规则组和 DMA 机制理解没到位。下面这套流程不绕理论,直接从 STM32 标准库的 ADC 采集入手,先把 1 路由规则组连续转换读通,再把 2 路、多路接到 DMA 的搬运链上,最后解决数据错位、电压换算和采样周期这几件工程里绕不开的事。适合正在用标准库 v3.5 做裸机开发的读者,Keil 环境下可以直接跟着改。
2. STM32 ADC 的底层机制与标准库初始化骨架
2.1 规则组只有一个数据寄存器:多路采集的天然瓶颈
STM32F103 的 ADC 是 12 位逐次逼近型转换器,转换完成后结果放在一个 16 位的数据寄存器里。以 ADC1 为例,它最多有 18 个通道,其中 16 路来自 GPIO、两路来自芯片内部(温度传感器和内部参考电压 Vrefint)。这些通道不能同时转换,必须按“规则组”里的顺序排队。规则组最多能排 16 个通道,转换时从序号 1 开始依次执行,每个通道转换完,EOC 标志位置位,结果写入同一个寄存器 ADC_DR。
这里就是新手最常见困惑的来源:规则组不是“一个通道一个寄存器”,而是“所有通道共用一个寄存器”。如果你在扫描模式下开了 2 路,第 1 路转换完把结果写进 DR,第 2 路转换完又把结果写进同一个 DR,上一次的数据就被覆盖。所谓多路采集,本质上要解决的是“在 DR 被覆盖之前把结果搬走”。理解这一点,后面看 DMA 配置就不会觉得那些参数是死记硬背。
2.2 标准库新建工程与 ADC 时钟链路:三个 RCC 别漏
标准库 v3.5 新建工程时,外设库、启动文件和 Device 头文件的搭法和常规 GPIO 工程一样,但 ADC 初始化里 RCC 配置最容易漏。F103 的 ADC 时钟从 APB2 来,使用前必须做两件事:打开 ADC1 的 APB2 时钟,再通过 RCC_ADCCLKConfig 设置分频。GPIO 也要给时钟,但要把引脚模式设成 GPIO_Mode_AIN,模拟输入模式下推挽、开漏、上下拉全部不生效,这是 ADC 和普通 IO 初始化的显著区别。
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); 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);这段代码的前提是 APB2 已经跑到 72MHz。F103 的 ADC 时钟上限是 14MHz,所以 ADCCLK 的分频要从 Div6 起步,12MHz 是最常用的一档。把 RCC_PCLK2_Div2 或 Div4 用上去,ADC 时钟超限,采样值可能完全不正常。下面这张表是 72MHz 主频下的常见分频选择,新建工程时可以直接对着选。
| RCC_ADCCLKConfig 参数 | 实际 ADC 时钟 | 是否可用 |
|---|---|---|
| RCC_PCLK2_Div2 | 36MHz | 超限,不可用 |
| RCC_PCLK2_Div4 | 18MHz | 超限,不可用 |
| RCC_PCLK2_Div6 | 12MHz | 常用 |
| RCC_PCLK2_Div8 | 9MHz | 可用,精度损失可忽略 |
如果你用的是 STM32F407 的 ADC 工程,标准库的时钟配置思路一样,但分频系数和上限不同:F4 的 ADC 时钟允许到 36MHz,而且时钟源配置还涉及 PLL 的输出,不能直接把这里的 Div6 抄过去。标准库里 ADC_InitTypeDef 结构体的每个成员都对应 ADC_CR1、ADC_CR2 和 ADC_SQR1 的某几个位,逐个赋值比直接改寄存器可读性好,这也是标准库在调试期的优势。另外在 Keil 里建标准库工程时,记得把 stm32f10x_adc.c 和 stm32f10x_dma.c 加进外设库源文件组,并把头文件路径补上,不然编译时会报 implicit declaration,这是标准库新建工程最常见的报错,跟 ADC 本身无关但拦住了不少人。
2.3 通道要排队:ADC_RegularChannelConfig 的 Rank 不是物理编号
ADC 引脚确定后,还要把通道排进规则组序列,标准库的做法是调用 ADC_RegularChannelConfig(ADC1, ch, rank, sampleTime)。ch 是物理通道号,比如 ADC_Channel_0 对应 PA0;rank 是转换顺序,第几个转由 rank 决定。一个通道可以在规则组里出现多次,rank 也可以不连续,但相同的 rank 同时挂两个物理通道时,后配置的那个会覆盖先配置的,这个细节在动态切换通道时尤其坑。
初始化函数本身要处理的东西不多,只有一个 ADC_InitTypeDef 和一次校准。整体顺序我一般这样组织:先 RCC,再 GPIO,然后 ADC_Init,开 ADC,最后做一次标准校准,再启动转换。校准必须等两个标志位完成,否则首次采集值会偏。
ADC_InitTypeDef ADC_InitStructure; ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1));参数说明放这里:ADC_ScanConvMode 决定能不能一次扫描多个通道,单路先关掉;ContinuousConvMode 开起来后,规则组转完一轮会自动从头再来一遍;ExternalTrigConv_None 表示使用软件触发,后面用 ADC_SoftwareStartConvCmd 启动转换;DataAlign_Right 右对齐,12 位有效数据落在寄存器低 12 位。这一套是标准库 ADC 工程的最小骨架,单路和多路只差 Scan、通道数和 DMA 三处。
3. 标准库实现 ADC 单路采集:连续转换与电压换算
3.1 轮询读取是最小实现:EOC 标志与数据寄存器
单路采集不需要 DMA,最简单可靠的方式是软件触发加轮询。每次请求一个通道时,先重新配置一次规则组序列,然后启动转换,等待 EOC 标志置位,最后从 ADC_GetConversionValue 读值。标准库里这个函数返回的是数据寄存器右对齐后的值,直接就能用。
u16 adc_read_single(u8 adc_ch) { ADC_RegularChannelConfig(ADC1, adc_ch, 1, ADC_SampleTime_55Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while(ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); return ADC_GetConversionValue(ADC1); }这里有几个点值得说透。第一,每次调用都重新配置 rank 是有意的,不是多余操作,这样同一个函数想采哪个通道就采哪个,不用为每个通道写一套逻辑。第二,触发命令只需要在软件触发模式下用,如果用了定时器触发,要改成等待触发事件标志。第三,EOC 标志在读完 ADC_DR 后自动清零,如果你在 while 循环里只查标志不读寄存器,第一次查完是好的,第二次开始就永远等不到新的 EOC。
实际项目里我不会在中断或者主循环里频繁调用这个函数做连续采样,它的定位是“按需单次采样”。比如按键按下时读一次电池电压、进入某个状态时读一次光敏传感器,这种低频场景轮询完全够用。如果要求持续输出,就要看下一节的连续模式。
3.2 连续模式与单次模式的取舍:看场景而不是照抄
标准库里 ADC_ContinuousConvMode 一旦打开,规则组转换完成一轮后不会停,而是自动重新开始下一轮。这个模式看起来省事,实际上它对读取方提出了要求:EOC 置位后 DR 里的数据只能保持到下一次转换完成,如果读取不及时,拿到的可能已经是下一通道或者下一轮的数值。轮询读连续模式反而容易因为主循环调度不稳定而丢数据。
| 使用方式 | 单次模式 | 连续模式 |
|---|---|---|
| 按需读一次 | 合适,功耗低 | 浪费,转换停不下来 |
| 持续输出 + 轮询 | 需要反复触发 | 有覆盖风险,不推荐 |
| 持续输出 + DMA | 每轮触发一次 | 标准做法,推荐 |
结论很明确:连续模式基本是给 DMA 准备的。单路场景要连续读,我一般也直接把 DMA 方案提前用上,而不是靠轮询去追 EOC;多路场景更是如此。如果你只在中断里读,可以在 EOC 中断回调里立即把 ADC_GetConversionValue 的结果存进全局变量,理论上可行,但中断频率和主循环读取之间的竞争条件需要自己小心。
3.3 电压换算的精度边界:别把 3.3V 当成真理
ADC 读回来的数本身没有物理意义,必须换算成电压。12 位分辨率下,满量程是 4096 个分度,理论公式是 V = adc_val / 4096 * Vref。Vref 在这里是 VDDA,不是某个你脑补的 3.3V。很多板子 VDDA 直接接 3.3V,但 LDO 输出、电源纹波和 PCB 走线都会让实际 VDDA 偏离标称值,所以换算结果天生带着误差。
提高换算精度的一个常见做法是用精密基准源反推 Vref。具体操作是:把一个已知电压(比如 2.048V)接到某个 ADC 通道,多读几次取平均,然后用公式反解 Vref = 已知电压 * 4096 / adc_val,把算出来的 Vref 固化成宏。这样做不用改硬件,适合要求几毫伏级一致性但又没条件上外部基准的场合。后面第 5 章还有基于内部参考电压的修正方案,和这个思路互补。
4. 标准库实现 ADC 2 路与多路采集:规则组扫描与 DMA 搬运
4.1 为什么 2 路以上的主流方案一定是 DMA
回到第 2 章说的那个瓶颈:规则组只有一个数据寄存器,多通道转换结果在 DR 里排队等待读取,而下一个通道的完成会覆盖之前的数据。理论上你可以用 EOC 中断,每次转换完成进中断把 DR 读走;实际做 2 路以上时,中断频率会变得很高,而且中断响应延迟直接决定你能跑多快的采样率。标准库时代做多路 ADC 采集,我基本没见有人用“纯中断逐次搬运”,主流做法是把 DMA 通道挂到 ADC1 的 DMA 请求上。
DMA 解决的是“自动搬运”问题:每次规则通道转换完成,ADC 会发出 DMA 请求,DMA 控制器把 ADC_DR 里的 16 位数据搬到内存数组,整个过程不需要 CPU 参与。2 路是多路的最小形态,DMA 缓冲区长度设 2 就能跑;多路也就是把缓冲区长度和规则组序列同步扩展。理解了规则组和 DMA 的配合,2 路和比如 8 路的配置差距只有一个宏。
4.2 多路 DMA 初始化:以 3 路为例,2 路改一个宏
下面是完整的多路初始化代码,通道采样接口是 PA0、PA1、PA2,对应 ADC 的通道 0、1、2。2 路场景把 ADC_CH_NUM 改成 2,去掉 PA2 对应的 GPIO 和第三个通道配置即可。
#define ADC_CH_NUM 3 u16 g_adc_buf[ADC_CH_NUM]; void ADC_Multi_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; DMA_InitTypeDef DMA_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); /* ADC1 的 DMA 请求默认挂在 DMA1 的 Channel1 上 */ DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)(&(ADC1->DR)); DMA_InitStructure.DMA_MemoryBaseAddr = (u32)g_adc_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = ADC_CH_NUM; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; 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); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; 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 = ADC_CH_NUM; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); }代码里值得画重点的参数有四个。DMA_DIR_PeripheralSRC 表示外设是数据源,方向是从 ADC 外设读到内存;PeripheralDataSize 和 MemoryDataSize 都配成 HalfWord,是因为 ADC_DR 是 16 位寄存器,半字正好对应一个通道的采样值,内存数组必须用 u16;MemoryInc 打开,让 DMA 每搬一次自动把目的地址向前移一个单位;Circular 环形模式在缓冲区填满后自动回绕到数组开头,和 ADC 连续扫描正好配对,不需要手动重装。
规则组配置里有个关键对应关系,务必记牢:rank 为 1 的通道转换结果一定落在 g_adc_buf[0],rank 为 2 落在 g_adc_buf[1],依此类推。很多人调完发现数据对不上,十有八九是把物理通道号当成数组下标了。比如你把 ADC_Channel_1 配成 rank 1,那 PA1 的电压就在 g_adc_buf[0],而不是 g_adc_buf[1]。
初始化顺序上,DMA 的使能要先于 ADC 转换启动,ADC 的 DMA 请求开关(ADC_DMACmd)要在 ADC_Cmd 打开之前设置好。标准库对顺序没有强约束,但先 DMA 后 ADC 这个习惯能避免极少数情况下启动转换后 DMA 请求没人接的问题。校准放在 ADC 使能之后、软件触发之前,等待标志位完成再启动,首次数据才可信。
4.3 读数据不是简单地直接访问数组:标志位与临界保护
DMA 一直在后台写 g_adc_buf,主循环读这个数组时会把一个 16 位值分成两次内存访问,而 DMA 写入也可能发生在任意时刻。假如主循环正读到 g_adc_buf[0] 的低字节,DMA 刚好把下一次转换结果写进来,读出来的就是一个新旧拼接的数。单次偶尔发生看不出来,多路扫描下数组越大,错位概率越明显。
最省事的保护方式是查询 DMA 传输完成标志再读数。在环形模式下,缓冲区被填满一次就置一次 TC1 标志,读到标志后把它清掉,再取数组值,能保证这一轮的三个值都是同一个扫描周期产出的。
if(DMA_GetFlagStatus(DMA1_FLAG_TC1) == SET) { DMA_ClearFlag(DMA1_FLAG_TC1); process_adc_values(g_adc_buf, ADC_CH_NUM); }更稳的做法是开 DMA 的半传输和传输完成中断,在中断里把 g_adc_buf 复制到一块独立的内存,主循环只处理副本。标准库可以用 DMA_ITConfig(DMA1_IT_HT1 | DMA1_IT_TC1, ENABLE) 开启两个中断,复制进缓冲区用 memcpy 即可。这个方案能让主循环完全不阻塞,代价是多占一块内存,多路数越大越划算。对应中断服务函数是 DMA1_Channel1_IRQHandler,在标准库里通过 DMA_GetITStatus(DMA1_IT_HT1) 和 DMA_GetITStatus(DMA1_IT_TC1) 区分半满和全满事件。
| 中断请求宏 | 触发时机 | 适合做什么 |
|---|---|---|
| DMA1_IT_HT1 | 缓冲区前半已填满 | 搬运前半份数据 |
| DMA1_IT_TC1 | 整个缓冲区更新完 | 搬运全部数据,更新业务值 |
顺带提一句,如果你用的是 HAL 库工程,ADC 多路 DMA 的配置会把外设句柄和 DMA 句柄关联起来,初始化入口不同,但底层寄存器位语义和这里完全一致。标准库的好处是寄存器看得见,出问题容易定位,这也是我建议学习阶段不要直接跳 HAL 的原因。
5. 采样周期、Vrefint 校准与多路验证技巧
5.1 采样周期不是随便抄:和信号源内阻强相关
标准库把采样时间做成了枚举参数,从 1.5 个周期到 239.5 个周期一档一档往上加。采样时间直接影响输入电容充电的窗口,信号源内阻越大,需要的采样时间越长。下面这张表是 12MHz ADC 时钟下的时间与适用场景。
| 采样时间参数 | 周期数 | 单通道耗时(含 12.5 周期量化) | 适用场景 |
|---|---|---|---|
| ADC_SampleTime_1Cycles5 | 1.5 | 约 1.2us | 低内阻信号源、快速采样 |
| ADC_SampleTime_55Cycles5 | 55.5 | 约 5.7us | 常规传感器输出,最常用 |
| ADC_SampleTime_239Cycles5 | 239.5 | 约 21us | 高内阻信号、电池电压 |
多路扫描时,每一路都会完整走完“采样+量化”,总时间基本是单通道时间乘以路数。3 路都用 55.5 周期时,一轮完整扫描约 17us,换算过来每组数据 58k 次/秒,绝大多数传感器场景都够用。如果你把采样周期设到 1.5,速度上去了,采样电容没充满,采样值会在几个 LSB 之间乱跳,肉眼看到的现象就是“读数不稳但平均一下又像对的”。
5.2 用 Vrefint 修正 VDDA,比换精密电源更省事
F103 内置了一个 1.2V 左右的参考电压 Vrefint,它被接到内部通道 17 上。把 ADC_RegularChannelConfig 的通道参数改成 ADC_Channel_Vrefint 采一次,理论上会得到一个接近固定值的 ADC 原始读数。如果 VDDA 实际低于 3.3V,同一个 Vrefint 的读数会变大,反过来就能推出 VDDA 实际值:Vref_actual = 1.20 * 4096 / vrefint_val。
用这个实测 Vref 去重算第 3 章的电压公式,可以消除电源电压偏移带来的系统性误差。注意 Vrefint 的 1.2V 本身有制造偏差,严格校准还得查数据手册里的精度指标,但做量产前的粗校准已经比写死 3.3V 强很多。内部温度传感器通道 16 也可以参与验证,多路采集配置里加一路内部通道,能顺便确认规则组配置没有把内部通道的 rank 挤乱。
5.3 多路通道错位的终极验证:三个已知电压解决争端
验证多路配置是否真的按 rank 对应正确,与其反复看代码,不如接三个已知电压实测一次。比如通道 0 接 1.0V,通道 1 接 2.0V,通道 2 接 3.3V 的三分之一电压附近,通过串口把 g_adc_buf 打出来看比例。如果数组下标和预期电压对不上,改 rank 顺序就行。反过来,如果电压能对上但数值波动超过十几个 LSB,先查采样周期是否太短,再查电源地和信号源地之间有没有共地问题。把“接三个已知电压再写校正表”当成多路 ADC 量产的固定动作,比事后抓数据错位快得多。
本文还有配套的精品资源,点击获取