Go 语言实现RISC-V TLS相关重定向小计

最近想给 RISC-V 的 Go 内部链接器开 PIE 支持。看了眼 AMD64 和 ARM64 的代码,不就是判断个 iscgoflag_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 的 auipclui 虽然都是 U-type,但 opcode 不一样,立即数编码方式也不一样。AMD64 可以直接 patch 立即数字段,RISC-V 这里必须整条指令换掉

而且 auipc 是 PC-relative,lui 是绝对值。addi 跟着的 pcrel_lotprel_lo 重定位类型也不同。

我一开始想暴力算:

auipc := ctxt.Arch.ByteOrder.Uint32(buf)
lui := (auipc &^ 0x7F) | 0x37  // 改 opcode
// 还要改立即数……

太丑了,而且容易出错。

只好读 link/obj 代码,找办法

花了一天读 cmd/link/internal/riscv64cmd/internal/obj/riscv 的源码。发现内部链接器在 reloc 阶段是可以直接改指令编码的。

关键是 ctxt.Arch.ByteOrder.PutUint32 直接写内存。我需要在 reloc 函数里:

  1. 算出 TLS 的 LE 偏移:ldr.SymValue(symIdx) - ctxt.Tlsoffset
  2. auipc + addi 替换成 lui + addi
  3. 用新的立即数写回去

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 + addilui + addi 虽然都是两条指令,但编码完全不同,必须整条换掉。

而且 RISC-V 的 GCC 对外部 TLS 变量默认用 LE 模型(依赖 TLS copy relocations),本身就有点特立独行。

CL 741860 加了 R_RISCV_TLS_IE 的重定位处理,CL 742200 正式启用 PIE 内部链接。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理