Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-bc生成 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],消息为 “no main function 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
编译或检查有错误、没有输入文件、输入文件读不到、工作线程 panic1
参数错误(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取值默认说明
--emitfiles / stdout / checkfiles写回文件、打到 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、调用未支持的externWindows 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)。

类型映射:

RiddleC
i8 / i16 / i32 / i64int8_t / int16_t / int32_t / int64_t
u8 / u16 / u32 / u64uint8_t / uint16_t / uint32_t / uint64_t
isize / usizeptrdiff_t / size_t
boolbool
charuint32_t
str / &str / &mut strriddle_str({ const char* ptr; size_t len; })
[T] / &[T] / &mut [T]riddle_slice({ void* ptr; size_t len; })
&T / &mut TT*;共享引用也不加const,因为mut字段要能通过共享引用写入
*const T / *mut T内部值是T*;extern "C"声明里统一成void*
[T; N]C 数组;N == 0写成[1]
struct Sriddle_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_initvoid rgc_init(void *stack_bottom)记录栈底;没初始化时回收直接返回
rgc_allocvoid *rgc_alloc(size_t size, const uint32_t *descriptor)size == 0提升为 1;按阈值决定是否先回收;校验描述符;≤1 KiB 走 size class,否则malloc;清零描述符列出的指针槽后登记;存活期间地址不移动
rgc_reallocvoid *rgc_realloc(void *ptr, size_t size)保留原前缀,可能换地址,继承原描述符
rgc_freevoid rgc_free(void *ptr)接受NULL;只释放精确命中的基址,内部指针与外来指针静默忽略
rgc_collectvoid rgc_collect(void)一轮完整标记清扫
rgc_is_allocatedint rgc_is_allocated(const void *ptr)精确基址当前是否已登记
riddle_alloc_bytesvoid *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。