呃没错,又是我,我又来开新坑了:
这次尝试移植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_NODE和gpios
这个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有哪些必要的部分呢?