简介:面向STM32嵌入式开发者的ADC(模数转换)HAL库开发资源包,聚焦基于HAL库完成ADC初始化、通道配置、触发源设置、启动转换、结果读取与错误处理等完整流程,适合正在学习HAL库或需要快速上手ADC多通道采集、DMA传输及校准应用的开发者。压缩包共1000个文件,大小24.08MB,以C源码(559个.c、243个.h)为主体,包含STM32CubeMX生成的.ioc与.mxproject工程配置、KEIL/IAR工程文件、链接脚本(.icf/.sct)、编译产物(.o/.axf/.hex/.map)以及相关库文件,便于直接对照工程学习或移植。已有226人浏览学习。内容预览显示其中集成CMSIS DSP数学库及ADC转换示例,涵盖线性插值、DCT、FFT等常用算法源文件,可辅助开发者理解ADC采样数据在数字信号处理中的应用。学习后可掌握HAL库ADC驱动编写思路、常见配置项含义及工程构建方法,减少查阅手册的时间。
1. 直接用寄存器写 ADC 和用 HAL 库写 ADC,差在哪里?
做过标准外设库或者直接操作寄存器的开发者,初次换到 HAL 库写 ADC 时,多半会有一个共同的困惑:一个本来十几行就能读完数据的 ADC 初始化,HAL 库硬生生拆成了ADC_HandleTypeDef、ADC_ChannelConfTypeDef再加上一堆HAL_ADC_ConfigChannel、HAL_ADC_Start、HAL_ADC_PollForConversion,看起来绕了一大圈,好像什么都没干。但真正切换到 HAL 库做 ADC 开发之后,你会发现这套封装的价值不在「少写代码」,而在「把状态机、DMA 触发、中断回调、多通道扫描和过采样这些容易出错的细节,收敛到一套统一的接口上」。这篇笔记围绕一个具体目标展开:抛开图形化配置工具的自动生成,搞清楚 HAL 库 ADC 从初始化到拿到稳定数值的完整链路——怎么配参数、怎么选阻塞/中断/DMA、怎么处理校准,以及那些让新手反复翻车的隐蔽细节。适合正在从寄存器或标准库迁移、或者被 HAL 库的 ADC 回调机制搞晕的开发者;看完就能在本地把最小工程跑通,并把采样数值的可靠性调到够用。
2. 最小可跑的 ADC 工程:从引脚到第一组电压数据
2.1 初始化到底初始化了什么:时钟、引脚与 ADC 实例
HAL 库的 ADC 初始化代码看起来冗长,但核心只有三件事:给 ADC 外设供时钟、把 GPIO 引脚配置成模拟输入、把分辨率/对齐方式/扫描模式这些基本参数写进ADC_HandleTypeDef并调用HAL_ADC_Init。常见做法是在图形化配置工具里选好引脚和时钟树,再由工具生成MX_ADC1_Init,不过很多开发者并不清楚这段生成代码每一行在干什么,后续排查问题时只能靠猜。
先看一个最精简的初始化函数,以单 ADC 实例、单通道、右对齐、12 位分辨率为例:
void MX_ADC1_Init(void) { hadc1.Instance = ADC1; // 选中 ADC1 外设 hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; // ADC 时钟 = PCLK2 / 4 hadc1.Init.Resolution = ADC_RESOLUTION_12B; // 12 位分辨率 hadc1.Init.ScanConvMode = DISABLE; // 单通道模式,不需要扫描 hadc1.Init.ContinuousConvMode = ENABLE; // 连续转换 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE; // 软件触发 hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; // 右对齐 hadc1.Init.NbrOfConversion = 1; // 转换通道数量 hadc1.Init.DMAContinuousRequests = DISABLE; hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV; // 每次转换结束都产生 EOC if (HAL_ADC_Init(&hadc1) != HAL_OK) { Error_Handler(); } }这段配置里最容易被忽略的是ADC_CLOCK_SYNC_PCLK_DIV4这一项。ADC 的输入时钟不能超过芯片手册规定的上限,一般常见系列是 36MHz 或 40MHz 左右,如果 PCLK2 是 72MHz 而你不分频,ADC 时钟就超限了,表现是转换结果整体漂移,而且没有任何报错。Resolution选 12 位时单次转换周期固定,选 8 位或 6 位可以略微加快转换速度,但换来的是量化噪声变大,后面会细说。
配置完hadc1之后还有一个隐形步骤:HAL 库会通过HAL_ADC_MspInit回调函数完成 GPIO 和时钟的底层初始化。这段函数在图形化工具生成代码时是自动出现的,手动建工程时往往要自己补:
void HAL_ADC_MspInit(ADC_HandleTypeDef* hadc) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_ADC1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0; // 假设用 PA0 GPIO_InitStruct.Mode = GPIO_MODE_ANALOG; // 模拟输入 GPIO_InitStruct.Pull = GPIO_NOPULL; // ADC 引脚通常不要上下拉 HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); }这里有个新手经常踩的点:GPIO 配置成模拟输入时,Pull一定要设成GPIO_NOPULL。如果内部上拉被打开,就等于在待测信号上并联了一个几十千欧的电阻,分压效应会直接导致读数偏低,尤其被测源是高内阻的时候,误差可能到百分之十几。MspInit 这个名字看起来像某个库函数内部的东西,但它是整个初始化链路里唯一负责「引脚复用」的地方,项目里 ADC 换了引脚却忘了同步改这里,是极常见的翻车原因。
2.2 阻塞式采集的完整代码与返回值陷阱
初始化做完,最直观的读取方式是阻塞式:发起一次转换,然后原地等待转换结束,再从数据寄存器拿数值。HAL 库对应的三个函数是HAL_ADC_Start、HAL_ADC_PollForConversion和HAL_ADC_GetValue。最小代码如下:
uint32_t read_adc_once(void) { uint32_t adc_val = 0; if (HAL_ADC_Start(&hadc1) != HAL_OK) { return 0xFFFFFFFF; // 启动失败,返回一个不可能出现的值 } // 等待转换完成,超时 100ms if (HAL_ADC_PollForConversion(&hadc1, 100) != HAL_OK) { return 0xFFFFFFFF; } adc_val = HAL_ADC_GetValue(&hadc1); // 读取转换结果 return adc_val; }这段代码逻辑上是对的,但实际项目里很少有人会每读一次就调用一次Start。原因在于HAL_ADC_Start不只是「开始转换」,它还负责清状态标志位、复位 DMA 或中断相关寄存器。频繁调用会让 ADC 的状态机反复重启,如果发生在中断里,还有可能和其他读操作冲突。常见做法是开机初始化后调用一次HAL_ADC_Start,之后在循环里反复执行PollForConversion和GetValue。
PollForConversion的超时参数也有讲究。阻塞模式里这个参数的单位是毫秒,但它不是「转换时间」,而是「等待状态翻转的最长时间」。ADC 单次转换通常只有几微秒到几十微秒,100ms 的超时已经非常宽裕。如果这里频繁返回HAL_TIMEOUT,先别急着加超时值——说明 ADC 可能根本没有启动成功,或者时钟配置有误。把超时加长只是掩盖症状,查时钟和外设时钟开关才是正路。另外注意返回值有三个层级:HAL_OK表示转换完成,HAL_TIMEOUT表示超时,HAL_ERROR(或HAL_BUSY)表示外设状态不允许当前操作。很多开发者只判断HAL_OK和非HAL_OK,导致HAL_BUSY被当成超时处理,方向一开始就偏了。
2.3 电压值换算:前 200 次采样要丢弃吗
拿到原始 ADC 数值之后,下一步必然要换算成实际电压。这个公式本身很简单:
float voltage = (float)adc_val * 3.3f / 4095.0f;但有几个细节会直接影响换算精度。第一,基准电压不一定是精确的 3.3V。开发板上的稳压芯片输出往往有正负 1% 到 2% 的误差,讲究一点的场合应该用万用表实测 VREF+ 引脚的电压,再把实测值代进公式。第二,GetValue返回的数据是右对齐的原始值,12 位分辨率下范围是 0 到 4095,拿它乘 3.3 再除以 4096 还是 4095 会有千分位级别的误差——通常无所谓,但如果你在做校准表,建议统一用 4095 作为除数,避免和芯片手册里的满量程定义产生歧义。第三,刚上电的前几次转换结果往往偏大或偏小,这不是芯片坏了,而是 ADC 内部采样电容还没稳定。经验做法是丢弃前 200 次采样,或者至少丢弃前几十次,再开始做滤波或平均——这个习惯在从待机模式唤醒后同样适用。
3. 中断与 DMA:把 CPU 从采样循环里放出来
3.1 三组启动 API 到底有什么区别:Start、Start_IT、Start_DMA
HAL 库给 ADC 提供了三组启动方式:HAL_ADC_Start(阻塞)、HAL_ADC_Start_IT(中断)、HAL_ADC_Start_DMA(DMA)。同一个 ADC 实例,三组 API 背后的状态机其实是同一个,区别只在于「转换结束谁来通知你」。
用Start_IT时,每次转换完成会产生中断,CPU 在中断服务函数里拿到数据。适合需要每转换一次就立刻处理一次的场合,比如过零检测、快速均值滤波。DMA 方式则完全不打扰 CPU:ADC 转换完成后由 DMA 把数据搬到内存缓冲区,CPU 可以干别的事,等一批数据攒够了再集中处理。三者选型的判断标准很简单——如果读取频率高于 1kHz 或者数据要连续采集,用 DMA;如果只是按键扫描、电池电压监控这类低频任务,阻塞式足够,别把简单问题复杂化。
下面把Start_IT的最小使用方式完整展示,因为它的回调机制是 HAL 库新手最容易迷路的地方:
// 启动中断方式采集,每次转换完成自动进入回调 HAL_ADC_Start_IT(&hadc1); // 这个回调函数由 HAL 库在 ADC 中断里自动调用 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc->Instance == ADC1) { raw_value = HAL_ADC_GetValue(&hadc1); // 读取本次结果 HAL_ADC_Start_IT(&hadc1); // 重新启动下一次转换 } }注意回调结束前的那行HAL_ADC_Start_IT(&hadc1):在非连续转换模式下,一次转换完成后 ADC 会停下来,必须重新调用启动函数才能触发下一次转换。很多人第一次用回调会发现「只进一次回调,后面再也不来了」,原因就是这里漏了重启。如果你的配置里把ContinuousConvMode设为ENABLE,转换会自动连续进行,中断也会持续触发,不需要重复调用启动——但那样的话中断频率等于 ADC 转换频率,CPU 可能在中断里忙不过来,反而不如 DMA 合适。
Start_IT还有个隐患:回调函数执行期间时长不能超过一个转换周期,否则下一次转换完成产生的同优先级中断会被挂起,数据读取会延迟。所以回调里不要做浮点换算、不要打印日志、不要做延时。只把原始数值存到一个全局变量或队列里,换算放到主循环。
3.2 多通道扫描 + DMA 循环模式:缓存开多大、数据怎么对齐
多通道采集在 HAL 库里的写法和单通道有一个明显的分界点:需要把ScanConvMode设为ENABLE,并在ChannelConfig里按顺序配置每个通道的 Rank(转换顺序)。DMA 模式下数据会按 Rank 的顺序依次填入缓冲区,这是最容易出错的环节——不是数值读错了,而是数值和通道对不上。
以常见的四通道循环采样为例,配置如下:
ADC_ChannelConfTypeDef sConfig = {0}; // 配置注入通道顺序,Rank 1 先转换 sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_84CYCLES; HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = 2; HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_2; sConfig.Rank = 3; HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_3; sConfig.Rank = 4; HAL_ADC_ConfigChannel(&hadc1, &sConfig);DMA 缓冲区和启动代码:
#define ADC_CHANNEL_COUNT 4 #define ADC_BUF_SIZE 4000 // 4 个通道 × 1000 轮,需要是通道数的整数倍 uint16_t adc_buffer[ADC_BUF_SIZE]; // DMA 传输目标 void adc_dma_start(void) { HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUF_SIZE); }这里特别强调ADC_BUF_SIZE必须是通道数的整数倍,不是随便写个 1024 就完事。DMA 循环模式下,缓冲区写满一轮会触发一次传输完成中断,如果长度不是通道数的整数倍,下一轮数据会从缓冲区的中间位置开始覆盖,导致后续只能用「每 N 个数据从 index 0 开始对通道」的错位方式处理,很容易把通道 0 的数据当成通道 1。在多通道 DMA 场景里,通道数映射到缓冲区下标是固定的:index = rank - 1 + n × channel_count。每次 DMA 传输完成中断里,你从缓冲区头部开始读取一组channel_count个数据,就是一轮完整的扫描结果。
3.3 采样频率的边界:怎么算,算错了会怎样
使用 DMA 连续采样时,采样频率不是随便定的,它由 ADC 时钟和采样时间共同决定。一个典型的计算过程:假设 ADC 时钟 36MHz,12 位分辨率需要 12.5 个时钟周期的转换时间,采样时间配 84 个时钟周期,那么单次转换总周期数为12.5 + 84 = 96.5,转换频率等于36MHz / 96.5 ≈ 373kHz。这个值是多通道扫描时所有通道共享的——如果你配了 4 个通道,每个通道的实际采样率要再除以 4,约 93kHz。
算这个数不是为了好看,是决定 DMA 缓冲区和数据处理逻辑的起点。如果你的系统每 10ms 需要一组四通道数据,那采样率远高于需求,可以考虑调低 ADC 时钟分频或减少采样时间,降低功耗。反过来,如果采样需求是每通道 200kHz,四通道扫描总吞吐需要 800kHz,而当前配置只有 373kHz,那唯一出路是缩短采样时间或者降低分辨率,或者改用多个 ADC 实例并行工作——配参数之前先把这笔账算清楚,能避免后面调试时对着波形怀疑人生。
4. HAL ADC 踩坑记录:校准、采样时间与状态机玄学
4.1 忘了校准,离谱的读数偏差让你怀疑芯片是坏的
现象:新板子第一次上电,ADC 读到的电压比实测电压偏低了接近 20%,而且所有通道都一致偏低。用万用表量引脚电压明明是 2.0V,读回来只有 1.6V 左右。查了基准、查了分压电阻都没问题。
原因:部分系列的 ADC 在复位后内部偏移误差没有被修正,必须在首次使用前执行一次校准。这个步骤在图形化配置工具自动生成代码时通常是有的,但如果你手动裁剪过初始化流程或者用的是移植的工程,很容易把校准这段漏掉。校准的本质是把 ADC 内部的电容阵列失调电压测量出来并存储到校准寄存器,不做这一步,转换结果会带一个固定的偏移误差。
解决:在HAL_ADC_Init之后、第一次启动转换之前,加一段校准调用:
// ADC 初始化后、首次转换前必须校准 HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED);注意校准函数必须在 ADC 空闲状态下调用,不能在转换中途执行;校准完成后也不需要反复调用——同一颗芯片、同样的电源条件下,校准一次就够了。如果你的系统经常会从低功耗模式唤醒,建议唤醒后重新校准,因为低功耗模式可能切断了 ADC 的内部参考源,导致校准数据失效。
4.2 采样时间太短,高阻抗信号源的读数整体偏低
现象:把同一个传感器分别接到两个开发板,一个读数正常,另一个整体偏低且波动很大。代码完全一样,唯一区别是传感器引线长短和 PCB 走线宽度。
原因:ADC 内部采样电容需要在采样时间内充满电,充满速度取决于外部源阻抗和电容的乘积。源阻抗越高,需要的采样时间越长。如果把SamplingTime配成最短档位,外部源阻抗又比较高,采样电容充不满,转换结果自然偏低。这属于用量程和分辨率查不出来的软故障。
解决:把采样时间从一个极端调到另一个极端做对照实验,能最直观地确诊。假设之前用的是 1.5 周期,先换成ADC_SAMPLETIME_84CYCLES或更大值,看读数是否明显上升。如果上升幅度超过 1%,基本可以确认是采样时间不足。常见的经验值:直接测量运放输出这类低阻抗信号,用短采样时间没问题;直接测电位器、分压电阻网络、离子传感器这类高阻抗源,至少要用 84 周期以上。注意采样时间加长会降低最高转换频率,它是精度和速度的交换,不是白拿的好处。
4.3 连续转换模式下调低分频,过采样数据出现规律毛刺
现象:开启连续转换和 DMA 循环后,当把 ADC 时钟从分频 4 调成分频 2(即提高 ADC 时钟频率),采集到的波形数据出现每 N 个点重复一次的毛刺,频率恰好和 PWM 中断频率一致。
原因:ADC 时钟频率提高之后,单次转换时间变短,此时如果系统里同时存在高频开关噪声(比如 PWM 驱动、DC-DC 开关),这些噪声的频谱会落入采样带宽内,混叠后表现为规律性毛刺。调低分频前因为采样时间较长,等效于对高频噪声做了一定程度的平均,掩盖了这个问题。
解决:一是把采样时间调回去,恢复原本的低速高密室模式;二是从时域上想办法,把 ADC 采样时刻和 PWM 开关时刻错开——比如用定时器触发 ADC,把触发点放在 PWM 关断的稳定窗口。如果两种方案都不可行,就只能在软件上做中值滤波或滑动平均。这条坑的本质是:ADC 采样时钟不是越高越好,尤其当你的系统里还有其他开关噪声源时,过高的采样时钟只会让噪声混叠更严重。
4.4 HAL_ADC_PollForConversion 返回 HAL_BUSY,状态机卡死
现象:代码第一次调用HAL_ADC_Start后正常读到了数据,第二次再读时PollForConversion直接返回HAL_BUSY,而且之后再启动也始终是HAL_BUSY。重启程序又能正常读一次,周而复始。
原因:HAL 库的 ADC 状态机里,如果上一次转换没有正常结束(比如你在转换中途调用了HAL_ADC_Stop,或者某次读取超时后没有把转换序列彻底关掉),ADC 会一直停在忙状态。HAL_ADC_Start在忙状态下不会重启转换,而是返回HAL_BUSY。代码里只对HAL_ADC_Start做了!= HAL_OK判断,于是把这个错误当成了「启动失败」,没有意识到是状态机卡死。
解决:不要在PollForConversion超时之后直接进入下一次读取循环。超时之后应该先调用HAL_ADC_Stop把转换序列复位,再重新 Start。更稳妥的做法,是在每次读取完成后主动调用HAL_ADC_Stop,让状态机回到可再次启动的姿态。如果你是单通道低频轮询,这个操作的开销可以忽略;如果是 DMA 循环模式,不要随便Stop,否则 DMA 会停在一个半满状态,缓存里残留无效数据。
4.5 多通道 DMA 数据错位:缓存大小不是随便填的
现象:四通道 DMA 采集,缓冲长度设成 1000,前 1000 个数据看起来通道顺序正常,之后每 1000 个数据里就有一次通道错位——通道 0 的数据出现在通道 1 的位置,持续一小段后又自己恢复。
原因:DMA 循环模式和 ADC 的转换序列之间存在对齐问题。ADC 每完成一轮四通道扫描会产生一次 DMA 请求,DMA 把这 4 个数据搬入缓冲区。当缓冲区写满时,如果恰好写到了某个通道的中间位置(即缓冲区大小不是 4 的整数倍),DMA 从头开始覆盖时,通道和缓冲区的下标对应关系就乱了。之后每次满缓冲区中断时,你按「每 4 个一组」切分数据,通道标签从中间开始错位,直到凑巧再次对齐。
解决:缓冲区长度必须设成通道数的整数倍。实际项目里我更建议把缓冲区开成通道数的倍数乘以一个较大的轮数,比如四通道 × 250 轮 = 1000,这样每次 DMA 传输完成中断时,缓冲区边界天然落在某一轮转换的结束位置。如果你需要更高频率的实时处理,可以把 DMA 设置成半传输中断和数据传输完成中断两个回调,这样缓冲区可以做双缓冲,CPU 在 DMA 写满后半区时处理前半区,数据不会互相覆盖。
5. 精度下限与验证:三种实测手段与一处长期习惯
HAL 库不是精度问题的解药,它只是把「读 ADC」这个动作标准化了。真正决定你能从 ADC 里挖出多少有效位数的,是布线、参考源和校准这三个环节。这里分享三个我常用来验证 ADC 链路是否健康的实测手段。
第一种,短路测试。把 ADC 输入引脚直接接地,连续采样 1000 个点,理想情况下所有数值都是 0 或极少数 1。如果读到的基数偏高且波动明显,说明存在外部串扰或 PCB 漏电。再把引脚接到 VREF+,采样值应该在满量程附近,最大偏差不超过几个 LSB。这套测试不用任何精密仪器,五分钟就能定位大部分「数值漂移」问题。
第二种,分压线性度测试。用一个精密电位器(多圈可调那种,别用碳膜微调)从 VREF+ 到地搭一个分压器,中点在 ADC 引脚上。每旋转一小格记录一次读数,然后以实测电压为横轴、ADC 原始值为纵轴画散点图。理想情况下所有点应该落在一条过原点的直线上。如果中间段有弯曲,通常不是 ADC 的问题,而是电位器负载效应——这说明你的信号源阻抗太高,需要缩短采样时间或加一级运放缓冲。
第三种,校准前后对比。同一条件下读一组数据,执行一次校准,再读一组,把前后均值差记录下来。这个差值代表芯片出厂失调的真实幅度。你可以把这个数值写进配置文件的常量里,以后换芯片批次的时候重新测一次,就能通过简单补偿把失调误差压到最底。
最后说一个我在多项目里养成的习惯:ADC 相关的所有配置参数——采样时间、分辨率、时钟分频、校准状态——全部集中到一个结构体,且在每个参数的旁边加一行注释,写明这个值是拍板定的,依据是什么,以及如果调大调小会牺牲什么。因为 ADC 的参数调节不像逻辑代码那样有明确的错误与正确的分界,它更像一组旋钮:你把采样时间拧大了,噪声小了但吞吐低了;你把分辨率从 12 位降到 8 位,速度快了但精度没了。过了两个月回来看代码,如果没有注释,你根本记不住当时为什么在这组参数上停手。这也是我反复提醒团队里新人的一句话:ADC 配置没有标准答案,只有可复现的权衡。参数定下来之前,先用手段一和手段二把硬件底子验证一遍,再谈优化——否则你优化的可能是噪声本身。希望这篇笔记能帮你在 HAL 库的 ADC 链路上少走几段冤枉路。
本文还有配套的精品资源,点击获取