1. 项目缘起从“测个电压”到“稳定显示”的实战思考最近在调试一个基于STM32的小型数据采集板核心任务之一就是实时读取一个传感器接口比如PA1引脚上的电压并把它稳定地显示出来。听起来很简单对吧不就是配置一下ADC读个数然后通过串口或者屏幕显示一下。但真动起手来你会发现从“能读到数”到“读得准、显示稳”之间隔着不少细节。比如你可能会遇到读数跳得厉害明明电压没变ADC值却在上下乱蹦或者屏幕刷新时数字闪烁看着心烦又或者想用OLED这种小屏做个简洁的界面却卡在了驱动和显示逻辑上。这个项目标题“STM32之ADC(获取某个端口电压并显示)”恰恰点中了嵌入式开发中的一个经典且高频的需求场景。无论是做电池电量监测、环境传感器信号采集还是简单的电位器调参界面都绕不开ADC采集与显示这一环。本文的目的就是以一个具体的PA1端口电压采集与显示为例抛开那些笼统的理论直接深入到代码配置、硬件连接、数据处理和显示优化的每一个实操环节分享我在这类项目中积累的一些“踩坑”经验和“稳如老狗”的调试技巧。无论你是刚接触STM32的新手还是想优化现有采集系统的老鸟希望这些接地气的细节能给你带来直接的帮助。2. 硬件连接与ADC通道配置从原理图到代码的映射拿到一个“读取PA1电压”的任务第一步不是打开Keil写代码而是先搞清楚硬件上的信号链路。PA1是STM32芯片上的一个GPIO引脚它可以通过复用功能配置为ADC的输入通道。具体是哪个ADC的哪个通道需要查芯片的数据手册Datasheet。以常见的STM32F103C8T6为例PA1对应的是ADC1的通道1Channel 1。这个映射关系是硬件固定的写代码时必须匹配。2.1 信号前端处理不是所有电压都能直接接STM32的ADC输入引脚通常只能承受0到3.3VVREF电压的电压。如果你的待测信号范围超出此区间或者信号源内阻很大直接连接可能会导致读数不准甚至损坏芯片。场景一测量高于3.3V的电压。比如想用STM32监控一个12V的电池电压。这时必须使用电阻分压电路。假设我们用R130kΩ R210kΩ串联接到12V电池两端从R2上取电压接到PA1。那么PA1测得的电压 V_PA1 12V * (R2 / (R1R2)) 12V * (10k/40k) 3.0V 落在了ADC量程内。在代码中我们需要将这个ADC读数反向换算回真实的电池电压V_battery ADC_Value * (3.3V / 4095) * ((R1R2) / R2)。这里4095是12位ADC的最大数字量2^12 - 1。电阻的精度和温漂会直接影响最终测量精度对于电池电压监测1%精度的金属膜电阻通常就够了。场景二信号源阻抗过高。如果信号来自一个高内阻的传感器如某些光电管直接连接ADC会导致采样电容充电时间过长在ADC的采样保持阶段无法稳定到目标电压从而引入误差。解决方法是在ADC输入引脚前加一个电压跟随器运算放大器构成利用运放高输入阻抗、低输出阻抗的特性进行缓冲。即使不用运放也建议在PA1引脚到地之间接一个几十到几百皮法的小电容如100pF它可以充当电荷池帮助稳定采样时刻的电压并滤除一部分高频噪声。实操连接示例假设我们测量一个0-3.3V的可调电位器电压。电位器两端分别接3.3V和GND中间滑动端接PA1。同时在PA1引脚与GND之间并联一个100pF的瓷片电容。STM32的VDDA模拟电源和VSSA模拟地务必连接稳定干净的3.3V和GND最好通过磁珠或小电阻与数字电源隔离并在靠近芯片引脚处放置10uF和0.1uF的退耦电容。这是保证ADC基准源稳定的关键很多读数跳动问题根源就在于此。2.2 CubeMX中的ADC配置详解使用STM32CubeMX进行初始化可以节省大量时间。关键配置步骤如下引脚配置在Pinout视图找到PA1将其功能设置为“ADC1_IN1”。ADC1参数配置Mode选择“Independent mode”独立模式。如果项目需要多ADC同步或交替采样才会用到其他模式。Scan Conversion Mode如果只扫描PA1这一个通道设为“Disable”。如果使能需要配合DMA适用于多通道自动循环采样。Continuous Conversion Mode连续转换模式。如果希望ADC一次启动后就不停地转换选“Enabled”。对于单次读取选“Disabled”然后在代码中每次触发转换。Discontinuous Conversion Mode不连续转换模式。通常禁用。End Of Conversion Selection转换结束标志选择。通常用默认的“EOC flag at the end of single conversion”单次转换后置位EOC标志。Data Alignment数据对齐方式。强烈建议选择“Right alignment”右对齐。这样读取到的16位变量ADC_Value的低12位就是有效数据高4位为0处理起来非常直观。左对齐在某些计算场景下有用但更易出错。External Trigger Conversion Source外部触发源。如果不用定时器触发等高级功能就选“Software trigger”软件触发。ADC通道配置 在“ADC1”的“Configuration”标签页下进入“ADC Settings”子标签。在“Rank”列表中添加一个规则通道Regular Channel。选择“Channel 1”对应PA1。然后设置Sampling Time采样时间。这是非常关键的参数。它决定了ADC内部采样电容连接外部信号源进行充电的时间。时间太短电容充电不足读数会偏低且不稳定时间太长则影响转换速率。对于信号源阻抗较高的情况比如前面说的电位器输出阻抗可能几kΩ到几十kΩ需要更长的采样时间。对于STM32F1采样时间以周期数为单位可以选择1.5到239.5个周期。我的经验是对于一般的传感器信号设置在55.5到239.5个周期是比较稳妥的。可以先设为“239.5 Cycles”确保读数稳定再根据实际需求尝试降低以提高速度。生成代码配置好时钟树确保ADC时钟不超过14MHz对于F1系列后生成代码。注意CubeMX生成的代码会初始化ADC但通常不会自动开始转换。你需要调用HAL_ADC_Start()来启动ADC在连续模式下或者调用HAL_ADC_Start_IT()启动中断模式或者调用HAL_ADC_PollForConversion()进行轮询转换。3. 软件驱动与数据采集轮询、中断与DMA之争配置好硬件和基础参数后接下来就是在代码中获取ADC值。这里有三种主流方式轮询、中断和DMA。选择哪种取决于你的应用对实时性、CPU占用率和系统复杂度的要求。3.1 轮询方式简单直接适合低速单次采样轮询是最简单的方式。在你需要读取电压的时候启动一次转换然后等待转换完成最后读取结果。// 假设已定义 hadc1 为 ADC1 的句柄 uint32_t adc_value 0; float voltage 0.0f; // 启动一次转换 HAL_ADC_Start(hadc1); // 等待转换完成超时时间设为10ms if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { // 读取转换结果 adc_value HAL_ADC_GetValue(hadc1); // 停止转换如果是单次模式此调用非必须但是个好习惯 HAL_ADC_Stop(hadc1); // 将ADC值转换为电压值 (VREF3.3V, 12位ADC) voltage (float)adc_value * 3.3f / 4095.0f; }优缺点分析优点代码直观易于理解和调试。不涉及中断优先级等复杂问题。缺点HAL_ADC_PollForConversion函数会阻塞CPU直到转换完成或超时。对于采样时间设为239.5个周期约17us 14MHz的情况阻塞时间很短可以接受。但如果需要高频采样或者在等待ADC时系统还有其他任务要处理阻塞就会成为问题。适用场景手动触发采样、上电自检、低速巡检比如每秒几次等对实时性要求不高的场合。3.2 中断方式异步通知CPU利用率更高中断方式允许ADC在转换完成后主动通知CPUCPU在等待期间可以执行其他任务。首先在CubeMX中使能ADC全局中断NVIC Settings。然后在代码中// 启动ADC中断模式转换 HAL_ADC_Start_IT(hadc1); // ADC转换完成中断回调函数 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { uint32_t adc_value HAL_ADC_GetValue(hadc); // 注意在中断回调里进行浮点运算或复杂处理要谨慎避免耗时过长。 // 通常只是将数据存入缓冲区或设置一个标志位。 g_adc_raw_value adc_value; g_adc_data_ready_flag 1; // 如果需要连续转换不需要再次调用 Start_IT因为中断模式会自动重新开始。 // 如果是单次模式需要在这里重新调用 HAL_ADC_Start_IT 来触发下一次。 } } // 在主循环中检查标志位并处理 if (g_adc_data_ready_flag) { g_adc_data_ready_flag 0; voltage (float)g_adc_raw_value * 3.3f / 4095.0f; // ... 进行显示或其他处理 }优缺点分析优点非阻塞提高了CPU利用率。响应及时转换完成即刻进入中断处理。缺点增加了中断系统的复杂性。高频采样时中断频繁进出消耗的CPU时间可能比轮询等待更多因为中断有压栈、跳转等开销。在中断服务程序中进行浮点运算或调用printf等耗时函数是大忌会严重影响系统实时性。适用场景中等频率采样例如几百Hz到几KHz且需要在数据就绪后立即进行简单处理的场合。3.3 DMA方式解放CPU应对高速连续采样这是处理高速、连续、多通道ADC采样的终极利器。DMA直接存储器访问可以在ADC转换完成后自动将数据搬运到指定的内存数组中完全不需要CPU干预。在CubeMX中配置在ADC1配置中将“Scan Conversion Mode”设为“Enabled”因为DMA通常配合多通道或连续扫描。在“DMA Settings”标签页添加一个DMA请求。方向设为“Peripheral To Memory”模式可以是“Circular”循环模式缓冲区满后从头开始适合持续采集或“Normal”正常模式传输一次后停止。生成代码。软件代码如下#define ADC_BUFFER_SIZE 256 uint32_t adc_dma_buffer[ADC_BUFFER_SIZE]; // DMA目标缓冲区 // 启动ADC DMA传输 HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFFER_SIZE); // DMA传输完成一半或全部完成的中断回调如果使能了DMA中断 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { // 处理前半部分数据 adc_dma_buffer[0...127] } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 处理后半部分数据 adc_dma_buffer[128...255] // 在循环模式下处理完数据后DMA会自动开始下一轮填充无需重启。 } // 主循环中可以直接访问 adc_dma_buffer 获取最新数据 // 例如取最近一次转换的值 latest_adc_value adc_dma_buffer[ADC_BUFFER_SIZE - 1]; voltage (float)latest_adc_value * 3.3f / 4095.0f;关键细节与避坑缓冲区溢出在循环模式下如果CPU处理数据的速度跟不上DMA填充的速度新数据会覆盖未处理的旧数据。解决方案是使用“乒乓缓冲区”或双缓冲区准备两个缓冲区DMA填满A时触发中断CPU处理A同时DMA去填充BB满时触发中断CPU处理BDMA回填A如此循环。数据对齐确保DMA接收缓冲区地址对齐并且数据类型uint32_t与ADC数据宽度匹配。使用uint32_t数组是因为HAL库的DMA传输函数参数是uint32_t*即使ADC是12位也按32位单元搬运。内存一致性如果使用了CPU缓存在一些高性能MCU中需要注意DMA操作的内存区域是否被缓存可能需要配置为“非缓存”或手动进行缓存无效化/清理操作。适用场景音频采集、高速数据记录、多通道同步采样配合ADC的扫描模式等对实时性和带宽要求高的场合。我的选择建议对于“获取某个端口电压并显示”这种项目如果显示刷新率在10Hz以下轮询方式最简单可靠。如果希望系统更“流畅”ADC采样和显示刷新都在一个主定时器中断里协同调度那么用中断方式更合适。只有当你需要以非常高的速率采样并存储或者同时采样多个通道时才需要考虑DMA。4. 数据处理与滤波让显示的数字“稳”下来直接从ADC读出来的原始值几乎不可能是完全稳定的。电源纹波、数字电路噪声、电磁干扰都会导致读数在小范围内波动。直接把这个波动的值显示出来屏幕上的数字就会不停跳动用户体验很差。因此必须进行数据处理。4.1 软件滤波算法选型与实践1. 算术平均滤波这是最常用、最简单的滤波方法。连续采样N次求和后取平均值。#define SAMPLE_TIMES 32 uint32_t adc_sum 0; for(int i0; iSAMPLE_TIMES; i) { adc_sum get_adc_value(); // 你的ADC读取函数 HAL_Delay(1); // 适当延时避免采样间隔过短 } uint32_t adc_average adc_sum / SAMPLE_TIMES;优点简单有效能显著平滑随机噪声。缺点速度慢占用内存需要存储多次采样值或实时累加。对于突变的信号反应迟钝。技巧N取2的幂次如4,8,16,32,64这样最后的除法可以用右移运算代替在无硬件除法的MCU上效率极高。例如adc_average adc_sum 5;等价于除以32。2. 滑动平均滤波维护一个长度为N的队列环形缓冲区每次新采样值进入队列同时丢弃最旧的值然后计算队列中所有值的平均值。#define MOVING_AVG_SIZE 16 uint32_t adc_buffer[MOVING_AVG_SIZE] {0}; uint8_t buffer_index 0; uint32_t buffer_sum 0; // 每次采样后调用 uint32_t new_val get_adc_value(); buffer_sum buffer_sum - adc_buffer[buffer_index] new_val; // 减去旧的加上新的 adc_buffer[buffer_index] new_val; buffer_index (buffer_index 1) % MOVING_AVG_SIZE; uint32_t filtered_val buffer_sum / MOVING_AVG_SIZE;优点实时性好每次输出都是基于最近N个点的平均对信号跟得更紧。内存占用固定。缺点同样对突变信号有延迟。需要维护缓冲区和索引代码稍复杂。3. 一阶低通滤波指数加权平均这是一种非常高效且常用的滤波方法特别适合MCU。它不需要存储历史数据队列。float alpha 0.1f; // 滤波系数介于0~1之间。越小滤波效果越强响应越慢。 float filtered_voltage 0.0f; // 滤波后的电压值 // 在主循环或定时中断中执行 float new_voltage (float)get_adc_value() * 3.3f / 4095.0f; filtered_voltage alpha * new_voltage (1 - alpha) * filtered_voltage;原理当前输出值 系数α × 当前输入值 (1-α) × 上一次输出值。它相当于给历史数据一个指数衰减的权重。优点计算量极小一次乘法和一次加法内存占用极少只需保存上一个输出值。可以通过调整α在平滑度和响应速度之间取得平衡。缺点对于脉冲干扰的抑制不如平均滤波。α的选择需要根据采样周期和信号特性来调试。参数调试经验α ≈ (采样周期T) / (滤波时间常数τ)。例如你希望滤除100Hz以上的噪声τ≈0.01s采样周期是0.001s1kHz那么α ≈ 0.001/0.01 0.1。可以从0.1开始尝试观察显示效果。4. 中值滤波取最近N次采样值排序后取中间值。对于偶发性的尖峰脉冲干扰例如开关动作引起的毛刺有奇效。#define MEDIAN_SIZE 5 // 通常取奇数 uint32_t window[MEDIAN_SIZE]; // ... 维护一个滑动窗口 ... // 排序 window (可以使用简单的冒泡排序因为N很小) // filtered_val window[MEDIAN_SIZE/2]; // 取中值优点能有效滤除椒盐噪声保护数据不受个别异常值影响。缺点需要排序计算量相对较大。不适合实时性要求极高的场景。通常与平均滤波结合使用如先中值滤波去掉野点再平均滤波平滑。我的实战策略在显示电压的项目中我通常采用“一阶低通滤波”。它的平滑效果足够好代码简单对STM32F103这类资源有限的芯片非常友好。将α设置为0.05到0.2之间显示的数字就会非常稳定同时又不会感觉有明显的延迟。如果发现偶尔有跳变的毛刺可以在进行一阶滤波之前先对原始ADC值做一个限幅滤波如果本次采样值与上次值的差超过一个阈值比如对应0.1V的变化则认为可能是干扰直接丢弃或用上次值代替。4.2 标定与校准提高绝对精度滤波解决了“稳”的问题但“准”的问题还需要标定。我们计算电压的公式V ADC_Value * 3.3 / 4095基于两个理想假设1. 参考电压VREF精确等于3.3V2. ADC没有零漂和增益误差。实际上单片机的3.3V电源可能有±5%的偏差ADC本身也有误差。对于要求不高的场合可以忽略。但如果需要较高精度就需要校准。两点校准法准备两个精确的参考电压源例如0V短路到地和3.000V使用基准电压芯片如TL431提供。将0V接入PA1读取ADC值记为ADCL。将3.000V接入PA1读取ADC值记为ADCH。计算斜率k和截距bk 3.000 / (ADCH - ADCL)b -k * ADCL(或者b 0 - k * ADCL)对于任何未知电压的ADC读数ADCx其真实电压为V_real k * ADCx b可以将k和b作为常数保存在代码或Flash中。这个方法可以消除ADC的零点误差和增益误差以及参考电压不准带来的影响。5. 显示模块集成与界面优化电压值计算出来后需要显示给人看。显示方式多种多样这里以最常用的串口打印和OLED屏幕为例。5.1 串口打印最快速的调试显示在开发阶段通过串口将电压值打印到电脑的串口助手上是最直观的调试方式。#include stdio.h // ... 重定向printf到串口代码 ... float filtered_voltage ...; // 经过滤波和校准后的电压值 printf(Voltage: %.3f V\r\n, filtered_voltage); HAL_Delay(200); // 控制打印频率避免刷屏注意频繁调用printf尤其是浮点数格式化会消耗大量CPU时间和栈空间。在正式产品中应避免。可以优化为定点数整数打印或者仅在需要时打印。5.2 OLED显示以SSD1306 I2C为例打造嵌入式UIOLED显示美观、省电是很多嵌入式产品的选择。1. 驱动移植 网上有丰富的SSD1306驱动代码如OLED.c和OLED.h。你需要根据使用的MCU和I2C引脚修改底层的I2C_WriteByte函数通常就是调用HAL库的HAL_I2C_Master_Transmit。确保I2C时钟配置正确通常400kHz。2. 显示电压值 核心是调用OLED的字符串显示函数。但直接显示浮点数printf到字符串再显示在MCU上效率低。推荐使用定点数显示。// 假设 filtered_voltage 2.456V uint16_t voltage_int (uint16_t)(filtered_voltage * 1000); // 转换为毫伏整数 2456 uint8_t buf[10]; sprintf((char*)buf, %d.%03d, voltage_int / 1000, voltage_int % 1000); // 2.456 // 或者手动拆分避免sprintf buf[0] 0 (voltage_int / 1000); // 整数部分 buf[1] .; buf[2] 0 ((voltage_int % 1000) / 100); // 十分位 buf[3] 0 ((voltage_int % 100) / 10); // 百分位 buf[4] 0 (voltage_int % 10); // 千分位 buf[5] V; buf[6] \0; OLED_ShowString(30, 20, buf, 16); // 在指定坐标显示字符串3. 界面防闪烁与局部刷新 直接全屏清屏OLED_Clear再重绘所有内容会导致屏幕闪烁。优化方法是局部刷新。静态部分如标题“Voltage:”、单位“V”、边框等只在初始化时绘制一次。动态部分即变化的数字。在更新数字前先用背景色黑色在旧数字的位置重绘一次相当于“擦除”旧数字然后再绘制新数字。这样就只有数字区域在变化避免了全屏闪烁。// 假设旧数字显示在 (30, 20)字体大小8x16 OLED_ShowString(30, 20, , 16); // 用空格覆盖旧内容宽度根据数字最大长度预留 OLED_ShowString(30, 20, buf, 16); // 绘制新数字4. 添加进度条或模拟表盘 纯数字显示不够直观可以增加一个简单的水平进度条来形象化表示电压占满量程如3.3V的比例。#define BAR_MAX_WIDTH 100 // 进度条最大像素宽度 #define BAR_MAX_VAL 3.3f // 电压满量程 uint8_t bar_length (uint8_t)((filtered_voltage / BAR_MAX_VAL) * BAR_MAX_WIDTH); // 绘制进度条背景白色矩形框 OLED_DrawRectangle(10, 40, 10BAR_MAX_WIDTH, 48); // 绘制进度条填充反白填充内部 OLED_Fill(11, 41, 11bar_length, 47, 1); // 1表示点亮像素这样电压变化时进度条的长度也会随之变化视觉效果更好。6. 系统整合与稳定性提升实战将ADC采集、滤波处理和显示刷新这几个模块整合到一个稳定的系统中需要合理的任务调度和时序控制。盲目地在主循环里不停地采样和刷新不仅效率低还可能因为时序混乱导致显示卡顿或采样不匀。6.1 基于定时器的节奏控制我推荐使用一个基本定时器如TIM6/TIM7来为整个系统提供“心跳”。假设我们设定定时器中断频率为100Hz10ms一次。在定时器中断服务函数中void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { static uint8_t adc_sample_cnt 0; static uint8_t display_refresh_cnt 0; // 任务1每2个中断20ms采样一次ADC即50Hz采样率 adc_sample_cnt; if (adc_sample_cnt 2) { adc_sample_cnt 0; // 启动一次ADC转换假设用轮询或中断方式 start_adc_conversion_and_get_value(); // 此函数会更新全局的 filtered_voltage } // 任务2每10个中断100ms刷新一次显示即10Hz刷新率 display_refresh_cnt; if (display_refresh_cnt 10) { display_refresh_cnt 0; g_display_update_flag 1; // 设置显示更新标志 } } }在主循环中while (1) { if (g_display_update_flag) { g_display_update_flag 0; update_oled_display(); // 调用OLED显示更新函数 } // 其他低优先级任务如按键扫描、通信处理等 HAL_Delay(1); }这种架构的好处是采样率固定且已知便于设计滤波器参数如一阶低通滤波的α。显示刷新率稳定避免了因主循环执行时间不确定导致的显示卡顿。模块解耦ADC采样和显示刷新在时间上错开互不阻塞。6.2 抗干扰与硬件布局心得即使软件做得再好硬件布局不当也会引入噪声。电源隔离给模拟部分VDDA、VSSA的供电尽量通过磁珠或0欧电阻从数字电源分离。在VDDA和VSSA引脚附近务必放置一个10uF的钽电容和一个0.1uF的瓷片电容。信号走线ADC输入线PA1的走线应远离高频数字信号线如时钟线、PWM输出线。如果可能用地线包围或采用屏蔽线。接地模拟地和数字地单点连接。通常可以在MCU下方将VSSA和VSS通过一个0欧电阻或磁珠连接。测试技巧当怀疑是电源噪声时可以尝试用外部干净的线性稳压源如LM1117-3.3单独给VDDA供电看读数是否变得稳定。这是一个非常有效的排查手段。6.3 调试与问题排查清单当你发现ADC读数不准或不稳时可以按以下清单排查[ ]基准电压是否稳定测量VDDA引脚的实际电压并用它代替公式中的3.3进行计算。[ ]采样时间是否足够尝试将采样时间调到最大如239.5 Cycles看读数是否稳定。如果稳定了说明原采样时间不足。[ ]是否有滤波原始值是否波动很大加上软件滤波如一阶低通再观察。[ ]输入信号本身是否干净用示波器观察PA1引脚上的实际波形看看是否有毛刺或振荡。[ ]代码中读取ADC的时机是否正确是否在转换完成标志置位后才读取数据轮询方式是否检查了超时[ ]如果是多通道扫描DMA配置是否正确缓冲区大小、数据对齐、中断处理是否正确[ ]显示部分是否单独测试过能否稳定显示一个固定的数字排除显示驱动本身的问题。通过这样一个从硬件到软件、从原理到实操的完整梳理一个简单的“读取电压并显示”项目就能变成一个稳定、可靠、显示美观的嵌入式子系统。这其中的每一个环节——精准的配置、合适的数据处理、高效的显示优化以及稳定的系统调度——都是项目成败的关键细节。
STM32 ADC电压采集与显示:从配置、滤波到OLED显示的实战指南
1. 项目缘起从“测个电压”到“稳定显示”的实战思考最近在调试一个基于STM32的小型数据采集板核心任务之一就是实时读取一个传感器接口比如PA1引脚上的电压并把它稳定地显示出来。听起来很简单对吧不就是配置一下ADC读个数然后通过串口或者屏幕显示一下。但真动起手来你会发现从“能读到数”到“读得准、显示稳”之间隔着不少细节。比如你可能会遇到读数跳得厉害明明电压没变ADC值却在上下乱蹦或者屏幕刷新时数字闪烁看着心烦又或者想用OLED这种小屏做个简洁的界面却卡在了驱动和显示逻辑上。这个项目标题“STM32之ADC(获取某个端口电压并显示)”恰恰点中了嵌入式开发中的一个经典且高频的需求场景。无论是做电池电量监测、环境传感器信号采集还是简单的电位器调参界面都绕不开ADC采集与显示这一环。本文的目的就是以一个具体的PA1端口电压采集与显示为例抛开那些笼统的理论直接深入到代码配置、硬件连接、数据处理和显示优化的每一个实操环节分享我在这类项目中积累的一些“踩坑”经验和“稳如老狗”的调试技巧。无论你是刚接触STM32的新手还是想优化现有采集系统的老鸟希望这些接地气的细节能给你带来直接的帮助。2. 硬件连接与ADC通道配置从原理图到代码的映射拿到一个“读取PA1电压”的任务第一步不是打开Keil写代码而是先搞清楚硬件上的信号链路。PA1是STM32芯片上的一个GPIO引脚它可以通过复用功能配置为ADC的输入通道。具体是哪个ADC的哪个通道需要查芯片的数据手册Datasheet。以常见的STM32F103C8T6为例PA1对应的是ADC1的通道1Channel 1。这个映射关系是硬件固定的写代码时必须匹配。2.1 信号前端处理不是所有电压都能直接接STM32的ADC输入引脚通常只能承受0到3.3VVREF电压的电压。如果你的待测信号范围超出此区间或者信号源内阻很大直接连接可能会导致读数不准甚至损坏芯片。场景一测量高于3.3V的电压。比如想用STM32监控一个12V的电池电压。这时必须使用电阻分压电路。假设我们用R130kΩ R210kΩ串联接到12V电池两端从R2上取电压接到PA1。那么PA1测得的电压 V_PA1 12V * (R2 / (R1R2)) 12V * (10k/40k) 3.0V 落在了ADC量程内。在代码中我们需要将这个ADC读数反向换算回真实的电池电压V_battery ADC_Value * (3.3V / 4095) * ((R1R2) / R2)。这里4095是12位ADC的最大数字量2^12 - 1。电阻的精度和温漂会直接影响最终测量精度对于电池电压监测1%精度的金属膜电阻通常就够了。场景二信号源阻抗过高。如果信号来自一个高内阻的传感器如某些光电管直接连接ADC会导致采样电容充电时间过长在ADC的采样保持阶段无法稳定到目标电压从而引入误差。解决方法是在ADC输入引脚前加一个电压跟随器运算放大器构成利用运放高输入阻抗、低输出阻抗的特性进行缓冲。即使不用运放也建议在PA1引脚到地之间接一个几十到几百皮法的小电容如100pF它可以充当电荷池帮助稳定采样时刻的电压并滤除一部分高频噪声。实操连接示例假设我们测量一个0-3.3V的可调电位器电压。电位器两端分别接3.3V和GND中间滑动端接PA1。同时在PA1引脚与GND之间并联一个100pF的瓷片电容。STM32的VDDA模拟电源和VSSA模拟地务必连接稳定干净的3.3V和GND最好通过磁珠或小电阻与数字电源隔离并在靠近芯片引脚处放置10uF和0.1uF的退耦电容。这是保证ADC基准源稳定的关键很多读数跳动问题根源就在于此。2.2 CubeMX中的ADC配置详解使用STM32CubeMX进行初始化可以节省大量时间。关键配置步骤如下引脚配置在Pinout视图找到PA1将其功能设置为“ADC1_IN1”。ADC1参数配置Mode选择“Independent mode”独立模式。如果项目需要多ADC同步或交替采样才会用到其他模式。Scan Conversion Mode如果只扫描PA1这一个通道设为“Disable”。如果使能需要配合DMA适用于多通道自动循环采样。Continuous Conversion Mode连续转换模式。如果希望ADC一次启动后就不停地转换选“Enabled”。对于单次读取选“Disabled”然后在代码中每次触发转换。Discontinuous Conversion Mode不连续转换模式。通常禁用。End Of Conversion Selection转换结束标志选择。通常用默认的“EOC flag at the end of single conversion”单次转换后置位EOC标志。Data Alignment数据对齐方式。强烈建议选择“Right alignment”右对齐。这样读取到的16位变量ADC_Value的低12位就是有效数据高4位为0处理起来非常直观。左对齐在某些计算场景下有用但更易出错。External Trigger Conversion Source外部触发源。如果不用定时器触发等高级功能就选“Software trigger”软件触发。ADC通道配置 在“ADC1”的“Configuration”标签页下进入“ADC Settings”子标签。在“Rank”列表中添加一个规则通道Regular Channel。选择“Channel 1”对应PA1。然后设置Sampling Time采样时间。这是非常关键的参数。它决定了ADC内部采样电容连接外部信号源进行充电的时间。时间太短电容充电不足读数会偏低且不稳定时间太长则影响转换速率。对于信号源阻抗较高的情况比如前面说的电位器输出阻抗可能几kΩ到几十kΩ需要更长的采样时间。对于STM32F1采样时间以周期数为单位可以选择1.5到239.5个周期。我的经验是对于一般的传感器信号设置在55.5到239.5个周期是比较稳妥的。可以先设为“239.5 Cycles”确保读数稳定再根据实际需求尝试降低以提高速度。生成代码配置好时钟树确保ADC时钟不超过14MHz对于F1系列后生成代码。注意CubeMX生成的代码会初始化ADC但通常不会自动开始转换。你需要调用HAL_ADC_Start()来启动ADC在连续模式下或者调用HAL_ADC_Start_IT()启动中断模式或者调用HAL_ADC_PollForConversion()进行轮询转换。3. 软件驱动与数据采集轮询、中断与DMA之争配置好硬件和基础参数后接下来就是在代码中获取ADC值。这里有三种主流方式轮询、中断和DMA。选择哪种取决于你的应用对实时性、CPU占用率和系统复杂度的要求。3.1 轮询方式简单直接适合低速单次采样轮询是最简单的方式。在你需要读取电压的时候启动一次转换然后等待转换完成最后读取结果。// 假设已定义 hadc1 为 ADC1 的句柄 uint32_t adc_value 0; float voltage 0.0f; // 启动一次转换 HAL_ADC_Start(hadc1); // 等待转换完成超时时间设为10ms if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { // 读取转换结果 adc_value HAL_ADC_GetValue(hadc1); // 停止转换如果是单次模式此调用非必须但是个好习惯 HAL_ADC_Stop(hadc1); // 将ADC值转换为电压值 (VREF3.3V, 12位ADC) voltage (float)adc_value * 3.3f / 4095.0f; }优缺点分析优点代码直观易于理解和调试。不涉及中断优先级等复杂问题。缺点HAL_ADC_PollForConversion函数会阻塞CPU直到转换完成或超时。对于采样时间设为239.5个周期约17us 14MHz的情况阻塞时间很短可以接受。但如果需要高频采样或者在等待ADC时系统还有其他任务要处理阻塞就会成为问题。适用场景手动触发采样、上电自检、低速巡检比如每秒几次等对实时性要求不高的场合。3.2 中断方式异步通知CPU利用率更高中断方式允许ADC在转换完成后主动通知CPUCPU在等待期间可以执行其他任务。首先在CubeMX中使能ADC全局中断NVIC Settings。然后在代码中// 启动ADC中断模式转换 HAL_ADC_Start_IT(hadc1); // ADC转换完成中断回调函数 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { uint32_t adc_value HAL_ADC_GetValue(hadc); // 注意在中断回调里进行浮点运算或复杂处理要谨慎避免耗时过长。 // 通常只是将数据存入缓冲区或设置一个标志位。 g_adc_raw_value adc_value; g_adc_data_ready_flag 1; // 如果需要连续转换不需要再次调用 Start_IT因为中断模式会自动重新开始。 // 如果是单次模式需要在这里重新调用 HAL_ADC_Start_IT 来触发下一次。 } } // 在主循环中检查标志位并处理 if (g_adc_data_ready_flag) { g_adc_data_ready_flag 0; voltage (float)g_adc_raw_value * 3.3f / 4095.0f; // ... 进行显示或其他处理 }优缺点分析优点非阻塞提高了CPU利用率。响应及时转换完成即刻进入中断处理。缺点增加了中断系统的复杂性。高频采样时中断频繁进出消耗的CPU时间可能比轮询等待更多因为中断有压栈、跳转等开销。在中断服务程序中进行浮点运算或调用printf等耗时函数是大忌会严重影响系统实时性。适用场景中等频率采样例如几百Hz到几KHz且需要在数据就绪后立即进行简单处理的场合。3.3 DMA方式解放CPU应对高速连续采样这是处理高速、连续、多通道ADC采样的终极利器。DMA直接存储器访问可以在ADC转换完成后自动将数据搬运到指定的内存数组中完全不需要CPU干预。在CubeMX中配置在ADC1配置中将“Scan Conversion Mode”设为“Enabled”因为DMA通常配合多通道或连续扫描。在“DMA Settings”标签页添加一个DMA请求。方向设为“Peripheral To Memory”模式可以是“Circular”循环模式缓冲区满后从头开始适合持续采集或“Normal”正常模式传输一次后停止。生成代码。软件代码如下#define ADC_BUFFER_SIZE 256 uint32_t adc_dma_buffer[ADC_BUFFER_SIZE]; // DMA目标缓冲区 // 启动ADC DMA传输 HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFFER_SIZE); // DMA传输完成一半或全部完成的中断回调如果使能了DMA中断 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { // 处理前半部分数据 adc_dma_buffer[0...127] } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 处理后半部分数据 adc_dma_buffer[128...255] // 在循环模式下处理完数据后DMA会自动开始下一轮填充无需重启。 } // 主循环中可以直接访问 adc_dma_buffer 获取最新数据 // 例如取最近一次转换的值 latest_adc_value adc_dma_buffer[ADC_BUFFER_SIZE - 1]; voltage (float)latest_adc_value * 3.3f / 4095.0f;关键细节与避坑缓冲区溢出在循环模式下如果CPU处理数据的速度跟不上DMA填充的速度新数据会覆盖未处理的旧数据。解决方案是使用“乒乓缓冲区”或双缓冲区准备两个缓冲区DMA填满A时触发中断CPU处理A同时DMA去填充BB满时触发中断CPU处理BDMA回填A如此循环。数据对齐确保DMA接收缓冲区地址对齐并且数据类型uint32_t与ADC数据宽度匹配。使用uint32_t数组是因为HAL库的DMA传输函数参数是uint32_t*即使ADC是12位也按32位单元搬运。内存一致性如果使用了CPU缓存在一些高性能MCU中需要注意DMA操作的内存区域是否被缓存可能需要配置为“非缓存”或手动进行缓存无效化/清理操作。适用场景音频采集、高速数据记录、多通道同步采样配合ADC的扫描模式等对实时性和带宽要求高的场合。我的选择建议对于“获取某个端口电压并显示”这种项目如果显示刷新率在10Hz以下轮询方式最简单可靠。如果希望系统更“流畅”ADC采样和显示刷新都在一个主定时器中断里协同调度那么用中断方式更合适。只有当你需要以非常高的速率采样并存储或者同时采样多个通道时才需要考虑DMA。4. 数据处理与滤波让显示的数字“稳”下来直接从ADC读出来的原始值几乎不可能是完全稳定的。电源纹波、数字电路噪声、电磁干扰都会导致读数在小范围内波动。直接把这个波动的值显示出来屏幕上的数字就会不停跳动用户体验很差。因此必须进行数据处理。4.1 软件滤波算法选型与实践1. 算术平均滤波这是最常用、最简单的滤波方法。连续采样N次求和后取平均值。#define SAMPLE_TIMES 32 uint32_t adc_sum 0; for(int i0; iSAMPLE_TIMES; i) { adc_sum get_adc_value(); // 你的ADC读取函数 HAL_Delay(1); // 适当延时避免采样间隔过短 } uint32_t adc_average adc_sum / SAMPLE_TIMES;优点简单有效能显著平滑随机噪声。缺点速度慢占用内存需要存储多次采样值或实时累加。对于突变的信号反应迟钝。技巧N取2的幂次如4,8,16,32,64这样最后的除法可以用右移运算代替在无硬件除法的MCU上效率极高。例如adc_average adc_sum 5;等价于除以32。2. 滑动平均滤波维护一个长度为N的队列环形缓冲区每次新采样值进入队列同时丢弃最旧的值然后计算队列中所有值的平均值。#define MOVING_AVG_SIZE 16 uint32_t adc_buffer[MOVING_AVG_SIZE] {0}; uint8_t buffer_index 0; uint32_t buffer_sum 0; // 每次采样后调用 uint32_t new_val get_adc_value(); buffer_sum buffer_sum - adc_buffer[buffer_index] new_val; // 减去旧的加上新的 adc_buffer[buffer_index] new_val; buffer_index (buffer_index 1) % MOVING_AVG_SIZE; uint32_t filtered_val buffer_sum / MOVING_AVG_SIZE;优点实时性好每次输出都是基于最近N个点的平均对信号跟得更紧。内存占用固定。缺点同样对突变信号有延迟。需要维护缓冲区和索引代码稍复杂。3. 一阶低通滤波指数加权平均这是一种非常高效且常用的滤波方法特别适合MCU。它不需要存储历史数据队列。float alpha 0.1f; // 滤波系数介于0~1之间。越小滤波效果越强响应越慢。 float filtered_voltage 0.0f; // 滤波后的电压值 // 在主循环或定时中断中执行 float new_voltage (float)get_adc_value() * 3.3f / 4095.0f; filtered_voltage alpha * new_voltage (1 - alpha) * filtered_voltage;原理当前输出值 系数α × 当前输入值 (1-α) × 上一次输出值。它相当于给历史数据一个指数衰减的权重。优点计算量极小一次乘法和一次加法内存占用极少只需保存上一个输出值。可以通过调整α在平滑度和响应速度之间取得平衡。缺点对于脉冲干扰的抑制不如平均滤波。α的选择需要根据采样周期和信号特性来调试。参数调试经验α ≈ (采样周期T) / (滤波时间常数τ)。例如你希望滤除100Hz以上的噪声τ≈0.01s采样周期是0.001s1kHz那么α ≈ 0.001/0.01 0.1。可以从0.1开始尝试观察显示效果。4. 中值滤波取最近N次采样值排序后取中间值。对于偶发性的尖峰脉冲干扰例如开关动作引起的毛刺有奇效。#define MEDIAN_SIZE 5 // 通常取奇数 uint32_t window[MEDIAN_SIZE]; // ... 维护一个滑动窗口 ... // 排序 window (可以使用简单的冒泡排序因为N很小) // filtered_val window[MEDIAN_SIZE/2]; // 取中值优点能有效滤除椒盐噪声保护数据不受个别异常值影响。缺点需要排序计算量相对较大。不适合实时性要求极高的场景。通常与平均滤波结合使用如先中值滤波去掉野点再平均滤波平滑。我的实战策略在显示电压的项目中我通常采用“一阶低通滤波”。它的平滑效果足够好代码简单对STM32F103这类资源有限的芯片非常友好。将α设置为0.05到0.2之间显示的数字就会非常稳定同时又不会感觉有明显的延迟。如果发现偶尔有跳变的毛刺可以在进行一阶滤波之前先对原始ADC值做一个限幅滤波如果本次采样值与上次值的差超过一个阈值比如对应0.1V的变化则认为可能是干扰直接丢弃或用上次值代替。4.2 标定与校准提高绝对精度滤波解决了“稳”的问题但“准”的问题还需要标定。我们计算电压的公式V ADC_Value * 3.3 / 4095基于两个理想假设1. 参考电压VREF精确等于3.3V2. ADC没有零漂和增益误差。实际上单片机的3.3V电源可能有±5%的偏差ADC本身也有误差。对于要求不高的场合可以忽略。但如果需要较高精度就需要校准。两点校准法准备两个精确的参考电压源例如0V短路到地和3.000V使用基准电压芯片如TL431提供。将0V接入PA1读取ADC值记为ADCL。将3.000V接入PA1读取ADC值记为ADCH。计算斜率k和截距bk 3.000 / (ADCH - ADCL)b -k * ADCL(或者b 0 - k * ADCL)对于任何未知电压的ADC读数ADCx其真实电压为V_real k * ADCx b可以将k和b作为常数保存在代码或Flash中。这个方法可以消除ADC的零点误差和增益误差以及参考电压不准带来的影响。5. 显示模块集成与界面优化电压值计算出来后需要显示给人看。显示方式多种多样这里以最常用的串口打印和OLED屏幕为例。5.1 串口打印最快速的调试显示在开发阶段通过串口将电压值打印到电脑的串口助手上是最直观的调试方式。#include stdio.h // ... 重定向printf到串口代码 ... float filtered_voltage ...; // 经过滤波和校准后的电压值 printf(Voltage: %.3f V\r\n, filtered_voltage); HAL_Delay(200); // 控制打印频率避免刷屏注意频繁调用printf尤其是浮点数格式化会消耗大量CPU时间和栈空间。在正式产品中应避免。可以优化为定点数整数打印或者仅在需要时打印。5.2 OLED显示以SSD1306 I2C为例打造嵌入式UIOLED显示美观、省电是很多嵌入式产品的选择。1. 驱动移植 网上有丰富的SSD1306驱动代码如OLED.c和OLED.h。你需要根据使用的MCU和I2C引脚修改底层的I2C_WriteByte函数通常就是调用HAL库的HAL_I2C_Master_Transmit。确保I2C时钟配置正确通常400kHz。2. 显示电压值 核心是调用OLED的字符串显示函数。但直接显示浮点数printf到字符串再显示在MCU上效率低。推荐使用定点数显示。// 假设 filtered_voltage 2.456V uint16_t voltage_int (uint16_t)(filtered_voltage * 1000); // 转换为毫伏整数 2456 uint8_t buf[10]; sprintf((char*)buf, %d.%03d, voltage_int / 1000, voltage_int % 1000); // 2.456 // 或者手动拆分避免sprintf buf[0] 0 (voltage_int / 1000); // 整数部分 buf[1] .; buf[2] 0 ((voltage_int % 1000) / 100); // 十分位 buf[3] 0 ((voltage_int % 100) / 10); // 百分位 buf[4] 0 (voltage_int % 10); // 千分位 buf[5] V; buf[6] \0; OLED_ShowString(30, 20, buf, 16); // 在指定坐标显示字符串3. 界面防闪烁与局部刷新 直接全屏清屏OLED_Clear再重绘所有内容会导致屏幕闪烁。优化方法是局部刷新。静态部分如标题“Voltage:”、单位“V”、边框等只在初始化时绘制一次。动态部分即变化的数字。在更新数字前先用背景色黑色在旧数字的位置重绘一次相当于“擦除”旧数字然后再绘制新数字。这样就只有数字区域在变化避免了全屏闪烁。// 假设旧数字显示在 (30, 20)字体大小8x16 OLED_ShowString(30, 20, , 16); // 用空格覆盖旧内容宽度根据数字最大长度预留 OLED_ShowString(30, 20, buf, 16); // 绘制新数字4. 添加进度条或模拟表盘 纯数字显示不够直观可以增加一个简单的水平进度条来形象化表示电压占满量程如3.3V的比例。#define BAR_MAX_WIDTH 100 // 进度条最大像素宽度 #define BAR_MAX_VAL 3.3f // 电压满量程 uint8_t bar_length (uint8_t)((filtered_voltage / BAR_MAX_VAL) * BAR_MAX_WIDTH); // 绘制进度条背景白色矩形框 OLED_DrawRectangle(10, 40, 10BAR_MAX_WIDTH, 48); // 绘制进度条填充反白填充内部 OLED_Fill(11, 41, 11bar_length, 47, 1); // 1表示点亮像素这样电压变化时进度条的长度也会随之变化视觉效果更好。6. 系统整合与稳定性提升实战将ADC采集、滤波处理和显示刷新这几个模块整合到一个稳定的系统中需要合理的任务调度和时序控制。盲目地在主循环里不停地采样和刷新不仅效率低还可能因为时序混乱导致显示卡顿或采样不匀。6.1 基于定时器的节奏控制我推荐使用一个基本定时器如TIM6/TIM7来为整个系统提供“心跳”。假设我们设定定时器中断频率为100Hz10ms一次。在定时器中断服务函数中void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { static uint8_t adc_sample_cnt 0; static uint8_t display_refresh_cnt 0; // 任务1每2个中断20ms采样一次ADC即50Hz采样率 adc_sample_cnt; if (adc_sample_cnt 2) { adc_sample_cnt 0; // 启动一次ADC转换假设用轮询或中断方式 start_adc_conversion_and_get_value(); // 此函数会更新全局的 filtered_voltage } // 任务2每10个中断100ms刷新一次显示即10Hz刷新率 display_refresh_cnt; if (display_refresh_cnt 10) { display_refresh_cnt 0; g_display_update_flag 1; // 设置显示更新标志 } } }在主循环中while (1) { if (g_display_update_flag) { g_display_update_flag 0; update_oled_display(); // 调用OLED显示更新函数 } // 其他低优先级任务如按键扫描、通信处理等 HAL_Delay(1); }这种架构的好处是采样率固定且已知便于设计滤波器参数如一阶低通滤波的α。显示刷新率稳定避免了因主循环执行时间不确定导致的显示卡顿。模块解耦ADC采样和显示刷新在时间上错开互不阻塞。6.2 抗干扰与硬件布局心得即使软件做得再好硬件布局不当也会引入噪声。电源隔离给模拟部分VDDA、VSSA的供电尽量通过磁珠或0欧电阻从数字电源分离。在VDDA和VSSA引脚附近务必放置一个10uF的钽电容和一个0.1uF的瓷片电容。信号走线ADC输入线PA1的走线应远离高频数字信号线如时钟线、PWM输出线。如果可能用地线包围或采用屏蔽线。接地模拟地和数字地单点连接。通常可以在MCU下方将VSSA和VSS通过一个0欧电阻或磁珠连接。测试技巧当怀疑是电源噪声时可以尝试用外部干净的线性稳压源如LM1117-3.3单独给VDDA供电看读数是否变得稳定。这是一个非常有效的排查手段。6.3 调试与问题排查清单当你发现ADC读数不准或不稳时可以按以下清单排查[ ]基准电压是否稳定测量VDDA引脚的实际电压并用它代替公式中的3.3进行计算。[ ]采样时间是否足够尝试将采样时间调到最大如239.5 Cycles看读数是否稳定。如果稳定了说明原采样时间不足。[ ]是否有滤波原始值是否波动很大加上软件滤波如一阶低通再观察。[ ]输入信号本身是否干净用示波器观察PA1引脚上的实际波形看看是否有毛刺或振荡。[ ]代码中读取ADC的时机是否正确是否在转换完成标志置位后才读取数据轮询方式是否检查了超时[ ]如果是多通道扫描DMA配置是否正确缓冲区大小、数据对齐、中断处理是否正确[ ]显示部分是否单独测试过能否稳定显示一个固定的数字排除显示驱动本身的问题。通过这样一个从硬件到软件、从原理到实操的完整梳理一个简单的“读取电压并显示”项目就能变成一个稳定、可靠、显示美观的嵌入式子系统。这其中的每一个环节——精准的配置、合适的数据处理、高效的显示优化以及稳定的系统调度——都是项目成败的关键细节。