☰
FreeModbus主从同栈移植与RS-485丢帧排障
2026/10/28 4:27:24 网站建设 项目流程

简介:面向嵌入式开发初学者与已有STM32基础的工程师,这份PDF资料围绕FreeModbus协议栈的主从机开发展开,重点解决开源FreeModbus仅有从机、主机源码收费且优质参考资料零散的问题。文档结合STM32H743与FreeRTOS平台,梳理主站、从站移植思路,并给出注释较详尽的DEMO说明,便于读者理解协议栈接口、任务调度与请求模式。资源共1个文件,为PDF格式,大小约120KB,适合作为案头速查与入门索引。内容涉及FreeModbus V1.6、主机使用说明、主从流程图、协议文档整理及工程结构说明,可帮助读者快速建立从协议概念到工程落地的整体认知,也便于在真实项目中对照排查通信问题。目前已有505人学习,适合希望系统掌握Modbus主从通信的嵌入式从业者。

1. 一条 485 总线上三台表,为什么只有一台丢帧

现场最典型的一幕:一条 RS-485 总线上挂三台仪表,主站每 200 ms 轮询一轮,两台稳得很,第三台隔几分钟丢一次。抓波形看,帧头帧尾都在,CRC 却校验不过。这活儿最后十有八九落到两处——T3.5 帧间隔定时不准,或者 485 收发方向切早了。这份资源就是冲这类现场问题准备的:以 FreeModbus V1.6 源码打底,把原本只开源从机的那套协议栈补出了主站,并移植到 STM32H743 + FreeRTOS 上跑通,主站和从站能在同一套工程里共存,附带的还有主从流程图、协议文档和调试工具。手上有一块 H7 或 F4 板子要接仪表、做过从站想摸主站轮询、想看清状态机到底怎么转的,都能从里面挖到东西。下面按“架构分层—从机—主站—排障—改造”一路拆下去。

2. 架构与移植分层:主站代码为什么长得跟从站一个样

2.1 补主站的动机与技术选型

FreeModbus 本身是个很轻的协议栈,从机部分开源,代码量小、可裁剪、移植层抽象得干净,一个port/目录就能把 MCU 相关的东西全隔离掉。麻烦的是主站:官方主站要授权,网上能找到的开源主站实现风格五花八门,有的把 PDU 和串口收发糊在一个文件里,有的干脆用阻塞读串口,接进 RTOS 就卡死。这套移植的出发点很直接——主站照着从机的文件划分和函数命名重写一遍,让eMBInit和eMBMasterInit、eMBPoll和eMBMasterPoll、eMBRegHoldingCB和eMBMasterRegHoldingCB成对出现,应用层迁移成本压到最低。

技术选型上没什么花活:RTU 模式为主,ASCII 和 TCP 的框架保留但不作为重点;实时性交给 FreeRTOS,事件通知用队列,主站等待响应用信号量;STM32H743 的串口和定时器资源充足,一个串口给 Modbus,一个定时器给 T3.5,互不打架。选 H7 不是因为协议栈吃性能,而是现场板子就是它,顺手把 F4 上能跑的移植习惯带过来了。这里有个判断:协议栈本身的 CPU 占用极低,1 ms 轮询一次都嫌浪费,真正吃精度的是帧间隔定时器和中断响应延迟,芯片性能反而不是瓶颈。

协议栈支持的方法覆盖了常用集合:0x01 读线圈、0x02 读离散输入、0x03 读保持寄存器、0x04 读输入寄存器、0x05 写单线圈、0x06 写单寄存器、0x0F 写多线圈、0x10 写多寄存器,异常响应 0x80 加原功能码那一套也实现了。主站侧把这些功能码封装成一组eMBMasterReq*函数,应用层不需要自己拼 PDU。这点对二次开发很关键——现场最常见的需求是“多轮询几个寄存器”,如果每次都要手搓字节序,出错概率高得离谱。

2.2 分层结构与工程目录

协议栈的分层很清楚:应用层只认回调函数,协议层管 PDU 编解码和状态机,端口层管串口、定时器和事件。移植的时候真正需要动的只有端口层那三四个文件,其余原样编译。目录大致长这样:

freemodbus/ ├── modbus/ │ ├── mb.c # 从站协议核心:状态机调度入口 │ ├── rtu/ │ │ ├── mbrtu.c # 从站 RTU 收发与 CRC │ │ └── mbrtumaster.c # 新增:主站 RTU 收发(与从站风格对齐) │ ├── ascii/ # ASCII 模式,保留未启用 │ ├── tcp/ # TCP 模式,保留未启用 │ ├── functions/ │ │ ├── mbfunccoils.c # 0x01 / 0x05 / 0x0F │ │ ├── mbfuncdisc.c # 0x02 │ │ ├── mbfuncholding.c # 0x03 / 0x06 / 0x10 │ │ ├── mbfuncinput.c # 0x04 │ │ └── mbfuncother.c # 异常响应与不支持功能码 │ └── include/ │ ├── mb.h # 对外 API 与错误码 │ ├── mbport.h # 端口层需要实现的函数声明 │ └── mbconfig.h # 裁剪开关:功能码、缓冲区长度、从站数量 ├── port/ │ ├── portserial.c # 串口初始化、收发使能、字节读写 │ ├── porttimer.c # T3.5 定时器 │ └── portevent.c # 事件队列(RTOS 或裸机标志位) └── demo/ └── stm32h743_freertos/ # 移植示例工程

mbconfig.h是第一个要看的文件。里面几个开关直接决定内存占用:MB_FUNC_HANDLERS_MAX控制能挂多少个功能码处理函数,MB_PDU_SIZE_MAX定 PDU 缓冲,MB_PDU_FUNC_READ_REGCNT_MAX限制一次能读多少个寄存器。默认值偏保守,现场如果一次要读 100 个保持寄存器,得把这个上限往上调,否则从站回异常码 0x03(非法数据值),抓包看半天会以为是地址错了。

端口层要实现的东西一共四组,mbport.h里声明得明明白白:

/* 串口:初始化、收发使能、字节级读写、字节到达检测 */ BOOL xMBPortSerialInit(UCHAR ucPort, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity); void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable); BOOL xMBPortSerialGetByte(CHAR *pucByte); BOOL xMBPortSerialPutByte(CHAR ucByte); /* 定时器:以 50us 为一个 tick 单位,从站用它判 T3.5 */ BOOL xMBPortTimersInit(USHORT usTimeOut50us); void vMBPortTimersEnable(void); void vMBPortTimersDisable(void); /* 事件:协议层靠它知道“帧到了”“发送完了”“超时了” */ BOOL xMBPortEventInit(void); BOOL xMBPortEventPost(eMBEventType eEvent); BOOL xMBPortEventGet(eMBEventType *peEvent);

这四组函数就是协议栈和硬件之间唯一的契约。理解了这一点,移植到别的芯片只是换个外设库的事——串口换成任意 USART,定时器换成任意 TIM,事件换成队列或者裸机标志位。

2.3 主从同栈的任务模型

主站和从站要在同一块板子上跑,第一个要回答的问题是任务怎么划分。常见做法有两种:一种是一个 Modbus 任务里循环调eMBPoll()和eMBMasterPoll(),另一种是拆成两条任务,从站一条阻塞等事件,主站一条按调度周期发起请求。这套移植走的是后者,理由是主站轮询的节奏由业务决定(有的表 200 ms 一轮,有的 2 s 一轮),跟从站的被动响应节奏完全不搭,塞一个循环里会互相拖。

但拆任务就带来共享串口的问题。主站发请求的瞬间,如果从站正好也在收,总线上就撞了。所以需要一个串口互斥锁,主站在发起请求前拿锁,收到响应或超时后释放。骨架大致是这样:

static SemaphoreHandle_t xMutexUart; /* 串口互斥 */ static QueueHandle_t xMbevtQueue; /* 从站事件队列 */ /* 从站任务:被动响应,阻塞等事件 */ static void vSlaveTask(void *pvParameters) { eMBInit(MB_RTU, 0x01, 0, 115200, MB_PAR_NONE); /* 从站地址 0x01 */ eMBEnable(); for (;;) { if (xSemaphoreTake(xMutexUart, portMAX_DELAY) == pdTRUE) { (void)eMBPoll(); /* 内部非阻塞,取事件处理 */ xSemaphoreGive(xMutexUart); } vTaskDelay(pdMS_TO_TICKS(1)); /* 1ms 让出,避免饿死低优先级任务 */ } } /* 主站任务:主动轮询,节奏由业务表决定 */ static void vMasterTask(void *pvParameters) { eMBMasterInit(MB_RTU, 0, 115200, MB_PAR_NONE); /* 主站无地址 */ eMBMasterEnable(); for (;;) { prvPollOneSlave(); /* 见第 4 章的调度器 */ vTaskDelay(pdMS_TO_TICKS(200)); /* 一轮间隔 */ } }

eMBPoll()和eMBMasterPoll()都不是阻塞函数,它们只是查一下事件队列有没有待处理项,有就推进状态机,没有就立刻返回。真正让任务睡下去的是外层的vTaskDelay。这一点跟很多人对“协议栈轮询”的直觉相反——以为它内部会死等帧,其实不会,等待的活儿全交给了定时器和串口中断。也正因为如此,从站任务里那把互斥锁的持有时间很短,不会把主站卡太久。

严格来说,用互斥锁保护两个任务对同一串口的访问只是“不撞”的最低要求,真要主从同栈还得看业务形态:如果本机既是某段总线的主站,又要作为从站被上一级 PLC 轮询,那最好分两个串口,各走各的物理链路,省掉一堆仲裁逻辑。这套工程里两种接法都留了位置,具体用哪种,看现场机柜里还剩几根线。

3. 从机侧拆解:状态机、寄存器回调与 T3.5 定时器

3.1 初始化序列与轮询入口

从机的启动流程是固定的三步:eMBInit配模式、地址、波特率和校验,eMBEnable打开收发使能并启动定时器,然后任务循环里反复调eMBPoll。这三步顺序不能乱,eMBInit之前必须先初始化好串口和定时器的底层外设,否则xMBPortSerialInit里访问未初始化的外设会直接飞。

eMBErrorCode eStatus; /* 步骤 1:初始化,RTU 模式,从站地址 0x01,115200 8N1 */ eStatus = eMBInit(MB_RTU, 0x01, 0, 115200, MB_PAR_NONE); if (eStatus != MB_ENOERR) { /* MB_EINVAL: 参数错 MB_ENORES: 资源不足 */ prvErrHandler(eStatus); } /* 步骤 2:使能,内部会调用 vMBPortSerialEnable(TRUE, FALSE) 进入接收态 */ eStatus = eMBEnable(); if (eStatus != MB_ENOERR) { prvErrHandler(eStatus); } /* 步骤 3:轮询,放在任务循环里,1ms 一次足够 */ for (;;) { (void)eMBPoll(); vTaskDelay(pdMS_TO_TICKS(1)); }

eMBInit的五个参数里,eMode选MB_RTU或MB_ASCII,ucSlaveAddress就是本机从站地址,ucPort在 STM32 移植里一般忽略或者映射到串口号,ulBaudRate和eParity决定串口配置。校验位这个参数特别容易翻车:MB_PAR_NONE是 8N1,MB_PAR_EVEN是 8E1,选错了从站根本不会应答,但串口上能看到字节,抓包工具里显示全是乱码或者校验错。

eMBPoll内部维护一个接收状态机,状态大致是四个:STATE_RX_INIT等第一个 T3.5 超时来确认总线空闲,STATE_RX_IDLE等待帧起始,STATE_RX_RCV正在收字节,STATE_RX_ERROR出错了等总线静默。状态切换全靠字节到达中断和定时器超时中断驱动,eMBPoll只是把事件取出来往后推。所以调eMBPoll的频率不用太高,1 ms 一次绰绰有余,但也不能几百毫秒才调一次,否则事件积压,响应会明显变慢。

3.2 寄存器回调怎么写

协议栈把“寄存器在哪”这个问题完全交给应用层,靠四个回调函数:eMBRegCoilsCB、eMBRegDiscreteCB、eMBRegHoldingCB、eMBRegInputCB。读、写、读写混合三种操作都用同一个回调,通过eMBRegisterMode区分。保持寄存器是最常用的,写法如下:

#define REG_HOLDING_NREGS 128 static USHORT usRegHoldingBuf[REG_HOLDING_NREGS]; /* 应用数据,协议栈不碰 */ eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { USHORT i; /* 越界检查:地址从 0 开始算,usAddress + usNRegs 不能超上限 */ if ((usAddress + usNRegs) > REG_HOLDING_NREGS) { return MB_ENOREG; /* 协议栈会回异常码 0x02 */ } if (eMode == MB_REG_READ) { for (i = 0; i < usNRegs; i++) { /* Modbus 线上是大端:高字节在前 */ pucRegBuffer[i * 2] = (UCHAR)(usRegHoldingBuf[usAddress + i] >> 8); pucRegBuffer[i * 2 + 1] = (UCHAR)(usRegHoldingBuf[usAddress + i] & 0xFF); } } else { /* MB_REG_WRITE */ for (i = 0; i < usNRegs; i++) { usRegHoldingBuf[usAddress + i] = (USHORT)((pucRegBuffer[i * 2] << 8) | pucRegBuffer[i * 2 + 1]); } } return MB_ENOERR; }

三个参数的含义:pucRegBuffer是协议栈给的缓冲区,读的时候往里填,写的时候从里取;usAddress是报文里的起始寄存器地址,注意这里是 0 基;usNRegs是寄存器个数;eMode区分读写。

有两个细节值得专门说。一是字节序,Modbus 线上传输统一大端,而 STM32 是小端,所以必须手动拆字节,直接把USHORT*强转成UCHAR*拷过去,读出来的数值在高低位上会整个反掉,现象是“读 100 回来 25600”,第一次遇到会怀疑人生。二是返回值,MB_ENOREG触发异常码 02(非法数据地址),MB_ENOERR是正常,别的地方还有MB_EINVAL。有不少人越界了直接返回 0,结果从站回一个正常响应带一堆垃圾数据,主站那边看着“通了”,实际数值全错,这种问题排查起来最费时间。

usRegHoldingBuf是应用和协议栈之间的唯一交接面。工程里常见的做法是把它当影子寄存器用:定时器中断里把采集到的真实数据刷进来,主站读的时候直接读影子,避免在回调里做耗时操作。回调是在协议栈任务上下文里跑的,里面要是加了 10 ms 的延时或者等信号量,整个从站响应都会被拖慢,主站那边就该报超时了。

3.3 T3.5 定时器与帧边界判定

RTU 模式靠时间间隔判断帧边界:3.5 个字符时间内没有新字节,就认为一帧结束。这个时间跟波特率挂钩,按 11 位一个字符(1 起始 + 8 数据 + 1 校验 + 1 停止)算,9600 bps 下约 4.01 ms,115200 bps 下约 0.335 ms。协议栈的定时器接口用的是 50 μs 为一个 tick,初始化时传进去的是 tick 数:

/* usTimeOut50us = T3.5 换算成 50us 的个数 */ BOOL xMBPortTimersInit(USHORT usTimeOut50us) { /* 定时器计数频率 20kHz -> 1 tick = 50us */ /* 例如 72MHz / 3600 = 20kHz,预分频写 3599 */ htim3.Init.Prescaler = 3599; htim3.Init.Period = usTimeOut50us - 1; /* 只开更新中断,不开自动重装载,每次 Enable 重新计数 */ return (HAL_TIM_Base_Init(&htim3) == HAL_OK); }

xMBPortTimersInit只做一件事:把定时器配成 50 μs 分辨率。真正决定超时时长的是调用方传进来的usTimeOut50us,协议栈内部会根据波特率算好。vMBPortTimersEnable和vMBPortTimersDisable负责在每收到一个字节时重置计数——收到字节就清零重新数,数满 3.5 个字符时间还没新字节,就触发更新中断,中断里xMBPortEventPost(EV_FRAME_RECEIVED),协议栈据此判定一帧收完。

这里是最容易踩坑的地方。用 FreeRTOS 的 1 ms 系统 tick 去做 T3.5,115200 下根本分不出来(0.335 ms 比 1 ms 还小),9600 下勉强能用但会抖,现象就是“大部分帧正常,偶尔丢一帧”。正确姿势是独立硬件定时器,50 μs 分辨率,中断里只发事件、不做任何耗时操作。另外定时器中断优先级要设得比串口略低或者同级,别让它把串口字节中断给堵了。

4. 主站侧拆解:阻塞/非阻塞请求与轮询调度

4.1 主站请求 API 与返回码

主站的对外接口是按功能码封装的,风格和从站回调对齐。每个请求函数的参数基本是四件套:从站地址、起始寄存器地址、数量、超时时间。这套移植额外加了第五个参数——阻塞标志,让应用层自己选调用模型。

/* 读保持寄存器:从站 0x02,起始地址 0x0000,读 10 个,超时 100ms,非阻塞 */ eMBErrorCode eStatus; eStatus = eMBMasterReqReadHoldingRegister(0x02, 0x0000, 10, 100, FALSE); switch (eStatus) { case MB_ENOERR: /* 非阻塞模式下表示“请求已入队” */ break; case MB_MASTER_BUSY: /* 上一笔请求还没结束,本次被拒 */ break; case MB_EMASTEREOUT: /* 阻塞模式下:等响应超时 */ break; default: break; }

阻塞和非阻塞的区别值得单独说清楚,因为它直接决定应用层怎么写:

模式调用后行为返回值含义适用场景
阻塞bBlock = TRUE一直等到响应到达或超时直接返回本次请求结果单从站、低频、逻辑简单的场景
非阻塞bBlock = FALSE立即返回,不等只表示请求是否被接受多从站轮询、有实时性要求的任务

阻塞模式写起来最省心,一行调用拿到结果,但它会把调用任务挂住。多从站轮询场景里,一台设备掉线就是 100 ms 卡顿,轮询表长一点整个周期就崩了。非阻塞模式把“发请求”和“拿结果”拆开,调用返回MB_ENOERR只代表请求发出去了,结果要通过回调或者查询状态拿。工程里默认走非阻塞,阻塞模式留给初始化阶段或者调试用。

超时参数的单位是毫秒,实际生效的是“发送完成后等第一个字节”和“字节间超时”两个阶段。设太小,慢速设备来不及回;设太大,掉线设备拖垮整轮。经验值是按从站响应时间的两到三倍给,串口波特率 9600 时一般 100~200 ms,115200 时 50 ms 就够。

4.2 非阻塞轮询调度器

多从站场景的调度器本身不复杂,核心是把“轮询表”和“状态机”分开。表描述要问谁、问什么,状态机管什么时候发下一笔。

typedef struct { UCHAR ucSlaveAddr; /* 从站地址 */ USHORT usStartAddr; /* 起始寄存器(0 基) */ USHORT usRegCount; /* 寄存器个数 */ USHORT usPeriodMs; /* 轮询周期 */ ULONG ulLastTick; /* 上次发起时刻 */ } PollItem_t; static PollItem_t s_pollTable[] = { { 0x02, 0x0000, 10, 200, 0 }, /* 电表:电压电流 */ { 0x03, 0x0010, 4, 500, 0 }, /* 温控器:温度设定 */ { 0x05, 0x0000, 2, 1000, 0 }, /* 变频器:状态字 */ }; static void prvPollOneSlave(void) { static UCHAR s_ucIndex = 0; ULONG ulNow = xTaskGetTickCount(); PollItem_t *pItem = &s_pollTable[s_ucIndex]; /* 到点、且主站空闲、且上一笔已经收完,三者同时满足才发 */ if ((ulNow - pItem->ulLastTick) >= pdMS_TO_TICKS(pItem->usPeriodMs) && !eMBMasterIsBusy()) { if (eMBMasterReqReadHoldingRegister(pItem->ucSlaveAddr, pItem->usStartAddr, pItem->usRegCount, 100, FALSE) == MB_ENOERR) { pItem->ulLastTick = ulNow; } else { /* 请求被拒:记一次失败,下一轮再试,不要原地重试 */ } s_ucIndex = (s_ucIndex + 1) % (sizeof(s_pollTable) / sizeof(s_pollTable[0])); } }

这段的逻辑是按表项轮转,每次只发一笔。三个判断条件缺一不可:ulNow - ulLastTick >= period保证节奏,!eMBMasterIsBusy()保证上一笔请求已经结束(收到响应或者超时),第三点隐含在eMBMasterReqReadHoldingRegister返回成功里。少了IsBusy判断,连续两笔请求会把状态机冲掉,现象是“偶尔回一个不属于本次请求的数据”,相当难查。

ulLastTick用的是xTaskGetTickCount(),不是绝对时间。注意无符号减法在 tick 回绕时仍然成立,这是 FreeRTOS 的惯用法,别改成直接比较大小。

调度器还有个容易忽略的点:发送失败时的处理。上面代码里请求被拒时只记录、不重试,下一轮照常。原地重试几乎一定会把事情搞得更糟——总线本来就忙,连续重发只会挤占其他从站的时间片,还会让日志里堆满相同的错误,掩盖真正的问题源。真要重试,放在业务层做,并且带退避。

4.3 主站回调、超时与异常码回传

主站收到响应后,数据往哪放?跟从站对称,通过回调交给应用层:

/* 主站读保持寄存器的响应回调,usAddress 是响应里的起始地址 */ eMBErrorCode eMBMasterRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { USHORT i; for (i = 0; i < usNRegs; i++) { /* 同样是线上大端,转成本机 USHORT */ g_usRs485Cache[i] = (USHORT)((pucRegBuffer[i * 2] << 8) | pucRegBuffer[i * 2 + 1]); } g_usRs485Count = usNRegs; g_ucRs485SrcAddr = g_ucMasterCurrentSlave; /* 记住是哪个从站回的 */ g_xRs485DataReady = pdTRUE; /* 通知业务任务 */ return MB_ENOERR; }

回调里只做字节搬运和标志置位,别做业务处理。g_usRs485Cache这类全局缓存是主站模式的常见做法——同一时刻只有一笔请求在飞,用一块全局缓存加一个“当前从站地址”就够了,不需要为每个从站分配独立缓冲,省 RAM 也省心。

超时和异常响应走另一条路径。从站回异常响应(功能码最高位置 1)时,协议栈解析出异常码,通过主站错误回调上报,常见的是 01 非法功能、02 非法数据地址、03 非法数据值、04 从站设备故障。应用层要能区分“超时”和“从站回了异常”——超时通常意味着物理层或者地址配错了,异常响应说明从站是活的,只是请求内容有问题。这两种情况在现场的处理方式完全不同,日志里必须分开记。

重试策略上,这套移植没有在协议栈里内置自动重试,原因上面提过:协议栈层重试会把时序搅乱。工程里的做法是在业务层用状态机做两连击——第一次超时记一次,下一轮再失败就判定设备离线,把它的表项周期临时拉长,等设备恢复了再降回来。这套降级逻辑写起来不到三十行,但能让一条总线在一台设备挂掉时继续正常工作。

5. 常见问题排查:五个把帧吃掉的现场坑

5.1 现象:偶发丢帧,CRC 校验不过,重发就好

原因:T3.5 定时器精度不够。用 FreeRTOS 的 1 ms tick 或者 SysTick 去数帧间隔,115200 波特率下 3.5 个字符只有 0.335 ms,根本分辨不出来,帧尾和下一帧帧头会被粘成一帧;9600 下虽然勉强够用,但中断延迟一抖就判错。

解决:换独立硬件定时器,配成 50 μs 分辨率,收到字节就重置计数。另外注意xMBPortTimersInit里传进来的usTimeOut50us不能小于 1,波特率极高时要确认这个值算下来不是 0,否则定时器永远不会触发。改完拿示波器量一下最后一个字节停止位到定时器中断的间隔,对得上才算过。

5.2 现象:一收数据就进 HardFault,或者卡死在中断里

原因:串口中断优先级数值小于configMAX_SYSCALL_INTERRUPT_PRIORITY(即逻辑优先级高于 FreeRTOS 允许的范围),却在中断服务函数里调用了xQueueSend之类的 FreeRTOS API。Cortex-M 上这类违规不一定立刻报错,表现可能是随机死机、某个任务再也不调度,排查起来像玄学。

解决:把所有会调用 FreeRTOS API 的中断优先级数值设成大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY,并且中断里一律用FromISR结尾的版本,退出前调用portYIELD_FROM_ISR。Modbus 的串口和定时器中断都在这个范围内的话,优先级就得往下压一点,实测对帧间隔判定没有影响。

5.3 现象:从站偶尔回一帧,但主站收到的是截断的最后几个字节

原因:RS-485 方向引脚切换太早。发送完成中断(TXE)只表示数据寄存器空了,移位寄存器里还有字节没发完,这时候拉低 DE 会让最后一个字节被截断,对方收到的帧长度不够,CRC 自然错。

解决:改用发送完成(TC)中断里拉低 DE,或者 TXE 之后再等一个字符时间。还有一种更稳的做法是把 DE 控制交给串口外设的硬件流控或者 RS-485 模式(部分 STM32 系列支持 DE 自动管理),寄存器一配就不用管时序了。切完方向再观察,重点看从站回帧的最后一个字节是否完整。

5.4 现象:读回来的数值整体错位一个寄存器,或者读到的全是 0

原因:寄存器地址 0 基和 1 基搞混了。Modbus 协议报文里的地址是 0 基,而设备手册上写的往往是 40001、40002 这种 1 基编号,直接把手册上的数字填进eMBMasterReqReadHoldingRegister的usRegAddr,整体就偏了一位。

解决:写映射表的时候统一减 1,并且把这个转换收敛到一个宏里,别在每个调用点手写。从站侧的回调同理,usAddress进来就是 0 基,usRegHoldingBuf[usAddress]直接索引即可,不要又减一次。这类错误最典型的特征是“读到的值看起来像隔壁寄存器的”,一旦发现错位规律,基本就能锁定。

5.5 现象:主站和从站同时工作时,总线上出现两帧重叠,或者主站请求永远超时

原因:主从同栈抢同一个串口。主站刚把请求发出去,从站任务那边因为收到上一帧的残留数据进了接收态,两边同时操作串口外设,发送和接收状态互相覆盖。

解决:加串口互斥锁是最直接的办法,主站发起请求前拿锁,收到响应或超时后释放;从站处理一帧期间也持锁。更彻底的做法是分两个物理串口,主站一路、从站一路,从根上消除仲裁。另外注意vMBPortSerialEnable的调用时机,主站发送前要关接收、发完再开,这个切换必须在锁内完成,否则中间窗口里从站中断一进来就乱套。

6. 二次开发:加一个私有功能码并把它跑通

现场设备经常有自己的私有功能码,比如某型号变频器用 0x41 读序列号。扩展开关在mbconfig.h里,eMBRegisterCB把功能码和处理函数绑在一起,实现只要照着mbfuncholding.c的骨架改。

/* 私有功能码 0x41:请求 PDU = [0x41][偏移H][偏移L],响应 = [0x41][6 字节序列号] */ static eMBException prvReadSnFunction(UCHAR *pucFrame, USHORT *pusLength) { USHORT usOffset; UCHAR i; /* pucFrame[0] 是功能码,数据从 pucFrame[1] 开始 */ usOffset = (USHORT)((pucFrame[1] << 8) | pucFrame[2]); if (usOffset != 0 || *pusLength != 3) { return MB_EX_ILLEGAL_DATA_VALUE; /* 异常码 0x03 */ } pucFrame[1] = 0x41; /* 响应功能码 */ for (i = 0; i < 6; i++) { pucFrame[2 + i] = g_ucDeviceSn[i]; /* 6 字节序列号 */ } *pusLength = 8; /* 功能码 + 6 字节数据 + 无附加 */ return MB_EX_NONE; } /* 注册:放在 eMBEnable 之前 */ if (eMBRegisterCB(0x41, prvReadSnFunction) != MB_ENOERR) { prvErrHandler(MB_EINVAL); }

几个参数要盯住:pucFrame指向整个 PDU 的起始,第 0 字节就是功能码,所以数据从下标 1 开始;*pusLength进函数时是请求长度,出函数前必须改成响应长度,写错了主站会收到长度不符的帧;返回值是eMBException类型,MB_EX_NONE表示正常,其他值协议栈会自动组异常响应。私有功能码还会带来一个坑:MB_FUNC_HANDLERS_MAX默认值可能不够,加之前先确认mbconfig.h里留了余量,注册失败返回MB_EINVAL多半就是这个原因。

主站侧要对称地加一个eMBMasterReqReadSn和对应的响应解析,风格参考eMBMasterReqReadHoldingRegister,同样是组 PDU、发请求、等响应、校验功能码。别图省事直接在应用层用串口裸发——那样就绕过了状态机和总线仲裁,前面辛苦搭起来的调度器全废。

多从站轮询这块,把第 4 章的s_pollTable改成从 Flash 读取就行。参数(地址、起始寄存器、数量、周期)做成结构体存 Flash,上电加载,配合一个串口命令或者按键进入配置模式修改。这样现场换设备不用重新烧固件,省掉很多来回。配置写入记得带 CRC 和版本号,掉电写坏了一整张表就全乱了。

验证手法上,手边有主站模拟工具和从站模拟工具各一个是最省事的:从站模拟工具挂在本机虚拟串口上,冒充仪表回应,主站代码不用改就能验证 PDU 编解码和超时逻辑;反过来用主站模拟工具轮询自己写的从站,能快速验证回调里的地址映射。再往下就是示波器看波形,重点量三个点:帧起始到 T3.5 定时器启动的延迟、最后一个字节停止位到方向引脚翻转的间隔、两帧之间的空闲时间。这三个数对上了,基本上就没有玄学问题了。

工程里附的主从流程图在这时候特别有用,尤其调状态机的时候——对着图看当前停在哪个状态,比在代码里翻switch快得多。协议文档用来核对异常码和 PDU 长度,有些细节(比如写多寄存器的字节数字段)光看代码容易漏。

最后说个我自己的习惯。早几年调一条 485 总线,三台设备来回丢帧,我上来就怀疑从站固件有问题,改了两天对方的代码,最后发现是自己 T3.5 定时器用了 SysTick 分频,115200 下压根分不出来。从那以后我每次移植 Modbus,第一件事就是拿示波器量帧间隔,量完再动别的代码,宁可多花二十分钟,也不在这上面栽第二次。希望帮到你。

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

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

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

立即咨询