最近想给 RISC-V 的 Go 内部链接器开 PIE 支持。看了眼 AMD64 和 ARM64 的代码,不就是判断个 iscgo 和 flag_race 嘛,复制粘贴改改就行。
结果编译完一跑,炸了:
relocation R_RISCV_TLS_IE cannot be used in PIE
开开关,然后发现 TLS_IE 这货
其他架构开 PIE 走内部链接早就能用了,RISC-V 这边一直是个 TODO。我寻思着把外部链接器的判断逻辑抄过来:
case sys.RISCV64:
if iscgo || flag_race {
ldflag = "gcc"
}
然后就被 R_RISCV_TLS_IE 糊了一脸。
先问了下 Claude,它给我讲了一通 TLS 的四种模型:LE、IE、GD、LD。我说你别讲概念了,我就想知道 Go 内部链接器怎么 patch 这个重定位。它说:"建议参考其他架构的实现。"
……我要是看得懂还问你?
翻书,《Linkers and Loaders》、ELF spec、RISC-V ABI 翻了一圈。大概搞明白了:
- TLS_LE:本地执行,直接
tp + offset,指令是lui + addi - TLS_IE:初始执行,走 GOT 拿 TLS 偏移,指令是
auipc + addi
其他架构在内部链接 PIE 时,遇到 TLS_IE 都是直接转成 LE。因为内部链接的 PIE 说白了还是静态链接,不需要动态加载器解析,直接把偏移算出来硬编码就行。
我心想,这思路简单啊,抄作业!
不过我的第一反应不是"转 LE",而是:凭什么 RISC-V 就不能原生支持 TLS_IE?
我在 cmd/link 里硬加了 R_RISCV_TLS_IE 的处理,试图在内部链接时解析 GOT 条目。写完之后编译倒是过了,运行时测试挂了。
一查,内部链接器生成 GOT 的时候,对 TLS 符号的处理跟普通全局符号不一样。RISC-V 的 GOT 条目需要 R_RISCV_64 重定位填充,但内部链接器在 dynreloc 阶段根本没给 TLS 符号生成正确的动态重定位。搞出一堆 R_RISCV_NONE 的残留,链接器自己都不认。
折腾了两天,服了。
其他架构把 IE 转 LE 是唯一的路
AMD64 的实现:
case objabi.R_TLS_IE:
// 直接 patch 立即数,指令长度不变
ARM64实现:
case objabi.R_TLS_IE:
// adrp + ldr 改成 movz + movk
都很优雅。然后我看 RISC-V 的 TLS_IE 序列:
auipc t0, %pcrel_hi(symbol)
addi t0, t0, %pcrel_lo(symbol)
而 LE 应该是:
lui t0, %tprel_hi(symbol)
addi t0, t0, %tprel_lo(symbol)
问题来了:RISC-V 的 auipc 和 lui 虽然都是 U-type,但 opcode 不一样,立即数编码方式也不一样。AMD64 可以直接 patch 立即数字段,RISC-V 这里必须整条指令换掉。
而且 auipc 是 PC-relative,lui 是绝对值。addi 跟着的 pcrel_lo 和 tprel_lo 重定位类型也不同。
我一开始想暴力算:
auipc := ctxt.Arch.ByteOrder.Uint32(buf)
lui := (auipc &^ 0x7F) | 0x37 // 改 opcode
// 还要改立即数……
太丑了,而且容易出错。
只好读 link/obj 代码,找办法
花了一天读 cmd/link/internal/riscv64 和 cmd/internal/obj/riscv 的源码。发现内部链接器在 reloc 阶段是可以直接改指令编码的。
关键是 ctxt.Arch.ByteOrder.PutUint32 直接写内存。我需要在 reloc 函数里:
- 算出 TLS 的 LE 偏移:
ldr.SymValue(symIdx) - ctxt.Tlsoffset - 把
auipc + addi替换成lui + addi - 用新的立即数写回去
但 lui + addi 的立即数拆分有坑。lui 是高 20 位,addi 的低 12 位是有符号的。如果低 12 位是负数(比如 0xFFF),lui 的高 20 位要加 1。
我翻了下 cmd/internal/obj/riscv,发现里面有类似的立即数拆分逻辑。抄了:
func splitImm(v int64) (hi, lo int64) {
lo = v << 52 >> 52 // 符号扩展低 12 位
hi = (v - lo) >> 12
return
}
然后:
hi, lo := splitImm(off)
rd := (auipc >> 7) & 0x1F
lui := 0x37 | (rd << 7) | ((hi & 0xFFFFF) << 12)
addi := 0x13 | (rd << 7) | (rd << 15) | ((lo & 0xFFF) << 20)
ctxt.Arch.ByteOrder.PutUint32(buf, lui)
ctxt.Arch.ByteOrder.PutUint32(buf[4:], addi)
这样看起来顺眼多了。
测试
改完跑测试:
$ go test -v -run TestPIE cmd/link
=== RUN TestPIE/riscv64
--- PASS: TestPIE/riscv64 (0.12s)
再跑 all.bash 的 riscv64 交叉编译:
$ GOARCH=riscv64 GOOS=linux go test -c -buildmode=pie fmt
# 编译成功
搞定。
回头看
RISC-V 的 TLS 处理确实跟别家不一样。AMD64、ARM64 做 IE 转 LE 基本是同长度指令替换,RISC-V 这里 auipc + addi 和 lui + addi 虽然都是两条指令,但编码完全不同,必须整条换掉。
而且 RISC-V 的 GCC 对外部 TLS 变量默认用 LE 模型(依赖 TLS copy relocations),本身就有点特立独行。















