从 RT-Thread 迁移到 Zephyr:API 对照与实战

一、迁移动机

Mill 控制器(STM32F103ZE + DR154 网关)原固件基于 RT-Thread,业务模块沿用 STM32 HAL 驱动 + 手工组 Modbus 帧。本次迁移到 Zephyr 的原因有三:

  1. RT-Thread 维护停滞:核心仓库近一年几乎没有活跃提交,关键 BSP(STM32F1)长期停留在旧版本,社区问题响应缓慢。
  2. Zephyr 生态更成熟:原生支持 Device Tree、MCUboot、LittleFS、Modbus 库、Counter/Watchdog/GPIO 等驱动框架,可直接替换大量自研 BSP 代码。
  3. 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-ThreadZephyr
静态定义线程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)
获取系统 tickHAL_GetTick()k_uptime_get_32()
tick 超时宏rt_tick_from_millisecond(ms)K_MSEC(ms)
立即返回RT_WAITING_NOK_NO_WAIT
永久等待RT_WAITING_FOREVERK_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-ThreadZephyr
静态定义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-ThreadZephyr
静态定义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-ThreadZephyr
静态定义队列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 + HALZephyr
拿设备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 拿描述符。

功能HALZephyr
拿引脚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 pinctrlpinctrl_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 库:

功能原 BSPZephyr modbus
读输入寄存器手组帧 + CRCmodbus_read_input_regs(iface, slave, addr, &val, 1)
读保持寄存器手组帧 + CRCmodbus_read_holding_regs(iface, slave, addr, &val, 1)
写单线圈手组帧 + CRCmodbus_write_coil(iface, slave, addr, true/false)
写单寄存器手组帧 + CRCmodbus_write_holding_reg(iface, slave, addr, val)
CRC 计算应用层手算库内部处理

Relay.c 保留了 _Unlocked 和持锁两层接口:Relay_ToggleOutput 这种”读当前状态 + 写新状态”的两步事务必须包成一个原子操作,中间不能被其他线程的总线请求插入。

5.4 Flash 与 UART

功能HALZephyr
擦除扇区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*)addrflash_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_InitDR154_Init业务层初始化
G780s_IsAutoModeDR154_IsAutoMode运行模式查询
G780s_GetRelayCtrlDR154_GetRelayCtrl云端寄存器读取
G780s_UpdateDataDR154_UpdateData采集数据提交
G780sRemoteConfigDR154RemoteConfig远程配置结构体

建议迁移时用 grep -r "G780s_" 先列清单,再用脚本批量替换,避免漏改导致符号未定义。

七、移除与保留的功能

移除

  1. 按键业务联动:原工程 app_threads.c 里有按键事件驱动的继电器联动,新版简化为纯云端命令驱动,按键只做 DI 去抖后翻转对应通道。
  2. health mailbox:原工程用 rt_mb_send 上报健康采样数据,新版没有独立健康采样需求,且 MCUboot 的 boot_write_img_confirmed 已经在 main.c 开头完成镜像确认,整个 mailbox 链路删掉。
  3. Upgrade-Confirm 线程:原工程有一个独立线程处理升级确认,Zephyr 用 MCUboot 后这部分逻辑归入 main 启动流程。

保留

  1. DI 去抖:16 路 DI 按 bit 维度独立计时,debounce_ms 内不更新稳定值,稳定后上升沿在手动模式下翻转对应输出。
  2. 规则引擎:AUTO 模式下遍历规则表,持续满足 hold_ms 后触发继电器动作,支持 GE/LE/GT/LT/EQ/NE 六种比较。
  3. 现场设备故障冷却:PT100/Weight/Relay DO/DI 任一连续失败 3 次,进入 5 秒冷却期,期间跳过该设备采集,避免 RS485 总线被故障设备拖死。
  4. 配置热更新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_takek_msleep(3000) 等待外设初始化完成,不会抢在 main 之前访问未初始化的外设。

九、迁移经验总结

  1. 能静态就别动态K_THREAD_DEFINE / K_MSGQ_DEFINE / K_SEM_DEFINE / K_MUTEX_DEFINE 全部静态定义,内核启动即就绪,省掉所有 rt_*_init 调用,也避免忘了 init 导致的野指针。
  2. HAL 句柄 → device tree:所有外设通过 DT_NODELABEL 拿设备,引脚配置在 dts 里声明,编译期校验,比 HAL 的 GPIO_PIN(B, 5) 魔法数字强得多。
  3. LL 库补位:Zephyr 驱动框架覆盖不到的场景(如 TIM 外部时钟脉冲计数),用 SoC 厂商的 LL 库直接操作寄存器,pinctrl 仍走 Zephyr,不要整块回到 HAL。
  4. 迁移顺序:先迁内核原语(线程/互斥/队列),再迁外设驱动(GPIO/UART/Modbus),最后迁业务模块(DR154 配置/规则引擎)。每迁一块就用 printf 验证一次,避免一次改太多定位不到问题。
  5. MCUboot 必须确认boot_write_img_confirmed() 放在 main 第一行,OTA swap 后不确认就是定时炸弹——下次重启会回滚到旧固件,OTA 看似成功实际失败。

迁移后代码量减少约 30%,主要省掉的是手工 CRC 计算、Modbus 组帧、HAL 句柄管理这三块重复劳动。Zephyr 的 device tree + 驱动框架前期学习成本不低,但一旦跑通,新增外设就是改 dts + 调 DEVICE_DT_GET,比每次重写 BSP 快得多。