资讯详情

资讯详情

STM32实战:ADC电阻分压省IO与Modbus浮点传输

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 传统接法到底吃掉了多少IO4档旋转开关常见的形态有两种一种是单刀多掷型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 电路结构设计我用的是最经典的“上拉固定下拉切换”结构。原理图很简单三条线路VCC3.3V经过一个固定上拉电阻R_p接到ADC输入脚PA1PA1再经过旋转开关的公共端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理论值判定阈值10Ω0V0 30022KΩ0.55V682300 ~ 115034.7KΩ1.06V13171151 ~ 2010410KΩ1.65V2048 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.5fIEEE754下它的32位二进制是0x41CC0000。在大端内存布局里从低地址到高地址依次是0x41、0xCC、0x00、0x00。在x86小端系统上从低地址到高地址则是0x00、0x00、0xCC、0x41。Modbus传输时常用的四种组合如下模式寄存器1寄存器2常见于标准大端 AB CD0x41CC0x0000大多数工业仪表、西门子PLC字节交换 CD AB0xCC410x0000部分国产仪表、流量计全小端 GH EF0x00000xCC41部分PLC/DCS、某些国产设备字交换 EF GH0x00000x41CC部分运动控制设备这里的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整体发送流程和超时处理那部分才是串口通信真正容易乱的地方。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →