ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式实战:一个ADC脚读四档开关与Modbus浮点数传输

嵌入式实战:一个ADC脚读四档开关与Modbus浮点数传输 最近在调一块控制板IO口紧张得跟春运火车票似的面板上还非得加一个4档旋转开关用于模式切换。另一边上位机不停发报文要求读取当前温度值走的Modbus RTU但Modbus那套寄存器协议骨子里根本不认识float。这两个问题看着八竿子打不着结果在同一天下午把我按在工位上反复摩擦。这篇笔记就是把4档旋转开关怎么用一个ADC脚读出来和Modbus里float怎么拆开传、怎么还原这两件事的完整思路和踩坑过程记录下来给同样在嵌入式调试路上挣扎的朋友一个参考。先放结论旋转开关的档位识别我用的是ADC分压法——一个模拟输入引脚加三个电阻搞定四档省下一个GPIO给别的功能用浮点数传输本质就是搞清楚IEEE754地32位怎么塞进两个16位寄存器以及上位机到底按什么字节序和字序来解读。1. 4档旋转开关省钱方案的选择两个IO直读为什么被我否了1.1 常见的采集方式对比先说一下背景。这个板子上的MCU是一颗常规的Cortex-M内核芯片GPIO本来就不富裕外设又占了七八个脚剩下几个还要接按键、LED和通信芯片。面板上的旋转开关是用来切换工作模式的四档分别是A/B/C/D应用层需要实时知道当前在哪个档。最直观的做法是2个GPIO接开关旋到不同位置输出两位二进制编码比如00、01、10、11。这个方案代码逻辑极简中断或轮询都能做只要注意上拉电阻别漏焊就行。但问题是两个GPIO被占死后续如果产品改版要加到6档、8档又得再掰一个脚出来而且面板走线多两根结构上也麻烦。第二种方案是用并转串芯片比如74HC165或者PCF8574把开关状态经过移位寄存器或I2C总线读回来。这个方案适合档位特别多、板子上已经有I2C总线的情况。但为此加一颗芯片物料成本、PCB面积、驱动代码全都要跟着上去为4个档位搞这么大动静不划算。第三种方案就是我最终用的电位器式分压——把旋转开关的每一档接到不同阻值的电阻网络上公共端接到MCU的ADC引脚。旋到不同档位ADC引脚上的电压就不一样软件量电压就能反推档位。4档只需要1个模拟输入引脚省了一个GPIO未来扩到6档、8档只需要增加电阻档位网络引脚数不变。这才叫真正的省IO。1.2 我最终采用的分压检测思路分压检测的核心就一句话在同一个节点上用不同阻值的下拉电阻或者上拉电阻把电压拉到不同的电平用ADC去测量这个电平。实际电路我画成了这样VCC(3.3V) ── R_pull(10kΩ) ──┬── ADC引脚 │ 旋转开关公共端 │ ┌──────────────┼──────────────┐ │ │ │ 档位1 档位2 档位3 档位4 │ │ │ │ GND R1(10kΩ) R2(20kΩ) VCC │ │ GND GND开关在档位1时ADC点直接短路到GND电压0V档位2时经过10kΩ电阻到GND与上拉10kΩ分压ADC电压为1.65V档位3时经过20kΩ电阻到GNDADC电压为2.2V档位4时ADC点直接接VCC电压3.3V。于是四个档位对应四个清晰且间隔均匀的电压台阶。这个方案的妙处在于不管旋钮怎么旋ADC引脚上始终有一个确定的电平不像悬空输入那样飘忽不定。而且如果未来需要增加档位只要在开关的闲置触点上再接一个不同阻值的分压电阻软件上多写一条判断分支就够了。这里有一个容易犯的错误很多人图省事直接依赖MCU内部上拉外部只接电阻到GND。内部上拉的阻值在芯片手册里通常给的是一个范围比如30kΩ~50kΩ离散性很大而且随温度漂移也明显。结果就是分压电压不确定判档阈值没法标定。我调试时吃过这个亏换了外部精密电阻后电压档位才稳定下来。2. 分压电路设计电阻取值、电压挡位表与精度分析2.1 分压网络的电压计算选定方案之后核心工作就是算电阻值。我这边VCC是3.3VADC是12位分辨率STM32系列常见但实际使用中考虑到电源噪声有效精度按10位估都够用。假设上拉电阻为R_pull开关某档接的下拉电阻为R_down那ADC点电压就是V_adc VCC × R_down / (R_pull R_down)我选了R_pull10kΩ档位2的下拉R110kΩ档位3的下拉R220kΩ。代入之后的四个档位电压如下表档位回路电阻ADC电压理论值12位ADC采样值1直接GND0V02R110kΩ1.65V20483R220kΩ2.20V27314直接VCC3.30V4095这里有个小细节档位4直接接VCC后上拉电阻R_pull两端都是3.3V没有电流流过所以ADC点就是完整的3.3V不存在分压损耗。唯一要注意的是开关公共端的触点电阻如果开关触点氧化或者接触不良会产生一个额外的小电阻串在回路里导致档位2和档位3的电压略微偏移。所以选旋转开关时尽量挑镀金触点或密封式的普通国产拨码开关在这个场景下能用但长期可靠性要打个问号。2.2 精度分析5%电阻还是1%电阻电阻精度直接影响档位阈值的划分。用5%精度的电阻算一下最坏情况R_pull10kΩ实际可能是9.5kΩR110kΩ实际可能是10.5kΩ那档位2的电压就会变成V_adc 3.3 × 10.5 / (9.5 10.5) 1.7325V反过来R_pull10.5kΩ、R19.5kΩ时档位2电压变成V_adc 3.3 × 9.5 / (10.5 9.5) 1.5675V两者相差0.165V而档位2和档位3的理论间隔只有0.55V。再用同样的方式算档位3两个档位的电压区间边界可能靠得非常近。用5%电阻不是不能用但阈值表必须留足余量而且生产一致性差每块板子的判档边界都不一样后续维护起来头大。我最终选了1%精度的贴片电阻0603封装批发价几分钱一颗成本几乎可以忽略但电压一致性好了很多。实测五块板子同一个档位的ADC值偏差在±15 LSB12位以内阈值余量非常充足。2.3 几个容易翻车的电路细节第一ADC引脚的输入阻抗。现在MCU的ADC输入级通常有一个采样电容采样瞬间会从外部电路抽取电荷。外部源阻抗越小采样误差越小。我选的电阻网络等效源阻抗最大也就10kΩ//20kΩ≈6.7kΩSTM32的ADC手册建议源阻抗低于10kΩ左右才比较稳妥我这个设计刚好压线。第二开关切换瞬间的悬空状态。旋钮开关在换挡时公共端会在两个触点之间短暂悬空。如果没有上拉电阻这个悬空会让ADC引脚电压乱飘如果恰好飘进某个档位的判定区间软件就会误判一次。所以我的电路里保留R_pull10kΩ还有一个作用旋钮换挡过程中ADC点电压被上拉电阻拉到VCC附近软件判断为档位4而不会误判成档位2或档位3。等旋钮稳定接触后再采样就不会有毛刺。如果你用的是那种先断后通的旋转开关这个上拉必不可少。第三VCC的纹波。ADC的参考电压如果直接取自VCC而VCC是开关电源供电纹波会有几十毫伏甚至上百毫伏这直接体现在ADC采样值上。我在VCC和ADC参考脚之间加了一颗100nF的滤波电容软件上再做中值滤波实测纹波对档位判断的影响基本消除了。3. 软件识别档位滤波、迟滞和阈值表的配合3.1 为什么直接区间判断会出问题硬件搭好了软件上最容易犯的错误就是简单粗暴地写一堆if分支if (adc_val 600) { mode MODE_1; } else if (adc_val 2300) { mode MODE_2; } ...这样写的直接后果是旋钮正好卡在档位边界时ADC采样值在两个区间之间反复横跳应用层会看到模式在疯狂切换。尤其是人手拧旋钮的时候根本不可能稳稳定在触点正中央微小的机械振动都会造成接触电阻变化进而导致ADC值波动。另一个隐患是上电瞬间。MCU刚复位时ADC外设还没配置好读取的值可能是随机的就直接进入了某个档位分支。如果档位恰好控制的是一个执行机构那上电瞬间可能会误动作一下。3.2 采样滤波与挡位状态机我的处理方式分三层。第一层是采样滤波。每次判断档位前连续采8次ADC值去掉最大值和最小值剩下6次取平均。这一步能过滤掉大部分随机噪声和电源纹波。8次采样在MCU眼里就是微秒级的事完全不影响实时性。第二层是迟滞比较。所谓迟滞就是进入某个档位的阈值和离开某个档位的阈值不一样留出一个重叠或者说空白区域。比如当前在档位2只有ADC值大于1950才切到档位3只有ADC值小于900才切回档位1。当前在档位3只有ADC值大于2600才切到档位4只有ADC值小于1850才切回档位2。这样即使在边界附近有抖动只要抖动的幅度没有超过整个迟滞窗口档位就不会乱跳。迟滞窗口的宽度要结合电阻精度和采样波动一起看我这边R_pull、R1、R2用1%精度12位ADC的采样波动在最坏情况下不超过±80 LSB所以迟滞窗口设在±150 LSB就足够安全。第三层是防抖确认。采到的新档位不能立即生效必须连续3次采样都落在同一个新档位区间才会真正更新状态标志。这跟按键消抖是一个道理只是把毫秒级的时间窗换成了采样次数。实测下来旋钮快速划过档位2到档位3再回来状态机最多丢掉一次切换事件不会产生中间态误动作。3.3 切换抖动实测这里放一个我实际观察到的数据。用示波器探头点在ADC引脚上人为快速旋转开关可以看到换挡瞬间的波形有明显的下冲和振铃持续时间大概在几百微秒到一毫秒。如果用软件在那段时间去采样读出来的值可能既不是上一档也不是下一档而是中间某个模糊数值。这也是为什么连续3次确认比采样一次就认定要靠谱得多。我的经验是把档位识别做成一个独立的小函数输入是经过滤波的ADC值输出是状态机更新后的当前档位。这样应用层不用关心底层怎么滤波、怎么防抖只要调用这个函数就能拿到稳定的档位值。如果更换了不同阻值的分压电阻只需要改一张阈值表。static const uint16_t adc_thresholds[MODE_MAX 1] { 0, // MODE_1 下边界 900, // MODE_1 上边界 / MODE_2 下边界 1950, // MODE_2 上边界 / MODE_3 下边界 2600, // MODE_3 上边界 / MODE_4 下边界 4095 // MODE_4 上边界 }; uint8_t read_switch_mode(void) { uint16_t adc adc_filtered_average(); static uint8_t current_mode MODE_1; static uint8_t pending_count 0; uint8_t target_mode current_mode; if (adc adc_thresholds[MODE_1_HIGH]) { target_mode MODE_1; } else if (adc adc_thresholds[MODE_2_HIGH]) { target_mode MODE_2; } else if (adc adc_thresholds[MODE_3_HIGH]) { target_mode MODE_3; } else { target_mode MODE_4; } if (target_mode ! current_mode) { pending_count; if (pending_count 3) { current_mode target_mode; pending_count 0; } } else { pending_count 0; } return current_mode; }4. Modbus传输float的底层问题寄存器排列组合与IEEE7544.1 float在内存和寄存器中的本质说完开关来聊第二个问题。Modbus RTU协议里最基础的寻址单位是保持寄存器Holding Register一个寄存器16位。而C语言里的float是32位所以一个float在Modbus里必然要占两个连续的寄存器。这是所有人都知道的前提但恰恰是在这两个寄存器怎么排列上翻车率极高。先回顾一下IEEE754单精度浮点数的内存布局。float总共32位从高到低依次是1位符号位、8位指数位、23位尾数位。例如123.45f这个数在内存中的原始字节序列十六进制是0x42 0xF6 0xE6 0x66如果把这4个字节拼成一个32位数那就是0x42F6E666。不同的CPU架构和编译器可能影响这个字节在内存里的排列顺序但对Modbus来说我们真正关心的是当这个float需要拆成两个16位寄存器时高16位和低16位怎么放。4.2 四种排列组合与上位机兼容Modbus协议规范本身并没有规定float必须怎么拆。于是各个设备厂商各搞各的形成了实际上的几种惯例。如果把float 0x42F6E666拆成两个16位值高位字是0x42F6低位字是0xE666排列上就有两种情况寄存器地址N存高位字N1存低位字通常叫大端字序ABCD顺序寄存器地址N存低位字N1存高位字通常叫小端字序CDAB顺序如果再把寄存器内部的字节序考虑进来排列组合又翻一倍。好在绝大多数现代MCU和上位机组态软件都默认使用大端字节序高位字节在前真正的分歧主要集中在字序上。常见的命名规则顺序名称寄存器N内容寄存器N1内容常见场景ABCD0x42F60xE666大部分PLC、组态王、昆仑通态CDAB0xE6660x42F6某些进口仪表、特定厂家设备BADC0xF6420x66E6少见但某些老式仪表会这样DCBA0x66E60xF642Modbus Poll里也称作Little-endian word swap这里必须强调一个大多数初学者会忽视的坑下位机发送时的顺序和上位机解析时的顺序必须完全匹配而这个匹配关系往往不是靠协议文档而是靠实际测试确认的。不同厂家的上位机软件默认的float解析字序可能完全不同。同一个设备在组态王里显示正常换到另一家触摸屏上就变成天文数字多半就是这个原因。4.3 用123.45演示四种排列的寄存器值拿123.45f也就是0x42F6E666来做个演示。假设它占用Modbus寄存器地址100和101那四种排列下寄存器里的值和最终还原出来的结果如下排列方式寄存器100寄存器101上位机按ABCD解读上位机按CDAB解读下位机按ABCD发0x42F60xE666123.45正确乱值下位机按CDAB发0xE6660x42F6乱值123.45正确所谓乱值具体有多乱如果上位机按ABCD解读CDAB的数据等于把float的尾数高位当成指数、指数当成尾数还原出来可能是一个超大数或者接近0的数。比如0xE66642F6对应的浮点数值大约是-4.9e28一眼就能看出不对但有些情况下乱值看起来也在正常范围内比如把12345.0拆成高低字如果高低字顺序搞反解析出来可能是几百万的量级不是完全离谱这时候反而更难排查。5. 拆分还原代码与现场排错实例5.1 三种实现方式对比明白了排列规则代码实现实际上非常直接。我推荐用联合体union的方式因为代码最简洁可读性最高union Float32 { float f; uint32_t u; uint16_t w[2]; }; // 将两个Modbus寄存器内容还原为float // 参数reg_high对应地址Nreg_low对应地址N1 static float modbus_get_float(uint16_t reg_high, uint16_t reg_low) { union Float32 data; data.w[1] reg_high; // 高16位 data.w[0] reg_low; // 低16位 return data.f; } // 将float拆成两个寄存器值 static void modbus_set_float(float f, uint16_t *reg_high, uint16_t *reg_low) { union Float32 data; data.f f; *reg_high data.w[1]; *reg_low data.w[0]; }这里必须说明一个前提上面的代码假设单片机是小端模式x86、ARM Cortex-M默认都是w[1]对应的是uint32的高16位w[0]对应低16位正好和Modbus的ABCD排列吻合。如果你的下位机是用了大端模式的MCU比如部分AVR或MSP430可以通过配置改成大端那联合体里的下标就要反过来。如果你不想依赖编译器的结构体字节对齐和端模式也可以用纯位运算版本逻辑一模一样static float modbus_get_float_bits(uint16_t reg_high, uint16_t reg_low) { uint32_t tmp ((uint32_t)reg_high 16) | reg_low; float f; memcpy(f, tmp, sizeof(f)); return f; }位运算版本的好处是即使在各种编译优化选项下行为也完全确定坏处是不如联合体直观。有经验的工程师会用memcpy来避免C语言的类型双关type punning未定义行为这一点在写库代码给别人用的时候尤其重要。5.2 FreeModbus移植时的接入位置如果你的项目和我一样是在FreeModbus上做移植那拆分还原逻辑通常放在eMBRegHoldingCB这个回调函数里。这个回调函数的职责是当主站发起读保持寄存器请求时从你的寄存器数组里取数据返回当主站发起写保持寄存器请求时把收到的数据写入寄存器数组。实际做法是在MCU内部维护一个uint16_t regs[10]之类的数组float变量不直接暴露给Modbus主站而是通过拆分函数把值写进这个数组。比如// 把内部温度值同步到Modbus寄存器 float temperature read_temperature(); modbus_set_float(temperature, regs[0], regs[1]); // 主站读温度时FreeModbus直接把regs[0], regs[1]发出去反过来如果主站要下传设定温度FreeModbus会把收到的两个寄存器值写入regs[2]和regs[3]然后在协议栈空闲时float setpoint modbus_get_float(regs[2], regs[3]); apply_setpoint(setpoint);这样一来通信层的逻辑和业务层的逻辑完全解耦主站无论什么时候来读拿到的都是最近一次同步过的值。5.3 一个真实排查过程上位机读出几百倍的数值最后分享一个我实际遇到的排错案例。设备连上上位机后温度显示成了11958.3而实际上现场温度只有25.6。第一反应是校准系数错了但核对了好几遍EEPROM里的参数没有问题。然后把Modbus Poll当作调试工具接上去直接读原始寄存器值。发现地址0和1两个寄存器的内容是寄存器0: 0x41CC 寄存器1: 0xCCCD用计算器验证0x41CCCCCD这个32位数正好就是25.6f的IEEE754表示说明下位机发出的数据本身完全正确。那问题就出在上位机的解析配置上。进入上位机的变量配置界面找到这个温度点的数据类型发现它确实选了float但字序word order默认是Little-endian也就是按CDAB解读。改成Big-endian后重新连上温度显示立刻变成25.6。这次的教训是排错时不要一头扎进下位机代码里翻半天先把数据链路拆开看每一段的真实数据。用Modbus Poll这类工具直接看原始寄存器值能迅速定位问题是出在下位机的发送端、中间链路还是上位机的解析端。如果手头没有现成的调试工具也可以临时写个串口助手脚本用Python的struct模块把收到的寄存器字节拼起来转一下浮点数同样能验证。这里顺便提醒一句通信协议联调时永远先看原始报文再谈解析结果顺序不能反。最后分享一个我自己的小习惯把所有Modbus寄存器地址、数据类型、字序约定整理成一个公共头文件写清楚每个float量的地址映射和排列规则然后让下位机开发和上位机开发共用同一份文档。这样一来即使频繁改动寄存器分布也能互相核对不至于各拍脑袋导致联调时候互相扯皮。
返回列表