
简介基于STM32F103的RS485串口Modbus协议开关量数据采集与控制工程面向单片机开发者和工业自动化应用人员解决STM32作为Modbus RTU从站时的远程开关量采集与控制问题适合用于串口设备联网、PLC通信及工控IO模块开发。工程选用FreeModbus协议栈主函数中清晰展示了端口初始化、eMBInit参数配置、eMBEnable启动协议栈以及循环调用eMBPoll处理请求的流程并结合LED_Poll和Button_Poll演示线圈寄存器与物理输入输出的映射刷新方式便于理解Modbus寄存器读写机制。压缩包共106个文件包含48个C源码、45个头文件以及Keil工程配置、可烧录hex、辅助批处理脚本等整体仅374KB。目前已有1412人学习下载工程结构完整、注释清晰可直接移植用于实际项目也可作为毕业设计或课程设计的参考方案。1. 为什么是 STM32F103 RS485 Modbus 这套组合工控现场最常见的一个诉求就是让一块 MCU 采集一堆干接点信号、再驱动几个继电器同时上位机或 PLC 需要实时读状态、下命令。实现这个需求最不折腾的路径就是 STM32F103 做从站、RS485 做物理链路、Modbus RTU 做应用协议代码量不大协议栈还可以完全自己维护。别看这套东西年代久远它仍然是配电柜、环保监测、农业灌溉这类场景里出镜率最高的方案——原因在于 RS485 的抗干扰能力和 Modbus 的极简帧结构至今没有更好的替代。这篇博文就顺着「物理层电路 → 协议帧分析 → 从站协议栈实现 → 现场调试」的完整链路讲透代码给到手边能直接抄的版本适合要做开关量采集板的嵌入式工程师也适合被 PLC 维修师傅拉着改方案的设备集成人员。信息密度会偏高但每一节都能落地。2. RS485 链路与 Modbus RTU 帧先厘清物理层与协议层的分工2.1 物理层半双工差分链路与自动收发电路怎么选RS485 是半双工差分总线用 A、B 两线的电压差表示逻辑 1 和 0共模抑制能力强所以能支持 1200 米级传输和最多 32 个节点标准负载。这决定了它的驱动方式发送时把收发器置为发送态发完一帧要立刻切回接收态否则总线上别的从站回数据时你自己还在占线。收发控制有两种常见做法。第一种是 MCU 用独立 GPIO 控制收发器的 DE/RE 引脚发送前拉高、发送完拉低时序完全受控。第二种是自动收发电路把 TXD 信号通过电容、三极管或 NMOS 反相后接到 DE 和 RE 上让硬件自己完成方向切换。自动收发的优点是省一路 GPIO但代价是速率上限和总线空闲电平的表现不如 GPIO 控制稳定尤其在高波特率下容易发生「最后一个字节被截断」。我一般推荐 GPIO 控制方案一主多从时更稳妥如果板子面积紧张再上自动收发。电路上STM32F103 的 USART_TX 接收发器 DIUSART_RX 接 RO方向控制脚接 DE 和 RE 短接后的公共端。2.1.1 自动收发的经典参考电路以常用的 MAX3485 为例如果非要做自动收发典型电路是这样的TXD 经过一个 NMOS如 2N7002反相后去控制 DE/RE同时在 DE/RE 上接 10kΩ 下拉电阻保证空闲时处于接收态。具体连接// 伪代码描述连接关系实际是硬件接线 // MCU_TXD ---- MAX3485 DI // -- NMOS 栅极NMOS 漏极接 DE/RE源极接地 // MAX3485 RO -- MCU_RXD // 注意自动收发方案中DI 悬空时总线会被 A 上拉、B 下拉钳位这套电路的核心思路是在 TXD 为高时 NMOS 导通、DE/RE 拉低收发器处于接收态TXD 为低时 NMOS 截止、DE/RE 被上拉电阻拉高进入发送态。因为 UART 空闲时 TXD 是高电平所以平时都停在接收状态。要提醒一句这种方案在发送起始位由高变低时才切到发送态但 DE 建立时间通常需要几百纳秒波特率超过 115200 后起点易失真。实测中我都是保守用 9600 或 19200能稳定跑通。2.1.2 开关量输入采集光耦隔离与硬件滤波开关量输入的本质是接一个干接点或 NPN 三极管开漏输出MCU 通过检测电平变化来判断通断。但现场线缆长、电机启停频繁地电位差和浪涌都可能顺着信号线进 MCU所以光耦隔离几乎必须。典型接法是外部 24V 电源经限流电阻进光耦如 PC817、TLP521的阳极接点闭合时发光二极管导通光耦输出侧三极管拉低 MCU 的 GPIOGPIO 配置为输入上拉。这个方案的优点是输入和 MCU 完全电气隔离共模电压再乱也不怕。参数上限流电阻取值要让光耦原边电流在 5~10mA 之间。24V 供电时R (24 - 1.2) / 0.008 ≈ 2.7kΩ取 2.7kΩ 或 3.3kΩ 都行。MCU 一侧加 10nF 电容到地做硬件滤波再结合软件消抖。2.1.3 开关量输出继电器/晶体管驱动与续流二极管输出侧控制继电器、电磁阀或指示灯。直接拿 GPIO 驱动继电器不现实因为继电器线圈电流几十毫安、反向电动势能到几百伏。中间必须有驱动级。常见电路是 NPN 三极管或 ULN2003 驱动继电器线圈线圈两端反向并联续流二极管如 1N4148否则关断瞬间的反峰会击穿驱动管。具体参数5V 继电器线圈电阻约 70Ω工作电流约 70mAGPIO 经 1kΩ 电阻驱动 S8050 即可基极电流约 3mA放大倍数按 100 算能驱动 300mA 以内负载余量充足。2.1.4 RS485 终端电阻与总线偏置总线两端要各接一个 120Ω 终端电阻匹配阻抗节点多时还要考虑 A、B 间偏置电阻。常见做法是在主站端加 510Ω 上拉、下拉到 A/B 线保证总线空闲时 A 高于 B避免误收噪声。从站侧也可预留 120Ω 焊盘位置。2.2 协议层Modbus RTU 的帧结构、功能码与 3.5 字符间隔物理层搞定了接下来是 Modbus RTU 帧结构。Modbus RTU 是主从式协议一主多从从站地址范围 1~2470 是广播地址。报文格式固定为地址码 | 功能码 | 数据区 | CRC16 低字节 | CRC16 高字节 1B | 1B | N B | 1B | 1B帧与帧之间的时间间隔必须不小于 3.5 个字符传输时间。以 9600 波特率、无校验 8N1 为例一个字符 10 位3.5 字符就是 3.5 × 10 / 9600 ≈ 3.65ms。串口中断里必须用定时器或系统节拍来做帧间隔判断收到一字节后计时超过 3.5 字符没来下一字节就算帧结束。开关量采集控制涉及的功能码就四个功能码含义操作对象0x01读线圈输出口状态0x03读保持寄存器可读写的 16 位寄存器0x05写单线圈单个输出口0x06写单个保持寄存器单个寄存器采集开关量输入时用 0x02 读离散输入更规范但如果输入状态已经被映射到保持寄存器里直接用 0x03 读也能用很多现成固件就这么干。2.3 波特率、超时与地址分配三个要在工程上定的参数Modbus RTU 从站的波特率设置要和主站一致085 不支持自动波特率检测一旦不匹配帧接收就乱套。常见选择是 9600 或 19200距离远、干扰强的现场用 9600距离短、数据量大的场合用 19200。地址分配规划好从站地址写在 Flash方便现场通过拨码或上位机修改。超时参数也有讲究帧间超时设 3.5 字符时间但主站发起请求到从站返回响应的响应超时建议设 100ms 以上给从站留出处理时间和总线仲裁余量。这个参数在测试工具或 PLC 组态里配置不在单片机侧。3. 硬件落地从 MCU 管脚到接入开关量输入输出3.1 最小系统之外的 RS485 收发器电路STM32F103 的 USART1 默认引脚是 PA9/PA10USART2 是 PA2/PA3USART3 是 PB10/PB11。工程上我习惯用 USART1 做调试日志RS485 走 USART3这样调试信息和总线通讯互不干扰。具体的连接关系参照 2.1 节这里给一个 SW 无干扰的完整推荐电路参数收发器MAX34853.3V 供电直接和 STM32 同电源或 SP3485DE/RE 控制MCU 任意 GPIO输出推挽接 10kΩ 下拉保证上电时处于接收态A/B 线各串一个 10Ω 电阻做限流再并一个 TVS 管如 SMBJ6.0CA到地终端电阻 R120 预留 0603 焊位组网时按需焊接3.2 开关量输入采集的完整回路计算以 PC817 光耦为例假设外部接入 24V 电源正向压降 VF 约为 1.2V电流传输比 CTR 至少 50%。要让输出侧 MCU 引脚可靠拉低原边电流取 5~10mAR_in (24V - 1.2V) / 8mA ≈ 2.85kΩ取标准值 3kΩ 光耦输出侧上拉电阻取 10kΩMCU GPIO 配置为输入模式MCU 引脚内部上拉约 30~50kΩ光耦导通时输出低电平可以做到 0.2V 以下逻辑可靠。3.3 开关量输出驱动继电器到强电负载的完整链路8 路继电器输出推荐直接用 ULN2003八个达林顿管内部自带续流二极管只需外部把 COM 脚接继电器供电正极省掉一堆分立元件。如果是 4 路以内S8050 或 2N5551 也够用。每组输出加 LED 指示灯串联 1kΩ 电阻接 3.3V 电源即可。4. 软件实现STM32 从站协议栈的框架与关键代码4.1 字节接收状态机按帧组装 Modbus RTU 报文Modbus RTU 从站侧的核心是串口接收接收质量直接决定协议栈能否正确工作。我建议用状态机的方式逐字节处理不依赖不定长中断和环形缓冲的复杂逻辑逻辑清晰、出问题好查。代码如下// modbus_rtu.c —— 从站接收状态机 #define MB_RX_IDLE 0 // 空闲 #define MB_RX_RECEIVING 1 // 接收中 #define MB_RX_COMPLETE 2 // 一帧完成 #define MB_FRAME_MAX_LEN 256 #define MB_3_5_CHAR_TICKS 8 // 9600bps 时约 4ms static uint8_t rx_buf[MB_FRAME_MAX_LEN]; static uint8_t rx_len 0; static uint8_t rx_state MB_RX_IDLE; static volatile uint8_t frame_ready 0; // 在串口中断里调用 void mb_uart_rx_isr(uint8_t byte) { if (rx_state MB_RX_IDLE) { rx_len 0; rx_state MB_RX_RECEIVING; } if (rx_state MB_RX_RECEIVING) { if (rx_len MB_FRAME_MAX_LEN) { rx_buf[rx_len] byte; } // 收到字节后重置帧超时定时器 mb_frame_timer_reset(); } } void mb_frame_timer_isr(void) { if (rx_state MB_RX_RECEIVING) { if (mb_frame_timer_expired()) { // 超过 3.5 字符时间 rx_state MB_RX_COMPLETE; frame_ready 1; } } }这一段的逻辑要点在于帧结束不是靠帧尾字节判断而是靠字节间隔3.5 字符时间判断。定时器超时后才能把刚收到的一串字节当作一帧去解析。4.1.1 定时器时长换算方法3.5 字符时间 3.5 × (1 8 1) / 波特率。对于不同波特率整理成下表波特率单字符位3.5 字符时间推荐定时器 ticks1ms 基准960010 位约 3.65ms4ms1920010 位约 1.82ms2ms3840010 位约 0.91ms1ms11520010 位约 0.30ms1ms注意 115200 时 3.5 字符不到 1ms如果主站发送速度稍慢就会把一帧拆成几段所以高速通讯下最好配合 DMA 总线空闲中断来处理。4.2 CRC16 查表实现与报文校验逻辑CRC16 是 Modbus RTU 的帧尾多项式是 0xA001反向多项式。逐位计算慢但代码短查表法快但要多占 512 字节 Flash——对 STM32F103 的 64KB Flash 来说完全不是问题。很多初学是从网上下表或自己生成这里给一个可靠的标准查表实现// modbus_crc16.c —— 查表法 CRC16/Modbus static const uint16_t crc16_table[256] { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, // 其余 248 项省略取标准 Modbus CRC 表 }; uint16_t mb_crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { uint8_t idx (crc ^ data[i]) 0xFF; crc (crc 8) ^ crc16_table[idx]; } return crc; // 返回值为 CRC 低字节在前、高字节在后 }查表时注意 Modbus CRC 在网络上传输时是低字节在前、高字节在后所以计算完成后要按crc 0xFF、crc 8的次序填充到帧尾。用 modpoll 或虚拟串口测试时如果总提示 CRC 错误九成是高低字节顺序填反了。4.3 寄存器映射、功能码处理与写操作落板4.3.1 地址映射表设计开关量模块的地址规划要固定避免后期改固件导致上位机全部要动。常规做法是数据对象Modbus 地址功能码寄存器/线圈区间开关量输入 1~80x00~0x070x02 读离散输入位映射开关量输出 1~80x00~0x070x01 读线圈 / 0x05 写单线圈位映射输入状态镜像寄存器0x10000x03 读保持寄存器位 0~7 对应输入 1~8输出控制寄存器0x10010x03 读 / 0x06 写位 0~7 对应输出 1~8设备地址寄存器0x20000x03 读 / 0x06 写掉电保存输入用离散输入地址更符合 Modbus 规范但上位机组态时很多工程师只建保持寄存器所以把输入状态再做一份到保持寄存器镜像两端兼顾。4.3.2 功能码分发与帧校验void mb_poll(void) { if (!frame_ready) return; uint16_t crc mb_crc16(rx_buf, rx_len - 2); uint16_t crc_rx rx_buf[rx_len - 2] | (rx_buf[rx_len - 1] 8); if (crc ! crc_rx) { // CRC 校验失败丢弃 rx_len 0; frame_ready 0; rx_state MB_RX_IDLE; return; } uint8_t slave rx_buf[0]; if (slave ! mb_slave_addr slave ! 0x00) { // 地址不匹配 return; } switch (rx_buf[1]) { case 0x01: mb_read_coils(); break; case 0x02: mb_read_discrete_inputs(); break; case 0x03: mb_read_holding_regs(); break; case 0x05: mb_write_single_coil(); break; case 0x06: mb_write_single_reg(); break; default: mb_send_exception(0x01); break; // 非法功能码 } rx_len 0; frame_ready 0; rx_state MB_RX_IDLE; }这段代码是协议栈的主循环入口建议放在while(1)里调用。需要展开填写的是各个功能码的处理函数以 0x05 写单线圈为例void mb_write_single_coil(void) { // 帧格式: 地址 0x05 起始地址高 起始地址低 数据高 数据低 CRC低 CRC高 uint16_t addr (rx_buf[2] 8) | rx_buf[3]; uint16_t value (rx_buf[4] 8) | rx_buf[5]; if (addr 8) { mb_send_exception(0x02); // 非法数据地址 return; } if (value 0xFF00) { coil_out[addr] 1; // 输出位置 1 } else if (value 0x0000) { coil_out[addr] 0; // 输出位清零 } else { mb_send_exception(0x03); // 非法数据值 return; } GPIO_WriteBit(coil_port[addr], coil_pin[addr], coil_out[addr] ? Bit_SET : Bit_RESET); // 原样回显请求作为响应 mb_send_response(rx_buf, rx_len); }这里有一个细节Modbus 0x05 写线圈的数据只有0xFF00和0x0000两种合法值其他值要回异常码。有些初学者会把这一位当作高低电平直接映射导致上位机下发失败。4.3.3 读离散输入寄存器镜像void mb_read_discrete_inputs(void) { uint16_t start (rx_buf[2] 8) | rx_buf[3]; uint16_t num (rx_buf[4] 8) | rx_buf[5]; if (num 0x03E8 || start num 16) { mb_send_exception(0x02); return; } uint8_t resp[8] {0}; resp[0] mb_slave_addr; resp[1] 0x02; resp[2] (num 7) / 8; // 字节数 for (uint16_t i 0; i num; i) { // input_state[i] 是 GPIO 读取后存的状态数组 if (input_state[start i]) { resp[3 i / 8] | (1 (i % 8)); } } uint16_t crc mb_crc16(resp, 3 resp[2]); resp[3 resp[2]] crc 0xFF; resp[3 resp[2] 1] crc 8; mb_uart_send(resp, 3 resp[2] 2); }从站响应的字节数计算很关键。读取线圈或离散输入时数据区是位打包的8 个位合成一个字节不足 8 位的也要按字节补齐。5. 验证与实测技巧从 modpoll 到逻辑分析仪5.1 用 modpoll 做一轮主从闭环测试开发阶段还没有 PLC上位机侧测试最有用的工具是 modpoll它没图形界面但命令行参数齐全适合脚本化测试。安装后通过 USB 转 RS485 模块连接 STM32 从站波特率 9600 从站地址 1执行# 读起始地址 0 的 8 个线圈 modpoll -m rtu -a 1 -r 0 -c 8 -t 0 -b 9600 /dev/ttyUSB0 # 写线圈地址 3置为 ON modpoll -m rtu -a 1 -r 3 -t 0 -b 9600 -w 1 /dev/ttyUSB0参数含义-m rtu指定 Modbus RTU 模式-a 1是从站地址-r 0起始寄存器地址-c 8读取数量-t 0数据类型为线圈-b 9600波特率-w 1是写操作。实测时如果 modpoll 返回超时先看 USB 转 485 模块的 A/B 是否接反返回 CRC 错误就先检查从站的波特率和数据位设置。5.2 串口数据记录仪排查帧交换过程modpoll -e参数可以把发送和响应帧以 16 进制打印出来这是排查协议栈问题最高效的方法。看到的现象和对应原因如下现象原因只能发出请求、收不到响应RS485 方向控制未切换回接收态连续收到同一字节的重复帧波特率不匹配或上行 A/B 反接CRC 校验总失败CRC 表项不对或字节序反了发送帧带上了回环数据回环测试未关闭或自发自收配置错误另外推荐在 STM32 的USART_SR寄存器里看 ORE 溢出错误位如果频繁溢出多半是主循环处理不及需要改用 DMA 接收。5.3 现场避坑从 PCB 布线到通讯参数检查最后收在几个现场最容易出问题的点上。RS485 的 A/B 线要双绞不要走长平行线总线的屏蔽层单端接地千万不要两端都接大地否则会形成地环流。供电侧开关量输入和 RS485 要分电源域隔离电源或用 DC-DC 模块隔开。通讯参数匹配问题从站侧建议把波特率、地址写入片内 Flash支持通过保持寄存器修改这样现场调试时不需要反复接烧录器。改造完成之后把地址表打印出来贴在设备面板上比任何注释都好用。本文还有配套的精品资源点击获取