第一章:RTU 模式初始化与上下文创建
一、RTU 模式创建函数 modbus_new_rtu()
1.1 函数原型和参数
cs
modbus_t *modbus_new_rtu(
const char *device, // 串口设备名(如 "/dev/ttyUSB0")
int baud, // 波特率(如 9600、115200)
char parity, // 奇偶校验:'N'=无,'E'=偶,'O'=奇
int data_bit, // 数据位(通常为 8)
int stop_bit // 停止位(1 或 2)
)
1.2 内存分配流程
cs
// 1. 分配主上下文结构体
ctx = (modbus_t *) malloc(sizeof(modbus_t));
if (ctx == NULL) {
return NULL;
}
// 2. 初始化通用部分
modbus_init_common(ctx);
// 3. 设置后端为 RTU 模式
ctx->backend = &modbus_rtu_backend;
// 4. 分配 RTU 专用数据空间
ctx->backend_data = (modbus_rtu_t *) malloc(sizeof(modbus_rtu_t));
if (ctx->backend_data == NULL) {
modbus_free(ctx);
errno = ENOMEM;
return NULL;
}
// 5. 获取 RTU 数据指针(方便访问)
ctx_rtu = (modbus_rtu_t *) ctx->backend_data;
1.3 设备名存储
cs
// 分配设备名内存(长度+1是为了存放字符串结束符'\0')
ctx_rtu->device = (char *) malloc((strlen(device) + 1) * sizeof(char));
if (ctx_rtu->device == NULL) {
modbus_free(ctx);
errno = ENOMEM;
return NULL;
}
// 复制设备名(跨平台兼容)
#if defined(_WIN32)
strcpy_s(ctx_rtu->device, strlen(device) + 1, device);
#else
strcpy(ctx_rtu->device, device); // 单片机通常用这个
#endif
二、串口参数配置(针对单片机)
2.1 基本参数设置
cs
// 设置数据位和停止位(这两项是单片机通信的关键)
ctx_rtu->data_bit = data_bit; // 通常为 8
ctx_rtu->stop_bit = stop_bit; // 通常为 1
2.2 单字节传输时间计算
重要知识点:RTU 协议需要精确的时间控制,这个时间用于帧间隔检测。
cs
// 计算传输一个字节需要的时间(微秒)
ctx_rtu->onebyte_time =
1000000 * (1 + data_bit + (parity == 'N' ? 0 : 1) + stop_bit) / baud;
公式解释:
-
1000000:将秒转换为微秒(1秒 = 1,000,000 微秒) -
1:起始位(总是存在) -
data_bit:数据位数 -
(parity == 'N' ? 0 : 1):奇偶校验位(有则1,无则0) -
stop_bit:停止位数 -
baud:波特率(位/秒)
示例计算(9600bps, 8N1):
cs
单字节时间 = 1000000 × (1 + 8 + 0 + 1) / 9600
= 1000000 × 10 / 9600
≈ 1041.67 微秒
≈ 1.04ms
2.3 RTS(请求发送)相关设置
cs
// RTS 控制模式(单片机中常用)
ctx_rtu->rts = MODBUS_RTU_RTS_NONE; // 默认不控制
// 设置RTS控制函数(单片机可能需要重写这个函数)
ctx_rtu->set_rts = _modbus_rtu_ioctl_rts;
// RTS切换延时(使用单字节传输时间)
ctx_rtu->rts_delay = ctx_rtu->onebyte_time;
三、RTU 后端函数表(核心操作集)
3.1 后端结构体定义
cs
const modbus_backend_t _modbus_rtu_backend = {
.backend_type = MODBUS_BACKEND_TYPE_RTU,
.header_length = MODBUS_RTU_HEADER_LENGTH, // RTU头长度
.checksum_length = MODBUS_RTU_CHECKSUM_LENGTH, // CRC校验长度
.max_adu_length = MODBUS_RTU_MAX_ADU_LENGTH, // 最大ADU长度
// 关键函数指针
.set_slave = _modbus_set_slave, // 设置从站地址
.build_request_basis = _modbus_rtu_build_request_basis, // 构建请求
.build_response_basis = _modbus_rtu_build_response_basis, // 构建响应
.send_msg_pre = _modbus_rtu_send_msg_pre, // 发送前处理
.send = _modbus_rtu_send, // 发送数据
.receive = _modbus_rtu_receive, // 接收数据
.check_integrity = _modbus_rtu_check_integrity, // CRC校验
.connect = _modbus_rtu_connect, // 连接串口
.close = _modbus_rtu_close, // 关闭串口
.flush = _modbus_rtu_flush, // 清空缓冲区
.select = _modbus_rtu_select, // 多路复用(单片机不常用)
.free = _modbus_rtu_free // 释放资源
};
3.2 RTU 协议格式说明
cs
RTU 帧格式:
+--------+--------+--------+----------+------------+--------+--------+
| 地址 | 功能码 | 数据 | ... | 数据 | CRC低 | CRC高 |
+--------+--------+--------+----------+------------+--------+--------+
| 1字节 | 1字节 | n字节 | ... | ... | 1字节 | 1字节 |
+--------+--------+--------+----------+------------+--------+--------+
总长度:1 + 1 + n + 2 = n+4 字节
四、单片机应用的关键要点
4.1 时间控制(重要!)
-
RTU 帧间需要有 3.5个字符时间 的间隔
-
帧内字符间不超过 1.5个字符时间
-
计算公式:
cs帧间隔时间 = 3.5 × 单字节时间 = 3.5 × (1+数据位+校验位+停止位) ÷ 波特率
4.2 内存管理注意事项
cs
// 初始化成功检查顺序:
// 1. ctx (主结构体) 分配
// 2. ctx->backend_data (RTU数据) 分配
// 3. ctx_rtu->device (设备名) 分配
// 失败时需要反向释放:
if (ctx_rtu->device == NULL) {
modbus_free(ctx); // 内部会释放 backend_data
return NULL;
}
4.3 针对单片机的适配建议
cs
// 1. 串口初始化(单片机特有)
void modbus_rtu_serial_init(modbus_t *ctx) {
modbus_rtu_t *ctx_rtu = (modbus_rtu_t *)ctx->backend_data;
// 配置单片机串口(根据平台实现)
serial_config(ctx_rtu->device,
ctx_rtu->baud,
ctx_rtu->data_bit,
ctx_rtu->parity,
ctx_rtu->stop_bit);
}
// 2. 重写 RTS 控制(如果需要)
void modbus_rtu_set_rts(modbus_t *ctx, int mode) {
modbus_rtu_t *ctx_rtu = (modbus_rtu_t *)ctx->backend_data;
if (mode == MODBUS_RTU_RTS_UP) {
// 设置单片机 GPIO 为高电平
GPIO_SetHigh(RTS_PIN);
} else {
// 设置单片机 GPIO 为低电平
GPIO_SetLow(RTS_PIN);
}
// 必要的延时(确保电平稳定)
delay_us(ctx_rtu->rts_delay);
}
五、代码流程图
cs
modbus_new_rtu()
├── 分配 modbus_t 结构体
├── 初始化通用部分 (modbus_init_common)
├── 设置后端类型为 RTU
├── 分配 RTU 专用数据
├── 复制设备名
├── 设置串口参数 (数据位/停止位)
├── 计算单字节传输时间 ← 重要!
├── 设置 RTS 相关参数
└── 返回上下文指针
第二章:从站地址设置与 Modbus 上下文结构详解
2.1 Modbus 主/从模式基础概念
2.1.1 两种工作模式
cs
Modbus 通信模式:
├── 主站模式(Master)
│ ├── 发起请求
│ ├── 控制通信时序
│ └── 一个主站可对应多个从站
│
└── 从站模式(Slave)
├── 响应请求
├── 被动接收命令
└── 每个从站有唯一地址(1-247)
2.1.2 地址范围规则
| 地址范围 | 用途 | 说明 |
|---|---|---|
| 0 | 广播地址 | 所有从站都能接收,但不回复响应 |
| 1-247 | 正常从站地址 | 标准Modbus RTU地址范围 |
| 248-255 | 保留地址 | 特殊用途或扩展地址 |
2.2 Modbus 上下文结构体(核心数据结构)
2.2.1 结构体定义分析
cs
struct modbus {
// 1. 从站地址(核心参数)
int slave; // 当前设备的从站地址
// 2. 文件描述符
int s; // Socket 或文件描述符(串口/网络)
// 3. 调试和错误处理
int debug; // 调试模式开关
int error_recovery; // 错误恢复模式
int quirks; // 特殊处理标志(兼容性)
// 4. 超时设置(重要!)
struct timeval response_timeout; // 响应超时
struct timeval byte_timeout; // 字节接收超时
struct timeval indication_timeout; // 指示超时(TCP用)
// 5. 后端框架
const modbus_backend_t *backend; // 指向后端函数表
void *backend_data; // 指向RTU/TCP专用数据
};
2.2.2 成员详细解释
1. slave 成员
cs
int slave; // 取值范围:0-255
-
主站模式时:表示要访问的目标从站地址
-
从站模式时:表示自身的地址
-
特殊值 0:广播地址
2. 文件描述符 s
cs
int s; // 在单片机中通常是串口句柄
-
在 RTU 模式下:串口文件描述符
-
在 TCP 模式下:socket 描述符
-
单片机中:可能是 UART 句柄编号
3. 超时设置(关键参数)
cs
struct timeval response_timeout; // 完整响应的超时时间
struct timeval byte_timeout; // 单个字节接收的超时时间
timeval 结构体定义:
cs
struct timeval {
time_t tv_sec; // 秒
suseconds_t tv_usec; // 微秒
};
4. 后端框架指针
cs
const modbus_backend_t *backend; // 指向第一章讲的函数表
void *backend_data; // 指向第一章讲的 modbus_rtu_t 结构
2.3 设置从站地址函数详解
2.3.1 主函数:modbus_set_slave()
cs
int modbus_set_slave(modbus_t *ctx, int slave)
{
// 1. 参数有效性检查
if (ctx == NULL) {
errno = EINVAL; // 设置错误码:无效参数
return -1;
}
// 2. 调用后端的具体实现函数
return ctx->backend->set_slave(ctx, slave);
}
调用示例:
cs
// 设置从站地址为 1
if (use_backend == RTU) {
modbus_set_slave(ctx, SERVER_ID); // SERVER_ID = 1
}
2.3.2 实际实现函数:_modbus_set_slave()
cs
static int _modbus_set_slave(modbus_t *ctx, int slave)
{
// 1. 确定最大从站地址
int max_slave = (ctx->quirks & MODBUS_QUIRK_MAX_SLAVE) ? 255 : 247;
/*
quirk 解释:
MODBUS_QUIRK_MAX_SLAVE 是一个特殊标志位
当设置了该标志时,允许使用扩展地址范围(0-255)
否则使用标准地址范围(0-247)
*/
// 2. 地址范围验证
/* 广播地址是 0 (MODBUS_BROADCAST_ADDRESS) */
if (slave >= 0 && slave <= max_slave) {
// 3. 设置地址到上下文
ctx->slave = slave;
} else {
// 4. 地址无效,设置错误
errno = EINVAL; // 错误码:EINVAL = 无效参数
return -1;
}
return 0;
}
2.4 代码执行流程分析
2.4.1 完整的调用链
cs
应用层代码
↓
modbus_set_slave(ctx, 1) // 用户调用
↓
检查 ctx 是否为空 // 参数验证
↓
ctx->backend->set_slave(ctx, 1) // 调用后端函数
↓
_modbus_set_slave(ctx, 1) // 实际执行
↓
检查 quirk 标志确定最大地址 // 兼容性处理
↓
验证地址范围 0 <= slave <= max // 范围检查
↓
设置 ctx->slave = 1 // 成功设置
↓
返回 0 // 成功
2.4.2 错误处理流程
cs
应用层代码
↓
modbus_set_slave(NULL, 1) // 错误:ctx为空
↓
设置 errno = EINVAL // 错误码
↓
返回 -1 // 失败
或者:
↓
modbus_set_slave(ctx, 300) // 错误:地址超范围
↓
ctx->backend->set_slave(ctx, 300)
↓
_modbus_set_slave(ctx, 300)
↓
检查 quirk 标志:假设 max=247
↓
300 > 247,地址无效
↓
设置 errno = EINVAL
↓
返回 -1
2.5 单片机应用注意事项
2.5.1 地址设置时机
cs
// 正确的使用顺序
modbus_t *ctx = modbus_new_rtu("/dev/ttyUSB0", 9600, 'N', 8, 1);
if (ctx == NULL) {
// 处理错误
}
// 设置为主站模式,目标从站地址为1
modbus_set_slave(ctx, 1); // 作为主站,要访问从站1
// 或者设置为从站模式,自身地址为1
modbus_set_slave(ctx, 1); // 作为从站,自身地址为1
2.5.2 广播地址的特殊处理
cs
// 广播地址(地址0)的使用
modbus_set_slave(ctx, MODBUS_BROADCAST_ADDRESS); // 地址0
// 广播特点:
// 1. 所有从站都会接收命令
// 2. 没有从站会回复响应
// 3. 用于同时控制多个从站
// 4. 只能用于写操作(不能读)
2.5.3 超时设置(单片机中重要!)
cs
// 设置超时(针对单片机可能调整)
struct timeval timeout;
timeout.tv_sec = 1; // 1秒
timeout.tv_usec = 0; // 0微秒
// 设置响应超时
modbus_set_response_timeout(ctx, &timeout);
// 设置字节超时(更关键)
timeout.tv_sec = 0;
timeout.tv_usec = 500000; // 0.5秒 = 500毫秒
modbus_set_byte_timeout(ctx, &timeout);
单片机推荐超时设置:
cs
// 对于 9600bps 的 RTU:
// 计算一个字节时间:1.04ms
// 一帧最大长度:256字节 ≈ 266ms
// 建议设置:
// 字节超时:2-3倍的单字节时间(2-3ms)
// 响应超时:1.5倍的整帧时间(400ms)
2.6 常见问题与调试
2.6.1 地址设置失败的常见原因
cs
// 原因1:上下文未初始化
modbus_t *ctx = NULL;
modbus_set_slave(ctx, 1); // 错误!ctx为NULL
// 原因2:地址超出范围
modbus_set_slave(ctx, 300); // 错误!标准范围是0-247
// 原因3:未考虑 quirk 标志
// 如果设备支持扩展地址,需要设置 quirk 标志
2.6.2 调试技巧
cs
// 1. 开启调试模式
modbus_set_debug(ctx, TRUE); // 打印调试信息
// 2. 检查错误代码
int result = modbus_set_slave(ctx, 300);
if (result == -1) {
printf("错误代码: %d, 错误信息: %s\n", errno, strerror(errno));
// errno = EINVAL 表示参数无效
}
// 3. 验证设置是否成功
modbus_set_slave(ctx, 1);
// 可以添加日志打印 ctx->slave 的值
2.7 本章核心知识点总结
-
Modbus 地址系统:
-
0为广播地址,1-247为正常从站地址
-
广播地址用于写操作,不产生响应
-
-
上下文结构体 (struct modbus):
-
包含通信的所有状态信息
-
slave 成员的双重含义(主站目标/从站自身)
-
超时设置对稳定性至关重要
-
-
地址设置流程:
-
通过后端函数表调用具体实现
-
支持 quirk 标志扩展地址范围
-
严格的参数验证
-
-
单片机应用要点:
-
合理设置超时时间(基于波特率计算)
-
注意主/从模式的地址含义不同
-
广播地址的特殊用途
-
-
错误处理:
-
使用 errno 获取错误原因
-
EINVAL 表示参数无效
-
检查函数返回值判断成功与否
-
第三章:RTU 串口连接建立与初始化
3.1 连接建立的整体流程
3.1.1 连接函数调用链
cs
应用层代码
↓
modbus_connect(ctx) // 用户调用连接函数
↓
参数验证(检查ctx是否有效)
↓
ctx->backend->connect(ctx) // 调用后端连接函数
↓
_modbus_rtu_connect(ctx) // RTU模式的具体实现
↓
open(ctx_rtu->device, flags) // 打开串口设备
↓
设置串口参数(波特率、数据位等)
↓
返回文件描述符/句柄
3.2 连接函数源码分析
3.2.1 主连接函数:modbus_connect()
cs
int modbus_connect(modbus_t *ctx)
{
// 1. 参数有效性检查
if (ctx == NULL) {
errno = EINVAL; // 设置错误码:无效参数
return -1;
}
// 2. 调用后端的具体连接函数
return ctx->backend->connect(ctx);
}
代码解析:
-
参数验证 :首先检查上下文指针
ctx是否为空 -
错误处理 :如果为空,设置
errno = EINVAL(参数无效) -
调用后端:通过函数指针调用具体的后端连接函数
-
返回值:返回后端函数的结果(成功为0,失败为-1)
3.2.2 RTU 连接实现:_modbus_rtu_connect()
cs
/* POSIX 系统实现 */
static int _modbus_rtu_connect(modbus_t *ctx)
{
// 1. 打开串口设备文件
ctx->s = open(ctx_rtu->device, flags);
// 2. 检查打开是否成功
if (ctx->s < 0) {
// 3. 打开失败的处理
if (ctx->debug) {
// 如果开启了调试模式,打印错误信息
fprintf(stderr,
"ERROR Can't open the device %s (%s)\n",
ctx_rtu->device,
strerror(errno));
}
return -1; // 返回失败
}
// 4. 配置串口参数(波特率、数据位、停止位、校验位等)
// 5. 清空缓冲区等其他初始化操作
return 0; // 返回成功
}
3.3 关键概念详解
3.3.1 文件描述符(File Descriptor)
在 POSIX 系统中:
cs
ctx->s = open(ctx_rtu->device, flags);
-
open():系统调用,打开设备文件 -
ctx->s:文件描述符(整数),后续所有串口操作都使用这个值 -
文件描述符值:
-
>= 0:有效的文件描述符 -
< 0:表示错误(通常是-1)
-
在单片机中(重要区别!):
cs
// 单片机中没有文件描述符概念,通常是:
ctx->s = uart_open(ctx_rtu->device); // 返回UART句柄
// 或者直接使用串口编号
ctx->s = UART1_HANDLE; // 固定值
3.3.2 open() 函数的 flags 参数
cs
// 常见的串口打开标志(POSIX)
#define O_RDWR 0x0002 // 读写模式
#define O_NOCTTY 0x0080 // 不分配控制终端
#define O_NDELAY 0x0004 // 非阻塞模式
// 典型组合:
int flags = O_RDWR | O_NOCTTY | O_NDELAY;
3.3.3 错误处理机制
1. 错误代码传递:
cs
if (ctx->s < 0) {
// open() 失败时,系统会设置 errno
// errno 是全局变量,记录最后一次系统调用的错误
return -1;
}
2. 调试信息输出:
cs
if (ctx->debug) {
fprintf(stderr, "ERROR Can't open the device %s (%s)\n",
ctx_rtu->device,
strerror(errno));
}
-
ctx->debug:调试标志,由用户设置 -
strerror(errno):将错误代码转换为可读的错误信息
常见的 errno 错误代码:
| 错误代码 | 宏定义 | 含义 |
|---|---|---|
| 2 | ENOENT | 文件/设备不存在 |
| 13 | EACCES | 权限不足 |
| 16 | EBUSY | 设备忙(被占用) |
| 19 | ENODEV | 设备不存在 |
| 22 | EINVAL | 参数无效 |
3.4 单片机上的实现差异
3.4.1 单片机串口连接流程
cs
// 单片机版本的 _modbus_rtu_connect()
static int _modbus_rtu_connect_stm32(modbus_t *ctx)
{
modbus_rtu_t *ctx_rtu = (modbus_rtu_t *)ctx->backend_data;
// 1. 初始化串口硬件(没有open()系统调用)
UART_HandleTypeDef *huart;
if (strcmp(ctx_rtu->device, "UART1") == 0) {
huart = &huart1;
ctx->s = (int)huart; // 将句柄指针转换为整数存储
} else if (strcmp(ctx_rtu->device, "UART2") == 0) {
huart = &huart2;
ctx->s = (int)huart;
} else {
// 设备名无效
return -1;
}
// 2. 配置串口参数
huart->Instance = USART1;
huart->Init.BaudRate = ctx_rtu->baud;
huart->Init.WordLength = (ctx_rtu->data_bit == 8) ?
UART_WORDLENGTH_8B : UART_WORDLENGTH_9B;
huart->Init.StopBits = (ctx_rtu->stop_bit == 1) ?
UART_STOPBITS_1 : UART_STOPBITS_2;
huart->Init.Parity = (ctx_rtu->parity == 'N') ? UART_PARITY_NONE :
(ctx_rtu->parity == 'E') ? UART_PARITY_EVEN :
UART_PARITY_ODD;
huart->Init.Mode = UART_MODE_TX_RX;
// 3. 初始化串口
if (HAL_UART_Init(huart) != HAL_OK) {
return -1;
}
// 4. 清空接收缓冲区
__HAL_UART_FLUSH_DRREGISTER(huart);
return 0;
}
3.4.2 单片机中的文件描述符替代方案
cs
// 方案1:使用指针作为标识
typedef struct {
UART_TypeDef *uart;
IRQn_Type irq;
uint32_t baud_rate;
} uart_handle_t;
// 方案2:使用枚举作为标识
typedef enum {
UART_ID_1 = 0,
UART_ID_2,
UART_ID_3,
UART_ID_MAX
} uart_id_t;
// 方案3:直接使用硬件寄存器地址
ctx->s = (int)USART1; // 将寄存器地址作为标识
3.5 完整的连接初始化流程
3.5.1 POSIX 系统完整流程
cs
static int _modbus_rtu_connect(modbus_t *ctx)
{
modbus_rtu_t *ctx_rtu = (modbus_rtu_t *)ctx->backend_data;
struct termios tios;
// 1. 打开设备
int flags = O_RDWR | O_NOCTTY | O_NDELAY;
ctx->s = open(ctx_rtu->device, flags);
if (ctx->s < 0) {
// 错误处理
return -1;
}
// 2. 获取当前串口设置
tcgetattr(ctx->s, &tios);
// 3. 设置波特率
cfsetispeed(&tios, ctx_rtu->baud);
cfsetospeed(&tios, ctx_rtu->baud);
// 4. 设置数据位
tios.c_cflag &= ~CSIZE;
switch (ctx_rtu->data_bit) {
case 5: tios.c_cflag |= CS5; break;
case 6: tios.c_cflag |= CS6; break;
case 7: tios.c_cflag |= CS7; break;
case 8: tios.c_cflag |= CS8; break;
default: return -1;
}
// 5. 设置校验位
if (ctx_rtu->parity == 'N') {
tios.c_cflag &= ~PARENB; // 无校验
} else if (ctx_rtu->parity == 'E') {
tios.c_cflag |= PARENB; // 偶校验
tios.c_cflag &= ~PARODD;
} else if (ctx_rtu->parity == 'O') {
tios.c_cflag |= PARENB; // 奇校验
tios.c_cflag |= PARODD;
}
// 6. 设置停止位
if (ctx_rtu->stop_bit == 1) {
tios.c_cflag &= ~CSTOPB; // 1位停止位
} else {
tios.c_cflag |= CSTOPB; // 2位停止位
}
// 7. 应用设置
tcsetattr(ctx->s, TCSANOW, &tios);
// 8. 清空缓冲区
tcflush(ctx->s, TCIOFLUSH);
return 0;
}
3.5.2 单片机中的关键初始化步骤
cs
// 单片机串口初始化核心步骤
int mcu_uart_init(modbus_t *ctx)
{
// 1. 使能时钟(STM32为例)
__HAL_RCC_USART1_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
// 2. 配置GPIO引脚
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_9|GPIO_PIN_10; // TX/RX
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 3. 配置串口中断(如果需要)
HAL_NVIC_SetPriority(USART1_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(USART1_IRQn);
return 0;
}
3.6 调试与错误排查
3.6.1 连接失败的常见原因
POSIX/Linux 系统:
-
设备不存在:路径错误或设备未连接
bash
$ ls -l /dev/ttyUSB0 # 检查设备是否存在 -
权限不足:需要root权限或加入dialout组
bash
$ sudo chmod 666 /dev/ttyUSB0 # 临时解决 $ sudo usermod -aG dialout $USER # 永久解决 -
设备被占用:另一个程序正在使用串口
bash
$ lsof /dev/ttyUSB0 # 查看谁在占用
单片机系统:
-
时钟未使能:串口外设时钟未开启
-
引脚冲突:GPIO引脚被其他功能占用
-
参数错误:波特率等参数超出硬件支持范围
3.6.2 调试技巧
cs
// 1. 在单片机中添加调试输出
int _modbus_rtu_connect(modbus_t *ctx)
{
printf("尝试打开串口: %s\n", ctx_rtu->device);
printf("波特率: %d\n", ctx_rtu->baud);
ctx->s = open(ctx_rtu->device, flags);
printf("打开结果: fd=%d, errno=%d\n", ctx->s, errno);
// 2. 检查系统资源(单片机可能有限制)
if (ctx->s >= MAX_FILE_DESCRIPTORS) {
printf("错误:文件描述符耗尽\n");
return -1;
}
}
3.7 本章核心知识点总结
-
连接函数分层结构:
-
应用层调用
modbus_connect() -
中间层调用后端函数指针
-
底层实现
_modbus_rtu_connect()
-
-
文件描述符概念:
-
POSIX 系统中标识打开文件的整数
-
单片机中通常用硬件句柄或寄存器地址代替
-
负值表示错误
-
-
错误处理机制:
-
使用
errno传递错误代码 -
通过
strerror()转换为可读信息 -
支持调试模式输出详细信息
-
-
单片机实现差异:
-
没有
open()系统调用 -
需要直接配置硬件寄存器
-
关注时钟使能、引脚配置等底层操作
-
-
串口参数配置:
-
波特率、数据位、停止位、校验位
-
需要与从站设备完全一致
-
配置错误会导致通信失败
-
-
调试建议:
-
在关键步骤添加调试输出
-
检查权限和设备状态
-
验证参数的有效性
-
第四章:Modbus请求构建与数据发送流程(写多个线圈)
4.1 整体流程概览
4.1.1 完整的请求-响应流程
cs
应用程序调用 modbus_write_bits()
↓
构建请求帧(地址+功能码+数据)
↓
计算字节数并打包线圈数据
↓
添加CRC校验
↓
发送请求(可能涉及RTS控制)
↓
等待并接收响应
↓
验证响应(确认帧)
↓
返回结果给应用程序
4.2 功能码详解:写多个线圈(0x0F)
4.2.1 功能码15的作用
-
功能码:0x0F(十进制15)
-
作用:一次性写入多个线圈(Coil)状态
-
线圈:Modbus中的开关量输出,每个线圈1位(0或1)
4.2.2 请求报文格式(重要!)
cs
RTU模式(二进制):
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| 地址 | 功能码 | 起始地址高 | 起始地址低 | 数量高 | 数量低 | 字节数 | 数据1 | 数据2 | CRC低 | CRC高 |
+--------+--------+-----------+-----------+--------+--------+--------+--------+--------+--------+--------+
| 1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 1字节 | n字节 | ... | 1字节 | 1字节 |
+--------+--------+-----------+-----------+--------+--------+--------+--------+--------+--------+--------+
示例(写11个线圈,起始地址0x0013):
+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+
| 0x05 | 0x0F | 0x00 | 0x13 | 0x00 | 0x0B | 0x02 | 0xD1 | 0x05 | CRC低 | CRC高 |
+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+
↑从站地址5 ↑功能码15 ↑起始地址19(0x13) ↑线圈数11(0x0B) ↑2字节数据 ↑CRC校验
4.3 源码逐步分析
4.3.1 函数入口:modbus_write_bits()
cs
int modbus_write_bits(modbus_t *ctx, int addr, int nb, const uint8_t *src)
{
int rc;
int i;
int byte_count;
int req_length;
uint8_t req[MAX_MESSAGE_LENGTH]; // 请求缓冲区
// 1. 构建请求基础部分(地址+功能码+起始地址+数量)
req_length = ctx->backend->build_request_basis(
ctx, MODBUS_FC_WRITE_MULTIPLE_COILS, addr, nb, req);
// 2. 计算需要多少字节来存储线圈状态
byte_count = (nb / 8) + ((nb % 8) ? 1 : 0);
// 3. 设置字节数到请求中
req[req_length++] = byte_count;
// 4. 打包线圈数据(重点!)
// ... 数据打包代码
// 5. 发送请求并接收响应
rc = send_msg(ctx, req, req_length);
if (rc >= 0) {
uint8_t rsp[MAX_MESSAGE_LENGTH];
// 6. 接收从站的确认响应
rc = _modbus_receive_msg(ctx, rsp, MSG_CONFIRMATION);
if (rc == -1)
return -1;
// 7. 验证响应是否正确
rc = check_confirmation(ctx, req, rsp, rc);
}
return rc;
}
参数说明:
-
addr:起始地址(如0x0013) -
nb:要写入的线圈数量(如11) -
src:线圈状态数组,每个元素一个线圈(0=OFF,1=ON)
4.3.2 构建请求基础部分
cs
static int _modbus_rtu_build_request_basis(
modbus_t *ctx, int function, int addr, int nb, uint8_t *req)
{
// 断言:必须已经设置了从站地址
assert(ctx->slave != -1);
// 字节0:从站地址
req[0] = ctx->slave;
// 字节1:功能码
req[1] = function; // 0x0F = 写多个线圈
// 字节2-3:起始地址(16位,大端序:高字节在前)
req[2] = addr >> 8; // 高8位
req[3] = addr & 0x00FF; // 低8位
// 字节4-5:线圈数量(16位,大端序)
req[4] = nb >> 8; // 高8位
req[5] = nb & 0x00FF; // 低8位
// 返回基础部分的长度:6字节
return _MODBUS_RTU_PRESET_REQ_LENGTH; // 6
}
重要概念:大端序(Big-Endian)
cs
16位地址0x0013在报文中的存储:
高8位:0x00 → req[2]
低8位:0x13 → req[3]
为什么这样设计?Modbus协议规定使用大端序(网络字节序)
单片机通常是小端序,需要转换
4.3.3 线圈数据打包算法(核心!)
cs
// 假设:nb=11个线圈,src[]包含11个值(0或1)
// 计算需要多少字节:11/8=1余3,所以需要2字节
byte_count = (nb / 8) + ((nb % 8) ? 1 : 0); // = 2
// 设置字节数到请求报文中
req[req_length++] = byte_count; // req[6] = 0x02
// 开始打包数据
int bit_check = 0; // 已处理的位数计数器
int pos = 0; // src数组的当前位置
for (i = 0; i < byte_count; i++) {
int bit;
bit = 0x01; // 从最低位(bit0)开始
req[req_length] = 0; // 清空当前数据字节
// 处理当前字节的8个位(或少于8个,如果nb不是8的倍数)
while ((bit & 0xFF) && (bit_check < nb)) {
// 如果当前线圈状态为1,设置对应位
if (src[pos++] % 2 == 1)
req[req_length] |= bit;
// 否则清除该位(已经是0,但明确操作)
else
req[req_length] &= ~bit;
bit = bit << 1; // 移到下一位
bit_check++; // 已处理位数+1
}
req_length++; // 移动到下一个数据字节
}
打包示例:
cs
线圈状态数组src[] = [1,0,1,0,0,1,0,1,0,1,1]
对应线圈地址:0x0013, 0x0014, ..., 0x001D
打包过程:
第一个字节(i=0):
bit=0x01(00000001) → 处理线圈0x0013(1) → 设置bit0 → 字节=00000001
bit=0x02(00000010) → 处理线圈0x0014(0) → 清除bit1 → 字节=00000001
bit=0x04(00000100) → 处理线圈0x0015(1) → 设置bit2 → 字节=00000101
... 依次处理8个线圈
最终第一个字节:11010001 = 0xD1
第二个字节(i=1):
处理剩余的3个线圈
bit=0x01 → 处理线圈0x001B(0) → 清除bit0 → 字节=00000000
bit=0x02 → 处理线圈0x001C(1) → 设置bit1 → 字节=00000010
bit=0x04 → 处理线圈0x001D(1) → 设置bit2 → 字节=00000110
最终第二个字节:00000101 = 0x05(注意:bit2=1, bit0=1?这里需要验证)
注意:实际打包顺序是从字节的最低位(bit0)开始,对应第一个线圈
4.4 发送请求流程
4.4.1 发送主函数:send_msg()
cs
static int send_msg(modbus_t *ctx, uint8_t *msg, int msg_length)
{
int rc;
int i;
// 1. 发送前预处理(主要是添加CRC校验)
msg_length = ctx->backend->send_msg_pre(msg, msg_length);
// 2. 调试模式:打印发送的报文
if (ctx->debug) {
for (i = 0; i < msg_length; i++)
printf("[%.2X]", msg[i]);
printf("\n");
}
// 3. 发送数据(支持错误恢复模式)
do {
rc = ctx->backend->send(ctx, msg, msg_length);
if (rc == -1) {
_error_print(ctx, NULL);
if (ctx->error_recovery & MODBUS_ERROR_RECOVERY_LINK) {
// 错误恢复处理:可能重新连接等
}
}
} while ( /* 错误恢复模式下重试条件 */ );
return rc;
}
4.4.2 发送前预处理:添加CRC校验
cs
static int _modbus_rtu_send_msg_pre(uint8_t *req, int req_length)
{
// 计算CRC16校验值(从地址字节开始,到数据结束)
uint16_t crc = crc16(req, req_length);
/* 根据MODBUS规范,CRC的低字节在前,高字节在后 */
req[req_length++] = crc & 0x00FF; // 低字节
req[req_length++] = crc >> 8; // 高字节
return req_length; // 返回新的长度(原长度+2)
}
CRC计算要点:
-
计算范围:从从站地址到最后一个数据字节
-
字节顺序:先低字节,后高字节
-
算法:Modbus使用CRC-16-IBM(多项式0x8005)
4.4.3 RTU发送函数(包含RTS控制)
cs
static size_t _modbus_rtu_send(modbus_t *ctx, const uint8_t *req, int req_length)
{
#if defined(_WIN32)
// Windows实现(略)
#else
// Linux/POSIX实现
#if HAVE_DECL_TIOCM_RTS // 如果支持RTS控制
modbus_rtu_t *ctx_rtu = (modbus_rtu_t *)ctx->backend_data;
if (ctx_rtu->rts != MODBUS_RTU_RTS_NONE) {
size_t size;
if (ctx->debug) {
fprintf(stderr, "Sending request using RTS signal\n");
}
// 1. 设置RTS信号(对于RS485,控制发送使能)
ctx_rtu->set_rts(ctx, ctx_rtu->rts == MODBUS_RTU_RTS_UP);
// 2. 延时,确保信号稳定
usleep(ctx_rtu->rts_delay);
// 3. 发送数据
size = write(ctx->s, req, req_length);
// 4. 延时,等待数据完全发送
// 公式:每个字节时间×字节数 + RTS切换延时
usleep(ctx_rtu->onebyte_time * req_length + ctx_rtu->rts_delay);
// 5. 恢复RTS信号(关闭发送使能,切换回接收)
ctx_rtu->set_rts(ctx, ctx_rtu->rts != MODBUS_RTU_RTS_UP);
return size;
} else {
// 不使用RTS,直接发送
return write(ctx->s, req, req_length);
}
#endif
}
单片机中的RTS实现(重要!):
cs
// 单片机版本的set_rts函数(控制RS485收发切换)
void mcu_set_rts(modbus_t *ctx, int on) {
if (on) {
// 拉高引脚,使能发送(RS485的DE引脚)
HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_SET);
// 如果是半双工,同时禁用接收
HAL_GPIO_WritePin(RS485_RE_GPIO_Port, RS485_RE_Pin, GPIO_PIN_SET);
} else {
// 拉低引脚,禁用发送,使能接收
HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(RS485_RE_GPIO_Port, RS485_RE_Pin, GPIO_PIN_RESET);
}
}
4.5 接收响应与确认
4.5.1 接收确认响应
cs
// 接收响应报文
rc = _modbus_receive_msg(ctx, rsp, MSG_CONFIRMATION);
// 验证响应
rc = check_confirmation(ctx, req, rsp, rc);
确认响应格式(写多个线圈的响应):
cs
+--------+--------+--------+--------+--------+--------+--------+
| 地址 | 功能码 | 起始地址高 | 起始地址低 | 数量高 | 数量低 | CRC |
+--------+--------+-----------+-----------+--------+--------+--------+
| 1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 2字节 |
+--------+--------+-----------+-----------+--------+--------+--------+
验证内容:
-
从站地址是否匹配
-
功能码是否相同(还是0x0F)
-
起始地址是否一致
-
线圈数量是否一致
4.6 单片机实现要点
4.6.1 数据打包优化(单片机版本)
cs
// 单片机优化版本的数据打包
void pack_coils_to_bytes(const uint8_t *coils, int nb, uint8_t *bytes) {
int byte_index = 0;
int bit_mask = 0x01;
bytes[0] = 0; // 初始化第一个字节
for (int i = 0; i < nb; i++) {
// 设置或清除对应位
if (coils[i]) {
bytes[byte_index] |= bit_mask;
} else {
bytes[byte_index] &= ~bit_mask;
}
// 移动到下一位
bit_mask <<= 1;
// 如果处理完8个线圈,移动到下一个字节
if (bit_mask == 0x100) { // 超过8位
bit_mask = 0x01;
byte_index++;
if (byte_index < ((nb + 7) / 8)) {
bytes[byte_index] = 0; // 初始化下一个字节
}
}
}
}
4.6.2 发送时的时序控制
cs
// 单片机发送函数(不使用系统调用write)
int mcu_uart_send(modbus_t *ctx, const uint8_t *data, int length) {
modbus_rtu_t *ctx_rtu = (modbus_rtu_t *)ctx->backend_data;
UART_HandleTypeDef *huart = (UART_HandleTypeDef *)ctx->s;
// 1. 计算发送所需时间(用于延时)
uint32_t total_time_us = ctx_rtu->onebyte_time * length;
// 2. 如果有RTS控制,先使能发送
if (ctx_rtu->rts != MODBUS_RTU_RTS_NONE) {
ctx_rtu->set_rts(ctx, 1);
HAL_Delay(ctx_rtu->rts_delay / 1000); // 转换为毫秒
}
// 3. 发送数据(阻塞方式)
HAL_StatusTypeDef status = HAL_UART_Transmit(huart, (uint8_t*)data, length, 1000);
// 4. 等待数据完全发送
// 方法1:计算延时
// HAL_Delay(total_time_us / 1000 + 1); // +1确保完全发送
// 方法2:检查发送完成标志(更准确)
while (huart->gState != HAL_UART_STATE_READY) {
// 等待发送完成
}
// 5. 恢复RTS(切换回接收)
if (ctx_rtu->rts != MODBUS_RTU_RTS_NONE) {
HAL_Delay(ctx_rtu->rts_delay / 1000);
ctx_rtu->set_rts(ctx, 0);
}
return (status == HAL_OK) ? length : -1;
}
4.7 调试技巧与常见问题
4.7.1 调试报文内容
cs
// 在单片机中打印调试信息
void debug_print_request(uint8_t *req, int length) {
printf("发送请求(%d字节):", length);
for (int i = 0; i < length; i++) {
printf("%02X ", req[i]);
}
printf("\n");
// 解析报文内容
printf("解析:\n");
printf(" 从站地址: %d\n", req[0]);
printf(" 功能码: 0x%02X\n", req[1]);
printf(" 起始地址: 0x%02X%02X = %d\n", req[2], req[3], (req[2]<<8)|req[3]);
printf(" 线圈数量: %d\n", (req[4]<<8)|req[5]);
printf(" 字节数: %d\n", req[6]);
// 打印线圈数据
printf(" 线圈数据: ");
for (int i = 7; i < 7 + req[6]; i++) {
printf("%02X ", req[i]);
}
printf("\n");
// 打印CRC
printf(" CRC: %02X %02X\n", req[length-2], req[length-1]);
}
4.7.2 常见问题排查
问题1:数据打包错误
cs
症状:从站接收到的线圈状态不正确
排查:
1. 检查字节顺序(大端序/小端序)
2. 验证位打包顺序(最低位对应第一个线圈)
3. 检查字节数计算:(nb+7)/8
问题2:CRC校验失败
cs
症状:从站不响应或返回异常响应
排查:
1. 验证CRC计算范围(从地址到数据)
2. 检查CRC字节顺序(低字节在前)
3. 使用CRC计算工具对比
问题3:RTS时序问题
cs
症状:数据发送不完整或冲突
排查:
1. 检查RTS切换延时是否足够
2. 验证发送完成后再切换RTS
3. 使用逻辑分析仪观察时序
4.8 本章核心知识点总结
-
请求报文结构:
-
6字节基础头 + 1字节数 + n字节数据 + 2字节CRC
-
使用大端序存储16位值
-
-
数据打包算法:
-
每个线圈对应1位(0或1)
-
从字节的最低位(bit0)开始打包
-
需要计算字节数:(nb+7)/8
-
-
CRC校验:
-
计算从地址到数据的所有字节
-
低字节在前,高字节在后
-
使用Modbus标准CRC-16算法
-
-
发送流程:
-
先添加CRC,再发送
-
RTS控制用于RS485收发切换
-
需要精确的时序控制
-
-
单片机实现要点:
-
优化数据打包算法
-
实现准确的RTS控制
-
确保发送完成后再切换状态
-
-
调试方法:
-
打印完整报文进行验证
-
使用串口调试助手辅助测试
-
逐步验证每个环节
-
🔗 核心知识链总览
我为你串联整个学习过程的知识点,从零开始理解libmodbus RTU的工作机制:
一、三层架构思想(贯穿始终的核心)
cs
应用层(用户代码)
↓
Modbus API层(modbus_xxx函数)
↓
后端抽象层(函数指针表)
↓
具体实现层(RTU/TCP实现)
关键理解 :每个modbus_t结构体都有一个backend指针,指向具体的函数实现表,这种设计实现了"一次编写,多平台运行"。
二、完整工作流程串联
cs
1. 创建上下文(modbus_new_rtu)
├── 分配modbus_t主结构
├── 分配modbus_rtu_t专用数据
├── 计算单字节时间(通信时序基础)
└── 设置后端函数表
2. 配置参数(modbus_set_slave)
├── 设置主/从站地址
├── 地址范围验证(0-247标准,0为广播)
└── 存储在ctx->slave中
3. 建立连接(modbus_connect)
├── 打开串口设备(POSIX:open())
├── 单片机:初始化UART硬件
├── 配置串口参数(波特率、数据位、停止位、校验位)
└── 获得文件描述符/句柄
4. 构建请求(以写线圈为例)
├── 功能码:0x0F(写多个线圈)
├── 地址大端序存储(高字节在前)
├── 线圈数据位打包(从字节最低位开始)
├── 计算CRC(低字节在前)
└── RTS控制(RS485收发切换)
5. 发送请求
├── 可能的重试机制(错误恢复模式)
├── RTS时序控制(发送前使能,发送后延时再关闭)
└── 单片机需确保发送完成再切换
6. 接收响应
├── 验证确认帧(地址、功能码、地址、数量)
└── 错误处理和重试
三、关键数据结构关联
cs
struct modbus (通用上下文)
├── int slave ← 第二章讲解(地址设置)
├── int s ← 第三章讲解(串口句柄)
├── backend指针 ← 第一章讲解(指向函数表)
└── backend_data指针 ← 第一章讲解(指向RTU数据)
↓
struct modbus_rtu_t (RTU专用数据) ← 第一章讲解
├── char *device (串口设备名)
├── int baud (波特率)
├── uint32_t onebyte_time ← 核心!(单字节传输时间)
└── RTS相关参数 (RS485控制)
四、时间计算的核心公式串联
-
单字节时间(第一章基础):
cs单字节时间(微秒) = 1000000 × (起始位1 + 数据位 + 校验位0/1 + 停止位) ÷ 波特率 -
帧间隔时间(RTU协议要求):
cs帧间隔 = 3.5 × 单字节时间 (用于区分不同帧) -
RTS延时时间(第四章应用):
cs发送前延时 = RTS延时(通常=单字节时间) 发送后延时 = 数据发送时间 + RTS延时 数据发送时间 = 单字节时间 × 字节数
五、单片机实现的特殊性串联
-
无文件描述符 :用UART句柄或寄存器地址代替
ctx->s -
无系统调用 :直接操作硬件寄存器而非
open()/write() -
RTS控制:自己实现GPIO控制收发切换
-
延时精度:需要高精度定时器保证时序
-
内存管理:静态分配代替动态分配更安全
六、从理论到实践的知识点应用
-
初始化阶段:
-
使用
modbus_new_rtu()创建上下文 -
用
modbus_set_slave()设置地址 -
调用
modbus_connect()建立连接
-
-
通信阶段:
-
构建请求时注意大端序存储16位值
-
数据打包时从字节最低位开始
-
CRC计算范围:从地址到数据,低字节在前
-
RS485通信必须控制RTS时序
-
-
调试阶段:
-
开启调试模式查看原始报文
-
验证每个环节:地址→数据→CRC
-
使用时序分析工具验证时间间隔
-
七、常见错误排查链
cs
通信失败
↓
检查连接(第三章)
├── 设备路径是否正确?
├── 波特率等参数是否匹配?
└── 权限是否足够?
↓
检查地址(第二章)
├── 从站地址是否正确?
├── 是否设置了slave?
└── 地址是否在有效范围?
↓
检查数据(第四章)
├── 数据打包顺序是否正确?
├── CRC计算是否正确?
└── RTS时序是否准确?
↓
检查时序(第一章基础)
├── 单字节时间计算是否正确?
├── 帧间隔是否足够?
└── 响应超时设置是否合理?
八、学习路径建议
-
先理解架构:三层分离思想
-
再掌握流程:创建→配置→连接→通信
-
重点掌握公式:时间计算是RTU核心
-
实践调试:从简单到复杂逐步验证
-
单片机适配:理解差异,针对性修改
九、核心记忆点
-
一个核心结构 :
modbus_t贯穿始终 -
两个关键指针 :
backend和backend_data -
三个时间概念:单字节时间、帧间隔、RTS延时
-
四个通信阶段:初始化、连接、请求、响应
-
五个调试要点:地址、数据、CRC、时序、硬件
🎯 终极总结:一句话记住libmodbus RTU
"通过三层架构抽象,使用时间精确控制的串口协议,按照Modbus规范打包数据,实现可靠的半双工通信。"
📚 完整知识树
cs
libmodbus RTU
├── 架构设计
│ ├── 三层抽象(应用-抽象-实现)
│ ├── 函数指针表机制
│ └── 上下文隔离设计
│
├── 核心数据结构
│ ├── modbus_t(通用上下文)
│ ├── modbus_backend_t(函数表)
│ └── modbus_rtu_t(RTU数据)
│
├── 初始化流程
│ ├── 内存分配(三级分配)
│ ├── 参数计算(单字节时间)
│ └── 后端绑定(RTU函数表)
│
├── 通信流程
│ ├── 连接建立(open/硬件初始化)
│ ├── 请求构建(大端序+位打包)
│ ├── 校验计算(CRC低字节在前)
│ ├── 发送控制(RTS时序)
│ └── 响应处理(确认验证)
│
└── 单片机适配
├── 硬件直接操作(无系统调用)
├── 静态内存管理
├── 精确时序控制
└── GPIO手动控制(RTS/485)