恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
嵌入式实战:用ADC读取旋转开关档位与Modbus传输Float字节序解析
首页
资讯中心
/
嵌入式实战:用ADC读取旋转开关档位与Modbus传输Float字节序解析
嵌入式实战:用ADC读取旋转开关档位与Modbus传输Float字节序解析
发布时间:2026/9/6 9:07:22
先交代一下背景。这是一篇我自己攒下的嵌入式调试记录正好写到第6篇。前段时间在搞一块以STM32F103为主控的工控小板上板子正面有个4档旋转开关用来切换工作模式背面走的是RS485和上位机走Modbus RTU协议。按理说这两件事都不算新东西但真调起来一个卡在“IO不够用”一个卡在“float读回来全是乱码”前后折腾了我大半个下午。回头一想这两个坑其实特别典型一个是模拟信号换数字IO的思路一个是协议层面对内存字节序的理解都是嵌入式开发里绕不过去的基础功。这篇笔记就分两块讲前半段说4档旋转开关怎么用1路ADC代替4个GPIO电路怎么搭、阈值怎么划、软件怎么消抖后半段说Modbus里float为什么经常“拆不对、合不上”以及我实测下来最稳的几种拆分还原写法。项目的整体情况就是一块MCU控制板资源有限、上位机协议固定适合正在做类似设备或者其他单片机项目的朋友参考。1. 方案选型先想想“档位”到底是什么1.1 从功能需求反推IO占用这个4档旋转开关在设备上不是摆设它对应的是四种运行模式硬件上就是单刀四掷的机械开关。最开始设计时硬件同事的方案很朴素4个档位触点各接一个GPIO公共端接地程序里读4个引脚的电平组合来区分档位。这个方案理论上一眼就能看懂但它直接吃掉了4个IO。当时板子上GPIO已经排得很满一个串口、一个SPI屏幕、几个按键、两个LED再加上这4个IO选型就得往更大封装的芯片上走连带着PCB面积、成本都要涨。而且4个独立IO还有一个隐藏问题机械开关的触点不是理想的打到某个档位时其他触点悬空如果软件或者硬件上没有做上拉/下拉处理读取到的电平会受环境影响偶发误判。所以当我建议“不如走ADC采集”的时候硬件同事第一反应是“档位和模拟电压怎么对应”其实原理很简单不同档位接不同阻值的分压电阻ADC采集到的电压不一样用电压区间反推档位。1.2 为什么不是编码器、不是拨码开关有人可能会问要省IO那直接上旋转编码器不好吗或者换拨码开关一样也能减少IO。这个取决于应用场景。旋转编码器输出两路正交脉冲适合“旋转了多少格”这种增量场景程序要维护当前状态一旦上电时旋钮在什么位置是不知道的还得校准。而我们的需求是“上电就要知道旋钮现在处于哪个位置”这是个绝对位置检测编码器并不合适。拨码开关倒是能解决绝对位置但它的手感、面板安装方式、防护等级和产品定位都对不上而且拨码开关通常一小片不便于用户在设备正面操作。所以综合来看单刀四掷旋转开关本身就是最合理的机械选型剩下的问题只是“怎么少占IO”。把开关触点接成电阻网络、用ADC读电压正好完美匹配“绝对位置检测只占1个引脚”这两个约束这是当时选择这个方案的根本原因。另外从成本角度说ADC采样在很多MCU上是免费资源特别是STM32这种片上ADC通道多的芯片这个方案只增加了几个分压电阻几乎不增加硬件成本。而省下来的3个GPIO可以用来接更多按键或者功能口实用性一下子就上来了。2. 电阻分压方案设计先把电压算明白2.1 电路结构与档位电压计算先给一个最直观的电路模型。旋转开关是单刀四掷公共端接到MCU的ADC引脚同时对地接一个R0。另一端4个档位触点分别经过一个上拉电阻接到VDD。开关打到某个档位时相当于这个档位的上拉电阻和R0组成分压器ADC引脚电压就是VDD在该电阻串上的分压结果。这样选型的好处很明显每个档位只用一个电阻加上一个公共的R0总共5个电阻就能得到4个清晰可分的电压。我实测用的VDD是3.3VR0取10kΩ四个上拉电阻分别选3.3kΩ、10kΩ、30kΩ、100kΩ算出来的电压如下表这个表也是我调试时的原始记录开关档位上拉电阻理论电压12位ADC读数我的判定区间档位13.3kΩ约2.48V约3079≥ 2050档位210kΩ1.65V约20481200 ~ 2050档位330kΩ约0.825V约1024550 ~ 1200档位4100kΩ约0.30V约372 550这里每一档的电压间隔都超过0.45V对应到12位ADC码值相邻档的理论间隔都在1000个码左右容差空间相当充裕。计算电压的公式就是分压公式Vadc VDD × R0 / (R0 Rx)比如档位1Vadc 3.3 × 10 / (10 3.3) ≈ 2.48V。选择这些电阻值还有一个讲究它们都在E24序列里价格便宜、好采购精度只要1%就完全够用。ADC引脚上的对地电阻R0还有一个作用——当开关在换挡间隙、触点离开所有档位的短暂瞬间ADC引脚会通过R0被拉到0V程序读到接近0的数值可以用来判断“正在切换档位”而不是当成某个有效档位处理这个细节对后续消抖很有用。2.2 阈值划分的工程余量电压算完之后很多人会犯一个错误直接拿理论电压的中点当阈值然后压着边界用。这样不是不行但工程上我不建议因为机械开关的接触电阻会影响分压值电阻本身也有误差再加上ADC本身有量化误差压着边界判断必然会出现“临界抖动”。我更推荐的做法是每个档位的判定区间只取两个相邻档位理论电压之间大约60%70%的区域把靠近理论值的上下30%区域看作“安全缓冲带”。比如档位2理论读数2048档位3理论读数1024这两个档的边界我放在1200。这样即使电源电压有波动、电阻有误差只要ADC读数没有偏离理论值超过200多个码就不会误判。实际上我实测这板子的4个档位读数分别稳定在3010、2015、1010、360附近距离判定阈值都还有三五百码的余量怎么都不会串。另外要强调一点阈值划分不能只靠一次随机采样必须结合滤波。下一节会讲具体怎么滤波。2.3 硬件上的三个容易忽视的点第一个是共地。旋转开关装在设备面板上通过排线连到主板如果面板侧和主板侧的地不是同一个地ADC读到的电压会随机漂。我们这里因为是同一个外壳内的PCB组件共地没问题但如果是外接面板的走线建议ADC采样点旁边加一个0.1μF电容到地滤掉长线耦合的干扰。第二个是ESD。旋转开关是人工操作的金属端子容易被静电打MCU的ADC引脚属于模拟输入耐压和抗静电能力都不如数字IO强。我在产品化的时候在ADC脚上串了一个1kΩ电阻再并联一个100nF电容到地等于做了一个简单的RC低通滤波同时限制了ESD电流进入MCU。如果环境更恶劣可以再加一个TVS管成本几毛钱但能避免返修。第三个是参考电压。STM32F103的ADC参考电压默认是VDDA也就是模拟电源。如果VDDA和数字电源共用一个LDO而负载变化导致3.3V有较大纹波理论上分压点的绝对电压也会跟着波动。但实际上因为我们把分压电阻的上端接的就是同一路VDDADC的参考也是同源的这就出现了一个很有用的特性——两个同源抵消ADC读数约等于R0和Rx的比例而不是绝对电压。所以只要电阻比例稳定读数就基本不受电源波动影响。这算是这个方案的一个隐藏优点后面3.3节我会单独展开。3. ADC采集的软件处理读数之后还要过三关3.1 初始化与多次采样取均值STM32F103的ADC初始化很常规我用的标准外设库单通道、软件触发、12位分辨率。一个容易忽略的配置是采样时间通道的采样时间决定了采样保持电容充电是否充分。如果采样时间太短输入阻抗稍高就会导致读数偏低。我在ADC的采样时间上配到了最长的239.5周期因为分压电阻网络的等效内阻在几十kΩ量级长采样时间才能保证稳定。基础采样代码是这个样子void ADC1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); ADC_DeInit(ADC1); RCC_ADCCLKConfig(RCC_PCLK2_Div6); ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode DISABLE; ADC_InitStructure.ADC_ContinuousConvMode DISABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); } uint16_t ADC_ReadAverage(uint8_t times) { uint32_t sum 0; for (uint8_t i 0; i times; i) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); sum ADC_GetConversionValue(ADC1); } return (uint16_t)(sum / times); }连续采样8到16次之后取平均能明显减少白噪声和电源纹波带来的读数跳动。实际测试中我单次采样档位1的读数有时会在3060到3090之间跳取16次平均之后就稳定在3080左右这比任何复杂的数字滤波都直接有效。3.2 档位判定与软件消抖读数稳定之后判断档位本身不难用一个区间查找就行。但更关键的是防止换挡瞬间的误判。机械旋转开关在换挡过程中动触点会先断开当前档再滑到下一个档整个过程的毫秒级时间里ADC电压会先掉到接近0V再跳到新档位电压。如果程序在掉到0V的瞬间判定为“档位4”就会出现一次闪变。我的做法是加一层“连续确认”机制同一档位连续读到比如5次每次间隔10ms也就是说同一个状态必须稳定保持50ms以上才认为切换成功。这个思路跟按键消抖一模一样只是把“电平”换成了“档位状态”。typedef enum { SW_POS_UNKNOWN 0, SW_POS_1, SW_POS_2, SW_POS_3, SW_POS_4 } SW_Pos_t; SW_Pos_t SW_DetectPosition(uint16_t adc) { if (adc 550) return SW_POS_4; if (adc 1200) return SW_POS_3; if (adc 2050) return SW_POS_2; return SW_POS_1; } SW_Pos_t SW_GetPositionWithDebounce(void) { static SW_Pos_t lastPos SW_POS_UNKNOWN; static uint8_t confirmCount 0; SW_Pos_t curPos SW_DetectPosition(ADC_ReadAverage(8)); if (curPos lastPos) { if (confirmCount 5) return curPos; } else { lastPos curPos; confirmCount 0; } return lastPos; }这段代码里只有连续5次检测到同一个档位才返回这个档位即使换挡时出现一次0V的“空档”只要下一次又回到正常档位计数就会清零重来不会把瞬时误判当作档位切换。这是调试过程中最实用的一条经验。3.3 为什么这个方案对电源波动不敏感前面2.3节提了一句“同源抵消”这里展开讲因为这个特性很多人没意识到。ADC的转换结果本质上是“输入电压与参考电压的比例”在STM32上参考电压就是VDDA。而我们的分压电路上拉电阻的上端也是VDD。当电源从3.3V漂到3.0V时分压点电压会跟着比例降下来同时ADC参考电压也降了两个量同比例变化最后ADC读出来的数字几乎不变。这在硬件上相当于做了一个“自举”般的处理。对比一下如果用独立IO读档位3.3V降到3.0V只要电平阈值没越过问题也不大但如果分压电阻上端接的是另一个电源而ADC参考接VDD那电源漂移就会直接变成读数漂移。所以在硬件设计时一定要保证分压上拉和ADC参考用的是同一条电源轨。这个细节如果做错了软件写再多滤波也没用。4. Modbus侧float问题出在“内存视图”上4.1 档位采完之后怎么传出去旋钮档位确认完之后下一步就是把状态上报给上位机。项目里的设备参数不只档位一个还有一组模拟量比如温度、压力、速度这些上位机需要实时读取。上位机走的是Modbus RTU保持寄存器是16位宽一次读写的基本单位就是两个字节。问题是C语言里的float是32位占4个字节一份float数据正好要占两个保持寄存器。这看起来只是一个“一拆二、二合一”的过程但坑就在于你拆的时候是按什么顺序拆的。很多时候MCU这边往寄存器里写float可能只是把float的地址强转成uint16_t指针然后连续写两个寄存器。为什么这样会出乱码因为float在内存里不是“人类阅读顺序”存储的尤其在ARM Cortex-M这类小端处理器上数值0x41480000这样的float在内存里看到的字节顺序是00 00 48 41和你把寄存器读出来十六进制看到的顺序完全相反。协议、上位机、下位机三者的“字节视图”对不上就会解析出莫名其妙的数字。4.2 IEEE754格式和内存字节序float遵循IEEE754标准32位一共分三段最高位1位是符号位接下来8位是指数位最后23位是尾数位。以12.5f为例它的二进制科学计数法是1.1001×2³所以符号位是0指数是3加上127的偏移量得到1300x82尾数是10010000000000000000000。拼起来就是0x41480000。这个值如果是纯大端传输字节顺序应该是41 48 00 00。但是STM32是小端处理器float变量在内存里的地址从低到高存的是00 00 48 41。你如果直接把内存地址扔给Modbus发送函数发出的字节就是00 00 48 41上位机按IEEE754还原读出来的数值会是一个接近零的极小值或者干脆是NaN。问题就出在这个“内存视图”和“协议视图”之间的转换上。4.3 Modbus传输Float时的四种顺序在Modbus相关工具的设置界面里经常能看到“ABCD”“CDAB”“BADC”“DCBA”这四种选项它们对应的就是32位数据里四个字节的排列顺序。用大端视角下的字节序列来表示假设原始float是41 48 00 00那么模式字节顺序相当于高低16位解析结果ABCD41 48 00 00高字0x4148低字0x000012.5BADC48 41 00 00高字0x4841低字0x0000乱码CDAB00 00 41 48高字0x0000低字0x4148接近0的极小值DCBA00 00 48 41高字0x0000低字0x4841接近0的极小值这里最坑的一点是不同上位机软件对“ABCD”的叫法并不完全统一有的软件里“ABCD”指的是“寄存器内字节大端寄存器字序大端”有的则默认用“CDAB”。你光看参数表很难确定最好的办法是先用PC端工具动态切换选项看哪个值正常再回到下位机去固定自己的发送顺序。在我的项目里上位机最终配置的是ABCD所以下位机要把float按大端字节序填进两个寄存器高字在前、低字在后。5. float拆分还原的三种实现方式5.1 union方案代码最简洁推荐日常用union是我在日常开发里最常用的一种方式因为它把“同一块内存两种看法”表达得最直观。定义一个联合体里面既可以按float访问也可以按字节数组访问typedef union { float f; uint8_t bytes[4]; } FloatByteUnion; void FloatToRegs(float value, uint16_t *regs) { FloatByteUnion u; u.f value; regs[0] ((uint16_t)u.bytes[3] 8) | u.bytes[2]; regs[1] ((uint16_t)u.bytes[1] 8) | u.bytes[0]; } float RegsToFloat(uint16_t reg0, uint16_t reg1) { FloatByteUnion u; u.bytes[0] (uint8_t)(reg1 0xFF); u.bytes[1] (uint8_t)((reg1 8) 0xFF); u.bytes[2] (uint8_t)(reg0 0xFF); u.bytes[3] (uint8_t)((reg0 8) 0xFF); return u.f; }在小端MCU上FloatByteUnion u里4个字节的顺序是内存低地址到高地址对应b000、b100、b248、b341。由于我们要发送的是大端序41 48 00 00所以发送时要把b3放到寄存器0的高字节b2放到寄存器0的低字节再把b1放到寄存器1的高字节b0放到寄存器1的低字节。上面的代码就是这么干的。还原的时候倒过来先从寄存器里把4个字节抽出来再按小端内存顺序填进union最后读u.f就是原始float。整个过程只有位运算和赋值没有指针强转编译器和读者都不会产生误会。5.2 位运算配合指针适合数据搬运第二种方式是把float先当作32位整数处理再用位移拆成两个16位字。思路是这样先通过一个uint32_t的中间量拿到float的二进制位再按字节/字两个维度拆分。下面这段代码用memcpy把float的位模式拷进uint32_t避免直接指针强转可能带来的别名问题uint32_t bits; uint16_t regs[2]; memcpy(bits, floatVal, 4); regs[0] (uint16_t)((bits 16) 0xFFFF); regs[1] (uint16_t)(bits 0xFFFF);这串代码在小端CPU上bits这个uint32_t的数值是0x41480000右移16位后regs[0]是0x4148regs[1]是0x0000。这个结果和5.1节的union方案完全一样但看起来更“数学化”。当你有多个变量要打成一个发送缓冲区时这种方式的代码更紧凑先把每个float转成两个寄存器值再统一填入发送数组。还原的时候反向操作先拼回uint32_t再memcpy到float变量里。有一点要特别注意“寄存器0存高16位、寄存器1存低16位”是我们主动约定的协议行为不是天然正确。如果你的上位机配置的是CDAB模式也就是先低字后高字那发送时就要把regs[0]和regs[1]调换过来。所以代码本身不重要重要的是写代码的人清楚自己在按什么顺序传输。5.3 memcpy方案最安全和可读第三种方式直接用memcpy把字节搬进缓冲区适合在已有字节流缓冲区中做收发解析。比如你有一个uint8_t txBuf[8]要把一个float填充到第4到第7字节uint8_t txBuf[8]; float value 12.5f; uint8_t *p (uint8_t *)value; txBuf[4] p[3]; txBuf[5] p[2]; txBuf[6] p[1]; txBuf[7] p[0];这就是最简单的“小端内存转大端发送”的字节搬移。收到数据时反过来uint8_t rxBuf[8]; uint8_t *p (uint8_t *)value; p[0] rxBuf[6]; p[1] rxBuf[7]; p[2] rxBuf[4]; p[3] rxBuf[5];这种做法非常直观你甚至不需要知道union和位运算只要记住“高位字节放前、低位字节放后”。如果项目里还涉及其他字节序比如16位寄存器内字节交换只需要调整下标顺序逻辑不易写错也方便review。数据量少的时候我偏好这种方法数据量多、要封装通用协议解析器时我更推荐统一封装成5.1节的函数这样调用处干净。5.4 兼容不同大小端MCU的通用写法上面的代码默认MCU是小端。如果你的项目未来可能要跑在大端MCU上或者你正在写一个跨平台通信库可以在发送/接收函数里加一个预处理宏或运行时判断。运行时判断最简洁的方法就是取一个已知float的内存字节来看int IsLittleEndian(void) { uint16_t test 0x1234; return (*(uint8_t *)test 0x34); }然后根据大小端决定下标映射关系。实际工程中绝大多数场景都是小端ARM和x86但写成一个静态判断并不复杂防止将来移植时踩坑。我见过的不少“协议代码”移个平台就出乱码基本都是这里写死了。6. 现场调试问题实录与排查思路6.1 旋钮档位临界抖动的完整处理第一次上电测试时我把旋钮打在档位2观察上位机显示的档位状态发现它偶尔会跳到档位3过几百毫秒又跳回来。最开始怀疑是电阻精度问题但用万用表量分压点电压非常稳定。后来用示波器看ADC引脚波形才发现触点接触的瞬间存在几十毫伏到一百毫伏的跌落持续几毫秒。这不是电源问题而是机械开关接触电阻随机变化。针对这个问题我做了三件事一是把ADC采样时间调到最长二是把单次采样改成16次平均三是在状态切换判定里加了50ms连续确认。改完之后反复旋转开关几十次没有再出现档位闪变。这个经验也可以推广到金属按键、拨码开关等一切机械触点相关的模拟量采样场景。6.2 Modbus解析回去的float不对按什么顺序排查如果你遇到上位机读出来的float是不对的我的排查顺序是第一步上位机直接按“两个无符号16位整数”的格式读这两个寄存器看看原始是0x4148和0x0000还是0x4841、0x0041等。如果原始值已经不对问题在下位机发送逻辑如果原始值是对的问题就出在上位机的浮点解析顺序配置。第二步下位机这边先用Modbus调试工具读一次原始寄存器值对比发送端写入的值。我推荐先在PC上用Modbus Poll这类工具把两个寄存器按U16显示等看到的十六进制数值符合预期再切到Float显示去选字节序这样能很明确地定位是发送端还是接收端的问题。还有一个常见问题上位机读回来永远是“接近0的极小值”比如1.4e-42。这个现象十有八九是字节序反了。因为0x00004148这种低值段在IEEE754里属于非规格化数值极小而正常的物理量一般不会是这种数量级。看到这种数字第一反应就是检查“寄存器顺序是不是低字在前”。6.3 float传输的替代方案对比最后聊聊另一个思路既然float在Modbus里这么麻烦能不能干脆不用float直接把数值放大成整数再传这个方案在工业现场很常见比如把温度乘以10存成uint16_t上位机收到后自己再除以10。好处是省寄存器、没有字节序问题、可读性好坏处是精度固定、量程有限。以uint16_t为例最大65535如果温度范围-40到200摄氏度乘以10之后最大2000没问题但如果数据是压力0.001kPa级别的精细数值放大1000倍也可能溢出这时候只能用float或者32位整数。我的个人观点是如果协议是你自己定的而且数值范围和精度都很固定用“放大整数”是最省心的如果协议是甲方定的、或者设备要对接第三方组态软件那就必须老老实实传输标准float这个时候字节序的一致性就是生死线。我现在做的这个项目参数表是客户提供的明确要求按IEEE754 float传输所以只能把拆分还原做得严谨可靠。如果两种方案都可以选建议在项目初期就统一不要前十个参数用整数、后十个用float后期维护会非常难受。到这里两个核心问题的处理过程都讲完了。最近几次调试我越来越体会到很多嵌入式问题最终都是“内存和信号怎么约定”的问题旋钮档位看似是数字逻辑实际上用模拟电压表达更高效float看似是一个数值实际上在传输时只是一串字节。把这两层关系想清楚代码怎么写都顺。如果你也在做类似的项目建议先拿万用表把每个档位的实际电压量一遍再用Modbus调试工具把原始寄存器值空读一遍这两个实测数据能帮你少走太多弯路。