Zephyr 自定义 Board:STM32F103ZE 的 DTS 配置

把一个原先用裸 STM32 StdPeriph 写的 Mill 项目迁到 Zephyr,第一步就是把它跑在哪个板子上告诉 Zephyr。Zephyr 不像 RT-Thread 那样一个 BSP 文件搞定,它把板子描述拆成了一组小文件:YAML 描述板子身份、CMake 描述烧录器、Kconfig 描述默认配置、DTS 描述硬件。这篇文章记录 Mill 项目里 stm32f103ze 这块自定义 Board 的完整配置。

Board 目录结构

所有板子相关文件都放在 boards/stm32f103ze/ 下,一共六个文件:

文件作用
board.yml板子的元数据:名称、vendor、所属 SoC
stm32f103ze.yaml板子的硬件能力清单(ram/flash/supported)
board.cmake烧录器配置(J-Link device/speed)
Kconfig.stm32f103ze板子选项声明,select SoC
Kconfig.defconfig板子被选中后默认打开的 Kconfig
stm32f103ze.dts硬件描述:时钟、外设、引脚、分区

board.yml 极简,把 vendor 标成 zhiqiangme、SoC 指向 stm32f103xe

board:
  name: stm32f103ze
  full_name: STM32F103ZE
  vendor: zhiqiangme
  socs:
    - name: stm32f103xe

stm32f103ze.yaml 告诉 Zephyr 这块板子有 64KB RAM、512KB Flash,支持 GPIO 和 UART,工具链走 zephyr

identifier: stm32f103ze
name: STM32F103ZE
type: mcu
arch: arm
toolchain:
  - zephyr
ram: 64
flash: 512
supported:
  - gpio
  - uart
vendor: zhiqiangme

board.cmake 指定 J-Link 烧录,4MHz 速率、烧完自动复位:

board_runner_args(jlink "--device=STM32F103ZE" "--speed=4000" "--reset-after-load")
include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake)

Kconfig.stm32f103ze 只有一件事:声明 BOARD_STM32F103ZE 这个选项,并 select SoC SOC_STM32F103XEKconfig.defconfig 则在板子被选中时把 SERIAL/CONSOLE/UART_CONSOLE/GPIO 默认开起来,这样应用层就不用每次都写 CONFIG_SERIAL=y

DTS 根节点与 chosen

DTS 的根节点声明了板子的 model 和 compatible。这里 compatible 用 zhiqiangme,stm32f103ze,前缀是 vendor,后缀是板子型号:

/ {
    model = "Mill STM32F103ZE Board";
    compatible = "zhiqiangme,stm32f103ze";

    chosen {
        zephyr,console = &usart1;
        zephyr,shell-uart = &usart1;
        /* SMP-over-UART 复用 USART1,mcumgr CLI 直接本地升级 */
        zephyr,uart-mcumgr = &usart1;
        zephyr,sram = &sram0;
        zephyr,flash = &flash0;
        /* MCUboot 需要的分区 chosen */
        zephyr,code-partition = &slot0_partition;
    };
    ...
};

chosen 节点把 Zephyr 各子系统指向具体硬件:

  • console / shell-uart 都指向 USART1,shell 和 console 共用一个串口
  • uart-mcumgr 也指向 USART1,这样 SMP-over-UART 直接复用 console,本地用 mcumgr CLI 升级固件不用切串口
  • code-partition 指向 slot0_partition,MCUboot 引导完之后跳到这个分区执行应用

时钟树:HSE 8MHz × PLL9 = 72MHz

STM32F103ZE 的标准跑法:板载 8MHz 晶振走 HSE,PLL 倍频 9 倍到 72MHz。AHB 不分频,APB1 二分频(36MHz,外设最高只能跑 36MHz),APB2 不分频(72MHz)。

&clk_hse {
    /* 板载 8MHz 晶振(非外部时钟信号,不能用 hse-bypass) */
    clock-frequency = <DT_FREQ_M(8)>;
    status = "okay";
};

&pll {
    mul = <9>;
    clocks = <&clk_hse>;
    status = "okay";
};

&rcc {
    clocks = <&pll>;
    clock-frequency = <DT_FREQ_M(72)>;
    ahb-prescaler = <1>;
    apb1-prescaler = <2>;
    apb2-prescaler = <1>;
};

注释里特别强调:板载晶振是 8MHz 晶振本体,不是外部已经整形好的时钟信号,所以不能用 hse-bypass 走旁路模式,必须走正常 OSC_IN/OSC_OUT 模式。DT_FREQ_M(8) 是 Zephyr 的宏,展开成 8000000,避免直接写一长串零。

三路 USART 各司其职

USART1:console + shell + mcumgr

USART1 接 PA9/PA10,115200 波特率,承担 console、shell、SMP-over-UART 三件事。这三者复用一个串口靠 Zephyr 的 uart_pipe 机制,应用层不直接读 UART 寄存器,而是注册一个 pipe,mcumgr 的 SMP 包从 pipe 进出。

&usart1 {
    pinctrl-0 = <&usart1_tx_pa9 &usart1_rx_pa10>;
    pinctrl-names = "default";
    current-speed = <115200>;
    status = "okay";
};

USART2:Modbus 主站

USART2 接 PA2/PA3,38400 波特率,挂一片 RS485 收发器,作 Modbus 主站。原本 STM32 工程用 nanomodbus + 自己写的 Modbus_Master,迁到 Zephyr 后改用原生 CONFIG_MODBUS。DTS 里在 usart2 节点下挂一个 modbus_master 子节点,compatible 是 zephyr,modbus-serial,DE 引脚通过 de-gpios 指向 PD7:

&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";
    };
};

这条总线上挂着 PT100(ID=1,CH4)、Weight(CH3)、Relay(ID=2,16 路)三台 Modbus 从机。de-gpios 让 Zephyr 的 modbus 驱动在发送前自动拉高 PD7 切到发送态,发完拉低回到接收态,不用应用层管方向。

USART3:RS485 三协议时分复用

USART3 是这块板子上最复杂的一路。PB10/PB11,115200,连一片 RS485 收发器,总线上同时挂着 DR154 DTU(MQTT 透传)和若干 Modbus 设备。三条协议在同一条 RS485 总线上时分复用:

  • SMP-COBS:mcumgr 远程升级
  • JSON:DR154 DTU 透传 MQTT 业务数据
  • Modbus RTU:Modbus 从站

通过首字节路由区分协议。DTS 里 USART3 不挂 modbus-serial 子节点,原因是 Zephyr 的 modbus-serial 要求独占 UART 中断,而这里 USART3 已经被 app_usart3_router.c 独占:

&usart3 {
    pinctrl-0 = <&usart3_tx_pb10 &usart3_rx_pb11>;
    pinctrl-names = "default";
    current-speed = <115200>;
    status = "okay";
};

Modbus 从站改用 CONFIG_MODBUS_RAW_ADU 跑裸 ADU 模式:router 收齐一帧 RTU 后调 modbus_raw_submit_rx 喂给 modbus 库,modbus 库算出响应后通过 raw_tx_cb 回调交回 router 发出去。这样既保住了 router 的协议路由能力,又复用了 Zephyr modbus 库的 RTU 校验和从站接口。

RS485 DE 控制引脚

USART3 那路 RS485 的方向控制引脚 PA5,单独定义成一个 gpio-leds 兼容节点。高电平发送、低电平接收:

rs485_de: rs485-de {
    compatible = "gpio-leds";
    de_line: de-line {
        gpios = <&gpioa 5 GPIO_ACTIVE_HIGH>;
        label = "RS485 DE";
    };
};

之所以用 gpio-leds compatible,是因为 Zephyr 没有专门的 “gpio-direction-control” binding,gpio-leds 是最贴近的:它本质就是一组能被名字引用、能被 gpio_dt_spec 取到的 GPIO。应用层在 app_usart3_router.c 里通过 gpio_dt_spec 拿到这根线,发送前拉高、发完拉低。

TIM2/3/4/8 流量计脉冲计数

板子接了 4 路涡轮流量计,每路对应一个定时器做脉冲计数:TIM2_CH1→PA15、TIM3_CH1→PA6、TIM4_CH1→PB6、TIM8_CH1→PC6。定时器配成外部时钟模式 1(TI1 作时钟源),Period=0xFFFF、Prescaler=0,每来一个脉冲计数值加 1,溢出后由应用层累加。

&timers2 { st,prescaler = <0>; status = "okay"; };
&timers3 { st,prescaler = <0>; status = "okay"; };
&timers4 { st,prescaler = <0>; status = "okay"; };
&timers8 { st,prescaler = <0>; status = "okay"; };

这里有个坑:Zephyr 的 st,stm32-timers 父节点 binding 不接受 pinctrl,counter 驱动也只支持内部时钟。所以 DTS 里只配 prescaler 和 status,pinmux 和外部时钟模式在 flowmeter.c 里手动调 stm32_pinctrl_configure()LL_TIM_* 寄存器 API 配置。这是 Zephyr 的硬件抽象和 STM32 LL 库混用的典型场景:能用 DTS 描述的尽量 DTS,DTS 表达不了的退化到寄存器层。

LED 运行指示灯

PB5 和 PE5 各接一颗 LED,低电平亮,和原 STM32 工程的 LED.c 保持一致:

/ {
    aliases {
        led0 = &led_r;
        led1 = &led_g;
        watchdog0 = &iwdg;
    };

    leds {
        compatible = "gpio-leds";

        led_r: led_r {
            gpios = <&gpiob 5 GPIO_ACTIVE_LOW>;
            label = "LED Red";
        };

        led_g: led_g {
            gpios = <&gpioe 5 GPIO_ACTIVE_LOW>;
            label = "LED Green";
        };
    };
};

aliases 里把 led0/led1 指过去,应用层就能用 device_get_binding("led0") 或者 LED0_NODE 宏拿到,不用硬编码 GPIO 端口。watchdog0 把 IWDG 也别名出去,喂狗代码不用关心具体看门狗设备名。

IWDG 看门狗

原 STM32 工程直接写 IWDG->KR,迁到 Zephyr 后改用 wdt 驱动:

&iwdg {
    status = "okay";
};

应用层通过 device_get_binding("watchdog0") 拿到设备句柄,调 wdt_setup() 启动,之后周期性调 wdt_feed() 喂狗。Flash 长操作(擦除扇区、写页)期间会临时关中断,需要在擦除回调里主动喂狗,否则看门狗复位。

SPI2 + NM25Q128 外接 16MB NOR Flash

板载 SPI NOR Flash 选的是国产 NOR-MEM 的 NM25Q128E,16MB 容量,挂 SPI2 上做日志存储。引脚分配:PB12=CS(GPIO 软件控制)、PB13=SCK、PB14=MISO、PB15=MOSI。WP# 和 HOLD# 直接接 3V3 拉高,不需要驱动层管。

&spi2 {
    pinctrl-0 = <&spi2_sck_master_pb13 &spi2_miso_master_pb14 &spi2_mosi_master_pb15>;
    cs-gpios = <&gpiob 12 GPIO_ACTIVE_LOW>;
    pinctrl-names = "default";
    status = "okay";

    nm25q128: spi-nor-flash@0 {
        compatible = "jedec,spi-nor";
        reg = <0>;
        spi-max-frequency = <40000000>;
        size = <DT_SIZE_M(128)>;
        status = "okay";
        /* NOR-MEM NM25Q128E 的 JEDEC ID(RDID 0x9f) */
        jedec-id = [52 21 18];

        partitions {
            compatible = "fixed-partitions";
            #address-cells = <1>;
            #size-cells = <1>;

            lfs_partition: partition@10000 {
                label = "lfs";
                reg = <0x00010000 0x00ff0000>;
            };
        };
    };
};

几个要点:

  • compatible = "jedec,spi-nor" 让 Zephyr 的 SPI NOR 驱动接管,启动时通过 RDID 命令读 JEDEC ID 验片
  • jedec-id = [52 21 18] 是 NM25Q128E 的真实 ID,必须填对,否则驱动会拒绝识别
  • spi-max-frequency = <40000000> 上限 40MHz。实际跑不了这么快,因为 SPI2 挂在 APB1(36MHz),分频后最高 18MHz
  • cs-gpios 用 GPIO 软件控制 CS,不用 SPI 硬件 NSS,因为硬件 NSS 在某些场景下时序不好控制
  • size = <DT_SIZE_M(128)> 声明 128Mbit = 16MB,驱动据此计算扇区数

NM25Q128 和 W25Q128 引脚兼容、命令集兼容、JEDEC ID 不同(W25Q128 是 ef 40 18)。如果硬件换片,只需改 jedec-id,驱动层不用动。

SPI NOR 上的 LittleFS 分区

NM25Q128 上只分了一个 LittleFS 分区,从 0x10000 到 0xFFFFFF,约 16MB:

lfs_partition: partition@10000 {
    label = "lfs";
    reg = <0x00010000 0x00ff0000>;
};

前 64KB(0x00000–0x0FFFF)保留,留作 Flash 头部或元数据。LittleFS 在这个分区上跑,负责日志文件、配置快照、运行状态。RAM 占用约 544B(读缓存 + 写缓存 + 文件表),对 64KB SRAM 的 STM32F103ZE 完全没压力。

片内 Flash 分区布局

STM32F103ZE 片内 512KB Flash,page size 2KB。为了跑 MCUboot 双槽升级,分成 5 个分区:

分区label起始地址大小范围用途
boot_partitionmcuboot0x0800000064KB0x08000000–0x0800FFFFbootloader
slot0_partitionimage-00x08010000220KB0x08010000–0x08046FFF应用运行槽
slot1_partitionimage-10x08047000220KB0x08047000–0x0807DFFF升级暂存槽
scratch_partitionscratch0x0807E0004KB0x0807E000–0x0807EFFFMCUboot swap 中转
storage_partitionstorage0x0807F0004KB0x0807F000–0x0807FFFFNVS 配置存储
&flash0 {
    partitions {
        compatible = "fixed-partitions";
        #address-cells = <1>;
        #size-cells = <1>;

        boot_partition: partition@0 {
            label = "mcuboot";
            reg = <0x00000000 0x00010000>;
            read-only;
        };
        slot0_partition: partition@10000 {
            label = "image-0";
            reg = <0x00010000 0x00037000>;
        };
        slot1_partition: partition@47000 {
            label = "image-1";
            reg = <0x00047000 0x00037000>;
        };
        scratch_partition: partition@7e000 {
            label = "scratch";
            reg = <0x0007e000 0x00001000>;
        };
        storage_partition: partition@7f000 {
            label = "storage";
            reg = <0x0007f000 0x00001000>;
        };
    };
};

几个设计取舍:

  • slot 大小 220KB(0x37000),和原 STM32 工程保持一致,避免 MCUboot 校验时镜像大小对不上
  • scratch 扩到 4KB(2 个 page),原工程是 2KB(1 个 page),实际跑 MCUboot swap 时发现 1 个 page 不够用,swap 边界会出问题,扩到 2 个 page 后稳定
  • storage 4KB,给 NVS 或 LittleFS 存配置,2 个 page 是 STM32F1 的最小可擦除单元
  • mcuboot 标 read-only,防止应用层误写 bootloader 区

zephyr,code-partition = &slot0_partition 在 chosen 里指向 slot0,告诉 MCUboot 跳到哪执行、告诉 Zephyr 应用跑在哪个分区。

小结

Zephyr 把板子描述拆成六个文件的好处是职责清晰:YAML 管”是什么”、CMake 管”怎么烧”、Kconfig 管”默认开什么”、DTS 管”硬件长什么样”。Mill 这块板子的 DTS 最值得记的部分,是它把原来散落在 STM32 工程各处的硬件配置集中到了一个文件里——时钟树、三路 USART 的分工、RS485 方向控制、流量计定时器、LED、看门狗、外挂 Flash、片内 Flash 分区,全在 stm32f103ze.dts 里一目了然。换板子时改这一个文件,应用层代码几乎不动,这就是设备树的回报。