这一篇是嵌入式调试笔记的第6篇,放在一起聊两个看似不相关、实际在设备联调时经常一起蹦出来的问题:一个是4档旋转开关怎么用一个IO就完成档位采集,另一个是Modbus通信里的float数据怎么保证拆分和还原不出乱子。这两个问题我在STM32F103标准库工程里都实际踩过,工程是基于Freemodbus v1.6移植的Modbus RTU从站,用RS232转RS485和上位机通信。旋转开关是用来切设备工作模式的,浮点数是用来上报电压、温度这类模拟量的。两个问题单独看不难,但凑在一起就挺典型——IO不够用要想办法省,省完IO数据要上报又绕不开Modbus的字节序。
1. 旋转开关ADC采样方案:从4个IO到一个IO的取舍
1.1 为什么值得为旋转开关省IO
4档旋转开关最常见的接法是一个档位接一个IO,公共端接GND,4个档位要占4个输入IO。有的旋钮开关内部本身就是编码输出,可以省到2个IO;但那种开关贵,而且不一定买得到合适的档位数和面板尺寸。
我当时的情况是板上IO已经排得很满,剩两个空闲IO还得留着给以后扩展用,4个IO去读一个旋钮实在奢侈。网上看到有人用ADC分压方式做一个IO读多个档位,原理很简单,但真正做起来有几个细节容易翻车,这里把我的选型过程完整记录下来。
用ADC方案后,旋转开关的公共端接STM32的ADC输入引脚,上拉电阻接VCC,每个档位触点串一个不同阻值的电阻到GND。开关拨到哪个档位,ADC引脚就被拉到对应的分压值,测出电压就知道是第几档。这样只占一个ADC通道,节省下来的IO可以干别的。
1.2 电阻分压的阻值选取和分档电压计算
开关档位对应的电阻阻值不是随便定的,需要考虑三个约束:各档电压差要足够大、任意档位电压不能超过ADC参考电压、电阻在常规精度下偏差不会导致误判。
我用的VCC是3.3V,ADC是12位,参考电压直接接3.3V。上拉电阻选10k,四个档位的下拉电阻选10k、4.7k、2.2k、1k,对应的分压值可以算出来。
| 档位 | 下拉电阻 | 分压计算 | 电压值 | ADC采样值 |
|---|---|---|---|---|
| 档1 | 10k | 3.3 × 10 / (10+10) | 1.65V | 2048 |
| 档2 | 4.7k | 3.3 × 4.7 / (10+4.7) | 1.056V | 1310 |
| 档3 | 2.2k | 3.3 × 2.2 / (10+2.2) | 0.595V | 738 |
| 档4 | 1k | 3.3 × 1 / (10+1) | 0.3V | 372 |
这几个阻值选完有个好处:相邻档位的ADC值间隔都在400以上,对12位ADC来说分辨率远够用。电阻用5%精度的普通贴片,算上最恶劣偏差,各档ADC值也不会出现重叠。还有一个故意留的检测点:如果旋钮拨在空档,公共端悬空,上拉电阻会把ADC拉到接近3.3V,采样值接近4095,这个状态可以当作“无档位”处理。
1.3 直接比较ADC原始值还是换算电压
我见过有人写程序时先把ADC原始值换算成毫伏,再和理论电压比较判断档位。这样做不是不行,但多了一道乘除法,还没有必要。ADC原始值和电压是线性关系,分档判断完全可以在ADC域做。
这里要留意的是ADC参考电压的精度。如果VREF用的是芯片内部参考或者LDO输出,可能会有几个毫伏偏差,反映到ADC值上可能几十个码。所以我的阈值不是直接用理论ADC值加减一个固定偏移,而是根据实测值校准一次。具体做法是:拨到每一档,用串口把当前ADC原始值打印出来,记录实际值,再取相邻档位实测值的中点作为分界阈值。实测比理论计算可靠得多,因为板上的电阻实际阻值和标称值总会有偏差。
// 实测后标定的分档阈值,单位是ADC原始值 #define GEAR_1_MIN 1600 #define GEAR_2_MIN 1000 #define GEAR_3_MIN 550 #define GEAR_4_MIN 0 #define GEAR_INVALID 2600 // 大于这个值认为是空档/接触不良2. 档位识别的软件处理:滤波、迟滞、状态机
2.1 为什么不能简单读一次ADC就判断档位
旋转开关是机械触点,拨动的时候会有抖动,而且开关在切换过程中有一个短暂的悬空过程。如果程序只是单次读取ADC然后判断档位,会出现两种情况:刚拨过去瞬间读到的是上一个档位的残留电压,导致档位跳变;或者是拨到中间悬空位置读到接近4095的值,被误判成空档。
机械抖动的时间一般是几毫秒到十几毫秒,对ADC采样来说足够造成一次错误数据。我用的方案是“去极值平均滤波”:连续采样8次,去掉一个最大值和一个最小值,剩下6个取平均。这样既滤掉了偶发的尖峰,又不会像纯中值滤波那样在频繁切换时反应迟钝。8次采样在ADC时钟配置下耗时不到1毫秒,完全不影响响应速度。
2.2 迟滞窗口解决临界抖动
光滤波还不够,还有一个临界问题:如果旋钮停在两档之间的电压区间附近,比如因为电阻精度问题,采样值恰好落在档位阈值附近,那么微小噪声就会导致程序在两档之间反复横跳。
迟滞窗口的做法是:判断档位时,不同方向的切换使用不同的阈值。比如从档2切到档1,需要ADC值大于1600;但从档1切回档2,需要ADC值小于1500。中间留100个码的滞回区间。这样即使采样值在阈值附近抖动,也不会反复触发切换。
我实现的时候把阈值表分成两组,一组是“向上切”的阈值,一组是“向下切”的阈值。每次判断档位时,根据当前档位选择对应的阈值表。
2.3 完整判档实现
判档逻辑我写成了一个独立函数,输入是滤波后的ADC值,输出是当前档位。函数内部记录上一次的档位状态,只有连续5次判断结果一致时才更新输出档位。这相当于在时间维度上又加了一层防抖。
uint8_t gear_adc_to_index(uint16_t adc_value, uint8_t current_gear) { uint8_t new_gear = GEAR_INVALID; if (adc_value > GEAR_INVALID) { new_gear = GEAR_INVALID; } else if (adc_value > 1600) { new_gear = GEAR_1; } else if (adc_value > 1000) { new_gear = GEAR_2; } else if (adc_value > 550) { new_gear = GEAR_3; } else { new_gear = GEAR_4; } // 档位变化时连续确认5次才生效,防止抖动 static uint8_t confirm_count = 0; static uint8_t pending_gear = GEAR_INVALID; if (new_gear != current_gear) { if (new_gear == pending_gear) { if (++confirm_count >= 5) { confirm_count = 0; return new_gear; } } else { pending_gear = new_gear; confirm_count = 1; } return current_gear; } confirm_count = 0; pending_gear = GEAR_INVALID; return current_gear; }这个函数每次被调用时传入当前有效档位,内部维护一个待确认档位和计数。实测下来,快速拨动旋钮时档位切换干净利落,没有出现跳档或者卡在无效状态的情况。
3. Modbus float拆分的本质:寄存器宽度与字节序
3.1 为什么float必须拆成两个寄存器
Modbus协议里寄存器是16位宽的,而C语言里的float是32位,一个float必须占用两个连续的Modbus保持寄存器,这在任何Modbus实现里都一样。Freemodbus移植到STM32上时,底层处理的就是uint16_t数组,应用层往外抛数据之前,必须自己先把float拆成两个16位整数。
这个拆法听起来简单,但麻烦在于拆完之后的顺序。两个16位寄存器,先放高16位还是低16位,每个16位内部的2个字节是高位在前还是低位在前,组合起来有4种排列方式。上位机组态软件、触摸屏、各种Modbus调试工具,对“float的寄存器顺序”默认值往往不一样,经常出现数据传过去但数值完全不对的情况。
3.2 IEEE754单精度格式速查
拆float之前先把IEEE754单精度格式过一遍,后面排查问题会用到。一个float共32位:第31位是符号位,第30到23位共8位是指数位,第22到0位共23位是尾数。特殊值在调试中很有用,我经常用它们来验证字节序。
| 数值 | 十六进制表示 | 高16位 | 低16位 |
|---|---|---|---|
| 0.0 | 0x00000000 | 0x0000 | 0x0000 |
| 1.0 | 0x3F800000 | 0x3F80 | 0x0000 |
| -2.5 | 0xC0200000 | 0xC020 | 0x0000 |
| 100.0 | 0x42C80000 | 0x42C8 | 0x0000 |
| 3.14159 | 0x40490FD0 | 0x4049 | 0x0FD0 |
从这张表能看出,很多常见整数的float值低16位是0x0000。这个特性在排查字节序时非常好用:如果上位机把低16位的0x0000丢了或者放错了位置,数值就会变得很奇怪,而用低16位非零的值才能真正看出高低字有没有互换。
3.3 四种字序组合和它们的表现
float的4个字节,记作A(最高字节)、B、C、D。Modbus寄存器字序和字节序组合起来,常见的有这么几种:
| 顺序名称 | 寄存器1 | 寄存器2 | 实际字节排列 |
|---|---|---|---|
| AB CD | 0xAB | 0xCD | AB CD |
| CD AB | 0xCD | 0xAB | CD AB |
| BADC | 0xBA | 0xDC | BA DC |
| DCBA | 0xDC | 0xBA | DC BA |
其中AB CD又叫大端字序大端字节序,是Modbus协议标准里最常用的排列。CD AB是小端字序大端字节序,在很多PLC和触摸屏里默认用这种。BADC和DCBA是把单个寄存器里两个字节也颠倒,一般在单片机直接往发送缓冲区丢结构体时偶然会出现。
我在STM32从站里用的是AB CD,就是先发高16位再发低16位,每个16位内部高字节在前。上位机如果配置成CD AB,读数就会变成两个寄存器顺序互换,1.0会读成0x00003F80,数值变成天文数字。
4. float拆分还原的工程代码与验证方法
4.1 拆分和还原的C实现
拆分float存入寄存器时,用memcpy把float的位模式转到uint32_t,然后移位取出高16位和低16位。反过来还原时,先把两个寄存器拼成uint32_t,再memcpy到float。用memcpy而不是直接强转指针,是因为C语言里对float对象做指针强转再解引用会涉及别名问题,在某些编译器优化下可能得到错误结果,memcpy是标准行为。
// float拆成两个modbus寄存器 void float_to_modbus_regs(float value, uint16_t *reg_hi, uint16_t *reg_lo) { uint32_t data32; memcpy(&data32, &value, 4); *reg_hi = (uint16_t)(data32 >> 16); *reg_lo = (uint16_t)(data32 & 0xFFFF); } // 两个modbus寄存器还原float float modbus_regs_to_float(uint16_t reg_hi, uint16_t reg_lo) { uint32_t data32 = ((uint32_t)reg_hi << 16) | reg_lo; float value; memcpy(&value, &data32, 4); return value; }在Freemodbus里用的时候,读保持寄存器回调函数里把要上报的float数组通过这个函数写到寄存器缓冲区,写保持寄存器回调里从缓冲区读两个寄存器后还原成float存储。我在工程里定义了一个联合体,用结构体和数组共用内存的方式操作,代码更简洁:
typedef union { float f; uint8_t bytes[4]; uint16_t regs[2]; } float32_byte_t; // 拆分 float32_byte_t conv; conv.f = temperature; reg_buffer[0] = conv.regs[1]; // 高16位 reg_buffer[1] = conv.regs[0]; // 低16位 // 还原 float32_byte_t conv; conv.regs[1] = reg_buffer[0]; conv.regs[0] = reg_buffer[1]; temperature = conv.f;这种联合体写法在STM32这种小端芯片上,regs[0]对应低16位,regs[1]对应高16位,自己要清楚这一点,注释写明白。
4.2 用Modbus Poll做实测验证
写好代码后不能只靠人眼看,我使用Modbus Poll做主站工具连接从站,在寄存器窗口看原始16位数值。具体验证流程分三步。
第一步,从站代码里固定写一个float值传到寄存器。比如固定写1.0,按AB CD顺序应该看到寄存器0=0x3F80,寄存器1=0x0000。第二步,用Modbus Poll的写寄存器功能往从站写两个值,比如写0x3F80和0x0000,从站程序还原后通过串口打印,如果打印出1.0,说明还原逻辑正确。第三步,测一个非整数如3.14159,看低16位0x0FD0是否正确传输。
第一步和第二步分别验证发送和接收两个方向。只测发送方向或者只测接收方向,都可能漏掉问题。
4.3 特殊值法快速定位字节序错误
联调时如果发现float值不对,先别急着改代码,用一组特殊值很快就能推断出是哪种字序错误。发1.0过去,正确是0x3F800000。如果上位机读出来是这样几种值,可以反推:
- 读到0x00003F80,说明两个16位寄存器顺序反了,CD AB字序问题。
- 读到0x803F0000,说明高16位内部两个字节反了,同时在低16位的位置上出现了0x0000,其实是BADC。
- 读到0x8000003F,说明字节和字都反了,DCBA。
我实际遇到过一种情况,1.0读出来是个很小的数值,不是标准的天文数字,原因是上位机把两个寄存器当成一个有符号32位整数处理了。这时候看原始寄存器窗口,0x3F80和0x0000都正确,但上位机软件的数据类型配置错了,应该配置成Float类型而不是32位整数类型。
5. 联调中遇到的三个典型故障复盘
5.1 故障一:数值翻了几百倍,看起来毫无规律
有一次温度上报数据在触摸屏上显示成几万度,明显不对。用Modbus Poll读寄存器原始值,发现地址0和地址1的内容一个像0x42C8一个像0x0000,但位置反了。触摸屏按CD AB的寄存器顺序解析,从站按AB CD发,低16位的0x0000被当成高16位,数值自然大得离谱。
这类故障的排查思路是:先看寄存器窗口的原始十六进制值是否和预期一致,再用1.0这种低16位为0的特殊值锁定问题根源。触摸屏的配置界面里会有“Float Word Order”或者“字节顺序”选项,改成和从站一致即可,不用改从站代码。
5.2 故障二:负数全部变成正数
有次上报一个负温度值,上位机显示成正的几十万。看寄存器窗口,寄存器0是0xC020,寄存器1是0x0000,十六进制完全正确。问题出在上位机解析程序把两个寄存器定义成了无符号整数来拼位模式,最高位被丢弃,负数就变正数了。
这个案例提醒我:用寄存器0xC020和0x0000还原float时,拼接顺序、有符号无符号类型、字节序三个环节都可能出问题。我后来在从站测试固件里加了固定输出-2.5的模式,专门用来做通信联调,一次性定位符号位是否保留。
5.3 故障三:精度丢失的误解
还有个现象需要澄清:用Modbus传float时,从站发给上位机的值和上位机显示的值看起来“有误差”,比如发送3.14159,上位机显示3.14159,但再传回来变成3.1415899。这不是Modbus拆分还原造成的,是float本身只有大约7位有效十进制数字,3.14159在计算机里存的本来就不是精确值,显示的差异只是十进制转换时暴露了二进制浮点数的近似本质。
拆分还原过程只是把float的位模式原封不动搬运,不会增加误差。如果业务对精度要求高,一种是改用double,但占用4个寄存器;另一种更实用的是用定点数,把实际值乘以100或1000用整数传输,上位机再除以系数还原,这样在Modbus这种16位寄存器协议里反而更可控。
我在实际项目里的经验是:对温度这类本身传感器精度只有0.1的量,直接用float省事;对累计量、金额这类要求分毫不差的,必须用整数放大倍数传输,千万别迷信float。
这个6档旋转开关采集和Modbus float处理,到这里已经完整跑通了。旋转开关那块如果后面想再省一路ADC出来,可以考虑串阻值改成按对数分布,这样档位更多时也不用增加IO。float那块如果设备将来要接不同品牌PLC,建议把字节序做成上位机可配置的参数,存到EEPROM里,别写死在代码中,联调能少跑好几趟现场。