ADC分压省IO与Modbus浮点传输:嵌入式开发的编码与还原实战
2026/9/7 1:50:08 网站建设 项目流程

旋转开关省IO这件事,是我在一台温控仪表上被逼出来的。板子上要接的东西太多,数码管、按键、报警灯、RS485,还有拨码开关的备份配置,轮到4档旋转开关时,真的找不到4个空闲IO了。后来我研究了一圈,决定用ADC分压方案:一个ADC通道识别全部4个档位,只用一颗10k电阻加四档分压电阻,就把硬件的IO占用从4路压到1路。这个方案在我手里跑了半年多,中间踩了一次电容干扰导致的档位跳变,把软件滤波和迟滞补上之后就彻底稳了。

同一个月,我在给这台仪表对接Modbus RTU。仪表要把PID设定值、当前温度这类float参数通过16位寄存器传给上位机。真正让我记住的不是协议栈移植本身,而是float的拆分还原。一个字节序搞反,上位机显示的温度就变成1.4e-45。我一度以为采集电路坏了,后来发现纯粹是“字序+字节序”没对上。这篇笔记把这两个问题的完整思路、电路、代码和排查过程记录下来,希望对同样抠IO、处理Modbus浮点传输的开发者有点用。

1. 4档旋转开关:为什么硬要从4个IO抠成1个IO

1.1 直接接GPIO的问题,比想象中多

很多人第一反应是:4档开关嘛,每个触点接一个GPIO不就行了。按钮、跳线帽都是这么干的。但现实里这样做的代价往往被低估。

首先是IO占用。4个独立触点对应4个GPIO,这还没算要不要上下拉。如果一个产品上的数码管要扫描、按键要矩阵、还有编码器和报警输出,GPIO往往早就被分配完了。我用的是STM32F103,管脚看着不少,但算上复用冲突、烧录引脚、晶振引脚,真正灵活可用的也没剩几个。为了一个旋钮再挤4个IO出去,其他功能就得让路,甚至可能逼着换更大封装的芯片,成本和布线工作量都上来了。

其次是电平悬空问题。旋转开关的未接通触点,理论上什么都不是,实际在传感器线和面板引线长了以后,那个引脚就是一根天线,噪声耦合进来会让GPIO电平乱跳。独立触点方案通常需要把未用触点固定到确定的电平,单独几个引脚没问题,多了纯粹是埋雷。

更隐蔽的问题是机械抖动。旋钮在换挡瞬间,动静触点之间会有几十毫秒的不稳定,金属氧化、触点污染还会让接触电阻变大,如果只是简单读GPIO,要么不断重试,要么加一堆RC滤波。这样的代码能写,但每一个引脚都这么伺候,维护成本就不可控了。所以只要条件允许,我一般尽量把这种开关从“数字量输入”转成“模拟量识别”,一次只管理一个通道。

1.2 三种省IO方案横向对比:移位寄存器、编码芯片、ADC分压

我当时把市面上常见的省IO招数都过了一遍,主要是三条路。

第一种是串行移位,典型芯片是74HC165。原理是8个并口输入,通过三线SPI时序串行读出,占用数据、时钟、锁存3个IO。好处是理论上一颗芯片能读8路甚至更多,档位多了很划算。坏处也很明显:旋转开关本身只有4档,为4路输入专门挂一片SOIC-16的165,板子面积和BOM成本都不小,还要写读时序,如果主控的SPI口已经占满,还得用IO口模拟三线SPI,代码量未必比直接接4个IO少。

第二种是编码芯片,比如74HC148优先编码器。4路输入编码成2位二进制,能省到2个IO。在这个场景里它比165合适,但需要额外电源、去耦电容,还要处理输入悬空。而且优先编码器的输出是反码,软件里还得做一层映射,不够直观。

第三种就是ADC分压。把4个档位接成不同的分压电阻网络,公共端输出一个随档位变化的模拟电压,MCU用一路ADC采集,一个IO全搞定。虽然占用的是ADC通道而不是普通GPIO,但对绝大多数单片机来说,用一路模拟量余量比用4路GPIO要划算得多。判断逻辑也简单,把ADC值映射到档位区间就行。

三者的横向对比我整理成了表:

方案IO占用电路成本软件复杂度可靠性适用场景
直接GPIO4一般,需防抖IO富余
74HC165移位3中,需SPI时序8路以上输入
优先编码器2~3中,需反码映射对IO敏感
ADC分压1(ADC)低,只要电阻低,区间判读高,留好阈值IO紧张

最后我选了ADC分压,核心原因就是它只用1个IO,而且不用额外芯片。对4档这种“信息量极小”的场景,一路电阻网络加一路ADC是最经济的解。

1.3 为什么说ADC分压是“信息量换IO”

有人会担心:ADC方案不是把数字逻辑变成模拟测量了吗,测量精度会不会变成新的瓶颈?

其实要分清楚。普通数字输入需要判断的只有0和1两个状态,而现在要判断的是4个档位,也就是4个离散电平。这本质上是用电压的“幅度信息”去换IO的“数量信息”。只要4个电压分得开,每个档位的判读窗口留够余量,可靠性不会比GPIO差。

这个思路还有一个隐藏优点:如果以后产品改版要做6档或者8档开关,只需要改电阻网络和软件阈值,硬件IO占用还是1路。对需要维护多个产品型号的项目来说,这种“不动的设计”是很值钱的。

当然,前提是单片机有空闲ADC通道,而且对开关状态的响应实时性要求不是极端苛刻。ADC采样本身是微秒级,加上滤波、平均、去抖,完整判断一次档位大概几毫秒,对于人手动扭旋钮的场景完全够用。如果项目要求微秒级响应,那ADC方案就不合适,得老老实实走编码器或者直接GPIO。

2. 采样电路与档位判断:从电阻选择到软件去抖

2.1 分压网络与阻值计算

电路结构并不复杂:旋转开关的公共端COM接到一个上拉电阻R0到VCC,再从COM处引线到MCU的ADC引脚;4个档位触点分别接4个下拉电阻R1、R2、R3、R4到GND。旋到哪个档位,那个档位的电阻就接入分压网络,COM点电压随之改变。

关键在于阻值怎么选。首先R0不能太大,要给ADC输入引脚足够的驱动;也不能太小,否则流过开关的电流偏大,长期使用对触点不利。我习惯取R0=10k,然后在3.3V供电、12位ADC(满量程4096)的条件下,把4个档位的分压电阻设为:

  • 档位1:R=0,直接接地,电压0V
  • 档位2:R=3.3k,电压约为0.82V
  • 档位3:R=10k,电压为1.65V
  • 档位4:R=30k,电压约为2.48V

这里给出计算过程。以档位2为例,分压公式是:

Vadc = VCC * R / (R0 + R) = 3.3 * 3.3 / (10 + 3.3) ≈ 0.82V

12位ADC的数字量:

ADC = Vadc / VCC * 4096 = 0.82 / 3.3 * 4096 ≈ 1018

同理可得其他档位:

档位电阻R理论电压理论ADC值软件判读区间
100V00~400
23.3k0.82V1018500~1500
310k1.65V20481600~2400
430k2.48V30722500~3300

可以看到,相邻档位之间都留了至少100 LSB的死区,这就是硬件层面的抗干扰空间。ADC噪声通常只有几个LSB,加上电阻1%误差,实际采样值和理论值的偏差也不会超过几十个LSB,所以这种阈值窗口足够稳。

还有两个细节要提醒。一是分压电阻尽量用1%精度,5%的老电阻在不同温度下偏差会明显增大;二是R0上端要并一颗0.1uF的陶瓷电容到GND,放在ADC引脚附近,能滤掉不少高频毛刺,我第一次做的时候漏了这颗电容,档位跳变就是从这里来的。

2.2 软件判读:平均采样加稳定窗口

硬件确定了,软件就相对简单。我用了两层处理:第一层是多次采样取平均,把白噪声压下去;第二层是连续3次读到同一个档位才更新状态,把开关抖动挡在外面。

ADC采样代码比较简单,标准库下大概是这样:

uint16_t adc_read_once(void) { ADC_RegularChannelConfig(ADC1, ADC_Channel_4, 1, ADC_SampleTime_55Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); return ADC_GetConversionValue(ADC1); } uint16_t get_gear_adc(void) { uint32_t sum = 0; for (uint8_t i = 0; i < 8; i++) { sum += adc_read_once(); } return (uint16_t)(sum / 8); }

档位映射函数:

#define GEAR_INVALID 0 #define GEAR1 1 #define GEAR2 2 #define GEAR3 3 #define GEAR4 4 uint8_t voltage_to_gear(uint16_t adc) { if (adc < 400) return GEAR1; if (adc < 1500) return GEAR2; if (adc < 2400) return GEAR3; if (adc < 3300) return GEAR4; return GEAR_INVALID; }

最后是带稳定窗口的状态机:

uint8_t gear_sample(void) { static uint8_t stable_gear = GEAR_INVALID; static uint8_t last_gear = GEAR_INVALID; static uint8_t count = 0; uint16_t adc = get_gear_adc(); uint8_t g = voltage_to_gear(adc); if (g == last_gear) { if (++count >= 3) { stable_gear = g; count = 0; } } else { last_gear = g; count = 0; } return stable_gear; }

注意,ADC值落在死区(比如420)时,voltage_to_gear返回GEAR_INVALID,这时候不会更新last_gear,所以系统会保持上一次稳定档位不变。这个行为是刻意设计的,比一读到死区就处理成“无档位”要合理得多,用户转到一半当抖动过去,状态自动落在新档位,不会误报。

2.3 实操里容易踩的几个坑

第一个坑是VCC不稳。如果分压网络直接接3.3V,而3.3V是从LDO出来的,纹波小还好;如果直接接车载电瓶前的12V转5V再转3.3V这种多级电源,开关切换瞬间电压会抽动,ADC值跟着漂。我的建议是ADC的参考电压也尽量用同一路稳定3.3V,并对分压网络电源做独立RC滤波。

第二个坑是旋转开关的接触电阻。质量一般的开关用久了,触点会氧化,接触电阻可以从几十毫欧涨到几百毫欧。这个值跟3.3k、30k的分压电阻比不算大,但如果用了100k级别的电阻,接触电阻就开始影响电压了。所以分压电阻别选太大,我通常控制在1k到100k之间。

第三个坑是PCB布线。ADC引脚的走线不要跟PWM输出、继电器驱动这类强干扰线长时间平行,特别是没有铺地隔开的时候。我第一次画的板子,旋钮线从继电器旁边绕过去,一吸合继电器档位就跳,后来重新走线加电容才压下去。这类问题在原理图上看不出来,只能靠EMC经验积累。

3. Modbus里的float:核心不是“拆”而是“序”

3.1 IEEE 754单精度浮点在内存里到底长什么样

很多MCU开发者对float的理解停留在“小数”这个层面,一旦要把float塞进Modbus寄存器,就陷入字节序泥潭。

先看IEEE 754单精度float的结构,一共32位,分成三段:

  • 符号位:1位,0为正,1为负
  • 指数部分:8位
  • 尾数部分:23位

拿1.25举例。1.25转换为二进制是1.01,规范化后写成1.01×2^0,所以指数位存0+127=127(0x7F),尾数部分去掉整数位的1,剩下01,补满23位。最终得到的32位数据是:

0 01111111 01000000000000000000000

用十六进制表示就是0x3FA00000。

这一步容易卡,但还不算最坑的。真正的问题是:这4个字节在内存里是按什么顺序排列的。STM32、ESP32这些Cortex-M内核默认小端,意思是低地址存低字节,所以0x3FA00000在内存里从低地址到高地址依次是:

00 00 A0 3F

如果用调试器把内存按“uint32_t”读出来,看到的是0x3FA00000,没毛病;但如果把内存按字节取出来,就一定是上面这个倒序。这个特点后面所有Modbus拆分代码都要考虑到。

3.2 Modbus 16位寄存器如何映射float

Modbus最常用的寄存器是保持寄存器,每个寄存器16位,一个float要占2个寄存器。于是问题来了:这4个字节怎么塞进两个16位寄存器里?

行业里常见的做法是“先高字后低字”,意思是第一个寄存器存float的高16位,第二个寄存器存float的低16位。1.25拆开后:

  • 寄存器A(地址N):0x3FA0
  • 寄存器B(地址N+1):0x0000

但在把寄存器通过Modbus帧发送时,还要考虑寄存器内部的字节顺序。Modbus应用协议规范里,寄存器传输默认高字节在前,所以0x3FA0发送时是两个字节:0x3F、0xA0。

到这里,一套完整但还没考虑到“厂商自定义顺序”的映射关系就是:

float f = 1.25f; // 小端内存:00 00 A0 3F // Modbus寄存器(高字在前): // 寄存器0: 3F A0 // 寄存器1: 00 00

如果对方设备不是按“高字在前、字内高字节在前”来实现,那读出来的数据就对不上。这也是Modbus float通信里最容易扯皮的地方。

3.3 四种字节序组合,几乎是行业内约定各自为政

同样是1.25(0x3FA00000),不同设备的寄存器数据可以完全不同。常见组合有四种:

组合含义1.25的发送字节
ABCD高字在前,字内高字节在前3F A0 00 00
BADC高字在前,字内低字节在前A0 3F 00 00
CDAB低字在前,字内高字节在前00 00 3F A0
DCBA低字在前,字内低字节在前00 00 A0 3F

搜一下“16进制转float工具在线”,把上面四组数据贴进去,只有第一组能得到1.25,其他组要么是巨大的正数,要么是一个负数,要么直接显示NaN。我在现场遇到过把0x3FA00000读成0x00003FA0然后显示成5.88e-39的情况,那就是典型的CDAB顺序错误。

所以在写代码之前,第一件事不是敲键盘,而是翻协议文档,确认对方上位机、组态软件或PLC用的是哪种顺序。文档如果没写,就先用Modbus Poll这类工具读几个已知数值,反向推出来再写。

4. float拆分还原代码:从基础实现到FreeModbus集成

4.1 基础版本:memcpy加左移拼接

知道原理之后,代码其实很薄。我用的是“先把float拷贝成uint32_t,再按字拆”的方式,下面这两个函数就是我的标准动作:

#include <string.h> void float2regs(float f, uint16_t *reg_hi, uint16_t *reg_lo) { uint32_t tmp; memcpy(&tmp, &f, 4); *reg_hi = (uint16_t)(tmp >> 16); *reg_lo = (uint16_t)(tmp & 0xFFFF); } float regs2float(uint16_t reg_hi, uint16_t reg_lo) { uint32_t tmp = ((uint32_t)reg_hi << 16) | reg_lo; float f; memcpy(&f, &tmp, 4); return f; }

为什么非要用memcpy?因为C标准里floatuint32_t之间直接强转指针属于“严格别名”问题,写出来可能是:

uint32_t tmp = *(uint32_t *)&f;

这在大部分MCU上能跑,但编译器开O2优化后可能出幺蛾子,尤其涉及对齐和类型别名时,行为是未定义的。用memcpy或union既是标准做法,也让代码意图更明确:我们不是在做数值转换,只是在重新解释内存里的位模式。

这段代码在小端平台(STM32、GD32、ESP32)上是正确的。1.25进来,先变成tmp=0x3FA00000,高字寄存器是0x3FA0,低字寄存器是0x0000,跟前面讲的一致。

4.2 大小端兼容的防御写法

如果项目可能跑在不同字节序的平台上,或者你想让代码更健壮一点,可以加一个小工具函数判断字节序,再决定字节排布。判断方法很简单,把一个16位值的低字节取出来看看:

uint8_t is_little_endian(void) { uint16_t test = 0x0102; return (*(uint8_t *)&test == 0x02); }

然后在拆分函数里根据大小端做不同处理。不过在工业产品里,主控平台一般是固定的,这种泛化写太多反而增加维护成本。我更推荐的做法是:在float2regs/regs2float函数顶部加注释,明确注明“基于小端平台”,换平台时重点检查这里就行。

唯一需要注意的例外是:如果主控是Cortex-M内核,但编译器配置成了大端模式,那内存布局就全反了。这种情况很少见,但一旦出现,上面所有基于小端的推演都要推倒重来,排查起来非常痛苦。

4.3 FreeModbus回调里的落地写法

我这边主控是STM32F103,标准库v3.5,FreeModbus v1.6,跑RS485上的Modbus RTU。FreeModbus把Modbus帧协议栈都处理好了,我只需要在保持寄存器回调里把数据搬进搬出。

FreeModbus回调里拿到的pucRegBuffer是大端字节序,也就是说帧里的第一个字节直接对应寄存器高8位。所以写寄存器时,两个字节拼一个16位寄存器的常规写法是:

uint16_t reg_value = ((uint16_t)pucRegBuffer[0] << 8) | pucRegBuffer[1];

我通常会在工程里维护一个全局保持寄存器数组,只放两个元素:

static uint16_t usRegHoldingBuf[2]; eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { eMBErrorCode eStatus = MB_ENOERR; USHORT i; if ((usAddress >= 0) && (usAddress + usNRegs <= 2)) { while (usNRegs > 0) { i = usAddress; if (eMode == MB_REG_WRITE) { usRegHoldingBuf[i] = ((uint16_t)pucRegBuffer[0] << 8) | pucRegBuffer[1]; pucRegBuffer += 2; } else { *pucRegBuffer++ = (uint8_t)(usRegHoldingBuf[i] >> 8); *pucRegBuffer++ = (uint8_t)(usRegHoldingBuf[i] & 0xFF); } usAddress++; usNRegs--; } } else { eStatus = MB_ENOREG; } return eStatus; }

应用层要读取上位机下发的目标温度时,就调用:

float get_target_temp(void) { return regs2float(usRegHoldingBuf[0], usRegHoldingBuf[1]); }

要主动上报当前温度时,调用:

float2regs(current_temp, &usRegHoldingBuf[0], &usRegHoldingBuf[1]);

这样一个float参数就完整走通了。要注意的是,地址偏移和寄存器映射必须前后一致,两边都按同一个协议文档对齐。我踩过的坑是协议文档写“温度占用40001、40002”,实际Modbus Poll访问的是0基地址,两者差了1,导致读出来的数据永远是上一组float的低半段,看起来像是随机数。

5. 现场排查实录:档位误跳与浮点乱码

5.1 现象一:档位偶尔跳变,持续几十毫秒后恢复

第一版调完,功能正常,但现场反馈说旋钮扭到3档,偶尔会跳成2档,然后又跳回来。我开始以为是代码逻辑问题,查了一两天,最后用示波器看ADC引脚波形才发现:当继电器吸合的瞬间,ADC线上叠加了一个窄毛刺,把采样值打到了档位2区间里。

具体原因是ADC引脚离继电器驱动管太近,而且我忘了在ADC引脚端并滤波电容。处理办法有两个层面:

  • 硬件上,在分压网络输出到ADC引脚之间串一颗1k电阻,再在引脚对地并0.1uF电容,构成一个RC低通,截止频率约1.6kHz,对继电器噪声衰减很大。
  • 软件上,再加上第2.2节那种“连续3次一致才更新”的稳定窗口,相当于又加了一道数字滤波。

两道一起上之后,再用继电器反复吸合测试,档位一次都没跳过。这里我学到的教训是:ADC采样这种模拟链路,不能只盯软件,硬件噪声永远是第一源头。

5.2 现象二:上位机读到float变成1.4e-45

Modbus通信调好之后,上位机工程师跟我说温度读出来不对,显示接近0的极小科学计数法数字。我第一反应是采集算错了,赶紧用一个固定的1.25作为设定值写进去,结果上位机读回来是5.88e-39。

用Modbus Poll连上之后,我看到寄存器值分别是0x3FA0和0x0000,这两个值本身是对的。问题出在把两个寄存器拼成float上。上位机端按协议文档应该是“先低字后高字”,把0x3FA0当低字、0x0000当高字,拼出来就是0x00003FA0,转成float自然是极小值。

这种问题靠肉眼看代码很难发现,最有效的方法是找一个在线16进制转float工具,手算一遍:

  • 如果拼法是0x3FA00000,得到1.25,正确
  • 如果拼法是0x0000A03F,得到类似1.4e-38的极小值,就是字序或字节序有一步反了

后来我们跟对方确认了协议,他们的产品用的是DCBA顺序,跟我的ABCD正好全反。解决办法不是在MCU端改,因为可能有别的设备也在按ABCD读数据,而是在上位机配置里把float字节序改成DCBA。协议兼容这种事情,很多时候不是谁对谁错,是谁先定规矩。

5.3 写保护与NaN处理

除了正常的拆分还原,还有两个防御性逻辑值得加上。

第一个是写保护。上位机往PLC里写浮点设定值的时候有个习惯,先把寄存器写成0,再分成两次写入两个寄存器。如果MCU端每写一个寄存器就立刻更新应用变量,中间态就会把目标参数短暂变成0。所以我在FreeModbus回调里,只在低字寄存器也写完时才触发生效。简单做法是先存bounce buffer,两个寄存器都更新后一次性解析成float,再存进应用变量。

第二个是NaN和非法值。浮点运算一旦出错,可能产生NaN或无穷大,在Modbus上表现出来的就是0x7FC00000这类特殊序列。上位机收到后可能直接显示异常。我的做法是解析后加一句:

if (isfinite(f)) { g_target_temp = f; }

或者把期望范围也做死,超出物理范围的值直接丢弃,防止现场误操作把参数写成一个离谱的温度。

这套旋转开关分压识别和Modbus float拆装,看起来是两个不相关的问题,本质却都是在做同一件事:用确定性的规则,把离散信息通过有限的物理链路传输出去,然后在另一端准确还原。旋转开关靠电压幅度编码,float靠两个16位寄存器编码,信息本身没变,变的是编码规则。

我自己的体会是,这一类调试问题往往不是难在原理,而是难在“文档缺失+现场组合”,所以写代码之前,先把手上的协议文档和硬件方案读透了再动手,比多写几百行代码都管用。

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

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

立即咨询