ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用ADC省IO:旋转开关采集与Modbus浮点传输的嵌入式实战

用ADC省IO:旋转开关采集与Modbus浮点传输的嵌入式实战 1. 需求背景省IO不是抠门是硬需求做嵌入式调试最常遇到的一个尴尬局面就是功能明明很简单但MCU的引脚就是不够用。我这次调试的项目里有一个4档旋转开关要采集状态乍一听很基础——4个档位4个IO口读电平就完事了。但如果你的主控是个引脚紧张的芯片比如STMF103系列的48pin封装或者你在做一个小型传感器节点GPIO基本被外设占满那么为了一个旋转开关掏出4个IO真的是非常奢侈。这时候最常见的替代方案是改硬件——用ADC采样分压电压来判定档位只占1个ADC引脚。听着简单但实际落地的时候有不少坑分压网络怎么设计档位和电压的映射关系怎么保证稳定并联电阻之类的细节有没有踩过这些都要仔细过一遍。而在这个项目里第二个需求也很有意思采集到的数据需要走Modbus RTU协议上报给上位机但Modbus寄存器是16bit的一个float是32bit怎么把一个带小数点的数塞进寄存器这就涉及到float的拆分与还原。两个问题叠在一起恰好是嵌入式日常开发中很典型的“资源约束型设计”和“通信协议数据处理”的结合场景。我这次把整个调试过程梳理了一遍写下来给后面做类似需求的朋友参考。2. 4档旋转开关的ADC采集方案设计2.1 为什么要用分压而不是直接读电平先说回IO采集的问题。一个旋转开关如果档位不多最直接的做法是每个档位接一个IO旋转到哪个档位就拉低哪个IOMCU读电平状态。这种方案逻辑简单代码也不需要什么复杂判断。优点是响应快缺点只有一个——占用IO多。但很多做设备的老工程师会告诉你能用模拟量的地方就不要用数字量去堆IO。因为一旦你用ADC采样一个引脚能同时识别多个状态省下来的IO可以接按键、接LED、接传感器给后续功能扩展留出空间。4档旋转开关用1个ADC引脚替代4个普通IO省3个引脚看起来不多但如果你同时做两组开关省6个引脚意义就很明显了。很多低成本MCU选型的时候引脚数量直接决定价格档位可能省几个IO就能从44pin降到32pinBOM成本直接下降这才是省IO真正的意义。2.2 分压网络的具体接法我的用法是这样的旋转开关的公共端接到VCC4个档位分别接不同阻值的下拉电阻到GND再从公共端引一根线到MCU的ADC引脚。这样旋转到不同档位时ADC引脚读到的电压就不同。标准的分压公式是Vout VCC * R_down / (R_up R_down)但要注意这个公式里的R_up是上拉电阻如果旋转开关公共端直接接VCC那就是0欧上拉R_down上的电压就是VCC分不出差别。所以正确做法是VCC和开关公共端之间串联一个固定电阻R1公共端再分别通过R2、R3、R4、R5下拉到GND。示意图大概是VCC -- R1 -- 开关公共端 -- ADC引脚 |-- R2 -- GND档位1 |-- R3 -- GND档位2 |-- R4 -- GND档位3 |-- R5 -- GND档位4开关转到不同位置时接入不同的下拉电阻和R1组成分压ADC读到的电压随之变化。2.3 阻值怎么选并联电阻的坑这里最大的一个坑就是并联效应。ADC引脚的内阻虽然不是无穷大但通常能达到几十千欧到兆欧级别这对高阻值分压网络影响很大。更常见的问题是当你用万用表去量分压电压的时候表笔本身也有内阻如果你的分压电阻选得太大表笔一搭上去电压就变了。我第一次搭这个电路的时候随手选了四个10k的电阻觉得阻值对称、方便计算。但实际接上去发现ADC读数在档位3和档位4之间区分度不够原因是档位3和档位4之间的电压差太小了。算一下就知道如果R1也是10k四个档位下拉电阻分别是10k、20k、30k、40k那么分压比分别是0.5、0.667、0.75、0.8。档位3和档位4的电压差只有0.05VCC在3.3V系统里就是0.165V虽然ADC理论上能分辨出来但如果你系统里有一点纹波或者ADC参考电压有误差就容易误判。后来我把阻值换成了按等比数列分布的方案R1取10k四个下拉电阻分别取4.7k、10k、22k、47k。这样算下来分压比分别是0.68、0.5、0.31、0.175相邻档位之间的电压差明显拉开了。任何时候都要记住档位映射的电压不是线性分布反而更安全留出足够的电压间隔去对抗噪声。还有一点必须说明——并联电阻的计算。当你把开关打到某一个档位时只有那个档位的下拉电阻接入电路其他档位的电阻是断开的所以不用考虑并联。但如果你用的是一个有多刀结构的旋转开关某个位置可能同时接通两路那就要注意了。正常情况下4档旋转开关是单刀4掷每一档只有一路导通所以这个坑实际上不太会遇到但如果你拿两个2档开关组合出4档就要小心公共端和两个开关同时导通的并联问题。推荐的实际电路参数放在这里方便后面抄作业档位下拉电阻分压比R110kADC读数3.3V参考12bit14.7k0.68约2788210k0.5约2048322k0.31约1270447k0.175约717这个表里的理论值距离比较均匀实测下来即使有5%的电阻误差每个档位的ADC区间也不会重叠判档非常可靠。2.4 代码实现的判档逻辑硬件搭好之后软件上要做的就是一个简单的区间判断。用STM32的HAL库举个例子读ADC的原始值然后做一个区间表#define ADC_THRESHOLD_12BIT 4096 typedef struct { uint16_t min_adc; uint16_t max_adc; uint8_t gear; } gear_map_t; static const gear_map_t gear_table[] { {2600, 3000, 1}, {1900, 2200, 2}, {1100, 1400, 3}, { 550, 850, 4}, }; uint8_t read_gear_switch(void) { uint16_t adc_val read_adc_single(); for (uint8_t i 0; i 4; i) { if (adc_val gear_table[i].min_adc adc_val gear_table[i].max_adc) { return gear_table[i].gear; } } return 0; // 未知状态 }这里有几个细节值得说一下。第一区间判断的阈值绝对不能取理论值的正中间而要留出足够的余量。因为电阻有精度误差ADC有非线性误差电源电压可能有波动区间边界设得太紧温度一变化就可能误判。第二ADC采样建议做多次滤波。旋转开关是机械触点旋转的时候会有抖动如果ADC采样恰好落在抖动瞬间数值可能是不稳定的。我一般会连续采样8次去掉最大值和最小值然后取平均值再去做区间判断效果很好。第三如果系统支持外部中断可以不轮询ADC而是通过一个比较器或者把ADC引脚配置成窗口比较模式只在电压变化跨越阈值时触发中断。不过大部分低成本方案直接轮询就够了看你的实时性要求。3. Modbus中float的拆分与还原原理3.1 一个float在内存里到底是什么样子做嵌入式通信开发天天跟寄存器打交道但很多人对float的存储格式反而比较虚。这里先讲清楚基础。一个float在C语言里占4字节也就是32bit按IEEE 754标准分为三部分1位符号位、8位指数位、23位尾数位。符号位(1bit) | 指数位(8bit) | 尾数位(23bit)比如12.5这个数二进制科学计数法是1.1001 * 2^3那么符号位是0正数指数是3127130也就是10000010尾数是10010000000000000000000。整个32位就是0 10000010 10010000000000000000000具体转换规则可以查IEEE 754的资料不用死记。但你需要理解一点float在内存里是一串连续的字节而这串字节怎么解释成浮点数取决于CPU的字节序和编译器。在STM32这种小端模式的MCU上float val 12.5f;在内存里的样子是低位字节在前。你可以用联合体看一下typedef union { float f; uint8_t bytes[4]; } float_bytes_t; float_bytes_t test; test.f 12.5f; // test.bytes[0] 0x00 // test.bytes[1] 0x48 // test.bytes[2] 0x4A // test.bytes[3] 0x41这就是小端模式。而Modbus RTU协议在大多数工业设备的实现中寄存器传输是大端模式高字节在前。也就是说你在MCU内存里看到的字节顺序和你要往Modbus报文里填的字节顺序很可能是反的。3.2 Modbus寄存器为什么不能直接装floatModbus协议本身的寄存器宽度是16bit一个寄存器只能放一个uint16或者short这是协议定死的。想传一个32bit的float就得拆成两个16bit的寄存器这也是Modbus最常见的扩展用法之一。拆法有讲究。假设我们要传一个float变量在内存里是4个字节Byte0、Byte1、Byte2、Byte3。按Modbus寄存器的规则每个寄存器装2个字节那么寄存器N保存Byte0和Byte1寄存器N1保存Byte2和Byte3这里有一个约定俗成的规则叫“ABCD顺序”。你可以理解为把float的4个字节按从高位到低位的顺序展开然后每两个字节塞进一个寄存器。实际项目里最常见的是“大端寄存器、大端字节序”也就是第一个寄存器的低字节放最高有效字节第二个寄存器的低字节放次高有效字节但不同厂家、不同PLC的做法不一定一致有的用“CDAB”顺序有的用“BADC”顺序。这就是Modbus float通信最常见的坑——两边字节序对不上读出来的数据要么是天文数字要么是NaN。3.3 字节序问题的本质字节序问题本质上是一个“约定”问题。发送方和接收方必须事先约定好float的4个字节以什么顺序放入两个寄存器。我做过的项目里最常见的是两种方式A寄存器高字节在前float高字节在前大端 Byte0 - 寄存器N的高字节 Byte1 - 寄存器N的低字节 Byte2 - 寄存器N1的高字节 Byte3 - 寄存器N1的低字节 方式B寄存器高字节在前float低字节在前小端 Byte3 - 寄存器N的高字节 Byte2 - 寄存器N的低字节 Byte1 - 寄存器N1的高字节 Byte0 - 寄存器N1的低字节如果你的上位机用的是modbus poll或者组态软件里面一般有word order和byte order的设置项本质就是在选择这两种方式。如果两侧设置不一致数据必然错。3.4 我用的拆分还原方法我自己在项目里用的是联合体加位移组合的方式既清晰又不容易出错。核心思路是先把float的4个字节拿出来再按约定的顺序填入寄存器数组。发送方向把float拆成两个寄存器void float_to_regs(float value, uint16_t *reg_high, uint16_t *reg_low) { uint8_t bytes[4]; memcpy(bytes, value, 4); // 按照大端方式填充寄存器 *reg_high (uint16_t)((bytes[0] 8) | bytes[1]); *reg_low (uint16_t)((bytes[2] 8) | bytes[3]); }接收方向还原floatfloat regs_to_float(uint16_t reg_high, uint16_t reg_low) { uint8_t bytes[4]; bytes[0] (reg_high 8) 0xFF; bytes[1] reg_high 0xFF; bytes[2] (reg_low 8) 0xFF; bytes[3] reg_low 0xFF; float result; memcpy(result, bytes, 4); return result; }这里我刻意用了memcpy而不是用强制类型转换指针因为强制转换可能会触发对齐问题在一些RISC架构的MCU上直接异常。另外一个更简洁的写法是直接定义一个联合体作为寄存器映射typedef union { float value; struct { uint16_t reg_high; uint16_t reg_low; } regs; } float_regs_t;这样在Modbus回调里直接操作联合体成员就行代码最精简。但要注意这种写法取决于编译器的结构体对齐规则通常不会有问题但如果你在两个不同架构的MCU之间通信这个联合体的内部布局可能不一样稳妥起见还是用上面的函数方式。4. 大小端问题、调试工具与经验记录4.1 万用表和逻辑分析仪之外的利器硬件部分调试旋转开关我用的工具其实挺普通的一块万用表、一个USB转TTL模块、一个逻辑分析仪。如果你想快速验证分压网络算得对不对不用先接MCU直接拿万用表量开关公共端的电压和理论值对比一下误差在预期范围内再往MCU上接。调试Modbus协议的时候逻辑分析仪是神器。Modbus RTU是异步串口波特率通常9600或115200逻辑分析仪采一下TX/RX两路直接能看到报文内容比对十六进制和协议文档问题基本一目了然。比单纯依赖串口调试助手打印要直观得多。还有一个工具叫modbus poll是PC端模拟Modbus主站的工具用来调试从站设备非常方便。你可以在软件里设置寄存器地址、功能码、数据格式float、int等然后周期性地轮询从站设备观察返回的数据是否正常。我最常用它来验证从站设备上报的float数据在指定字节序下能不能正确解析。4.2 一个典型的字节序错误排查实录有一次做联调从站设备上报的温度值在上位机上显示出来是一个接近零的巨大负数大概类似-5.2e-39这种明显不合理的数字。我当时第一反应是float拆解得不对但查了一遍代码发现逻辑没问题。后来用modbus poll把原始寄存器值dump出来发现两个寄存器的值分别是0x4120和0x0000。手动算一下如果按标准的ABCD顺序这个值应该是0x41200000对应十进制10.0完全符合预期。但上位机显示的是错误值说明上位机在解析的时候用了CDAB顺序——把低16位放在了前面。解决办法很简单要么改上位机的word order设置要么改从站的寄存器填充顺序两边统一即可。这里要提醒的是协议调试遇到float数据异常优先检查字节序八成以上的问题都出在这里而不是算法本身。4.3 旋转开关采集的滤波策略旋转开关是机械触点旋转过程不可避免会有抖动。如果MCU只是偶尔采样一次恰好采到开关切换的中间状态ADC数值可能落在一个没有档位匹配的区间被判为未知状态。更麻烦的是如果开关触点氧化接触电阻变大分压比会发生偏移可能让档位3的ADC读数漂到档位2的判定区间。我在代码里做的处理是连续采样8次去掉最大值和最小值中间6次取平均然后再去查表。实际效果很稳定。手势抖动再严重6次平均之后的数据也不会跳档。另外如果设备对实时性要求不高可以加一个状态“去抖”逻辑——新档位需要连续确认两次才认定生效否则维持旧档位。这个思路和按键去抖是同一个道理。4.4 关于ADC参考电压的稳定性不要小看参考电压对分压采集的影响。很多MCU的ADC参考电压直接取自VCC如果你的系统里有大电流负载比如继电器、电机VCC会波动ADC读数也会跟着漂。这种情况下有两个解决方案一是选用内部参考电压如果有的话让Vref和VCC解耦二是在软件上做归一化每次读ADC之前先读一下基准电压值比如VCC的真实值把ADC结果换算成实际电压。第二个方案在分压采集场景下特别实用因为你的判定区间是电压区间而不是原始ADC区间。uint16_t read_adc_normalized(void) { uint32_t sum 0; for (uint8_t i 0; i 8; i) { sum read_adc_raw(); } uint16_t avg sum / 8; // 读取基准通道计算实际VCC uint16_t vref_raw read_adc_vref(); float vcc_real VREF_INT / ((float)vref_raw / 4095.0f); // 换算成实际电压 float volt avg * vcc_real / 4095.0f; // 然后根据电压值查表判档 return volt_2_gear(volt); }这个方法牺牲了一点性能但换来了很强的抗干扰能力。如果你的设备工作在工业现场电机频繁启停这个归一化处理能极大地减少误判。4.5 Modbus报文中的float还原细节再说回Modbus。很多朋友在做从站设备的时候习惯直接把float的原始内存字节塞进发送缓冲区里省事。但这里有个隐患如果你使用的通信协议栈在半双工模式下发送缓冲区的字节序处理不当或者你对齐控制字的时候没注意大小端就可能出现低字节和高字节互换的情况。更稳妥的做法是像前面那个函数一样用uint16_t为单位先组织好寄存器值然后用Modbus协议栈提供的API把寄存器数组写入响应报文。这样你操作的是逻辑层的数据而不是物理层的字节流不容易出问题。同理接收方向也不要直接对原始报文做memcpy到float变量先把两个寄存器的值解析成uint16_t再通过函数还原float逻辑清晰排查也方便。5. 一个小技巧浮点数传输的精度问题提前算好分享一个我踩过的坑。Modbus的寄存器是16bit两个寄存器组合成32bit刚好能装一个float。但float本身只有23位尾数意味着十进制有效数字大约7位。如果你要传一个类似123.456789的数float存不下这么高的精度你拆出来再还原得到的可能是123.456787。这不是Modbus的问题也不是你拆分代码的问题而是float本身的精度上限。所以在设计通信协议的时候先想清楚这个数到底需要多少精度。如果你是传温度、湿度、压力这类传感器数据精度要求不高float完全够用。但如果你要传一个有严格精度要求的累计量比如电表的累加电量建议改用int32或者直接放大若干倍后传整数。比如保留两位小数就把实际值乘100后转成int32还原的时候再除以100。这种方式在工业现场更常见也完全避开了浮点精度和字节序的问题。我自己现在设计自定义协议的时候凡是能传整数的绝不传浮点凡是能用int32的绝不用float。这算是一个经验之谈吧。6. 关于调试流程的一个总建议这个项目里的两个需求——旋转开关的ADC采集和Modbus float的拆分还原——看似独立实际上是同一个调试验证思想的两种体现先把物理量转换成数值再把数值按照约定的格式在链路上传输最终在另一端还原成可用的数据。调试这类问题我的习惯是分三步走第一步单独验证硬件。不接MCU用万用表量分压电压确认每个档位电压与理论值吻合。第二步单独验证软件。用串口把ADC原始值和判档结果打印出来和万用表的读数对照确认采样与换算逻辑正确。Modbus部分单独写一个测试函数构造已知float值拆卸后再还原断言结果一致。第三步整机联调。硬件和软件都通了之后再把Modbus报文接上用modbus poll之类的工具做端到端验证。这三步走下来一般能快速锁定问题出在硬件还是软件是电气问题还是协议问题。我见过不少人一上来就整个链路联调出了问题到处找最后发现是旋转开关的电阻选得不对导致ADC档位重叠——这种问题放在第一步测电压的时候就能发现。调试的工作中看起来越简单的环节往往越能节省后面的排查时间。分压网络多花十分钟量一下电压可能就避免了一整天的联调排错。这是我做这个项目最大的体会。
返回列表