写嵌入式这段时间,最常让我头疼的不是什么高深算法,反而是那些看起来不起眼的小功能:明明一个旋转开关就能解决的档位选择,硬生生占了四个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.3V,ADC引脚通过上拉电阻R_up接到3.3V,旋转开关公共端接ADC引脚,四个档位触点分别接R1、R2、R3、R4到GND。
当旋到第1档时,等效电路是R_up在上、R1在下,ADC电压为:
V1 = 3.3 × R1 / (R_up + R1)
以此类推。我实际用的电阻值是R_up=10kΩ,R1=1kΩ,R2=2.2kΩ,R3=4.7kΩ,R4=10kΩ,算下来:
- 1档:3.3 × 1 / (10 + 1) ≈ 0.30V
- 2档:3.3 × 2.2 / (10 + 2.2) ≈ 0.594V
- 3档:3.3 × 4.7 / (10 + 4.7) ≈ 1.106V
- 4档:3.3 × 10 / (10 + 10) = 1.65V
为什么选这几个阻值?两个原因:
- 电阻都是E24系列常见值,好买,误差1%精度也不贵。
- 挡位之间的电压差足够大。1档到2档差了约0.29V,2档到3档差了约0.51V,3档到4档差了约0.54V。STM32的12位ADC在3.3V参考电压下,每个LSB大约是0.8mV,0.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最小系统板上做了验证,把旋转开关接到PA1(ADC1的通道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低字在后 | 大多数PLC |
| CDAB | 寄存器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位寄存器值还原成float):
float 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读保持寄存器 | 40001 | uint16 | 运行状态 |
| 03读保持寄存器 | 40002 | uint16 | 温度高16位 |
| 03读保持寄存器 | 40003 | uint16 | 温度低16位 |
| 06写保持寄存器 | 40001 | uint16 | 启停控制 |
在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, ®_hi, ®_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, ®_hi, ®_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格式改成对应的,或者改代码里的拆分顺序。
举例:我读到的40001=0x4049,40002=0x0E71,按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电压唯一判定,最好能在设备初始化时把当前档位打印到串口日志里,这样以后联调时不用猜板子当前到底旋到哪了。