首页/新闻资讯/正文详情

STM32F103 ADC电压采集:LCD1602与串口显示对齐实战

发布时间:2026/11/5 5:55:13 来源:云帆数科 栏目:资讯中心
STM32F103 ADC电压采集:LCD1602与串口显示对齐实战
简介面向STM32F103嵌入式入门与课程实践的ADC采集、LCD显示与串口通信例程包围绕STM32F103ZET6展开帮助读者理解12位ADC多通道采样、LCD驱动与UART数据上传的完整链路。包内共88个文件以C源码与H头文件为主配套Keil工程文件、启动汇编、HEX烧录文件、说明文档及调试配置压缩包约241KB结构按HARDWARE、CORE、SYSTEM、USER等模块组织便于按外设定位代码。该资源已有12086人学习下载适合作为课堂实验、课程设计或自学练手的参考。读者可从中获得ADC采样配置、LCD初始化与字符显示、串口发送及工程编译下载等可直接运行的代码框架并借助现成工程快速验证电压采集与显示效果进而修改采样通道、显示格式和串口协议积累嵌入式软硬件联调与排错经验。1. 一块 F103、一个电位器、一块 LCD这条三段式链路到底卡在哪手上有块 STM32F103 最小系统板想用 ADC 采集电压、把数值实时显示在 LCD 液晶屏上、同时通过串口把电压数据发到电脑——这条链路看着只有三段真跑起来却总有一头对不上屏上数字乱跳、串口助手一屏乱码、万用表量出 1.648V 而串口报的是 1.42V。三个外设单独调都能跑拼在一起就互相拖后腿问题从来不在某一行代码而在采样、换算、显示、回传这四段之间的时序和口径没有对齐。这套东西的适用面比想象中宽电源模块的老化测试台要看实时电压电池分容柜要记录放电曲线教学板要演示 ADC 原理甚至产线工装上的简易电压巡检用一块 F103 加 1602 屏就够了。下面按我实际搭这套链路时的顺序拆先把 ADC 的时钟、采样时间、参考电压这些“地基”定死再解决码值到毫伏的换算和滤波然后是 LCD 与串口的输出对齐最后把五个最常见的翻车现场逐个拆开。2. 采样链路先立住ADC 时钟、采样时间与参考电压的选型账很多人拿到例程直接改个通道号就跑跑出来的数据能用但不知道余量在哪一旦换了传感器或者加了分压电阻立刻翻车。这一章把 ADC 的三个物理约束讲透时钟上限、采样时间与源阻抗的匹配、参考电压的来源。这三项定不下来后面滤波和校准都是白费力气。2.1 ADC 时钟树为什么 12MHz 比“能跑就行”更稳STM32F103 的 ADC 挂在 APB2 上系统跑 72MHz 时 APB2 也是 72MHz而 ADC 的时钟上限是 14MHz。分频系数只有 2、4、6、8 四档对应 36MHz、18MHz、12MHz、9MHz——前两档直接超标只能选 /6 或 /8。/6 得 12MHz留了 2MHz 余量是绝大多数工程的默认选择/8 得 9MHz转换慢一些但更保守在电源噪声大、走线长的板子上我会优先用它。超标会怎样不是立刻死机而是转换结果的低位开始随机抖动你用示波器看 VDDA 干干净净码值却在 ±15 LSB 上下飘这种“玄学”现象排查起来极其费时。我见过有人把RCC_PCLK2_Div4当成“4MHz”在用实际是 18MHz调了两天以为是电位器接触不良。/* adc_init.c —— ADC1 通道 0PA0单通道规则组标准库 SPL */ #include stm32f10x.h void adc_pa0_init(void) { GPIO_InitTypeDef gpio; ADC_InitTypeDef adc; /* 1. 时钟ADC1 与 GPIOA 都挂在 APB2 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); /* ADC 时钟 PCLK2 / 6 72MHz / 6 12MHz上限 14MHz */ RCC_ADCCLKConfig(RCC_PCLK2_Div6); /* 2. 引脚必须配模拟输入配成浮空/上拉会把电平钳住 */ gpio.GPIO_Pin GPIO_Pin_0; gpio.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, gpio); /* 3. 规则组独立模式、单通道、单次、右对齐、软件触发 */ ADC_DeInit(ADC1); adc.ADC_Mode ADC_Mode_Independent; adc.ADC_ScanConvMode DISABLE; adc.ADC_ContinuousConvMode DISABLE; adc.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; adc.ADC_DataAlign ADC_DataAlign_Right; adc.ADC_NbrOfChannel 1; ADC_Init(ADC1, adc); /* 4. 通道 0采样 55.5 个 ADC 周期源阻抗偏高时靠它兜底 */ ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); /* 5. 顺序不能反先使能 ADC再复位校准、启动校准 */ ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); } uint16_t adc_read_once(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); /* 等转换结束 */ return ADC_GetConversionValue(ADC1); /* 0 ~ 4095 */ }这段初始化的关键参数只有三个RCC_PCLK2_Div6决定转换速度和噪声水平ADC_SampleTime_55Cycles5决定采样保持电容能不能充满ADC_DataAlign_Right决定你后面移位的方向。ADC_NbrOfChannel 1配合ScanConvMode DISABLE意味着每次软件触发只转换一个通道读一次 EOC 拿一个值。如果后面要扩到多通道这两个参数必须同时改只改一个会出现“读到的永远是第一个通道”的情况。校准那四行是最容易被抄漏的。F103 的 ADC 内部有电容失配不校准的话零点偏移能到十几个 LSB而且每块板子都不一样。校准必须在ADC_Cmd(ENABLE)之后、第一次转换之前完成顺序颠倒会读到固定值。2.2 采样时间与源阻抗电位器直连为什么会偏低采样保持电路可以等效成一个开关串一个 1kΩ 左右的电阻再对一个 8pF 的电容充电。采样时间就是给这个 RC 充电留的窗口窗口不够电容上的电压还没跟上输入电压就关门了读出来的码值必然偏低——而且信号源内阻越大偏低越明显。F103 的采样时间有 8 档从 1.5 个周期到 239.5 个周期。换算成时间要看 ADC 时钟12MHz 下总转换时间 (采样周期数 12.5) / 12MHz。采样周期总转换时间 12MHz粗略可接受的源阻抗典型用途1.51.17 us几 kΩ 以内运放跟随输出7.51.67 us约 10 kΩ低阻分压13.52.17 us约 20 kΩ一般传感器28.53.42 us约 40 kΩ电位器抽头55.55.67 us约 60 kΩ高阻信号、默认稳妥档239.521.0 us约 300 kΩ高阻或加了 RC表里的阻抗是按 8pF 采样电容、1kΩ 内部串联电阻、要求建立误差小于 0.5 LSB 粗略估的数量级参考即可具体以型号参考手册的公式为准。10kΩ 电位器两端接 3.3V 和 GND抽头处的输出阻抗最大是 2.5kΩ两端各 5k 并联用 28.5 或 55.5 周期完全够但如果你为了抗干扰在抽头和地之间并了 100nF那充电电荷由电容提供采样瞬间反而更稳代价是两次采样之间电容要重新跟上信号变化RC 时间常数必须远小于采样间隔。超过 VDDA 的输入不要直接怼上去。F103 的模拟输入有到 VDDA 的钳位二极管超过 3.3V 会通过二极管往电源灌电流轻则读数全乱重则烧引脚。测 5V 或 12V 必须分压分压电阻别用 100k100k 这种大阻值否则又回到采样时间不够的老问题常见做法是 10k10k 配 100nF。2.3 轮询、中断还是 DMA三条路径的取舍单通道、几十赫兹的刷新率轮询完全够用代码三行没有任何并发问题。这也是我在这套“采集显示串口”组合里默认选的方案因为 LCD1602 的写时序本身就要占几百微秒采样再快也快不过屏幕刷新用中断纯属给自己找麻烦。多通道扫描或者要几百赫兹以上的采样率时才轮到 DMA把 ADC 配成连续转换 扫描模式DMA 循环搬运到一个数组里CPU 只在需要的时候读数组。中断方式介于两者之间适合“转换完成后立刻做一次计算”的场景但在 115200 波特率下串口发送本身就是阻塞的中断里再调用 printf 会直接卡死。方案CPU 占用适用刷新率坑点软件触发轮询低阻塞等待约 6us1 ~ 200 Hz忘记清 EOC 标志转换完成中断中200 Hz ~ 2 kHz中断里做浮点运算DMA 循环搬运最低2 kHz 以上数组被覆盖时读到半新半旧3. 从码值到毫伏定点换算、均值滤波与 VREFINT 校准拿到 0~4095 只是半成品真正要显示和上传的是毫伏值。这一段有三层换算公式怎么写才不溢出、噪声怎么压、VDDA 不准怎么办。三层都做完读数才能拿去和万用表对。3.1 12 位码值到毫伏的定点化换算最直白的写法是mv code * 3300 / 4095。这里有两个细节值得较真一是满量程该除 4095 还是 4096二是浮点还是整数。严格按传递函数输入等于 VDDA 时输出码值是 4095但 4095 对应的理论输入是 VDDA × 4095/4096所以物理上更“正确”的是除 4096两者差 0.024%在 1% 精度的电位器面前毫无意义——工程上除 4095 更直观因为 4095 直接映射到 3300mV。我用 4095除非要写校准报告。浮点的问题在 Cortex-M3 上很实际F103 没有 FPU一次 float 除法走软件库要几十微秒放在 100Hz 的循环里不算什么但放进串口发送前的格式化里就有点浪费。更重要的是printf(%.3f)在 Keil 的 MicroLIB 下默认不支持浮点格式化屏幕上打出空白或者一串问号很多人卡在这里以为串口坏了。/* vconv.c —— 码值到毫伏全程整数运算 */ #define VREF_MV 3300u /* 名义 VDDA单位 mV */ #define ADC_FULL 4095u uint16_t code_to_mv(uint16_t code) { /* 先乘后除4095 * 3300 13,513,500uint32 装得下 ADC_FULL/2 是做四舍五入不加的话整体偏小半个 LSB */ return (uint16_t)(((uint32_t)code * VREF_MV ADC_FULL / 2) / ADC_FULL); } /* 把毫伏打成 3.298 这种定宽字符串避开浮点 printf */ void mv_to_str(uint16_t mv, char *buf) { uint16_t whole mv / 1000u; uint16_t frac mv % 1000u; /* 手动拼字符不依赖 sprintf省 2KB 左右的库空间 */ buf[0] (char)(0 whole); buf[1] .; buf[2] (char)(0 frac / 100u); buf[3] (char)(0 (frac / 10u) % 10u); buf[4] (char)(0 frac % 10u); buf[5] \0; }code_to_mv里最要紧的是“先乘后除”和强制转uint32。code是uint16_tcode * 3300在 16 位运算下会溢出回绕得到的结果毫无规律编译器不一定报警。加ADC_FULL/2是四舍五入代价是 1 个 LSB 的一半收益是显示值不会系统性偏低。mv_to_str是为了绕开浮点 printf。如果你坚持用printf(%.3f)在 Keil 里要勾选 Use MicroLIB 并开启浮点支持体积会增加好几 KB用 GCC 编译则要加-u _printf_float。这两种配置在不同工程模板里表现不一致我宁愿自己拼字符串行为完全可控。3.2 滑动窗口与中值滤波的组合拳12 位 ADC 在 3.3V 下 1 个 LSB 约 0.8mV理论分辨率不错但实际读数的抖动往往有 ±3 到 ±5 LSB。噪声来源有三类VDDA 上的开关电源纹波、采样时间不够导致的建立误差、以及信号本身的真实波动。前两类靠滤波能压第三类压不掉也不该压。我的默认组合是“9 点中值 16 点滑动平均”。中值负责干掉偶发的尖峰比如继电器吸合瞬间的干扰滑动平均负责压随机噪声16 点平均理论上能把噪声降到 1/4。/* filter.c —— 9 点中值 16 点滑动平均 */ #define MED_N 9 #define AVG_N 16 static uint16_t s_med[MED_N]; static uint16_t s_avg[AVG_N]; static uint8_t s_med_idx, s_avg_idx; static uint32_t s_avg_sum; static uint16_t median9(uint16_t *a) { uint16_t t[MED_N]; uint8_t i, j; for (i 0; i MED_N; i) t[i] a[i]; /* 9 个元素冒泡排序交换次数固定比快排更适合小数组 */ for (i 0; i MED_N - 1; i) for (j 0; j MED_N - 1 - i; j) if (t[j] t[j 1]) { uint16_t tmp t[j]; t[j] t[j 1]; t[j 1] tmp; } return t[MED_N / 2]; } uint16_t filter_push(uint16_t raw) { uint16_t med; s_med[s_med_idx] raw; s_med_idx (uint8_t)((s_med_idx 1) % MED_N); med median9(s_med); /* 先去尖峰 */ s_avg_sum - s_avg[s_avg_idx]; /* 滑动窗口减去最老的 */ s_avg[s_avg_idx] med; s_avg_sum med; s_avg_idx (uint8_t)((s_avg_idx 1) % AVG_N); return (uint16_t)(s_avg_sum / AVG_N); /* 窗口长 16编译器会自动优化成右移 */ }两个参数值得改MED_N建议用奇数5 点反应快但抗尖峰弱9 点是折中超过 15 点会让阶跃响应变慢到肉眼可见。AVG_N取 2 的幂除法会被编译器优化成移位16 或 32 都行取 8 时噪声改善约 2.8 倍取 32 改善约 5.6 倍但响应延迟也跟着涨。如果被测电压本身变化很快比如测电机 PWM 的平均电压滑动窗口就不能太长否则读数会“滞后”。滤波放在哪也很关键。我一般把filter_push放在采样之后、换算之前对原始码值滤波这样毫伏换算只做一次。反过来的写法先转毫伏再滤波多一次乘法且在整数除法下会引入额外的截断误差。3.3 用内部基准反推 VDDA前面所有换算都假设 VDDA 就是 3300mV。现实中 USB 供电的板子 VDDA 可能是 3.28VLDO 输出可能 3.35V电池供电时更是从 4.2V 一路掉到 3.3V。这个偏差是系统性的1.5% 的误差在 3.3V 上就是 50mV比滤波能改善的噪声大得多。F103 内部有一个 VREFINT 通道通道 17典型值 1.20V出厂时每颗芯片的实际值被校准并烧在系统存储区。采到这个通道的码值后就能反推当前的真实 VDDA/* vref.c —— 用 VREFINT 反推 VDDA修正换算基准 */ /* 常见位置 0x1FFFF7BA具体以对应型号参考手册为准 */ #define VREFINT_CAL_ADDR ((uint16_t *)0x1FFFF7BA) #define VREFINT_TYP_MV 1200u void adc_vrefint_init(void) { /* 通道 17 采样时间要求 17.1us用最大档 239.5 周期最保险 */ ADC_RegularChannelConfig(ADC1, ADC_Channel_17, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); } uint16_t read_vdda_mv(void) { uint32_t code, vdda; uint16_t cal *VREFINT_CAL_ADDR; /* 出厂校准码值只读 */ ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); code ADC_GetConversionValue(ADC1); if (code 0) return 3300; /* 防除零退回到名义值 */ /* VDDA 1.20V * 4095 / VREFINT码值用出厂校准码可以进一步消掉误差 */ vdda (uint32_t)VREFINT_TYP_MV * cal / code; return (uint16_t)vdda; }VREFINT_CAL_ADDR指向出厂写入的校准码值是“VDDA 恰好 3.3V 时 VREFINT 通道的读数”。用它比用固定的 1200mV 典型值更准因为每颗芯片的 VREFINT 实际值有 ±30mV 的离散。换算时改成VDDA 3300 * cal / code就能把离散也消掉。这个函数不要每个采样周期都调。VREFINT 的采样需要 17.1us 以上加上切换通道的开销会让主循环明显变慢。我的做法是上电后采 8 次取平均算出一个g_vdda_mv存起来之后每 5 秒或温度变化超过 5 度时再刷新一次。4. 两条输出通路LCD1602 刷屏与串口帧格式怎么对齐采样和换算做完数据要送到两个地方给现场的人看给电脑记。这两条通路的脾气完全不同——LCD 慢且对时序敏感串口快但格式必须能被解析器认出。放在同一个主循环里顺序和节流方式决定了最后是“稳定显示”还是“两边都在抖”。4.1 LCD1602 的初始化时序与写操作1602 是字符屏控制器兼容 HD447808 位并口模式下需要 RS、RW、EN 三根控制线和 8 根数据线。最省事的接法是 RW 直接接地只写不读省掉忙标志检测代价是每条命令后必须给足延时。初始化顺序不能变上电后等 40ms 以上然后依次写 0x388 位、2 行、5×7 点阵、0x0C开显示、关光标、0x06写入后地址加 1、0x01清屏之后要等 1.52ms 以上。/* lcd1602.c —— 8 位并口RW 接地纯写模式 */ #include stm32f10x.h #define LCD_RS_H() GPIO_SetBits(GPIOB, GPIO_Pin_0) #define LCD_RS_L() GPIO_ResetBits(GPIOB, GPIO_Pin_0) #define LCD_EN_H() GPIO_SetBits(GPIOB, GPIO_Pin_1) #define LCD_EN_L() GPIO_ResetBits(GPIOB, GPIO_Pin_1) static void lcd_delay_us(uint32_t n) { /* 粗略软件延时72MHz 下每轮约 0.1us够 LCD 用 */ while (n--) { __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); } } static void lcd_write_byte(uint8_t dat) { /* 数据线用 PB8~PB15先清后写 */ GPIOB-BRR 0xFF00; GPIOB-BSRR ((uint32_t)dat) 8; LCD_EN_H(); lcd_delay_us(2); /* 使能脉宽 450ns这里给足 2us */ LCD_EN_L(); lcd_delay_us(50); } void lcd_cmd(uint8_t cmd) { LCD_RS_L(); lcd_write_byte(cmd); } void lcd_data(uint8_t dat) { LCD_RS_H(); lcd_write_byte(dat); } void lcd1602_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_11 | GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15; gpio.GPIO_Mode GPIO_Mode_Out_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, gpio); lcd_delay_us(50000); /* 上电等 50ms手册要求 40ms */ lcd_cmd(0x38); /* 8 位、双行、5x7 */ lcd_delay_us(2000); lcd_cmd(0x0C); /* 开显示、无光标、不闪烁 */ lcd_delay_us(2000); lcd_cmd(0x06); /* 写入后地址自动 1 */ lcd_delay_us(2000); lcd_cmd(0x01); /* 清屏 */ lcd_delay_us(3000); /* 清屏要 1.52ms 以上这里给 3ms */ } /* 在指定行列写字符串row: 0/1col: 0~15 */ void lcd_puts(uint8_t row, uint8_t col, const char *s) { uint8_t addr (uint8_t)((row 0 ? 0x00 : 0x40) col); lcd_cmd((uint8_t)(0x80 | addr)); /* 0x80 是设置 DDRAM 地址命令 */ while (*s) lcd_data((uint8_t)*s); }三个延时是最容易抄错的地方。上电 40ms 那一条很多人写成 1ms结果上电后第一行显示黑块、后面全空清屏命令要 1.52ms 那条更隐蔽写成 100us 后屏上会残留上一次的字符看起来像“刷新不干净”。我用软件延时的原因是 1602 在 4 位模式下连忙标志都可能读不准干脆全部给足固定时间比调时序省事得多。lcd_puts里那个0x80是设置 DDRAM 地址命令第二行起始地址是 0x40不是 0x10写错的话第二行会从第一行后半段开始显示。4.2 显示刷新节流与定宽格式化刷屏频率是个需要主动设计的参数。1602 写一个字符大约 50us一行 16 个字符接近 1ms加上换行和命令开销一次完整刷新接近 2ms。如果主循环每 10ms 采一次样就刷一次屏CPU 有 20% 的时间卡在 LCD 上采样节拍还会被拉得忽快忽慢串口输出的时间戳就全乱了。我的做法是把刷新固定在 100ms采样保持 10ms 一次中间累积 10 个样本。这样屏幕上的数字每秒跳 10 次人眼看着是连续变化又不会闪。采样和串口发送各走各的节拍互不影响。/* display.c —— 定宽格式化避免位数变化导致残影 */ void lcd_show_mv_at(uint16_t mv) { char line[17]; uint16_t whole mv / 1000u; uint16_t frac mv % 1000u; /* 固定 16 个字符不足补空格V 后面顶格 */ line[0] U; line[1] ; line[2] ; line[3] (char)(0 (whole % 10u)); line[4] .; line[5] (char)(0 frac / 100u); line[6] (char)(0 (frac / 10u) % 10u); line[7] (char)(0 frac % 10u); line[8] V; line[9] ; line[10] \0; lcd_puts(0, 0, line); /* 整行覆盖不做局部更新 */ } /* 主循环里的节流10ms 采样、100ms 刷屏、100ms 串口 */ void main_loop(void) { uint16_t tick 0; while (1) { uint16_t raw adc_read_once(); uint16_t mv code_to_mv(filter_push(raw)); if (tick % 10 0) { lcd_show_mv_at(mv); /* 100ms 一次 */ uart_send_frame(mv, raw); /* 与刷屏同节拍方便比对 */ } delay_ms(10); } }lcd_show_mv_at里把整行补成 16 字符再写的用意是避免“3.298”和“3.29”这种位数变化留下残影。如果只写有效字符从 3.298 变到 3.30 时最后一位的 8 会留在屏上看起来像 3.308。整行覆盖是零成本的解法。采样和刷屏同节拍有个额外好处屏幕上的数和串口发的数是同一个样本对着看能立刻发现“哪一个通路的问题”。如果采样 10ms、刷屏 100ms 但串口 10ms 发一次就会看到串口数据在跳而屏幕不动误以为滤波器有问题。4.3 串口重定向 printf 与帧结构USART1 挂在 APB2PA9 是 TX、PA10 是 RX115200-8-N-1 是最通用的配置。72MHz 下USARTDIV 72e6 / (16 × 115200) 39.0625小数部分用 1/16 表示误差极小不需要额外补偿。重定向 printf 是标准操作Keil 下勾选 Use MicroLIB然后实现fputcGCC 下实现_write。真正需要设计的是帧结构——如果你的数据是给上位机解析的纯文本的“3.298”没法区分断帧一旦丢一个字节后面全错位。/* uart.c —— USART1 115200-8-N-1带帧头帧尾和异或校验 */ #include stm32f10x.h #include stdio.h void uart1_init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); gpio.GPIO_Pin GPIO_Pin_9; /* TX 复用推挽 */ gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_10; /* RX 浮空输入 */ gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, gpio); usart.USART_BaudRate 115200; usart.USART_WordLength USART_WordLength_8b; usart.USART_StopBits USART_StopBits_1; usart.USART_Parity USART_Parity_No; usart.USART_HardwareFlowControl USART_HardwareFlowControl_None; usart.USART_Mode USART_Mode_Tx | USART_Mode_Rx; USART_Init(USART1, usart); USART_Cmd(USART1, ENABLE); } /* Keil MicroLIB 的重定向入口 */ int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; } /* 帧格式$V,3.298,4092,2A\r\n 帧头 $V、毫伏、原始码、异或校验 */ void uart_send_frame(uint16_t mv, uint16_t raw) { uint8_t sum 0; sum ^ (uint8_t)(mv 8); sum ^ (uint8_t)(mv 0xFF); sum ^ (uint8_t)(raw 8); sum ^ (uint8_t)(raw 0xFF); /* 用整数拆位不依赖浮点 printf */ printf($V,%u.%03u,%u,%02X\r\n, mv / 1000u, mv % 1000u, raw, sum); }帧头$V让解析器可以靠它重新同步\r\n给了行尾异或校验覆盖了电压和原始码两个字段。校验值我特意用了简单的按字节异或因为上位机用 Python 解析时一行代码就能算不需要引入 CRC 库。频率上115200 下传一行约 22 字节大约 1.9ms100ms 发一次占用不到 2% 的带宽完全不用担心阻塞。但如果有人把串口发送塞进 ADC 的中断里一旦波特率降到 9600一次发送就 23ms中断会一直挂着主循环直接卡死——这是后面避坑章里要展开的一条。5. 避坑排查五个把读数搞废的现场下面五条都是我在不同板子上真实撞过的按“现象、原因、解决”写。每条的排查代价都超过半天希望你能跳过。5.1 采样侧读数乱跳和整体偏低现象一读数在 ±20 LSB 上下随机跳滤波开到 32 点也压不住。原因通常不在代码而在 VDDA。USB 供电的板子VBAT 到 VDDA 之间只有一个 0.1uF开关电源的纹波直接叠在参考电压上ADC 采多少全看运气。另一个常见原因是采样时间设成了 1.5 周期源阻抗只要超过 5k 就开始排队充电。 解决先用示波器看 VDDA 引脚上的交流成分超过 20mVpp 就得加 LC 或换 LDO把采样时间改到 55.5 周期源阻抗高的再往上加到 239.5。现象二所有读数比万用表系统性低 80~150mV。这个“系统性”是关键噪声是随机的偏移是固定的。原因有三个GPIO 配成了GPIO_Mode_IN_FLOATING而不是GPIO_Mode_AIN内部保护电路会钳住一部分电平分压电阻后面没有加滤波电容采样瞬间电压被拉低或者 VDDA 实际是 3.2V你还在按 3300 算。 解决先检查GPIO_Init的 Mode再在分压点对地并 100nF最后用 VREFINT 反推一次真实 VDDA 对账。5.2 输出侧屏花和串口乱码现象三LCD 上电后第一行全黑块或者只有一半字符显示。原因是初始化延时不够。1602 内部复位需要 40ms 以上很多人上电就跑lcd_cmd(0x38)控制器还没准备好命令被吞掉。另一个原因是使能脉冲太窄EN拉高后立刻拉低实际脉宽不到 100ns控制器采样不到。 解决上电延时给到 50msEN脉宽给 2us 以上清屏后等 3ms。如果还是花屏检查 RW 是不是真的接地了——悬空的 RW 会让控制器进入读状态数据线冲突。现象四串口助手收到一堆乱码偶尔夹着几个正常字符。按概率排波特率不匹配HSE 没起振导致系统时钟退回 HSI 8MHz此时 115200 的配置实际输出 12800 左右TX/RX 接反没有共地。 解决先量 PA9 上的波形用示波器测一个字节的时间宽度115200 下一位是 8.68us对不上就是时钟问题。再检查SystemInit里有没有等 HSE 就绪起振失败会静默退出到 HSI程序照跑只是所有时序快了 9 倍。现象五printf(%.3f)打出来是空白或者“??”。原因是 MicroLIB 默认不带浮点格式化编译器把%f当成了未知格式符。这不是串口的问题是 C 库的配置问题。 解决要么在 Keil 的 Target 选项里勾选浮点支持体积增加约 3KB要么按 3.1 节的办法手工拼字符串。我在 F103 这种 64KB Flash 的片子上从来选后者。5.3 系统级校准顺序和中断里的阻塞调用现象六校准做完了读数还是有一个固定的几十 LSB 偏移。大概率是校准的顺序错了。正确顺序是开 ADC 时钟 →ADC_Cmd(ADC1, ENABLE)→ 复位校准 → 等复位完成 → 启动校准 → 等校准完成 → 配通道 → 触发转换。我见过把ADC_Cmd放在最后的写法校准在 ADC 关闭状态下执行等于没做而不做校准的管子零点偏移就是几十 LSB。 解决把校准代码固定成一个adc_calibrate()函数每次上电调用一次不要散落在初始化里。另外单次转换模式下每次ADC_SoftwareStartConvCmd之前不需要重新校准别在循环里反复调那个过程要几十个周期。还有一条容易忽略的串口发送是阻塞的while (USART_GetFlagStatus(...) RESET)会一直等。如果波特率是 9600发一行 22 字节要 23ms放在 10ms 的采样循环里会让采样节拍彻底失真。要么把波特率提到 115200要么改成 DMA 发送不要在中断里调 printf。6. 让读数能交差两点校准、上位机回放与一条验证流水线板子跑起来、屏上有数、串口有输出只能算“能用”。要拿去记录数据、写进测试报告还得证明这个数是准的。我现在的习惯是上电先跑一遍三点验证短接 GND 看零点、接一个已知基准看中点、接 VDDA 看满量程三个点对不上就不往下走。零点这一项最容易被跳过。把 PA0 直接接地读数应该是 0~2 LSB如果稳定在 30 以上说明校准没生效或者输入端有漏电流。满量程这一项要把 PA0 接到 VDDA注意是同一个网络别接到 3.3V 排针上中间可能隔了磁珠读数应该在 4090~4095。两点校准是给有精度要求的场景准备的。用万用表量两个实际电压点比如 1.000V 和 2.500V记录串口输出的码值算出增益和偏移# calib.py —— 用两点实测反算增益和偏移输出直接写进固件 import serial, re, time def read_once(ser): line ser.readline().decode(ascii, errorsignore).strip() m re.match(r\$V,(\d\.\d),(\d),([0-9A-F]{2}), line) if not m: return None return float(m.group(1)), int(m.group(2)) def collect(ser, n20): vals [] while len(vals) n: v read_once(ser) if v: vals.append(v[1]) time.sleep(0.05) vals.sort() return vals[len(vals)//2] # 取中位数避开偶发尖峰 ser serial.Serial(COM3, 115200, timeout1) input(把探头接到 1.000V回车继续) c1 collect(ser) input(把探头接到 2.500V回车继续) c2 collect(ser) # 码值到毫伏的线性修正mv (code - off) * gain gain (2500.0 - 1000.0) / (c2 - c1) off c1 - 1000.0 / gain print(f#define CAL_GAIN {gain:.6f}f) print(f#define CAL_OFF {off:.2f}f)这段脚本的关键在中位数取值而不是平均因为串口偶尔会丢帧或者被 USB 转串口的驱动打断平均值会被一两个异常值拉偏。gain的单位是 mV/codeoff是码值偏移写回固件后换算函数改成mv (uint16_t)((code - CAL_OFF) * CAL_GAIN)注意CAL_OFF是浮点得先转成定点或者用 Q 格式别在中断里跑浮点。验证的时候我会同时做三件事屏幕读数、串口日志、万用表实测三个数在同一时刻对齐。如果屏幕和串口一致但和万用表差 100mV问题在采样侧如果屏幕和万用表一致但串口差问题在格式化如果三个都不一样先回去看 VDDA。血泪经验是每次改完 ADC 配置哪怕只改了一个采样周期也要把 GND 和 VDDA 两个点重新跑一遍。我有一次把采样时间从 55.5 改到 1.5 想提高刷新率零点直接从 2 LSB 跳到 40 LSB屏上显示 0.03V第一眼还以为是代码抄错了查了两小时才发现是采样窗口不够、内部采样电容没放干净。从那以后我把“短接测零点”写成了上电自检的一部分开机第一件事就是看 GND 的读数是否小于 3 LSB不过就闪个提示不进入主循环。希望帮到你。本文还有配套的精品资源点击获取

相关推荐

Makefile与终端进度条开发实战指南
Makefile与终端进度条开发实战指南

1. 项目概述在Linux/Unix开发环境中,make工具和Makefile文件是每个开发者必须掌握的核心技能。它们就像项目构建的"自动化流水线",能够根据文件依赖关系智能地只重新编译需要更新的部分。而实现一个终端进度条,则是检验Makefile编写… · 2026/11/5 5:55:13

多智能体系统涌现性风险:机制与防控实践
多智能体系统涌现性风险:机制与防控实践

1. 项目背景与核心议题去年在斯坦福的虚拟小镇实验中,25个GPT-4智能体自主运行两周后,意外出现了信息窃取、社交操纵等未被预设的行为模式。这个现象让我开始系统性研究智能体涌现性风险(Emergent Risk)——当多个AI智能体在复杂环… · 2026/11/5 5:55:13

Makefile自动化构建工具:从基础到高级应用
Makefile自动化构建工具:从基础到高级应用

1. Make与Makefile基础解析第一次接触Make工具是在2013年维护一个C跨平台项目时,当时被各种依赖关系搞得焦头烂额。Makefile就像项目的交通指挥系统,它能自动判断哪些文件需要重新编译,哪些可以跳过,这个认知彻底改变了我对构建流… · 2026/11/5 5:55:13

预期功能安全SOTIF:无故障车辆为何仍会撞人?
预期功能安全SOTIF:无故障车辆为何仍会撞人?

简介:面向智能网联汽车功能安全从业者、系统与软件工程师及高校相关专业学习者,这份资料提供预期功能安全(SOTIF)领域的核心标准文本,重点解决自动驾驶与辅助驾驶系统在无故障情况下因性能局限、感知偏差和算法约束而引… · 2026/11/5 8:33:14

智能驾驶中的鸟瞰视角(BEV)技术解析与应用实践
智能驾驶中的鸟瞰视角(BEV)技术解析与应用实践

1. 鸟瞰视角在智能驾驶中的核心价值在智能驾驶系统中,鸟瞰视角(BEV,Birds Eye View)技术正逐渐成为环境感知的关键组成部分。与传统的前视或环视摄像头相比,鸟瞰视角通过将多个摄像头的图像拼接并投影到俯视平面&#… · 2026/11/5 8:33:14

vLLM分布式数据并行原理与调优实践
vLLM分布式数据并行原理与调优实践

1. 项目背景与核心概念 在大模型推理领域,分布式并行技术是突破单卡显存限制的关键手段。最近在调试一个基于vLLM框架的分布式数据并行(DP)实现时,遇到了几个典型问题。这里记录下vLLM中DP并行的实现原理、常见问题及解决方案。 vLLM作为当前最流行的高… · 2026/11/5 8:33:14

MySQL内置函数实战技巧与性能优化指南
MySQL内置函数实战技巧与性能优化指南

1. MySQL内置函数全面解析作为一名长期与MySQL打交道的开发者,我发现很多刚入门的同行对内置函数的理解仅限于表面用法。今天我将结合多年实战经验,深度剖析MySQL最常用的四类内置函数,带你掌握那些官方文档里没写的实用技巧。2. 日期函数实战… · 2026/11/5 8:33:14

0-1 BFS算法在水质检测最短路径问题中的应用
0-1 BFS算法在水质检测最短路径问题中的应用

1. 问题背景与算法选型蓝桥杯竞赛中的"水质检测"问题是一个典型的图论最短路径问题。题目通常描述为:给定一个NM的网格地图,其中某些网格是污染源(障碍物),检测员需要从起点出发,找到到达目标点的… · 2026/11/5 8:33:14

高效会议互动设计:10个实战方案提升40%会议效率
高效会议互动设计:10个实战方案提升40%会议效率

1. 会议互动环节设计的核心价值会议冷场和跑题是职场中最常见的两大痛点。根据我过去五年为37家企业设计会议流程的经验,传统会议模式中平均每小时的无效时间高达18分钟。而精心设计的互动环节能将会效提升40%以上,这个数据来自我们对实施新会议模式的23… · 2026/11/5 8:32:13

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38

多智能体协作系统架构设计与实践优化
多智能体协作系统架构设计与实践优化

1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39

大模型系统性入门实战指南:从原理到部署微调的全链路路线
大模型系统性入门实战指南:从原理到部署微调的全链路路线

提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39

鸿蒙文件系统沙箱机制解析与开发实践
鸿蒙文件系统沙箱机制解析与开发实践

1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码