资讯详情

资讯详情

四档旋转开关省IO与Modbus浮点拆分:嵌入式调试实战笔记

嵌入式调试笔记第六篇记录一次4档旋转开关省IO采集以及Modbus协议里float拆分还原的完整过程。这两天在调一块小控制板面板上有一个4档旋转开关用来选择工作模式同时设备要通过Modbus RTU把一个浮点参数上报给上位机。原本以为都是很成熟的操作结果一个卡在IO口分配上一个卡在浮点数据怎么拆成寄存器上前后折腾到凌晨才理清楚。先说为什么这两个问题能凑到一篇笔记里。这块控制板上IO几乎全用出去了按键扫描、继电器控制、状态灯、通信指示外加一个485收发控制脚剩余的空闲IO屈指可数。4档旋转开关如果按最直观的做法每个档位接一个普通IO口一下就要占4个引脚而且这4个引脚只能用来看开关状态做不了别的事。至于Modbus那边浮点参数要按IEEE 754格式拆成两个16位寄存器才能放进协议帧里一旦字序理解错上位机读回去的不是天文数字就是乱码。这篇笔记适合正在做STM32、FreeModbus或类似Modbus从站设备并且遇到“IO不够用”或者“浮点寄存器顺序不对”的同行。我会把两个问题分别展开讲清楚计算过程、代码写法和联调时容易踩的坑。1. 为什么两块完全不搭的调试会出现在同一篇笔记里1.1 一台控制板让两个问题撞在一起这块板子的结构不复杂一块STM32F103C8T6当主控LED数码管显示几个按键一个4档旋转开关后面带了一路RS485转Modbus RTU从站需要上报一路温度值。温度值是浮点例如23.5摄氏度。旋钮档位意味着不同的运行模式上位机也要能读到当前旋钮在哪个档位。IO不够用是第一个结。PCB上预留的IO本来是按“每个档位一个引脚”设计的4个档位就是4个引脚但实际排下来发现剩下的引脚只有2个普通的连3个都凑不齐更不用说4个。重新改板来不及了只能从接线和采集方案上想办法。Modbus那边是第二个结。FreeModbus协议栈本身处理的是寄存器不是C语言的float类型。温度值要发给上位机就绕不开“把float拆成两个寄存器”这一步。刚开始我随手写了个联合体转换结果上位机读出来完全不对数值直接乱掉。后来才发现问题不是数据拆错了而是发送顺序和组态软件默认的字序不一样。这两个问题看似独立本质却很像都想用更少的资源表达同样的信息。开关占IO浮点占寄存器核心都是“编码方案”的选择问题。所以在调试笔记里放在一起写也方便自己后面回顾。1.2 这次调试的环境和涉及的工具硬件方面用得比较朴素MCU是STM32F103C8T6标准外设库V3.5开发环境是Keil MDK。Modbus协议栈用的是FreeModbus v1.6这个版本比较老但胜在稳定网上资料也齐很多项目里都在用。注意一点FreeModbus v1.6跑在标准库上如果你用的是HAL库移植时有些接口名不一样但核心逻辑是通用的。调Modbus我习惯用Modbus Poll做上位机模拟配合一个USB转RS485的调试器。Modbus Poll可以按16进制看寄存器原始值这个能力在排查字节序问题时几乎是必须的。如果你手头没有这个工具其他能直接查看保持寄存器原始十六进制值的调试软件也行关键是不要一上来就让软件把寄存器解析成浮点否则你根本不知道协议栈收到的16位数据到底长什么样。另外我还用到了一个很土但有效的东西串口打印。在协议栈的读写回调函数里加几行printf把读到的寄存器地址和数据长度打出来能帮你快速定位是协议栈的问题还是应用层的问题。2. 旋转开关省IO从4根线到1个ADC通道的方案推演2.1 常规接法和ADC分压接法的成本对比最常规的接法是每个档位接一个普通IO开关闭合时IO被拉到GND断开时IO被上拉电阻拉高。这种接法优点是逻辑简单、抗干扰好代码里只要读四个引脚的电平谁拉低就说明开关在哪个档位几行代码搞定。缺点是IO开销大4档开关占4个IO如果是8档就得占8个。另一种思路是用编码器工业上很多旋转开关本身带二进制编码输出4档只需要2个IO。但这种开关比较贵而且我们手里这块板的开关只是普通的机械旋转开关一个公共端加4个独立触点没有编码输出所以这条路走不通。ADC分压方案就是把开关变成电阻网络。公共端接ADC引脚每个档位接不同阻值的电阻到GND再用一个固定上拉电阻把公共端接到3.3V。这样每个档位对应一个不同的分压电压ADC读到的值落在哪个区间就知道当前是哪个档位。一片这样的电阻网络成本还不到一角钱换来的却是从4个IO直接降到1个ADC通道。这里我需要说明一个选型原则ADC方案的前提是MCU有空闲的ADC通道。STM32F103的ADC1可以有16个通道一般板子用掉几个之后往往还有富余所以这个条件不苛刻。如果系统的ADC通道也全部被占了那就只能考虑换带更多引脚的MCU或者用移位寄存器方案但那属于更大范围的硬件调整不在这次讨论里。总之ADC分压方案是在“不改板不动MCU”前提下最省IO的实用解法。2.2 分压电阻网络与档位阈值计算我用的电阻取值是工程最常见的E24系列上拉电阻R0取10kΩ四个档位分别接10kΩ、4.7kΩ、2.2kΩ、1kΩ到GND。这里R0和档位电阻构成分压网络计算公式是V 3.3 × R档 / (R0 R档)。ADC是12位满量程4095对应的ADC码值就是V / 3.3 × 4095。实际的档位电压和ADC码值算出来是这样档位下拉电阻分压电压ADC码值110kΩ1.650V204824.7kΩ1.055V130932.2kΩ0.595V73841kΩ0.300V372相邻档位的ADC码值差距分别是739、571、366最低也超过300个码。对于12位ADC来说这个间隔足够大正常情况下不会因为电阻误差或者电源纹波导致误判。但如果把电阻值取得太低或者太接近码值间隔压到100以内可靠性就会显著下降所以取值时要自己核算一遍码值间隔留出足够余量。然后就是阈值划分。我取了相邻档位码值的中点作为边界档位1和档位2的边界(2048 1309) / 2 ≈ 1678档位2和档位3的边界(1309 738) / 2 ≈ 1024档位3和档位4的边界(738 372) / 2 ≈ 555判定逻辑就是ADC值大于等于1678判为档位11024到1678之间判为档位2555到1023判为档位3小于555判为档位4。注意这里的区间都是包含边界的用C语言实现时建议写成“大于等于”或“小于”的清晰组合避免边界条件重叠。2.3 悬空档位与ADC饱和有效状态之外的兜底机械旋转开关换挡的时候公共端会短暂地和所有档位触点都断开。这个时间非常短连1ms都不到但足以让ADC引脚悬空。悬空后上拉电阻10kΩ会把引脚拉到接近3.3VADC读值靠近4095。如果不做处理换挡瞬间就会被误判成一个无效甚至不存在的档位。我的做法是设一个无效区ADC值大于3800时直接返回一个“无效档位”状态应用层在旋钮档位不确定时保持上一次的有效值不变。这里3800的阈值留了大约300个码的余量因为3.3V经过上拉后ADC读值一般在4050以上理论上不会落到3800以下但如果电源纹波太大或者PCB走线附近有干扰留些余量更稳。另外还要考虑接触电阻。旋转开关的触点氧化或者弹片老化之后接触电阻可能从几十毫欧涨到几百毫欧。这会让分压比值发生微小偏移但因为电阻阻值都在kΩ级别几百毫欧影响非常小可以忽略。真正不能忽略的是档位电阻本身的精度我选的是1%精度电阻如果用了5%的普通电阻档位1的10kΩ误差就有500Ω档位电压大概偏差2.5%对应的ADC码值偏差约50个码。所以这里电阻精度建议选1%不贵但能显著提高裕量。3. 档位识别的采样与防抖开关不是“读一下”就能完事的3.1 去极值平均滤波与区间判定ADC单次采样容易受毛刺影响尤其当采样引脚靠近继电器或电源走线时一个干扰脉冲就可能让单次采样值偏离几十甚至上百个码。所以我用了工程上常用的去极值平均滤波连续采样N次去掉一个最大值和一个最小值剩下的求平均用这个平均值做区间判定。#define SWITCH_ADC_CHANNEL ADC_Channel_1 uint16_t switch_adc_average(uint8_t times) { uint32_t sum 0; uint16_t min_val 4095, max_val 0; uint16_t val; uint8_t i; for (i 0; i times; i) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); val ADC_GetConversionValue(ADC1); if (val min_val) min_val val; if (val max_val) max_val val; sum val; } sum - min_val max_val; return (uint16_t)(sum / (times - 2)); }采样次数我用的是8次去掉极值后平均6次。这个数量兼顾了响应速度和稳定性。如果系统里电磁干扰特别严重可以把次数加到16次但代价是档位响应变慢。一个经验是旋转开关的机械动作至少会有几十毫秒的稳定时间只要滤波耗时小于这个稳定时间就不会影响手感。得到平均值之后判定函数就很简单了uint8_t rotate_switch_get_level(uint16_t adc) { if (adc 3800) return SWITCH_INVALID; if (adc 1678) return 1; if (adc 1024) return 2; if (adc 555) return 3; return 4; }3.2 档位滞回临界抖动最有效的抑制手段只做平均滤波还不够。当开关触点老化或者接触不稳定时ADC值可能在某个阈值附近来回抖动导致档位在两个值之间反复跳变。举个例子档位2和档位3的边界是1024如果触点轻微氧化采样值在1000到1050之间波动那每一次采样都可能切到不同的档位。这种问题上电瞬间偶尔出现一次还能接受操作旋钮时反复跳档就很不舒服了。解决思路是滞回比较原理和施密特触发器类似。当档位从低往高切换时要用更低的边界值确认从高往低切换时要用更高的边界值确认。这样中间的过渡带就形成了一个安全区只有ADC值真正越过安全区时档位才变化。我在代码里实现了一个简单的滞回逻辑边界在原来基础上左右各偏移约80到150个码#define LEVEL1_BOUND_HIGH 1800u #define LEVEL1_BOUND_LOW 1550u #define LEVEL2_BOUND_HIGH 1100u #define LEVEL2_BOUND_LOW 900u #define LEVEL3_BOUND_HIGH 650u #define LEVEL3_BOUND_LOW 460u static uint8_t sw_current_level SWITCH_INVALID; uint8_t rotate_switch_update(uint16_t adc) { uint8_t raw_level; if (adc 3800) { return sw_current_level; } if (adc LEVEL1_BOUND_HIGH) raw_level 1; else if (adc LEVEL2_BOUND_HIGH) raw_level 2; else if (adc LEVEL3_BOUND_HIGH) raw_level 3; else raw_level 4; if (sw_current_level SWITCH_INVALID) { sw_current_level raw_level; return sw_current_level; } if (raw_level sw_current_level) { /* 向更高档位切换用低边界确认 */ switch (raw_level) { case 2: if (adc LEVEL1_BOUND_LOW) sw_current_level 2; break; case 3: if (adc LEVEL2_BOUND_LOW) sw_current_level 3; break; case 4: if (adc LEVEL3_BOUND_LOW) sw_current_level 4; break; } } else if (raw_level sw_current_level) { /* 向更低档位切换用高边界确认 */ switch (raw_level) { case 1: if (adc LEVEL1_BOUND_HIGH) sw_current_level 1; break; case 2: if (adc LEVEL2_BOUND_HIGH) sw_current_level 2; break; case 3: if (adc LEVEL3_BOUND_HIGH) sw_current_level 3; break; } } return sw_current_level; }这套滞回逻辑并不是唯一的标准答案里面的边界值要根据实际采样噪声大小来调。比如你实测发现ADC在稳态时抖动不超过±20个码那滞回带取80到100个码就足够。调大的好处是更稳坏处是档位切换变“迟钝”如果操作者拧得快可能需要拧到位之后再等一小会儿才能生效。3.3 接近开关触点的工程细节RC滤波与采样时序硬件上我也加了一个简单的RC低通滤波ADC引脚串一个100Ω电阻再接一个100nF电容到GND。这个RC的截止频率大约16kHz主要用来抑制高频毛刺和射频干扰不会影响旋钮这种慢变信号。采样时需要注意RC滤波会让电压建立需要时间特别是开关从一档切换到另一档的瞬间电容要经历一个充放电过程。如果采样太快可能读到的是过渡过程中的电压而不是稳定电压。所以在时序上我建议每次采样之前先等一小段稳定时间比如切换档位后延时5到10ms再开始采样。如果不想在代码里干等也可以靠滤波算法自动消化但处理换挡瞬间的暂态响应不如“先等再采”来得干脆。我实际测试下来10ms延时加上8次采样平均整个档位识别过程大约20ms左右对面板操作来说完全感知不到延迟。还要注意ADC采样时间。STM32F103的ADC采样时间建议配置得长一点对高阻抗信号源尤其重要。我当时把采样时间配成了239.5个周期而不是最短的1.5周期因为分压网络等效阻抗在2k到5k之间虽然不算特别高但更长的采样时间能保证采样电容完全充满读数更稳定。4. Modbus里float的拆分一个浮点为什么要占两个寄存器4.1 IEEE754单精度格式与寄存器对的映射逻辑要理解float在Modbus里怎么拆分先得知道float在内存里长什么样。一个32位单精度浮点数由三部分组成1位符号位、8位指数位、23位尾数位。任何十进制浮点数比如23.5最终在内存里是一个固定的32位二进制组合也就是0x41BC0000这种形式。Modbus协议的最小数据单元是寄存器一个寄存器16位可以放2个字节。协议栈读写的都是寄存器不是字节流。所以一个32位的float必须被拆成两个16位的寄存器这两个寄存器在设备端往往连续存放文档里可能叫“寄存器对”或者“双字寄存器”。拆的时候要决定高16位放哪个寄存器低16位放哪个寄存器这就是字序问题。为什么要强调寄存器而不是字节因为Modbus RTU串行链路上你把一个寄存器发送出去时协议栈会先发送高字节再发送低字节这是协议规定的。但两个寄存器的先后顺序协议栈并不关心协议栈只负责把它给你的数据原样发出去。于是高字在前的ABCD和低字在前的CDAB总线上都能发但对端能不能正确解释取决于两边约定是否一致。4.2 联合体拆分的直观写法和潜在隐患很多教程里给的方案是用C语言的联合体让float和两个uint16_t共用同一块内存typedef union { float f; uint16_t u16[2]; } f32_reg_t; void pack_float_abcd(float value, uint16_t regs[2]) { f32_reg_t t; t.f value; regs[0] t.u16[1]; /* 高16位放前面 */ regs[1] t.u16[0]; } float unpack_float_abcd(const uint16_t regs[2]) { f32_reg_t t; t.u16[1] regs[0]; t.u16[0] regs[1]; return t.f; }这段代码在STM32上完全能跑因为STM32是32位小端处理器float内存里低地址存的是低字节u16数组的第一个元素正好对应float的低16位。所以words[1]拿到的是高16位words[0]是低16位逻辑正确。但这种写法有个隐患它隐式依赖小端平台。如果哪天把代码移植到Cortex-M核的大端模式或者遇到某些编译器对联合体type punning的行为不一致这里就会出问题。C标准里对联合体的这种用法严格来说是“实现定义行为”GCC、ARMCC等常见嵌入式编译器都支持但严谨性上不如直接用memcpy和位运算。我以前也觉得联合体方便直到有一次把同一套代码从STM32移植到另一颗M0核心的国产芯片上因为那款芯片的默认字节序配置不同float读出来彻底乱了排查了好一阵才想起来是这里埋的雷。4.3 用memcpy加移位实现平台无关的拆分还原后来我改成了memcpy加移位的写法。先把float的32位bit pattern复制到一个uint32_t变量里然后用移位提取高16位和低16位void pack_float_abcd_portable(float value, uint16_t regs[2]) { uint32_t bits; memcpy(bits, value, 4); regs[0] (uint16_t)(bits 16); regs[1] (uint16_t)(bits 0xFFFF); } float unpack_float_abcd_portable(const uint16_t regs[2]) { uint32_t bits ((uint32_t)regs[0] 16) | (uint32_t)regs[1]; float value; memcpy(value, bits, 4); return value; }这段代码里有两点值得说明。第一memcpy只是把内存里的4个字节原样复制到另一个4字节变量里它不做字节序转换所以不会受平台大小端影响。第二把uint32_t的数值通过移位拆成高低16位这个过程是纯数值运算也与内存字节序无关。这样一来代码在大小端平台上都能得到一致的IEEE754位模式平台相关的问题就消除了。性能方面完全不用担心。Modbus读写的频率一般很低一个浮点参数的拆分还原也就几条指令外加一次memcpy编译器优化后开销很小。我实际测试过在48MHz的STM32F103上一秒钟读几十次寄存器毫无压力。5. 字序实战ABCD、CDAB、BADC、DCBA到底怎么选5.1 用123.456推演四种字序的差异拿123.456这个数来推演可能最直观。123.456的IEEE754十六进制是0x42F6E979拆开后高16位是0x42F6低16位是0xE979。这两个字在两个寄存器里谁先谁后或者寄存器内部字节要不要交换就出现了四种常见组合。我用四个寄存器对来表示这四种字序发送到总线上依次是字序寄存器0内容寄存器1内容串行总线字节流ABCD0x42F60xE97942 F6 E9 79CDAB0xE9790x42F6E9 79 42 F6BADC0xF6420x79E9F6 42 79 E9DCBA0x79E90xF64279 E9 F6 42大多数Modbus设备用的是ABCD也就是“高字在前每个寄存器内部高字节在前”。但也有相当一部分设备默认CDAB。BADC和DCBA比较少见通常出现在一些早期PLC的特殊配置里。注意这里说的“大端”往往只指最后一个维度也就是每个寄存器内部高字节在前但它没有规定两个寄存器之间的顺序所以看到一个设备文档写“大端模式”无法确定是ABCD还是CDAB必须实测。5.2 联调时怎么实测设备的真实字序最可靠的办法是把你这边从站的浮点上报寄存器里写入一个已知值然后在上位机工具里按十六进制读原始寄存器值。比如说好的是123.456你先通过从站程序让那对寄存器里存好这个数然后用Modbus Poll按hex方式读回来。如果看到的是42 F6 E9 79那字序是ABCD如果看到E9 79 42 F6那字序是CDAB。对照上表可以快速判断。如果没有现成的Modbus工具也可以用串口助手直接监视总线上从站回复的报文。从站响应中对应数据域的4个字节和上表的总线字节流完全一致。不要根据报文的起始地址去猜直接用这个已知值对照是最快的。我习惯在工程代码里把字序做成一个宏方便根据设备改#define MODBUS_FLOAT_ORDER_ABCD 1 #define MODBUS_FLOAT_ORDER_CDAB 2 #define MODBUS_FLOAT_ORDER_BADC 3 #define MODBUS_FLOAT_ORDER_DCBA 4 #define MODBUS_FLOAT_ORDER MODBUS_FLOAT_ORDER_ABCD然后在打包和解包函数里用条件编译或者if-else选择对应顺序。这样换一台设备联调时只需要改一个宏不用翻代码去找具体的交换逻辑。5.3 组态软件解析类型与寄存器地址偏移的坑字序确认之后上位机组态软件里的配置也要对得上。很多组态软件在定义一个标签时要选择数据类型是float还是int以及字序选项是“高字在前”还是“低字在前”。选择错误就会重复出现乱码问题。这属于软件配置层面的坑排查时要和从站代码一起看。另外还有一个容易踩的地址偏移问题。Modbus协议里的地址从0开始编号但很多设备文档习惯用PLC风格的地址比如保持寄存器从40001开始。文档说“浮点起始地址40001占用2个寄存器”那么实际Modbus报文里的起始地址是0x0000第二个寄存器是地址0x0001对应文档里的40002。如果你按十进制偏移去读把第二个寄存器当成40010或者40011那就完全错了。这个细节看起来基础但我在现场真见过有人因此调了一下午没调通。6. 两个“翻车现场”的完整排查与最终验证6.1 翻车一旋钮档位偶发跳变问题不在电阻精度第一次把旋钮和ADC采集代码都联起来之后我以为就稳了。结果现场测试时操作旋钮快速从档位1拨到档位4偶尔会出现档位停在2或者3上不动的情况再过一秒又自己跳到4。我一开始怀疑是电阻精度不够但用万用表量每一档的电压都很准用串口打印ADC码值也完全对应。问题出现的频率不高十次里有两次。后来我用示波器挂在ADC引脚上看波形发现问题出在机械开关的弹跳上。旋转开关从一档拧到另一档的过程中触点是“断开旧触点、滑过空档、接通新触点”这样的过程如果拧得不够干脆新触点接触的瞬间会有一连串几毫秒的弹跳导致ADC引脚电压在目标档位和悬空满量程之间来回摆动。我的滤波算法虽然能过滤采样毛刺但对这种持续时间较长的“真触点抖动”无能为力它会把悬空瞬间读到的高ADC值当成有效状态又因为滞回逻辑不够快而卡在中间状态。我的修正方法是把“连续确认”加到状态机里档位发生变化时不要立即确认而是连续10ms后再采样一次如果两次判断一致才切换。这样触点抖动的几十毫秒足够被跳过同时因为代码里已经有滞回正常操作的手感基本不受影响。修完之后连续拧了200次开关没有出现一次误判。6.2 翻车二打包和解包字序没有保持对称Modbus浮点上位机读出来乱的第二晚我用Modbus Poll读原始寄存器发现从站发出去的数据确实是42 F6 E9 79按ABCD顺序发的一点问题没有。但组态软件那边显示成巨大的负数明显不对。我在上位机里把解析字序从ABCD改成CDAB显示立刻就成了123.456。这说明从站发的是ABCD上位机默认CDAB两边没对齐。关键教训在于打包和解包必须是一对完全对称的操作但这仅仅针对从站内部代码和上位机的解析属于两套系统不能想当然。我在从站代码里把四个字序的打包函数都写出来了然后在FreeModbus的回调函数里根据宏定义选择对应顺序同时把宏值同步给上位机配置。重点是代码里所有读浮点和写浮点的地方都要统一用同一个宏不要某些地方手动指定ABCD另一些地方手动指定CDAB这种不一致最难查。验证方法我用的是回环法上位机通过06功能码往从站写一个浮点再从03功能码读回来比较写入值和读回值是否完全一致。这里注意不要只看打印输出直接用memcmp比较两段内存的最稳妥因为float打印经过格式化可能掩盖细微的bit位差异。我当时的测试程序把写入值和读回值的4个字节都打印成十六进制逐字节比对完全一致才算是通过。6.3 收尾档位整数寄存器与浮点寄存器对要分开看最后一个收尾工作是旋钮档位的上报。旋钮档位是整数一个16位寄存器足够装下但不少组态软件在读取浮点时是按寄存器对来解析的。如果我把档位放在和浮点紧邻的地址区上位机如果按浮点解析一个包含档位数据的寄存器对就会得到一个毫无意义的小数比如档位2会变成类似2.8e-45这样几乎为零的数值。我的处理方案是把旋钮档位放在独立的保持寄存器地址和浮点寄存器对之间至少隔开一个地址上位机按无符号整型来解析这个寄存器。从站代码里档位寄存器的读回调返回当前滞回状态机的值浮点寄存器对返回打包好的两个寄存器。两者井水不犯河水。最后再分享一个实测中的小技巧当旋钮档位值有变化时我习惯在RTU从站的响应里不做任何额外延迟直接返回当前寄存器值。FreeModbus的默认工作方式是主站请求、从站应答从站寄存器数据始终是“最新值”不需要主动上报机制。把档位识别逻辑放在ADC采样中断里更新保持寄存器的值始终是最新的这样上位机读到的任何时刻的档位都是准确的。旋钮档位的滞回逻辑、浮点字序宏、上位机配置这三样东西是我每次做类似项目都会先固定下来的三个点。确认这三处没问题Modbus设备联调的大部分迷茫都能提前避开。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →