嵌入式IO不够用?ADC分压读取4档开关与Modbus浮点传输解析
2026/9/7 15:19:17 网站建设 项目流程

嵌入式项目里,IO口永远不够用——这是我调过几块板子之后的真实感受。尤其是在给主控选型已经定死、PCB已经画完的场合,临时想多读一个4档旋转开关,结果发现GPIO全都占满了,只能硬着头皮从现有引脚里“抠”。这期调试笔记就记录了我怎么用尽量少的IO把4档旋转开关的档位读回来,以及因为要把档位数据通过Modbus上报给上位机,顺手踩平了那个经典问题:Modbus寄存器里的float到底怎么拆分、怎么还原。

这个内容很适合正在做设备参数切换、手动/自动模式选择、量程切换这类功能的工程师,也很适合刚接触Modbus协议还不清楚寄存器字节序的初学者。全文没有通用框架,全部是我在实际项目中的处理思路和可复现的C代码。

1. IO资源紧张时,读取4档旋转开关的几种思路

1.1 先说一个真实场景:板子已经画完,IO已经不够

我手头这个项目,主控是一颗Cortex-M0内核的国产MCU,引脚本来就少,还要挂一块LCD、一个串口接外部设备、两个按键、两路继电器输出,同时还留了SWD调试口。这时候现场提出要增加一个4档旋钮,用来切换设备的4种运行模式。

最开始的想法很朴素:4个档位,每个档位对应一根信号线,拉低哪根就是哪个档位,4个GPIO搞定。可我掰着手指头数了数,空闲IO只剩2个,其中一个还只能做模拟输入(ADC),另一路是普通GPIO。这比喻就像你想住大房子,结果预算只够买个小户型——你只能在布局上想办法。

所以核心矛盾就变成了:怎么用2个IO(甚至1个)把4个档位稳定读出来?

1.2 旋转开关常见类型与读取方式的取舍

先理清“4档旋转开关”到底是什么。常见的分两类:

  • 单刀四掷波段开关:一个公共端,四个定子端,旋到哪个档位,公共端就和哪个定子端导通。这种开关引脚多,但内部逻辑简单。
  • 编码式旋转开关:内部带有编码逻辑,比如2位格雷码输出,有的带定位但需要额外电路。这类开关通常有几个输出引脚,配合上拉/下拉可以直接读出二进制码。

如果是前一种最常见的波段开关,常规接法是:公共端接GND,四个定子端分别接四个GPIO,并各自带上拉电阻。旋到对应档位时,对应引脚被拉低,其余为高。这个方案的特点是:直观、稳定、互不干扰,但费IO。

如果IO不够,有两条省IO路线:

  1. 用2个GPIO读2位编码值(前提是开关内部支持或外部搭一个编码网络)。
  2. 用1个ADC引脚读不同的电压档位(利用电阻分压网络)。

我把这两种放一起对比了一次:

读取方式IO占用抗干扰能力实现复杂度适用场景
4个GPIO直读4较好(数字信号)极低引脚富余时的首选
2个GPIO编码读取2较好(数字信号)中(需要处理编码组合)引脚较紧,且开关可改接编码
1路ADC分压读取1中(模拟电压易受干扰)中(需要设计分压电阻)引脚极紧,对成本敏感
矩阵扫描4较高按键多、成矩阵布局

最终我选的是1路ADC分压读取。原因很直接:我手里恰好那个空闲ADC脚的ADC精度够用(12位),而且分压电阻成本极低,软件上只需要一个通道采样加阈值判断。如果你手头有2个普通GPIO,也可以用2bit编码方案,但我的情况是只有一个ADC脚合适,所以ADC方案胜出。

需要说明的是,ADC分压方案虽然省IO,但模拟信号对于电源波动和走线干扰更敏感。所以在PCB已经固定的情况下,必须验证机械旋钮在不同转速下切换瞬间的波形,否则容易出现档位跳变。

2. 单个ADC引脚采集4档旋转开关

2.1 电阻分压网络设计与档位电压计算

这套电路的本质是给每个档位分配一个可区分的电压区间。ADC读到的电压落在哪个区间,就判定当前是哪个档位。

我用的旋转开关是常见的单刀四掷:公共端COM接ADC采样点,四个定子端分别接不同的下拉电阻到GND,同时从COM端接一个上拉电阻到VCC。旋到某一档时,COM端的电压就是 VCC在下拉电阻和上拉电阻之间的分压值。

举个计算例子:

  • VCC = 3.3V
  • 上拉电阻 R_up = 10kΩ
  • 四个档位的下拉电阻分别为:
档位下拉电阻理论电压(V)ADC读数(12位)
档位10Ω(直接GND)00
档位23.3kΩ0.821017
档位310kΩ1.652048
档位4悬空(不接下拉,等效∞)3.34095

计算公式很简单:V_adc = VCC * R_down / (R_up + R_down)。

档位4我选择悬空,读取接近VCC。这样做有一个小问题:旋到档位4时,ADC引脚内阻对采样的影响比较大,而且悬空点在机械开关切换瞬间容易耦合噪声。如果现场环境干扰大,建议档位4也用一个很大的下拉电阻替代悬空,比如100kΩ,这样电压约3.0V,仍然和档位3的1.65V有足够区分度。

我实际分压计算时并没有严格按理论值来,原因很简单:电阻存在误差,且MCU的VCC和ADC参考电压不一定完全相等。我的做法是先按理论值把电阻选好,然后用精密万用表实测各档位的ADC原始值,写入固件时以实测值为准。

这里补一条经验:档位之间的电压间隔不能只靠ADC分辨率去分,还要考虑温度漂移和电源纹波。比如12位ADC有4096个码值,但实际可用的可靠分辨率通常只有10有效位左右。档位间隔至少要留出200~300个ADC码值,否则系统稳定性会很差。

2.2 软件滤波与档位判定逻辑

ADC原始值读出来之后,不能直接用一次采样结果去判断档位。机械开关切换过程中存在抖动,ADC引脚还会有毛刺。我用了“连续采样N次,去掉最大最小,取平均”的办法,然后再用滞回区间判断。

具体逻辑如下:

  • 每次判定前,连续采集8次ADC值,去掉一个最大值和一个最小值,剩下的6次取平均。
  • 档位切换判定带滞回量。例如档位1的理论区间是[0, 300],档位2是[800, 1200]。实际判定时不直接按区间切换,而是判断当前档位与上一次档位之间是否跨越了阈值边界,防止在临界点反复抖动。
  • 连续3次判定结果一致,才认为档位稳定。

这么做的好处是能明显消除旋钮快速旋转时的误判。我一开始为了省事只做了一次采样,结果在实验室转旋钮时偶尔会跳到别的档位,后来加了滤波加去抖,问题就消失了。

如果MCU有ADC参考电压基准引脚(VREF+),尽量把VREF+接一个低纹波的稳压源,不要直接用VCC。我那个板子VREF和VCC是同一个网络,所以电源纹波直接反映到了ADC码值上,只能靠软件滤波兜底。

2.3 实际代码:ADC采样与档位识别示例

下面这段代码基于标准库写了伪代码,但思路可以平移到任何MCU上。这里我只贴关键部分。

#define ADC_CHANNEL_CNT 8 static uint16_t buf[ADC_CHANNEL_CNT]; uint16_t adc_read_average(void) { uint16_t max = 0; uint16_t min = 4095; uint32_t sum = 0; uint8_t i; // 假设adc_read_single()已经启动转换并读取一个原始值 for (i = 0; i < ADC_CHANNEL_CNT; i++) { buf[i] = adc_read_single(); if (buf[i] > max) max = buf[i]; if (buf[i] < min) min = buf[i]; } for (i = 0; i < ADC_CHANNEL_CNT; i++) { sum += buf[i]; } sum = sum - max - min; return (uint16_t)(sum / (ADC_CHANNEL_CNT - 2)); } uint8_t judge_gear(uint16_t adc_value) { static uint8_t last_gear = GEAR_UNKNOWN; uint8_t gear; if (adc_value < 100) { gear = GEAR_1; } else if (adc_value > 800 && adc_value < 1200) { gear = GEAR_2; } else if (adc_value > 1700 && adc_value < 2300) { gear = GEAR_3; } else if (adc_value > 3500) { gear = GEAR_4; } else { return last_gear; } if (gear == last_gear) { return gear; } // 新的档位连续三次一致才更新 static uint8_t stable_count = 0; if (stable_count < 3) { stable_count++; return last_gear; } stable_count = 0; last_gear = gear; return gear; }

注意,上述阈值中的窗口是故意留出来的。比如档位2的理论值约1017,我只用了[800,1200]这个区间,给电阻误差和电源波动留了余量。另外在程序初始化时,要先把上一次档位值读一次,避免上电瞬间出现错误档位。

3. Modbus协议里float存储与传输的底层逻辑

3.1 IEEE754浮点格式简单回顾

档位采集读出来了,接下来要上报给上位机。我的项目通讯用的是Modbus RTU,参数需要上传为float。既然涉及float,就绕不开IEEE754格式。

IEEE754单精度浮点数占4字节,总共32位:最高位(第31位)是符号位S,接着8位是指数位E,低23位是尾数位M。数值表示为:

(-1)^S * 1.M * 2^(E-127)

这套格式是硬件浮点单元和编译器共同遵守的。正常写代码时,你不需要手动去算这些位,但当你需要在Modbus报文中传输float时,就必须搞清楚这4字节在内存里是怎么排的,因为Modbus报文不是按照C语言的float类型直接传递,而是按字节/寄存器一帧一帧地发。

我遇到过不少新手,写代码时直接把float类型指针强制转成uint8_t*发出去,结果上位机收到的数据完全是乱的。原因很简单:没有统一字节序和寄存器字序。

3.2 大小端与寄存器字序的排列关系

Modbus协议规定,每个寄存器是16位,发送时高位字节在前。也就是说,单个寄存器内部一定遵循大端字节序,这是协议标准,必须遵守。但float有4字节,需要占用两个寄存器,而协议并没有规定这两个寄存器的先后顺序,于是出现了两种常见排列方式:

  • 字序“高字在前”:第一个寄存器的值是float的高16位,第二个是低16位,报文顺序是 Reg_H, Reg_L。
  • 字序“低字在前”:第一个寄存器是低16位,第二个是高16位,顺序变成 Reg_L, Reg_H。

再加上每个16位寄存器内部的字节序(虽然标准是大端,但有些设备实现时搞成了小端),组合起来有4种常见形态。我用一个float例子来说明。

假设float值1.0,在STM32小端模式下,内存中的4字节如果是: 地址低→高:00 00 80 3F 那么这4字节可以拼接成两种16位寄存器:

  • 大端字节序:第一个寄存器存0x3F80,第二个存0x0000。
  • 小端字节序:第一个寄存器存0x803F,第二个存0x0000。

所以在设计Modbus协议时,必须明确两件事:

  1. 寄存器内字节是大端还是小端(一般建议大端,符合Modbus规范)。
  2. 两个寄存器谁在前(高16位在前还是低16位在前)。

我的项目里,设备作为Modbus从机,主机是第三方组态软件,协议文档只写了“float格式”。但现场通讯总是不对,后来抓包才发现,主机默认的是“高字在前,寄存器内大端字节序”,而我的从机代码用了memcpy直接把内存字节塞进寄存器,字序完全反了。这就是典型的拆分时没考虑字序。

所以做Modbus通讯,别急着写代码,先和上位机确认:你的float排列是Big-Endian(AB CD)还是Word Swap(CD AB)。很多组态软件里都有个“字节序/字序”的配置选项,一次对话能省好几小时调试时间。

4. float的拆分与还原工程实现

4.1 从float到两个Modbus寄存器的拆分方法

在MCU固件里,最稳妥的拆分方式是用联合体或memcpy,而不是用移位拼接。原因在于,C语言里移位32位float容易出现未定义行为,而且代码可读性差。不过为了讲清楚原理,我用三种方式都写一遍。

方法一:联合体(union)拆分

typedef union { float f; uint8_t bytes[4]; uint16_t words[2]; } float32_byte_t; void float_to_modbus_regs(float value, uint16_t *reg_high, uint16_t *reg_low) { float32_byte_t data; data.f = value; // 小端MCU上:bytes[0]是低位字节,bytes[3]是高位字节 // 按照“高字在前、寄存器内大端”的Modbus兼容格式: *reg_high = ((uint16_t)data.bytes[3] << 8) | data.bytes[2]; *reg_low = ((uint16_t)data.bytes[1] << 8) | data.bytes[0]; }

这段代码假设MCU是小端模式。STM32默认就是小端,没问题。如果MCU是大端模式,需要把索引对调。

方法二:memcpy拆分

#include <string.h> void float_to_two_regs(float value, uint8_t *reg_buf) { uint8_t tmp[4]; memcpy(tmp, &value, 4); // 组包:reg_buf[0]高字节1,reg_buf[1]低字节1,reg_buf[2]高字节2,reg_buf[3]低字节2 // 这里tmp[0]是低字节,tmp[3]是高字节 reg_buf[0] = tmp[3]; reg_buf[1] = tmp[2]; reg_buf[2] = tmp[1]; reg_buf[3] = tmp[0]; }

方法三:用算术移位拼字节

void float_to_modbus_data(float value, uint8_t *out) { uint32_t bits; memcpy(&bits, &value, 4); out[0] = (bits >> 24) & 0xFF; out[1] = (bits >> 16) & 0xFF; out[2] = (bits >> 8) & 0xFF; out[3] = bits & 0xFF; }

注意,memcpy(&bits, &value, 4)是把float的位模式复制到uint32_t变量,这在不同字节序下表现一致,因为bits变量和value在内存里是相同的位模式。之后用移位运算取出各个字节,是跨平台最稳的方式。我推荐你写协议栈时用这种方法。

4.2 浮点还原:从两个寄存器到float

还原就是把拆分反过来。假设从Modbus收到两个寄存器r_high和r_low(已按照“高字在前”约定),在从机端还原float:

float modbus_regs_to_float(uint16_t reg_high, uint16_t reg_low) { uint32_t bits; bits = ((uint32_t)reg_high << 16) | reg_low; // 小端MCU上,直接按位模式解释为float float value; memcpy(&value, &bits, 4); return value; }

这里有个细节:如果MCU是大端,bits的存放顺序就和上面不同。但绝大多数嵌入式平台都是小端,所以这段代码在STM32上是可用的。

如果你是从机固件里需要把从主机收到的两个寄存器还原成float,那思路一模一样,只是数据来源从寄存器数组变成报文缓冲区。

4.3 调试时最常用的一招:先打印16进制再看float

有一次我明明按“高字在前”组包了,上位机读回来的还是乱码。最后我把从机发出的4字节用串口打出来:

40 49 0F DB

然后用在线16进制转float工具一换算,正好是3.14159274,说明固件侧没什么问题。问题出在上位机组态软件的配置上,它默认按“低字在前”解析。改一个选项就好了。

所以遇到float乱码,先别怀疑自己的拆分代码有bug,先用一条调试语句把4个原始字节打出来,再对照在线工具确认这4字节对应的float值。如果字节正确,问题大概率是上位机解析顺序。

我在工程里常用一个函数:

void log_float_hex(float value) { uint8_t tmp[4]; memcpy(tmp, &value, 4); printf("hex: %02X %02X %02X %02X\r\n", tmp[3], tmp[2], tmp[1], tmp[0]); }

注意我打印tmp[3]到tmp[0],对应Modbus大端排列顺序。上位机收到的就是这样的字节序,直接对照。

5. 现场调试踩坑与排查技巧

5.1 旋转开关档位误判的典型原因

实际调试时,档位误判大多不是电阻算错,而是机械接触抖动和电源干扰。

我遇到过一次:旋钮从2档转到3档,程序有时候会识别成1档。用示波器测量分压点,发现开关通断瞬间,波形不是干净的阶跃,而是反复弹跳几百微秒。在弹跳期间,ADC采样到中间电压,恰好落到了别的档位区间。后来我在软件上加入了多次采样去抖,同时把ADC采样时间拉长,问题才解除。

还有一次是电源噪声。板子上的继电器吸合瞬间,VCC瞬间跌落接近200mV,ADC的值跟着跳,档位就乱了。后来在ADC引脚加了一个0.1uF的滤波电容到GND,并把ADC采样放到另一个线程里,不跟继电器动作抢时间,问题解决。

给大家几条防误判经验:

  • 旋钮切换后加至少5ms的延时,再开始采ADC。
  • 档位阈值留滞回区间,不要用单一比较点。
  • 最好在固件里把最近N次的有效档位存成滑动窗口,输出多数表决结果。
  • 机械开关在潮湿或灰尘环境下,接触电阻会增大,档位电压会偏移,阈值不能卡得太死。

5.2 float乱值的排查流程速查

这里把Modbus float传输中常见的错误现象整理成表:

现象可能原因排查方法
上位机显示负数或极小值符号位被挪到了错误位置打印原始4字节,验证是否等于理论字节序
数值对但精度不对(小数点后乱)解析成了32位整型或双精度截断确认组态软件里的变量类型是否为32位浮点
高16位和低16位交换字序不对把Reg_H和Reg_L交换后测试
16位寄存器内部字节交换寄存器内小端字节序组态软件里切换“字节交换/Word Swap”选项
偶尔乱值报文空闲间隔不足,帧粘连检查Modbus帧间隔时间,确保大于3.5字符时间

我自己的排查顺序固定为:

  1. 先用串口抓从机发出的原始报文,确认寄存器里的4字节数据是多少。
  2. 用16进制转float工具或者自己写一个小工具,验证这4字节代表的float值是否正确。
  3. 如果正确,再去调整上位机的解析配置。
  4. 如果都不行,检查主从机的波特率、校验位、停止位是否一致。

5.3 一个额外的坑:浮点值恰好在寄存器边界

还有个别情况要注意:当float值恰好是比较规整的数,比如1.0、2.0、100.0时,它的尾数部分有很多零,即使字序错了,有时候看起来依然“比较正常”,容易让人误以为通讯对了。比如1.0用大端排列是3F800000,字序交换后变成00003F80,上位机读出来是一个极小的浮点数,这个还好发现;但如果值换成2.0,大端是40000000,交换后是00004000,读出来也是极小值。所以我建议测试时用带小数位的特殊值,比如1234.5678,它能检验出大多数字节序错误。

另外,如果上位机要求的是“低字在前”,而你的代码写死了“高字在前”,则只需改动一个宏定义。我在协议栈里就留了一个宏:

#define MODBUS_FLOAT_WORD_ORDER_BIG_ENDIAN 1 #if MODBUS_FLOAT_WORD_ORDER_BIG_ENDIAN #define FLOAT_HIGH_WORD(regs) (regs[0]) #define FLOAT_LOW_WORD(regs) (regs[1]) #else #define FLOAT_HIGH_WORD(regs) (regs[1]) #define FLOAT_LOW_WORD(regs) (regs[0]) #endif

这样后期适配不同主机时,只改宏,不动算法。

6. 调试完之后的总结与个人体会

这期笔记从“IO不够用”开始,最后落到Modbus的float字节序问题,看起来是两个独立的技术点,实际上都是嵌入式调试里最磨人的“小问题”。旋转开关省IO采集,本质上是在引脚资源约束下做取舍;而Modbus的float拆分还原,则是在协议细节上做取舍。取舍没有对错,只要前期想清楚,后期就不会被来回折腾。

就4档旋转开关而言,如果你有条件用2个 GPIO 做编码,肯定优先用2个GPIO;如果没有GPIO,ADC分压完全可行,但一定要处理好机械抖动和电源纹波。我个人以后再做类似功能,大概率还会选ADC方案,因为省下来的IO可以留作他用,后期扩展传感器时非常宝贵。

Modbus float传输这件事,我强烈建议你在写代码之前先把“字节序+字序”用文档固定下来,并且用特殊值做联调。不要觉得这是小事,很多现场问题都是这个不起眼的顺序折腾了大半天。调试过程中,多利用串口打印原始字节,再配合在线转换工具,可以省下大量猜疑时间。

这一篇调试笔记就先写到这里。如果你在旋转开关ADC采集上有更巧妙的电路,或者被Modbus浮点序坑过,欢迎在评论区分享你的经历。

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

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

立即咨询