第 1 章:5 分钟跑起来

上一章讲了 goc 是什么。这一章把它跑起来。

目标:5 分钟内在这台机器上编译并运行第一个 C 程序,然后交叉编出一个 Linux 可执行文件。

环境要求

要求
必需Go(构建工具链)、bash
可选Python 带 unicorn 绑定(跑 Linux 腿验证用)、MinGW 的 objdump(看 DLL 依赖用)

goc 本身只用 Go 写,没有 C 依赖。

第一步:编译工具链

git clone https://github.com/yafengabc/goc.git
cd goc
bash build.sh

一条命令产出四个可执行文件:

bin/
├── goc.exe        编译器(汇编器已内置)
├── goa.exe        汇编器(goc 编 C 用不到,独立手工写汇编才用)
├── elfcheck.exe   ELF 结构校验
└── msgboxcheck.exe 驱动 GUI 对话框并断言

⚠️ build.sh 走 Go 工具链,第一次会拉依赖。如果 go 不在 PATH 里,先确认 go version 能跑通。

第二步:第一个程序

/* hello.c */
int main() {
    printf("hello, world\n");
    return 0;
}

直接编译并运行——注意不需要单独的编译+链接两步,goc 一次吐出可执行文件:

./bin/goc.exe run src/examples/hello.c

输出:

compiled src/examples/hello.c -> ...\hello.exe (6656 bytes)
hello, world

run 会把程序编译到临时目录再执行,并把程序的退出码透传出来——所以它可以直接用在脚本里做回归测试。

其它几种用法:

./bin/goc.exe -c src/examples/hello.c                 # 只编译,产出 hello.o(可重定位目标文件)
./bin/goc.exe -S src/examples/hello.c                 # 只输出汇编(hello.asm)
./bin/goc.exe -E src/examples/hello.c                 # 只预处理,输出到 stdout
./bin/goc.exe -c -o bin/goc-out src/examples/hello.c  # 产物集中到目录
./bin/goc.exe a.c b.c -o app.exe                      # 多个 .c 编成一个可执行
./bin/goc.exe a.o b.o -o app.exe                      # 链接多个目标文件

-c 是 gcc 的意思:只编译,不链接,产出真正可重定位的目标文件(PE 目标下是 COFF,Linux 目标下是 ELF64)。所以完整的两步构建和 gcc 一样:

./bin/goc.exe -c a.c                            # a.o
./bin/goc.exe -c b.c    # b.o
./bin/goc.exe a.o b.o -o app.exe                      # 链接

也可以混着来——命令行里只要出现一个 .o,整个命令就走链接阶段,.c 会先被编译成临时的 .o 再一起链接:

./bin/goc.exe -c a.c && ./bin/goc.exe a.o b.c -o app.exe

-S 出来的汇编值得看一眼,它带个自明的头:

; generated by goc -- assembled by goa, no gcc involved
section .text
global _start

extern ExitProcess, kernel32
extern GetStdHandle, kernel32
extern WriteFile, kernel32

_start:
	mov r12, [rsp]
	lea r13, [rsp+8]
	and rsp, -16
	sub rsp, 48
	call main
	mov rcx, rax
	call __goclib_exit

三个细节:

  1. _start 而不是 mainCRTStartup——没有 CRT 启动代码。goc 自己造入口桩:取 argc/argv、对齐栈、call main、把返回值交给 __goclib_exit。
  2. extern ExitProcess, kernel32——归属 DLL 直接写在原型的逗号后面,编译期收集,不需要单独的中心表。
  3. 没有 msvcrt——写文件走 GetStdHandle + WriteFile。

第三步:看一眼产物体积

这是 goc 最直观的卖点:

$ ls -l hello.exe
-rwxr-xr-x 1 song 197121 6656 hello.exe

6656 字节。对比 gcc 编同样的程序(同样 MSYS2 ucrt64,-O2,加不加 -static 结果一样):

$ ls -l gcc_hello.exe
-rwxr-xr-x 1 song 197121 38989 gcc_hello.exe

小83%。而且 gcc 那 38989 里绝大部分是 CRT 启动代码和 printf 家族——不是你的代码。

第四步:交叉编译 Linux

改一个参数就行:

./bin/goc.exe -c -target linux -o hello.elf src/examples/hello.c

产出静态 ELF64。这个头跟 Windows 版完全不同——它没有导入表:

$ file hello.elf
ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

调用约定也从 Win64 切到了 SysV:参数从 rcx/rdx/r8/r9 变成 rdi/rsi/rdx/rcx/r8/r9,且没有 32 字节 shadow space。库那边同样按 __linux__ 分支重编:写走 write syscall,堆分配用 brk 做 bump allocator,退出转 exit_group。

💡 Windows 上没法 exec ELF,所以验证 Linux 产物需要 QEMU。goc 自带 tools/ucrun.py(Unicorn,即 QEMU 的 TCG 核心)跑真指令语义。CI 的 Ubuntu job 则直接在真实内核上 exec这些 ELF——那才是最硬的证明。

三个必踩的坑

坑 1:-o 的语义照抄 gcc

路径不存在时,-o 表示的是输出文件名,不是目录。

# bin/goc-out 不存在 → 会写出一个叫 "bin/goc-out" 的文件
./bin/goc.exe -c -o bin/goc-out hello.c

所以要输出到目录,必须先建:

mkdir -p bin/goc-out
./bin/goc.exe -c -o bin/goc-out hello.c

这不是文档吹毛求疵——README 记载 run_tests_linux.sh 当年就因为少了 mkdir -p,第一个例子写出个名叫 bin/goc-out 的文件,后面所有例子一律 Not a directory。

坑 2:zip 必须整目录解压

Release 里的 zip 是开箱即用的工具链目录,但 goc 在运行时要从磁盘读 C 源码(goclib/)。只把 goc.exe 拷出来会立刻失败:

cannot find the goclib C library

它不静默出错,但也编不了任何东西。查找顺序在 goc/libfs.go:

GOCLIB_PATH 环境变量 → exe 旁边的目录 → exe 的上级目录 → 当前工作目录及其各级上级

想让多份编译器共用一份库,设 GOCLIB_PATH 指向它即可。

坑 3:printf 的宽度被忽略

printf("%5d\n", 42);     // goc 输出:42      (标准 C 应为    42)
printf("%02x\n", 7);     // goc 输出:7       (标准 C 应为    07)
printf("%.2f\n", 3.14159); // goc 输出:3.14   (这个是对的)

宽度一概忽略,精度只有 %f 和 %g 认。这是与标准 C 明确的差异,goclib/stdio.c 里 vfmt 的注释写明了是有意为之。

支持的格式是%d %i %u %o %x %X %s %c %f %g %p %%,长度修饰符 l h L z j t 会被解析(所有变参槽位都是 8 字节,解析掉即等价)。没有 %e %a %n。

如果不想踩这个坑,用内建的 print——它按静态类型分派,不走格式化:

print("hello");   // 走 str_print,一个 write 调用
print(42);        // 走 int_print
print(42L);       // 走 long_print

跑测试

想验证整条链路,跑套件:

bash run_tests.sh              # 本机全量,最后汇总 pass=N fail=M
bash run_tests_linux.sh        # 在真Linux 上直接 exec ELF
cd src/goa && bash run_tests.sh  # 只跑汇编器自己的用例

run_tests.sh 一次做八件事:构建 → 找带 unicorn 的 Python → Windows 腿 ×3(-O0/-O1/-Os)→ Linux 腿 ×3 → 委托 goa 套件 → 三个模块单测。

有个设计值得注意:-O1/-Os 腿拿的是和 -O0 同一份 golden。

-O0  输出与历史管线逐字节相同   ← 所以 golden 永远成立
-O1  起才开始跑优化 pass

也就是说优化 pass 只能"不许改变可观察输出",而不是"重新生成一份新的期望"。这个护栏比"优化后再更新 golden"严格得多——后者会让优化 bug 悄悄变成新基线。

⚠️ 找不到可import unicorn 的 Python 时,脚本会大声跳过 Linux 腿并打 WARNING,而不是假装通过。这是有意的。

两个环境变量:GOC_PYTHON=/path/to/python 指定带 unicorn 的解释器;GOC_SKIP_MSGBOX=1 跳过 GUI 用例(需要交互式桌面,CI runner 没有)。

多文件

多个 .c 各自是独立翻译单元,宏、typedef、struct 标签互不干扰:

./bin/goc.exe main.c util.c math.c -o app.exe
  • static 符号只在本文件可见,重名时自动改成每文件唯一的内部名
  • 两个文件都定义的同名外部符号 → 重复定义错误

⚠️ 但不能消费 .o。goc 没有独立的链接阶段,别指望先编成目标文件再链接。

下一步

现在你能编译、运行、交叉编译了。下一章写第一个 Windows GUI 程序——用 MessageBox 弹个框,顺便验证产物真的只依赖系统 DLL。


本章命令与数字核对于 2026-10-06,goc 提交 f200cd1。用 goc --help 看完整选项;-O* -Wall -W* -std -m* -g -static -pthread -f* -s -l -L 里的多数选项接受但忽略(goc 是单遍编译器)。