IO不够用?1个ADC引脚读取4档旋转开关,附Modbus浮点数传输实战
2026/9/6 10:02:16 网站建设 项目流程

做项目最怕什么?不是逻辑复杂,而是引脚不够。这个月接手一台小设备的控制器,主控 MCU 的 IO 早就排满了:LCD 背光、两个按键、一个蜂鸣器、两路继电器,还要留一路给 RS485 的收发方向切换。等轮到面板上那个 4 档旋转开关时,我盯着原理图看了十分钟,硬是抠不出两个干净的 GPIO。于是才有了这第 6 篇调试笔记:用 1 个 ADC 引脚读 4 个档位,顺带把浮点数通过 Modbus 上送到触摸屏时遇到的字节序问题一起解决了。这两个问题本身不复杂,但牵扯到电路选型、软件判档、通讯协议兼容性,联调时踩的坑一个不少。

如果你现在也遇到“IO 不够用”的窘境,或者正在被 Modbus 报文里的 float 搞到怀疑人生,这篇笔记可以给你一个完整的参考方案。我会从原理讲起,把电阻分压怎么取值、ADC 判档怎么做滤波和滞回、float 在 Modbus 寄存器里怎么拆分和还原,全部拆开讲清楚。

1. 先说结论:方案怎么选,为什么选 ADC 分压

旋转开关这玩意儿,最直观的接法是每个档位对应一个 IO,几档就占几个引脚。4 档要 2 个 IO 就能编码出来,但如果 IO 富裕,也可以豪横地一个档位一个 IO。问题是,我这板子上真的挤不出两个 GPIO 了。那还有什么路子?

常见方案有这么几条:

方案占用资源优点缺点
GPIO 直读至少 2 个 IO逻辑简单,代码好写引脚不够就完蛋
串行 IO 扩展芯片1 个 I2C/SPI + 若干引脚可扩展大量 IO增加成本和故障点,需要驱动代码
ADC 分压采样1 个 ADC + 几个电阻最省 IO,成本几乎为零依赖 ADC 精度,判档要留容差
移位寄存器(如 74HC165)2~3 个 IO适合大量按键输入需要并行读入逻辑,稍显复杂

我最后选了 ADC 分压。原因很直接:这项目里 MCU 恰好还剩一个 ADC 引脚,而且 ADC 通道数量一般比普通 IO 多,很多芯片动辄十几个 ADC 输入通道,用来做档位检测属于“物尽其用”。电阻的成本可以忽略不计,焊接也简单,不需要额外芯片。

有人会担心:ADC 读电压做档位判断,会不会不够可靠?说实话,如果只有两三个档位,把电压区间留够,稳定性是没问题的。4 档也完全可行,关键是阻值选得好不好、软件判档有没有做容错。机器在工业现场跑,温度漂移、电源波动、电阻误差都会影响采样值,所以电路和软件都要往“宽区间、多冗余”的方向设计。

这个方案的另一个隐含好处是:如果以后面板改成 6 档、8 档开关,只要重新算一下分压电阻,代码加两个映射表条目就行,PCB 都不用动。硬件上的灵活性,是 GPIO 直读方案给不了的。

2. 电阻分压测档位:电路选型与档位电压设计

2.1 分压原理与电阻取值计算

先讲电路怎么搭。常见的做法有“上拉分压”和“下拉分压”两种,本质一样,就是把不同的档位电阻接到不同的电压节点上,让 ADC 引脚读出不同的电压。

我采用的是“下拉固定电阻 + 开关切换上拉电阻”的结构:ADC 引脚接一个固定电阻到 GND,旋转开关的公共端接 VCC,每个档位通过各自的电阻连接到 ADC 引脚。这样档位 1~4 分别对应不同的上拉电阻值,ADC 读到的电压就是 VCC 经过上拉电阻和下拉电阻分压后的结果。

公式非常简单:

Vadc = VCC * R_down / (R_down + R_switch)

我这板子的 VCC 是 3.3V,ADC 引脚外接固定下拉电阻 R_down 取 10kΩ。4 个档位对应的上拉电阻设计如下:

  • 档位 1:R_switch = 0Ω(直通),Vadc = 3.3V
  • 档位 2:R_switch = 1.0kΩ,Vadc = 3.3 * 10 / (10 + 1) ≈ 3.0V
  • 档位 3:R_switch = 3.3kΩ,Vadc = 3.3 * 10 / (10 + 3.3) ≈ 2.48V
  • 档位 4:R_switch = 10kΩ,Vadc = 3.3 * 10 / (10 + 10) = 1.65V

看一眼这几个值,相邻档位之间的电压差分别是 0.3V、0.52V、0.83V。最小的一段只有 0.3V,也就是档位 1 和档位 2 之间的间隔。对于 12 位 ADC 来说,3.3V 参考电压下 0.3V 对应约 372 个 LSB,容量足够。但为了更稳,我后来把档位 2 的上拉电阻改成了 680Ω,算下来电压是 3.3 * 10 / (10 + 0.68) ≈ 3.09V,和档位 1 的间隔拉到 0.21V?不对,这样反而更小了。正确的思路是让档位 1 和档位 2 的电压差拉大而不是缩小——如果有条件,最好是让四个电压点在整个量程内均匀铺开。

如果重新设计,我会选择这样的组合:R_down = 10kΩ,R_switch 分别为 0Ω、2.2kΩ、5.6kΩ、12kΩ,计算出来的电压大约为 3.3V、2.68V、2.11V、1.5V,相邻间隔分别是 0.62V、0.57V、0.61V,分布均匀得多。但实际项目里电阻往往就地取材,手头有什么用什么,所以本例还是按 0Ω、1kΩ、3.3kΩ、10kΩ 来讲,这套阻值也足够用。

注意一个细节:档位 1 直通 VCC 到 ADC 引脚,如果这个时候恰好开关处于切换的中间态,两个触点同时接通,就相当于 VCC 直接通过两个电阻网络对地。由于每个支路都有电阻限流,不会烧东西,但瞬间电流会大一些。所以下拉电阻不能选得太小,比如 1kΩ,那档位 1 对地电流就是 3.3mA,虽然也能接受,但浪费功耗。10kΩ 档位下电流只有 0.33mA,属于比较舒服的范围。

2.2 从电压区间到档位:量化误差与裕量分析

模拟电压最终要变成 ADC 的数字量,所以软件判档之前,必须把电压区间转成 ADC 的量化值。我用的 MCU 是 12 位 ADC,满量程 4095,参考电压就是 VCC 3.3V。

ADC 数字值的计算公式:

ADC_Value = Vadc / Vref * 4095

把四个档位的电压换算过来:

档位理论电压ADC 数字值(12位)
13.30V4095
23.00V3723
32.48V3077
41.65V2047

理论上这几个值间隔很大,但工程上不能只按理论值定阈值。电阻有 5% 甚至 10% 的误差,开关接触电阻也有几十毫欧到几百毫欧,电源纹波、ADC 噪声、参考电压温漂,统统都要考虑。所以我一般会把相邻两档的中点作为阈值边界,同时留出至少 ±5% 的余量。按中点算,档位 1 和档位 2 的边界是 3.15V,对应 ADC 值约 3909;档位 2 和档位 3 的边界是 2.74V,对应约 3400;档位 3 和档位 4 的边界是 2.06V,对应约 2556。

有了这些边界,档位判定就变成非常简单的区间判断。我的实际代码里还加了一个“无效区”判断:如果 ADC 读到的值低于某个下限(比如 800,对应 0.64V 左右),说明开关处于悬空、接触不良或非法位置,此时不能贸然认定是任何一个档位。

3. 软件判档:滤波、阈值与滞回设计

3.1 ADC 采集配置与采样滤波

硬件电路只是第一步,真正让“4 档旋转开关”变成稳定输入的是软件。ADC 的采样值不能拿来直接用,机械开关在转动的瞬间,触点会经历一连串的抖动和接触电阻变化,ADC 毛刺非常明显。如果直接拿单次采样值去判档,面板旋钮稍微转半格,系统就可能误判好几个档位。

我的做法是两层处理:先用中值滤波剔除突发毛刺,再做连续 N 次确认的消抖。中值滤波的原理很简单,连续采 7 次,排序后取中间那个值。对于偶发的高频噪声和开关抖动,中值滤波效果比均值滤波好得多,因为它不会被极端值拉偏。均值滤波更像一把“低通滤波器”,适合平滑周期性噪声,但在剔除尖峰毛刺这件事上不如中值来得干脆。

核心代码大概长这样:

#define SW_ADC_SAMPLE_COUNT 7 static uint16_t sw_adc_filtered_value(void) { uint16_t buf[SW_ADC_SAMPLE_COUNT]; for (int i = 0; i < SW_ADC_SAMPLE_COUNT; i++) { buf[i] = read_switch_adc(); // 底层ADC读取 delay_us(200); // 两次采样之间留一点间隔 } // 简单插入排序,找中值 for (int i = 1; i < SW_ADC_SAMPLE_COUNT; i++) { uint16_t tmp = buf[i]; int j = i - 1; while (j >= 0 && buf[j] > tmp) { buf[j + 1] = buf[j]; j--; } buf[j + 1] = tmp; } return buf[SW_ADC_SAMPLE_COUNT / 2]; }

注意两次采样之间加上 200μs 左右的延时,目的是让片内采样电容有充足时间充电。尤其是分压电阻网络阻抗较大时,如果采样时间太短,ADC 采到的电压会偏低。ST 的参考手册里也提到过,外部输入阻抗越大,需要的采样时间越长。很多同学移植完代码发现电压值老是差一点,大概率就是采样时间没设够。

STM32 标准库或 HAL 库里配置 ADC 采样时间时,我习惯直接选最长的那个档位,比如 239.5 个周期。对于这种低频缓变的旋转开关信号,多花几十个周期的采样时间完全无所谓,换来的却是稳定的读数。

3.2 查表法判档和滞回切换

滤波完成后,就开始判档。我维护了一张档位映射表,把 ADC 区间和档位一一对应:

typedef struct { uint16_t thresh_high; uint8_t level; } switch_level_map_t; static const switch_level_map_t switch_map[] = { { 4095, 1 }, { 3908, 2 }, { 3399, 3 }, { 2555, 4 }, // 小于2555继续向下判,低于下限则无效 };

这里的判断逻辑是从高往低查,找到一个 ADC 值小于等于阈值的档位就返回。我在表里刻意把边界值留了一点空隙,防止临界状态来回跳动。

但是光有阈值还不够。旋转开关在档位切换瞬间,ADC 会先经过一个“中间未知区”,如果这个过程中滤波后的值从一个档位跳到另一个档位,再跳回去,就会导致系统状态频繁抖动。

解决办法是滞回和确认计数。简单说:只有当某个新档位连续被确认多次,才真正切换当前状态;如果只是偶尔一次跳变到别的档位,直接忽略。参考实现:

static uint8_t current_level = 0; static uint8_t confirm_count = 0; #define CONFIRM_TIMES 5 uint8_t switch_get_level(void) { uint16_t adc = sw_adc_filtered_value(); uint8_t raw_level = 0xFF; // 无效档位 if (adc >= 3909) { raw_level = 1; } else if (adc >= 3400) { raw_level = 2; } else if (adc >= 2556) { raw_level = 3; } else if (adc >= 800) { raw_level = 4; } if (raw_level == current_level) { confirm_count = 0; return current_level; } if (raw_level == 0xFF) { // 无效采样,不要清零确认计数,维持原档位 return current_level; } confirm_count++; if (confirm_count >= CONFIRM_TIMES) { current_level = raw_level; confirm_count = 0; } return current_level; }

这套逻辑写起来不复杂,但实际效果非常明显。机械开关随便怎么拧,输出档位都稳稳的。注意一个细节:无效区间的采样不能清空 confirm_count,否则在临界位置缓慢转动时,确认计数一直被清零,档位永远切不过去,用户体验会很差。我一开始在这里踩了个坑,后来加了“无效采样不参与消抖计数”的判断才解决。

4. Modbus 上送 float:字节序拆分的原理与实现

4.1 为什么 Modbus 要手动拆 float

档位采集好了,下一步就是把数据传给上位机。项目里用的是 Modbus RTU,设备作为从机,触摸屏作为主机,通过 RS485 读取。这就撞上了第二个问题:Modbus 协议本身只定义了寄存器(16 位)和线圈(1 位)两种数据类型,float 是 32 位数据,没法直接塞进一个寄存器里,必须拆成两个连续的保持寄存器。

严谨地说,Modbus 协议并没有规定 32 位数据在寄存器里的排列顺序。它只规定“一个寄存器 16 位”,但 float 拆成两个寄存器后,哪个寄存器是高 16 位、哪个是低 16 位,甚至字节内部的高低位顺序,都取决于设备厂商的实现。这是现场联调最容易出 bug 的地方,也是最常见的“两边都觉得自己没错”的坑。

IEEE 754 标准的 float 由 4 个字节组成,内存布局是:1 位符号位、8 位指数位、23 位尾数位。比如数值 1.0 的十六进制表示是0x3F800000,在小端模式的 MCU(STM32、GD32 等绝大多数 ARM Cortex-M)内存里,按地址从低到高排列是00 00 80 3F。如果把这段内存原封不动地塞进两个 16 位寄存器,就会得到0x00000x803F,这显然不是人类习惯看的数据。

所以在做 Modbus 从机时,绝对不能偷懒直接拿uint16_t*去强转 float 指针。跨平台、跨字节序、跨编译器的情况下,这种写法纯属给自己埋雷。正确做法是老老实实按字节重新排列,把所有字节序问题都显式写清楚。

4.2 拆分与还原的 C 语言实现

我的从机代码里,上报 float 数据的函数是这样写的:

static void pack_float_to_regs(float value, uint16_t *regs) { uint8_t bytes[4]; memcpy(bytes, &value, 4); // 按大端排列输出到两个寄存器 // regs[0] = 高16位, regs[1] = 低16位 regs[0] = ((uint16_t)bytes[3] << 8) | bytes[2]; regs[1] = ((uint16_t)bytes[1] << 8) | bytes[0]; }

这段代码的运行逻辑是:先把 float 的位模式原样拷贝到字节数组,然后不管 MCU 本身是哪种字节序,我都手动把最高字节bytes[3]放到第一个寄存器的最高位。这样发出来的 Modbus 报文就是标准的“大端字节序 + 高字在前”格式。

反过来,从两个寄存器还原出 float 的函数:

static float unpack_float_from_regs(const uint16_t *regs) { uint8_t bytes[4]; bytes[0] = (uint8_t)(regs[1] & 0xFF); bytes[1] = (uint8_t)(regs[1] >> 8); bytes[2] = (uint8_t)(regs[0] & 0xFF); bytes[3] = (uint8_t)(regs[0] >> 8); float value; memcpy(&value, bytes, 4); return value; }

用数值 1.0 验证一下:0x3F800000拆完后,regs[0] = 0x3F80regs[1] = 0x0000。Modbus 报文中先发0x3F80再发0x0000,上位机接收后还原得到 1.0,完全正确。

不过这里必须提醒一句:上面的实现对应的是“高字在前(AB CD)”的寄存器顺序。如果你对接的 PLC、触摸屏、组态软件默认是“低字在前(CD AB)”,那regs[0]regs[1]需要交换位置。不同的厂商定义可能完全相反,常见的四种排列如下:

格式名寄存器顺序(以 1.0 为例)说明
AB CDreg[0]=0x3F80, reg[1]=0x0000高字在前,字内大端,最常见
CD ABreg[0]=0x0000, reg[1]=0x3F80低字在前,字内大端
BADCreg[0]=0x803F, reg[1]=0x0000高字在前,字内小端
DCBAreg[0]=0x0000, reg[1]=0x803F低字在前,字内小端

联调时我最先要做的就是翻设备手册,确认它支持哪一种。实在没有手册,就用 Modbus Poll 这类调试工具的“Float 显示模式”反复切换格式,看哪个能显示出正常的数值,然后按那个格式固定下来。

4.3 寄存器地址对齐与数据类型约定

除了字节序,还有一个容易忽略的问题:float 占用两个连续寄存器,如果你的寄存器地址表没有设计好,中间插了别的变量,整个数据就会串位。

比如 40001 和 40002 是一个 float,40003 是另一个 float,那就没问题。但如果 40001 和 40002 之间想塞一个 16 位的状态字,float 的 40001 就只占了半截,后面 40002 会被别的数据覆盖,读取结果必然错乱。

所以我在设计寄存器映射表时,会刻意把 float 变量放在偶数地址起始的位置,让两个寄存器保持连续,并且每个 float 变量之间不再插入其他类型的数据。这个规矩看起来呆板,但在后期增加变量时能避免一大堆莫名其妙的解析问题。

协议文档里也要写清楚:哪个地址范围,数据类型是什么,字节序是什么,缩放系数是多少。我见过很多项目“代码能跑,但是过三个月自己也忘了寄存器表怎么排的”,最后全靠翻聊天记录,非常痛苦。建议在自己的代码仓库里放一份寄存器表 Markdown 文件,每次改动都要同步更新。

5. 联调与排错实录:那些最容易翻车的细节

5.1 现场遇到的两个典型案例

第一个案例是 ADC 采样值系统性偏低。板子焊好之后,用万用表量 ADC 引脚的电压,4 个档位都符合理论值,但固件通过串口打印出来的 ADC 值总是比理论低 2%~3%。排查过程很快:万用表是 10MΩ 内阻的,几乎不影响电路;但 MCU 的 ADC 采样保持电容只有几个 pF,外部分压网络的等效电阻接近 10kΩ,在默认的短采样时间下电容根本充不满,所以采样值偏低。把 ADC 采样时间从默认的 1.5 个周期改成 239.5 个周期之后,偏差立刻消失了。这个坑在低阻抗的传感器信号上不明显,但在分压电阻网络这种高阻源场景下几乎是必现的。

第二个案例是 Modbus 触摸屏上温度显示小数点错位。设备里的温度变量是 float 类型,寄存器地址也连续,从机上报的数据用 Modbus Poll 读出来完全正确,但到了触摸屏上却显示成几十倍的大数。最后查出来的原因是触摸屏组态软件里那个变量被配置成了“32 位无符号整数”,而不是“32 位浮点”。解析方式不对,再标准的字节序也白搭。这类问题不在协议层,而在应用配置层,排查起来最耗费耐心。后来我养成了一个习惯:联调开始前,先把自己手头的数据类型、缩放系数、字节序写在一张纸上,然后拿着这张纸和设备侧工程师逐项确认。

5.2 常见问题速查表

现象可能原因排查方法
ADC 采样值总比万用表低采样时间太短,采样电容未充满把 ADC 采样时间调到最长,确认外部阻抗不超过几十 kΩ
旋转开关拧到某档,偶尔跳到相邻档阈值区间太小,或接触电阻影响增大相邻档电压间隔,检查电阻精度,增加中值滤波
开关转动瞬间系统误触发接触抖动产生的毛刺未过滤中值滤波 + 连续 N 次确认消抖
判档永远停在某个档位无法切换无效区间的采样把确认计数清零了无效档位不参与消抖计数,维持当前状态
Modbus 读出来的 float 完全不对两个设备字节序约定不一致确认是 AB CD 还是 CD AB,交换寄存器测试
float 只有部分数据对得上寄存器表里插入了其他变量,寄存器未对齐检查寄存器偏移量,float 必须占用连续两个寄存器
某些值偶尔读出来是 NaN 或 Inf发送端 float 未初始化或越界检查源数据是否有除零、越界赋值

5.3 关于省 IO 设计的一点心得与扩展

这次做完整套方案,我的体感是:省 IO 并不是什么高深技术,核心思路就是“多路信息通过模拟量叠加到一根引脚上”。除了旋转开关,按键矩阵、电位器、温湿度传感器都可以用类似思路处理,前提是模拟信号之间有足够的区分度。

如果想进一步扩展,还可以把 ADC 分压方案和低功耗唤醒结合起来。比如系统休眠时,旋转开关的档位变化会导致 ADC 引脚电压变化,借助 MCU 的模拟唤醒功能,不用额外按键就能把设备从睡眠中唤醒。这个玩法对电池供电的产品很有价值,我后面应该会单独写一篇。

至于 Modbus 的 float 传输,一句话总结我踩坑三年换来的经验:不要在协议层赌对方和你的字节序一致,必须在设计文档里写明排列方式,并在联调时用工具验证。多花十分钟确认,能让你少熬一个通宵。

这套方案目前已经在我手头的小批量设备上稳定运行了两个月,旋转开关没有出现误判,Modbus 数据上送也正常。如果后面遇到更复杂的多档位采集或者更多样的浮点协议兼容问题,再回来补这篇笔记。

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

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

立即咨询