BCU 平台 Modbus 主机功能开发:液冷机组 & 消防传感器
继 RS485 驱动适配(THVD1406 → ISO3082)完成后,BCU 需要在已有从机功能基础上新增两路 Modbus 主机,分别对接液冷机组和消防传感器。本文记录完整的设计与实现过程。
一、项目背景
1.1 当前系统状态
BCU 基于 RK3568 平台,已有 4 路 RS485,全部配置为 Modbus 从机:
| 串口 | 设备 | 角色 | 协议 | 用途 |
|---|---|---|---|---|
| 串口 1 | /dev/ttyS3 |
从机 | MODBUS_RTU | EMS 通信 |
| 串口 2 | /dev/ttyS4 |
从机 | MODBUS_RTU | HMI 通信 |
| 串口 3 | /dev/ttyS5 |
从机 | --- | 待改为液冷主机 |
| 串口 4 | /dev/ttyS9 |
从机 | --- | 待改为消防主机 |
1.2 新增需求
- 液冷机组 (串口 3,
/dev/ttyS5):BCU 作为 Modbus 主机,周期性读取液冷运行状态、故障信息、运行参数,支持远程开关机和参数设置 - 消防传感器 (串口 4,
/dev/ttyS9):BCU 作为 Modbus 主机,每 200ms 轮询 5 个复合探测器(烟雾/温度/CO/VOC/H₂),同时读取消防主机状态和报警阈值
1.3 芯片选型
两路主机同样使用 ISO3082 隔离型 RS485 收发器,DE/RE 通过独立 GPIO 控制方向:
| 串口 | UART | DE GPIO | chip/line | 全局编号 |
|---|---|---|---|---|
/dev/ttyS5 |
UART5 | GPIO3_C1 | chip3, line17 | 113 |
/dev/ttyS9 |
UART9 | GPIO3_B5 | chip3, line13 | 109 |
二、协议分析
2.1 消防传感器协议
-
物理层:RS485,9600bps,8N1,MODBUS RTU
-
主从关系:BMS 为主机,消防控制器为从机(默认地址 1)
-
功能码:0x03(读保持寄存器)、0x06(写单个寄存器)
-
寄存器分布:
寄存器地址 内容 说明
────────── ────────────────────── ──────────────────
0 ~ 54 复合探测器 1~5 每个占 11 个寄存器
偏移 0 烟雾浓度值 uint16
偏移 1 温度值 uint16, ×0.1℃
偏移 2 一氧化碳浓度 uint16, ppm
偏移 3 VOC 浓度 uint16, ppm
偏移 4 氢气浓度 uint16, %LEL
偏移 5 通讯状态 0=正常, 1=故障
偏移 6~10 烟雾/温度/CO/VOC/H₂ 报警 0=正常, 1=报警(锁定)1000 ~ 1008 主机状态区域 报警/电压/输出/输入
1009 ~ 1015 可读写区域 复位/地址/阈值探测器基地址 = (N - 1) × 11
-
报警锁定:报警后保持,写 12345 到地址 1009 复位
-
广播改地址:忘记地址时可用广播地址 0 写寄存器 1010
2.2 液冷机组协议
- 物理层:RS485,9600bps,MODBUS RTU
- 主从关系:BMS 为唯一主站,液冷为从站(默认地址 1)
- 功能码:0x03(读)、0x06(写单个)、0x10(写多个)
关键寄存器:
| 地址 | 参数 | 类型 | 说明 |
|---|---|---|---|
| 状态反馈 (0x0000~0x003F) | |||
| 0x0000 | 运行状态+模式 | U16 | 低字节状态,高字节模式 |
| 0x0001 | 开关量输入 DI | U16 位 | |
| 0x0002 | 继电器输出 DO | U16 位 | |
| 0x0003 | 供液温度 | S16 | ×0.1℃ |
| 0x0004 | 回液温度 | S16 | ×0.1℃ |
| 0x000C | 水泵转速 | U16 | % |
| 0x000D | 压缩机转速 | U16 | RPM |
| 0x0012 | 供水压力 | S16 | ×0.1bar |
| 0x0019~0x001D | 故障状态字 1~5 | U16 位 | |
| 0x0020 | 变频器故障代码 | U16 | |
| 0x0022 | 系统总故障等级 | U16 | |
| 用户参数 (0x1000~0x1011) | |||
| 0x1001 | 工作模式 | U16 | 0 待机/1 制冷/2 制热/3 自循环/4 自动 |
| 0x1003 | 制冷预设温度 | U16 | 0~55℃ |
| 0x1010 | RS485 地址 | U16 | 可修改 |
| 控制 (0x0300) | |||
| 0x0300 | 开关机命令 | U16 | Bit0=开机, Bit1=关机, Bit2=报警复位 |
- 故障等级:一级(停整机)、二级(停部分功能)、三级(仅报警)
- 轮询策略:液冷 10s 一次,消防 200ms 一次
三、架构设计
3.1 整体架构
┌─────────────────────────┐
│ bcu_cfg.db │
│ 串口配置.主从机配置 │
│ ├─ 串口1: 从机 (EMS) │
│ ├─ 串口2: 从机 (HMI) │
│ ├─ 串口3: 主机 (液冷) │
│ └─ 串口4: 主机 (消防) │
└──────┬──────────────────┘
│
┌───────────────┼───────────────┐
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ slave thread │ │ master thread│ │ master thread│
│ modbus_slave_│ │ (液冷/ttyS5) │ │ (消防/ttyS9) │
│ thread() │ │ │ │ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
↓ ↓ ↓
┌──────────────────────────────────────────────┐
│ ctx->points[] │
│ [10001~] EMS 数据 (从CAN来) │
│ [30001~] 消防数据 ← 主机轮询写入 │
│ [50001~] 液冷数据 ← 主机轮询写入 │
└──────────────────┬───────────────────────────┘
↓
┌────────────────┐
│ shm_table │ ← EMS上报/逻辑判断/冻结帧
│ (共享内存) │
└────────────────┘
3.2 主机线程 GPIO 时序
由于 ISO3082 的 DE 和 RE 并联,必须精确控制 GPIO:
空闲状态:DE = 0(接收模式,等待从机数据)
│
↓
构建 Modbus 请求帧 + CRC
│
rs485_set_tx() → GPIO 写 "1" → DE = 1, RE = 1 → 发送模式
│
modbus_send_raw_request() → 发送请求帧
│
rs485_set_rx() → tcdrain() + GPIO 写 "0" → DE = 0, RE = 0 → 接收模式
│
modbus_receive_confirmation() → 等待从机应答
│
↓
解析应答,写入 points[]
关键点 :不能直接使用
modbus_read_registers()高级 API,因为 libmodbus 内部将发送和接收封装在一起,GPIO 无法在中间切换。必须使用modbus_send_raw_request()+modbus_receive_confirmation()分离收发。
3.3 文件改动范围
| 文件 | 改动类型 | 改动内容 |
|---|---|---|
COM/global.h |
修改 | SerialCtx 增加 master_slave、poll_interval_ms |
COM/rs485/rtu.c |
修改 | ① load_serial_config() 读取"主从机配置" ② 线程创建处分发主/从 ③ GPIO 函数去 static |
COM/rs485/rtu_master.c |
新增 | 主机线程 + 消防/液冷轮询逻辑 |
Core/CMakeLists.txt |
修改 | 编译列表增加 rtu_master.c |
四、关键代码实现
4.1 数据结构扩展 (global.h)
c
typedef struct {
// ... 原有字段 ...
// RS485 DE GPIO
int de_gpio_num;
int de_gpio_fd;
// 主从配置
int master_slave; // 0 = 从机, 1 = 主机
int poll_interval_ms; // 主机轮询间隔 (ms)
// Modbus 相关
modbus_t* mb_ctx;
modbus_mapping_t* mb_mapping;
} SerialCtx;
4.2 数据库配置读取 (rtu.c)
在 load_serial_config() 中增加两列读取:
c
else if ( strcmp ( col_name, "主从机配置" ) == 0 )
{
const char* v = ( const char* ) sqlite3_column_text ( stmt, i );
serial->master_slave = ( v && strcmp ( v, "主机" ) == 0 ) ? 1 : 0;
}
else if ( strcmp ( col_name, "协议轮询时间(ms)" ) == 0 )
{
serial->poll_interval_ms = sqlite3_column_int ( stmt, i );
}
4.3 线程分发 (rtu.c)
c
// serial_init_all() 中
if ( serial->master_slave == 1 )
{
pthread_create ( &serial->thread, NULL, modbus_master_thread, serial );
printf ( "Master start: %s (slave=%d, interval=%dms)\n",
serial->device, serial->slave_id, serial->poll_interval_ms );
}
else
{
pthread_create ( &serial->thread, NULL, modbus_slave_thread, serial );
printf ( "Slave start: %s (addr=%d)\n", serial->device, serial->slave_id );
}
4.4 底层收发函数 (rtu_master.c)
手动构建 Modbus 请求帧 + CRC,分离 GPIO 控制:
c
static int master_read_hr(SerialCtx *ctx, int uart_fd,
uint16_t addr, uint16_t nb, uint16_t *dest)
{
uint8_t req[8];
req[0] = (uint8_t)ctx->slave_id;
req[1] = 0x03; // 读保持寄存器
req[2] = (addr >> 8) & 0xFF;
req[3] = addr & 0xFF;
req[4] = (nb >> 8) & 0xFF;
req[5] = nb & 0xFF;
uint16_t crc = master_crc16(req, 6); // 计算 CRC
req[6] = crc & 0xFF;
req[7] = (crc >> 8) & 0xFF;
rs485_set_tx(ctx); // DE = 1, 发送模式
int sent = modbus_send_raw_request(ctx->mb_ctx, req, 8);
rs485_set_rx(ctx, uart_fd); // tcdrain + DE = 0, 接收模式
if (sent < 0) return -1;
uint8_t rsp[MODBUS_RTU_MAX_ADU_LENGTH];
int rc = modbus_receive_confirmation(ctx->mb_ctx, rsp);
if (rc <= 0) return rc;
/* 解析响应: rsp[0]=地址, rsp[1]=0x03, rsp[2]=字节数, rsp[3..]=数据 */
for (int i = 0; i < nb; i++)
dest[i] = (rsp[3 + i*2] << 8) | rsp[3 + i*2 + 1];
return 0;
}
4.5 消防传感器轮询
c
static int poll_fire_sensor(SerialCtx *ctx, CTX *global_ctx)
{
static int fire_ok_cnt = 0;
uint16_t buf[128];
int fail = 0;
// burst 1: 5 个探测器 × 11 寄存器 = 55 个 (地址 0~54)
if (master_read_hr(ctx, fd, 0, 55, buf) == 0) {
for (int d = 0; d < 5; d++) {
int base = d * 11;
for (int j = 0; j < 11; j++) {
int reg = base + j;
for (int k = 0; k < global_ctx->point_count; k++) {
if (points[k].data_addr == reg &&
points[k].point_id >= 30000 &&
points[k].point_id < 40000) {
points[k].current_value = buf[reg];
// 同步到共享内存
shm_table->points[k] = points[k];
}
}
}
}
} else { fail++; }
// burst 2: 主机状态 (地址 1000, 9 个)
// burst 3: 阈值 (地址 1009, 7 个)
// ...
}
4.6 液冷机组轮询
c
static int poll_liquid_cooling(SerialCtx *ctx, CTX *global_ctx)
{
// burst 1: 运行状态 (地址 0, 36 个) --- 含温度/压力/转速/故障
// burst 2: 变频器功率 (地址 60, 1 个)
// burst 3: 用户参数 (地址 4096, 18 个) --- 含模式/预设温度/地址/波特率
// ...
}
4.7 GPIO 控制函数 (rtu.c)
跨文件可调用:
c
// 初始化:export GPIO → 输出 → 初始 LOW
int rs485_de_gpio_init(SerialCtx *ctx);
// 清理:拉低 → close → unexport
void rs485_de_gpio_cleanup(SerialCtx *ctx);
// 发送前:DE = HIGH
void rs485_set_tx(SerialCtx *ctx) {
if (ctx->de_gpio_fd >= 0) write(ctx->de_gpio_fd, "1", 1);
}
// 发送后:等 FIFO 空 → DE = LOW
void rs485_set_rx(SerialCtx *ctx, int uart_fd) {
if (ctx->de_gpio_fd >= 0) {
tcdrain(uart_fd);
write(ctx->de_gpio_fd, "0", 1);
}
}
五、数据库配置
5.1 "串口配置" 表增加字段
| 新增列 | 说明 | 示例值 |
|---|---|---|
| 主从机配置 | "主机" 或 "从机" | 主机 |
| 协议轮询时间(ms) | 主机轮询间隔 | 200(消防)/ 10000(液冷) |
5.2 BCU 完整串口配置
| 串口ID | 开关 | 串口号 | 波特率 | 主从机配置 | 轮询时间 | 从机ID | 用途 |
|---|---|---|---|---|---|---|---|
| 1 | 开启 | 串口1 | 19200 | 从机 | 150 | 16 | EMS |
| 2 | 开启 | 串口2 | 9600 | 从机 | 150 | 16 | HMI |
| 3 | 开启 | 串口3 | 9600 | 主机 | 10000 | 1 | 液冷 |
| 4 | 开启 | 串口4 | 9600 | 主机 | 200 | 1 | 消防 |
5.3 新增点表
- 消防传感器配置点表 :72 行,点位 ID 30001~30071
- 5 个探测器 × 11 寄存器 + 主机状态 9 个 + 阈值 7 个
- 液冷机组配置点表 :45 行,点位 ID 50001~50044
- 运行状态 26 个 + 用户参数 18 个 + 开关机命令 1 个
六、测试验证
6.1 无设备测试(先验证框架)
bash
sudo systemctl restart com_run
sudo journalctl -u com_run -f
期望日志:
--- Start Modbus threads ---
Slave start: /dev/ttyS3 (addr=16) ← EMS 从机
Slave start: /dev/ttyS4 (addr=16) ← HMI 从机
Master start: /dev/ttyS5 (slave=1, interval=10000ms) ← 液冷主机
Master start: /dev/ttyS9 (slave=1, interval=200ms) ← 消防主机
serial5~11: disabled
[/dev/ttyS3] RS485 DE GPIO116 (chip3,line20) OK
[/dev/ttyS4] RS485 DE GPIO110 (chip3,line14) OK
[/dev/ttyS5] RS485 DE GPIO113 (chip3,line17) OK
[/dev/ttyS9] RS485 DE GPIO109 (chip3,line13) OK
[FIRE] 3/3 read bursts failed ← 未接设备,正常超时
[LC] 3/3 read bursts failed ← 未接设备,正常超时
6.2 实测结果
| 检查项 | 结果 |
|---|---|
| GPIO 初始化 (4 路) | ✅ |
| 从机线程 (EMS/HMI) | ✅ |
| 主机线程 (液冷/消防) | ✅ |
| 主从分发正确 | ✅ |
| 轮询间隔准确 | ✅ |
| 超时机制正常 | ✅ |
| 共享内存同步 | ✅ |
| 硬件联调 | 待接设备验证 |
6.3 接设备后验证
bash
# 消防成功日志(每 2s 一条)
[FIRE] OK #1 det1_smoke=25 temp=28.5C
# 液冷成功日志(每 2min 一条)
[LC] OK #1 state=0x0201 supplyT=22.0C
# 验证共享内存数据
cat /dev/shm/point_shm # 或通过 EMS 查询点位 30001/50001
七、经验总结
7.1 为什么不在内核层做
BCU 硬件设计时未使用 UART 的 RTS 引脚,而是独立 GPIO 控制 ISO3082 的 DE/RE。内核 RS485 框架(ioctl(TIOCSRS485))依赖 RTS 自动切换,无法适配独立 GPIO 的场景。应用层控制虽然多了一次系统调用的开销,但灵活性更高。
7.2 libmodbus 的坑
对于 RS485 半双工 + GPIO 方向控制的场景,不能 使用 modbus_read_registers() 等高级 API,因为内部封装了发送和接收,GPIO 无法插入切换。必须:
rs485_set_tx → modbus_send_raw_request → rs485_set_rx → modbus_receive_confirmation
7.3 tcdrain() 不能省
UART 有硬件 FIFO(通常 64 字节),write() 返回只代表数据进了内核缓冲区,不代表硬件发送完成。不做 tcdrain() 会导致 GPIO 提前拉低,截断正在发送的数据帧,造成总线冲突。
7.4 编码问题
项目源文件是 GBK 编码,含有大量中文注释。跨平台编辑时容易导致乱码。建议开发时统一用 UTF-8,或者在板端直接编辑。
7.5 编译踩坑
inline函数跨.c文件调用时,C99 标准要求必须提供外部定义体,否则链接报undefined reference。解决方案:去掉inline关键字,让编译器自行优化。- CMakeLists.txt 新增源文件后要确保路径正确。
7.6 后续优化
- 写命令支持 :当前
master_write_hr()已实现但未接入轮询流程,后续可用来下发液冷开关机、参数修改等控制指令 - 通信故障上报:连续 N 次失败后可标记通讯故障点位
- 动态地址配置:利用消防传感器广播地址 0 改地址的能力,实现从机地址自动分配
项目 :BCU 电池管理控制单元
平台 :OK3568(Rockchip RK3568)
日期 :2026-06-10
作者:zzj