2026/9/6 9:07:30

节省IO的旋转开关采集与Modbus浮点字节序实战解析

节省IO的旋转开关采集与Modbus浮点字节序实战解析 上周调完一块带Modbus RTU从机功能的小板子前前后后卡了我两个晚上问题都不算高端一个是4档旋转开关怎么读才能省IO另一个是float浮点数在Modbus报文里怎么传才能让上位机认。旋转开关本来设计的是4个IO直接拉电平判断板子IO一紧这个方案立刻就不香了float则是在大小端和字节序上栽了跟头上位机读回来的数完全没法看。这篇把这两段折腾记下来一个是从4个引脚省到1个引脚的采集思路一个是Modbus下float拆分和还原的完整做法都是嵌入式开发里特别基础、但特别容易翻车的细节。1. 起因4档旋转开关吃掉4个IO这笔账越算越亏1.1 板子上的IO账本这块板子的MCU是STM32F103C8T648脚封装看着引脚不少剔掉电源、晶振、复位、SWD、BOOT真正能自由支配的GPIO也就三十几个。板上还要挂数码管、按键、外部Flash、RS485收发控制脚、指示灯杂七杂八算下来空闲IO只剩两三个。旋转开关在这块板子上是用来设定Modbus从机地址的1到4号地址对应四个档位。最初硬件同事给的方案很直接4个IO各接一个档位触点开关拧到哪一路哪一路就被拉低软件读4个引脚状态就知道档位。功能没有任何问题但一块小板上同时要出4个GPIO对我这种习惯把IO省着用的人来说看着实在心疼。尤其后期还要加功能IO是真不够。所以这次的优化目标非常明确把4档旋转开关的采集引脚从4个压到1个最好不用额外芯片只加电阻电容。1.2 三条路线的取舍当时我在纸上列了三个备选方案引脚占用额外器件软件复杂度可靠性4个IO直接读取44个上拉电阻低高74HC148编码器21颗芯片中高ADC分压单引脚采集14-5个电阻1个电容中高中高74HC148那种8-3编码芯片确实能省IO但为了一个旋转开关多焊一颗芯片、多画一片封装在小批量产品里很不划算。74HC165移位寄存器也是类似问题串行读虽然只占两三根线可成本和布线复杂度都不低。ADC分压方案虽然软件上要做阈值判断、防抖、滤波但对一个不常动的旋转开关来说采集周期充裕软件复杂度完全可控硬件成本几乎为零。最后我选了ADC方案方向定了剩下的就是怎么把电压档位拉开、把判定做稳。2. 电阻分压网络设计4个档位对应4个电压2.1 电路结构和核心公式电路非常简单旋转开关的公共端接MCU的ADC引脚例如PA1VCC经过一个上拉电阻R1接到PA1开关的4个档位引脚分别通过4个不同阻值的电阻接到GND。开关拧到哪个档位就相当于把对应的电阻Rsw接入到采样点和地之间。电压公式就是最基础的分压Vadc VCC * Rsw / (R1 Rsw)以VCC3.3V、R110K为例我选的四个接地电阻和对应结果档位Rsw接地电阻理论电压理论ADC码12位档110K1.65V2048档220K2.20V2730档333K2.53V3143档451K2.76V3424这里有个很关键的细节如果ADC的参考电压VREF接的就是VCC那ADC读到的值其实和VCC无关。因为ADC读数等于Vadc / VREF * 4096而Vadc本身是VCC的分压结果VCC一约就没了。ADC码 4096 * Rsw / (R1 Rsw)这是个很省心的特性——电源电压波动不会影响档位判定。所以我把分压网络接在VDD上而不是单独接一颗基准源成本和稳定性都兼顾了。2.2 为什么没硬凑等间隔最开始我确实想过做等间隔电压比如1.0V、1.5V、2.0V、2.5V这样的梯度看着整齐。但实际选电阻时才发现E24标准电阻系列里要凑出严格等间隔的分压比得浪费不少阻值或者串并联一堆电阻纯属给自己找事。我的判断标准只有一个档位之间的最小电压差要大于0.2V在12位ADC上大约是250个码以上。上表里档3和档4间距最小电压差约0.23V对应281个码完全够用。选完这四个电阻再用分压公式反算理论值直接作为阈值依据。另外多说一句ADC引脚的输出阻抗也要看一眼。分压网络的戴维南等效阻抗是R1和Rsw并联我这四个档位的等效阻抗在5K到8.5K之间STM32的ADC采样保持电路完全带得动。为了稳妥PA1对地并联一个100nF电容既滤高频噪声又能给采样保持电容一个稳定的电压源。这个电容带来的时间常数最大也就是8.5K乘以100nF约0.85ms读取前稍等一下再采样就行不影响旋转开关这种低频操作。2.3 档位阈值怎么定才不容易误判理论值算得再漂亮实际板上还要考虑电阻误差。普通5%精度电阻最坏情况下两个电阻同时偏到极限档位电压可能偏移几十个毫伏在ADC上就是几十个码的偏移。如果阈值直接取两档理论值的中点理论上很舒服但一旦电阻误差叠加就可能判错。所以我把判定做成区间而不是用单一边界。以档3和档4为例档3理论值3143档4理论值3424两档中点约3283把档3的上限定在3180档4的下限定在3380中间留约200码的死区落在死区里就返回“档位无效”等待下一次采样这样虽然每个档位的可判定区间收窄了但换来的是抗电阻误差和抗电源噪声的能力。批量生产时如果电阻换成1%精度死区还可以再收一收留给阈值判断更多余量。3. STM32端采集代码平均滤波、档位映射、旋转防抖3.1 ADC初始化和读取代码我用HAL库示意标准库的读者替换成ADC_GetConversionValue那一套就行思路完全一致。PA1对应ADC1的通道1。ADC_HandleTypeDef hadc1; static void RotaryAdc_Init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_ADC1_CLK_ENABLE(); gpio.Pin GPIO_PIN_1; gpio.Mode GPIO_MODE_ANALOG; gpio.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOA, gpio); hadc1.Instance ADC1; hadc1.Init.ScanConvMode DISABLE; hadc1.Init.ContinuousConvMode DISABLE; hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; hadc1.Init.DataAlign ADC_RIGHTALIGN; hadc1.Init.NbrOfConversion 1; HAL_ADC_Init(hadc1); ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel ADC_CHANNEL_1; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); HAL_ADCEx_Calibration_Start(hadc1); }读取函数我做了8次采样取平均。之所以不采一次直接用是因为旋转开关从一档拧到另一档的过程中触点切换会有抖动单次采样很容易采到两个档位之间的中间电压。uint16_t ReadRotaryAdcValue(void) { uint32_t sum 0; uint8_t i; for (i 0; i 8; i) { HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { sum HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); DelayMs(1); } return (uint16_t)(sum / 8); }HAL库每次Start/Stop会有点开销但对旋转开关来说毫秒级采集周期无所谓。要是用连续转换模式也完全可以代码还能再简化。3.2 档位判定直接用ADC码而不是电压判定函数我直接拿ADC码操作不换算成毫伏。原因在前面说过ADC参考和分压共用一个VCC时ADC码只是分压比的投影跟VCC波动无关。如果换算成毫伏反而引入了“当前VCC到底是不是3.3V”这个变量。typedef enum { GEAR_INVALID 0, GEAR_1, GEAR_2, GEAR_3, GEAR_4 } SW_Gear_e; SW_Gear_e ParseRotaryGear(uint16_t adc) { if (adc 1900 adc 2300) return GEAR_1; if (adc 2450 adc 2900) return GEAR_2; if (adc 3020 adc 3230) return GEAR_3; if (adc 3380 adc 3520) return GEAR_4; return GEAR_INVALID; }这个区间是根据理论值加减余量定的换到你自己板上最好先用万用表或串口把四个档位的实际ADC值打印出来再微调区间。别直接抄。3.3 旋转防抖连续两次一致才算数光有平均滤波还不够因为平均滤波只能滤掉随机毛刺挡不住旋转过程中档位真实变化带来的过渡电压。比如用户从档1拧到档2如果恰好在这期间采样平均值可能落在两个档位区间之间返回GEAR_INVALID。这不是坏事但界面会闪一下“无效”状态。为了让档位变化既快速又稳定我在上层加了一个连续两次一致的判断#define STABLE_CONFIRM_COUNT 2 static uint8_t stable_cnt 0; static SW_Gear_e last_gear GEAR_INVALID; static SW_Gear_e current_gear GEAR_INVALID; SW_Gear_e GetRotaryGear(void) { uint16_t adc ReadRotaryAdcValue(); SW_Gear_e g; /* 信号不在任何档位区间直接清零计数不更新状态 */ if (adc 1800 || adc 3600) { stable_cnt 0; return current_gear; } g ParseRotaryGear(adc); if (g last_gear) { stable_cnt; if (stable_cnt STABLE_CONFIRM_COUNT) { current_gear g; stable_cnt STABLE_CONFIRM_COUNT; } } else { last_gear g; stable_cnt 0; } return current_gear; }这里的核心思路是第一次采到某个档位先记住等到下一次采样结果和它一致才更新最终状态。如果两次结果不同说明开关还在动那就继续等。旋转开关不是高速输入这种防抖方式比按键那种延时消抖更省心也不用阻塞式Delay。4. Modbus传float的本质2个寄存器、4字节和四种字节序4.1 float的内存形态旋转开关的档位读得差不多了接下来是另一个大头float怎么在Modbus里传。Modbus保持寄存器是16位宽一个寄存器装两个字节而IEEE 754标准的float是32位必然占用两个连续的保持寄存器。举个具体例子25.6这个浮点数在IEEE 754下的二进制编码是符号位0 指数位10000011 尾数位10011001100110011001100合在一起就是十六进制的0x41CCCCCD。这块不用自己手算C语言里做一次memcpy或者联合作业就能看到这4个字节。4.2 Modbus寄存器是按大端走的Modbus RTU的串行帧里寄存器内容按照网络字节序发送高字节先发、低字节后发。如果从机上送两个寄存器0x41CC和0xCCCD那么报文数据域是41 CC CC CD这正好是IEEE 754标准下float按大端排列的4个字节也是大多数Modbus主站工具默认使用的ABCD顺序。这里就是坑的开始。STM32这类Cortex-M内核是小端模式float变量25.6在内存里的排列是低地址到高地址CD CC CC 41如果你写代码时直接把这个内存内容塞进两个uint16_t寄存器得到的寄存器是0xCCCD和0x41CC。发送时每个寄存器再按高字节优先发出报文数据域就变成了CD CC 41 CC上位机按默认的ABCD顺序解析把CD CC 41 CC当成4个字节的IEEE 754数据读出来的自然是个乱七八糟的浮点数。4.3 四种字节序能把你绕晕主站工具和组态软件里一般会让你选浮点字节序常见的有ABCD、CDAB、BADC、DCBA这四种字节序名称寄存器1寄存器2发送字节流对25.6的解析结果ABCD0x41CC0xCCCD41 CC CC CD25.6CDAB0xCCCD0x41CCCD CC 41 CC乱数BADC0xCC410xCDCCCC 41 CD CC乱数DCBA0xCDCC0xCC41CD CC CC 41乱数不同厂家的上位机默认字节序不一样有的默认CDAB有的默认ABCD。所以排查网络通信问题时先别急着怀疑协议栈写错了很可能是从机端往寄存器里放数据的顺序和主站默认的解析顺序对不上。5. 三种拆分还原写法横评union、memcpy、手动移位哪个靠谱5.1 union方案代码最少但最容易踩内存布局的坑union方案的代码量最少typedef union { float value; uint8_t bytes[4]; } Float32_t; Float32_t fdata; fdata.value 25.6f; uint16_t regs[2]; regs[0] ((uint16_t)fdata.bytes[3] 8) | fdata.bytes[2]; regs[1] ((uint16_t)fdata.bytes[1] 8) | fdata.bytes[0];注意这里不是简单地把fdata.bytes[0]和bytes[1]塞给regs[1]而是手动把高字节放前面。如果图省事直接在STM32上写regs[0] fdata.h[0]在小端模式下h[0]是0xCCCD发出去就成了CDAB顺序上位机默认按ABCD解析就废了。union的最大问题在于它完全依赖目标平台的内存字节序。同一份代码挪到一个大端MCU上行为立刻变。所以我只建议在本地调试时用不该作为协议层的通用写法。5.2 memcpy方案语义清楚但别直接往uint16数组里拷用memcpy把float先拷贝到一个uint8_t临时数组再手工组装寄存器void FloatToModbusRegs(float f, uint16_t regs[2]) { uint8_t tmp[4]; memcpy(tmp, f, 4); regs[0] ((uint16_t)tmp[3] 8) | tmp[2]; // 高字 regs[1] ((uint16_t)tmp[1] 8) | tmp[0]; // 低字 } float ModbusRegsToFloat(const uint16_t regs[2]) { uint8_t tmp[4]; float f; tmp[3] (uint8_t)(regs[0] 8); tmp[2] (uint8_t)(regs[0] 0xFF); tmp[1] (uint8_t)(regs[1] 8); tmp[0] (uint8_t)(regs[1] 0xFF); memcpy(f, tmp, 4); return f; }这个方案的优点是完全通过uint8_t数组操作不依赖平台大小端。memcpy的语义也很直白读代码的人一眼能看出这是在拷贝原始字节而不是做数值转换。代价是多了一个临时数组但对嵌入式来说这点开销可以忽略。要注意的是这里特意先把float拷贝到uint8_t数组再组装寄存器没有直接memcpy(regs, f, 4)。后者会把小端MCU的内存字节序原样带到寄存器里等于把坑换了个形式保留了下来。5.3 手动移位方案最可控适合跨平台协议解析我最终在工程里用的是手动移位加uint32_t位模式的方式void FloatToModbusRegsShift(float f, uint16_t regs[2]) { uint32_t u32; memcpy(u32, f, 4); regs[0] (uint16_t)(u32 16); regs[1] (uint16_t)(u32 0xFFFF); } float ModbusRegsToFloatShift(const uint16_t regs[2]) { uint32_t u32 ((uint32_t)regs[0] 16) | regs[1]; float f; memcpy(f, u32, 4); return f; }不直接*(float *)u32的原因是C语言的严格别名规则。这么写通常也能跑但遇到高优化等级编译时标准允许编译器假设不同类型指针不指向同一块内存容易产生诡异结果。用memcpy做位模式转换是最稳妥的跨平台写法。这套方案本质上把所有操作都变成了纯数值运算左移、右移、按位或。数值运算不区分大端小端所以代码挪到任何平台上只要IEEE 754格式不变行为就一致。5.4 三个方案怎么选方案代码量平台无关性踩坑风险适用场景union直接映射最少差高快速原型、单平台调试memcpy字节数组中等好低通用工程首选uint32_t移位组合中等最好低跨平台协议栈、长期维护项目我的习惯是凡是涉及协议传输的多字节浮点一律用移位组合方案。代码多不了几行但换芯片、换编译器的时候能少很多莫名其妙的排查。union这种省事写法留给自己本地临时验证用。6. Modbus Poll联调实录从读出一堆乱数到稳定显示25.606.1 搭测试链路和寄存器规划板子作为Modbus RTU从机USB转485接到电脑旋转开关拨到1档从机地址就是1。通信参数9600、8N1。上位机用Modbus Poll当主站。规划两个保持寄存器寄存器0x0000float的高16位寄存器0x0001float的低16位在Modbus Poll里配置读保持寄存器功能码03起始地址0寄存器数量选2。这个“数量填2”非常关键float占两个寄存器只填1就读出半个float后面全是乱码。6.2 第一轮现象完全没法看的大负数一开始我没有用上面的规范写法直接从机内部用了union的简化版本。板子发出来的响应帧我用TX调试串口打印出来是这样的01 03 04 CD CC 41 CC CRC高字节 CRC低字节数据域是CD CC 41 CC而不是期望的41 CC CC CD。Modbus Poll默认按ABCD解析读回来的值是个负的亿级数面板上看起来非常吓人。这里顺带说一句排查这类问题最有效的手段就是从机自己把响应帧的原始字节打印出来。上位机显示已经经过了“字节序解析”这层包装看不出底层到底发了什么串口打印一帧原始数据问题就清晰了。6.3 排查链路一层一层排除我没有一上来就改代码而是按下面这个顺序逐项排除先写一个整数寄存器测试链路。让从机在0x0002寄存器放固定值0x1234Poll读回来显示4660。整数没问题说明地址、功能码、CRC、收发链路都是通的问题被锁定在float解析这一层。检查读取寄存器数量。Modbus请求报文的最后两个字节是寄存器数量必须为0x0002。如果配成1那从机只回两个字节float一定不完整。在Modbus Poll里临时切换显示格式。把浮点格式从Float ABCD改成Float CDAB发现读值变成了25.60这就反向证明了从机发出的确实是CDAB字节序。再回到从机端修字节序而不是要求的“上位机设置一下就行”。最后这个选择要解释一下上位机可能不止一台不同软件默认设置不一样。从机端输出标准ABCD顺序是大家都能接受的最大公约数。为了匹配某台测试软件去改从机字节序后面换一台上位机又得改回来迟早出事。6.4 修复后验证把从机改成移位组合方案后同样的寄存器再读串口打印出来的响应帧是01 03 04 41 CC CC CD CRC高字节 CRC低字节Modbus Poll用默认Float ABCD格式读取稳定显示25.60。连续读了几分钟数据没有任何跳动。我还做了另一个方向的验证在Poll的写寄存器窗口写入一个整数0x1234从机读回来打印确认是这个值证明主站到从机的下行通路也正常。到这里float的拆分和还原就算彻底闭环了。7. 复盘这几天攒下的坑和以后直接能用的经验7.1 旋转开关采集的3个坑第一个坑是开关悬空。某些旋转开关在特定角度会断开所有触点如果采样点没有任何低阻通路ADC引脚就会悬空读数乱跳。设计时最好加一个100K左右的下拉电阻到GND让未接入任何档位的状态下ADC读到一个固定低值软件里把低于最低档区间的值判为无效即可。第二个坑是电阻精度。5%电阻在档位间距小时会吃掉大量判定余量。板上如果空间允许这几个分压电阻直接上1%精度成本差不了多少但省心很多。如果已经用了5%那就出厂前用串口把四个档位的实际ADC打印出来按实测值重标定阈值。第三个坑是旋转瞬间的毛刺。我见过有同事在开关转动的瞬间读到两个档位的中间值然后系统立刻切换了配置结果设备工作状态跳变。解决方式就是正文里的连续两次一致防抖宁可让档位更新慢几毫秒也不能让它乱跳。7.2 Modbus float相关的坑寄存器数量一定别配错。float占两个保持寄存器这是新手最容易犯的问题。你很可能发现从机回的数据长度只有一半这时候先看请求数量字段别急着查协议栈。字节序问题要记清楚。小端MCU上直接内存拷贝得到的顺序往往不是上位机默认的ABCD从机端必须手动把字节按大端顺序组装进寄存器。最稳的做法是全程不依赖平台字节序用移位和掩码构造寄存器值。另外一个经验是别拿浮点原始值做精确比较。Modbus传过来的25.6在小数点后面第几位可能不是精确的0比如显示成25.599999或25.600001。这是IEEE 754浮点数的固有精度问题不是通信错误。上位机需要两位小数就在显示层做格式化不要在下位机强行把float凑成某个十进制数再传。7.3 可复用的封装建议这套代码我最后整理成了两个独立模块一个叫RotarySwitch负责ADC滤波、档位判定、防抖另一个叫ModbusFloat负责float和寄存器数组之间的编解码。两个模块完全不依赖业务逻辑后续新项目直接拖过去用只需要改一下ADC通道号。这两个模块都被我用在同一个工程里一个负责“我当前是哪个从机地址”一个负责“地址对应的数据怎么发出去”。旋转开关从硬件上决定了从机地址Modbus float承载的是设备内部的测量值。硬件采集搞不定数据源头就是歪的协议解析搞不定数据到了上位机也是歪的。两件事本质上都是“让数据在正确的位置被正确解读”。最后分享一个我现在养成的习惯凡是涉及Modbus浮点传输的新工程我在写第一版代码时就直接用移位组合方案写编码解码函数并且用串口打印模块把响应帧原始字节打出来。别嫌麻烦等你在Modbus Poll里看到一屏乱数、然后花半小时去查到底是谁的字节序没对齐时就明白这个习惯能帮你省多少时间了。