从 RT-Thread 迁移到 Zephyr:API 对照与实战
一、迁移动机
Mill 控制器(STM32F103ZE + DR154 网关)原固件基于 RT-Thread,业务模块沿用 STM32 HAL 驱动 + 手工组 Modbus 帧。本次迁移到 Zephyr 的原因有三:
- RT-Thread 维护停滞:核心仓库近一年几乎没有活跃提交,关键 BSP(STM32F1)长期停留在旧版本,社区问题响应缓慢。
- Zephyr 生态更成熟:原生支持 Device Tree、MCUboot、LittleFS、Modbus 库、Counter/Watchdog/GPIO 等驱动框架,可直接替换大量自研 BSP 代码。
- MCUboot 原生支持:RT-Thread 时代需要自己拼 bootloader 和镜像确认逻辑,Zephyr 直接集成 MCUboot,
boot_write_img_confirmed()一行解决 swap 后的回滚风险。
迁移后业务代码量减少约 30%,主要省掉的是手工 CRC 计算、Modbus 组帧、HAL 句柄管理这三块重复劳动。
二、线程 API 对照
RT-Thread 用 rt_thread_init + rt_thread_startup 两步创建线程,需要手动定义栈数组和线程控制块。Zephyr 用 K_THREAD_DEFINE 宏静态定义,内核启动时自动创建并启动,无需手动调用 startup。
| 功能 | RT-Thread | Zephyr |
|---|---|---|
| 静态定义线程 | rt_thread_init(&tcb, name, entry, arg, stack, size, prio, tick) | K_THREAD_DEFINE(name, stack_size, entry, p1, p2, p3, prio, 0, 0) |
| 启动线程 | rt_thread_startup(&tcb) | 内核自动启动 |
| 延时(毫秒) | rt_thread_mdelay(ms) | k_msleep(ms) |
| 获取系统 tick | HAL_GetTick() | k_uptime_get_32() |
| tick 超时宏 | rt_tick_from_millisecond(ms) | K_MSEC(ms) |
| 立即返回 | RT_WAITING_NO | K_NO_WAIT |
| 永久等待 | RT_WAITING_FOREVER | K_FOREVER |
迁移示例(maint 线程):
/* RT-Thread 版 */
rt_thread_t maint_thread;
rt_thread_init(&maint_tcb, "maint", app_maint_entry, RT_NULL,
maint_stack, 4096, 6, 10);
rt_thread_startup(&maint_tcb);
/* Zephyr 版:一行搞定,内核自动启动 */
K_THREAD_DEFINE(maint_thread, 4096, app_maint_entry,
NULL, NULL, NULL, 6, 0, 0);
K_THREAD_DEFINE 的第 8 个参数是线程选项(K_USER/K_ESSENTIAL 等),第 9 个参数是延迟启动的毫秒数,普通业务填 0, 0 即可。线程入口签名固定为 void entry(void *p1, void *p2, void *p3),不用的参数用 ARG_UNUSED(p1) 消除警告。
三、同步原语对照
3.1 互斥锁
| 功能 | RT-Thread | Zephyr |
|---|---|---|
| 静态定义 | rt_mutex_t mutex; rt_mutex_init(&mutex, "name") | K_MUTEX_DEFINE(name) |
| 加锁 | rt_mutex_take(&mutex, timeout) | k_mutex_lock(&mutex, timeout) |
| 解锁 | rt_mutex_release(&mutex) | k_mutex_unlock(&mutex) |
3.2 信号量
| 功能 | RT-Thread | Zephyr |
|---|---|---|
| 静态定义 | rt_sem_t sem; rt_sem_init(&sem, "name", 0, RT_IPC_FLAG_FIFO) | K_SEM_DEFINE(name, 0, 1) |
| 获取 | rt_sem_take(&sem, timeout) | k_sem_take(&sem, timeout) |
| 释放 | rt_sem_release(&sem) | k_sem_give(&sem) |
异步日志服务用 K_SEM_DEFINE(s_log_start_sem, 0, 1) 阻塞消费线程,直到 main 完成 LittleFS 挂载后 k_sem_give 才放行:
K_SEM_DEFINE(s_log_start_sem, 0, 1);
void AppLoggerService_ThreadEntry(void *p1, void *p2, void *p3)
{
/* 等待 main 完成 LittleFS 挂载 */
k_sem_take(&s_log_start_sem, K_FOREVER);
while (1) { /* 消费循环 */ }
}
3.3 消息队列与邮箱
RT-Thread 区分消息队列(rt_mq_*,定长小消息)和邮箱(rt_mb_*,传递指针大小的 32 位值)。Zephyr 没有独立邮箱原语,统一用 k_msgq 传递定长结构体,指针传递可以放进结构体里。
| 功能 | RT-Thread | Zephyr |
|---|---|---|
| 静态定义队列 | rt_mq_init(&mq, "name", msg_size, pool, sizeof(pool), RT_IPC_FLAG_FIFO) | K_MSGQ_DEFINE(name, msg_size, depth, align) |
| 发送 | rt_mq_send(&mq, &msg, sizeof(msg)) | k_msgq_put(&mq, &msg, timeout) |
| 接收 | rt_mq_recv(&mq, &msg, sizeof(msg), timeout) | k_msgq_get(&mq, &msg, timeout) |
| 邮箱发送 | rt_mb_send(&mb, (uint32_t)value) | 改用 k_msgq_put 传结构体 |
| 邮箱接收 | rt_mb_recv(&mb, &value, timeout) | 改用 k_msgq_get 收结构体 |
迁移示例(日志服务队列):
/* RT-Thread 版:需要定义消息池 + 手动 init */
struct rt_messagequeue mq;
char mq_pool[16 * sizeof(LogMsg)];
rt_mq_init(&mq, "logq", sizeof(LogMsg), mq_pool, sizeof(mq_pool), RT_IPC_FLAG_FIFO);
/* Zephyr 版:一行静态定义,内核自动初始化 */
K_MSGQ_DEFINE(s_log_queue, sizeof(LogServiceMessage), 16, 4);
K_MSGQ_DEFINE 第 4 个参数是对齐字节数,结构体含 uint32_t 时填 4,含 double 时填 8。
四、看门狗迁移
RT-Thread 时代直接调 STM32 HAL 的 HAL_IWDG_Refresh,Reload 值靠宏 APP_IWDG_RELOAD(1875) 手算。Zephyr 用 wdt 驱动框架,统一通过 device tree 拿设备,wdt_install_timeout 安装超时通道,wdt_feed 喂狗。
| 功能 | RT-Thread + HAL | Zephyr |
|---|---|---|
| 拿设备 | hiwdg.Instance = IWDG; | DEVICE_DT_GET(DT_NODELABEL(iwdg)) |
| 配置超时 | hiwdg.Init.Prescaler=256; Reload=1875; | wdt_install_timeout(dev, &cfg) |
| 启动 | HAL_IWDG_Init(&hiwdg) | wdt_setup(dev, WDT_OPT_PAUSE_HALTED_BY_DBG) |
| 喂狗 | HAL_IWDG_Refresh(&hiwdg) | wdt_feed(dev, chan_id) |
static const struct device *iwdg_dev;
static int iwdg_chan_id = -1;
int app_business_init(void)
{
iwdg_dev = DEVICE_DT_GET(DT_NODELABEL(iwdg));
if (device_is_ready(iwdg_dev)) {
struct wdt_timeout_cfg cfg = {
.window = { .min = 0, .max = 6000 }, /* 6s 超时 */
.flags = WDT_FLAG_RESET_SOC,
};
iwdg_chan_id = wdt_install_timeout(iwdg_dev, &cfg);
if (iwdg_chan_id >= 0) {
wdt_setup(iwdg_dev, WDT_OPT_PAUSE_HALTED_BY_DBG);
}
}
return 0;
}
static void app_iwdg_feed(void)
{
if (iwdg_chan_id >= 0) {
wdt_feed(iwdg_dev, iwdg_chan_id);
}
}
WDT_OPT_PAUSE_HALTED_BY_DBG 让调试器暂停时看门狗不咬人,避免单步调试时反复复位。
五、外设迁移
5.1 GPIO(LED)
STM32 HAL 时代 LED 引脚写死在 BSP/LED.c 里,迁移后改由 device tree 的 leds 节点声明,应用层用 GPIO_DT_SPEC_GET 拿描述符。
| 功能 | HAL | Zephyr |
|---|---|---|
| 拿引脚 | GPIO_PIN(B, 5) | GPIO_DT_SPEC_GET(DT_NODELABEL(led_r), gpios) |
| 配置输出 | GPIO_Init(&GPIOB, &init) | gpio_pin_configure_dt(&spec, GPIO_OUTPUT_INACTIVE) |
| 写电平 | HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, 0) | gpio_pin_set_dt(&spec, 0) |
| 翻转 | HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_5) | gpio_pin_toggle_dt(&spec) |
static const struct gpio_dt_spec led_r = GPIO_DT_SPEC_GET(DT_NODELABEL(led_r), gpios);
rc = gpio_pin_configure_dt(&led_r, GPIO_OUTPUT_INACTIVE);
gpio_pin_toggle_dt(&led_r);
GPIO_OUTPUT_INACTIVE 表示初始输出”非激活电平”(active-low 的 LED 初始为灭),语义比 HAL 的 GPIO_PIN_SET/RESET 更清晰。
5.2 TIM 外部时钟(流量计)
流量计用 TIM2/3/4/8 的 CH1 输入捕获 + 外部时钟模式累计脉冲。RT-Thread 时代直接传 TIM_HandleTypeDef*,Zephyr 没有等价的 TIM 句柄封装,采用混合方案:
- 应用层接口改为
uint8_t timer_idx(0=TIM2, 1=TIM3, 2=TIM4, 3=TIM8) - 底层用 STM32 LL 库(
stm32_ll_tim.h)直接操作寄存器 - 引脚配置用 Zephyr pinctrl(
pinctrl_configure_pins)
/* 原 RT-Thread 版 */
void Flowmeter_Init(FlowmeterHandle *fm, TIM_HandleTypeDef *htim, ...);
/* Zephyr 版 */
void Flowmeter_Init(FlowmeterHandle *fm, uint8_t timer_idx, ...);
/* 内部映射 */
static TIM_TypeDef *const fm_tim_base[4] = { TIM2, TIM3, TIM4, TIM8 };
/* pinctrl 配置:TIM2_CH1=PA15 partial remap, TIM3_CH1=PA6, ... */
static const pinctrl_soc_pin_t fm_tim_ch1_pins[4][1] = {
{ { STM32F1_PINMUX('A', 15, GPIO_IN, TIM2_REMAP1) } },
{ { STM32F1_PINMUX('A', 6, GPIO_IN, NO_REMAP) } },
{ { STM32F1_PINMUX('B', 6, GPIO_IN, NO_REMAP) } },
{ { STM32F1_PINMUX('C', 6, GPIO_IN, NO_REMAP) } },
};
pinctrl_configure_pins(pins, 1u, (uintptr_t)TIMx);
LL_TIM_SetClockSource(TIMx, LL_TIM_CLOCKSOURCE_EXT_MODE1);
LL_TIM_EnableCounter(TIMx);
这是迁移中少数不能”纯 Zephyr”的地方——Zephyr 的 counter 驱动不支持外部时钟模式 1 的脉冲计数场景,只能用 LL 库补齐。pinctrl 仍然走 Zephyr 框架,避免回到裸写 GPIO_Init。
5.3 Modbus RTU 主站
原 BSP(Relay.c/Weight.c/Temperature.c)手工组 Modbus 帧 + 手算 CRC16 + BusService_Lock 串行化。迁移后全部走 Zephyr 原生 modbus 库:
| 功能 | 原 BSP | Zephyr modbus |
|---|---|---|
| 读输入寄存器 | 手组帧 + CRC | modbus_read_input_regs(iface, slave, addr, &val, 1) |
| 读保持寄存器 | 手组帧 + CRC | modbus_read_holding_regs(iface, slave, addr, &val, 1) |
| 写单线圈 | 手组帧 + CRC | modbus_write_coil(iface, slave, addr, true/false) |
| 写单寄存器 | 手组帧 + CRC | modbus_write_holding_reg(iface, slave, addr, val) |
| CRC 计算 | 应用层手算 | 库内部处理 |
Relay.c 保留了 _Unlocked 和持锁两层接口:Relay_ToggleOutput 这种”读当前状态 + 写新状态”的两步事务必须包成一个原子操作,中间不能被其他线程的总线请求插入。
5.4 Flash 与 UART
| 功能 | HAL | Zephyr |
|---|---|---|
| 擦除扇区 | HAL_FLASHEx_Erase(&erase, &err) | flash_erase(dev, offset, size) |
| 写入 | HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, data) | flash_write(dev, offset, buf, len) |
| 读 | 直接 *(uint32_t*)addr | flash_read(dev, offset, buf, len) |
| 分区表 | 链接脚本手写 | fixed-partition device tree 节点 |
| UART 接收回调 | HAL_UART_RxCpltCallback(huart) | uart_irq_callback_set(dev, cb) |
| UART 中断使能 | __HAL_UART_ENABLE_IT(huart, UART_IT_RXNE) | uart_irq_rx_enable(dev) |
Flash 操作从 HAL_FLASH_Program 按 word/halfword 写,迁移到 Zephyr flash_write 一次写整段 buffer,由驱动内部处理对齐和扇区擦除。分区由 fixed-partition 在 device tree 里声明,应用代码用 FIXED_PARTITION_OFFSET / FIXED_PARTITION_SIZE 拿到偏移,不再依赖链接脚本。
六、业务模块命名统一
设备型号从 G780s 升级到 DR154,业务前缀同步改名。这是一次全局替换,不涉及逻辑变更:
| 旧前缀 | 新前缀 | 说明 |
|---|---|---|
G780s_Init | DR154_Init | 业务层初始化 |
G780s_IsAutoMode | DR154_IsAutoMode | 运行模式查询 |
G780s_GetRelayCtrl | DR154_GetRelayCtrl | 云端寄存器读取 |
G780s_UpdateData | DR154_UpdateData | 采集数据提交 |
G780sRemoteConfig | DR154RemoteConfig | 远程配置结构体 |
建议迁移时用 grep -r "G780s_" 先列清单,再用脚本批量替换,避免漏改导致符号未定义。
七、移除与保留的功能
移除
- 按键业务联动:原工程
app_threads.c里有按键事件驱动的继电器联动,新版简化为纯云端命令驱动,按键只做 DI 去抖后翻转对应通道。 - health mailbox:原工程用
rt_mb_send上报健康采样数据,新版没有独立健康采样需求,且 MCUboot 的boot_write_img_confirmed已经在main.c开头完成镜像确认,整个 mailbox 链路删掉。 - Upgrade-Confirm 线程:原工程有一个独立线程处理升级确认,Zephyr 用 MCUboot 后这部分逻辑归入
main启动流程。
保留
- DI 去抖:16 路 DI 按 bit 维度独立计时,
debounce_ms内不更新稳定值,稳定后上升沿在手动模式下翻转对应输出。 - 规则引擎:AUTO 模式下遍历规则表,持续满足
hold_ms后触发继电器动作,支持 GE/LE/GT/LT/EQ/NE 六种比较。 - 现场设备故障冷却:PT100/Weight/Relay DO/DI 任一连续失败 3 次,进入 5 秒冷却期,期间跳过该设备采集,避免 RS485 总线被故障设备拖死。
- 配置热更新:
DR154RemoteConfig.sequence自增检测,NVS 加载或config_set命令更新后,sensor 线程在下一个循环热同步流量计采样参数和换算系数。
八、初始化顺序
Zephyr 的 K_THREAD_DEFINE 线程由内核在 main 之前自动启动,但业务初始化(外设、文件系统、配置加载)仍在 main 中顺序执行。线程入口需要用信号量阻塞,等 main 把依赖项准备好后再放行。main.c 的 8 步初始化顺序如下:
int main(void)
{
/* 0. MCUboot 镜像确认(swap 后必须主动确认,否则下次启动回滚) */
boot_write_img_confirmed();
/* 1. LED + 总线互斥(BusService 静态 mutex) */
app_led_init();
BusService_Init();
/* 2. 现场总线主站 USART2(PT100/Weight/Relay) */
app_modbus_init();
/* 3. USART3 路由(SMP/JSON/Modbus 分流)—— 必须在 DR154_Init 之前 */
app_usart3_router_init();
/* 4. SMP-over-MQTT transport(注册 smp_transport + RS485 DE 引脚) */
app_smp_mqtt_init();
/* 5. DR154 业务层(NVS + 配置 + HMAC + tele 缓冲) */
DR154_Init();
/* 6. Modbus 从站(USART3 RAW,注册 user_callbacks 桥接 DR154_*) */
app_modbus_slave_init();
/* 6.5. NM25Q128 LittleFS 文件系统挂载(日志存储用) */
app_storage_init();
/* 6.6. 文件 Logger 初始化,解除消费线程阻塞 */
AppLoggerService_Init();
/* 7. 传感器(4 路 Flowmeter 初始化 + 采集参数) */
app_sensors_init();
/* 8. 业务线程(IWDG 安装,maint/sensor 已由 K_THREAD_DEFINE 自动启动) */
app_business_init();
return 0;
}
关键约束:
- 步骤 3 必须在 5 之前:DR154 的 tele 发送依赖 USART3 路由器已就绪,否则首帧会丢。
- 步骤 6.6 在 6.5 之后:LittleFS 没挂上,文件 Logger 初始化会失败,消费线程永远不会
k_sem_give。 - 步骤 7 在 8 之前:Flowmeter 句柄必须先初始化,maint 线程才不会读到空句柄。
K_THREAD_DEFINE的线程虽然内核启动时就 ready,但入口都用k_sem_take或k_msleep(3000)等待外设初始化完成,不会抢在main之前访问未初始化的外设。
九、迁移经验总结
- 能静态就别动态:
K_THREAD_DEFINE/K_MSGQ_DEFINE/K_SEM_DEFINE/K_MUTEX_DEFINE全部静态定义,内核启动即就绪,省掉所有rt_*_init调用,也避免忘了 init 导致的野指针。 - HAL 句柄 → device tree:所有外设通过
DT_NODELABEL拿设备,引脚配置在 dts 里声明,编译期校验,比 HAL 的GPIO_PIN(B, 5)魔法数字强得多。 - LL 库补位:Zephyr 驱动框架覆盖不到的场景(如 TIM 外部时钟脉冲计数),用 SoC 厂商的 LL 库直接操作寄存器,pinctrl 仍走 Zephyr,不要整块回到 HAL。
- 迁移顺序:先迁内核原语(线程/互斥/队列),再迁外设驱动(GPIO/UART/Modbus),最后迁业务模块(DR154 配置/规则引擎)。每迁一块就用
printf验证一次,避免一次改太多定位不到问题。 - MCUboot 必须确认:
boot_write_img_confirmed()放在main第一行,OTA swap 后不确认就是定时炸弹——下次重启会回滚到旧固件,OTA 看似成功实际失败。
迁移后代码量减少约 30%,主要省掉的是手工 CRC 计算、Modbus 组帧、HAL 句柄管理这三块重复劳动。Zephyr 的 device tree + 驱动框架前期学习成本不低,但一旦跑通,新增外设就是改 dts + 调 DEVICE_DT_GET,比每次重写 BSP 快得多。