news 2026/9/8 14:16:05

STM32实战:ADC电阻分压省IO与Modbus浮点传输

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32实战:ADC电阻分压省IO与Modbus浮点传输

2. 开头:IO不够用还硬挤,这是我这次调试的真实状态

做嵌入式调试,最怕的不是逻辑复杂,而是资源快用完了还得硬挤。最近在调一块小控制板,主控选的是STM32F103这颗老将,IO口已经排到了极限,原来面板上还预留了一个4档旋转开关用来切工作模式。最初的设计是每个档位拉一个IO进来检测,结果一数引脚直接爆炸。另一边,设备还要通过Modbus RTU向上位机上报数据,其中就有一个float类型的温度测量值。Modbus寄存器只有16位,float要占32位,怎么拆分、怎么还原、大小端怎么统一,这里面的坑比想象中多。两个问题叠在一起,正好凑成这一篇调试笔记。

这篇内容主要分两条线:一条讲怎么用单路ADC加电阻分压网络,把4档旋转开关的检测IO从4个压缩到1个,还顺便把判档稳定性做上去;另一条讲Modbus通讯里float的存储规则、拆分还原的三种常见代码实现,以及调试时怎么快速判断设备用的是哪种字节序。适合正在做STM32、GD32这类MCU项目,手头IO紧张、又要走Modbus上报浮点数的朋友参考。内容都是实际调板子过程中验证过的,不是理论推演。

1. 旋转开关为什么需要“省IO”采集

1.1 传统接法到底吃掉了多少IO

4档旋转开关常见的形态有两种:一种是单刀多掷型(1P4T),公共端COM加四个固定端;另一种是编码输出型,内部集成了类似BCD码的逻辑,四个引脚输出二进制组合。无论哪种,传统做法里都得占IO——单刀多掷的需要4个输入IO,编码型的也得4个。碰上板子引脚本来就紧张的时候,这4个IO是真的心疼。

其实旋转开关的应用场景很固定:设备前面板上的模式切换,比如手动、自动、远程、本地四档。操作频率很低,一个档位可能几个月都不动一次,但它就是得让人能随时手动切。既然操作频率低、切换速度慢,那采集速度根本不需要毫秒级,反而更需要抗抖动、抗误判。这个特性决定了它非常适合用慢速ADC来采集,而不是占用宝贵的快速IO口。

1.2 省IO思路:一个ADC通道解决全部档位

思路其实一句话就能说清:把4个档位分别接4个不同阻值的下拉电阻,公共端接ADC引脚,再用一个上拉电阻把ADC引脚拉到参考电压。开关转到哪个档位,ADC引脚就被拉到一个固定的分压值。这样不管多少档,3档也好4档也罢,一个ADC通道就能全包。

这个思路本质上是把“档位信息”编码成了“模拟电压”,再用ADC量化。付出的代价无非是几个电阻,换回来的是省下3~4个IO,而且这些IO往往还能复用到其他数字功能上。另一个隐藏的好处是:如果MCU的IO分配实在紧张,这个ADC引脚甚至可以和别的传感器分时复用,只要切换开关别太频繁就行。

不过ADC分压方案最怕两件事:一是电阻误差导致档位电压重叠,二是软件滤波做得不够导致判档抖动。前者要靠选型和计算来兜底,后者要靠在程序里做迟滞判断和多次采样来规避。这两块我在后面会详细展开。

2. 电阻分压网络:原理、计算与实操

2.1 电路结构设计

我用的是最经典的“上拉固定+下拉切换”结构。原理图很简单,三条线路:VCC(3.3V)经过一个固定上拉电阻R_p接到ADC输入脚PA1;PA1再经过旋转开关的公共端COM;旋转开关的4个固定端分别接档位电阻R1~R4到GND。

开关在哪个档位,就等于把对应的档位电阻接到PA1和GND之间,与上拉电阻形成分压。PA1的电压公式:

Vadc = VCC × R_档 / (R_p + R_档)

注意,档位1我刻意选择直接接地,也就是R1 = 0Ω,不要让任何一个档位悬空。这个细节很关键,悬空就意味着ADC引脚对地阻抗无限大,外部干扰随便一碰就会跳变,判档很容易出错。

2.2 阻值计算与档位划分

电阻选型我有几个约束:上拉电阻不能太大,否则ADC引脚输入阻抗并联后会拉低分压精度;也不能太小,否则一直有个毫安级的电流在跑,低功耗场景下扛不住。折中下来R_p选10KΩ。

档位电阻我选了四颗:R1 = 0Ω、R2 = 2KΩ、R3 = 4.7KΩ、R4 = 10KΩ。12位ADC满量程4095,按VREF = 3.3V折算:

档位下拉电阻理论电压ADC理论值判定阈值
10V0< 300
22KΩ0.55V682300 ~ 1150
34.7KΩ1.06V13171151 ~ 2010
410KΩ1.65V2048> 2010

阈值不是简单取均值的,而是反过来偏向相邻低档位:判档在相邻值中位线附近再加一个迟滞窗口。比如档位1和档位2的理论中位线在341,我实际取的判档边界是300,同时在连续两次采样方向不一致时允许保留上一次结果,这样开关在临界点反复抖动时不会来回跳。

三组电阻的压差计算下来:第1~2档差0.55V,第2~3档差0.51V,第3~4档差0.59V,最小压差也超过了40%的满量程,后面做AD采样就算碰到1%精度电阻、温漂十几ppm的叠加,也足够稳稳拉开。

2.3 软件滤波与判档逻辑

切换开关本质上是个机械过程,触点接触瞬间极容易弹跳,ADC值会短时间乱跳。如果主循环跑得快,一个乱跳的采样值就足以让系统误判档位,轻则显示错误,重则跑错整套工作逻辑。

软件上我做三层滤波:

第一层是连续8次采样取平均,中间剔除最大最小值,基本把随机噪声压下去。第二层是判档迟滞,也就是上文提到的阈值窗口,只有连续2次进入同一个区间才更新档位。第三层是在开关切换后的前80ms内强制丢弃采样结果,给触点弹跳留足稳定时间。三管齐下之后,我拿示波器看过PA1引脚电压,切换瞬间的毛刺基本被软件完全兜住,实测几百次手动切换零误判。

采样代码大概长这样:

#define SW_ADC_CH 1 #define SW_SAMPLE_CNT 8 #define SW_DEBOUNCE_MS 80 uint8_t get_switch_position(void) { static uint8_t last_pos = 0; uint8_t pos = 0; uint16_t adc_value; if (sw_timer_elapsed < SW_DEBOUNCE_MS) return last_pos; adc_value = adc_sample_filtered(SW_ADC_CH, SW_SAMPLE_CNT); if (adc_value < 300) pos = 1; else if (adc_value < 1150) pos = 2; else if (adc_value < 2010) pos = 3; else pos = 4; if (pos == last_pos) { return pos; } else { last_pos = pos; return pos; } }

2.4 硬件设计上容易踩的坑

第一坑是ADC参考电压和VCC不一致。如果MCU的VDDA是3.0V而分压网络用的是3.3V,那所有电压值都得重新折算,否则判档边界全偏。所以分压网络的供电我直接接到VDDA,保证参考和采样本源一致,算起来最省心。

第二坑是PCB布线。开关离MCU远了中间线要短接压降,我这条线长度差不多5cm,走线宽度0.5mm,信号出来不加跟随器问题也不大。但要是板子结构决定线特别长,ADC输入端的滤波电容一定要加上,一般是10nF到100nF之间,我用的是33nF,对分压点位没有可感知的影响。

第三坑是档位电阻的精度。如果只求功能演示那5%的厚膜电阻够了,但工业环境下还是建议1%精密电阻。四档位电阻值跨了三个数量级还包含0Ω直地,用一排直插排阻也方便生产。这里我提醒一句:如果档位1是直地,必须确认开关在档位1时的接触电阻足够小,不然分压出来的电压会偏离0V,导致第一档误判。

3. Modbus中float的存储逻辑与坑

3.1 为什么一个float要占用两个寄存器

Modbus协议按寄存器(Register)操作,每个寄存器16位。但IEEE 754单精度浮点数float是32位的,由1位符号位、8位指数位、23位尾数位构成。这意味着一个float要拆成两个16位寄存器才能传输,这在Modbus的保持寄存器(Holding Register)里是再常见不过的操作。

关键问题在于:两个寄存器里装的是哪个字节?顺序怎么排?先发哪个寄存器?不同的厂商在这里有各自的习惯,甚至同一个厂商不同型号设备都不同。网上经常有人问“我用Modbus读回来的float完全是个天文数字”或者“符号位对不上”,绝大多数都是字节序和字序组合不对导致的。

3.2 字节序与字序的四种组合

先约定一个float变量的内存字节,从低地址到高地址依次记为A、B、C、D,注意单片机的内存布局不一定和传输顺序一致。

假设一个浮点数25.5f,IEEE754下它的32位二进制是0x41CC0000。在大端内存布局里,从低地址到高地址依次是0x41、0xCC、0x00、0x00。在x86小端系统上,从低地址到高地址则是0x00、0x00、0xCC、0x41。

Modbus传输时,常用的四种组合如下:

模式寄存器1寄存器2常见于
标准大端 AB CD0x41CC0x0000大多数工业仪表、西门子PLC
字节交换 CD AB0xCC410x0000部分国产仪表、流量计
全小端 GH EF0x00000xCC41部分PLC/DCS、某些国产设备
字交换 EF GH0x00000x41CC部分运动控制设备

这里的ABC D到底是哪个对应内存哪个,严格讲取决于程序怎么memcpy和排列。实际调试时更快的办法不是背这些表,而是直接看设备手册里写的“寄存器字序”和“字节序”,或者干脆发一个已知数去试探。

我自己调试时最常用的一招:给从站写一个已知浮点数,比如把1.0这个float拆到两个寄存器里,1.0的IEEE表示是0x3F800000。如果读回来寄存器值(按地址从低到高)是0x3F80、0x0000,那就是标准大端;如果是0x0000、0x3F80,那就是字交换;如果是0x803F、0x0000,字节交换;如果是0x0000、0x803F,全小端。用这个办法一次就能定下来,不用去猜。

4. 实操代码:float的拆分与还原

4.1 方式一:union共用体,最简单直观

C语言里union是最惯用的做法,我实际项目里大部分情况也是用它。union的好处是内存不重新拷贝,直接按两种视角看同一段内存,代码又短又不容易出错。

typedef union { float f; uint16_t u16[2]; uint8_t u8[4]; } float_byte_t; /** * @brief 将float拆成两个Modbus寄存器(标准大端模式) * @param value 浮点数 * @param regs 输出的寄存器数组,regs[0]为高16位 */ void float_to_regs_big(float value, uint16_t regs[2]) { float_byte_t fb; fb.f = value; // 小端系统:u16[1]是高16位,u16[0]是低16位 regs[0] = fb.u16[1]; regs[1] = fb.u16[0]; } /** * @brief 两个Modbus寄存器还原为float(标准大端模式) */ float regs_to_float_big(const uint16_t regs[2]) { float_byte_t fb; fb.u16[0] = regs[1]; fb.u16[1] = regs[0]; return fb.f; }

这里注意一个问题:上面的代码默认目标单片机是小端系统(STM32就是小端)。如果代码要跨平台,比如既跑在STM32又跑在PC上,那直接用u16[1]/u16[0]这种写法在不同平台结论是相反的。所以我更推荐下面这个基于字节指针的通用写法。

4.2 方式二:字节指针操作,跨平台通用

直接用uint8_t指针取float的4个字节,再手动拼寄存器。这样做的好处是,无论系统是大小端,都能通过显式的字节下标拿到准确位置。下面这段是标准大端模式的实现:

void float_to_modbus_big(float value, uint16_t regs[2]) { uint8_t *p = (uint8_t *)&value; regs[0] = ((uint16_t)p[3] << 8) | p[2]; // 最高16位 regs[1] = ((uint16_t)p[1] << 8) | p[0]; // 最低16位 } float modbus_big_to_float(const uint16_t regs[2]) { union { float f; uint8_t b[4]; } fb; fb.b[3] = (uint8_t)(regs[0] >> 8); fb.b[2] = (uint8_t)(regs[0] & 0xFF); fb.b[1] = (uint8_t)(regs[1] >> 8); fb.b[0] = (uint8_t)(regs[1] & 0xFF); return fb.f; }

这个写法看着死板,但它稳。字节下标全在代码里写死了,后面如果要切换四字节序的另一种组合,改的是下标顺序而不是整体逻辑。我在调不同品牌仪表时经常要做这组宏:

#define BIG_ENDIAN_WORD 0 #define BYTE_SWAP 1 #define WORD_SWAP 2 #define LITTLE_ENDIAN_ALL 3

4.3 方式三:32位整型中转,适合无优化场景

还有一种方法是先把float原样拷贝到一个uint32_t变量里,然后用移位操作拆成两个16位。这个方法对编译器优化不敏感,代码可读性也不错,尤其适合老式8051、STM32不开优化时使用的场景。

uint32_t tmp; memcpy(&tmp, &value, 4); // 借用整型中转,规避大小端别名问题 uint16_t hi = (uint16_t)(tmp >> 16); uint16_t lo = (uint16_t)(tmp & 0xFFFF); // 标准大端 regs[0] = hi; regs[1] = lo;

还原更简单:

uint32_t tmp = ((uint32_t)regs[0] << 16) | regs[1]; float result; memcpy(&result, &tmp, 4); return result;

这个方式本质还是利用大端模式下的高字在前,在标准大端模式下用起来最顺手。如果设备要用小端,就调换regs[0]和regs[1]的顺序。

4.4 验证与测试:别等联调才发现字节序反了

写完成分还原函数,立刻做自测,不要等到整机联调再查。我自己用的也是最简单有效的办法:在main函数里跑一段自检代码,把已知的浮点数拆完再合成,对比误差;再拿几个边界值测一下,包括0.0、负数、大于65535的数、NaN这种非常规值。

void float_self_test(void) { float test_values[] = {0.0f, 1.0f, 25.5f, -3.14f, 65535.0f}; uint16_t regs[2]; float result; uint8_t i; for (i = 0; i < sizeof(test_values)/sizeof(float); i++) { float_to_regs_big(test_values[i], regs); result = regs_to_float_big(regs); if (result - test_values[i] < 0.0001 && test_values[i] - result < 0.0001) { // 测试通过 } else { // 记录错误测试向量 } } }

如果你手边有串口调试助手或者上位机模拟工具,比如Modbus Poll这类主站软件,也可以直接把从站跑起来,用Modbus Poll读取对应寄存器地址,看看读回来的值是不是你写入的数。这个方法能一次性验证寄存器地址安排、字节序、设备地址等多个环节,比单独看代码靠谱得多。

5. 调试中遇到的坑与排查技巧

5.1 旋转开关档位误判的现场故事

我第一次在样机上调这个分压方案时,出现了大面积误判:档位2和档位3在切换后偶尔显示成彼此。排除代码逻辑之后,用万用表量了PA1引脚,发现两个档位的实际电压和理论值差了将近100mV。排查下来是电阻选型用了5%精度的普通电阻,R_p实际量出来是10.4K,档位2电阻量出来1.9K,两个偏差叠加,把相邻档位的间隙吃掉了不少。

换掉电阻之后我顺便加了一处小改进:把ADC采样和判档放进同一个非阻塞流程里,采样期间不响应外部事件,避免主循环在处理其他任务时采样点被拉长导致滤波均值失真。

5.2 Modbus float数完全不对的排查清单

Modbus读回来的float不对,通常不是通讯错误,而是字节序/字序匹配错误。我整理了一个排查顺序:

第一步,确认寄存器地址有没有错位。很多设备从地址4开始放第一个float,有些人手册没看清,从地址0或者5开始读,读回来的值自然不对。

第二步,用已知数值确认模式。给从站写入一个结构明确的数,比如1.0就是0x3F800000,看看读回来的两个寄存器分别是多少,对照上面的模式表锁定设备用的哪种排序。

第三步,确认主站和从站的模式一致。Modbus Poll这类软件大多带浮点格式设置,默认是大端,如果从站是小端,两边都得设置统一。这里没有“谁对谁错”,只有“两边一致”。

5.3 一个容易被忽略的细节:寄存器地址以0还是1起始

Modbus协议里,保持寄存器的地址编号在不同设备技术支持描述里可能是“0开头”也可能是“1开头”,这看似小事,实际在float这种连续两寄存器的场合特别容易翻车。我见过一个设备手册写“浮点数据从地址40003开始”,而它实际在报文里的寄存器地址是0x0002。这种“协议地址”和“数据地址”的偏移,用Modbus Poll这类工具时尤其要留意,地址填错一位,数据就全乱。

5.4 大小端不一致时如何快速适配

适配不同设备时,不要改函数内部逻辑,而是维护一个字节序配置宏,四个模式各写一组寄存器组装/解析程序。在系统初始化时根据硬件拨码或者项目编译项选择一组,这样调试新设备时就是改一个宏或者一个数组的事,不用到处改代码。

typedef enum { MODBUS_FLOAT_BIG_ENDIAN = 0, MODBUS_FLOAT_CDAB, MODBUS_FLOAT_EFGH, MODBUS_FLOAT_GH_EF } modbus_float_order_t;

代码里统一在一个文件里放四个版本的解析函数,每版都是纯函数,输入寄存器数组输出float,或者反过来,逻辑互不干扰。主程序只关心当前设备用的哪种,一个switch分发到对应处理函数就行。

写在最后的一个经验

这个项目做完,我自己印象最深的不是某个算法多精妙,而是IO不够用逼出来的分压方案,居然比原来4个IO老接法更稳。机械开关用ADC采集天然带滤波属性,反而是好事。Modbus float的问题则是老生常谈,但每次换设备都得再踩一轮。如果是刚开始接触Modbus的朋友,建议你把那个“写1.0、看寄存器”的试错法刻在脑子里,遇到浮点数据对不上,这招永远最快。后面如果有空,我再整理一下这套板子上的Modbus RTU整体发送流程和超时处理,那部分才是串口通信真正容易乱的地方。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 14:15:33

windows10卸载edge浏览器并将chrome设为默认浏览器

windows10卸载edge浏览器注意禁用Edge浏览器更新服务!!!Edge高于93版本禁用Edge更新服务删除更新服务程序删除Edge更新文件卸载Edge浏览器阻止Edge重新安装取消edge别名将chrome设为默认浏览器注意 此操作方法仅适用于Windows10,Windows11 21H2已经无法卸载Edge. 可以使用这个…

作者头像 李华
网站建设 2026/9/8 14:11:46

ABAP动态SQL安全指南:语法与语义校验方法详解

动态 SQL 在 ABAP 开发里一直是个让人又爱又恨的东西。爱它的人觉得它灵活&#xff0c;一条SELECT (lv_sql)能把静态 SQL 写不出来的复杂查询统统搞定&#xff1b;恨它的人踩过几次坑之后&#xff0c;看到动态 SELECT 就条件反射地头皮发麻。别问我是怎么知道的——线上程序半夜…

作者头像 李华
网站建设 2026/9/8 14:11:43

化学专业毕业论文降重避坑指南,别瞎改试剂参数,找对章节才是关键

化学专业毕业论文降重一直是老大难。实验方法、仪器型号、试剂名称都属于固定专业表述&#xff0c;一经查重比对&#xff0c;实验章节重复率轻轻松松突破 50%。不少同学反复修改三四轮&#xff0c;重复率卡在 32% 就再也下不去&#xff0c;眼看着答辩时间一天天逼近&#xff0c…

作者头像 李华
网站建设 2026/9/8 14:11:36

阿里云部署OpenClaw智能体:Docker容器化实战指南

1. 部署前准备&#xff1a;搞清楚 OpenClaw 是什么&#xff0c;以及为什么要上云端 如果你最近在逛技术社区&#xff0c;应该会发现 OpenClaw&#xff08;原 Clawdbot&#xff09;突然成了高频词。简单说&#xff0c;这是一个开源的智能体&#xff08;Agent&#xff09;运行框架…

作者头像 李华
网站建设 2026/9/8 14:11:31

16GB显存跑176B模型:llama.cpp量化与推理优化实战

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

作者头像 李华
网站建设 2026/9/8 14:11:25

Ubisoft La Forge动画数据集实战解析:动捕数据下载与处理全指南

简介&#xff1a;面向动作生成与角色动画研究&#xff0c;Ubisoft La Forge 动画数据集&#xff08;LAFAN1&#xff09;是一个专业级人体骨骼动画基准&#xff0c;配合SIGGRAPH 2020《Robust Motion In-Betweening》论文代码&#xff0c;适用于研究人体动作插值、姿态过渡与运动…

作者头像 李华