FreeModbus主从入门:STM32+FreeRTOS跑通RTU
简介面向嵌入式开发者的Modbus学习与移植资料围绕STM32H743与FreeRTOS平台重点解决FreeModbus仅有从机开源、主机需付费且缺少优质开源主机协议栈的痛点。内容覆盖协议原理、主从站移植与联调适合已具备一定单片机与实时操作系统基础、希望深入掌握Modbus主从通信的中高级工程师。压缩包共1个PDF文件约120KB以图文形式集中呈现协议要点、移植流程与调试思路便于按顺序通读或按需检索。作者在原有从机源码基础上补充了主机实现接口风格与从机保持一致支持主从机在同一协议栈内运行并提供阻塞与非阻塞两种请求模式及可自定义超时时间兼容常用Modbus方法。资料还梳理了主机与从机流程图、工程文件结构及协议文档要点有助于读者理解调用关系、快速定位通信异常并完成二次开发。目前已有506人学习下载可作为协议入门与工程落地的参考。1. 先弄明白这到底是什么Modbus 就是设备之间的问答规矩第一次拆开电柜的人多半会懵两根线接上去老师傅在电脑上敲了个 40001屏幕上就跳出一个数字嘴里还念着“读一下保持寄存器”。这里没有神秘操作只有一套很朴素的问答规矩——一台设备负责问另一台负责答问什么用编号表示答什么用数字表示这就是 Modbus。它不关心你用的是哪家芯片、哪种语言只管“问的格式”和“答的格式”对不对得上。FreeModbus 则是把这套规矩写成 C 代码的开源协议栈可以直接塞进 STM32 这类单片机里跑配上 FreeRTOS 之后设备还能一边应答上位机一边干自己的活。这套资源适合谁刚接触嵌入式、手里有块板子、打开例程满屏英文函数却不知道从哪行开始改的新手。2. 把术语翻成人话主站、从站、寄存器、功能码各管什么例程打开第一眼扑面而来的是 eMBInit、eMBRegHoldingCB、MB_REG_READ 这些名字。它们不是故意为难人只是把“谁问、问什么、答到哪”这三件事拆成了代号。这一章的任务很单纯把这些代号一个个翻成日常说法翻完之后你再回去看代码至少能知道每一行在跟谁打交道。2.1 主站和从站一个负责提问一个负责回答一条总线上只能有一个主站但可以挂很多从站。主站是唯一有资格开口提问的角色从站只有在被点名时才说话绝不会主动插嘴。这个设计看着很死板其实正是它稳定的原因没有抢话就没有冲突。每个从站都有一个地址取值范围是 1 到 247。地址 0 是广播主站发出去所有从站都听但谁也不许回248 到 255 留作特殊用途新手阶段基本碰不到。所以你在例程里看到0x01、0x02这样的数字别当成随便写的常量那是“门牌号”上位机发的帧里必须带同一个号从站才理你。主站问一次从站答一次这一来一回叫一次“事务”。事务和事务之间要有安静的空档好让设备分清上一句和下一句。这个空档在 RTU 模式下大约是 3.5 个字符的传输时间波特率越高这个时间越短。新手最容易忽略的正是这个“安静时间”后面讲坑的时候还会遇到它。2.2 四张表与功能码数据到底存在哪儿Modbus 把所有数据分成四类分别放在四张“表格”里。主站要来取数或者写数得先说清楚我要动的是哪张表、第几个位置。这四张表和最常用的功能码对应关系如下数据区位宽读写常见功能码线圈1 位读写01 读、05 写单个、0F 写多个离散输入1 位只读02输入寄存器16 位只读04保持寄存器16 位读写03 读、06 写单个、10 写多个对新手来说日常打交道最多的就是保持寄存器。开关量用线圈模拟量、参数、设定值一般塞进保持寄存器。位和字的区别先不用纠结记住一句话线圈是“开或关”寄存器是“一个 0 到 65535 的数”。这里有个绕不开的玄学地址差一。设备厂商的文档喜欢写成 40001、40002而协议报文里的地址是从 0 开始算的所以 40001 在报文里其实是 0x0000。你在上位机软件里填 40001抓包看到的却是 00 00第一次遇到会以为软件出 bug 了。记住这条换算能少调半天。2.3 手搓一帧报文把问答落到字节上概念讲完落到字节上才踏实。一帧 Modbus RTU 报文的结构其实很短从站地址 功能码 数据 两字节校验。下面这段 Python 代码就地生成一帧“读从站 1 的 10 个保持寄存器”的请求并自己算校验你可以直接跑一下看看结果。def crc16(data: bytes) - bytes: 标准 Modbus CRC16初值 0xFFFF多项式反向 0xA001 crc 0xFFFF for b in data: crc ^ b for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 # 低位为 1 时异或多项式 else: crc 1 return bytes([crc 0xFF, crc 8]) # 注意低字节在前 # 从站地址 1功能码 03起始地址 0读取数量 10 frame bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A]) print((frame crc16(frame)).hex( )) # 输出01 03 00 00 00 0a c5 cd逐段解释一下。前两个字节01 03是“敲谁的门、办什么事”从站看到 03 就知道主站要读保持寄存器。接下来四个字节00 00 00 0A是参数前两个是起始地址对应人说的 40001后两个是数量一次读 10 个。最后两字节是 CRC16 校验用于让接收方判断这帧在传输中有没有被干扰坏掉。提示CRC 的低字节在前、高字节在后这一点和报文里其他多字节字段高字节在前正好相反。写代码时把这两套顺序记反是最常见的翻车点。从站的应答会是什么样地址 功能码 字节数 数据 CRC。功能码 03 的应答里第三个字节是“后面有多少个数据字节”读 10 个寄存器就是 20 字节写成十六进制是 0x14。如果从站不高兴会把功能码最高位置 1 变成 0x83后面跟一个异常码比如 02 表示地址越界03 表示数据值不合法。看到 0x80 以上的功能码别怀疑线路先怀疑自己发的参数。2.4 为什么这套三十年前的规矩今天还在用新技术层出不穷Modbus 却还活得好好的原因很实际。第一是简单一帧报文最多 256 字节没有握手、没有加密、没有会话状态单片机用几百字节内存就能实现第二是省钱两根双绞线就能挂一串设备布线成本低第三是通用PLC、仪表、变频器、传感器几乎都支持随便找一个调试软件就能对话。对新手来说简单意味着可以完整地看懂一遍。学一套复杂协议你可能调通半年还不知道内部发生了什么学 Modbus你花一个下午就能把一帧报文从头到尾拆干净知道每个字节为什么在那儿。这种“能看穿”的感觉对建立信心特别重要。3. 为什么新手适合从 FreeModbus 这套例程入手省掉的正是最难那部分知道了协议长什么样下一个问题是为什么不是自己从零写一份答案有点扎心——协议本身简单但把它写成能在真实硬件上稳定跑的代码难点全在边角料上。这套基于 STM32H743 和 FreeRTOS 移植的 FreeModbus 主从例程价值就在于把这些边角料提前处理完了。3.1 自己从零写一份协议栈会翻车在哪先说校验。CRC16 算法网上一搜一大把但真正写对不容易查表法还是逐位算、初值给多少、结果高低字节怎么排任何一处错了收到的帧全是“校验失败”。而校验失败时你是看不到数据的只能看到一片安静等于面对一个黑匣子。再说帧边界。串口是流式数据没有“包”的概念你怎么知道这一帧到哪儿结束靠的就是前面提到的 3.5 字符空闲时间。这个时间在低波特率下是几毫秒在高波特率下可能只有几十微秒定时器精度不够就会把一帧拆成两半或者把两帧粘成一帧。还有超时和重发。主站发出去一帧从站没回应你等多久等太短误判等太长卡住整个任务。重发几次重发时序列号怎么处理这些问题文档里不会写只能自己踩坑踩出来。最后是并发。设备一边要应答主站一边要采样、控制、刷屏。如果协议处理写成死循环阻塞等待整个系统就僵住了。这时候才体现出 RTOS 的必要性。3.2 FreeModbus 的取舍从机开源主机得自己补FreeModbus 本身是一款开源协议栈但有个现实情况从机部分是开源的主机源码是要收费的。而网上的开源主机实现又普遍不太好用接口风格和从机对不上合到一个工程里像拼两块不同牌子的积木。这份例程的作者就是在这个背景下补出了主机部分并且做了几件对新手很友好的事。一是接口风格统一。主机新增的源码刻意保持了原有从机的命名和调用习惯你学会看从机代码切到主机不用重新建立心智模型。二是主从可以在同一套协议栈里运行做网关类设备时不用维护两份代码。三是支持 FreeRTOS协议栈的轮询和业务任务能各自独立。四是给了阻塞和非阻塞两种请求模式超时时间还可以自己定。五是常用功能码都覆盖了。阻塞和非阻塞的差别用一张表说清对比项阻塞模式非阻塞模式调用后行为一直等到应答或超时立刻返回结果靠回调通知适用场景上电初始化、简单的单任务程序多任务系统、界面不能卡顿风险总线慢时整个任务被拖住回调里不能做耗时操作新手建议先用它跑通第一个例子跑通之后再迁移过去刚开始不建议纠结选哪个。先把阻塞模式跑通看到数据回来心里有底了再改成非阻塞理解“发起”和“完成”是两件事。3.3 移植层只有三个口子串口、定时器、事件协议栈再复杂跟硬件打交道的入口其实只有三个文件串口、定时器、事件。这也是新手最该先看的三处因为换一块板子你要改的基本就是它们。串口层负责收发字节、控制收发方向。RS485 是半双工发送和接收共用一对线中间需要一个引脚控制方向这个切换时机的把握就是很多人翻车的地方。定时器层提供毫秒级的节拍协议栈靠它判断超时和帧间隔。事件层是把“串口收到数据了”“定时器到点了”这类事情通知给协议栈的主循环在裸机里可能是个标志位在 FreeRTOS 里通常用信号量或任务通知实现。下面这段是典型的初始化顺序注释写在每一步旁边eMBErrorCode eStatus; /* 第一步初始化协议栈参数依次是模式、从站地址、串口号、波特率、校验 */ eStatus eMBInit(MB_RTU, 0x01, 1, 115200, MB_PAR_NONE); if (eStatus ! MB_ENOERR) { /* 常见原因串口号超出范围、波特率不支持、串口初始化函数没实现 */ Error_Handler(); } /* 第二步使能协议栈此时开始监听总线 */ eMBEnable(); /* 第三步主循环里周期调用轮询收帧、校验、应答全靠它 */ for (;;) { eMBPoll(); vTaskDelay(pdMS_TO_TICKS(1)); /* 别延时太久否则帧间隔判断会失准 */ }参数里最容易被忽略的是串口号。例程默认用的是哪一路串口你得跟自己的板子对上否则初始化返回错误或者更糟——返回成功但总线上一片死寂。波特率和校验位必须和上位机完全一致差一个校验位都通不了。3.4 为什么把例程放到 FreeRTOS 上跑更接近真实项目裸机跑 Modbus 当然可以主循环里轮询一下就行。但真实设备很少只干一件事它要读传感器、要驱动继电器、要刷显示屏、要处理按键还要应答上位机。这些事情的时间尺度差得很远硬塞进一个循环里迟早互相拖累。放到 FreeRTOS 上协议栈通常单独占一个任务优先级设得比业务任务高一些保证总线上的请求能被及时响应。串口中断只负责把字节丢进缓冲区并通知任务绝不在中断里做解析和应答这种耗时活。这个分工一旦建立起来系统就变得可预测了总线响应慢你去查协议任务有没有被饿着业务卡顿你去查是不是在等一个阻塞的 Modbus 请求。这套例程把任务划分、优先级、缓冲区都摆好了你打开工程就能看到一个完整的骨架而不是一段孤零零的协议代码。对新手来说这种“能跑起来的整体”比十篇原理文章都管用。4. 第一个能跑通的例子让从站的一个寄存器被读出来到这儿该动手了。第一个目标定得越小越好让板子作为从站上位机发一帧读请求能读回一个你事先写死的数字。别一上来就接真实传感器变量越少出问题时越好定位。4.1 动手前先备齐板子、串口、工具、工程硬件这边需要一块能跑起来的 STM32 开发板、一根下载线、一个 USB 转串口或 USB 转 485 的转换器。如果板子上是 RS485 接口注意 A、B 两根线别接反接反了现象是完全没有回应很容易被误判成代码问题。软件这边需要编译环境、串口助手或者 Modbus 调试工具。资源包里带了主从调试工具主站模拟和从站模拟各一个正好对着用。工程这边直接用资源包里那份已经移植好的 STM32 FreeRTOS FreeModbus 主从代码别自己新建工程从头配。新手最容易把时间耗在环境搭建上配置半天编译不过热情就没了。先用别人的工程跑通再回头理解它为什么这么组织。打开工程之后先找到配置文件把从站地址、串口号、波特率、校验方式这四个值确认一遍因为后面所有调试都以这四个值为准。4.2 五步把从站跑起来第一步确认串口初始化的引脚和你的板子一致。例程里写的是哪两个引脚你的板子是不是这两个这一步错了后面全白搭。第二步确认定时器节拍是 1 毫秒协议栈靠它算超时。第三步注册寄存器回调也就是告诉协议栈“别人来读保持寄存器时你去哪个数组里取数”。第四步在主任务里周期调用轮询函数。第五步编译下载用调试工具发请求。第三步的回调是整个例程的核心看懂它你就看懂了 Modbus 从站的工作方式/* 主站读保持寄存器时协议栈回调此函数取数据 */ eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { /* usAddress 是协议内的偏移地址从 0 开始不是文档上的 40001 */ if ((usAddress usNRegs) 16) { return MB_ENOREG; /* 越界从站会回异常码 02 */ } if (eMode MB_REG_READ) { for (USHORT i 0; i usNRegs; i) { USHORT usVal g_usHoldingReg[usAddress i]; *pucRegBuffer (UCHAR)(usVal 8); /* 高字节先发 */ *pucRegBuffer (UCHAR)(usVal 0xFF); /* 低字节后发 */ } } else { /* 写寄存器主站发来的字节同样是高字节在前要拼回来 */ for (USHORT i 0; i usNRegs; i) { g_usHoldingReg[usAddress i] (USHORT)((*pucRegBuffer 8) | *pucRegBuffer); } } return MB_ENOERR; }几个参数说明一下。pucRegBuffer是协议栈给的缓冲区指针你往里填数据它负责发出去注意别越界写。usAddress是起始地址单位是寄存器且从 0 开始算。usNRegs是这次要操作的寄存器个数主站要几个你就填几个别多填也别少填。eMode区分读和写同一个回调要处理两个方向这是新手容易漏的地方——只写了读分支一测写功能就异常。数组里放什么第一个例子就放个固定值比如g_usHoldingReg[0] 1234。等你能读回 1234再换成真实的传感器数据。4.3 用上位机发一帧请求看数字回来打开调试工具选好串口和参数从站地址填 1功能码选 03起始地址填 0数量填 1点发送。如果一切正常你会看到返回的数值是 1234。这一刻的成就感很关键它证明线路、参数、代码三样东西至少对齐了。如果没回来先别改代码。把串口助手打开看板子到底有没有往外发字节。有字节但内容不对问题在数据组织完全没字节问题在接收或者地址匹配。这个二分法能帮你砍掉一半的排查范围比漫无目的地翻代码高效得多。4.4 换成主站让板子主动去问别人从站跑通之后可以试试反过来的角色。把另一台设备或者调试工具设成从站让你的板子当主站去读它。主站请求的写法大概是这样/* 非阻塞方式向从站 2 读 2 个保持寄存器超时 1000 毫秒 */ eMBMasterReqReadHoldingRegister(0x02, 0x0000, 2, 1000); /* 函数立刻返回真正的结果在回调里判断 */ void eMBMasterRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode, eMBException eException) { if (eException MB_EX_NONE) { /* 成功数据在 pucRegBuffer 里高字节在前 */ } else { /* 超时、地址非法、从站忙都从 eException 里看 */ } }参数的含义和从站那边是镜像的目的地地址、起始地址、数量、超时毫秒数。不同移植版本的函数名可能略有出入按下发时编译器提示的原型对参数就行别死记名字。写完记得在主循环里也调用主站的轮询函数主机模式的超时和重发全靠它驱动。5. 新手最容易踩的 5 个坑串口灯闪了但数据没回来5.1 现象调试工具一直显示超时板子的发送灯却在闪原因通常是两边参数没对齐。波特率差一档、校验位一个选无一个选偶、停止位不一致都会造成“我发了你不认”。还有一种隐蔽情况波特率数值相同但芯片时钟配置错了实际波特率有偏差短帧能通、长帧就错。解决办法是先固定一组参数两边写死115200、8 数据位、无校验、1 停止位。然后用逻辑分析仪或者示波器量一下一个位的宽度算出来的波特率和设定值对不上就去查时钟树。没有仪器的话把波特率降到 9600 试一次低速率对时钟误差更宽容能通说明就是精度问题。5.2 现象读回来的数据全是 0或者从站回了个异常码 02原因是地址差一。上位机填的是 40001报文里发的是 0x0000而从站回调里的usAddress也是 0。很多人在回调里写if (usAddress 1) return MB_ENOREG;等于把第一个寄存器判成非法主站自然收到 02 异常。解决办法是把换算关系贴在显示器边上文档地址减 40001 得到偏移量回调里直接用偏移量。写判断时用usAddress usNRegs 数组长度这种形式别用小于 1 去卡下界。5.3 现象单步调试能通全速跑就超时原因是轮询不及时。RTOS 里协议任务的优先级设得太低被业务任务饿着或者某个任务里写了长延时把整个调度拖住。还有一种情况是串口中断里做了太多事导致后续字节被覆盖。解决办法是给协议栈单独一个任务优先级高于普通业务任务循环里只调用轮询和极短的延时。中断服务函数里只做“收字节、存缓冲区、发通知”三件事解析和应答全部放到任务里。改完再看如果还超时就在轮询前后各翻转一个空闲引脚用示波器看调用间隔是不是稳定。5.4 现象从站明明回了主站这边却收不到正确数据原因多半在 RS485 的方向切换。半双工总线上发送完最后一个字节后要立刻切回接收但如果切得太早最后一个字节还没从移位寄存器里发完就被截断了对端收到的帧校验必然失败。解决办法是在串口的发送完成中断里切换方向而不是在“写缓冲区”函数返回之后。如果芯片不支持发送完成中断就在发送后加一个按波特率算出来的小延时宁可多等几十微秒也别抢那一瞬间。5.5 现象主站和从站跑在同一个工程里一开就乱原因是资源冲突。同一路串口不能同时被主站实例和从站实例占用定时器、缓冲区也可能被两边同时改。看着代码没报错运行起来就是一顿乱回。解决办法是物理上分开主站用一路串口从站用另一路井水不犯河水。如果硬件只有一路串口那就得分时复用明确什么时间片当主站、什么时间片当从站切换时把状态机复位干净。这类设计对新手偏难第一个项目建议直接上两路串口。6. 下一步学什么从能读一个寄存器到能写自己的主站跑通第一个例子之后很多人会卡在一个尴尬的位置例程能跑但换个需求就不会改了。这时候需要的不是继续读原理而是按顺序补几块具体的技能。下面这条路径是我带新人时反复用的顺序别乱每一格都要动手验证过再往下走。阶段目标验证方式第一步改从站地址和波特率让上位机重新连上换个地址还能读到同一个值第二步把固定值换成真实变量比如内部温度手捏传感器读数跟着变第三步实现写寄存器从上位机改一个参数断电重启后参数还在第四步用主站去读另一台设备先阻塞后非阻塞拔掉从站线能正确报超时第五步看流程图理清一帧从进串口到被应答的完整路径能对着代码说出每一步在哪个函数第三步值得多说一句。写寄存器会把“字节序”这个坑暴露得干干净净主站发来两个字节你得拼成一个 16 位整数拼反了就是一个荒谬的大数。我第一次调这个的时候把高字节低字节弄反读回来是 1234写进去变成 53764盯着屏幕怀疑了半天硬件。第四步是很多人止步的地方。主站的难点不在发而在“没回应怎么办”。超时、重发次数、重发时总线状态怎么复位这些细节决定了一套代码能不能上现场。建议故意制造故障拔线、断电、把从站地址改错看你的程序是会卡死、会误判成功还是能干净地报错。能干净报错才算过关。第五步是分水岭。资源包里带的主机和从机流程图别当成摆设拿一支笔顺着箭头走一遍把每个判断框对应到代码里的哪一行标出来。走完这一遍你对协议栈的理解会从“照着改”变成“知道该改哪儿”。最后说个习惯。从那以后我每次接新板子都强制走一遍“串口自发自收”的验证短接收发发什么收什么确认波特率和引脚没问题再去碰协议。这一步只花五分钟却能挡掉后面一大半莫名其妙的排查。希望这份例程能帮你把入门这段路走顺也希望你别在同一个坑里待太久。本文还有配套的精品资源点击获取