开发笔记:gocl(LLVM 后端)踩过的坑
这是一篇开发笔记,不是教程。记录的是把 goc 的 C 代码交给 LLVM 编译、再用自研链接器合成可执行文件这条路上踩到的坑——大部分是「读错字段不会报错、只是结果不对」那一类。教程正文里不写这些。
起因
goc 的默认后端是自研的 goa:手写 x86-64 代码生成 + 一条优化管线,产物是 PE32+ 或静态 ELF64,不依赖 gcc 也不依赖 libc。
后来想验证一件事:LLVM 能不能替掉 goa 后端。如果能,就等于免费拿到一整套中端优化、跳转表、向量化。做法是让 LLVM 把整个程序(用户函数 + C 库全部函数 + 全局变量)编译成单个 COFF 对象,再由 goa 贡献入口桩和镜像布局,把两者链接成 exe。
这条路子听起来只是"换个后端",实际踩出来的坑比预想多得多——因为 LLVM 吐出来的 COFF 对象要由我这个半吊子链接器吃下去,而 COFF/PE 里"读错字段不报错"的地方特别多。
这篇按主题分节,每节是若干条坑。每条都尽量写清"现象 → 根因 → 修法",因为这类坑的共性就是:现象离根因很远。
一、libLLVM 的 FFI:没有 cgo 怎么调
gocl 不链接 libLLVM,而是运行时用 syscall.LazyProc 动态加载。好处是 goc 本身不依赖 LLVM 装没装,坏处是所有 ABI 约定都得自己记对。
坑 1:LLVMCreateTargetMachine 返回 NULL 直接崩进程
少调一个初始化函数:LLVMInitializeNativeTarget / NativeAsmPrinter / NativeAsmParser 都调了,AsmParser 也注册了,但 LLVMTargetHasAsmBackend() 返回 0,随后 LLVMCreateTargetMachine 空指针崩溃——无异常、无诊断。
根因是 x86 后端要单独初始化 TargetMC。AsmParser/AsmPrinter 注册了但没有 MC 层,AsmBackend 标志就是 0。
初始化序列必须完整且有序:TargetInfo → Target → TargetMC → AsmPrinter → AsmParser。
坑 2:LLVMGetTargetFromTriple 的出参顺序和头文件相反
按头文件写的 (triple, &err, &target) 传,拿到垃圾指针,而且返回码仍然是 0——看起来"成功了"。真实签名是 (triple, &target, &err)。
附带一个容易误判的点:它返回的 target 是 TargetRegistry 里的静态存储,地址看着像栈地址(0x7ffc…)是正常的,别当成野指针去查。
坑 3:返回指针的函数不能判 rc != 0
LLVMCreateTargetMachine、LLVMCreateMemoryBufferWithContentsOfFile 明明返回非空,却被自己的错误检查报成"失败"。
这些函数返回的就是指针本身,非 0 即成功,没有状态码语义。按状态码判必然误报。判指针非空。
同类还有 CPU / Features 参数:必须传空字符串,传 NULL 会让整个编译器段错误。LLVM 23 不做 null 检查,直接解引用。同一个函数,ctypes 传空 bytes 能过、传 0 就崩——这个差异极难猜。
LLVMCreateTargetMachine 在 LLVM 23 还是 9 个参数(末尾多了 ThreadCount),按文档的 8 参数传会读到垃圾。
坑 4:LLVMRunPasses 调不动——无 cgo 方案的真正技术死点
LLVMStringRef 是 16 字节聚合,MSVC x64 ABI 下按值传递等于间接传指针,实际调用形态是 5 个指针参数。4 参 / 5 参 / 直接传 StringRef 结构体,全都崩。
Go 的 LazyProc.Call 是变参 uintptr 调用,无法表达"聚合按值传"这一 ABI 规则。
结论是这是限制不是 bug:长期只能拿到 TargetMachine 的 CodeGenOptLevel,拿不到中端优化管线(GVN / LICM / 向量化)。所有优化必须靠 irPasses() 返回的管线字符串。手工拼栈传参可行,但风险过高,没做。
附带:必须用
LazyProc.Call而不是syscall.SyscallN,因为后者要传裸uintptr,读回还得转unsafe.Pointer,而 CI 有go vet的 unsafeptr 门禁,必然红。
坑 5:libLLVM.dll 依赖 libzstd.dll,且文件名大小写敏感
LoadLibrary 失败。libLLVM.dll(不是 libllvm.dll)依赖 libzstd.dll,得从 D:/msys/ucrt64/bin/ 复制一份到 bin/。
坑 6:静态链接 libLLVM 省 29MB,但运行时崩
| 方案 | 大小 | 说明 |
|---|---|---|
| 动态(现状) | gocl.exe 3.2MB + libLLVM-23.dll 109.8MB | 128.4 MB,两个文件 |
| 静态单个 exe | 99.2 MB | 一个文件,省 29MB / −23% |
静态版在 LLVMInitializeX86Target() 之后段错误。
排除了"库不全"(X86 后端在 libLLVMX86CodeGen.a,而 libLLVMTargetX86.a 根本不存在)和"符号缺失"。崩溃点是 TargetRegistry 的 C++ 全局构造顺序问题——--start-group 能解库间循环依赖,解不了 C++ 静态初始化顺序。
判断:这是工具链限制不是代码 bug。真要攻,方向是 MinGW 的 .ctors 排序(-Wl,--sort-section=name),或者改用 clang/lld 构建 LLVM(它们的 .CRT$XCU 优先级支持完整,很可能直接能跑)。
顺带记一个容易漏的清单:静态链接需要的系统库里有 ole32(CoTaskMem/CoInitialize)、oleaut32、ntdll(RtlGetLastNtStatus)、uuid(CLSID_FileOperation / FOLDERID_* GUID),漏了都是链接期才报。
坑 7:LLVM 23 的 C API 导出面残缺(66/78 可用)
缺的必须绕:LLVMBuildLoad / LLVMGetConstInt → 用带显式类型的版本 LLVMBuildLoad2 / LLVMBinaryOperator;LLVMCreateTarget 是 C++ 符号未导出 → 用 LLVMGetTargetFromTriple;LLVMGetNumFunctions / LLVMGetFunction / LLVMGetTargetMachine 根本不存在 → 遍历或换别的办法。
换 LLVM 22 救不了变参:22 同样不接受 vaarg 指令关键字(两版报同一个 expected instruction opcode)。22 只多一个 LLVMWriteBitcodeToFile,价值有限。
二、LLVM IR:畸形输出的重灾区
这一层产出畸形 IR。几乎每一条都是 LLVM verifier 精确指出来的——给 82 个 src/examples 跑一遍 goc -fllvm -S(走 verifyModule + AsmPrinter),基线是 57 OK / 25 FAIL。比逐个猜语法快得多。
坑 8:运算符名不是 C 的拼写
直接透传 + - * / % & | ^ 被拒,报 expected instruction opcode。LLVM 叫 add sub mul udiv/sdiv urem/srem and or xor——除法/取余有符号无符号各一套,必须按类型选变体。
坑 9:call 的每个参数必须显式标注类型
同模块后面才定义的函数(递归)报 invalid type for function argument;补 declare 又报重定义。两条路都不通——LLVM 只从前置 declare 推断函数类型。唯一解是每个实参写成 i32 %t3 形式显式带类型。
坑 10:比较结果是 i1 不是 i32
控制表达式拿 i1 去 icmp ne ..., 0 类型不符;ret i32 %i1 非法。LLVM 的 i1 没有"提升成 int"的自动转换。
修法是引入独立布尔类型标记(boolIr() → KBool → i1),i1 参与算术前先 zext。
坑 11:binaryType 对纯算术一律 return nil(本轮最关键)
(a+b)、x/64 拿到 nil → e.ty(nil) 回落 "i32" → 发出 sdiv i32 配 i64 操作数。
根因很微妙:运行时 binary() 自己用 arithCommon 算对了宽度,错的是"嵌套表达式作为操作数时"的静态类型查询。修法是让 * / % & | ^ 返回 arithCommon、<< >> 返回左操作数提升类型、比较返回公共类型。
同类还有一串:下标未扩展成 i64(toI64:signed→sext、unsigned→zext、ptr→ptrtoint)、toInt 对整型常量误发 ptrtoint(只对 KPtr 才发)、移位计数宽度必须与被移值同宽、exprType 漏了 Unary - / ~ / AssignExpr / CondExpr / NumLit / StrLit 这些节点。
坑 12:NumLit.IsFloat 语义误用
3.14159(无后缀)被走整数分支 → 输出 double 0;NAN/INFINITY 宏同理,20 多处 sdiv i32 0, 0。
IsFloat 只表示"有 f 后缀 → 宽度是 float",不表示"是否浮点"。判浮点一律用 Kind == TDouble。这个坑在代码审查阶段被独立踩到两次。
坑 13:struct 尾部 padding 成员是错的
struct S{char*p; int n;} 被发成 {ptr,i32,[8 x i8]}(24 字节,C 里是 16)。根因是自行用 t.Size − 成员 size 之和 补 pad——LLVM struct 本来就按 datalayout 自动补齐。删掉即可。
union 类似的错:把"最宽成员宽度"当追加 padding,union U{char*p; int x;} 变 16 字节(C 是 8)。pad = size − 首成员;且 union 初始化按 C 语义只填第一个成员。
坑 14:聚合常量语法的两个硬约束
嵌套 struct 必须带类型名(%point { i32 1, i32 2 }),跳过元素必须写 zeroinitializer。裸 {...} 报 expected '}'。
这两条不是猜的——是搭了个临时 IR oracle(直接拿 .ll 文本喂 CompileToObject)实证出来的,比查文档快。
坑 15:switch 里三个独立 bug
① 无条件 trunc:trunc i32→i32 和 trunc i8→i32 都非法;
② default 语句被塞进上一个 case;
③ 用 *switchCase 指针跟踪当前分支,append 扩容使指针失效 → default 的语句跑到别的 arm 的 label 下。
第三个最阴:指针在 append 扩容后指向旧内存。改用 cur int 索引跟踪。
坑 16:函数名用作值时 decay 成 null
printf_lite_with(vfmt_i, ...) 这种"传格式化函数"的写法生成 store ptr null → 空指针跳飞。
irEmitter.ident 末段的注释明明写着「a function used as a value」,但代码走 zeroLiteral。不是 printf 专属:任何 int (*fp)(int) = twice 在 LLVM 后端下都段错误。
修法:typeResolver.isFuncName + irEmitter.fnPtrTy,且——LLVM 里函数符号本身就是其地址,转换结果就是裸符号,不做 load。
坑 17:decay 规则(约 10 个 examples 的最大类)
两种方向都错:
- 数组/全局该取基址却发 load:
load i32, ptr @G_g_arr然后当指针用; - 全局标量该 load 却漏 load:
icmp ne i32 %t11, @G_g。
根因是 llivrexpr 的 ident/rvalue 取值路径 + decay 规则。修法是在早期为所有全局注册类型(名字类型决定下标是否 decay、标量是否 load,错了是畸形 IR 而非缺符号)。这条和 lib 全局裁剪一起修的——类型注册不裁剪,定义才裁剪。
坑 18:alignOfLlir off-by-one
判据写成 len(ty) > 5 && ty[:5] == "float",5 > 5 恒假 → float/double 永远得 align 1;顺带 i1 会得 align 0(非法)。修正判据并把 align 钳到 >= 1。
三、变参(va_list):模型冲突
坑 19:printf("%d", x) 崩,printf("hi") 不崩
只读取变参的程序崩,不读变参的正常。
根因是 va_list 模型不匹配。goc 的 va_list 是 char*,原生后端按"8 字节平坦游标"走;LLVM 侧 llvm.va_start intrinsic 填的却是 Win64 四字段结构(gp_offset / fp_offset / overflow_arg_area / reg_save_area),游标推进本该由 vaarg 指令负责。最初把 va_arg 也按平坦游标写,与 va_start 建的结构完全对不上。
而 LLVM 23 已不接受 vaarg 指令关键字(ExpandVariadics pass 只做展开),@llvm.va_arg(ptr, [i32, i8*]) intrinsic 也被拒。clang 同样是前端自行展开 → 只能按 Win64 ABI 在前端展开。
坑 20:va_list 局部量必须给 24 字节存储
llvm.va_start 写的是 Win64 四字段结构,goc 的 char* typedef 只有 8 字节 → 踩坏后面三个槽。
第一版就栽在一致性上:va_start 写 %t11、实参求值读 %t10,而 %t10 从未写过。修法是新增 vaListSlot / vaSlots / paramNames 三个字段,保证 va_start、每个 va_arg、va_end、以及把 ap 传给别的函数时都落在同一个槽。
坑 21:无优化管线时 Win64 变参丢浮点实参
printf("%f", 1.25) → 0.000000。
Win64 要求浮点实参同时进 XMM 与整数寄存器,无管线版本缺这段搬运,goc 的扁平 va_list 读不到。
这直接决定了 -O 映射必须是"无 -O 也走 default<O1>"——是正确性要求,不是性能选择。既修正确性又不增体积(-O1 的 bench2 16896B < 无管线的 19968B)。
顺带修了个映射错误:-O1 原本映射到 LLVMOptLess,比不带旗标的 LLVMOptDefault 还低,等于"要求优化反而更慢"。
坑 22:Win64 上 va_list 按值传是潜在隐患
goclib 的 printf_lite_with(..., va_list ap) 是按值传,Win64 ABI 要求 va_list*。当前用例已过(va2 跨函数传通过),但多层嵌套转发可能暴露。已知限制,不是 bug。
四、COFF / PE:读错不报错的重灾区
这一节是全部坑里最值得写的——COFF/PE 里"读错字段不会报错、只是结果不对"的地方特别多。
坑 23:COFF 符号名判断反了 —— 一个符号都解析不出来
COFF 的 8 字节 name 字段,首 4 字节非 0 就是内联文本,首 4 字节为 0 才是字符串表偏移(长名字)。段名判断同理。
原写法是"非 0 = 偏移",判断整个反了。这个错误的特性是:不崩溃,只是所有符号都指向无关字节。
坑 24:符号表必须索引对齐 —— aux 记录也占一个符号槽位
重定位引用的索引整体前移,指向错误符号。量化过:nsym=23 但只有 16 个非 aux 条目,差 7 个 = 6 个段符号的 aux + 1 个 FILE 的 aux。
读的时候要 i += 1 + nAux,且 aux 要占位。
坑 25:IMAGE_RELOCATION 字段顺序是 {VirtualAddress(4), SymbolTableIndex(4), Type(2)}
按 (off, type, sym) 读,偏移看着正常但符号索引是 0x240004 这种垃圾值。符号索引在中间;且 Type 只有 2 字节,用 rd32 读会吞掉下一条记录的前 2 字节。
坑 26:重定位语义换算(goa fixup ↔ COFF)
- COFF
REL32:S + A - P,P = 字段开头 → goaripAdj = -4 - COFF
ADDR32NB:P = 所在段的开头 →ripAdj = -(off+4)
两类都能用现有 Fixup 结构表达,不需要改 applyFixup。
另外重定位 offset 是**“COFF 段内偏移”**,必须加上该段在 goa 段内的 baseOf,否则所有补丁提前几十字节、污染的是无关代码而不是报错。
坑 27:COFF REL32 分支没读 addend —— 控制台输出全哑、文件写正常
这是最值得写的一条"IR 全对但运行时结果错"。
现象:LLVM 后端下所有控制台输出全丢(puts / printf / putchar / fprintf(stderr)),显式 fflush、写 2000 字节、甚至 > file 重定向都没输出;但 fopen + fwrite + fclose 完全正常(out.txt 内容正确)。细节:putchar 返回 EOF 且置 _err;fwrite 返回 0 且不置错。
用户代码直接 GetStdHandle(-11) + WriteFile 能正常打印。
定位三件套:
- 运行时探针:镜像 FILE 布局写
struct F{...},强转stdout,把字段用 exit code 传出。结果 native 位图 = 51(fd+writable+base+off==-1),LLVM 侧 = 1(只有 fd 非 0)→_writable/_base/_size/_off全是 0。完美解释症状:fwrite因!f->_writable直接 return 0(不置错),fputc同样短路并置_err。而文件 I/O 走 heap FILE 逐字段赋值,故不受影响。 objdump -d -r t1.obj看 addend,objdump -d t1.exe看落点。- 落点对照表:偏移 0 的引用全对(
stdin_file/stdout_file/out_buf),带偏移的全错(stdin_file+32→ 落到stdin_file+0)。
根因:coffmerge.go 的 case relAMD64Rel32: 没有读 addend(旁边 ADDR32NB 分支读了)。
COFF 重定位条目本身没有 addend 字段 —— A 就存在待修补字段的当前字节里,必须 rd32(cs.data, off) 读出来。语义 S + A - P,P = 字段末尾(off+4),且 A 已含 trailing 补偿(实测:mov %rax, sym+32(%rip) → A=32, trailing=0;movq $imm, sym+8(%rip) → A=4, trailing=4 → 4+4=8;call → A=0)。
goa 的 fixup 公式是 CPU目标 = symRVA + addend + trailing − ripAdj,故只需 addend = 字段值、ripAdj 保持 0。call 的 A=0 所以不受影响——这也是为什么入口桩的 call 一直是好的,这个"为什么偏偏是它没事"是定位的关键线索。
教训:现象"只有控制台写失败、文件写成功"很容易误导向 FILE 层/缓冲逻辑(第一版就误判成"goclib stdio 的 _pos 维护")。但字段读出 0 说明是"写入没落到该落的地方",应立刻怀疑重定位/地址而非业务逻辑。
-fllvm -S 落 .s + objdump -d -r 是最快的定位路径。
坑 28:.bss 段没有文件字节,必须用 vsize 推进游标
只用 len(data) → .bss 段 cur == 0 → BuildPE 跳过该段 → 符号解析到下一个段的地址(.bss 的 counter 撞上 .pdata)→ 运行时访问冲突。
典型的"不报错的静默错位"。
坑 29:.pdata / .xdata 必须是独立段,不能并进 merged blob
unwind 表项存的是"相对自己段起点的 RVA",搬进共享 blob 会让每一条都失效。
修法:planUnwindSections / unwindSectionOut / imageEndOf;异常目录(data directory index 3)写 pdataRVA / pdataSize;Fixup 加 absolute 字段(ADDR32NB 要写绝对 RVA,不能走"target − 字段位置"的相对算法)。
坑 30:Win64 没有数据重定位,必须走 .refptr 槽
int *gp=&g; return *gp; 段错误。解码 .text 发现 lea rax,[rip+X] 目标 = 0x1000(.text 首)而不是 .data —— 位移是合法的,指令能执行,所以表现为崩溃而非链接错误。
Win64 COFF 引用未定义数据必须走 .rdata$.refptr.<name> 八字节槽(LLVM 就这么发)。goa 三处都不认识它:
- 段名不在
coffSectionMap→ 单独建段 → 镜像构建器只输出已知段,槽被丢弃; - undefined 符号解析不到 → RIP 相对位移算成"从段首起";
attdirective.go:458早已理解.refptr(AT&T 路径),但 COFF 合并路径没接。
修法:$.refptr. 段名归一到 .rdata;段接收时记 refptrFor[name] = loc;undefined 符号遇 refptr 则把符号指向槽位置。
这是 gocl 能出 exe 的最后一道阻碍。
坑 31:其他静默坑
- 导入名必须带
.dll后缀:同一 DLL 出现两个描述符(kernel32和kernel32.dll),loader 精确匹配找不到kernel32→0xC0000139(STATUS_ENTRYPOINT_NOT_FOUND),进程第一条指令都跑不到且零诊断。 __main是 LLVM 的 CRT 初始化桩:对象里每个函数都引用它,不映射就链接失败。合成一个内容为ret的 anchor 符号。ADDR32NB的 addend 要从被修补的那个段读,不是从目标段读。- 整个程序只能有一个
Image:对象里的main在第一个 Image,桩的call mainfixup 在第二个,永不相遇 →undefined symbol referenced: main。 .refptr修复时的变量遮蔽:if i := strings.LastIndex(...)里的i是字节偏移,同函数里baseOf[i+1]越界 →index out of range [7] with length 5(读起来像越界,实际是用了错的那个i)。
坑 32:COFF 对象的真实结构(实测数字,可作基线)
- 段共 6 个:
.text(0xd8).data(0x20).bss(4).xdata(0x18).rdata(0xf).pdata(0x18) - 重定位只有 2 种类型(比预想可控得多):
REL32× 10(代码内 call / lea rip-relative)、ADDR32NB× 6(.pdata的 RVA) @feat.00:sec = -1= absolute,安全 cookie 表- COMDAT:
.pdata带 auxcomdat 0,链接时要去重
五、体积:必须落到段表 + 对齐上量
坑 33:无条件发射全部 380 个 goclib 函数 → exe 100k+
原生后端靠 c.need 不动点只发可达的十几个,IR 前端没有这层。修法是加 llvmRoots(prog, lib, linux) 做不动点可达性游走。
实测(-dump-ir 数 ^define):
| 程序 | 发射前 | 发射后 |
|---|---|---|
int main(){return 42;} | 380 函数 | 2(3072B vs 原生 1536B) |
printf 用例 | 380 函数 | 43(19456B vs 原生 7680B) |
坑 34:printf 调用点特化不在 IR 前端 —— hello world 2.5× 膨胀
printf("hello, world\n") 一个空 main:goa 后端 6656 B,LLVM 侧 16896 B。
量化(按 .asm 标签行数统计):
| .asm 行 | 标签数 | exe 字节 | |
|---|---|---|---|
| goa 后端 | 1577 | 15 | 6656 |
| LLVM 侧 | 4362 | 67 | 16896 |
差异构成:共享 14 个标签,LLVM 独有 53 个(占其 .asm 的 80.9%)。多发的大头全是 printf 浮点格式化链,本程序一个都没用到:vfmt 791 行、double_to_hex 342、double_to_exp 298、fmt_int_part 265、exp 247……另有 heap_alloc/heap_free 在 LLVM 版里没有任何 callq 站点,纯虚引用。
根因链:printf → vfmt(printf 的核心分派器,函数体含覆盖所有格式符的 switch,含 %f)→ 静态引用收集把整条浮点链拉进来。
goa 后端没有 vfmt,因为 (*CG).genCall 里有调用点特化(constantFormatFwrite / constantFormatLite):printf 字面量格式串直接特化成直接写 stdout,完全绕过 vfmt。LLVM 侧完全没有这一步——特化挂在 goa 的 codegen 路径上,不在 IR 前端。
反直觉的另一半:共享函数里 LLVM 优化得更好(__goclib_file_flush 200→57 行、memcpy 61→19 行),函数体净膨胀才 +736 行——优化红利完全被多发的 3530 行淹没。所以"先补特化再谈 LLVM 优化水平",否则测出来的都是噪声。
这是架构缺口不是 bug:特化逻辑放错层了。
坑 35:靠 LLVM 自己折叠格式串——两条都走不通
先试了这条路,实测走不通,两条原因都要记住:
- IR 里
@printf是define而非declare(goclib 的 printf 被当普通函数发射),SimplifyLibCalls只处理已知 libc 符号,不认; - 格式串在 IR 里是
alloca+ 逐字节 store +getelementptr,即使认得 printf 也看不到字面量。
对照实验很能说明问题:绕开 printf 用 puts 时 LLVM 侧是 6144 < goa 后端 7680 ——膨胀完全来自 printf 链,与 LLVM 后端无关。
修法:特化判定抽到 src/printfspec.go,用 printfQueries 参数化两个查询(程序是否自定义该名 / 有同名函数指针变量),两个后端共用。hello world 16896 → 5632 字节(比 goa 后端 6656 还小 15%)。
坑 36:【铁律】可达性裁剪必须走同一个特化判定,否则互相抵消
llvmRoots 原本独立扫原始 AST,看到 printf 而非改写后的目标 → 裁剪把 printf 拉回来、特化被抵消,改了等于没改。
两条纪律:
llvmRoots也走specializePrintfCall;- Call 与 Ident 两类引用要合并进一次遍历——两遍并存时第二遍会把刚摘掉的 printf 又加回来(第一版就犯了这个)。
附带:改写还引入原调用没有的引用(fwrite 拿到的 __goclib_stdout() 是个 Call),要一并标记可达,否则链接缺符号。
顺带修了个静默 bug:shadow 判定查的是 tr.funcDefs,但那张表同时装用户函数和 goclib 的 wanted 函数 → fwrite 永远被判"被遮蔽",特化从不触发。新增 tr.userDefs 只装用户函数。
坑 37:PE 段级文件对齐 —— 36 字节的段吃掉 512
print("hello world")(走 str_print,printf 特化链完全没参与)LLVM 侧 3072 字节,原版 2048。
我先查错了方向:认定是"IR 前端无条件发射全部 25 个 lib.globals",改完发现体积纹丝不动(IR 里 25→0 个 G_ 定义、.data 231→183,但总大小不变)。
教训:改完必须重新看段级数据;不能因为"找到一个看起来合理的缺陷"就认定它是根因。
真根因 = PE 段级文件对齐。LLVM 多的 .pdata/.xdata(Win64 SEH 展开表)表本身很小(每函数 .pdata 12B + .xdata ~11B),但 PE 每个段在文件里占 FileAlignment 的整数倍,512 是 Windows 接受的最小值 → 两个段 = 实打实 1024B,与内容多少无关。三个函数花 1024 存 72 字节。规模越大亏越多。
段表对比(print 场景):.text goa 719 / LLVM 452、.data 231 / 183、.pdata — / 36(raw 512)、.xdata — / 36(raw 512)。代码数据两边 LLVM 都更小。
coff.go 原注释写「keeping them costs nothing」——按 36+36 字节算的,忽略了文件对齐。
修法:新增 Section.Unmapped。COFF 合并照常并入这两个段的字节(符号照常解析、ADDR32NB 重定位照常应用 → 不留悬空引用),只是不给镜像地址。Unmapped 是纯 PE 概念,ELF 侧不碰。
丢的是崩溃后 post-mortem 回溯栈。goc 只编 C、无 C++ 异常、无自己的 unwinder,且原生后端早就接受了同样损失 → 体积决策非正确性决策。
结果:print("x") 2048→1536;printf("plain") 6656→4096;printf("%d",3) 7680→5120;printf("%f",1.5) 14848→7680;puts 7680→4608。不再有任何一档更大。
测试要直接读 PE 段表 + data directory[3],不比总大小——表将来变大也不会在重新引入浪费时仍通过。
坑 38:FileAlignment 设 0x1000 导致 93 字节的 .text 占 4096
hello.exe 16384 字节。SectionAlignment 必须保持 0x1000(页大小,改不了),但 FileAlignment 可以。改成 0x200(PE 规范最小值,也是 MSVC 默认)→ 1536 字节。
附带坑:合并段后 符号基址表 symBase 和节的 VirtualAddress 是两回事 —— .data 节的 VA 是 dataBase,而 .data 里符号的 RVA 是 dataBase + 偏移,两者不能混用。
坑 39:printf("%%\n") 是个真漏洞(两边共有)
%% 是字面百分号,ScanLiteFormat 对它 continue 但没有置 has → liteTargetFor 返回 false → 整个程序退回全量 vfmt(22016 字节 vs 应有的 ~4096)。
goc 也一样(22016/37376),所以不是回归而是两边共有的功能缺口,也是唯一一处 LLVM 后端明显吃亏的常见写法。
修法方向(未做):%% 应视作"无转换但需解转义"——要么单独走 fwrite(格式串需先解转义成单个 %),要么让它算作 has=true 并给 lite 加解转义。
fprintf/sprintf/snprintf 一律未特化(没有对应 lite 入口),这是设计取舍不是 bug。
坑 40:体积实测对照(-O2,21 个用例)
总和:LLVM 侧 229888 / goa 侧 366592(小 37%)。
| 档 | LLVM 侧 | goa 侧 | delta |
|---|---|---|---|
| 空程序 | 1536 | 1536 | 0 |
| 无 %(putchar / puts / printf 字面量) | 3584~4608 | 5120~6144 | +1536 |
整数 lite(%s / %d / %x%o%c / %u%i%X) | 5632 | 6144 | +512 |
浮点 lite(%f / %f×2 / 混合) | 7680~8192 | 13312 | +5120~5636 |
math.h(%f+sqrt) | 8192 | 13824 | +5632 |
未特化(%e / %g / %% / %ld / fprintf…) | 18944~22016 | 31744~37376 | +12800~15872 |
IR 侧函数数印证特化生效:printf 字面量 14 个函数且没有 vfmt;%d 22 个,走 printf_lite + vfmt_i;%% 43 个,整套 vfmt + double_to_buf 全被拉进来。
六、AT&T 前端:六个翻译难点
goa 原本只吃 Intel/NASM 语法,而 LLVM 的 AsmPrinter 输出 AT&T/GAS,两者不兼容——早期只能让 libLLVM 直接吐 COFF。加了 AT&T 前端后(逐行译成 goa 内部语法再喂既有 encode(),x86 编码逻辑一行未改),链条才真正打通。
六个难点全部由 72 个真实样本暴露,不是推演:
坑 41:操作数方向不能一律翻转
- 尾随内存操作数即目标 → 需翻;
- 但
add/cmp配寄存器源时顺序已经是对的 → 翻了会报错; - 纯存储
mov与立即数必须翻(立即数物理上在末尾字节); - 三操作数
imul是轮转,不是交换。
这是 8086 src/dst 歧义留下的历史包袱,不是可以统一处理的规则。
坑 42:内存操作数自带逗号
8(%rax,%rbx,4), %eax —— naive split(',') 会把寻址撕碎。必须按括号深度切分。
坑 43:内存宽度只能从助记符后缀取
decl -4(%rbp) 是 32 位,但 AT&T 没有 dword 前缀。得把后缀宽度转写进 memWidth。
坑 44:movq/movd 歧义
movq %rsi,%rcx = GPR;movq %xmm0,%rax = SSE。靠操作数是否含 xmm 区分。
坑 45:GAS 无操作数惯用语按源宽度命名
cqto(= 64 位,goa 的 cqo)若译成 cdq → rdx 高半段未定义。还有 cwtq/cltq → cwde/cdqe。
坑 46:dot 标签有两种作用域
.LBB* / .Ltmp* 是函数内的(要加函数名前缀);.str.0 / .LCPI0_3 是文件级且被十几个函数共用 —— 按 NASM 惯例一律加前缀会让第二次引用就悬空。
同源历史 bug(goa 侧):
.开头的标签曾是全局的(defineSym后者覆盖),num.asm里fact和fib各有一个.Lrec→fact的jg .Lrec跳进fib的.Lrec。这曾是"calc.asm 那个无法解释的 bug"的真正根因(当时绕过去了没找到)。修法:qualify()拼上最近的全局标签名,恢复 NASM/GAS 语义。
坑 47:跳转表重定位需要"一对符号"
72 个真实 LLVM 样本只有 4 个能链成 PE,其余 68 个全卡在 .long .LBB29_14-.LJTI29_0(switch 跳转表项,含义是"case 目标相对表基的偏移")。
两个标签通常分居不同段(case 在 .text、表在 .rdata),汇编期无法化简;而 goa 的 Fixup 一处只记一个符号。
修法:Fixup 加 sym2 携带被减数;applyFixup 在 sym2 非空时写 (sym - sym2);PE/ELF 两侧对称解析。assemble 侧用 attSplitSymDiff 识别 A-B——减号是指令名里唯一可能出现的字符,符号名不含它,切分无歧义。
结果:72 样本解析 72/72、链接 72/72(此前 4/72)。
坑 48:跳转表的验证方法(关键)
合成 6 路 switch,链成 PE 后用 tools/peun.py 在 Unicorn 里真跑,每个 case 累加不同权重、退出码即总和。
这一条必需——表项算错时字节仍合法、镜像仍能加载,只有真正执行才暴露(跳进无关代码或 trap)。而且 peun.py 对 LLVM 产物不可用(恒在 rip=入口报 unmapped access,栈模拟不建映射),但在 AT&T 这条链路上它是必需的。
坑 49:AT&T 路径的四个静默 bug
.section名解析把反斜杠混进字符集:IndexAny(nm, "\",")截引号,字符集里有反斜杠 → Windows CRLF 的\r先命中 → 段名变成"dr"→ 退化成.data里的匿名段;- MSVC 栈探测缺失:LLVM 把 >1 页栈帧降级成
call ___chkstk_ms(三个下划线,x64 MSVC ABI 名),必然链接失败; - 分号注释未剥离 → 行尾注释被当成后续操作符(GAS 用
#,但手写汇编常用;); - goa 风格的无点
section/global掉进指令流:section .rdata,"dr"因带引号被HasPrefix(".")拦掉。
坑 50:两份实现只会互相遮蔽
为 ___chkstk_ms 新建了 src/goa/winstack.go,后来发现 coffmerge.go:122 早已正确实现,且带 16 字节对齐填充(缺了对齐会让 COFF 合并进来的符号落位偏移,入口桩调 main 会跳到函数前的对齐字节上立刻 fault)。删掉重复实现后实测 8192 字节栈帧的函数 rc=0。
教训:加"发射某符号"的代码前,先 grep 同名符号的其它发射点。
顺带一个测试方法上的要点:
att_test.go用字节级等价对照——同程序分别用两种语法写,要求.text完全相同,且 Intel 一侧手写而非从前端导出,否则测试是同义反复。
七、线程局部存储:表象是"递归坏了",根因完全不同
这是最近才修的一个,值得单开一节——因为它是我判断错了一次的方向。
坑 51:Nim 生成的 fib 递归在 LLVM 后端静默无输出
gocl 跑 Nim 生成的 C 递归(fib)静默无输出,goc 正确输出 fib 317811。
第一反应是"递归代码生成有问题"。于是逐步隔离:
| 用例 | goc | gocl(修前) |
|---|---|---|
32 位 fib(30) | 832040 | 832040 ✓ |
64 位 long long fib(30) | 832040 | 832040 ✓ |
尾递归 sum(100) | 5050 | 5050 ✓ |
| goto 早返 + 调用间错误检查的递归 | 317811 | 317811 ✓ |
| 递归中读一个 TLS 变量 | 832040 | 0 ✗ |
| 非递归读一个 TLS 变量 | 5 | 1073754112 ✗ |
纯 C 递归全部正确 → 不是递归的问题。改写成 64 位、goto 早返、去掉调用约定修饰(N_NIMCALL 在 x64 上是无修饰)都仍然正确 → 也不是这些。
最小复现:_Thread_local int g_x = 5; int readx(void){ return g_x; } → gocl 返回 1073754112(垃圾)。
Nim 侧机制:Nim 每个递归调用后执行 if (NIM_UNLIKELY(*nimErr_)) 读一个 TLS 错误标志 nimInErrorMode;gocl 把这次读生成成垃圾 → 误判为真 → 提前返回 0 / 无输出。
所以根因是 gocl 缺 TLS 支持,与递归无关。
坑 52:为什么 gocl 的 TLS 是坏的
两层原因叠加:
- IR 侧:把 TLS 全局当普通
extern全局(noteExternGlobal),LLVM 于是生成load @G_x——直接读静态模板地址,完全绕过 Windowsgs:0x58/ Linuxfs的 per-thread 机制; - 链接侧:
linkData里 TLS 全局被continue跳过了,所以Data.TLSVars是空的 → 链接器根本没铺.tls段、没建 TLS 目录、没定义G_goc_tls_index。
坑 53:修法——复用原生后端已有的设施,不新造一套
关键认知是:原生后端早就把 TLS 做完整了(codegen 手写访问序列 + emit.go 铺 .tls 段 + pe.go 建 PE TLS 目录)。gocl 只需要按同一套布局产出访问代码,把段和目录交回链接器。
具体四处:
translate.go新增ComputeTLSLayout,按原生tlsPlace规则(8 字节对齐 +link.TLSAlignedSize槽宽)给每个 TLS 全局分配偏移;linkData用同一个函数填Data.TLSVars;- 访问 TLS 全局改经 helper
__goc_tls_slot(off)取 per-thread 地址,不再load @G_x; emit.go在有 TLS 变量时把__goc_tls_slot汇编进入口桩:- Windows:
mov ecx,[rip+G_goc_tls_index]→mov rax,gs:[rax+0x58]→mov rax,[rax+rcx*8]→lea - Linux:
lea [rip+__tls_start]+ off
- Windows:
两个必须注意的点:
noteExternGlobal是陷阱:把 TLS 声明成 extern 全局就直接退化成"读模板地址",必须跳过;- IR 访问偏移与链接器
.tls布局必须用同一个函数算,否则逐条错位。
结果:8 文件 +181/−27。验证 readtls / fibtls / 多类型混合 + 写入回读 / 取地址写回四类用例在 gocl 下全对,goc 同用例无回归,Nim 五个内核 gocl 从 4/5 变 5/5。
八、Nim 生成的 C
坑 54:NIM_STRLIT_FLAG 被静态初始化器折成 0
Nim 的字符串字面量标志是 NIM_STRLIT_FLAG = ((NU)(1) << 62),写成
setLengthStrV2 据此判断是否需要新分配。
echo 3.5 在两个后端下都段错误。最小复现证明 ((NU)(1) << 62) 在 goc 下 cap 被算成 0、gcc 下正确。
根因:foldConstInit 不处理 CastExpr → 这个常量表达式折不出来 → 静态初始化器被写成 0 → setLengthStrV2 误判需要重新分配。
修法:加 CastExpr / CondExpr / 一元 ! / _Bool·_BitInt 截断(新增 foldCastInt / truncInt)。
这条与后端无关(两后端共享
common/link),但它揭示了一类问题:前端常量的折叠能力决定了 C 库能不能用,而不是"编译器支持不支持这个语言特性"。
坑 55:goclib 缺 Nim 运行时函数
完整 bench.nim(含 std/strutils)拉进 resize__system_u3063、eqdestroy___system_u3728 等 Nim 运行时函数,goclib 尚未实现 → 暂不可编。
这是运行时覆盖率的事,与后端无关,所以基准改用 5 个自包含内核(sieve / fib / intloop / strbuild / float)。
九、架构与工程坑
坑 56:混合双生成器是本轮所有 bug 的根源
最初的设计是"用户函数走 LLVM、goclib 走原生"。两个生成器共享符号表,在三处分歧,每一处都表现为链接错误或静默的错误结果:
- 全局变量名(
G_前缀不一致); - undefined 符号是"导入"还是"对方已定义";
- 变参调用的格式串重写。
修法:IR 侧拥有全部 C 代码(用户函数 + goclib 全部函数 + 全局变量含初始值);goa 只负责启动桩(非 C,按平台 ABI 设置进程栈)+ 链接 COFF 对象。分工变成"两种不同种类的代码"而非"两个编译器抢同一批符号"。
配套的一条原则:IR 翻译器必须与 codegen 零耦合。IR 生成器是 CG 的另一个输出后端,直接用它的 exprType/lookupVar/scopes;自己写第二套类型推导 = 对同一程序给两个答案,两者终将分歧然后静默编译错。
坑 57:link.Data 的布尔开关不够用
全量 LLVM 下所有函数与全局都在对象里,但仍有"哪些要桩补、哪些对象已定义"的区分——同一后端里两种情况并存。布尔开关表达不了。
新增两个按符号判定的谓词:SymbolInObject(谁定义)与 NeedsSlotBinding(谁要桩补)。
踩过:给 d.StrLabs 传 nil map → panic: assignment to entry in nil map。
坑 58:-fllvm 在 goc 里已是死标志
src/goc/main.go 里 cfg.llvm = true 设置后全文不再读取 → goc -fllvm 与纯 goc 行为一致(都走 goa 后端,产物同为 7680B)。LLVM 后端只能经 gocl 进入。
后果:验证裁剪后的 libLLVM.dll 时,第一次误用 goc -fllvm 测的其实是 goa,结论无效,得用 gocl 重测。
坑 59:go build -o x.exe . 对 package compiler 产出的是 archive
src/goc 改成 package compiler 后,go build -o bin/goc . 产出 archive(!<arch>)。Linux 上执行它 Permission denied,81 个例子全部报 (compile): ./bin/goc: Permission denied——看起来像编译器全崩了。
判据:凡是把 -o 指向 exe 的 go build,都要确认包参数指向 main package(./cmd/xxx 或带 func main 的目录),而不是 .。
坑 60:库查找必须「上下交替」,且 filepath.Join(dir, "..") 不会爬升
库在 src/goclib/、exe 在 bin/,两者是兄弟目录不是父子。只上溯从 bin/ 到 D:/ 永远不到 src/;只下潜从 bin/ 只碰到 goc-out* 构建目录。
正确写法:每层先 descend 再 climb,两个方向交替——“先试完一个方向再试另一个"的搜索表达不了"上溯到根再下潜进 src/"。
Go 坑:filepath.Join(dir, "..") 会 Clean 掉 .. 直接返回父目录 → 上溯必须用 filepath.Dir() 逐级循环,用 Join 写的循环根本不会爬升。
坑 61:CI 连红三次 —— 本地全绿 ≠ CI 绿
src/goa/llvm.go 整个文件用 syscall.LazyDLL(Windows-only)却没有 //go:build windows,Linux 上 go vet 报 10 处 undefined。连续三次 push 全红,同一个原因。不是新引入的,是目录重组让依赖图变化后暴露。
修法不是给整个绑定加标签(那会让所有提到 goa.OpenLLVM 的调用方都编不过),而是拆三文件:
llvmapi.go(无约束):ErrNoLLVM+ 档位常量——错误与档位是调用方要能说出名字的东西,必须跨平台可用;llvm.go(//go:build windows):整个绑定;llvm_stub.go(//go:build !windows):同名导出的桩。
不能用 ErrNoLLVM —— 那说的是"库没装”,而这平台上装了也没用。
另一个教训:加约束要连测试一起加,否则 Linux vet 会在 undefined: llvmAPI 上再红一次。
铁律:改完必须双平台验(逐 module go vet ./... && GOOS=linux go vet ./...);推送后要 gh run list 看结果,不能推完就当完事。
坑 62:rebase 的四条铁律
- 只解冲突文件,其余原样保留。曾试图用
git show <mycommit>:<file>“恢复"自动合并的 4 个文件,那会覆盖掉远端的自动合并(远端给CompileToObject加了第 5 参linux bool,用旧版会have/want签名不匹配、build failed,且伪装成前端__builtin_va_list解析错,浪费一轮排查); - 判断"失败是否我引入"要双向对照:先把工作区置为纯 origin 跑全套确认全绿,再恢复我的改动跑,才定位到归属;
- 合并冲突区若两侧结构差异大,整函数重建比逐块拼更可靠;重建后必删被自动合并保留的重复尾段;
gofmt -l对冲突文件必跑——rebase 后必查,冲突解决不会自动格式化。
坑 63:-S 被静默 no-op,且第一轮修复也是错的
gocl -S file.c 不输出汇编(实际仍出 exe)。
第一轮错误修复:把 -S 接到 cfg.DumpAsm(写 goa 入口桩)→ 桩里没有用户的代码。-S 应该走 LLVM 的 AsmPrinter 生成 C 函数体的 AT&T .s(这是 gcc -S 的对等物)。
正确修复:复用早已存在但一直没接线的 emitIRAssembly。输出命名:给了 -o 落到该路径,没给落到 <源>.s。内容边界:只含 C 函数体(不含入口桩),与 gcc -S 不含 CRT 启动一致。
坑 64:其他环境坑(都会反复踩)
- Git Bash 直接跑 PE 会误报崩溃:
./x.exe报Segmentation fault,但cmd //c "x.exe"退出码正确。验证 PE 必须走 cmd。 - 同秒内批量"编译 + 运行"多个程序会假失败:libLLVM.dll 并发加载竞争。每之间
sleep 0.2~0.3s。 - 只看顶层
--- FAIL行会误判成"全红”:e2e 实际 9/10 通过。 bin/goc遮蔽bin/goc.exe:bin/里一个陈旧的无扩展名文件,PATH 优先选它 → 所有"改了源码没生效"的假象都源于此。goc 项目一律用绝对路径bin/goc.exe。go build缓存不刷新//go:embed:新增src/goclib/*.h后产物 embed 仍是旧头。症状是skipping unavailable system header。修法:touch src/headers.go。- 批量 sed 改路径后必须 grep 目标串确认为 0:某次 8 处漏改,全表现为"某腿 FAIL"而非编译错误;其中
./cmd/goa被误改成./goa,而 goa module 的主包目录就叫cmd/goa,与 cmd/goc 不是一回事。 go mod tidy联网超时;本地 replace 的 module 也要 go.sum 条目(间歇性,清缓存时才报)。加 module 后逐个检查所有 go.mod,别只加被直接 import 的那个。commit -m的反引号会被 shell 吞掉(bash 双引号串里是命令替换,报command not found,提交成功但消息留空洞)。长提交信息一律用git commit -F <file>。- 这个仓库的 shell 与 Python heredoc 会把
\n写成字面/n/:症状是 LLVM 报 parse error,我因此误判过一次"段错误"。写多行 Go/asm 字符串必须用文件写入工具。
还没做的
按"能不能靠现有手段做"分三类:
能力缺口
gocl -target linux报"LLVM 后端尚未实现 ELF 对象";- 位域 /
_BitInt/ 内联汇编仍走原生路径(llvmEligible排除); - goclib 缺 Nim 运行时函数,完整
bench.nim暂不可编。
工具链限制
LLVMRunPasses调不动 → 无中端优化(GVN/LICM/向量化),只能用管线字符串;- MinGW 静态链接 libLLVM 的 C++ 全局构造顺序不可控;
- Win64 上
va_list按值传给printf_lite_with(应为va_list*),多层转发可能暴露。
已知功能缺口(两边共有,不是回归)
printf("%%")不触发 lite;fprintf/sprintf/snprintf一律未特化。
三条元教训
写完回头看,这 100 多条坑里最值得记住的是三条方法论:
一、“IR 是对的但运行时结果错"这一类最值钱,定位手段已经成型。
现象离根因极远(控制台全哑 → 重定位丢了 4 字节 addend)。有效的三板斧:
- 运行时探针把内部结构体字段用 exit code 位图传出(native=51 vs LLVM=1,一眼定位);
objdump -d -r看 addend +objdump -d看落点 + 落点对照表(偏移 0 全对、带偏移全错 → 直接指向重定位);- 凡"汇编对但运行时错”,第一怀疑对象是指令编码的 size 修饰符——窄类型存储用
C7(4 字节)而不是C6(1 字节)会淹没邻居槽,这类 bug 汇编层面完全看不出来。
二、COFF/PE 的坑集中在"非对称字段"和"隐含约定"。
8 字节符号名的首 4 字节语义与直觉相反、IMAGE_RELOCATION 的符号索引在中间、Type 只有 2 字节、aux 记录要占符号槽、COFF 重定位条目根本没有 addend 字段(A 藏在待修补字节里)、ADDR32NB 的 P 是段首、Win64 没有数据重定位要走 .refptr 槽、__main 是伪符号、@feat.00 的 sec = -1。
读错这些不会崩溃,只是结果不对——所以必须靠"落点对照表"这类外部验证,不能指望实现自己报警。
三、体积问题要落到"段表 + 对齐"上量,不要只看内容大小。
PE 每段占 FileAlignment 整数倍(512 最小)→ .pdata+.xdata 内容才 72 字节却要占 1024;导入表膨胀 100% 在 .data;可达性裁剪与特化必须走同一个判定,否则互相抵消。
还有一条元教训是关于排查方法的:我曾把"IR 前端无条件发射全部 25 个 lib.globals"当成 print 体积反常的根因,改完发现体积纹丝不动。不能因为"找到一个看起来合理的缺陷"就认定它是根因——改完必须重新看段级数据。
结果
- Nim 跨编译器验证:5 个自包含内核(sieve / fib / intloop / strbuild / float)在 goc 与 gocl 下全部与 gcc 逐位一致;
- 计时:三家 wall-clock 都在 0.16–0.20s,由 Nim 运行时启动主导,差异在 5–10% 噪声内——基准的真正价值是正确性校验而非计时;
- 体积:-O2 下 LLVM 侧总和比原生小 37%,且不再有任何一档更大;
- TLS:从"读出垃圾值"到四类用例全对;
- 回归:476 ok / 30 fail,失败集合与修复前逐条相同(全是本机 Windows 跑不了的 Linux 腿),零回归。