嵌入式IO不够用?用ADC分压实现多档开关检测与Modbus浮点传输详解
2026/9/6 2:21:45 网站建设 项目流程

做嵌入式的小伙伴肯定都有过这种经历:板子快画完了,发现IO口不够用。我这阵子调试一个带4档旋转开关的小设备,就撞上了这个问题。传统接法一个档位一根IO,四个档位要占掉4个输入口,剩下的引脚还得留给通信、指示灯和按键,根本不够分。最后我把4档旋转开关改成了电阻分压加一路ADC采集,硬生生省下3个IO口。同一台设备还要通过Modbus RTU把采集数据上报给上位机,结果float在16位寄存器里拆来拆去又出了乱码,前后折腾了大半天才把字节序理顺。这篇笔记就把这两件事的完整方案、计算过程和踩坑记录写清楚,给正在折腾IO规划或者Modbus浮点通讯的朋友做个参考。

1. IO不够用?4档旋转开关的一路ADC采集方案

1.1 场景分析与方案选型

先说说我遇到的实际场景。设备面板上有一个4档旋转开关,用来切换工作模式:手动、半自动、全自动、校准,四个状态必须能被MCU实时识别。最开始画原理图的时候,我习惯性给每个档位分配一个IO,开关拨到哪一档,对应的引脚就被拉低(或者拉高)。电路设计很简单,逻辑判断也直观,调试时一根线一根线测过去就行。

问题出在IO预算上。主控用的是STM32F103系列,总共就那么几十个引脚,除了旋转开关还要接485收发、状态指示灯、按键、调试串口,以及几个传感器输入。等我把所有外设分配完,发现手头只剩下一两个空余IO,根本不够4个档位的输入。这时候就得换思路。

常见替代方案有三个:一是用拨码开关加编码器,把4个状态编码成2位二进制,只需要2个IO,但拨码开关手感差、体积大,而且4档旋转开关已经定了,改硬件不现实。二是用专门的IO扩展芯片,比如PCF8574,通过I2C读状态,占2个IO(SDA/SCL),但加片子要改板子、加代码,杀鸡用牛刀。三就是这次用的方案:把档位开关接成电阻分压网络,用一路ADC读取不同电压值来反推档位。

第三种方案的优势非常明显:只占用1个ADC引脚,不需要改结构、不需要加芯片,成本几乎为零,只要在开关引脚上多焊几个电阻就行。它的代价是软件上要做ADC采样、滤波和阈值判断,比单纯读IO稍微绕一点,但这个复杂度完全可控。我现在做这类产品,凡是遇到“多选一”的开关类输入,只要不是高速切换场景,优先考虑ADC分压,IO省下来还能干别的。

1.2 电阻分压电路与参数计算

电路结构其实就三样东西:一个固定上拉电阻、一个ADC采样点、四个档位各自的电阻。公共端接ADC引脚,通过一个上拉电阻接到VCC;旋转开关的四个档位分别接四颗不同阻值的电阻到GND。开关拨到不同的档位,就相当于把对应阻值的电阻接入分压回路,ADC引脚读到的电压自然不同。

选阻值的时候有两个目标:档位之间压差要足够大、便于稳定判断,同时电流不能太大、免得白白耗电。我用的参考电压是3.3V,上拉电阻选了10kΩ,四个档位分别接10kΩ、20kΩ、33kΩ、100kΩ。这些都是E24标准系列,采购方便,价格便宜。

分压计算公式是:

Vout = VCC × R档 / (R上拉 + R档)

逐档算下来:

档位接地电阻分压输出12位ADC读数
110kΩ1.65V2048
220kΩ2.20V2731
333kΩ2.53V3142
4100kΩ3.00V3724

相邻档位之间的电压差分别是0.55V、0.33V、0.47V,换算成ADC读数相差683、411、582。STM32F103的ADC是12位分辨率,这个区分度非常充足,即使电阻精度差一点、电源有纹波,也不会误判。如果板子空间的功耗敏感,可以把上拉电阻加大到47kΩ,档位电阻同步放大,分压关系不变,但电流能降下来,代价是ADC引脚输入阻抗对采样稳定性的要求更高,采样时间要相应加长。

这里有个容易忽略的坑:旋转开关从一档拨到另一档的过程中,触点会经过短暂的开路或抖动状态,ADC会读到一个不稳定的跳变值。软件里必须处理这个瞬态,不能读一次就下结论。另外,如果MCU的ADC参考电压引脚(VREF+)和供电不是同一个电源,分压计算要以VREF+的实际电压为基准,而不是想当然用3.3V。

2. 软件实现:采样滤波、档位判断与防抖

2.1 ADC初始化与多次采样预处理

硬件电路只能保证分压关系正确,真正的可靠性要靠软件。我首先把ADC配置成单通道、软件触发、12位精度,采样时间拉长到55.5个周期。为什么采样时间很重要?因为分压网络等效于一个电阻源驱动ADC内部采样电容,采样时间太短会导致电容充电不完全,低阻档位还好,高阻档位(尤其是100kΩ那一路)读数会偏低。

读取的时候不能直接拿一次转换结果当档位依据,至少要连续采样16次。我用的方法是:16次采样里去掉一个最大值、去掉一个最小值,剩下的14次求和取平均。这样能把毛刺和偶发异常值过滤掉,得到的值比较平滑。代码大致长这样:

#define ADC_SAMPLE_TIMES 16 static uint16_t adc_read_single(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)) { } return ADC_GetConversionValue(ADC1); } static uint16_t adc_read_filtered(void) { uint32_t sum = 0; uint16_t max_val = 0; uint16_t min_val = 4095; for (uint8_t i = 0; i < ADC_SAMPLE_TIMES; i++) { uint16_t v = adc_read_single(); sum += v; if (v > max_val) max_val = v; if (v < min_val) min_val = v; } return (uint16_t)((sum - max_val - min_val) / (ADC_SAMPLE_TIMES - 2)); }

这个函数每次返回一个经过预处理的ADC读数,后续档位判断就基于这个值。采样周期可以根据实际速度调整,我这里是每秒判断一次,功耗和响应速度都能接受。

2.2 档位判定:阈值、滞回与连续确认

ADC读数算出来了,怎么映射回档位?最简单的方法是设三个阈值:小于2390认为是1档,小于2936认为是2档,小于3433认为是3档,其余是4档。阈值取的是相邻两档ADC读数中点,比如1档和2档的中点是(2048+2731)/2=2389.5,取整2390。

但只用单阈值判断有个问题:如果读数正好在阈值附近波动,档位会来回跳。比如档位2的实际分压恰好因为电阻偏差偏低了一点,读数在2385到2395之间跳,系统就会在1档和2档之间反复横跳。解决方法是加滞回区间,也就是“上去”和“下来”用不同的阈值。更简单的土办法是连续确认:连续5次采样都在同一个档位,才认为档位真正切换了。

我实际用的是连续确认加基础阈值的组合:

#define GEAR_TH_1_2 2390 #define GEAR_TH_2_3 2936 #define GEAR_TH_3_4 3433 #define GEAR_CONFIRM_CNT 5 typedef enum { GEAR_UNKNOWN = 0, GEAR_1, GEAR_2, GEAR_3, GEAR_4 } gear_t; static gear_t gear_now = GEAR_UNKNOWN; static gear_t gear_from_adc(uint16_t adc) { if (adc < GEAR_TH_1_2) return GEAR_1; if (adc < GEAR_TH_2_3) return GEAR_2; if (adc < GEAR_TH_3_4) return GEAR_3; return GEAR_4; } gear_t gear_update(void) { uint16_t adc = adc_read_filtered(); gear_t candidate = gear_from_adc(adc); static uint8_t confirm_cnt = 0; static gear_t last_candidate = GEAR_UNKNOWN; if (candidate == last_candidate) { if (++confirm_cnt >= GEAR_CONFIRM_CNT) { confirm_cnt = 0; if (gear_now != candidate) { gear_now = candidate; /* 档位已变化,在这里做状态上报 */ } } } else { last_candidate = candidate; confirm_cnt = 0; } return gear_now; }

这套逻辑跑下来,机械抖动的毛刺会被过滤得一干二净。实测中即使我用手快速来回拨开关,输出档位也只会按顺序变化,不会出现跳变或者卡死。核心思想是:模拟量采集永远不要直接映射状态,一定要经过滤波和时间上的确认,这是搞ADC输入的基本功。

2.3 档位状态上报与Modbus联动

档位判出来了,怎么用?如果只是板子上点个灯,那直接查一下变量就行。但我的需求是要通过Modbus把档位状态上报给上位机,所以我在保持寄存器里专门划了一个地址存放当前档位,值就是1到4。这只占一个寄存器,非常省空间。

这里有一个细节:不要每个判断周期都把档位写入寄存器,最好只在档位变化的时候写。一方面减少对寄存器区的无意义写入,另一方面也方便上位机做事件检测。我在gear_update函数里已经预留了“if (gear_now != candidate)”这个分支,实际工程里就是在这个位置更新Modbus寄存器,同时置一个标志位通知主循环需要发送数据或者更新显示。

如果后续想把档位信息和浮点参数联动上报,比如不同档位对应不同的量程系数,这个系数本身是个float,那就引出了第二部分的问题:float怎么在Modbus里传。

3. Modbus里的float为什么总乱码:从IEEE 754到字节序

3.1 为什么一个float要占两个寄存器

Modbus协议的基础单位是寄存器,一个寄存器16位。而C语言里的float是32位的,按IEEE 754标准存储,正好等于两个寄存器。所以要在Modbus里传一个浮点数,必须把它拆成两个16位,分别放进两个连续的保持寄存器,接收方再把两个寄存器拼回去。

IEEE 754单精度浮点数的32位结构是:1位符号位、8位指数位、23位尾数位。一个数比如100.5,在内存里对应的32位十六进制是0x42C90000。实际换算是这样:100.5的二进制是1100100.1,科学计数法是1.1001001×2^6,指数为6,加上偏移量127得到133,即0x85,尾数是10010010000000000000000,拼起来就是0 10000101 10010010000000000000000,也就是0x42C90000。这个编码规则搞清楚了,后面字节序问题就很好理解了。

很多新手第一次遇到Modbus浮点乱码,第一反应是怀疑485接线、波特率、CRC校验之类的问题,折腾一圈发现通讯完全正常,问题出在数据解释上。这就是因为float本身是32位,而Modbus寄存器是16位,中间的拆分和组合规则没对上。

3.2 字节序才是最大的坑

拆分组合的规则就是字节序,最常见的排列模式有ABCD、CDAB、BADC、DCBA四种。假设一个float变量在内存中的4个字节从高位到低位依次是b3、b2、b1、b0(b3是最高字节),那么:

模式寄存器0的内容寄存器1的内容备注
ABCDb3 b2b1 b0高字在前,高字节在前
CDABb1 b0b3 b2低字在前,高字节在前
BADCb2 b3b0 b1高字在前,低字节在前
DCBAb0 b1b2 b3低字在前,低字节在前

Modbus协议标准本身是偏向大端的,寄存器内部的字节序通常默认高字节在前,所以ABCD模式被认为是“标准”模式。但是现实中很多PLC、仪表、传感器厂商有自己的内部约定,特别是欧洲一些设备喜欢用CDAB模式,也就是把低16位放在第一个寄存器,高16位放在第二个寄存器。加上一些上位机组态软件自己还有额外的字节序设置,多一层排列组合,乱码概率直线上升。

用100.5这个数举例,它的IEEE 754编码是0x42C90000,最高字节b3=0x42,b2=0xC9,b1=0x00,b0=0x00。如果是ABCD模式,两个寄存器读出来是0x42C9和0x0000。如果是CDAB模式,读出来就是0x0000和0x42C9。所以只要看Modbus Poll读出来的寄存器原始值,立刻就能判断当前设备是哪种排列。

3.3 用Modbus Poll快速确认字节序

判断字节序的办法很简单,根本不需要猜。我在从机寄存器区里专门留了两个测试寄存器,初始化的时候把一个已知浮点数比如100.5拆好放进去。联调的时候打开Modbus Poll,读取这两个寄存器的原始值,方法如下:

  1. 在从机代码里,把保持寄存器地址0x1000和0x1001写死为100.5拆分后的两个16位值,先按ABCD模式填。
  2. 打开Modbus Poll,连接从机,功能码03,起始地址0x1000,数量2。
  3. 观察寄存器值:如果显示0x42C9和0x0000,说明主站按ABCD解释,完美。如果显示0x0000和0x42C9,说明数据其实是CDAB排列,主站和从机有一边需要调整。
  4. 如果看到0xC942或0x0042这种奇怪的组合,说明寄存器地址错位、读写长度不对,或者从机代码里float拆分时字节本身已经打乱了。

我后来在工程里一直保留这个测试寄存器,不只是调试时用,产品出厂自检时也能跑一遍,确认通讯链路的数据解释一致。网上那些“16进制转float在线工具”很多也支持四种字节序切换,配合Modbus Poll的原始值几步就能定位问题。Modbus Poll没注册也能读数据,只是功能受限,调试基本够用。

4. 浮点收发代码:联合体、memcpy与位运算的取舍

4.1 联合体的直观写法与隐患

C语言里把float拆成两个uint16_t,很多老工程师第一反应是用联合体:

typedef union { float f; uint16_t u16[2]; uint8_t u8[4]; } float_map_t;

用起来确实简单,赋值给f,然后读u16[1]和u16[0]就行。但是这种写法有一个平台相关的问题:联合体成员的字节布局取决于主机的字节序。在x86和ARM小端模式下,u8[0]存的是float的最低字节;在大端模式下,u8[0]存的是最高字节。如果只在STM32上跑,小端模式是固定的,代码没问题。可一旦代码被移植到其他平台,或者编译器做了特殊的结构体对齐优化,联合体成员的对齐和填充可能和预期不一致,浮点数据就会悄悄出错。

更重要的是严格别名规则。C标准里,通过联合体的另一个成员来读取当前活动成员的值,在不同编译器下的行为并不完全一致。GCC和Keil实际支持这种用法,也跑得好好的,但这属于“知道坑在哪才能用”的写法。产品代码里我倾向用更保守、可移植性更强的方式。

4.2 推荐:memcpy加位运算的可移植写法

我推荐的方案是:先用memcpy把float按字节拷贝到一个uint32_t变量里,然后对这个uint32_t做位运算,按需要的字节序拆成两个16位寄存器值。memcpy不关心主机的字节序,它做的就是纯字节搬运;真正的字节序控制完全由后面的移位和掩码操作决定,逻辑清晰,换平台也不会翻车。

#include <string.h> /* 以AB-CD模式为例:寄存器0存高16位,寄存器1存低16位 */ void float_to_modbus(float value, uint16_t regs[2]) { uint32_t bits = 0; memcpy(&bits, &value, sizeof(bits)); regs[0] = (uint16_t)(bits >> 16); regs[1] = (uint16_t)(bits & 0xFFFFu); } float modbus_to_float(const uint16_t regs[2]) { uint32_t bits = ((uint32_t)regs[0] << 16) | regs[1]; float value = 0.0f; memcpy(&value, &bits, sizeof(value)); return value; }

这段代码在STM32上跑起来,把100.5传进去,regs[0]得到0x42C9,regs[1]得到0x0000,和前面表格对得上。如果设备的字节序是CDAB,只要把两个寄存器的赋值顺序调换一下,也就是regs[0]存低16位、regs[1]存高16位,其余逻辑完全一样。

如果一台设备需要适配不同上位机或者不同协议版本,可以把字节序抽成枚举参数:

typedef enum { FLOAT_ORDER_ABCD = 0, FLOAT_ORDER_CDAB, FLOAT_ORDER_BADC, FLOAT_ORDER_DCBA } float_order_t; void float_to_modbus_ex(float value, uint16_t regs[2], float_order_t order) { uint32_t bits = 0; memcpy(&bits, &value, 4); switch (order) { case FLOAT_ORDER_ABCD: regs[0] = (uint16_t)(bits >> 16); regs[1] = (uint16_t)(bits & 0xFFFFu); break; case FLOAT_ORDER_CDAB: regs[0] = (uint16_t)(bits & 0xFFFFu); regs[1] = (uint16_t)(bits >> 16); break; default: /* BADC和DCBA需要再做字节交换,不常用,这里不展开 */ break; } }

实际项目里,BADC和DCBA出现概率很低,遇到也是先怀疑设备手册看错了。我通常在代码注释里写清楚当前使用的模式,免得过几个月自己回来看代码一脸懵。

4.3 在FreeModbus工程中的挂载方式

常见的基于FreeModbus v1.6移植的STM32工程里,寄存器读写是通过一个uint16_t数组模拟的,比如usRegHoldingBuf,应用层所有数据都映射到这个数组。这时候float和寄存器的交互就是简单调用关系:上报时,先调用float_to_modbus把float拆进两个数组元素;接收时,调用modbus_to_float从两个数组元素还原出float。

需要注意地址对齐:写入float时,起始寄存器地址如果是奇数(按寄存器编号算),要小心后续区域的数据错位。比如float占了寄存器0和1,下一个变量就应该从寄存器2开始放,不能从1开始。虽然不会崩,但数据会互相覆盖,而且是那种特别难查的隐蔽bug。

我还习惯在代码里把寄存器地址定义成宏,浮点参数的起始地址、长度、字节序模式都集中在一个头文件里。这样上位机联调时一旦发现字节序不匹配,改一处宏定义就能全局切换,不用到处改代码。这个习惯在维护老设备兼容性时特别有用。

5. 实测踩坑记录与排查速查表

5.1 档位误判与ADC读数跳变

第一次联调时,旋转开关拨到3档,上位机显示成了2档,而且偶尔还会跳回3档。排查下来发现不是阈值算错了,而是我ADC采样时间设得太短。用示波器看ADC引脚的波形,发现100kΩ那一路的电压建立过程很慢,每次切换档位后大约有几十毫秒的充放电过程,采样早了就读到旧电压的余值。解决方法有两个:一是把ADC采样时间拉长到最大档位,二是切换档位后延迟一点再进行下一次判断。我两个都做了,问题彻底消失。

还有一次是档位4附近读数漂移,查了半天发现是板子上一颗电阻虚焊。所以这里也提醒一句:分压电阻的焊点质量直接影响结果,出厂前最好做一次全档位校准测试,每个档位的ADC读数落在哪个区间、和理论值差多少,记录下来作为出厂参数。

5.2 Cortex-M0上强转导致HardFault

有次在Cortex-M0内核的芯片上跑代码,程序没跑多久就进了HardFault。查反汇编发现是float指针强转成uint16_t指针后直接解引用导致的。Cortex-M0对未对齐访问支持很差,float变量在内存里的地址如果不是2字节对齐,强转指针后读一个16位值就可能触发硬件异常。解决办法很简单:不要做这种强转,统一用memcpy。这也是我为什么在浮点收发部分强调用memcpy加位运算,而不是指针强转或者联合体。踩过一次坑之后,我写嵌入式代码基本不碰指针强转处理多字节数据。

5.3 Modbus浮点乱码排查流程

如果上位机读出来的浮点数明显不对,我建议按下面这个顺序排查,不要直接改代码瞎试:

第一步,用Modbus Poll读原始寄存器值。不要看浮点解释结果,把两个寄存器的值切到十六进制显示,记下来。第二步,和目标浮点数的IEEE 754编码对照。比如期望是100.5,编码就是0x42C90000。第三步,比较两个寄存器和0x42C90000四个字节的对应关系:看到0x42C9在第一个、0x0000在第二个,是ABCD;反过来则是CDAB。第四步,确认主站那边的“数据类型”设置和“字节序/字序”设置是否与从机实际发送一致。很多组态软件里这两项是分开配置的,单独改对某一项还不能解决。第五步,如果原始寄存器值本身就和预期对不上,说明问题在从机代码里,去查float拆分函数。

5.4 常见问题速查表

现象可能原因排查方向
档位读数跳变、误判旋转开关抖动、ADC采样时间短加滤波、连续确认、延长采样时间
档位读数整体偏移分压电阻精度低、VREF不准确换1%精度电阻,核对参考电压
Modbus浮点乱码字节序不匹配用测试寄存器配合Modbus Poll对比IEEE 754编码
Cortex-M0运行异常指针强转导致未对齐访问改用memcpy拷贝数据
浮点数据错位但值能对上寄存器起始地址没有对齐检查参数放置地址,统一按寄存器编号规划

最后分享一个我自己坚持了很久的习惯:凡是Modbus从机工程,我一定在寄存器表里预留两个专用的测试寄存器,初始值就放100.5这个数。联调时先读这两个寄存器,字节序对不对、地址对不对、读写长度对不对,一眼就能看出来。旋转开关那边,阻值能选E24标准系列就不要用非标阻值,采购好买,Debug也好查。这两条经验帮我少加了不少班,也希望能帮正在看这篇笔记的你省点时间。

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

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

立即咨询