STM32 ADC采集电压通过RS-485发送给PC的完整实现
简介面向STM32开发者的ADC采集与RS485通信参考工程适合需要实现模拟量采集并通过485总线回传上位机的工业数据场景。工程基于STM32标准外设库包含ADC通道初始化、电压采样转换以及串口转485发送的完整代码可直接在开发板上验证也能作为后续项目移植基线。压缩包共185个文件以.c/.h源码和头文件为主同时提供.s启动文件、.uvproj工程配置以及.hex/.axf编译镜像整体大小仅4.4MB目录结构清晰便于查阅主程序与底层库的调用关系。已有673人学习/下载对初学STM32通信或准备工业数据采集项目的开发者有直接参考价值可从中学习串口收发、485方向控制与ADC数据处理的实现思路并复用一套经过验证的工程模板。1. stm32_adc采集电压通过485发送给PC一条几十米电压回传链路的完整骨架stm32_adc采集电压通过485发送给PC说白了就是把ADC引脚上的模拟电压在本地先转成数字量再用RS-485差分总线把结果传回上位机。我最早在一条产线上做配电柜电压回传控制柜到中控室隔着近四十米直接拉模拟信号线压降和干扰都很明显把采集放在设备端、只把结果通过485上传一次就把问题解决了。这套链路适合两类人一是入门STM32之后想碰真实串口通讯协议的同学二是要快速部署远端电压监测的硬件工程师。它不涉及复杂嵌入式系统但恰好把ADC、UART、RS-485收发器和PC端解析四个环节全部串起来。下文按“器件怎么选、参数怎么配、代码怎么写、坑在哪里”的顺序展开例程基于一款常见的Cortex-M3内核通用单片机和HAL库换到其他型号只需替换引脚与时钟配置。2. 系统链路与硬件选型从ADC引脚到485芯片怎么接2.1 先看整条链路电压从哪里进来数据在哪里变成差分信号一个最简单的节点由四段组成被测电压源接到分压/滤波电路再进STM32的ADC引脚单片机把采样结果打包成若干字节通过UART送到RS-485收发器收发器把单端TTL电平转成A/B两根线上的差分信号电脑侧用一个USB转485适配器把差分信号还原成字节交给上位机解析。每一段都有自己容易翻车的地方。我习惯先想清楚“在哪一步把模拟量变成数字量”。传统做法是把电压信号直接拉到中控室几十米线缆会引入压降电机启停时还会耦合干扰信号源内阻与仪表输入阻抗不匹配时读数会偏高偏低。现在这套方案不同ADC直接在设备端完成数字化后面走的是RS-485逻辑电平与模拟信号相比485的差分电压只有几伏不易受地电位差和耦合噪声影响也没有了模拟长线的电容和压降问题。所以标题里的关键转折点在于“采集在本地传输的是打包后的电压值”。传输速率上9600bps是最稳的起点。一个9字节的帧在9600bps下大约需要9.4ms对电压监测这种慢变量完全够用。如果后面想挂多个节点9500bps或115200bps都行但我会建议先按9600调通再往高速走。2.2 485收发器怎么接MAX485与SP3485的接线差异从芯片选型看常见的低成本方案是MAX485和SP3485。MAX485的供电范围宽5V也能跑SP3485是3.3V供电版本和STM32F103这类3.3V系统直连时更省事。两者引脚功能基本一致接法也相同。如果板子上只有3.3V电源我一般直接用SP3485避免电平转换的额外麻烦。引脚功能接到哪里DI发送数据输入接STM32的UART_TXRO接收数据输出接STM32的UART_RXDE发送使能接一个GPIO方向控制脚/RE接收使能与DE连到同一个GPIOA差分正端接到双绞线的A线B差分负端接到双绞线的B线VCC电源3.3V或5V一致即可GND地与单片机共地典型的接法是把DE和/RE短接在一起用一个GPIO控制高电平表示发送低电平表示接收。注意485是半双工总线同一时刻只能有一个节点在发其他节点都在听。STM32的UART_TX和DI直连即可不需要加限流电阻RO如果暂时不用也可以悬空但我会建议还是接到UART_RX上联调时能借回环测试确认芯片好坏。A/B接线最容易出错的一点是极性。485标准规定A端电压高于B端时代表逻辑1A和B反接会导致接收端完全收不到数据这个细节我放在第5章展开。2.3 ADC模拟输入电路分压、滤波与基准电压如果被测电压范围在0到3.3V之间可以直接用一根线接PA0但更好的是串一个100Ω到1kΩ的限流电阻再并联一个0.1uF电容到地。这个电容能滤掉高频毛刺电阻能限制外部灌入电流防止芯片引脚损坏。如果被测电压范围不确定比如可能是0到10V那就得分压。分压电路我常用20kΩ与10kΩV_ADC V_in × 10 / (20 10)10V输入时V_ADC约3.33V刚好贴近3.3V参考范围。分压电阻的精度直接影响读数做批量校准时至少要选1%精度。另外分压电阻的源阻抗会拉长ADC内部采样电容的充电时间源阻抗越高需要的采样时间越长否则读数会偏低这也是很多人说“电压值怎么差这么多”的根源之一。基准电压方面F103的VREF内部接VDDA大多数开发板上就是稳压后的3.3V。LDO输出不一定正好3.300V可能是3.28V或3.32V这会导致ADC满量程对应电压偏移。要求高时用测量实际VREF再写进代码或外接一个高精度基准源。对初版验证来说3.3V基准已经够用。3. 用CubeMX配置工程ADC采样参数、UART与485方向控制3.1 CubeMX里ADC和串口的几个关键参数我用CubeMX生成工程的步骤大致如下以一款72MHz主频的通用单片机为例。时钟配置外部晶振8MHzPLL倍频到72MHzAPB2总线时钟也保持72MHzADC外设时钟从这里分频得到。ADC1使能通道IN0对应PA0引脚的“ADC1_IN0”模式设置为模拟输入。扫描模式关掉连续转换先关掉用单次转换更可控。分辨率选12位触发方式用软件触发采样周期选55.5周期起步。如果分压电阻源阻抗较大直接选239.5周期更稳。USART2开异步模式波特率96008位数据无校验1位停止位。PA2对应TXPA3对应RX。找一个空闲GPIO作为485方向脚比如PA8配置为推挽输出初始电平设为低代表接收状态。上述配置里最容易被忽略的是采样周期。ADC内部是一个采样电容采样时间就是给它充电的时间窗口。源阻抗越高充得越慢窗口太短时采到的电压明显偏低。50kΩ源阻抗下用1.5周期采样读数可能只有真实值的六七成改成55.5周期后基本恢复。3.2 ADC读取与电压换算的最小代码生成工程后ADC读取可以封装成一个函数我这里用HAL库的实现/* 返回电压值单位 mV */ uint32_t read_voltage_mv(ADC_HandleTypeDef *hadc) { uint16_t raw 0; uint32_t vol_mv 0; if (HAL_ADC_Start(hadc) ! HAL_OK) { return 0; } if (HAL_ADC_PollForConversion(hadc, 10) HAL_OK) { raw HAL_ADC_GetValue(hadc); /* 12位ADC满量程4095对应VREF电压 */ vol_mv (uint32_t)((float)raw * 3300.0f / 4095.0f); } HAL_ADC_Stop(hadc); return vol_mv; }这里有三个点要说明。第一HAL_ADC_Start启动一次转换HAL_ADC_PollForConversion会等待转换完成并返回状态超时设为10ms如果ADC坏了或配置不对这个函数不会一直死等。第二电压换算的公式是raw * 基准电压 / 4095代码里写3300mV如果你的板子VREF不是标准3.3V就改成实测值。第三HAL_ADC_Stop在单次转换模式下可以省掉但加上它更稳妥之后再次调用函数能保证从头开始一次完整转换。浮点运算在F103上会被编译器转成软件浮点库性能有开销但电压采集频率低完全可接受。如果想省事避开浮点可以用整数写法vol_mv ((uint32_t)raw * 3300u) / 4095u;效果一样。3.3 485发送函数方向切换顺序与TC标志485的半双工特性决定了发送前必须先把DE拉高让收发器进入发送模式发完后再拉低回到接收模式。看起来简单但时序上有讲究。下面是一个最小发送函数#define RS485_DE_PORT GPIOA #define RS485_DE_PIN GPIO_PIN_8 void rs485_turn_tx(uint8_t on) { HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, on ? GPIO_PIN_SET : GPIO_PIN_RESET); } uint8_t rs485_send_bytes(UART_HandleTypeDef *huart, uint8_t *data, uint16_t len) { if (data 0 || len 0) { return 0; } rs485_turn_tx(1); /* 先切到发送方向 */ HAL_UART_Transmit(huart, data, len, 100); /* 阻塞发送全部字节 */ while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) RESET); rs485_turn_tx(0); /* 末尾移出后再切回 */ return 1; }为什么一定要等待TC标志TC是发送完成标志表示最后一个字节的移位寄存器已经全部移位输出。如果只等TXE标志那只是数据从数据寄存器搬到了移位寄存器最后一个字节还在往外送。这时如果把DE拉低收发器会立刻切断输出最后一个字节的尾部被截断PC端收到就是半截帧。这个血泪经验我经历过不止一次记法也很简单先DE高再发送再等TC最后DE低。阻塞发送期间CPU是干等的这对一个只做电压采集和上报的小系统来说不是问题。如果后面想同时处理按键、屏幕刷新等其他任务再把发送改成DMA方式见第6章。4. 自定义帧协议并在PC端用Python解析电压4.1 为什么不能只发裸电压粘包、错帧与校验很多人第一次做串口通信时会想直接把电压值用UART发出去不就行了问题在于PC端收到的是一个没有边界的字节流。485总线上如果同时存在多个节点或者帧与帧之间间隔不稳定PC的串口缓冲区里可能一次收到两个半帧也可能一个帧被分成两次到达。如果程序按固定9字节去切一旦第一个字节错位后面全部错位。所以通信两端必须约定一个帧格式既要有能识别帧头的固定字节也要有长度字段来告诉接收方这一帧数据有多长最后还要有校验字段来判断这一路数据有没有传错。电压采集属于慢变量哪怕偶发一帧CRC错丢掉了也不影响使用但如果不做校验错帧会显示成异常电压反而更难排查。对于这种短帧我用过最简单的格式是帧头两字节设备地址数据长度两字节电压CRC16两字节帧尾一共9字节。帧头固定为0xAA 0x55帧尾固定为0x0D中间字段全部有明确含义。4.2 帧格式与STM32发送端的CRC16实现我使用的帧格式定义如下字段长度说明0xAA1字节帧头1进入帧识别0x551字节帧头2防止误入设备ID1字节例如0x01多节点时区分设备数据长度1字节固定0x02代表后面数据区为2字节电压高字节1字节电压值单位mV高8位电压低字节1字节电压值单位mV低8位CRC16高字节1字节对“设备ID、长度、电压高、电压低”计算CRC16低字节1字节同上0x0D1字节帧尾可辅助校验帧完整性CRC16算法我直接用经典的Modbus CRC16初值0xFFFF多项式0xA001。STM32端代码很短uint16_t crc16_modbus(const uint8_t *data, uint32_t len) { uint16_t crc 0xFFFF; for (uint32_t i 0; i len; i) { crc ^ data[i]; for (uint32_t j 0; j 8; j) { if (crc 1) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }打包发送的代码是这样的uint8_t tx_frame[9]; uint16_t vol_mv read_voltage_mv(hadc1); tx_frame[0] 0xAA; /* 帧头1 */ tx_frame[1] 0x55; /* 帧头2 */ tx_frame[2] 0x01; /* 设备ID */ tx_frame[3] 0x02; /* 数据长度 */ tx_frame[4] (uint8_t)(vol_mv 8); /* 电压高字节 */ tx_frame[5] (uint8_t)(vol_mv 0xFF); /* 电压低字节 */ uint16_t crc crc16_modbus(tx_frame[2], 4); tx_frame[6] (uint8_t)(crc 8); /* CRC高字节 */ tx_frame[7] (uint8_t)(crc 0xFF); /* CRC低字节 */ tx_frame[8] 0x0D; /* 帧尾 */ rs485_send_bytes(huart2, tx_frame, sizeof(tx_frame));这里我算CRC时只算“设备ID、长度、电压高、电压低”这4个字节帧头和帧尾不参与计算。原因是帧头主要用于对齐查找帧尾用于辅助判断帧结束它们都直接影响校验逻辑的话会让PC端排查变复杂。两端约定好就行不是标准答案。4.3 PC端Python串口解析代码与调试输出PC端我用Python加PySerial库先安装依赖pip install pyserial解析代码的核心思路是把所有读到的字节先放进一个缓冲区每进入一次循环就尝试从缓冲区头部解析一帧成功就返回电压失败就弹掉一个字节重新对齐。这样粘包和半包都能处理帧头连续出现也不会卡死。import serial def crc16_modbus(data: bytes) - int: crc 0xFFFF for b in data: crc ^ b for _ in range(8): if crc 1: crc (crc 1) ^ 0xA001 else: crc 1 return crc def try_parse(buf: bytearray): 从缓冲区解析一帧电压数据解析不到就返回None while len(buf) 9: if buf[0] ! 0xAA or buf[1] ! 0x55: buf.pop(0) continue # 数据长度应为2字节帧尾应为0x0D if buf[3] ! 0x02 or buf[8] ! 0x0D: buf.pop(0) continue calc crc16_modbus(bytes(buf[2:6])) got (buf[6] 8) | buf[7] if calc ! got: buf.pop(0) continue vol_mv (buf[4] 8) | buf[5] device_id buf[2] del buf[:9] return (device_id, vol_mv) return None ser serial.Serial( portCOM3, baudrate9600, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout0.2 ) buf bytearray() while True: r ser.read(1024) if not r: continue buf.extend(r) result try_parse(buf) if result: dev, mv result print(f设备 {dev}: {mv} mV {mv / 1000.0:.3f} V)这段代码的边界处理逻辑值得说明。len(buf) 9先保证缓冲区里至少有一整帧的长度再开始解析避免不完整帧被当错帧弹出。buf[0]和buf[1]不是帧头时只弹一个字节因为下一个字节可能是真正的帧头。帧尾或CRC校验失败时同样弹一个字节让后续字节有机会重新对齐。串口参数列表里timeout0.2意味着ser.read(1024)最多阻塞0.2秒有数据就立即返回已读到部分。如果你在Linux系统上端口名需要改成/dev/ttyUSB0这类实际设备名。调试时可以把r打印出来先用串口助手确认STM32端确实在发数据再让Python接管解析。5. ADC采集电压走485的典型问题与排查顺序5.1 收到全0或全4095输入悬空与采样时间不足现象上位机稳定显示0.000V或接近满量程不管怎么碰硬件都不变。原因通常有两种。第一PA0引脚悬空ADC输入没有确定电平读到的数值在某一个随机点徘徊如果板子内部有下拉或上拉结果就固定为0或满量程。第二源阻抗太高而采样周期太短采样电容在窗口内没有充满读数低于实际值严重时甚至接近0。解决先用万用表量PA0引脚电压确认确实有电压信号再在CubeMX里把采样周期拉到55.5周期或239.5周期最后给输入端一个确定的分压电压排除接线问题。基本5分钟内就能定位。5.2 最后一个字节总是丢方向切换时机不对现象PC端收到的帧长度总差1字节或帧尾0x0D丢失CRC校验经常失败偶发下一帧的第一个字节是0x0D。原因DE引脚拉低得太早。串口发送分两步先写入数据寄存器再逐位移出如果只等发送完成中断就切方向最后一个字节还在移位寄存器里485收发器输出被中断尾巴就丢了。解决使用我在3.3节里写的__HAL_UART_GET_FLAG(huart, UART_FLAG_TC)等TC置位后再拉低DE。如果使用HAL的HAL_UART_Transmit它内部已经等待过TC但保险起见再等一次并没有坏处。还有一种玄学情况是USB转485适配器切换收发方向需要额外时间这时可以再追加一个1字节时间的延时让总线从容进入空闲态。5.3 A、B接反导致完全无数据现象上位机长时间收不到一帧数据用示波器看A/B线也没有正常波形或者收到一堆0xFF乱码。原因485空闲态要求A电平高于B电平A、B接反后整个总线极性反向收发器无法正确识别起始位自然什么都解不出来。这是485连线最容易犯的错。解决把收发器A端与USB转485适配器的A端对齐通常对应DB9针头的3脚B对应8脚线材颜色约定是A白B蓝。联调前先用万用表测A、B之间的直流电压空闲时应在2V到5V之间如果测到负值就是反了。5.4 拉长距离后频繁CRC错接地与终端电阻现象桌面上1米内联调一切正常装到现场后30米左右开始断断续续出现CRC错误重发次数多到影响使用。原因485是差分传输能抵消两根线上的共模干扰但前提是两端设备的逻辑地电位差不超过收发器允许范围。很多工程偷懒只接A、B两根线两台设备地电位不同共模电压一大收发器输出就会混乱。长距离下还有信号反射问题没有终端电阻会让波形过冲。解决先把A、B之外再拉一根地线把STM32板和USB转485适配器的GND连起来这一步往往能解决七成问题。距离超过30米时在总线两端各接一个120Ω终端电阻吸收反射波。注意终端电阻只接两端不要在每个节点都接。5.5 与万用表读数偏差过大基准电压与分压电阻误差现象万用表量到1.230V程序读回来只有1.08V或者普通电压段都偏低。原因一个是分压电阻精度不够标称10kΩ的实际值可能是10.5kΩ分压比变了另一个是板子的VREF不是整3.3V满量程对应电压偏低还有一个是样品保持时间太短输入阻抗对分压网络形成负载效应。解决先用万用表实测VREF电压把换算代码里的3300换成实际值。分压电阻换成1%精度并用电桥测出真实分压比。如果输入源阻抗本身就高在ADC引脚和分压点之间加一个运算放大器做电压跟随器测出来的值就和理论值基本对上了。6. 从调通到好用平均滤波、DMA发送与联调自测技巧电压采集这种慢变量最立竿见影的改进就是做软件平均。ADC单次采样总有些随机噪声我一般连续采16次去掉最大最小值再做均值。注意平均次数不要太多16次在9600波特率下不会拖慢发送节奏但如果你把电压当控制信号用过度平均会掩盖真实变化。uint32_t vol_mv_sum 0; for (int i 0; i 16; i) { vol_mv_sum read_voltage_mv(hadc1); } vol_mv vol_mv_sum / 16;发送端如果把阻塞轮询改成DMACPU就能腾出来处理其他任务。DMA的920发送方式是在HAL_UART_Transmit_DMA之后在HAL_UART_TxCpltCallback回调里把DE拉低。这个回调只会在整帧DMA完成后触发方向切换时机反而比轮询更好控制。注意DMA发送需要在主循环里避免重复发送同一缓冲区否则下次启动DMA之前上一帧可能还没发完。联调时我最推荐先做一个回环自测把STM32板的485输出A、B直接短接回自己的RO或者在PC端把USB转485适配器的A、B直接短接用串口助手发什么收什么。这个测试能快速确认PC端串口通道、驱动和波特率没有问题再接入STM32节点。第二步是固定发送已知数据比如先把电压值写死成1234mVPC端看解析是否正确这样能排除ADC部分的干扰让故障定位更聚焦确认485链路无误后再恢复真实ADC采样。这套链路从调通到稳定我最大的体会是“先锁通信再查采集”。串口和485的坑往往比ADC本身多把帧格式和方向切换理清楚后面只是多做几轮滤波。希望帮到你。本文还有配套的精品资源点击获取