独立运行

Lobsters Hottest 工具

摘要

本文介绍了如何将一个名为 Solod 的 Go 到 C 翻译工具变得独立运行的技术,方法是移植标准库包以使其无需 libc 即可工作,使用独立头文件和编译器内建函数等手段。

<p><a href="https://lobste.rs/s/8cq6jl/going_freestanding">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/20 14:45

# 进入自由环境 来源:https://antonz.org/going-freestanding/ 创建一个可编译为C语言的Go子集(https://antonz.org/solod/)(我称之为Solod)从来不是我的最终目标。我喜欢用Go编写C代码,但缺少标准库让开发变得非常受限。因此,下一步合乎逻辑的行动是移植Go的标准库。在某个时刻,我决定尽可能让多个包实现*freestanding*(独立运行)——即不依赖任何libc实现或特定操作系统的运行时环境。这个过程相当顺利。Solod现在有37个标准库包,其中31个可以在自由环境下工作。本文将介绍我为此采用的技术。内容并无真正创新之处,如果你有C语言经验,可能早已熟知这些方法。但我认为记录这一方法仍具价值——无论是对我自己还是对有兴趣的读者。 自由环境模式 • 头文件 • 内置函数 • 内存操作 • 原子操作 • 纯C实现 • 内存分配 • 值传递而非指针 • 目标钩子 • 仅托管环境适用 • 测试 • 总结 ## 自由环境模式 C语言有两种环境类型。在*托管*环境中,你可以使用完整的标准库——无论是C标准要求的库,或是更佳的POSIX库。在*自由*环境中,你几乎一无所有。编译器会通过以下方式指示当前环境: ```c #if __STDC_HOSTED__ // 可用libc #else // 需要自行实现 #endif ``` 只需传递`-ffreestanding`编译选项并链接`-nostdlib`,就完成了:你不再拥有`printf`、`malloc`,甚至`memcpy`。没有熵源、没有文件系统操作、没有时钟。如果说libc是“困难模式”,那么这就是“不可能模式”。尽管存在限制,自由环境模式在微控制器、WebAssembly沙箱、内核以及任何不依赖操作系统的场景中非常有用。 ## 自由环境头文件 自由环境并不意味着“仅使用C语言”。即使没有libc,C标准仍保证某些头文件可用,因为它们定义的是类型和宏而非函数: ```c float.h stdalign.h stdbool.h stdint.h limits.h stdarg.h stddef.h ... ``` 所有需要实际函数实现的头文件都被移除: ```c assert.h math.h stdlib.h time.h errno.h stdio.h string.h ... ``` 为了区分托管/自由环境,我们引入`builtin.h`——一个包含在每个标准库包中的通用头文件: ```c #if __STDC_HOSTED__ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <assert.h> #include <math.h> #include <time.h> #include <errno.h> #define so_build_hosted #else #include <stdbool.h> #include <stdint.h> #include <stddef.h> #endif // __STDC_HOSTED__ ``` 各个包遵循相同模式:根据`so_build_hosted`标志区分托管与自由环境的实现。 ## 编译器内置函数 GCC和Clang在不依赖libc的情况下实现了一些C标准函数,这些被称为编译器内置函数。 `__builtin_trap`使程序异常终止,可用于实现简易断言和panic机制: ```c #ifdef so_build_hosted #define so_panic(msg) \ do { \ fprintf(stderr, "panic: %s\n %s:%d (func %s)\n", \ msg, __FILE__, __LINE__, __func__); \ exit(1); \ } while (0) #else #define assert(cond) \ do { \ if (!(cond)) __builtin_trap(); \ } while (0) #define so_panic(msg) \ do { \ (void)msg; \ __builtin_trap(); \ } while (0) #endif // so_build_hosted ``` > 为简洁起见,后文将主要展示自由环境版本,省略托管环境版本。 `__builtin_alloca`函数在栈上分配内存,其封装版本限制了每次分配的最大大小: ```c #define alloca __builtin_alloca // 使用alloca可分配的最大内存 // 默认为64KB #ifndef SO_MAX_ALLOCA_SIZE #define SO_MAX_ALLOCA_SIZE (64 << 10) // 字节为单位 #endif #define so_alloca(size) ({ \ size_t _size = (size_t)(size); \ if (_size > SO_MAX_ALLOCA_SIZE) \ so_panic("alloca: size exceeds maximum allowed"); \ _size ? alloca(_size) : NULL; \ }) ``` ## 内存操作 `string.h`中的`memxxx`函数也有对应的内置函数,因此你可能期望自由环境构建能直接提供它们: ```c // int memcmp(const void* lhs, const void* rhs, size_t n); #define memcmp __builtin_memcmp // void* memcpy(void* dst, const void* src, size_t n); #define memcpy __builtin_memcpy // void* memmove(void* dst, const void* src, size_t n); #define memmove __builtin_memmove // void* memset(void* dst, int ch, size_t n); #define memset __builtin_memset ``` 可惜天下没有免费午餐。 `__builtin_memcpy`并非独立的`memcpy`实现。当`n`较小且在编译时已知时,编译器会将其展开为几个加载/存储指令。但如果`n`较大或仅在运行时已知,它会发出对真实`memcpy`的调用——与libc提供的符号相同。更糟的是,你甚至无需显式提及`memcpy`就能使用它。假设你这样复制大型结构体: ```c typedef struct { char buf[4096]; } Big; void copy(Big* a, const Big* b) { *a = *b; } ``` 当为`aarch64-freestanding`编译代码时,目标文件会包含对`memcpy`的未定义引用。零初始化局部数组同样会导致`memset`问题。这两个名称都不会出现在源码中,它们都是由编译器引入的。因此自由环境仍需提供`memcpy`、`memmove`、`memset`和`memcmp`以保证通用场景下的内存操作正常。 WebAssembly涵盖了其中三项:`memcpy`、`memmove`和`memset`可映射到`memory.copy`和`memory.fill`指令。由于没有比较指令,`memcmp`在WebAssembly中仍是实际函数调用。在其他目标平台上,工具链通常提供全部四个函数(如`zig cc`即使使用`-nostdlib`也会提供)。若未提供,则需要自行实现纯C版本: ```c #undef memcpy void* memcpy(void* dst, const void* src, size_t n) { unsigned char* d = dst; const unsigned char* s = src; while (n--) *d++ = *s++; return dst; } #undef memset void* memset(void* dst, int ch, size_t n) { unsigned char* d = dst; while (n--) *d++ = (unsigned char)ch; return dst; } #undef memmove void* memmove(void* dst, const void* src, size_t n) { // 因篇幅省略实现 } #undef memcmp int memcmp(const void* lhs, const void* rhs, size_t n) { const unsigned char* l = lhs; const unsigned char* r = rhs; for (; n--; l++, r++) { if (*l != *r) return *l - *r; } return 0; } ``` 即使自行实现函数,保留这些宏定义(`#define memcpy __builtin_memcpy`等)仍有价值。这样编译器在适用时仍可使用其内置实现。 > 有趣事实:在`-O2`及以上优化级别,GCC可能将你的自定义`memcpy`实现重新折叠为对`memcpy`的调用,导致无限递归。`-ffreestanding`因隐含`-fno-builtin`可防止此现象,但保障并不绝对。可使用`-fno-tree-loop-distribute-patterns`彻底禁用此行为。 `string.h`中的其他函数未被覆盖。没有编译器提供`memchr`或`strlen`,这些函数始终需要自行编写——下文将详细介绍。 ## 原子操作 另一组有用的编译器内置函数是`__atomic_xxx`,它们提供原子、线程安全的内存访问操作。这些函数作用于普通对象而非`_Atomic`对象: ```c // so_atomic_load 原子加载p处的值 #define so_atomic_load(p) \ (__atomic_load_n((p), __ATOMIC_SEQ_CST)) // so_atomic_store 原子存储v到p处 #define so_atomic_store(p, v) \ (__atomic_store_n((p), (v), __ATOMIC_SEQ_CST)) ``` 这使得移植Go的`sync/atomic`类型变得简单。所有类型(原子整数、无符号整数、布尔值和指针)都使用相同的两个加载/存储宏: ```c // Bool是原子布尔值,零值为false typedef struct atomic_Bool { bool v; } atomic_Bool; // Load原子加载并返回x中存储的值 bool atomic_Bool_Load(atomic_Bool* x) { return so_atomic_load(&x->v); } // Store原子存储val到x中 void atomic_Bool_Store(atomic_Bool* x, bool val) { so_atomic_store(&x->v, val); } ``` > 严格的`atomic_Bool`类型并非必需——`atomic_Bool_Load`和`atomic_Bool_Store`对普通`bool*`同样有效。但类型封装仍有价值。使用裸指针时,`*x = true`会形成隐蔽的数据竞争(看起来像普通代码),而封装器强制你显式编写`x->v = true`。无需包含`stdatomic.h`。但CPU必须原生支持所用的整数位宽。例如32位目标上的64位原子操作会变为对libatomic的调用而非单条指令。这是另一话题。 ## 纯C实现 若编译器未提供实现,则需自行编写。最好使用libc函数名以保持调用点不变。`bytes.IndexByte`需要的`memchr`是典型例子。虽然有`__builtin_memchr`,但它不是完整实现,需自行提供: ```c #ifndef so_build_hosted // 自由环境的memchr实现 static inline void* memchr(const void* s, int c, size_t n) { const unsigned char* p = s; unsigned char target = (unsigned char)c; while (n--) { if (*p == target) return (void*)p; p++; } return NULL; } #endif ``` 当然,部分DIY实现并非易事。幸运的是Go标准库包含许多独立算法,如`strconv`中的字符串转换函数或`math/bits`中的整数运算。将它们移植到C几乎是机械性工作: ```c // Go版本 const m3 = 0x00ff00ff00ff00ff // ReverseBytes32返回字节顺序反转后的值 func ReverseBytes32(x uint32) uint32 { const m = 1<<32 - 1 x = x>>8&(m3&m) | x&(m3&m)<<8 return x>>16 | x<<16 } // C版本 static const int64_t m3 = 0x00ff00ff00ff00ff; uint32_t bits_ReverseBytes32(uint32_t x) { const int64_t m = ((int64_t)1 << 32) - 1; x = ((x >> 8) & (m3 & m)) | ((x & (m3 & m)) << 8); return (x >> 16) | (x << 16); } ``` ## 内存分配 内存分配需要不同的技术。简单方法是实现使用静态缓冲区的自由环境`malloc`: ```c extern char so_heap[SO_HEAP_SIZE]; extern size_t so_heap_offset; static inline void* malloc(size_t size) { // 简化版本未考虑对齐 if (size > SO_HEAP_SIZE - so_heap_offset) { return NULL; } void* ptr = &so_heap[so_heap_offset]; so_heap_offset += size; return ptr; } ``` 这或许满足测试需求,但避免在生产环境中使用。我们不重新实现`malloc`,而是消除对它的需求,让调用者提供内存。首先定义分配器接口,使调用者不依赖特定实现: ```c // Allocator定义内存分配器接口 // 简化版本未包含Realloc和对齐功能 typedef struct { void* self; so_R_ptr_err (*Alloc)(void* self, so_int size); void (*Free)(void* self, void* ptr, so_int size); } mem_Allocator; ``` **so类型说明** `so_int`是目标位宽的整数: ```c #if SIZE_MAX == 0xFFFFFFFFu typedef int32_t so_int; #else typedef int64_t so_int; #endif ``` `so_String`是指向底层字符串字节及其长度的指针: ```c typedef struct { const char* ptr; so_int len; } so_String; ``` `so_Error`是封装错误数据的接口值: ```c typedef struct { void* self; so_String (*Error)(void* self); } so_Error; ``` `so_R_ptr_err`是实现(指针+错误)对的结果类型: ```c typedef struct { void* val; so_Error err; } so_R_ptr_err; ``` 还有其他类似类型如`so_R_int_err`(整数+错误)或`so_R_f32_bool`(float32+布尔值)。 接着提供内存池分配器,其本身即是自由环境设计: ```c // Arena是在线性固定缓冲区内进行递增分配的内存分配器 typedef struct { so_Slice buf; so_int offset; } mem_Arena; mem_Arena mem_NewArena(so_Slice buf) { return (mem_Arena){.buf = buf}; } so_R_ptr_err mem_Arena_Alloc(void* self, so_int size) { // 简化版本未考虑对齐 mem_Arena* a = self; assert(size > 0 && "mem: invalid allocation size"); if (size > so_len(a->buf) - a->offset) { return (so_R_ptr_err){.val = NULL, .err = mem_ErrOutOfMemory}; } void* ptr = &so_at(so_byte, a->buf, a->offset); a->offset += size; return (so_R_ptr_err){.val = ptr, .err = (so_Error){}}; } void mem_Arena_Free(void* self, void* ptr, so_int size) { // 内存池中释放为空操作 (void)self; (void)ptr; (void)size; } void mem_Arena_Reset(void* self) { mem_Arena* a = self; a->offset = 0; } ``` 使用示例: ```c typedef struct Point { so_int x; so_int y; } Point; // 准备内存池 so_byte data[1024]; so_Slice buf = {.ptr = data, .len = sizeof(data)}; mem_Arena arena = mem_NewArena(buf); mem_Allocator alloc = { .self = &arena, .Alloc = mem_Arena_Alloc, .Free = mem_Arena_Free }; // 分配Point // mem_Alloc是调用Alloc"方法"并在失败时panic的宏 Point* p = mem_Alloc(Point, alloc); p->x = 11; p->y = 22; ``` 在自由环境中,内存池比缓冲区支持的`malloc`更优,因为调用者决定可用内存量及释放时机。 ## 值传递而非指针 Go的构造函数通常返回指针: ```go // 字符串读取器 type Reader struct { s string i int64 // 当前读取索引 prevRune int // 前一个字符的索引,若<0则表示无效 } // NewReader返回从s读取的新Reader func NewReader(s string) *Reader { return &Reader{s, 0, -1} } ``` 使用上一节的内存分配器,可大致翻译为: ```c // 字符串读取器 typedef struct { so_String s; int64_t i; so_int prevRune; } strings_Reader; // NewReader返回从s读取的新Reader // 返回的读取器已分配内存,调用者拥有所有权 strings_Reader* strings_NewReader(mem_Allocator alloc, so_String s) { strings_Reader* r = mem_Alloc(strings_Reader, alloc); r->s = s; r->i = 0; r->prevRune = -1; return r; } ``` 与其盲目遵循Go习惯用法,更好的方式是完全消除分配并返回值: ```c // NewReader返回从s读取的新Reader strings_Reader strings_NewReader(so_String s) { return (strings_Reader){.s = s, .prevRune = -1}; } ``` 这并非编写自由环境代码的特定技巧,而是几乎适用于所有C库的实践。 ## 目标钩子 某些功能无法以平台无关的方式实现。只有目标平台知道如何输出字节、读取时钟或生成随机数——这些都依赖硬件。你可以声明函数(钩子)并让用户代码定义它们: | 钩子名 | 功能描述 | |--------|----------| | `so_write_out` | 向输出发送字节 | | `so_crand_read` | 读取随机字节 | | `so_time_wall` | 获取当前墙上时钟时间 | | `so_time_mono` | 获取当前单调时间 | | `so_time_sleep` | 暂停指定时长 | 用户随后可调用其硬件支持的特定API: ```c so_int so_write_out(const uint8_t* buf, so_int size) { return board_uart_write(buf, size); } int64_t so_time_mono(void) { return (int64_t)board_uptime_ms() * 1000000; } ``` 若用户未提供实现怎么办?我们仍希望标准库能编译并工作(除非有人调用缺失函数)。为实现此目标,可使用弱定义: ```c // so_write_out丢弃字节并报告完整写入 // 使得panic和fmt不输出任何内容且不报告错误

相似文章

依赖 Go

Lobsters Hottest

Solod 是一种新的系统编程语言,它是 Go 的一个子集,复用 Go 的工具链和标准库,同时编译为 C11,提供手动内存管理。

Solod: Go 可以成为更好的 C

Hacker News Top

Solod 是 Go 的一个严格子集,可编译为可读的 C11,零运行时,无GC,拥有丰富的标准库,让 Go 开发者获得系统级控制,同时为 C 程序员提供熟悉的工具链。

关于C扩展、可移植性和替代编译器

Lobsters Hottest

本文讨论了编写可移植C代码的实际挑战,这些挑战源于对非标准编译器扩展和glibc条件头文件的依赖,并通过构建C编译器的示例进行说明。

就用Go

Lobsters Hottest

一篇带有强烈观点的开发者文章倡导使用Go编程语言,强调其简洁的语法、强大的标准库、高效的并发模型以及单二进制部署,作为对过于复杂的现代技术栈的实用替代方案。

优化CPU密集型Go热路径的笔记

Hacker News Top

本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。