STM32F103 MODBUS RTU从站控制6路继电器实践详解
2026/9/9 0:07:43 网站建设 项目流程

简介:基于STM32F103的串口MODBUS RTU 6通道继电器切换资源,是一套可直接落地的工业控制方案。面向嵌入式开发、PLC通信及物联网设备调试人员,解决多路继电器远程通断控制与上位机协议对接问题。资源已在实际项目中验证,程序源码基于Keil平台编写,配合Cadence设计的原理图与PCB,从硬件到固件形成完整闭环,适合需要快速集成或参考硬件设计的工程师。

包体共246个文件,约7.24MB。其中包含52个C源码与49个头文件,构成STM32固件主体;35个crf、34个o等为编译中间产物,便于排查构建过程;原理图dsn与PCB brd文件为Cadence工程,可直接编辑;另有hex烧写文件、uvprojx工程文件及bat脚本,方便直接使用和二次开发。

已有875人学习下载。资料价值在于提供经过量产验证的完整工程,不仅包含MODBUS RTU协议栈实现、继电器驱动电路,还有USB转TTL接口设计,省去协议调试与硬件打样弯路,拿来即可用。

1. 项目概述与核心需求

这次做的是一个 STM32F103 跑 MODBUS RTU 从站,控制 6 路继电器通断的小项目。需求本身不复杂:上位机通过串口(RS485 或直连 TTL)发 MODBUS RTU 协议帧,单片机解析后控制 6 路继电器,支持单路控制、多路同时控制、状态读取。但实际做下来,从协议移植到硬件接线,有一堆细节值得记录,这也是我写这篇文章的初衷。

先说为什么选 STM32F103。作为经典的 M3 内核单片机,它主频 72MHz、资源丰富,标准外设库和 HAL 库都很成熟,网上资料多到爆炸,遇到问题基本都能搜到答案。而且对继电器这种低速设备来说,F103 的性能绰绰有余,项目成本也能压得很低。6 路继电器切换本身不涉及高实时性要求,MODBUS RTU 的波特率通常在 9600 或 19200,一帧数据也就几十个字节,串口中断完全够用。

再聊 MODBUS RTU 本身。MODBUS 协议在工业控制领域几乎是事实标准,RTU 模式是二进制传输,每帧数据有 CRC 校验,可靠性和实时性都不错。这里用的是从站模式:上位机是主站,发命令帧给单片机,单片机收到后解析地址、功能码、数据段,执行对应操作后回复响应帧。最常用的功能码就两个:0x01 读线圈状态、0x05 写单线圈、0x0F 写多线圈。本项目中 6 路继电器对应 6 个线圈地址,功能码 0x01 用于查询状态,0x05 和 0x0F 用于控制。

整个项目我拆成了几个模块:串口驱动(USART1 接收发送)、MODBUS RTU 协议解析、继电器控制逻辑、定时器超时判断(用于 RTU 帧间隔检测)。协议部分我原本打算用 FreeMODBUS,后来觉得项目简单直接手写解析,反而更灵活。后面我会详细对比这两种方式的取舍。

2. 整体方案设计与硬件选型

2.1 系统框架

系统结构很简单:上位机(PC 或触摸屏)通过 USB 转 TTL 或 RS485 转接板连接到 STM32F103 的 USART1,单片机根据收到的 MODBUS RTU 帧控制 GPIO 引脚,进而驱动光耦隔离继电器模块。继电器输出端接负载,比如灯具、电磁阀、小功率电机等。

在实际工业场景里,RS485 更常见。因为 RS485 是差分信号,抗干扰能力强,传输距离能到上百米,而且支持多设备组网(同一总线上挂多个从站,每个从站分配不同地址)。如果只是桌面上调试,直接用 USB 转 TTL 接到单片机的 PA9(TX)、PA10(RX)就行。这里有个坑:USB 转 TTL 模块的地线必须和单片机共地,否则串口通信会不稳定,大概率收到乱码。

2.2 选用自由协议还是 FreeMODBUS

最初我考虑过 FreeMODBUS,它是一套开源的 MODBUS 协议栈实现,移植到 STM32 标准库有现成教程(配 v3.5 库很常见)。FreeMODBUS 的优势是协议栈健壮,支持 RTU、ASCII、TCP 多种模式,而且对帧间隔、异常处理都考虑得很周全;缺点是代码量大,配置繁琐,对 6 路继电器这种简单控制有点杀鸡用牛刀。

后来我决定手写一套精简版 MODBUS RTU 协议解析。原因有三:一是项目功能单一,只需要 0x01、0x05、0x0F 三个功能码;二是手写代码可以完全掌控解析逻辑,出问题好排查;三是可以严格控制代码体积和 RAM 占用。当然自由协议也有代价,比如 CRC 校验、超时处理这些都得自己写,但对动手实践来说反而是提升的机会。

如果你做的是比较复杂的产品,比如支持多个功能码、多寄存器读写、带广播帧、需要异常响应,那么直接用 FreeMODBUS 或 libmodbus 移植会更安全。如果只是像我这样的中小型控制项目,手写解析完全够用,而且能让你对 MODBUS 协议的细节理解得更透。

2.3 继电器驱动电路设计

继电器模块我用的是市售光耦隔离继电器模块,低电平触发,带 LED 指示。每个模块输入侧有三个引脚:VCC(接 3.3V 或 5V)、GND(接单片机 GND)、IN(接单片机 GPIO)。注意,虽然模块标注支持 3.3V 驱动,但实际测试中部分模块在 3.3V 下可靠吸合的效果一般,所以建议:如果模块是 5V 供电的,输入侧高电平至少要在 4V 以上。STM32 的 GPIO 输出 3.3V,直接接上去可能不够稳定。解决办法是:要么选 3.3V 兼容的继电器模块(带光耦的通常可以),要么加一级 NPN 三极管(比如 SS9013)或 N-MOS 管做电平转换和驱动增强。

网上有好多基于 SS9013 的 12V 继电器驱动电路,原理就是在基极串一个电阻接单片机 GPIO,发射极接地,集电极接继电器线圈和续流二极管。继电器线圈是感性负载,断电瞬间会产生反向电动势,必须并联续流二极管(1N4007 或 1N4148)来吸收,否则容易击穿三极管或单片机引脚。这在选型时特别重要,额定电压、线圈电流、触点容量都要看数据手册。

我用的是 5V 光耦继电器模块,输入侧 IN 引脚直接接 STM32 的 GPIO,PC0、PC1、PC2、PC3、PC4、PC5 依次对应 6 路继电器。实测下来,3.3V 高电平对部分模块的可控性不稳,所以我加了 3.3V 转 5V 的逻辑电平转换模块,确保继电器驱动可靠。这一步建议不要省,否则现场经常会遇到“有时候能吸合有时候不能”的灵异问题。

3. 核心细节解析与实操要点

3.1 USART1 串口配置

STM32 中串口配置说难不难,说简单也有不少细节。我用的是标准库 v3.5,USART1 的引脚是 PA9(TX)和 PA10(RX),复用推挽输出和浮空输入。波特率选 9600 还是 19200?MODBUS RTU 标准里没有强制,但一般从站都会配置为 9600 8 N 1(9600 波特率、8 位数据位、无校验、1 位停止位)。9600 是工业现场最常用的选择,稳定性最好;19200 傳輸更快,但对线材和干扰更敏感。我的建议:有噪声干扰的环境优先 9600,短距离桌面调试可以用 19200 或 38400。

串口初始化代码要点如下:

void USART1_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); // TX PA9 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); // RX PA10 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 9600; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, &USART_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); }

关于中断接收,我用的是 RXNE(读数据寄存器非空)中断。每收到一个字节就进一次中断,把数据扔进接收缓冲区。接收完一帧后需要判断帧间隔,MODBUS RTU 规定:一帧内字节间间隔不能超过 1.5 个字符时间,帧与帧之间至少 3.5 个字符时间。最简单可靠的方式是用两个定时器,一个负责字节超时(3.5 字符时间),一个负责帧处理。如果项目资源紧张,也可以用 SysTick 或单个 TIM 做超时判断。

实际使用中更稳妥的做法是:不用固定延时,而是记录每字节到达时刻,判断当前字节与上一字节的时间差是否超过 3.5 字符时间。9600 波特率下一个字符约 1ms,3.5 字符就是 3.5ms,用定时器计时完全没有压力。我用 TIM2 做了一个 100us 的时基,在处理完每字节后清零计时,中断每次进来加一,超过 35 就认为帧超时并复位帧索引。这种方法对任意波特率通用,代码也直观。

3.2 MODBUS RTU 协议帧格式

MODBUS RTU 帧格式是固定的四段:地址码(1 字节)、功能码(1 字节)、数据段(N 字节)、CRC 校验(2 字节,低字节在前)。最常见的读取线圈状态请求如下:

地址 功能码 起始地址高 起始地址低 线圈数量高 线圈数量低 CRC低 CRC高 0x01 0x01 0x00 0x00 0x00 0x06 0x?? 0x??

这个帧的意思是:向地址 0x01 的从站读取从寄存器地址 0x0000 开始的 6 个线圈状态。从站响应帧是:

地址 功能码 字节数 数据(位打包) CRC低 CRC高 0x01 0x01 0x01 0x2D 0x?? 0x??

数据的每一位对应一个线圈状态,bit0 对应起始地址的第一个线圈。比如 6 路继电器都闭合,那么对应字节的 bit0~bit5 都是 1,这个字节就是 0x3F。

写单线圈(0x05)的帧如下:

地址 功能码 线圈地址高 线圈地址低 数据高 数据低 CRC低 CRC高 0x01 0x05 0x00 0x00 0xFF 0x00 0x?? 0x??

这里有个 MODBUS 协议规定的特殊点:写单线圈时,数据段必须是 0xFF00 表示闭合,0x0000 表示断开,其他值非法。这和其他寄存器写功能(直接写 0x0001)不同,别搞混了。

写多线圈(0x0F)的帧稍微复杂一点:

地址 功能码 起始地址高 起始地址低 线圈数量高 线圈数量低 字节数 数据 CRC 0x01 0x0F 0x00 0x00 0x00 0x06 0x01 0x2D 0x?? 0x??

3.3 CRC 校验实现

MODBUS RTU 的 CRC 是 CRC16-IBM/MODBUS,多项式是 0xA001(即常规 CRC16 多项式 0x8005 反射后得到)。注意:低字节在前发送,也就是先发 CRC 低 8 位,再发 CRC 高 8 位。

CRC 计算函数如下:

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

如果你想要性能更好,可以用查表法,预先计算 0~255 的 CRC 表,之后每个字节只需一次查表和异或,速度提升明显。但对于 9600 波特率,逐位法完全没压力,毕竟处理器是 72MHz,一帧数据也就十几个字节,逐位计算也就几十微秒的事。

发送响应帧时,添加 CRC 的代码(注意高低字节顺序):

frame[frameLen++] = crc & 0xFF; // 低字节在前 frame[frameLen++] = (crc >> 8) & 0xFF;

接收端校验时,要单独验证 CRC 是否正确。如果 CRC 校验失败,最简单策略是直接丢弃,不回任何响应,等待主站超时重试。这是 MODBUS 标准的推荐做法,避免坏帧继续传递。

4. 实操过程与核心环节实现

4.1 硬件连接

我用的是 STM32F103C8T6 最小系统板(蓝色板那种),加一块 6 路光耦隔离继电器模块。硬件连接如下:

STM32F103C8T6继电器模块说明
PA9 (USART1_TX)-接 USB 转 TTL 的 RX
PA10 (USART1_RX)-接 USB 转 TTL 的 TX
GNDGND必须共地
PC0IN1继电器 1
PC1IN2继电器 2
PC2IN3继电器 3
PC3IN4继电器 4
PC4IN5继电器 5
PC5IN6继电器 6
3.3V 或外接 5VVCC按模块要求供电

注意:单独给继电器模块供电是很有必要的。如果直接用电脑 USB 给单片机供电,同时继电器模块也从同一个 5V 取电,那么继电器吸合的瞬时电流可能拉低电压,导致单片机复位。实际测试中,吸合 3 路以上时电压跌落已经相当可观。所以我用了一个外置 5V 适配器给继电器模块单独供电,单片机用 USB 供电,两者共地。这个方案跑起来非常稳定。

4.2 接收缓冲区和状态机

我定义了一个接收缓冲区,采用简单的环形队列或者直接线性数组。由于 MODBUS RTU 是半双工协议,同一时间只会有一个帧在传输,所以用线性数组加帧索引就够了:

#define RX_BUFFER_SIZE 64 uint8_t rxBuffer[RX_BUFFER_SIZE]; uint8_t rxIndex = 0; uint8_t frameReady = 0;

在串口中断中,每收到一个字节就存入 rxBuffer,并清零超时计时器。超时计时器累加到阈值后,认为当前帧接收完毕,置位 frameReady。这种做法的优点是逻辑简单,对时序要求不高,几乎不会出错。

帧处理函数放在主循环中轮询,或者放到超时中断里执行。我放在主循环里,只在 frameReady 置位后处理:

if (frameReady) { frameReady = 0; ProcessModbusFrame(rxBuffer, rxIndex); rxIndex = 0; }

注意:处理完帧后必须将 rxIndex 归零,否则会导致错误拼接。

4.3 主站地址和功能码处理

MODBUS 从站必须有个地址,我的地址是 0x01。帧首字节如果不是 0x01,直接丢弃。另外还支持广播地址 0x00,广播帧不需要回应,这个可以根据实际需求加。如果你做的是多从站组网,每个从站的地址可以通过拨码开关设置,而不是写死在代码里。

功能码处理逻辑如下:

void ProcessModbusFrame(uint8_t *buffer, uint8_t len) { uint16_t crcReceived = buffer[len - 2] | (buffer[len - 1] << 8); uint16_t crcCalc = ModbusCRC16(buffer, len - 2); if (crcReceived != crcCalc) { return; // CRC error, ignore frame } uint8_t slaveAddr = buffer[0]; if (slaveAddr != SLAVE_ADDR) { return; } uint8_t funcCode = buffer[1]; switch (funcCode) { case 0x01: // Read coils HandleReadCoils(buffer, len); break; case 0x05: // Write single coil HandleWriteSingleCoil(buffer, len); break; case 0x0F: // Write multiple coils HandleWriteMultipleCoils(buffer, len); break; default: // Send exception response (illegal function) // + 0x80 to func code break; } }

非法功能码时要发送异常响应帧,功能码加 0x80,异常码 0x01(非法功能)。这个细节很多初学者会忽略,但 MODBUS 协议里明确要求从站对非法功能码回复异常帧,而不是静默。如果在工业现场对接触摸屏或组态软件,静默会导致上位机报错“设备无应答”,排查起来很费劲。

4.4 读线圈和写线圈实现

6 路继电器的线圈地址映射为 0x0000~0x0005。这里推荐用数组变量保存继电器状态,而不是每次读 GPIO 状态,因为 GPIO 引脚可能被复用或上下拉影响。

#define RELAY_COUNT 6 uint8_t relayStates = 0x00; // bit0-5 对应6路继电器 void HandleReadCoils(uint8_t *buffer, uint8_t len) { if (len != 8) return; uint16_t startAddr = (buffer[2] << 8) | buffer[3]; uint16_t quantity = (buffer[4] << 8) | buffer[5]; if (quantity > 16) quantity = 16; uint8_t byteCount = (quantity + 7) / 8; uint8_t statusByte[2] = {0, 0}; for (uint8_t i = 0; i < quantity; i++) { uint16_t coilAddr = startAddr + i; if (coilAddr < RELAY_COUNT) { if (relayStates & (1 << coilAddr)) { statusByte[i / 8] |= (1 << (i % 8)); } } } uint8_t resp[32]; resp[0] = SLAVE_ADDR; resp[1] = 0x01; resp[2] = byteCount; resp[3] = statusByte[0]; if (byteCount > 1) { resp[4] = statusByte[1]; } uint16_t respLen = 4 + (byteCount - 1); uint16_t crc = ModbusCRC16(resp, respLen); resp[respLen++] = crc & 0xFF; resp[respLen++] = (crc >> 8) & 0xFF; SendResponse(resp, respLen); }

写单线圈(0x05)实现:

void HandleWriteSingleCoil(uint8_t *buffer, uint8_t len) { if (len != 8) return; uint16_t coilAddr = (buffer[2] << 8) | buffer[3]; uint16_t value = (buffer[4] << 8) | buffer[5]; if (coilAddr >= RELAY_COUNT) return; if (value == 0xFF00) { relayStates |= (1 << coilAddr); } else if (value == 0x0000) { relayStates &= ~(1 << coilAddr); } else { // invalid value, send exception 0x03 return; } UpdateRelayGPIO(); // response is echo of request SendResponse(buffer, len); }

写多线圈(0x0F)实现类似,只是解析数据段不是单个 0xFF00/0x0000,而是按位取数据字节。注意:如果数据字节数不够,就必须丢弃并考虑异常响应。实际调试中,上位机软件(特别是 ModbusPoll)对异常响应很敏感,格式不对会导致通信报错,所以解析函数必须严格处理长度和数量。

4.5 继电器 GPIO 驱动

GPIO 初始化的关键是将 PC0~PC5 配置为推挽输出,默认电平拉高(继电器模块低电平触发时,默认断开):

void Relay_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOC, &GPIO_InitStructure); GPIO_SetBits(GPIOC, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5); }

更新继电器状态函数:

void UpdateRelayGPIO(void) { if (relayStates & 0x01) GPIO_ResetBits(GPIOC, GPIO_Pin_0); // 低电平触发,复位为吸合 else GPIO_SetBits(GPIOC, GPIO_Pin_0); if (relayStates & 0x02) GPIO_ResetBits(GPIOC, GPIO_Pin_1); else GPIO_SetBits(GPIOC, GPIO_Pin_1); // ... 以此类推 PC2~PC5 }

如果你的继电器模块是高电平触发的,那逻辑正好反过来,GPIO_SetBits 表示吸合,GPIO_ResetBits 表示断开。看模块说明书或模块上的丝印就可以确定,实测时也可以通过万用表测量输入引脚电压来判断。

4.6 调试工具与踩坑

调试 MODBUS RTU 必不可少的工具是串口调试助手和 ModbusPoll。左边发 MODBUS 请求帧,右边观察响应帧,方便比对。推荐几个常用工具:

  • 串口调试助手:用来裸看串口收发数据,发十六进制帧;
  • ModbusPoll:作为模拟主站,自动发送读写请求,验证从站响应是否符合协议;
  • 虚拟串口工具:如果你没有真实主站设备,可以用虚拟串口对(VSPD 一类)配合 ModbusPoll 调试。

如果你手头没有 USB 转 TTL,推荐 CH340 或 FTDI 芯片的模块。CH340 便宜,几块钱,但 Windows 驱动最好去官网下载,别用系统自动安装的旧驱动,可能不稳定。FTDI 芯片的模块贵一些,但兼容性更好,尤其在 Mac 和 Linux 下很友好。实际项目里我还遇到过 CH340 模块跑 115200 高波特率丢字节的问题,降级到 9600 就正常,而 FTDI 没问题,这也是老生常谈了。

USB 转 TTL 模块的 TX 和 RX 需要交叉连接:模块 TX 接单片机 RX(PA10),模块 RX 接单片机 TX(PA9)。别接成同向,否则数据自说自话,啥也收不到。接完后先打开串口调试助手,发一个读取寄存器命令,看看能不能收到响应。比如发送报文:

01 01 00 00 00 06 3C 0B

这条帧是:地址 01,功能码 01,起始地址 0000,数量 0006,CRC 为 3C 0B(计算出来就是这样的)。如果一切正常,从站应回类似下面这条:

01 01 01 3F 90 0C

其中 0x3F 是 6 路继电器状态位打包后的结果(0000 0011 1111),表示 6 路全部闭合。如果返回的不是预期值,先在电脑上用硬件串口检测每个字节的波形,确认波特率、数据位等是否匹配。

5. 常见问题与排查技巧实录

5.1 串口收不到数据

排查步骤比较简单:先确认 USB 转 TTL 是否安装驱动,设备管理器里有没有 COM 口号;再确认串口调试助手波特率是否与程序一致;然后用示波器或万用表量一下模块 TX 引脚有没有波形。如果你用的是 STM32 最小系统板,很多板子的 PA9/PA10 已经被板载 USB 相关电路占用,或者需要短路帽插上才引出,这个细节非常容易踩坑。最好先看一下板子原理图,确认引脚引出来了再接线。

如果发送帧单片机一直没有反应,先用串口调试助手自发自测验一下:把 USB 转 TTL 模块的 TX 和 RX 短接,发送的数据如果能原样收回来,说明模块没问题。然后接上单片机,打开调试助手,给单片机发一个读状态帧,看调试助手能否收到响应。如果收不到,检查单片机程序是否跑起来了(点个 LED 看)。

5.2 CRC 校验错误

CRC 错误常见原因有两个:一个是上位机软件计算的 CRC 和你单片机计算的 CRC 算法不一致;另一个是在调试助手手动输入帧时,CRC 填的是错的或填反了高低字节。建议用 ModbusPoll 这类自动计算 CRC 的工具验证,不要手动输 CRC。手动输的话也要记住 RTU 模式下 CRC 是低字节在前,例如 CRC 是 0x3C0B,帧里先填 0x0B 再填 0x3C,很多人在这里搞反。

另一个容易忽略的点:计算 CRC 的范围是从地址码到数据段结束,不包括 CRC 本身。计算长度 len 是帧总长减 2,我前面代码里的 ModbusCRC16(buffer, len - 2) 就是干这个的。如果长度传错了,CRC 必然校验不过。

5.3 帧超时导致数据黏包

如果你之前用的是固定延时来判断帧结束(比如接收完先延时 10ms 再处理),在波特率变化或上位机连续快速发送时容易出问题。RTU 协议的要求是 3.5 字符时间,9600 波特率下约 4ms,19200 下约 2ms。固定延时取值不好就可能导致一帧被拆成两帧,或者两帧粘成一帧。

我解决的办法是使用定时器超时判断,前面已经说过。如果不用定时器,也可以用另一个思路:接收中断里直接判断当前帧的第一个字节地址对不对,不对就直接丢弃,减少无用帧占缓存。但最好的方案仍然是定时器超时。

5.4 继电器抖动或误动作

继电器模块在上电瞬间或者复位瞬间,GPIO 引脚可能是高阻输入态,外部干扰或内部上拉会导致电平不确定,继电器可能瞬间误吸合。解决方法是:在 GPIO 引脚外部加一个 10K 下拉电阻到地(低电平触发模块),确保上电默认断开;或者在代码初始化 GPIO 时,先设置寄存器输出低电平再配置复用模式。建议硬件上就加上下拉,软件也要保证 Init 顺序。

另一个常见问题是继电器吸合时电源跌落。多路同时吸合时,电流冲击很大,如果电源功率不足会直接导致单片机复位。前面提到过,解决办法是独立电源给继电器模块供电,并共地。如果负载是交流 220V 设备,注意触点容量和工作电流匹配,建议留一定余量。

5.5 波特率混乱和数据错位

如果你接收到的数据是完整帧但内容明显不对,比如地址不是 0x01,或者功能码乱跳,首先怀疑波特率不匹配。用逻辑分析仪或示波器抓串口波形,能直接看到每一位的宽度是否匹配。也可以写一个小程序:收到任意字节后直接回显,上位机发什么收什么,来判断底层串口是否正常。

另外一个不太容易想到的原因是:上位机软件把 MODBUS ASCII 模式和 RTU 模式搞混了。ASCII 模式下会有冒号、十六进制 ASCII 字符、CR/LF 等,跟 RTU 的二进制帧完全不同。在 ModbusPoll 等工具里一定要选 RTU,别选 ASCII。

6. 扩展与升级思路

6 路继电器只是最基础的形态,做完之后可以直接朝几个方向扩展,成本增加很少,但能适用的场景一下子广了不少。

6.1 接入 RS485 组网

当前我调的是 USB 转 TTL 直连,如果想真正拿到工业现场用,建议加一个 TTL 转 RS485 模块,把串口信号转成差分信号。RS485 组网时每个从站地址不同,上位机轮询所有从站即可。STM32F103 的 USART1 TX/RX 接 MAX485 一类芯片,注意 DE 方向控制引脚接线,发送时拉高 DE,接收时拉低。半双工切换时机要处理好,否则会出现发送完还停留的收尾字节产生冲突。

6.2 蓝牙 BLE 无线控制

如果你不想拉线,可以用蓝牙模块(比如 HC-08 或 JDY-31)替代串口线,与手机端 BLE 透传 APP 配合。MODBUS RTU 帧原样通过蓝牙透传,软件协议栈不用改,只换物理传输层。不过蓝牙和 485 的时序不一样,串口透传的延迟和丢包需要测试,尤其当通信频率高时要注意。蓝牙 BLE 的优势是调试方便,手机当主站随时连,缺点是距离有限(约 10 米),且信道干扰时可能有丢包。

6.3 增加 Modbus TCP 网关

如果你后面想接入 PLC 或上位机组态软件,可以考虑用 W5500 或 ENC28J60 以太网模块,做 MODBUS TCP 网关。TCP 和 RTU 的协议差异主要在帧头(MBAP 头)和 CRC 上,把 RTU CRC 去掉,加上事务标识、协议标识、长度字段,就变成 TCP 帧了。网上有现成的移植方案,可以直接参考。

6.4 增加 IO 扩展和 ADC 采集

只有 6 路继电器显然不够做完整的 I/O 系统。你可以用 74HC595 或 MCP23017 扩展更多输出通道,或者用 ADC 采集模拟量(比如温度、电流采集),并把数据放到 MODBUS 保持寄存器中,供主站读取。这样从一台简单的“6 路继电器控制器”,就变成了一个功能完整的远端 I/O(Remote I/O)设备。这类设备在工业现场是标配,自己做一台出来就会对 MODBUS 的理解深很多。

7. 最后分享一些实操心得

写到这里,整个项目已经讲得比较透了。最后再说几个我实际踩过的坑,希望对你有帮助。

第一,正在调试时千万不要盲改波特率。之前我有一次现场调高波特率到 115200,结果串口助手和程序那边都改到了,但 CRC 一直出错,排查了半天才发现 CH340 在高波特率下丢字节。工业调试长时间挂在 9600 不是没道理的,速度没那么重要,稳定才重要。

第二,继电器线圈一定要加续流二极管。这个我刚开始做的时候忽略过,后来继电器关断瞬间把三极管击穿了一次,换上续流二极管后问题消失。感性负载关断时产生的反向电动势是真实存在且破坏力不小的,选模块或者自己做驱动电路时千万别省。

第三,手写 MODBUS 协议时不要着急,先拿 ModbusPoll 把所有功能码、异常帧测一遍,再用真实上位机测一遍。ModbusPoll 的日志功能会把每一帧收发都记录下来,排查通信问题比盲打盲试效率高太多了。

如果这篇文章对你有帮助,你可以照着跑一边,遇到问题欢迎在评论区留言交流。后面我可能会再写一篇把 STM32F103 和 RS485、云端远程控制接起来的案例,感兴趣的朋友可以先关注着。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询