ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32上的Modbus-RTU:串口、定时器与RS485配合实战指南

STM32上的Modbus-RTU:串口、定时器与RS485配合实战指南

简介:一套基于STM32微控制器与RS485总线的Modbus-RTU主从机通信完整工程,面向嵌入式开发者和工业通信学习者,重点解决多从机轮询采集、寄存器读取及主从角色切换等问题。系统默认运行在主机模式,自动寻址地址为01的从机获取数据;通过按键1~3可分别查询01、02、03号从机数据,按下按键4则切换为从机模式,本机地址变为0x02并响应其他主机请求。代码中还包含LED状态指示、串口收发控制、定时器超时与帧间隔判断,采用非DMA方式实现,流程直观,便于理解Modbus-RTU帧格式与RS485收发切换逻辑。压缩包内共163个文件,大小3.02MB,以C语言源码为主,包含37个.h头文件和36个.c源文件,附带Keil工程配置、编译生成的hex/axf、备份文件、脚本与说明文档,可直接导入工程编译烧录。该资源已有515人学习浏览,借助完整的主机、从机双角色代码和按键交互演示,可快速掌握工业现场常用通信方案的工程实现,适合在此基础上二次开发或移植到实际项目。

1. 为什么说 Modbus-RTU 在 STM32 上的难点不是协议,而是串口、定时器和 RS485 的配合

把 Modbus-RTU 移植到 STM32 上其实不复杂:一个串口(UART)收发字节,一个定时器判断帧结束,一颗 RS485 收发器切换方向。很多人卡住,不是不懂协议,而是把这三者的配合时序搞乱了,最后出来的现象非常玄学——从机偶尔回、主机发出去没反应、CRC 明明对了解析还是错。这份资源把主机和从机代码都拆好了,串口和定时器部分也是能直接用的,适合两种人:一是手里有带 RS485 的传感器或仪表盒子,想用 STM32 做数据采集;二是要做多设备组网,需要自己维护一套主从轮询逻辑。文章后面我会把协议底子、收发实现、主从代码怎么落地说清楚,再把我踩过的几个坑逐个拆开讲。

2. Modbus-RTU 协议底子:帧格式、功能码、寄存器模型与 CRC16 这关先过

2.1 帧格式与三个关键时间

Modbus-RTU 的帧结构非常固定,一帧最多 256 字节。没有起始符,没有结束符,靠的是时间间隔来切帧。帧结构见下表:

字段长度说明
从机地址1 字节0x01 ~ 0xF7,0x00 为广播地址
功能码1 字节03 读保持寄存器、06 写单个寄存器、16(0x10)写多个寄存器等
数据域N 字节寄存器地址、寄存器数量、字节数、数据值,最大 253 字节
CRC162 字节低字节在前,高字节在后

关键的时间参数有三个:t1.5、t3.5 和字符时间。Modbus 标准规定,一帧内字节与字节之间的间隔要小于 t1.5,一帧与下一帧之间的间隔要大于 t3.5。t3.5 等于 3.5 个字符时间,每个字符按 11 位算(1 起始位 + 8 数据位 + 1 校验位 + 1 停止位的情况),所以 t3.5 = 11 × 3.5 / 波特率。波特率 9600 时约 3.57ms,115200 时约 0.334ms。

这也是为什么必须在串口之外配一个定时器:UART 接收中断只能告诉你“收到一个字节”,判断“这一帧收完没有”得靠定时器开一个时间窗口。窗口超时还没收到下一个字节,就认为帧结束了,可以进协议解析。这个窗口如果设短了,一帧会被拆成几段;设长了,主机轮询周期会被拖慢。

2.2 功能码与寄存器模型

嵌入式 Modbus 设备最常用的功能码就三个:03 读保持寄存器、06 写单个寄存器、16(0x10)写多个寄存器。功能码 0x80 加原功能码表示异常响应,比如 0x83 表示读保持寄存器请求失败。

功能码名称用途响应帧特征
0x03读保持寄存器读传感器数据、设备状态返回寄存器数量、数据字节数、寄存器值
0x06写单个寄存器设置参数、开关控制镜像返回请求帧
0x10写多个寄存器批量下发参数返回起始地址和寄存器数量
0x81/0x83/0x90异常响应非法功能码、非法地址、非法数据数据域为异常码

寄存器模型分四类:线圈(0x 开头)、离散输入(1x 开头)、输入寄存器(3x 开头)、保持寄存器(4x 开头)。实际项目里保持寄存器用得最多,一个寄存器 16 位,多字节数据大端传输,高位在前。

有一点容易绕晕:协议里寄存器地址是从 0 开始的偏移量。比如读保持寄存器请求01 03 00 00 00 01,意思是读从机地址 0x01 的保持寄存器区,从偏移 0x0000 开始读 1 个寄存器。对外描述成“40001 地址”是上位机或触摸屏的习惯,代码里操作的就是偏移地址。这一层不提前理清,后面看到抓包数据会半天对不上。

2.3 CRC16 实现与参数核对

CRC16 是 Modbus-RTU 唯一的校验手段,算法参数固定:多项式 0xA001(反向写法,实际是 0x8005 的按位反序)、初值 0xFFFF。工程上不推荐逐位计算,直接用查表法,速度差一个数量级。

static uint16_t crc_table[256]; /* 初始化 CRC 查表:按 0xA001 反向多项式生成 */ void crc16_table_init(void) { uint16_t i, j, crc; for (i = 0; i < 256; i++) { crc = i; for (j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc = crc >> 1; } } crc_table[i] = crc; } } /* 计算一帧数据的 CRC16 */ uint16_t modbus_crc16(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc = (crc >> 8) ^ crc_table[(crc ^ *buf++) & 0xFF]; } return crc; }

逻辑说明:modbus_crc16对传入的整帧数据(从从机地址开始,到数据域结束,不含 CRC 本身)计算 CRC。返回值是 16 位校验值。发送时必须先发低字节,再发高字节,这是 Modbus-RTU 协议明确规定的字节序。

参数说明:多项式 0xA001 是 0x8005 反向过来的,初值固定 0xFFFF,这两个参数一个都不能改。表驱动法的本质就是一个字节一个字节地拿当前 CRC 的低字节去查表,查出来的值跟原 CRC 的高字节异或,效率远高于按位循环。

这里给一个核对抓手:请求01 03 00 00 00 01的 CRC 是0x840A,发送顺序为0x0A 0x84。我每次移植完 CRC 代码,第一件事就是用这帧数据在串口助手上核对,对了再往下走,省得后面所有调错都叠加在一起。

3. 串口、定时器与 RS485 方向切换:收一帧干净数据的必要条件

3.1 串口参数与波特率误差先算清楚

串口参数用 8 数据位、无校验、1 停止位(8N1)最常见。有些设备要求偶校验,但绝大多数传感器和仪表出厂默认就是 8N1,先把无校验跑通,再根据具体设备文档去调校验位也不迟。

UART_HandleTypeDef huart2; static void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 9600; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart2); }

逻辑说明:这里以 USART2 为例,配置成 9600 波特率、8N1、收发模式。实际工程里波特率从 9600 到 115200 都常见,传感器类设备多数默认 9600。

参数说明:重点不是代码本身,而是波特率误差。STM32F103 的 USART1 挂在 APB2(72MHz 或 36MHz),USART2、USART3 挂在 APB1(最大 36MHz)。计算波特率分频值是BRR = PCLK / (16 × BaudRate),如果除不尽,实际波特率就跟目标有偏差。误差超过 2% 后,帧会偶发乱码,尤其线长、干扰大的场合更明显,而且这种故障不是每次都出现,排查起来很费劲。

我一般会在移植时直接把 APB1、APB2 的时钟配置打开,看一眼时钟树,确保 USART 的输入时钟是整一点的数值,再去算波特率误差。像 72MHz 下跑 115200 是能整除的,36MHz 下跑 9600 也整除,这两个组合基本不用操心。

3.2 定时器做 3.5 字符超时,别直接依赖 SysTick

判断帧结束,常见做法是“收到任意字节后清定时器,定时器溢出就代表帧结束”。这里定时器要用硬件定时器,不建议直接用 SysTick 做 1ms 滴答。9600 波特率下 t3.5 约 3.57ms,SysTick 勉强够用;但切到 115200 时 t3.5 约 0.334ms,1ms 的滴答粒度根本分不出帧间隔,会把连续两帧当成一帧。

/* TIM2 配置:1MHz 计数频率,ARR 按 t3.5 换算 */ static void MX_TIM2_Init(uint32_t baudrate) { float t35_us = 11.0f * 3.5f / baudrate * 1000000.0f; TIM_HandleTypeDef htim2 = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 36 - 1; /* 36MHz 分频到 1MHz,计数周期 1us */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = (uint32_t)t35_us - 1; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start_IT(&htim2); }

逻辑说明:TIM2 挂在 APB1 上,输入时钟 36MHz,预分频 35,得到 1MHz 计数频率,1 个计数单位就是 1us。把Period设为 t3.5 对应的微秒数减 1,计数器从 0 数到 ARR 溢出就产生一次更新中断。t35_us的计算是把“字符时间 = 11 位 / 波特率”乘以 3.5 再换算成微秒。

参数说明:变更波特率时,Period必须同步重算。代码里传baudrate参数,就是为了避免换波特率时忘记更新定时器。如果希望留一点余量,可以把Period乘一个 1.2 系数,但不能超过 t3.5 太多,不然主机切换下一帧时从机还在等。

接收逻辑里,每收到一个字节就把计数器清零重载,同时把数据放进接收缓冲区。UART 接收中断和定时器更新中断在两个回调里配合:

volatile uint8_t rx_buf[256]; volatile uint16_t rx_len = 0; volatile uint8_t rx_byte; volatile uint8_t frame_ready = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { rx_buf[rx_len++] = rx_byte; __HAL_TIM_SET_COUNTER(&htim2, 0); /* 清计数器,重新开始计时 */ } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2 && rx_len > 0) { HAL_TIM_Base_Stop_IT(&htim2); /* 帧结束,停掉定时器 */ frame_ready = 1; /* 主循环里查这个标志位 */ } }

逻辑说明:收到一个字节,进HAL_UART_RxCpltCallback,把字节存进缓冲区,然后清空 TIM2 计数器,重新开始计时。只要字节间隔小于 ARR 溢出时间,定时器就不会溢出,帧就一直在累积。一旦字节间隔超过 t3.5,TIM2 更新中断触发,置frame_ready。主循环看到这个标志后,把整帧数据交给解析函数,然后重新开启 UART 接收。

参数说明:rx_buf长度 256,刚好是 RTU 帧上限。rx_len是当前帧累计长度,解析完成后必须清零,否则下一帧数据会跟在后面。用 1MHz 计数时,3600 的 ARR 对应 3.6ms,跟 9600 波特率下的 t3.5 很接近,实际 3.57ms 也够用。

3.3 RS485 DE/RE 方向切换:发送完成才算完成

RS485 是半双工,同一时刻只能有一个方向在发。一般把收发器的 DE 和 RE 引脚接在一起,用一个 GPIO 控制方向:拉高进入发送态,拉低进入接收态。方向切换这件事,最容易翻车。

#define RS485_DE_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET) #define RS485_DE_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET) void rs485_send_frame(uint8_t *buf, uint16_t len) { RS485_DE_HIGH(); /* 切到发送方向 */ HAL_UART_Transmit(&huart2, buf, len, 100); /* 阻塞发送 */ /* 关键:等发送移位寄存器彻底移完 */ while (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TC) == RESET); RS485_DE_LOW(); /* 再切回接收方向 */ }

逻辑说明:HAL_UART_Transmit返回时,数据只是从软件缓冲区搬到了 UART 发送数据寄存器,最后一个字节可能还在移位寄存器里没发完。如果此时立刻把 DE 拉低,收发器瞬间切到接收方向,总线上的最后几个位会被截断,从机收到的是残缺帧,CRC 必挂。正确的姿势是等UART_FLAG_TC(发送完成标志)置位,再去拉低 DE。

参数说明:HAL_UART_Transmit第三个参数是超时时间,设为 100ms 即可,正常一次发几个字节用不了 1ms。while轮询 TC 标志是最稳的写法,没有任何延时误差。要注意的是 DMA 场景:很多人以为 DMA 传输完成中断里拉低 DE 就行,但 DMA 完成同样不等于移位寄存器发送完成,仍然要等 TC 标志。

4. 主机与从机代码落地:从状态机到轮询调度的完整实现

4.1 从机:地址匹配、状态机与功能码分派

从机的核心逻辑可以抽象成三步:收帧 → CRC 校验和地址匹配 → 功能码分派并回帧。地址不匹配直接忽略,不回任何数据。

typedef enum { MB_IDLE, MB_RX, MB_HANDLE, MB_TX } mb_state_t; mb_state_t mb_state = MB_IDLE; void modbus_slave_poll(void) { uint16_t crc_calc; if (frame_ready == 0) { return; } /* 1. 帧完整性检查 */ if (rx_len < 4) { /* 最短帧:地址+功能码+CRC */ mb_state = MB_IDLE; frame_ready = 0; rx_len = 0; return; } /* 2. CRC 校验:对整个帧除 CRC 外的部分计算 */ crc_calc = modbus_crc16((uint8_t *)rx_buf, rx_len - 2); if ((uint8_t)(crc_calc & 0xFF) != rx_buf[rx_len - 2] || (uint8_t)(crc_calc >> 8) != rx_buf[rx_len - 1]) { frame_ready = 0; rx_len = 0; return; /* CRC 错,静默丢弃 */ } /* 3. 地址匹配 */ if (rx_buf[0] != slave_addr) { /* 不是发给我的 */ frame_ready = 0; rx_len = 0; return; } mb_state = MB_HANDLE; handle_modbus_frame(); /* 功能码处理,组织回帧 */ mb_state = MB_TX; rs485_send_frame((uint8_t *)tx_buf, tx_len); frame_ready = 0; rx_len = 0; }

逻辑说明:modbus_slave_poll放在主循环里轮询调。frame_ready被置位说明定时器判定一帧收完了。第一步先查最小长度:地址 1 字节、功能码 1 字节、CRC 至少 2 字节,加起来 4 字节,少于 4 字节的帧直接丢掉。第二步用modbus_crc16对整个帧不含 CRC 的部分重新计算,跟帧尾两字节比对,不一致就静默丢弃,不回异常帧。第三步比对从机地址,不匹配也静默丢弃。

参数说明:slave_addr是编译期宏,比如0x01。如果你想做地址拨码,把它改成uint8_t变量,从 GPIO 读取拨码开关状态即可。CRC 校验失败不回帧是 Modbus 协议的标准行为,因为无法确认帧是发给谁的,乱回帧反而会造成总线冲突。

4.2 功能码处理:03 读、06 写、16 批量写

功能码处理是从机代码的核心。下面这段覆盖了最常用的读保持寄存器和写保持寄存器。

#define REG_NUM 8 static uint16_t regs[REG_NUM]; /* 保持寄存器映射表 */ void handle_modbus_frame(void) { uint8_t func = rx_buf[1]; uint16_t start_addr; uint16_t reg_cnt; uint16_t i; /* 功能码异常:只支持 03/06/16 */ if (func != 0x03 && func != 0x06 && func != 0x10) { tx_buf[0] = slave_addr; tx_buf[1] = func | 0x80; tx_buf[2] = 0x01; /* 01: 非法功能码 */ tx_len = 3; tx_len += 2; /* 补 CRC,代码略,同上 */ return; } start_addr = (rx_buf[2] << 8) | rx_buf[3]; /* 读保持寄存器:功能码 0x03 */ if (func == 0x03) { reg_cnt = (rx_buf[4] << 8) | rx_buf[5]; if (start_addr + reg_cnt > REG_NUM) { /* 地址越界 */ tx_buf[0] = slave_addr; tx_buf[1] = 0x83; tx_buf[2] = 0x02; /* 02: 非法数据地址 */ tx_len = 5; return; } tx_buf[0] = slave_addr; tx_buf[1] = 0x03; tx_buf[2] = reg_cnt * 2; /* 字节数 */ for (i = 0; i < reg_cnt; i++) { tx_buf[3 + i * 2] = (regs[start_addr + i] >> 8) & 0xFF; tx_buf[4 + i * 2] = regs[start_addr + i] & 0xFF; } tx_len = 3 + reg_cnt * 2; tx_len += 2; /* 追加 CRC 的位置 */ } /* 写单个寄存器:功能码 0x06,镜像返回请求帧 */ if (func == 0x06) { if (start_addr >= REG_NUM) { tx_buf[0] = slave_addr; tx_buf[1] = 0x86; tx_buf[2] = 0x02; tx_len = 5; return; } regs[start_addr] = (rx_buf[4] << 8) | rx_buf[5]; memcpy((uint8_t *)tx_buf, (uint8_t *)rx_buf, rx_len); tx_len = rx_len; tx_len += 2; } /* 最后统一在 tx_buf 尾部补 CRC */ uint16_t crc = modbus_crc16((uint8_t *)tx_buf, tx_len - 2); tx_buf[tx_len - 2] = crc & 0xFF; tx_buf[tx_len - 1] = crc >> 8; }

逻辑说明:handle_modbus_frame先做功能码合法性检查,只认 03、06、16,其余全部回0x80 | func的异常帧。读请求把起始地址和寄存器数量组合出来,先做越界检查再组织响应。写单个寄存器的响应帧按协议要求镜像请求帧,原样返回。最后统一补 CRC,发送前再走rs485_send_frame的方向切换。

参数说明:REG_NUM是保持寄存器数量,资源里默认 8 个,够放温度、湿度、状态这类数据。start_addr + reg_cnt > REG_NUM的判断是必须的,很多安全隐患都出在这里,寄存器地址越界会把内存写坏。回复的字节数reg_cnt * 2是 16 位寄存器的字节数,这里别写成reg_cnt,否则上位机解析会少一半数据。

4.3 主机:组帧、等待超时与多从机轮询

主机的逻辑跟从机正好相反:发请求帧,等从机回帧,超时重试。由于 RS485 是半双工,主机发出请求后必须立刻切回接收方向,然后等待从机应答。等待窗口要大于从机的处理时间,一般不小于 20ms。

uint8_t build_request(uint8_t addr, uint8_t func, uint16_t start_addr, uint16_t reg_cnt, uint8_t *buf) { uint16_t crc; buf[0] = addr; buf[1] = func; buf[2] = (start_addr >> 8) & 0xFF; buf[3] = start_addr & 0xFF; buf[4] = (reg_cnt >> 8) & 0xFF; buf[5] = reg_cnt & 0xFF; crc = modbus_crc16(buf, 6); buf[6] = crc & 0xFF; buf[7] = crc >> 8; return 8; } uint8_t wait_for_response(uint16_t timeout_ms) { uint32_t start = HAL_GetTick(); while (HAL_GetTick() - start < timeout_ms) { if (frame_ready) { uint16_t crc = modbus_crc16((uint8_t *)rx_buf, rx_len - 2); if ((uint8_t)(crc & 0xFF) == rx_buf[rx_len - 2] && (uint8_t)(crc >> 8) == rx_buf[rx_len - 1]) { return 1; } frame_ready = 0; rx_len = 0; } } return 0; /* 超时无有效应答 */ }

逻辑说明:build_request把地址、功能码、起始地址、寄存器数量填进缓冲区,再补上 CRC。wait_for_response用HAL_GetTick做超时上限,在窗口内不断查frame_ready,收到帧就校验 CRC。

参数说明:timeout_ms要根据从机响应速度调。普通传感器从机通常在 10ms 内回帧,填 50ms 足够;如果总线上挂了 PLC 这类响应慢的设备,要放宽到 200ms。重试逻辑我一般做 3 次:第一次超时,隔 20ms 重发;三次都失败,标记该从机离线,继续轮询下一台。别做无限重试,一台上电慢的设备会把整条总线的轮询周期拖死。

多从机轮询可以用一个结构体数组把每台设备的地址、寄存器区间、数据存放位置都管起来:

typedef struct { uint8_t addr; uint16_t start_reg; uint16_t reg_cnt; uint16_t *data_buf; } modbus_node_t; modbus_node_t nodes[] = { { 0x01, 0x0000, 2, sensor1_data }, { 0x02, 0x0000, 4, sensor2_data }, };

逻辑说明:主循环里遍历这个数组,对每个节点执行“组帧 → 发送 → 等待应答 → 解析数据”的流程。应答解析时,除了 CRC 校验,还要确认应答的地址等于请求地址、功能码等于请求功能码,防止总线上有其它设备窜扰导致错收。

参数说明:data_buf指向一个uint16_t数组,接收到的寄存器值直接按顺序存进去。这样应用层代码只需要看sensor1_data[0]就能拿到第一个寄存器的实时值,无需关心协议细节。

5. 避坑与排查:RS485 总线上几个翻车现场

5.1 方向切换不够干净,从机收不到完整帧

现象:主机发请求后,从机一次都不回。用串口助手抓主机 TX,数据看着没问题,但接上从机就是没反应。

原因:DE 引脚拉低太早。HAL_UART_Transmit返回后,最后一个字节可能还在移位寄存器里,此时切到接收方向,最后几个位被硬生生截断。从机收到的帧长度不对,CRC 必然校验失败,按协议要求静默丢弃,所以主机看到的就是“发出去没人回”。

解决:发送完成后必须等待UART_FLAG_TC置位再拉低 DE。这个是所有 RS485 半双工通信里最容易被忽视的坑。另外注意:DMA 传输完成中断里拉低 DE 也是一样的隐患,DMA 完成 ≠ 移位寄存器发送完毕,必须在 TC 中断里处理。

5.2 波特率误差导致偶发乱码

现象:短距离通信正常,线拉到 20 米以上就开始偶发乱码;天气热的时候故障率明显上升。

原因:波特率误差超过了 RS485 接收端的容忍范围。STM32F103 的串口时钟来自 APB1/APB2,如果分频数除不尽,实际波特率跟目标有偏差,线长了以后波形边沿退化,误差叠加就直接错位。温度升高会让芯片内部 RC 精度进一步下降。

解决:先用逻辑分析仪抓实际波形,量一下每位宽度是否跟理论值一致。然后在初始化时把时钟树配置打开,让串口时钟尽量整除目标波特率。9600 和 115200 在 72MHz / 36MHz 的主频下都有不错的组合,避开 4800、19200 这类容易除出小数的档位。距离长的话把波特率降到 9600,450 米内基本够用。

5.3 终端电阻、手拉手接线与地电位

现象:总线上挂 5 台设备,单独跟每台通信都正常,组网后偶发一帧错误,主机重试又恢复。

原因:要么没接终端电阻,信号在总线末端反射;要么接线方式不是手拉手菊花链,而是星形接法,反射叠加在数据帧上。另一个隐蔽原因是各设备供电不共地,RS485 的 A/B 线之间出现共模电压差。

解决:总线物理链路首尾两端各接一个 120Ω 终端电阻,中间设备不接。接线顺序必须手拉手:主机 → 设备1 → 设备2 → ... → 最后一台设备,不允许从中间分叉出去再接长线。共地问题最稳的办法是让所有设备的地电位拉通,或者用带隔离的 RS485 收发器(比如 ISO3082)。保护电路方面,A/B 线上加 TVS 管到地,防止插拔瞬间的静电打坏收发器。

5.4 CH340 下程序失败或下载后串口不工作

现象:板载 RS485 收发器,CH340 下载程序时提示连接失败;有时候下载成功了,串口调试助手打开就卡住。

原因:CH340 的 TXD 直接连到了 RS485 收发器的 DI 引脚。如果 DE 处于发送方向,CH340 的下载数据会被 485 芯片放大到总线上,但平时 DE 处于接收方向时,CH340 的 TXD 信号被 485 芯片屏蔽,下载协议握手失败。更常见的是 RS485 收发器的 RO 引脚输出一直把 RX 线拉在某个电平,导致串口工具一打开就误判收到数据。

解决:下载程序时把 RS485 的 DE 引脚强制拉低,或者干脆加一个跳线帽,把 485 芯片的 RO 和 CH340 的 RX 断开。工程化做法是设计一键下载电路时,把 BOOT0 拉高和 DE 拉低联动。调试时优先用 TTL 串口直接连 STM32 的 PA9/PA10,绕开 485 芯片,先把协议逻辑调通再切回 RS485 总线路。

5.5 三极管/MOS 自动收发电路在高波特率下波形畸变

现象:有人为了省一个 GPIO,用三极管或 MOS 管搭“免方向控制”电路,9600 波特率下一切正常,换成 230400 后从机完全乱码。

原因:这类自动收发电路靠 TXD 的电平反向驱动 DE,方向切换的边沿取决于三极管/MOS 管的开关速度和上拉电阻的充放电时间。波特率一高,位宽从 104us 缩到 4.3us,方向切换沿还没稳定,数据位已经开始输出了,收发器在发送态和接收态之间反复横跳。

解决:低速场景(≤19200)可以用自动收发电路,但 115200 及以上不建议。改用 GPIO 直接控制 DE,配合 TC 标志切换,时序完全可控。如果是自己画板,直接在原理图阶段就留一个 MCU GPIO 到 DE/RE 的网络,比后期飞线改板子省事得多。

6. 进阶:DMA 加空闲中断收帧,把 Modbus 从机的 CPU 占用降下来

6.1 为什么要换成 DMA 收帧

前面的实现是每个字节触发一次 UART 接收中断,外加一次定时器更新中断或超时查询。9600 波特率下,一帧 8 个字节也就是 8 次中断,CPU 负担不大。但一旦波特率拉到 115200,一秒钟上千字节,每字节一次中断的开销就很可观了,而且中断里还要做缓冲区写入和定时器清零。更麻烦的是,从机同时还要处理其它任务,中断一多,主循环的实时性就会受影响。换成 DMA 加 UART 空闲中断后,数据由 DMA 自动搬进缓冲区,CPU 只在“一帧收完”这个时刻被唤醒一次。

6.2 HAL 库的 DMA 加空闲中断实现

STM32 的 UART 自带空闲帧检测:总线空闲超过一个字节时间就产生 IDLE 中断。把 DMA 设成循环模式接收,数据持续进缓冲区;一旦 IDLE 中断触发,就说明一帧结束了,此时从 DMA 缓冲区里取数据即可。

uint8_t dma_rx_buf[256]; static void MX_USART2_UART_Init_DMA(void) { /* 基础 UART 配置同前,BaudRate 按实际调 */ huart2.Init.BaudRate = 9600; HAL_UART_Init(&huart2); /* 绑定 DMA 通道,循环模式 */ hdma_uart2_rx.Instance = DMA1_Channel6; hdma_uart2_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_uart2_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_uart2_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_uart2_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_uart2_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_uart2_rx.Init.Mode = DMA_CIRCULAR; HAL_DMA_Init(&hdma_uart2_rx); __HAL_LINKDMA(&huart2, hdmarx, hdma_uart2_rx); /* 开启 DMA 接收 + 空闲中断 */ HAL_UARTEx_ReceiveToIdle_DMA(&huart2, dma_rx_buf, sizeof(dma_rx_buf)); __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART2) { uint16_t len = Size; /* 本次事件已经收到的字节数 */ uint16_t crc; if (len >= 4) { crc = modbus_crc16((uint8_t *)dma_rx_buf, len - 2); if ((uint8_t)(crc & 0xFF) == dma_rx_buf[len - 2] && (uint8_t)(crc >> 8) == dma_rx_buf[len - 1]) { /* 完整有效帧,交给协议解析 */ handle_modbus_frame_from_buf((uint8_t *)dma_rx_buf, len); } } /* 重新开启接收,准备下一帧 */ HAL_UARTEx_ReceiveToIdle_DMA(&huart2, dma_rx_buf, sizeof(dma_rx_buf)); } }

逻辑说明:DMA 处于循环模式,收到的数据不断写入dma_rx_buf,写满后自动从头开始覆盖。HAL_UARTEx_ReceiveToIdle_DMA会把 DMA 和空闲中断绑定在一起,一帧数据结束后触发HAL_UARTEx_RxEventCallback,参数Size就是本次收到的字节数。因为 DMA 是循环模式,如果一帧超过缓冲区长度,数据会覆盖,所以要保证dma_rx_buf不小于 256 字节。

参数说明:Size拿到的就是已接收字节数,不需要再读 DMA 计数器,这是 HAL 库封装好的行为。有些旧版本固件库需要手动读DMA_GetCurrDataCounter来计算长度,移植时留意一下版本差异。另外,空闲中断触发后要立刻重新调用一次HAL_UARTEx_ReceiveToIdle_DMA,否则只能收到第一帧。

6.3 验证方法与后续扩展

换成 DMA 方案后,验证工作分三步走。第一步,用逻辑分析仪抓总线波形,看每帧之间的间隔是否大于 t3.5,小于 t1.5 的字节间隔是否正常。第二步,用 Modbus Poll 或串口助手连续读从机保持寄存器 100 次,统计错误率,正常应为 0。第三步,人为把总线拔掉再插上,看主机能否在超时后自动重连,从机能否在恢复后正常应答。

这套 DMA 收帧逻辑同样可以复用到 Modbus 主机侧,只需要把空闲中断后的处理函数从“解析请求”改成“解析应答”。资源里的从机代码和主机代码结构是分开的,寄存器映射表、CRC 校验、超时重试都是独立模块,改起来不需要动协议骨架。我那之后每次上新的 RS485 项目,都强制自己按“接收超时 → CRC 校验 → 方向切换 → 压测”这个顺序完整走一遍,再急也不敢跳步。希望帮到你。

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

返回列表