ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

节省GPIO的旋转开关ADC采样方案与Modbus浮点传输避坑指南

2026/9/6 14:10:16 拓冰建站 浏览量
节省GPIO的旋转开关ADC采样方案与Modbus浮点传输避坑指南 1. 一个IO口不够用的烂摊子最后怎么收场的前阵子接了个小项目面板上要加一个4档旋转开关用来切设备的工作模式。单看这个需求一点不复杂麻烦的是主控板已经定死了用的是一颗引脚快被占满的单片机UART、SPI、I2C、几个按键、数码管扫描、指示灯全算上GPIO只剩俩。旋转开关如果走常规思路——每个档位分配一根IO去读电平4档就得4个IO这板子根本没戏。有人可能会说用MUX啊用译码器啊用IO扩展芯片啊。这些方案当然能解决IO数量的问题但都绕不开一件事要加器件。项目已经走到调试阶段PCB没空间BOM不想动加芯片意味着改板、改物料、重新焊接周期至少一周起步。我当时坐工位上盯着原理图看了半天突然想到另一个路子为什么不把档位变成电压用ADC去读一颗电阻一颗电容加上单片机上一路本来就富余的ADC通道4档旋转开关就能被压缩成一根采样线。硬件改动几乎为零软件上做一下滤波和判断就完事。这就是4档旋转开关省IO采集的基本思路用模拟量承载数字信息用ADC通道换掉三根GPIO。当然这事后面还牵扯出了另一个问题——设备里有一堆温度、压力、速度数据要往外发走的Modbus RTU浮点数是强需求而Modbus寄存器又只认16位整型。float怎么塞进两个寄存器又怎么从两个寄存器里还原出来这在调试时也折腾了我一阵。今天这篇笔记就把这两件事一起整理出来。2. 旋转开关省IO不是省了IO而是把数字问题变成了模拟问题2.1 常规方案的成本账先列一下常规方案各要付出什么代价方便后面对比。方案一每个档位接一根IO四档开关常开或常闭触点分别接到4个GPIO程序里读电平判断。问题在于IO占用量大而且旋转开关的档位如果带中间停位你还得考虑几个档位之间的互锁逻辑输入上拉到开关侧还是MCU侧也得斟酌。这种方案最直接但直接不等于划算——在IO紧张的板上4个IO的代价太大了。方案二IO扩展芯片典型的是PCF8574这类I2C扩展8路输入输出一颗芯片解决问题。但引入新IC意味着I2C地址配置、上拉电阻、去耦电容、PCB走线全都要跟着动。对量产产品来说每加一颗芯片都是真金白银哪怕只有几毛钱乘上产量就不是小数字。而且I2C总线本身要是已经挂了一堆设备还得小心地址冲突。方案三移位寄存器74HC165并行转串行逻辑简单但需要额外的时钟线、数据线、锁存线驱动代码还得写一套。调试串口本来就要用再挤两根线出来也肉疼。方案四ADC分压采样每个档位串不同阻值的电阻和一颗固定电阻组成分压网络ADC读到的电压随档位变化。一根线搞定一切。四档开关用分压方案硬件上只需要在开关端加两三颗电阻软件上写个读ADC并查表的函数优势非常直观。2.2 为什么模拟化往往是嵌入式里的妥协最优解接触嵌入式时间长了你会发现一个规律很多看似不严谨的方案反而在工程上活得很好。数字信号理论上最抗干扰但代价是IO数量、通信带宽、电路复杂度。模拟信号理论上容易受干扰但只要信噪比和判定区间设计得当现场一样跑得稳。ADC采样电阻分压判断档位本质上是用电压值做编码。这个编码有一个前提——每个档位的电压必须有足够的间隔容得下电阻精度误差、电源纹波、ADC量化误差、温度漂移这些因素叠加出来的偏差。档位电压间隔设计得好整个系统容错率就高设计得不好临界点上跳来跳去是必然的。我在实际设计时给4个档位分配的目标电压大致是档位目标电压分压比说明1接近0V直接接地或极小分压2约1/3 VCC间隔拉得比较大3约2/3 VCC避开中点减少误判4接近VCC直接接VCC或极大分压关键点是档位2和档位3不能刚好落在1/2 VCC附近。中点电压附近是最危险的地方因为一旦有几十毫伏的纹波ADC采样值上下抖动档位判断就会在2和3之间反复横跳。把档位人为错开判定可靠性会明显提升。2.3 分压电阻的取值计算假设VCC是3.3VADC是12位理论分辨率大约是0.8mV/LSB。实际使用中因为电源噪声和参考电压精度稳定分辨率能做到5mV级别就算不错了。我的取值思路是固定一颗10kΩ下拉电阻R1到GND旋转开关的公共端接VCC四个档位分别接不同阻值的电阻到公共端。四档对应的电阻值分别是R2a、R2b、R2c、R2d分压点电压就是V_adc VCC * R1 / (R1 R2x)我选择了4个档位的电阻为0Ω直通、3.3kΩ、10kΩ、∞悬空。对应的电压是档位R2xV_adc理论12位ADC读数近似10Ω3.30V409523.3kΩ2.48V3076310kΩ1.65V20484∞悬空0V0等等这里出现了一个问题——档位3刚好是1.65V正好是VCC的一半就是我前面说最危险的位置。实际调的时候我改了一下把三档的电阻改成15kΩ电压变成1.24V左右ADC读数约1536。四档之间就错开了4095、3076、1536、0档位间隔都在几百到一千个LSB完全够用。这个例子说明一个道理理论计算只是起点设计时必须把最坏情况考虑进去。1/2 VCC虽然看起来好算但不是好工程。3. ADC采样与档位判定的工程化实现不只是读个数值那么简单3.1 硬件注意点RC滤波和参考电压ADC采样线不能直接一根线拉过去就完事。旋转开关是机械触点拨动瞬间会产生抖动电压会毛刺不断。我的做法是在ADC采样点对地加一颗100nF的电容做硬件滤波和分压电阻形成一个低通滤波器把高频噪声直接滤掉。如果现场电磁环境比较复杂可以换成1μF电容代价是响应变慢但机械开关本身操作频率很低慢一点无所谓。另外一个隐含问题是ADC的参考电压。很多单片机内部参考电压并不精准不同芯片之间能差个百分之几。如果产品有多个批次同一档位在不同板子上的ADC读数可能差不少。我建议把判定阈值设成比例值而不是绝对ADC值。比如直接用ADC读数/满量程这个比值和预设区间比较这样参考电压的影响就被抵消了因为分压比才是核心。3.2 软件滤波中值滤波和滞回判断一杯水的抖动你喝一口就知道烫不烫但ADC采样值每秒跳几十次你每次跳都去判断档位系统就会不稳定。我的做法是两层处理第一层采样滤波。每次要判断档位时连续采样5次去掉最大值和最小值剩下3次取平均。这种方式能过滤掉大部分随机的尖峰干扰。如果对实时性要求更高可以改成滑动中值滤波但在旋转开关这种低频场景里简单粗暴的排序取中位就够用了。uint16_t adc_filtered_read(void) { uint16_t samples[5]; uint16_t tmp; int i, j; for (i 0; i 5; i) { samples[i] adc_read_single(); } /* 冒泡排序只排前4个最后一个就是最大值 */ for (i 0; i 4; i) { for (j 0; j 4 - i; j) { if (samples[j] samples[j 1]) { tmp samples[j]; samples[j] samples[j 1]; samples[j 1] tmp; } } } /* 去掉samples[0]最小值和samples[4]最大值取中间三个的平均 */ return (uint16_t)(((uint32_t)samples[1] samples[2] samples[3]) / 3); }第二层滞回比较。这是整个档位判断里最核心的部分。如果只设定一个固定阈值ADC读数在阈值附近抖动档位输出就会反复横跳。解决办法是设置两个阈值——进入某档需要高于高阈值离开该档需要低于低阈值。这样形成一个滞回区间采样值在区间内抖动时档位不会变化。以档位2为例ADC理论值约3076。我设置高阈值为3200低阈值为2950。只有读数超过3200才认为进入档位1注意这里档位顺序要看电压大小方向只有读数低于2950才认为离开档位2。中间这250个LSB的宽度就是用来吸收噪声的缓冲带。uint8_t read_switch_position(void) { uint16_t adc_val adc_filtered_read(); static uint8_t current_pos 0; switch (current_pos) { case 1: /* 当前认为在档位1只有读数跌到低阈值以下才切换 */ if (adc_val 3500) current_pos 2; break; case 2: if (adc_val 3800) current_pos 1; else if (adc_val 2400) current_pos 3; break; case 3: if (adc_val 2800) current_pos 2; else if (adc_val 800) current_pos 4; break; case 4: if (adc_val 1200) current_pos 3; break; default: current_pos 2; break; } return current_pos; }这段代码的典型特征是用了静态变量记住上一次状态。状态机只有在读数超过边界时才发生迁移否则维持原状。这种模式在按键消抖、编码器判断、传感器阈值检测里都能用建议直接收藏。3.3 实测中遇到的假档位问题实际调试时我发现一个挺坑的现象旋转开关拨到某个档位时偶尔读数会突然跳到另一个档位的区间但过几十毫秒又恢复正常。示波器一抓问题出在机械触点上——开关在拨动后并没有立刻完全贴合而是有一个短暂的弹跳接触电阻变化过程持续几毫秒到十几毫秒。软件上除了加滤波还可以做一个档位确认机制连续两次读到的档位一致才认为有效。在Modbus上报中这个历史的档位值还可以带一个短延时比如拨动后50ms再上报给开关一个稳定时间。事实证明机械结构的问题用软件延时兜底比单纯依赖硬件触点质量更现实。4. Modbus里float的坑寄存器不认识浮点数但你的数据是4.1 IEEE 754浮点数一次说清楚旋转开关的问题解决后紧接着是数据上报。设备里有几路模拟量需要走Modbus上传比如温度、电压、压力全是浮点数。Modbus协议里寄存器是16位一个单位一次最多读写16位float是32位一个float必须拆成两半塞进相邻的两个寄存器里。float在内存中的存储遵循IEEE 754标准32位float的结构是SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM S1位符号位 E8位指数位偏移127 M23位尾数位举例12.5这个数先把12.5转成二进制1100.1规格化1.1001 × 2³符号位0指数3127130即10000010尾数是10010000000000000000000拼起来01000001010010000000000000000000十六进制0x41480000也就是说float在内存里就是4个字节顺序是低位字节在低地址小端模式十六进制表示0x00 0x00 0x48 0x41从低地址到高地址。这个字节序后面是验证Modbus收发数据高低位是否正确的关键。4.2 float拆分与还原几种实现方式方式一指针强制转换最省事但可读性差uint16_t reg_high, reg_low; float temp 12.5f; uint8_t *p (uint8_t *)temp; reg_low (p[0] 8) | p[1]; /* 低位寄存器 */ reg_high (p[2] 8) | p[3]; /* 高位寄存器 */这段代码的逻辑是把float的内存字节按小端顺序取出低16位放进第一个寄存器高16位放进第二个寄存器。这种方式简洁但直接依赖平台字节序。如果你的平台是大端模式结果就完全反了。而且p[0] 8这种写法有潜在问题——如果char是有符号的移位时会做符号扩展。我建议把p声明成uint8_t *确保无符号。方式二共用体union方式我最推荐的typedef union { float f; uint16_t u16[2]; uint8_t u8[4]; } float_reg_t; float_reg_t ft; ft.f 12.5f; /* 此时 ft.u16[0] 是低位寄存器的值ft.u16[1] 是高位寄存器的值 */union的妙处在于内存里的同一块空间用float解释就是12.5用uint16数组解释就是两个寄存器值用uint8数组解释就是4个字节。Modbus填寄存器时直接取出ft.u16[0]和ft.u16[1]收到寄存器后直接ft.u16[0] reg0; ft.u16[1] reg1;然后读ft.f就还原出浮点数。不过要提醒一下union方式同样依赖平台的字节序和大小端。在x86、ARM Cortex-M这些主流小端平台上u16[0]对应低16位u16[1]对应高16位这个和Modbus协议中高字节在前的字节序是两回事后面会讲。方式三移位组合显式控制最不依赖平台float bytes_to_float(uint8_t b0, uint8_t b1, uint8_t b2, uint8_t b3) { uint32_t bits ((uint32_t)b0 24) | ((uint32_t)b1 16) | ((uint32_t)b2 8) | (uint32_t)b3; float f; memcpy(f, bits, sizeof(f)); return f; }这种方式完全不关心平台字节序你告诉它哪个字节在高位它就按哪个字节在高位组合。缺点是代码稍长但是移植性最好跨平台、跨编译器都不会出问题。三种方式怎么选我的经验是裸机程序自产自销union最方便代码要跨平台、要上RTOS、要给多个MCU用移位memcpy方式最稳赶工期、调试验证用指针转换但记得用uint8_t *4.3 寄存器高低字节顺序搞反了数据直接变成天文数字这是Modbus报文先看一串实际抓的帧数据。用Modbus Poll工具读取从站地址0x01的保持寄存器起始地址0x0000数量201 03 00 00 00 02 C4 0B 请求帧 01 03 04 41 48 00 00 7A 4B 响应帧响应帧里寄存器数据是41 48 00 00这4个字节组合起来是0x41480000正是12.5的IEEE 754表示。这里能看到Modbus RTU的字节序规则一个寄存器16位内部是高字节在前也就是第一个字节是41第二个是48。但这里有个极易踩坑的地方float拆分后到底是哪个寄存器存高位哪个存低位Modbus协议本身并没有规定float在多个寄存器里的排列顺序实际由设备厂商自己定。主流约定是高字在前Big-Endian ordering第一个寄存器存float的高16位第二个寄存器存低16位。对应到上面12.5的例子第一个寄存器就是0x4148第二个是0x0000。如果你的程序里按照低字在前去拆那收到的float就会变成0x0000 0x4148 → 内存里是 00 00 48 41小端下 → 数值约1.11e-38一个正常温度值突然变成接近于0的极小数字或者变成1.4e-45这类非规格化数第一反应就是字节序反了。这个坑我踩了一次就记住了现在写代码前先确认协议文档里有没有明确寄存器字节序没有就默认按高字在前处理然后再用Modbus Poll或者串口助手实测验证一遍。5. 联调、验证和那个让我浪费一下午的float 0.1问题5.1 轮询工具怎么配合验证写完了拆分和还原的逻辑一定要用工具做闭环验证不能直接在设备之间传。我习惯用Modbus Poll当主机还可以用虚拟串口把Modbus Slave挂在电脑上模拟从机。验证路径分两步第一步设备端发电脑端收。把设备的某个寄存器地址设为浮点数据12.5用Modbus Poll去读。如果结果正好显示12.5说明字节序对如果显示奇怪的小数或天文数字十有八九是顺序问题。我专门做了一个小工具函数把收到的两个寄存器值打印成十六进制人工核对是不是41 48 00 00——这种原始数据的核对比看十进制浮点结果直观得多。第二步电脑端写设备端收。用Modbus Poll的写功能往设备寄存器写入12.5然后设备把收到的float值通过串口打印出来。如果打印12.50说明还原正确打印别的就有问题。调试Modbus最好全程保证参数一致波特率、数据位、校验位、停止位四者有一点不匹配收到的就是乱码。我手头备一个USB转485的小工具抓报文用这比光看调试串口输出要有用得多因为能看到最底层的字节。5.2 名义上是12.5读回来变成12.500001的真相联调中会遇到一个很有迷惑性的现象设备写出的数据是12.5Modbus Poll读到后显示12.500001或者反过来——写入12.4设备端收到后打印成12.399999。这不是字节序问题也不是通信错误。这是IEEE 754浮点数的二进制精度问题。12.4这种小数在二进制里无法精确表示只能用最接近的二进制浮点值来表示而那个最接近的值转换成十进制显示时就变成了12.399999或12.400001之类的数。处理办法要看应用场景如果数据用于显示在界面上取1位或2位小数显示层直接四舍五入比如格式化%.1f。如果数据用于运算和控制那就接受浮点的微小误差运算时用误差带比较比如判断温度是否达到目标值允许±0.05℃的偏差。如果对精度要求极高比如电量累积建议改用定点数数据乘以100转成整数再传接收端再除以100。很多成熟设备都是这么干的简单可靠。5.3 我调试中实际用的拆分还原参考代码最后放一段我实际在STM32上验证过的代码完整演示float拆分进寄存器、从寄存器还原float。注意我用的是union方式配合大小端检测宏。#include string.h #include stdint.h typedef union { float f; uint16_t u16[2]; uint8_t u8[4]; } float_reg_t; /* 检查平台大小端返回1为小端0为大端 */ static int is_little_endian(void) { uint16_t x 0x0001; return *((uint8_t *)x) 0x01; } /* 把float拆成Modbus寄存器值out[0]为高位寄存器out[1]为低位寄存器 */ void float_to_modbus_regs(float value, uint16_t out[2]) { float_reg_t ft; ft.f value; if (is_little_endian()) { /* 小端下u8[0]是最低字节u8[3]是最高字节 按Modbus高字在前out[0] 高16位 u8[2]8 | u8[3] */ out[0] (uint16_t)((ft.u8[2] 8) | ft.u8[3]); out[1] (uint16_t)((ft.u8[0] 8) | ft.u8[1]); } else { /* 大端下直接取前两个字节和後两个字节 */ out[0] ft.u16[0]; out[1] ft.u16[1]; } } /* 从Modbus寄存器还原floatregs[0]为高位寄存器regs[1]为低位寄存器 */ float modbus_regs_to_float(const uint16_t regs[2]) { float_reg_t ft; if (is_little_endian()) { ft.u8[0] (uint8_t)(regs[1] 0xFF); ft.u8[1] (uint8_t)(regs[1] 8); ft.u8[2] (uint8_t)(regs[0] 0xFF); ft.u8[3] (uint8_t)(regs[0] 8); } else { ft.u16[0] regs[0]; ft.u16[1] regs[1]; } return ft.f; }这段代码有一个好处不管平台大小端输出到Modbus总线的字节顺序都保持一致也就是高字在前每字高字节在前这是工业设备里最常见的约定。如果你的设备协议明确要求低字在前把out[0]和out[1]换一下位置就行。5.4 用Modbus Poll按F8切换显示格式一眼看穿数据对不对Modbus Poll默认以无符号整数显示寄存器值看float之前在单元格上右键把显示格式切换成float它自己会按IEEE 754解析两个相邻寄存器。但这里有个大坑——Modbus Poll的float解析默认也是按高字在前处理的但不同版本的显示选项不一样。我遇到过一个情况设备是按高字在前发的Poll显示正常另一个设备按低字在前发的Poll显示就乱了。手动在设备端把两个寄存器的顺序互换一下再读一次就好了。所以联调时强烈建议先看原始十六进制再切换float显示。具体操作是在Modbus Poll的寄存器值区域右键选择Set Display Format里的HEX十六进制确认读到的字节是否跟自己预期一致。字节对浮点数显示那就顺理成章字节错再怎么调显示格式都没用。6. 总结一下我这套方案的取舍和几个能直接抄的小技巧针对标题里的两个问题我的最终解决方案可以浓缩成三句话旋转开关档位用ADC采样用电阻分压把档位编码成电压再用中值滤波和滞回比较做数字端防抖Modbus传float就是把4字节按IEEE 754拆成两个寄存器值发送和接收要严格保持同样的字节序约定调试时永远不要直接看十进制浮点结果要看原始十六进制帧字节对了再谈数值。具体几个可以抄作业的要点分压电阻选择固定电阻10kΩ档位电阻0Ω、3.3kΩ、15kΩ、悬空对应的ADC读数约4095、3076、1536、0。如果板子供电不稳用比例阈值而不是绝对阈值判断。软件防抖分层连续采样5次去掉最大最小取平均状态迁移用滞回比较档位确认连续两次一致才生效。这三个层次叠加起来我在实际设备上跑了一周没出现过一次误判。float通信的字节序上位机、下位机、协议文档三方对清楚默认按高字在前实现。发送和接收用同一套规则千万别各写各的。验证步骤写完一定要用Modbus Poll二次验证先看十六进制原始值再切换float显示两边数据对上了才算真正跑通。最后分享一个调试习惯我写完浮点收发之后会塞一个自测函数进去往自己发自己收校验还原出来的数值和原始值之差是不是在允许误差内。这一步放在开机自检流程里现场运行谁动过参数、谁改过寄存器设置一查自检日志就能定位。调试笔记写到第6篇越来越觉得嵌入式这行拼的不是谁懂的高深技巧多而是谁能把字节序对不对小数点后差一位算不算错这种基本功抠得细。细节抠到位系统跑起来就扎实。