用1个ADC采集旋转开关档位:Modbus浮点传输的字节序处理笔记

📅 发布时间:2026/9/9 7:32:05
用1个ADC采集旋转开关档位:Modbus浮点传输的字节序处理笔记
做Modbus从站仪表这件事很多人都会在两处卡住一是设备面板上的旋转开关想用来设置站地址/波特率结果IO口捉襟见肘二是要把浮点数温度、校准系数、模拟量通过Modbus回传到触摸屏或组态软件发现float和寄存器怎么都对不上。这篇调试笔记就把这两个问题一起聊透——4档旋转开关如何用1个ADC通道完成采集float在Modbus中如何正确拆分与还原。文中所有电阻取值、判档阈值、字节序处理都是我实际调过的参数可以直接抄。1. 旋转开关为什么值得用1个IO去采直接读与电阻分压的账先说场景。我手头那个小板子是做RS485从站的功能简单但面板要给用户留一个4档旋转开关用来设定从站地址1~4。板子上的GPIO已经被按键、指示灯、传感器占用得七七八八剩下的IO寥寥无几。最直观的做法是旋转开关的4个档位引脚各接一个IO公共端接地程序里读4个引脚的电平状态。这种方式逻辑最简单但代价是4个IO一下就没了而且其中3个IO在绝大多数时间里只是用来区分“哪个档位被按下”利用率极低。第二个常见方案是2个IO加编码。4档开关理论上2个IO能组合出4种状态做一个查找表就能省一半引脚。但问题在于旋转开关在拧动过程中有机械过渡中间会出现短暂悬空或误接触如果只用2位编码过渡态很容易编码出非法组合你还得写一堆容错逻辑。而且旋转开关本身是单刀四掷结构不是现成的2位格雷码编码器用2个IO反而要加二极管或特殊接法成本不比4个IO省多少。所以我把目光放到了一直空闲的ADC通道上。几乎所有MCU都会留几个ADC输入平时未必用得上但拿来读旋转开关这种“离散档位”刚刚好。思路其实很老套公共端接VDD4个档位引脚分别串一颗不同阻值的电阻到ADC采样点采样点对地再接一颗固定电阻R0形成一个分压网络。开关拧到不同档ADC引脚上的电压就不同软件根据电压区间反推档位。这样只占1个IO成本就是几颗电阻在仪器仪表和工业表头里是非常成熟的低成本方案。有人可能会质疑ADC精度靠谱吗万一分压差太小或者电压波动导致误判怎么办这就是接下来要解决的核心问题——分压电阻怎么选阈值怎么留裕量。只要这一步设计得扎实可靠性一点都不比数字IO差。2. 分压网络计算实例从选电阻到留足判定裕量我实际用的拓扑是旋转开关公共端接3.3V档位1~4引脚分别串R1、R2、R3、R4到ADC节点ADC节点对地接R0ADC节点直接进MCU采样引脚。开关旋到某一档时只有该档对应的电阻接入电路其他档位引脚悬空等效电路就是VDD经过当前档位电阻Rn再经R0到GND。分压公式是Vn VDD × R0 / (R0 Rn)为了让不同档位之间的电压差足够大我开始定的原则是相邻档位电压差不小于0.5V。按12位ADC、参考电压3.3V来算0.5V对应大约620个LSB因为4096 / 3.3 × 0.5 ≈ 620这个间隔已经非常安全了。电阻选型尽量用E24系列常见阻值方便采购也别用太特殊的值。我的选值是R0 10kΩR1 1kΩR2 3.3kΩR3 10kΩR4 33kΩ。实际计算如下档位Rn (kΩ)理论电压 (V)12位ADC读数113.000372323.32.48230803101.65020484330.767952相邻档位的ADC码值间隔分别是643、1032、1096最小间隔也有643个LSB换算成电压大约0.52V。即使电源纹波、电阻精度误差、ADC零漂全叠加在一起也很难让两个档位之间的读数跨越半个间隔。用5%精度的贴片电阻就足够了不需要上0.1%的精密电阻成本可以压得很低。为什么第一档我不直接用“公共端直接接ADC节点”也就是Rn0的方式算一下就知道3.3V通过10k直接分到ADC就是满量程3.3V。从数字上也可以但实际旋转开关的触点接触电阻会有几十毫欧到几百毫欧直接串联在电源路径上影响很小可不加电阻就没办法和后面档位的分压逻辑统一。更关键的是如果开关在切换瞬间短暂断开ADC节点会通过R0被拉到0V落进第4档的区间。这在后面软件判档时会讲怎么处理。还有一个容易忽略的点从ADC引脚看进去的源阻抗。MCU的ADC内部有采样电容采样瞬间会从外部抽取电荷如果源阻抗太高采样电容充不满采出来的值会偏低。我这里的最大源阻抗出现在R4档等效阻抗约R4∥R0 33k∥10k ≈ 7.7kΩ对STM32这种内置ADC来说把采样时间配到最长档完全没问题。如果你用的MCU手册比较严格或者分压电阻整体跨到100k以上就要特别小心尽量加长采样时间。3. ADC判定档位的工程细节波动、抖动与边界处理硬件电路定了软件判档反而才是最容易出问题的环节。我第一次调的时候就遇到一个现象开关明明停在1档读回来的ADC值偶尔会跳到3000以下一查发现是读太快分压节点电压还没稳定就采样了。虽然RC时间常数很小7.7kΩ × 十几pF的采样电容时间常数在微秒级但加上开关触点的毛刺和ADC本身的采样误差单次采样结果波动能达到几十个LSB。所以第一道保险是多次采样取平均值或者更稳一点去掉最大值和最小值再平均。下面是我实际用的判档代码框架通道和采样次数按自己板子调整即可#define SWITCH_ADC_CH ADC_CHANNEL_5 #define SAMPLE_TIMES 32 static uint16_t read_switch_adc_average(void) { uint32_t sum 0; uint16_t val 0; for (uint32_t i 0; i SAMPLE_TIMES; i) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); val ADC_GetConversionValue(ADC1); sum val; } return (uint16_t)(sum / SAMPLE_TIMES); } uint8_t get_switch_level(void) { uint16_t adc read_switch_adc_average(); if (adc 3400) { return 1; } else if (adc 2560) { return 2; } else if (adc 1500) { return 3; } else { return 4; } }这里阈值为什么要取3400、2560、1500原理是取相邻档位理论ADC值的中间点。1档理论值3723、2档理论值3080中间点约34012档与3档中间点约25643档与4档中间点约1500。我取了整数3400、2560、1500相当于把每个档位的判定区间放宽到了最大范围任何一侧只要不偏移超过两百多个码值就不会误判。旋转开关的机械抖动是第二个坑。虽然波段开关不像按键那样需要大量消抖但拧动瞬间会出现中间悬空ADC读数可能短暂跳到0或者一个中间值。如果这时候把档位变化直接用于修改设备参数就会导致逻辑错乱。我的做法是只在设备上电时读一次档位作为地址如果需要运行时识别则加一个简单的“连续确认”逻辑连续读到同一个档位3次才认为档位真正切换。另外如果你的产品功耗敏感分压网络会一直在耗电。我这个取值在3.3V下1档的电流大约3.3/(1k10k) 0.3mA4档电流更小对于电池供电设备来说基本可以忽略。但如果想进一步省电可以考虑把电阻整体放大10倍R0100k、R110k等把电流压到几十微安代价是ADC源阻抗变大需要更长的采样时间。这是典型的功耗和采样精度之间的取舍具体看你的应用场景。4. Modbus传输float的三个难点精度、寄存器长度与字节序旋转开关省IO只是这篇笔记的一半。另一半是Modbus里的float传输我认为这个坑远比开关判档更隐蔽。当你辛辛苦苦把温度、校标系数、电压等浮点数据通过Modbus传给上位机结果对面读出来一个几千万的数很多人第一反应是自己数学算错了其实往往是字节序和寄存器顺序的问题。先说标准。IEEE 754单精度float占用32位4字节而Modbus保持寄存器是16位一个。所以一个float必然占用两个连续的保持寄存器。这本身不复杂复杂的是字节序和字序的约定。Modbus串行链路协议规定单个16位寄存器内部是大端模式也就是高字节先发、低字节后发。但跨寄存器的32位数据官方并没有强制规定哪个寄存器在前哪个在后于是不同厂商的实现就出现了多种顺序常见的有“高字在前”和“低字在前”两种Modbus Poll这类调试工具里通常对应“ABCD”和“CDAB”之类的选项。接下来是MCU自身的字节序。你可能觉得“我的MCU是STM32C语言里float就是4字节直接把它塞进寄存器不就完了吗”但问题在于STM32内存是小端存储3.14这个数在内存里的实际字节顺序是C3 F5 48 40而按Modbus要求、也按人类直觉我们应该在报文中发送40 48 F5 C3。如果直接把内存字节按顺序塞进寄存器报文里就会出现C3 F5开头的字节上位机按float解析自然乱七八糟。第三个难点是精度问题。很多人在遇到float传输麻烦后干脆“固定乘1000转成整数”再传。这种做法不是不行很多仪表产品也这么干但它是靠双方约定来的你得告诉上位机这个寄存器的量纲是“千分之一摄氏度”还是“千分之一伏特”一旦量程或小数位数变了所有寄存器的含义都要跟着改。而直接传IEEE 754 float则通用得多任何支持浮点寄存器的主站软件都能直接识别不需要额外约定缩放系数。所以我认为除非你完全控制主站和从站两端且量程很固定否则还是老老实实把float拆分好更一劳永逸。5. 拆分与还原的实现代码union解法与手动解包拆分float最直观的方式是C语言的union。把一个float和一个uint8_t数组放进同一个联合体里float占用4字节数组也能以字节视角访问同一块内存。在小端MCU上u.b[0]是float的最低字节u.b[3]是最高字节。Modbus报文需要的是大端字节序所以发送时把数组倒过来输出即可。typedef union { float f; uint8_t b[4]; } float_bytes_t; // 本机float - Modbus报文中的4字节大端 void float_to_modbus_bytes(float value, uint8_t out[4]) { float_bytes_t u; u.f value; out[0] u.b[3]; out[1] u.b[2]; out[2] u.b[1]; out[3] u.b[0]; } // Modbus报文中的4字节大端- 本机float float modbus_bytes_to_float(const uint8_t in[4]) { float_bytes_t u; u.b[3] in[0]; u.b[2] in[1]; u.b[1] in[2]; u.b[0] in[3]; return u.f; }如果你是往现成的Modbus协议栈里写代码通常在回调函数里拿到的是两个16位寄存器值而不是裸字节。这时可以换一种思路先把float重解释成uint32_t再把高16位和低16位分别填到两个寄存器里发送时以Modbus寄存器为单位就行。// 发送端把float写入两个保持寄存器 uint32_t tmp 0; memcpy(tmp, value, sizeof(float)); holding_regs[addr] (uint16_t)(tmp 16); // 高16位寄存器编号addr holding_regs[addr 1] (uint16_t)(tmp 0xFFFF);// 低16位寄存器编号addr1// 接收端从两个保持寄存器还原float uint32_t tmp ((uint32_t)holding_regs[addr] 16) | ((uint32_t)holding_regs[addr 1] 0xFFFF); float value 0.0f; memcpy(value, tmp, sizeof(float));这里有个细节值得多说两句。tmp 16得到的是数值上的高16位在内存里它是0x4048的数值当协议栈按Modbus规范发送这个寄存器时会自动把0x4048按大端字节序发到线上也就是先发0x40再发0x48。最终线上字节正好是40 48 F5 C3和3.14的IEEE 754标准表示完全一致。接收时把两个寄存器拼回uint32_t数值再用memcpy放到float里在小端MCU上这个浮点数值就是3.14。整个过程没有用到任何硬件字节序反转操作因为寄存器的按序发送已经帮我们完成了大小端转换。用union还是用memcpy移位我个人的习惯是在协议栈回调里用移位拼寄存器因为代码意图更清楚别人看代码能立刻明白你是在拼32位数据在裸字节收发场合用union因为少几次运算。两者本质是一样的选顺手的即可。6. 实测报文解析与一次字节序错乱的排查记录理论说再多不如拿一次真实调试过程说话。那次我从站代码写好后用Modbus Poll模拟主站去读结果窗口里显示的值根本不是预期的3.14而是一个完全没有意义的大数。我当时第一反应是ADC采集错了后来冷静下来用串口助手抓了总线上的原始报文才一步步定位到字节序。请求帧Modbus Poll发出是01 03 00 00 00 02 84 0A这是从站地址1功能码03读保持寄存器起始地址0x0000读2个寄存器。从站返回的响应帧是01 03 04 40 48 F5 C3 ... ...功能码03之后的0x04表示返回数据长度为4字节接着就是40 48 F5 C3。把这串十六进制手工拿去对照IEEE 754工具解析结果正好是3.14。这说明从站发出去的报文本身完全没有问题。问题出在Modbus Poll的显示配置上——上位机软件默认的float字序和我发的寄存器顺序不一致只需要在Poll的数据类型设置里改成“ABCD”还是“CDAB”就能正常显示。这种问题在实际调试中非常常见不是你程序写错了而是主站和从站的“约定”没对齐。另一次踩坑是真正意义上的从站代码错误。我用裸串口手动发送寄存器字节没经过协议栈结果抓到的响应帧是F5 C3 40 48这就是典型的小端内存字节直接外发的结果3.14在STM32内存里是C3 F5 48 40我只把后两个字节和前两个字节做了交换变成了F5 C3 40 48却没按彻底的倒序处理。正确做法就是上面代码里的out[0]u.b[3]、out[1]u.b[2]、out[2]u.b[1]、out[3]u.b[0]少一步都不行。为了让大家以后排查问题时有据可依我把常见的现象和原因整理成一张速查表现象可能原因处理办法数值离谱数量级完全不对字节序或寄存器顺序反了换主站float字序或修从站代码数值像整数且很小主站把float寄存器当int读改主站数据类型为float恒为0或固定不变可能发的是0.0f或者地址越界检查寄存器地址范围和写入值偶尔出现极大值通信干扰或寄存器未更新检查CRC、终端电阻、采样滤波正负数符号反了符号位和最大值判反检查字节序/字序组合再补充一个调试时很有用的小工具如果你手边有Python可以直接用一行命令验证收到的字节是否正确import struct data bytes.fromhex(4048F5C3) print(struct.unpack(f, data)[0]) # 输出 3.14嵌入式的开发环境里不一定有Python但可以在PC上临时装一个。验证字节序列、验证上位机配置都能用比每次手算IEEE 754快得多。关于float传输最后还想提醒一点如果你的从站协议需要兼容多个主站比如不同品牌的组态软件、PLC、触摸屏最好把寄存器顺序明确写在通信协议文档里。因为即便你的从站固定按“高字在前”输出有的上位机默认按“低字在前”解析没有文档约定每次对接新主站都是折磨。我现在的习惯是协议文档里直接注明“float占用连续两个寄存器高字在前寄存器内字节序遵循Modbus大端”并给出示例3.14对应字节40 48 F5 C3。这样现场调试能省掉一大半扯皮。回到这篇笔记最早的问题。旋转开关用1个ADC通道做采集本质上是用模拟量的连续性换数字IO的数量float拆分还原则是用明确的字节序约定换跨设备的数据互通。这两件事看起来不相关但在一个小设备里它们经常同时出现而且都是那种“做一遍觉得简单、不写明白下次还得重新踩”的典型问题。按文中的阻值选型和代码框架基本可以直接平移到自己板子上唯一要改的就是ADC通道和采样时间的微调。如果你也正好在调这两种功能希望这篇笔记能帮你跳过几个我踩过的坑。