STM32 HAL库ADC开发指南:单通道到DMA、多通道、校准与滤波踩坑总结
简介面向嵌入式工程师与电子竞赛学生的STM32 HAL库ADC开发完整工程包覆盖从初始化、通道配置到多通道采集的整套流程可直接用于学习或项目二次开发。资源基于STM32系列芯片围绕十二位ADC转换实现了结构体参数配置、通道优先级排序、定时器与外部触发源设置、DMA数据传输、校准接口、低功耗模式及错误回调等关键机制代码注释详尽有助于理解采样时序、转换周期与噪声抑制等实际工程问题。压缩包共1000个文件大小约24.08MB其中559个C源码实现核心逻辑243个头文件定义寄存器映射与接口51个汇编文件及IAR/Keil工程配置、链接脚本、icf启动配置、hex/axf固件、map映射表和ioc图形化工程文件共同构成完整可构建的工程目录支持IAR与Keil两种常用工具链。目前已有226人学习适合对照开发板同步练习也可将DMA多通道采集、定时器触发等方案迁移至工业控制、传感器采集等实时信号处理场景。1. 调 ADC 三天没出准数才知道 HAL 库藏着这些门道干嵌入式这几年ADC 是绕不过去的坎。HAL 库把寄存器封装得看着挺清爽HAL_ADC_Start、HAL_ADC_GetValue两三个函数就能拿到原始值可你真放到项目里就发现不对劲同一块板子、同一个管脚电池电压测出来飘了 0.2V多通道扫一遍通道顺序对不上DMA 模式偶尔数据全是 0。这些坑不是芯片坏了而是库的配置、校准、时序没吃透。这篇笔记不聊理论就按我拆过的几个模拟项目把 STM32 HAL 库 ADC 从单通道到 DMA、双 ADC、滤波校准的完整走法拆开讲新手能按步骤跑通老手直接看参数边界和踩坑点省得再走一遍弯路。2. 单通道采集初始化结构体、校准与电压换算公式2.1 最小可跑工程CubeMX 配置与关键参数用 HAL 库做 ADC我一般不手写寄存器直接在 CubeMX 里把外设初始化生成好再往业务逻辑里填代码。打开 CubeMX 选好芯片型号在 Pinout 视图里把某个引脚配成 ADC 输入这个动作实际上是把这个引脚的模拟功能打开也就是 GPIO 配置成GPIO_MODE_ANALOG。这里有个细节经常被忽略ADC 引脚不需要配置上下拉也不应该配置推挽输出CubeMX 会默认给你设成 Analog。如果你手写初始化GPIO 那步写错了比如设成了输入下拉测出来的电压会普遍偏低一点点而且源阻抗越高偏得越明显。早年我在这上面栽过后来习惯每次生成代码后先看一眼MX_ADC1_Init()里有没有单独初始化 GPIO 的部分没有就手动补上。ADC 参数页里需要先定几个东西。Resolution 选多少位一般选 12-bit这是大部分 STM32 的默认值位数越高分辨率越高采样时间这里我建议先用默认值跑通后再按需调大。Conversion Mode 选 Single 表示单次转换选 Continuous 表示连续转换DMA 模式下一般用 Continuous。扫描模式要看通道数单通道就关掉但是如果你开了 ScanConvMode 又只配了一个通道功能上没问题但会多出没必要的开销。下面是 CubeMX 生成的一段典型 ADC 初始化代码我加了注释方便对照。ADC_HandleTypeDef hadc1; void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ClockPrescaler ADC_CLOCK_SYNC_PCLK_DIV4; // ADC 时钟分频 hadc1.Init.Resolution ADC_RESOLUTION_12B; // 12 位分辨率 hadc1.Init.ScanConvMode DISABLE; // 单通道不用扫描 hadc1.Init.ContinuousConvMode DISABLE; // 单次转换 hadc1.Init.DiscontinuousConvMode DISABLE; // 不使能不连续模式 hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; // 软件触发 hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; // 数据右对齐 hadc1.Init.NbrOfConversion 1; // 转换通道个数 if (HAL_ADC_Init(hadc1) ! HAL_OK) { Error_Handler(); } }这段代码的逻辑很清楚先指定用哪个 ADC 外设然后配置它的工作模式。ClockPrescaler决定了 ADC 的采样时钟频率分频越小 ADC 时钟越高转换越快但精度会受一点影响具体的上限要看芯片手册里的 ADC 时钟频率限制。NbrOfConversion要和后面配置的通道数量一致不然转换流程会异常。DataAlign选右对齐这样读取DR寄存器时数值就是正常的二进制值不需要移位处理。2.2 HAL_ADC_Start、PollForConversion 与读取流程初始化完成之后标准单次读取流程是三步启动转换、等待转换完成、读取结果。我一般会把它封装成一个函数业务层只调一次就能拿到一个原始值。uint16_t adc_read_single(ADC_HandleTypeDef *hadc) { uint16_t raw 0; if (HAL_ADC_Start(hadc) ! HAL_OK) { return 0xFFFF; // 启动失败返回一个明显异常值 } if (HAL_ADC_PollForConversion(hadc, 100) HAL_OK) { raw HAL_ADC_GetValue(hadc); // 读取转换结果寄存器 } HAL_ADC_Stop(hadc); return raw; }这里要注意两个地方。第一个是HAL_ADC_PollForConversion的第二个参数是超时时间单位毫秒。如果返回超时通常是 ADC 时钟没配好或者触发方式选成了外部触发但你一直在等软件触发。第二个是每次读取完要调用HAL_ADC_Stop否则你已经关了连续模式但外设还处在使能状态下次启动会出一些不稳定的行为。还有一点容易被忽略HAL 库在有 DMA 时读取结果的路径完全不同这个函数不适用于 DMA 模式。在单次阻塞模式下如果使能了中断转换完成会进HAL_ADC_ConvCpltCallback这个回调里也可以拿到值和 Poll 是两条路线别混用。2.3 把 ADC 原始值换算成真实电压公式与参考电压陷阱拿到 12 位原始值后电压换算公式是float voltage (float)raw * 3.3f / 4095.0f;这个公式理论没错但问题藏在3.3f上。很多开发板的 VREF 实际不是精确的 3.3V可能实测是 3.29V也可能是 3.31V你用理想值算出来的电压自然带偏差。比较稳的做法是用万用表实测板子上的 VREF 电压再把那个实测值代进公式。我一般会把基准电压做成一个宏方便不同板卡之间切换。#define ADC_VREF_MV 3300.0f // 实测板卡基准电压单位 mV #define ADC_MAX_RAW 4095.0f float adc_to_millivolt(uint16_t raw) { return (float)raw * ADC_VREF_MV / ADC_MAX_RAW; }换算单位用 mV 比 V 更实用免去一堆小数。若芯片内部有 VREFINT 通道还可以进一步校准这个在后面的章节展开。很多刚接触的同学会把分辨率当成精度12 位分辨率意味着理论上能分辨出 3300/4096 约 0.8mV 的变化但实际噪声、PCB 布局、源阻抗会让有效精度打折这是正经产品里 ADC 调参的重头。3. 多通道与 DMA扫描顺序、数据对齐与缓存设计3.1 多通道扫描模式的配置顺序单通道只是开胃菜实际项目里至少要同时采三五路模拟量比如三路电池电压加一路温度。这时要打开 ScanConvMode并且把NbrOfConversion设成通道个数然后逐一配置每个通道的采样时间、增益等参数。一个核心坑通道配置顺序必须和转换顺序一致。ADC_ChannelConfTypeDef结构体里的Rank字段决定这个通道排第几个转换。CubeMX 生成代码时会按你在界面上添加的顺序自动排 Rank但如果你后来手动增删了通道Rank 没有跟着调就会出现数据错位。例如下面这段配置了两个通道Rank1 的是通道 3Rank2 的是通道 5那么转换结果缓冲区里的第一个 16 位数据一定是通道 3 的第二个才是通道 5 的。void MX_ADC1_Init(void) { // ... 省略公共参数 hadc1.Init.ScanConvMode ENABLE; hadc1.Init.NbrOfConversion 2; // ... HAL_ADC_Init 之后 } void adc_channels_config(ADC_HandleTypeDef *hadc) { ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel ADC_CHANNEL_3; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_84CYCLES; HAL_ADC_ConfigChannel(hadc, sConfig); sConfig.Channel ADC_CHANNEL_5; sConfig.Rank 2; sConfig.SamplingTime ADC_SAMPLETIME_84CYCLES; HAL_ADC_ConfigChannel(hadc, sConfig); }SamplingTime这个参数值得多说两句。它决定采样保持电容充电的时间单位是 ADC 时钟周期。信号源内阻越大需要的采样时间越长。如果采样时间太短电容还没充到稳定电压就开始转换结果会偏小。一般来说直接推挽输出的低阻信号源用 12 到 84 周期都行但接的是运放输出或者高阻传感器采样时间至少选 84 周期起步有些地方甚至要求 480 周期以上。这属于调参里的玄学区你说不清到底该多少只能拿信号源内阻和示波器来试。3.2 DMA 搬运与半字对齐问题多通道连续转换时如果每路都用 PollForConversion 去读CPU 会被占死。标准做法是开 DMA让硬件把转换结果自动搬进内存CPU 只需要在最后处理一整个数组。DMA 模式配置有一个我踩过的大坑数据宽度。ADC 数据寄存器是 16 位的12 位有效数据存在低 12 位所以 DMA 搬运的内存变量类型必须是uint16_tDMA 外设数据宽度和内存数据宽度都应配成 Half Word半字。有些人图省事定义成uint32_t数组结果每个通道的数据被拆得七零八落数值完全对不上。#define ADC_CH_NUM 2 uint16_t adc_buf[ADC_CH_NUM] {0}; void adc_dma_start(ADC_HandleTypeDef *hadc, uint16_t *buf, uint32_t len) { HAL_ADC_Start_DMA(hadc, (uint32_t *)buf, len); }注意一个细节HAL_ADC_Start_DMA的第二个参数被强转成了uint32_t *但实际搬运时 HAL 内部是按半字操作的。传进去的 buf 必须是 16 位对齐的数组长度就是转换通道数。如果在多通道扫描模式下只搬运一次DMA 会一次把 N 个通道的转换结果按顺序写进数组但不会自动再次启动。若开了连续转换模式DMA 要配成循环模式否则数据只更新一轮就停了。3.3 双 ADC 同步采样与交替采样有些应用需要两路信号在同一时刻采样做相位对比或者需要更高的等效采样率。部分 STM32 型号支持双 ADC 模式用 ADC1 和 ADC2。同步采样模式下两个 ADC 由同一触发信号启动同时各自转换一路通道转换结果分别存在各自的 DR也可以通过 DMA 按规则排序一次搬走。void adc_dual_mode_config(ADC_HandleTypeDef *hadc1, ADC_HandleTypeDef *hadc2) { ADC_MultiModeTypeDef multimode {0}; multimode.Mode ADC_DUALMODE_REGSIMULT; // 同步采样结果在 ADC1 的 DR multimode.DualADC1 hadc1; multimode.DualADC2 hadc2; HAL_ADCEx_MultiModeConfigChannel(hadc1, multimode); }这种模式下的 DMA 长度要按双倍通道数算因为 ADC1 的 DR 会依次容纳两个 ADC 的结果。比如 ADC1 采通道 0ADC2 采通道 1那么 DMA 缓冲区的第一个元素是 ADC1 的结果第二个是 ADC2 的结果。数据区分靠位置而不是靠标志位所以配对顺序别搞反。4. HAL 库 ADC 踩坑排查五个最容易翻车的环节4.1 第一次转换结果总是偏小现象上电后第一次调用HAL_ADC_Start到HAL_ADC_GetValue读到的值比预期低比如输入 3.0V 却读到 2.95V第二次以后恢复正常。原因ADC 内部采样电容在首次工作时没有完成充电。尤其在信号源内阻较高、采样时间设置偏短时第一枪必然偏。另一个相关因素是部分芯片需要在上电后做一次校准校准结果存进CALFACT寄存器不校准的话增益误差会偏大。解决一是在初始化后统一做一次校准调用HAL_ADCEx_Calibration_Start并等待它完成二是业务上丢弃第一次转换结果实际产品里我都是采两轮第一轮的结果只用来暖机static void adc_warmup(ADC_HandleTypeDef *hadc) { HAL_ADCEx_Calibration_Start(hadc, ADC_SINGLE_ENDED); adc_read_single(hadc); // 丢首次 }4.2 多通道数据错位或串通道现象扫码顺序配了两个通道DMA 数组里第一个值始终是第二个通道的读数或者读数随采样时间变化而跳变。原因通道 Rank 顺序与 DMA 缓冲区位置对应关系没对上也可能是 ScanConvMode 没打开导致后续通道根本没进入转换队列。还有一种情况是你在运行时动态改过通道配置Rank 没重新规定。解决逐一核对Channel、Rank和NbrOfConversion三者的对应关系。调试时可以先用 PollForConversion 模式单通道轮流读一遍确认每个通道硬件没接错再切到 DMA。把 DMA 数组打出来对比硬件输入就能定位是顺序错还是接线错。4.3 DMA 中断开了但数据一直不更新现象开了 ADC DMA 的转换完成中断回调里也写了处理代码但数组里的值不变化调试发现 DMA 传输计数一直是零。原因HAL_ADC_Start_DMA里会同时使能 ADC 的 DMA 请求和 DMA 通道。如果 DMA 配置里 Data Width 填了 Byte而 ADC 数据寄存器是 Half WordDMA 会认为只传了半个有效数据计数错乱。如果 DMA 配置里外设地址没设置成hadc-Instance-DR数据根本不知道从哪搬。解决检查 DMA 初始化结构体外设地址填ADC1-DR方向 PeripheralToMemory外设数据宽度和内存数据宽度都选 HalfWord内存地址自增。循环模式下Mode填 Circular单次模式填 Normal。改完再不行就把 DMA 中断优先级调高一些别被其他中断饿死。4.4 注入通道抢占导致规则通道结果异常现象规则通道转换过程中如果开启了注入通道组规则转换会被打断恢复后有些 HAL 版本下HAL_ADC_GetValue读回来的数据不是完整结果。原因这是芯片硬件行为。注入转换优先级高于规则转换一旦触发就会暂停当前规则转换执行注入序列然后恢复规则转换。HAL 库对这种情况没有自动补偿错误地用中断读取就会拿到半截数据。解决产品里如果规则通道和注入通道都要用优先级和触发源要规划好。一般规则通道用 DMA 循环模式注入通道用软件触发注入转换完通过HAL_ADCEx_InjectedConvCpltCallback单独处理不干扰规则通道的 DMA 搬运。4.5 低功耗模式下 ADC 数据漂移现象系统进入 Stop 模式再唤醒后ADC 读数整体偏移或者出现偶发跳变。原因Stop 模式下 ADC 时钟被切断深度低功耗模式甚至会把 ADC 的参考电压电路断电。唤醒后 ADC 内部状态没有恢复到转换条件校准数据也丢了。解决退出低功耗后重新初始化 ADC先做一次全量校准再开始转换。不要复用进低功耗前缓存的校准值。5. 让采样值稳定可用滤波、过采样与校准的进阶组合拳5.1 滑动平均滤波窗口ADC 原始值直接拿去用肉眼看着就跳尤其电源纹波大的时候。最简单的滑动平均值滤波窗口开 8 到 16 个点效果立竿见影。窗口太大响应慢太小滤不干净8 是大部分场景的折中。#define ADC_SAMPLE_BUF_SIZE 8 uint16_t sample_buf[ADC_SAMPLE_BUF_SIZE]; uint8_t sample_idx 0; uint32_t sample_sum 0; uint16_t adc_sample_filtered(ADC_HandleTypeDef *hadc) { uint16_t new_val adc_read_single(hadc); sample_sum - sample_buf[sample_idx]; sample_buf[sample_idx] new_val; sample_sum new_val; sample_idx (sample_idx 1) % ADC_SAMPLE_BUF_SIZE; return (uint16_t)(sample_sum / ADC_SAMPLE_BUF_SIZE); }逻辑就是环形缓冲每次新值进来替换最旧的值平均值用累加和算避免每次循环求和。注意sample_sum用 32 位不然 8 个 4095 叠加乘了之后会溢出。5.2 硬件过采样软件滤波省事但采样率被拖慢。部分 STM32 型号自带硬件过采样在 ADC 内部累计多笔采样再右移输出等效提升分辨率。寄存器里配置 OVFS 字段比如 16 次过采样右移 2 位等效 14 位分辨率。hadc1.Init.Overrun ADC_OVR_DATA_OVERWRITE; // 在 CubeMX 的 ADC 配置页里使能 OverSamplingRatio16, RightShift2开过采样后读取值不再是 12 位原始值而是做过平均和缩放的结果电压换算公式需要按有效位数调整。之前有个项目用 12 位 ADC 量压力传感器开 16 倍过采样后满量程跳动从 ±5mV 降到 ±1mV效果相当可观。5.3 Vrefint 校准法换板子、换电源、环境温度变了参考电压都会漂。部分芯片内部有个固定的基准电压通道 VREFINT它在出厂时有一个校准值和温度系数。把实际测得的 VREFINT ADC 值和出厂校准值做对比可以反推当前 VREF 电压从而修正所有通道的换算系数。#define VREFINT_CAL (*((uint16_t *)0x1FFFF7BA)) // 出厂校准值 #define VREFINT_FACTORY 3.0f // 校准时的基准电压 float adc_get_vref_mv(ADC_HandleTypeDef *hadc) { uint16_t vrefint_raw adc_read_vrefint(hadc); return (float)VREFINT_CAL * VREFINT_FACTORY / (float)vrefint_raw; }这个做法常见于电池供电设备因为电池电压波动直接导致 VREF 漂移。从那以后我每次布板、改电源、换批次芯片都会强制走一遍 Vrefint 校准流程验证一遍所有通道的电压读数和万用表对得上再往下做。ADC 调参这种事靠一次配置走运不如靠一套固定流程避免翻车希望帮到你。本文还有配套的精品资源点击获取