拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32省IO读取旋转开关档位与Modbus浮点传输字节序实战

这段时间在调一块基于STM32F103的采集板主控资源紧巴巴的IO口被传感器、继电器、数码管占得差不多了结果客户还提了个需求面板上要加一个4档旋转开关用来切换设备的工作模式同时这个档位状态还需要通过Modbus RTU上报给上位机。任务落到这里就变成两个问题硬件上怎么用最少的IO把旋转开关档位读出来软件上怎么把采集到的浮点数据比如温度、电压和这个档位数值塞进Modbus协议里传给上位机。这两个问题单独看都不难但合在一起踩的坑还真不少。尤其是Modbus里传float这件事很多做嵌入式一两年的朋友第一次碰都会在字节序上翻车。这篇笔记就把我这次调试过程中验证过的方案、算过的参数、写出来的代码连同调了一天一夜才搞明白的坑一起整理出来。内容适合正在做单片机采集、仪表通信、工业控制这类项目的朋友参考尤其是你的MCU引脚不够用、又要跑Modbus通信的场景。1. 先聊聊4档旋转开关怎么“省IO采集”1.1 产品上真实遇到的情况客户给的旋转开关是市场上很常见的那种机械式多档位开关四档每一档对应一个不同的功能模式。一开始硬件同事画的方案很直接四个档位各拉一根线到单片机的四个GPIO哪个引脚被拉低就表示开关在第几档。这个做法最直观代码也最好写但问题在于我们这块板子IO真的不够了而且这4个引脚还要过板内走线、接插件成本也是实实在在的。于是就开始想省IO的办法。第一个想到的是换编码开关比如那种格雷码输出的旋转编码器用两个IO就能读4个状态甚至用AB相还能读位置变化。但是物料已经定了采购渠道、结构开孔都是按这个旋钮开关来的临时换开关肯定不现实。那就只能在现有这个旋转开关的电气结构上做文章。第二个想到的是芯片方案比如用74HC165移位寄存器串转并用两个IO时钟数据把4个开关状态读出来代价是多一颗芯片和一个GPIO口如果是3线SPI方式要三根线。如果不加锁存器的话用并转串方式需要三根线时钟、数据、锁存加个锁存引脚。这个方案可行但增加了器件成本和故障点。最终采用的是电阻分压ADC采样方案用一个ADC引脚就把4档状态读出来了。这也是很多低成本仪器仪表里常见的做法——用不同的电阻把档位映射成不同的电压区间然后采样判断。这样做的好处是只占一个GPIO而且那个GPIO还能顺便用来做按键扫描什么的非常灵活。1.2 采样电路设计电阻分压计算过程在设计分压电路之前先用万用表把旋转开关每一档的通断关系量清楚。我手里这个开关是公共端COM加4个触点旋到某一档时COM只会和对应的那个触点导通单刀4掷。这样就好办了把COM接到ADC引脚四个触点分别接不同的电阻到地或者说以不同组合接分压电阻。常见的接法是这样的ADC引脚通过一个上拉电阻Rup接到VCC同时COM端连着ADC引脚四个触点各自串一个电阻接到GND。当开关旋到某一档时等于对应的那个电阻被接入和Rup形成分压。四个档位对应四个不同的电阻值VC引脚电压就不同。以3.3V供电为例假设上拉电阻Rup取10kΩ四个下拉电阻分别取R1680kΩ、R2100kΩ、R327kΩ、R44.7kΩ根据分压公式 Vout VCC × R_down / (R_up R_down)档位1R1 680kV1 3.3 × 680 / (10 680) ≈ 3.25V档位2R2 100kV2 3.3 × 100 / (10 100) ≈ 3.00V档位3R3 27kV3 3.3 × 27 / (10 27) ≈ 2.37V档位4R4 4.7kV4 3.3 × 4.7 / (10 4.7) ≈ 1.06V注意这里有个细节下拉电阻越大分压得到的电压越接近VCC下拉电阻越小电压越低。所以四个档位的电压是逐渐降低的而且中间的间隔很大最容易被单片机ADC分辨。但是我实际计算时又考虑了另一件事就是MCU的ADC常常用的是内部基准有些芯片基准不是精确的3.3V可能是2.9V或者3.0V另外VCC在板上可能随着负载波动导致分压结果跟着漂。所以我在选电阻阻值的时候刻意让每个档位的电压区间拉开至少1V左右而不是算出一个刚好能分辨的值就停手。上面这组电阻在3.3V供电下电压间隔在0.3V到0.65V之间12位ADC0~4095在3.3V参考下分辨率大约0.8mV最小间隔0.3V对应约370个ADC码值余量非常充足。另外我还加了一路电源电压采样通过另一个ADC通道采集VDDA在软件里做比例换算而不是默认参考电压正好等于3.3V。这样即使电源波动也能准确获得ADC引脚的实际电压值避免档位误判。1.3 采样代码ADC均值滤波加迟滞判断硬件方案确定后软件就好办多了。这里分享我的实际代码用的是STM32标准库风格逻辑上HAL库也差不多的。uint16_t adc_read_channel(ADC_TypeDef *adc, uint8_t ch) { ADC_RegularChannelConfig(adc, ch, 1, ADC_SampleTime_239Cycles5); ADC_SoftwareStartConvCmd(adc, ENABLE); while (!ADC_GetFlagStatus(adc, ADC_FLAG_EOC)); return ADC_GetConversionValue(adc); } uint16_t adc_read_avg_mv(ADC_TypeDef *adc, uint8_t ch) { uint32_t sum 0; uint16_t i; for (i 0; i 16; i) { sum adc_read_channel(adc, ch); delay_us(100); } sum / 16; // 12位ADC值转换成mVvref_mv为实测VDDA电压 return (uint16_t)((uint32_t)sum * vref_mv / 4095); }接下里是档位判定。我建议不要用固定的电压边界一刀切因为机械开关在切换瞬间有抖动触点也会有氧化导致接触电阻变化如果电压正好落在边界附近单片机会在相邻两档之间反复横跳。我使用的办法是增加一个迟滞区间#define SW_LV1_MID_MV 2900 #define SW_LV2_MID_MV 2100 #define SW_LV3_MID_MV 1200 #define SW_LV4_MID_MV 400 #define SW_HYSTERESIS_MV 200 uint8_t sw_get_level(void) { static uint8_t current 1; uint8_t target; int16_t diff; uint16_t mv adc_read_avg_mv(ADC1, ADC_Channel_1); // 按中值分界粗判目标档位 if (mv (SW_LV1_MID_MV SW_LV2_MID_MV) / 2) target 1; else if (mv (SW_LV2_MID_MV SW_LV3_MID_MV) / 2) target 2; else if (mv (SW_LV3_MID_MV SW_LV4_MID_MV) / 2) target 3; else target 4; // 只有目标档位变化且偏离当前档位中心超过迟滞值时才切档 if (target ! current) { switch (current) { case 1: diff (int16_t)mv - SW_LV1_MID_MV; break; case 2: diff (int16_t)mv - SW_LV2_MID_MV; break; case 3: diff (int16_t)mv - SW_LV3_MID_MV; break; default: diff (int16_t)mv - SW_LV4_MID_MV; break; } if (abs(diff) SW_HYSTERESIS_MV) current target; } return current; }这个函数的执行逻辑是第一次读到的电压大概率会被正确识别之后只有当电压明显偏离当前档位的中心点超过200mV时才允许切到新的目标档位。这样做的好处是ADC采样的噪声、电源的微小波动、开关触点接触电阻的变化都被这个200mV的“死区”吞噬掉了不会出现档位在临界处来回抖动的现象。当然迟滞也不能设太大否则用户拨动开关后要等很久才响应体感很差。档位间电压差如果只有几百毫伏迟滞设200mV已经是上限了。遇到档位少的场景我会尽量拉开电阻差值把每个档位的电压中心点拉开1V以上这样迟滞区间可以设得更宽松用户体验和稳定性兼得。1.4 备选方案2个IO做二进制编码除了ADC方案还有一种做法是用2个IO直接读取4档状态前提是旋转开关的触点是独立的能自由组合。比如常见的四位二进制编码开关内部4组触点分别对应二进制位旋到不同位置会闭合不同的触点组合。将4组触点按两个IO口的上下拉组合方式接好就能实现2线读取4状态。实际接线做法是把4组触点按表1的方式分组两组触点一组接IO1、一组接IO2每一档通过不同的触点组合让IO1/IO2呈现00、01、10、11四种状态。档位IO1IO2触点闭合情况100K1闭合通地201K2闭合通地、K3闭合接VCC310K4闭合接VCC、K5闭合通地411K6、K7闭合分别接VCC这个方案相比ADC方案的好处是不需要ADC外设任意两个普通GPIO都能用坏处是开关本身必须支持这种触点组合而普通单刀4掷开关根本做不到需要选特定的编码旋转开关或拨码开关。所以我这次手里这个开关没有这种输出关系最终还是走了ADC分压方案。2. Modbus里float的拆分与还原2.1 为什么Modbus里传float这么麻烦Modbus协议自诞生以来寄存器宽度一直是16bit也就是说不管RTU还是TCP模式最小的数据单元是一个16位的字。而float在C语言里通常是32位必须拆成两个16位寄存器才能传输。问题就出在这个“拆”上。很多刚接触Modbus的工程师以为float不过是两个整型寄存器拼起来按顺序发过去就行。但实际上float在内存里的二进制表示遵循IEEE754标准在你把float类型强制转换成uint32的时候它并不是简单地等于数值而是被拆成了符号位、指数位和尾数位三部分。所以如果你按“先发高16位、再发低16位”的顺序把uint32的两个部分发过去上位机那边还要用同样规则把它拼回float这里任何一个环节字节序不一致收到的就是乱码。举个例子数字25.5的IEEE754十六进制表示是0x41CC0000。如果上位机把它当作0x000041CC来解读得到的值大约是2.386e-41完全不是25.5。这种情况在工程上特别常见两边都在各自开发都没有错错在“顺序”没有对齐。另外还有一个更隐蔽的问题C语言标准里float在内存中的存储顺序大小端是由硬件平台决定的而Modbus协议本身定义了寄存器内部高位在前却没有定义多个寄存器之间的组合顺序。于是不同厂商的设备就把这个顺序定义得五花八门衍生出了ABCD、CDAB、BADC、DCBA这四种常见的排列方式。2.2 四种字节序一张表看明白假设一个float的32位二进制按从高位到低位拆分成4个字节分别记为A、B、C、D其中A是最高字节、D是最低字节。那么这四种排列方式指的是两个Modbus寄存器内部以及两个寄存器之间的放置顺序排列方式第一个寄存器低地址第二个寄存器高地址常见设备ABCDABCD西门子等多数欧洲设备Modbus Poll默认CDABCDAB部分国产仪表、某些温控器BADCBADC少见部分老式仪表DCBADCBAAB PLC、部分三菱设备注意这里说的ABCD只是字节顺序的简写不是说寄存器里放的是ASCII字符。实际二进制拆法如下float 25.5 0x41CC0000其中A0x41、B0xCC、C0x00、D0x00。ABCD方式第一个寄存器0x41CC第二个寄存器0x0000CDAB方式第一个寄存器0x0000第二个寄存器0x41CCBADC方式第一个寄存器0xCC41第二个寄存器0x0000DCBA方式第一个寄存器0x0000第二个寄存器0xCC41看到区别了吗本质是两个维度的变化一是两个寄存器先后顺序是否颠倒二是寄存器内部的高低字节是否交换。Modbus标准只规定了一个寄存器里的高字节在前而跨寄存器顺序是厂商自定义的所以联调前第一步永远是先确认主站和从站用的是哪一种排列。注意Modbus Poll里查看数据时如果看到浮点数值完全不对但整数有意义十有八九就是这里出了问题。Modbus Poll支持在寄存器显示格式里切换“Float ABCD”和“Float CDAB”联调时先快速切换一下格式就能初步判断对方用的是哪种顺序。2.3 float拆分与还原的参考代码这里给出一个通用的、不依赖平台大小端的拆分和还原函数可以用在STM32上也能用在PC端、嵌入式Linux上。核心思路是把float的内存位模式拷贝到一个uint32变量里然后再按字节操作避免直接定义联合体带来的大小端隐患。typedef enum { MODBUS_FLOAT_ABCD 0, MODBUS_FLOAT_CDAB 1, MODBUS_FLOAT_BADC 2, MODBUS_FLOAT_DCBA 3 } modbus_float_order_t; void modbus_float_to_regs(float value, uint16_t *reg_hi, uint16_t *reg_lo, modbus_float_order_t order) { uint32_t bits; uint8_t b[4]; memcpy(bits, value, 4); b[0] (uint8_t)(bits 24); // A 最高字节 b[1] (uint8_t)(bits 16); // B b[2] (uint8_t)(bits 8); // C b[3] (uint8_t)(bits); // D 最低字节 switch (order) { case MODBUS_FLOAT_ABCD: *reg_hi ((uint16_t)b[0] 8) | b[1]; *reg_lo ((uint16_t)b[2] 8) | b[3]; break; case MODBUS_FLOAT_CDAB: *reg_hi ((uint16_t)b[2] 8) | b[3]; *reg_lo ((uint16_t)b[0] 8) | b[1]; break; case MODBUS_FLOAT_BADC: *reg_hi ((uint16_t)b[1] 8) | b[0]; *reg_lo ((uint16_t)b[3] 8) | b[2]; break; case MODBUS_FLOAT_DCBA: *reg_hi ((uint16_t)b[3] 8) | b[2]; *reg_lo ((uint16_t)b[1] 8) | b[0]; break; } } float modbus_regs_to_float(uint16_t reg_hi, uint16_t reg_lo, modbus_float_order_t order) { uint32_t bits 0; uint8_t b[4]; switch (order) { case MODBUS_FLOAT_ABCD: b[0] (reg_hi 8) 0xFF; b[1] reg_hi 0xFF; b[2] (reg_lo 8) 0xFF; b[3] reg_lo 0xFF; break; case MODBUS_FLOAT_CDAB: b[2] (reg_hi 8) 0xFF; b[3] reg_hi 0xFF; b[0] (reg_lo 8) 0xFF; b[1] reg_lo 0xFF; break; case MODBUS_FLOAT_BADC: b[1] (reg_hi 8) 0xFF; b[0] reg_hi 0xFF; b[3] (reg_lo 8) 0xFF; b[2] reg_lo 0xFF; break; case MODBUS_FLOAT_DCBA: b[3] (reg_hi 8) 0xFF; b[2] reg_hi 0xFF; b[1] (reg_lo 8) 0xFF; b[0] reg_lo 0xFF; break; } bits ((uint32_t)b[0] 24) | ((uint32_t)b[1] 16) | ((uint32_t)b[2] 8) | b[3]; memcpy(bits, bits, 4); // no-op仅为示意下面才是正确写法 return *(float *)bits; }上面还原函数倒数第二行我故意留了个无效操作实际工程不要写这个。正确写法还是要用memcpy拷贝避免直接通过指针类型转换引起的未定义行为float modbus_regs_to_float(uint16_t reg_hi, uint16_t reg_lo, modbus_float_order_t order) { uint32_t bits 0; uint8_t b[4]; // ... 上面的 switch 填充 b[4] ... bits ((uint32_t)b[0] 24) | ((uint32_t)b[1] 16) | ((uint32_t)b[2] 8) | b[3]; float value; memcpy(value, bits, 4); return value; }拆分的函数也同理最后不要用*(uint32_t *)value这种写法统一走memcpy最稳。C语言里的基于类型双关的强转会偶发优化问题虽然大多数编译器在默认优化级别下没问题但既然有标准做法就别给自己埋雷。2.4 浮点传输的精度陷阱float占用32位有效精度大概在6~7位十进制数字。在Modbus里传float经常遇到的一个场景是采集温度或者压力值比如温度是25.36℃这个值用float表示没有任何问题精度完全够。但如果你要传的是一个很大的整数比如累积流量计的累计值达到了12345678这时候float的有效精度就不够了显示出来可能是12345679或者12345678.5误差就来了。更常见的问题反而是负数。IEEE754里负数有符号位如果上位机解析时按无符号整数处理负数的原始位模式会变成一个很大的正整数比如-10.0的十六进制是0xC1200000当作无符号int看待是3246391296完全对不上。上位机如果用了有符号整型去解析也是不对的因为位模式是IEEE754的不是补码。所以在设计协议的时候我现在的习惯是能用整型传输的坚决不传float。比如档位号就是个0~255的整数用一个寄存器16bit就够了。温度如果精度要求0.1℃就先在单片机里乘以10存成short再传上位机拿到再除以10。这样不仅省寄存器还避免了浮点解析的顺序问题和精度问题。只有当数值范围变化大、又必须保持小数精度时才考虑使用float类型。3. 把档位和浮点数据装进Modbus报文3.1 寄存器地址规划这次采集板要从设备里上报的数据包括4档旋转开关档位1~4、板内温度float、电瓶电压float单位V。我规划的保持寄存器如下寄存器地址数据含义数据类型备注0x0000旋转开关档位uint161~40x0001板内温度float高16位ABCD与0x0002配合0x0002板内温度float低16位ABCD0x0003电瓶电压float高16位ABCD与0x0004配合0x0004电瓶电压float低16位ABCD寄存器不够用时可以直接申请更多的保持寄存器区间Modbus允许连续地址处理起来也方便。这里我故意把温度/电压的两个寄存器放在连续地址上就是为了直接支持上位机一次读多个寄存器的功能码0x03。3.2 数据刷新与协议栈对接我用的Modbus协议栈是FreeModbus在stm32f103标准库环境上移植的。模块的主循环里定时调用eMBPoll()处理协议栈事件应用层只需要在需要上报时把数据刷新到寄存器映射表里。关键代码大概是这样uint16_t usRegHoldBuf[5]; extern uint8_t sw_get_level(void); void app_update_modbus_regs(void) { uint16_t temp_hi, temp_lo; uint16_t volt_hi, volt_lo; float board_temp sensor_read_temperature(); float bat_voltage sensor_read_voltage(); // 档位直接填充 usRegHoldBuf[0] sw_get_level(); // float拆分固定使用ABCD顺序 modbus_float_to_regs(board_temp, temp_hi, temp_lo, MODBUS_FLOAT_ABCD); usRegHoldBuf[1] temp_hi; usRegHoldBuf[2] temp_lo; modbus_float_to_regs(bat_voltage, volt_hi, volt_lo, MODBUS_FLOAT_ABCD); usRegHoldBuf[3] volt_hi; usRegHoldBuf[4] volt_lo; }把数据刷新的调用放在1秒定时器里。注意不要在一个任务里又是刷新数据又是调用eMBPoll容易造成寄存器数组被读写冲突。如果工程里有RTOS建议把寄存器数组定义成volatile或者用临界区保护访问。3.3 用Modbus Poll在上位机侧验证联调的时候我习惯先用Modbus Poll这个工具模拟主站。打开软件后设置从站地址、功能码03、起始地址0000、寄存器数量0005再设置数据格式为Big-endian按Modbus协议默认就能直接看到档位和两个浮点值。如果发现浮点数据显示乱码先不要急着改代码在Modbus Poll里把浮点显示格式从“Float ABCD”切换到“Float CDAB”看一次。如果切换后数据恢复正常说明从机发的字节序是CDAB这就能快速定位问题出在字节序还是出在数据本身。档位数据则可以在实际旋转开关后观察对应寄存器的变化同时监听串口日志确保软件没有反复切换档位。如果看到档位在相邻两档之间抖动那就是迟滞窗口没设计好回看采集值再做调整。4. 调试中遇到的坑与排查速查表4.1 档位采集中遇到的两个案例第一个坑是ADC电压在电池供电时漂移。这块板子是电池供电的电池电压从4.2V下降到3.4V的时候我最初固定的换算比例就失效了导致同一档位在不同电量下采到的电压不一样。排查办法是用万用表实测VDDA和代码里的vref_mv对比马上就发现了问题。解决方法是在主循环里周期性读一次VDDA的ADC值动态更新换算比例。具体到STM32F103它内部有VREFINT通道可以用它校准VDDA代码写起来也不复杂。第二个坑是旋转开关本身的接触电阻。新品测试一切正常装到设备里用了两个月后偶尔出现档位从2跳成3的情况。用万用表量开关触点发现导通电阻从几十毫欧涨到了几十欧姆因为这个触点氧化了。在分压电路里接触电阻是串入分压通路的虽然几十欧姆对10kΩ量级的分压电阻影响很小但如果设计时选的电阻值太接近就会出问题。后来我把档位中心电压的间隔拉大了迟滞区间从150mV加到了250mV这个现象就消失了。4.2 浮点传输中踩过的两个隐藏bug第一个是本地验证一切正常到了客户现场数据完全乱掉。原因是客户的组态软件按CDAB方式读取而我们按ABCD方式发送。两边都是对的但组合不到一起。后面我在协议文档里明确标注了“浮点数采用ABCD字节序”同时把Modbus Poll里切到CDAB给客户演示了乱码效果问题一目了然。第二个是整型转float的精度丢失。当时传输一个电压值MCU里用ADC采样后已经做了mV换算存的是整数毫伏值比如5312表示5.312V。按理说完全可以按整数传但客户要求协议里按float传于是我在转换时除以1000后变成5.312但为了调试方便在日志里打印出来的值有时是5.3119998看起来非常难看。这只是打印精度导致的视觉误差不是传输错误但客户不理解。后来我干脆在协议设计成放大1000倍的整型数值彻底避开了这个解释成本。4.3 问题速查表现象可能原因排查方法档位在相邻档间跳变电压边界太接近、无迟滞加大档位电压间隔加迟滞判断电池供电时档位漂移ADC参考电压不是固定3.3V实时采集VDDA换算实际电压开关用久了误判触点接触电阻变大选择阻值拉开电压区间提高迟滞阈值浮点显示乱码主从端字节序不一致用Modbus Poll切换显示格式快速定位浮点小数位不准float精度限制或整型转float误差协议改用缩放整数传输负数解析成巨大正数上位机按无符号整型解析了float检查上位机数据类型设置同一帧数据两次读不一致刷新数据与协议栈读寄存器竞争寄存器数组加volatile保证原子刷新这个速查表我贴在工位上了每次配Modbus设备遇到对不上的情况先按表排查比从零分析快很多。最后再分享两个小习惯踩了这么多次坑之后我养成了两个习惯。第一个是拿到旋转开关第一件事不是查手册而是拿万用表把每一档的通断关系和阻值变化量一遍画成表格再设计电路。开关这种机械件数据手册画得再清楚也没有实测心里踏实。第二个是写Modbus相关代码前先和上位机工程师确认字节序和寄存器地址规划并且把这个规划写进通信协议文档里。哪怕只有一行“float采用ABCD顺序”也能省掉后续大量的联调时间。这两个习惯看起来不起眼但实实在在帮我省了好几个加班的夜晚。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门