单片机基础知识(协议篇)--Modbus RTU
Modbus RTU 功能码完整详解 + 报文实例
Modbus RTU 采用二进制串行协议,主从问答机制,所有报文无起始/结束标记,依靠3.5字符静默间隔区分帧。
通用帧基础结构(所有功能码共用头部)
从站地址(1B) + 功能码(1B) + 数据域 + CRC校验(2B,低字节在前)
前置概念区分(重中之重,避免混淆)
- 线圈 Coil:1位,0/1,读写;俗称开关量、DO/DI
- 离散输入 Discrete Input:1位,只读;DI
- 保持寄存器 Holding Register:16位,读写;最常用,模拟量输出、参数
- 输入寄存器 Input Register:16位,只读;AI采集值
地址惯例(行业通用)
- 00001~09999:线圈(读写)
- 10001~19999:离散输入(只读)
- 30001~39999:输入寄存器(只读)
- 40001~49999:保持寄存器(读写)
协议报文内偏移地址从0开始!40001寄存器 = 报文地址
0000
标准常用功能码列表
01H 读线圈 Read Coils
作用:读取多个线圈(1bit,0/1),读写型开关量
请求报文格式
| 从站 | 功能码 | 起始地址(2B) | 读取数量(2B) | CRC |
|---|---|---|---|---|
| 1B | 01 | AddrHi AddrLo | NumHi NumLo | CRC Lo,CRC Hi |
响应报文格式
| 从站 | 功能码 | 字节长度N | N字节线圈数据 | CRC |
|---|
线圈按bit打包,低位对应低地址线圈;不足8位高位补0
实例
需求:1号从站,读取线圈0~6(共7个线圈)
请求:01 01 00 00 00 07 7C 08
- 01:从站地址1
- 01:功能码01
- 00 00:起始地址0(线圈00001)
- 00 07:读取7个线圈
- 7C 08:CRC
假设返回线圈状态:线圈0=1,线圈1=0,线圈2=1,线圈3=1,线圈4=0,线圈5=0,线圈6=1
bit排布:bit0(线圈0) ~ bit6(线圈6) →0101101→ 0x2D
响应报文:01 01 01 2D 54 0F
- 01:从站
- 01:功能码
- 01:后续数据长度1字节
- 2D:线圈bit数据
- 54 0F:CRC
02H 读离散输入 Read Discrete Inputs
作用:读取只读开关量(DI),1bit,不可写
报文结构与01H完全一致,仅功能码不同。
实例:1号从站读取离散输入地址0~3(4点DI)
请求:01 02 00 00 00 04 79 F9
03H 读保持寄存器 Read Holding Registers ⭐最常用
作用:读取保持寄存器(16位,读写),参数、模拟输出
请求格式
|从站|03|起始地址(2B)|寄存器数量(2B)|CRC|
响应格式
|从站|03|字节长度|寄存器数据(2字节/个)|CRC|
实例
需求:1号从站,读取40001、40002寄存器(偏移地址0,数量2)
请求报文:01 03 00 00 00 02 94 0B
- 01 从站1
- 03 读保持寄存器
- 00 00 起始偏移0(40001)
- 00 02 读取2个寄存器
响应:寄存器0=0x0A01,寄存器1=0x00C8
响应报文:01 03 04 0A 01 00 C8 FB 54
- 04:4字节数据(2个寄存器 ×2)
- 0A 01:寄存器0
- 00 C8:寄存器1
04H 读输入寄存器 Read Input Registers
作用:读取只读16位输入寄存器(AI模拟采集)
报文结构与03H完全相同,仅功能码04。
实例:1号从站读取30001(偏移0)1路AI
请求:01 04 00 00 00 01 31 84
05H 写单个线圈 Write Single Coil
作用:单点开关输出,置线圈ON/OFF
请求:
|从站|05|线圈地址(2B)|强制值(2B)|CRC|
ON=FF 00;OFF=00 00;其他值非法
实例
1号从站,线圈0(00001)置ON
请求:01 05 00 00 FF 00 8C 3A
正常情况下从站原样回复这条报文作为应答。
线圈0置OFF:01 05 00 00 00 00 CD CA
06H 写单个保持寄存器 Write Single Register ⭐高频使用
作用:写入1个16位保持寄存器
请求格式
|从站|06|寄存器地址(2B)|写入数值(2B)|CRC|
从站正常应答:原样返回请求帧
实例
1号从站,40001(偏移0)写入数值0x1234
请求报文:01 06 00 00 12 34 A3 91
从站成功则直接返回:01 06 00 00 12 34 A3 91
0FH (15) 写多个线圈 Write Multiple Coils
作用:批量写多个开关量线圈
请求:
|从站|0F|起始地址(2B)|线圈数量(2B)|数据字节长度N|N字节bit数据|CRC|
实例:1号从站,线圈0~2共3个线圈:线圈0=1,线圈1=0,线圈2=1
请求:01 0F 00 00 00 03 01 05 26 94
- 0003:3个线圈
- 01:数据长度1字节
- 05 → bit0=1 bit1=0 bit2=1
成功应答只返回:01 0F 00 00 00 03 54 05
10H (16) 写多个保持寄存器 Write Multiple Registers ⭐高频
作用:批量写入一组保持寄存器
请求格式
|从站|10|起始地址(2B)|寄存器数量(2B)|数据字节长度|寄存器数据|CRC|
实例
1号从站,偏移地址0、1两个寄存器,分别写入0x0001、0x0002
请求:01 10 00 00 00 02 04 00 01 00 02 74 17
解析:
- 0002:2个寄存器
- 04:4字节数据
- 00 01 / 00 02 两个寄存器值
成功应答:01 10 00 00 00 02 10 08
扩展功能码(工业设备偶尔用到)
07H 读异常状态(极少用)
08H 诊断功能码(线路测试)
子功能00:返回查询报文;用于通讯链路测试
示例:01 08 00 00 00 00 C0 7B
11H (17) 报告从站ID
读取从站设备型号、版本信息,很多国产设备不支持。
17H (23) 读写多个寄存器(组合功能)
合并指令:先读一批寄存器,同时写入另一批寄存器
一次问答完成读+写,减少总线轮询次数。
异常响应格式(所有功能码通用)
通讯出错、地址越界、非法功能码时,从站返回异常帧:从站地址 | 功能码+0x80 | 异常码 | CRC
常见异常码:
- 01:非法功能码
- 02:非法数据地址(寄存器/线圈不存在)
- 03:非法数据值
- 04:从站设备故障
- 06:从站忙,请重试
示例:访问不存在寄存器,03功能码异常响应01 83 02 C0 F1
01=从站;83=03+0x80;02=非法地址
快速选型建议(开发直接参考)
- 单点模拟量写入 →06H
- 多点寄存器批量写入 →10H
- 读取参数寄存器 →03H
- AI采集只读输入寄存器 →04H
- 单个继电器输出 →05H
- 多路继电器批量控制 →0FH
- DI只读开关采集 →02H
- 可读写开关采集 →01H
二、完整报文
- 一份可直接复制到串口调试工具的全部功能码测试报文清单
- Modbus RTU CRC16简易计算代码(C语言)
- 区分「寄存器地址偏移」和「40001标准地址」的常见踩坑汇总
Modbus RTU 配套资料合集
一、全套可直接复制串口调试工具测试报文(RTU,带CRC)
使用说明:串口工具选择HEX发送,间隔 ≥3.5字符(9600波特约4ms)
从站地址统一使用01(1号从站)
1. 01H 读线圈 Read Coils
需求:读取线圈06(7个线圈,0000100007)
请求:01 01 00 00 00 07 7C 08
2. 02H 读离散输入 Read Discrete Inputs
需求:读取DI地址03(4点,1000110004)
请求:01 02 00 00 00 04 79 F9
3. 03H 读保持寄存器 Read Holding Registers
需求:读取40001、40002(偏移0,2个寄存器)
请求:01 03 00 00 00 02 94 0B
4. 04H 读输入寄存器 Read Input Registers
需求:读取30001(偏移0,1路AI)
请求:01 04 00 00 00 01 31 84
5. 05H 写单个线圈 Write Single Coil
线圈0(00001)置ON:01 05 00 00 FF 00 8C 3A
线圈0(00001)置OFF:01 05 00 00 00 00 CD CA
6. 06H 写单个保持寄存器 Write Single Register
40001写入 0x1234:01 06 00 00 12 34 A3 91
7. 0FH (15) 写多个线圈 Write Multiple Coils
线圈0/1/2:1、0、1
请求:01 0F 00 00 00 03 01 05 26 94
8. 10H (16) 写多个保持寄存器 Write Multiple Registers
偏移0写入0x0001,偏移1写入0x0002
请求:01 10 00 00 00 02 04 00 01 00 02 74 17
9. 08H 诊断测试(回显测试)
链路测试,从站原样返回数据
请求:01 08 00 00 00 00 C0 7B
10. 异常报文参考(访问不存在寄存器)
主站发送:01 03 01 00 00 01 84 0A
预期从站异常应答:01 83 02 C0 F1
二、Modbus CRC16 标准C语言代码(嵌入式直接使用)
多项式:0xA001;输出CRC低字节在前(RTU标准)
#include<stdint.h>// Modbus CRC16 计算uint16_tModbus_CRC16(uint8_t*data,uint16_tlen){uint16_tcrc=0xFFFF;uint16_ti,j;for(i=0;i<len;i++){crc^=data[i];for(j=0;j<8;j++){if(crc&0x0001){crc>>=1;crc^=0xA001;}else{crc>>=1;}}}returncrc;}/* 使用示例 uint8_t buf[] = {0x01,0x03,0x00,0x00,0x00,0x02}; uint16_t crc = Modbus_CRC16(buf, sizeof(buf)); // RTU帧拼接:原数组 + crc低字节 + crc高字节 */三、高频踩坑汇总(寄存器地址最大误区)
1. 地址两套体系区分【重中之重】
1)PLC/组态软件显示地址(用户地址)
- 00001 线圈
- 10001 离散输入
- 30001 输入寄存器
- 40001 保持寄存器
2)Modbus报文内偏移地址(协议原始地址,从0开始)
- 40001 → 报文地址 =0x0000
- 40002 → 报文地址 =0x0001
❌ 新手最常错:直接把40001填入报文,导致访问40002寄存器!
2. 字节序问题
Modbus寄存器内部:高字节在前
寄存器0x1234 在报文中:12 34
注意:浮点数(32bit)需要额外区分AB/CD、BA/DC等大小端,协议本身不规定。
3. CRC顺序
RTU CRC:低字节先发,高字节后发
例如CRC=0x940B → 报文追加0B 94
4. 线圈bit排布规则
01H/0FH多个线圈:字节bit0 = 起始地址线圈
示例:1字节数据0x05→ bit0=1,bit1=0,bit2=1
5. 功能码异常响应规则
正常响应 = 原功能码
出错响应 = 原功能码 | 0x80(最高位置1)
异常码说明:
| 异常码 | 含义 |
|---|---|
| 01 | 非法功能码 |
| 02 | 非法数据地址(寄存器不存在) |
| 03 | 非法数据值 |
| 04 | 从站设备故障 |
| 06 | 从站忙,稍后重试 |
6. 读写数量上限
- 01H/02H:最多2008个点位
- 03H/04H:最多125个寄存器
- 0FH:最多2008线圈
- 10H:最多123寄存器
超出上限直接返回异常码02
四、补充实用小工具建议
- 串口调试推荐:
Modbus Poll(主站仿真)、Modbus Slave(从站仿真),直接粘贴上述报文测试; - 在线CRC计算器:搜索Modbus CRC16 A001,快速校验报文。
三、STM32裸机Modbus RTU从站极简框架代码
补充交付:STM32 Modbus RTU 从站极简裸机框架 + Excel速查表文本版
一、Excel可直接粘贴的Modbus RTU功能码速查表
复制下面整块内容,粘贴进ExcelA1单元格,自动分列(制表符分隔)
功能码 名称 英文名称 访问对象 读写属性 单次最大数量 典型用途 01H 读线圈 Read Coils 线圈(0xxxx) 读 2008点 多路开关输出状态读取 02H 读离散输入 Read Discrete Inputs 离散输入(1xxxx) 只读 2008点 DI无源开关采集 03H 读保持寄存器 Read Holding Registers 保持寄存器(4xxxx) 读 125个 读取参数、设定值 04H 读输入寄存器 Read Input Registers 输入寄存器(3xxxx) 只读 125个 AI模拟量采集 05H 写单个线圈 Write Single Coil 线圈(0xxxx) 写 1点 继电器单点开关 06H 写单个保持寄存器 Write Single Register 保持寄存器(4xxxx) 写 1个 单参数下发 08H 诊断 Diagnostics 链路诊断 - - 通讯测试、故障排查 0FH(15) 写多个线圈 Write Multiple Coils 线圈(0xxxx) 写 2008点 批量继电器控制 10H(16) 写多个保持寄存器 Write Multiple Registers 保持寄存器(4xxxx) 写 123个 批量参数下发 11H(17) 报告从站ID Report Slave ID 设备信息 只读 - 读取设备型号固件 17H(23) 读写寄存器 Read/Write Multiple Registers 保持寄存器(4xxxx) 读写 读125/写121 一问一答同时读写二、STM32裸机 Modbus-RTU 从站极简框架(标准库,可直接移植)
适配USART1,9600 8N1;依靠串口空闲中断识别RTU帧;仅实现最常用:03/06/10功能码,可自行扩展01/02/05/0F
硬件:RS485 DE/RE引脚 PA0(高电平发送,低电平接收)
1. 头文件 modbus_rtu.h
#ifndef__MODBUS_RTU_H#define__MODBUS_RTU_H#include"stm32f10x.h"#include<stdint.h>#defineMODBUS_SLAVE_ADDR0x01//从站地址#defineMODBUS_REG_NUM100//保持寄存器总数externuint16_tHoldingReg[MODBUS_REG_NUM];//保持寄存器数组voidModbus_USART_Init(uint32_tbaud);voidModbus_ProcessFrame(uint8_t*buf,uint16_tlen);uint16_tModbus_CRC16(uint8_t*data,uint16_tlen);#endif2. 源文件 modbus_rtu.c
#include"modbus_rtu.h"uint8_ttx_buf[256];uint16_tHoldingReg[MODBUS_REG_NUM]={0};//CRC16 标准函数uint16_tModbus_CRC16(uint8_t*data,uint16_tlen){uint16_tcrc=0xFFFF;uint16_ti,j;for(i=0;i<len;i++){crc^=data[i];for(j=0;j<8;j++){if(crc&0x0001){crc>>=1;crc^=0xA001;}elsecrc>>=1;}}returncrc;}//485发送使能staticvoidRS485_TX_EN(uint8_ten){if(en)GPIO_SetBits(GPIOA,GPIO_Pin_0);elseGPIO_ResetBits(GPIOA,GPIO_Pin_0);}//串口发送数据staticvoidUSART_SendDataBuf(uint8_t*buf,uint16_tlen){RS485_TX_EN(1);uint16_ti;for(i=0;i<len;i++){USART_SendData(USART1,buf[i]);while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET);}while(USART_GetFlagStatus(USART1,USART_FLAG_TC)==RESET);RS485_TX_EN(0);}//发送异常响应staticvoidSendErrResp(uint8_tslave,uint8_tfunc,uint8_terr_code){uint8_tframe[5];uint16_tcrc;frame[0]=slave;frame[1]=func|0x80;frame[2]=err_code;crc=Modbus_CRC16(frame,3);frame[3]=crc&0xFF;frame[4]=(crc>>8)&0xFF;USART_SendDataBuf(frame,5);}//帧解析处理核心voidModbus_ProcessFrame(uint8_t*buf,uint16_tlen){uint8_tslave_addr=buf[0];uint8_tfunc_code=buf[1];uint16_tstart_addr,reg_num,crc_rx,crc_calc;uint16_ti,tx_len=0;//校验CRCcrc_rx=(buf[len-1]<<8)|buf[len-2];crc_calc=Modbus_CRC16(buf,len-2);if(crc_rx!=crc_calc)return;//非本机从站地址,丢弃if(slave_addr!=MODBUS_SLAVE_ADDR)return;start_addr=(buf[2]<<8)|buf[3];switch(func_code){//03 读保持寄存器case0x03:reg_num=(buf[4]<<8)|buf[5];if((start_addr+reg_num)>MODBUS_REG_NUM||reg_num==0||reg_num>125){SendErrResp(slave_addr,func_code,0x02);return;}tx_buf[0]=slave_addr;tx_buf[1]=0x03;tx_buf[2]=reg_num*2;tx_len=3;for(i=0;i<reg_num;i++){tx_buf[tx_len++]=(HoldingReg[start_addr+i]>>8)&0xFF;tx_buf[tx_len++]=HoldingReg[start_addr+i]&0xFF;}break;//06 写单个寄存器case0x06:if(start_addr>=MODBUS_REG_NUM){SendErrResp(slave_addr,func_code,0x02);return;}HoldingReg[start_addr]=(buf[4]<<8)|buf[5];//应答直接返回原请求(去除接收CRC)for(i=0;i<6;i++)tx_buf[i]=buf[i];tx_len=6;break;//10 写多个寄存器case0x10:reg_num=(buf[4]<<8)|buf[5];if((start_addr+reg_num)>MODBUS_REG_NUM||reg_num==0||reg_num>123){SendErrResp(slave_addr,func_code,0x02);return;}uint8_tdata_len=buf[6];for(i=0;i<reg_num;i++){HoldingReg[start_addr+i]=(buf[7+i*2]<<8)|buf[8+i*2];}tx_buf[0]=slave_addr;tx_buf[1]=0x10;tx_buf[2]=buf[2];tx_buf[3]=buf[3];tx_buf[4]=buf[4];tx_buf[5]=buf[5];tx_len=6;break;default:SendErrResp(slave_addr,func_code,0x01);return;}//追加CRC校验uint16_tcrc=Modbus_CRC16(tx_buf,tx_len);tx_buf[tx_len++]=crc&0xFF;tx_buf[tx_len++]=(crc>>8)&0xFF;USART_SendDataBuf(tx_buf,tx_len);}//串口初始化 + 空闲中断voidModbus_USART_Init(uint32_tbaud){GPIO_InitTypeDef gpio_cfg;USART_InitTypeDef usart_cfg;NVIC_InitTypeDef nvic_cfg;RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA|RCC_APB2Periph_USART1,ENABLE);//PA0 485收发控制gpio_cfg.GPIO_Pin=GPIO_Pin_0;gpio_cfg.GPIO_Mode=GPIO_Mode_Out_PP;gpio_cfg.GPIO_Speed=GPIO_Speed_50MHz;GPIO_Init(GPIOA,&gpio_cfg);RS485_TX_EN(0);//PA9 TX PA10 RXgpio_cfg.GPIO_Pin=GPIO_Pin_9;gpio_cfg.GPIO_Mode=GPIO_Mode_AF_PP;GPIO_Init(GPIOA,&gpio_cfg);gpio_cfg.GPIO_Pin=GPIO_Pin_10;gpio_cfg.GPIO_Mode=GPIO_Mode_IN_FLOATING;GPIO_Init(GPIOA,&gpio_cfg);usart_cfg.USART_BaudRate=baud;usart_cfg.USART_WordLength=USART_WordLength_8b;usart_cfg.USART_StopBits=USART_StopBits_1;usart_cfg.USART_Parity=USART_Parity_No;usart_cfg.USART_HardwareFlowControl=USART_HardwareFlowControl_None;usart_cfg.USART_Mode=USART_Mode_Rx|USART_Mode_Tx;USART_Init(USART1,&usart_cfg);//使能空闲中断、接收中断USART_ITConfig(USART1,USART_IT_RXNE,ENABLE);USART_ITConfig(USART1,USART_IT_IDLE,ENABLE);USART_Cmd(USART1,ENABLE);nvic_cfg.NVIC_IRQChannel=USART1_IRQn;nvic_cfg.NVIC_IRQChannelPreemptionPriority=1;nvic_cfg.NVIC_IRQChannelSubPriority=1;nvic_cfg.NVIC_IRQChannelCmd=ENABLE;NVIC_Init(&nvic_cfg);}3. 中断服务函数 usart1.c
#include"modbus_rtu.h"#defineREC_BUF_MAX128uint8_trec_buf[REC_BUF_MAX];uint16_trec_cnt=0;voidUSART1_IRQHandler(void){uint8_tch;if(USART_GetITStatus(USART1,USART_IT_RXNE)!=RESET){ch=USART_ReceiveData(USART1);if(rec_cnt<REC_BUF_MAX)rec_buf[rec_cnt++]=ch;}//空闲中断:一帧接收完成,调用解析if(USART_GetITStatus(USART1,USART_IT_IDLE)!=RESET){USART_ReceiveData(USART1);//清除IDLE标志if(rec_cnt>4){Modbus_ProcessFrame(rec_buf,rec_cnt);}rec_cnt=0;}}4. main.c调用示例
externuint16_tHoldingReg[];intmain(void){SystemInit();Modbus_USART_Init(9600);HoldingReg[0]=0x0001;//40001初始值HoldingReg[1]=0x0002;//40002初始值while(1){//后台任务}}三、扩展说明 & 移植指引
- 如何增加01/02/05/0F功能码
在Modbus_ProcessFrame的switch分支追加case,参照寄存器处理逻辑,增加线圈数组uint8_t CoilBuf[]; - RTU 3.5字符间隔说明
本框架使用串口空闲中断自动实现帧分隔,不需要定时器判断静默间隔,是嵌入式最常用方案; - 常见移植修改点
- 修改
MODBUS_SLAVE_ADDR修改从站地址 - 修改PA0为你硬件对应的485 DE/RE引脚
- 更换USART只需修改串口寄存器与中断名
- 限制:最大接收长度REC_BUF_MAX=128,足够绝大多数工业场景
