news 2026/2/9 16:03:10

STM32F1系列中RS485 Modbus协议源代码移植指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F1系列中RS485 Modbus协议源代码移植指南

手把手教你把 Modbus RTU 移植到 STM32F1:从零构建 RS485 通信从站

在工业现场,你是否遇到过这样的场景——多个传感器分布在几十米甚至上百米外,需要统一上传数据,但用 Wi-Fi 不稳定、CAN 成本高、RS232 又只能点对点?这时候,RS485 + Modbus RTU就是你的最佳选择。

而如果你正在使用STM32F103系列(比如经典的“蓝丸”板),那恭喜你,这颗芯片完全具备实现稳定 Modbus 从机的能力。本文不讲空话,不堆术语,带你一步步完成Modbus RTU 协议栈的完整移植,涵盖串口配置、半双工控制、帧边界识别、CRC 校验和功能码响应等核心环节,最终让你的 STM32 能被 ModScan、组态王这类上位机软件准确读写。


为什么是 RS485?为什么选 Modbus?

先说清楚我们为什么做这件事。

工业环境复杂:干扰大、距离远、设备多。RS485 采用差分信号传输,A/B 两线压差决定逻辑电平,天生抗共模干扰。它支持总线式拓扑,一条线上挂 32 个节点(通过高阻收发器可扩展到 256),最长通信距离可达1200 米(低速下),非常适合分布式采集系统。

Modbus 则是工业界的“普通话”。它简单、开放、无版权,绝大多数 PLC、HMI、SCADA 系统都原生支持。其中Modbus RTU使用二进制编码,比 ASCII 更紧凑高效,是嵌入式系统的首选。

两者结合,构成了最经济可靠的工业通信方案之一。


硬件怎么接?别让接线毁了你的努力

再好的软件也架不住硬件翻车。典型的 STM32F1 + MAX485 接法如下:

STM32F103C8T6 MAX485 PA9 (USART1_TX) ──→ RO (接收输出,不用) PA10 (USART1_RX) ←── DI (发送输入) PB6 (GPIO_DIR) ───→ DE/RE (方向控制) A ────────────────→ RS485_A B ────────────────→ RS485_B GND ───────────────→ GND

关键点:
-DE 和 RE 脚通常短接,由一个 GPIO 控制收发方向。
-总线两端必须并联 120Ω 终端电阻,抑制信号反射,尤其是超过 10 米时。
-所有设备共地,避免地电位差导致通信异常。如果无法共地,建议加磁隔离模块(如 ADM2483)。
-电源去耦不可少:MAX485 旁务必加 0.1μF 陶瓷电容。
-防雷击保护:户外应用可在 A/B 线间加 TVS 二极管(如 SMAJ5.0A)。


半双工控制:别让 MCU “自言自语”

RS485 是半双工,同一时刻只能发或收。STM32 发完一帧后必须立刻切回接收模式,但这里有个坑:你得等最后一个 bit 完全送出后再切换方向,否则末尾数据可能被自己收到,造成干扰。

错误示范:

RS485_DIR_TX(); USART_Send(data, len); RS485_DIR_RX(); // ❌ 错了!此时数据可能还在移位寄存器里

正确做法是等待发送完成标志(TC),再加一点安全延时:

void rs485_send(uint8_t *data, uint8_t len) { RS485_DIR_TX(); // 拉高 DE/RE,进入发送模式 for (int i = 0; i < len; i++) { while (!(USART1->SR & USART_SR_TXE)); // 等待数据寄存器空 USART1->DR = data[i]; } while (!(USART1->SR & USART_SR_TC)); // ✅ 关键:等待整个帧发送完毕 delay_us(500); // 延时确保波形彻底结束(根据波特率调整) RS485_DIR_RX(); // 切回接收 }

这个delay_us(500)很重要。比如在 9600bps 下,1bit ≈ 104μs,3.5 字符时间约 3.67ms。我们只延时 500μs,是因为 TC 标志已保证数据发出,这只是保险起见。


如何判断一帧数据结束了?定时器来帮你

Modbus RTU 没有明确的起始/结束标志,而是靠帧间空闲时间 ≥ 3.5 个字符时间来界定帧边界。

举个例子:9600bps,每个字符 10bit(8N1),则 1 字符时间 = 1.04ms,3.5 字符 ≈ 3.67ms。只要两个字节之间的间隔超过这个值,就认为上一帧结束了。

怎么实现?USART 接收中断 + 定时器超时检测是最可靠的方法。

步骤拆解:

  1. 收到第一个字节 → 启动定时器(初值设为 3.67ms)
  2. 后续每收到一个字节 → 清零定时器重新计时
  3. 定时器溢出 → 触发“帧结束”事件 → 进入协议解析

这样即使中断延迟了几微秒,也能准确捕获完整帧。

配置 TIM3 作为帧超时检测器:

void timer3_init(uint32_t baudrate) { RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; uint32_t prescaler = SystemCoreClock / 1000000 - 1; // 1MHz 计数频率 uint32_t arr = ((1000000 * 10) / baudrate) * 3.5; // 3.5 字符时间(us) TIM3->PSC = prescaler; TIM3->ARR = arr; TIM3->DIER |= TIM_DIER_UIE; // 允许更新中断 TIM3->CR1 |= TIM_CR1_CEN; // 先不启动,等收到第一字节再开 }

USART 中断中重置定时器:

void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uint8_t data = USART1->DR; ring_buffer_push(&rx_buf, data); // 第一次收到数据,启动定时器;否则重置 if (!TIM3->CR1 & TIM_CR1_CEN) { TIM3->CNT = 0; TIM3->CR1 |= TIM_CR1_CEN; } else { TIM3->CNT = 0; // 自动清零即可 } } }

定时器溢出处理帧结束:

void TIM3_IRQHandler(void) { if (TIM3->SR & TIM_SR_UIF) { TIM3->SR &= ~TIM_SR_UIF; TIM3->CR1 &= ~TIM_CR1_CEN; // 停止计时 modbus_frame_complete(); // 处理完整帧 } }

这套机制非常稳健,能应对各种波特率偏差和中断抖动。


CRC16 校验:通信正确的最后一道防线

Modbus RTU 要求每一帧末尾附加2 字节 CRC16,低位在前。收到数据后必须先校验,通过才处理,否则静默丢弃。

标准多项式:x^16 + x^15 + x^2 + 1,对应 0x8005,常用反向查表法加速。但对于 STM32F1 这类资源有限的平台,直接计算也完全够用。

uint16_t modbus_crc16(uint8_t *buf, int len) { uint16_t crc = 0xFFFF; for (int i = 0; i < len; i++) { crc ^= buf[i]; for (int j = 0; j < 8; j++) { if (crc & 1) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }

使用时注意:校验范围不包含 CRC 自身。例如收到 8 字节数据,应拿前 6 字节计算 CRC,与最后 2 字节对比。

uint16_t received_crc = (frame[7] << 8) | frame[6]; uint16_t calc_crc = modbus_crc16(frame, 6); if (received_crc != calc_crc) { return; // CRC 错误,丢弃 }

功能码怎么响应?别被 0x03 和 0x10 弄晕

Modbus 主从架构中,STM32 作为从机,只需被动响应主站请求。常见功能码包括:

功能码名称用途
0x03读保持寄存器最常用,读配置或状态
0x06写单个保持寄存器设置参数
0x10写多个保持寄存器批量写入
0x01读线圈读开关量输出
0x02读输入状态读开关量输入

0x03为例,主站发:

[01][03][00][00][00][02][CRC] 地址 功能 起始地址 数量

从机需返回:

[01][03][04][XX][XX][XX][XX][CRC] 数据长度=4 实际数据

代码结构建议分层处理:

void modbus_frame_complete(void) { uint8_t *frame = rx_buffer.data; uint8_t len = rx_buffer.count; if (len < 6) return; // 最小帧长 if (frame[0] != DEVICE_ADDR && frame[0] != 0x00) return; // 地址不匹配 uint16_t crc_rcv = (frame[len-1] << 8) | frame[len-2]; uint16_t crc_cal = modbus_crc16(frame, len-2); if (crc_cal != crc_rcv) return; // CRC 错误 modbus_handle_request(frame, len); // 解析并响应 }
void modbus_handle_request(uint8_t *req, int len) { uint8_t func = req[1]; switch (func) { case 0x03: handle_read_holding(req); break; case 0x06: handle_write_single_reg(req); break; case 0x10: handle_write_multiple_regs(req); break; default: send_exception(func, 0x01); // 非法功能码 break; } }

寄存器数据建议用数组映射:

uint16_t holding_registers[100]; // 对应 40001~40100 void handle_read_holding(uint8_t *req) { uint16_t start = (req[2] << 8) | req[3]; uint16_t count = (req[4] << 8) | req[5]; if (start >= 100 || count == 0 || start + count > 100) { send_exception(0x03, 0x02); // 非法数据地址 return; } uint8_t resp[256] = {0}; resp[0] = req[0]; // 从站地址 resp[1] = 0x03; resp[2] = count * 2; for (int i = 0; i < count; i++) { resp[3 + i*2] = holding_registers[start + i] >> 8; resp[3 + i*2 + 1] = holding_registers[start + i] & 0xFF; } uint16_t crc = modbus_crc16(resp, 3 + count*2); resp[3 + count*2] = crc & 0xFF; resp[4 + count*2] = crc >> 8; rs485_send(resp, 5 + count*2); }

实战案例:做个温湿度从站,让 ModScan 能读到数据

假设你用 STM32F103C8T6 接了一个 SHT30 温湿度传感器(I2C),现在要把它变成 Modbus 从站。

寄存器映射设计:

Modbus 地址含义类型存储位置
40001温度Holdingholding_reg[0]
40002湿度Holdingholding_reg[1]
40003报警阈值Holdingholding_reg[2]

主循环定时读取 SHT30 并更新寄存器:

int main(void) { system_init(); modbus_init(); while (1) { float temp, humi; sht30_read(&temp, &humi); holding_registers[0] = (int)(temp * 10); // 30.5°C → 305 holding_registers[1] = (int)(humi * 10); // 65.2% → 652 delay_ms(1000); // 每秒更新一次 } }

上位机用 ModScan 发送:

01 03 00 00 00 02 C4 0B

你的 STM32 应返回:

01 03 04 01 2C 02 8F XX XX

(假设温度 30.0°C,湿度 65.5%)


踩过的坑和避坑指南

❌ 问题 1:总是收不到数据

  • 检查 MAX485 的 DE/RE 是否接反?
  • 是否忘了初始化 USART 的 RX 引脚?
  • 波特率是否一致?STM32 默认内部时钟不准,务必使用外部晶振

❌ 问题 2:帧拆分错误,数据错乱

  • 定时器 ARR 值算错了?确认按当前波特率动态设置。
  • 中断优先级太低?将 USART 和 TIM 中断设为较高优先级。

❌ 问题 3:发送后总线冲突

  • 方向切换太快!确保等TC标志再切回接收。
  • 多个设备同时发?检查是否只有一个主站。

✅ 最佳实践总结:

  1. 地址可配:通过按键或 EEPROM 设置从站地址。
  2. 看门狗必开:用 IWDG 防止程序卡死。
  3. 低功耗优化:空闲时进 Sleep,中断唤醒。
  4. 日志指示:用 LED 快闪表示接收,慢闪表示发送。
  5. 预留升级接口:Bootloader 支持远程固件更新。

写在最后:这不是终点,而是起点

当你第一次看到 ModScan 成功读出寄存器数值时,那种成就感是真实的。但这只是开始。

掌握了 RS485 + Modbus RTU,你就打通了工业通信的任督二脉。下一步可以尝试:
- 实现 Modbus TCP over ENC28J60
- 移植 FreeRTOS 实现多任务调度
- 加入 OTA 远程升级
- 封装成通用库,一键移植到新项目

核心技术关键词回顾

Modbus RTURS485 半双工帧间隔检测CRC16 校验STM32F1 USART定时器同步功能码解析寄存器映射工业通信主从架构

如果你也在做类似的项目,欢迎留言交流调试心得。毕竟,每一个成功的通信背后,都藏着无数个“为什么收不到”的深夜。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/2/8 12:40:49

5分钟学会:用单文件库搞定图像元数据解析

5分钟学会&#xff1a;用单文件库搞定图像元数据解析 【免费下载链接】stb stb single-file public domain libraries for C/C 项目地址: https://gitcode.com/gh_mirrors/st/stb 在嵌入式开发和资源受限环境中&#xff0c;处理图像元数据往往意味着引入庞大的第三方库依…

作者头像 李华
网站建设 2026/2/4 23:29:26

大数据领域数据可视化的数据预处理

大数据领域数据可视化的数据预处理 关键词:大数据、数据可视化、数据预处理、数据清洗、数据转换 摘要:本文聚焦于大数据领域数据可视化中的数据预处理环节。在大数据时代,海量数据蕴含着巨大价值,但要将这些数据以直观的可视化形式呈现,数据预处理是关键的基础步骤。文章…

作者头像 李华
网站建设 2026/2/6 4:55:02

74194双向移位控制原理:图解说明核心要点

74194双向移位控制原理&#xff1a;从流水灯到数据通路的实战解析你有没有遇到过这种情况——单片机GPIO不够用了&#xff0c;但又想驱动一排LED实现“跑马灯”效果&#xff1f;或者在设计通信接口时&#xff0c;需要把并行数据转成串行发送出去&#xff1f;这时候&#xff0c;…

作者头像 李华
网站建设 2026/2/6 9:04:08

Qwen3-Next指令微调实战:构建专属行业大模型的捷径

Qwen3-Next指令微调实战&#xff1a;构建专属行业大模型的捷径 在当今企业智能化转型的浪潮中&#xff0c;一个现实问题正不断浮现&#xff1a;通用大模型虽然“见多识广”&#xff0c;但在面对金融合规审查、医疗诊断辅助、法律条文解析等专业场景时&#xff0c;往往显得“外行…

作者头像 李华
网站建设 2026/2/6 3:23:29

AD导出Gerber文件教程:新手入门必看的完整指南

从AD导出Gerber文件&#xff1a;新手避坑实战指南你是不是也经历过这样的时刻&#xff1f;PCB画了整整两周&#xff0c;DRC全过&#xff0c;3D视图完美无瑕&#xff0c;信心满满地点击“生成制造文件”&#xff0c;结果工厂回信&#xff1a;“顶层阻焊没开窗”、“钻孔文件缺失…

作者头像 李华