MCU引脚不够?ADC分压读旋转开关,Modbus浮点传输字节序详解

发布时间:2026/9/8 6:56:28
MCU引脚不够?ADC分压读旋转开关,Modbus浮点传输字节序详解 前阵子调一块控制板MCU引脚紧张板上要塞两个4档旋转开关一个切换工作模式、一个选参数段位。按最直接的思路两个开关就是8个IO这板子根本兜不住。当时认真考虑过加PCF8574扩展IO后来被硬件老同事一句话点醒“就四个档位ADC分压不就完了。”于是有了这篇笔记的核心内容。这块板子后续还要跟触摸屏走Modbus RTU通信把两个float型浮点参数传到屏上显示。在Modbus的float拆分还原上又卡了两天翻了各种工具和帖子才把字节序理顺。这篇就把省IO采集和float的拆分还原一起记录下来适合正在用STM32、国产MCU做控制板或传感器采集的工程师参考尤其是遇到IO不够用、Modbus传浮点数乱码这类问题的时候。1. 为什么一个旋转开关把我逼到IO预算边缘做嵌入式的朋友都有这种体验原理图上多一个芯片PCB上就多一片面积BOM里就多一份成本固件里就多一套驱动代码。IO扩展芯片比如PCF8574、74HC165、MCP23008虽然能解决引脚不足但代价是引入I2C或SPI总线依赖要配从机地址、要处理总线死锁、要多一路供电去耦。在一个“功能不复杂、出货量可能很大”的控制板上能用电阻解决的方案坚决不引入新芯片。旋转开关这个形态本身很讨巧机械定位一拨就停在对应档位现场工人不用看菜单、不用按键循环掉电也不丢状态比按键加数码管的方案直观得多。既然硬件形态定了问题就变成怎么用最少的IO把4个档位读回来。4个档位最原始的做法是每个档位给一个IO开关拨到哪一路哪一路就被拉到低电平程序检测哪个IO为低就知道当前档位。这方案逻辑简单但4个档位吃4个IO两个开关就是8个还不算中间可能加的状态指示灯、通讯接口、使能引脚IO预算瞬间崩盘。中间也讨论过矩阵扫描方案后来发现根本不适合矩阵扫描是为“多个独立按键”设计的适合按键分布在行列交叉点上的场景。旋转开关本质是单刀多掷一个公共端选通多个固定触点的其中之一用矩阵扫描去读它等于拿大炮打蚊子还白白占了好几个IO。还是得走模拟量思路用ADC一个通道解决。这个思路的实际代价就是几个贴片电阻硬件简单、软件可控、成本极低占用一个模拟输入引脚。拨动开关时ADC读到的电压变化非常清晰可靠性完全够用。2. ADC分压替代IO扩展四个电阻搞定四个档位2.1 电阻分压网络怎么搭先说硬件连接。旋转开关的公共端COM接到MCU的ADC引脚同时通过一只上拉电阻接到3.3V。开关的四个固定触点分别经过四只不同阻值的电阻接到GND。开关拨到第几档等价于在ADC引脚和GND之间接入对应阻值的下拉电阻与上拉电阻构成分压网络ADC读到的电压自然不同。我实际用的阻值如下开关公共端到3.3V上拉电阻100 kΩ第1档下拉电阻1 kΩ第2档下拉电阻10 kΩ第3档下拉电阻33 kΩ第4档下拉电阻100 kΩ分压公式就是中学物理的分压公式( V_{ADC} V_{CC} \times \frac{R_{down}}{R_{up} R_{down}} )。按3.3V供电算出来的结果档位下拉电阻理论ADC电压12位ADC读数约1档1 kΩ0.033 V402档10 kΩ0.300 V3723档33 kΩ0.820 V10164档100 kΩ1.650 V2048四档电压从0V到1.65V分布的间隔非常明显相邻档位的最小压差也有0.267V对应约330个ADC码值即使用8位精度的ADC也能区分更不用说12位ADC。这样设计的好处是电阻误差、电源纹波、温度漂移都不会轻易让档位判定串线。2.2 上拉电阻为什么选100k而不是10k阻值选择有两个考量点静态功耗和ADC采样时间。如果用10k上拉档1导通时电流是3.3V/10k1k≈0.3mA看起来不大但工业设备常年通电能省一点是一点。换成100k上拉后静态电流降到30µA级别对电池供电或低功耗场景更友好。但100k上拉也有副作用档位分压网络的源阻抗会比较高。比如第4档上拉100k和下拉100k并联等效电阻50k。STM32的ADC内部是开关电容结构采样瞬间要给采样电容充电信号源阻抗太高会导致采样不准确。解决方法是把ADC采样周期调长比如配置成239.5个ADC时钟周期。这个细节如果不注意读出来的电压会偏低档位判定就会出现偶发错误。还有个值得提的点旋转开关如果存在空档位COM引脚会通过上拉电阻直接拉到3.3VADC会读到接近满量程的电压。程序里可以把“电压超过2.5V”判成异常状态当作开关处于空档或接线断开来处理这样硬件上等于多了一个自检通道。3. 固件里的档位识别平均采样、阈值区间和异常处理3.1 采样滤波怎么做硬件分压网络给的是模拟电压软件侧的关键是把它稳定地翻译成档位。我通常的做法是连续采样8次去掉最大值和最小值把中间6次取平均。这样能滤掉绝大多数随机噪声而且代码量很少。对旋转开关这种“机械定位静止保持”的信号来说不需要上复杂的卡尔曼滤波平均值滤波已经绰绰有余。ADC初始化时有一个参数必须改采样时间。因为分压网络等效阻抗可能到50k如果还用默认的1.5周期或7.5周期采样内部采样电容还没充满就被采集了读值会偏小。我在STM32标准库或HAL库里把采样时间设置为ADC_SAMPLETIME_239CYCLES_5实测电压和万用表测的误差在1%以内。下面是HAL库风格的ADC多次采样取平均的代码#define ADC_AVG_TIMES 8 static uint32_t adc_read_avg(uint32_t times) { uint32_t sum 0; for (uint32_t i 0; i times; i) { HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { sum HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); HAL_Delay(2); } return sum / times; }3.2 档位阈值划分和状态异常处理得到平均ADC码值后直接判断它落在哪个区间。关键点在于不要用单个精确阈值点切分而是每个档位保留一个判定区间区间之间留出死区。这样即使电阻精度有偏差、电源电压稍有波动档位判定依然稳定。我按12位ADC满量程4095划分如下判定结果ADC读数范围对应逻辑档位档位00 ~ 1201档档位1250 ~ 5002档档位2800 ~ 13003档档位31700 ~ 24004档异常其他不确定/空档120~250、500~800、1300~1700这些空隙就是死区目的是避免阈值附近的偶然波动造成误判。哪怕电阻精度漂了10%这些区间依然是安全的。typedef enum { SW_LEVEL_0 0, SW_LEVEL_1, SW_LEVEL_2, SW_LEVEL_3, SW_LEVEL_ERR 0xFF } sw_level_t; #define ADC_RAW_MAX 4095 #define VREF_MV 3300 sw_level_t sw_level_detect(void) { uint32_t raw adc_read_avg(ADC_AVG_TIMES); if (raw 120) return SW_LEVEL_0; else if (raw 500) return SW_LEVEL_1; else if (raw 1300) return SW_LEVEL_2; else if (raw 2400) return SW_LEVEL_3; else return SW_LEVEL_ERR; }我把这个判定函数放在一个100ms周期定时器里调用检测到档位变化时置一个“档位变更标志”主循环看到标志就去更新控制参数。不建议在中断里直接做ADC读取和判定太占用中断时间放主循环足够快旋转开关是机械操作100ms的响应完全感知不到延迟。3.3 为什么不用比较器或光耦隔离方案有人可能会问既然要省IO能不能用比较器比如LM393把电压转换成数字电平这样只占一个普通GPIO可以但没必要。比较器方案需要额外的参考电压电路、迟滞电阻网络元件更多、成本更高。ADC方案直接利用MCU内部自带的外设外部只要电阻。只要不是对成本极其敏感的极端情况ADC方案都是更优解。还有一点如果开关安装位置离MCU比较远走线长、电磁干扰大可以考虑在对地电阻并一个小电容比如100nF到GND和分压网络一起构成低通滤波器。这个电容放在靠近MCU ADC引脚的位置效果最好。我这次板子开关和MCU在同板距离很短没加电容也没问题。4. Modbus只认16位寄存器float凭什么塞进去省IO问题解决之后紧接着是通信侧的麻烦。这块板子需要把两个温度参数上报给触摸屏。温度是带小数的浮点数直接放Modbus寄存器不行。Modbus协议里所有数据都装在16位寄存器里一个寄存器只能表达0~65535的整数放不下一个32位的浮点数。要理解float拆分首先得明白float在内存里到底长什么样。以1.0f为例在IEEE 754标准下它的32位二进制是符号位0指数位0111 1111即127偏移量为127尾数位000 0000 0000 0000 0000 0000组合起来是0x3F800000。换成人话float的32位由1位符号、8位指数、23位尾数构成解读规则和整数完全不同。Modbus寄存器是按16位组织所以一个float必须拆成两个16位寄存器来传。关键来了怎么拆拆完按什么顺序拼这个顺序在业内没有统一标准甚至是Modbus浮点通信里最常见的坑。为了看清楚字节序可以把float的4个字节命名A最高有效字节0x3FB次高有效字节0x80C次低有效字节0x00D最低有效字节0x00在STM32这类小端单片机里float变量在内存中的字节排列顺序是D、C、B、A低地址放最低字节。而Modbus报文在串行线路上传输时默认先传高字节这是所谓的大端字节序。如果直接把内存里的4个字节按地址顺序塞进寄存器出来的寄存器内容会是寄存器00x0000对应DC寄存器10x3F80对应BA这就是小端排列很多Modbus主站组态屏、上位机软件默认不认这种顺序它们期望的是大端排列寄存器00x3F80对应AB高16位在前寄存器10x0000对应CD低16位在后这两个顺序传出来的数据用Modbus Poll看寄存器原始值时是一样的但是拼回float后完全不是同一个数一个量级对、一个直接变成乱码。这也是为什么很多人遇到“明明寄存器里是0x3F80 0x0000上位机显示却是NaN或者几百亿”。Modbus浮点寄存器顺序的常用约定大概有几种名称寄存器0寄存器1字节序列大端序ABCDA BC D最常见多数组态屏默认小端序CDABC DA B部分国产设备默认字交换BADCB AD C少数仪表好消息是主流Modbus主站软件组态屏、Modbus Poll都支持设置寄存器顺序。坏消息是如果你写从机程序时没有做字节序转换那就只能在主站侧硬调遇到不支持的软件就只能干瞪眼。正确做法是在从机端就把浮点数按大端序整理好让主站保持默认设置就好。5. float拆分还原的代码实现及字节序修罗场5.1 方法一union联合体加字节重排最简单直观的方法是定义一个联合体让float和字节数组共享同一块内存然后手工把字节逆序之后填入Modbus发送缓冲区。假设通信缓冲区out_buf里约定按大端序存放4个字节从机发送代码可以这样写static void float_to_modbus_bytes(float val, uint8_t *out_buf) { union { float f; uint8_t b[4]; } u; u.f val; // 小端内存里是 D C B A转换后变成大端 A B C D out_buf[0] u.b[3]; // A out_buf[1] u.b[2]; // B out_buf[2] u.b[1]; // C out_buf[3] u.b[0]; // D }接收端还原浮点数就是把过程反过来static float modbus_bytes_to_float(const uint8_t *in_buf) { union { float f; uint8_t b[4]; } u; // 大端传输 A B C D还原到小端内存为 D C B A u.b[0] in_buf[3]; u.b[1] in_buf[2]; u.b[2] in_buf[1]; u.b[3] in_buf[0]; return u.f; }这种方式代码清晰一眼就懂也是我项目里最常写的版本。需要注意union在某些编译器上可能涉及别名问题但嵌入式主流编译器GCC、Keil对这类用法支持良好只要不是作死开严格别名优化检查实际使用没有任何问题。5.2 方法二直接对两个16位寄存器操作如果使用的是FreeModbus这类协议栈通常在保持寄存器回调函数里读写的是uint16_t数组。这时float的拆分还原可以直接面向“两个寄存器”做不用去凑4个字节。#define REG_HIGH 0 #define REG_LOW 1 void float_to_regs(float val, uint16_t *regs) { union { float f; uint16_t u16[2]; } u; u.f val; // 小端MCU上u16数组是 u16[0]低16位, u16[1]高16位 // Modbus约定高16位寄存器在前 regs[REG_HIGH] u.u16[1]; regs[REG_LOW] u.u16[0]; } float regs_to_float(const uint16_t *regs) { union { float f; uint16_t u16[2]; } u; u.u16[1] regs[REG_HIGH]; u.u16[0] regs[REG_LOW]; return u.f; }这个版本的代码假定MCU是小端、Modbus侧是大端。如果项目里既有大端MCU比如部分Cortex-M内核虽然ARM是小端但仍然有不常见的配置或者某个主站偏偏要CDAB就必须把这个宏定义改成可配置项比如#define MODBUS_FLOAT_ORDER_ABCD 0 #define MODBUS_FLOAT_ORDER_CDAB 1 #define MODBUS_FLOAT_ORDER MODBUS_FLOAT_ORDER_ABCD void float_to_regs(float val, uint16_t *regs) { union { float f; uint16_t u16[2]; } u; u.f val; #if MODBUS_FLOAT_ORDER MODBUS_FLOAT_ORDER_ABCD regs[0] u.u16[1]; regs[1] u.u16[0]; #else regs[0] u.u16[0]; regs[1] u.u16[1]; #endif }留这个开关很值钱项目后期换主站设备、换上位机软件时不用改业务逻辑改一行宏定义就行。5.3 顺序约定比代码本身更重要这段是我最想强调的拆分还原的代码网上到处都是但真正坑人的永远是“两边没约好”。我这次遇到的真实情况是MCU从机端上报float时按大端序ABCD处理触摸屏组态软件里寄存器设置也选了大端但显示出来的温度值依然一动不动是0.0。查了一下午最后发现触摸屏组态里“寄存器数量”写错了只分配了1个寄存器16位而float需要2个寄存器32位。数据就只传了一半过来另一半永远是0。另一个高频翻车点用Modbus Poll查看寄存器原始值时确认了寄存器0是0x3F80、寄存器1是0x0000大端序没错但上位机显示的“浮点数”却不对。后来才知道有些上位机的浮点数每个寄存器内部还要再反转一次字节序也就是寄存器0内部是0x803F才对。这种就只能靠主站软件格式化设置灵活处理或者从机端按对方文档调整。所以做Modbus float传输之前先把双方的数据手册翻出来确认三件事寄存器地址从哪个偏移开始每个float占用几个寄存器2个寄存器顺序是ABCD还是CDAB还是别的高级定制这三件事在联调前确认好能省下整整一天。6. 联调翻车现场从单机正常到主从不通的排查链路6.1 单机好端端一接上就废这块板子最迷惑的调试经历是用Modbus Poll直接连从机MCU读浮点数一切正常用Modbus Slave模拟从机设备连到触摸屏上位机上位机读数也正常。两个方向单独测试都OK可一旦把真实设备挂到现场485总线上就出现“偶尔通了、刷新几次就掉线、或者根本连不上”的鬼问题。这种“单点正常、组网失败”的典型顺序是先查物理层再查参数层最后查数据层。不要一上来就怀疑代码也不要一上来就怀疑触摸屏。物理层最常见的问题就是485的A、B线接反。RS-485是差分信号A、B反接之后信号极性反转有的从机设备还能勉强工作有的直接没有响应。判断方法很简单拿一个USB转485模块挂到总线上抓报文如果主机发出请求后总线上一片寂静从机完全没反应多半就是A/B反了或者GND没共地。还有终端电阻问题。现场总线上只要超过两台设备首尾两端必须各接一个120Ω终端电阻。我遇到过总线传输质量差、数据偶发校验错误最后就是中间某台设备没接终端电阻、线又拉得很长导致的信号反射。虽然调试时短距离没问题一拉到现场就不行。6.2 主机从机单测都正常合到一起数据乱套排除物理层之后如果主机能发出请求、从机也能回复但读到寄存器的值乱七八糟就需要抓完整报文来分析。用USB转485接在总线上配合串口调试助手可以看到类似这样的交互主机发送01 03 00 00 00 02 C4 0B读取从机地址1、功能码03、起始寄存器0、数量2。从机回复01 03 04 3F 80 00 00 B4 E0返回4个字节数据0x3F800000。看到这个过程后重点核对返回的4个数据字节和实际浮点数是否一致。0x3F800000就代表1.0。如果从机发的是00 00 80 3F那是CDAB小端序主站默认就会解析错误。这时优先去从机端修正字节序把数据按大端序输出不要去改主站的格式设置因为主站可能被很多其他设备共用乱改会影响其他通信。6.3 CRC校验和从机地址那些阴沟有一个细节特别容易翻车从机设备地址和功能码都对但CRC校验就是过不去。很多工程师用串口工具手动拼报文时会不小心算错CRC16。建议调试时先用Modbus Poll或Modbus Slave这种现成工具把主从链路跑通再动代码不要在CRC上做过多手工推理太容易出错。另外从机地址冲突也是个隐蔽问题。如果总线上存在两个地址相同的设备主机发请求后两台从机都会尝试回复总线信号直接打架主机关联设备超时。排查方式是把设备逐个断开看恢复正常即可定位。6.4 调试工具配置要领调试Modbus RTU手头至少要有一个主机调试工具和一个从机调试工具。我一般主机端用Modbus Poll、从机端用Modbus Slave两个工具分别验证链路的两半。关键配置参数就几个串口号、波特率、数据位8、停止位1、校验方式None/Even/Odd、从机地址、寄存器起始地址和读取数量。一个小技巧先把波特率降到9600来抓问题。波特率越低信号质量对线缆要求越宽松如果9600稳定、115200不稳定基本确定是物理层信号质量问题而不是程序逻辑问题。等把物理层修好再把波特率调回115200。那些在线“hex转float工具”也很管用抓包抓到3F 80 00 00别去脑内心算直接粘工具里转成1.0能少烧不少脑细胞。我当时就是靠这些工具确认了寄存器顺序才没去改从机的物理代码。一点收尾的体会这次板子的经验核心就两条旋转开关省IO靠的是ADC分压加阈值区间判定阻值选型时把功耗和ADC采样时间一起考虑进去Modbus浮点传输靠的是对float字节序的明确约定代码反而最简单。如果再让我做一次我会把旋转开关的判定阈值做成可配置项下发到寄存器方便现场适配不同电源电压和电阻精度这会是一个很好的扩展方向。调试遇到“单机都正常、连起来就翻车”的问题时稳下心按物理层、参数层、数据层的顺序逐步排查是最快也最不烧脑的路径。