2. 开头:IO不够用还硬挤,这是我这次调试的真实状态
做嵌入式调试,最怕的不是逻辑复杂,而是资源快用完了还得硬挤。最近在调一块小控制板,主控选的是STM32F103这颗老将,IO口已经排到了极限,原来面板上还预留了一个4档旋转开关用来切工作模式。最初的设计是每个档位拉一个IO进来检测,结果一数引脚直接爆炸。另一边,设备还要通过Modbus RTU向上位机上报数据,其中就有一个float类型的温度测量值。Modbus寄存器只有16位,float要占32位,怎么拆分、怎么还原、大小端怎么统一,这里面的坑比想象中多。两个问题叠在一起,正好凑成这一篇调试笔记。
这篇内容主要分两条线:一条讲怎么用单路ADC加电阻分压网络,把4档旋转开关的检测IO从4个压缩到1个,还顺便把判档稳定性做上去;另一条讲Modbus通讯里float的存储规则、拆分还原的三种常见代码实现,以及调试时怎么快速判断设备用的是哪种字节序。适合正在做STM32、GD32这类MCU项目,手头IO紧张、又要走Modbus上报浮点数的朋友参考。内容都是实际调板子过程中验证过的,不是理论推演。
1. 旋转开关为什么需要“省IO”采集
1.1 传统接法到底吃掉了多少IO
4档旋转开关常见的形态有两种:一种是单刀多掷型(1P4T),公共端COM加四个固定端;另一种是编码输出型,内部集成了类似BCD码的逻辑,四个引脚输出二进制组合。无论哪种,传统做法里都得占IO——单刀多掷的需要4个输入IO,编码型的也得4个。碰上板子引脚本来就紧张的时候,这4个IO是真的心疼。
其实旋转开关的应用场景很固定:设备前面板上的模式切换,比如手动、自动、远程、本地四档。操作频率很低,一个档位可能几个月都不动一次,但它就是得让人能随时手动切。既然操作频率低、切换速度慢,那采集速度根本不需要毫秒级,反而更需要抗抖动、抗误判。这个特性决定了它非常适合用慢速ADC来采集,而不是占用宝贵的快速IO口。
1.2 省IO思路:一个ADC通道解决全部档位
思路其实一句话就能说清:把4个档位分别接4个不同阻值的下拉电阻,公共端接ADC引脚,再用一个上拉电阻把ADC引脚拉到参考电压。开关转到哪个档位,ADC引脚就被拉到一个固定的分压值。这样不管多少档,3档也好4档也罢,一个ADC通道就能全包。
这个思路本质上是把“档位信息”编码成了“模拟电压”,再用ADC量化。付出的代价无非是几个电阻,换回来的是省下3~4个IO,而且这些IO往往还能复用到其他数字功能上。另一个隐藏的好处是:如果MCU的IO分配实在紧张,这个ADC引脚甚至可以和别的传感器分时复用,只要切换开关别太频繁就行。
不过ADC分压方案最怕两件事:一是电阻误差导致档位电压重叠,二是软件滤波做得不够导致判档抖动。前者要靠选型和计算来兜底,后者要靠在程序里做迟滞判断和多次采样来规避。这两块我在后面会详细展开。
2. 电阻分压网络:原理、计算与实操
2.1 电路结构设计
我用的是最经典的“上拉固定+下拉切换”结构。原理图很简单,三条线路:VCC(3.3V)经过一个固定上拉电阻R_p接到ADC输入脚PA1;PA1再经过旋转开关的公共端COM;旋转开关的4个固定端分别接档位电阻R1~R4到GND。
开关在哪个档位,就等于把对应的档位电阻接到PA1和GND之间,与上拉电阻形成分压。PA1的电压公式:
Vadc = VCC × R_档 / (R_p + R_档)
注意,档位1我刻意选择直接接地,也就是R1 = 0Ω,不要让任何一个档位悬空。这个细节很关键,悬空就意味着ADC引脚对地阻抗无限大,外部干扰随便一碰就会跳变,判档很容易出错。
2.2 阻值计算与档位划分
电阻选型我有几个约束:上拉电阻不能太大,否则ADC引脚输入阻抗并联后会拉低分压精度;也不能太小,否则一直有个毫安级的电流在跑,低功耗场景下扛不住。折中下来R_p选10KΩ。
档位电阻我选了四颗:R1 = 0Ω、R2 = 2KΩ、R3 = 4.7KΩ、R4 = 10KΩ。12位ADC满量程4095,按VREF = 3.3V折算:
| 档位 | 下拉电阻 | 理论电压 | ADC理论值 | 判定阈值 |
|---|---|---|---|---|
| 1 | 0Ω | 0V | 0 | < 300 |
| 2 | 2KΩ | 0.55V | 682 | 300 ~ 1150 |
| 3 | 4.7KΩ | 1.06V | 1317 | 1151 ~ 2010 |
| 4 | 10KΩ | 1.65V | 2048 | > 2010 |
阈值不是简单取均值的,而是反过来偏向相邻低档位:判档在相邻值中位线附近再加一个迟滞窗口。比如档位1和档位2的理论中位线在341,我实际取的判档边界是300,同时在连续两次采样方向不一致时允许保留上一次结果,这样开关在临界点反复抖动时不会来回跳。
三组电阻的压差计算下来:第1~2档差0.55V,第2~3档差0.51V,第3~4档差0.59V,最小压差也超过了40%的满量程,后面做AD采样就算碰到1%精度电阻、温漂十几ppm的叠加,也足够稳稳拉开。
2.3 软件滤波与判档逻辑
切换开关本质上是个机械过程,触点接触瞬间极容易弹跳,ADC值会短时间乱跳。如果主循环跑得快,一个乱跳的采样值就足以让系统误判档位,轻则显示错误,重则跑错整套工作逻辑。
软件上我做三层滤波:
第一层是连续8次采样取平均,中间剔除最大最小值,基本把随机噪声压下去。第二层是判档迟滞,也就是上文提到的阈值窗口,只有连续2次进入同一个区间才更新档位。第三层是在开关切换后的前80ms内强制丢弃采样结果,给触点弹跳留足稳定时间。三管齐下之后,我拿示波器看过PA1引脚电压,切换瞬间的毛刺基本被软件完全兜住,实测几百次手动切换零误判。
采样代码大概长这样:
#define SW_ADC_CH 1 #define SW_SAMPLE_CNT 8 #define SW_DEBOUNCE_MS 80 uint8_t get_switch_position(void) { static uint8_t last_pos = 0; uint8_t pos = 0; uint16_t adc_value; if (sw_timer_elapsed < SW_DEBOUNCE_MS) return last_pos; adc_value = adc_sample_filtered(SW_ADC_CH, SW_SAMPLE_CNT); if (adc_value < 300) pos = 1; else if (adc_value < 1150) pos = 2; else if (adc_value < 2010) pos = 3; else pos = 4; if (pos == last_pos) { return pos; } else { last_pos = pos; return pos; } }2.4 硬件设计上容易踩的坑
第一坑是ADC参考电压和VCC不一致。如果MCU的VDDA是3.0V而分压网络用的是3.3V,那所有电压值都得重新折算,否则判档边界全偏。所以分压网络的供电我直接接到VDDA,保证参考和采样本源一致,算起来最省心。
第二坑是PCB布线。开关离MCU远了中间线要短接压降,我这条线长度差不多5cm,走线宽度0.5mm,信号出来不加跟随器问题也不大。但要是板子结构决定线特别长,ADC输入端的滤波电容一定要加上,一般是10nF到100nF之间,我用的是33nF,对分压点位没有可感知的影响。
第三坑是档位电阻的精度。如果只求功能演示那5%的厚膜电阻够了,但工业环境下还是建议1%精密电阻。四档位电阻值跨了三个数量级还包含0Ω直地,用一排直插排阻也方便生产。这里我提醒一句:如果档位1是直地,必须确认开关在档位1时的接触电阻足够小,不然分压出来的电压会偏离0V,导致第一档误判。
3. Modbus中float的存储逻辑与坑
3.1 为什么一个float要占用两个寄存器
Modbus协议按寄存器(Register)操作,每个寄存器16位。但IEEE 754单精度浮点数float是32位的,由1位符号位、8位指数位、23位尾数位构成。这意味着一个float要拆成两个16位寄存器才能传输,这在Modbus的保持寄存器(Holding Register)里是再常见不过的操作。
关键问题在于:两个寄存器里装的是哪个字节?顺序怎么排?先发哪个寄存器?不同的厂商在这里有各自的习惯,甚至同一个厂商不同型号设备都不同。网上经常有人问“我用Modbus读回来的float完全是个天文数字”或者“符号位对不上”,绝大多数都是字节序和字序组合不对导致的。
3.2 字节序与字序的四种组合
先约定一个float变量的内存字节,从低地址到高地址依次记为A、B、C、D,注意单片机的内存布局不一定和传输顺序一致。
假设一个浮点数25.5f,IEEE754下它的32位二进制是0x41CC0000。在大端内存布局里,从低地址到高地址依次是0x41、0xCC、0x00、0x00。在x86小端系统上,从低地址到高地址则是0x00、0x00、0xCC、0x41。
Modbus传输时,常用的四种组合如下:
| 模式 | 寄存器1 | 寄存器2 | 常见于 |
|---|---|---|---|
| 标准大端 AB CD | 0x41CC | 0x0000 | 大多数工业仪表、西门子PLC |
| 字节交换 CD AB | 0xCC41 | 0x0000 | 部分国产仪表、流量计 |
| 全小端 GH EF | 0x0000 | 0xCC41 | 部分PLC/DCS、某些国产设备 |
| 字交换 EF GH | 0x0000 | 0x41CC | 部分运动控制设备 |
这里的ABC D到底是哪个对应内存哪个,严格讲取决于程序怎么memcpy和排列。实际调试时更快的办法不是背这些表,而是直接看设备手册里写的“寄存器字序”和“字节序”,或者干脆发一个已知数去试探。
我自己调试时最常用的一招:给从站写一个已知浮点数,比如把1.0这个float拆到两个寄存器里,1.0的IEEE表示是0x3F800000。如果读回来寄存器值(按地址从低到高)是0x3F80、0x0000,那就是标准大端;如果是0x0000、0x3F80,那就是字交换;如果是0x803F、0x0000,字节交换;如果是0x0000、0x803F,全小端。用这个办法一次就能定下来,不用去猜。
4. 实操代码:float的拆分与还原
4.1 方式一:union共用体,最简单直观
C语言里union是最惯用的做法,我实际项目里大部分情况也是用它。union的好处是内存不重新拷贝,直接按两种视角看同一段内存,代码又短又不容易出错。
typedef union { float f; uint16_t u16[2]; uint8_t u8[4]; } float_byte_t; /** * @brief 将float拆成两个Modbus寄存器(标准大端模式) * @param value 浮点数 * @param regs 输出的寄存器数组,regs[0]为高16位 */ void float_to_regs_big(float value, uint16_t regs[2]) { float_byte_t fb; fb.f = value; // 小端系统:u16[1]是高16位,u16[0]是低16位 regs[0] = fb.u16[1]; regs[1] = fb.u16[0]; } /** * @brief 两个Modbus寄存器还原为float(标准大端模式) */ float regs_to_float_big(const uint16_t regs[2]) { float_byte_t fb; fb.u16[0] = regs[1]; fb.u16[1] = regs[0]; return fb.f; }这里注意一个问题:上面的代码默认目标单片机是小端系统(STM32就是小端)。如果代码要跨平台,比如既跑在STM32又跑在PC上,那直接用u16[1]/u16[0]这种写法在不同平台结论是相反的。所以我更推荐下面这个基于字节指针的通用写法。
4.2 方式二:字节指针操作,跨平台通用
直接用uint8_t指针取float的4个字节,再手动拼寄存器。这样做的好处是,无论系统是大小端,都能通过显式的字节下标拿到准确位置。下面这段是标准大端模式的实现:
void float_to_modbus_big(float value, uint16_t regs[2]) { uint8_t *p = (uint8_t *)&value; regs[0] = ((uint16_t)p[3] << 8) | p[2]; // 最高16位 regs[1] = ((uint16_t)p[1] << 8) | p[0]; // 最低16位 } float modbus_big_to_float(const uint16_t regs[2]) { union { float f; uint8_t b[4]; } fb; fb.b[3] = (uint8_t)(regs[0] >> 8); fb.b[2] = (uint8_t)(regs[0] & 0xFF); fb.b[1] = (uint8_t)(regs[1] >> 8); fb.b[0] = (uint8_t)(regs[1] & 0xFF); return fb.f; }这个写法看着死板,但它稳。字节下标全在代码里写死了,后面如果要切换四字节序的另一种组合,改的是下标顺序而不是整体逻辑。我在调不同品牌仪表时经常要做这组宏:
#define BIG_ENDIAN_WORD 0 #define BYTE_SWAP 1 #define WORD_SWAP 2 #define LITTLE_ENDIAN_ALL 34.3 方式三:32位整型中转,适合无优化场景
还有一种方法是先把float原样拷贝到一个uint32_t变量里,然后用移位操作拆成两个16位。这个方法对编译器优化不敏感,代码可读性也不错,尤其适合老式8051、STM32不开优化时使用的场景。
uint32_t tmp; memcpy(&tmp, &value, 4); // 借用整型中转,规避大小端别名问题 uint16_t hi = (uint16_t)(tmp >> 16); uint16_t lo = (uint16_t)(tmp & 0xFFFF); // 标准大端 regs[0] = hi; regs[1] = lo;还原更简单:
uint32_t tmp = ((uint32_t)regs[0] << 16) | regs[1]; float result; memcpy(&result, &tmp, 4); return result;这个方式本质还是利用大端模式下的高字在前,在标准大端模式下用起来最顺手。如果设备要用小端,就调换regs[0]和regs[1]的顺序。
4.4 验证与测试:别等联调才发现字节序反了
写完成分还原函数,立刻做自测,不要等到整机联调再查。我自己用的也是最简单有效的办法:在main函数里跑一段自检代码,把已知的浮点数拆完再合成,对比误差;再拿几个边界值测一下,包括0.0、负数、大于65535的数、NaN这种非常规值。
void float_self_test(void) { float test_values[] = {0.0f, 1.0f, 25.5f, -3.14f, 65535.0f}; uint16_t regs[2]; float result; uint8_t i; for (i = 0; i < sizeof(test_values)/sizeof(float); i++) { float_to_regs_big(test_values[i], regs); result = regs_to_float_big(regs); if (result - test_values[i] < 0.0001 && test_values[i] - result < 0.0001) { // 测试通过 } else { // 记录错误测试向量 } } }如果你手边有串口调试助手或者上位机模拟工具,比如Modbus Poll这类主站软件,也可以直接把从站跑起来,用Modbus Poll读取对应寄存器地址,看看读回来的值是不是你写入的数。这个方法能一次性验证寄存器地址安排、字节序、设备地址等多个环节,比单独看代码靠谱得多。
5. 调试中遇到的坑与排查技巧
5.1 旋转开关档位误判的现场故事
我第一次在样机上调这个分压方案时,出现了大面积误判:档位2和档位3在切换后偶尔显示成彼此。排除代码逻辑之后,用万用表量了PA1引脚,发现两个档位的实际电压和理论值差了将近100mV。排查下来是电阻选型用了5%精度的普通电阻,R_p实际量出来是10.4K,档位2电阻量出来1.9K,两个偏差叠加,把相邻档位的间隙吃掉了不少。
换掉电阻之后我顺便加了一处小改进:把ADC采样和判档放进同一个非阻塞流程里,采样期间不响应外部事件,避免主循环在处理其他任务时采样点被拉长导致滤波均值失真。
5.2 Modbus float数完全不对的排查清单
Modbus读回来的float不对,通常不是通讯错误,而是字节序/字序匹配错误。我整理了一个排查顺序:
第一步,确认寄存器地址有没有错位。很多设备从地址4开始放第一个float,有些人手册没看清,从地址0或者5开始读,读回来的值自然不对。
第二步,用已知数值确认模式。给从站写入一个结构明确的数,比如1.0就是0x3F800000,看看读回来的两个寄存器分别是多少,对照上面的模式表锁定设备用的哪种排序。
第三步,确认主站和从站的模式一致。Modbus Poll这类软件大多带浮点格式设置,默认是大端,如果从站是小端,两边都得设置统一。这里没有“谁对谁错”,只有“两边一致”。
5.3 一个容易被忽略的细节:寄存器地址以0还是1起始
Modbus协议里,保持寄存器的地址编号在不同设备技术支持描述里可能是“0开头”也可能是“1开头”,这看似小事,实际在float这种连续两寄存器的场合特别容易翻车。我见过一个设备手册写“浮点数据从地址40003开始”,而它实际在报文里的寄存器地址是0x0002。这种“协议地址”和“数据地址”的偏移,用Modbus Poll这类工具时尤其要留意,地址填错一位,数据就全乱。
5.4 大小端不一致时如何快速适配
适配不同设备时,不要改函数内部逻辑,而是维护一个字节序配置宏,四个模式各写一组寄存器组装/解析程序。在系统初始化时根据硬件拨码或者项目编译项选择一组,这样调试新设备时就是改一个宏或者一个数组的事,不用到处改代码。
typedef enum { MODBUS_FLOAT_BIG_ENDIAN = 0, MODBUS_FLOAT_CDAB, MODBUS_FLOAT_EFGH, MODBUS_FLOAT_GH_EF } modbus_float_order_t;代码里统一在一个文件里放四个版本的解析函数,每版都是纯函数,输入寄存器数组输出float,或者反过来,逻辑互不干扰。主程序只关心当前设备用的哪种,一个switch分发到对应处理函数就行。
写在最后的一个经验
这个项目做完,我自己印象最深的不是某个算法多精妙,而是IO不够用逼出来的分压方案,居然比原来4个IO老接法更稳。机械开关用ADC采集天然带滤波属性,反而是好事。Modbus float的问题则是老生常谈,但每次换设备都得再踩一轮。如果是刚开始接触Modbus的朋友,建议你把那个“写1.0、看寄存器”的试错法刻在脑子里,遇到浮点数据对不上,这招永远最快。后面如果有空,我再整理一下这套板子上的Modbus RTU整体发送流程和超时处理,那部分才是串口通信真正容易乱的地方。