公司最近有一台现场设备要升级固件,MCU的引脚资源本来就不宽裕,还得加一个4档旋转开关用来切换设备运行模式,同时上位机那边又希望通过Modbus RTU直接读到浮点型的温度值。这两个需求单看都不难,难的是它们凑到一起后,硬件上没法再给每个档位单独分配一个IO,Modbus协议里又只认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档为0,Vadc就是0V;当某个档位开路时,Vadc就等于VREF。实际上4档设计中可以安排一个档位直接短接到地,再安排一个档位开路,中间两个档位用电阻分压,这样3个电阻就能实现4档,进一步减少元件。
我实际用的配置如下:
| 档位 | R档阻值 | 理论Vadc | 12位ADC读数(理论) |
|---|---|---|---|
| 档位1 | 0欧姆(短路) | 0V | 0 |
| 档位2 | 3.3K欧姆 | 0.816V | 1013 |
| 档位3 | 10K欧姆 | 1.65V | 2048 |
| 档位4 | 开路 | 3.3V | 4095 |
R1选择了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,那么判断边界就取(0+1013)/2=506,也就是说ADC采样值小于506判为档位1,大于等于506判为档位2。档位2和档位3的边界是(1013+2048)/2=1530,档位3和档位4的边界是(2048+4095)/2=3071。这样的区间划分方式对电阻误差的容忍度最高,也不会出现因为一个边界值选了整数导致判断跳变的问题。
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、通道5,12位分辨率,连续的软件触发采集。滤波采用中值+限幅的组合方式:每轮采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-Endian | 0x41 0xCC 0xCC 0xCD |
| 低字在前 | 低字节在前 | Little-Endian | 0xCD 0xCC 0xCC 0x41 |
| 高字在前 | 低字节在前 | Mixed-Endian | 0xCC 0xCD 0x41 0xCC |
| 低字在前 | 高字节在前 | Mixed-Endian | 0x41 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系列默认就是小端,所以这里直接用了小端的假设。如果换到大端MCU,regs数组的索引需要反过来,建议在工程里加一个编译期判断。
用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里,对后续维护和交接都有很大帮助。