序言:标准化、可插拔、大一统的 Zephyr

本站的前一篇文章将 Lua 移植到了 RP2350 裸机上,还集成了 microrl(键盘交互增强)、FatFs(让 MCU 可以读写文件)、TinyUSB MSC(让 Windows 可以读写文件)。然而,这个过程是艰苦的。简而言之:

  1. 我们需要阅读所有这些第三方库的文档和内部实现,而这些第三方库的代码质量、文档丰富度差异很大。例如 Lua 作为一个复杂的程序,却可以毫无修改地运行在 MCU 上;microrl 非常短小,但我们得去 hack 源代码才能实现想要的功能。
  2. 为了集成一些基础组件,我们需要写很多胶水代码。例如在 FatFs 提供的文件 API 基础上实现 newlib stub。
  3. 缺乏任务调度。例如,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 设计绝非易事。

安装 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 文档进行配置:

  1. 创建一个 toolchain,点击 Add environment ‣ From file,使用 Zephyr 的 .venv\Scripts\activate.bat
  2. 用 IDE 打开 blinky 示例项目,在 Settings 里面找到 West,选择开发板型号
  3. 右上角的编译和运行按钮应该能用了。调试方面,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 文档,发现它要求存在 stdinstdoutstderr 这三个 FILE* 类型的全局变量,而 FILE 对象内需要有 putget 函数,用于输出单个字符。下面是 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_devapi->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;
	};
💡
在 Zephyr 中,每个开发板都会有自己的设备树,描述了硬件资源。例如,如果开发板上面有一颗黄色 LED,则可以在设备树中定义 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 到输出到终端的过程:

  1. 用户程序调用 printf()
  2. picolibc 调用 stdout->put()
  3. stdout 是由 Zephyr 提供的胶水代码定义的,它的 put() 函数会调用到 _stdout_hook()
  4. Zephyr 的 drivers/console/uart_console.c 会调用 __stdout_hook_install,将 console_out 注册为 _stdout_hook 函数
  5. console_out() 会把 \n 替换成 \r,然后调用 uart_console_devapi->poll_out() 函数
  6. 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=yCONFIG_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_threadk_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));

至此,我们明白了静态线程是如何自动运行的:

  1. 使用 K_THREAD_DEFINE 定义线程时,会在全局变量创建栈空间、线程指针等,同时在 _static_thread_data_area 段放置一个 _static_thread_data 结构体
  2. 链接时,链接器脚本会按照 _static_thread_data_area 的大小,定义 __static_thread_data_list_start__static_thread_data_list_end
  3. 程序启动时,通过上述头、尾偏移,遍历所有 _static_thread_data 结构体并安排启动
💡
在 Zephyr 中,线程和队列都可以在运行时创建,但我们使用了 K_MSGQ_DEFINEK_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 分区的相关文档mcubootimage-0image-1image-0-lpcoreimage-1-lpcoreimage-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 的。

可想而知,如果没有逻辑来注册这里的 putget 回调,那 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 结构体的成员分别是 ungetflagsputgetflush,这里 stdout 和 stderr 的结构都是正常的;而我们通过 fopen 手动打开的 FILEput0x00000001,显然不合法。这是因为,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 = 400283cara = 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 都能正常运行。

💡
按理来说,在 ROM 中存放 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 #100119hal_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 时,会需要用到这个函数。放弃实现。
  • getcfreadfopenfclosefwrite:可以实现。
  • ferrorclearerr:其实不需要,因为 lua 自己有 fallback 实现。
  • tmpfile:仅是为了暴露 io.tmpfile() 接口。放弃实现。
  • ungetc:仅被 liolib.c 使用,用于 read_numbertest_eof,在读入整数时可能调用。我们可以通过对每个 FILE * 维护一个 buffer 实现,也可以暂缓实现。
  • fprintf:仅被 liolib.c 使用,只会被用于输出 "%lld""%.14g"。我们通过 sprintf 实现。
  • renameremove:仅 os 库需要。启用 CONFIG_POSIX_FILE_SYSTEM 选项时,posix/options/fs.c 基于 zvfs_renamezvfs_unlink 提供实现。
  • printf:libc 已实现。

整理一下,我们需要为 lua 提供的是:

  • (文件打开和关闭)fopenfclose
  • (文件读写)getcfreadfwritefprintf

先考虑如何尽可能复用 picolibc 中的代码。我们发现,其实 getcfreadfwritefprintf 都可以正确操作 __file 类型的 stream。只要 fopenfclose 操作的也是 __file 对象,所有的事情都能解决。我们采用一种比较 hack 的方案:修改 liblua 的 cmake 文件,引入 my_libc.c,提前定义 fopenfclose。从而,编译 liblua.a 时,这两个函数会被静态编译进 ELF,而不是声明为 undefined symbol,也就不会在最后编译 app 时把 libc 的 fopenfclose 实现链接进来。

来考虑如何实现 fopen。它通过第二个参数指定如何打开文件,例如 "wb"。picolibc 自带__stdio_flags 函数用于把这种 type 字符串转换成 open() 的 flags,但它的标志位与 Zephyr fs 的不一致。例如,Zephyr 的 FS_O_APPEND0x20,而 picolibc 的 O_APPEND0x8,所以我们略加修改。另外还有一件事值得注意:在 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。