本文是一篇纯粹的造轮子文章。把 Lua 移植到树莓派 MCU 这件事,已经被各路人马完成过很多次了——例如 MicroLua、jf_1137202360、noza_os。然而,我们仍然选择自己动手,是因为可以趁机学习如何将“原本面向 Linux 开发的第三方库”,移植到各种嵌入式平台上。Lua 是一个极佳的教学语言,足够简单也足够小巧,移植 Lua 的难度远低于移植 Python。但另一方面,这个任务也并非探囊取物:如果想要它真正有用,我们至少要让用户能通过 Lua 点亮 LED,所以我们得给 Lua 添加全局函数 set_led();此外,我们需要让 MCU 启动后可以执行 init.lua,这要求我们实现文件系统。本文将一步步达成这些目标。
在动手实操之前,我们先从理论上想一想,面向 Linux 开发的库,与面向 MCU 开发的库,其核心区别在哪里?
——硬件资源。Linux 程序拥有海量的内存,程序员往往随手分配一个 8KB 的 buffer;然而 MCU 内存紧张,STM32F103C8T6 仅有 20KB 的 RAM,就算 RP2350 也只有 520KB,经不起这样挥霍。除了 RAM 之外,ROM 也是个严重问题,STM32F103C8T6 只有 64KB ROM,代码稍微复杂一点,编译产物就能撑满这点空间,更不用谈往 ROM 中塞一些媒体资源。
——MCU 缺失大量 API。Linux 上的 C 语言开发是建立在 glibc 上的,开发者会使用 fork()、pthread_create()、fopen()、connect() 等函数,然而这些 API 在 MCU 上面很可能不存在。说句公道话,我们的处境比上世纪的 8051 开发者好得多,newlib 帮我们实现了 printf()、malloc()、memcpy() 等函数,但我们不可能为一个没有网络功能的 MCU 实现 connect()。有些 Linux 程序会调用 popen(),启动另一个程序,并从管道中读取运行结果;有些程序强烈依赖于高版本 linux kernel,例如 wireguard-tools、containerd。想让这样的代码在 MCU 上工作是几乎不可能的,好在 Lua REPL 并不需要做这样的事。
作为一道前菜,笔者将以 libmarkdown(即 Orc/discount,一个 markdown 处理库,我们 fuzz 过这个程序,见本站 2023 年的文章)为例,实践第一次移植。
移植 libmarkdown:资源估算和 API 依赖分析
本节的目标很简单:在 RP2350 上,使用 libmarkdown,把用户输入的 md 转成 html 输出。我们先在 Linux 上编译 libmarkdown:
wget https://github.com/Orc/discount/archive/refs/tags/v2.2.7d.tar.gz
tar -zxvf v2.2.7d.tar.gz
cd discount-2.2.7d
CFLAGS="-Wno-incompatible-pointer-types" ./configure.sh
make
观察 make 过程:
# make
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c main.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c pgm_options.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c gethopt.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c notspecial.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o mkdio.o mkdio.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o markdown.o markdown.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o dumptree.o dumptree.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o generate.o generate.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o resource.o resource.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o docheader.o docheader.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o branch.o tools/branch.c
cc -Wno-return-type -Wno-implicit-int -L. -Wno-incompatible-pointer-types -o branch branch.o
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -DBRANCH=`./branch` -DVERSION=\"`cat VERSION`\" -c version.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o toc.o toc.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o css.o css.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o xml.o xml.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o Csio.o Csio.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o xmlpage.o xmlpage.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o basename.o basename.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o emmatch.o emmatch.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o github_flavoured.o github_flavoured.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o setup.o setup.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o mktags.o mktags.c
cc -Wno-return-type -Wno-implicit-int -L. -Wno-incompatible-pointer-types -o mktags mktags.o
./mktags > blocktags
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o tags.o tags.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o html5.o html5.c
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o flags.o flags.c
./librarian.sh make libmarkdown VERSION mkdio.o markdown.o dumptree.o generate.o resource.o docheader.o version.o toc.o css.o xml.o Csio.o xmlpage.o basename.o emmatch.o github_flavoured.o setup.o tags.o html5.o flags.o
a - mkdio.o
a - markdown.o
a - dumptree.o
a - generate.o
a - resource.o
a - docheader.o
a - version.o
a - toc.o
a - css.o
a - xml.o
a - Csio.o
a - xmlpage.o
a - basename.o
a - emmatch.o
a - github_flavoured.o
a - setup.o
a - tags.o
a - html5.o
a - flags.o
cc -Wno-return-type -Wno-implicit-int -L. -Wno-incompatible-pointer-types -o markdown main.o pgm_options.o gethopt.o notspecial.o -lmarkdown
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o mkd2html.o mkd2html.c
cc -Wno-return-type -Wno-implicit-int -L. -Wno-incompatible-pointer-types -o mkd2html mkd2html.o pgm_options.o gethopt.o notspecial.o -lmarkdown
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c makepage.c
cc -Wno-return-type -Wno-implicit-int -L. -Wno-incompatible-pointer-types -o makepage makepage.o pgm_options.o gethopt.o notspecial.o -lmarkdown
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o theme.o theme.c
cc -Wno-return-type -Wno-implicit-int -L. -Wno-incompatible-pointer-types -o theme theme.o pgm_options.o gethopt.o notspecial.o -lmarkdown
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o echo.o tools/echo.c
cc -Wno-return-type -Wno-implicit-int -L. -Wno-incompatible-pointer-types -o echo echo.o
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o cols.o tools/cols.c
cc -Wno-return-type -Wno-implicit-int -L. -Wno-incompatible-pointer-types -o cols cols.o
cc -Wno-return-type -Wno-implicit-int -I. -Wno-incompatible-pointer-types -c -o pandoc_headers.o tools/pandoc_headers.c
cc -Wno-return-type -Wno-implicit-int -L. -Wno-incompatible-pointer-types -o pandoc_headers pandoc_headers.o pgm_options.o gethopt.o notspecial.o -lmarkdown
# du -h libmarkdown.a
132K libmarkdown.a
# du -h markdown
112K markdown
# ldd markdown
linux-vdso.so.1 (0x00007ffcc2d4f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000782bd8254000)
/lib64/ld-linux-x86-64.so.2 (0x0000782bd846a000)
可见,libmarkdown 的编译过程非常简单:先编译出 libmarkdown,然后用 -lmarkdown 把 libmarkdown.a 打包进各个 ELF 程序。我们只需将 libmarkdown 移植到 RP2350,不用管其他 ELF。所以,对我们来说,编译出 libmarkdown 就可以结束了。
从 ldd 和 du 命令的执行结果可以看出,静态打包 libmarkdown 也只需要 100+ KB 的空间,而 RP2350 一般拥有超过 2MB 的 ROM,所以 ROM 资源这块不存在问题。那么 RAM 是否存在问题呢?我们可以基于 libmarkdown 写一个小程序,循环将一小段 md 转为 html,以模拟工作负载;同时用 Valgrind Massif 监控它的堆栈占用。app.c 实现如下:
#include <stdio.h>
#include "markdown.h"
void work() {
const char md[] = "Helloooo";
Document *doc = mkd_string(md, sizeof(md), 0);
mkd_compile(doc, 0);
char *html = NULL;
mkd_document(doc, &html);
// puts(html);
mkd_cleanup(doc);
}
int main(void) {
for(int i=0; i<100000; i++) {
work();
}
}
编译它,并使用 Valgrind 监控其占用空间:
gcc app.c -o app -L. -lmarkdown
valgrind --tool=massif --stacks=yes --massif-out-file=valgrind.txt ./app
ms_print valgrind.txt > out.txt
ms_print 会输出许多有趣的信息,例如,它提示 Qchar() 函数一下子就分配了 4800 个字节的堆内存。我们主要关注各个快照的堆、栈空间占用情况:
--------------------------------------------------------------------------------
Command: ./app
Massif arguments: --stacks=yes --massif-out-file=valgrind.txt
ms_print arguments: valgrind.txt
--------------------------------------------------------------------------------
KB
7.766^#
|#
|#
|#
|#:: @: ::::::::::::::::: :: :: :: :: : : : : :
|#:: @: ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:: @: : ::: ::::: ::::: :: : : : : : : : : :
|#:::: @: : ::: ::::: ::::: :: : : : @: :: ::: :::: ::
|#::: ::@:::::::::::::::::: ::::: ::::: :: : : : @: :: : : :::::::
|#::: : @::: :: :: :: ::::: ::::: ::::: ::::: ::: ::: :@: ::::: :::::::::
0 +----------------------------------------------------------------------->Mi
0 863.5
Number of snapshots: 56
Detailed snapshots: [1 (peak), 6, 40]
--------------------------------------------------------------------------------
n time(i) total(B) useful-heap(B) extra-heap(B) stacks(B)
--------------------------------------------------------------------------------
0 0 0 0 0 0
1 161,678 7,952 5,523 165 2,264
2 14,491,525 6,416 5,508 156 752
3 34,813,824 6,408 5,508 156 744
4 48,549,076 1,424 508 108 808
5 70,896,937 1,104 484 92 528
6 89,909,626 6,408 5,508 156 744
7 110,371,597 6,416 5,508 156 752
8 123,409,344 864 332 44 488
9 137,895,868 864 332 44 488
10 152,382,392 864 332 44 488
11 166,868,916 864 332 44 488
12 192,799,003 5,960 5,423 145 392
13 208,820,507 864 288 64 512
14 233,996,824 1,192 508 108 576
15 250,017,860 1,192 508 108 576
16 266,038,896 1,192 508 108 576
17 282,059,932 1,192 508 108 576
18 295,792,279 6,416 5,508 156 752
19 310,845,111 6,416 5,508 156 752
20 325,897,943 6,416 5,508 156 752
21 340,950,775 6,416 5,508 156 752
22 356,003,607 6,416 5,508 156 752
23 371,056,439 6,416 5,508 156 752
24 386,109,271 6,416 5,508 156 752
25 401,162,103 6,416 5,508 156 752
26 416,214,935 6,416 5,508 156 752
27 431,267,767 6,416 5,508 156 752
28 446,320,599 6,416 5,508 156 752
29 461,373,431 6,416 5,508 156 752
30 476,426,263 6,416 5,508 156 752
31 490,819,080 6,416 5,508 156 752
32 504,551,189 6,288 5,508 156 624
33 525,149,354 520 136 16 368
34 552,613,907 6,416 5,508 156 752
35 573,212,414 632 136 16 480
36 600,677,015 6,416 5,508 156 752
37 621,275,522 632 136 16 480
38 648,740,123 6,416 5,508 156 752
39 669,338,630 632 136 16 480
40 683,070,769 1,408 608 128 672
41 696,803,231 6,416 5,508 156 752
42 717,401,738 632 136 16 480
43 731,133,877 1,408 608 128 672
44 744,866,339 6,416 5,508 156 752
45 765,464,846 632 136 16 480
46 779,196,985 1,408 608 128 672
47 792,929,447 6,416 5,508 156 752
48 813,527,954 632 136 16 480
49 827,260,093 1,408 608 128 672
50 840,297,900 6,416 5,508 156 752
51 853,335,671 1,248 452 92 704
52 866,373,561 6,416 5,508 156 752
53 879,411,320 912 388 84 440
54 892,449,069 1,224 508 108 608
55 905,486,836 6,416 5,508 156 752
可见堆+栈占用一直没超过 8KB。n=1 的那个快照中,栈占用了 2264B,之后回落;堆内存始终没有超过 6KB。从总量上看,RP2350 显然能提供这样多的 RAM 资源。不过,根据 pico sdk 文档(章节 6.1),PICO_STACK_SIZE 默认只有 0x800 字节,即 2048B。我们需要将它改得更高。

既然要改 PICO_STACK_SIZE,我们就需要精确地知道栈空间峰值。Valgrind 这次报告的是 2264B,但真正的栈峰值很有可能没有被采样到,我们需要更多的样本来支持判断。将 work() 次数改为 10 次,同时让 Valgrind 采样更多快照:
valgrind --tool=massif --stacks=yes --massif-out-file=valgrind.txt --max-snapshots=1000 ./app
再次分析数据(见下图),发现程序会在初次 work() 时,做一些初始化操作(估计是准备某种常量表),此时会占用大量的栈空间,峰值达到 7528 字节,在这段过程中没有任何 malloc。此后有 10 个堆内存的峰,对应 10 次 md 到 html 的转换。
程序除了堆栈要占用 RAM 之外,还有全局变量(.data 和 .bss 段)。用 size 指令查看:
# size app
text data bss dec hex filename
63747 2648 32 66427 1037b app
综上,我们的这个程序的栈峰值大约为 8KB,堆峰值大约为 6KB,全局变量占用 2.68KB。稳妥起见,将 PICO_STACK_SIZE 设为 16KB 比较合理。我们还剩下 520 - 16 - 2.68 = 501.32KB 留给堆。上述数据是在 x64 环境下统计的,与 ARMv8-M 的实际占用必然有差别,但由于 RP2350 是 32bit 的,指针占用的空间会比 x64 少,所以我们可以抱有信心。
我们确定了硬件资源可行,接下来就要确认 MCU 上能否提供 libmarkdown 所需的 API。我们先写一个程序 convert_line.c,每次读入一行 md,立即转 html 输出并释放空间:
#include <stdio.h>
#include <string.h>
#include "markdown.h"
int main(void) {
static char inp[1024];
while (~scanf("%1023s", inp)) {
Document *doc = mkd_string(inp, strlen(inp), 0);
mkd_compile(doc, 0);
char *html = NULL;
mkd_document(doc, &html);
puts(html);
mkd_cleanup(doc);
}
return 0;
}
编译之后,用 readelf -r convert_line 指令可以看 PLT 表,这是它运行时所需要的外部 API:
Relocation section '.rela.plt' at offset 0x1578 contains 33 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000013000 000100000007 R_X86_64_JUMP_SLO 0000000000000000 free@GLIBC_2.2.5 + 0
000000013008 000300000007 R_X86_64_JUMP_SLO 0000000000000000 srandom@GLIBC_2.2.5 + 0
000000013010 000400000007 R_X86_64_JUMP_SLO 0000000000000000 strncmp@GLIBC_2.2.5 + 0
000000013018 000600000007 R_X86_64_JUMP_SLO 0000000000000000 toupper@GLIBC_2.2.5 + 0
000000013020 000700000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000013028 000800000007 R_X86_64_JUMP_SLO 0000000000000000 qsort@GLIBC_2.2.5 + 0
000000013030 000900000007 R_X86_64_JUMP_SLO 0000000000000000 vsnprintf@GLIBC_2.2.5 + 0
000000013038 000a00000007 R_X86_64_JUMP_SLO 0000000000000000 strlen@GLIBC_2.2.5 + 0
000000013040 000b00000007 R_X86_64_JUMP_SLO 0000000000000000 strchr@GLIBC_2.2.5 + 0
000000013048 000c00000007 R_X86_64_JUMP_SLO 0000000000000000 fputs@GLIBC_2.2.5 + 0
000000013050 000d00000007 R_X86_64_JUMP_SLO 0000000000000000 memset@GLIBC_2.2.5 + 0
000000013058 000f00000007 R_X86_64_JUMP_SLO 0000000000000000 fputc@GLIBC_2.2.5 + 0
000000013060 001000000007 R_X86_64_JUMP_SLO 0000000000000000 memchr@GLIBC_2.2.5 + 0
000000013068 001100000007 R_X86_64_JUMP_SLO 0000000000000000 calloc@GLIBC_2.2.5 + 0
000000013070 001200000007 R_X86_64_JUMP_SLO 0000000000000000 strcmp@GLIBC_2.2.5 + 0
000000013078 001300000007 R_X86_64_JUMP_SLO 0000000000000000 putc@GLIBC_2.2.5 + 0
000000013080 001500000007 R_X86_64_JUMP_SLO 0000000000000000 memcpy@GLIBC_2.14 + 0
000000013088 001600000007 R_X86_64_JUMP_SLO 0000000000000000 time@GLIBC_2.2.5 + 0
000000013090 001700000007 R_X86_64_JUMP_SLO 0000000000000000 random@GLIBC_2.2.5 + 0
000000013098 001800000007 R_X86_64_JUMP_SLO 0000000000000000 tolower@GLIBC_2.2.5 + 0
0000000130a0 001900000007 R_X86_64_JUMP_SLO 0000000000000000 malloc@GLIBC_2.2.5 + 0
0000000130a8 001a00000007 R_X86_64_JUMP_SLO 0000000000000000 strncasecmp@GLIBC_2.2.5 + 0
0000000130b0 001b00000007 R_X86_64_JUMP_SLO 0000000000000000 __isoc99_sscanf@GLIBC_2.7 + 0
0000000130b8 001c00000007 R_X86_64_JUMP_SLO 0000000000000000 realloc@GLIBC_2.2.5 + 0
0000000130c0 001d00000007 R_X86_64_JUMP_SLO 0000000000000000 memmove@GLIBC_2.2.5 + 0
0000000130c8 001e00000007 R_X86_64_JUMP_SLO 0000000000000000 strtoul@GLIBC_2.2.5 + 0
0000000130d0 001f00000007 R_X86_64_JUMP_SLO 0000000000000000 __isoc99_scanf@GLIBC_2.7 + 0
0000000130d8 002000000007 R_X86_64_JUMP_SLO 0000000000000000 sprintf@GLIBC_2.2.5 + 0
0000000130e0 002100000007 R_X86_64_JUMP_SLO 0000000000000000 fwrite@GLIBC_2.2.5 + 0
0000000130e8 002200000007 R_X86_64_JUMP_SLO 0000000000000000 bsearch@GLIBC_2.2.5 + 0
0000000130f0 002400000007 R_X86_64_JUMP_SLO 0000000000000000 strdup@GLIBC_2.2.5 + 0
0000000130f8 002500000007 R_X86_64_JUMP_SLO 0000000000000000 strstr@GLIBC_2.2.5 + 0
000000013100 002600000007 R_X86_64_JUMP_SLO 0000000000000000 __ctype_b_loc@GLIBC_2.3 + 0
逐一分析这些项目。
- stdio.h 中的函数:puts, vsnprintf, fputs, fputc, putc, sprintf, fwrite
- stdlib.h 中的函数:free, srandom, qsort, calloc, random, malloc, realloc, strtoul, bsearch
- string.h 中的函数:strncmp, strlen, strchr, memset, memchr, strcmp, memcpy, strncasecmp, memmove, strdup, strstr
- ctype.h 中的函数:toupper, tolower
- time.h 中的函数:time
大部分函数都在预料范围内,除了 srandom、random、time 这三个。查阅代码,可以发现,它们的唯一用途是遮蔽邮箱地址。configure.sh 会自动判断是否存在 srandom 和 random,并生成合法的 config.h 文件。
接下来,我们要观察上述函数在 pico sdk 中是否可用。去 newlib 文档里逐一寻找,可以发现所有函数都存在,所以我们无需自行编写实现。
scanf / printf 可以通过 CDC 串口与用户交互?这显然不是 newlib 自己实现的,而是由 pico sdk 实现的。newlib_interface.c 里面提供了 _read() 和 _write() 函数(它们被称为“stub”),而 newlib 依托这些 stub 函数,实现了更高层的 scanf、printf 等功能。另一个例子:newlib 的 malloc 函数需要 _sbrk() 这个 stub,pico sdk 提供了一个很简单的实现。现在,硬件资源确认没有问题,API 也确认全部存在。只需要将 libmarkdown 引入项目,本节的任务就完成了。然而,如果我们尝试使用 arm 工具链执行 configure.sh,会立即失败:
export CXX=arm-none-eabi-g++
export AS=arm-none-eabi-as
export LD=arm-none-eabi-ld
export AR=arm-none-eabi-ar
export RANLIB=arm-none-eabi-ranlib
export STRIP=arm-none-eabi-strip
./configure.sh
# Configuring for [markdown]
# checking the C compiler (arm-none-eabi-gcc) does not compile code properly
调试一番,发现 configure.sh 为了检查编译器是否可用,会调用编译器编译一些代码。例如,它调用 AC_CHECK_FUNCS srandom 以检查 srandom() 是否存在,而 AC_CHECK_FUNCS 的内部实现是把 int main() {srandom;} 写进临时 .c 文件并尝试编译。与此同时,我们并没有提供 newlib stub 函数,所以编译会因为缺失 _sbrk() 等符号而失败。

而且,就算我们能让 gcc 把代码编译出来,也得面临另一个问题:构建 libmarkdown 时,会现场编译一个 config.sed 程序,供后续步骤使用;但我们如果交叉编译,那么 config.sed 就是一个 arm eabi 程序,跑不起来。
所以,我们换一条路走:先在 Linux x64 环境下进行 configure,至少让它先把 config.h、Makefile 生成出来;在 make 步骤再去换工具链。当然,这个做法是铤而走险,毕竟 Linux x64 gcc 并不是 arm eabi gcc,这个过程可预见地会引入很多不一致性。以 INITRNG 这个宏为例:configure 脚本会判断是否存在 srandom、srand,如果存在就采用,否则 fallback 到空操作 (void)1。在我们这套“Linux 上执行 configure,make 时再换工具链”方案下,由于它在 Linux 上存在,所以 config.h 会采用 srandom((unsigned int)x) 作为 INITRNG(x) 的实现;然而一旦嵌入式 runtime 里面不存在 srandom,那就直接编译失败,没有 fallback。
-specs=nosys.specs,同时不再现场编译 config.sed 程序,而是我们手动编译一个 x64 的 config.sed。两种方案各有优劣,评估之后认为前一种方案工程量更小。在 Linux 上执行 configure:
# CFLAGS="-Wno-incompatible-pointer-types" ./configure.sh
Configuring for [markdown]
checking the C compiler (cc) oh ick, it looks like gcc
CFLAGS="-Wno-incompatible-pointer-types" are okay
Looking for cpp (using $CC -E as a cpp pipeline)
looking for install (/usr/bin/install)
Checking __attribute__((__destructor__)) (yes)
looking for ar (/usr/bin/ar)
looking for ranlib (/usr/bin/ranlib)
looking for pkg-config (/usr/bin/pkg-config)
checking for "volatile" keyword (found)
checking for "const" keyword (found)
Checking for "inline" keyword (found)
defining WORD & DWORD scalar types (using standard types in <inttypes.h>)
looking for a reentrant basename (found)
looking for header libgen.h (found)
looking for header stdlib.h (found)
looking for the alloca function (found in alloca.h)
looking for header sys/types.h (found)
looking for header pwd.h (found)
looking for the getpwuid function (found)
looking for header sys/stat.h (found)
looking for the stat function (found)
special file macros in sys/stat.h: S_ISSOCK S_ISCHR S_ISFIFO.
looking for the srandom function (found)
looking for the memset function (found)
looking for the random function (found)
looking for the strcasecmp function (found)
looking for the strncasecmp function (found)
looking for the fchdir function (found)
looking for header malloc.h (found)
looking for find (/usr/bin/find)
looking for "ln -s" (/usr/bin/ln)
looking for ar (/usr/bin/ar)
looking for ranlib (/usr/bin/ranlib)
looking for sed (/usr/bin/sed)
generating Makefile
generating version.c
generating mkdio.h
generating libmarkdown.pc
于是,我们拿到了 Makefile。现在又有两条路:要么手工交叉编译出 libmarkdown.a,以后 RP2350 程序直接引用这个 .a 和对应的 .h;要么编写 cmake 文件,将 libmarkdown 作为子模块(这条路线更优雅)。无论采取哪一种方案,我们都要研究 make 过程,弄清楚各个源码文件是如何变成 libmarkdown.a 的。观察 make 输出(已简化):
cc -I. -c -o mkdio.o mkdio.c
cc -I. -c -o markdown.o markdown.c
cc -I. -c -o dumptree.o dumptree.c
cc -I. -c -o generate.o generate.c
cc -I. -c -o resource.o resource.c
cc -I. -c -o docheader.o docheader.c
cc -I. -c -o branch.o tools/branch.c
cc -L. -o branch branch.o
cc -I. -DBRANCH=`./branch` -DVERSION=\"`cat VERSION`\" -c version.c
cc -I. -c -o toc.o toc.c
cc -I. -c -o css.o css.c
cc -I. -c -o xml.o xml.c
cc -I. -c -o Csio.o Csio.c
cc -I. -c -o xmlpage.o xmlpage.c
cc -I. -c -o basename.o basename.c
cc -I. -c -o emmatch.o emmatch.c
cc -I. -c -o github_flavoured.o github_flavoured.c
cc -I. -c -o setup.o setup.c
cc -I. -c -o mktags.o mktags.c
cc -L. -o mktags mktags.o
./mktags > blocktags
cc -I. -c -o tags.o tags.c
cc -I. -c -o html5.o html5.c
cc -I. -c -o flags.o flags.c
./librarian.sh make libmarkdown VERSION mkdio.o markdown.o dumptree.o generate.o resource.o docheader.o version.o toc.o css.o xml.o Csio.o xmlpage.o basename.o emmatch.o github_flavoured.o setup.o tags.o html5.o flags.o
几个异常点:BRANCH 和 VERSION 宏是现场执行脚本获取的,我们将它改成常量,从而 branch 程序就可以不编译了;./mktags > blocktags 是往 blocktags 文件里面写了一些常量,我们运行它一次,获得 blocktags 文件(内部是一个 c 语言的结构体),不再编译 mktags 程序。简化后的编译指令清单如下:
cc -I. -c -o mkdio.o mkdio.c
cc -I. -c -o markdown.o markdown.c
cc -I. -c -o dumptree.o dumptree.c
cc -I. -c -o generate.o generate.c
cc -I. -c -o resource.o resource.c
cc -I. -c -o docheader.o docheader.c
cc -I. -DBRANCH="" -DVERSION=\"2.2.7d-rp2350\" -c version.c
cc -I. -c -o toc.o toc.c
cc -I. -c -o css.o css.c
cc -I. -c -o xml.o xml.c
cc -I. -c -o Csio.o Csio.c
cc -I. -c -o xmlpage.o xmlpage.c
cc -I. -c -o basename.o basename.c
cc -I. -c -o emmatch.o emmatch.c
cc -I. -c -o github_flavoured.o github_flavoured.c
cc -I. -c -o setup.o setup.c
cc -I. -c -o tags.o tags.c
cc -I. -c -o html5.o html5.c
cc -I. -c -o flags.o flags.c
./librarian.sh make libmarkdown VERSION mkdio.o markdown.o dumptree.o generate.o resource.o docheader.o version.o toc.o css.o xml.o Csio.o xmlpage.o basename.o emmatch.o github_flavoured.o setup.o tags.o html5.o flags.o
分析 librarian.sh:
#! /bin/sh
#
# Build static libraries, hiding (some) ickiness from the makefile
ACTION=$1; shift
LIBRARY=$1; shift
VERSION=$1; shift
case "$ACTION" in
make) # first strip out any libraries that might
# be passed in on the object line
objs=
for x in "$@"; do
case "$x" in
-*) ;;
*) objs="$objs $x" ;;
esac
done
/usr/bin/ar crv $LIBRARY.a $objs
/usr/bin/ranlib $LIBRARY.a
rm -f $LIBRARY
/usr/bin/ln -s $LIBRARY.a $LIBRARY
;;
files) echo "${LIBRARY}.a"
;;
install)/usr/bin/install -m 644 ${LIBRARY}.a $1
;;
esac
加个 set -x 看一眼实际执行的指令,得到:
/usr/bin/ar crv libmarkdown.a mkdio.o markdown.o dumptree.o generate.o resource.o docheader.o version.o toc.o css.o xml.o Csio.o xmlpage.o basename.o emmatch.o github_flavoured.o setup.o tags.o html5.o flags.o
/usr/bin/ranlib libmarkdown.a
现在,我们创建 lib/discount-2.2.7d/CMakeLists.txt,描述这个库的构建方式:
cmake_minimum_required(VERSION 4.0)
project(discount C)
add_library(markdown STATIC mkdio.c markdown.c dumptree.c generate.c resource.c docheader.c version.c toc.c css.c xml.c Csio.c xmlpage.c basename.c emmatch.c github_flavoured.c setup.c tags.c html5.c flags.c)
target_compile_definitions(markdown PUBLIC BRANCH="" VERSION="2.2.7d-rp2350")
target_include_directories(markdown PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
顶层 CMakeLists.txt 写法:
cmake_minimum_required(VERSION 4.0)
set(PICO_PLATFORM "rp2350-arm-s")
set(PICO_SDK_PATH "C:\\Users\\neko\\Documents\\Software\\pi-pico\\pico-sdk")
set(picotool_DIR "C:\\Users\\neko\\Documents\\Software\\pi-pico\\picotool")
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
include(pico_sdk_import.cmake)
project(pico_lib_test C CXX ASM)
set(CMAKE_CXX_STANDARD 20)
pico_sdk_init()
add_subdirectory(lib/discount-2.2.7d)
add_executable(pico_lib_test main.c)
target_link_libraries(pico_lib_test pico_stdlib markdown)
pico_add_extra_outputs(pico_lib_test)
在 main.c 中:
#include <stdio.h>
#include "pico/stdlib.h"
#include "markdown.h"
int main(void) {
stdio_init_all();
puts("# libmarkdown for RP2350");
static char inp[1024];
while(scanf("%1023s", inp) != EOF) {
Document *doc = mkd_string(inp, (int) strlen(inp), 0);
mkd_compile(doc, 0);
char *html = NULL;
mkd_document(doc, &html);
printf("md: %s\n", inp);
printf("html: %s\n", html);
mkd_cleanup(doc);
}
return 0;
}
编译一切正常。

下一步是调大堆栈空间。然而,意外发生了:
# target_compile_definitions(pico_lib_test PRIVATE PICO_HEAP_SIZE=0x64000)
# target_compile_definitions(pico_lib_test PRIVATE PICO_STACK_SIZE=0x4000)
[81/84] Building C object CMakeFiles/pico_lib_test.dir/C_/Users/neko/Documents/Software/pi-pico/pico-sdk/lib/tinyusb/src/common/tusb_fifo.c.obj
[82/84] Building C object CMakeFiles/pico_lib_test.dir/C_/Users/neko/Documents/Software/pi-pico/pico-sdk/lib/tinyusb/src/tusb.c.obj
[83/84] Linking CXX executable pico_lib_test.elf
FAILED: ld.exe: pico_lib_test.elf section `.stack_dummy' will not fit in region `SCRATCH_Y'
region `SCRATCH_Y' overflowed by 12288 bytes
collect2.exe: error: ld returned 1 exit status
ninja: build stopped: subcommand failed.
链接器提示,我们声明的 16KB 栈空间比 SCRATCH_Y 大了 12KB,所以无法满足。RP2350 有 8 个 64KB 的大 bank(条带化之后映射到 0x20000000 - 0x2007ffff),以及 2 个 4KB 的小 bank(起始地址分别是 0x20080000 和 0x20081000)。文档提到,这两个小 bank,即 scratch x、scratch y,设计用途是给各个 cpu 核专用,典型例子就是栈空间。把栈空间安排到 scratch bank 之后,栈空间访问就不会与主 bank 的访问撞在同一个 bank 导致拖慢速度。
把栈放进 scratch y,是链接器脚本决定的,具体参考 pico sdk 的 memmap_default.ld 文件。我们可以把它复制到自己项目下改改,作为我们项目特定的链接器脚本。这又有两种改法:可以把 .stack_dummy {...} > SCRATCH_Y 改成 .stack_dummy {...} > RAM ,同时修改 __StackTop 等;另一种改法,是去改 MEMORY 块定义。默认版本如下:
MEMORY
{
INCLUDE "pico_flash_region.ld"
RAM(rwx) : ORIGIN = 0x20000000, LENGTH = 512k
SCRATCH_X(rwx) : ORIGIN = 0x20080000, LENGTH = 4k
SCRATCH_Y(rwx) : ORIGIN = 0x20081000, LENGTH = 4k
}
地址总线上,0x20000000 到 0x20081fff 以内的每个 word 都是可以读写的,尽管物理上有 bank 区别,但对上层完全透明。我们可以合法地缩减 RAM 分区尺寸,同时增大 SCRATCH_Y 分区,只要最终它们仍然连续覆盖了从 0x20000000 开始的 520KB 区域。所以我们将 ld 脚本改为:
MEMORY
{
INCLUDE "pico_flash_region.ld"
RAM(rwx) : ORIGIN = 0x20000000, LENGTH = 500k
SCRATCH_X(rwx) : ORIGIN = 0x2007d000, LENGTH = 4k
SCRATCH_Y(rwx) : ORIGIN = 0x2007e000, LENGTH = 16k
}
cmake 文件添加一行:
pico_set_linker_script(pico_lib_test ${CMAKE_CURRENT_SOURCE_DIR}/custom_memmap.ld)
刷入固件,正常工作:

用 IDA 看一眼 ELF 文件,.stack_dummy 确实被放到了正确的位置,有足够的空间。

PICO_USE_STACK_GUARDS 默认处于关闭状态,见pico sdk 相关代码,详情可参考论坛文章。本文之前提到过,0x20000000 到 0x20081fff 以内的地址全都是可读写的,栈从高向低生长,堆自底向上生长,中间没有夹着其他数据,所以只要堆和栈不撞上,就不会有事。本章小结:
- 通过 valgrind 估算堆栈使用量。注意调高快照频率。
- 通过readelf读 PLT 表,观察程序需要的外部 API。
- 可以考虑先在 linux 上 configure,再手写 CMakeLists.txt 把源码集成到自己的项目中。
- 如果栈空间超过限制,可以改链接器脚本。
任务规划
经过 libmarkdown 的练手,我们大概明白了移植 C/C++ 程序的常规流程。本文接下来就要实现开头提出的那个目标——把 Lua REPL 移植到 RP2350。详细地说:
- MCU 启动后,提供 REPL 与用户交互。用户可以运行常规的 Lua 代码,还能通过
set_led()控制 LED。 - MCU 插入电脑时,将自己模拟成一个 U 盘,用户可以将
.lua文件复制到 U 盘中,并在 REPL 中require()之。特殊地,init.lua将在 REPL 之前运行。
先说说 REPL 交互。Lua 官方的 Makefile 中就同时有 linux 和 linux-readline 这两个编译目标,如果我们选择的编译目标是 linux,那么 lua 程序无法使用左右光标:

但如果我们选择 linux-readline,就可以通过左右键移动光标,甚至能用上下键翻历史记录,这是因为程序使用了 GNU readline 库来提供交互体验。然而 readline 是一个庞大的库,libreadline.so.8.2 文件本身就有 356KB,它还进一步依赖 libtinfo;这对于 MCU 来说,超重了。
于是,我们得找个轻量级的库来替代 GNU readline。最终的主程序逻辑大致是:
- 调用
luaL_dofile执行init.lua - 主循环:用类似于 readline 的库读取用户输入,然后调用
luaL_dostring执行用户输入的脚本
所以,我们需要引入的库有:liblua(我们自己写一个依赖于 liblua.a 的 REPL 而不是使用现成的 lua.c);类 readline 库;文件系统(选择 FatFs)。本文采用增量迭代的方法完成任务,先移植一个最基础的 liblua(没有 readline 也没有 fs)。
移植基础 liblua:一个最小的 REPL
我们不使用官方的 lua.c 和 luac.c,而是直接调用 liblua,所以只需要打包 liblua.a。先看一眼编译过程:
$ make liblua.a
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lapi.o lapi.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c lcode.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lctype.o lctype.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o ldebug.o ldebug.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o ldo.o ldo.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o ldump.o ldump.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lfunc.o lfunc.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lgc.o lgc.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c llex.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lmem.o lmem.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lobject.o lobject.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lopcodes.o lopcodes.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c lparser.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lstate.o lstate.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lstring.o lstring.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o ltable.o ltable.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o ltm.o ltm.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lundump.o lundump.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lvm.o lvm.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lzio.o lzio.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lauxlib.o lauxlib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lbaselib.o lbaselib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lcorolib.o lcorolib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o ldblib.o ldblib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o liolib.o liolib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lmathlib.o lmathlib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o loadlib.o loadlib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o loslib.o loslib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lstrlib.o lstrlib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o ltablib.o ltablib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o lutf8lib.o lutf8lib.c
gcc -O2 -Wall -Wextra -DLUA_COMPAT_5_3 -c -o linit.o linit.c
ar rcu liblua.a lapi.o lcode.o lctype.o ldebug.o ldo.o ldump.o lfunc.o lgc.o llex.o lmem.o lobject.o lopcodes.o lparser.o lstate.o lstring.o ltable.o ltm.o lundump.o lvm.o lzio.o lauxlib.o lbaselib.o lcorolib.o ldblib.o liolib.o lmathlib.o loadlib.o loslib.o lstrlib.o ltablib.o lutf8lib.o linit.o
ar: `u' modifier ignored since `D' is the default (see `U')
ranlib liblua.a
结构算是相当简单,看看编译产物 liblua.a 的大小:
$ du -h liblua.a
500K liblua.a
$ size liblua.a
text data bss dec hex filename
15266 0 0 15266 3ba2 lapi.o (ex liblua.a)
14717 0 0 14717 397d lcode.o (ex liblua.a)
257 0 0 257 101 lctype.o (ex liblua.a)
7578 0 0 7578 1d9a ldebug.o (ex liblua.a)
8289 0 0 8289 2061 ldo.o (ex liblua.a)
2156 0 0 2156 86c ldump.o (ex liblua.a)
2009 0 0 2009 7d9 lfunc.o (ex liblua.a)
14379 0 0 14379 382b lgc.o (ex liblua.a)
8649 296 0 8945 22f1 llex.o (ex liblua.a)
1243 0 0 1243 4db lmem.o (ex liblua.a)
6297 0 0 6297 1899 lobject.o (ex liblua.a)
83 0 0 83 53 lopcodes.o (ex liblua.a)
19419 0 0 19419 4bdb lparser.o (ex liblua.a)
3226 0 0 3226 c9a lstate.o (ex liblua.a)
2615 0 0 2615 a37 lstring.o (ex liblua.a)
6259 0 0 6259 1873 ltable.o (ex liblua.a)
3131 320 0 3451 d7b ltm.o (ex liblua.a)
3797 0 0 3797 ed5 lundump.o (ex liblua.a)
23734 664 0 24398 5f4e lvm.o (ex liblua.a)
433 0 0 433 1b1 lzio.o (ex liblua.a)
13639 48 0 13687 3577 lauxlib.o (ex liblua.a)
6375 512 0 6887 1ae7 lbaselib.o (ex liblua.a)
2208 192 0 2400 960 lcorolib.o (ex liblua.a)
6485 352 0 6837 1ab5 ldblib.o (ex liblua.a)
8964 480 0 9444 24e4 liolib.o (ex liblua.a)
6013 624 0 6637 19ed lmathlib.o (ex liblua.a)
5587 224 0 5811 16b3 loadlib.o (ex liblua.a)
3776 256 0 4032 fc0 loslib.o (ex liblua.a)
20181 448 0 20629 5095 lstrlib.o (ex liblua.a)
4700 128 0 4828 12dc ltablib.o (ex liblua.a)
3633 112 0 3745 ea1 lutf8lib.o (ex liblua.a)
201 176 0 377 179 linit.o (ex liblua.a)
text 总和为 225299 字节,data 总和为 4832 字节,符合预期。现在写个小程序,调用 luaL_dostring 运行 Lua 代码:
#include "lua.h"
#include "lualib.h"
#include "lauxlib.h"
int main() {
lua_State *L = luaL_newstate();
luaL_openlibs(L);
for(int i=0; i<5; i++) {
luaL_dostring(L, "result = 3 * 5; print(result)");
}
lua_close(L);
}
观察编译产物:
$ gcc basic_test.c -o basic_test -L. -llua -lm
$ du -h basic_test
292K basic_test
$ size basic_test
text data bss dec hex filename
249528 6112 48 255688 3e6c8 basic_test
用 valgrind 测量堆栈空间:
valgrind --tool=massif --stacks=yes --massif-out-file=valgrind.txt --max-snapshots=1000 ./basic_test
我们注意到,Lua VM 启动后,连续五次调用 luaL_dostring(即 n=800 附近那几个栈峰值位置),占用的总空间越来越大,分配的堆并没有回收。这是因为 Lua VM 内部存在 GC 机制,会在合适时机出来收拾空间。把调用次数改为 1000 次,再观察堆栈空间占用:
可见 GC 工作正常,总 RAM 占用未超过 60KB,可以不加修改地用于 RP2350。实际上 Lua 的 GC 策略是可调整的(通过 LUA_GCSETPAUSE、LUA_GCSETSTEPMUL 等参数),如果内存不够用,可以换成更激进的 GC 参数,甚至可以手动实现 GC。
分析完 ROM 和 RAM,接下来分析 API 依赖情况。读出 PLT 表:
Relocation section '.rela.plt' at offset 0x4318 contains 76 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000041000 000100000007 R_X86_64_JUMP_SLO 0000000000000000 __ctype_toupper_loc@GLIBC_2.3 + 0
000000041008 000200000007 R_X86_64_JUMP_SLO 0000000000000000 getenv@GLIBC_2.2.5 + 0
000000041010 000300000007 R_X86_64_JUMP_SLO 0000000000000000 free@GLIBC_2.2.5 + 0
000000041018 000400000007 R_X86_64_JUMP_SLO 0000000000000000 log2@GLIBC_2.29 + 0
000000041020 000500000007 R_X86_64_JUMP_SLO 0000000000000000 localtime@GLIBC_2.2.5 + 0
000000041028 000700000007 R_X86_64_JUMP_SLO 0000000000000000 abort@GLIBC_2.2.5 + 0
000000041030 000800000007 R_X86_64_JUMP_SLO 0000000000000000 __errno_location@GLIBC_2.2.5 + 0
000000041038 000900000007 R_X86_64_JUMP_SLO 0000000000000000 ldexp@GLIBC_2.2.5 + 0
000000041040 000a00000007 R_X86_64_JUMP_SLO 0000000000000000 remove@GLIBC_2.2.5 + 0
000000041048 000c00000007 R_X86_64_JUMP_SLO 0000000000000000 ferror@GLIBC_2.2.5 + 0
000000041050 000d00000007 R_X86_64_JUMP_SLO 0000000000000000 fread@GLIBC_2.2.5 + 0
000000041058 000e00000007 R_X86_64_JUMP_SLO 0000000000000000 strtod@GLIBC_2.2.5 + 0
000000041060 000f00000007 R_X86_64_JUMP_SLO 0000000000000000 localeconv@GLIBC_2.2.5 + 0
000000041068 001000000007 R_X86_64_JUMP_SLO 0000000000000000 pow@GLIBC_2.29 + 0
000000041070 001100000007 R_X86_64_JUMP_SLO 0000000000000000 clock@GLIBC_2.2.5 + 0
000000041078 001200000007 R_X86_64_JUMP_SLO 0000000000000000 fclose@GLIBC_2.2.5 + 0
000000041080 001300000007 R_X86_64_JUMP_SLO 0000000000000000 strlen@GLIBC_2.2.5 + 0
000000041088 001400000007 R_X86_64_JUMP_SLO 0000000000000000 system@GLIBC_2.2.5 + 0
000000041090 001500000007 R_X86_64_JUMP_SLO 0000000000000000 strchr@GLIBC_2.2.5 + 0
000000041098 001600000007 R_X86_64_JUMP_SLO 0000000000000000 difftime@GLIBC_2.2.5 + 0
0000000410a0 001700000007 R_X86_64_JUMP_SLO 0000000000000000 snprintf@GLIBC_2.2.5 + 0
0000000410a8 001800000007 R_X86_64_JUMP_SLO 0000000000000000 fputs@GLIBC_2.2.5 + 0
0000000410b0 001900000007 R_X86_64_JUMP_SLO 0000000000000000 memset@GLIBC_2.2.5 + 0
0000000410b8 001a00000007 R_X86_64_JUMP_SLO 0000000000000000 log@GLIBC_2.29 + 0
0000000410c0 001b00000007 R_X86_64_JUMP_SLO 0000000000000000 strspn@GLIBC_2.2.5 + 0
0000000410c8 001c00000007 R_X86_64_JUMP_SLO 0000000000000000 cos@GLIBC_2.2.5 + 0
0000000410d0 001d00000007 R_X86_64_JUMP_SLO 0000000000000000 fputc@GLIBC_2.2.5 + 0
0000000410d8 001e00000007 R_X86_64_JUMP_SLO 0000000000000000 memchr@GLIBC_2.2.5 + 0
0000000410e0 001f00000007 R_X86_64_JUMP_SLO 0000000000000000 acos@GLIBC_2.2.5 + 0
0000000410e8 002000000007 R_X86_64_JUMP_SLO 0000000000000000 memcmp@GLIBC_2.2.5 + 0
0000000410f0 002100000007 R_X86_64_JUMP_SLO 0000000000000000 fgets@GLIBC_2.2.5 + 0
0000000410f8 002200000007 R_X86_64_JUMP_SLO 0000000000000000 frexp@GLIBC_2.2.5 + 0
000000041100 002300000007 R_X86_64_JUMP_SLO 0000000000000000 _setjmp@GLIBC_2.2.5 + 0
000000041108 002400000007 R_X86_64_JUMP_SLO 0000000000000000 strcmp@GLIBC_2.2.5 + 0
000000041110 002500000007 R_X86_64_JUMP_SLO 0000000000000000 log10@GLIBC_2.2.5 + 0
000000041118 002600000007 R_X86_64_JUMP_SLO 0000000000000000 fprintf@GLIBC_2.2.5 + 0
000000041120 002700000007 R_X86_64_JUMP_SLO 0000000000000000 ftell@GLIBC_2.2.5 + 0
000000041128 002800000007 R_X86_64_JUMP_SLO 0000000000000000 feof@GLIBC_2.2.5 + 0
000000041130 002a00000007 R_X86_64_JUMP_SLO 0000000000000000 fopen64@GLIBC_2.2.5 + 0
000000041138 002b00000007 R_X86_64_JUMP_SLO 0000000000000000 freopen64@GLIBC_2.2.5 + 0
000000041140 002c00000007 R_X86_64_JUMP_SLO 0000000000000000 clearerr@GLIBC_2.2.5 + 0
000000041148 002d00000007 R_X86_64_JUMP_SLO 0000000000000000 memcpy@GLIBC_2.14 + 0
000000041150 002e00000007 R_X86_64_JUMP_SLO 0000000000000000 tmpfile64@GLIBC_2.2.5 + 0
000000041158 002f00000007 R_X86_64_JUMP_SLO 0000000000000000 cosh@GLIBC_2.2.5 + 0
000000041160 003000000007 R_X86_64_JUMP_SLO 0000000000000000 time@GLIBC_2.2.5 + 0
000000041168 003100000007 R_X86_64_JUMP_SLO 0000000000000000 sinh@GLIBC_2.2.5 + 0
000000041170 003200000007 R_X86_64_JUMP_SLO 0000000000000000 fflush@GLIBC_2.2.5 + 0
000000041178 003300000007 R_X86_64_JUMP_SLO 0000000000000000 tan@GLIBC_2.2.5 + 0
000000041180 003400000007 R_X86_64_JUMP_SLO 0000000000000000 ungetc@GLIBC_2.2.5 + 0
000000041188 003500000007 R_X86_64_JUMP_SLO 0000000000000000 strcoll@GLIBC_2.2.5 + 0
000000041190 003600000007 R_X86_64_JUMP_SLO 0000000000000000 mktime@GLIBC_2.2.5 + 0
000000041198 003700000007 R_X86_64_JUMP_SLO 0000000000000000 atan2@GLIBC_2.2.5 + 0
0000000411a0 003800000007 R_X86_64_JUMP_SLO 0000000000000000 strpbrk@GLIBC_2.2.5 + 0
0000000411a8 003900000007 R_X86_64_JUMP_SLO 0000000000000000 fmod@GLIBC_2.38 + 0
0000000411b0 003a00000007 R_X86_64_JUMP_SLO 0000000000000000 fseek@GLIBC_2.2.5 + 0
0000000411b8 003b00000007 R_X86_64_JUMP_SLO 0000000000000000 realloc@GLIBC_2.2.5 + 0
0000000411c0 003c00000007 R_X86_64_JUMP_SLO 0000000000000000 setlocale@GLIBC_2.2.5 + 0
0000000411c8 003d00000007 R_X86_64_JUMP_SLO 0000000000000000 setvbuf@GLIBC_2.2.5 + 0
0000000411d0 003e00000007 R_X86_64_JUMP_SLO 0000000000000000 longjmp@GLIBC_2.2.5 + 0
0000000411d8 003f00000007 R_X86_64_JUMP_SLO 0000000000000000 strftime@GLIBC_2.2.5 + 0
0000000411e0 004000000007 R_X86_64_JUMP_SLO 0000000000000000 memmove@GLIBC_2.2.5 + 0
0000000411e8 004100000007 R_X86_64_JUMP_SLO 0000000000000000 rename@GLIBC_2.2.5 + 0
0000000411f0 004200000007 R_X86_64_JUMP_SLO 0000000000000000 gmtime@GLIBC_2.2.5 + 0
0000000411f8 004300000007 R_X86_64_JUMP_SLO 0000000000000000 sin@GLIBC_2.2.5 + 0
000000041200 004400000007 R_X86_64_JUMP_SLO 0000000000000000 tanh@GLIBC_2.2.5 + 0
000000041208 004500000007 R_X86_64_JUMP_SLO 0000000000000000 asin@GLIBC_2.2.5 + 0
000000041210 004600000007 R_X86_64_JUMP_SLO 0000000000000000 exit@GLIBC_2.2.5 + 0
000000041218 004700000007 R_X86_64_JUMP_SLO 0000000000000000 fwrite@GLIBC_2.2.5 + 0
000000041220 004800000007 R_X86_64_JUMP_SLO 0000000000000000 tmpnam@GLIBC_2.2.5 + 0
000000041228 004a00000007 R_X86_64_JUMP_SLO 0000000000000000 sqrt@GLIBC_2.2.5 + 0
000000041230 004b00000007 R_X86_64_JUMP_SLO 0000000000000000 strerror@GLIBC_2.2.5 + 0
000000041238 004c00000007 R_X86_64_JUMP_SLO 0000000000000000 getc@GLIBC_2.2.5 + 0
000000041240 004d00000007 R_X86_64_JUMP_SLO 0000000000000000 strstr@GLIBC_2.2.5 + 0
000000041248 004e00000007 R_X86_64_JUMP_SLO 0000000000000000 exp@GLIBC_2.29 + 0
000000041250 004f00000007 R_X86_64_JUMP_SLO 0000000000000000 __ctype_tolower_loc@GLIBC_2.3 + 0
000000041258 005000000007 R_X86_64_JUMP_SLO 0000000000000000 __ctype_b_loc@GLIBC_2.3 + 0
观察一遍,发现两个问题:fs 相关的函数我们一个都没有(stub 是空实现);_setjmp 也没有。考察 ldo.c 代码,得知 longjmp 是用于处理 exception 的,如果是 C++ 编译则采用 throw;posix 则采用 _longjmp/_setjmp;否则采用 longjmp/setjmp。幸运的是,arm-none-eabi-gcc 有 longjmp 和 setjmp。至于 fs 问题,我们先不管它,以后再来实现。
破车上路,我们来把 Makefile 翻译成 CMakeLists。原文摘录如下:
LUA_A= liblua.a
CORE_O= lapi.o lcode.o lctype.o ldebug.o ldo.o ldump.o lfunc.o lgc.o llex.o lmem.o lobject.o lopcodes.o lparser.o lstate.o lstring.o ltable.o ltm.o lundump.o lvm.o lzio.o
LIB_O= lauxlib.o lbaselib.o lcorolib.o ldblib.o liolib.o lmathlib.o loadlib.o loslib.o lstrlib.o ltablib.o lutf8lib.o linit.o
MYOBJS=
BASE_O= $(CORE_O) $(LIB_O) $(MYOBJS)
$(LUA_A): $(BASE_O)
$(AR) $@ $(BASE_O)
$(RANLIB) $@
所以 liblua.a 等于上述所有 .o 文件之和。这些 .o 与 .c 是一一对应的,所以马上就能编出 CMakeLists.txt:
cmake_minimum_required(VERSION 4.0)
project(lua_548 C)
add_library(lua STATIC 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)
target_include_directories(lua PUBLIC .)
编写极简 REPL:
#include <stdio.h>
#include "lua.h"
#include "lualib.h"
#include "lauxlib.h"
#include "pico/stdlib.h"
int main() {
stdio_init_all();
lua_State *L = luaL_newstate();
luaL_openlibs(L);
static char buf[1024];
while (1) {
printf("lua > ");
scanf("%s", buf);
printf("%s\n", buf);
const int ret = luaL_dostring(L, buf);
if (ret != LUA_OK) {
const char *err = lua_tostring(L, -1);
printf("Error: %s\n", err);
lua_pop(L, 1);
}
}
lua_close(L);
}
刷入,做点测试:

可见 Lua VM 基本功能运行正常,但缺失文件系统。另外,输入字符的时候是没有回显的,就如同 Linux 终端里面执行了 stty -echo 一样。我们之后要移植 microrl,实现回显和方向键控制。
值得注意的是,在 mobaxterm 中与串口交互时,当你按下 enter 键,发送的一般都是 CR 而不是 CRLF 或 LF。这就是
fgets()等函数看起来卡死的原因。想发送 LF,需要按下 Ctrl+J 组合键。
另外,前文提出,要允许用户使用 set_led() 点亮 LED。这非常简单,我们写个 C 函数,注入到 Lua VM 即可:
static int set_led(lua_State *L) {
const int64_t level = luaL_checkinteger(L, 1);
gpio_put(PICO_DEFAULT_LED_PIN, level);
return 0;
}
void lua_init() {
L = luaL_newstate();
luaL_openlibs(L);
gpio_init(PICO_DEFAULT_LED_PIN);
gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT);
gpio_put(PICO_DEFAULT_LED_PIN, 0);
lua_register(L, "set_led", set_led);
}
执行 set_led(1) 即可打开 LED,执行 set_led(0) 以关闭。
本章小结:
- 使用 valgrind 分析 VM 型程序的空间消耗时,注意 GC 带来的影响。
- 使用 Ctrl+J 发送\n。
- 要给 Lua 添加新函数,只需用 C 实现,然后通过lua_register()注册。
移植 microrl:更好的交互
Helius/microrl 和 antirez/linenoise 都是 GNU readline 的轻量级替代。microrl 是面向 MCU 而开发的,但它并不是一个通用的 readline 库——它会把输入字符串处理成 argv[] 格式,并执行 int execute (int argc, const char * const * argv) 回调。这与我们的应用场景其实并不完全匹配,然而研究了 linenoise 之后,发现 linenoise 更难适配到 MCU(它依赖 termios.h 和 ioctl.h),所以决定仍然采用 microrl,做一点修改以适配我们的 REPL 用途。
cmake_minimum_required(VERSION 4.0)
project(microrl_151 C)
add_library(microrl STATIC microrl.c)
target_include_directories(microrl PUBLIC .)
配置文件 config.h 改动:
- 注释掉
#define _USE_COMPLETE,关闭补全功能 _COMMAND_LINE_LEN改成 1000+1- 修改 prompt,把
IRin改成lua(为了保持长度不变,加了一个空格)
写点代码来测试功能:
void print(const char *str) {
printf("%s", str);
}
int on_cmd(int argc, const char *const *argv) {
printf("on_cmd argc=%d argv[0]=%s\n", argc, argv[0]);
return 0;
}
microrl_t rl;
microrl_t *prl = &rl;
int main() {
//...
microrl_init(prl, print);
microrl_set_execute_callback(prl, on_cmd);
while (1) {
char ch = getchar();
// printf("getchar: %d [%c]\n", ch, ch);
microrl_insert_char(prl, ch == '\r' ? '\n' : ch); // 我们按下 enter 发送 CR,会被改写成 LF
}
现在,已经可以把它当终端用了:

接下来就是把它改造得不再把输入字符串切分成多个 argv。我们仍然保持回调函数的签名不变,只是永远保证 argc=1 且 argv[0] 等于用户输入的字符串。跟踪 _COMMAND_TOKEN_NMB 宏的 usage,即可找到相关逻辑 new_line_handler:
//*****************************************************************************
void new_line_handler(microrl_t * pThis){
char const * tkn_arr [_COMMAND_TOKEN_NMB];
int status;
terminal_newline (pThis);
#ifdef _USE_HISTORY
if (pThis->cmdlen > 0)
hist_save_line (&pThis->ring_hist, pThis->cmdline, pThis->cmdlen);
#endif
status = split (pThis, pThis->cmdlen, tkn_arr);
if (status == -1){
// pThis->print ("ERROR: Max token amount exseed\n");
pThis->print ("ERROR:too many tokens");
pThis->print (ENDL);
}
if ((status > 0) && (pThis->execute != NULL))
pThis->execute (status, tkn_arr);
print_prompt (pThis);
pThis->cmdlen = 0;
pThis->cursor = 0;
memset(pThis->cmdline, 0, _COMMAND_LINE_LEN);
#ifdef _USE_HISTORY
pThis->ring_hist.cur = 0;
#endif
}
它调用了 split (pThis, pThis->cmdlen, tkn_arr) 函数来做切分。继续看 split 函数:
static int split (microrl_t * pThis, int limit, char const ** tkn_arr)
{
int i = 0;
int ind = 0;
while (1) {
// go to the first whitespace (zerro for us)
while ((pThis->cmdline [ind] == '\0') && (ind < limit)) {
ind++;
}
if (!(ind < limit)) return i;
tkn_arr[i++] = pThis->cmdline + ind;
if (i >= _COMMAND_TOKEN_NMB) {
return -1;
}
// go to the first NOT whitespace (not zerro for us)
while ((pThis->cmdline [ind] != '\0') && (ind < limit)) {
ind++;
}
if (!(ind < limit)) return i;
}
return i;
}
这个函数的逻辑是根据 \0 切分 pThis->cmdline 字符串,把每个 argv 的起始位置存入 tkn_arr。显然我们输入的字符串没有这么多个 \0,必然是其他函数把我们的空格换成了 \0。翻找代码,发现:
static int microrl_insert_text (microrl_t * pThis, char * text, int len)
{
int i;
if (pThis->cmdlen + len < _COMMAND_LINE_LEN) {
memmove (pThis->cmdline + pThis->cursor + len,
pThis->cmdline + pThis->cursor,
pThis->cmdlen - pThis->cursor);
for (i = 0; i < len; i++) {
pThis->cmdline [pThis->cursor + i] = text [i];
if (pThis->cmdline [pThis->cursor + i] == ' ') {
pThis->cmdline [pThis->cursor + i] = 0;
}
}
pThis->cursor += len;
pThis->cmdlen += len;
pThis->cmdline [pThis->cmdlen] = '\0';
return true;
}
return false;
}
直接注释掉 pThis->cmdline [pThis->cursor + i] = 0; 这一行。药到病除:

现在我们拥有了一个可回显、可上下键选择历史记录的交互界面。虽然它不支持一次性输入多行代码,但 Lua 代码本来就可以不换行,所以对于我们这种玩具项目来说够用了。把 callback 改为执行 Lua 代码:
#include <stdio.h>
#include "lua.h"
#include "lualib.h"
#include "lauxlib.h"
#include "microrl.h"
#include "pico/stdlib.h"
microrl_t rl;
microrl_t *prl = &rl;
lua_State *L;
void lua_init() {
L = luaL_newstate();
luaL_openlibs(L);
}
void print(const char *str) {
printf("%s", str);
}
int on_cmd(int argc, const char *const *argv) {
const int ret = luaL_dostring(L, argv[0]);
if (ret != LUA_OK) {
const char *err = lua_tostring(L, -1);
printf("Error: %s\n", err);
lua_pop(L, 1);
return 1;
}
return 0;
}
int main() {
stdio_init_all();
lua_init();
microrl_init(prl, print);
microrl_set_execute_callback(prl, on_cmd);
while (1) {
char ch = getchar();
microrl_insert_char(prl, ch == '\r' ? '\n' : ch);
}
lua_close(L);
}
一切正常,写个求质数的程序:

本章小结:
- 使用类 readline 库,以实现回显 + 交互增强。
- 轻微修改第三方库的配置项和代码,从而适配 MCU 场景。
移植 FatFs:让 Lua 能管理文件
我们接下来就要移植 fs,让 Lua 可以读写文件、执行 init.lua。但与直觉相反,“让 windows 可以读写文件”与“让 MCU 程序可以读写文件”,其实是没什么联系的,代码也无法复用。这是因为,windows 访问 U 盘,并非去读一个个文件,而是读一个个 block,“文件系统”这层抽象是 Windows 内核提供的。MCU 想要访问文件,就得自行实现文件系统这层抽象。fs 选型方面,由于需要 MCU 和 Windows 都支持,所以 littlefs 不太合适。我们选择 FAT 格式。
接下来,我们要做的两件事:
- 移植 FatFs,将自己的一部分空闲 flash 作为可读可写的空间,从而 MCU 程序自己能读写文件。
- 移植 TinyUSB MSC,把这部分 flash 空间假装成 U 盘,暴露给 Windows 系统。
我们需要知道哪些 flash 空间可以用来当作 FAT32 存储区域。借此机会,我们来学习 RP2350 的 ROM 启动流程:
- 硅片上固化了 32KB 的 bootrom,起始地址 0x00000000,包含一些 bootloader 和 API。uf2 拖拽烧录也是在这个 ROM 里面实现的。
- XIP 地址空间起始于 0x10000000,这也是 .text 段的起始位置。RP2350 的 flash 启动分为三种方式:flash image boot(默认)、flash partition boot 和 partition-table-in-image。这与 RP2040 很不一样,RP2040 是 flash 的前 128 字节包含一个“boot2” bootloader,但 RP2350 并不使用 boot2。
先来看 flash image boot,它将 flash 视为一个整体。在 flash 的前 4KB,必须存在一个 IMAGE_DEF block,样例如下:

我们来检查 ELF 文件,果然在 0x10000138 位置出现了 IMAGE_DEF:

根据文档,在 IMAGE_DEF 没有指定入口点的情况下,bootrom 假设 flash 是以向量表开始的。向量表会包含初始栈指针 SP、reset handler 等信息,bootrom 按照向量表载入 SP 并执行 reset handler。向量表如下:

上图中,前 4 个字节是初始 SP(0x20082000)无误;然而,reset handler 并不在 0x1000015D,而是在 0x1000015C:

这是因为,ARM 指令集中,函数地址的 LSB 是 thumb 标记,为 0 则表示是 ARM 模式(32 位指令集),为 1 则是 thumb 模式(16 / 32 位混合指令集)。由于 Cortex-M33 只支持 thumb-1 和 thumb-2 指令集,故中断向量表中的所有函数地址都必然是奇数。
所以,在我们当前的程序中,从 0x10000000 开始依次存放了向量表、binary_info_header、IMAGE_DEF 块、程序代码、.rodata、binary_info。flash 内容到 0x10038380 就结束了,此后的空间都不会被程序用到。我们这块开发板的 flash 容量是 4MB,意味着约有 3871 KB 的空间是空闲的。
再来看第二种启动模式,flash partition boot。如果 flash 的前 4KB 里面有 PARTITION_TABLE 而没有 IMAGE_DEF,则进入这种模式。bootrom 会在分区表里面搜索可启动的分区(即包含有合法的 IMAGE_DEF),并以之启动。显然,这个启动模式很适合用来做 A/B 分区。bootrom 会启动 A、B 两个分区中版本号较高者。
最后一种启动模式是 partition-table-in-image。若 flash 的前 4KB 里面既有 PARTITION_TABLE 又有 IMAGE_DEF,那么 bootrom 会(不加验证地)导入分区表,并按照 IMAGE_DEF 启动。这是比较罕见的情况,详情见 RP2350 datasheet。
要完成本文任务,我们可以继续选择默认的 flash image boot,在代码中硬编码 [0x10100000, 0x10200000) 为 fs 区域;也可以选择 flash partition boot,显式地指定分区表,然后在代码中读取分区表,获得目标区域位置。后者是更优雅的实现方式,而且如果我们以后要做“A/B 分区装代码,A 和 B 都有自己的数据分区”,采取分区表也更容易实现。
我们先看看默认情况下的分区情况:
> picotool reboot -f -u
> picotool partition info
there is no partition table
un-partitioned_space : S(rw) NSBOOT(rw) NS(rw), uf2 { 'absolute', 'rp2350-arm-s', 'rp2350-riscv', 'data' }
没有分区表,所有的数据都在未分区空间内。那我们参考 官方 partition_info 示例程序,编一个分区表 pt.json:
{
"version": [1, 0],
"unpartitioned": {
"families": ["absolute"],
"permissions": {
"secure": "rw",
"nonsecure": "rw",
"bootloader": "rw"
}
},
"partitions": [
{
"name": "Firmware",
"id": 1,
"start": 0,
"size": "1024K",
"families": ["rp2350-arm-ns", "rp2350-arm-s", "rp2350-riscv"],
"permissions": {
"secure": "rw",
"nonsecure": "rw",
"bootloader": "rw"
}
},
{
"name": "Data",
"id": 2,
"size": "1024K",
"families": ["data"],
"permissions": {
"secure": "rw",
"nonsecure": "rw",
"bootloader": "rw"
}
}
]
}
在 cmake 文件中指定分区表:
pico_embed_pt_in_binary(rp_lua_repl ${CMAKE_CURRENT_LIST_DIR}/pt.json)
刷入固件:
> picotool load rp_lua_repl.uf2
Family ID 'rp2350-arm-s' can be downloaded in partition 0:
00000000->00100000
Loading into Flash: [==============================] 100%
> picotool reboot -f -u
The device was rebooted into BOOTSEL mode.
> picotool partition info
un-partitioned_space : S(rw) NSBOOT(rw) NS(rw), uf2 { 'absolute' }
partitions:
0(A) 00000000->00100000 S(rw) NSBOOT(rw) NS(rw), id=0000000000000001, "Firmware", uf2 { 'rp2350-arm-s', 'rp2350-arm-ns', 'rp2350-riscv' }, arm_boot 1, riscv_boot 1
1(A) 00100000->00200000 S(rw) NSBOOT(rw) NS(rw), id=0000000000000002, "Data", uf2 { 'data' }, arm_boot 0, riscv_boot 0
有了分区表,我们就不用硬编码 data 分区的地址了。从 RP2350 datasheet 5.10.6 章节(Custom bootloader)抄点代码,做个实验:
int main() {
stdio_init_all();
static uint32_t buf[1024];
for (int t = 0; t < 100000; t++) {
int rc = rom_get_partition_table_info(buf, sizeof(buf) / sizeof(buf[0]),
PT_INFO_SINGLE_PARTITION | PT_INFO_PARTITION_LOCATION_AND_FLAGS | (
(t % 2) << 24));
printf("\n\n::read rc = %d\n", rc);
for (int i = 0; i < 64; ++i) {
printf("%lx ", buf[i]);
}
puts("");
uint16_t first_sector_number = (buf[1]
& PICOBIN_PARTITION_LOCATION_FIRST_SECTOR_BITS)
>> PICOBIN_PARTITION_LOCATION_FIRST_SECTOR_LSB;
uint16_t last_sector_number = (buf[1]
& PICOBIN_PARTITION_LOCATION_LAST_SECTOR_BITS)
>> PICOBIN_PARTITION_LOCATION_LAST_SECTOR_LSB;
uint32_t data_start_addr = first_sector_number * 0x1000;
uint32_t data_end_addr = (last_sector_number + 1) * 0x1000;
uint32_t data_size = data_end_addr - data_start_addr;
printf("first_sector_number = %x, last_sector_number = %x\n", first_sector_number, last_sector_number);
printf("data_start_addr = %lx, data_end_addr = %lx, data_size = %lx\n", data_start_addr, data_end_addr, data_size);
sleep_ms(1000);
}
// ...
运行结果:
::read rc = 3
8010 fc1fe000 fc0e1001 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
first_sector_number = 0, last_sector_number = ff
data_start_addr = 0, data_end_addr = 100000, data_size = 100000
::read rc = 3
8010 fc3fe100 fc011601 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
first_sector_number = 100, last_sector_number = 1ff
data_start_addr = 100000, data_end_addr = 200000, data_size = 100000
第 0 个分区是 [0, 0x100000);第 1 个分区是 [0x100000, 0x200000),符合预期。
rom_get_partition_table_info() 这个函数的签名十分怪异,它是 bootrom 里面固化的 ROM_FUNC_GET_PARTITION_TABLE_INFO 函数的包装,详见包装代码和 原始 bootrom 代码。现在,我们手里有 256 个物理 sector,每个 sector 包含 4096 字节,共计 1024KB,起始地址和结束地址都是已知的。下面来移植 FatFs。根据 FatFs 文档,我们需要提供的函数包括:
- 基本依赖:disk_status, disk_initialize, disk_read
- 支持写入:disk_write, get_fattime, disk_ioctl (CTRL_SYNC)
- 支持 mkfs 新建文件系统:disk_ioctl (GET_SECTOR_COUNT), disk_ioctl (GET_BLOCK_SIZE)
FatFs 会提供 f_open()、f_write()、f_lseek() 等函数,我们需要再手写一个胶水层,基于 FatFs API 实现 _open、_write、_lseek 等 stub,从而 newlib 可以给 Lua 提供货真价实的 fopen、fread 等 API。
先引入 lib。ff.c 是 FatFs 的主要实现,diskio.c 则是一个移植模板。我们在 cmake 中只包含 ff.c,等到主程序去提供 disk_ioctl 等 API:
cmake_minimum_required(VERSION 4.0)
project(fatfs_16 C)
add_library(fatfs STATIC ff.c diskio.c)
target_include_directories(fatfs PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
修改 ffconf.h 中的几个配置:
#define FF_USE_MKFS 1
#define FF_MIN_SS 4096 // 适配物理 section size
#define FF_MAX_SS 4096
// 允许使用以下风格的文件名:1:/filename、flash:/filename、/flash/filename
#define FF_STR_VOLUME_ID 2
#define FF_VOLUME_STRS "flash"
// 无实时时钟,采用固定时间戳
#define FF_FS_NORTC 1
这样就可以编译出 libfatfs.a 了,不过它会缺很多函数实现。把 diskio.c 和 diskio.h 挪到 /port 目录下,将其作为主程序的一部分,链接 pico_stdlib 和 fatfs:
add_executable(rp_lua_repl main.c port/diskio.c)
target_link_libraries(rp_lua_repl pico_stdlib lua microrl fatfs)
实现 fatfs 所需的函数:
#include "ff.h"
#include "diskio.h"
#include <stdio.h>
#include "pico.h"
#include "boot/bootrom_constants.h"
#include "boot/picobin.h"
#include "pico/bootrom.h"
#include "pico/flash.h"
#include "hardware/flash.h"
static uint32_t first_sector_number, last_sector_number;
void get_flash_section_count() {
static uint32_t buf[3];
rom_get_partition_table_info(
buf,
3,
PT_INFO_SINGLE_PARTITION | PT_INFO_PARTITION_LOCATION_AND_FLAGS | (1 << 24)
);
first_sector_number = (buf[1] & PICOBIN_PARTITION_LOCATION_FIRST_SECTOR_BITS) >>
PICOBIN_PARTITION_LOCATION_FIRST_SECTOR_LSB;
last_sector_number = (buf[1] & PICOBIN_PARTITION_LOCATION_LAST_SECTOR_BITS) >>
PICOBIN_PARTITION_LOCATION_LAST_SECTOR_LSB;
printf("init fatfs: first sector = %ld, last sector = %ld\n", first_sector_number, last_sector_number);
}
DSTATUS disk_status(BYTE pdrv) {
return 0;
}
DSTATUS disk_initialize(BYTE pdrv) {
return 0;
}
DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) {
printf("disk_read: pdrv=%d sector=%lu count=%d\n", pdrv, sector, count);
memcpy(buff, (void *) (XIP_NOCACHE_NOALLOC_BASE + (first_sector_number + sector) * 0x1000), count * 0x1000);
return RES_OK;
}
struct disk_write_param_t {
BYTE pdrv;
const BYTE *buff;
LBA_t sector;
UINT count;
};
void raw_disk_write(void *param) {
struct disk_write_param_t *p = param;
uint32_t offset = (first_sector_number + p->sector) * 0x1000;
// 先擦除再烧入
flash_range_erase(offset, p->count * 0x1000);
flash_range_program(offset, p->buff, p->count * 0x1000);
}
DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) {
printf("disk_write: pdrv=%d sector=%lu count=%d\n", pdrv, sector, count);
struct disk_write_param_t param = {pdrv, buff, sector, count};
const int rc = flash_safe_execute(raw_disk_write, ¶m, UINT32_MAX);
hard_assert(rc == PICO_OK);
return RES_OK;
}
DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) {
printf("disk_ioctl: pdrv=%d cmd=%d\n", pdrv, cmd);
switch (cmd) {
case GET_SECTOR_COUNT:
*(LBA_t*)buff = last_sector_number - first_sector_number + 1;
break;
case GET_SECTOR_SIZE:
*(WORD*)buff = 0x1000;
break;
case GET_BLOCK_SIZE:
*(DWORD*)buff = 1;
break;
default:
break;
}
return RES_OK;
}
做个简单的测试:
static int test_fs(lua_State *L) {
int ret = 0;
FIL fp;
ret = f_open(&fp, "hello.txt", FA_CREATE_ALWAYS | FA_WRITE);
printf("f_open ret = %d\n", ret);
const char str[] = "aloha oe";
UINT cnt;
ret = f_write(&fp, str, sizeof(str), &cnt);
printf("f_write ret = %d\n", ret);
ret = f_close(&fp);
printf("f_close ret = %d\n", ret);
ret = f_open(&fp, "hello.txt", FA_READ);
printf("f_open ret = %d\n", ret);
char b[100];
ret = f_read(&fp, b, sizeof(b), &cnt);
printf("f_read ret = %d\n", ret);
puts(b);
return 0;
}
文件读写正常:

hello.txt 写数据时,底层只发生了一次 disk_read;当我们调用 fclose() 关闭文件时,数据才真正落盘。这是缓存机制导致的。现在来实现 newlib stub。统计一下有哪些函数要写:
- pico SDK 空实现的:
_open、_close、_lseek、_stat - 需要覆写的:
_read、_write(因为它们现在只处理 stdin / stdout 这两个 fd)
由于 pico sdk 里面的 stub 都是 __weak 弱实现,我们只需要在主程序中再次定义这些函数,就可以覆盖这些默认实现。我们需要管理 fd 到 FIL 结构体的映射,这里为了偷懒,采用 STL map。实际生产中要考虑内存碎片问题。
#include "fs_stub.h"
#include <stdio.h>
#include "ff.h"
#include <map>
#include <sys/stat.h>
#include <fcntl.h>
#include "pico/stdio.h"
#include "pico/time.h"
std::map<int, FIL> fd_map;
int fd_cnt = 10;
extern "C" {
int _open(const char *name, int flags, int mode) {
int fd = fd_cnt++;
printf("[stub] _open: name = %s, flags = %d, mode = %d\n", name, flags, mode);
uint8_t fatfs_flags = 0;
if ((flags & 0b11) == O_RDONLY) {
fatfs_flags |= FA_READ;
} else if ((flags & 0b11) == O_WRONLY) {
fatfs_flags |= FA_WRITE;
} else if ((flags & 0b11) == O_RDWR) {
fatfs_flags |= FA_READ | FA_WRITE;
}
if (flags & O_APPEND) {
fatfs_flags |= FA_OPEN_APPEND;
} else if ((flags & O_CREAT) && (flags & O_EXCL)) {
fatfs_flags |= FA_CREATE_NEW;
} else if (flags & O_TRUNC) {
fatfs_flags |= FA_CREATE_ALWAYS;
} else if (flags & O_CREAT) {
fatfs_flags |= FA_OPEN_ALWAYS;
} else {
fatfs_flags |= FA_OPEN_EXISTING;
}
int ret = f_open(&fd_map[fd], name, fatfs_flags);
printf("(fd = %d) call f_open: name = %s, fatfs_flags = %d -> ret = %d\n", fd, name, fatfs_flags, ret);
return ret == FR_OK ? fd : -1;
}
int _close(int file) {
printf("[stub] _close: fd = %d\n", file);
int ret = f_close(&fd_map[file]);
printf("(fd = %d) call f_close -> ret = %d\n", file, ret);
fd_map.erase(file);
return ret == FR_OK ? 0 : -1;
}
int _lseek(int file, int ptr, int dir) {
printf("[stub] _lseek: fd = %d, ptr = %d, dir = %d\n", file, ptr, dir);
FIL *fp = &fd_map[file];
int new_pos;
switch (dir) {
case SEEK_SET: new_pos = ptr;break;
case SEEK_CUR:
new_pos = f_tell(fp) + ptr;
break;
case SEEK_END:
new_pos = f_size(fp) + ptr;
break;
default:
return -1;
}
int ret = f_lseek(fp, new_pos);
printf("(fd = %d) call f_lseek: new_pos = %d -> ret = %d\n", file, new_pos, ret);
return ret == FR_OK ? 0 : -1;
}
int _fstat(int file, struct stat *st) {
printf("[stub] _fstat: fd = %d\n", file);
FIL *fp = &fd_map[file];
st->st_mode = (fp->obj.attr & AM_DIR) ? S_IFDIR : S_IFREG;
st->st_size = f_size(fp);
printf("(fd = %d) call f_size -> st_mode = %lu, st_size = %lu\n", file, st->st_mode, st->st_size);
return 0;
}
int _read(int handle, char *buffer, int length) {
if (handle == STDIO_HANDLE_STDIN) {
return stdio_get_until(buffer, length, at_the_end_of_time);
}
printf("[stub] _read: fd = %d, length = %d\n", handle, length);
uint cnt;
int ret = f_read(&fd_map[handle], buffer, length, &cnt);
printf("(fd = %d) call f_read: cnt = %d, ret = %d\n", handle, cnt, ret);
return ret == FR_OK ? (int) cnt : -1;
}
int _write(int handle, char *buffer, int length) {
if (handle == STDIO_HANDLE_STDOUT || handle == STDIO_HANDLE_STDERR) {
stdio_put_string(buffer, length, false, true);
return length;
}
printf("[stub] _write: fd = %d, length = %d\n", handle, length);
uint cnt;
int ret = f_write(&fd_map[handle], buffer, length, &cnt);
printf("(fd = %d) call f_write -> cnt = %d, ret = %d\n", handle, cnt, ret);
return ret == FR_OK ? (int) cnt : -1;
}
}
现在,Lua 的 io 模块也正常工作了:
lua > file = io.open("test.txt", "w")
lua > file:write("Hello from lua")
lua > file:close()
lua > file = io.open("test.txt", "a")
lua > file:write(" +++ append!")
lua > file:close()
lua > file = io.open("test.txt", "r")
lua > print(file:read("*all"))
Hello from lua +++ append!
lua > file:close()
然而,测试 require 功能时出现了问题:

我们写入了 mylib.lua,但它并不在 require() 所尝试的文件范围内。查阅资料,发现 require 的搜索路径是 package.path 决定的。默认 path 是为 Linux 设计的。可以修改成 ?.lua:
lua > print(package.path)
/usr/local/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?/init.lua;/usr/local/lib/lua/5.4/?.lua;/usr/local/lib/lua/5.4/?/init.lua;./?.lua;./?/init.lua
lua > package.path = '?.lua'
lua > print(package.path)
?.lua
lua > require('mylib') -- 正常工作
我们可以改掉 Lua 的默认 path。只需要加一句 cmake:
target_compile_definitions(lua PUBLIC LUA_PATH_DEFAULT="?.lua")
效果:

至此,我们完成了 FatFs 的移植,可以通过 Lua 读写文件,也能调用 require() 了。本文的最后一项任务,是让 RP2350 把自己伪装成 U 盘,以便被 windows 读写。
本章小结:
- 移植 FatFs 的步骤:用 pico sdk 实现 FatFs 所需的 API,然后利用 FatFs 提供的 API 实现 newlib stub。
- RP2350 有多种启动模式。可以给 flash 分区。
- pico sdk 默认的_read()和_write()stub 只考虑了 stdio,移植时可能需要覆写。
实现 TinyUSB MSC:让 Windows 能管理文件
本文的运行环境是裸机。在这种情况下,程序必须频繁执行 tinyusb 的 tud_task() 函数,以及时处理 USB 事件。不过,我们使用 usb 串口时,并没有在自己的程序中调用这个函数。阅读 pico_stdio_usb 模块的相关代码可知,usb cdc 读写数据时都会调用 tud_task();除此之外,还会有后台任务调用它。这里的后台任务是指:如果可以给 USBCTRL_IRQ 添加处理器,则会在 USBCTRL_IRQ 触发后调用 tud_task();如果不能给它添加处理器,则会每 1ms 调用一次 tud_task()。
默认情况下 PICO_STDIO_USB_ENABLE_IRQ_BACKGROUND_TASK = 1,即开启后台任务。当然,如果我们想要手动管理 tinyusb,则可以将后台任务关闭。
tinyusb 的主线代码中已经包含了 rpi mcu 的移植,所以我们无需为 tinyusb 提供底层函数,只需要写上层应用。按照 tinyusb 文档,我们要做的实际上只有:
- 配置
tusb_config.h - 启动时调用
tusb_init();运行时频繁调用tud_task() - 对于要用到的功能,实现回调函数。例如,pico sdk 通过
tud_cdc_line_coding_cb()回调,监测 CDC 串口波特率变化,实现了“波特率变成 1200 则重启到 usb boot 模式”,详见代码
我们已经有了 CDC class 用作虚拟串口,还需要一个 MSC class 用作 U 盘。先来看看 pico sdk 提供的默认 src/rp2_common/pico_stdio_usb/include/tusb_config.h:
#define CFG_TUD_CDC (1)
#define CFG_TUD_CDC_RX_BUFSIZE (TUD_OPT_HIGH_SPEED ? 512 : 64)
#define CFG_TUD_CDC_TX_BUFSIZE (TUD_OPT_HIGH_SPEED ? 512 : 64)
#define CFG_TUD_CDC_EP_BUFSIZE (TUD_OPT_HIGH_SPEED ? 512 : 64)
#define CFG_TUD_VENDOR (1)
#define CFG_TUD_VENDOR_RX_BUFSIZE (256)
#define CFG_TUD_VENDOR_TX_BUFSIZE (256)
其中最重要的一句是 #define CFG_TUD_CDC 1,它指明了我们需要一个 CDC class。而 pico-examples 中的 dev_multi_cdc 项目需要两个 CDC class,它的配置文件是:
#define CFG_TUD_CDC (2)
#define CFG_TUD_CDC_RX_BUFSIZE (64)
#define CFG_TUD_CDC_TX_BUFSIZE (64)
#define CFG_TUD_CDC_EP_BUFSIZE (64)
#define CFG_TUD_ENDPOINT0_SIZE (64)
CDC 收数据的回调函数的签名是 void tud_cdc_rx_cb(uint8_t itf),其中 itf 表明了是第几个 CDC,所以两个 CDC 可以互不干扰地工作。
再看看 dev_hid_composite 例子,它的配置文件表示自己只有一个 HID 设备,没有其他设备。
#define CFG_TUD_ENDPOINT0_SIZE 64
#define CFG_TUD_HID 1
#define CFG_TUD_CDC 0
#define CFG_TUD_MSC 0
#define CFG_TUD_MIDI 0
#define CFG_TUD_VENDOR 0
#define CFG_TUD_HID_EP_BUFSIZE 16
依样画葫芦,我们需要一个 CDC + MSC 设备,则在 tusb_config.h 中设置 CFG_TUD_CDC = 1、CFG_TUD_MSC = 1 即可。配置文件就是把 pico sdk 的 src/rp2_common/pico_stdio_usb/include/tusb_config.h 抄过来,加两句:
#define CFG_TUD_MSC (1)
#define CFG_TUD_MSC_EP_BUFSIZE 0x2000
另一个需要准备的文件是 usb_descriptors.c,它远比 tusb_config.h 复杂。我们首先要搞清楚 USB 协议中的一些基础概念,可参考 tinyusb 文档:
- USB 有四种传输方式。一个 endpoint 就是一条单向的数据通路。
- device 有且仅有一个设备描述符(device descriptor)。包括 Vendor ID、USB Version 等。
设备描述符中的bNumConfigurations字段表示一共有多少个配置描述符。 - device 可以有多个配置描述符(configuration descriptor),每个配置描述符对应一种工作模式(例如,高功耗 / 低功耗可以是不同的模式),但是大部分情况下只有一个配置描述符。
配置描述符的bNumInterfaces字段表示它有多少个接口描述符。 - 一个配置描述符可以有多个接口描述符(interface descriptor),例如键鼠设备会有一个键盘接口、一个鼠标接口。
接口描述符的bNumEndpoints表示它除了端点 0(用于 control transfer)之外还有多少个其他端点。bInterfaceClass表示接口类别,例如 HID 是 0x03。 - 一个接口描述符可以有多个端点描述符(endpoint descriptor)。端点描述符的字段包括地址、传输类型、最大数据包大小等。
- 有一种特殊的描述符:字符串描述符(string descriptors),它是一个字典。例如,设备描述符中有一个
iManufacturer字段,只占一个字节,即制造商名称在字典中的序号;主机想要获知完整的制造商名称字符串,则查询字符串描述符。
现在,我们来解释 tinyusb 官方 cdc_msc 示例中的 usb_descriptors.c 文件。
static tusb_desc_device_t const desc_device = {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = USB_BCD,
// Use Interface Association Descriptor (IAD) for CDC
// As required by USB Specs IAD's subclass must be common class (2) and protocol must be IAD (1)
.bDeviceClass = TUSB_CLASS_MISC,
.bDeviceSubClass = MISC_SUBCLASS_COMMON,
.bDeviceProtocol = MISC_PROTOCOL_IAD,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = USB_VID,
.idProduct = USB_PID,
.bcdDevice = 0x0100,
.iManufacturer = 0x01,
.iProduct = 0x02,
.iSerialNumber = 0x03,
.bNumConfigurations = 0x01
};
uint8_t const *tud_descriptor_device_cb(void) {
return (uint8_t const *) &desc_device;
}
上面这个结构体是设备描述符。它是复合设备,所以它将自己的 bDeviceClass 字段设为 TUSB_CLASS_MISC,bDeviceProtocol 设为 MISC_PROTOCOL_IAD。而 iManufacturer 、iProduct、iSerialNumber 这三项都是字典序号。bNumConfigurations 为 1,表示它只有一个配置描述符。
static uint8_t const desc_fs_configuration[] = {
// Config number, interface count, string index, total length, attribute, power in mA
TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100),
// Interface number, string index, EP notification address and size, EP data address (out, in) and size.
TUD_CDC_DESCRIPTOR(ITF_NUM_CDC, 4, EPNUM_CDC_NOTIF, 16, EPNUM_CDC_OUT, EPNUM_CDC_IN, 64),
// Interface number, string index, EP Out & EP In address, EP size
TUD_MSC_DESCRIPTOR(ITF_NUM_MSC, 5, EPNUM_MSC_OUT, EPNUM_MSC_IN, 64),
};
上面这个结构体是配置描述符。它首先包含了供电需求等配置信息,然后是两个 interface。usbd.h 中提供了很多宏,所以用户可以方便地使用 TUD_CDC_DESCRIPTOR 来描述一个 CDC 接口。它实际上的展开方式是:
#define TUD_CDC_DESCRIPTOR(_itfnum, _stridx, _ep_notif, _ep_notif_size, _epout, _epin, _epsize) \
/* Interface Associate */\
8, TUSB_DESC_INTERFACE_ASSOCIATION, _itfnum, 2, TUSB_CLASS_CDC, CDC_COMM_SUBCLASS_ABSTRACT_CONTROL_MODEL, CDC_COMM_PROTOCOL_NONE, 0,\
/* CDC Control Interface */\
9, TUSB_DESC_INTERFACE, _itfnum, 0, 1, TUSB_CLASS_CDC, CDC_COMM_SUBCLASS_ABSTRACT_CONTROL_MODEL, CDC_COMM_PROTOCOL_NONE, _stridx,\
/* CDC Header */\
5, TUSB_DESC_CS_INTERFACE, CDC_FUNC_DESC_HEADER, U16_TO_U8S_LE(0x0120),\
/* CDC Call */\
5, TUSB_DESC_CS_INTERFACE, CDC_FUNC_DESC_CALL_MANAGEMENT, 0, (uint8_t)((_itfnum) + 1),\
/* CDC ACM: support line request + send break */\
4, TUSB_DESC_CS_INTERFACE, CDC_FUNC_DESC_ABSTRACT_CONTROL_MANAGEMENT, 6,\
/* CDC Union */\
5, TUSB_DESC_CS_INTERFACE, CDC_FUNC_DESC_UNION, _itfnum, (uint8_t)((_itfnum) + 1),\
/* Endpoint Notification */\
7, TUSB_DESC_ENDPOINT, _ep_notif, TUSB_XFER_INTERRUPT, U16_TO_U8S_LE(_ep_notif_size), 16,\
/* CDC Data Interface */\
9, TUSB_DESC_INTERFACE, (uint8_t)((_itfnum)+1), 0, 2, TUSB_CLASS_CDC_DATA, 0, 0, 0,\
/* Endpoint Out */\
7, TUSB_DESC_ENDPOINT, _epout, TUSB_XFER_BULK, U16_TO_U8S_LE(_epsize), 0,\
/* Endpoint In */\
7, TUSB_DESC_ENDPOINT, _epin, TUSB_XFER_BULK, U16_TO_U8S_LE(_epsize), 0
tinyusb 中,我们是通过回调函数来提供描述符。尽管描述符有“device - configuration - interface - endpoint” 这四级结构,但我们只写 tud_descriptor_device_cb() 和 ud_descriptor_configuration_cb(),接口和端点描述符被包含在配置描述符中了。此外,用 tud_descriptor_string_cb() 返回字典表。
再来观察另一个例子。pico-examples 的 dev_multi_cdc 案例有两个串口,我们看看它的 usb_descriptors.c。
tusb_desc_device_t const desc_device = {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = CDC_EXAMPLE_BCD,
.bDeviceClass = TUSB_CLASS_MISC,
.bDeviceSubClass = MISC_SUBCLASS_COMMON,
.bDeviceProtocol = MISC_PROTOCOL_IAD,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = CDC_EXAMPLE_VID,
.idProduct = CDC_EXAMPLE_PID,
.bcdDevice = 0x0100,
.iManufacturer = 0x01,
.iProduct = 0x02,
.iSerialNumber = 0x03,
.bNumConfigurations = 0x01
};
uint8_t const *tud_descriptor_device_cb(void)
{
return (uint8_t const *)&desc_device;
}
设备描述符与 cdc_msc 案例几乎相同。接下来是配置描述符:
uint8_t const desc_configuration[] = {
// config descriptor | how much power in mA, count of interfaces, ...
TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x80, 100),
// CDC 0: Communication Interface - TODO: get 64 from tusb_config.h
TUD_CDC_DESCRIPTOR(ITF_NUM_CDC_0, 4, EPNUM_CDC_0_NOTIF, 8, EPNUM_CDC_0_OUT, EPNUM_CDC_0_IN, 64),
// CDC 0: Data Interface
//TUD_CDC_DESCRIPTOR(ITF_NUM_CDC_0_DATA, 4, 0x01, 0x02),
// CDC 1: Communication Interface - TODO: get 64 from tusb_config.h
TUD_CDC_DESCRIPTOR(ITF_NUM_CDC_1, 4, EPNUM_CDC_1_NOTIF, 8, EPNUM_CDC_1_OUT, EPNUM_CDC_1_IN, 64),
// CDC 1: Data Interface
//TUD_CDC_DESCRIPTOR(ITF_NUM_CDC_1_DATA, 4, 0x03, 0x04),
};
uint8_t const * tud_descriptor_configuration_cb(uint8_t index)
{
// avoid unused parameter warning and keep function signature consistent
(void)index;
return desc_configuration;
}
配置描述符用 TUD_CDC_DESCRIPTOR 声明了两个 CDC 接口。最后是字符串表:
char const *string_desc_arr[] = {
// switched because board is little endian
(const char[]) { 0x09, 0x04 }, // 0: supported language is English (0x0409)
"Raspberry Pi", // 1: Manufacturer
"Pico (2)", // 2: Product
NULL, // 3: Serials (null so it uses unique ID if available)
"Pico SDK stdio" // 4: CDC Interface 0
"Custom CDC", // 5: CDC Interface 1,
"RPiReset" // 6: Reset Interface
};
uint16_t const *tud_descriptor_string_cb(uint8_t index, uint16_t langid)
{
// TODO: check lang id
(void) langid;
size_t char_count;
// Determine which string descriptor to return
switch (index) {
case STRID_LANGID:
memcpy(&_desc_str[1], string_desc_arr[STRID_LANGID], 2);
char_count = 1;
break;
case STRID_SERIAL:
// try to read the serial from the board
char_count = board_usb_get_serial(_desc_str + 1, 32);
break;
default:
// COPYRIGHT NOTE: Based on TinyUSB example
// Windows wants utf16le
// Determine which string descriptor to return
if ( !(index < sizeof(string_desc_arr) / sizeof(string_desc_arr[0])) ) {
return NULL;
}
// Copy string descriptor into _desc_str
const char *str = string_desc_arr[index];
char_count = strlen(str);
size_t const max_count = sizeof(_desc_str) / sizeof(_desc_str[0]) - 1; // -1 for string type
// Cap at max char
if (char_count > max_count) {
char_count = max_count;
}
// Convert ASCII string into UTF-16
for (size_t i = 0; i < char_count; i++) {
_desc_str[1 + i] = str[i];
}
break;
}
// First byte is the length (including header), second byte is string type
_desc_str[0] = (uint16_t) ((TUSB_DESC_STRING << 8) | (char_count * 2 + 2));
return _desc_str;
}
字符串表的 0 位置是特殊的,要返回语言 ID 数组。例如,0x0409 表示 en_US。STRID_SERIAL 字符串不是硬编码,而是去读芯片的序列号。其他的字符串采用硬编码,并转换成 USB 规范所要求的 UTF-16 格式。
至此,我们已经知道自己的 usb_descriptors.c 该如何配置了:以 pico sdk 的 stdio_usb_descriptors.c 为基础,加入我们的 MSC 接口。主要改动:
#define USBD_ITF_CDC (0)
#define USBD_ITF_RPI_RESET (2)
#define USBD_ITF_MSC (3) // 新增
#define USBD_ITF_MAX (4) // 接口总量从 3 改成 4
#define USBD_MSC_EP_OUT 0x03 // 新增
#define USBD_MSC_EP_IN 0x83 // 新增
#define USBD_STR_0 (0x00)
#define USBD_STR_MANUF (0x01)
#define USBD_STR_PRODUCT (0x02)
#define USBD_STR_SERIAL (0x03)
#define USBD_STR_CDC (0x04)
#define USBD_STR_RPI_RESET (0x05)
#define USBD_STR_MSC (0x06) // 新增
static const uint8_t usbd_desc_cfg[USBD_DESC_LEN] = {
TUD_CONFIG_DESCRIPTOR(1, USBD_ITF_MAX, USBD_STR_0, USBD_DESC_LEN,
USBD_CONFIGURATION_DESCRIPTOR_ATTRIBUTE, USBD_MAX_POWER_MA),
TUD_CDC_DESCRIPTOR(USBD_ITF_CDC, USBD_STR_CDC, USBD_CDC_EP_CMD,
USBD_CDC_CMD_MAX_SIZE, USBD_CDC_EP_OUT, USBD_CDC_EP_IN, USBD_CDC_IN_OUT_MAX_SIZE),
TUD_RPI_RESET_DESCRIPTOR(USBD_ITF_RPI_RESET, USBD_STR_RPI_RESET)
TUD_MSC_DESCRIPTOR(USBD_ITF_MSC, USBD_STR_MSC, USBD_MSC_EP_OUT, USBD_MSC_EP_IN, 64), // 新增
};
static const char *const usbd_desc_str[] = {
[USBD_STR_MANUF] = USBD_MANUFACTURER,
[USBD_STR_PRODUCT] = USBD_PRODUCT,
[USBD_STR_SERIAL] = usbd_serial_str,
[USBD_STR_CDC] = "Board CDC",
[USBD_STR_RPI_RESET] = "Reset",
[USBD_STR_MSC] = "Neko MSC" // 新增
};
最后,我们要实现 MSC 所需的 callback。回调清单可以在 msc_device.h 找到,写法可以参考 tinyusb 中以 RAM 伪装 U 盘的例子。实现如下:
extern uint32_t first_sector_number, last_sector_number;
int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize) {
memcpy(buffer, (void *) (XIP_NOCACHE_NOALLOC_BASE + (first_sector_number + lba) * 0x1000), bufsize);
return (int32_t) bufsize;
};
struct flash_write_param_t {
uint8_t lun;
uint32_t lba;
uint32_t offset;
uint8_t *buffer;
uint32_t bufsize;
};
void raw_flash_write(void *param) {
struct flash_write_param_t *p = param;
flash_range_erase((first_sector_number + p->lba) * 0x1000 + p->offset, p->bufsize);
flash_range_program((first_sector_number + p->lba) * 0x1000 + p->offset, p->buffer, p->bufsize);
}
int32_t tud_msc_write10_cb(uint8_t lun, uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize) {
struct flash_write_param_t param = {lun, lba, offset, buffer, bufsize};
const int rc = flash_safe_execute(raw_flash_write, ¶m, UINT32_MAX);
hard_assert(rc == PICO_OK);
return bufsize;
};
void tud_msc_inquiry_cb(uint8_t lun, uint8_t vendor_id[8], uint8_t product_id[16], uint8_t product_rev[4]) {
const char vid[] = "Neko";
const char pid[] = "RP2350 flash";
const char rev[] = "1.0";
memcpy(vendor_id, vid, strlen(vid));
memcpy(product_id, pid, strlen(pid));
memcpy(product_rev, rev, strlen(rev));
};
bool tud_msc_test_unit_ready_cb(uint8_t lun) {
return true;
};
void tud_msc_capacity_cb(uint8_t lun, uint32_t *block_count, uint16_t *block_size) {
*block_count = last_sector_number - first_sector_number + 1;
*block_size = 0x1000;
};
int32_t tud_msc_scsi_cb(uint8_t lun, uint8_t const scsi_cmd[16], void *buffer, uint16_t bufsize) {
return -1;
};
烧入程序,电脑上出现了 U 盘:

可以拷贝文件:

不过,程序还是有点 bug。大概是由于缓存原因,Windows 新建的文件,MCU 无法立即读取(反之亦然);Windows 修改文件后,MCU 虽然能读到新的内容,但仍然以为文件的大小没有变动。重启 MCU 即可解决问题。下图是从 MCU 读取 Windows 创建的文件:

虽然需要手动重启 MCU 以同步文件,但我们确实让 Windows 和 MCU 都能读写 fat fs 了。文初挖下的坑只有最后一步待填:让 MCU 启动时先执行 init.lua。这很简单,在进 REPL 之前加一句:
if (luaL_dofile(L, "init.lua") != LUA_OK) {
printf("init.lua error: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
}
功能正常:

现在用 init.lua 实现 LED 闪烁。由于 Lua 没有自带 sleep 函数,我们注册一个:
static int sleep_ms_for_lua(lua_State *L) {
int n = luaL_checkinteger(L, 1);
sleep_ms(n > 0 ? n : 1);
return 0;
}
// lua_register(L, "sleep_ms", sleep_ms_for_lua);
编写 init.lua:
while(true) do
set_led(0);
sleep_ms(1000);
set_led(1);
sleep_ms(1000);
end
重启 MCU,LED 果然开始闪烁。
本章小结:
- USB 的描述符是分层的:device - configuration - interface - endpoint。此外还有字符串描述符作为字典。
- 欲使用 tinyusb,则写一个tusb_config.h指定自己要用哪些 class(如 CDC、MSC、HID 等),并实现所需的回调。
- 描述符也是通过回调返回的,编写时可以参考其他项目的usb_descriptors.c。
结语:嵌入式软件栈的分层架构
经历了本文的实验,我们注意到,很多嵌入式软件可以如此描述:“给我提供函数 A、B、C,我会给你提供 X、Y、Z”。而且,很多情况下,这些函数不是显式注册的,而是通过命名约定的。例如,我们给 FatFs 提供了 disk_read 等直接操作物理 flash 的函数,于是它为我们实现了 f_open 等函数。我们利用 FatFs 提供的这些函数,实现了 newlib stub,于是 newlib 为我们提供了 fread 等类似于 POSIX 接口的高层函数。基于 newlib 提供的这些接口,Lua 可以读写文件,并给用户暴露 require()、io.input() 这些顶层 API。
这样的软件栈,与网络协议栈很相似——上下层之间只约定接口,不约定具体实现;每一层的具体实现都可替换(例如,我们完全可以把 FatFs 替换成 littlefs,无非 Windows 读写要借助专业软件)。当然,由于命名原因,我们可能需要写额外的胶水代码,例如把 FatFs 提供的 f_open 翻译成 newlib 所需的 _open。
从本文也可以看出,在 MCU 中自行集成文件系统和 usb 协议栈,并非一件易事。而且,本文是裸机环境,还会遇到“如何实现频繁调用 tud_task() 这类问题”——实际上我们是通过复用 pico_stdio_usb 中的现成方案,绕过了这个问题。然而,很多库都需要定时执行任务,总有些时候我们是绕不开的。典型的 MCU 应用程序还可能包含网络栈、图形界面等,组件越来越多,软件栈越来越复杂,移植过程会耗费大量精力。
幸而,我们有一个现代化的解决方案——Zephyr RTOS。本站的下一篇文章,将详细解释 Zephyr 是如何解决这些难点的。