博客定位:扩展 Modbus 离散 IO 功能,完成 RTU 全部常用功能码;对比 RTU 与 TCP 差异,建立以太网 Modbus 认知,可直接发布 CSDN。
前言
前面章节我们完成保持寄存器(03/06/16)主机与从机、异常码、故障排查。在工业现场除了寄存器存储数值,还有大量开关量:继电器输出、光耦输入、阀门开关,Modbus 使用线圈 (Coil) 与**离散输入 (Discrete Input)**处理开关信号。
很多教程只讲保持寄存器,跳过线圈功能码,实际 IO 板、PLC、继电器模块大量使用 05、0F 功能码。本章手写实现05 强制单线圈、0F 强制多线圈;同时简单介绍 Modbus‑TCP 协议,对比 RTU 与 TCP 的异同。
读完本章你将掌握:
- 线圈、离散输入、保持寄存器、输入寄存器四类对象区别
- 05 功能码写单个线圈原理 + STM32 裸机代码
- 0F 功能码批量写多个线圈原理 + STM32 裸机代码
- 线圈位操作、字节打包、掩码处理工程实现
- Modbus‑TCP 报文结构、和 RTU 的核心差异
<hr>
1 Modbus 四类存储对象完整区分
Modbus 协议定义 4 种独立地址空间,地址互相独立,不能混用,很多调试故障来源于混淆四类空间。
| 对象 | 功能码 | 读写属性 | 用途 | 数据宽度 |
|---|---|---|---|---|
| 线圈 Coils | 01 读线圈 / 05 写单线圈 / 0F 写多线圈 | 可读可写 | 继电器输出、阀门、开关输出 | 1bit(开关 0/1) |
| 离散输入 Discrete Inputs | 02 读离散输入 | 只读 | 外部光耦输入、按键状态、限位信号 | 1bit(开关 0/1) |
| 保持寄存器 Holding Registers | 03 读 /06 写单 /10 写多 | 可读可写 | 参数、转速、温度、电压数值 | 16bit |
| 输入寄存器 Input Registers | 04 读输入寄存器 | 只读 | 传感器实时采集值 | 16bit |
重点:线圈是按 bit 操作。8 个线圈打包成 1 个字节。低位代表低地址线圈。 例如 1 个字节:bit0 → 线圈地址 0;bit1 →线圈地址 1 ... bit7 →线圈地址 7。
同样注意手册大多1 起始 ,代码数组下标使用0 起始,地址需要减 1。
<hr>
2 05 功能码:强制单个线圈(写单路开关输出)
2.1 协议帧格式
主机请求帧:
表格
| 从机地址 | 功能码 0x05 | 线圈地址高 | 线圈地址低 | 状态高字节 | 状态低字节 | CRC 低 | CRC 高 |
|---|
线圈状态约定:
- 置 ON:
0xFF 0x00 - 置 OFF:
0x00 0x00
注意:不是写入 1 和 0!协议规定固定使用
0xFF00代表打开,0x0000代表关闭。
从机正常应答:原样返回主机请求帧;参数非法返回异常帧。
2.2 从机线圈数据结构定义
#define COIL_MAX_NUM 16 //最大线圈数量,16路开关输出
uint8_t coil_buf[(COIL_MAX_NUM +7)/8]; //位存储,每字节存8路线圈
// 设置指定线圈
void Modbus_Coil_Set(uint16_t coil_addr, uint8_t val)
{
uint16_t byte_idx = coil_addr / 8;
uint8_t bit_idx = coil_addr % 8;
if(coil_addr >= COIL_MAX_NUM) return;
if(val != 0)
{
coil_buf[byte_idx] |= (1<<bit_idx);
}
else
{
coil_buf[byte_idx] &= ~(1<<bit_idx);
}
}
// 读取指定线圈状态
uint8_t Modbus_Coil_Get(uint16_t coil_addr)
{
uint16_t byte_idx = coil_addr /8;
uint8_t bit_idx = coil_addr %8;
if(coil_addr >= COIL_MAX_NUM) return 0;
return (coil_buf[byte_idx] >> bit_idx) & 0x01;
}
2.3 从机 05 功能码处理函数
uint8_t Slave_Write_Single_Coil(void)
{
uint16_t coil_addr;
uint16_t coil_cmd;
uint8_t tx_len=0;
coil_addr = (rx_buf[2] <<8) | rx_buf[3];
coil_cmd = (rx_buf[4] <<8) | rx_buf[5];
//地址越界判断
if(coil_addr >= COIL_MAX_NUM)
{
Slave_Return_Exception(0x02);
return 5;
}
//校验合法命令,只允许0xFF00或者0x0000
if(coil_cmd != 0xFF00 && coil_cmd !=0x0000)
{
Slave_Return_Exception(0x03);
return 5;
}
//设置线圈状态
if(coil_cmd == 0xFF00)
{
Modbus_Coil_Set(coil_addr,1);
//业务:可以在这里控制IO输出继电器
//Relay_ON(coil_addr);
}
else
{
Modbus_Coil_Set(coil_addr,0);
//Relay_OFF(coil_addr);
}
//正常应答,原样返回请求帧
tx_buf[tx_len++] = SLAVE_ADDR;
tx_buf[tx_len++] = 0x05;
tx_buf[tx_len++] = rx_buf[2];
tx_buf[tx_len++] = rx_buf[3];
tx_buf[tx_len++] = rx_buf[4];
tx_buf[tx_len++] = rx_buf[5];
Modbus_Append_CRC(tx_buf,&tx_len);
return tx_len;
}
2.4 主机 05 发送函数
//写单个线圈:slave_addr从机地址,coil_addr线圈地址,val 1打开 0关闭
void Modbus_Master_Write_Single_Coil(uint8_t slave_addr,uint16_t coil_addr,uint8_t val)
{
tx_len =0;
modbus_tx_buf[tx_len++] = slave_addr;
modbus_tx_buf[tx_len++] =0x05;
modbus_tx_buf[tx_len++] = (coil_addr>>8)&0xFF;
modbus_tx_buf[tx_len++] = coil_addr &0xFF;
if(val)
{
modbus_tx_buf[tx_len++] =0xFF;
modbus_tx_buf[tx_len++] =0x00;
}
else
{
modbus_tx_buf[tx_len++] =0x00;
modbus_tx_buf[tx_len++] =0x00;
}
Modbus_Append_CRC(modbus_tx_buf,&tx_len);
rx_len =0;
modbus_tick = HAL_GetTick();
USART_Send_Data(modbus_tx_buf,tx_len);
}
3 0F 功能码:强制多线圈(批量写多路开关)
3.1 协议说明 0F 实现一次性批量设置多个线圈状态。
主机请求帧: 从机地址 0x0F 起始地址高 起始地址低 线圈数量高 线圈数量低 字节数 N个字节数据 CRC低 CRC高
每一个 bit 代表一路线圈,字节最低 bit 对应线圈起始地址。
从机应答:返回:从机地址 +0x0F + 起始地址 2 字节 + 线圈数量 2 字节 +CRC。不返回线圈数据。
3.2 从机 0F 批量写线圈实现
uint8_t Slave_Write_Multi_Coil(void)
{
uint16_t start_addr = (rx_buf[2]<<8)|rx_buf[3];
uint16_t coil_num = (rx_buf[4]<<8)|rx_buf[5];
uint8_t byte_cnt = rx_buf[6];
uint16_t i;
uint8_t bit_pos;
uint8_t tx_len=0;
//地址越界检查
if(start_addr + coil_num > COIL_MAX_NUM)
{
Slave_Return_Exception(0x02);
return 5;
}
//字节数校验
uint8_t expect_byte = (coil_num +7)/8;
if(byte_cnt != expect_byte)
{
Slave_Return_Exception(0x03);
return 5;
}
//循环批量写入线圈
for(i=0; i< coil_num; i++)
{
uint8_t data_byte = rx_buf[7 + i/8];
bit_pos = i%8;
uint8_t bit_val = (data_byte >> bit_pos) & 0x01;
Modbus_Coil_Set(start_addr + i, bit_val);
//业务层同步控制IO
}
//组装应答帧
tx_buf[tx_len++] = SLAVE_ADDR;
tx_buf[tx_len++] = 0x0F;
tx_buf[tx_len++] = rx_buf[2];
tx_buf[tx_len++] = rx_buf[3];
tx_buf[tx_len++] = rx_buf[4];
tx_buf[tx_len++] = rx_buf[5];
Modbus_Append_CRC(tx_buf,&tx_len);
return tx_len;
}
常见踩坑:线圈数量不是 8 整数倍时,最后字节高位的无效 bit,设备需要忽略,不处理。
4 Modbus‑TCP 基础入门
4.1 RTU 与 TCP 对比
Modbus‑RTU:串口 485 物理层,帧尾携带 CRC 校验;一主多从;波特率控制通信速度。 Modbus‑TCP:以太网物理层,基于 TCP 协议,默认端口 502 。没有 CRC 校验,可靠性依靠 TCP。
Modbus‑TCP 报文头部增加 MBAP 头(7 字节) MBAP 头结构:
| 事务标识 2B | 协议标识 2B | 长度 2B | 单元标识 1B |
|---|
后面紧跟 PDU(和 RTU 除去 CRC 的 PDU 完全一样)。
- 事务标识:主机自增序号,匹配请求应答;
- 协议标识:固定 0;
- 长度:后面字节总数量;
- 单元标识:相当于 RTU 的从机地址。
核心:Modbus‑TCP 的 PDU 部分,和 RTU 去掉 CRC 后的 PDU 一模一样,业务逻辑代码可以大量复用,只需要处理 MBAP 包头。
4.2 RTU 与 TCP 开发的区别
- RTU 依靠 3.5 字符空闲判断帧结束;TCP 是流式字节流,依靠 MBAP 头长度字段解析完整帧。
- RTU 需要 CRC 校验;TCP 不需要 CRC。
- RTU 总线一问一答;TCP 可以建立长连接,支持多请求。
工程建议:已经写好的 RTU 功能码处理函数,移植 Modbus‑TCP 时,只需要剥离 CRC,增加 MBAP 包头解析,业务逻辑几乎不用改动。
5 线圈开发工程避坑总结
- 05 功能码不是写入 1 和 0,必须使用 0xFF00/0x0000,很多新手这里出错。
- 线圈是 bit 存储,注意字节内 bit 顺序,低位对应低线圈地址。
- 批量写线圈 0F,线圈总数不是 8 倍数时,最后字节高位无效 bit 直接丢弃,不要写入。
- 线圈地址同样区分手册 1 起始,代码 0 起始。 5.Modbus‑TCP 无 CRC,不要把 RTU 的 CRC 带到 TCP 报文中。
6 本章总结
-
完整区分 Modbus 四类地址空间:线圈、离散输入、保持寄存器、输入寄存器。
-
实现 05 写单线圈、0F 批量写多线圈主机与从机代码,可以直接控制继电器开关量 IO。
-
理解 bit 打包字节的处理逻辑,工业 IO 板开发必备。
-
了解 Modbus‑TCP 报文 MBAP 头部,明白 RTU 与 TCP 的复用关系。