ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式省IO方案:一个引脚读4档开关与Modbus浮点数传输完整指南

嵌入式省IO方案:一个引脚读4档开关与Modbus浮点数传输完整指南 最近调一台设备面板要加一个4档旋转开关主控的IO口已经排得密密麻麻得想尽办法省IO另一路数据要过Modbus RTU上报里面有个温度值是float必须拆成寄存器再还原。这篇调试笔记就把这两件事的完整思路和可直接抄的代码记下来给同样在单片机、PLC、现场仪表之间来回折腾的朋友一个参考。这两件事听起来没有交集但做现场调试时经常一起出现面板输入要省引脚通讯数据又要跨寄存器传输任何一个环节踩坑现象都很诡异。1. 先理清需求4档开关为什么成了IO大户1.1 常规接法一档一个引脚4档吃4个IO最常见的4档旋转开关本质上就是一个单刀四掷开关公共端只有一根四个触点分别对应四个档位。很多人拿到手的第一反应是四个触点直接接四个IO公共端接GND然后程序里读四个引脚的电平谁被拉低就是当前档位。这种接法逻辑上没问题调试也简单但代价很直接4个档位吃掉4个IO。嵌入式设备里IO向来是稀缺资源。用STM32F103C8T6这种48脚封装的片子在现场很常见本来要接按键、数码管、串口、指示灯IO已经排得很紧。如果再加一个4档旋钮直接多吃4个引脚很多时候意味着要换更大封装的MCU或者砍掉其他功能。为了一个旋钮去动硬件选型怎么看都不划算。还有一层容易被忽略的成本单片机引脚数量上去之后PCB走线、封装尺寸、BOM成本都会跟着涨。所以做产品的人一般不这么干而是想尽办法把档位识别这件事压缩到最少的引脚上。1.2 省IO的本质用模拟量或时间量换引脚要省IO核心思路就一句话用模拟量或者时间量去换引脚数量。数字IO只能表达0和1一个引脚最多给两个状态但如果你把档位信息转换成一个模拟电压值或者一段可测量时间那么一个引脚就能承载很多状态。电压维度靠ADC实现。不同档位接不同阻值的电阻在ADC引脚上分压出不同的电压程序读取电压区间来识别档位。这是最稳定、最常用的做法四个档位只需要一个带ADC功能的引脚。时间维度靠RC充放电实现。普通IO先输出高低电平给电容充放电然后切成输入模式用定时器测量电压翻转经过的时间。不同档位接不同电阻充电时间不同靠时间范围判断档位。这个方案不需要ADC任意一个普通IO都能做。这两种方案我都在实际项目里用过各有适用场景。后面分别把电路设计、代码和踩坑记录展开说。对于只有4档的旋转开关来说处理起来比想象中简单但恰恰是简单的事现场出了问题才更难排查因为第一反应不会怀疑它。2. 方案实测1个ADC引脚读4档分压法2.1 电阻分压怎么设计四档电压各是多少先说我最后采用的ADC分压方案。电路结构不复杂VCC接一个10K上拉电阻R_up到采集点采集点同时接ADC引脚和旋转开关的公共端。旋转开关四个档位触点分别通过R11K、R23.3K、R310K、R433K接到GND。当旋到某个档位时只有一个电阻被接入采集点电压就是标准的电阻分压结果。以VCC3.3V为例每档的预期电压用分压公式计算V_adc VCC * R_x / (R_up R_x)。具体算下来是这样档位下拉电阻采集点电压12位ADC码值11K0.30V约37223.3K0.82V约1017310K1.65V约2047433K2.53V约3138为什么选这几个阻值第一个原因是电压间隔拉得足够开。相邻档位之间的电压差都超过0.5V对应ADC码值相差600左右留了很大的裕量。第二个原因是这几个电阻都是E24系列里的常用值电子市场随手就能买到不需要定制。这里要特别注意一个细节判定档位时不要用理论电压值作为边界阈值而应该取相邻档位的中间值。比如档1和档2的ADC码值分别是372和1017那判定分界线就设在(3721017)/2≈694附近。这样即使电阻有误差、电源有波动也不容易越界。电阻精度建议选1%的金属膜电阻不要为了省几毛钱用5%的普通碳膜电阻。4档分压的裕量虽然已经很大但现场设备可能工作在宽温度范围电阻温漂会叠加到电压上1%精度能省掉很多隐形麻烦。2.2 采样判定代码滤波、迟滞、区间判断ADC分压法的代码很简单但简单不代表可以乱写。直接读一次ADC就判断档位现场会偶尔出现跳变。我一般会做两件事多次采样取平均再加入迟滞确认。下面是基于STM32 HAL库的示例平台不同思路完全一样uint8_t get_switch_position(void) { uint32_t sum 0; uint16_t sample 0; uint8_t i; for (i 0; i 16; i) { HAL_ADC_Start(hadc); HAL_ADC_PollForConversion(hadc, 10); sample HAL_ADC_GetValue(hadc); sum sample; HAL_Delay(1); } uint16_t avg sum / 16; if (avg 694) { return 1; } else if (avg 1532) { return 2; } else if (avg 2592) { return 3; } else { return 4; } }阈值分别是相邻档位ADC码值的中间值档1与档2之间取694档2与档3之间取(10172047)/21532档3与档4之间取(20473138)/2≈2592。这样不管ADC值向上偏移还是向下偏移都有足够的容忍度。多次采样取平均是为了滤掉ADC采样噪声和机械触点接触瞬间的毛刺。16次采样在1ms间隔下总共耗时十几毫秒对旋钮输入来说完全可以接受。如果项目对档位误判特别敏感可以在上层再加一层“连续两次一致才更新”的状态确认逻辑static uint8_t last_pos 0xFF; uint8_t get_switch_position_debounce(void) { uint8_t pos get_switch_position(); static uint8_t pending_pos 0; static uint8_t pending_cnt 0; if (pos last_pos) { pending_cnt 0; return last_pos; } if (pos pending_pos) { pending_cnt; } else { pending_pos pos; pending_cnt 1; } if (pending_cnt 2) { last_pos pending_pos; pending_cnt 0; } return last_pos; }这个逻辑的效果是第一次读到新档位先不认连续第二次读到相同结果才更新。机械旋转开关切换档位时常见的抖动、接触不良基本都能被这一层过滤掉。实测下来档位切换干净利落不会出现跳档。2.3 实际接线、滤波电容和防抖细节电路上还有一个容易被忽略的点ADC引脚到GND之间加一个0.1uF陶瓷电容。一方面是滤掉电源和空间耦合进来的高频噪声另一方面是让ADC采样期间的电压更稳定。注意电容容值不要加太大否则旋钮切换档位后电压建立时间变长影响实时性。0.1uF配10K级别的电阻时间常数在毫秒级实测没问题。不要用MCU的内部上拉电阻来代替外部上拉。内部上拉阻值范围很宽一般在30K到50K之间离散性大算出来的分压点会很飘。外部10K电阻虽然多占一点PCB面积但好处是阻值确定、温漂可控。还有一点要提醒如果设备用电池供电VCC会随电量下降而波动。若ADC的参考电压就是VCC那么分压比例基本不变对判定影响不大如果ADC参考电压是独立的高精度基准那VCC跌落会导致所有档位电压等比下降必须重新计算阈值或者改用电压比例而不是绝对码值。3. 备用方案1个普通IO读4档RC充放电计时法3.1 原理一个IO先在输出模式充电再切输入模式计时ADC分压方案虽好但有个前提MCU得有空闲的ADC通道。有些项目里ADC已经被电流、电压、温度传感器占满了有的8位单片机根本就没ADC可怎么办这时候可以用RC充放电计时法一个普通IO就能读4档。原理其实不复杂电路上旋转开关的四个触点分别接四个不同阻值的电阻到VCC公共端接一个节点A。节点A同时接单片机IO引脚和一只电容C到GND。当旋到某个档位时相当于在VCC和节点A之间接入某个电阻。程序分两步操作第一步把IO配置为推挽输出低电平。此时节点A被强制拉到GND电容放电电压归零。第二步把IO切换为浮空输入模式同时启动定时器。此时VCC通过旋转开关选中的电阻给电容充电节点A电压从0开始上升。当电压上升到IO引脚的高电平阈值时读取到电平1停止定时器。由于不同档位接入的电阻不同R1 R2 R3 R4电容充电速度不同时间长短就代表了档位。整个过程中IO口先当输出用再当输入用这就是“一个IO当两个用”的关键。3.2 实现要点与计时范围估算举例估算一下时间。取C0.1uF四个档位电阻分别取10K、47K、100K、220KVCC3.3V单片机输入高电平阈值估算为2V。电容充电公式是V(t) VCC * (1 - e^(-t/(RC)))。解一下t -RCln(1 - Vt/VCC)代入Vt2V、VCC3.3Vln(1 - 2/3.3)约等于-0.931所以t ≈ RC*0.931。档位充电电阻充电时间估算110K0.93ms247K4.38ms3100K9.31ms4220K20.48ms四个时间范围差异明显用定时器计数值判断很容易区分。代码思路如下uint32_t measure_charge_time(void) { uint32_t time_us 0; GPIO_MODE_OUTPUT(IO); GPIO_WRITE_LOW(IO); DELAY_MS(2); // 确保电容放干净 TIMER_CLEAR(); TIMER_START(); GPIO_MODE_INPUT(IO); // 切输入开始充电 while (GPIO_READ(IO) 0) { if (TIMER_GET() 50000) { // 50ms超时保护 break; } } time_us TIMER_GET(); TIMER_STOP(); return time_us; }超时保护必须有。万一开关接触不良、电阻虚焊充电时间会无限拉长没有超时判断程序就卡死在while里了。实际工程中任何一个等待外部状态的循环都必须有超时保护这是做嵌入式的基本素养。RC法的优点是不挑引脚缺点也很明显判断一次档位需要等待毫秒级的时间期间CPU被占用电容容差一般有10%到20%电阻温漂也会叠加所以档位时间区间必须留得很宽。实测下来这个方案适合对实时性要求不高、同时ADC资源实在紧张的场景。3.3 两种省IO方案怎么选把ADC分压法和RC充放电计时法放在一起对比对比项ADC分压法RC充放电计时法占用资源1个ADC引脚1个普通IO需要定时器不需要需要稳定性高抗干扰能力强一般受温漂和电容精度影响采样速度快微秒级慢毫秒级代码复杂度低中等典型场景大部分MCU方案无ADC资源或引脚稀缺我的习惯是只要能腾出一个ADC通道优先用ADC分压法。RC法虽然也能用但每次读档位要占用几毫秒甚至几十毫秒在需要频繁读取的场合体验不好。而且RC法的电压阈值是芯片输入高电平阈值不同批次芯片会有差异换一颗MCU可能要重新标定时间区间不如ADC法直接用码值判定省心。4. Modbus里的float拆分还原没那么简单4.1 为什么float必须拆成两个寄存器Modbus协议里保持寄存器和输入寄存器都是16位宽度一个寄存器最大只能表示0到65535。而C语言里的float是32位一个寄存器无论如何塞不下只能拆成两个连续的16位寄存器来传输。问题就出在“怎么拆”上。float在内存里是一段32位的位模式不是普通整数那么简单。如果直接把浮点数强转成uint32_t再截成高低16位丢掉的不是精度而是整个数据的结构。必须要完整的32位都保留下来拆分只是把位模式按两个16位块搬运不能做任何截断。我见过不少人在这里图省事写类似这样的代码uint16_t reg1 (uint16_t)(*(uint32_t *)value);这种写法有三个问题强制类型转换可能违反编译器的严格别名规则如果地址不对齐在部分内核上会触发硬件异常代码换到不同字节序的平台上结果完全不一样。在STM32上可能恰好能跑但这不是一个可移植的写法。4.2 IEEE754结构速记符号位、指数、尾数要想彻底搞懂为什么float拆分容易出问题得先回顾一下IEEE754单精度浮点数的结构。32位float由三部分组成1位符号位8位指数位23位尾数位。数值计算公式是(-1)^s * (1 尾数) * 2^(指数 - 127)。平时写代码不关心这些细节也没问题但做Modbus联调时这些知识直接决定你能不能快速定位问题。因为当你从Modbus寄存器里读出一堆16位整数时要能一眼看出它像不像一个float。最有用的一个特征值是1.0。1.0的IEEE754表示是0x3F800000展开成字节就是40 80 00 00。这个数非常有规律适合做联调测试。举个例子数值16进制位模式高字/低字拆分1.00x3F8000000x3F80, 0x00003.140x4048F5C30x4048, 0xF5C3100.50x42C900000x42C9, 0x0000当你用调试工具看到寄存器值是0x3F80和0x0000时心里就该有个概念这可能就是浮点数1.0。这也是我在联调时最喜欢用的定位手段——先写死一个1.0发出去看上位机能不能正确显示。4.3 字节序排列对照高字/低字、高字节/低字节float拆到Modbus寄存器里表面上看只是“两个寄存器”实际排列方式有四种组合。这四种组合在网络上和各个工具里的命名五花八门有的叫ABCD、CDAB、BADC、DCBA但不同工具对这四个词的定义还有出入。与其死记字母不如只关心两个独立选项第一个选项两个寄存器哪个在前。高字在前表示第一个寄存器放32位数据的高16位低字在前表示第一个寄存器放低16位。第二个选项寄存器内部字节是否交换。Modbus协议规定寄存器本身按高字节先发但如果下位机直接按小端内存顺序把float的4字节塞进两个寄存器就会出现每个寄存器内部字节交换的情况。组合起来就是四种情况。我用float1.0来演示假设它在大端规范下的字节序是40 80 00 00字序字节序寄存器1寄存器2字节流高字在前高字节在前0x3F800x000040 80 00 00高字在前低字节在前0x803F0x000080 40 00 00低字在前高字节在前0x00000x3F8000 00 40 80低字在前低字节在前0x00000x803F00 00 80 40小端MCU的工程师最容易犯的错是直接把float所在的4字节内存按顺序拆成两个寄存器发出去。在STM32这类小端芯片上float1.0的内存字节是00 00 80 40按顺序拆成寄存器就是0x0000和0x803F对应表格里最后一行。上位机如果按最常见的“高字在前、高字节在前”去解析得到的自然不是1.0。我之前帮朋友排查过信捷PLC和海康相机之间的Modbus TCP通信两边都是各自按自己默认的顺序解析float温度数据怎么读都不对。最后就是用1.0这个特征值把这四种组合逐一试了一遍才对齐。所以项目刚开始联调时先把特征值发出来验证字节序能省掉后面一大半扯皮时间。5. 代码实现float拆分与还原的C语言封装5.1 为什么用memcpy而不是直接强转指针前面已经提过直接强转指针的隐患这里再展开说一下。在C语言里把一个float类型的地址强转成uint32_t指针再解引用叫做“类型双关”。C标准对这种写法有严格的限制编译器在开启优化后可能假设不同类型的指针不会指向同一块内存从而产生不符合预期的行为。这就是所谓的严格别名规则问题。更实际的问题是字节序差异。在一个小端MCU上*(uint32_t *)value得到的整数其内存表示和value一样都是小端位模式能对上但代码一旦迁移到大端平台结果就变了。虽然大多数嵌入式芯片是小端但项目保不齐哪天换平台。正确做法是用memcpy。memcpy做的就是把一段字节原样拷贝到另一个地方不存在字节序解释的问题。先拷贝到uint32_t变量里再对这个整数做移位和掩码移位的效果和大小端无关因为右移操作的是整数的数值语义。5.2 拆分函数实现float转两个寄存器下面这个函数把float的32位位模式拆成两个16位寄存器默认协议约定是“高字在前”#include stdint.h #include string.h void float_to_modbus_regs(float value, uint16_t *reg_hi, uint16_t *reg_lo) { uint32_t tmp; memcpy(tmp, value, sizeof(tmp)); // 把float的位模式原样搬到uint32_t *reg_hi (uint16_t)(tmp 16); // 高16位 *reg_lo (uint16_t)(tmp 0xFFFF); // 低16位 }这段代码无论跑在小端还是大端芯片上结果都一致。tmp的位模式等于float的位模式tmp 16取的是这个32位整数的高16位不涉及内存字节顺序。调用方式uint16_t hi, lo; float_to_modbus_regs(3.14f, hi, lo);拆完之后把hi和lo写到连续的两个保持寄存器地址里就算完成了float拆分。5.3 还原函数实现两个寄存器拼回float从Modbus收到的两个寄存器还原成float是拆分的逆操作float modbus_regs_to_float(uint16_t reg_hi, uint16_t reg_lo) { uint32_t tmp ((uint32_t)reg_hi 16) | reg_lo; float value; memcpy(value, tmp, sizeof(value)); return value; }这个函数同样默认“高字在前”。如果协议写的是“低字在前”调用时把两个参数对调即可float value modbus_regs_to_float(reg_lo, reg_hi);这两种写法对应两种字序用户在协议层确认好是哪一种直接选择对应的调用方式就行。如果连寄存器内部的字节序都要交换那就得在上面对每个16位寄存器做一次字节交换uint16_t swap16(uint16_t x) { return (uint16_t)((x 8) | (x 8)); } float value modbus_regs_to_float(swap16(reg_hi), swap16(reg_lo));实际项目中遇到需要swap16的情况不多但一旦遇上多半是上位机PLC的组态软件里固定了某种字节序下位机只能去适配它。这时候上面这段就是标准解法。5.4 用1.0做特征值上板验证代码写完先别急着联调在板子上跑一个自测程序用1.0这个特征值验证拆分还原是否正确void float_self_test(void) { uint16_t hi, lo; float back; float_to_modbus_regs(1.0f, hi, lo); printf(split: 0x%04X 0x%04X\n, hi, lo); // 期望 0x3F80 0x0000 back modbus_regs_to_float(hi, lo); printf(restore: %f\n, back); // 期望 1.000000 }如果串口打印出来第一个寄存器不是0x3F80而是0x0000说明代码里字序反了或者平台字节序和预期不同。这一步在实验室就能发现问题不用等到现场连上位机才一脸懵。我在项目里习惯把这套自测代码放进一个调试命令里通过串口命令行触发。每次改完协议代码或者换了开发板先跑一遍自测确认没有破坏拆分还原逻辑再去做外部联调。这个习惯帮我挡掉了不少低级错误。6. 联调实录与常见问题排查6.1 Modbus Poll/Slave联调一屏看出字节序问题现场联调时我会同时开两个工具Modbus Poll当主站Modbus Slave当从站。如果是调下位机就用Modbus Poll去读自己写的从站程序观察寄存器的原始值。关键操作在Modbus Poll的寄存器显示窗口里把数据显示格式改成Float并在设置里切换字序和字节序。当你读到两个连续的寄存器时工具会按照你设定的顺序把它们拼成一个float显示出来。如果显示的数值不对就在设置里把Word Order和Byte Order各切换一次直到显示正常。这个过程能快速区分问题到底在下位机还是上位机。下位机发0x3F80和0x0000Poll如果显示1.0说明发送无误如果显示0.0或者一个很怪的数值就是上位机解析顺序和下位机发送顺序不一致。还有一个小技巧在Modbus Poll里读寄存器时先以Unsigned Dec或者Hex格式显示看原始值。比如你在下位机写死float1.0Hex格式下应该看到两个寄存器分别是3F80和0000。先把原始值对上了再切到Float显示去验证解析规则否则两个环节混在一起出了问题很难分清是谁的锅。6.2 旋转开关档位跳变现场怎么处理旋转开关的跳变问题在实验室很难复现一到现场就频繁出现。我遇到最多的情况是操作者旋转速度太快每次切换档位时程序会读到一两个中间值导致档位显示乱跳。原因主要是机械触点在切换过程中存在抖动分压电压在短时间内快速变化。ADC如果采样正好落在电压跳变的中间读到的码值就会处于两个档位的边界附近从而误判。处理办法有三个层次第一层是硬件滤波。ADC引脚对地加0.1uF电容把电压变化速率降下来。这个措施最便宜效果也不错但电容不能太大否则切换后电压建立太慢。第二层是软件采样。多次采样取平均加上连续两次一致才更新状态的确认逻辑。前面给的代码基本能覆盖这个需求。第三层是刹车逻辑。如果系统对档位切换响应速度要求高可以在检测到ADC值发生变化时先等一个固定时间比如20ms让机械触点稳定下来再重新采样。这个延时只出现在跳变瞬间正常读取不受影响。现场处理时不要一上来就改代码。先拿万用表量一下ADC引脚在四个档位下的实际电压看和理论计算差多少。有时候是旋钮本身质量问题触点接触电阻偏大导致某档电压严重偏移。这时候改软件阈值只能遮一时换一个合格的旋钮才是根本解法。6.3 高频故障速查表把调试过程中常见的故障现象和排查方向整理成一张表方便现场快速定位现象可能原因检查与解决办法Modbus读到的float是0或巨大数值字序/字节序不匹配用1.0特征值测试尝试切换字序和字节序float数据偶尔对、偶尔乱寄存器地址错开没有连续读取确认保持寄存器地址连续且从寄存器序号开始的地方读取旋转开关档位偶尔跳机械触点抖动硬件加0.1uF电容软件多次采样加状态确认某一档始终读成另一档分压电阻阻值偏大/偏小万用表实测各档电压换1%精度电阻重新标定从站返回float上位机解析始终不对上位机软件显示格式配置错误Modbus Poll中把显示类型设为Float确认字序字节序RC法读档位时间溢出电容虚焊或电阻没接好检查焊接确认超时保护逻辑生效6.4 定位float异常的标准流程这里给一个完整的排查流程照做基本能解决90%的float解析问题第一步下位机里写死一个float1.0发送给两个连续的保持寄存器。第二步用Modbus Poll以Hex格式读取这两个寄存器。如果读到3F80和0000说明下位机发送符合“高字在前”的大端顺序如果读到0000和3F80说明下位机是“低字在前”。第三步在Modbus Poll里把显示格式切到Float看是否显示1.0。如果显示的不是1.0在工具的Float设置里逐一切换字节顺序和字顺序。第四步找一个在线16进制转float工具把两个寄存器拼成的4字节十六进制输入进去人工验证这个字节流到底代表什么浮点数。这套流程最关键的点是“每一步只验证一个变量”。先确认寄存器原始值再确认解析规则不要同时调整两边的配置否则永远找不到根因。我自己做联调时还有个习惯把所有联调数据打印成日志包含时间戳、寄存器地址、原始值、解析后的浮点值。一旦现场反馈数据异常先翻日志看是哪一段开始乱的往往比现场反复复现要快得多。最后说一个我在实际项目里养成的习惯任何涉及到Modbus浮点数的项目我一定会在协议文档和代码注释里同时写清楚“float按高字在前、高字节在前发送每个浮点数据占用两个连续保持寄存器”。哪怕项目只有我一个人开发也要写。因为半年后回看代码能救你的不是记忆是当时随手写下的那一行约定。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进