简介:面向嵌入式开发者的FreeModbus协议栈进阶资料,聚焦STM32H743与FreeRTOS环境下的主站、从站移植与联调,适合已接触Modbus基础、希望打通主机侧闭源瓶颈并完成工程落地的读者。内容围绕主机源码与原有从机风格及接口保持一致、主从同栈运行、FreeRTOS适配、阻塞与非阻塞请求模式、自定义超时以及常用Modbus方法支持等要点展开,并配有流程图与协议整理思路,便于理解协议栈分层、任务调度和异常处理。正文还涉及功能码处理、超时重传、状态机切换、串口收发缓冲及主从并发运行等调试关注点,可帮助读者对照移植细节、快速定位通信异常。整份资料为1个PDF文件,约120KB,轻量便于查阅。已有505人学习,适合作为从入门到精通的案头参考,用于建立主从站开发框架并查漏补缺。
1. 接上板子才发现从站能通、主站没有源码:先把 FreeModbus 这套环境定下来
手上有一块 STM32H743 的板子,跑着 FreeRTOS,项目要接 Modbus。多数人的第一反应是搜 FreeModbus 然后下载 v1.6,从站跑通了,串口助手一敲就能回数据。等到要写主站去轮询别的设备时,问题来了:FreeModbus 只有从机开源,主机源码是收费的,网上能直接拿来用的开源主机协议栈也不多。这一步卡住的人不在少数。本文围绕 FreeModbus 在 STM32H743 加 FreeRTOS 上的主从移植来展开,先把环境与版本列清楚,再一步步落到安装、配置、验证,让读者照着走完能自己把主从两端都跑起来。嵌入式系统里 Modbus 协议这种组合太常见了,装不好就是无穷无尽的超时与乱码。
这套环境值不值得花时间,取决于你在哪一类人里。如果你只是想让一块板子当从站,被上位机读几个保持寄存器,原版 FreeModbus v1.6 加裸机就够了,根本不用上 RTOS。如果你要的是同一套代码里主站和从站共存,一边被上位机采集、一边去轮询下游仪表,那原版从站源码是拼不出来的,必须用移植过主机的那份工程:主站接口风格跟从站保持一致,支持主从在同一协议栈里运行,支持 FreeRTOS,请求可以用阻塞或非阻塞模式,超时时间自己定。第 5 章和第 6 章会专门讲这两种调用模式在任务里的差别,选错了轻则响应变慢,重则任务饿死。
环境基线先摆出来:STM32H743 平台,FreeRTOS 内核,FreeModbus v1.6 源码作底,在此之上加移植的主机层,工具链用 Keil 或 STM32CubeIDE 都行(工程是标准的 ARM 工程,串口外设按 H7 的 HAL 改),调试工具用 Modbus Poll 和 Modbus Slave 这对 7.0 版本。串口参数是 Modbus RTU 的老规矩:9600 或 115200、8 数据位、无校验或有校验、1 停止位,从站地址 1~247。下面从源码包怎么放开始,一步步装到能通。
2. 环境准备与源码落位:FreeModbus v1.6 与移植工程怎么摆
装这类协议栈,翻车点很少在代码本身,多半在目录结构乱放、文件漏加。先把两份东西分清:freemodbus-v1.6是原始开源从站源码,作为对照和底层参考;移植工程是已经加了主机层、接了 FreeRTOS、适配 STM32H743 的完整代码。两者不要混在一个目录里改,否则出问题分不清是自己的移植错了还是原版就这样。
2.1 源码包里的文件各管什么
解压后先做一遍清点,我一般会把这几个文件记在纸上,加文件时挨个对:
| 文件 | 用途 | 装的时候怎么用 |
|---|---|---|
| freemodbus-v1.6.zip | 原版从站源码 | 只读参考,比对从站接口原型 |
| FreeModbusMasterSlave(Rev1.0.0).zip | 移植后的 STM32+FreeRTOS 主从工程 | 主工程,日常改的是这份 |
| ModbusSlave 7.0 / ModbusPoll_7.0 | 上位机模拟从站与主站 | 没有真实仪表时拿来对测 |
| FreeModbus V1.6 主机使用说明.md | 主机 API 调用说明 | 写应用层前先读参数含义 |
| 主机流程图.vsd / 从机流程图.vsd | 状态机流程 | 调不通时对着看状态走到哪 |
| Modbus应用协议.doc | 协议整理文档 | 查功能码与报文格式 |
装之前想清楚一件事:调试工具是装在 PC 上的,通过 USB 转 485 接到板子。如果你的板子只有 TTL 串口,中间得加一个 485 转换模块,A/B 线别接反,反了就是一个字节都收不到,这种低级错误排查起来最耗时间。
2.2 移植工程的目录结构与环境对照
工程文件结构图那份 png 一定要先看,它标出了 port 层、协议核心层、应用层的分界。按常规做法,目录大致分四块:协议核心(mb.c、mbfuncdisc.c 这类功能码实现)、RTU 串口移植层(portserial.c、porttimer.c)、FreeRTOS 适配层(临界区、事件、任务封装)、应用层(主从任务的创建与回调)。装的时候只改 port 层和 FreeRTOS 适配层,核心层不要动,动了以后升级或者对照原版就没法比了。
平台适配的关键对照如下,这几个宏定义错了,编译能过但跑起来就是不通:
| 配置项 | 裸机原版常见值 | STM32H743+FreeRTOS 下要改成 |
|---|---|---|
| 串口收发方式 | 中断 + 轮询 | 中断/DMA,中断里只置标志 |
| 定时器 | 硬件 TIM 计时 | 硬件 TIM,注意 H7 主频高,预分频要重算 |
| 临界区 | 关中断 | taskENTER_CRITICAL / taskEXIT_CRITICAL |
| 延时 | 空循环 | vTaskDelay,禁止在协议栈里忙等 |
| 缓冲区大小 | 256 字节 | 按实际帧长定,别小于 256 |
H7 的主频和 F1/F4 差得远,定时器预分频是最容易拍脑袋的地方。3.5 字符超时是按波特率算的,115200 下一字符约 87 微秒,3.5 字符大约 300 微秒,这个量级的定时器基准要算准,算错了从站会误判帧结束,现象就是偶尔能通、偶尔超时,非常玄学。
2.3 把工程导进 IDE 并补齐依赖
以 Keil 为例,导入工程后先确认这几件事再编译:
# 1. 确认 FreeRTOS 内核版本与工程一致(H7 建议 CubeH7 自带版本) # 路径一般在 Middlewares/Third_Party/FreeRTOS/Source ls Middlewares/Third_Party/FreeRTOS/Source/include # 2. 检查 FreeRTOSConfig.h 里的堆大小,协议栈任务 + 缓冲区别给太小 grep -E "configTOTAL_HEAP_SIZE|configMINIMAL_STACK_SIZE" FreeRTOSConfig.h # 3. 确认串口与定时器的 HAL 初始化在 main 里先于协议栈初始化 grep -n "MX_USART\|MX_TIM" Core/Src/main.c第一步是确认内核版本对得上,H7 的工程如果混用了老版本 FreeRTOS 头文件,编译报的错会指向portmacro.h里的一堆宏,看着像协议栈的错,其实是内核版本不对。第二步堆大小,主从同跑至少留出两个任务栈加帧缓冲,configTOTAL_HEAP_SIZE给小了会在创建任务时直接返回失败,现象是板子起来就卡死,串口一声不响。第三步是初始化顺序,串口和定时器必须在协议栈初始化之前完成配置,顺序反了协议栈拿到的句柄是空的,第一次发送就进 HardFault。
编译通过之后别急着烧,先看一眼 map 文件里协议栈占了多大空间,H7 的 Flash 不缺,但 RAM 要留意,尤其是把发送接收缓冲开大之后。这一步做完,环境算是立住了,接下来才是配置。
3. 从站安装配置:RTU 串口、定时器与回调三处必须对上
从站是这套环境里先跑通的一半,因为上位机工具现成,验证成本低。配置的重点不在协议栈本身,而在 port 层那几个函数和三个回调。装的时候建议顺序是:先串口能自发自收,再定时器能按微秒级中断,最后接上协议栈。
3.1 portserial.c 里串口收发的改法
原版 portserial.c 是按裸机中断写的,移植到 FreeRTOS 上要做两件事:中断里不做耗时操作,只把字节塞进缓冲并置标志;发送完成用中断而不是死等。核心改动集中在收发使能和中断服务里:
/* 串口初始化:按 H7 的 HAL 改,波特率、校验位从配置宏取 */ BOOL xMBPortSerialInit( UCHAR ucPORT, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity ) { huart1.Instance = USART1; huart1.Init.BaudRate = ulBaudRate; /* 与上位机严格一致 */ huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = ( eParity == MB_PAR_NONE ) ? UART_PARITY_NONE : UART_PARITY_EVEN; huart1.Init.Mode = UART_MODE_TX_RX; if( HAL_UART_Init( &huart1 ) != HAL_OK ) return FALSE; return TRUE; /* 上层据此决定是否继续初始化 */ } /* 接收中断:只做搬运,绝不在这里解析帧 */ void USART1_IRQHandler( void ) { UCHAR ucByte; if( __HAL_UART_GET_FLAG( &huart1, UART_FLAG_RXNE ) ) { ucByte = ( UCHAR )( huart1.Instance->RDR & 0xFF ); ( void )xMBPortSerialPutByte( ( CHAR )ucByte ); /* 入环形缓冲 */ vMBPortTimersEnable(); /* 每来一字节重置 3.5 字符计时 */ } HAL_UART_IRQHandler( &huart1 ); }xMBPortSerialInit里波特率和校验必须和上位机一字不差,尤其校验位,很多仪表默认偶校验,工程里配成无校验,报文能发出去但从站 CRC 校验不过,回的就是异常码或者干脆不回。中断服务里那行vMBPortTimersEnable是 RTU 帧判定的关键:RTU 靠总线空闲 3.5 个字符判断一帧结束,所以每收到一个字节就要把定时器重置一次,定时器一旦溢出就认为帧收完了。这行漏掉,从站就永远等不到帧结束,表现为上位机发什么都超时。
发送方向同理,xMBPortSerialPutByte把数据送到发送寄存器,最后一个字节发完触发发送完成中断,再调用pxMBFrameCBTransmitterEmpty通知协议栈可以发下一帧。别在发送函数里用HAL_UART_Transmit阻塞等,一个帧几十毫秒,阻塞会拖垮同优先级任务。
3.2 定时器与 FreeRTOS 临界区的配合
定时器这块,原版用硬件 TIM 产生 3.5 字符超时。H7 上要注意 TIM 时钟源和预分频的换算:
/* 定时器初始化:以 1us 为基准,超时值由协议栈按波特率动态给 */ void vMBPortTimersInit( USHORT usTimeOut50us ) { /* H7 定时器时钟一般 200MHz 左右,预分频到 1MHz 得到 1us 计数 */ uint32_t uwPrescaler = ( uint32_t )( ( SystemCoreClock / 1000000U ) - 1U ); htim3.Init.Prescaler = uwPrescaler; htim3.Init.Period = usTimeOut50us; /* 协议栈换算好的计数值 */ HAL_TIM_Base_Init( &htim3 ); }usTimeOut50us这个参数名容易误导,它是以 50 微秒为单位的计数值,协议栈内部按波特率算好传进来,移植时不要自己再乘一遍,乘错了超时就变成原来的几十倍,从站会一直等不到帧尾。临界区用 FreeRTOS 的宏替换掉原来的关中断写法,taskENTER_CRITICAL在 Cortex-M 上关的是可配置优先级以下的中断,串口中断优先级如果设得比configMAX_SYSCALL_INTERRUPT_PRIORITY还高,就会出现临界区里中断照进、数据被踩的情况,这是 FreeRTOS 上最隐蔽的一类坑,第 6 章会展开。
3.3 从站回调与应用层对接
协议栈初始化调用eMBInit( MB_RTU, ucSlaveAddr, ucPort, ulBaudRate, eParity ),再eMBEnable()。真正和业务打交道的是四个回调:读输入寄存器、读写保持寄存器、读线圈、写线圈。以保持寄存器为例:
/* 从站保持寄存器回调:寄存器地址从 usAddress 开始,读 N 个到 pucRegBuffer */ eMBErrorCode eMBRegHoldingCB( UCHAR * pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode ) { eMBErrorCode eStatus = MB_ENOERR; USHORT i; if( ( usAddress >= REG_HOLDING_START ) && ( usAddress + usNRegs <= REG_HOLDING_START + REG_HOLDING_NREGS ) ) { usAddress -= REG_HOLDING_START; /* 协议地址转本地下标 */ for( i = 0; i < usNRegs; i++ ) { if( eMode == MB_REG_READ ) pucRegBuffer[2*i+1] = ucRegHolding[usAddress+i] >> 8, pucRegBuffer[2*i] = ucRegHolding[usAddress+i] & 0xFF; else ucRegHolding[usAddress+i] = ( pucRegBuffer[2*i] << 8 ) | pucRegBuffer[2*i+1]; } } else { eStatus = MB_ENOREG; /* 越界返回异常码 02 */ } return eStatus; }这段回调有两个细节必须做对。一是地址换算,Modbus 报文里的地址是从 0 开始的协议地址,业务里常用的 40001 是显示地址,两者差一个偏移,混用会导致读到的寄存器整体错一位。二是字节序,Modbus 是大端,pucRegBuffer里高字节在前,H7 是小端,拼接和拆分都得手动换序,漏了就是数值全错但通信正常,比不通还难查。回调返回MB_ENOREG时协议栈会自动回异常码,这是标准行为,不要让回调自己发报文。
装到这里从站基本能通了,用 Modbus Poll 连上读几个寄存器验证一下,再往下走主站。
4. 主站安装配置:请求模式、超时与主从同栈共存
主站是这份移植工程真正的增量。原版只有从站,主机层是按从站风格补出来的,所以接口用法跟从站一致,但调用范式不同:从站是被动响应,主站是主动发起请求,要考虑在哪个任务里发、阻塞还是非阻塞、超时给多少。
4.1 主机初始化与请求函数
主机初始化和从站分开,一套栈里主从各自有初始化接口,初始化顺序建议先从站后主机,两边共用一个串口时由协议栈内部仲裁。主机发起请求的典型写法:
/* 主站读保持寄存器:从站地址 1,起始地址 0,读 4 个,超时 1000ms */ eMBMasterReqHandle_t xHandle; eMBErrorCode eStatus; eStatus = eMBMasterReqReadHoldingRegister( 1, 0x0000, 4, 1000 ); if( eStatus == MB_ENOERR ) { /* 非阻塞模式:立即返回,结果在回调里拿 */ xMBMasterRunReqTimeoutStart(); } else { /* 忙或参数错,按实际业务决定重试 */ }几个参数的含义要拎清。第一个是从站地址,1~247,0 是广播,广播写能发但不会有回应,读广播永远不会返回,这是新手最容易误用的点。第二个起始地址是协议地址,不是 40001 那种显示地址。第四个超时单位由移植层定义,一般是毫秒,给小了在长总线上会误判超时,给大了主站任务被占死,常规做法是按最差往返时间留 2 到 3 倍余量。返回值只代表请求有没有排进去,不代表对方回了正确的数据,后者要等回调。
主机和从站同栈运行时,串口是共享资源,主站在等回应期间如果从站也要应答,协议栈内部按状态机切换,不需要应用层加锁。但如果你自己在外层又套了一层互斥锁,反而可能死锁,这个坑我见过不止一次。
4.2 回调里拿结果,别在发起处等
主机请求的结果走回调,包含功能码、从站地址、数据、错误码:
/* 主站完成回调:无论成功超时都从这里出 */ void eMBMasterRegHoldingCB( UCHAR * pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode, eMBErrorCode eStatus ) { if( eStatus != MB_ENOERR ) { /* 超时 / 异常码 / CRC 错都在这里统一处理 */ xMasterFaultCnt++; return; } /* 数据在 pucRegBuffer,大端,拆分时注意换序 */ for( USHORT i = 0; i < usNRegs; i++ ) { usSensorValue[i] = ( pucRegBuffer[2*i] << 8 ) | pucRegBuffer[2*i+1]; } xMasterOkCnt++; }回调里不要做耗时操作,它跑在协议栈任务或中断上下文里,塞进去一个几十毫秒的处理会拖慢后续请求。我一般只在回调里拷数据、置标志,业务逻辑放回自己的任务里处理。错误处理上,超时和异常码要分开计数,超时多半是物理层或从站没上电,异常码是对方拒绝了这个请求,两者的排查方向完全不同。
4.3 阻塞与非阻塞怎么选
移植工程给应用层留了两种请求模式。非阻塞模式发起后立即返回,结果靠回调,主站任务可以继续干别的,适合一个任务轮询多个从站的场景,把请求排进队列,回调里根据从站地址分发。阻塞模式发起后任务挂起等结果,代码写起来直白,适合单个从站、顺序读写的简单业务。
选阻塞模式要确认调用它的任务优先级不是最高,否则等回应期间把低优先级的串口处理任务饿死,就永远等不到结果。实时操作系统里这类优先级反转不想显式处理的话,用非阻塞模式更稳。超时时间在两个模式下含义一样,都是按毫秒算的协议栈内部计时,跟 FreeRTOS 的 tick 无关,别拿pdMS_TO_TICKS去套。
配置到这里,主站已经能发出请求。下一步是把它跑起来验证,以及真出问题时怎么救。
5. 主从同栈调试与报文验证:用工具对测,别靠猜
装完不等于通。这套环境最省时间的验证方式是 PC 上开 Modbus Slave 模拟从站、Modbus Poll 模拟主站,板子中间做桥,或者反过来让板子当从站、PC 当主站。先把链路打通,再看业务数据。
5.1 串口与接线先自检
接线上,485 的 A 接 A、B 接 B,如果模块标注是 A+/B-,对应关系要查模块手册,接反了通信全无。串口参数两边必须一致:波特率、数据位、校验、停止位。RTU 模式下校验位改了,CRC 计算不受影响,但物理层采样会错,现象是能收到字节但全是错的。用示波器或者逻辑分析仪看波形是最后手段,先用串口助手自发自收确认硬件通路,再上协议栈。
另一个常被忽略的是终端电阻。485 总线长于几十米、或者波特率上到 115200,两端各加 120 欧终端电阻,不加的时候短距离能通、长距离时通时不通,这种玄学问题会让人怀疑代码。
5.2 验证用的报文对照
从站被读保持寄存器时,标准请求是01 03 00 00 00 04 44 09(地址 1、功能码 03、起始 0、数量 4、CRC)。板子回的是01 03 08 xx xx ... CRC。用 Modbus Poll 里自带的报文监视看收发,对不上就逐段比:地址对不对、功能码对不对、字节数对不对、CRC 对不对。CRC 错说明物理层或字节序有误,功能码异常回01 83 02这种,说明回调返回了MB_ENOREG,是地址越界。
主站方向,让板子去读 Modbus Slave 模拟的从站,把 Slave 的寄存器预置成已知值,读回来对比。读不到就分三步看:请求有没有发出去(串口助手抓板子的 TX)、从站有没有回(抓 RX)、回调有没有被调用(打个翻转 GPIO 或者打印)。三步定位能把问题范围缩到很小。
5.3 主从共存时的时序观察
主从同栈最需要验证的是时序。让上位机持续读板子从站,同时板子主站持续轮询下游,观察两边有没有互相拖慢。常见现象是主站请求发出后,从站响应变慢几十毫秒,这是因为串口被主站占用,从站的应答要等主站这一轮结束。业务上一般能接受,但如果上位机超时设得很短就会误判掉线,这时要么加大上位机超时,要么调整主站轮询周期,别在协议栈里硬改。
调试工具里的 7.0 版本功能够用,扫描从站、看报文、改寄存器都有。装的时候注意不要把 Modbus Poll 和 Modbus Slave 同时绑到同一个 COM 口,Windows 下会直接报占用,两个工具分两个 USB 转 485 或者错开使用。
6. 装坏了的排查清单:五类高频坑与恢复手法
这套环境装坏的方式有迹可循,下面五条是按我遇到的频率排的,每条都按现象、原因、解决写,照着对能省不少时间。
坑一:从站偶尔超时,重发就好。现象是上位机大部分请求能通,隔一段时间超时一次。原因是 3.5 字符定时器预分频算错,或者接收中断里没有每字节重置定时器,导致帧边界判断偏早或偏晚。解决是把定时器基准校准到微秒级,确认vMBPortTimersEnable在接收中断里被调用,并且中断优先级低于configMAX_SYSCALL_INTERRUPT_PRIORITY。
坑二:编译过、烧进去没反应,串口一声不响。现象是板子上电后协议栈初始化返回错误或者直接 HardFault。原因多是初始化顺序错,串口和定时器没先于eMBInit完成,或者 FreeRTOS 堆不够导致任务创建失败。解决是先看eMBInit返回值,再把configTOTAL_HEAP_SIZE加大到至少 16KB 以上,主从同跑建议 32KB,同时确认main里外设初始化在协议栈之前。
坑三:通信正常但数据全是错的。现象是读回来的寄存器值跟预置的对不上,或者高低字节颠倒。原因是 Modbus 大端与 H7 小端的字节序没换,或者协议地址与显示地址的偏移没处理。解决是在回调里统一做( hi << 8 ) | lo的拼接,地址换算只在回调入口做一次,不要在业务层再减。
坑四:主站请求发出去,永远等不到回调。现象是调用请求函数返回成功,但回调不触发。原因是超时参数单位理解错,或者主站任务优先级过高把串口处理任务饿死,也可能是阻塞模式下在中断里调用了请求函数。解决是把请求函数放在任务上下文里调,超时按毫秒给足,非阻塞模式优先,阻塞模式确认任务优先级低于串口服务任务。
坑五:临界区里数据被踩,偶发 CRC 错。现象是高负载下偶发校验错,重启就好。原因是用了taskENTER_CRITICAL但串口中断优先级配得比configMAX_SYSCALL_INTERRUPT_PRIORITY高,临界区挡不住中断,缓冲被并发改写。解决是把串口和定时器中断的抢占优先级调到该宏的数值之下(数值更大),这是 FreeRTOS 上最容易被忽略的移植细节。
这五条覆盖了大部分装坏的情况。真遇到协议栈内部状态机卡死,把主机流程图和从机流程图调出来,对着看当前状态停在哪一步,比盲改代码快得多。
7. 装完怎么验证跑通:一套三分钟的回归手法
装完的验收不能只看“能通一次”。我的习惯是固定跑一套回归,三分钟能过就算这套环境立住了。具体做法是准备一个已知内容的从站模拟(Modbus Slave 里预置 10 个保持寄存器,值设成 0x0001 到 0x000A),然后依次做三件事。
第一件,单从站回归。让板子只当从站,用 Modbus Poll 以 100ms 周期连续读 5 分钟,统计超时次数。合格标准是零超时,偶发一两次要看不合格的原因是不是上位机侧的问题。第二件,单主站回归。板子只当主站,去读模拟从站的那 10 个寄存器,连续轮询 5 分钟,对比每次读回的值和预置值是否一致,同时看主站回调里成功和失败计数。第三件,主从共存回归。上位机读板子从站的同时,板子主站轮询下游,两边都跑 5 分钟,观察两边超时率有没有上升。
三件事跑完,再补一个边界测试:读一个不存在的地址,确认返回异常码而不是卡死;把从站断电,确认主站超时后能恢复而不是永久挂起;把波特率改错一位,确认错误能被上层感知。这几步能覆盖大部分现场会遇到的异常。
参数上留个表,回归时对着填:
| 验证项 | 观察点 | 合格标准 |
|---|---|---|
| 从站连续读 | 上位机超时次数 | 5 分钟内 0 次 |
| 主站连续轮询 | 回调成功/失败计数 | 失败计数不随时间增长 |
| 主从共存 | 双方超时率 | 与单独跑时无显著差异 |
| 异常地址 | 返回码 | 异常码 02,不卡死 |
| 从站掉电恢复 | 主站后续请求 | 恢复供电后自动继续 |
这套手法的价值在于把“看着能通”变成“有数据支撑的能通”。我在项目里吃过一次亏:现场调试时单次读写都正常,装到设备上跑了半小时开始偶发超时,回头查是定时器预分频在低温下漂了,如果有五分钟连续回归这一步,问题在实验室就能暴露。从那以后我每次装完 FreeModbus 的主从环境,都强制走一遍这套三分钟回归,不跳步。希望这套流程能帮到你,少在时序和字节序上耗时间,把精力留给业务逻辑。
本文还有配套的精品资源,点击获取