简介:基于正点原子精英板,这份Modbus主机程序模板为嵌入式开发者提供了一个可直接使用的通信基础框架,面向工业自动化领域需要快速集成Modbus RTU主站功能的研发场景,尤其适合在Cortex-M3内核平台上进行数据采集与控制项目开发。该模板已在精英板上成功移植,并借助Modbus Slave从机模拟软件联调验证,通信稳定可靠。程序内部包含串口初始化、RTU帧结构封装、常用功能码实现以及超时重发与CRC错误处理机制,涵盖读保持寄存器、读输入寄存器、写单个线圈和写单个寄存器等操作。压缩包共107个文件,以50个C源文件与49个H头文件为主体,另含Keil工程配置、hex固件、批处理脚本与调试配置,整体仅370KB,目录清晰适合直接导入工程研读。已有3651人学习下载,可借助源码梳理Modbus主站状态机的运行逻辑、异常重试策略与协议分层设计,为后续在STM32平台上开发更复杂的工业通信功能打下基础。
1. 把一颗STM32当主站用:这个Modbus主机模板解决了什么
很多做嵌入式的开发者找“stm32 modbus”例程时,搜到的大多是从机demo——从机好做,收一帧回一帧,状态被动。但真到项目里,往往是自己的板子要主动去轮询温控表、电表、PLC这些从站设备,这时候就得自己组请求帧、自己算CRC、自己处理超时重发,从机例程几乎帮不上忙。这份模板就是为这个场景准备的:基于STM32F103ZET6精英板的Modbus RTU主站工程,开机后按顺序向从站发请求、收响应、解析数据,串口和GPIO都预留了清晰的修改点,不依赖RTOS,裸机状态机就能跑。适合两类人:一类是刚接触Modbus总线、想快速起一个主机原型;另一类是已经在做数据采集,想把轮询逻辑和业务代码解耦的开发者。
2. 工程结构与硬件资源分配:一份可复用的主站骨架
2.1 目录结构与文件职责
拿到工程先不要急着编译,先把文件归属捋清楚。这类厂商模板一般沿用的是“SYSTEM + HARDWARE + USER”三层结构,Modbus相关代码通常会独立成一个MODBUS目录,方便后续整体拷贝到别的项目里。
| 路径 | 职责 |
|---|---|
| USER/main.c | 系统初始化、主循环轮询入口 |
| SYSTEM/delay.c / sys.c | 时钟配置、延时、中断分组 |
| SYSTEM/usart.c | 串口底层收发、printf重定向 |
| HARDWARE/ | 板载外设驱动,如LED、按键、RS485方向脚 |
| MODBUS/modbus_master.c | 主机状态机、请求组帧、响应解析 |
| MODBUS/modbus_master.h | 数据结构、功能码定义、对外接口 |
| MODBUS/crc16.c / crc16.h | Modbus RTU专用CRC16计算 |
模板的文件分工很干净:硬件相关和协议相关不混在一起。这样做的直接好处是,不管你是把Modbus代码移植到另一块STM32,还是换一套编译环境,只需要动底层串口和延时两个文件。
2.2 串口、定时器和RS485方向脚的分配
精英板上的USART1默认接在PA9/PA10,模板里一般就把USART1当作Modbus主站收发口。这里有个容易被忽视的问题——如果代码里同时用USART1做printf调试,总线报文和调试信息就会混在一起。常见做法是:Modbus占用USART1,printf改到USART2,或者干脆关闭printf。
RS485方向控制是整个主机工程里最容易翻车的硬件点。模板会在某个普通GPIO上挂一个485方向引脚,通常叫DE或RE,发送前置高、发送完拉低。引脚号不固定,可以按自己板子的实际连接改,例如:
#define RS485_DE_HIGH() GPIO_SetBits(GPIOC, GPIO_Pin_7) #define RS485_DE_LOW() GPIO_ResetBits(GPIOC, GPIO_Pin_7)参数说明:GPIOC和GPIO_Pin_7只是示例,如果板子上485芯片的DE脚在PD3,就改成GPIO_D和GPIO_Pin_3。方向脚切换的时机很关键,具体会在避坑章节展开。
2.3 请求表结构:把业务和协议分开
模板里通常不会把每一帧报文写死在主循环,而是定义一张请求表,每条记录代表一次总线操作。这个设计看着简单,却是后期扩展的核心:
typedef struct { uint8_t slave_addr; // 从站地址,1~247 uint8_t func_code; // 功能码,0x03读保持寄存器,0x06写单个寄存器 uint16_t start_reg; // 起始寄存器地址 uint16_t reg_cnt; // 寄存器数量 uint32_t timeout_ms; // 本次请求的超时时间 } MB_Request_t;逻辑说明:主循环遍历这张表,把每条记录组装成Modbus RTU请求帧发出去,收到合法响应后再更新对应数据区。slave_addr填255表示广播帧,广播帧不需要等待响应;reg_cnt决定响应帧长度,比如读10个寄存器,响应里的数据区就是20字节。把协议参数和数据解析分离,后续加从站时只需要往表里加一行,主循环代码不用动。
3. 核心模块拆解:一帧Modbus RTU请求的完整生命周期
3.1 RTU帧结构:每个字节都有明确含义
Modbus RTU是二进制帧,没有类似“:”的帧头,靠时间间隔和CRC来定边界。一个读保持寄存器请求帧的具体组成如下表:
| 字段 | 长度 | 含义 | 示例值 |
|---|---|---|---|
| 从站地址 | 1字节 | 目标从站 | 0x01 |
| 功能码 | 1字节 | 读保持寄存器 | 0x03 |
| 起始地址高字节 | 1字节 | 寄存器地址高8位 | 0x00 |
| 起始地址低字节 | 1字节 | 寄存器地址低8位 | 0x00 |
| 寄存器数高字节 | 1字节 | 数量高8位 | 0x00 |
| 寄存器数低字节 | 1字节 | 数量低8位 | 0x01 |
| CRC低字节 | 1字节 | 校验低字节在前 | 0x84 |
| CRC高字节 | 1字节 | 校验高字节 | 0x0A |
这里有个常见误区:很多人习惯把CRC按大端方式先发高字节,结果从站一直不响应。RTU规定CRC是低字节在前,初学者在这里踩坑的比例非常高。
3.2 CRC16计算:多项式0xA001和初值0xFFFF
主站组帧时不需要依赖从站回应,CRC由自己算。Modbus RTU用的CRC16是查表法,标准多项式反射后是0xA001,初始值为0xFFFF。模板里一般提供两个版本,查表版速度快,适合频繁轮询;计算版代码短,适合学习。下面是计算版的完整实现:
uint16_t MB_CRC16(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }逻辑说明:每进来一个字节,先和CRC低字节异或,再右移8次,每次低位为1就异或0xA001。这个循环对整帧的“地址+功能码+数据”部分计算,不包括CRC自身。调用时把帧头指针和长度传进去——注意长度要算到CRC前一位,不要把CRC本身也丢进去算。
发送前还需要做一步:把计算出来的crc拆成低字节和高字节,依次填入报文末尾。填的时候直接按数组顺序写:buf[len] = crc & 0xFF; buf[len+1] = crc >> 8;。
3.3 主机状态机:IDLE、发送、等待、完成、错误
Modbus主机和从机最大的区别是:从机可以纯粹靠中断收包,主机却要管理“我发出去了没有、对方回没回、超时了怎么办”这一整条链路。模板里的状态机通常长这样:
typedef enum { MB_STATE_IDLE = 0, MB_STATE_TX, MB_STATE_WAIT_RESP, MB_STATE_DONE, MB_STATE_ERROR } MB_State_t; static MB_State_t mb_state = MB_STATE_IDLE; static uint32_t mb_start_tick;主循环里的驱动逻辑可以这样理解:IDLE时取出表中一条请求,组装好帧,调发送函数后进入TX;发送完成中断把状态切到WAIT_RESP,同时记录当前tick;在等待状态里轮询当前tick减去start_tick,超过timeout_ms就进ERROR;接收线程解析到合法响应帧,状态置DONE。ERROR和DONE都可以回到IDLE,让主循环继续读下一个从站。
状态机的核心意义不在于“状态”本身,而在于每个状态都对应明确的退出条件。很多二次开发者在模板上加功能时,喜欢直接在主循环里塞延时等待响应,这会把整个轮询节奏卡死。正确的改法是:永远不让主循环阻塞,超时用tick差值判断,这也是模板Design的默认约定。
3.4 串口接收:IDLE空闲中断和3.5字符时间间隔
RTU帧没有包头,接收端如何知道一帧结束了?标准答案是:帧内字节间隔要小于1.5个字符时间,帧间间隔要大于3.5个字符时间。主机等待响应时,收到第一个字节后持续接收,直到总线空闲超过3.5字符时间,就认定帧结束。
STM32F103的USART带IDLE空闲中断,正好可以用来检测一帧接收完成。模板里一般的处理方式是:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { rx_buf[rx_len++] = USART_ReceiveData(USART1); USART_ClearITPendingBit(USART1, USART_IT_RXNE); } if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART1); // 清除IDLE标志 USART_ClearITPendingBit(USART1, USART_IT_IDLE); mb_rx_frame_done = 1; // 交给主循环解析 } }逻辑说明:RXNE中断把每个字节搬进接收缓冲区,IDLE中断则表示一帧接收完成,置位标志后由主循环去解析。参数上要注意rx_buf长度和rx_len类型——如果计划接收最大256字节的响应,缓冲区建议给到300字节,rx_len不要用uint8_t,防止溢出回绕;经典错误是缓冲区只开128字节,遇到读多寄存器响应直接越界。
4. 跑通模板:编译、烧录和模拟从站联调
4.1 打开工程并检查三处关键配置
模板拿到手后,先用MDK打开工程文件,不要上来就点编译。依次检查三处:第一,芯片型号是不是STM32F103ZET6,如果工程是从别的板子拷贝来的,型号不匹配会导致后续烧录失败;第二,C/C++头文件路径里有没有包含MODBUS目录,一般模板已经配好,挪动过路径需要重新加;第三,Debug选项里的Flash Download算法是否选中了512KB容量的F1系列算法。
编译大概率能一次通过,如果报USART相关函数找不到,优先检查usart.c有没有被添加到工程里,而不是去改代码——这类问题八成是源文件没包含。
4.2 硬件接线与RS485方向确认
如果手头有RS485转USB模块,接线就很简单:开发板485的A接转换器A,B接B。用USB转TTL模块也可以调试,但TTL电平无法长距离传输,且需要确认板载485芯片是否已做隔离。更稳妥的做法是先用杜邦线把PA9、PA10直接连到USB转TTL的RX、TX,绕过485芯片验证基本收发逻辑。
这里有一个很容易犯的硬件错误:USB转TTL模块的TX接开发板的RX,RX接开发板的TX,这个“交叉连接”很多人接反。接反的典型现象是调试助手能看到请求帧,但从站永远不回,因为请求根本没到对方串口。
4.3 用PC端从站模拟器建一个假从站
在PC上运行Modbus从站模拟器,设置从站地址为1、串口号对应USB模块、波特率选9600或115200,数据格式选8N1。然后在模拟器里配置几个保持寄存器,比如地址0x0000放置一个递增值,这样主站读回来后能看到数据变化,便于确认解析链路是通的。
运行模板后,先从模拟器日志里观察有没有收到请求帧。这一步能快速定位问题属于“没发出来”还是“发出来但解析失败”。如果模拟器收到了请求并回复,但模板这边一直显示超时,问题几乎都出在方向脚切换或响应帧解析上。
4.4 观察点:用串口助手同时看两个方向
调试Modbus主机最有效的手段是串口助手配合USB转RS485模块,监听总线上的原始报文。正常一次读保持寄存器操作的全过程是这样的:
发送: 01 03 00 00 00 01 84 0A 接收: 01 03 02 00 32 B7 6D逻辑说明:这条响应帧里,01是地址、03是功能码、02表示后面有2个字节数据、00 32是寄存器里读到的值0x0032、B7 6D是CRC。如果监听总线上能看到这两段报文,但主站代码拿到的数据不对,那问题就缩小到代码内的缓冲区处理;反之,如果压根看不到应答,就需要回溯到从站模拟器配置和RS485方向控制上。
5. 移植后的排查笔记:Modbus主机最常见的几类翻车现场
5.1 现象:串口助手能看到主机发出完整请求帧,但模拟从站报CRC错误
这是初学者遇到最多的一个问题,帧内容、波特率看起来都对,但模拟器就是拒绝处理。原因通常不是CRC算法写错了,而是发送时字节顺序不对——CRC低字节和高字节位置颠倒。Modbus RTU规定低字节先发,正确的发送顺序是“数据区结束后紧跟crc & 0xFF”,很多人习惯按大端方式先发高字节。
解决方法:在发送前把帧打印出来,用在线CRC计算工具核对请求帧末尾两个字节;确认无误后再对比自己代码里拼接报文的顺序。这里有个通用经验:凡是涉及CRC校验的协议,先用小样本人工推一遍,不要指望一次写对。
5.2 现象:从站模拟器确实回了数据,但模板一直进超时分支
从站应答报文总线上能看到,主站却始终等不到。这类问题的根源在于:接收中断只在等待状态开启,或者请求发出后接收缓冲区没有清空。比如上一帧残留数据把rx_len顶到了一个错误值,新响应来了以后帧长度判断不过,状态机永远回不到DONE。
解决方法是把“进入等待前清空接收缓冲”作为一个强制流程,收帧时对长度做上限保护,超过最大帧长就立刻重置。另外检查中断优先级:串口中断优先级不能低于SysTick,否则在tick更新被阻塞时,超时计时会漂移,极端情况下会把正常响应误判成超时。
5.3 现象:首字节偶尔丢失,导致整帧解析失败
RS485链路里典型表现是:从站已经响应,但主站收到的第一字节是0x00或直接没有。原因几乎都出在RS485方向脚的切换时序上——发送完最后一字节后立即拉低DE脚,但此时移位寄存器可能还没把最后一个停止位发完,总线被强行释放,接收端把这一段的电气噪音当成第一个字节。
解决方法:在“发送完成”中断里,不要立刻拉低DE脚,而是先等待“发送移位寄存器为空”的标志,或者干脆加一个几十微秒的软件延时再切方向。这块属于硬件时序问题,逻辑上代码看着都对,但示波器一量就能看到方向脚切换早了。
5.4 现象:9600波特率一切正常,调到115200后频繁收到拆包
帧间识别依赖3.5字符时间间隔,波特率越高,这个时间窗口越短。115200波特率下3.5个字符约0.4毫秒,如果主循环里处理其他任务超过这个时间,本来属于同一帧的字节会被拆成两段,IDLE中断提前触发。
解决思路有两个:一是把收帧边界判断从纯IDLE中断改成“定时器窗口”方式,即每收到一个字节就清零定时器,定时器溢出才确认帧结束,这样比IDLE更抗主循环延迟;二是如果硬件方案允许,优先选用9600波特率做Modbus总线,稳定第一。
6. 二次开发思路:把它升级成一个多从站轮询调度器
6.1 轮询表驱动:从“点对点”到“一主多从”
模板默认是单从站轮询,改成多从站不用大动干戈,只需要把请求表扩展成数组,并在主循环里维护一个当前索引。常见做法是这样:
const MB_Request_t poll_table[] = { { 0x01, 0x03, 0x0000, 0x0002, 100 }, { 0x02, 0x03, 0x0000, 0x0001, 100 }, { 0x03, 0x03, 0x0010, 0x0008, 150 }, }; #define POLL_TABLE_SIZE (sizeof(poll_table) / sizeof(poll_table[0]))主循环只处理当前索引对应的请求,状态机一回到IDLE就把索引加一,到表尾再回绕。这样从站数量变化时,业务代码一行都不用改。
6.2 超时重发与故障标志
工业现场总线不能只看“跑通了”,还要看“异常了怎么恢复”。建议在模板状态机的ERROR分支里维护一个错误计数,连续失败3次就跳过该从站,并置位对应的故障标志位。这个设计在调试时特别有用——凭标志位就能快速看出到底是哪个从站掉线,而不是靠串口日志一条条翻。
重发逻辑实现时注意:重发次数不要无限累加,否则故障从站会拖慢整个轮询周期;优先保证在线从站的数据刷新率。
6.3 把解析结果接入业务层
响应帧解析出来的寄存器值是裸数据,真实项目里得转换成温度、电压、开关状态。模板通常会预留一个“数据更新回调”点:解析完成并把结果写入全局结构体后,调用一个可以被业务侧修改的入口函数,业务层在这里做工程单位换算和数据归档。这种“协议层只负责搬运字节,业务层只负责解释语义”的划分,是目前比较顺手的扩展方式。
说实话,我自己第一次改这种模板时也犯过一个低级错误:直接在解析函数里写了业务逻辑,结果协议层和业务层耦合得很深,后面加一个从站要改三处代码。现在我的习惯是,协议和数据区的接口永远保持“buffer + length”这种最原始的形态,单位换算一律放业务层。从那以后每次改动代码,我都会强制走一遍“接模拟从站跑轮询、看一眼原始报文、再改业务层”的流程,等确认协议没被破坏才收工。希望这个模板的拆解能帮你少踩点这些已经被人踩平的坑。
本文还有配套的精品资源,点击获取