背景

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_holdingFC03读 PT100/Weight/Relay 保持寄存器
app_modbus_read_input_uint16FC04读输入寄存器
app_modbus_read_coilsFC01读线圈状态
app_modbus_read_discrete_inputsFC02读离散输入
app_modbus_write_single_coilFC05写单线圈(继电器控制)
app_modbus_write_single_registerFC06写单寄存器

app_modbus_get_iface() 暴露 iface 索引,供 relay.c 这类需要连续多步事务的调用方自己持锁调用底层 modbus_* API。

BusService 互斥锁

USART2 是单总线,sensor 线程、relay 线程、业务线程都会通过它访问不同从站。如果不串行化,两个线程交叉发送会导致从站解析错帧。

bus_service.cK_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,三个发送路径都按同一套流程操作:

  1. k_mutex_lock(&usart3_tx_mutex, K_MSEC(200))
  2. 拉高 rs485_de(PA5)
  3. uart_poll_out 逐字节发送
  4. 等待发送完成
  5. 拉低 rs485_de
  6. 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 传输争用带宽。