FFI 与底层工具链
本页覆盖直接操作编译器与运行时的部分:riddlec、riddle fmt/run/repl、C 后端产物、extern "C"、GC 运行时与解释器。项目级构建、依赖与目标平台配置见Clue 构建器。
riddlec
riddlec是单文件级编译器:读入 Riddle 源码,跑完整管线,按需要打印 MIR 或写出 C。
riddlec [--verbose] [--no-std] [--backend c] [--target <triple>] [--output <file>] <file>...
| flag | 短 | 取值 | 说明 |
|---|---|---|---|
--verbose | -v | — | 逐阶段状态打到 stdout |
--no-std | — | — | 不加载随编译器附带的 std |
--backend | -b | c | 生成 C 并写文件 |
--emit | — | c / mir | 见下 |
--target | — | triple | 目标平台,同时决定 usize/isize 的宽度;缺省是宿主或RIDDLE_TARGET |
--output | -o | 路径 | 输出文件 |
--version | -V | — | 版本号加构建时的 git hash |
--help | -h | — | 帮助 |
--emit与--backend互斥,两者的行为也不对称:
--emit mir把整个程序的 MIR 打印到 stdout,不写文件,退出码 0,输出以module main {开头。--emit c与--backend c是同一条代码生成路径:写到-o,没给-o就写到第一个输入文件同名的.c。两个 flag 互斥,所以--emit c不会带上--backend,但生成结果与之一致,main缺失同样在这一阶段报E0401。
其余行为:
- 多个
FILES会拼成一个包(用换行分隔),各自保留 source map,因此文件之间可以互相引用;入口是第一个文件。目录不能作为输入。 -o以.c结尾就原样使用;其它扩展名追加.c(-o app.h写出app.h.c);不给-o时用第一个输入文件的 stem 加.c,写在当前工作目录。- 没有
main时--backend先报error[E0401],消息为 “nomainfunction found in the entry package”,不写文件。 -v按顺序打印target: <triple>以及parse、macro expansion、hir lower、type check、move + escape analysis、MIR lowering的状态(ok/failed/skipped);parse 失败时后面几项直接打印为skipped。- 诊断只有 rustc 风格的人类可读文本,写 stderr,末尾是
error: aborting due to N previous error(s)。没有 JSON 输出选项。
退出码:
| 情形 | 退出码 |
|---|---|
| 成功 | 0 |
| 编译或检查有错误、没有输入文件、输入文件读不到、工作线程 panic | 1 |
参数错误(flag 互斥、非法--target等) | 2 |
--target恰好支持七个 triple:x86_64-unknown-linux-gnu、aarch64-unknown-linux-gnu、i686-unknown-linux-gnu、x86_64-pc-windows-msvc、i686-pc-windows-msvc、aarch64-pc-windows-msvc、aarch64-apple-darwin。传给--target的其它值由 clap 报参数错误(退出码 2),RIDDLE_TARGET里的非法值走普通错误路径。
选中的 triple 同时给出 usize/isize 的宽度:i686-* 下 4294967296usize 报 E0011,因为生成的 C 要把它装进 32 位的 size_t;在 64 位宿主上交叉编译到 i686-* 也同样拒绝,宿主宽度不再参与判断。riddle run和riddle repl 不选 triple——MIR 解释器在任何宿主都把 usize 当成一个 8 字节字,所以那条路径固定按 64 位检查。
环境变量:RIDDLE_TARGET在没给--target时决定目标;设置了RIDDLEC_PHASE_TIMING(值任意)时每个阶段的耗时打印到 stderr。
riddle fmt
riddle fmt复用riddlec的格式化器,编辑器里的textDocument/formatting也是同一份实现。
| flag | 取值 | 默认 | 说明 |
|---|---|---|---|
--emit | files / stdout / check | files | 写回文件、打到 stdout、只检查 |
--check | — | 关 | 等价于--emit check,与--emit互斥 |
--tab-size | 整数 | 4 | 缩进宽度,最小 1 |
--hard-tabs | — | 关(用空格) | 用制表符缩进 |
[FILES]... | 路径 | 空 | 不给文件或给-时读 stdin |
- stdin 模式下再给别的文件名会报错并退出码 2。
- 每个文件单独处理:读不到就报错并继续下一个;有词法或语法错误就打
riddle fmt: <name>:<line>:<col>: <message>并跳过该文件。 files只写内容真的变化的文件;check对每个会变的文件打印would reformat <path>。- 退出码:任何文件失败,或
check模式下有文件会变,都是 1;否则 0。stdin 加check且内容不一致时退出码 1。 - 格式化只改空白、保留 token 文本,换行风格跟随原文(原文含 CRLF 就用 CRLF)。
riddle fmt src/main.rid
riddle fmt --check src/main.rid
cat src/main.rid | riddle fmt --emit stdout
riddle run
riddle run [--seed <N>] <FILE> [-- <程序参数>...]
riddle run编译单个文件后交给内置解释器,不调用 C 工具链。文件之后的参数原样传给程序,--可写可不写;std::env::args()的第 0 项是源文件路径,不是可执行文件名。--seed <u64>设置std::random的种子,0表示按时钟播种。
| 情形 | 退出码 |
|---|---|
| 源文件读不到 | 2 |
编译失败(含缺少main,此时报error[E0401]) | 1 |
| 程序正常返回 | main的返回值 |
panic、abort、unreachable、调用未支持的extern | Windows 3,其它平台 134 |
| 栈溢出与内部错误 | 101 |
panic 的位置经过 source map 映射,指向真实源文件(穿过宏展开,也能映进随编译器附带的 std)。
riddle repl
riddle repl没有 flag,提示符是>>>,括号未闭合时用|续行(判定只看圆括号、方括号、花括号的净余额,不解析语法)。
- 顶层定义(
fun、struct、use等)累积进会话;let与表达式行写进一个自动生成的main。 - 每次求值都把整个会话重新编译并重跑,所以之前的副作用会重放,只有新增的输出会上报。
- 表达式打印自己的
{:?}值,并绑定到__供后续行使用;值是()时不打印。 - 命令:
:help或:?、:reset(清空会话)、:mir(打印最近一次成功编译的 MIR)、:quit/:exit/:q。其它:xxx只提示未知命令,不退出。 panic("...")这种不带路径的调用在 REPL 里不可用(会报未解析的名字),用panic!("...")宏。- 只有
:quit与 EOF 结束会话,都是退出码 0;行编辑器初始化失败是 1。 - 历史只存在内存里,没有
:load、:type等命令。
C 后端
每个包只生成一个.c文件,没有.h:prelude、类型定义、GC 描述符表、函数前置声明、函数体和 C 入口都在这一个文件里。文件头两行是// Generated by Riddle和一条建议的编译命令。
符号命名:
| 对象 | 名字 |
|---|---|
| 函数 | riddle_f_<名字每个字节的两位小写 hex> |
| 类型 | riddle_t_<symbol 的 hex>,symbol 形如struct::<id>::<Name> |
| 字段 | riddle_m_<hex> |
| 形参与局部值 | riddle_v_<hex> |
| 元组 | riddle_tuple_<hash16>,字段是f0、f1… |
| 函数指针 | riddle_fn_<hash16> |
| 导出函数 | 源码原名(extern "C"定义或#[c_export]) |
main有两种形态,取决于程序是否需要运行时(用到堆分配、调用rgc_*或读进程参数):
/* 不需要 runtime,例如 --no-std 下空的 fun main() {} */
int main(void) { return 0; }
/* 需要 runtime */
int main(int argc, char **argv) {
riddle_args_init((int32_t)argc, argv);
int rgc_stack_anchor = 0;
rgc_init(&rgc_stack_anchor);
return (int)riddle_f_main();
}
需要 runtime 时源码里的main改名成 mangled 名,由这层包装调用。默认加载 std 时基本都会走这一形态。返回()的main在 C 里是int加return 0;,返回i32的就是int32_t main(void);带参数的main在需要 runtime 时是编译错误(runtime-backed main cannot take parameters)。
类型映射:
| Riddle | C |
|---|---|
i8 / i16 / i32 / i64 | int8_t / int16_t / int32_t / int64_t |
u8 / u16 / u32 / u64 | uint8_t / uint16_t / uint32_t / uint64_t |
isize / usize | ptrdiff_t / size_t |
bool | bool |
char | uint32_t |
str / &str / &mut str | riddle_str({ const char* ptr; size_t len; }) |
[T] / &[T] / &mut [T] | riddle_slice({ void* ptr; size_t len; }) |
&T / &mut T | T*;共享引用也不加const,因为mut字段要能通过共享引用写入 |
*const T / *mut T | 内部值是T*;extern "C"声明里统一成void* |
[T; N] | C 数组;N == 0写成[1] |
struct S | riddle_t_<hex> |
(A, B) | 生成的 struct,字段f0、f1 |
enum E | 带tag和 payload 字段的 struct,变体字段名是{Variant}_{index}或{Variant}_{field} |
() | 返回位置是void,值位置是riddle_unit(unsigned char) |
运算与陷阱的生成方式与解释器一一对应:整数加减乘先提升到无符号 carrier 再还原;除零与MIN / -1由内联守卫打印riddle: division by zero之类的消息后abort();移位量按宽度取模;浮点转整数饱和(NaN 变 0);浮点余数调用fmod/fmodf;下标越界内联检查并abort();&str相等按内容(memcmp)比较,裸指针比较按地址。panic 渲染成thread 'main' panicked at <file>:<line>:<column>:加消息,然后abort()。
后端会直接拒绝的形态:
- 参数或返回值是裸
[T]、&[str]、切片返回值; - 裸
str参数或返回值(类型检查器通常更早报 E0043); - 导出名或 extern 名不是合法的 C 标识符;
gc = false时出现rgc_*调用。
手动编译生成的 C
riddlec --backend c -o hello.c hello.rid
cc hello.c crates/gc/src/runtime.c crates/gc/src/args_runtime.c -o hello
生成的头部注释就是这条命令的形态:需要 runtime 时是cc out.c runtime.c args_runtime.c -o out,否则是cc out.c -o out。手动编译要自己保证 C11,并显式给出一个内存运行时;进程参数由args_runtime.c提供,和内存运行时分开,因此自定义分配器不必实现std::env那部分。
两个容易踩的点:
- 浮点余数会生成
fmod/fmodf调用。在 glibc 目标上链接时要自己加-lm;clue的链接参数里没有-lm。 clue也不加-pthread:随附运行时是单线程的,不需要线程库。
extern “C”
声明块必须是unsafe extern "C" { ... },否则解析期报extern blocks must use unsafe extern。块里的函数默认是 unsafe 的,只有显式写成safe fun的声明才能在安全代码里直接调用:
unsafe extern "C" {
safe fun abs(x: i32) -> i32;
fun malloc(size: usize) -> *mut u8;
fun free(pointer: *mut u8);
}
fun main() -> i32 {
let buffer = unsafe { malloc(16) };
unsafe { free(buffer); }
abs(-42)
}
- 没有函数体的单函数声明(
extern "C" fun name(...) -> T;)是解析错误:single-function extern declarations are not supported; use an unsafe extern block。 - 带函数体的顶层
extern "C" fun name(...) { ... }是导出定义,不是导入。 - 声明里不允许泛型参数,解析期就会报错。
- 后端不按符号名内置任何 C 函数,
abs、malloc、strlen都是普通外部符号,由系统库或你自己的 C 代码提供。 safe fun是声明者对调用契约的承诺:标错的声明会让安全代码直接踩到未定义行为。
&str跨导入边界时会复制成 NUL 结尾的 C 字符串:
unsafe extern "C" {
safe fun puts(text: &str) -> i32;
}
fun main() -> i32 {
puts("hello from riddle")
}
调用点把参数字节复制到临时缓冲区并补一个 NUL,以const char*传入,调用结束即释放,所以 C 侧不能保存这个指针;参数里含嵌入的 NUL 会在运行时abort()(riddle: C string contains an embedded NUL)。导入返回&str时,C 侧必须返回 NUL 结尾的字符串,长度由strlen恢复后拷进托管内存。
切片只支持&[T](T有大小且不是str),并且会拆成两个 C 参数:&[i32]变成const int32_t*加size_t,&mut [T]用T*加size_t。结构体可以按值传递,生成的是extern int32_t takes_pair(riddle_t_<hex>);这样的声明。
unsafe、原始指针与as转换:
unsafe fun dereference(pointer: *const i32) -> i32 {
unsafe { *pointer }
}
fun main() -> i32 {
let pointer = 0usize as *const i32;
unsafe { dereference(pointer) }
}
解引用原始指针、索引原始指针、调用 unsafe 函数都必须写在unsafe { }里(否则报 E0046)。unsafe fun的函数体仍从安全上下文开始,内部的不安全操作要自己再包一层块。unsafe不关闭类型、可变性、移动和借用检查;不安全函数项也不会满足安全的Fn、FnMut、FnOnce约束,不能借安全 callable 参数绕开检查。
导出给 C
两种写法都导出源码原名:
#[c_export]
pub fun add(x: i32, y: i32) -> i32 {
x + y
}
extern "C" fun multiply(x: i32, y: i32) -> i32 {
x * y
}
两者的&str ABI 不同:
- 带函数体的
extern "C" fun保持riddle_str { const char* ptr; size_t len; },长度不丢; #[c_export]把&str参数写成const char*,函数体入口用strlen还原长度,所以 C 侧必须传 NUL 结尾的字符串。
导出名直接占用 C 的全局命名空间,只有“是不是合法 C 标识符”这一层检查(含关键字黑名单),没有前缀和命名空间。普通 Riddle 函数(包括库包的pub函数)名字一律被 mangle,C 侧不能按名字调用。
C 侧看到的 ABI 仍在预览阶段,会不兼容地变化:例如rgc_alloc已经从rgc_alloc(size_t)变成rgc_alloc(size_t, const uint32_t*)。跨语言边界建议显式传指针和长度,而不是依赖内部结构体布局。
GC 运行时
默认运行时是crates/gc/src/runtime.c(mark-sweep),crates/gc/src/args_runtime.c提供进程参数,两者分开链接;gc = false时改用crates/gc/src/no_gc_runtime.c。
| 符号 | 签名 | 语义 |
|---|---|---|
rgc_init | void rgc_init(void *stack_bottom) | 记录栈底;没初始化时回收直接返回 |
rgc_alloc | void *rgc_alloc(size_t size, const uint32_t *descriptor) | size == 0提升为 1;按阈值决定是否先回收;校验描述符;≤1 KiB 走 size class,否则malloc;清零描述符列出的指针槽后登记;存活期间地址不移动 |
rgc_realloc | void *rgc_realloc(void *ptr, size_t size) | 保留原前缀,可能换地址,继承原描述符 |
rgc_free | void rgc_free(void *ptr) | 接受NULL;只释放精确命中的基址,内部指针与外来指针静默忽略 |
rgc_collect | void rgc_collect(void) | 一轮完整标记清扫 |
rgc_is_allocated | int rgc_is_allocated(const void *ptr) | 精确基址当前是否已登记 |
riddle_alloc_bytes | void *riddle_alloc_bytes(size_t size) | 无类型字节存储,默认运行时等于rgc_alloc(size, NULL) |
rgc_stat_* | 见下 | 统计读数 |
统计访问器:rgc_stat_live_bytes、rgc_stat_live_objects、rgc_stat_total_objects、rgc_stat_collections、rgc_stat_typed_objects、rgc_stat_conservative_objects、rgc_stat_marked_objects、rgc_stat_typed_payload_scans、rgc_stat_conservative_payload_scans、rgc_stat_last_mark_ns、rgc_stat_last_sweep_ns、rgc_stat_heap_bytes、rgc_stat_chunks。都返回size_t。
描述符:
descriptor[0]是指针槽数量,descriptor[1 + i]是第 i 个槽在载荷内的字节偏移。NULL表示布局未知,退回逐字保守扫描;{ 0u }表示精确的“没有指针”,最省。- 登记时校验:槽数超过
size / sizeof(uintptr_t),或某个偏移加一个机器字超出对象,都会静默退化为NULL。 - 编译器侧一张描述符最多列 512 个槽(
RGC_MAX_DESCRIPTOR_SLOTS):恰好 512 个槽仍然会生成描述符,第 513 个槽才退化为NULL。 - 只是“无法忠实描述”的情况也会退化:还没降级成 tagged struct 的
enum、查表失败的类型。 - typed 分配只清零描述符列出的槽,其余字节不清零;传
NULL的分配完全不清零。堆分配不保证返回零初始化内存。
分配形状与扫描:
- ≤1 KiB 的分配从 64 KiB 的 chunk 里按 16 字节 size class 切块(共 64 类),更大的直接
malloc;清扫只把块还给对应的空闲链或malloc,chunk 本身一直留着。 - 根集是捕获的寄存器加
[当前帧, stack_bottom)之间的机器字;保守扫描按机器字对齐,内部指针解析到所属对象。 - 对象头存在带外登记表里,交给用户的地址就是载荷起始地址;精确查找走地址哈希表。
- 每轮回收后阈值变为
max(RGC_MIN_HEAP, live * 2),RGC_MIN_HEAP默认 1 MiB,可以用-D覆盖(编译期宏)。 - 设置了
RGC_DEBUG_STATS(值任意,存在即开)时,每轮回收往 stderr 打一行,含存活字节与对象数、typed/conservative 计数、本轮回收量、mark/sweep 微秒、下次阈值与两类扫描次数。
这块 ABI 是技术预览:不支持移动式回收、finalizer 和多线程栈注册,实现按单线程写。
gc = false时:
- 只提供分配器:
riddle_alloc(malloc,失败abort())、riddle_alloc_bytes(等于riddle_alloc,永不扫描)、riddle_realloc、riddle_free,产物里没有任何rgc_*符号。 - 编译器把堆分配降成
riddle_alloc(sizeof(T))(不带描述符)、释放降成riddle_free,不生成描述符表,也不发rgc_init。 - 引用逃逸不再是可接受的提升,而是编译期错误 E0310:需要自己保证不返回、也不保存指向栈值的引用。
自定义 runtime 写在Clue.toml的[runtime] source里(相对包根的 C 文件路径,必须存在),并且只对二进制包有效;gc = false不能与source同时给。provider 至少要实现rgc_init、rgc_alloc、rgc_realloc、rgc_free、rgc_collect;描述符参数可以忽略(一直保守扫描也是安全的),进程参数那部分不用管。自定义源码原样参与构建指纹,改一个字节就会重新构建。
解释器
riddle run和riddle repl用的是树遍历解释器,输入是完全 lowering 之后的 MIR——与 C 后端同一份,所以两侧语义按同一套规则实现:整数环绕、除零与MIN / -1的 trap、移位按宽度取模、浮点转整数饱和、下标越界abort、&str按内容比较、panic消息与位置映射、riddle: <message>形式的 abort 文本都一致。
标准库声明的extern由内置 shim 提供,覆盖:
- 字符与格式化输出:
putchar、riddle_fmt_fputc_stderr、riddle_io_stdin; - 文件系统:
riddle_fs_fopen、riddle_fs_fclose、riddle_fs_fread、riddle_fs_fwrite、riddle_fs_fflush、riddle_fs_fgetc、riddle_fs_ferror、riddle_fs_exists、riddle_fs_size、riddle_fs_is_file、riddle_fs_is_dir、riddle_fs_read_dir、riddle_fs_remove、riddle_fs_rename; - 进程与参数:
abort、riddle_process_exit、riddle_argc、riddle_argv_at、riddle_argv_len; - 时间与随机:
riddle_time、riddle_sleep_ms、riddle_random_u32、riddle_random_u64; - 内存门面:
rgc_realloc、rgc_free、riddle_str_slice_ptr、riddle_mem_swap。
把随附 std 的 extern 声明与这张 shim 表对差,有五条没有实现:riddle_alloc_bytes、riddle_proc_call_site_start、riddle_proc_call_site_end、riddle_proc_emit_diagnostic、riddle_proc_set_output。它们都来自std::proc_macro,所以过程宏相关的那条路径在解释器下不可用。其余extern(包括用户自己声明、期望链接到真实 C 符号的那些)会报riddle: interpreter does not support extern加符号名,并以退出码 3(Windows)或 134 结束。
内存行为与 C 侧不同:
- 指针是“分配 id 加偏移”,id 0 是空指针;字符串与函数指针靠 interning 保持身份。
- 每次分配都零初始化,
rgc_realloc的 shim 分配新块并拷贝min(旧长, 新长)。 - 内存会回收:栈帧返回时释放它的
alloca块,HeapFree与rgc_free释放程序持有的堆块,rgc_realloc释放旧块,空出来的 id 由后续分配复用,所以分配表不随运行时长单调增长。字符串字面量和提升后的存储不属于栈帧槽位,一直有效。 - 读到已释放的指针报
dangling pointer to released allocation N,读到越界偏移报memory access of N bytes at offset M escapes allocation K,不会把过期数据当正常结果返回。 - 运行配置是 256 MiB 栈与
max_depth = 200_000,超过报stack overflow。