用1个ADC采集旋转开关档位:Modbus浮点传输的字节序处理笔记
2026/9/9 7:31:29 网站建设 项目流程

做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输入,平时未必用得上,但拿来读旋转开关这种“离散档位”刚刚好。思路其实很老套:公共端接VDD,4个档位引脚分别串一颗不同阻值的电阻到ADC采样点,采样点对地再接一颗固定电阻R0,形成一个分压网络。开关拧到不同档,ADC引脚上的电压就不同,软件根据电压区间反推档位。这样只占1个IO,成本就是几颗电阻,在仪器仪表和工业表头里是非常成熟的低成本方案。

有人可能会质疑:ADC精度靠谱吗?万一分压差太小或者电压波动导致误判怎么办?这就是接下来要解决的核心问题——分压电阻怎么选,阈值怎么留裕量。只要这一步设计得扎实,可靠性一点都不比数字IO差。

2. 分压网络计算实例:从选电阻到留足判定裕量

我实际用的拓扑是:旋转开关公共端接3.3V,档位1~4引脚分别串R1、R2、R3、R4到ADC节点,ADC节点对地接R0,ADC节点直接进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.0003723
23.32.4823080
3101.6502048
4330.767952

相邻档位的ADC码值间隔分别是643、1032、1096,最小间隔也有643个LSB,换算成电压大约0.52V。即使电源纹波、电阻精度误差、ADC零漂全叠加在一起,也很难让两个档位之间的读数跨越半个间隔。用5%精度的贴片电阻就足够了,不需要上0.1%的精密电阻,成本可以压得很低。

为什么第一档我不直接用“公共端直接接ADC节点”也就是Rn=0的方式?算一下就知道,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,中间点约3401;2档与3档中间点约2564;3档与4档中间点约1500。我取了整数3400、2560、1500,相当于把每个档位的判定区间放宽到了最大范围,任何一侧只要不偏移超过两百多个码值就不会误判。

旋转开关的机械抖动是第二个坑。虽然波段开关不像按键那样需要大量消抖,但拧动瞬间会出现中间悬空,ADC读数可能短暂跳到0或者一个中间值。如果这时候把档位变化直接用于修改设备参数,就会导致逻辑错乱。我的做法是只在设备上电时读一次档位作为地址,如果需要运行时识别,则加一个简单的“连续确认”逻辑:连续读到同一个档位3次才认为档位真正切换。

另外,如果你的产品功耗敏感,分压网络会一直在耗电。我这个取值在3.3V下,1档的电流大约3.3/(1k+10k) = 0.3mA,4档电流更小,对于电池供电设备来说基本可以忽略。但如果想进一步省电,可以考虑把电阻整体放大10倍(R0=100k、R1=10k等),把电流压到几十微安,代价是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是STM32,C语言里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位寄存器,编号addr+1
// 接收端:从两个保持寄存器还原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通道和采样时间的微调。如果你也正好在调这两种功能,希望这篇笔记能帮你跳过几个我踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询