尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ADC电阻分压实现4档开关省IO采集,及Modbus浮点传输详解

ADC电阻分压实现4档开关省IO采集,及Modbus浮点传输详解 刚把板子从调试台上拆下来趁着示波器探头还有余温赶紧把这几天折腾的东西记下来。这块板子的需求本身不复杂一个4档旋转开关用来切换设备的工作模式另外还要把采集到的温度值通过Modbus上报给触摸屏。但真正动起手来才发现4个档位偏偏只给我剩了一个空闲IO口而触摸屏那边又是点名要读float型的浮点数。这两件事看着八竿子打不着实际做起来都藏着不少细节——一个是“怎么用一个ADC引脚把旋转开关的4个档位读干净”另一个是“float在Modbus寄存器里到底怎么拆分、怎么还原”。这篇笔记就把这两块完整展开从电路选型到代码实现再到联调时踩过的坑一次性讲透。先说结论4档旋转开关省IO采集最终选了ADC电阻分压方案一个ADC通道搞定4个档位float的拆分还原核心是搞清楚IEEE 754格式和Modbus寄存器的字序、字节序关系。这两块内容无论你是用STM32、GD32还是ESP32只要涉及“IO口不够用”和“Modbus传浮点数”都能直接套用。1. 项目背景与整体方案选型1.1 4档旋转开关要解决什么硬件面板上需要用户手动旋转一个开关来选择设备的工作模式常见的就是4档自动、手动、调试、关闭。MCU必须实时知道开关当前拧到了哪个位置并根据档位改变运行逻辑。如果IO资源充裕最土的办法是旋转开关的公共端接地4个分档引脚分别接4个GPIO每个GPIO带上拉电阻。开关拧到哪一档对应那个GPIO就被拉到低电平MCU读电平就知道档位。这是最直接、最可靠的做法代码也就两三行的事。但问题是这块板子绝大部分IO已经被传感器、通信接口、指示灯占满了。PCB Layout时数来数去给旋转开关只留了一个引脚。这种情况下要么改板子要么想办法用更少的IO读更多的状态。改板子不太现实于是只能在采集方案上做文章。1.2 省IO方案横向对比与选型结论“用1个IO读4个开关状态”这个需求行业内有好几种成熟做法我把它们在脑子里的对比整理成了下面这个表格方案所需IO数优点缺点适用场景GPIO直读4个电路简单软件直接实时性好占用IO太多IO富余的板子优先编码器(如74HC148)2-3个读多档位时IO占用较少需要额外芯片4档优势不明显8档以上开关SPI移位寄存器(如74HC165)3个(SCK/LOAD/MISO)可并行扩展大量开关成本高时序复杂需软件模拟十几个按键/开关的输入面板ADC电阻分压1个只用1个模拟引脚成本极低电路简单需要MCU有ADC判档逻辑稍复杂IO紧张但有空闲ADC通道的板子我的场景正好符合最后一种MCU是带12位ADC的常见型号板上就有空闲的ADC通道。用ADC电阻分压方案一个引脚就能覆盖未来扩展更多档位的需求哪怕以后从4档扩到6档乃至8档硬件上只需要补电阻软件上只改阈值表简直不要太方便。这里要特别说明一下ADC方案并不是所有场合都合适。如果系统里没有ADC外设或者ADC通道被占满了那该上编码器还是得上。选型永远是从“手里有什么、缺什么”出发没有绝对最优只有最适合当前约束的方案。2. 4档旋转开关省IO采集设计与实现2.1 电阻分压网络设计ADC方案的电路结构不复杂一个固定上拉电阻R接到电源旋转开关不同档位接通不同阻值的下拉电阻到地ADC采样点就放在电阻网络中间。我最终选定的电阻参数和对应电压如下开关档位下拉电阻阻值理论分压(3.3V供电)12位ADC采样值(约)1档3.3KΩ0.82V10172档7.5KΩ1.41V17503档15KΩ1.98V24574档33KΩ2.53V3140上拉电阻R取10KΩ分压公式是 V_adc VCC × R_dn / (R R_dn)。计算时故意让4个档位的电压在0.8V到2.5V区间内大致均匀分布相邻档位电压差都在0.57V左右。12位ADC在3.3V参考电压下1个LSB约等于0.8mV所以相邻档位之间有700个LSB左右的间距区分度绰绰有余。电阻选值有几个关键点一是不能选得太小否则回路电流偏大增加功耗二是不能选得太大否则ADC输入阻抗可能拉低分压点电压导致采样值偏离计算值。10K级别的电阻在低功耗MCU场景下是比较保险的选择。精度方面这里对绝对精度要求极低只要相邻档位电压落差不至于混淆普通5%精度电阻完全够用。一个很有意思的数学特性12位ADC的采样值是电压与VCC的比例即 ADC_raw ≈ V_adc / VCC × 4096 ≈ R_dn / (R R_dn) × 4096。只要ADC参考电压直接取VCC那么采样值只由电阻比值决定VCC本身的波动会被抵消掉。也就是说电源电压从3.3V掉到3.0V读出来的档位判断结果依然稳定。这个特性让我在实际调试中少了很多麻烦。2.2 档位判定代码实现电路设计好之后软件就水到渠成了。核心逻辑分两步先采样ADC再把采样值换算成电压或直接落到阈值区间里判断当前档位。阈值不能选档位电压的中间值而应该取相邻档位电压的中点。实际计算一下1档0.82V和2档1.41V的中点约为1.11V对应ADC值约13782档1.41V和3档1.98V的中点约为1.70V对应ADC值约21093档1.98V和4档2.53V的中点约为2.26V对应ADC值约2805代码写成这样以12位ADC为例#define ADC_FULL_SCALE 4095U #define VREF_MV 3300U static uint8_t adc_to_switch_level(uint16_t adc_val) { uint32_t mv (uint32_t)adc_val * VREF_MV / ADC_FULL_SCALE; if (mv 1150U) { return 1; } else if (mv 1750U) { return 2; } else if (mv 2350U) { return 3; } else { return 4; } }这里我留了一定的余量比如1档和2档的中点实际是1110mV但代码里用了1150mV多出来的几十毫伏就是为电阻误差和温度漂移准备的。阈值宁可给足余量也不要卡在中间值上否则一旦电阻误差偏大ADC值就可能落在错误区间。实际项目中ADC采样一般不会只采一次就判定。旋转开关这玩意儿是机械触点拧动的瞬间存在抖动触点可能会短暂开路或两个触点同时导通。最稳妥的做法是连续采样多次去掉最大值和最小值后取平均再做档位判定。2.3 稳定性处理滤波、去抖与扩展机械开关在动作过程中会出现短暂的不确定状态具体到ADC分压网络上就是电压会在不同档位电压之间乱跳几毫秒。如果程序在这一瞬间采样可能采到一个“四不像”的电压值既不属于当前档位也不属于目标档位。解决思路分硬件和软件两层。硬件上在ADC采样引脚对地并联一个100nF的电容可以滤除大部分高频抖动毛刺。这个电容对ADC读数有另一个好处它相当于一个低通滤波器能抑制外界电磁干扰串进采样引脚的噪声。很多ADC引脚对源阻抗有要求分压网络源阻抗通常在几千欧姆级别并联100nF完全可行。软件上除了刚才说的多次采样取平均值还要做“档位状态确认”逻辑。我惯用的手法是连续N次读到同一档位值才认为档位切换有效否则维持上一次的有效状态。N取3到5比较合适太少起不到去抖作用太多会拖慢响应速度。#define STABLE_COUNT 3U static uint8_t switch_level 1; static uint8_t same_count 0; void switch_scan_task(void) { uint8_t cur; uint16_t samples[4]; for (int i 0; i 4; i) { samples[i] adc_read_channel(SWITCH_ADC_CH); } uint16_t sum 0; for (int i 0; i 4; i) { sum samples[i]; } cur adc_to_switch_level(sum 2); if (cur switch_level) { same_count 0; return; } if (same_count STABLE_COUNT) { switch_level cur; same_count 0; // 触发档位切换回调切换运行模式 mode_switch_handler(switch_level); } }如果板上还有富余的ADC通道这个方案可以继续扩展每个开关独占一个ADC通道MCU循环扫描即可。如果连ADC通道都不够用还可以外扩CD4051模拟多路复用器用3个GPIO选通8路模拟输入一个ADC通道就能接8个旋转开关。这就属于另一个更复杂的工程问题了这里先不展开。3. Modbus中float的拆分与还原3.1 float在Modbus里的组织形式Modbus协议最早是为PLC设计的它只认识16位的寄存器。一个标准的保持寄存器Holding Register是16位而一个float类型的数据占32位4字节。这就产生了一个最基本的矛盾一个float没法直接塞进一个Modbus寄存器里必须拆成两个寄存器来传。float本身的内存格式遵循IEEE 754标准32位数据分为3个部分1位符号位、8位指数位偏移127、23位尾数位。比如12.34这个数用十六进制表示是0x414570A4按大端字节序排就是41 45 70 A4这4个字节。这几个字节的理解方式是后面所有字节序问题的根源。Modbus的16位寄存器本身在传输时是高字节在前大端模式。但两个连续的寄存器谁存高16位、谁存低16位协议标准并没有硬性规定由设备厂商自行定义。这就导致实际项目中出现了至少4种常见排列方式AB CD、CD AB、BADC、DCBA。我在这篇笔记里主要讨论最常见的两种AB CD顺序第一个寄存器存float的高16位0x4145第二个寄存器存低16位0x70A4。报文里数据字节依次是41 45 70 A4。CD AB顺序第一个寄存器存float的低16位0x70A4第二个寄存器存高16位0x4145。报文里数据字节依次是70 A4 41 45。这两种顺序互换后读出的数据千差万别。原本12.34如果用错了顺序解析出来可能是某个天文数字也可能直接变成一个接近0的极小值甚至干脆是NaN非数值。这里有个特别容易踩的坑很多人以为“我把float拆成两个u16放进寄存器就完事了”结果上位机那边读出来全是不对劲的大数或负数。根本原因就在于“放进寄存器”这个动作你放进去了对方按什么顺序读出来是另一回事AB CD和CD AB必须一致才能对上。3.2 拆分还原的代码实现代码层面float拆成两个16位寄存器有几种写法我把它们都整理出来方便之后写代码时直接选用。第一种用联合体union。这是嵌入式里最常见的写法代码直观typedef union { float f; uint32_t u32; uint8_t bytes[4]; } f32_conv_t; // 将float写入两个Modbus寄存器 // order: 0AB CD(高16位在前), 1CD AB(低16位在前) void float_to_modbus_regs(float value, uint16_t *regs, uint8_t order) { f32_conv_t conv; conv.f value; if (order 0) { regs[0] (uint16_t)(conv.u32 16); regs[1] (uint16_t)(conv.u32 0xFFFFU); } else { regs[0] (uint16_t)(conv.u32 0xFFFFU); regs[1] (uint16_t)(conv.u32 16); } } // 从两个Modbus寄存器还原float float modbus_regs_to_float(const uint16_t *regs, uint8_t order) { f32_conv_t conv; if (order 0) { conv.u32 ((uint32_t)regs[0] 16) | regs[1]; } else { conv.u32 ((uint32_t)regs[1] 16) | regs[0]; } return conv.f; }第二种用memcpy。当你的编译器对union做严格别名优化时这种做法更“标准安全”可读性稍差一点#include string.h uint32_t float_to_u32(float value) { uint32_t u; memcpy(u, value, sizeof(uint32_t)); return u; } float u32_to_float(uint32_t value) { float f; memcpy(f, value, sizeof(float)); return f; }第三种完全用移位和位运算不依赖任何结构体。写起来长但在纯C89环境下可移植性最好。我个人建议嵌入式项目中用union方案就够了代码清晰、效率也高。唯一要注意的是union在跨平台时大小端问题需要考虑但Modbus传输本身就是跨平台场景最终都要显式按字节序组织数据所以只要把“协议字节序”和“本机内存字节序”分开处理union本身不会带来额外麻烦。3.3 字节序变体与调试判断口诀实际联调时经常会遇到“明明代码对上位机读出来还是不对”的情况。这时首先要做的不是改代码而是确认对方上位机软件里选择的浮点格式是什么。Modbus Poll这类工具在查看保持寄存器时有专门的浮点格式选择项常见的就是Float AB CD、Float CD AB这两个选项对应的就是上文说的两种顺序。这种情况下我整理了一条自用的排查口诀读出的数值在合理范围但小数点位置不对、精度异常优先检查寄存器间字序到底是高字在前还是低字在前。读出的数值完全离谱变成亿级大数、负数、NaN或无穷大大概率是字节序整体反了需要调整寄存器数据的字节排列。读出的数值像是两个独立的短整型拼出来的数字说明对方根本没按float解析而是把两个寄存器当成了两个int16读出来了。调试时抓Modbus原始报文是最直接的手段。以Modbus RTU为例读两个寄存器正好是一个float的请求帧是01 03 00 02 00 02 65 CB其中01是从机地址03是读保持寄存器功能码00 02是起始寄存器地址00 02是寄存器数量65 CB是CRC校验。从机返回的响应帧里数据部分长这样假设数据是12.34按AB CD顺序01 03 04 41 45 70 A4 8B 4D其中04表示后面跟4个数据字节41 45 70 A4正好是12.34的IEEE 754大端字节序。看到这4个字节后可以用一个Python一行代码验证import struct print(struct.unpack(f, bytes([0x41, 0x45, 0x70, 0xA4]))[0]) # 输出 12.34000015258789浮点精度误差正常现象如果报文里这4个字节的顺序是70 A4 41 45那对应的就是CD AB顺序。拿到报文后先用这个办法确认字节顺序再去改上位机设置或下位机代码效率会高很多。4. 联调实录问题排查与工具技巧4.1 开关档位偶发误判的排查我这次调试遇到的最头疼问题是旋转开关拧到3档程序偶尔会判断成2档或4档而且不是每次都会复现十次里有那么一两次。先用万用表量了ADC引脚的电压发现静止状态下电压是稳定的和计算值1.98V基本吻合。那问题就出在动态过程上。用示波器接着ADC引脚来回拧开关看到切换瞬间的波形确实有毛刺电压会跳起一个接近VCC的尖峰然后再落到目标值。这个尖峰被ADC采到就可能误判成别的档位。处理办法是双管齐下。硬件上在ADC引脚并了一个100nF电容尖峰毛刺明显变小。软件上把采样次数从单次改成4次平均再加上连续3次一致才切换档位的去抖逻辑。改完之后反复拧了几百次开关误判彻底消失。这个案例给了一个经验机械开关类的输入硬件上做滤波、软件上做去抖缺一不可。如果只图省事在软件里加延时去抖响应速度会变慢如果只靠硬件电容又没法完全消除接触抖动对采样的影响。两者配合才是正解。4.2 Modbus浮点读值乱码排查这个问题的现场症状是上位机触摸屏上绑定的温度显示静止时是几百万度的离谱数值而且还会随机跳动。排查第一步用串口抓Modbus报文看原始数据。结果报文数据字节是04 41 45 70 A4。等一下前面多了一个04这是Modbus RTU响应帧里的字节数标记跟着的三个寄存器数据是高16位41 45、低16位70 A4。单看41 45 70 A4这4个字节按大端float解析确实就是12.34下位机那边发出来的数据是正确的。那问题就出在上位机组态软件的数据解析设置上。打开触摸屏工程里的寄存器配置发现它的浮点格式默认是“低字在前”也就是CD AB顺序而下位机发的是AB CD顺序。两边一个字序相反解析结果自然天上地下。把组态软件的浮点格式改成“高字在前”之后温度值立刻恢复正常。这个问题值得多说一句Modbus协议本身只定义了寄存器的传输格式16位、高字节在前但没有规定“多个寄存器拼接成浮点数时谁在前谁在后”。AB CD和CD AB这两种格式在实际设备中都有厂商使用所以哪怕代码完全按照协议写也大概率会踩到字序不匹配的坑。联调前先把双方协议文档里的数据格式定义翻出来对一遍能省下至少半天排查时间。4.3 调试工具箱与个人心得这一路调试用到的工具不多但每样都不可或缺。串口助手是抓Modbus报文的利器。配置好波特率、数据位、校验位之后能直接看到主机发出来的请求帧和从机返回的响应帧。早期用串口助手人工解析数据帧后面就换成Modbus Poll这类专用上位机工具了。Modbus Poll不仅能自动查询从机寄存器还能按浮点格式显示数据。最实用的功能是它能同时显示原始寄存器值和解析后的浮点值改一下显示格式就能立刻看出字序对不对。16进制和浮点互转的工具也很实用。市面上在线转换器不少但在离线开发环境里我会直接用Python快速验证数据就像上文那段代码一样。只要知道波特率等基础参数抓一帧报文贴进去就能判断字节顺序对不对。关于ADC分压电阻我这次还有一个教训一开始为了贪图简洁直接用了一个100K的上拉电阻结果ADC读数在几个数值之间飘忽不定。原因是STM32的ADC输入阻抗比较高当外部源阻抗超过10K级别以后采样保持电容在采样时间内充不到足够电压导致读数偏移。后来把上拉电阻换成10K配合下拉电阻的并联等效阻抗也在K级别读数才稳定下来。这也解释了为什么分压电阻网络中上拉和下拉电阻的数量级要控制住不能为了省功耗无限加大。最后再分享一个经验凡是涉及Modbus传输浮点数据的项目我会在协议文档里明确写明“浮点数采用IEEE 754单精度寄存器按AB CD顺序排列”并且在实际代码里也把这个顺序写进注释。看似简单的一句话能避免联调时双方扯皮半天。工程上“写清楚”和“做对”同样重要有时候前者比后者更能省时间。这次调试笔记就先写到这里。两次踩坑回头看都是有规律可循的硬件上要理解机械开关的接触特性和ADC采样的阻抗匹配软件上要理解浮点数据在通信协议里的字节组织规则。这些内容单独看都不算难但堆到同一个项目里就容易让人焦头烂额。希望这篇笔记能帮到在做类似项目的朋友少走几步弯路。
返回列表