ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

嵌入式省IO采集与Modbus浮点数字节序实战:4档旋钮和float拆分还原

2026/9/9 8:50:34 拓冰建站 浏览量
嵌入式省IO采集与Modbus浮点数字节序实战:4档旋钮和float拆分还原 做嵌入式的人大概都有这种体验样机阶段最烦心的往往不是功能做不出来而是那些看起来很简单的事轮番翻车。这次就翻了两辆——设备面板上的4档旋转开关按常规做法得占4个GPIO去读结果主控引脚已经排满另一个是设备通过Modbus RTU向上位机上报一个float参数联调时上位机读出来的却是个天文数字。这篇嵌入式调试笔记第6期就把这两件事的完整处理过程记下来4档旋转开关怎么用最少的IO完成采集以及Modbus里float怎么拆分、怎么还原、字节序到底坑在哪。内容偏实战代码可以直接抄适合正在跟IO资源或通信协议较劲的嵌入式开发者参考。1. 先说这两件事为什么值得记下来1.1 面板旋钮与IO资源之间的矛盾这次项目是一台小型的现场检测设备面板上有一个4档旋转开关用来切换工作模式。MCU用的是STM32F103系列功能越加越多外设引脚已经占到七七八八留给开关的IO只剩下一两个。按通常的思路4档旋转开关本质上就是哪个触点被接通的问题直接把4个触点各接一个GPIO输入、公共端接GND代码里依次判断就好——这是最直观的做法但代价是4个引脚。在引脚富余的时候没人会纠结这个问题可真到排布时你会发现为了一个用户偶尔才拧一下的模式开关占掉4个引脚完全划不来。于是就有了两个替代方向要么想办法用更少的引脚读出同样的4个状态要么把读取逻辑挪到别的总线上。这次实际采用的是前者这也是嵌入式里很典型的省IO采集需求——不光是旋钮拨码开关、按键矩阵、跳线帽检测本质都是同一类问题。1.2 Modbus浮点数不只是搬数据另一件事出在通信侧。设备采集到的温度、流量这类参数都是浮点数上位机通过Modbus RTU来读取。Modbus协议大家都不陌生读写寄存器、CRC校验一步步来就能跑通。但float这个数据类型在Modbus里有个天然矛盾Modbus寄存器是16位的而一个float是32位必须拆成两个连续的寄存器才能传送。听起来很简单对吧拆两个寄存器发出去就是了。可真正联调时才发现同一个float不同设备给出的寄存器顺序竟然不一样。上位机那边按默认顺序解出来数值完全不对。这种问题纯粹是两边没对齐查起来比功能性的bug更恼人因为代码逻辑没问题、通信链路没问题、CRC也没问题就是解析出来一团乱码。记录下这个问题的完整排查思路后面遇到的人能少走不少弯路。1.3 这两块内容能用在哪些地方旋钮这块的ADC分压方案适合MCU自带ADC、引脚又紧张的场景比如小家电、仪器仪表、工业控制面板二进制的方案则适合要求高可靠性、能接受多占一个引脚的场合。Modbus float拆分还原这块则更通用凡是走Modbus的从站设备温度、压力、流量、频率这类float参数几乎都会遇到不管你是用STM32、GD32还是其他MCU只要自己做协议栈就绕不开这段代码。2. 4档旋转开关省IO采集方案拆解2.1 常规直读为什么不可取先明确一下常规做法的问题。一个单刀4掷的旋转开关公共端COM接GND4个档位触点各接一个GPIO内部上拉或外部上拉。档位切到哪一路对应的GPIO就会被拉低程序读4个引脚的电平组合就能知道当前在几档。这种接法的优点是逻辑简单、响应快、抗干扰好代码就是几个HAL_GPIO_ReadPin的事。缺点是引脚占用太多——4档就要4个引脚。如果主控引脚充足这完全不是问题但像这次引脚已经排满的情况就得换思路。所以省IO不是要省出什么黑科技而是要在引脚数量和硬件成本/复杂度之间找一个平衡点。下面两个方案一个用1个ADC引脚一个用2个普通GPIO各有取舍。2.2 方案A电阻分压加单路ADC1个IO搞定这个方案的思路很直接把4个档位变成4个不同的电压再用MCU自带ADC去读电压反推档位。硬件上只需要4个等值电阻搭一个分压网络旋转开关的公共端接ADC引脚4个档位触点分别接网络上的不同节点。电路结构是这样的VCC(3.3V) ── R1(10k) ──A── R2(10k) ──B── R3(10k) ──C── R4(10k) ── GND 旋转开关的4个档位触点: 档位1接VCC 档位2接A点 档位3接B点 档位4接C点 旋转开关的公共端COM: 接MCU的ADC引脚 ADC引脚对GND并一只100k电阻防止开关切换瞬间悬空假设VCC3.3V4个电阻均为10k那么各节点电压为VCC点3.3VA点3.3 × 30/40 2.475VB点3.3 × 20/40 1.65VC点3.3 × 10/40 0.825VMCU的ADC是12位满量程4095所以4个档位对应的理想ADC值约为4095、3071、2047、1023。相邻档位之间的电压差有0.825V换算成ADC数值差大约1024余量非常大。即便电阻精度差一点、电源纹波多一点、ADC有量化误差也不至于判错档位。这里有个细节值得多说一句为什么不在档位1直接接GND而是从VCC往下分原因很简单——这样4个电压都在0V以上且等间距ADC线性区正中判定最稳。如果某个档位电压太低接近0V在开关切换瞬间公共端悬空或被干扰下拉时就很容易误判成最低档。再串一个100k的GND电阻是为了让悬空状态有个确定电位配合代码里的连续采样可以把误判概率压到很低。2.3 代码实现ADC采样与档位判定ADC初始化这里不展开假设已经用STM32CubeMX配好了hadc1单通道采样。档位判定代码最关键的是阈值怎么取相邻档位的理想ADC值分别是4095和3071取中间值3583作为分界3071和2047的分界是25592047和1023的分界是1535。写成代码就是连续几个区间判断。#define TH_POS1_POS2 3583 #define TH_POS2_POS3 2559 #define TH_POS3_POS4 1535 uint8_t Switch_ReadRaw(void) { uint16_t adc 0; uint8_t pos 0; HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 5) HAL_OK) { adc HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); if (adc TH_POS1_POS2) pos 1; else if (adc TH_POS2_POS3) pos 2; else if (adc TH_POS3_POS4) pos 3; else pos 4; return pos; }注意这个Switch_ReadRaw只是读一次并粗暴地映射档位不能直接拿去用因为机械开关在转动的瞬间会有抖动ADC值会来回跳。我在这个项目里的做法是连续读3次每次间隔5ms至少2次结果相同才认定是当前档位uint8_t Switch_GetPosition(void) { uint8_t count[5] {0}; uint8_t i; for (i 0; i 3; i) { count[Switch_ReadRaw()]; HAL_Delay(5); } for (i 1; i 4; i) { if (count[i] 2) return i; } return 0; // 3次都没达成一致返回无效 }这个消抖逻辑其实和按键消抖一个道理只是按键只有按下/抬起两种状态旋钮有4种状态所以用一个计数数组来投票。实际测试下来正常拧动开关基本一次就能读到稳定档位快速连拧偶发返回0应用层只需要在返回0时保留上一次有效档位即可。2.4 方案B二进制编码双IO直读如果不想用ADC还有一个数字电路思路的方案用2个GPIO的4种组合状态来表示4个档位。这就相当于把开关的机械位置编码成2位二进制数00、01、10、11分别对应1到4档。硬件上有两种做法。第一种是直接用双刀4掷开关每一个档位把公共端接到两根输出线的不同组合上这是最干净的做法第二种是普通单刀4掷开关加二极管编码网络每个档位触点通过1N4148把对应的引脚拉到低电平。GPIO配置为内部上拉输入平时默认高电平开关接通哪一路就被拉低。编码关系可以这样定义档位GPIO1GPIO2二进制码1低低002低高013高低104高高11代码就非常简单了uint8_t Switch_GetPosition_GPIO(void) { uint8_t gpio1 HAL_GPIO_ReadPin(SW_GPIO1_Port, SW_GPIO1_Pin); uint8_t gpio2 HAL_GPIO_ReadPin(SW_GPIO2_Port, SW_GPIO2_Pin); uint8_t code (gpio1 1) | gpio2; return code 1; }这里有个工程上的坑普通旋转开关在转动过程中动触点可能会短暂地同时碰到相邻两个静触点导致两个引脚同时被拉低或出现不确定的中间态。所以即便是数字电平读取也建议保留上面提到的连续3次投票逻辑或者加一个RC低通滤波。二极管编码网络在这里还有额外作用——防止两个引脚通过开关触点互相导通形成意外的电流回路。2.5 选型建议什么时候用哪个这两个方案我实际都搭过各自的优缺点列个表对比项方案AADC分压方案B双IO编码IO占用1个ADC2个外围器件4只电阻2只上拉电阻或二极管网络抗干扰能力中模拟量对噪声相对敏感较好纯数字电平判决响应速度取决于ADC转换时间直接读引脚最快对MCU要求必须有ADC通道普通GPIO即可推荐场景引脚极度紧张、省成本引脚有一点余量、要求高可靠如果MCU自带ADC且引脚紧张我强烈建议用方案A因为省引脚最彻底成本就是4个10k电阻实际效果也足够稳。只有两种情况我会选方案B一是目标平台根本没有多余ADC通道二是工作环境电磁干扰特别厉害模拟信号容易被污染数字电平更让人放心。顺带提一句还有一种更奢侈的省IO方式是走I2C扩展GPIO比如PCF8574一个芯片能换8个IO适用于本来就要接I2C总线的场景但单为旋钮加一颗芯片成本和布线都不划算。3. Modbus里float的拆分与还原3.1 一个float要占几个寄存器为什么Modbus协议里的基本数据单元是保持寄存器或输入寄存器每个寄存器固定16位。而C语言里的float是IEEE 754标准的单精度浮点数占用32位也就是4个字节。一个寄存器装不下所以规范的做法是占用两个连续寄存器地址为N和N1N存高16位N1存低16位。从Modbus报文的角度看一次读寄存器的响应里如果要返回两个寄存器数据域就是4个字节。这4个字节在总线上按什么顺序排列协议本身只规定寄存器内部高字节在前却没有强制规定两个寄存器之间谁是高字于是各厂商各显神通字节序就成了嵌入式联调里经久不衰的坑。打个比方Modbus协议规定了一个箱子寄存器里东西从左到右怎么摆但没规定箱子A和箱子B在货架上谁在前。PLC厂家A习惯把高字箱放前面仪表厂家B习惯把低字箱放前面两边直接对接时你按A的算法解析B的数据自然就是一团乱麻。3.2 IEEE 754结构速览要把float拆得明明白白起码得知道它内部长什么样。一个32位浮点数按位划分是第31位符号位0为正1为负第30到23位指数位8位带127的偏移第22到0位尾数位23位隐含最高位的1比如3.14这个值在C语言里用float类型表示它的十六进制是0x4048F5C3。拆开来看符号位0指数位0x80即128尾数部分0x48F5C3。这个值本身可以不用手动算写代码的时候直接用memcpy把float搬到uint32_t里就能看到。关键点是0x4048F5C3在内存里和在线路上有两种截然不同的长相。在x86和STM32这类小端机内部这4个字节在内存里是C3 F5 48 40而Modbus报文里寄存器是按大端表示的我们要发出去的应该是40 48 F5 C3。中间这层转换就是拆分的核心工作。3.3 拆分float到两个寄存器最常见的拆分写法是借助memcpy加移位。先解释一下为什么不用强制类型转换在C语言里把一个float*直接强转成uint32_t*再取值是未定义行为编译器可能因为严格别名规则优化出意想不到的结果而且在某些架构上还会遇到对齐问题。memcpy是标准库函数编译器通常能把它优化成一条指令既安全又高效。#include string.h #include stdint.h // 默认AB CD字序高16位在前 void Float_To_Regs(float value, uint16_t *reg_hi, uint16_t *reg_lo) { uint32_t tmp 0; memcpy(tmp, value, 4); *reg_hi (uint16_t)(tmp 16); *reg_lo (uint16_t)(tmp 0xFFFF); }调用方式Float_To_Regs(3.14f, regs[0], regs[1])。执行后regs[0]是0x4048regs[1]是0xF5C3正好对应报文中两个寄存器的值。这里我默认了高字在前的AB CD顺序这是Modbus社区里最主流的约定很多PLC和组态软件都认这个顺序。3.4 从两个寄存器还原float从站要发float出去主站那边就得倒过来还原。还原逻辑完全对称float Regs_To_Float(uint16_t reg_hi, uint16_t reg_lo) { uint32_t tmp 0; float value 0.0f; tmp ((uint32_t)reg_hi 16) | reg_lo; memcpy(value, tmp, 4); return value; }用这组函数主站收到寄存器值0x4048和0xF5C3还原出来就是3.14。如果收到的顺序相反是0xF5C3和0x4048你还按高字在前去算得到的数就不是3.14而是一个巨大的负数——这就是开头说的天文数字。3.5 四种字节序与兼容处理实际项目中浮点数的寄存器顺序基本逃不出下面四种顺序寄存器0寄存器1报文字节序列常见场景AB CD0x40480xF5C340 48 F5 C3Modbus标准、多数PLCCD AB0xF5C30x4048F5 C3 40 48不少国产仪表、小端MCU直发BADC0x48400xC3F548 40 C3 F5很少见字内交换DCBA0xC3F50x4840C3 F5 48 40完全按小端内存直发为什么会有这么多种根源在于不同MCU/协议栈作者对从内存搬到发送缓冲区的处理不同。在小端MCU上直接memcpy一个float到发送缓冲区发出去的就是DCBA如果代码里顺手做了一个16位交换就可能变成CDAB。所以我做从站的时候习惯把字节序做成一个可配置项一个函数统一处理出问题只要改配置不用动逻辑。typedef enum { FLOAT_ORDER_ABCD 0, // 高字在前标准 FLOAT_ORDER_CDAB, // 低字在前 FLOAT_ORDER_BADC, // 字内字节交换 FLOAT_ORDER_DCBA // 全字节反序 } FloatOrder_t; static uint16_t ByteSwap16(uint16_t v) { return (uint16_t)((v 8) | (v 8)); } static uint32_t ByteSwap32(uint32_t v) { return ((v 0xFF000000u) 24) | ((v 0x00FF0000u) 8) | ((v 0x0000FF00u) 8) | ((v 0x000000FFu) 24); } void Float_To_Regs_Order(float value, uint16_t regs[2], FloatOrder_t order) { uint32_t tmp 0; memcpy(tmp, value, 4); switch (order) { case FLOAT_ORDER_ABCD: regs[0] (uint16_t)(tmp 16); regs[1] (uint16_t)(tmp 0xFFFF); break; case FLOAT_ORDER_CDAB: regs[0] (uint16_t)(tmp 0xFFFF); regs[1] (uint16_t)(tmp 16); break; case FLOAT_ORDER_BADC: regs[0] ByteSwap16((uint16_t)(tmp 16)); regs[1] ByteSwap16((uint16_t)(tmp 0xFFFF)); break; case FLOAT_ORDER_DCBA: tmp ByteSwap32(tmp); regs[0] (uint16_t)(tmp 16); regs[1] (uint16_t)(tmp 0xFFFF); break; default: regs[0] (uint16_t)(tmp 16); regs[1] (uint16_t)(tmp 0xFFFF); break; } } float Regs_To_Float_Order(uint16_t regs[2], FloatOrder_t order) { uint32_t tmp 0; float value 0.0f; switch (order) { case FLOAT_ORDER_ABCD: tmp ((uint32_t)regs[0] 16) | regs[1]; break; case FLOAT_ORDER_CDAB: tmp ((uint32_t)regs[1] 16) | regs[0]; break; case FLOAT_ORDER_BADC: tmp ((uint32_t)ByteSwap16(regs[0]) 16) | ByteSwap16(regs[1]); break; case FLOAT_ORDER_DCBA: tmp ByteSwap32(((uint32_t)regs[0] 16) | regs[1]); break; default: tmp ((uint32_t)regs[0] 16) | regs[1]; break; } memcpy(value, tmp, 4); return value; }有了这组函数不管对方设备是哪种顺序你只需要确认好设备手册里写的是哪种然后把枚举值传进去就行。我自己的协议栈里每个float寄存器映射表都会带一个order字段上电时从配置文件里读出来这样同一套代码适配不同主站时非常省事。3.6 用Modbus Poll验证实际设备顺序联调的时候怎么确定对面设备到底是哪种顺序我的经验是随手用Modbus Poll配合一个已知数据来验证。具体做法是在从站代码里固定写一个已知的float值比如3.14映射到两个连续的保持寄存器地址。然后用Modbus Poll去读这两个地址的原始寄存器值注意要先直接把显示格式设成Hex或Unsigned不要一开始就设成Float。读到的两个16位数值对照上面那张表就知道设备实际用的是哪种顺序了。比如我这次读到的寄存器值是0xF5C3和0x4048那么按AB CD解析是0xF5C34048这是个极其离谱的浮点数绝对值在10的38次方量级按CD AB解析是0x4048F5C3正好是3.14。一目了然这台设备就是CD AB的字序。如果手头没有Modbus Poll也可以用串口助手抓报文自己数一下响应帧里那4个数据字节的顺序效果一样。用Modbus Poll的好处是省得手算CRC和地址直接看图说话。4. 实测中的坑与排查实录4.1 旋钮档位偶发跳变采样抖动处理第一版代码我偷懒直接用单次ADC采样映射档位结果发现快速拧动开关时经常出现档位显示先跳到别的档位、过几十毫秒才恢复正常的现象。仔细一想这是两个问题叠加一是机械开关触点接触抖动ADC值在切换瞬间不稳定二是电阻分压网络在开关悬空的瞬间没有确定的电平参考。处理办法我在2.3节已经写了就是连续采3次投票外加在ADC引脚加了一个100k下拉电阻。这里有个细节值得强调投票窗口不能太短我试过1ms间隔连续采样还是有概率采到抖动沿上的值5ms间隔比较稳因为普通波段开关的抖动持续时间一般不到2ms5ms基本可以躲过去。注意这个数值是我在普通国产波段开关上实测的如果你的开关质量较差或使用环境有振动可以适当把间隔加到10ms代价是档位响应稍慢但用户拧一下开关本来也不在乎多等几十毫秒。4.2 float值变成天文数字字序排查路线再说Modbus这边的坑。第一次联调时主站那边设的是AB CD字序从站这边直接把float用memcpy塞进了发送缓冲区发出来的其实是DCBA顺序。结果上位机显示的温度值大得离谱甚至变成负数绝对值特别大的数。我当时的排查路线是第一步先确认通信链路本身没问题。用Modbus Poll直接读寄存器把显示格式设为Hex看每次读回来的值是不是稳定且符合预期。结果读到的是0xC3F5和0x4840稳定说明链路没问题。第二步把这两个值和已知的3.14做比对。3.14的IEEE 754表示是0x4048F5C3而设备发出的是C3 F5 48 40两者是完美的字节反序正是DCBA。第三步回到代码里查源头。发现从站程序里确实就是一句memcpy(send_buf, float_val, 4)完全没有做字节序转换。在小端MCU上这个操作等价于把DCBA顺序直接发出去了。改成调用Float_To_Regs之后上位机显示恢复正常。这个排查过程没有什么高深技巧核心就一句话先确认基础通信再锁定寄存器原始值最后对照IEEE 754表示反推代码问题。越早用原始值去比就越不会被上层的显示异常干扰。4.3 常见问题速查表把这次遇到的以及平时同行问得比较多的问题整理成一个表方便随手查现象可能原因处理方法旋钮档位偶尔跳变触点抖动或ADC采样到切换瞬间连续采样3次取多数票采样间隔5ms某档位ADC值与理论值偏差大电阻精度低、VCC波动换1%精度电阻按实测值校准阈值开关切换瞬间采样到0V公共端悬空ADC引脚并联100k下拉电阻Modbus读float得到天文数字寄存器字序不匹配用Hex格式读原始寄存器值对照字节顺序Modbus读float数值接近但差一点按float显示时四舍五入或精度丢失确认寄存器是float32而不是double或int32强转float指针取值不稳定严格别名规则未定义行为/对齐问题改用memcpy或union方式用union拆分后顺序和预期不一样忘记小端机内存布局u16[0]是低字统一用数值移位方式不依赖内存布局另外有一个在使用键的经验无论旋钮还是Modbus float都要在交付前做一张引脚-档位-电压/ADC值或者寄存器-字序-物理量的对照表放在工程文档里。联调现场最容易出的问题不是代码多难而是换了个上位机、换了个设备两边拿着各自的理解对数据谁也说不清谁对。表格一摆所有争议当场消失。这次记录的旋钮采集和float拆分看起来是两个独立的小问题但它们背后其实是一个习惯在嵌入式开发里凡是涉及物理世界到数据世界的转换都要先想清楚硬件怎么表达、协议怎么定义、代码怎么翻译三层对齐了才不容易翻车。我在实际项目里体会最深的是省IO和字节序这种问题第一次遇到可能要折腾大半天但只要把方案和排查思路记进笔记里第二次遇到基本就是十分钟的事。这篇笔记就当个参考后面再有人被4档旋钮和Modbus float折磨直接把这篇甩给他就行。