ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ADC省IO采集旋转开关与Modbus浮点传输实战

ADC省IO采集旋转开关与Modbus浮点传输实战 写嵌入式这段时间最常让我头疼的不是什么高深算法反而是那些看起来不起眼的小功能明明一个旋转开关就能解决的档位选择硬生生占了四个IO明明一个float温度值在Modbus寄存器里拆来拆去最后对不上号。今天这篇笔记就把这两个问题一次性讲透——4档旋转开关怎么用1路ADC省IO采集以及Modbus通信里float数据怎么拆分、怎么还原才能在PLC和上位机里不出错。都是我在实际项目里验证过的做法代码和电路图都是可以直接抄作业的。适合谁看搞STM32、单片机、工控仪表、传感器采集的嵌入式工程师还有刚入门想知道“IO不足怎么办”“Modbus浮点数为什么乱码”的初学者。看完你应该能直接在自己的板子上复现这两块功能。1. 旋转开关省IO的整体思路与方案选型1.1 为什么要想办法省IO做嵌入式项目的朋友都有这种体验MCU的IO数量永远不够用。一个稍复杂点的产品LCD要并口、按键要扫描、SPI要三根线、UART要两根线、还有几个传感器中断……等你把外设挂完发现留给拨码开关和旋转开关的IO已经没几个了。4档旋转开关——就是那种旋到1、2、3、4挡位有明显定位感的开关旋转时内部触点会接通不同的引脚比如常见的是公共端和对应档位引脚导通。如果按照最原始的办法四个档位用四个IO去读那就是妥妥的浪费。两个IO用二进制编码能读4档但接线要按编码表来软件还得做译码而且有些旋转开关本身只支持“单触点接通”你没法直接把它的输出当成二进制编码来用。用ADC分压法是性价比最高、也最省IO的方案——一路ADC就能完成四档识别。这对STM32F103这种自带多路ADC的MCU来说简直不要太合适反正ADC通道一般都有剩余用一路不心疼。1.2 三种采集方案横向对比方案占用IO数电路复杂度软件复杂度可靠性4路IO直接读4极简每路加下拉即可极简读电平就行最高每路独立2路IO二进制编码2需要改开关接线或用拨码开关组需要查表译码较高但接线易错1路ADC分压1适中一个上拉加几个分压电阻需要ADC采样和档位判定高注意电阻精度和滤波我实际项目里常用的是“上拉电阻 每档不同下拉电阻”的方案。原理很简单旋转开关公共端接MCU的ADC引脚同时通过一个固定上拉电阻接到VCC四个档位触点分别接不同阻值的下拉电阻到GND。旋到第N档时对应档位的下拉电阻与公共端连通ADC引脚处的电压就由这个下拉电阻和上拉电阻分压决定。不同档位对应不同电阻也就对应不同电压MCU只要测电压就知道当前是第几档。这方案的优点是只需要1个IO哪怕是BF板子的ADC通道紧张也扛得住。缺点是档位识别完全依赖电压值所以电阻精度、ADC参考电压精度和软件判定窗口都要设计好。这些细节我会在后面展开。1.3 为什么ADC方案在工业场景下够用有人担心ADC采集会不会受干扰旋转开关的触点有接触电阻电压会不会漂实际上只要做好三点——电阻分压区间够大、ADC采样加滤波、软件判定留足窗口这方案在工业现场完全没问题。我做过一个温控仪表项目面板上就需要一个4档旋转开关来选探头类型。如果用4个IO当时那个MCU的IO已经排满了用ADC方案后一路ADC搞定实测在-20℃到60℃的环境温度变化下档位判断从未出过错。关键在于旋转开关不像电位器那样需要连续电压识别它只有4个离散状态电压间隔就大抗干扰能力强得多。2. ADC分压采集的硬件设计2.1 电阻分压的具体计算先说电路结构假设VCC是3.3VADC引脚通过上拉电阻R_up接到3.3V旋转开关公共端接ADC引脚四个档位触点分别接R1、R2、R3、R4到GND。当旋到第1档时等效电路是R_up在上、R1在下ADC电压为V1 3.3 × R1 / (R_up R1)以此类推。我实际用的电阻值是R_up10kΩR11kΩR22.2kΩR34.7kΩR410kΩ算下来1档3.3 × 1 / (10 1) ≈ 0.30V2档3.3 × 2.2 / (10 2.2) ≈ 0.594V3档3.3 × 4.7 / (10 4.7) ≈ 1.106V4档3.3 × 10 / (10 10) 1.65V为什么选这几个阻值两个原因电阻都是E24系列常见值好买误差1%精度也不贵。挡位之间的电压差足够大。1档到2档差了约0.29V2档到3档差了约0.51V3档到4档差了约0.54V。STM32的12位ADC在3.3V参考电压下每个LSB大约是0.8mV0.29V的间隔相当于360多个LSB就算硬件上有噪声软件上有误差也不会判错。提示千万别用等比例电阻什么2k、4k、6k、8k因为分压公式是非线性的算出来电压不一定等间隔反而不利于判定。用上面这种“上拉固定、下拉递增”的组合电压单调递增且间隔都比较舒服。2.2 完整电路与引脚规划实际接线时我建议把齿轮开关放在板边走线短一点ADC采样脚和上拉电阻之间不要铺地铜太近避免数字噪声耦合。旋转开关选型上我用的是常见的1P4T旋转开关——一个公共端COM四个档位触点旋转有定位感。这种开关其实还可以用在键盘扫描、地址配置等场景用途很广。公共端接ADC引脚四个触点分别接四个下拉电阻电阻另一端统一接到GND。ADC引脚旁边的滤波电容建议加一个100nF到GND。作用有两个一是滤掉高频噪声二是给ADC采样电容提供电荷采样值更稳定。如果板子上有空间再串一个1kΩ电阻进ADC脚可以进一步限制噪声电流但注意串阻会和采样电容组成一个RC低通采样时间要留够否则会影响精度。好在我们的应用是按钮式的档位识别对采样速率要求极低慢一点完全没关系。2.3 省IO方案的另一个思路这里再提一个思路如果你不想用ADC也可以用RC充放电法省IO——用普通IO口输出高电平给电容充电再把IO切到输入模式测量电容放电到逻辑阈值的时间间接测电阻。这个方案不占ADC但测量时间不稳定、精度也差我试过一次就不再用了。ADC方案明显更省心前提是MCU得有ADC外设。如果你用的芯片连ADC都没有那只能老老实实上两路IO编码方案了。3. 软件读取与档位判定3.1 ADC采样的代码实现ADC初始化这块以STM32F103标准库为例。配置成单通道连续采样或者单通道单次转换都行关键是在读取函数里做多次采样取平均。我习惯的做法是每次档位判断时连续读8次ADC去掉最大值和最小值剩下的6次取平均然后再做档位判定。这样即使有一次采样被干扰也不会影响结果。uint16_t adc_read_average(void) { uint32_t sum 0; uint16_t val[8]; uint16_t max, min; uint8_t i; for (i 0; i 8; i) { val[i] adc_read_single(); // 单次转换结果 } max val[0]; min val[0]; for (i 0; i 8; i) { if (val[i] max) max val[i]; if (val[i] min) min val[i]; sum val[i]; } sum - max; sum - min; return (uint16_t)(sum / 6); }这种“掐头去尾平均法”比单纯的均值滤波效果好很多因为单次采样可能因为电源毛刺或开关抖动产生极值去掉极值后再平均结果基本就是稳定电压。3.2 档位判定阈值窗口怎么留得到平均ADC值后下一步是判定当前处于哪一档。理论上3.3V参考下12位ADC读0.30V是约372读0.594V是约738读1.106V是约1373读1.65V是约2048。但实际上电阻有误差参考电压有偏差所以不能拿这些理论值死卡得留判定窗口。我用的方法是先把每个档位的理论ADC值算出来再以相邻两档的中间值作为边界看采样值落在哪个区间。#define THRESHOLD_1_2 ((ADC_1ST ADC_2ND) / 2) #define THRESHOLD_2_3 ((ADC_2ND ADC_3RD) / 2) #define THRESHOLD_3_4 ((ADC_3RD ADC_4TH) / 2) uint8_t get_switch_position(uint16_t adc_val) { if (adc_val THRESHOLD_1_2) return 1; else if (adc_val THRESHOLD_2_3) return 2; else if (adc_val THRESHOLD_3_4) return 3; else return 4; }这个判定的逻辑是取相邻两档的理论值中点作为分界只要实际电压没有偏移超过“相邻间隔的一半”就不会判错。以1档和2档为例理论值中点是555实际如果电阻误差导致1档电压到了550还是很安全因为离中点还有一段距离。注意千万不要直接拿“离哪个理论值最近”来判定虽然逻辑上相似但代码里更常见的还是区间比较因为区间比较效率更高也不容易出边界问题。3.3 软件上的防抖处理旋转开关是机械结构旋的时候会有抖动触点也会在临界位置短暂接触不良。如果程序在主循环里反复读开关可能会在切换瞬间出现“1档→2档→1档”这样的跳变如果这个档位恰好控制着设备运行参数那就是个隐患。我的做法是给开关状态做一个“连续确认”机制第一次读到一个档位后不立即生效而是延时20ms再读一次如果两次结果一致才认为档位有效如果两次不一致继续等待。uint8_t get_switch_position_debounce(void) { uint8_t pos1, pos2; pos1 get_switch_position(adc_read_average()); delay_ms(20); pos2 get_switch_position(adc_read_average()); if (pos1 pos2) return pos1; else return get_switch_position_debounce(); // 重新确认 }这里用了递归实际项目里更推荐改成带计数的循环避免递归深度不可控。不过在这类低速场景下递归也没啥问题代码还更直观。20ms的延时要加在获取档位的函数里不能在主循环里统一延时因为主循环可能还有其他任务要跑。3.4 实测效果我在一块STM32F103C8T6最小系统板上做了验证把旋转开关接到PA1ADC1的通道1用串口打印档位值。连续旋转500次每次停留约1秒没有任何一次误判。ADC数值在静止状态下的抖动范围大约是±5个LSB远远小于档位间的几百个LSB间隔识别非常可靠。4. Modbus中float数据拆分还原原理4.1 Modbus寄存器与float的冲突Modbus协议本身是为PLC和工业设备设计的一个寄存器是16位能存的最大无符号整数是65535。但工程里的温度、压力、流量这些变量经常是float——32位。一个float塞不进一个寄存器就得拆分。比如我做的仪表要上报当前温度3.14℃如果用8位整数表示314精度不够因为可能是3.1415℃。用float就得拆成两个寄存器来传。关键问题是两个寄存器谁在前谁在后每个寄存器里高字节和低字节又是怎么排的这就是最容易出乱子的地方。4.2 字节序与寄存器顺序的坑先看float在内存里的真面目。一个float在C语言里占4字节按IEEE 754标准存储第31位是符号位第30到23位是指数第22到0位是尾数。通信双方只要都把同一段二进制数据按相同顺序拆装数值就不会错。以3.14f为例它在内存里的4个字节可能是71 0E 49 40小端序存储x86架构和ARM默认就是这种把它当成一个无符号32位整数看就是0x40490E71。Modbus寄存器传输数据时两个寄存器应该有约定。最常见的约定叫“ABCD”第一个寄存器存高16位第二个寄存器存低16位。也就是寄存器40001存0x4049寄存器40002存0x0E71接收方要还原就把第一个寄存器左移16位和第二个寄存器按位或起来得到0x40490E71再按float解释。但很多设备不是按ABCD来的还有BADC按字节对调、CDAB按16位字对调、DCBA全反等。Modbus Poll这类调试工具里就专门有这个选项顺序说明常见场景ABCD寄存器1高字在前寄存器2低字在后大多数PLCCDAB寄存器1低字在前寄存器2高字在后部分仪表BADC字节对调某些国产设备DCBA字节和字都反特殊协议栈我做项目的经验是先确认从站和主站约定的是哪种顺序如果两边不一致就会出现“数据看起来有数值但完全不对”的诡异现象比如读回来的温度是几千、几万或者负数看起来毫无规律。4.3 C语言实现拆分还原知道了原理代码就很简单了。拆分float时我推荐用memcpy因为用强制类型转换可能违反严格别名规则在编译器优化下出问题。拆分代码把float拆成两个16位寄存器值void float_to_modbus_regs(float value, uint16_t *reg_hi, uint16_t *reg_lo) { uint32_t raw; // 将float按位复制到uint32_t memcpy(raw, value, sizeof(raw)); // 取高16位和低16位 *reg_hi (uint16_t)(raw 16); *reg_lo (uint16_t)(raw 0xFFFF); }还原代码把两个16位寄存器值还原成floatfloat modbus_regs_to_float(uint16_t reg_hi, uint16_t reg_lo) { uint32_t raw; float value; // 组装成uint32_t raw ((uint32_t)reg_hi 16) | reg_lo; // 按位复制到float地址 memcpy(value, raw, sizeof(value)); return value; }注意这套代码默认是“寄存器1存高16位、寄存器2存低16位”也就是Modbus Poll里的ABCD顺序。如果你在对端设备上发现数据不对优先试试Modbus Poll里的其他三种Data Format大概率有一个能对得上。提示我们MCU内部存float是按小端存的但这套代码不需要管小端大端。你把float复制成一个uint32_t再手动拆高16位和低16位得到的是逻辑上的二进制值和内存字节排列无关非常安全。要是你用“取地址然后按字节发”的方式才会遇到大小端问题。5. 在STM32与FreeModbus里落地5.1 移植FreeModbus时寄存器表怎么规划我用的Modbus协议栈是FreeModbus v1.6移植到STM32F103标准库串口用RS232或者RS485都行。FreeModbus里用户只需要实现几个回调函数其中最重要的是读写保持寄存器Holding Register。假设我们的设备有当前温度float占两个寄存器、设备状态uint16占一个寄存器、启动/停止开关uint16占一个寄存器。那寄存器地址可以这样规划功能码寄存器地址协议地址数据格式说明03读保持寄存器40001uint16运行状态03读保持寄存器40002uint16温度高16位03读保持寄存器40003uint16温度低16位06写保持寄存器40001uint16启停控制在FreeModbus中读写保持寄存器的回调函数是eMBRegHoldingCB里面有一个usRegNRegs参数表示本次访问的寄存器数量。比如主站一次读3个寄存器就把寄存器表的三项都返回。5.2 把ADC采集到的档位和float数据一起上报假设我的仪表采集到的温度是一个float变量temperature旋转开关的档位是一个uint8_t变量switch_pos我想让主站能读到这个温度和档位。在主循环里先把温度拆到寄存器表把档位也放到寄存器表extern uint16_t usRegHoldingBuf[]; extern uint16_t usRegHoldingBufLen; void update_modbus_regs(void) { uint16_t reg_hi, reg_lo; // 温度寄存器假设温度值已经更新到temperature float_to_modbus_regs(temperature, reg_hi, reg_lo); usRegHoldingBuf[1] reg_hi; // 40002 usRegHoldingBuf[2] reg_lo; // 40003 // 档位寄存器放入低8位即可 usRegHoldingBuf[0] switch_pos; }注意FreeModbus的访问机制是当主站发起读写时协议栈会调用eMBRegHoldingCB把usRegHoldingBuf中的内容打包发送。所以我们的任务就是不断更新usRegHoldingBuf里的数据让寄存器表随时保持最新。但这里有个关键点——如果更新频率太快而主站正在读取寄存器会不会读到半新半旧的数据FreeModbus在发送数据期间我们更新usRegHoldingBuf是可能影响到正在打包的对像的。实际做法有两种在eMBRegHoldingCB回调里更新但这样要判断操作方向代码绕一点。在更新寄存器表前加一个临界区保护关中断后再更新更新完再开中断。我一般用第二种void update_modbus_regs(void) { uint16_t reg_hi, reg_lo; float_to_modbus_regs(temperature, reg_hi, reg_lo); __disable_irq(); usRegHoldingBuf[1] reg_hi; usRegHoldingBuf[2] reg_lo; usRegHoldingBuf[0] switch_pos; __enable_irq(); }因为关中断的时间只是两个寄存器赋值的瞬间毫秒级延时都算不上对整个系统实时性的影响可以忽略。5.3 用Modbus Poll验证还原正确性Modbus Poll是调试Modbus从站的神器强烈建议每个搞工控的开发者都备一份。新建连接时填从站地址、功能码、串口参数波特率、数据位、停止位、校验位然后点连接就能周期性读到寄存器值。读float时在Modbus Poll里把对应寄存器改显示格式为“float”或“float inverse”就能直接看到还原后的浮点数。如果你的从站按ABCD顺序就选Float ABCD如果按CDAB就选Float CDAB。不同的从站会有不同的顺序试一下就知道。我调试时会同时打开16位整型显示和float显示两列。整型列用来检查寄存器高16位和低16位是否正常拆分float列用来直观验证数值对不对。如果float列显示的数值和预期一致就说明写进寄存器的数据没问题如果不一致先别急着查协议栈先检查自己的拆分代码和字节序选择。6. 常见问题与排错技巧6.1 旋转开关部分的典型故障掉进这个坑的人不少我整理成一份速查表现象可能原因处理方法档位跳变显示忽1忽2旋转开关抖动或ADC采样太快没滤波加软件防抖延时20ms再确认一次某两个档位识别混淆分压电阻精度不够或ADC参考电压不稳换1%精度电阻检查电源纹波必要时加滤波电容所有档位都读到同一个值公共端接触不良或上拉/下拉电阻焊错万用表测各引脚通断和电压排查虚焊ADC值漂浮不定采样引脚悬空或噪声干扰检查开关是否在空档位加100nF滤波电容上电瞬间读错档位MCU复位时ADC尚未稳定主循环里延时100ms后首次采样校准我印象最深的一个问题是某批板子有大约5%在3档和4档之间偶尔误判。排查到最后发现是电阻批次问题3档用的4.7k电阻实际阻值偏差到了3%而那批板的3.3V电源纹波又比设计值大。后来把电阻全部换成1%精度并在ADC引脚加了一个100nF电容问题彻底消失。6.2 float数据乱码的排查思路Modbus读float读出来不对八成是顺序问题。我摸索出一套排查流程先把寄存器按无符号整型显示读出40001和40002两个值。用手算或者写个小脚本把这俩值按ABCD组合成一个32位数转成float看看。如果不是依次按CDAB、BADC、DCBA试。找到正确组合后看是哪种顺序然后去设备配置里把Modbus格式改成对应的或者改代码里的拆分顺序。举例我读到的400010x4049400020x0E71按ABCD组合是0x40490E71解析float就是3.14如果设备实际按CDAB传那40001的0x4049其实才是低16位的位置组合出来是0x0E714049解析出来就是一个特别大的数。这套方法基本能覆盖90%以上的float乱码问题。剩下10%是Modbus寄存器地址偏移搞错了——比如协议文档里说寄存器地址是0001但实际功能码读取时要从0000开始或者PLC侧地址需要加1也会让数据对不上。6.3 Modbus通讯层面的其他坑串口参数不一致从站和主站的波特率、校验位、停止位必须完全一致。STM32里配置的是8位数据位1位停止位无校验但PLC侧默认可能是8E1偶校验对不上就会一直报CRC错误。RS485方向控制FreeModbus在发送完成中断里控制DE引脚方向这个时序如果没做好会出现“回波干扰”或者“收到自己的数据”。我用的是RS485自动收发电路带延时芯片省了很多麻烦。寄存器数量不匹配主站读3个寄存器但从站定义的寄存器表长度不够FreeModbus会回复异常码。调试时看Modbus Poll右侧的“Exception Response”就能发现。最后再分享一个小经验这两个功能其实经常在同一个项目里出现旋转开关设置设备参数设备把这些参数通过Modbus上报给上位机或PLC。我建议你在做旋转开关档位判定和float寄存器规划时一定要把档位、温度、压力这些变量的含义都写进寄存器映射表里版本化保存。因为现场调试时你不一定记得自己当初把温度放在40002还是40003也不一定记得哪个设备用的CDAB顺序、哪个用的ABCD。有一张随时能查的映射表调试效率能提升一大截。另外旋转开关的档位最好不要只靠ADC电压唯一判定最好能在设备初始化时把当前档位打印到串口日志里这样以后联调时不用猜板子当前到底旋到哪了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进