资讯动态

STM32 Modbus RTU从站:DMA空闲中断与RS485方向控制

发布时间:2026/10/26 9:02:30 来源:尧图企业网站定制
简介面向嵌入式开发者的STM32F103ZET6 RS485通信实战工程围绕DMA数据传输与Modbus协议给出主机读取多个从机寄存器、向指定寄存器写入数据的完整实现思路适合已掌握C语言和STM32基础、希望进阶工业总线通信的读者参考。压缩包共171个文件约3.32MB以38个.h头文件和37个.c源文件为核心配套Keil工程文件、启动文件、链接脚本、hex输出及map映射等编译产物便于直接打开、编译与对照分析。已有8318人学习下载。工程涵盖RS485方向控制、串口与DMA初始化、0x03/0x06功能码报文解析构建、DMA自动收发、超时重试与通信异常恢复等模块可作为工业自动化、物联网设备主从通信项目的代码骨架和排错参考。1. 从一块偶发超时的从站板说起去年调一块采集板主站轮询 20 个从站示波器上每一帧都完整主站却隔三差五报超时——最后发现从站在最后一个字节的停止位还没发完就把 RS485 方向脚拉低了。这类毛病在 STM32 DMA Modbus 的组合里特别常见协议本身不复杂难的是把「什么时候算收完一帧」「什么时候能切方向」这两件事做扎实。这份工程就围绕这两点搭了一套 Modbus RTU 从站串口用 DMA 搬数据空闲中断切帧方向脚由 GPIO 控制保持寄存器与输入寄存器分开映射支持 03/04/06/10 四个功能码CRC16 查表校验。适合做传感器采集、仪表对接、PLC 联调又不想背整套协议栈的人代码量小每一层都能看穿。2. 硬件与工程骨架方向脚、DMA 通道和目录分工拿到一份 485 从站代码别急着看协议解析先看两处方向脚怎么控DMA 通道怎么分。这两处定了后面所有问题的形态基本就定了。我见过太多工程在协议层写得漂漂亮亮结果卡在「发完最后一字节忘了切回接收」这种一行代码上调两天。2.1 收发器方向控制一个 GPIO 决定整条总线的死活RS485 是半双工同一对差分线同一时刻只能有一个说话的人。常见的收发器把 DE驱动器使能和 RE接收器使能做成互补实际接线时把这两个脚并在一起接一个 GPIO拉高进发送态拉低回接收态。网上不少例程把 RE 直接接地、只控 DE这样本机在发送时接收器仍然开着会把自己发出去的数据原样收回来如果你恰好开了回环或者用中断收就会自己跟自己打架。先把这个最底层的开关封装掉后面所有代码都只调它/* rs485.h —— 方向脚封装DE/RE 并联接在 PA8 */ #define RS485_DE_PORT GPIOA #define RS485_DE_PIN GPIO_Pin_8 static inline void rs485_tx_enable(void) { GPIO_SetBits(RS485_DE_PORT, RS485_DE_PIN); /* 驱动器上电占住总线 */ } static inline void rs485_rx_enable(void) { GPIO_ResetBits(RS485_DE_PORT, RS485_DE_PIN); /* 释放总线回到高阻接收 */ } /* 上电第一件事把方向脚定死在接收态再干别的 */ void rs485_gpio_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_ResetBits(RS485_DE_PORT, RS485_DE_PIN); /* 先写电平再配模式 */ gpio.GPIO_Pin RS485_DE_PIN; gpio.GPIO_Mode GPIO_Mode_Out_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(RS485_DE_PORT, gpio); }这里有一个顺序上的讲究GPIO_ResetBits要写在GPIO_Init之前。GPIO 复位后默认是浮空输入如果你先配成推挽输出再写电平中间那几十个时钟周期里引脚电平是不确定的收发器可能瞬间把总线拉低。总线上挂十几个从站时这一个毛刺足够让别的从站收到一串 0x00 起始的垃圾帧。血泪经验是板子上再给 DE 加一个 10k 下拉电阻软件顺序错了也还有硬件兜底。另一个参数是方向切换的时机这个不在 GPIO 层解决放到 3.3 和 4.3 讲——简单说就是「发送前置高要早于第一个字节写进 DR发送后拉低要晚于 TC 置位」早了晚了都是坑。2.2 工程目录从站代码该分成哪几层从站代码最容易写成一坨串口初始化、CRC、功能码、业务逻辑全塞在main.c里改一个寄存器地址要翻 800 行。我一般按「驱动 / 协议 / 应用」三层拆协议层不认识任何具体业务应用层只跟寄存器数组打交道modbus_slave/ ├── app/ │ ├── main.c # 主循环初始化 轮询队列 刷输入寄存器 │ ├── app_regmap.c # 业务寄存器定义、写寄存器钩子 │ └── app_regmap.h ├── bsp/ │ ├── bsp_rs485.c # 方向脚、收发器时序 │ ├── bsp_rs485.h │ ├── bsp_uart_dma.c # USART DMA IDLE 初始化 │ └── bsp_uart_dma.h ├── modbus/ │ ├── modbus_slave.c # 帧解析、功能码分发、异常响应 │ ├── modbus_slave.h │ ├── modbus_crc.c # CRC16 查表 │ └── modbus_reg.c # 保持/输入寄存器区与越界检查 └── third_party/ └── ringbuf.c # 无锁环形队列中断塞主循环取modbus/这层是可以整包复制到下一个项目里的只要它对外只暴露三个接口初始化时传进从站地址和波特率、注册寄存器区首地址与长度、主循环里调一次轮询函数。app/那层负责真正跟硬件打交道比如读到温度传感器的值往input_reg[]里写或者收到写保持寄存器的请求后立刻改 PWM 占空比。这条分界线划清楚了后面加功能码、加寄存器都只是往表里添一行的事。2.3 资源映射DMA 通道和中断优先级怎么排STM32F1 系列的 DMA1 通道和外设是硬绑定的不能随便挑选错了现象是「DMA 初始化不报错但一个字节都收不到」特别玄学。常用的几组映射我列在下面画板子之前先对一遍外设请求数据方向DMA 控制器 / 通道典型用法USART1_RX外设 → 内存DMA1 通道 5485 接收循环模式USART1_TX内存 → 外设DMA1 通道 4485 发送普通模式可选USART2_RX外设 → 内存DMA1 通道 6第二路 485 或调试口USART3_RX外设 → 内存DMA1 通道 3第三路TIM2_UP事件DMA1 通道 2定时采样搬运可选中断优先级按「谁丢了数据谁优先」来排USART 的 IDLE 中断排最高抢占优先级 1因为它是帧边界信号被拖晚了 DMA 可能已经把下一帧的开头覆盖上来定时器采样中断排次一级2主循环里那些按键、LED 之类的东西压根不开中断扔轮询里。抢占优先级相同的情况下中断服务函数里别做浮点运算和memcpy大块数据那些活留给主循环。还有一处容易漏开了 DMA 接收之后USART 的 RXNE 中断就别再开了。两个都开DMA 在搬数据的同时 RXNE 也在置位中断频率高得离谱CPU 全耗在进出中断上更糟的是你在 RXNE 里读一次 DR等于从 DMA 手里抢走一个字节接收缓冲区从此对不上号。这在调试时表现为「短帧正常长帧丢中间几个字节」非常难查。3. 串口空闲中断 DMA 收包一帧 Modbus RTU 是怎么被接住的Modbus RTU 用帧间隔分帧没有帧头帧尾两帧之间至少静默 3.5 个字符时间。传统做法是开一个定时器每收一个字节重置计数超时就认为一帧结束——能用但字节一多中断就密集。用 DMA 收 IDLE 中断判帧是更省 CPU 的路子DMA 自己把字节搬到内存CPU 只在总线静下来那一刻被打断一次。3.1 串口与 DMA 初始化一次配对后面都靠它初始化的顺序是有讲究的我习惯按「时钟 → 引脚 → 方向脚 → 串口参数 → DMA → 中断」这个顺序走每一步都能单独验证。下面这段是 USART1 收、DMA1 通道 5 搬、循环模式的完整初始化/* bsp_uart_dma.c */ #define MODBUS_RX_BUF_SIZE 256 /* 要大于协议允许的最长帧03 功能码最长约 256 */ uint8_t modbus_rx_buf[MODBUS_RX_BUF_SIZE]; void modbus_uart_init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef uart; DMA_InitTypeDef dma; NVIC_InitTypeDef nvic; /* 1. 时钟USART1 在 APB2DMA1 在 AHB */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* 2. 引脚PA9 复用推挽输出PA10 浮空输入 */ gpio.GPIO_Pin GPIO_Pin_9; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_10; gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, gpio); /* 3. 方向脚先落地成接收态 */ rs485_gpio_init(); /* 4. 串口8 数据位、无校验、1 停止位收发都开 */ uart.USART_BaudRate baud; uart.USART_WordLength USART_WordLength_8b; uart.USART_StopBits USART_StopBits_1; uart.USART_Parity USART_Parity_No; uart.USART_HardwareFlowControl USART_HardwareFlowControl_None; uart.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, uart); /* 5. DMA1 通道 5 USART1_RX循环模式一次装满整个缓冲区 */ DMA_DeInit(DMA1_Channel5); dma.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; /* 外设端固定是数据寄存器 */ dma.DMA_MemoryBaseAddr (uint32_t)modbus_rx_buf; /* 内存端是接收缓冲区首地址 */ dma.DMA_DIR DMA_DIR_PeripheralSRC; /* 外设是源内存是目的 */ dma.DMA_BufferSize MODBUS_RX_BUF_SIZE; dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; /* 外设地址不自增 */ dma.DMA_MemoryInc DMA_MemoryInc_Enable; /* 内存地址自增 */ dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; dma.DMA_Mode DMA_Mode_Circular; /* 循环模式省掉重装 */ dma.DMA_Priority DMA_Priority_High; dma.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, dma); DMA_Cmd(DMA1_Channel5, ENABLE); /* 6. 打开串口的 DMA 接收请求再开空闲中断 */ USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); USART_Cmd(USART1, ENABLE); /* 7. NVIC */ nvic.NVIC_IRQChannel USART1_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 0; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); }逐段说下关键参数。DMA_PeripheralBaseAddr取的是USART1-DR的地址不是USART1-SR取错了 DMA 会去搬状态寄存器的值每搬一次就把错误标志读一遍看起来「有数据」但全是 0 或 0x20 这类鬼东西。DMA_DIR_PeripheralSRC的意思是数据从外设流向内存写成MemorySRC就是反的接收会一个字节都没有。DMA_Mode_Circular是这套方案的核心——环形模式下 DMA 搬满 256 字节后自动回卷不需要在中断里重新初始化代价是你得自己算「这一帧从哪开始、有多长」3.2 会讲。MODBUS_RX_BUF_SIZE取 256 是个折中03/04 功能码一次最多读 125 个寄存器响应帧 255 字节左右留 256 刚好够。如果你只读几个寄存器可以缩到 64省 RAM 也能提高缓存命中但别缩到比请求帧还小否则主站一次读 100 个寄存器请求帧本身就把缓冲区写穿了。3.2 空闲中断切帧怎么知道这一帧收完了IDLE 中断的触发条件是「接收线路上出现一个字符时间的静默」。STM32 的清 IDLE 标志有个固定动作先读 SR 再读 DR少读一次都清不掉中断会一直重入。这个动作顺序写反了是新手最常翻的车之一。/* 每帧解析完丢进环形队列主循环慢慢处理 */ typedef struct { uint8_t *data; uint16_t len; } modbus_frame_t; extern ringbuf_t modbus_rx_queue; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { /* 清 IDLE读 SR 再读 DR顺序不能反 */ volatile uint16_t tmp USART1-SR; tmp USART1-DR; (void)tmp; /* 循环模式下 CNDTR 是剩余计数总量减剩余就是本帧字节数 */ uint16_t remain DMA_GetCurrDataCounter(DMA1_Channel5); uint16_t len MODBUS_RX_BUF_SIZE - remain; if (len MODBUS_MIN_FRAME len MODBUS_RX_BUF_SIZE) { modbus_frame_t frame { modbus_rx_buf, len }; ringbuf_push(modbus_rx_queue, frame); /* 只搬运不解析 */ } /* 关键disable → 重设计数 → enable让内存指针回到缓冲区首地址 */ DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, MODBUS_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }DMA_GetCurrDataCounter返回的是「还能搬多少个字节」所以帧长 缓冲区大小 − 剩余计数。MODBUS_MIN_FRAME一般取 4地址 功能码 CRC 两字节低于这个长度说明是干扰毛刺直接扔掉别送进协议层。后半段那三行是最容易省略、也最容易出事的地方。循环模式下 DMA 搬完 256 字节才自动回卷可一帧往往只有 820 字节内存指针停在缓冲区中间。如果只重设CNDTR而不先DMA_Cmd(DISABLE)内存地址寄存器不会重新加载下一帧会从偏移处接着写——现象是「第一帧正常从第二帧开始数据整体错位CRC 全错」而且错得很有规律特别像协议解析写错了实际跟协议一点关系没有。至于为什么中断里只做入队不做解析解析一帧要算 CRC、查表、翻寄存器、组响应几十微秒起步主站轮询 20 个从站时帧间隔可能只有几毫秒中断里干重活很容易错过下一帧的 IDLE。用环形队列隔开中断的活儿控制在几微秒剩下的交给主循环这是这套架构能扛住高频轮询的原因。3.3 帧间隔 T3.5 与主循环消费IDLE 中断判的是「一个字符时间的静默」而 Modbus RTU 规定帧间隔至少 3.5 个字符时间。这两个值不相等所以 IDLE 只能算「疑似帧尾」真正的合法性还得在协议层补一刀给每帧打一个时间戳两帧之间间隔不足 3.5 字符时间的视为一帧被拆开或者总线抖动该合并合并、该丢弃丢弃。用 SysTick 做一个毫秒计数器就够不必上高精度定时器/* 波特率 9600 时一个字符11 bit约 1.15ms3.5 字符约 4ms */ #define MODBUS_T35_MS(baud) ((3500U * 11U) / ((baud) / 100U) / 100U 1U) void modbus_poll(void) { modbus_frame_t frame; static uint32_t last_tick 0; while (ringbuf_pop(modbus_rx_queue, frame)) { uint32_t now sys_tick_get(); if (last_tick ! 0 (now - last_tick) MODBUS_T35_MS(g_modbus_baud)) { last_tick now; continue; /* 间隔太短判为残帧丢弃 */ } last_tick now; modbus_process_frame(frame.data, frame.len); } }MODBUS_T35_MS这个宏算的是 3.5 个字符时间对应的毫秒数加 1 是为了向上取整——波特率 115200 时 3.5 字符只有 0.33ms整数毫秒算不出来这时候就别纠结 T3.5 了直接用 IDLE 中断的判定即可高速率下 IDLE 的粒度已经比 T3.5 粗。反过来9600 波特率下 T3.5 约 4ms如果你的主循环里有耗时超过 4ms 的任务比如 Flash 擦写、LCD 刷屏必须把它挪到通信之外的时间片否则modbus_poll被拖延队列积压看起来就像「从站反应慢」。主循环里的结构大致是这样先modbus_poll()再刷输入寄存器读传感器、算工程量最后处理写寄存器的副作用。刷新输入寄存器建议按固定周期做比如 100ms 一次别每轮循环都读 I2C 传感器那会把主循环周期拖到几毫秒以上反过来影响通信。4. Modbus RTU 协议栈寄存器映射、功能码分发与 CRC协议层要做的事其实就三件校验帧、按功能码分发、组响应。写得好不好区别在于「加一个新功能码要改几处」。我的标准是改一处——往分发表里加一行再写一个处理函数主流程一行不动。4.1 功能码分发表新增功能码不该动主流程先定义状态码和处理函数签名。处理函数只负责 PDU 部分的解析和填充地址、CRC、异常响应的封装全部由外层统一收口。这样每个处理函数都短得能一眼看完也不会有人忘记加 CRC。/* modbus_slave.h */ typedef enum { MODBUS_OK 0, MODBUS_ERR_CRC 1, /* 帧校验失败静默丢弃不回包 */ MODBUS_ERR_ADDR 2, /* 不是本机地址静默丢弃 */ MODBUS_ERR_FC 3, /* 不支持的功能码回异常码 01 */ MODBUS_ERR_PARAM 4, /* 地址或数量越界回异常码 02/03 */ } modbus_status_t; /* pdu 指向功能码之后的数据resp 用于填响应 PDU */ typedef modbus_status_t (*modbus_handler_t)(const uint8_t *pdu, uint16_t pdu_len, uint8_t *resp, uint16_t *resp_len); typedef struct { uint8_t fc; modbus_handler_t handler; } modbus_entry_t; static const modbus_entry_t modbus_table[] { { 0x03, handle_read_holding }, /* 读保持寄存器 */ { 0x04, handle_read_input }, /* 读输入寄存器 */ { 0x06, handle_write_single }, /* 写单个保持寄存器 */ { 0x10, handle_write_multiple }, /* 写多个保持寄存器 */ }; #define MODBUS_TABLE_SIZE (sizeof(modbus_table) / sizeof(modbus_table[0]))fc用裸的十六进制而不是枚举是为了跟协议文档对得上——调试时抓包看到0x10直接能在表里搜到不用在枚举名和数值之间来回换算。handler的四个参数里pdu和pdu_len是输入resp和resp_len是输出这种「输入输出分离」的签名比让处理函数直接操作全局缓冲区好测得多单元测试里传两个数组就能跑。分发逻辑放在modbus_process_frame里顺序是查地址 → 查 CRC → 查功能码 → 调处理函数 → 组响应。注意 CRC 校验要在功能码分发之前CRC 错的帧要静默丢弃而不是回异常响应——总线上有个干扰毛刺你回一个异常帧主站会认为从站有问题反而把问题放大。void modbus_process_frame(uint8_t *buf, uint16_t len) { if (len 4) return; /* 地址功能码CRC */ if (buf[0] ! g_slave_addr buf[0] ! 0xFF) return; /* 0xFF 是广播 */ uint16_t crc_calc modbus_crc16(buf, len - 2); uint16_t crc_recv (uint16_t)buf[len - 1] 8 | buf[len - 2]; if (crc_calc ! crc_recv) return; /* CRC 错静默丢弃 */ uint8_t fc buf[1]; uint8_t resp[MODBUS_MAX_PDU]; uint16_t resp_len 0; modbus_status_t st MODBUS_ERR_FC; for (uint8_t i 0; i MODBUS_TABLE_SIZE; i) { if (modbus_table[i].fc fc) { st modbus_table[i].handler(buf[2], len - 4, resp, resp_len); break; } } if (st MODBUS_OK) modbus_send_response(g_slave_addr, fc, resp, resp_len); else if (buf[0] ! 0xFF) /* 广播不回异常帧 */ modbus_send_exception(g_slave_addr, fc, (uint8_t)(st MODBUS_ERR_FC ? 0x01 : 0x02)); }这里有两个细节。一是 CRC 的比较方式接收到的 CRC 低字节在前、高字节在后跟寄存器数据的大端序正好相反(uint16_t)buf[len-1] 8 | buf[len-2]这个写法别改成buf[len-2] 8 | buf[len-1]改了之后的现象是「所有帧 CRC 都错」你会怀疑人生。二是广播地址 0xFF广播帧不回应答也不回异常但 06/10 这类写操作仍然要执行——不少实现图省事buf[0] ! g_slave_addr直接 return广播就失效了。4.2 寄存器映射保持寄存器和输入寄存器分开管Modbus 的四类寄存器里实际项目最常用的是保持寄存器03 读、06/10 写和输入寄存器04 读。前者是可读可写的配置参数、控制字后者是只读的测量值。分成两个数组比塞进一个数组再用地址段区分清晰得多也顺手把「输入寄存器被写」这种非法操作挡在类型层面。/* modbus_reg.c */ #define HOLDING_REG_NUM 64 #define INPUT_REG_NUM 32 static uint16_t holding_reg[HOLDING_REG_NUM]; /* 03 / 06 / 10 */ static uint16_t input_reg[INPUT_REG_NUM]; /* 04 只读 */ /* 弱函数应用层重写它寄存器一被写就立刻响应不用等主循环下一轮 */ __weak void on_holding_reg_written(uint16_t addr, uint16_t value) { (void)addr; (void)value; } uint16_t *modbus_input_reg_base(void) { return input_reg; } uint16_t *modbus_holding_reg_base(void) { return holding_reg; } /* 主循环里周期调用把传感器值刷进输入寄存器 */ void app_refresh_input_reg(void) { input_reg[0] (uint16_t)(sensor_read_temp() * 10); /* 温度 ×10单位 0.1℃ */ input_reg[1] (uint16_t)sensor_read_humidity(); input_reg[2] g_run_state; /* 运行状态字 */ input_reg[3] (uint16_t)(sys_tick_get() 0xFFFF); /* 心跳主站可判从站是否活着 */ }__weak那个钩子函数是这套结构里最值钱的一行。写保持寄存器的典型场景是主站下发控制命令比如「启动」「改目标温度」「切手动模式」如果等主循环下一轮扫描到寄存器变化再动作中间可能隔着几十毫秒用钩子函数handle_write_single写完立刻调一次响应就是即时的。默认实现是空函数不重写也不会报链接错误。至于越界检查放在每个处理函数里做不要指望在分发表外层统一拦——因为 03 和 06 的边界条件不一样03 一次最多 125 个06 只能写一个统一拦反而写不干净。static modbus_status_t handle_read_holding(const uint8_t *pdu, uint16_t pdu_len, uint8_t *resp, uint16_t *resp_len) { if (pdu_len 4) return MODBUS_ERR_PARAM; uint16_t start ((uint16_t)pdu[0] 8) | pdu[1]; /* 起始地址大端 */ uint16_t count ((uint16_t)pdu[2] 8) | pdu[3]; /* 寄存器数量大端 */ if (count 0 || count 125) return MODBUS_ERR_PARAM; if ((uint32_t)start count HOLDING_REG_NUM) return MODBUS_ERR_PARAM; resp[0] (uint8_t)(count * 2); /* 后续字节数 */ for (uint16_t i 0; i count; i) { resp[1 i * 2] (uint8_t)(holding_reg[start i] 8); /* 高字节先发 */ resp[2 i * 2] (uint8_t)(holding_reg[start i] 0xFF); } *resp_len 1 count * 2; return MODBUS_OK; }(uint32_t)start count这里强制转 32 位不是多此一举start最大 65535count最大 125两个 uint16 相加会溢出回绕0xFFFF 2变成 1越界检查直接失效然后循环里holding_reg[0xFFFF]读飞。这类漏洞在实验室里永远测不出来到了现场主站发一帧异常请求就 HardFault 重启。4.3 CRC16 与响应组帧字节序错一次调一天CRC16 用查表法但 256 项的常量表贴进代码里难看又容易贴错。我一般用运行时生成256×8 次循环在 72MHz 上不到 1 毫秒main开头调一次就行省了手抄表的风险。/* modbus_crc.c */ static uint16_t s_crc_table[256]; void modbus_crc16_init(void) { for (uint16_t i 0; i 256; i) { uint16_t crc i; for (uint8_t bit 0; bit 8; bit) { /* Modbus 用的是反向多项式 0xA001不是 CRC-16/CCITT 的 0x1021 */ crc (crc 0x0001) ? ((crc 1) ^ 0xA001) : (crc 1); } s_crc_table[i] crc; } } uint16_t modbus_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; /* 初值固定 0xFFFF不是 0x0000 */ while (len--) crc (crc 8) ^ s_crc_table[(crc ^ *buf) 0xFF]; return crc; /* 不取反直接返回 */ }三个参数错了任何一个CRC 就全错多项式必须是0xA0010x8005的反转形式初值必须是0xFFFF最后不要取反。这三条跟常见的 CRC-16/CCITT、CRC-16/XMODEM 都不一样网上抄来的 CRC 代码十有八九是 CCITT 版本表现是「自己发的自己收能对上跟主站就是通不了」。组帧函数把地址、PDU、CRC 拼起来一次性发出去注意发送完等的是 TC 标志void modbus_send_response(uint8_t addr, uint8_t fc, const uint8_t *pdu, uint16_t pdu_len) { uint8_t frame[MODBUS_MAX_FRAME]; uint16_t n 0; frame[n] addr; frame[n] fc; if (pdu pdu_len) { memcpy(frame[n], pdu, pdu_len); n pdu_len; } uint16_t crc modbus_crc16(frame, n); frame[n] (uint8_t)(crc 0xFF); /* CRC 低字节在前和寄存器序相反 */ frame[n] (uint8_t)(crc 8); rs485_tx_enable(); for (uint16_t i 0; i n; i) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, frame[i]); } /* 必须等 TCTXE 只说明数据进了移位寄存器最后一个字节还没移出去 */ while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); rs485_rx_enable(); }USART_FLAG_TXE和USART_FLAG_TC的区别是这套代码里最容易翻车的一处。TXE 置位表示「发送数据寄存器空了可以写下一个字节」此时最后一个字节可能还躺在移位寄存器里往外移这时候拉低方向脚收发器立刻进高阻最后一个字节的停止位直接被截断。主站那边的表现是「数据都对就是 CRC 校验不过」因为 CRC 低字节恰好是倒数第二个字节往往是好的最后那个字节的高位被吃了。如果响应帧比较长可以把发送也交给 DMA通道 4CPU 只等传输完成中断在中断里拉低方向脚。这一点在 6 章会提属于二次开发的可选项。5. 排查从站不回包、CRC 报错、DMA 卡死的五个现场这套代码在现场能遇到的坑八成集中在五个地方。我把现象、原因、解决按我实际处理的顺序写下来照着对一遍基本能定位。5.1 收发时序上的三个现场现象一主站报超时示波器上看从站确实发了但最后一个字节的电平宽度比正常短一截。原因是方向脚拉低用的是 TXE 标志最后一个字节还没移完就切了高阻。解决就是 4.3 里写的改成等 TC再稳一点的做法是等 TC 之后加一两个微秒的空操作延时让收发器彻底释放。这个坑我在两个项目里各踩过一次第二次是因为换了个响应更快的收发器原本能跑的时序又不满足了。现象二从站的应答和主站的下一帧在总线上撞车两边都报错。原因是主站还在发、从站就开始回。Modbus 规定从站必须收完一整帧、校验通过才能应答而 IDLE 中断到modbus_process_frame之间还有队列和时间戳判断理论上不会抢跑。真正出问题的是 T3.5 判断写得太松帧间隔阈值按 9600 波特率算死了 4ms现场改成 19200 之后阈值还是 4ms主站两帧之间的空隙被误判成一帧从站把两帧拼起来解析解析失败后回了异常帧正好撞上主站的新请求。解决是把阈值跟着波特率算或者直接按上一章的宏动态算。现象三一上电整条总线被拉死所有从站都通信不上断电重启又好了。原因是方向脚在 GPIO 初始化为输出之前处于浮空态收发器的 DE 被外部干扰拉高把总线占住了或者是软件初始化顺序里先开了 USART 再配方向脚串口一使能就往外发了个 0x00。解决是rs485_gpio_init里先写电平再配模式硬件上再加 10k 下拉。这个问题的隐蔽之处在于它不是每次都出现取决于上电时电容充电的快慢实验室十次里可能只出一次。5.2 DMA 与缓冲区上的两个现场现象四第一帧正常从第二帧开始数据整体错位CRC 全错。原因是循环 DMA 里只重设了CNDTR没有先DMA_Cmd(DISABLE)内存地址寄存器没回卷第二帧从上一帧结束的位置接着写。解决就是 3.2 里那三行disable → set counter → enable。判断这个问题有个小技巧把接收缓冲区整个打印出来如果错位量正好等于上一帧的长度基本可以确定是这个问题跟协议解析一点关系没有。现象五长帧被拆成两段或者干脆丢一半。两种情况。一是MODBUS_RX_BUF_SIZE小于主站请求的最长帧DMA 写满 256 字节自动回卷把开头的字节覆盖了表现出来就是「读 100 个寄存器时好时坏」二是中断里做了耗时操作比如在 IDLE 里直接算 CRC下一帧的 IDLE 来了还没处理完队列满了丢帧。前者的解决是把缓冲区按最大可能帧长255 左右留足后者的解决是把解析挪到主循环中断里只入队——这也是第 3 章那套结构的意义。6. 二次开发把它改成你自己的从站设备这套代码真正能省时间的地方是改造成本很低。举个具体场景你手上有个设备要暴露 8 个测量值、4 个控制参数给 PLCPLC 那边固定用 03/06 功能码轮询。改动量是这样的改造点改动位置大致工作量改从站地址与波特率main.c里的两个宏2 行定义测量值到输入寄存器app_regmap.c的app_refresh_input_reg一个函数控制参数落到硬件重写on_holding_reg_written一个 switch加 0x01 线圈功能码modbus_table[]加一行 一个 handler约 40 行发送改 DMAmodbus_send_response换实现约 30 行最值得动手的是on_holding_reg_written那个钩子。假设保持寄存器 0 是「目标温度 ×10」重写之后主站一写就立刻更新 PWM 比较值不用等主循环扫描void on_holding_reg_written(uint16_t addr, uint16_t value) { switch (addr) { case 0: /* 目标温度单位 0.1℃限幅到 0~1200 也就是 0~120℃ */ if (value 1200) value 1200; pwm_set_duty(TEMP_PWM_CH, temp_to_duty(value)); break; case 1: /* 运行模式0 停止1 自动2 手动 */ g_run_mode (value 2) ? value : 0; break; default: break; } }注意这里的限幅是在钩子里做的不是在handle_write_single里。原因是从站不只有一种写入口——主站可能用 06 写单个也可能用 10 连续写一批限幅逻辑写在协议层就得写两遍写在钩子里只写一遍两条路径自动都有。验证环节我一般分三步走。第一步用电脑上的 Modbus 调试工具单发一帧 03 请求看响应字节数和 CRC 对不对这一步能过滤掉 80% 的组帧问题。第二步用脚本连续轮询 10000 次统计超时率和 CRC 错误率跑之前把串口助手关掉——两个程序抢一个串口报的错全是假的。第三步接 PLC 或主站真机跑满 24 小时观察有没有累积性的错误比如计数器慢慢偏移那多半是缓冲区错位没解决干净。发送改 DMA 是我在高速率场景下必做的一步。modbus_send_response里那个逐字节while(TXE)的循环在 115200 波特率下发 200 字节要 17ms这段时间 CPU 全被占着如果同时还有别的高优先级任务就会卡。改成 DMA 发送后CPU 只在传输完成中断里拉低方向脚代码结构基本不变把循环换成DMA_InitDMA_Cmd把rs485_rx_enable()挪到DMA1_Channel4_IRQHandler里即可。最后说个我自己的习惯。这套从站代码我每接一个新项目都会先只跑「回环测试」——方向脚强制拉高让从站自己发自己收把 CRC 和组帧逻辑验证一遍确认没问题再接真总线。有一次跳过了这步直接上现场查了整整一个下午才发现是主站发的是大端、我按小端解析跟 485 硬件一点关系没有。从那以后我每次移植这套代码都强制走一遍自发自收的回环再上真实总线。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑