背景
Mill 项目原 STM32 工程的 Modbus 部分由两块手写代码组成:
BSP/Modbus_Master.c:基于 nanomodbus + 自实现 UART 收发,手动控制 PD7 DE 引脚BSP/Modbus_Slave.c:手写从站,解析 RTU 帧、查g_registers表、生成响应
迁移到 Zephyr 后,这两部分改用 Zephyr 原生 Modbus 库(subsys/modbus)。主站走标准 modbus-serial 路径,从站因为 USART3 被 router 独占,改用 RAW ADU 模式。下面分别记录两条路径的关键配置和代码。
主站:DTS modbus-serial 节点
USART2 接 PT100、Weight、Relay 三类现场设备,原工程配置 TX=PA2、RX=PA3、DE=PD7、38400 baud。Zephyr 把这些参数全部下沉到 DTS:
&usart2 {
pinctrl-0 = <&usart2_tx_pa2 &usart2_rx_pa3>;
pinctrl-names = "default";
current-speed = <38400>;
status = "okay";
modbus_master: modbus_master {
compatible = "zephyr,modbus-serial";
de-gpios = <&gpiod 7 GPIO_ACTIVE_HIGH>;
status = "okay";
};
};
de-gpios = <&gpiod 7 GPIO_ACTIVE_HIGH> 这一行替代了原工程里手写的 PD7 = 1; delay; send; delay; PD7 = 0;。Zephyr modbus 库在发送前自动拉高 DE,发送完成后自动拉低,应用层完全不感知 RS485 方向切换。
prj.conf 里启用 Modbus 客户端角色:
CONFIG_MODBUS=y
CONFIG_MODBUS_ROLE_CLIENT_SERVER=y
CONFIG_MODBUS_SERIAL=y
主站初始化
app_modbus_init() 通过 DTS 节点名查到 iface 索引,再调 modbus_init_client 完成初始化。参数与原工程对齐:RTU 模式、38400 baud、8N1、250ms 接收超时。
#define MODBUS_NODE DT_NODELABEL(modbus_master)
const static struct modbus_iface_param client_param = {
.mode = MODBUS_MODE_RTU,
.rx_timeout = 250000, /* 250ms */
.serial = {
.baud = 38400,
.parity = UART_CFG_PARITY_NONE,
},
};
int app_modbus_init(void)
{
const char iface_name[] = { DEVICE_DT_NAME(MODBUS_NODE) };
client_iface = modbus_iface_get_by_name(iface_name);
if (client_iface < 0) {
return -ENODEV;
}
return modbus_init_client(client_iface, client_param);
}
DEVICE_DT_NAME(MODBUS_NODE) 展开成字符串 "modbus_master",modbus_iface_get_by_name 按这个名字查表得到整数索引。后续所有 API 都用这个索引调用。
主站 API 封装
对外暴露的 API 与 STM32 原版保持一致,每个函数内部围一对 BusService_Lock/Unlock:
int app_modbus_read_holding(uint8_t addr, uint16_t start, uint16_t count, uint16_t *out)
{
if (count == 0u || out == NULL || client_iface < 0) {
return -EINVAL;
}
int rc = BusService_Lock(APP_MODBUS_TX_TIMEOUT_MS);
if (rc != 0) {
return rc;
}
rc = modbus_read_holding_regs(client_iface, addr, start, out, count);
BusService_Unlock();
return rc;
}
完整 API 列表:
| 函数 | 功能码 | 用途 |
|---|---|---|
app_modbus_read_holding | FC03 | 读 PT100/Weight/Relay 保持寄存器 |
app_modbus_read_input_uint16 | FC04 | 读输入寄存器 |
app_modbus_read_coils | FC01 | 读线圈状态 |
app_modbus_read_discrete_inputs | FC02 | 读离散输入 |
app_modbus_write_single_coil | FC05 | 写单线圈(继电器控制) |
app_modbus_write_single_register | FC06 | 写单寄存器 |
app_modbus_get_iface() 暴露 iface 索引,供 relay.c 这类需要连续多步事务的调用方自己持锁调用底层 modbus_* API。
BusService 互斥锁
USART2 是单总线,sensor 线程、relay 线程、业务线程都会通过它访问不同从站。如果不串行化,两个线程交叉发送会导致从站解析错帧。
bus_service.c 用 K_MUTEX_DEFINE 静态定义互斥体,替代 STM32 原工程的 rt_mutex_init/take/release:
K_MUTEX_DEFINE(modbus_master_mutex);
int BusService_Lock(int32_t timeout_ms)
{
return k_mutex_lock(&modbus_master_mutex, K_MSEC(timeout_ms));
}
void BusService_Unlock(void)
{
(void)k_mutex_unlock(&modbus_master_mutex);
}
K_MUTEX_DEFINE 在启动时自动完成初始化,不需要运行时调 k_mutex_init。锁的粒度是单次事务(请求+响应),超时给 200ms,现场设备响应通常小于 100ms。
从站:为什么用 RAW 模式
USART3 的特殊性在于它是一条三协议复用总线:SMP-COBS(OTA 升级)、JSON(遥测)、Modbus RTU 三种帧通过首字节路由区分。app_usart3_router.c 独占了 USART3 的 UART 中断,负责收齐帧后按协议分发。
Zephyr 标准的 modbus-serial 从站要求独占 UART IRQ,这跟 router 冲突。所以从站改用 RAW ADU 模式:
CONFIG_MODBUS_RAW_ADU=y
CONFIG_MODBUS_NUMOF_RAW_ADU=1
CONFIG_MODBUS_NUMOF_RAW_ADU=1 会让 modbus 库注册一个名为 "RAW_0" 的虚拟接口。这个接口不绑定任何 UART,只负责协议处理。router 收齐一帧 RTU 后,通过 modbus_raw_submit_rx() 把 ADU 喂给 modbus 库,库在 workqueue 上下文调 user_callbacks 生成响应,再通过 raw_tx_cb 回调把响应交回应用,由应用负责发送。
数据流:
USART3 IRQ → router 收齐帧 → CRC 校验 → modbus_raw_submit_rx
↓
modbus 库调 user_callbacks
↓
DR154_ReadRegisters / WriteSingle*
↓
生成响应 ADU → raw_tx_cb 回调
↓
应用加 CRC + 控制 DE + USART3 发送
从站初始化
static int slave_iface = -1;
int app_modbus_slave_init(void)
{
struct modbus_iface_param param;
slave_iface = modbus_iface_get_by_name("RAW_0");
if (slave_iface < 0) {
return -ENODEV;
}
param.mode = MODBUS_MODE_RAW;
param.server.user_cb = &slave_callbacks;
param.server.unit_id = MODBUS_SLAVE_ADDR; /* 从站地址=10 */
param.rawcb.raw_tx_cb = slave_raw_tx;
param.rawcb.user_data = NULL;
return modbus_init_server(slave_iface, param);
}
MODBUS_MODE_RAW 表示这个 iface 不走 UART,ADU 由应用提交。slave_callbacks 是功能码处理回调表,slave_raw_tx 是响应发送回调。
user_callbacks 桥接 DR154
slave_callbacks 把 modbus 功能码桥接到 DR154 寄存器接口。DR154 维护一张 g_registers[106] 表(地址 0x00-0x69),与 STM32 从站和 Platform 后端保持一致。
static struct modbus_user_callbacks slave_callbacks = {
.holding_reg_rd = slave_holding_rd, /* FC03 */
.holding_reg_wr = slave_holding_wr, /* FC06 */
.input_reg_rd = slave_input_rd, /* FC04 */
.coil_rd = slave_coil_rd, /* FC01 */
.coil_wr = slave_coil_wr, /* FC05 */
};
FC03 和 FC04 共用 DR154_ReadRegisters,这是 STM32 原版的行为。FC06 桥接 DR154_WriteSingleRegister,FC05 桥接 DR154_WriteSingleCoil:
static int slave_holding_rd(uint16_t addr, uint16_t *reg)
{
uint8_t exc = DR154_ReadRegisters(addr, 1u, reg, NULL);
return (exc == 0u) ? 0 : -EIO;
}
static int slave_holding_wr(uint16_t addr, uint16_t reg)
{
uint8_t exc = DR154_WriteSingleRegister(addr, reg, NULL);
return (exc == 0u) ? 0 : -EIO;
}
static int slave_coil_wr(uint16_t addr, bool state)
{
uint8_t exc = DR154_WriteSingleCoil(addr, state, NULL);
return (exc == 0u) ? 0 : -EIO;
}
discrete_input_rd 和 *_fp 不注册(NULL),modbus 库收到对应功能码时返回 ILLEGAL_FC。
DR154_WriteSingleRegister 内部还处理维护解锁、维护命令、参数区写入等业务逻辑,这些跟 modbus 协议层无关,全部留在 DR154 模块内。
RAW 模式响应回调
slave_raw_tx 是 RAW 模式的核心。modbus 库生成响应 ADU 后调这个函数,但它不会自己加 CRC,也不会自己发送,这两件事都交给应用:
static int slave_raw_tx(const int iface, const struct modbus_adu *adu, void *user_data)
{
uint8_t tx[280];
uint16_t len;
uint16_t crc;
/* OTA 静默时拒绝响应 */
if (DR154_IsOtaSilence() != 0u) {
return -EBUSY;
}
/* 组装 RTU 帧 */
tx[0] = adu->unit_id;
tx[1] = adu->fc;
memcpy(&tx[2], adu->data, adu->length);
len = (uint16_t)(2u + adu->length);
/* 追加 CRC(低字节在前) */
crc = crc16_ansi(tx, len);
tx[len++] = (uint8_t)(crc & 0xFFu);
tx[len++] = (uint8_t)((crc >> 8) & 0xFFu);
/* USART3 发送互斥 */
int lock_rc = k_mutex_lock(&usart3_tx_mutex, K_MSEC(200));
if (lock_rc != 0) {
return lock_rc;
}
gpio_pin_set_dt(&rs485_de, 1);
k_busy_wait(100);
for (uint16_t i = 0u; i < len; i++) {
uart_poll_out(usart3_dev, tx[i]);
}
k_busy_wait(100);
gpio_pin_set_dt(&rs485_de, 0);
k_mutex_unlock(&usart3_tx_mutex);
return 0;
}
注意这里用的是 gpio_leds 节点 rs485_de(PA5),不是主站的 PD7。USART2 和 USART3 是两条独立的 RS485 总线,DE 引脚也不一样。
OTA 静默
OTA 升级期间,USART3 带宽要全部让给 SMP 传输。DR154_IsOtaSilence() 返回非零时,slave_raw_tx 直接返回 -EBUSY,丢弃已排队的 Modbus 响应。
这个检查放在响应发送阶段,而不是请求接收阶段。原因是 modbus 库已经处理了请求并生成了响应,如果这时候不发,对端会超时重试。这比在 router 层拒绝请求更干净,因为 modbus 库的状态机不会被半完成的请求污染。
router 侧也有同样的检查,app_usart3_router.c 在 OTA 静默期间直接不调 modbus_raw_submit_rx。
USART3 发送互斥
USART3 有三个发送源:SMP 响应、JSON 遥测、Modbus 从站响应。三者共享一条物理总线,必须串行化。usart3_tx_mutex 定义在 dr154_telemetry.c,三个发送路径都按同一套流程操作:
k_mutex_lock(&usart3_tx_mutex, K_MSEC(200))- 拉高
rs485_de(PA5) uart_poll_out逐字节发送- 等待发送完成
- 拉低
rs485_de k_mutex_unlock
这跟主站的 BusService 互斥锁是两套独立的锁,因为 USART2 和 USART3 是两条独立总线,不存在跨总线的事务。
小结
迁移后的代码量比原 STM32 工程少了一半。主站部分,DTS 把 DE 引脚、波特率、pinctrl 全部声明式配置好,应用层只剩 iface 查找和 API 调用。从站部分,RAW 模式让 modbus 库只管协议解析和响应生成,UART 收发留在 router 里,保住了 USART3 的三协议复用能力。
两套互斥锁的分工很清楚:modbus_master_mutex 串行化 USART2 主站访问,usart3_tx_mutex 串行化 USART3 发送。OTA 静默通过 DR154_IsOtaSilence 在响应发送阶段拦截,避免与 SMP 传输争用带宽。