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_STM32F103XE。Kconfig.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),分频后最高 18MHzcs-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_partition | mcuboot | 0x08000000 | 64KB | 0x08000000–0x0800FFFF | bootloader |
slot0_partition | image-0 | 0x08010000 | 220KB | 0x08010000–0x08046FFF | 应用运行槽 |
slot1_partition | image-1 | 0x08047000 | 220KB | 0x08047000–0x0807DFFF | 升级暂存槽 |
scratch_partition | scratch | 0x0807E000 | 4KB | 0x0807E000–0x0807EFFF | MCUboot swap 中转 |
storage_partition | storage | 0x0807F000 | 4KB | 0x0807F000–0x0807FFFF | NVS 配置存储 |
&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 里一目了然。换板子时改这一个文件,应用层代码几乎不动,这就是设备树的回报。