简介:一套面向STM32F407的HAL库单通道ADC采集与串口输出工程源码,适合嵌入式初学者及需要验证ADC外设驱动开发的读者。工程基于Keil MDK构建,通过ADC1的通道5(PA5)采集模拟电压,经12位转换后按公式换算为实际电压值,再由串口1发送至PC端串口助手显示,完整覆盖从初始化、采集、计算到通信输出的典型链路。资源共约1133个文件,压缩包约20.41MB,以592个C源码和284个头文件为核心,同时包含编译中间文件、库文件、链接脚本、uvprojx工程文件、hex烧录文件及CubeMX的ioc配置,可直接打开编译或烧录验证。各源码模块层次清晰,ADC通道参数、采样时间、数据对齐、串口波特率与转换公式等配置集中明确,兼顾校准细节,便于对照修改与二次开发。已有1487人次学习下载,适合想快速上手STM32 HAL库ADC应用或复习串口打印调试方法的开发者。
1. STM32F407 单通道 ADC 与 HAL 库:一套从采样到串口打印的最小链路
很多人的第一堂 F407 入门课是点灯,但真正让你觉得“这芯片能干活”的,往往是把这个标题里的事跑通:用 STM32F407 的 HAL 库,在单个模拟通道上采集电压,再通过串口把换算后的电压值打出来。这个闭环解决的是你以后所有传感器项目的第一公里——不管是 NTC 测温、电位器分压还是电池电压监测,干的都是同一件事:读一个 0~3.3V 的变化量,然后把它变成一个上位机能看懂的数字。适合正在学 HAL 库的入门者,也适合在做产测和调试时需要一个最小模拟量采集基线的人。把这条链路走通,后面加 DMA、加多通道扫描、加实时曲线,都只是在这个骨架上添砖。
2. HAL 库下 ADC 的关键机制:12 位分辨率、采样时间与参考电压怎么选
先把概念立住,再谈配置。很多人照着别人的工程抄了一遍,换个板子就不出数,问题就出在只复制了初始化结构体,没理解每个字段到底在限制什么。
2.1 12 位分辨率与电压换算公式
F407 内置的 ADC 是逐次逼近型(SAR),转换结果默认是 12 位无符号数,范围就是 0~4095。这个数字本身不代表电压,它代表参考电压被分成 4096 等份之后,你的输入信号落在第几份。所以换算公式只有一个:
电压值 = ADC值 × 参考电压 ÷ 4095
代码里的写法后面会见到,这里先强调两件事。第一,参考电压在绝大多数 F407 板卡上就是 VDDA,也就是模拟供电,一般量出来是 3.3V 附近,但它不一定严格等于 3.3V——这一点是很多人电压算不准的根源。第二,很多上位机协议里只传原始 ADC 值,由上位机换算,但调试阶段直接把电压值打出来,能少走很多弯路。
为什么强调“单通道”?因为 F407 的 ADC 支持多通道扫描,配置时要涉及规则序列器的下标(rank)设置;单通道就简单得多,只配置一个 rank,省掉序列器里最容易出错的部分。等你把单通道读稳了,再去看扫描模式,逻辑会顺很多。
2.2 采样周期、ADC 时钟与转换耗时的定量关系
HAL 库的初始化结构体里有一个成员叫 SamplingTime,常见取值有 3、15、28、56、84、112、144、480 周期(这里的周期指 ADC 时钟周期)。加上 12 位转换本身需要的 12 个 ADC 时钟周期,一次完整转换的总周期数是:
总周期 = 采样周期 + 12
这里的关键约束是 ADC 时钟(ADCCLK)。F407 的 ADCCLK 来自 APB2 时钟分频,上限在 36MHz 左右,而 APB2 在 168MHz 主频下默认是 84MHz,所以分频比至少要选 4,得到 21MHz 的 ADCCLK。也可以选 6 得到 14MHz,更保守一点。我一般用 21MHz,功耗和速度平衡得比较好,如果信号源内阻大,再把采样周期调长。
下面这张表是常见采样周期在 21MHz ADCCLK 下的实际耗时(不包含启动和读取的时间):
| 采样周期 | 总周期 | 约耗时(21MHz) |
|---|---|---|
| 3 | 15 | 0.71us |
| 15 | 27 | 1.29us |
| 84 | 96 | 4.57us |
| 480 | 492 | 23.4us |
注意一个高频误区:采样周期不是采样率。采样率还取决于你多长时间发起一次转换,程序里 HAL_Delay(500) 一拖,实际采样率就只有 2Hz 了。采样周期只决定一次转换内部“充电 + 转换”的时间,它影响的是精度,不是频率。
那采样时间到底怎么选?我的经验是:信号源阻抗低(比如运放输出、稳压电源直接分压)用 3~15 周期即可;信号源阻抗高(比如 10k 电位器、湿敏电阻)用 84 周期起步,否则 ADC 内部的采样电容充不满,读数会偏低且跳动。这个现象第五节还会遇到。
2.3 右对齐、参考电压与单次/连续转换模式
再补三个配置前必须理解的项。
数据对齐。F407 的数据寄存器是 16 位宽的,ADC 结果只有 12 位,所以结果可以左对齐或右对齐。我们永远用右对齐(ADC_ALIGN_RIGHT),这样拿到的整数直接就是 0~4095,运算最自然。左对齐只在某些 DMA 场景为了让数据“看起来像 16 位”才用,日常调试没有意义。
参考电压。前面说了参考电压通常等于 VDDA,量程就是 0~VDDA。如果板子上有外部参考电压芯片接入 VREF 引脚,那么算电压时要用那个外部参考值,而不是想当然 3.3V。
转换模式。HAL 里启动转换用 HAL_ADC_Start,配合单次转换模式,每转换完一次就停住,下次需要再 Start 一次;如果配置成连续转换模式,转换完会自动开始下一次,你只需要在循环里 Poll 或读值。单通道调试时建议先用单次转换,把流程走通后再切连续。
提示:理解 ADCCLK 上限这件事很关键。有人把 APB2 分频比选成 2,得到 42MHz 的 ADCCLK,超出了 F407 的 ADC 时钟规格,可能出现转换结果异常偏大或偶发跳变,且这种问题靠调试器很难看出来。
3. CubeMX 配置与工程生成:引脚、时钟树与串口的参数逐个设对
很多人喜欢跳过配置工具直接手写寄存器,但 HAL 库项目的主流工作流是用 ST 的配置工具生成初始化代码,再往里填业务逻辑。这一步省不掉,而且配置错了,后面代码写得再漂亮都是白搭。
3.1 引脚与时钟树:先让芯片按 168MHz 跑起来
打开配置工具,选对应型号,第一步不是动 ADC,而是先确认 RCC 和时钟树。大多数 F407 板子的外部晶振是 8MHz,在 RCC 里把 HSE 设为 Crystal/Ceramic Resonator,然后在 Clock Configuration 页面拉出 168MHz 的系统时钟。这时软件会自动算好各级分频:AHB 168MHz、APB1 42MHz、APB2 84MHz。
为什么先调这个?因为 ADC 和 USART 都挂在 APB 总线上,APB 时钟错了,后面 ADC 的 ADCCLK 和串口波特率一起错。特别是 APB1 与 APB2 上限不同,APB1 不能超过 42MHz,APB2 不能超过 84MHz,改分频比的时候留意别超。调完时钟树保存一次,再进外设配置。
引脚分配在 Pinout 视图里做。以 ADC1 的通道 1 为例:点击 PA1,把它设为 ADC1_IN1(ADC Input 1)。同一个引脚只能是模拟输入,别同时把 PA1 复用成其他外设。单通道模式下,只需要配这一个模拟输入引脚,不需要管其他通道。
3.2 ADC 与 USART 的参数表:按这个初始值来,少走弯路
进入 ADC1 配置页面,勾选 IN1 通道,右侧参数按下面这张表设置。这张表的取值几乎适用于所有初次调通的场景,等读值稳定后再按实际需求调整:
| 参数项 | 建议初始值 | 理由 |
|---|---|---|
| Resolution | 12-bit | F407 默认分辨率,位数越高越精细 |
| Clock Prescaler | 4(21MHz) | 满足 ADCCLK 上限,速度与稳定性兼顾 |
| Sampling Time | 15 Cycles | 通用值,信号源阻抗高时改大 |
| Scan Conversion | Disabled | 单通道不需要扫描 |
| Continuous Conversion | Disabled | 先用单次转换把流程走通 |
| Data Alignment | Right alignment | 计算电压最直接 |
| Number Of Conversion | 1 | 只有一个 rank |
USART1 配置同样在这里完成:模式选 Asynchronous,波特率 115200,数据位 8,无校验,1 个停止位,收发都打开。PA9/PA10 会被自动分配成 USART1 的 TX/RX,如果你用的板子串口在别的引脚,按原理图改,不要硬套默认分配。
注意:波特率 115200 不是必须的,9600 更稳,但 115200 在 F407 上毫无压力。真正要注意的是改用外部高速晶振后,USART 的时钟源就依赖 PLL 输出;如果时钟树配置乱了,波特率会系统性偏移,表现就是上位机能收到乱码。
3.3 生成代码前要确认的三个选项
在 Project Manager 页面里,有三处影响后续编码效率,我每次都会核对。
第一,代码生成方式。选中“按外设生成独立 .c/.h 文件”,这样 ADC 和 USART 的初始化代码会分别放进 adc.c、usart.c,而不是全部堆在 main.c 里。第二,勾选 MicroLIB(仅限 Keil MDK 环境)。这个库体积小,而且 printf 浮点输出不至于把你的 flash 撑爆。第三,工具链类型要和你的 IDE 对应。选错了,生成的启动文件和链接脚本都会对不上。
生成工程后,先编译一次,确认零错误再开始写业务代码。不要急着改 main.c,先打开 adc.c 里 MX_ADC1_Init 的初始化结构体,对照上一节的参数表看看是不是和你的配置一致。这一步能排查掉一半的“我明明配了怎么没生效”问题。
最后补一个很多人遇过的坑:从配置工具重新生成代码时,它会覆盖 main.c 里被标记的生成区,你在生成区外手写的自定义函数通常能保留,但在 adc.c 或 usart.c 初始化函数里私自改掉的参数会被重置。所以初始化参数尽量在工具里改,不要在生成的 .c 文件里改;业务代码放到生成区外面或独立文件。
4. 最小可运行示例:从 ADC 取数到 printf 打印电压值
代码阶段我习惯按三步走:先写一个能拿原始值的辅助函数,再解决 printf 重定向,最后在主循环里组起来。
4.1 先写一个不依赖主流程的 ADC 单次读取函数
在 main.c 里加一个函数,或者单独建一个 adc_util.c 都行。下面是阻塞式单次转换的写法:
/* 单次读取一次 ADC,成功返回 0~4095,超时返回 0xFFFFFFFF */ uint32_t ADC_ReadSingle(ADC_HandleTypeDef *hadc) { if (HAL_ADC_Start(hadc) != HAL_OK) /* 启动一次转换 */ { return 0xFFFFFFFF; } if (HAL_ADC_PollForConversion(hadc, 100) != HAL_OK) /* 等转换完成 */ { return 0xFFFFFFFF; } return HAL_ADC_GetValue(hadc); /* 读取数据寄存器 */ }逻辑其实就三步:Start 启动转换,PollForConversion 死等标志位,GetValue 把结果取回来。参数说明:hadc1 是 CubeMX 生成的 ADC 句柄,函数里接受指针,所以这个辅助函数以后也能用在 ADC2、ADC3 上。PollForConversion 的第二个参数是超时毫秒数,100 已经非常充裕,正常一次转换不到 1ms;如果超时多半是别的问题,而不是转换本身慢。
这里有一个 HAL 库细节:单次转换模式下,每次转换完成,ADC 就回到空闲状态,下一次读值前必须再调一次 HAL_ADC_Start。上面函数把 Start 放在里面,正好适配这个模式。如果你想改成连续转换,就把 Start 挪到主循环外,主循环里只做 Poll 和 GetValue。
4.2 把 printf 重定向到串口:分工具链处理
HAL_UART_Transmit 是串口发送的底层接口,但业务代码里我们更想用 printf。重定向方式取决于编译器。
/* Keil MDK / ARMCC 环境下:重定向 fputc 到 USART1 */ #include "stdio.h" int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }/* STM32CubeIDE / GCC 环境下:重定向 _write 到 USART1 */ #include "stdio.h" int _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 0xFFFF); return len; }注意几个点。第一,huart1 是 USART1 的句柄,如果你的串口是 USART2,就改成 huart2。第二,GCC 环境下只写 fputc 是不保证生效的,printf 底层最终调用的是 _write,这也是为什么很多人换到 STM32CubeIDE 后 printf 就不输出了。第三,0xFFFF 是发送超时,这里给一个足够大的值,避免高波特率下偶尔被中断打断。
如果你不想依赖 printf,也可以用 HAL_UART_Transmit 直接发字符串,这在中断里更安全,调试时也更可控。重定向的意义在于格式化方便:打印浮点、拼接多字段,都是 printf 一行的事。
4.3 主循环组装:原始值、电压值与打印输出
上电时做一次 ADC 校准,然后进主循环,这是完整的模板:
/* main() 中 while(1) 之前 */ HAL_ADCEx_Calibration_Start(&hadc1); /* 上电后校准一次,减小偏移误差 */ uint32_t raw; float volt; /* 主循环 */ while (1) { raw = ADC_ReadSingle(&hadc1); if (raw != 0xFFFFFFFF) /* 转换成功 */ { volt = (float)raw * 3.3f / 4095.0f; /* 电压 = ADC值 * 参考电压 / 4095 */ printf("RAW:%lu VOLT:%.3fV\r\n", raw, volt); } HAL_Delay(500); /* 0.5s 刷一次,方便肉眼观察 */ }HAL_ADCEx_Calibration_Start 是 F407 的 HAL 库特有的校准接口,上电后调用一次,ADC 内部会校准偏移误差。注意它应该在 HAL_ADC_Start 之前调用,所以放在主循环前面。
volt 的计算里 3.3f 是参考电压,这里先按标准值写。如果你的板子 VDDA 实测不是 3.3,比如万用表量到 3.28,就把 3.3f 换成 3.28f,电压显示会更准。这也是第六节校准方法的起点。打印格式里 RAW 和 VOLT 都带了标签,上位机上看到的每一行都能直接定位,比只打一个裸数字好维护得多。
5. 避坑与排查:5 个让单通道 ADC 采集翻车的高频问题
这一段是把经验里最常重复出现的问题按“现象 → 原因 → 解决”整理出来。如果你按前面的配置走完还是不对,先来这里对号入座。
5.1 采集值稳定在 4095 或 0
现象:串口打印的 RAW 值永远是 4095 或 0,换输入电压也不变。
原因:采集值恒为 4095,多数是 ADC 输入引脚悬空,内部等效电路把它拉到了接近参考电压的位置;采集值恒为 0,多数是引脚被接死到地,或者引脚根本没有被配置成模拟输入。还有一种是 CubeMX 里 PA1 没勾选 Analog Mode,GPIO 模式停留在 GPIO_OUTPUT 或 AF 复用,ADC 压根读不到外部电平。
解决:先检查 CubeMX 引脚视图,确认 PA1 是 ADC1_IN1 且引脚类型是 Analog;再用万用表量 PA1 对地电压,确认外部信号确实到达引脚了。如果悬空是常态,就在引脚和 GND 之间接一个 10k 下拉电阻,让未连接时稳定为 0,而不是随机漂。
5.2 同一个电压,读数跳动超过几十个 LSB
现象:输入电压用万用表量很稳,但串口打印的原始值在 ±50 甚至 ±200 之间跳,换算成电压就是几十毫伏的抖动。
原因:这条基本是采样时间和源阻抗不匹配。前文说过,ADC 内部采样电容需要时间充电,源阻抗越大,需要的时间越长。用 3 周期采样时间去采一个 10k 电位器分压输出,读数必然跳。另外,如果信号线较长又没有滤波电容,环境噪声会直接叠加进采样值。
解决:把采样时间调到 112 或 480 周期,再在 ADC 引脚对地并联一个 100nF 的电容。如果还跳,软件侧做 8 次或 16 次平均,放到第六节的正确用法里。硬件滤波和软件滤波一起上,读数能从 ±100 压到 ±5。
5.3 串口完全无输出,或者是一屏乱码
现象:程序烧进去,上位机串口助手什么都没收到;或者收到了但全是看不懂的乱码。
原因:无输出先确认 TX/RX 有没有接反,以及上位机选的串口号是不是 USB 转串口模块映射出来的那个。乱码的大头是波特率不匹配,而波特率不匹配往往是时钟树改了 PLL,USART 时钟也跟着变了,但代码里波特率分频系数没重新生成。还有一个很低级的坑:模块和板子没有共地,电平参考不同,数据全乱。
解决:先用最基础的 HAL_UART_Transmit 发一个固定字符串,比如在 main 里直接发 “OK\r\n”,上位机能收到说明链路是通的,再排查 printf 重定向。乱码方面,检查时钟树里 PCLK1/PCLK2 的数值,确保 APB1 42MHz、APB2 84MHz;串口模块 RX 接板子 TX、GND 接 GND,别接反。实在不行把波特率降到 9600 再验证一次,9600 对线材容错更高。
5.4 printf 用 %f 打印浮点,输出却是 0.000
现象:代码里写的 printf("VOLT:%.3f", volt),串口助手收到的全是 0.000,或者干脆没有数据;整数打印正常。
原因:这是工具链的浮点 printf 支持问题。ARMCC 环境下如果没有启用 MicroLIB,printf 的浮点支持可能没有被打进链接;GCC 环境下,默认 newlib-nano 不带浮点 printf,需要额外加链接参数 -u _printf_float。很多人换了 IDE 之后第一次遇到,误以为自己 ADC 读出来就是 0。
解决:Keil 项目里勾选 MicroLIB 并重新编译;STM32CubeIDE 里在链接器选项中添加 -u _printf_float。如果项目有体积限制不想引入浮点 printf,就把电压直接转成整数:unsigned int mv = raw * 3300 / 4095,打印 mv 而不是浮点数。这个办法最省事,也完全够看。
5.5 PollForConversion 一直返回 HAL_TIMEOUT
现象:进入主循环后,ADC_ReadSingle 每次返回 0xFFFFFFFF,串口打印的 RAW 全是 4294967295。
原因:最常见的是时机问题——在单次转换模式下,上一轮转换还在进行时你又调了一次 HAL_ADC_Start,HAL 库的状态机判定“忙”,这次 Start 被拒绝;或者你改了连续转换模式但主循环里没有去掉重复的 Start。另一个可能是 ADC 时钟没起来,比如 CubeMX 里 ADC1 没有被勾选,MspInit 里根本没有使能 ADC1 时钟。
解决:确认 CubeMX 里 ADC1 至少勾选了一个通道,这样生成的 HAL_ADC_MspInit 才会有时钟使能和 GPIO 初始化。单次模式下,严格按 4.1 的代码来:每一轮 Start 一次,Poll 一次,GetValue 一次。连续模式下,Start 只在初始化时执行一次,循环里只保留 Poll 和 GetValue。区分这两种模式,能解决九成以上的 HAL_TIMEOUT。
6. 进阶:从单通道起步,把滤波、校准和打印格式一次收口
单通道能稳定出数只是第一步,真正投入实际项目前,还有三个小改动能省掉你后面大量的返工。
第一个是软件滤波。最实用的不是复杂算法,而是滑动平均:维护一个长度为 8 或 16 的环形缓冲,每次取平均值。它不会像中值滤波那样牺牲响应速度,也能把随机噪声压得很低。注意滤波长度不要超过你对响应时间的要求,比如 500ms 刷一次屏就不适合开 64 点滑动平均,否则电压变化要几十秒才能反映出来。
第二个是参考电压校准。这个技巧能不能用取决于板子上有没有基准源,没有的话就用芯片内部的参考电压通道(比如 ADC1 的 VREFINT 通道)。方法很简单:先采集内部参考电压对应的 ADC 值 vref,再按 真实VDDA = 1.2f * 4095.0f / vref 算出真实 VDDA,把 3.3f 换成这个值。1.2V 是芯片内部的近似参考,精度有限,但对付电池供电这种“电压本来就漂”的场景绰绰有余。
第三个是给打印格式留后路。我一般会在 printf 里同时打印 RAW 和 VOLT,而不是只打电压,理由很简单:上位机除了看电压,还可能需要原始值做线性化修正。另外结尾统一用 \r\n,这样在各类串口工具里换行不串位,以后接上位机解析也少一个坑。
最后分享一个习惯:我调 ADC 从来不信板子标称的 3.3V,上电第一件事是拿万用表量 VDDA 引脚,把真实值写进代码常量。很多“电压为什么偏低”的问题,最后都落在参考电压这个变量上。读模拟量这事,玄学往往只是参数没较真。希望帮到你。
本文还有配套的精品资源,点击获取