news 2026/10/16 10:00:57

STM32+RS485+DMA实现Modbus RTU从站,告别串口中断风暴

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32+RS485+DMA实现Modbus RTU从站,告别串口中断风暴

简介:面向STM32嵌入式开发者的RS485与Modbus通信实战资料包,基于STM32F103ZET6平台讲解如何借助DMA实现高效主从数据交互,覆盖RS485接口配置、Modbus RTU读写报文(0x03/0x06功能码)解析与处理等核心环节,适合正在学习串口通信、工业总线协议或需要快速搭建主从机通信逻辑的开发者。资料包共171个文件,约3.32MB,以C源文件(.c)、头文件(.h)为主,配合工程文件、编译输出及调试记录,可对照源码理解DMA通道设置、串口收发缓冲和Modbus状态机实现。已有8310人学习下载。内容包含完整MDK工程配置、HAL/LL库API调用示例、收发流程注释以及错误处理与超时机制思路,可直接导入工程查看或作为修改模板,帮助缩短RS485+Modbus+STM32项目从设计到调通的周期。

1. 从串口“裸收”到 STM32 + RS485 + DMA + Modbus:一个量程差带来的重构

如果你在嵌入式现场待过,大概率碰到过这种场景:串口中断里逐字节收数据,来一帧还能顶住,来一个 100 字节的批量参数帧,CPU 直接被拖到卡顿,偶尔还丢寄存器。我把这个项目从“裸收”重构成了 STM32 + RS485 + DMA + Modbus 的完整从站实现,硬件用 STM32F103C8T6,物理层走 RS485 差分,数据搬运交给 DMA,协议层按 Modbus RTU 规范解析和应答。这套组合下来,一个批量读寄存器的请求从进串口到 CPU 介入处理,DMA 已经帮你把整帧抬进内存了,CPU 只做 CRC 和功能码分支。适合正在给设备加 Modbus 从站支持、又不想被串口中断拖垮 CPU 的嵌入式开发者。

2. 技术选型与整体架构:RS485 物理层、DMA 数据链路与 Modbus RTU 协议栈

2.1 为什么是 RS485 + DMA + Modbus 这个组合

先说为什么不用 RS232。RS232 只能点对点,抗干扰也一般,现场一拉几十米线,共模干扰直接让波形失真。RS485 是差分传输,A/B 两条线,能挂 32 个节点,1200 米内跑 115200 波特率都很稳。对工业设备来说,这不只是“能不能通信”的问题,是“能不能长期稳定通信”的问题。

Modbus RTU 是协议层的选择。PLC、组态软件、触摸屏基本都原生支持 Modbus,报文格式也简单:地址、功能码、数据、CRC16。对 MCU 来说,协议栈压力不大,但要注意它是个“严格时隙”协议,帧与帧之间必须大于 3.5 个字符时间,小于这个间隔就会被当成同一帧。这也是后面最容易翻车的地方。

DMA 解决的是 CPU 占用问题。115200 波特率下,一个字节间隔约 86.8 微秒,如果每个字节都进中断,CPU 大部分时间都在做压栈弹栈。DMA 不是 CPU,它能把接收到的字节连续搬运到内存缓冲区,直到一帧结束后触发串口空闲中断(IDLE),这时 CPU 才过来处理。项目中我实际测过,同样的数据量,改 DMA 后 CPU 中断次数从原来的每秒几万次降到一帧一次,效果非常直观。

2.2 工程目录结构与关键文件划分

这是当时整理的工程目录,没有把 HAL 驱动源码全列出来,只挑和 Modbus 相关的部分。

project/ ├─ Core/ │ ├─ Inc/ │ │ ├─ main.h │ │ ├─ modbus.h │ │ ├─ crc16.h │ │ └─ usart_dma.h │ └─ Src/ │ ├─ main.c │ ├─ modbus.c │ ├─ crc16.c │ └─ usart_dma.c ├─ Drivers/ │ └─ STM32F1xx_HAL_Driver/ ├─ MDK-ARM/ │ └─ Project.uvprojx └─ stm32f103c8tx_flash.ld

文件职责很简单:usart_dma.c只负责串口收发、DMA 启动、IDLE 中断搬运,不关心 Modbus 内容;modbus.c只处理协议拼装和功能码分发,不直接操作寄存器外设;crc16.c独立成一个模块,方便单元测试。这样分层有个好处,以后想把 Modbus 从 USART1 挪到 USART2,只需改一个上下文结构体里的 UART 句柄,协议逻辑一行不用动。

一个比较关键的设计是modbus_ctx_t上下文结构体。它把所有状态集中管理,避免一堆全局变量乱飞。我当时是这么定义的:

typedef struct { UART_HandleTypeDef *huart; uint8_t slave_addr; uint16_t reg_start; uint16_t reg_count; uint8_t rx_buf[256]; volatile uint16_t rx_len; uint8_t dma_rx_done; GPIO_TypeDef *dir_port; uint16_t dir_pin; void (*on_frame_ready)(struct modbus_ctx *ctx); } modbus_ctx_t;

参数说明:huart是 HAL 串口句柄,slave_addr是 Modbus 从站地址,rx_buf是 DMA 直接用到的接收缓冲,rx_len记录当前帧长度,dir_port和dir_pin用于 RS485 收发方向切换。on_frame_ready是帧就绪回调,主循环也能通过它拿到通知。

2.3 数据疏通全流程:从物理层到协议层

整体数据流我用一句话概括:RS485 收发器把差分信号转成 TTL 电平,送到 USART1 外设,DMA 在没人管的情况下把字节搬运到rx_buf,当总线空闲超过一个字符时间,USART 产生 IDLE 中断,CPU 在中断里计算收到的字节数,然后交给modbus_frame_parse做地址校验、CRC 校验和功能码处理,最后组响应帧,再通过 DMA 发送出去。

注意一个顺序问题:RS485 是半双工,发送时必须先把收发器方向引脚切到发送模式。如果方向引脚切换太慢,或者发完没等最后一字节真正从移位寄存器里出去就切回接收模式,响应帧最后一个字节会被截断。这个坑后面专门写一节。

主循环是典型的“事件驱动 + 轮询”:

while (1) { if (ctx.rx_len > 0) { uint16_t len = ctx.rx_len; ctx.rx_len = 0; modbus_frame_parse(&ctx); } handle_plc_loop(); HAL_Delay(10); }

ctx.rx_len在 IDLE 中断里被写入,主循环读到后清零再处理。这样协议解析不放在中断里,避免占用过多中断时间。handle_plc_loop是设备自己的业务逻辑,比如更新寄存器缓存、采集传感器数值,它和 Modbus 解耦,业务数据放进共享的寄存器数组里。

3. 核心模块拆解:DMA 环形接收、Modbus 帧解析与 CRC16 实现

3.1 串口 DMA 初始化:通道配置与空闲中断

STM32F103 的 USART1 有固定的 DMA 请求映射:发送是 DMA1 Channel4,接收是 DMA1 Channel5。初始化时要把接收 DMA 配成循环模式(Circular),因为接收是持续不断的,DMA 写完末尾会自动回到缓冲区开头,这样一帧数据可以由多个分段拼成,我们只需要关心“增量”。

static void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_usart1_rx.Instance = DMA1_Channel5; hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_usart1_rx); __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx); }

这里有个容易被忽略的参数:PeriphInc必须设为DMA_PINC_DISABLE。外设地址是 USART1 数据寄存器,它是固定的;如果配置成递增模式,DMA 会去读一串连续的地址,得到一堆垃圾字节。缓冲区地址内存递增、外设地址不递增,这是串口 DMA 的基本常识,但新手非常容易配反。

然后开启 USART1 的 IDLE 空闲中断:

HAL_UART_Receive_DMA(&huart1, ctx.rx_buf, 256); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

这两行代码的顺序很关键:先启动 DMA 接收,再打开 IDLE 中断。如果顺序反了,总线已经空闲时 IDLE 置位,你还没来得及启动 DMA,中断就白白丢了。实际项目里我一般先把HAL_UART_Receive_DMA放在初始化最后一步。

IDLE 中断的处理放在STM32F1xx_IRQHandler.c或直接写在it.c,大致长这样:

void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) && __HAL_UART_GET_IT_SOURCE(&huart1, UART_IT_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_DMAStop(&huart1); uint16_t remaining = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); ctx.rx_len = 256 - remaining; if (ctx.rx_len >= 4 && ctx.rx_len <= 256) { modbus_frame_parse(&ctx); } ctx.rx_len = 0; HAL_UART_Receive_DMA(&huart1, ctx.rx_buf, 256); } HAL_UART_IRQHandler(&huart1); }

逻辑说明:进来先清 IDLE 标志,然后停止 DMA,读取 DMA 剩余计数。DMA_CNDTR寄存器里存着还有多少字节没搬运完,用缓冲区大小减去剩余数,就是当前已经收到的字节数。这个数字就是本帧长度,交给 Modbus 解析。解析完必须重新调用HAL_UART_Receive_DMA,让 DMA 重新接管接收缓冲区。

HAL_UART_DMAStop有个副作用:它会把 DMA 通道和串口的 DMA 请求一起关掉,所以解析之后必须重启接收。否则第二次来的帧就不会触发 DMA 搬运,整个从站直接哑掉。我第一次调试时就是漏掉重启,结果每次只能正常应答一帧,之后就杳无音信。

3.2 CRC16 冗余校验:Modbus RTU 的底线

Modbus RTU 使用 CRC16-Modbus,多项式是 0xA001,初始值 0xFFFF,数据从最低位开始处理。这个 CRC 覆盖从地址到数据段的全部字节,不包括 CRC 本身。发送时低字节在前、高字节在后。

很多初学者容易拿标准 CRC16-CCITT 的代码来跑,结果奇偶校验老不对。Modbus 是 LSB 优先,不是 MSB 优先。下面是逐位版本:

uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *data++; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }

参数说明:data是参与校验的字节流,len是字节数,返回值直接就是符合 Modbus 字节序的 CRC 值。注意返回值和uint16_t在内存中的存储顺序:低字节在前,高字节在后。发送时直接:

uint16_t crc = crc16_modbus(frame, len); frame[len++] = crc & 0xFF; frame[len++] = crc >> 8;

如果你在 115200 波特率下处理大量帧,逐位 CRC 其实也够用,一帧几微秒就算完了。但如果你做的是 Modbus 主站,要同时轮询几十个从站,建议换成查表法,性能差距有一个数量级。

3.3 帧状态机:从字节流到完整数据帧

Modbus RTU 看起来是个二进制定长帧,实际上没有起始符、没有结束符,完全靠“帧间 > 3.5 个字符时间间隔”来区分帧。所以在中断里拿到rx_len之后,直接按内存中的完整帧解析即可。解析的第一步不是功能码,而是长度和 CRC。

static void modbus_frame_parse(modbus_ctx_t *ctx) { uint8_t *buf = ctx->rx_buf; if (ctx->rx_len < 8) return; uint16_t recv_crc = buf[ctx->rx_len - 2] | (buf[ctx->rx_len - 1] << 8); uint16_t calc_crc = crc16_modbus(buf, ctx->rx_len - 2); if (recv_crc != calc_crc) return; uint8_t addr = buf[0]; if ((addr != ctx->slave_addr) && (addr != 0xFF)) return; ... }

先把接收帧里最后两字节拼成recv_crc,再对除 CRC 以外的字节算一遍calc_crc,两者不等直接丢弃。这里我一般还会留一个crc_error_count计数器,方便现场排查线路质量。如果 CRC 错误频繁,基本可以断定是 RS485 收发器方向切换太快、波特率偏差大、或者线路干扰严重。

地址校验这里有个细节:Modbus 广播地址 0xFF 从站不回应,但需要执行写命令。所以读功能码遇到 0xFF 就不要回复,写功能码可以执行但不能应答。很多人漏掉这一点,导致广播时从站回帧,整条总线上所有从站被自己污染。

3.4 寄存器读写映射与响应处理

协议处理的核心数据载体是保持寄存器数组。MDK 工程里我定义了一个固定大小数组:

#define REG_COUNT 16 static uint16_t g_regs[REG_COUNT];

Modbus 03 功能码是“读保持寄存器”,请求格式:地址、功能码、起始寄存器、寄存器数量、CRC2 字节。响应格式:地址、功能码、字节数、数据、CRC。

static void modbus_handle_read_holding(modbus_ctx_t *ctx) { uint8_t *buf = ctx->rx_buf; uint16_t reg_addr = (buf[2] << 8) | buf[3]; uint16_t reg_qty = (buf[4] << 8) | buf[5]; if (reg_addr < ctx->reg_start || reg_addr + reg_qty > ctx->reg_start + ctx->reg_count) { modbus_send_exception(ctx, 0x02); return; } uint8_t resp[64]; uint8_t pos = 0; resp[pos++] = ctx->slave_addr; resp[pos++] = 0x03; resp[pos++] = reg_qty * 2; for (uint16_t i = 0; i < reg_qty; i++) { uint16_t value = g_regs[reg_addr + i - ctx->reg_start]; resp[pos++] = value >> 8; resp[pos++] = value & 0xFF; } uint16_t crc = crc16_modbus(resp, pos); resp[pos++] = crc & 0xFF; resp[pos++] = crc >> 8; modbus_rs485_send(ctx, resp, pos); }

逻辑说明:先判断起始地址和数量是否越界,越界返回异常码 0x02。注意响应里的寄存器编号是绝对地址,但数组索引是相对地址,所以取数据时要减掉reg_start。Modbus 数据大端在前,所以高字节先发。

06 功能码写单个寄存器相对简单:读请求里的寄存器地址和值,写入g_regs,然后原样回显请求帧即可。0x10 写多个寄存器要额外处理一个字节数:

static void modbus_handle_write_multiple(modbus_ctx_t *ctx) { uint8_t *buf = ctx->rx_buf; uint16_t reg_addr = (buf[2] << 8) | buf[3]; uint16_t reg_qty = (buf[4] << 8) | buf[5]; uint8_t byte_count = buf[6]; if (byte_count != reg_qty * 2) return; for (uint16_t i = 0; i < reg_qty; i++) { uint16_t value = (buf[7 + i * 2] << 8) | buf[8 + i * 2]; g_regs[reg_addr + i - ctx->reg_start] = value; } uint8_t resp[8]; resp[0] = ctx->slave_addr; resp[1] = 0x10; resp[2] = buf[2]; resp[3] = buf[3]; resp[4] = buf[4]; resp[5] = buf[5]; uint16_t crc = crc16_modbus(resp, 6); resp[6] = crc & 0xFF; resp[7] = crc >> 8; modbus_rs485_send(ctx, resp, 8); }

这地方有个常见错误:很多人忘记校验byte_count是不是真的等于reg_qty * 2。如果主站发来数量和长度不一致的脏帧,按长度循环读取会越界,直接把内存读崩。保守一点直接丢弃这种帧,比回异常更稳。

4. 项目编译与真机运行:从 CubeMX 配置到收发实测

4.1 最小硬件系统与接线表

我用的最小系统板是 STM32F103C8T6,RS485 收发器是 MAX3485,3.3V 供电,支持到 10Mbps。接线表如下:

引脚功能对应连接
PA9USART1_TXMAX3485 DI
PA10USART1_RXMAX3485 RO
PA0方向控制 GPIOMAX3485 DE + /RE
GND地与 RS485 总线共地
A/B差分总线连接从站或主站

这里把 DE 和 /RE 两个引脚短接到一起,用同一个 GPIO 控制。GPIO 输出高电平时收发器进入发送模式,低电平时为接收模式。之所以能这么做,是因为 MAX3485 的 DE 高有效,/RE 低有效,两个引脚合并后正好是“高发低收”。

短距离测试时,A/B 之间可以不接终端电阻,但跑 50 米以上建议在线路两端各接一个 120 欧姆电阻。不接终端电阻的表现是波形反射,远距离时 CRC 错误率会随线长增加,这个不是代码能救回来的。

4.2 时钟、串口与 GPIO 初始化配置

CubeMX 里配置要点:RCC 选择 HSE 外部晶振,时钟拉到 72MHz;USART1 模式选 Asynchronous,波特率 115200,8 数据位、1 停止位、无校验;DMA Settings 里添加 USART1_RX,Mode 选 Circular;PA0 配置为 GPIO_Output,初始电平要拉低,让设备上电先处于接收状态。

如果不用 CubeMX,手动初始化时最容易被绊倒的是 DMA 时钟。USART1_RX 在 DMA1 上,必须先使能 DMA1 时钟,否则 DMA 通道配置全部无效。另外 F103 的 DMA1 通道之间不能同时申请同一优先级,我习惯把所有串口 DMA 优先区分开。

优先级方面,我一般把 RX DMA 配成 High,TX 配成 Medium。因为 RX 是接收外部请求,丢了就得等主站重试;TX 是主动发出的响应,晚一会儿问题不大。

4.3 主循环与串口命令调试

代码烧进去以后,先用逻辑分析仪或 USART 调试助手验证发送部分。我调试时习惯把 modbus 模块中所有接收帧都打出来:

void modbus_debug_dump(const modbus_ctx_t *ctx) { printf("[RX] len=%d ctx=", ctx->rx_len); for (uint16_t i = 0; i < ctx->rx_len; i++) { printf("%02X ", ctx->rx_buf[i]); } printf("\r\n"); }

在modbus_frame_parse最开始调用这个函数,可以直观看到收到的原始帧。建议先只打印不处理,确认主站发的帧和你的地址、CRC 是否对得上。很多“协议不通”的问题,根因是主站发的地址和你slave_addr配的不是同一个值。

调试主站可以用现成软件 Modbus Poll,也可以直接用串口助手发十六进制帧。最低限度跑通 03 功能码,请求帧如下:

01 03 00 00 00 01 84 0A

对应解析:从站地址 0x01,功能码 0x03,起始寄存器 0x0000,读 1 个寄存器,最后两字节是 CRC。预期响应是:

01 03 02 00 00 B8 44

如果g_regs[0]默认是 0,响应应是00 00。如果不对,先看响应里的 CRC 是不是正确的。响应帧发出去后,逻辑分析仪测 RX 引脚方向电平,确认方向切换有没有吞字节。

4.4 用空闲中断和帧间隔判断帧边界

Modbus RTU 判帧靠时间间隔,不靠帧头帧尾。所以设备必须能区分“两个字节之间的空隙”和“两帧之间的空隙”。USART 的 IDLE 中断触发条件是总线空闲一个字节时间(即 1/波特率 * 10 比特),这个恰好可以作为帧结束标志。

但要注意:在 DMA 循环接收模式下,如果一帧正好落在缓冲区末尾,DMA 会从头部重新开始写,你的rx_buf中看到的可能是一段被截断的帧。处理办法是别把 DMA 缓冲区设得太小;我用 256 字节,Modbus RTU 最大帧 256 字节,正常不会拆成两段。如果真担心,可以在 DMA 半满中断里做帧拼接,但工程复杂度会明显上升,不是所有项目都值得。

5. 常见问题与避坑:RS485 方向切换、DMA 半传中断与帧粘连调试日志

5.1 响应帧最后一个字节总是丢

现象:Modbus Poll 能收到响应,但偶尔最后一位数据不对,CRC 校验失败,重试几次又正常。

原因:RS485 方向引脚切换太早。我在发送函数里先置高 PA0,然后调用HAL_UART_Transmit_DMA,发送完成后立即把 PA0 拉低。问题在于 DMA 完成中断产生时,最后一个字节可能还留在 USART 移位寄存器里没完全发完。此时切回接收模式,收发器关闭了发送通路,最后一个字节被截断。

解决:给 TX 完成中断加一点点延迟,或更规范一点,在HAL_UART_TxCpltCallback里切方向时先查__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC),确保发送移位寄存器为空再拉低。关键代码:

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { while (!__HAL_UART_GET_FLAG(huart, UART_FLAG_TC)); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET); } }

从那以后我所有 RS485 工程都会等 TC 标志,不再用固定延时去碰运气。定时延时会随波特率、系统时钟变化,TC 标志才是准确的硬件答案。

5.2 DMA 接收缓冲重复触发 IDLE,数据被覆盖

现象:设备刚上电时一切正常,连续跑几分钟后,Modbus 主站开始间歇性报超时,重新上电又恢复。

原因:IDLE 中断里处理完一帧后,如果没来得及重启 DMA,或者 DMA 已经开始新一轮接收,但旧帧数据还没被解析完,rx_buf的内容被新数据覆盖。确切说是重启 DMA 后,主站下一帧来得太快,旧帧的 CRC 已经不在内存里了。

解决:把“停止 DMA -> 计算长度 -> 解析 -> 重启 DMA”做成原子流程,在解析完成前不要重启 DMA。如果帧解析时间超过一个帧间隔,可以在中断里只置位dma_rx_done,把解析放到主循环:

if (dma_rx_done) { dma_rx_done = 0; modbus_frame_parse(&ctx); HAL_UART_Receive_DMA(&huart1, ctx.rx_buf, 256); }

注意这里主循环比中断慢,所以必须在主循环解析前关掉 DMA,等处理完再开。否则这段时间里进来的新帧会写到同一个缓冲,造成解析错乱。

5.3 把 CRC 算错或字节序发反

现象:能收到请求帧,但 CRC 校验失败,日志里recv_crc和calc_crc永远对不上。

原因:Modbus CRC 的字节序是低字节在前。用uint16_t值直接发送时,如果你把crc强转成uint8_t*发送,正好是低字节在前,这是对的;但如果你手写buf[len++] = crc >> 8; buf[len++] = crc & 0xFF;,把高字节放前面,主站就判你 CRC 错误。

解决:发送时严格按低字节在前。还有一个小坑:CRC 校验的数据范围只覆盖到数据段最后一个字节,不包含 CRC 本身。crc16_modbus(buf, len - 2)里的len是整个帧长度,所以要减 2。

5.4 串口波特率漂移导致偶尔帧粘连

现象:设备在调试器连接时一切正常,拔掉调试器单独跑,偶发 Modbus 主站读寄存器返回错误,但重新插上调试器又好了。

原因:如果系统时钟不是来自外部晶振,而是用内部 HSI,温漂和误差会累积到波特率上。HSI 的精度通常是 ±1%,Modbus RTU 的 3.5 字符间隔要求 UART 不能有明显偏差。调试器连接时供电稳定,单独跑时电源纹波大,就会出这种玄学问题。

解决:优先使用外部 8M 晶振,PLL 倍频到 72MHz。如果你在 F103 上已经用 HSI,请把时钟源换成 HSE。串口通信可靠性这块,晶振带来的收益比任何软件技巧都大。

5.5 调试里开了 O2 优化,回调变量失效

现象:不开优化时 Modbus 正常,一旦开 Keil/O2 优化,CRC 偶尔错误,DMA 长度变量偶尔等于 0。

原因:rx_len等变量被编译器优化掉了。中断和主循环之间共享的状态没有加volatile,编译器认为主循环里不可能被中断修改,就直接缓存寄存器副本,导致读到的长度是旧值。

解决:所有跨中断/主循环共享的标志位、长度变量都要声明成volatile。我的习惯是统一加volatile,并且把这类变量放到一个结构体里集中管理。更规范一点可以用内存屏障或关中断保护,但在状态机简单场景下,volatile已经足够。

6. 二次开发:把固定轮询改成事件驱动,抽一个可复用的 Modbus 从站引擎

项目跑通之后,很多人第一反应是“我来改成主站”,或者“把它塞进多设备系统里”。我的建议是先别急着写主站功能,而是把从站协议层抽出来,做成一个不依赖具体串口的引擎。

当前modbus.c里大量直接操作huart1、PA0,换一个板子就得改协议层代码。更好的做法是注册底层接口:

typedef int (*modbus_send_func)(uint8_t *data, uint16_t len); typedef void (*modbus_delay_func)(uint16_t ms); typedef void (*modbus_read_reg_cb)(uint16_t addr, uint16_t qty, uint16_t *val); typedef void (*modbus_write_reg_cb)(uint16_t addr, uint16_t qty, uint16_t *val);

这样modbus.c只负责帧解析和功能码分支,不再关心 USART 是哪个实例、DMA 通道是几号。每个项目的底层移植工作被压缩在usart_dma.c和rs485_gpio.c两个文件里。

实际改造时还有一个收益明显的小改动:把寄存器数组改成可配置范围。比如某些设备只需要 8 个寄存器,另一些需要 128 个,把REG_COUNT从宏改成结构体成员,同时把越界判断统一走reg_start和reg_count。我现在写 Modbus 模块,都是先定上下文结构体和回调接口,再填寄存器读写函数。

验证方法也很简单:先用串口调试助手做“半自动回归测试”,把 03、06、16 三个功能码的请求帧各存一列,设备每次复位后依次发送,检查响应是否稳定一致。尤其是 06 写单个寄存器和 16 写多个寄存器,响应帧必须和请求帧的数据段完全一致,这是 Modbus 从站最容易漏的细节。

从接手这个项目到稳定跑通,我最大的教训就是:一切靠时序的东西,都不要用肉眼目测和祈祷,第一次就把 RS485 方向切换、DMA 重启、CRC 字节序这三件事钉死在代码里。从那以后我每次加 Modbus 协议栈,都强制走一遍这三项自检,再开始写业务逻辑。希望帮到你。

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

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

邮件预览功能实现:EML解析与HTML安全过滤全指南

后台管理系统但凡涉及邮件模块&#xff0c;基本都会遇到一个高频需求&#xff1a;邮件预览功能。无论是客服工单里需要查看往来的邮件记录&#xff0c;运营后台要给营销邮件做发前检查&#xff0c;还是测试人员想快速验证一封邮件在不同客户端的渲染效果&#xff0c;邮件预览都…

作者头像 李华
网站建设 2026/10/16 10:00:44

PyInstaller打包实战:从安装到生成exe的完整避坑指南

简介&#xff1a;PyInstaller打包工具的早期安装包&#xff0c;面向需要将Python脚本转换为独立可执行文件的开发者&#xff0c;也适合想研究打包机制内部实现的进阶学习者。压缩包内共19个文件&#xff0c;核心的.py脚本承载打包逻辑&#xff0c;txt文件提供说明文档与测试样本…

作者头像 李华
网站建设 2026/10/16 9:59:53

Dify PDF解析实战:从文本抽取到RAG知识库的完整链路

做文档问答类项目&#xff0c;绕不开PDF。我手头这个项目叫【dify-pdf】&#xff0c;说白了就是在Dify平台上做一套PDF文档的处理与检索问答链路。最开始我以为PDF导入知识库是件小事&#xff0c;结果真正跑起来才发现&#xff0c;PDF这个格式从头到尾都在给RAG&#xff08;检索…

作者头像 李华
网站建设 2026/10/16 9:58:29

占位标题需求处理:从“111111”到可执行落地的完整方法

拿到这样一个标题时&#xff0c;我的第一反应不是抱怨&#xff0c;而是把它当成一个非常典型的“占位需求”案例。占位标题往往意味着提出需求的人还没想清楚&#xff0c;或者正在用最简单的编号先占住一个坑&#xff0c;本质上是在问&#xff1a;“这个方向值不值得往下做&…

作者头像 李华
网站建设 2026/10/16 9:57:07

Tello无人机PC端硬核控制:UDP通信+H.264裸流解码+OpenCV实时渲染

简介&#xff1a;本资源是一套面向嵌入式开发与无人机爱好者的大疆Tello PC端控制实战项目&#xff0c;聚焦UDP通信、GUI交互与实时视频处理三大核心技术&#xff0c;适用于具备C基础并希望拓展网络编程与多媒体开发能力的中级学习者。压缩包共17个文件&#xff0c;含2个核心cp…

作者头像 李华
网站建设 2026/10/16 9:56:43

Git仓库定时自动备份全攻略:从脚本编写到cron配置与防坑指南

上个月某开发者的笔记本硬盘突然报废&#xff0c;他第一反应是“没事&#xff0c;git仓库都在&#xff0c;推到一个托管平台上了”。等新机器装好系统、把仓库clone下来才发现&#xff0c;最近三周的提交全是显示“未推送”。那三周他一直在折腾一个跨平台的个人项目&#xff0…

作者头像 李华