本章导读
前六卷把 xlang 自身的语言版图走完了:值与容器、函数与 class、拷贝与并发。卷七转向外部世界——任何严肃语言都绕不开一个问题:怎么和 C 说话。操作系统的 API 是 C 的, decades 积累的高性能库是 C 的,硬件厂商给的驱动头文件还是 C 的。xlang 的答案干净利落:extern "c_symbol" 声明即绑定,.xl 与 .c 文件放进同一条 xl run/build 命令即混合编译,不需要独立的 ffi 库、不需要写胶水层、不需要先生成绑定。本章逐层展开:最小 extern 声明怎么工作、二十一种 C ABI 类型各自多宽(Windows 的 long=4 有个经典陷阱)、可变参数怎么对接 printf、string 与指针过边界时谁复制谁负责、最后用一个"C 加速统计 + xlang 主控"的实战把 run 与 build 两种混编模式都跑通。读完你就具备了把任何 C 库接入 xlang 的基本功。
会用 extern "c_symbol" 声明 C 函数(含系统库零配置直调)与 C 全局变量(双向共享读写);掌握 C ABI 类型族 21 种与 xlang 原生类型的对应关系及本机位宽(含 Windows long=4 陷阱);会用 c_vararg 对接 printf 类可变参数函数(显式转换规则);理解 string 跨边界的 UTF-8 直通与"返回即复制"生命周期;会用 point 操作 C 指针(malloc/free 实战,不透明三规则);会用 xl run/build 混编 .xl+.c(含 --inc-path/--lib-path/--link/--loadlib 四参数);会写 export "c_symbol" 让 C 回调 xlang;记住 extern 的五条限制。
32.1 为什么需要 C 边界
三个现实决定了这门课的分量。一,生态:操作系统(Win32/POSIX)、数据库驱动、编解码库、数值计算库——半个世纪的基础设施都是 C ABI 接口,接通 C 就接通了这一切。二,性能:热点路径用 C 写、主逻辑用 xlang 写,是脚本型语言的经典分工。三,嵌入:xlang 本身编译为 C 再交给 TCC(第 1 章),与 C 共处是它的底层常态——extern 不是外挂,是把编译器本来就有的通道开放给你。
C ABI 是这套互通的"世界语":不管什么语言编译的产物,只要按 C 的调用约定(参数怎么压栈、返回值怎么放、符号叫什么名字)出牌,就能互相调用。本章的 extern 解决"xlang 调 C",32.8 的 export "c_symbol" 顺带解决"C 调 xlang"——双向闭环一次打通。
32.2 extern 声明函数
最小的 extern 程序只有三行——声明 C 运行库的 puts,然后像普通函数一样调用:
extern "puts" func puts(string text) as int32 puts("xlang calls c") // 输出:xlang calls c
解析机制值得说清:没有 import、没有链接参数、没有头文件。xlang 编译为 C 后经 TCC 内存重定位运行,C 运行库(Windows 上是 msvcrt)默认就在链接名单里——所以 puts/printf/sqrt/malloc 这些标准库符号声明即可用。符号找不到时才在链接阶段报错(坑 1),报错文本直接来自 TCC:tcc: error: unresolved reference to 'xxx'。
声明里的类型映射很直觉:xlang string 参数对应 C 的 const char*,int 对应 64 位 long long,float 对应 double,bool 对应 C99 bool。需要精确的 C 宽度(int 就是 32 位 int)时用下一节的 C ABI 类型。extern 声明后的函数是一等 func 值:可以赋给 func 变量、装进 var 动态调用(深入细节 2)。
32.3 C ABI 类型族
xlang 原生类型的宽度是语言自己定的(int 恒 64 位),C 类型的宽度是平台定的——要严丝合缝对接某个 C 签名,用 C ABI 类型族,共 21 种:
| 分类 | 类型 | 对应 C 类型 | 本机位宽(实测) |
|---|---|---|---|
| 字符 | c_char / c_schar / c_uchar | char / signed char / unsigned char | 1 字节 |
| c_wchar | wchar_t | 2 字节 | |
| c_bool | bool(C99) | 1 字节 | |
| 短整型 | c_short / c_ushort | short / unsigned short | 2 字节 |
| c_int / c_uint | int / unsigned int | 4 字节 | |
| c_long / c_ulong | long / unsigned long | 4 字节(Windows) | |
| 长整型 | c_longlong / c_ulonglong | long long / unsigned long long | 8 字节 |
| 指针系 | c_size | size_t | 8 字节 |
| c_ssize / c_ptrdiff / c_intptr | intptr_t / ptrdiff_t / intptr_t | 8 字节 | |
| c_uintptr | uintptr_t | 8 字节 | |
| 浮点 | c_float / c_double | float / double | 4 / 8 字节 |
位宽表是让 C 的 sizeof 在本机实测打印的(代码见 battle 节前的 t7 实测)。最刺眼的一格是 long=4:Windows 用 LLP64 模型(long 保持 32 位),Linux/macOS 用 LP64(long 是 64 位)——同一个 c_long,换个平台宽度就变。这正是 ABI 类型族存在的意义:它精确绑定 C 类型而非绑定宽度,把平台差异留给编译器处理(深入细节 1)。
用法决策很简单:签名里出现什么 C 类型,就声明什么 ABI 类型。C 函数参数是 int 就用 c_int,是 size_t 就用 c_size;只有面对 long long/double/char* 这类"与 xlang 原生恰好等宽"的签名时才直接用 int/float/string。调用处传 xlang int 字面量会自动收窄到 c_int(超范围行为未定义,自己守住值域)。官方守门用例 c_abi_types.xl 对 21 种类型逐一做了 echo 往返验证,全部通过。
32.4 c_vararg 与 printf
C 的可变参数函数(printf 一族)用特殊占位类型 c_vararg 声明,调用时每个可变实参必须显式转成 C ABI 类型——格式串说 %d 就喂 c_int,%f 就喂 c_double:
extern "printf" func printf(string fmt, c_vararg) as c_int c_int n = printf("int=%d float=%.2f str=%s char=%c\n", c_int(42), c_double(3.14159), "xlang", c_int(65)) // int=42 float=3.14 str=xlang char=A printf("hex=%x oct=%o neg=%d\n", c_int(255), c_int(8), c_int(-7)) // hex=ff oct=10 neg=-7
为什么必须显式转换?C 可变参数没有原型检查,参数按调用方给定的位宽原样压栈,格式串在运行期才解读——给错宽度就是未定义行为。xlang int 是 64 位,直接喂 %d(期望 32 位)属于类型错位;但反过来 %lld 期望 64 位时,xlang int 可以直传(battle 节就是这么写的)。c_vararg 只能出现在 extern 声明里(坑 5),xlang 自己的函数没有可变参数机制。安全建议:printf 玩玩可以,生产代码优先用 snprintf 系并核对每个实参的转换。
32.5 extern 变量
C 的全局变量用 extern "c_symbol" Type name 声明后直接读写——双向共享同一块存储:
extern "host_counter" int hostCounter println(hostCounter) // 100:读到 C 侧初值 hostCounter = hostCounter + 5 println(hostCounter) // 105:xlang 侧写入 hostCounter = hostAdd(hostCounter, 7) println(hostCounter) // 112:C 函数读到的也是同一存储
这不是"复制一份到 xlang",是同一个内存地址的两个名字——xlang 写、C 立即可见,反之亦然。用途:读 C 库的状态变量(经典的 errno 就是 extern "errno" int errno)、共享配置字、做轻量计数器通道。注意声明的是"绑定"不是"定义":存储归 C 侧所有,xlang 只是获得了访问权。
32.6 string 跨边界
字符串过边界有两个方向,规则各一条,都实测过。方向一,xlang → C:string 以 UTF-8 字节流直达 const char*,无任何转码——中文字符按 3 字节计(实测 strlen("xlang 混合编译")=18:5 字母 + 1 空格 + 4 汉字 × 3)。方向二,C → xlang:C 返回 char* 时,xlang 立即复制成独立 string——这是关键安全设计:
string a = hostGreet("xlang") println(a) // hello, xlang string b = hostGreet("world") // C 侧 static 缓冲已被覆写 println(b) // hello, world println(a) // hello, xlang:a 安然无恙 println(a == b) // false:两个独立副本
如果 xlang 偷懒直接引用 C 的指针,a 就会在第二次调用后被覆写成 "hello, world"——这是 C 接口的经典陷阱(static 返回缓冲)。xlang 用"返回即复制"免疫了它:过边界的那一刻,字符串的生命周期就与 C 侧脱钩。代价是每次返回一次复制,对热路径上的大文本要心里有数。
32.7 point 与 C 指针
C 的 void* 及各类不透明指针,在 xlang 侧用 point 承接。内存管理全流程实战——分配、填充、求和、释放:
extern "host_alloc" func hostAlloc(c_size size) as point extern "host_fill" func hostFill(point p, c_int value, c_size size) extern "host_sum_bytes" func hostSumBytes(point p, c_size size) as int extern "host_free" func hostFree(point p) point buf = hostAlloc(c_size(16)) println(buf == null) // false:分配成功 hostFill(buf, c_int(3), c_size(16)) println(hostSumBytes(buf, c_size(16))) // 48(16 × 3) hostFree(buf) // 谁分配谁释放,规矩不变
point 的行为准则是"完全不透明",三条铁律实测:可以存储、传递、与 null 比较;不能转数值——int(p)、uint64(p)、c_intptr(p) 全部编译报错([sem] 该表达式不能转换为目标类型);不能解引用、不能指针运算。这是刻意的:xlang 不允许你从脚本侧解剖 C 的内存布局,指针的正确使用姿势就是"原样拿、原样还"。需要按标签区分不同指针来源时还有泛型形态 point<T>(不同标签互不隐式转换),第 33 章的 native class 会把它用成资源句柄的载体。
32.8 .xl+.c 混合构建
本章的杀手级特性:.xl 与 .c 直接放进同一条命令,run 即时跑、build 出独立 exe:
# 混合运行:xlang 主程序 + C 宿主实现,一条命令 xl run app.xl host.c # 混合构建:产出独立 exe,随处运行 xl build app.xl host.c -o app.exe # 完整参数面:头文件路径 / 库路径 / 链接库 / 运行期动态库 xl run app.xl host.c --inc-path include --lib-path lib --link mylib --loadlib extra.dll
混编还能双向:xlang 用 export "c_symbol" 把函数导出为 C ABI 符号,C 侧 extern 声明后回调——闭环实测:
// app.xl:导出 doubleIt 供 C 回调 export "xl6_double_it" func doubleIt(int v) as int println("[xlang] doubleIt " + string(v)) return v * 2 end extern "call_xlang_twice" func callXlangTwice(int v) as int println(callXlangTwice(10)) // 42 // host.c:extern long long xl6_double_it(long long); // call_xlang_twice 里调 xl6_double_it(v) + xl6_double_it(v+1) // 输出:[xlang] doubleIt 10 / [xlang] doubleIt 11 / 42
这个闭环意味着分层架构落地了:xlang 写编排与业务、C 写引擎与热点,两边互调零胶水。export 的完整能力(package 命名空间、头文件生成、ABI dump)属于下一章,本章只取"C 回调"这一勺。
32.9 extern 的边界
五条限制全部实测(报错原文见常见错误):
- 类型面受限:静态容器(array<int> 等)、多返回值、泛型实例不能直接进入 extern 签名——C ABI 只有标量、指针与字符串的天下。
- 不支持可选参数:extern 声明里写可选参数直接报错——C 没有这个概念。
- c_vararg 专属 extern:xlang 函数不能声明可变参数。
- 一符号一 ABI:同一 C 符号用两个不兼容的签名重复声明,报"incompatible ABI"。
- 符号缺失在链接期爆:声明了符号但没提供实现(忘放 host.c、拼错名字、没加 --link),错误来自 TCC 链接阶段而非 xlang 语义阶段——排查时先确认符号提供方在命令行里。
实战:C 加速统计
把全章焊起来:xlang 持有文本,C 做字节级分类统计并把结果写进 C 全局变量,xlang 用 extern 变量读回、用 printf 从 C 侧格式化输出——run 与 build 两种模式都验证:
long long stat_letters = 0, stat_digits = 0, stat_others = 0; void classify_text(const char* text) { stat_letters = stat_digits = stat_others = 0; for ( const unsigned char* p = (const unsigned char*)text; *p; p++ ) { unsigned char c = *p; if ( (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ) stat_letters++; else if ( c >= '0' && c <= '9' ) stat_digits++; else stat_others++; } }
extern "classify_text" func classifyText(string text) extern "stat_letters" int statLetters extern "stat_digits" int statDigits extern "stat_others" int statOthers extern "printf" func printf(string fmt, c_vararg) as c_int string text = "xlang 2026: C ABI in action!" classifyText(text) // C 函数跑完,结果在 C 全局里 println("letters = " + string(statLetters)) // 17 println("digits = " + string(statDigits)) // 4 println("others = " + string(statOthers)) // 7 printf("C printf: %lld letters, %lld digits, %lld others\n", statLetters, statDigits, statOthers) // xlang int 直喂 %lld
命令与结果:
xl run stats.xl stats_host.c letters = 17 / digits = 4 / others = 7(另含 C printf 一行) xl build stats.xl stats_host.c -o stats.exe && ./stats.exe 输出完全一致——run 与 build 双模式行为无差别
这个结构是系统编程的标准分工:xlang 负责"做什么"(持有数据、编排流程、呈现结果),C 负责"怎么快"(字节级循环、无装箱开销)。extern 变量在这里扮演了零成本的共享通道——比把三个计数器包成 C 函数逐个返回简洁得多。
深入细节
为什么需要 c_long 这种"宽度随平台变"的类型?看两种 64 位数据模型:Linux/macOS 用 LP64——long 和指针都是 64 位;Windows 用 LLP64——long 死守 32 位,只有 long long 和指针是 64 位。同一份 C 头文件 long count;,在 Linux 是 8 字节,在 Windows 是 4 字节。如果 xlang 图省事让你直接用 int(64 位)对接 long 参数,在 Windows 上调用约定就错位了——参数位宽不符,轻则数值截断,重则栈帧紊乱。ABI 类型族的哲学是"绑定 C 类型名,让编译器去解决宽度":c_long 在 Windows 展开为 4 字节、在 Linux 展开为 8 字节,同一份 xlang 代码两平台都正确。这也是为什么对接系统 API 时宁可啰嗦写 c_int/c_long,也不要凭"反正本机 int 是 32 位"的侥幸。
extern 声明之后,C 函数在 xlang 里的身份就是普通函数值:func(int, int) as int fn = cadd 赋值合法,装进 var 后动态调用 boxed(20, 22) 也合法(官方用例 cgen_extern_function_value.xl 逐条守门)。实现上 extern 声明会生成一个 xlang 侧的 wrapper——它做参数收窄、字符串封送、调用约定适配,wrapper 的入口就是一个标准 func 值。含义很实际:C 函数可以进你的回调表、可以喂给 task.spawn 后台化、可以像任何 xlang 函数一样被组合。ABI 边界只在声明那一刻存在,越过边界后它就是语言公民。
C 返回 char* 时 xlang 立即复制,表面是防 static 缓冲覆写,深层是 xlang 全书一以贯之的所有权纪律(第 27 章拷贝语义、29 章消息箱、31 章单次消费):跨过所有权威胁边界的值,落地即独立。C 侧那块内存归谁、活多久、会不会被下次调用覆写——xlang 一概不猜,复制完事。对照方向同样成立:xlang string 传入 C 时,C 侧拿到的是仅在调用期间有效的 const char*,想长期持有必须自己 strdup。两条规则合起来是一份清晰的边界合同:谁想要长期所有权,谁负责复制。point 恰好是合同的反面——它就是"不复制、不透明、原样归还"的通道,用于那些本来就该由 C 侧全权管理的资源(32.7 的 malloc/free)。什么时候用 string(值语义复制)、什么时候用 point(句柄语义借用),就是本章选型的最后一问。
⚠️ 常见错误
xl run app.xl tcc: tcc: error: unresolved reference to 'host_add'
extern 只是声明,符号的实现必须出现在命令行里(host.c、--link 的库、--loadlib 的 dll)或 C 运行库默认名单中。报这个错先检查三件事:host.c 放了没有、符号名拼对没有(C 侧定义与 extern 字符串逐字一致)、该 --link 的库加了没有。
extern "native_bad" func bad(array<int> value) as int
编译报错:bad.xl:1:30: [sem] parameter type cannot enter first-stage C ABI extern directly(第 1 行)。C ABI 只认标量、字符串与指针;array<int> 这类 xlang 容器没有对应的 C 形态。正确做法:拆成"指针 + 长度"两个参数(point + c_size),或先在 xlang 侧转成标量序列逐个传递。
extern "native_pair" func pair() as (int, int)
编译报错:bad.xl:1:43: [sem] C ABI extern does not support multiple return values(第 1 行)。C 函数只有一个返回值。正确做法:让 C 侧通过指针参数写出多个结果(byref/输出指针是 C 的标准模式),xlang 侧对应声明指针参数。
extern "native_opt" func opt(int a, int b = 1) as int
编译报错:bad.xl:1:44: [sem] C ABI extern does not support optional parameters(第 1 行)。C 没有默认实参,extern 签名必须完整。正确做法:声明完整签名,在 xlang 侧包一层带默认值的普通函数转发。
func myPrint(string fmt, c_vararg) as c_int return 0 end
编译报错:bad.xl:1:10: [sem] c_vararg 只能用于 extern C 函数声明(第 1 行)。可变参数是 C ABI 的特权,xlang 函数没有这个机制。正确做法:xlang 侧用数组参数(func(...items) 或 array<T>)实现"不定个数",需要调 C vararg 时在内部做映射。
本章小结
| 主题 | 要点 |
|---|---|
| extern 函数 | extern "符号" func 名(参数) as 返回;系统 C 库零配置直调;声明后是一等 func 值 |
| 类型映射 | int↔long long、float↔double、bool↔bool、string↔const char*;精确签名用 21 种 C ABI 类型 |
| 位宽陷阱 | Windows LLP64 下 long=4 字节;c_long 绑类型不绑宽度,跨平台才安全 |
| c_vararg | 仅 extern 可用;每个可变实参显式转 ABI 类型;xlang int 可直喂 %lld |
| extern 变量 | 与 C 全局双向共享同一存储;声明是绑定不是定义 |
| string 边界 | 入 C:UTF-8 直通;出 C:返回即复制,生命周期脱钩 |
| point | 承接 C 指针;可存/传/比 null;不可数值化、不可解引用;谁分配谁释放 |
| 混合构建 | xl run/build app.xl host.c;四参数 --inc-path/--lib-path/--link/--loadlib;export "符号" 支持 C 回调 |
| 限制 | 禁容器/多返回/可选参数;一符号一 ABI;符号缺失报 TCC unresolved reference |
复习题
- 声明 extern "puts" 后为什么不需要 --link 就能调用?什么情况下符号会在链接期报 unresolved reference?
- xlang 的 int 与 C 的 long 在 Windows 上各多宽?为什么对接 C 签名应该用 c_long 而不是"凭本机经验"?
- printf("...%d...", 42) 直接传 xlang int 有什么风险?%lld 时为什么又可以直传?
- extern 变量与"xlang 里再声明一个同名变量"的本质区别是什么?
- C 函数返回 static 缓冲区指针时,xlang 的 string 接收为什么不会踩覆写陷阱?
- point 能做哪三件事、不能做哪三件事?这个限制的用意是什么?
编程练习
声明 sqrt 与 pow(msvcrt 提供,注意用 c_double 接参数与返回值),计算 sqrt(2) 与 pow(2, 10)。参考结果:1.414213562373095 与 1024.0。提示:sqrt 的签名是 double sqrt(double)。
host.c 里写 c_fib(int n) 迭代版,xlang 里写 xlFib 同算法版,对 n=0..30 逐一比对。参考结果:mismatches = 0。这个模式是"用可信实现校验新实现"的标准做法——接 C 库时先写对照测试再上生产。
在 battle 的基础上升级:xlang 导出 export "xl6_report" func report(int kind, int count),C 的 classify_text 每统计完一类就回调 report 汇报;xlang 侧打印三行汇报。提示:C 侧 extern long long xl6_report(long long, long long);,kind 用 0/1/2 区分类别。这个练习把 extern 变量通道换成回调通道,体会两种共享方式的取舍。