独立运行
摘要
本文介绍了如何将一个名为 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
Solod 是一种新的系统编程语言,它是 Go 的一个子集,复用 Go 的工具链和标准库,同时编译为 C11,提供手动内存管理。
Solod: Go 可以成为更好的 C
Solod 是 Go 的一个严格子集,可编译为可读的 C11,零运行时,无GC,拥有丰富的标准库,让 Go 开发者获得系统级控制,同时为 C 程序员提供熟悉的工具链。
关于C扩展、可移植性和替代编译器
本文讨论了编写可移植C代码的实际挑战,这些挑战源于对非标准编译器扩展和glibc条件头文件的依赖,并通过构建C编译器的示例进行说明。
就用Go
一篇带有强烈观点的开发者文章倡导使用Go编程语言,强调其简洁的语法、强大的标准库、高效的并发模型以及单二进制部署,作为对过于复杂的现代技术栈的实用替代方案。
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。