序言:标准化、可插拔、大一统的 Zephyr
本站的前一篇文章将 Lua 移植到了 RP2350 裸机上,还集成了 microrl(键盘交互增强)、FatFs(让 MCU 可以读写文件)、TinyUSB MSC(让 Windows 可以读写文件)。然而,这个过程是艰苦的。简而言之:
- 我们需要阅读所有这些第三方库的文档和内部实现,而这些第三方库的代码质量、文档丰富度差异很大。例如 Lua 作为一个复杂的程序,却可以毫无修改地运行在 MCU 上;microrl 非常短小,但我们得去 hack 源代码才能实现想要的功能。
- 为了集成一些基础组件,我们需要写很多胶水代码。例如在 FatFs 提供的文件 API 基础上实现 newlib stub。
- 缺乏任务调度。例如,TinyUSB 要求我们频繁调用
tud_task(),裸机情况下很可能需要在主循环里手动调用,延迟难以控制;用中断也要考虑并发问题(参考 pico sdk 的 usb stdio 模块里的各种 mutex)。
Zephyr RTOS 解决了上述问题。RTOS 的本职工作是调度,但 Zephyr 除了解决调度问题之外,它还是一个标准化、可插拔、大一统的框架。说它标准化,是因为它定义了一整套接口,各个组件是依赖于接口(而非具体实现);说它可插拔,是因为无需用到的功能可以不引入,用到的功能也可以随便换实现,例如 fs 可以轻易从 FatFs 替换成 littlefs;说它大一统,是因为它硬件方面支持很多种 SoC,软件方面原生提供了网络栈、蓝牙协议栈、USB 协议栈等大量刚需功能,开发者无需离开 Zephyr 生态。
口说无凭,我们来看看 fatfs 与 littlefs 为什么可以随意切换。 Zephyr 提供了一个 File System 接口,支持多个 mount point 用于挂载多个文件系统,开发者全程是与 File System API(例如 fs_mount()、fs_read())交互,而非与具体的 FatFs 或 littlefs 的 API 交互。Zephyr 支持很多种 fs 实现,每种 fs 需要将自己注册到 zephyr 框架中,例如 FatFs 的注册:
static const struct fs_file_system_t fatfs_fs = {
.open = fatfs_open,
.close = fatfs_close,
.read = fatfs_read,
.write = fatfs_write,
.lseek = fatfs_seek,
.tell = fatfs_tell,
.truncate = fatfs_truncate,
.sync = fatfs_sync,
.opendir = fatfs_opendir,
.readdir = fatfs_readdir,
.closedir = fatfs_closedir,
.mount = fatfs_mount,
.unmount = fatfs_unmount,
.unlink = fatfs_unlink,
.rename = fatfs_rename,
.mkdir = fatfs_mkdir,
.stat = fatfs_stat,
.statvfs = fatfs_statvfs,
#if defined(CONFIG_FILE_SYSTEM_MKFS) && defined(CONFIG_FS_FATFS_MKFS)
.mkfs = fatfs_mkfs,
#endif
};
static int fatfs_init(void)
{
int rc = fs_register(FS_FATFS, &fatfs_fs);
return rc;
}
我们注意到,fs_file_system_t 结构体相当于一个标准接口,File System API 内部只需要使用这个结构体内的函数,就可以完成文件管理功能,而无需关心底层的 fs 实现。例如,File System 接口提供的 fs_opendir() 函数,就是把请求透传给了底层 fs 实现的 opendir(),详见代码:
int fs_opendir(struct fs_dir_t *zdp, const char *abs_path)
{
// 初始化和条件检查、错误处理已省略
rc = fs_get_mnt_point(&mp, abs_path, NULL); // 获取 mount point
zdp->mp = mp;
rc = zdp->mp->fs->opendir(zdp, abs_path); // 透传
return rc;
}
可见,FatFs 的上层胶水代码,Zephyr 已经帮我们写好了,所以我们可以直接用 Zephyr 的 File System 接口,而不是亲自与 FatFs 打交道;另一方面,Zephyr 也写好了 FatFs 的下层胶水代码,让它利用 Zephyr 提供的 Disk Access 接口读写 flash。请看代码:
DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count)
{
__ASSERT(pdrv < ARRAY_SIZE(PDRV_STR_ARRAY), "pdrv out-of-range\n");
if (disk_access_read(PDRV_STR_ARRAY[pdrv], buff, sector, count) != 0) {
return RES_ERROR;
} else {
return RES_OK;
}
}
于是,通过这一上一下两层胶水代码,FatFs 便被集成到了 Zephyr 生态中。上层应用通过 Zephyr 标准接口读写 FAT 分区;而 FatFs 自己又通过 Zephyr 标准接口读写 flash。对于上层应用和下层应用,具体采用了哪种 fs 实现,几乎是透明的。这种低耦合性,是 Zephyr 实现模块化的关键。
这套原理其实非常平凡,无非是依赖倒置原则,思路也被 TCP/IP 协议栈验证了可行性。然而,设计接口并不是一项 trivial 的工作。太少的接口会导致一些上层功能无法实现;太多的接口会给各个具体实现的维护者带来负担。太过抽象的接口会让本层实现存在样板代码,太过底层的接口又会让上层实现存在样板代码。即使是非常成功的 posix fs 标准,也得通过 ioctl 这种不太雅观的方式来提供扩展性、借助 stdio 这样更高抽象层级的模块提供易用性,可见 API 设计绝非易事。
开发环境搭建 & blink 示例
安装 Zephyr 的过程比较漫长,因为它会把所有的依赖项都下载到电脑上(大约 18GB),即使有些厂商的芯片你永远也用不到。如果下载过程中想要打发时间,笔者强烈建议去看看官方的一篇幻灯片,它解释了 Zephyr 的一些核心概念。
Windows 上安装 Zephyr 的步骤是:
winget install Kitware.CMake Ninja-build.Ninja oss-winget.gperf Python.Python.3.12 Git.Git oss-winget.dtc wget 7zip.7zip
cd $Env:HOMEPATH
python -m venv zephyrproject\.venv
zephyrproject\.venv\Scripts\Activate.ps1
pip install west
west init zephyrproject
cd zephyrproject
west update # 7.5GB
west zephyr-export
west packages pip --install
cd $Env:HOMEPATH\zephyrproject\zephyr
west sdk install # 10.4 GB
接下来,我们就能用 west 指令实现编译、烧录、调试了,不过要注意它仅在 venv 激活时可用。继续配置 IDE,笔者选择的是 CLion,它原生支持了 Zephyr,参考 Jetbrains 文档和 Zephyr 文档进行配置:
- 创建一个 toolchain,点击
Add environment ‣ From file,使用 Zephyr 的.venv\Scripts\activate.bat - 用 IDE 打开 blinky 示例项目,在 Settings 里面找到 West,选择开发板型号
- 右上角的编译和运行按钮应该能用了。调试方面,Cortex-M0 大概能一键调试,但 RP2350 和 ESP32C6 都有点麻烦。
对于 CLion 用户,有必要强调一句:不必指望依赖 CLion($\leq$ 2025.2)的内置调试功能。对于 Cortex-M0+、M33、RISC-V,它不太可能正常工作,不要像笔者一样在这件事上浪费若干小时。调试这件事依赖于物理调试器(J-Link、DAPLink、ST-LINK 等)、gdb server(openocd、probe-rs、segger 等)、gdb(arm、riscv 等各种架构)、可选的 gdb 前端(CLion、VS Code 等),这四者必须天衣无缝地协同工作,才能给用户提供良好的图形化调试体验。然而 gdb 前端是最容易出问题的,原因无他,绝大部分 gdb 前端都只考虑了 x86 目标。Zephyr 的典型用法是在 IDE 内写代码、构建、烧录;调试则使用 gdb 命令行。当然 pwndbg 等增强工具也可以使用。
CLion 的 west 支持是纸糊的,IDE 在没有证据的情况下假设 gdb server 一定运行在 3333 端口;使用非 IDE 自带的 gdb 则八成无法正常通讯。
笔者实测,CLion 2025.2.4 环境下,RP2040、ESP32C6、STM32G431、STM32H750、STM32H533 均不能按默认配置正常调试。如果读者知道哪款开发板可以开箱即用地调试,欢迎在评论区告知。
目前来看,debug-rs 是兼容性最强、最稳定的调试软件。
probe-rs gdb --chip <xxx>基本都能成功。
在本文中,我们将使用矽递科技的 XIAO ESP32C6 开发板。ESP32C6 是乐鑫出品的 RISC-V MCU,大核最高频率 160MHz,小核 20MHz。片内集成 512KB SRAM 和 320KB ROM(类似于 RP2350 的 32KB Boot ROM,不可编程,程序必须放在外部 flash),此开发板采用的 ESP32-C6FH4 型号自带 4MB flash。支持 2.4GHz WiFi6 和 BLE 5.3。而且,它自带一个 USB 接口用于提供 JTAG + CDC 功能,插上电脑就能调试,无需额外硬件,非常方便。
接下来,我们动手写一个自己的 blink。具体目标是:LED 默认以 1s 亮、1s 灭的频率闪烁;用户可以通过串口指定 LED 的亮、灭时长。
~/zephyrproject 目录是 Zephyr 的“工作区”,我们用 CLion 在该目录下新建一个项目 demo_blink,创建一个空的 prj.conf 文件,并把 CMakeLists.txt 改为:
cmake_minimum_required(VERSION 4.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) # 引入 Zephyr
project(demo_blink)
set(CMAKE_CXX_STANDARD 20)
target_sources(app PRIVATE src/main.cpp) # 注意这里是 app 而不是 demo_blink
打开 CLion 设置,把 West 的 toolchain 改成之前配置好的 venv 环境,开发板选择 xiao_esp32c6/esp32c6/hpcore 如下:

现在 west 指令应该正常工作了:

~/zephyrproject 是很不自然的。笔者也是如此认为。根据 issue #33521,原作者想要所有人都把项目放进 ~/zephyrproject,以便移除掉 zephyr-env.sh;但其他开发者反对,所以 Zephyr 提供了 ZEPHYR_BASE 作为折衷,允许用户把项目放到非工作区。本文为了简便起见,把项目放在了工作区内。我们的 main.cpp 如下:
#include <zephyr/kernel.h>
#include <ranges>
int main() {
for (const auto i : std::views::iota(0)) {
printf("Hello, world! cnt = %d\n", i);
k_msleep(1000);
}
}
尽管 Zephyr 支持 C++,但我们需要在配置文件中显式地开启。我们构建 guiconfig 这个 cmake target,打开图形界面配置 C++23,并保存到 prj.conf。

现在,我们的 prj.conf 变成了这样:
CONFIG_SERIAL=y
CONFIG_GPIO=y
CONFIG_CONSOLE=y
# CONFIG_STATIC_INIT_GNU is not set
CONFIG_UART_CONSOLE=y
CONFIG_CPP=y
CONFIG_STD_CPP23=y
CONFIG_REQUIRES_FULL_LIBCPP=y
烧录,然后在 CDC 串口上可以看到输出了:

为什么 printf 输出的字符串,会显示在 CDC 串口上?我们来研究一下背后的原理。Zephyr 默认的 libc 不是 newlib 而是 picolibc,所以我们查阅 picolibc 文档,发现它要求存在 stdin、stdout、stderr 这三个 FILE* 类型的全局变量,而 FILE 对象内需要有 put 和 get 函数,用于输出单个字符。下面是 FILE 结构体的定义:
struct __file {
__ungetc_t unget;
__uint8_t flags;
int (*put)(char, struct __file *); /* function to write one char to device */
int (*get)(struct __file *); /* function to read one char from device */
int (*flush)(struct __file *); /* function to flush output to device */
};
typedef struct __file __FILE;
typedef __FILE FILE;
显然,按照 Zephyr 的设计哲学,它会写一层胶水代码,使用标准 Zephyr API 来提供 picolibc 所需的这三个 FILE*。代码如下:
static LIBC_DATA int (*_stdout_hook)(int);
int z_impl_zephyr_fputc(int a, FILE *out)
{
(*_stdout_hook)(a);
return 0;
}
static int picolibc_put(char a, FILE *f)
{
zephyr_fputc(a, f); // 其中会调用 z_impl_zephyr_fputc(c, stream)
return 0;
}
static LIBC_DATA FILE __stdout = FDEV_SETUP_STREAM(picolibc_put, NULL, NULL, 0);
static LIBC_DATA FILE __stdin = FDEV_SETUP_STREAM(NULL, NULL, NULL, 0);
FILE *const stdin = &__stdin;
FILE *const stdout = &__stdout;
STDIO_ALIAS(stderr);
void __stdout_hook_install(int (*hook)(int))
{
_stdout_hook = hook;
__stdout.flags |= _FDEV_SETUP_WRITE;
}
void __stdin_hook_install(unsigned char (*hook)(void))
{
__stdin.get = (int (*)(FILE *)) hook;
__stdin.flags |= _FDEV_SETUP_READ;
}
可见,上面的代码对这三个 FILE* 的定义是:
stdout:具体实现需要用__stdout_hook_install提供stdin:具体实现需要用__stdin_hook_install提供stderr:默认是stdout的别名
那我们查询 __stdout_hook_install 的 caller。发现 drivers/console/uart_console.c 调用了这个函数:
static void uart_console_hook_install(void)
{
#if defined(CONFIG_STDOUT_CONSOLE)
__stdout_hook_install(console_out);
#endif
#if defined(CONFIG_PRINTK)
__printk_hook_install(console_out);
#endif
}
所以,它会将 console_out 作为“输出一个字符”的回调,注册给 stdout 以及 printk。跟进:
static int console_out(int c)
{
if (pm_device_runtime_get(uart_console_dev) < 0) {
return c;
}
if ('\n' == c) {
uart_poll_out(uart_console_dev, '\r');
}
uart_poll_out(uart_console_dev, c);
(void)pm_device_runtime_put_async(uart_console_dev, K_MSEC(1));
return c;
}
这个函数是把 \n 替换成 \r,然后调用 uart_poll_out()。继续跟进:
__pinned_func
static inline void uart_poll_out(const struct device * dev, unsigned char out_char)
{
compiler_barrier();
z_impl_uart_poll_out(dev, out_char);
}
static inline void z_impl_uart_poll_out(const struct device *dev, unsigned char out_char)
{
const struct uart_driver_api *api = (const struct uart_driver_api *)dev->api;
api->poll_out(dev, out_char);
}
于是,“输出一个字符”的任务,实际上被委派给了 uart_console_dev 的 api->poll_out 方法。那么,这个 uart_console_dev 又是如何选定的呢?观察代码:
#define DT_CAT(a1, a2) a1 ## a2
#define DT_CHOSEN(prop) DT_CAT(DT_CHOSEN_, prop)
static const struct device *const uart_console_dev =
DEVICE_DT_GET(DT_CHOSEN(zephyr_console));
所以,uart_console_dev 就是名为 DT_CHOSEN_zephyr_console 的那个设备。执行一遍编译,我们能在 build/zephyr/include/generated/zephyr/devicetree_generated.h 找到它:
#define DT_CHOSEN_zephyr_console DT_N_S_soc_S_uart_6000f000
对应地,地址 6000f000 的设备,定义在 build/zephyr/zephyr.dts:
/* node '/soc/uart@6000f000' defined in zephyr\dts\riscv\espressif\esp32c6\esp32c6_common.dtsi:347 */
usb_serial: uart@6000f000 {
compatible = "espressif,esp32-usb-serial";
reg = < 0x6000f000 0x1000 >;
interrupts = < 0x30 0x0 0x0 >;
interrupt-parent = < &intc >;
clocks = < &clock 0x3 >;
status = "okay";
};
所以,uart_console_dev 实质上是 USB 串口。那么,为什么被选中的 DT_CHOSEN_zephyr_console 是 USB 串口而不是物理串口呢?这是因为我们开发板的设备树中有指定:
chosen {
zephyr,sram = &sramhp;
zephyr,console = &usb_serial; // 这里确定了 DT_CHOSEN_zephyr_console
zephyr,shell-uart = &usb_serial;
zephyr,flash = &flash0;
zephyr,code-partition = &slot0_partition;
zephyr,ieee802154 = &ieee802154;
};
yellow_led。通过设备树,Zephyr 避免了在代码中硬编码与硬件绑定的字符串,大幅增强了可移植性。/chosen 是一个特殊的节点,它表示默认配置。当然,我们也可以通过 app.overlay 文件覆盖掉默认配置。接下来跟进 esp32-usb-serial 模块,它提供了具体的 dev->api->poll_out 实现。我们找到 poll_out 的注册位置:
static DEVICE_API(uart, serial_esp32_usb_api) = {
.poll_in = serial_esp32_usb_poll_in,
.poll_out = serial_esp32_usb_poll_out,
.err_check = serial_esp32_usb_err_check,
};
跟进 serial_esp32_usb_poll_out:
static void serial_esp32_usb_poll_out(const struct device *dev, unsigned char c)
{
struct serial_esp32_usb_data *data = dev->data;
/*
* If there is no USB host connected, this function will busy-wait once for the timeout
* period, but return immediately for subsequent calls.
*/
do {
if (usb_serial_jtag_ll_txfifo_writable()) {
usb_serial_jtag_ll_write_txfifo(&c, 1);
usb_serial_jtag_ll_txfifo_flush();
data->last_tx_time = k_uptime_get();
return;
}
} while ((k_uptime_get() - data->last_tx_time) < USBSERIAL_POLL_OUT_TIMEOUT_MS);
}
我们已经追踪到了 Zephyr 的尽头,再往下就到乐鑫提供的 HAL 库了。回顾一个字符串从调用 printf 到输出到终端的过程:
- 用户程序调用
printf() - picolibc 调用
stdout->put() stdout是由 Zephyr 提供的胶水代码定义的,它的put()函数会调用到_stdout_hook()- Zephyr 的
drivers/console/uart_console.c会调用__stdout_hook_install,将console_out注册为_stdout_hook函数 console_out()会把\n替换成\r,然后调用uart_console_dev的api->poll_out()函数uart_console_dev实为 USB 串口,其poll_out()函数会调用 ESP HAL 库
于是,我们也可以梳理出模块结构:用户程序 $\to$ picolibc $\to$ uart console $\to$ usb serial $\to$ HAL。这个抽象层次与 pico sdk 差异不大,不过 pico sdk 支持同时在物理串口和 CDC 串口输出。由于架构原因,想在 Zephyr 上支持 printf 同时输出到两个串口,还是略微麻烦一点。
搞定了 stdio,我们来看 GPIO 控制。由于开发板的设备树已经定义好了 yellow_led,我们直接拿来用:
#include <zephyr/kernel.h>
#include <ranges>
#include "zephyr/drivers/gpio.h"
#define LED_NODE DT_NODELABEL(yellow_led)
static const gpio_dt_spec led = GPIO_DT_SPEC_GET(LED_NODE, gpios);
int main() {
gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
for (const auto i : std::views::iota(0)) {
gpio_pin_set_dt(&led, 1);
printf("[%d] led = on\n", i);
k_msleep(1000);
gpio_pin_set_dt(&led, 0);
printf("[%d] led = off\n", i);
k_msleep(1000);
}
}
LED 开始按照预期行为闪烁:

我们已经验证了 LED 的操控能力,现在来讨论如何实现“通过串口指定周期”。我们得有一个线程执行死循环,用于频繁开关 LED;另外,我们得有一个线程负责与用户交互。这两个线程之间也是要通讯的,用户交互线程需要通过某种方式,把新的周期值告知 PWM 线程。我们在这里采用队列实现。定义队列:
struct blink_config_t {
int ms_on, ms_off;
};
K_MSGQ_DEFINE(blink_config_mq, sizeof(blink_config_t), 1, alignof(blink_config_t));
Zephyr 中大量使用宏来方便用户静态定义对象。K_MSGQ_DEFINE 宏的第一个参数是队列名,然后是单个元素的长度,然后是环形缓冲区大小;最后一个参数是对齐参数,要求是 2 的幂,一般可以使用 alignof 在编译期自动算出一个合适的值。上面的代码就是定义了一个 blink_config_t 的消息队列,缓冲区只容纳一条消息。
接下来是两个线程:
void blink_task() {
static blink_config_t current_config = {1000, 1000}, new_config;
gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
auto set_led = [](const int value, const int ms_delay) {
gpio_pin_set_dt(&led, value);
printf("[%9lld] set led = %d\n", k_uptime_get(), value);
k_msleep(ms_delay);
};
while (true) {
if (k_msgq_get(&blink_config_mq, &new_config, K_NO_WAIT) == 0) {
current_config = new_config;
}
set_led(1, current_config.ms_on);
set_led(0, current_config.ms_off);
}
}
K_THREAD_DEFINE(t_blink, 0x1000, blink_task, NULL, NULL, NULL, 7, 0, 0);
void shell_task() {
static blink_config_t config;
console_getline_init();
while (true) {
const char *s = console_getline();
sscanf(s, "%d %d", &config.ms_on, &config.ms_off);
k_msgq_put(&blink_config_mq, &config, K_FOREVER);
puts("OK");
}
}
K_THREAD_DEFINE(t_shell, 0x1000, shell_task, NULL, NULL, NULL, 7, 0, 0);
我们采用 console_getline() 而不是 scanf() 来读取用户输入,因为 picolibc 的 scanf() 会立即返回。要使用 console getline 功能,我们得在 conf 中打开 CONFIG_CONSOLE_SUBSYS=y 和 CONFIG_CONSOLE_GETLINE=y。
k_msgq_get 的功能是读取队列,它的最后一个参数决定了是否阻塞。除了 K_NO_WAIT(不阻塞)、K_FOREVER(无限期等待),还可以指定具体的等待时间。K_THREAD_DEFINE 是用于静态定义线程的宏,它的神奇之处在于,它不仅可以定义 k_thread* 变量、栈、启动参数、优先级等属性,还能让这个线程在指定时刻启动。K_THREAD_DEFINE 最后一个参数是 delay,决定了这个线程的启动延迟,以毫秒计。我们来追踪一下这种魔法具体是如何实现的。跟进 K_THREAD_DEFINE:
#define K_THREAD_DEFINE(name, stack_size, \
entry, p1, p2, p3, \
prio, options, delay) \
K_THREAD_STACK_DEFINE(_k_thread_stack_##name, stack_size); \
Z_THREAD_COMMON_DEFINE(name, stack_size, entry, p1, p2, p3, \
prio, options, delay)
它在全局变量区定义了一段线程栈空间,然后调用 Z_THREAD_COMMON_DEFINE 定义线程。跟进:
#define Z_THREAD_COMMON_DEFINE(name, stack_size, \
entry, p1, p2, p3, \
prio, options, delay) \
struct k_thread _k_thread_obj_##name; \
STRUCT_SECTION_ITERABLE(_static_thread_data, \
_k_thread_data_##name) = \
Z_THREAD_INITIALIZER(&_k_thread_obj_##name, \
_k_thread_stack_##name, stack_size,\
entry, p1, p2, p3, prio, options, \
delay, name); \
__maybe_unused const k_tid_t name = (k_tid_t)&_k_thread_obj_##name
这段代码定义了 k_thread 和 k_tid_t(就是我们给这个线程指定的名字,实际类型为 k_thread*)。最引人注目的是 STRUCT_SECTION_ITERABLE,将它层层展开:
Z_DECL_ALIGN(_static_thread_data) \
_k_thread_data_##name \
__in_section(__static_thread_data, static, _CONCAT(_k_thread_data_##name, _)) \
__used __noasan \
= Z_THREAD_INITIALIZER(
&_k_thread_obj_##name,
_k_thread_stack_##name, stack_size,
entry, p1, p2, p3, prio, options,
delay, name)
其中,Z_THREAD_INITIALIZER 就是把一系列参数装进 _static_thread_data 结构体。于是,这段代码的语义是:“把线程参数拼成一个结构体,放在 __static_thread_data 这个段”。我们来看看这个段:

ESP32-C6 手册中的地址映射表:

导出表中,__static_thread_data_list_start 这个符号记录了 _static_thread_data_area 段的起始地址。我们转进 kernel/init.c 看看静态线程的初始化逻辑:
static void z_init_static_threads(void)
{
STRUCT_SECTION_FOREACH(_static_thread_data, thread_data) {
z_setup_new_thread(
thread_data->init_thread,
thread_data->init_stack,
thread_data->init_stack_size,
thread_data->init_entry,
thread_data->init_p1,
thread_data->init_p2,
thread_data->init_p3,
thread_data->init_prio,
thread_data->init_options,
thread_data->init_name);
thread_data->init_thread->init_data = thread_data;
}
k_sched_lock();
STRUCT_SECTION_FOREACH(_static_thread_data, thread_data) {
k_timeout_t init_delay = Z_THREAD_INIT_DELAY(thread_data);
if (!K_TIMEOUT_EQ(init_delay, K_FOREVER)) {
thread_schedule_new(thread_data->init_thread,
init_delay);
}
}
k_sched_unlock();
}
它会遍历每一个静态线程,按照 init_delay 参数安排启动。值得关注的是 STRUCT_SECTION_FOREACH 这个宏:
#define STRUCT_SECTION_FOREACH(struct_type, iterator) \
STRUCT_SECTION_FOREACH_ALTERNATE(struct_type, struct_type, iterator)
#define STRUCT_SECTION_FOREACH_ALTERNATE(secname, struct_type, iterator) \
TYPE_SECTION_FOREACH(struct struct_type, secname, iterator)
#define TYPE_SECTION_FOREACH(type, secname, iterator) \
TYPE_SECTION_START_EXTERN(type, secname); \
TYPE_SECTION_END_EXTERN(type, secname); \
for (type * iterator = TYPE_SECTION_START(secname); ({ \
__ASSERT(iterator <= TYPE_SECTION_END(secname),\
"unexpected list end location"); \
iterator < TYPE_SECTION_END(secname); \
}); \
iterator++)
#define TYPE_SECTION_START_EXTERN(type, secname) \
extern type TYPE_SECTION_START(secname)[]
#define TYPE_SECTION_START(secname) _CONCAT(_##secname, _list_start)
#define TYPE_SECTION_END(secname) _CONCAT(_##secname, _list_end)
所以,这个宏的意思就是,从 _secname_list_start 开始,到 _secname_list_end 为止,迭代 type 对象的数组。其中 _secname_list_start 是 extern 的,实际来源是链接器脚本 linker_zephyr_pre0.cmd:
_static_thread_data_area : ALIGN_WITH_INPUT {
__static_thread_data_list_start = .;
KEEP(*(SORT_BY_NAME(.__static_thread_data.static.*)));
__static_thread_data_list_end = .;;
} > drom0_0_seg AT > FLASH
这个链接器脚本是编译时生成的。生成它的代码乃是 linker/iterable_sections.h:
#define ITERABLE_SECTION_ROM(struct_type, subalign) \
SECTION_PROLOGUE(struct_type##_area, ,) \
{ \
Z_LINK_ITERABLE(struct_type); \
} GROUP_ROM_LINK_IN(RAMABLE_REGION, ROMABLE_REGION)
#define Z_LINK_ITERABLE(struct_type) \
PLACE_SYMBOL_HERE(_CONCAT(_##struct_type, _list_start)); \
KEEP(*(SORT_BY_NAME(._##struct_type.static.*))); \
PLACE_SYMBOL_HERE(_CONCAT(_##struct_type, _list_end));
至此,我们明白了静态线程是如何自动运行的:
- 使用
K_THREAD_DEFINE定义线程时,会在全局变量创建栈空间、线程指针等,同时在_static_thread_data_area段放置一个_static_thread_data结构体 - 链接时,链接器脚本会按照
_static_thread_data_area的大小,定义__static_thread_data_list_start和__static_thread_data_list_end - 程序启动时,通过上述头、尾偏移,遍历所有
_static_thread_data结构体并安排启动
K_MSGQ_DEFINE 和 K_THREAD_DEFINE 这两个宏来静态定义。这样做的好处包括:相关结构(例如线程栈)可以在编译期就留下内存空间,避免了动态内存分配;方便对齐(文档提到 MPU 可能要求对齐);解耦(在自己的 C 文件中使用 K_THREAD_DEFINE 就能让线程自动运行起来,无需显式注册)。除此之外,我们可以把 main() 函数删掉,因为 Zephyr 程序的入口点并非 main() 函数。在 Zephyr 中,main() 函数无非是一个线程,退出了也不影响其他线程,这一点与 pico sdk 不一样。为了通过编译,Zephyr 会提供一个 main() 的弱实现,里面什么也不做。
刷入程序,正常工作:

有一项设计哲学上的事值得讨论。在 pico sdk 中,我们是将 sdk 作为库链接到自己的程序,cmake 文件的写法是 target_link_libraries(my_blink pico_stdlib)。然而,在 Zephyr 中,cmake 写法是 target_sources(app PRIVATE src/main.cpp),即把自己的源码添加到 app 这个 target 内,其中 app 是由 Zephyr 预定义的。所以 Zephyr 是框架而不是库。
再次集成 Lua REPL
前一篇文章中,我们在 RP2350 上集成了 Lua + microrl + FatFs + MSC。下面我们在 Zephyr 上再次集成 Lua REPL 和 fs。由于开发板上唯一的 USB 端口已经连接到 JTAG 了,我们这次不集成 MSC。
第一步依然是把 liblua 跑起来。新建一个项目 demo_lua,将 lua-5.4.8.tar.gz 解压到项目的 lib 文件夹下,lua cmake 如下:
cmake_minimum_required(VERSION 4.0)
project(lua_548 C)
zephyr_library_named(lua)
zephyr_library_sources(lapi.c lcode.c lctype.c ldebug.c ldo.c ldump.c lfunc.c lgc.c llex.c lmem.c lobject.c lopcodes.c lparser.c lstate.c lstring.c ltable.c ltm.c lundump.c lvm.c lzio.c lauxlib.c lbaselib.c lcorolib.c ldblib.c liolib.c lmathlib.c loadlib.c loslib.c lstrlib.c ltablib.c lutf8lib.c linit.c)
zephyr_include_directories(${CMAKE_CURRENT_LIST_DIR})
我们使用了 zephyr_ 前缀的指令,而不是原生 cmake 指令。这是因为 liblua.a 是在编译 app 之前先静态编译的,如果我们按照前一篇文章那样使用 cmake 的 add_library(lua STATIC lapi.c ...),则编译 liblua 时不会带上 Zephyr 的 CFLAGS(这个行为可以通过 compile_commands.json 观察到),采用的 libc 不是 picolibc。所以必须使用 zephyr_ 相关指令来送入编译选项。
另外,有趣的是,zephyr_include_directories 实际上是给 zephyr_interface 这个 target 添加 header 目录,而不是 lua target。这会造成 header 意外泄露(不需要 lua 的程序也能访问到 lua 的 header),我们可以改用 target_include_directories(lua PUBLIC ${CMAKE_CURRENT_LIST_DIR}),不过本文先继续使用 zephyr_include_directories。
项目的 cmake:
cmake_minimum_required(VERSION 4.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(demo_lua)
add_subdirectory(lib/lua-5.4.8/src)
target_sources(app PRIVATE src/main.c)
target_link_libraries(app PRIVATE lua)
prj.conf 文件:
CONFIG_SERIAL=y
CONFIG_GPIO=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
CONFIG_CONSOLE_SUBSYS=y
CONFIG_CONSOLE_GETLINE=y
CONFIG_DEBUG_OPTIMIZATIONS=y
CONFIG_POSIX_API=y # 这个要开,否则找不到 open 等 API
先尝试调用最基本的 Lua 功能:
#include "zephyr/kernel.h"
#include "lua.h"
#include "lauxlib.h"
#include "lualib.h"
int main() {
lua_State *L = luaL_newstate();
luaL_openlibs(L);
while (true) {
luaL_dostring(L, "print(114 * 514)");
k_msleep(1000);
}
return 0;
}
顺利跑通,接下来的任务是实现 REPL。我们无需集成 microrl,因为 Zephyr 的 console_getline() API 本身就支持回显、方向键移动光标、退格键删除。
#include "zephyr/kernel.h"
#include "zephyr/console/console.h"
#include "lua.h"
#include "lauxlib.h"
#include "lualib.h"
int main() {
lua_State *L = luaL_newstate();
luaL_openlibs(L);
console_getline_init();
while (true) {
printf("\033[32mlua >\033[0m ");
const char *s = console_getline();
const int ret = luaL_dostring(L, s);
if (ret != LUA_OK) {
const char *err = lua_tostring(L, -1);
printf("Error: %s\n", err);
lua_pop(L, 1);
}
}
return 0;
}
工作正常:

不过 io.open 永远返回 nil。我们下一步工作是集成 fs。
集成 littlefs:Disk Access 和 open/read
对于简单的 flash 而言,littlefs 是比 FatFs 更好的选择。它是 CoW 的,突然断电也不会导致文件系统损坏,而且有专为 flash 设计的磨损均衡。littlefs 的设计文档值得一读,软件工程和算法水平俱佳。
前文提到,我们不是直接与 littlefs 交互,而是与 Zephyr 的 File System API 交互。而 FatFs 依赖于 Disk Access API,所以我们得先确保 Disk Access 工作正常。
按照文档,如果想把 flash 分区作为 block device,我们需要修改设备树。先来看看目前的开发板设备树:
/dts-v1/;
#include <espressif/esp32c6/esp32c6_wroom_n4.dtsi>
#include "xiao_esp32c6-pinctrl.dtsi"
#include <zephyr/dt-bindings/input/input-event-codes.h>
#include <espressif/partitions_0x0_default.dtsi>
#include "seeed_xiao_connector.dtsi"
/ {
model = "Seeed XIAO ESP32C6 HP Core";
compatible = "seeed,xiao-esp32c6";
chosen {
zephyr,sram = &sramhp;
zephyr,console = &usb_serial;
zephyr,shell-uart = &usb_serial;
zephyr,flash = &flash0;
zephyr,code-partition = &slot0_partition;
zephyr,ieee802154 = &ieee802154;
};
leds: leds {
compatible = "gpio-leds";
yellow_led: led_0 {
gpios = <&gpio0 15 GPIO_ACTIVE_LOW>;
label = "User LED1";
};
};
aliases {
led0 = &yellow_led;
watchdog0 = &wdt0;
};
rf_switch: rf_switch {
compatible = "seeed,xiao-gpio-rf-switch";
enable-gpios = <&gpio0 3 GPIO_ACTIVE_LOW>;
select-gpios = <&gpio0 14 GPIO_ACTIVE_HIGH>;
};
};
&trng0 {
status = "okay";
};
&gpio0 {
status = "okay";
};
&wdt0 {
status = "okay";
};
&usb_serial {
status = "okay";
};
&i2c0 {
status = "okay";
clock-frequency = <I2C_BITRATE_FAST>;
pinctrl-0 = <&i2c0_default>;
pinctrl-names = "default";
};
&spi2 {
#address-cells = <1>;
#size-cells = <0>;
status = "okay";
pinctrl-0 = <&spim2_default>;
pinctrl-names = "default";
};
&uart0 {
status = "okay";
current-speed = <115200>;
pinctrl-0 = <&uart0_default>;
pinctrl-names = "default";
};
&wifi {
status = "okay";
};
&ieee802154 {
status = "okay";
};
&esp32_bt_hci {
status = "okay";
};
它引用了 partitions_0x0_default.dtsi:
#include <espressif/partitions_0x0_default_4M.dtsi>
继续跟进 partitions_0x0_default_4M.dtsi:
&flash0 {
partitions {
compatible = "fixed-partitions";
#address-cells = <1>;
#size-cells = <1>;
boot_partition: partition@0 {
label = "mcuboot";
reg = <0x0 DT_SIZE_K(64)>;
};
sys_partition: partition@10000 {
label = "sys";
reg = <0x10000 DT_SIZE_K(64)>;
};
slot0_partition: partition@20000 {
label = "image-0";
reg = <0x20000 DT_SIZE_K(1792)>;
};
slot1_partition: partition@1e0000 {
label = "image-1";
reg = <0x1E0000 DT_SIZE_K(1792)>;
};
slot0_lpcore_partition: partition@3a0000 {
label = "image-0-lpcore";
reg = <0x3A0000 DT_SIZE_K(32)>;
};
slot1_lpcore_partition: partition@3a8000 {
label = "image-1-lpcore";
reg = <0x3A8000 DT_SIZE_K(32)>;
};
storage_partition: partition@3b0000 {
label = "storage";
reg = <0x3B0000 DT_SIZE_K(192)>;
};
scratch_partition: partition@3e0000 {
label = "image-scratch";
reg = <0x3E0000 DT_SIZE_K(124)>;
};
coredump_partition: partition@3ff000 {
label = "coredump";
reg = <0x3FF000 DT_SIZE_K(4)>;
};
};
};
我们可以整理出表格:
| label | 起始位置 | 长度 |
|---|---|---|
| mcuboot | 0 | 64KB |
| sys | 0x10000 | 64KB |
| image-0 | 0x20000 | 1792KB |
| image-1 | 0x1E0000 | 1792KB |
| image-0-lpcore | 0x3A0000 | 32KB |
| image-1-lpcore | 0x3A8000 | 32KB |
| storage | 0x3B0000 | 192KB |
| image-scratch | 0x3E0000 | 124KB |
| coredump | 0x3FF000 | 4KB |
根据 flash 分区的相关文档,mcuboot、image-0、image-1、image-0-lpcore、image-1-lpcore、image-scratch 是 MCUBoot 定义的;storage 分区是用于 fs、KV 存储等用途。由于我们不会用到 KV 存储,所以正适合拿来当 littlefs 分区。根据 Disk Access 文档,我们编写 app.overlay:
/ {
mydisk {
compatible = "zephyr,flash-disk";
partition = <&storage_partition>;
disk-name = "mydisk";
cache-size = <4096>;
};
};
打开 Disk Access 功能:
CONFIG_DISK_ACCESS=y
CONFIG_DISK_DRIVERS=y
CONFIG_DISK_DRIVER_FLASH=y
写点代码测试一下:
void test_disk_access() {
const static struct flash_area *area;
flash_area_open(FIXED_PARTITION_ID(storage_partition), &area);
static struct flash_sector sec[1];
uint32_t cnt = 1;
flash_area_sectors(area, &cnt, sec);
printf(
"flash area :: ID = %d, offset = %lu, size = %lu, sector size = %d\n",
FIXED_PARTITION_ID(storage_partition),
FIXED_PARTITION_OFFSET(storage_partition),
FIXED_PARTITION_SIZE(storage_partition),
sec[0].fs_size
);
int ret = disk_access_init("mydisk");
printf("disk_access_init: ret = %d\n", ret);
static uint8_t buf[4096];
ret = disk_access_read("mydisk", buf, 0, 1);
printf("disk_access_read: ret = %d\n", ret);
printf("first 4 byte: %02x %02x %02x %02x\n", buf[0], buf[1], buf[2], buf[3]);
static const uint8_t s[4096] = "\xca\xfe\xba\xbe";
ret = disk_access_write("mydisk", s, 0, 1);
printf("disk_access_write: ret = %d\n", ret);
ret = disk_access_read("mydisk", buf, 0, 1);
printf("disk_access_read: ret = %d\n", ret);
printf("first 4 byte: %02x %02x %02x %02x\n", buf[0], buf[1], buf[2], buf[3]);
// 强制刷新缓存
ret = disk_access_ioctl("mydisk", DISK_IOCTL_CTRL_SYNC, NULL);
printf("disk_access_ioctl SYNC: ret = %d\n", ret);
fflush(stdout);
}
使用 probe-rs reset --core 0 发送复位指令,这样 CDC 串口不会中断。观察到启动后的输出:

disk_access_ioctl("mydisk", DISK_IOCTL_CTRL_SYNC, NULL),则变更被缓存起来,不会落盘。我们已经确认了 Disk Access 工作正常,接下来该让 littlefs 把这个分区用起来。先打开一些设置:
CONFIG_FILE_SYSTEM_LITTLEFS=y
CONFIG_POSIX_API=y
从示例抄点代码:
#include "zephyr/fs/littlefs.h"
FS_LITTLEFS_DECLARE_DEFAULT_CONFIG(storage);
static struct fs_mount_t lfs_storage_mnt = {
.type = FS_LITTLEFS,
.fs_data = &storage,
.storage_dev = (void *) FIXED_PARTITION_ID(storage_partition),
.mnt_point = "/lfs",
};
void test_fs() {
int ret = fs_mount(&lfs_storage_mnt);
printf("fs_mount: ret = %d\n", ret);
struct fs_file_t file;
fs_file_t_init(&file);
ret = fs_open(&file, "/lfs/hello.txt", FS_O_CREATE | FS_O_WRITE);
printf("fs_open: ret = %d\n", ret);
const char str[] = "hello, world!";
ret = fs_write(&file, str, sizeof(str));
printf("fs_write: ret = %d\n", ret);
fs_close(&file);
printf("fs_close: ret = %d\n", ret);
fs_file_t_init(&file);
ret = fs_open(&file, "/lfs/hello.txt", FS_O_READ);
printf("fs_open: ret = %d\n", ret);
static char buf[1024];
ret = fs_read(&file, buf, sizeof(buf));
printf("fs_read: ret = %d\n", ret);
printf("%s\n", buf);
}
File System API 工作正常:
fs_mount: ret = 0
fs_open: ret = 0
fs_write: ret = 14
fs_close: ret = 14
fs_open: ret = 0
fs_read: ret = 14
hello, world!
由于我们已经打开了 posix API 支持,所以来测试一下 posix 的 API。先是 read/write:
void test_fs_posix_read_write() {
const int fd = open("/lfs/posix_raw.txt", O_CREAT | O_RDWR | O_TRUNC);
printf("open: fd = %d, errno = %d (%s)\n", fd, errno, strerror(errno));
const char *msg = "hello world";
int ret = write(fd, msg, strlen(msg));
printf("write: ret = %d, errno = %d (%s)\n", ret, errno, strerror(errno));
lseek(fd, 0, SEEK_SET);
char buf[1024];
ret = read(fd, buf, 100);
printf("read: ret = %d, errno = %d (%s)\n", ret, errno, strerror(errno));
ret = close(fd);
printf("close: ret = %d, errno = %d (%s)\n", ret, errno, strerror(errno));
}
结果:
open: fd = -1, errno = 23 (Too many open files in system)
write: ret = -1, errno = 9 (Bad file number)
read: ret = -1, errno = 9 (Bad file number)
close: ret = -1, errno = 9 (Bad file number)
查阅一些资料之后,发现问题在 CONFIG_ZVFS_OPEN_ADD_SIZE_POSIX 这个配置项。默认情况它等于 3,也就是 stdin、stdout、stderr,除此之外开不了新的 fd。在 prj.conf 中将这个值改为 100:
CONFIG_ZVFS_OPEN_ADD_SIZE_POSIX=100
于是 read/write 正常工作了:
open: fd = 3, errno = 0 (Success)
write: ret = 11, errno = 0 (Success)
read: ret = 11, errno = 0 (Success)
close: ret = 0, errno = 0 (Success)
fwrite 调试:从 picolibc 转向 newlib
lua 使用的不是裸 open/read/write,而是 fopen 等高级 API。我们也来测一下可用性:
void test_fs_posix_fopen() {
FILE *fp = fopen("/lfs/posix.txt", "w+");
printf("fopen: fp = %p, errno = %d (%s)\n", fp, errno, strerror(errno));
const char *msg = "hello world";
int ret = fwrite(msg, sizeof(char), strlen(msg), fp);
printf("fwrite: ret = %d, errno = %d (%s)\n", ret, errno, strerror(errno));
ret = fseek(fp, 0, SEEK_SET);
printf("fseek: ret = %d, errno = %d (%s)\n", ret, errno, strerror(errno));
char buf[1024];
ret = fread(buf, sizeof(char), 100, fp);
printf("fread: ret = %d, errno = %d (%s)\n", ret, errno, strerror(errno));
ret = fclose(fp);
printf("fclose: ret = %d, errno = %d (%s)\n", ret, errno, strerror(errno));
}
出了些意外:
fopen: fp = 0x4080d3f8, errno = 0 (Success)
fwrite: ret = 0, errno = 0 (Success)
fseek: ret = -1, errno = 29 (Illegal seek)
这里 fopen 是成功的,但 fwrite 返回值是 0 且 errno 为 0,很不正常,接下来 fseek 就失败了。由于 picolibc 是以 .a 文件形式分发的,我们在 CLion 里也追不到源码。所以我们一边用 IDA 逆编译产物,一边看 Github 上的 picolibc 代码。

这个 fwrite 内部就是连续调用 stream->put 函数,然而,这个 put 函数是我们在 blink 章节追踪过的老朋友——它是用来实现 stdio 的。

可想而知,如果没有逻辑来注册这里的 put 和 get 回调,那 fwrite() 就不可能正常工作。现在来动态调试。启动 gdb server,用 riscv32-esp-elf-gdb 连接:
# 用乐鑫版 openocd 启动 gdb server
C:\Users\neko\Documents\Software\openocd-esp32\bin\openocd.exe -f 'C:/Users/neko/zephyrproject/zephyr/boards/seeed/xiao_esp32c6\support\openocd.cfg' -c 'gdb_port 3333'
# 或者用 probe-rs 启动 gdb server
probe-rs gdb --chip esp32c6 --gdb C:\Users\neko\zephyr-sdk-0.17.4\riscv64-zephyr-elf\bin\riscv64-zephyr-elf-gdb.exe .\build\zephyr\zephyr.elf
# 另一个窗口,运行 gdb
riscv32-esp-elf-gdb.exe -ex "file ./build/zephyr/zephyr.elf" -ex "target extended-remote 127.0.0.1:3333"
调试过程:
(gdb) info mem
Using memory regions provided by the target.
Num Enb Low Addr High Addr Attrs
0 y 0x00000000 0x00400000 flash blocksize 0x1000 nocache
1 y 0x00400000 0x40000000 rw nocache
2 y 0x40000000 0x40050000 ro nocache
3 y 0x40050000 0x100000000 rw nocache # 这里其实有问题,flash 应该是 ro 的
# 插入硬件断点。ESP32C6 不支持在 flash 上设置软件断点
# 参考 https://docs.espressif.com/projects/esp-idf/en/stable/esp32c6/api-guides/jtag-debugging/tips-and-quirks.html
(gdb) hb fwrite
(gdb) monitor reset halt
(gdb) c
# 断在 fwrite 入口点
(gdb) p/x $a3
$4 = 0x4080d3f8
(gdb) x/5wx $a3
0x4080d3f8 <fdtable+144>: 0x40815e84 0x4080e688 0x00000001 0x4080d404
0x4080d408 <fdtable+160>: 0x4080d404
(gdb) x/5wx stdout
0x4080e660 <__stdout>: 0x00000000 0x00000002 0x420043e2 0x00000000
0x4080e670 <__stdout+16>: 0x00000000
(gdb) x/5wx stderr
0x4080e660 <__stdout>: 0x00000000 0x00000002 0x420043e2 0x00000000
0x4080e670 <__stdout+16>: 0x00000000
程序中 __file 结构体的成员分别是 unget、flags、put、get、flush,这里 stdout 和 stderr 的结构都是正常的;而我们通过 fopen 手动打开的 FILE 的 put 是 0x00000001,显然不合法。这是因为,fwrite() 的最后一个参数是 __file 类型,而 fopen() 返回的实为 fd_entry 对象,它们结构不一致。
笔者提交了 issue #100276,但实话说,这个问题非常难解决。picolibc 是静态链接的,这导致我们几乎不可能修改 fwrite 的实现。考虑到我们曾经在 newlib 上正常运行了 lua REPL,我们这次也试试换成 newlib。
newlib:排障、报障、等待修复
我们先用官方 blink 示例验证一下 newlib 能否正常工作。用 guiconfig 选中 newlib:

这个操作等价于 CONFIG_NEWLIB_LIBC=y 配置项。烧录运行,发现程序立即崩溃:
ESP-ROM:esp32c6-20220919
Build:Sep 19 2022
rst:0x3 (LP_SW_HPSYS),boot:0x1e (SPI_FAESP-ROM:esp32c6-20220919
Build:Sep 19 2022
rst:0x18 (JTAG_CPU),boot:0x1e (SPI_FAST_FLASH_BOOT)
Saved PC:0x20000828
SPIWP:0xee
mode:DIO, clock div:2
load:0x40800000,len:0xaa30
load:0x4080aa40,len:0x2bf8
SHA-256 comparison failed:
Calculated: f7db62f58b23edc453be32b8d0366862f1db39c7d6c5c87d25333a790b16b38a
Expected: 0000000090290000000000000000000000000000000000000000000000000000
Attempting to boot anyway...
entry 0x40801cca
I (38) soc_init: ESP Simple boot
I (38) soc_init: compile time Nov 25 2025 10:09:39
I (39) soc_init: chip revision: v0.2
I (39) flash_init: SPI Speed : 80MHz
I (41) flash_init: SPI Mode : DIO
I (45) flash_init: SPI Flash Size : 4MB
I (49) boot: DRAM : lma=00000020h vma=40800000h size=0aa30h ( 43568)
I (55) boot: DRAM : lma=0000aa58h vma=4080aa40h size=02bf8h ( 11256)
I (61) boot: IROM : lma=00010000h vma=42800000h size=01cd0h ( 7376)
I (67) boot: IROM : lma=00020000h vma=42000000h size=0a1b8h ( 41400)
I (73) boot: libc heap size 400 kB.
I (76) spi_flash: detected chip: generic
I (80) spi_flash: flash io: dio
[00:00:00.000,000] <err> os: mtval: 0
[00:00:00.000,000] <err> os: a0: 4080aad0 t0: 400283c2
--- 4 messages dropped ---
[00:00:00.000,000] <err> os: a1: 4080ac90 t1: 408029f2
[00:00:00.000,000] <err> os: a2: 00000001 t2: 00000020
[00:00:00.000,000] <err> os: a3: 00000000 t3: 00000004
[00:00:00.000,000] <err> os: a4: 00000009 t4: 0000004c
[00:00:00.000,000] <err> os: a5: 00000000 t5: aaaaaaaa
[00:00:00.000,000] <err> os: a6: 4080adbc t6: aaaaaaaa
[00:00:00.000,000] <err> os: a7: 0000002e
[00:00:00.000,000] <err> os: sp: 4080f8f0
[00:00:00.000,000] <err> os: ra: 4003e52c
[00:00:00.000,000] <err> os: mepc: 400283ca
[00:00:00.000,000] <err> os: mstatus: 00001880
[00:00:00.000,000] <err> os:
[00:00:00.000,000] <err> os: s0: 4080ac90 s6: 42800208
[00:00:00.000,000] <err> os: s1: 42802000 s7: 00000000
[00:00:00.000,000] <err> os: s2: 4080aad0 s8: 00000000
[00:00:00.000,000] <err> os: s3: 428001f4 s9: 00000000
[00:00:00.000,000] <err> os: s4: 42801c68 s10: 00000000
[00:00:00.000,000] <err> os: s5: 42800000 s11: 00000000
[00:00:00.000,000] <err> os:
[00:00:00.000,000] <err> os: call trace:
[00:00:00.000,000] <err> os: 0: sp: 4080f910 ra: 42006240
[00:00:00.000,000] <err> os: 1: sp: 4080faa0 ra: 42004c56
[00:00:00.000,000] <err> os: 2: sp: 4080fac0 ra: 42000098
[00:00:00.000,000] <err> os: 3: sp: 4080fb30 ra: 4200105c
[00:00:00.000,000] <err> os: 4: sp: 4080fb34 ra: 4200104e
[00:00:00.000,000] <err> os:
[00:00:00.000,000] <err> os: >>> ZEPHYR FATAL ERROR 0: CPU exception on CPU 0
[00:00:00.000,000] <err> os: Current thread: 0x40818140 (unknown)
[00:00:00.094,000] <err> os: Halting system
崩溃原因是非法读写 0 地址。官方 blink 示例都能 crash,有点令人震惊。下面来追踪崩溃原因。我们注意到事发现场 mepc = 400283ca、ra = 4003e52c,回顾 ESP32-C6 手册,0x4000_0000 - 0x4004_FFFF 这 320 KB 的空间是内置的固化 ROM,0x4080_0000 - 0x4087_FFFF 这 512KB 是 SRAM,0x4200_0000 - 0x42FF_FFFF 这 16MB 是 flash。也就是说,从日志来看,是 flash 中的程序调用了 ROM 中的代码片段导致的崩溃。
如果这时去看 IDA 的 0x42006240 附近的代码(vfprintf_r),会发现 MEMORY[0x400004C8]() 这样的调用。这是因为 ELF 里面没有 0x4000_0000 那部分指令和数据,IDA 自然无法解码。不过没关系,我们从 MCU 里面 dump 一份。gdb 指令:
dump binary memory rom.bin 0x40000000 0x40050000
IDA 中选择 File -> Load file -> Additional binary file,装载 ROM 文件:

打开 Segments 这个 subview,标记 R 和 X:

现在,IDA 可以正常跳转了:


慢慢梳理出调用链:
| 代码位置 | 函数 | 调用语句 | 跳转到 |
|---|---|---|---|
| flash:42005F94 | vfprintf_r |
size_t decp_len = strlen (decimal_point); |
rom:400005D0 |
| rom:400005d0 | (跳转表) | j sub_4003E51A |
rom:4003E51A |
| rom:4003E51A | __swsetup_r |
jal sub_400283BE |
rom:400283BE |
| rom:400283BE | (内部函数) | jal t0, sub_4002F44A; lw a5, 4087FFD4h; lw a5, 0(a5) |
rom:400283CA 崩溃 |
我们面临两个问题:一是为什么 newlib 会去调用 ROM 中的__swsetup_r 函数而不是 newlib 本身自带的那个;二是为什么 lw a5, 0(a5) 那一条指令会崩溃。前者显然是链接器导致的,我们找到 zephyrproject\modules\hal\espressif\components\esp_rom\esp32c6\ld\esp32c6.rom.newlib.ld 这个链接器脚本:
/*
* SPDX-FileCopyrightText: 2022 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* ROM function interface esp32c6.rom.newlib.ld for esp32c6
*
*
* Generated from ./target/esp32c6/interface-esp32c6.yml md5sum 06c13e133e0743d09b87aba30d3e213b
*
* Compatible with ROM where ECO version equal or greater to 0.
*
* THIS FILE WAS AUTOMATICALLY GENERATED. DO NOT EDIT.
*/
/***************************************
Group newlib
***************************************/
/* Functions */
esp_rom_newlib_init_common_mutexes = 0x400004a4;
memset = 0x400004a8;
memcpy = 0x400004ac;
memmove = 0x400004b0;
memcmp = 0x400004b4;
strcpy = 0x400004b8;
strncpy = 0x400004bc;
strcmp = 0x400004c0;
strncmp = 0x400004c4;
strlen = 0x400004c8;
strstr = 0x400004cc;
bzero = 0x400004d0;
_isatty_r = 0x400004d4;
sbrk = 0x400004d8;
isalnum = 0x400004dc;
isalpha = 0x400004e0;
isascii = 0x400004e4;
isblank = 0x400004e8;
iscntrl = 0x400004ec;
isdigit = 0x400004f0;
islower = 0x400004f4;
isgraph = 0x400004f8;
isprint = 0x400004fc;
ispunct = 0x40000500;
isspace = 0x40000504;
isupper = 0x40000508;
toupper = 0x4000050c;
tolower = 0x40000510;
toascii = 0x40000514;
memccpy = 0x40000518;
memchr = 0x4000051c;
memrchr = 0x40000520;
strcasecmp = 0x40000524;
strcasestr = 0x40000528;
strcat = 0x4000052c;
strchr = 0x40000534;
strcspn = 0x40000538;
strcoll = 0x4000053c;
strlcat = 0x40000540;
strlcpy = 0x40000544;
strlwr = 0x40000548;
strncasecmp = 0x4000054c;
strncat = 0x40000550;
strnlen = 0x40000558;
strrchr = 0x4000055c;
strsep = 0x40000560;
strspn = 0x40000564;
strtok_r = 0x40000568;
strupr = 0x4000056c;
longjmp = 0x40000570;
setjmp = 0x40000574;
abs = 0x40000578;
div = 0x4000057c;
labs = 0x40000580;
ldiv = 0x40000584;
qsort = 0x40000588;
utoa = 0x40000598;
itoa = 0x4000059c;
__smakebuf_r = 0x400005c0;
__swhatbuf_r = 0x400005c4;
/* ZEPHYR: Keep PROVIDE for these symbols: */
PROVIDE ( strdup = 0x40000530 );
PROVIDE ( strndup = 0x40000554 );
PROVIDE ( rand = 0x40000590 );
PROVIDE ( srand = 0x40000594 );
PROVIDE ( rand_r = 0x4000058c );
PROVIDE ( atoi = 0x400005a0 );
PROVIDE ( atol = 0x400005a4 );
PROVIDE ( strtol = 0x400005a8 );
PROVIDE ( strtoul = 0x400005ac );
/*******************************************/
PROVIDE( fflush = 0x400005b0 );
PROVIDE( _fflush_r = 0x400005b4 );
PROVIDE( _fwalk = 0x400005b8 );
PROVIDE( _fwalk_reent = 0x400005bc );
PROVIDE( __swbuf_r = 0x400005c8 );
__swbuf = 0x400005cc;
__swsetup_r = 0x400005d0;
/* Data (.data, .bss, .rodata) */
syscall_table_ptr = 0x4087ffd4;
_global_impure_ptr = 0x4087ffd0;
所以,链接器抛弃了 newlib 自身的 __swsetup_r 实现,改而把所有使用 __swsetup_r 函数的地方,都引导到 0x400005d0 这个 ROM 中的位置。同时,0x400005d0 这里存放了一个跳转指令 j sub_4003E51A,转到具体实现。这套跳转表原理与 GOT 表是类似的。
接下来分析为什么会 crash。我们来看周围的汇编码:
rom:400283BE # FUNCTION CHUNK AT rom:4002F46E SIZE 0000000C BYTES
rom:400283BE
rom:400283BE jal t0, sub_4002F44A
rom:400283C2 lw a5, 4087FFD4h
rom:400283CA lw a5, 0(a5) # 在此崩溃
rom:400283CC jalr a5
rom:400283CE j loc_4002F46E
rom:400283CE # End of function sub_400283BE
上述汇编码等价于:
t = sub_4002F44A();
x = *0x4087FFD4; // 取出 0
y = *x; // 空指针解引用
y(t);
ROM 里面硬编码了 0x4087FFD4 这个地址,它在 ld 脚本中出现过:
syscall_table_ptr = 0x4087ffd4;
_global_impure_ptr = 0x4087ffd0;
所以,可以推测 0x4087ffd4 理应指向一个 syscall table,这个 table 内的每个项目都指向一个 handler。而 400283BE 函数就是取出并调用了第 0 个 handler。不过,崩溃时,0x4087ffd4 这个位置的内存是:
(gdb) x/20wx 0x4087FFD4
0x4087ffd4: 0x00000000 0x4004d0d0 0x4004d980 0x4004d940
0x4087ffe4: 0x4004d8cc 0x00000000 0x4087fa08 0x4087fa24
0x4087fff4: 0x4080cebc 0x4004a680 0x20000830 0x00000000
0x40880004: 0x00000000 0x00000000 0x00000000 0x00000000
0x40880014: 0x00000000 0x00000000 0x00000000 0x00000000
现在我们整理出了崩溃原因:
- newlib 中,
printf会调用vfprintf_r,后者会进一步调用__swsetup_r - 由于链接器脚本
esp32c6.rom.newlib.ld指定了__swsetup_r由 ROM 提供,故 CPU 会跳转到 ROM 中的这部分代码 - ROM 内的代码从
0x4087FFD4读一个指针并解引用,导致非法访问 NULL 地址
我们知道,__swsetup_r 是用来配置 stdout 的,newlib 对它的实现位于 wsetup.c。既然 newlib 自带了实现,那我们马上可以推论:如果目标机器不是 ESP32-C6 而是 RP2350 或 STM32,则原代码应该直接就能正常工作,无需任何改动。实验一下,果然其他 MCU 都能正常运行。
strlen() 这种函数没有什么问题,因为全世界对它的理解都是一致的;存放 rand() 也没问题,可以用上硬件 RNG;但存放 __swsetup_r() 还是有点不知所谓,它既牵涉到 buffer 动态分配又牵涉到 FILE 的具体结构,与特定版本的 libc 的耦合太深了。那么,我们下一步任务就是让 newlib 别使用 ROM 中的 __swsetup_r。这需要改 ld,但 ld 文件是 Zephyr 写死的,没有配置项,强行修改会导致与主线 Zephyr 源码失去同步,所以最好是促进主线 Zephyr 修复这个 bug。
我们提个 issue:ESP32-C6: crashes when using newlib #100077。issue 于 2025.11.26 下午提出,6 小时后就有开发者提交了修复代码,分别位于 Zephyr 主项目和 hal_espressif 项目:
- zephyr PR #100119 将
hal_espressif的版本需求更新到78f88d79bfdca7e84ec7aafb12c0ddd7440bf3d1 - hal_espressif Commit 1c7f755 修改了 esp32c2.rom.newlib.ld 文件,将 ROM 中的
_isatty_r、__smakebuf_r、__swhatbuf_r、__swsetup_r改为弱实现,从而链接器会选择 newlib 自带的实现而不是 ROM 中的。受影响 MCU 包括 ESP32-C2、ESP32-C6 和 ESP32-H2(均为 RISC-V 架构)。 - hal_espressif Commit 78f88d7 额外修复了笔者没发现的一个缺陷。原先版本的代码中,在初始化时钟时会调用
HAL_LOGW输出日志,但此时HAL_LOGW可能还未准备好。此 commit 将它们改成了ESP_EARLY_LOGW。
Zephyr 的 PR 于 2025.11.29 合入主线。我们更新 Zephyr:
cd ~\zephyrproject\zephyr
git pull
west update
再次编译烧录 blinky 示例,果然正常运行。然而,我们发现一个尴尬的问题:在 Zephyr 的 newlib hooks中,_open 等函数是强实现:
#ifndef CONFIG_POSIX_DEVICE_IO
int _read(int fd, void *buf, int nbytes)
{
ARG_UNUSED(fd);
return zephyr_read_stdin(buf, nbytes);
}
__weak FUNC_ALIAS(_read, read, int);
int _write(int fd, const void *buf, int nbytes)
{
ARG_UNUSED(fd);
return zephyr_write_stdout(buf, nbytes);
}
__weak FUNC_ALIAS(_write, write, int);
int _open(const char *name, int flags, ...)
{
return -1;
}
__weak FUNC_ALIAS(_open, open, int);
int _close(int file)
{
return -1;
}
__weak FUNC_ALIAS(_close, close, int);
#endif /* CONFIG_POSIX_DEVICE_IO */
我们可以通过定义 CONFIG_POSIX_SYSTEM_INTERFACES=y 以及 CONFIG_POSIX_DEVICE_IO=y,把上面这些函数换成 posix device io 提供的实现,代码如下:
int open(const char *name, int flags, ...)
{
int mode = 0;
va_list args;
if ((flags & O_CREAT) != 0) {
va_start(args, flags);
mode = va_arg(args, int);
va_end(args);
}
int zflags = posix_mode_to_zephyr(flags);
return zvfs_open(name, zflags, &posix_op_vtable);
}
// ...
理论上这就是最佳方案。如果我们自己来实现 open 等 newlib stub,写法会和这个几乎一致。但是,在开启 posix device io 的情况下,编译会失败,因为 newlib 自己实现了 fdopen(),而 device_io.c 又实现了一遍 fdopen():
FILE *fdopen(int fd, const char *mode)
{
return zvfs_fdopen(fd, mode);
}
至此,我们陷入了死胡同:用 picolibc 则 fopen 有 bug;用 newlib 则我们无法覆盖 Zephyr 写死的强实现 stub。万般无奈之下,我们只好回到 picolibc,并手动改写 fopen 等函数。
回归 picolibc & 手写 fopen
先来观察 lua 调用了哪些 fs API。从上一篇文章中,我们找到:
- remove, ferror, fread, fclose, fputs, fputc, fgets, fprintf, ftell, feof, fopen, freopen, tmpfile, fflush, ungetc, fseek, rename, fwrite, tmpnam, getc
但这是读 x64 ELF 的 PLT 表读出来的结果。我们想要读 MCU 的 RISC-V ELF 所使用的 libc API 清单,会很麻烦,因为编译产物是静态链接的,大量的函数混在一起。与其从 ELF 里面找 API 调用,还不如从源码里找。我们先从 Zephyr 文档整理出一份 posix fs 符号清单:
POSIX_DEVICE_IO = ['FD_CLR', 'FD_ISSET', 'FD_SET', 'FD_ZERO', 'clearerr', 'close', 'fclose', 'fdopen', 'feof', 'ferror', 'fflush', 'fgetc', 'fgets', 'fileno', 'fopen', 'fprintf', 'fputc', 'fputs', 'fread', 'freopen', 'fscanf', 'fwrite', 'getc', 'getchar', 'gets', 'open', 'perror', 'poll', 'printf', 'pread', 'pselect', 'putc', 'putchar', 'puts', 'pwrite', 'read', 'scanf', 'select', 'setbuf', 'setvbuf', 'stderr', 'stdin', 'stdout', 'ungetc', 'vfprintf', 'vfscanf', 'vprintf', 'vscanf', 'write']
POSIX_FD_MGMT = ['dup', 'dup2', 'fcntl', 'fgetpos', 'fseek', 'fseeko', 'fsetpos', 'ftell', 'ftello', 'ftruncate', 'lseek', 'rewind']
POSIX_FILE_SYSTEM = ['access', 'chdir', 'closedir', 'creat', 'fchdir', 'fpathconf', 'fstat', 'fstatvfs', 'getcwd', 'link', 'mkdir', 'mkstemp', 'opendir', 'pathconf', 'readdir', 'remove', 'rename', 'rewinddir', 'rmdir', 'stat', 'statvfs', 'tmpfile', 'tmpnam', 'truncate', 'unlink', 'utime']
POSIX_FS_ALL = set(POSIX_DEVICE_IO + POSIX_FD_MGMT + POSIX_FILE_SYSTEM)
接下来,用 clang 扫描 lua 的所有 .c 文件,找出函数调用,与上面的清单求交集:
import clang.cindex
import pathlib
clang.cindex.Config.set_library_file(r"C:\Program Files\LLVM\bin\libclang.dll")
index = clang.cindex.Index.create()
func_calls = {}
def visit(cursor):
if cursor.kind == clang.cindex.CursorKind.CALL_EXPR:
args = [
"".join(t.spelling for t in arg.get_tokens())
for arg in cursor.get_arguments()
]
# print(cursor.location.file.name, cursor.location.line, cursor.spelling, args)
func_calls[cursor.location.file.name] |= {cursor.spelling}
for child in cursor.get_children():
visit(child)
for path in pathlib.Path("lua-5.4.8/src").rglob("*.c"):
func_calls[str(path)] = set()
visit(index.parse(str(path)).cursor)
total_fn = set()
for p, fn in func_calls.items():
res = fn & POSIX_FS_ALL
if res:
print(p, res)
total_fn |= res
print(total_fn)
于是获得清单:
lua-5.4.8\src\lauxlib.c {'freopen', 'getc', 'fopen', 'ferror', 'fread', 'fclose'}
lua-5.4.8\src\liolib.c {'fwrite', 'fclose', 'clearerr', 'ungetc', 'getc', 'fopen', 'ferror', 'fread', 'tmpfile', 'fprintf'}
lua-5.4.8\src\loslib.c {'rename', 'remove'}
lua-5.4.8\src\luac.c {'printf'}
{'freopen', 'clearerr', 'ungetc', 'getc', 'fopen', 'rename', 'tmpfile', 'fprintf', 'fwrite', 'printf', 'remove', 'ferror', 'fread', 'fclose'}
下一步就是逐个考察这些函数,看看它们的用途。
freopen:仅被lauxlib.c使用,用于把一个以"r"方式打开的文件重新以"rb"方式打开。当我们调用luaL_loadfile时,会需要用到这个函数。放弃实现。getc、fread、fopen、fclose、fwrite:可以实现。ferror、clearerr:其实不需要,因为 lua 自己有 fallback 实现。tmpfile:仅是为了暴露io.tmpfile()接口。放弃实现。ungetc:仅被liolib.c使用,用于read_number和test_eof,在读入整数时可能调用。我们可以通过对每个FILE *维护一个 buffer 实现,也可以暂缓实现。fprintf:仅被liolib.c使用,只会被用于输出"%lld"和"%.14g"。我们通过sprintf实现。rename和remove:仅os库需要。启用CONFIG_POSIX_FILE_SYSTEM选项时,posix/options/fs.c 基于zvfs_rename和zvfs_unlink提供实现。printf:libc 已实现。
整理一下,我们需要为 lua 提供的是:
- (文件打开和关闭)
fopen、fclose - (文件读写)
getc、fread、fwrite、fprintf
先考虑如何尽可能复用 picolibc 中的代码。我们发现,其实 getc、fread、fwrite、fprintf 都可以正确操作 __file 类型的 stream。只要 fopen 和 fclose 操作的也是 __file 对象,所有的事情都能解决。我们采用一种比较 hack 的方案:修改 liblua 的 cmake 文件,引入 my_libc.c,提前定义 fopen 和 fclose。从而,编译 liblua.a 时,这两个函数会被静态编译进 ELF,而不是声明为 undefined symbol,也就不会在最后编译 app 时把 libc 的 fopen 和 fclose 实现链接进来。
来考虑如何实现 fopen。它通过第二个参数指定如何打开文件,例如 "wb"。picolibc 自带了 __stdio_flags 函数用于把这种 type 字符串转换成 open() 的 flags,但它的标志位与 Zephyr fs 的不一致。例如,Zephyr 的 FS_O_APPEND 是 0x20,而 picolibc 的 O_APPEND 是 0x8,所以我们略加修改。另外还有一件事值得注意:在 picolibc 中,如果 get() 被调用时文件已被读尽,那它应该返回 -2 而不是 -1,因为在 picolibc 中,_FDEV_EOF 是 -2,_FDEV_ERR 才是 -1。
int parse_mode(const char *mode, fs_mode_t *optr) {
int ret;
fs_mode_t o;
switch (mode[0]) {
case 'r': /* open for reading */
ret = __SRD;
o = FS_O_READ;
break;
case 'w': /* open for writing */
ret = __SWR;
o = FS_O_WRITE | FS_O_CREATE | FS_O_TRUNC;
break;
case 'a': /* open for appending */
ret = __SWR;
o = FS_O_RDWR | FS_O_APPEND;
break;
default: /* illegal mode */
errno = EINVAL;
return (0);
}
while (*++mode) {
switch (*mode) {
case '+':
ret |= (__SRD | __SWR);
o |= FS_O_RDWR;
break;
default:
break;
}
}
*optr = o;
return ret;
}
struct my_file_entry {
struct __file io_file;
struct fs_file_t fs_file;
};
#define GET_ENTRY(fp) ((struct my_file_entry *) fp)
int my_fs_put(char c, struct __file *f) {
return fs_write(&GET_ENTRY(f)->fs_file, &c, 1);
}
int my_fs_get(struct __file *f) {
char c;
int ret = fs_read(&GET_ENTRY(f)->fs_file, &c, 1);
// printk("call: fs_getc %p: c = %x, ret = %d\n", f, c, ret);
if (ret == 0) {
return _FDEV_EOF;
}
if (ret < 0) {
return _FDEV_ERR;
}
return c;
}
int my_fs_flush(struct __file *f) {
return fs_sync(&GET_ENTRY(f)->fs_file);
}
FILE *fopen(const char *filename, const char *mode) {
struct my_file_entry *it = k_malloc(sizeof(struct my_file_entry));
fs_file_t_init(&it->fs_file);
fs_mode_t fs_flags;
int io_flags = parse_mode(mode, &fs_flags);
if (!io_flags) {
return NULL;
}
if (fs_open(&it->fs_file, filename, fs_flags) < 0) {
return NULL;
}
it->io_file.put = my_fs_put;
it->io_file.get = my_fs_get;
it->io_file.flush = my_fs_flush;
it->io_file.flags = io_flags;
return &it->io_file;
}
int fclose(FILE *fp) {
struct my_file_entry *it = GET_ENTRY(fp);
int ret = fs_close(&it->fs_file);
k_free(it);
if (ret < 0) {
return -1;
}
return 0;
}
成功实现文件操作:
lua > f = io.open('/lfs/haha.txt', 'w')
lua > f:write("nya~")
lua > f:close()
lua >
lua > f = io.open('/lfs/haha.txt', 'r')
lua > print(f:read('*a'))
nya~
lua > f:close()
结语:先进的设计理念和纸糊的代码
看到这里,读者们会发现,我们飞速实现了 fs 的集成,然后耗费了海量的时间在解决各类 bug 上(笔者大约使用了 20 小时)。Zephyr 是一个快速发展中的框架,然而,大概是由于开发人力明显不足,他们的大部分精力都放在了使用频率最高的功能上,对于 posix 接口、小众 MCU 的支持明显不够。好在 Zehpyr 社区的响应速度足够快,issue 能在合理的时间之内获得解决。
在接下来的一段时间内,笔者会将 Zephyr 作为主力框架。不过,如果有 posix 兼容性需求,笔者还是建议选择 Apache NuttX。