拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32省IO实战:ADC分压旋钮识别与Modbus浮点数传输

公司最近有一台现场设备要升级固件MCU的引脚资源本来就不宽裕还得加一个4档旋转开关用来切换设备运行模式同时上位机那边又希望通过Modbus RTU直接读到浮点型的温度值。这两个需求单看都不难难的是它们凑到一起后硬件上没法再给每个档位单独分配一个IOModbus协议里又只认16位寄存器float塞不进去。这篇笔记就把这两块的实际处理过程完整记下来包括电阻分压选值计算、ADC采样消抖、float在Modbus里的字节序拆分和还原以及调试时踩过的几个坑给后面做类似东西的朋友一个参考。1. 项目背景与整体设计思路1.1 为什么4档旋钮会牵扯到IO资源问题先交代一下现场的具体情况。设备主控用的是STM32F103系列板子之前设计时已经把一个GPIO口用在了状态指示灯上另一个被模拟串口占用剩下能自由分配的IO几乎见底。现在要增加的旋转开关是4档位用来选择“自动模式”“手动模式”“校准模式”“待机模式”这四种工作状态。如果按最常规的做法每个档位接一个IO口检测4个电平状态至少需要2到4个GPIO这在当前板卡上确实拿不出来。重新改版加IO扩展芯片也不是不行但为了一个旋钮去改硬件、改BOM、重新打样周期和成本都不划算。这时想到的方案是把4个档位接到一个ADC引脚上通过不同阻值的电阻网络让每个档位在ADC引脚上产生一个不同的分压值MCU通过采集电压来区分当前是哪个档位。这样一来只需要占用一个带ADC功能的引脚就能实现4档甚至更多档位的识别IO占用一下子从4个降到了1个。这个思路在工业设备里其实很常见比如一些温控器、变频器的面板旋钮就是这么干的但实际做的时候有几个关键点容易被忽略。一是电阻分压值的设计要留出足够的余量不能理想化地取几个电压点就完事二是ADC采样的稳定性现场环境有电机、继电器电磁干扰会让ADC值抖动必须做软件滤波三是档位切换瞬间的毛刺问题旋钮是机械触点切换过程中会经过中间位置程序要能在时间上过滤掉这些中间状态。这套方案的实际收益很直接省了3个IO板子不用改版一个电阻网络加上软件判断就搞定了全部功能。1.2 Modbus寄存器和float天然不对齐的矛盾另一个需求来自于上位机。现场的上位机组态软件通过Modbus RTU协议和MCU通信需要读取当前的温度值。温度是浮点数比如25.6摄氏度在C语言里是4字节的float类型。但Modbus协议的数据单元是寄存器一个寄存器16位所以一个float必须拆成两个寄存器来传也就是4个字节分装到两个16位寄存器里。这里就会遇到一个经典问题float拆成4个字节后先发哪个字节后发哪个字节两个寄存器之间谁在前谁在后不同设备厂商的Modbus实现各不相同有的按照大端模式传输有的按照小端模式传输还有的更特殊字节序正常但寄存器序是反的。如果发送端和接收端的约定不一致上位机读出来的就是一个毫无意义的大数或者极小的数感觉像是数据乱码了。所以这一节要解决的核心事情有两件第一MCU端把float拆成两个寄存器的具体方法包括用union、指针、移位三种方式第二字节序和寄存器序的定义以及和上位机对齐的技巧。在做具体实现之前先把串口和Modbus的地址规划好。我这边用的Modbus从站地址是0x01温度值映射到保持寄存器地址0x0000和0x0001分别存float的低16位和高16位。这个地址映射方式后面还要专门说因为不同协议的地址偏移规则经常让人踩坑。2. 旋转开关省IO采集的电路设计与阈值计算2.1 电阻分压网络的电路形式和选型依据电路结构其实很简单就是标准的电阻分压。一个固定的上拉电阻接到基准电压另一端接ADC采样点采样点再通过不同档位电阻接到地。旋转开关转动时切换接入不同阻值的电阻ADC采样点的电压随之变化。假设基准电压是3.3V上拉电阻R1取10K欧姆四个档位分别接入R2、R3、R4、R5到地对应的电压公式是Vadc VREF * R档 / (R1 R档)这里R档是当前档位接入的电阻值。需要注意的是当某个档位直接短路到地时R档为0Vadc就是0V当某个档位开路时Vadc就等于VREF。实际上4档设计中可以安排一个档位直接短接到地再安排一个档位开路中间两个档位用电阻分压这样3个电阻就能实现4档进一步减少元件。我实际用的配置如下档位R档阻值理论Vadc12位ADC读数理论档位10欧姆短路0V0档位23.3K欧姆0.816V1013档位310K欧姆1.65V2048档位4开路3.3V4095R1选择了10K原因有三。第一10K阻值适中电流只有零点几毫安功耗可以忽略不计第二和3.3K、10K的档位电阻组合分压点正好落在ADC量程的1/4、1/2、3/4附近区间划分均匀第三电阻值在千欧级别对走线阻抗和寄生电容不敏感抗干扰性能比直接用兆欧级电阻好。2.2 ADC读数区间的划分和阈值确定理论值算出来了但不能直接拿理论值去判断档位因为电阻有精度误差ADC参考电压也有偏差现场温度变化还会让电阻值漂移。所以工程上要划出判断区间而不是判断单点。我第一次做的时候就是直接把0到200、900到1100这种区间写死在代码里结果产品在冬天和夏天分别测出来的ADC值差了40多个数差点误判档位。后来学乖了区间设计全部按“相邻两档电压差的中点作为边界”来算。以档位1和档位2为例档位1的理论ADC值是0档位2是1013那么判断边界就取(01013)/2506也就是说ADC采样值小于506判为档位1大于等于506判为档位2。档位2和档位3的边界是(10132048)/21530档位3和档位4的边界是(20484095)/23071。这样的区间划分方式对电阻误差的容忍度最高也不会出现因为一个边界值选了整数导致判断跳变的问题。2.3 实际电路中的器件误差和干扰问题电阻精度的影响需要认真算一笔账。普通贴片电阻精度是5%也就是3.3K的实际值可能在3.135K到3.465K之间10K的在9.5K到10.5K之间。把这些极限值代入分压公式算出来的ADC值偏差会超过100。如果直接用固定阈值判断档位2的实测值可能从900多飘到1100多和理论值的偏差不可忽略。所以我在方案里做了两件事。第一档位电阻全部选了1%精度的金属膜贴片电阻这个成本比5%的贵不了多少但对ADC采集的稳定性帮助很明显。第二在ADC采样点加了一个0.1uF的滤波电容到地和R1组成低通滤波能滤掉一部分高频干扰。对于工业现场来说这两个措施是必须的不是可选项。软件侧还要加防抖处理。机械旋钮在换挡时触点会有几毫秒到几十毫秒的不稳定接触阶段期间ADC值会剧烈跳变。我的做法是连续采集8次ADC值每次间隔5毫秒8次值两两之间的最大偏差小于50才算有效否则重新采样。连续两次有效采样对应的档位一致才认为档位切换完成。这个逻辑和按键消抖的思路一样只不过判断对象从电平变成了ADC区间。2.4 采样滤波和档位识别代码实现ADC这块用的是STM32的ADC1、通道512位分辨率连续的软件触发采集。滤波采用中值限幅的组合方式每轮采5次去掉最大值和最小值剩下3次取平均值作为本轮的有效采样值。这种处理方式对尖峰脉冲干扰有很好的抑制作用比单纯平均好不少。完整的一段参考代码如下#define ADC_CHANNEL 5 #define FILTER_CNT 5 #define ADC_SAMPLE_TIMES 8 uint8_t get_switch_gear(void) { uint16_t adc_raw, last_adc_raw; uint8_t sample_cnt 0, stable_cnt 0; uint8_t gear 0; while (stable_cnt 2) { adc_raw adc_filter_read(FILTER_CNT); if (sample_cnt 0) { last_adc_raw adc_raw; sample_cnt; continue; } if (abs(adc_raw - last_adc_raw) 50) { stable_cnt; } else { stable_cnt 0; sample_cnt; if (sample_cnt ADC_SAMPLE_TIMES) { sample_cnt 0; } } last_adc_raw adc_raw; delay_ms(5); } if (adc_raw 506) { gear 1; } else if (adc_raw 1530) { gear 2; } else if (adc_raw 3071) { gear 3; } else { gear 4; } return gear; }需要提醒的是判断阈值用的506、1530、3071是针对我的分压参数算出来的如果读者改了R1或者档位电阻这些值必须重新计算不能照抄。另外ADC回调里不要放耗时的滤波算法滤波最好在主循环或者定时器中断里处理避免阻塞中断响应。3. float在Modbus中的拆分与还原实战3.1 IEEE 754标准下的float内存布局要理解float怎么拆先得知道float在内存里长什么样。IEEE 754标准规定32位浮点数分为三部分1位符号位、8位指数位、23位尾数位。比如25.6这个数在内存中的二进制表示是0x41CCCCCD按大端序读出来就是四个字节0x41、0xCC、0xCC、0xCD。具体到这个项目温度传感器读出来的是一个float变量需要通过Modbus协议发给上位机。Modbus寄存器是16位一个所以两个寄存器能存4个字节恰好是一个float。问题在于这4个字节怎么分配到两个寄存器里。我先列一下最常见的几种排列方式方便对照寄存器顺序字节顺序常见叫法实际字节布局高字在前高字节在前Big-Endian0x41 0xCC 0xCC 0xCD低字在前低字节在前Little-Endian0xCD 0xCC 0xCC 0x41高字在前低字节在前Mixed-Endian0xCC 0xCD 0x41 0xCC低字在前高字节在前Mixed-Endian0x41 0xCC 0xCD 0xCC很多组态软件里会提供“字节交换”“字交换”的选项就是为了适配这些不同排列。最忌讳的是两头都按自己的习惯来做而没有任何约定调试时能折腾一个下午。3.2 用union实现float和寄存器的拆分C语言里实现float拆分最直观的方式是联合体。定义一个包含float和uint16_t数组的union两者共享同一块4字节内存直接对寄存器数组赋值就能完成拆分代码简洁且不易出错。typedef union { float value; uint16_t regs[2]; uint8_t bytes[4]; } Float32_Union; Float32_Union temp_union; temp_union.value temperature; // 大端字序即高16位在前 uint16_t reg_high temp_union.regs[1]; uint16_t reg_low temp_union.regs[0];上面这段代码默认编译器是小端模式也就是MCU内存中float的低字节存放在低地址。STM32系列默认就是小端所以这里直接用了小端的假设。如果换到大端MCUregs数组的索引需要反过来建议在工程里加一个编译期判断。用union的好处是零拷贝、零转换代码量少可读性好。缺点是依赖平台的字节序不能无脑跨平台复用。后面会介绍更通用的位运算方案。3.3 用指针和memcpy实现字节拆分的两种思路如果不想用union或者代码需要跨平台可以用指针逐字节读取float的内存内容。uint16_t float_to_high_reg(float f) { uint8_t *p (uint8_t *)f; // 小端模式p[0]是最低字节p[3]是最高字节 return (p[3] 8) | p[2]; // 取高16位 } uint16_t float_to_low_reg(float f) { uint8_t *p (uint8_t *)f; return (p[1] 8) | p[0]; // 取低16位 }用memcpy更安全因为它不受对齐和别名规则的影响。uint8_t buf[4]; memcpy(buf, temperature, sizeof(float)); uint16_t reg_low (buf[1] 8) | buf[0]; uint16_t reg_high (buf[3] 8) | buf[2];3.4 数值还原从两个寄存器拼回float上位机把两个寄存器值传回MCU端时需要执行反向操作先拼成4个字节再转成float。同样有多种方式这里给出一个带字节序参数的通用函数。实际项目中如果协议固定可以把这个参数做成宏直接去掉运行时的判断开销。float registers_to_float(uint16_t reg_high, uint16_t reg_low, uint8_t byte_order) { uint8_t buf[4]; if (byte_order BIG_ENDIAN) { // 大端高字在前低字在后 buf[0] (reg_high 8) 0xFF; buf[1] reg_high 0xFF; buf[2] (reg_low 8) 0xFF; buf[3] reg_low 0xFF; } else { // 小端低字在前高字在后 buf[0] (reg_low 8) 0xFF; buf[1] reg_low 0xFF; buf[2] (reg_high 8) 0xFF; buf[3] reg_high 0xFF; } float result; memcpy(result, buf, 4); return result; }这里我用memcpy把字节数组拷到float变量里避免了直接类型转换导致的严格别名问题编译时也不会有警告这是实际项目里比较稳妥的做法。4. Modbus协议挂接和寄存器地址规划4.1 从机地址和功能码的选择汇总一下Modbus功能码的选择读取保持寄存器用功能码0x03写单个寄存器用0x06写多个寄存器用0x10。温度作为实时采集值上层只读即可所以只需要实现0x03功能码。档位状态也可以放到保持寄存器里用0x03一起读上去这样上位机一次请求就能同时拿到温度和档位减少通信次数。从机地址在Modbus RTU协议里是8位我用的是0x01。如果现场有多个从机设备这个地址不能重复。4.2 保持寄存器地址映射表寄存器地址从0开始排列协议文档里通常用0x0000这种形式表示。但需要注意有些上位机软件里的地址表达是带偏移的比如把0x0000显示成40001这是因为Modbus协议把保持寄存器的起始地址定义为40001。实际通信时发出去的数据包里的地址还是0x0000只是界面显示不同。这个问题经常导致初学的人一头雾水搞不清楚为什么明明写的40001用调试工具看报文却是0。我这边的映射表如下寄存器地址内容说明0x0000温度低16位float的低字0x0001温度高16位float的高字0x0002当前档位1到4整数温度值用两个寄存器存储上位机读取时需要注意寄存器顺序。我当时用Modbus Poll软件调试时可以在“Reading”里设置寄存器数量为3然后按字序拼接解析。如果直接看寄存器里的十六进制数会看到0x0000和0x0001里的值像是“乱码”其实是把25.6拆开了需要按照之前约定的字节序拼回去。4.3 Modbus RTU帧格式和CRC校验Modbus RTU标准帧是从机地址、功能码、起始地址高字节、起始地址低字节、寄存器数量高字节、寄存器数量低字节、CRC低字节、CRC高字节。CRC校验计算用的是CRC16很多现成代码库可以直接用但注意CRC字节是低字节在前。这里特别强调一下CRC的顺序问题。我之前遇到过一次从机返回的报文在调试工具里看起来是正确的但上位机就是不认后来发现是CRC的字节序发反了发送时先发了高字节后发了低字节。Modbus协议规定CRC先发低字节再发高字节。这个细节翻文档时很容易看到但实际写代码时还是有很多人搞错。CRC16计算代码这里不贴了网上开源的一大堆但建议用逐字节查表的方式速度和代码量都好于逐位计算。4.4 浮点数据在Modbus调试工具中的验证方法调试Modbus float数据我强烈推荐使用Modbus Poll配合一个串口虚拟软件来做回环测试。先把MCU端串口接调试工具再用Modbus Slave模拟一个假的上位机或者用Modbus Poll直接去读MCU的寄存器。在Modbus Poll里寄存器显示默认是Hex模式读上来的两个寄存器分别显示对应的16位值。要确认float解析是否正确需要在对应的单元格里把显示格式改成Float类型并选择正确的字节序。不同版本的Modbus Poll设置入口略有不同一般是在“Cell Format”或者“Display Format”里修改。如果上位机显示出来的数据是“-0.0000”或者一个无比巨大的数十有八九是字节序选错了。通过Modbus Poll验证的好处是它能直观地看到原始寄存器值和解析后的float值调试时对照着看非常方便。我习惯同时打开Modbus Poll和串口监视器一边看报文一边看解析结果能更快定位问题。5. 常见问题与调试心得5.1 档位识别不准的排查方向和解决记录第一批样机测试时档位识别偶尔会跳变主要集中在从档位2切到档位3或者从档位3切到档位4的时候。排查过程分了三步。第一步用万用表量ADC引脚的电压发现切换瞬间电压确实存在抖动这是机械触点的固有特性。第二步用示波器抓波形发现干扰尖峰出现在切换瞬间而且幅度不小。第三步把滤波电容从0.1uF加大到1uF同时把ADC滤波算法改成中值平均的组合方式再实测就没有出现误判了。这里有一点要说明滤波电容加大后ADC响应速度会变慢。对这个项目来说档位切换本来就不需要多快的响应100ms内能识别出来就行所以加大电容是可行的。如果某个应用需要快速响应比如编码器那种高频变化这个方案就要慎重。5.2 浮点数据读出来是乱码的常见原因浮点数据显示不对最常见的有四种原因。第一种是字节序不对这个占了大半的情况前面反复强调过。第二种是寄存器地址没对上比如程序里读到的是0x0001、0x0002但上位机配置的是0x0000、0x0001错位读出来的数据自然全是乱的。第三种是CRC校验失败数据包被上位机丢弃看起来就是一直读不到数据。第四种是float转int时的隐式转换问题比如把25.6赋值给uint16_t变量得到的值是25而不是25.6的小数部分这种问题在代码里非常隐蔽。排查时我的习惯是先用串口助手把MCU发出的原始报文抓出来手工逐字节分析帧格式再用Modbus Poll读寄存器看寄存器里的原始hex值最后才看float解析结果。按照这个顺序排查大部分问题都能在半小时内定位。5.3 常见错误速查表现象可能原因验证/解决方法档位偶尔跳变机械触点抖动增加软件消抖、加大滤波电容ADC值整体偏低参考电压偏差用万用表实测VREF校准阈值档位一直显示某一档某档电阻虚焊/开路测量ADC引脚电压检查焊接温度值极大或极小字节序或字序不对用Modbus Poll切换字节序验证温度值整段偏移寄存器地址错位核对实际读写寄存器地址上位机收不到数据CRC校验失败/从机地址不对抓包检查CRC字节序、从机地址温度小数部分丢失float隐式转int检查代码中是否存在类型转换5.4 调试嵌入式的经验沉淀这几个问题的调试过程让我有些体会。硬件方案的选择不能只看IO数量还要考虑ADC精度、干扰环境、器件一致性。电阻分压这种方案看起来简单但实际上从选型、参数计算到消抖处理每一步都需要细致考虑任何一个环节粗心大意产品到了现场就会不稳定。另一个体会是Modbus这种老协议能经久不衰一个重要原因就是它的简单和通用。但简单不等于不需要严谨字节序、寄存器地址、CRC这些规则一旦出错调试成本往往是写代码时间的十倍以上。我在实际项目中养成一个习惯写通信相关代码前先把协议文档里的字节序、地址定义、校验规则全部抄在一个小本上做完一个功能就对照一条这个习惯帮我少走了很多弯路。最后再分享一个小技巧不管是模拟量采集还是数据通信在做完第一版后强烈建议花半天时间把所有阈值、寄存器地址、字节序之类的配置参数抽出来集中放在一个config.h文件里并写清楚来龙去脉。下次改版或者换芯片平台时只需要改这一个文件能省下大量排查时间。我自己就是在第三次改版时才后知后觉这个道理现在会把这类项目的所有关键参数都汇成一张参数表放在代码仓库的README里对后续维护和交接都有很大帮助。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门