
1. 现场为什么需要给旋转开关“省IO”1.1 IO不够用是常态旋钮档位比按键更吃资源前阵子给一台小型设备换控制板功能不算复杂但板子改到第五版时发现GPIO已经全部排满。设备面板上有个4档旋转开关用来切换四种运行模式供应商给定的方案是用4个普通IO各接一根线直接读取开关四个触点。按照这个接法MCU的IO至少要空出4个可当时主控是STM32F103C8T648脚封装PA和PB几乎全被传感器、继电器、通信接口占完了实在是挤不出来。旋转开关和独立按键不一样。独立按键一个IO对应一个按钮按压时拉低或拉高读起来很直接。旋转开关如果要保留4个档位用数字IO方案要么是4个IO分别接4个触点要么用带编码输出的旋转开关走格雷码最少也得2个IO。前者浪费后者对器件型号和采购有要求临时改方案不一定有现货。所以当我看到PCB上还剩一个ADC引脚时脑子里第一反应就是用分压电阻把档位变成电压用一个ADC通道把4个档位全读出来。这也是“省IO采集”最典型的做法成本就是几个电阻换来的是一个IO替代四个。这个思路在demo板上验证了一下效果很稳但过程中也踩了几个细节坑这篇笔记专门记一下。后面的内容分成两块前半段讲ADC分压读档位的计算和代码实现后半段讲Modbus里float的拆分与还原。看起来是两个独立主题实际在这个项目里是连在一起的——旋钮档位要通过Modbus寄存器上报给上位机而另一个温度变量在协议里是float类型。只要牵扯到“上位机能看到什么数”float的字序问题就绕不开。1.2 三种读取4档旋钮的方案对比先说清楚为什么最终选了ADC方案光说“省IO”不够还得看可靠性和成本。我列个表直接对比常见三种做法方案IO占用电路成本可靠性适用场景4个数字IO直接读4个最低只接线高每个档位都是独立电平IO资源富余的板子编码型旋转开关格雷码输出2个中开关本身贵高输出天然无竞争风险产品定型、可定制物料ADC分压采样1个几个电阻较高取决于分压设计和滤波IO紧张、档位数量不固定4个IO方案最直观但在这个项目里完全没有讨论空间。编码型旋转开关确实好2个IO就能读4档甚至16档内部已经做了编码逻辑外部不需要额外电路只可惜当时的物料清单里没有这类开关临时换料周期太长。ADC方案的优点是灵活用5个等值电阻加一个旋钮就能搭出来而且如果你想从4档扩到8档只需要增加分压抽头不需要加IO。缺点就是ADC链路多了几个环节电阻精度、参考电压、采样滤波都得考虑但这些问题在工程上都有成熟解法。还有一个隐藏优势ADC采到的原始码值除了能判断档位还能反映供电电压的波动。我在调试时加了一个日志把ADC原始值打出来一眼就能看出3.3V电源是否在正常范围。数字IO方案绝对给不了这个信息。当然这种做法也不是没有代价——旋钮档位靠阈值区间判断如果分压电阻精度太差或者机械开关接触电阻偏大临界状态会出现误判。解决办法后面详细说。2. ADC分压读档位的计算与代码实现2.1 分压链设计5个等值电阻就能搞定ADC方案的上半身是硬件分压网络。这里推荐一种阻值均匀、计算方便、买料也容易的接法——等值电阻串联分压链。直接画个示意3.3V --- R0(10k) --- node1 --- R1(10k) --- node2 --- R2(10k) --- node3 --- R3(10k) --- node4 --- R4(10k) --- GND旋钮的公共端接MCU的ADC引脚四个档位分别接到node1、node2、node3、node4。旋钮拧到哪个档ADC输入就等于该节点的电压。5个电阻全部用10kΩ、1%精度总阻值50kΩ功耗和源阻抗都在舒服的范围。有人会问为什么不用4个非等值电阻做分压比如让每档电压间隔更随机一些等值电阻最大的好处是设计计算快而且档位间隔天然均匀。5颗10kΩ串联每一级压降是3.3V的五分之一即0.66V。这个间隔对12位ADC来说非常充足STM32F103的ADC以3.3V为满量程时0.66V对应约819个LSB即使电阻精度、电源波动全算上也远不会发生相邻档位的码值区间重叠。节点电压以及对应的ADC码值直接算一遍档位节点理论电压ADC码值12位VREF3.3V档0node12.64V3276档1node21.98V2457档2node31.32V1638档3node40.66V819这里有个细节值得注意在VREF引脚接VCC的常见接法下ADC的结果本质上是采样电压与满量程电压的比值所以即使3.3V那路电源有轻微波动只要VREF跟随VCC波动读数依然稳定在同样的码值附近。如果板子用了外部独立基准那就得单独关注基准精度。这个项目用的是内部VREF校准效果足够。2.2 阈值计算与档位映射得到每个档位的中心码值之后还需要算出相邻档位的分界阈值。最简单的方法是取两个中心码值的算术平均值例如档0中心码3276和档1中心码2457的中间值2866就是两个档位的判定分界线。三组分界阈值档0/档1分界(3276 2457) / 2 2866档1/档2分界(2457 1638) / 2 2047档2/档3分界(1638 819) / 2 1228有了阈值表档位映射逻辑就很容易写了。C代码示例const uint16_t gear_threshold[3] { 2866, 2047, 1228 }; uint8_t gear_from_adc(uint32_t adc_value) { if (adc_value gear_threshold[0]) return 0; if (adc_value gear_threshold[1]) return 1; if (adc_value gear_threshold[2]) return 2; return 3; }逻辑顺序是从高档电压向低档电压比较先判断是否大于2866落入档0否则再判断是否大于2047落入档1以此类推。如果采样值低于1228意味着旋钮在最后一档。要注意这个表必须在程序初始化时从Flash读出或固定编译进固件不要放在容易丢失的RAM里。很多新手容易在阈值这里写错有人直接拿中心码值当阈值导致档位边界处不稳定有人没考虑ADC滤波把单次采样结果直接丢进映射函数结果机械触点抖动时档位跳来跳去。这两件事是ADC读旋钮方案最常见的翻车点下一节专门讲。2.3 防抖与滤波旋转机械触点的实际处理旋转开关本质上还是机械触点拧动瞬间会有接触抖动ADC读到的电压会短暂跳动。如果主循环每几十毫秒读一次转动旋钮时可能连续几帧读到不同的档位这在上位机上看就是状态乱跳。我的处理分两层第一层是ADC采样滤波第二层是档位确认状态机。采样滤波最简单有效的方法是连续采样多次取平均或者去掉最大最小值取中间平均。STM32F103的ADC单次采样很快一次转换耗时约1us到几个us连续采样8次平均几乎不占时间却能明显压低白噪声和偶发毛刺。所以读取函数里先累加8次再除以8uint32_t read_switch_adc_average(void) { uint32_t sum 0; uint8_t i; for (i 0; i 8; i) { sum adc_read_raw(SWITCH_ADC_CHANNEL); } return sum / 8; }滤波之后再做档位映射但这还不够。机械抖动持续的时间通常有几毫秒到几十毫秒光靠采样平均无法滤除触点接触不良造成的“稳定跳到另一个档位”的现象。稳妥的做法是加确认计数只有当连续多次读到同一个档位时才认为旋钮真的切过去了。#define SWITCH_CONFIRM_COUNT 3 static uint8_t last_gear 0xFF; static uint8_t confirm_cnt 0; uint8_t read_switch_gear_with_debounce(void) { uint32_t avg read_switch_adc_average(); uint8_t gear gear_from_adc(avg); if (gear last_gear) { confirm_cnt; } else { last_gear gear; confirm_cnt 0; } if (confirm_cnt SWITCH_CONFIRM_COUNT) { return last_gear; } return 0xFF; /* 尚未确认返回无效档位 */ }每次调用该函数都返回最新状态如果返回0xFF就说明档位还在确认中主程序可以继续保持上一帧的有效状态。这个机制跟按键消抖的思路完全一致只是把“电平”换成了“ADC档位”。实测下来连续3次确认在100ms的调用周期下已经不会漏判也不会有明显的切换延迟感。3. Modbus里的float到底是哪里最容易错3.1 16位协议寄存器容器与32位浮点数的矛盾Modbus协议的数据模型里最基本的寄存器单位是16位字。保持寄存器、输入寄存器一次读写都是以“寄存器个数”为单位的每个寄存器能装的是一个0到65535的整数。可float是32位一个寄存器装不下必须拆成两个寄存器格或者四个字节。问题就出在这个“拆”字上。uint16_t拆成两个字节顺序很直观但float的32位结构包含符号位、指数、尾数它是一段完整的位模式拆成两半时先装哪一半协议里没有规定死。Modbus官方标准只定义了协议的帧格式和功能码对应用层的数据排列方式尤其是多寄存器数据的大端还是小端留了大量空间给设备厂商自己定。这导致市场上不同品牌的PLC、仪表、传感器在寄存器里放float的顺序可能完全不同。我调过的设备里最常见的排列方式有两种一种是ABCD即高16位放在前一个寄存器低16位放在后一个寄存器另一种是CDAB低16位在前高16位在后。如果软件里默认按ABCD处理去读一台CDAB排列的设备读到的数值会面目全非。想判断设备到底是哪种排列不能靠猜得用原始寄存器数据反推。3.2 字序与字节序的组合关系展开讲之前先明确两个概念字序指32位数据拆成两个16位寄存器时高字在前还是低字在前字节序指每个16位寄存器内部或者说整个位模式转换成Modbus线路字节流时高字节在前还是低字节在前。Modbus RTU线上传输时每个寄存器的字节顺序通常约定为高字节在前这是协议实现层面的惯例比如寄存器值0x1234在帧里就是0x12、0x34这样顺序出现。但寄存器与寄存器之间以及4字节float变成两个寄存器时谁先谁后并没有强制规定。于是出现了四种常见排列名称排列名称第1个寄存器第2个寄存器典型阵营ABCD位31~16位15~0大部分IEC 61131-3 PLCCDAB位15~0位31~16部分国产仪器仪表、台达设备BADC位31~16字节交换位15~0字节交换个别特殊设备DCBA位15~0字节交换位31~16字节交换很少见在STM32这种ARM Cortex-M小端处理器上内存里一个float的四个字节实际排列是“低位字节在低地址”。如果直接把float的起始地址强制转换成uint16_t指针去读寄存器你会得到一个跟Modbus约定完全相反的值。很多网上流传的代码就是这么写的union强行读写两个uint16_t然后原样塞进寄存器数组。在PC上用模拟器可能没问题一烧到小端MCU上全乱。所以核心原则是不要依赖平台内存序用可移植的位运算来做拆分和还原。3.3 可移植的拆分/还原函数推荐的做法是先把float按位复制到一个uint32_t然后用移位操作拆成两个uint16_t。这样无论芯片是大端还是小端逻辑都保持一致。直接贴代码#include string.h /* 数据打包方向选择 */ #define FLOAT_ORDER_ABCD 0 /* 高字在前标准PLC默认 */ #define FLOAT_ORDER_CDAB 1 /* 低字在前 */ void float_to_modbus_regs(float value, uint16_t *regs, uint8_t order) { uint32_t bits; memcpy(bits, value, sizeof(bits)); if (order FLOAT_ORDER_ABCD) { regs[0] (uint16_t)(bits 16); regs[1] (uint16_t)(bits 0xFFFF); } else { regs[0] (uint16_t)(bits 0xFFFF); regs[1] (uint16_t)(bits 16); } } float modbus_regs_to_float(const uint16_t *regs, uint8_t order) { uint32_t bits; float value; if (order FLOAT_ORDER_ABCD) { bits ((uint32_t)regs[0] 16) | (uint32_t)regs[1]; } else { bits ((uint32_t)regs[1] 16) | (uint32_t)regs[0]; } memcpy(value, bits, sizeof(value)); return value; }关键的思路memcpy负责把float的位模式搬到uint32_t里这一步不改变数值的“物理位模式”无论大小端只要两个类型等长就是安全的。之后的操作全部基于uint32_t的移位和掩码跟CPU首页字节序无关。还原方向的memcpy同理把uint32_t的位模式解释成float。如果协议栈是类似FreeModbus这种直接用uint16_t保持寄存器数组的把拆好的两个值塞进regs数组就可以。如果协议栈允许直接填充发送缓冲区字节流还可以进一步把两个寄存器对应的高字节、低字节手动拼出四个字节reg0_hi, reg0_lo, reg1_hi, reg1_lo。但从可维护性和后续代码检查的角度我更推荐保持“寄存器数组”这种抽象让Modbus协议栈自己处理帧字节顺序应用层只关注字序。4. 用Modbus Poll验证float链路的完整排查过程4.1 先用十六进制看原始寄存器别急着看浮点格式接入Modbus Poll调试时最容易犯的错是一上来就把显示格式切成Float。Modbus Poll的Float显示下拉菜单里有ABCD、CDAB、BADC、DCBA几个选项你要是不知道设备是哪一种随便选一个大概率显示乱码这时候根本分不清是选错了格式还是寄存器地址错了。我自己的排查顺序固定是先把显示格式设为Unsigned或Hex读回原始寄存器值。假设设备标称“温度以IEEE754浮点数存放在寄存器0和1”上位机读到的原始值是寄存器地址Hex值00x41C810x0000把这个两个寄存器按ABCD拼接就是0x41C80000这个数就是25.0的IEEE754编码。符号位0指数字段0x83尾数字段是0x500000规格化后是1.1001 × 2^4等于25.0。用这种方式验证不依赖任何工具的浮点显示逻辑最可靠。如果读到的顺序反过来寄存器0是0x0000、寄存器1是0x41C8那就是典型的CDAB排列。此时在Modbus Poll里选CDAB显示就能看到25.0如果继续按ABCD显示读出来的会是1.75e-41这种毫无意义的极小值。所以第一步永远是看十六进制原始数据。4.2 手算0.1的IEEE754位模式做对照现场验证时如果手头没有现成的浮点数转换工具可以挑一个容易手算的数做参考比如25.0或者1.5。25.0的二进制展开是11001规格化成1.1001 × 2^4指数字段为127加4等于131即0x83尾数部分补零后整段位模式就是0x41C80000。这个数结构清晰适合快速对照。也可以用0.1验证自己的判断。0.1的二进制是无限循环小数float存储时会舍入成0x3DCCCCCD如果你在寄存器里读到0x3DCCCCCD说明设备发送的确实是0.1的float编码。我用过一种技巧先在Modbus Slave里把某两个寄存器手动设为0x3DCCCCCD然后在主站端读取并转换成float如果显示的数值是0.1附近说明主站的字序配置正确。这个方法不需要仪器光靠软件就能把数据链路的字序问题排查清楚。注意0.1在float里并不是精确的0.1而是0.100000001490116119384765625。如果某台设备把0.1按双精度或字符串方式传输就不适用这套十六进制对照法。别拿这个例子去怼仪表厂商它只是一个快速定位字序的工具。4.3 高字低字颠倒与轮询地址覆盖的两种典型故障实际工程里遇到最多的情况是高字低字颠倒和寄存器地址错位叠加在一起。举个例子一组数据区连续存放两个float变量变量A占寄存器0和1变量B占寄存器2和3。上位机如果按“一次读4个寄存器然后切分”的方式读取切分时把变量B的起始地址算错一位比如从寄存器1开始切那变量B读出来的就是变量A的低字加上变量B的高字数值完全错乱。这类故障在Modbus Poll界面上的表现很迷惑显示出来的数值有时一看就是浮点数范围内的“合理值”比如2.3、45.6但跟实际变量完全对不上。为什么会这样因为两个float的位模式被交叉拼接后仍然是一段合法的IEEE754位模式软件不会报错只会给出一个“错误但合法”的浮点数。这种问题查起来特别费时间如果现场只有一位工程师很可能怀疑是传感器本身输出不对。我的排查经验是不要在浮点显示模式下反复试直接把两个变量的原始寄存器值全部读出来按16位格式打印。先确认变量A对应的两个寄存器值是否和仪表文档一致再确认变量B。如果变量B读到的原始寄存器值里混进了变量A的低字基本可以断定是地址切分问题。另一个常见原因在西门子S7-1200这类PLC做主站时出现Modbus轮询指令如果起始地址或数据长度配置不当读取一个float时需要跨越2个寄存器但程序里把下一个变量的地址只加了1就会覆盖相邻寄存器的数据。这种问题本质是“寄存器地址重叠”跟float拆分是两回事却经常同时出现排查时要分清楚。5. 旋钮档位接入Modbus从站的完整最小示例5.1 寄存器地址规划回到项目的实际需求4档旋钮状态需要上报给上位机同时还有一个温度值要以上IEEE754格式输出。我用FreeModbus的从站框架寄存器规划尽量简单清晰寄存器地址变量类型说明0x0000当前档位uint16_t0~30x0001ADC原始码值uint16_t调试用保留0x0002温度float ABCD占0x0002和0x0003两个寄存器0x0004保留uint16_t留作扩展ADC原始码值保留给调试阶段用正式交付可以删除但在现场排查时非常有价值。温度变量占两个寄存器所以下一个变量要从0x0004开始不能从0x0003开始否则就会把温度的低字当新变量的高字。这种“寄存器空洞”虽然浪费了一个地址但可读性远好于把变量贴着排满工业现场宁可多留余量也不要把地址抠到极致然后给自己埋坑。5.2 主循环与协议栈回调的衔接FreeModbus这种带独立回调接口的协议栈用起来结构很清晰。主循环负责周期性采集旋钮档位和温度把最新值刷新到Map寄存器数组。协议栈在收到主站03功能码请求时直接把这个数组返回给主站不需要临时计算。这样主站轮询频率再高读到的一直是最近一次刷新的一致数据。代码骨架大致如下#define REG_GEAR 0 #define REG_ADC_RAW 1 #define REG_TEMP_HIGH 2 #define REG_TEMP_LOW 3 #define REG_HOLD_SIZE 5 static uint16_t usRegHoldBuf[REG_HOLD_SIZE]; void update_holding_registers(void) { uint32_t adc_avg read_switch_adc_average(); uint8_t gear read_switch_gear_with_debounce(); float temperature read_temperature(); usRegHoldBuf[REG_GEAR] gear; usRegHoldBuf[REG_ADC_RAW] (uint16_t)adc_avg; /* 按项目约定的大端字序 ABCD 写入 */ float_to_modbus_regs(temperature, usRegHoldBuf[REG_TEMP_HIGH], FLOAT_ORDER_ABCD); } /* FreeModbus 保持寄存器读取回调示意 */ eMBRegHoldingCB(...) { /* 返回 usRegHoldBuf 中对应地址的内容 */ memcpy(pucRegBuffer, usRegHoldBuf[usRegAddress], usNRegs * 2); }温度采样我这里只写了一个read_temperature()占位实际它内部可能是NTC、PT100或者数字传感器。重点看float_to_modbus_regs如何把float拆成两个uint16_t并写入寄存器数组以及为什么usRegHoldBuf[REG_TEMP_LOW]一定紧接着REG_TEMP_HIGH。这两个寄存器的顺序决定了主站用ABCD还是CDAB格式去读。如果后续设备换了一台要求CDAB的主站只需要把FLOAT_ORDER_ABCD改成FLOAT_ORDER_CDAB其他地方不用动。这个示例没有处理多字节读写的边界比如主站一次请求从地址0读4个寄存器会同时读到档位和ADC原始值。遇到跨边界的请求FreeModbus默认的处理策略是返回非法数据地址异常这点在Modbus调试时如果遇到功能码正常但响应异常码02要考虑一下是不是读了不连续的寄存器区间。建议在中断回调里加打印把每次请求的起始地址和寄存器个数打出来一眼就能定位地址规划问题。6. 这次调试踩过的坑和后续可扩展的地方6.1 ADC电源噪声与档位临界抖动的观察第一版demo板上旋钮档位的判定我用的是单次采样结果发现旋钮从档1拧到档2时偶尔会在一瞬间判定成档3。刚开始以为是阈值表算错了后来用串口把ADC原始值以10Hz频率打出来才看到东西分压链用的是10k电阻供电来自板上的3.3V LDOLDO前端有个DC-DC纹波大约20mV。正常情况下档2的中心码值是1638但叠加纹波后采样值在1620到1655之间摆动离档2/档3分界阈值1228很远按理说不该误判。真正的原因是机械开关接触瞬间的弹跳。旋钮内部触点不是瞬时切换的转动时公共端会先在原档位、中间悬空、新档位之间快速切换几下。中间悬空时ADC引脚经过上拉分压链悬空电压可能指向任意位置这时候单次采样可能落在1228以下于是就被判成了档3。后来加上连续3次确认的防抖机制后这个问题就消失了。想一下原因单片机的主循环读取间隔是30ms机械弹跳一般只有几毫秒连续3次读到同一档位几乎不可能出现在弹跳期间。所以这里有个经验ADC方案读档位采样平均只是治标确认计数才是治本。如果你在自己的板子上遇到“拧一下旋钮档位乱跳好几格”的问题先别怀疑硬件检查一下有没有做多次确认。6.2 软件校准思路和更多档位扩展还有一次在环境温度变化比较大的机柜里测试发现极限情况下档位边界安全裕量变小。其实问题不在温度而在电阻的温漂。普通厚膜电阻温漂大约100ppm/°C50kΩ分压链在温差40°C时阻值偏移约0.4%对应的码值偏移大约13个LSB跟819的档位间隔比可以忽略。但如果使用了精度差且温漂大的碳膜电阻或者绕线电阻偏差会更大所以选料时至少要保证电阻精度1%以上。如果对长期稳定性不放心可以在产品自检流程里加一个“软件标定”功能旋钮拧到最高档记录ADC码值作为上限拧到最低档记录下限。然后在上下限之间按等比例划分各档位区间。这个方法能消除电阻绝对精度带来的误差但不能消除温漂因为标定和实际使用时的温度可能不同。对于4档旋钮这种应用10kΩ 1%电阻直接用阈值表已经足够标定功能通常没必要。这个方案的可扩展性还不错。如果以后产品要从4档改成8档只需要把分压链的抽头从4个增加到8个同时把ADC阈值表从3个扩展到7个代码逻辑完全不变。IO占用依旧是一个ADC引脚这对很多引脚紧张的小板子来说是很有价值的余量。如果你也在调类似的旋钮采集或者Modbus浮点数据希望这篇笔记能帮你少走点弯路。