行业资讯
📅 2026/9/9 6:53:18
STM32实战:旋转开关ADC采样省IO与Modbus浮点传输字节序解析
这一篇是嵌入式调试笔记的第6篇放在一起聊两个看似不相关、实际在设备联调时经常一起蹦出来的问题一个是4档旋转开关怎么用一个IO就完成档位采集另一个是Modbus通信里的float数据怎么保证拆分和还原不出乱子。这两个问题我在STM32F103标准库工程里都实际踩过工程是基于Freemodbus v1.6移植的Modbus RTU从站用RS232转RS485和上位机通信。旋转开关是用来切设备工作模式的浮点数是用来上报电压、温度这类模拟量的。两个问题单独看不难但凑在一起就挺典型——IO不够用要想办法省省完IO数据要上报又绕不开Modbus的字节序。1. 旋转开关ADC采样方案从4个IO到一个IO的取舍1.1 为什么值得为旋转开关省IO4档旋转开关最常见的接法是一个档位接一个IO公共端接GND4个档位要占4个输入IO。有的旋钮开关内部本身就是编码输出可以省到2个IO但那种开关贵而且不一定买得到合适的档位数和面板尺寸。我当时的情况是板上IO已经排得很满剩两个空闲IO还得留着给以后扩展用4个IO去读一个旋钮实在奢侈。网上看到有人用ADC分压方式做一个IO读多个档位原理很简单但真正做起来有几个细节容易翻车这里把我的选型过程完整记录下来。用ADC方案后旋转开关的公共端接STM32的ADC输入引脚上拉电阻接VCC每个档位触点串一个不同阻值的电阻到GND。开关拨到哪个档位ADC引脚就被拉到对应的分压值测出电压就知道是第几档。这样只占一个ADC通道节省下来的IO可以干别的。1.2 电阻分压的阻值选取和分档电压计算开关档位对应的电阻阻值不是随便定的需要考虑三个约束各档电压差要足够大、任意档位电压不能超过ADC参考电压、电阻在常规精度下偏差不会导致误判。我用的VCC是3.3VADC是12位参考电压直接接3.3V。上拉电阻选10k四个档位的下拉电阻选10k、4.7k、2.2k、1k对应的分压值可以算出来。档位下拉电阻分压计算电压值ADC采样值档110k3.3 × 10 / (1010)1.65V2048档24.7k3.3 × 4.7 / (104.7)1.056V1310档32.2k3.3 × 2.2 / (102.2)0.595V738档41k3.3 × 1 / (101)0.3V372这几个阻值选完有个好处相邻档位的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.00x000000000x00000x00001.00x3F8000000x3F800x0000-2.50xC02000000xC0200x0000100.00x42C800000x42C80x00003.141590x40490FD00x40490x0FD0从这张表能看出很多常见整数的float值低16位是0x0000。这个特性在排查字节序时非常好用如果上位机把低16位的0x0000丢了或者放错了位置数值就会变得很奇怪而用低16位非零的值才能真正看出高低字有没有互换。3.3 四种字序组合和它们的表现float的4个字节记作A最高字节、B、C、D。Modbus寄存器字序和字节序组合起来常见的有这么几种顺序名称寄存器1寄存器2实际字节排列AB CD0xAB0xCDAB CDCD AB0xCD0xABCD ABBADC0xBA0xDCBA DCDCBA0xDC0xBADC 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顺序应该看到寄存器00x3F80寄存器10x0000。第二步用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里别写死在代码中联调能少跑好几趟现场。