☰
STM32F407 HAL库单通道ADC采集与串口打印实战详解
2026/10/20 19:13:04 网站建设 项目流程

简介:一套面向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)
3150.71us
15271.29us
84964.57us
48049223.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 通道,右侧参数按下面这张表设置。这张表的取值几乎适用于所有初次调通的场景,等读值稳定后再按实际需求调整:

参数项建议初始值理由
Resolution12-bitF407 默认分辨率,位数越高越精细
Clock Prescaler4(21MHz)满足 ADCCLK 上限,速度与稳定性兼顾
Sampling Time15 Cycles通用值,信号源阻抗高时改大
Scan ConversionDisabled单通道不需要扫描
Continuous ConversionDisabled先用单次转换把流程走通
Data AlignmentRight alignment计算电压最直接
Number Of Conversion1只有一个 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 引脚,把真实值写进代码常量。很多“电压为什么偏低”的问题,最后都落在参考电压这个变量上。读模拟量这事,玄学往往只是参数没较真。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询