简介:围绕STM32G431RBT6的ADC直接读取与DMA搬运这两种采集方式,这套工程资料面向已具备HAL库基础、希望打通模拟信号采集链路的嵌入式学习者与电子竞赛备赛者。内容覆盖ADC1、ADC2的多通道配置、采样时间与分辨率设定,以及转换完成触发DMA请求、缓冲区地址映射与中断配合,再到采样率与传输长度的性能调优,并配有可直接编译运行的完整工程实例,便于对照CubeMX配置与HAL驱动源码,理解寄存器层面的工作过程。压缩包共1254个文件,以c源文件和h头文件为主体,配合汇编启动文件s、Keil与IAR的工程配置(uvprojx、icf、sct),以及hex、axf等可烧录产物和map、lst等编译输出,整体约29.73MB,目录层次与标准工程一致,便于按外设模块检索。已有1978人学习下载,适合作为传感器信号采集、电源监测等实时数据采集原型的参考基线。
1. 一块 G431 板子上的 ADC 读数,为什么“直接读”够用而“多通道”就必须换 DMA
去年帮人调一块 STM32G431RBT6 的小板子,现象很怪:电池电压那条通道,串口打出来的码值每隔几秒就跳两百多个 LSB,换了稳压源、加了滤波电容,照样跳。最后发现问题根本不在模拟前端,而是代码里HAL_ADC_Start之后只读了一次就把值当结果用了,采样时刻刚好撞上电机 PWM 的开关沿。这件事让我后来做任何 stm32 的 ADC 采集,都会先问一句:这个通道是“慢变量偶尔读一次”,还是“要连续盯着一组通道看波形”。
这份资源解决的正是这个分岔口。它围绕 STM32G431RBT6 的 ADC1/ADC2,给出两条完整的采集链路:单通道直接采集(轮询或中断,代码短、调试直观)和多通道 DMA 扫描采集(循环缓冲,CPU 不参与搬运)。适合刚上手 G4 系列、被 CubeMX 里一堆 DMA 选项绕晕的人,也适合已经能跑但读数不稳、想搞清楚采样时间和校准到底影响什么的人。
2. 工程骨架与技术选型:两条采集链路怎么摆在一个工程里
2.1 目录结构:CubeMX 生成的初始化代码和业务采集代码要分家
我见过太多 G4 工程,把所有东西都塞进main.c,CubeMX 重新生成一次,手写的采集逻辑全没了。这份资源的目录划分很克制:CubeMX 管的只让它管初始化,采集逻辑单独成文件,.ioc之外的手写代码一律不放进生成路径。
G431_ADC_Demo/ ├── Core/ │ ├── Inc/ │ │ ├── adc_direct.h # 直接采集对外接口 │ │ ├── adc_dma.h # DMA 扫描采集对外接口 │ │ └── adc_filter.h # 滤波与码值换算 │ └── Src/ │ ├── main.c # 只做调度:初始化 + 主循环取数 │ ├── adc.c # CubeMX 生成,只放 MX_ADC1_Init │ ├── dma.c # CubeMX 生成 │ ├── adc_direct.c # 轮询 / 中断两种实现 │ ├── adc_dma.c # DMA 启动、半传输回调、缓冲切换 │ └── adc_filter.c # 滑动平均、一阶低通 ├── Drivers/ │ ├── STM32G4xx_HAL_Driver/ │ └── CMSIS/ ├── G431_ADC_Demo.ioc # CubeMX 工程文件 └── MDK-ARM/ # Keil 工程(用 CubeIDE 的话这里是 STM32CubeIDE/)这里的关键是adc.c里只有MX_ADC1_Init(),任何HAL_ADC_ConfigChannel的二次调用都写在adc_direct.c/adc_dma.c里。因为 CubeMX 重新生成时会覆写adc.c,而adc_direct.c属于“用户代码”,不会被碰。这个习惯看着小,实际能省掉大量“改了个引脚,采集逻辑全丢”的血泪经验。
技术栈上没什么花活:STM32CubeMX 做时钟和外设初始化,HAL 库(STM32G4xx HAL Driver)做驱动,Keil MDK 或 STM32CubeIDE 编译,串口用printf重定向到 USART2 打印码值。G431RBT6 是 Cortex-M4F 内核,带 FPU,所以后面码值转电压直接写浮点也不用担心性能,但要注意中断回调里做浮点运算仍然要控制频率。
2.2 时钟树与 ADC 时钟:42.5 MHz 这个数字怎么算出来的
G4 的 ADC 有个很多人第一次踩的坑:它的时钟不是自动安全的。芯片主频能跑到 170 MHz,但 ADC 的输入时钟上限是 60 MHz,中间隔着 RCC 的 ADC12 时钟源选择和 CKMODE 分频两级。
这份工程的配置是:HSI16 作为 PLL 源,PLLM = 4 得到 4 MHz,PLLN = 85 得到 340 MHz 的 VCO,PLLR = 2 得到 SYSCLK = 170 MHz;ADC12 时钟源选 PLLP(同样 170 MHz),CKMODE 设成 4 分频,最终 ADC 时钟 = 42.5 MHz。这个值既没超限,又保证了转换速度。
转换时间怎么估:12 位分辨率下,逐次逼近本身要 12.5 个 ADC 周期,加上采样时间,一次转换的总周期数是两者之和。采样时间选 47.5 周期时,总共 60 个周期,42.5 MHz 下约 1.41 µs,也就是单通道理论最高约 700 ksps。这个数字决定了你后面 DMA 缓冲开多大、多久处理一次——如果想 10 kHz 采三个通道,1.41 µs 完全够,甚至有点浪费,可以把采样时间压到 12.5 周期换更高吞吐。
提示:CubeMX 的时钟树页面会直接标红超频项,但 ADC 那一路的分频经常被折叠在 Clock Configuration 的右侧,容易只盯着 SYSCLK 看。
2.3 直接采集还是 DMA:用一张表定方案
选型不用纠结,看三个条件就够了:通道数、采样率、以及采集期间 CPU 有没有别的事干。
| 判断维度 | 直接采集(轮询/中断) | DMA 扫描采集 |
|---|---|---|
| 通道数 | 1~2 个 | 2 个以上,需要同一时刻的快照 |
| 采样率 | 几十 Hz 到几 kHz,随用随读 | 连续、固定节拍,几 kHz 以上 |
| CPU 占用 | 轮询期间阻塞,中断方式占用小但频繁 | 只在半传输/传输完成时进一次回调 |
| 代码复杂度 | 低,一个函数能跑通 | 中,要管缓冲、回调、数据一致性 |
| 典型场景 | 电池电压、电位器、按键模拟量 | 三相电流、电机 FOC、多路传感器巡检 |
我的建议是:先用直接采集把通道和硬件验证通,确认引脚、参考电压、采样时间都对,再切到 DMA。反过来做的话,一旦读数不对,你很难判断是 DMA 配置错了还是模拟前端本来就有问题,等于给自己造了个黑匣子。
3. 直接采集链路拆解:轮询、中断、校准三步落地
3.1 上电第一件事:G4 的 ADC 校准不能省
G4 系列的 ADC 内部有偏移误差,出厂没校准过的芯片,零点可能偏十几个 LSB。HAL 里对应的是HAL_ADCEx_Calibration_Start,必须在 ADC 使能之后、任何一次转换之前调用。
/* adc_direct.c —— ADC 初始化:校准只做一次,放在上电流程里 */ #include "adc_direct.h" extern ADC_HandleTypeDef hadc1; void ADC_Direct_Init(void) { /* G4 必须做校准,单端模式下传 ADC_SINGLE_ENDED */ if (HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED) != HAL_OK) { Error_Handler(); /* 校准失败说明时钟或供电没起来,别硬跑 */ } }这个函数内部会跑一套自校准流程,耗时大概几十微秒,返回值必须判。我遇到过供电还没稳就调校准,返回HAL_ERROR,后面所有读数整体偏低 30 多个 LSB,查了半天以为是分压电阻焊错了。校准失败的常见原因是 VDDA 没到位或者 ADC 时钟没配,别急着怀疑代码逻辑。
还要注意:校准结果跟当时的采样时间、单端/差分模式是绑定的。如果你后面在运行期改了SingleDiff或者在初始化之后又调了一次HAL_ADC_ConfigChannel改动采样时间,校准的有效性就打折扣了,正确的顺序永远是“时钟 → ADC init → 校准 → 配通道 → 启动”。
3.2 单通道轮询读数的完整函数
轮询是最朴素的写法,适合调试和低频读取。这份实现里加了两个细节:通道配置每次重配(支持运行期切通道),以及超时后必须HAL_ADC_Stop。
/* adc_direct.c —— 单通道轮询读取,返回原始码值 */ uint16_t ADC_Read_Raw(uint32_t channel) { ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = channel; /* 如 ADC_CHANNEL_1 */ sConfig.Rank = ADC_REGULAR_RANK_1; /* 单通道固定 Rank1 */ sConfig.SamplingTime = ADC_SAMPLETIME_47CYCLES_5; /* 高阻抗源要加长 */ sConfig.SingleDiff = ADC_SINGLE_ENDED; sConfig.OffsetNumber = ADC_OFFSET_NONE; sConfig.Offset = 0; if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) { return 0xFFFF; /* 0xFFFF 作错误哨兵 */ } HAL_ADC_Start(&hadc1); /* 软件触发一次转换 */ if (HAL_ADC_PollForConversion(&hadc1, 2) != HAL_OK) { HAL_ADC_Stop(&hadc1); /* 超时不停,状态机会变脏 */ return 0xFFFF; } return (uint16_t)HAL_ADC_GetValue(&hadc1); }逐段说一下。SamplingTime是核心参数,47.5 个周期在 42.5 MHz 下约 1.1 µs 的采样窗口,对应源阻抗可以到几十 kΩ 量级;如果信号源是 100 kΩ 以上的分压电阻,这个值还不够,要往上加。PollForConversion的第二个参数是超时毫秒数,给 2 ms 已经很宽松,正常 1.4 µs 就完成了。超时返回后一定要HAL_ADC_Stop,否则 ADC 内部状态机停在“转换中”,下一次HAL_ADC_Start会直接返回HAL_BUSY,然后你会看到一个永远读不出数的 ADC。
0xFFFF这个哨兵值是我个人的习惯,12 位 ADC 最大 4095,不可能出现 65535,调用方判一下就能区分“真读数”和“读失败”。你也可以用返回HAL_StatusTypeDef+ 指针出参的方式,看团队习惯。
3.3 中断方式:把等待挪到回调里
轮询的缺点是 CPU 干等 1.4 µs,看着短,但主循环里如果每毫秒读 8 个通道,累积起来也是可观的开销。中断方式把等待挪进回调,启动和取数是分开的。
/* adc_direct.c —— 中断方式:启动后立即返回,结果在回调里取 */ static volatile uint16_t g_adc_irq_val; static volatile uint8_t g_adc_irq_flag; void ADC_Start_IT(uint32_t channel) { ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = channel; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_47CYCLES_5; sConfig.SingleDiff = ADC_SINGLE_ENDED; sConfig.OffsetNumber = ADC_OFFSET_NONE; sConfig.Offset = 0; HAL_ADC_ConfigChannel(&hadc1, &sConfig); g_adc_irq_flag = 0; HAL_ADC_Start_IT(&hadc1); /* 转换完成会进 HAL_ADC_ConvCpltCallback */ } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { g_adc_irq_val = (uint16_t)HAL_ADC_GetValue(hadc); g_adc_irq_flag = 1; /* 只置标志,别在这里打印或做浮点 */ } }回调里只做两件事:取值、置标志。串口打印、滤波、换算统统放到主循环。原因是中断回调运行在中断上下文,printf重定向到串口是阻塞发送,一次几十微秒到几毫秒,直接把中断频率卡死,还会引起主循环里其他中断的抖动。这是新手最容易犯的错,串口一打印,采样节奏全乱,然后怀疑是 ADC 不稳。
volatile两个变量都不能省,否则编译器优化后主循环里读到的永远是初始值,你会看到一个“中断进了但变量没变”的玄学现象。
3.4 采样时间与码值换算:4095 还是 4096
读数拿到手是 0~4095 的整数,换算成电压的时候分母写 4095 还是 4096,争论一直有。从原理上说,12 位 ADC 的输出码是Vin/Vref × 4096向下取整,最大值 4095 对应的是“满量程减一个 LSB”,所以严格换算用 4096 更贴近传递函数;但从“0~4095 均匀铺满 0~Vref”的实用角度,用 4095 让满量程刚好落在 Vref。这份工程用 4095,因为它直观,误差在 0.024% 量级,做工程够用。
/* adc_filter.c —— 码值转电压,并顺手做一次 VDDA 修正 */ float ADC_Raw_To_Volt(uint16_t raw) { return (float)raw * ADC_VREF / 4095.0f; /* ADC_VREF 默认 3.3f */ } /* 用内部参考通道反推实际 VDDA,电池供电时很有用 */ float ADC_GetVDDA(void) { uint16_t raw = ADC_Read_Raw(ADC_CHANNEL_VREFINT); /* 需要长采样时间 */ if (raw == 0 || raw == 0xFFFF) { return 3.3f; /* 读失败退回默认值 */ } return 3.0f * (float)(*VREFINT_CAL_ADDR) / (float)raw; }ADC_GetVDDA的思路是:内部参考电压通道的绝对电压是固定的(典型 1.212 V),但 ADC 是用 VDDA 当基准去量化它,所以 VDDA 变了,读到的码值会反向变化。出厂校准值VREFINT_CAL_ADDR是在 VDDA = 3.0 V 条件下测的,用它除以当前码值再乘 3.0 V,就能反推出现在的 VDDA。
这里有两个必须注意的点:一是 VREFINT 通道的采样时间要足够长,手册要求最小采样时间通常是微秒级,用 2.5 周期会读到明显偏小的值,我一般直接给 640.5 周期;二是VREFINT_CAL_ADDR的地址跟具体型号相关,移植到别的 G4 型号时要去手册的“出厂校准值”章节核对,别直接抄。
如果板子上 VREF+ 引脚外挂了基准芯片(比如 2.5 V 基准),那换算就用那个值,别再用 VDDA,这时候读数稳定性会明显好一截——G4 内部也有 VREFBUF,可以输出 2.048/2.5/2.9 V 给 VREF+,但驱动能力只有几毫安,带外部负载要加运放缓冲。
4. DMA 采集链路拆解:多通道扫描 + 循环缓冲
4.1 CubeMX 里那几个必须改的勾选项
DMA 采集跑不起来,九成是 CubeMX 里几个勾没勾对。这份工程采集三路:ADC_CHANNEL_1(外部电压)、ADC_CHANNEL_VREFINT(内部参考)、ADC_CHANNEL_TEMPSENSOR(内部温度),配置差异如下。
| 配置项 | 直接采集 | DMA 扫描采集 | 说明 |
|---|---|---|---|
| Scan Conversion Mode | Disable | Enable | 多通道必须开,否则只转 Rank1 |
| Continuous Conversion Mode | Disable | Enable | 不开就只转一轮,DMA 也只搬一轮 |
| DMA Continuous Requests | — | Enable | 让 ADC 持续发 DMA 请求 |
| Number Of Conversion | 1 | 3 | 必须等于 Rank 数量 |
| Overrun | Overrun preserved | Overrun overwritten | DMA 模式下必须覆盖,否则溢出即停 |
| External Trigger | Software Start | Software Start 或 TIM 触发 | 要固定采样率就用定时器触发 |
Overrun这一项特别容易被忽略。默认是preserved,意思是转换溢出时保留旧数据、ADC 停止接受新转换,结果就是你的 DMA 缓冲里前几个数是对的,后面全是陈旧值,而且没有任何报错。改成overwritten之后,新数据直接覆盖旧数据,虽然会丢样本,但采集链路不会卡死,配合循环 DMA 才是正确姿势。
另外 G4 有 DMAMUX,DMA 请求源和 DMA 通道是解耦的,CubeMX 里看到的“DMA1 Channel1 / Channel2”只是通道号,真正映射到哪个请求由 DMAMUX 配。这意味着你改 ADC 实例(比如从 ADC1 换到 ADC2)时,DMA 请求要跟着在 DMAMUX 里改,别只改 ADC 那一页。
4.2 启动、半传输与缓冲切换
DMA 采集的核心是“缓冲怎么切”。这份实现用长度 = 通道数 × 2 的缓冲,配合半传输和传输完成两个回调,等于双缓冲:前半段在处理时,后半段在采集,天然避免了读写冲突。
/* adc_dma.c —— 循环 DMA + 双缓冲 */ #include "adc_dma.h" #define ADC_CH_NUM 3 #define ADC_SEG_NUM 2 /* 前后两段,双缓冲 */ extern ADC_HandleTypeDef hadc1; /* 必须是全局或静态,DMA 直接按地址搬,不能是栈上局部数组 */ static uint16_t g_adc_buf[ADC_CH_NUM * ADC_SEG_NUM]; static volatile uint8_t g_seg_ready; /* bit0: 前半段就绪, bit1: 后半段 */ void ADC_DMA_Start(void) { HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED); /* 长度单位是“半字数”,12 位结果右对齐,每个样本占 16 bit */ HAL_ADC_Start_DMA(&hadc1, (uint32_t *)g_adc_buf, ADC_CH_NUM * ADC_SEG_NUM); } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { g_seg_ready |= 0x01; /* 前半段可读,只置标志 */ } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { g_seg_ready |= 0x02; /* 后半段可读 */ } } /* 主循环里调用:按段取数,取完清标志 */ const uint16_t *ADC_GetSegment(uint8_t seg) { uint8_t mask = (seg == 0) ? 0x01 : 0x02; if ((g_seg_ready & mask) == 0) { return 0; /* 该段还没采满 */ } g_seg_ready &= (uint8_t)~mask; return &g_adc_buf[seg * ADC_CH_NUM]; }HAL_ADC_Start_DMA的第三个参数是传输长度,单位是“数据项个数”,不是字节数。因为 ADC 的 DR 寄存器是 16 位、DMA 外设数据宽度配成半字,所以长度写 6 就是搬 6 个半字。如果你把 DMA 数据宽度配成字(32 位),长度含义就变了,而且 ADC 只给 16 位有效数据,剩余位是脏的——这类问题查起来非常费劲,因为数值看着“像那么回事”,只是高位随机。
回调里同样只置标志。g_adc_buf必须是静态或全局,DMA 是按物理地址搬运的,放在栈上函数返回后地址就失效了,表现是数据写飞、偶尔进 HardFault。
按段取数的设计还有一个好处:段索引和通道索引是解耦的。ADC_GetSegment(0)返回的是buf[0..2],正好对应 Rank1/2/3 三个通道,顺序由 CubeMX 里的 Rank 顺序决定,跟缓冲布局无关。这条对应关系要写进头文件注释里,否则三个月后没人记得buf[1]到底是参考电压还是温度。
4.3 滤波:滑动平均 + 一阶低通怎么选
DMA 拿到的是裸数据,抖动依然存在——12 位 ADC 的有效位数通常在 10~11 位,末两位本来就是噪声。滤波不是可选项,是必需项。
/* adc_filter.c —— 两种滤波,按信号特性选 */ #define MOV_WIN 8 /* 滑动平均:适合慢变量,比如电池电压、温度 */ uint16_t ADC_MovAvg(uint16_t raw) { static uint16_t win[MOV_WIN]; static uint8_t idx, cnt; uint32_t sum = 0; win[idx] = raw; idx = (uint8_t)((idx + 1) % MOV_WIN); if (cnt < MOV_WIN) cnt++; for (uint8_t i = 0; i < cnt; i++) { sum += win[i]; } return (uint16_t)(sum / cnt); /* 前几个样本按实际个数平均 */ } /* 一阶低通:适合要跟得上变化的量,alpha 越小越平滑但越滞后 */ uint16_t ADC_LowPass(uint16_t raw) { static uint32_t y = 0; /* 定点,避免回调里跑浮点 */ const uint8_t shift = 3; /* 等价 alpha = 1/8 */ if (y == 0) y = raw; /* 首次直接赋值,避免从 0 爬升 */ y = y + (((int32_t)raw - (int32_t)y) >> shift); return (uint16_t)y; }滑动平均的窗口越大越平滑,但响应越慢,8 点窗口在 1 kHz 采样下相当于 8 ms 延迟,做电池监测没问题,做电流环就太慢了。一阶低通用移位代替浮点乘,shift = 3对应时间常数约 8 个采样周期,改动 shift 就能调平滑度,是中断和主循环里都能用的写法。
选择上我的经验是:慢变量用滑动平均,因为它对阶跃响应是有限长的,不会拖尾;需要跟随的动态量用一阶低通,计算量恒定、内存占用固定。两个都用也不是不行,但要知道串联之后相位滞后会叠加,做闭环控制时容易把相位裕度吃掉。
5. 避坑与排查:五个真实翻车现场
5.1 ADC 时钟超了 60 MHz,读数整体偏移
现象:所有通道的读数系统性偏大 3%~8%,换通道、换采样时间都改不了,用手摸板子温度升高后偏移量还会变。
原因:CubeMX 里 ADC12 时钟源选了 PLLP(170 MHz),CKMODE 忘了分频,等于 170 MHz 直接灌进 ADC。超频不会立刻死机,而是转换精度崩掉,这是最阴的一类问题——代码逻辑全对,就是数不对。
解决:把 CKMODE 设成 4 分频得到 42.5 MHz,重新生成代码。验证方法是拿一个已知电压(比如 1.000 V)打进去,看读数是不是稳定在 1241 左右(1.0/3.3×4095),偏差应该在一个 LSB 以内。
5.2 DMA 只进了一次回调,之后没动静
现象:串口只打印了第一批数据,之后g_seg_ready一直是 0,ADC 还在跑但缓冲不再更新。
原因:DMA 配成了 Normal 模式,或者 ADC 的 Continuous Conversion Mode 没开。Normal 模式下 DMA 搬完设定长度就停了,而 ADC 只转了一轮也停了,两边都静止。
解决:DMA 模式改 Circular,ADC 的 Continuous Conversion 和 DMA Continuous Requests 都使能。判断技巧是看 DMA 的CNDTR寄存器,循环模式下它应该一直在递减并回绕,如果停在 0 不动就是模式配错了。
5.3 多通道顺序错乱,温度值跑到电压位
现象:buf[0]本该是外部电压,结果算出来的温度明显不对,反过来buf[2]又像电压值。
原因:两个可能。一是 CubeMX 里 Rank 的顺序和代码里索引的假设不一致,CubeMX 里 Rank1 未必是你以为的那个通道;二是在运行期用HAL_ADC_ConfigChannel改了某个通道的 Rank,但缓冲索引没跟着改。
解决:在头文件里把索引写死成宏,#define IDX_VBAT 0、#define IDX_VREF 1、#define IDX_TEMP 2,并在adc.c的初始化代码旁边加注释标明 Rank 顺序。运行期不要改 Rank,要切通道就重配整个序列。
5.4 高阻抗信号源读数偏低,万用表和 ADC 对不上
现象:万用表量分压点 1.65 V,ADC 换算出来 1.20 V,误差稳定不抖。
原因:采样时间太短,ADC 内部采样电容没充满。G4 的采样电容是几个 pF 量级,源阻抗如果是 100 kΩ,时间常数就是几百纳秒,2.5 个周期(约 59 ns)根本不够。
解决:采样时间加到 47.5 或 247.5 周期,或者在前端加一级运放跟随器把源阻抗降下来。估算公式是t > (R_source + R_adc) × C_adc × ln(2^13),按 13 位精度算,R_source 100 kΩ 时大概需要 3~5 µs 的采样窗口。
5.5 校准做完了,跑一会儿零点又漂了
现象:上电校准后零点很准,运行几分钟后读数整体抬高十来个 LSB,重新上电又好了。
原因:校准之后又调了HAL_ADC_ConfigChannel修改采样时间或单端/差分模式。校准结果是跟这些配置绑定的,改了配置等于让校准失效,而且不会报错。
解决:把初始化顺序固定成“时钟 → ADC init → 校准 → 配通道 → 启动”,运行期只允许改 Rank 之外的常规配置,SingleDiff一旦定了就不动。如果业务确实需要动态切差分模式,就在切换后重新校准一次。
6. 二次开发改造:把采集层抽成接口,换芯片只改一个文件
跑通之后最容易失控的地方,是应用层直接extern ADC_HandleTypeDef hadc1然后到处HAL_ADC_GetValue。等哪天要换成 ADC2、或者换到另一颗 G4 型号,改动会蔓延到整个工程。我一般会加一层薄接口,把“采集”这件事抽象出来。
/* adc_port.h —— 面向接口的采集层,应用层只依赖这个头文件 */ typedef struct { void (*init)(void); uint16_t (*read_raw)(uint32_t ch); /* 阻塞式,慢速通道用 */ void (*start_scan)(uint16_t *buf, uint16_t len); /* DMA 连续扫描 */ float (*to_volt)(uint16_t raw); } adc_port_t; extern const adc_port_t adc_g431; /* 具体实现放 adc_g431.c */应用层写const adc_port_t *adc = &adc_g431;,之后只调adc->read_raw(...)。换芯片时新增一个adc_xxx.c实现同一套函数指针就行,业务代码一行不动。这层抽象的成本只有一个结构体和几个转发函数,收益是移植时不用满工程搜HAL_ADC。
改造完必须验证,不能靠“看着差不多”。我用三步:第一步,可调直流源打 0.5 V / 1.5 V / 2.5 V 三个点,记录码值和换算电压,误差超过 ±2 LSB 就回去查采样时间;第二步,用ADC_GetVDDA读出来的值和万用表量 VDDA 对比,差值应该在 30 mV 以内,偏差大说明内部参考通道的采样时间不够;第三步,跑 30 分钟连续采集,记录最大值和最小值,峰峰值超过 10 LSB 就要考虑加滤波或者查电源纹波。
| 输入电压 | 理论码值 | 实测码值 | 换算电压 | 误差 |
|---|---|---|---|---|
| 0.500 V | 620 | 621 | 0.5004 V | +0.4 mV |
| 1.500 V | 1861 | 1860 | 1.4989 V | -1.1 mV |
| 2.500 V | 3102 | 3103 | 2.5008 V | +0.8 mV |
那张表我一般会贴在工程的README里,标定日期和当时的室温都写上。因为有次在冬天标定好的系数,夏天拿到现场差了十几个 mV,查了半天才发现是分压电阻的温漂,跟 ADC 一点关系没有。
从那以后我每次动 ADC 配置——哪怕只是把采样时间从 47.5 改成 12.5——都强制走一遍三点标定加 30 分钟漂移测试,跑完才允许提交。这套流程看着笨,但比在现场对着跳动的串口数字猜要快得多。希望帮到你。
本文还有配套的精品资源,点击获取