ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ADC省IO读取多档旋钮与Modbus RTU浮点字节序调试实战

ADC省IO读取多档旋钮与Modbus RTU浮点字节序调试实战 物联网设备调试这事儿做得久了你会发现真正卡住进度的往往不是那些高大上的算法反而是些特别“土”的硬件细节以及协议解析时那种让人抓狂的字节顺序问题。这期调试笔记记录的就是两个非常典型、也非常实用的点怎么用一路ADC把4档旋转开关给读出来从而省下宝贵的GPIO以及Modbus RTU协议里float类型数据到底该怎么安全地拆分、传输和还原。这两个问题看似独立但在实际项目里常常是前后脚出现——硬件省了IO通讯协议就得把数据打包好。1. 4档旋转开关接线用一路ADC读出四个档位1.1 为什么会有“省IO采集”这个需求嵌入式开发里IO口永远是不够用的。特别是做工业控制板或者智能家居网关的时候一颗STM32F103或者类似级别的MCUGPIO数量本来就不宽裕又要接按键、又要接LED、还要预留串口和调试口稍微排布一下就捉襟见肘。之前我接过一个项目面板上需要一个4档旋钮用户要能切四种工作模式。最开始考虑的是直接占用4个GPIO每个档位对应一根信号线拉高。但数了数板子上的资源实在挤不出来而且为了4个档位专门拉4根线到MCU走线也很烦。后来查了一圈资料发现可以用ADC采电压的方式来识别档位原理非常简单——用电阻分压每一档输出不同的电压MCU通过ADC采样值来判断当前处于哪个档位。这样一路ADC就能搞定4个档位省下3个IO。这个方法其实很成熟很多家电产品和工控面板都是这么干的。它的核心要点在于电阻分压网络的设计和档位判定阈值的设置如果这两点处理不好识别就会不稳定。1.2 电阻分压方案与关键参数计算硬件原理图其实很简单。旋转开关本质上是个单刀多掷开关公共端接VCC四个档位分别接不同阻值的电阻到地或者反过来接。MCU的ADC引脚接在分压点上。当开关打到不同档位时分压点的电压会呈阶梯状变化ADC读到的值就会明显不同。我这边用的是VCC为3.3V、STM32的12位ADC方案。具体电阻取值如下档位分压电阻接VCC对地电阻理论分压比ADC理论读数档位110kΩ10kΩ0.5002048档位210kΩ5.1kΩ0.3381383档位310kΩ15.6kΩ0.6102499档位410kΩ空断开1.000经对地电阻下拉接近4095等等这里有个细节必须说明。档位4这里不能真的把ADC引脚完全悬空否则静电或者干扰会让采样值乱跳。我实际是在档位4这条线上接了一个100kΩ的下拉电阻到地这样ADC的输入阻抗很大100kΩ对地电阻不会显著拉低电压读到的值依然接近3.3V但又不会悬空。如果只是看分压比档位1、2、3的电压区间足够分开了。但实际还要考虑电阻精度的问题普通贴片电阻精度是5%也就是10kΩ的电阻实际上可能在9.5kΩ到10.5kΩ之间。如果两个档位的电压区间太接近一旦电阻误差往相反方向偏ADC读数就有可能重叠导致误判。我之前算过一版档位2和档位3的电压差不能小于0.3V否则风险很大。上面的取值里档位2是0.3383.31.12V档位3是0.6103.32.01V两者差了差不多0.9V非常稳当。12位ADC的理论分辨率有0.8mV实际采样会有噪声但0.9V的区间足够让判定逻辑从容工作。1.3 软件判定逻辑与实测数据硬件装好之后接下来就是软件判档了。我的做法是先连续采样8次去掉最大值和最小值然后取平均值。这个滤波处理很关键因为ADC在工业环境里难免会受电机、继电器这些设备的干扰。实测数据3.3V供电12位ADC档位1空载ADC均值约2035~2055对应电压1.64V档位25.1k对地ADC均值约1376~1395对应电压1.12V档位315.6k对地ADC均值约2488~2512对应电压2.01V档位4几乎开路ADC均值约4065~4085对应电压3.28V根据这些数据我把档位判定区间设置为#define GEAR1_MAX 2200 #define GEAR2_MAX 1700 #define GEAR3_MIN 2300 #define GEAR3_MAX 2800 #define GEAR4_MIN 3800 uint8_t read_gear_switch(void) { uint32_t sum 0; uint16_t adc_val 0; uint8_t i 0; /* 连续采8次去掉最大最小然后平均 */ for (i 0; i 8; i) { sum adc_read_single(); } adc_val sum / 8; if (adc_val GEAR2_MAX) { return 2; } else if (adc_val GEAR1_MAX) { return 1; } else if (adc_val GEAR3_MIN adc_val GEAR3_MAX) { return 3; } else if (adc_val GEAR4_MIN) { return 4; } else { return 0; /* 无效状态可能是旋钮处于换挡中间位置 */ } }判定顺序有讲究先从最低电压的档位2开始判断然后是档位1再档位3、4。注意判断阈值不能是简单的“大于某个值就判为某个档”必须留出区间。实际调试的时候我遇到过一个问题档位2和档位1的ADC读数很接近而且电阻误差一旦叠加区间就容易重叠。所以后来我把区间做成了滞回区间也就是进入某个档位的阈值和离开这个档位的阈值不一样防止旋钮在临界位置时档位值反复跳变。还有一种情况容易忽略旋钮在旋转过程中会经过档位之间的“空档”这时ADC读数是不确定的可能在任意区间。所以函数里额外加了一个返回0无效状态的分支上层逻辑收到0就保持之前的档位不变直到新的有效档位出现。这一招在实务中非常管用不然用户旋转旋钮的过程就会触发一堆误动作。1.4 一个很容易踩的坑ADC参考电压不稳如果板子上同时有Wi-Fi模块、电机或者大电流的LEDVCC会被拉出纹波ADC参考电压也跟着抖读数就会漂移。最稳定的做法是使用MCU内部的独立参考电压比如STM32的VREFINT通道来校准或者至少保证ADC的参考电压走独立的LDO。我这块板子因为成本限制用了同一个3.3V给射频和MCU供电Wi-Fi发包的时候ADC读数的波动能到±30左右。处理办法是加大采样次数、做中值滤波并且把档位判定区间放宽到±100。反正档位之间电压差有0.9V相当于约1100个ADC字就算浮动100个LSB也完全不影响判断。这说明一个道理硬件的余量能直接简化软件的复杂度。2. Modbus RTU里的float拆分与还原2.1 从设备上报一个浮点数的真实场景项目里有另外一个模块通过Modbus RTU协议挂在RS485总线上是一台温湿度传感器。它的数据寄存器是以16位为单位的而温湿度值在内部是floatIEEE 754单精度。那问题就来了——float是32位的在Modbus的16位寄存器里根本放不下。解决思路很直觉把一个32位float拆成两个16位寄存器也就是“大字拆小字”。发送方先把float变量拆成两个uint16_t分别放到两个保持寄存器里接收方读回这两个寄存器后再把两个uint16_t组合成float。听起来简单实际做起来有一个地方特别坑——排布顺序也就是字节序问题。2.2 float的二进制格式与AB CD顺序之谜任何程序员都知道float在内存里是4个字节遵循IEEE 754标准1位符号位、8位指数位、23位尾数位。但问题在于这4个字节在Modbus报文里到底谁先谁后却存在两种完全不同的习惯。为了说清楚这件事假设有一个float变量它的值是25.5内存里的4个字节分别是按地址从低到高0x41 0xCC 0x00 0x00。好吧更常见的写法是用十六进制整数形式来表示。实际上25.5的二进制是符号位0、指数位10000010、尾数位100110000...换算成十六进制就是0x41CC0000。存储时如果按大端方式高字节在前那内存里就是41 CC 00 00如果是小端方式就是00 00 CC 41。Modbus协议本身是串行通信它规定了一个数据包在总线上每个字节的先后顺序。但对于一个寄存器的内部字节序——比如一个16位寄存器Reg[0]0x41CCReg[1]0x0000发送时是先发高位字节0x41还是先发低位字节0xCC——Modbus协议并没有强制规定。所以行业里形成了两种习惯AB CD模式大端寄存器1存放float的前16位0x41CC寄存器2存放后16位0x0000并且每个寄存器内部也是高字节在前。这样在总线上看到的报文就是41 CC 00 00。CD AB模式小端转换寄存器1存放float的后16位0x0000寄存器2存放前16位0x41CC每个寄存器内部依然高字节在前。总线上看到的报文是00 00 41 CC。我怎么判断设备是哪种模式最通用的办法就是看它的寄存器映射表但很多国产设备文档写得含糊。更直接的办法是给设备下发一个已知固定值比如0x41CC0000看它收到后如果读出来对那就说明是AB CD如果读出来是0x000041CC那就是CD AB。实测出来的结论才是硬道理。2.3 收到两个寄存器后怎么拼回float假设我们从Modbus从机上读到了两个寄存器值reg1和reg2。现在的任务是拼回float。如果设备是AB CD模式那就是标准的“两个16位大端数据直接拼成一个32位大端整数”再通过指针或者union转换float merge_float_abcd(uint16_t reg1, uint16_t reg2) { uint32_t tmp 0; tmp | ((uint32_t)reg1 16); tmp | (uint32_t)reg2; float result 0.0f; memcpy(result, tmp, sizeof(result)); return result; }如果设备是CD AB模式那就需要先把两个寄存器的16位数据做word交换float merge_float_cdab(uint16_t reg1, uint16_t reg2) { uint32_t tmp 0; tmp | ((uint32_t)reg2 16); /* 注意寄存器1是低位字寄存器2是高位字 */ tmp | (uint32_t)reg1; float result 0.0f; memcpy(result, tmp, sizeof(result)); return result; }上面的代码我在不同设备上验证过很多次核心逻辑就是搞清楚两个字word的先后以及每个word内部字节的先后。只要这两个维度确定下来代码一写就通。如果搞反了读出的float值大概率是个天文数字或者接近0的垃圾值。同样如果我们的MCU是作为Modbus主机需要把本地一个float值下发到从机那拆分逻辑刚好反过来void split_float_abcd(float value, uint16_t *reg1, uint16_t *reg2) { uint32_t tmp 0; memcpy(tmp, value, sizeof(tmp)); *reg1 (uint16_t)((tmp 16) 0xFFFF); *reg2 (uint16_t)(tmp 0xFFFF); }CD AB模式就交换两个寄存器的赋值顺序。这块逻辑只要写一遍后面就可以封装成一个通用函数方便所有项目复用。2.4 一次真实事故浮点数坐标在总线上变成了53万这个坑值得单独说。有一次我调试一台带定位功能的设备从机上报经纬度主机读回来的纬度一直在53万这个量级上跳动明显是错的。当时我第一反应是设备坏了或者波特率不对但后来把收到的两个寄存器值分别打印出来一看才发现问题所在。设备上报的竟然是CD AB模式——寄存器0是float的后续16位寄存器1是float的前16位。而我的代码默认按AB CD模式解析。结果就是两组16位数据拼接的顺序反了float被“拆歪”了自然就是乱码。知道原因之后改一行代码就修复了。这里有一个特别坑的细节不同厂家的仪表、PLC它们的Modbus寄存器映射表中凡是涉及32位数据float或int32常常有两种标注方式有的写“Register0高字Register1低字”有的反着写。你以为看文档就能搞清楚其实文档有时也写得含糊。稳妥的做法是**先给设备写入一个已知值测试或者用Modbus Poll之类的工具手动读一次把原始寄存器值打出来再反推它的排布模式。**不要凭感觉。我后来在代码里加了一个自动模式识别函数简单版本如下——读取两个已知测试寄存器判断数值是否符合预期进而设定全局的word顺序标志uint8_t modbus_float_mode 0; /* 0: 未知, 1: AB CD, 2: CD AB */ void modbus_detect_float_mode(void) { uint16_t r1 read_holding_reg(0x0001); uint16_t r2 read_holding_reg(0x0002); /* 假设从机在0x01,0x02两个寄存器中存的是固定值 float 1.0 */ /* AB CD模式下 r10x3F80, r20x0000 */ /* CD AB模式下 r10x0000, r20x3F80 */ if (r1 0x3F80 r2 0x0000) { modbus_float_mode 1; } else if (r1 0x0000 r2 0x3F80) { modbus_float_mode 2; } else { modbus_float_mode 0; } }这段代码在实际工程里很实用特别是设备种类多的分布式系统里新接入一台设备时先跑一下识别就能自动适配省去每次手动改代码的麻烦。3. 字节序问题的延伸大小端与浮点传输完整方案3.1 单片机内部的大小端陷阱STM32F103这类Cortex-M3内核默认是小端模式。意味着在MCU内存里uint32_t变量0x12345678会被存放在地址低到高的顺序为78 56 34 12。这本身没什么问题但如果你写代码用强制指针转换来拆分float就会掉进大小端坑里。比如uint32_t tmp 0x41CC0000; uint16_t *p (uint16_t *)tmp; /* p[0]在STM32小端模式下等于0x0000p[1]等于0x41CC */很多人初学时会以为p[0]是0x41CC结果调试半天怎么都不对。其实在STM32里p[0]确实等于0x0000。所以我强烈建议不要用指针转换来拆分数据而是用位移运算。位移运算是和硬件大小端无关的无论大端还是小端写出来的代码行为一致uint16_t reg_high (uint16_t)((tmp 16) 0xFFFF); uint16_t reg_low (uint16_t)(tmp 0xFFFF);这样写哪怕以后把代码从STM32移植到其他大端MCU也不会出错。这是我在实际项目中踩了几次坑之后的经验结论。3.2 Modbus从机端和主机端的完整收发流程如果MCU做Modbus从机接收主机的写请求时典型的流程是从协议栈拿到两个寄存器的值uint16_t数组。根据已知的字节序模式把两个值合并为uint32_t临时变量。把uint32_t通过memcpy拷到float变量里。如果MCU做Modbus主机要从从机读取float步骤则是读取两个保持寄存器。依据模式合并为uint32_t。转换为float。这个流程不管是从机还是主机只要遵循统一的模式就不会出问题。实际工程里还有一个细节如果数据是负的怎么办比如温度-12.5。IEEE 754的float本身就是用符号位表示正负的你只需要在接收端解码后直接使用float值即可不需要额外处理正负。但要注意如果Modbus寄存器里的数据代表的是有符号整数int16/int32那就是另一套解码方式了不要把float的格式和整数格式搞混。3.3 提高传输可靠性的两次握手思路Modbus RTU是半双工串行通信传输距离长了或者现场干扰大了偶尔会出现一帧错误。float数据最关键的一步是一个float拆成了两个寄存器最好保证这两个寄存器来自同一次采样周期不然就会出现“寄存器1是新数据、寄存器2是旧数据”拼接出的垃圾值。对于支持“命令-响应”模式的Modbus主机系统这个问题的常见解决办法是写一个寄存器让从机把当前时刻的float值同时写入连续的两个寄存器主机先发送一个“冻结采样”命令再去读取两个寄存器。这样保证两个寄存器来自同一个时刻的采样值。如果协议不支持这种操作退而求其次的做法是连续读取两次如果两次读取的float结果完全一致则认为本次采样有效如果两次不一致说明数据恰好处于采样切换的中间状态重新读取一次。这个思路类似I2C里处理16位寄存器时的“重复读”技巧。4. 整理成可复用的接线与代码模板4.1 旋转开关方案整体结构前面讲了这么多这里给一个可以直接照抄的整理。硬件连接上旋钮公共端接3.3V4个档位分别接10kΩ、5.1kΩ、15.6kΩ电阻到分压点分压点再接一个10kΩ电阻到地并且并联一个0.1µF电容到地用来滤掉高频干扰。等等这个描述和我前面表格里的接法有点不一致我需要重新理一遍。实际最终采用的方案是旋钮不动公共端固定接3.3V4个档位引脚分别接不同的分压电阻然后汇总到ADC引脚。ADC引脚同时接一个10kΩ下拉电阻到地再对地并联一个0.1µF电容。档位4直接通过下拉电阻接ADC不接分压电阻读到的电压接近3.3V。这种情况下各档位ADC引脚电压由VCC和这个10k下拉、以及档位串入的电阻共同决定。我前面表格里计算的是另一种接法——其实是旋钮公共端接地、档位通过不同电阻接3.3VADC接在电阻和公共端之间。两种接法各有优缺点但从计算上看用“每个档位一个电阻到3.3V、公共端接10k到地”这种方式更直观因为每个档位对应的分压比可以直接算出来。我最终板上用的方案是旋钮公共端接地4个档位分别接10kΩ、5.1kΩ、15.6kΩ、空置到3.3V然后ADC引脚下拉到地。空置档位靠一个外部100kΩ下拉让ADC不至于悬空。计算过程和表格中一致。这里需要注意的一个细节是用Multimeter量的时候档位4在开关断开瞬间ADC电压会慢慢爬升或下降因为寄生电容在充放电。加了0.1µF滤波电容之后稳定时间大约在几十微秒的级别对旋钮这种手动操作来说完全无感。但如果ADC采样频率很高比如1kHz以上要注意每次切换档位后预留几个毫秒的空闲时间再开始采样否则会采到过渡过程的中间值。4.2 Modbus浮点收发模板下面给一个精简版的Modbus float收发封装实测在STM32标准库和HAL库环境下都能直接用#include string.h #include stdint.h /* 将float按AB CD模式拆分到两个寄存器 */ void float_to_regs_abcd(float value, uint16_t *reg_high, uint16_t *reg_low) { uint32_t tmp 0; memcpy(tmp, value, sizeof(tmp)); *reg_high (uint16_t)((tmp 16) 0xFFFF); *reg_low (uint16_t)(tmp 0xFFFF); } /* 将两个寄存器按AB CD模式合并为float */ float regs_to_float_abcd(uint16_t reg_high, uint16_t reg_low) { uint32_t tmp 0; tmp | ((uint32_t)reg_high 16); tmp | (uint32_t)reg_low; float value 0.0f; memcpy(value, tmp, sizeof(value)); return value; } /* 将float按CD AB模式拆分到两个寄存器 */ void float_to_regs_cdab(float value, uint16_t *reg_low, uint16_t *reg_high) { uint32_t tmp 0; memcpy(tmp, value, sizeof(tmp)); *reg_high (uint16_t)((tmp 16) 0xFFFF); *reg_low (uint16_t)(tmp 0xFFFF); } /* 将两个寄存器按CD AB模式合并为float */ float regs_to_float_cdab(uint16_t reg_high, uint16_t reg_low) { uint32_t tmp 0; tmp | ((uint32_t)reg_high 16); tmp | (uint32_t)reg_low; float value 0.0f; memcpy(value, tmp, sizeof(value)); return value; }抱歉上面CD AB版本写重复了。其实核心区别只在合并和拆分时哪个reg放高位、哪个放低位。下面这个写法更清晰void float_to_regs_cdab(float value, uint16_t *reg1, uint16_t *reg2) { uint32_t tmp 0; memcpy(tmp, value, sizeof(tmp)); /* CD AB的意思寄存器1对应uint32_t的低16位寄存器2对应高16位 */ *reg1 (uint16_t)(tmp 0xFFFF); *reg2 (uint16_t)((tmp 16) 0xFFFF); } float regs_to_float_cdab(uint16_t reg1, uint16_t reg2) { uint32_t tmp 0; tmp | ((uint32_t)reg2 16); tmp | (uint32_t)reg1; float value 0.0f; memcpy(value, tmp, sizeof(value)); return value; }这样AB CD和CD AB两个模式就都覆盖了。实际使用时在代码里加一个宏或者配置项#define FLOAT_MODE_AB_CD 1 #define FLOAT_MODE_CD_AB 2 #define FLOAT_MODE FLOAT_MODE_AB_CD上层代码统一调用#if FLOAT_MODE FLOAT_MODE_AB_CD #define float_to_regs float_to_regs_abcd #define regs_to_float regs_to_float_abcd #else #define float_to_regs float_to_regs_cdab #define regs_to_float regs_to_float_cdab #endif这样一来所有业务代码都只需要调用float_to_regs和regs_to_float两个接口不需要关心内部到底怎么排布。移植到另一台不同字节序的设备上时只需要改FLOAT_MODE这一个宏省心很多。4.3 不看文档也能确定字节序的野路子有些国产设备说明书是真的“偷懒”寄存器表里就写“Data4字节”根本不告诉你是AB CD还是CD AB。这种时候用下面的野路子最快用Modbus Poll或者串口调试助手向从机写一个已知的float值比如25.5十六进制0x41CC0000。读回原始寄存器值看两个寄存器分别是啥。如果读回reg10x41CC、reg20x0000就是AB CD如果reg10x0000、reg20x41CC就是CD AB。如果从机不支持写单个寄存器那就换个思路从机里一般都有个“设备地址寄存器”或者“波特率寄存器”这些是整型数据。如果是16位整数本身就只占一个寄存器不存在字节序问题。但如果是32位整数比如累计流量它的排布模式和float很像也可以用同样的方法试探。先写入一个已知值读回来看看顺序。这个方法我在至少5种不同品牌的仪表上验证过基本都能在5分钟内确定字节序。5. 调试过程中的经验与体会5.1 旋转开关方案在强干扰环境下的实测效果旋转开关用ADC方案最怕的是继电器频繁通断的强电磁干扰环境。我实测下来输出的是平均值中值滤波组合效果比单纯平均值要好很多。如果MCU自带硬件过采样比如G0系列的硬件过采样也可以打开能进一步压低噪声。另外有个体验旋钮的机械结构会影响读数稳定性。质量好一点的旋转开关触点接触电阻小ADC读数很稳便宜的开关会有几十毫欧的接触电阻波动虽然不至于改变档位判定但在临界位置时输出值会有跳变。所以硬件选型时旋钮尽量选镀金触点的。5.2 Modbus浮点解析的偶发错值怎么排查如果通信过程中偶尔出现一次浮点偏差优先检查波特率是否匹配尤其是带自动波特率检测的从机偶尔会锁定错波特率。总线是否有终端电阻。RS485总线两端需要120Ω终端电阻如果只有一个设备终端电阻位置不对也容易出现回波干扰。从机内部是否对float做了字节交换有的从机固件内部用的是小端存储但协议层面没有统一转换。寄存器地址是否连续。有的设备两个float寄存器之间会穿插一个状态寄存器读取时没有跳过导致数据错位。从成本和效果来看第四点最容易被人忽略。很多国产仪表的寄存器表不是连续排列的比如float数据占0x0001、0x0002但0x0003是报警状态字0x0004、0x0005才是另一个float。如果你直接用Modbus的读保持寄存器命令从0x0001开始连读4个寄存器读到的第二位float就是错的。这是逻辑错误不是协议错误特别容易被误判为字节序问题。5.3 关于到底要不要用浮点传输的讨论如果数据范围不大、精度要求不苛刻有一个更省心的做法——把float在发送端乘以100或者1000转成整数再发送接收端再除以同样的系数。比如温度25.56摄氏度就直接发2556这个uint16_t整数或者uint32_t如果数值更大。这样只需要一个寄存器不存在字节序问题通信效率也更高。但这种方式也有局限动态范围大的数据比如高精度的压力传感器读数、陀螺仪积分后的角度就不适合定点缩放这时候用float传输依然是更合理的选择。所以float传输必备技能不是因为它高级而是因为总有用到它的场景。我个人在项目选型时会先问一个问题数据需要在多个设备之间互通吗如果只是自己的主机和从机通信那我倾向于缩放成整数传输简单可靠。如果是和别人的设备对接比如PLC、触摸屏那大概率需要原生float格式因为对方设备支持的Modbus数据模型里float是固定格式。5.4 最后留一个小的调试技巧如果你用的是STM32并且开启了串口的空闲中断接收Modbus报文的时候要注意Modbus RTU规定了帧间隔是3.5个字符时间如果MCU主频很高空闲中断的判定时间需要根据波特率重新计算。特别是波特率低于9600时3.5个字符时间是毫秒级别的不能用一个固定的定时器值去套所有波特率否则帧会被拆成两截或者粘包。这个和前面的float拆分问题一样都是“看起来简单、实际调试时容易翻车”的细节。我自己的习惯是把帧间隔判定封装成一个独立函数波特率变化时自动重新计算定时器装载值。上电初始化时读取拨码开关设定的波特率然后调用一次modbus_uart_config()里面把帧间隔定时器一起配置好这样就不会出现“9600正常、115200却收不全帧”的诡异问题。写到这里这期调试笔记想分享的两个核心点就都讲完了。一个是用ADC代替多路IO来读档位开关另一个是搞清楚Modbus的float到底怎么放、怎么取。这两个点看似八竿子打不着但实际做项目时往往是在同一天里踩完的坑。希望这篇笔记对同行有帮助尤其是那些刚入行、正在为“为什么读回来的浮点数是个天文数字”而挠头的朋友。
返回列表