安装Zephyr的尝试

学习笔记

Posted by BY CGH on May 7, 2026

呃没错,又是我,我又来开新坑了:
这次尝试移植zephyr~这个是一个比较新的RTOS,由linux基金会提供支持~

windows安装

先装环境
winget install Kitware.CMake Ninja-build.Ninja oss-winget.gperf Python.Python.3.12 Git.Git oss-winget.dtc wget 7zip.7zip 然后找个目录,作为zephyr工作区。 创建一个python虚拟环境 py -3.12 -m venv .venv 然后激活这个环境 先允许当前用户运行脚本:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser 然后激活:

..venv\Scripts\Activate.ps1 再安装west pip install west 因为已经建好了目录,因此 west init . west update 之后就可以按教程来了: Get the Zephyr source code: 获取 Zephyr 源代码:

导出 Zephyr CMake 包 。这样 CMake 就可以自动加载构建 Zephyr 应用程序所需的样板代码。

west zephyr-export
Install Python dependencies using west packages.

使用 west packages 安装 Python 依赖项。

python -m pip install @((west packages pip) -split ' ')

跑完以上的,一个工作区就建好了。这个zephyr有点像ROS,有工作区之类的概念,构建方式也类似。这里我们只用工作区方式做好了,反正库需求不大。 和freertos不同,这个不算一个中间件,反而算是整个开发平台。从MCU出发变成了从RTOS出发,也有点像RT-Thread吧。 因此,安装好后我们第一步就是构建我们自己的BSP,然后在这个板级支持包上进行开发。

构建Zephyr H723xx BSP

首先新建一下目录,apps/base_frame(只要在apps下新建文件夹就行,名字什么的无所谓)。
一个项目的组成主要是:CMakelist.txt,类似于KConfig的prj.conf,还有src/下的核心代码(一般来说也可以加个include这样)
由于Zephyr是在BSP基础上开发,因此我们除了以上的build声明和src核心代码,还需要指明板级包的情况。在我们这种工作区的情况下,板级包就是./board文件夹
在ai帮助下我找到了D:\CODES\Zephyr\zephyr\boards\st\nucleo_h723zg 这个文件夹,根据它的内容,我们的板级结构应该包含:

  • board.yaml::一个描述板子高级元数据的 YAML 文件,包括板名和soc名等。
  • board.dts:用于描述板级硬件连接情况的设备树文件
  • Kconfig.board(在示例里为了区分,后缀名可能是plank,总之是指的板名,这里后缀是.nucleo_h723zg) : 用于把这个SOC和板子注册到Kconfig软件编译系统里面
  • Kconfig.defconfig():用于一些软件功能的默认选项
  • board_name.yaml(在示例里为了区分,文件名可能是plank,总之是板命,这里文件名是nucleo_h723zg):用于测试等的杂项配置文件

然后还有一些可选的文件(zephyr的文档里的可选更多,但是我把常用的都归类到必选了): board.cmake :用来调试和烧录 CMakeLists.txt : 用来引入第三方或者其他超出一般范畴的文件到编译中 doc\index.rst /board.webp:描述板子的图片和文档,贡献用

  • 我这里参考的链接有memfault的https://docs.memfault.com/docs/mcu/zephyr-guide ,在其中他把这种目录结构称之为T2类型拓扑。 还有Zephyr官方的https://docs.zephyrproject.org/latest/hardware/porting/board_porting.html

prj.conf

题外话

看文章的时候意外了解到libc,即标准库,在C中一般是使用<>好包裹的include部分。这部分库通常包含: 字符串操作:strcpy, strlen, strcmp
内存分配:malloc, free
格式化输入输出:printf, sprintf, scanf
数学函数:sin, cos, pow (有些单独归类为 libm)
AI如此解释:

当你写了一句 printf("Hello World"); 时,这段代码是谁实现的?C 语言编译器本身只负责把你写的代码翻译成机器码,并不自带这些函数的实现代码。这就需要提供一个“标准的库”(也就是 libc)链接到你的程序中。  

在 Windows 电脑上,这个库可能是微软提供的 UCRT (Universal C Runtime);在 Linux 电脑上,通常是 GNU C Library (glibc)。但是,这些电脑上的库太庞大了,随便一个函数的实现就可能占用几十 K 甚至几兆的内存,直接放到只有几百 K Flash 和几十 K RAM 的单片机上,根本放不下。

所以,嵌入式世界就需要** “微缩版”的 C 标准库 ** 在Zephyr中,使用的就是picolibc,一个现代化的嵌入式优化的库。 Keil里的microlib,实际上也是这种微缩库;

设备树dts文件的编写格式

Zephyr的设备树由三部分组成,dts,yaml,还有binding后的C文件。 其中dts是描述设备硬件情况的,其格式有点像结构体。从一个根节点’/’出发,节点之间用花括号{}隔开,末尾有分号;就和Struct结构体一样,可以嵌套,嵌套的如果有花括号就称为节点,如果没有就是这个节点的属性。
这里比较重要的属性有compatible,负责标识,一般格式为”(厂商名),(实际设备名)”,注意这个逗号在这里没有特殊含义,就是习惯的命名格式罢了。 .yaml/yml文件里面就存着对应的compatible标签。Cmake会在已经注册好的binding目录里面搜索到对应dts的yml文件,然后校验一下yml的配置是否与dts的一致,如果合法的话就会将这个配置转换为C宏然后提供使用
节点的前面可以加个标签名,如果没有标签名,就必须使用从根节点/开始的绝对路径。
和C一样,DTS也支持覆盖,可以在板级覆盖来修改一些参数,这就是BSP的主要修改部分。

如果你仔细看的话,会发现zephyr的文件夹里有dtsi和dts两种。他们的格式是一样的,但是dtsi是用来表示soc等command include,就是经常复用的部分

以DM-MC02为例的dts

开始编写main.c

zephyr在Zephyr\zephyr\samples\basic目录下有很多范例可供参考。

现在先以这个闪烁为例子:

#include <stdio.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>

/* 1000 msec = 1 sec */
#define SLEEP_TIME_MS   1000

/* The devicetree node identifier for the "led0" alias. */
#define LED0_NODE DT_ALIAS(led0)

/*
 * A build error on this line means your board is unsupported.
 * See the sample documentation for information on how to fix this.
 */
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);

int main(void)
{
	int ret;
	bool led_state = true;

	if (!gpio_is_ready_dt(&led)) {
		return 0;
	}

	ret = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
	if (ret < 0) {
		return 0;
	}

	while (1) {
		ret = gpio_pin_toggle_dt(&led);
		if (ret < 0) {
			return 0;
		}

		led_state = !led_state;
		printf("LED state: %s\n", led_state ? "ON" : "OFF");
		k_msleep(SLEEP_TIME_MS);
	}
	return 0;
}

可以看到,去掉各种注释,实际上代码只有一小段。

#include <stdio.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>

这段只是引用了zephy的核心文件。gpio.h包含了以下所有的gpio操作以及那个宏。 kernel包含了delay等OS API。


#define SLEEP_TIME_MS   1000

/* The devicetree node identifier for the "led0" alias. */
#define LED0_NODE DT_ALIAS(led0)

这段主要是宏,一个定义了任务运行频率,另外一个是访问设备树用的 点进去有段长长的注释:

/**
 * @brief Get a node identifier from /aliases
 *
 * This macro's argument is a property of the `/aliases` node. It
 * returns a node identifier for the node which is aliased. Convert
 * non-alphanumeric characters in the alias property to underscores to
 * form valid C tokens, and lowercase all letters.
 *
 * Example devicetree fragment:
 *
 * @code{.dts}
 *     / {
 *             aliases {
 *                     my-serial = &serial1;
 *             };
 *
 *             soc {
 *                     serial1: serial@40001000 {
 *                             status = "okay";
 *                             current-speed = <115200>;
 *                             ...
 *                     };
 *             };
 *     };
 * @endcode
 *
 * You can use DT_ALIAS(my_serial) to get a node identifier for the
 * `serial@40001000` node. Notice how `my-serial` in the devicetree
 * becomes `my_serial` in the DT_ALIAS() argument. Example usage with
 * DT_PROP() to get the current-speed property:
 *
 * @code{.c}
 *     DT_PROP(DT_ALIAS(my_serial), current_speed) // 115200
 * @endcode
 *
 * @param alias lowercase-and-underscores alias name.
 * @return node identifier for the node with that alias
 */
#define DT_ALIAS(alias) DT_CAT(DT_N_ALIAS_, alias)

顾名思义,这个是个获取节点别名(ALIAS)的宏,但是它实际返回什么呢?
注释里提到它返回节点别名的节点标识符(就是一种define宏),并且格式化为下划线+小写字母数字的c语言字符串格式。 它是一个DT_CAT的重定义。这个的定义如下:


/**
 * @brief Concatenation helper, 2 arguments
 *
 * This and the following macros are used to paste things together
 * with "##" *after* forcing expansion on each argument.
 *
 * We could try to use something like UTIL_CAT(), but the compiler
 * error messages from the util macros can be extremely long when they
 * are misused. This unfortunately happens often with devicetree.h,
 * since its macro-based API is fiddly and can be hard to get right.
 *
 * Keeping things brutally simple here hopefully makes some errors
 * easier to read.
 */
#define DT_CAT(a1, a2) a1 ## a2

a1 ## a2这种语法,是 C 预处理器的Token 拼接运算符(也叫 paste 运算符),就是字面意义上的字符串拼接,但发生在预处理阶段,结果是一个合法的 C 标识符 那么为什么不直接用a1 ## a2而是要再封装一层呢?注释里提到了,直接使用##可能会在宏展开之前就拼接了,例如

#define A_DEF a
#define B_DEF b
#define AB_DEF A_DEF ## B_DEF//结果为A_DEFB_DEF

使用DT_CAT可以避免这种情况 那为什么不用UTIL_CAT()这种现成的宏工具呢?因为这种的报错非常长。使用DT_CAT可以简单地处理这个情况。相关的内容在这个文件里提到了_posts\2026-05-20-一个有趣的编程技巧.md 同样的可以使用DT_NODELABEL()通过标签获取这个节点。也可以使用DT_PATH()输入完整路径获取这个节点。 显然使用别名是比较方便的,因此示例里一般都用这个
继续看,注释里提到DT_PROP,这里是从DTS获取属性的。 例如 ` DT_PROP(DT_ALIAS(my_serial), current_speed) // 115200 ` 就是根据ALIAS获取的节点标识符和已知的属性名,来从节点中提取对应的属性值。同样的也是编译时就展开为常量了 扯远了,现在我们回来看下一行 static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios); 这里就从设备树里面拿到了对应的结构体,可以看到这里有参数LED0_NODEgpios
这个LED0_NODE就是我们刚才获取的节点标识符。这里又涉及一个DT宏,GPIO_DT_SPEC_GET,这个又是另外一个宏的简便形式:GPIO_DT_SPEC_GET_BY_IDX(node_id, prop, 0) 这个宏的意思就是,从node_id指向的节点里面取一个属性的第一个条目。注释里说的prop是属性名,因此我们前面这个找不到定义的gpios就水落石出了:它是一个属性名。继续展开,就是一系列的结构体赋值,然后具体的值使用宏从DTS里面拿,这里就不赘述了 这个结构体gpio_dt_spec如下:

struct gpio_dt_spec {
	/** GPIO device controlling the pin */
	const struct device *port;
	/** The pin's number on the device */
	gpio_pin_t pin;
	/** The pin's configuration flags as specified in devicetree */
	gpio_dt_flags_t dt_flags;
};

可以看到,结构体成员是和我们的设备树里的定义是差不多的:

//这里结构体嵌套太深,我偷懒用AI的解释:
my_node {
    led-gpios = <&gpio0 5 GPIO_ACTIVE_LOW>;
                 │    │   │
                 │    │   └─ flags (GPIO_ACTIVE_LOW / GPIO_ACTIVE_HIGH 等)
                 │    └────── pin 编号 (5)
                 └─────────── GPIO 控制器引用 (&gpio0)
};

在这里,我们就完成了一个完整的流程,将硬件连接情况从dts中拉到了c语言里面。 观察这个示例的overlay(就是在板级基础上修改的dts):

/* SPDX-License-Identifier: Apache-2.0 */

/ {
	aliases {
		led0 = &led0;//我们从这里取得了节点标签
	};

	leds {
		compatible = "gpio-leds";

		led0: led_0 {
			gpios = <&gpio9 0 GPIO_ACTIVE_HIGH>;//我们获取的GPIO属性
			label = "Green LED 0";
		};
	};
};

&gpiote130 {
	status = "okay";
};

&gpio9 {
	status = "okay";
};

也就是相当于我们提前定义了一个结构体,其包含了LED的初始状态和对应引脚等等。 后面main的内容就很简单了,基本上就是对HAL库的抽象和封装。 值得一提的是,这里都使用的是含dt的,实际上也有更符合直觉的指定GPIO口等的API,这里大概是为了方便我们了解设备树吧 也就是说,我们的main.c很简单,就是做些我们需要做的:获取对应的外设,然后初始化

从串口终端开始

现在回到我们的BSP里面。按照RT-Thread的经验,我们应该从实现串口开始。让我们看看…Zephyr自带一套shell,我们先以uart这种方式来访问,就和 RT-Thread的BSP一样。那么我们首先得写个能用的dts,然后使其支持一个uart。

那么一个dts有哪些必要的部分呢?