这段时间在调一块基于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Ω,四个下拉电阻分别取R1=680kΩ、R2=100kΩ、R3=27kΩ、R4=4.7kΩ,根据分压公式 Vout = VCC × R_down / (R_up + R_down):
- 档位1(R1 680k):V1 = 3.3 × 680 / (10 + 680) ≈ 3.25V
- 档位2(R2 100k):V2 = 3.3 × 100 / (10 + 100) ≈ 3.00V
- 档位3(R3 27k):V3 = 3.3 × 27 / (10 + 27) ≈ 2.37V
- 档位4(R4 4.7k):V4 = 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位ADC(0~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值转换成mV,vref_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四种状态。
| 档位 | IO1 | IO2 | 触点闭合情况 |
|---|---|---|---|
| 1 | 0 | 0 | K1闭合通地 |
| 2 | 0 | 1 | K2闭合通地、K3闭合接VCC |
| 3 | 1 | 0 | K4闭合接VCC、K5闭合通地 |
| 4 | 1 | 1 | K6、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寄存器内部以及两个寄存器之间的放置顺序:
| 排列方式 | 第一个寄存器(低地址) | 第二个寄存器(高地址) | 常见设备 |
|---|---|---|---|
| ABCD | AB | CD | 西门子等多数欧洲设备,Modbus Poll默认 |
| CDAB | CD | AB | 部分国产仪表、某些温控器 |
| BADC | BA | DC | 少见,部分老式仪表 |
| DCBA | DC | BA | AB PLC、部分三菱设备 |
注意这里说的ABCD只是字节顺序的简写,不是说寄存器里放的是ASCII字符。实际二进制拆法如下:float 25.5 = 0x41CC0000,其中A=0x41、B=0xCC、C=0x00、D=0x00。
- ABCD方式:第一个寄存器0x41CC,第二个寄存器0x0000
- CDAB方式:第一个寄存器0x0000,第二个寄存器0x41CC
- BADC方式:第一个寄存器0xCC41,第二个寄存器0x0000
- DCBA方式:第一个寄存器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 | 旋转开关档位 | uint16 | 1~4 |
| 0x0001 | 板内温度 | float高16位(ABCD) | 与0x0002配合 |
| 0x0002 | 板内温度 | float低16位(ABCD) | |
| 0x0003 | 电瓶电压 | 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顺序”,也能省掉后续大量的联调时间。这两个习惯看起来不起眼,但实实在在帮我省了好几个加班的夜晚。