为 HarmonyOS 定制 Go 工具链:征服 musl emulated TLS

为 HarmonyOS 定制 Go 工具链:征服 musl emulated TLS

这两周移植自己一个安卓版本板凳软件到鸿蒙Next系统上,今天终于成功跑通了,记录一下最关键的实现部分

前言

Go 语言对 Linux/arm64 平台的支持非常成熟——前提是你运行在标准的 glibc 环境下。HarmonyOS 使用 musl libc,其 emulated TLS 机制与 glibc 的 TLS 实现存在根本性差异,导致标准 Go 编译的 c-shared 库在 HarmonyOS VPN 沙盒中无法正常加载。本文详细记录了对 Go 1.21.13 runtime 源码进行 12 处关键修改的全过程,涵盖 TLS 寻址模型重写、clone 系统调用适配、cgo 初始化重构等底层工作。

harmonyos_go_runtime


一、问题:标准 Go 在 HarmonyOS 上的崩溃

1.1 使用场景

CocoProxy HarmonyOS 客户端需要将 Go 代理引擎编译为 c-shared 模式的 .so 文件,通过 NAPI 被 VPN Extension Ability 加载。构建命令如下:

CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
CC=clang CGO_CFLAGS="--target=aarch64-linux-ohos --sysroot=..." \
go build -buildmode=c-shared -tags="with_gvisor,ohos" \
  -ldflags="-extldflags '... napi_reg.o'" \
  -o libcocoproxy.so

1.2 崩溃现象

使用标准 Go 1.21.13 编译的 .sodlopen 时,.init_array 中的 Go runtime 初始化函数 _rt0_arm64_linux_lib 崩溃。由于 .init_array 按顺序执行,后续的 RegisterCocoProxyModule(NAPI 模块注册函数)永远不会被调用,导致:

ArkCompiler: module libcocoproxy.so does not provide an export name 'getDebug'

1.3 崩溃的根因

崩溃发生在 Go runtime 尝试通过 TLS 访问 goroutine 的 g 指针时。标准 Go 假设的是 glibc 风格的 TLS 实现,而 HarmonyOS 的 musl libc 使用完全不同的 emulated TLS 机制。


二、TLS 实现差异:glibc vs musl

2.1 glibc TLS 工作原理

在 glibc 环境下,TLS 变量通过线程指针(TP)直接寻址。内核在创建线程时设置 TPIDR_EL0 寄存器指向 TLS 块,每个 TLS 变量有一个相对于 TP 的固定偏移量:

┌────────────────────────────────────────────────────────┐
│                    glibc TLS 模型                        │
│                                                          │
│  TPIDR_EL0 ──→ ┌─────────────────────┐                  │
│                │  TLS 变量 1 (offset=0)  │                  │
│                │  TLS 变量 2 (offset=8)  │                  │
│                │  ...                    │                  │
│                │  tls_g 偏移量 (如 0x10)  │ ← 运行时计算   │
│                │  ...                    │                  │
│                └─────────────────────────┘                  │
│                                                          │
│  访问 TLS 变量:                                           │
│    __asm__("mrs %0, tpidr_el0" : "=r"(tp));              │
│    value = *(tp + offset);        ← 直接加偏移量访问       │
│                                                          │
│  Go runtime 的 load_g:                                    │
│    MRS TPIDR_EL0, R0                                     │
│    MOVD tls_g(SB), R11                                   │
│    MOVD (R0)(R11), g      ← g = *(TP + tls_g)  一级间接  │
└────────────────────────────────────────────────────────┘

Go runtime 的 tls_g 变量存储的偏移量指向 TLS 块中一个 8 字节的 slot,该 slot 直接存放 g 指针。这是一级间接寻址:*(TP + tls_g) = g

2.2 musl emulated TLS 工作原理

musl libc 运行在没有内核 TLS 支持或 TLS 描述符(TLS descriptor)不可用的环境中,采用”模拟 TLS”方案:

  • 不使用 __tls_get_addr 系统调用
  • TLS 变量存储在线程特定数据(TSD)区中,通过 pthread_key 机制管理
  • pthread_setspecific(key, value) 将值存储在 TP 附近的 TSD 数组中
  • pthread_getspecific(key) 从 TSD 数组中读取值
┌───────────────────────────────────────────────────────┐
│                  musl emulated TLS 模型                   │
│                                                          │
│  TPIDR_EL0 ──→ ┌─────────────────────┐                  │
│                │  TSD slot 0           │                  │
│                │  TSD slot 1           │                  │
│                │  ...                  │                  │
│    tls_g ──→   │  TSD slot[k] ──→ ┌───────┐             │
│                │  ...              │ g_slot │ ← malloc 分配│
│                │                    └───┬───┘             │
│                └─────────────────────────┘               │
│                                          │                │
│                                          ▼                │
│                                     g = *g_slot           │
│                                                          │
│  访问 TLS 变量:                                           │
│    pthread_getspecific(key) → 搜索 TSD 数组找到 slot     │
│    或者: *(TP + computed_offset) → slot 指针 → value     │
│                                                          │
│  关键差异: *(TP + tls_g) 不是 g 值,                      │
│            而是 g_slot 的指针!                            │
│            需要 *( *(TP + tls_g) ) = g  两级间接          │
└────────────────────────────────────────────────────────┘

2.3 差异对比

              glibc                          musl
         ┌──────────┐                  ┌──────────┐
TP ──→   │ slot[k]  │ = g             │ slot[k]  │ ──→ ┌───────┐
         │          │                  │          │     │ g_ptr │ = g
         └──────────┘                  └──────────┘     └───────┘

  访问方式:                       访问方式:
  *(TP + offset) = g             *(TP + offset) = &g_ptr
  一级间接                        *(*(TP + offset)) = g
                                 两级间接

标准 Go 只做一级间接,在 musl 环境下读出的是 g_slot 的地址而不是 g 值。如果 g_slot 尚未初始化(为 NULL 或垃圾值),load_g 读到的就是无效指针,后续任何使用 g 的操作都会崩溃。


三、修改 1:两级 TLS 间接寻址

3.1 tls_arm64.h — 定义 MUSL_TLS 宏

文件: src/runtime/tls_arm64.h

// 标准版
#ifdef GOOS_linux
#define TLS_linux
#endif

// 定制版
#ifdef GOOS_linux
#define TLS_linux
#define TLSG_IS_VARIABLE
#define MUSL_TLS          // ← 新增:启用 musl TLS 代码路径
#endif

TLSG_IS_VARIABLE 告诉 Go runtime tls_g 是一个变量而非 TLS 段中的偏移量(因为 musl 下 TLS 不在 TLS 段中)。MUSL_TLS 在汇编中作为条件编译标志,选择两级间接寻址的代码路径。

3.2 tls_arm64.s — 重写 load_g / save_g

文件: src/runtime/tls_arm64.s

这是最核心的修改。将 load_gsave_g 从一级间接改为两级间接:

// ===== 标准版 load_g =====
TEXT runtime·load_g(SB),NOSPLIT,$0
    MRS_TPIDR_R0                // R0 = TP (Thread Pointer)
    MOVD  runtime·tls_g(SB), R27
    MOVD  (R0)(R27), g          // g = *(TP + tls_g)  ← 一级间接

// ===== 定制版 load_g =====
TEXT runtime·load_g(SB),NOSPLIT,$0
    WORD  $0xd53bd040           // MRS TPIDR_EL0, R0  (硬编码指令)
    MOVD  runtime·tls_g(SB), R27
    ADD   R27, R0, R27          // R27 = TP + tls_g (slot 地址)
    MOVD  (R27), R27            // R27 = *(TP + tls_g) = slot 指针 (一级)
    MOVD  (R27), g              // g = *slot = *(slot + 0)  (二级, tls_g2=0)
// ===== 标准版 save_g =====
TEXT runtime·save_g(SB),NOSPLIT,$0
    MRS_TPIDR_R0
    MOVD  runtime·tls_g(SB), R27
    MOVD  g, (R0)(R27)          // *(TP + tls_g) = g  ← 一级间接

// ===== 定制版 save_g =====
TEXT runtime·save_g(SB),NOSPLIT,$0
    WORD  $0xd53bd040           // MRS TPIDR_EL0, R0
    MOVD  runtime·tls_g(SB), R27
    ADD   R27, R0, R27          // R27 = TP + tls_g
    MOVD  (R27), R27            // R27 = *(TP + tls_g) = slot 指针
    MOVD  g, (R27)              // *slot = g  ← 两级间接写入

3.3 为什么要硬编码 MRS 指令

注意到定制版使用 WORD $0xd53bd040 而非 MRS_TPIDR_R0 宏。原因:

  • MRS_TPIDR_R0 定义在 tls_arm64.h,其实现因平台而异
  • 在修改了 tls_arm64.h 后,该宏的行为可能不符合预期
  • 硬编码 0xd53bd040 对应 MRS TPIDR_EL0, R0,这是 ARMv8 的标准指令,明确无歧义

同样,asm_arm64.s 中 rt0_go 的 TLS 初始化使用 WORD $0xd53bd060MRS TPIDRRO_EL0, R0,读取只读的 TPIDRRO_EL0),而不是宏。

3.4 tls_g 和 tls_g2 的存储方式

// 标准版: tls_g 在 TLS 段中(glibc 专用)
GLOBL runtime·tls_g+0(SB), TLSBSS, $8

// 定制版: tls_g 是普通数据变量(NOPTR),tls_g2 同理
DATA  runtime·tls_g+0(SB)/8, $0
GLOBL runtime·tls_g+0(SB), NOPTR, $8
DATA  runtime·tls_g2+0(SB)/8, $0
GLOBL runtime·tls_g2+0(SB), NOPTR, $8

关键差异:
TLSBSS:变量位于 TLS 段,每个线程有独立副本——这是 glibc 的方式
NOPTR + DATA:变量是普通全局数据,所有线程共享同一个值——musl 需要 inittls 在运行时设置偏移量

tls_g 存储的是 TSD 偏移量(如 0x70),tls_g2 存储二级偏移(通常为 0,即 slot 的起始位置就是 g)。

3.5 新增 getTLSg 函数

// 返回 tls_g 的值,供 Go 层面的 cocoAllocateTLS 使用
TEXT runtime·getTLSg(SB),NOSPLIT,$0-8
    MOVD  runtime·tls_g(SB), R0
    MOVD  R0, ret+0(FP)
    RET

3.6 race_arm64.s — load_g 宏的条件编译

文件: src/runtime/race_arm64.s

race 检测器也使用 load_g 宏,需要同样的两级间接修改:

// 标准版
#define load_g \
    MRS_TPIDR_R0 \
    TP_ALIGN \
    MOVD    runtime·tls_g(SB), R11 \
    MOVD    (R0)(R11), g           // 一级间接

// 定制版
#ifdef MUSL_TLS
#define load_g \
    MRS_TPIDR_R0 \
    TP_ALIGN \
    MOVD    runtime·tls_g(SB), R11 \
    ADD     R11, R0, R11 \
    MOVD    (R11), R11 \
    MOVD    runtime·tls_g2(SB), R26 \
    MOVD    (R11)(R26), g          // 两级间接
#else
#define load_g \
    MRS_TPIDR_R0 \
    TP_ALIGN \
    MOVD    runtime·tls_g(SB), R11 \
    MOVD    (R0)(R11), g
#endif

四、修改 2:CLONE_SETTLS 与新线程 TLS 分配

4.1 问题描述

标准 Go 创建新 OS 线程时,clone 系统调用不传 TLS 参数:

// 标准版 sys_linux_arm64.s
TEXT runtime·clone(SB),NOSPLIT|NOFRAME,$0
    MOVW  flags+0(FP), R0
    MOVD  stk+8(FP), R1
    ...
    MOVD  $SYS_clone, R8
    SVC                         // clone(flags, stk, mp, gp, fn)

子线程启动后,TPIDR_EL0 继承父线程的值(或为 0),没有独立的 TLS 块。在 glibc 环境下,cgo 的 pthread 机制会随后设置 TLS;但在 musl/OHOS 环境下,新线程的 TLS 必须在 clone 时就通过 CLONE_SETTLS 设置。

4.2 sys_linux_arm64.s — clone 增加 TLS 参数

文件: src/runtime/sys_linux_arm64.s

// 标准版: 5 个参数
// int64 clone(int32 flags, void *stk, M *mp, G *gp, void (*fn)(void));

// 定制版: 6 个参数
// int64 clone(int32 flags, void *stk, M *mp, G *gp, void (*fn)(void), void *tls);

TEXT runtime·clone(SB),NOSPLIT|NOFRAME,$0
    MOVW  flags+0(FP), R0
    MOVD  stk+8(FP), R1
    ...
+   MOVD  tls+40(FP), R3        // ← 新增:TLS 块地址 → x3 寄存器
    MOVD  $SYS_clone, R8
    SVC

    // 父进程返回
    CMP   ZR, R0
    BEQ   child
-   MOVW  R0, ret+40(FP)
+   MOVW  R0, ret+48(FP)        // ← 返回值偏移调整(多了 tls 参数)
    RET

child:
    ...
    MOVD  R10, g_m(R11)
    MOVD  R11, g
+   CALL  runtime·save_g(SB)   // ← 新增:子线程立即保存 g 到 TLS

CLONE_SETTLS 标志告诉内核将 x3 寄存器的值设置为新线程的 TPIDR_EL0。这样子线程从第一条指令起就有正确的线程指针。

4.3 os_linux.go — cloneFlags 增加 CLONE_SETTLS

文件: src/runtime/os_linux.go

// 标准版
cloneFlags = _CLONE_VM | _CLONE_FS | _CLONE_FILES |
    _CLONE_SIGHAND | _CLONE_SYSVSEM | _CLONE_THREAD

// 定制版
cloneFlags = _CLONE_VM | _CLONE_FS | _CLONE_FILES |
    _CLONE_SIGHAND | _CLONE_SYSVSEM | _CLONE_THREAD |
    _CLONE_SETTLS    // ← 新增:HarmonyOS/musl 需要在 clone 时设置 TLS

4.4 cocoAllocateTLS — 为新线程分配 TLS 块

文件: src/runtime/os_linux.go

新增函数,在每个新 OS 线程创建前分配 TLS 块并设置两级指针链:

const cocoTLSBlockSize = 16384  // 16KB TLS 块

//go:nosplit
//go:nowritebarrier
func cocoAllocateTLS() unsafe.Pointer {
    tlsG := getTLSg()       // 获取 tls_g 偏移量
    if tlsG == 0 {
        return nil
    }

    block := sysAlloc(cocoTLSBlockSize, &memstats.other_sys)  // 分配 16KB
    if block == nil {
        throw("cocoAllocateTLS: sysAlloc failed")
    }

    // TP 指向块的中间位置(前后都有空间,避免溢出)
    tpOffset := uintptr(cocoTLSBlockSize / 2)
    tp := uintptr(block) + tpOffset

    // 设置两级指针链
    slotAddr := tp + tlsG            // *(TP + tls_g) 指向的位置
    gSlotAddr := slotAddr + 8        // g-slot 的地址(tls_g2=0 时就是 slot 本身)
    *(*uintptr)(unsafe.Pointer(slotAddr)) = gSlotAddr  // slot → g-slot
    *(*uintptr)(unsafe.Pointer(gSlotAddr)) = 0          // g-slot → nil

    return unsafe.Pointer(tp)        // 传给 clone 的 TLS 参数
}

TLS 块的内存布局:

  分配的 16KB 块
  ┌─────────────────────────────────────────────────────┐
  │                    ← 前部空间 (8KB) →                  │
  │                                                       │
  │  ...                                                  │
  │  ┌──────────┐  ← TP 指向这里(块的中间)               │
  │  │ slot_ptr │  ← TP + tls_g:存储 g-slot 的地址       │
  │  │ g_slot   │  ← TP + tls_g + 8:存储 g 指针          │
  │  │ ...      │                                         │
  │  │                    ← 后部空间 (8KB) →                │
  └────────────────────────────────────────────────────┘

  load_g 的效果:
    R0 = TP
    R27 = tls_g
    R27 = TP + tls_g → 指向 slot_ptr
    R27 = *slot_ptr → 指向 g_slot
    g = *g_slot → g 值

4.5 newosproc — 传递 TLS 给 clone

func newosproc(mp *m) {
    ...
    tls := cocoAllocateTLS()                    // ← 新增
    print("newosproc: cocoAllocateTLS returned tls=", tls, "\n")

    var oset sigset
    sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
    ret := retryOnEAGAIN(func() int32 {
        r := clone(cloneFlags, stk, unsafe.Pointer(mp),
            unsafe.Pointer(mp.g0),
            unsafe.Pointer(abi.FuncPCABI0(mstart)),
            tls)                                 // ← 新增 tls 参数
        ...
    })
    ...
}

4.6 newosproc0 同样适配

func newosproc0(stacksize uintptr, fn unsafe.Pointer) {
    ...
    tls := cocoAllocateTLS()
    var flags int32 = cloneFlags
    if tls == nil {
        flags &^= _CLONE_SETTLS              // tls 为 nil 时不设置
    }
    ret := clone(flags, ..., fn, tls)         // 传递 tls
    ...
}

五、修改 3:cgo inittls 重写

5.1 原始 inittls 的问题

标准 Go 的 gcc_linux_arm64.c 中,x_cgo_inittls 函数实现非常简单:

// 标准版 — 只在 glibc 下有效
void (*x_cgo_inittls)(void **tlsg, void **tlsbase) __attribute__((common));
// 实际实现由 glibc 的 __tls_get_addr 机制提供
// musl 下这个指针为 NULL,不会调用

标准实现依赖 glibc 的 __tls_get_addr 和 ELF TLS 段,这些在 musl 下都不存在。需要完全重写 inittls 函数。

5.2 探测式 TSD 偏移量发现

musl 的 pthread_setspecific 将值存储在 TP 附近的 TSD 数组中,但具体的偏移量没有公开文档。我们的方案是通过”探针法”发现:

inittls 探测流程:

[1] 创建 pthread_key: k = pthread_key_create(&k, nil)

[2] 设置探针值: pthread_setspecific(k, magic1 = 0x23581321345589)

[3] 读取 TP: __asm__("mrs %0, tpidr_el0" : "=r"(tp))

[4] 搜索 TSD 数组:
    for (i = -4096; i < 4096; i += 8) {
        if (*(void**)(tp + i + k*8) == magic1) {
            // 可能找到了!
            // 用第二个 magic 值确认
            pthread_setspecific(k, magic2);
            if (*(void**)(tp + i + k*8) == magic2) {
                tsd_offset = i;  // 确认:TSD 数组起始偏移
                break;
            }
        }
    }

[5] 计算 tls_g 偏移量:
    *tlsg = tsd_offset + k * sizeof(void*)
    // 即:*(TP + *tlsg) = pthread_getspecific(k) 的存储位置

为什么搜索范围是 ±4KB?因为 musl 的 TSD 数组可能在 TP 之前或之后,偏移量不确定。用两个不同的 magic 值交叉验证,避免误匹配。

5.3 SIGSEGV 安全探测

由于 TP 附近并非所有内存都可读,搜索时可能触发 SIGSEGV。定制版使用 sigsetjmp/siglongjmp 做安全探测:

static sigjmp_buf inittls_jmpbuf;
static void inittls_sigsegv_handler(int sig) {
    siglongjmp(inittls_jmpbuf, 1);
}

// 在搜索前安装信号处理器
struct sigaction sa, oldsa;
sa.sa_handler = inittls_sigsegv_handler;
sigaction(SIGSEGV, &sa, &oldsa);
sigaction(SIGBUS, &sa, &oldsa);

// 搜索时用 sigsetjmp 保护
for (i = -4096; i < 4096; i += sizeof(void*)) {
    if (sigsetjmp(inittls_jmpbuf, 1) == 0) {
        // 尝试读取 *(TP + i + k*8)
        if (p[k] == magic1) { ... }
    }
    // 如果触发 SIGSEGV,会跳回 sigsetjmp,继续搜索
}

// 恢复原始信号处理器
sigaction(SIGSEGV, &oldsa, nil);

5.4 设置 g-slot

找到 TSD 偏移后,为 tls_g 分配 g-slot 并设置两级指针链:

// 为主线程分配 g-slot
static void *g_storage_main;
g_storage_main = NULL;
pthread_setspecific(k, &g_storage_main);
// 此时 *(TP + tls_g) = &g_storage_main
// load_g: *(*(TP + tls_g)) = *(&g_storage_main) = g_storage_main = g

// 验证
void *check = pthread_getspecific(k);
void *slot = *(void**)((char*)tp + (intptr_t)(*tlsg));
assert(check == slot);

5.5 x_cgo_init 签名变更

由于增加了 tls_g2 参数,x_cgo_init 的签名也变了:

// 标准版
void x_cgo_init(G *g, void (*setg)(void*), void **tlsg, void **tlsbase)

// 定制版
void x_cgo_init(G *g, void (*setg)(void*), void **tlsg, void **tlsbase, void **tlsg2)

对应 asm_arm64.s 中的调用也需要传入 tls_g2 的地址:

// 标准版 — 仅 Android 传 TLS base
#ifdef GOOS_android
    MRS_TPIDR_R0
    MOVD  R0, R3           // arg 3: TLS base
    MOVD  $tls_g(SB), R2   // arg 2: &tls_g
#else
    MOVD  $0, R2           // arg 2: 不使用
#endif

// 定制版 — 所有 Linux 环境都传
    WORD  $0xd53bd040      // MRS TPIDR_EL0, R0
    MOVD  R0, R3           // arg 3: TLS base
    MOVD  $tls_g(SB), R2   // arg 2: &tls_g
    MOVD  $tls_g2(SB), R4  // arg 4: &tls_g2  ← 新增

5.6 threadentry — 新线程的 TLS 设置

cgo 创建新线程时,threadentry 函数也需要设置两级 TLS:

static void* threadentry(void *v) {
    // 1. 探测当前线程的 TSD 偏移
    //    (可能与主线程不同,因为 pthread_key 编号可能不同)

    // 2. 分配 g-slot
    gslot = malloc(sizeof(void*));
    *gslot = NULL;
    pthread_setspecific(g_cgo_tls_key, gslot);

    // 3. 如果新线程的 tls_g 偏移与主线程不同,需要修正
    //    将主线程的 tls_g 偏移处也指向新线程的 g-slot
    if (new_tls_g != main_tls_g) {
        void **slot_at_main_offset = (void**)((char*)tp + main_tls_g);
        *slot_at_main_offset = gslot;
    }

    // 4. 进入 Go 代码
    crosscall1(ts.fn, setg_gcc, (void*)ts.g);
}

六、修改 4:fprintf → hilog

6.1 VPN 沙盒中的 stderr 崩溃

HarmonyOS VPN Extension 运行在受限沙盒中,stderr(fd=2)不是一个正常的文件描述符。向其写入会导致 SIGSEGV。Go runtime 的 cgo 代码中多处使用 fprintf(stderr)

// gcc_fatalf.c — 标准版
void fatalf(const char* format, ...) {
    fprintf(stderr, "runtime/cgo: ");    // ← SIGSEGV!
    vfprintf(stderr, format, ap);
    fprintf(stderr, "\n");
    abort();
}

// gcc_libinit.c — 标准版
void x_cgo_sys_thread_create(void* (*func)(void*), void* arg) {
    int err = _cgo_try_pthread_create(&p, NULL, func, arg);
    if (err != 0) {
        fprintf(stderr, "pthread_create failed: %s", strerror(err));  // ← SIGSEGV!
        abort();
    }
}

6.2 解决方案:动态加载 OH_Log_Print

// gcc_fatalf.c — 定制版
#include <dlfcn.h>

typedef void (*OH_LogPrintFunc)(int type, int level, unsigned int domain,
    const char *tag, const char *fmt, ...);

static OH_LogPrintFunc _get_oh_log_print(void) {
    static OH_LogPrintFunc fn = NULL;
    if (!fn) {
        void *h = dlopen("libhilog_ndk.z.so", RTLD_NOW);
        if (h) {
            fn = (OH_LogPrintFunc)dlsym(h, "OH_Log_Print");
        }
    }
    return fn;
}

void fatalf(const char* format, ...) {
    va_list ap;
    OH_LogPrintFunc logfn = _get_oh_log_print();
    if (logfn) {
        va_start(ap, format);
        logfn(OH_LOG_APP, OH_LOG_FATAL, 0, "runtime/cgo", format, ap);
        va_end(ap);
    }
    abort();
}

gcc_libinit.c 同理,将 fprintf(stderr) + abort() 改为调用 fatalf(),间接使用 hilog。

6.3 为什么用 dlopen 而非直接链接

  • libhilog_ndk.z.so 是 HarmonyOS 系统库,不在编译时的 sysroot 中
  • VPN 沙盒可能限制直接链接系统库
  • dlopen 运行时加载更灵活,加载失败时只是日志丢失,不会崩溃

七、修改 5:argv 空指针防护

7.1 问题描述

c-shared 模式下,Go runtime 的 args() 函数接收到的 argv 参数可能为 nil

// 标准版
func args(c int32, v **byte) {
    argc = c
    argv = v         // ← v 为 nil 时,后续 argv 使用会崩溃
    sysargs(c, v)    // ← 同样可能崩溃
}

这在 glibc 环境下不会发生,因为 dlopen 总是提供有效的 argv。但在 HarmonyOS 的 ArkCompiler NAPI 加载流程中,_rt0_arm64_linux_lib 被调用时 argv 确实可能为 nil。

7.2 解决方案

文件: src/runtime/runtime1.go

var csharedEmptyArgv [3]*byte  // 安全的空 argv

func args(c int32, v **byte) {
    argc = c
    if islibrary && v == nil {
        argv = &csharedEmptyArgv[0]   // ← 使用安全的空数组
    } else {
        argv = v
    }
    sysargs(c, argv)  // ← 传入安全的 argv
}

csharedEmptyArgv 是一个 3 元素的 *byte 数组,所有元素为零值。这样即使 sysargs 尝试访问 argv[0] 等,也不会解引用空指针。


八、修改 6:physPageSize 兜底

文件: src/runtime/os_linux.go

func osinit() {
    ncpu = getproccount()
    physHugePageSize = getHugePageSize()
    if physPageSize == 0 {        // ← 新增:OHOS 可能返回 0
        physPageSize = 4096
    }
    ...
}

OHOS 的 /proc/self/auxv 可能不包含 AT_PAGESZ 条目,导致 physPageSize 为 0。Go memory allocator 在 physPageSize=0 时会崩溃。设置默认值 4096(标准 ARM64 页大小)作为兜底。


九、修改 7:调试日志

文件: src/runtime/proc.gosrc/runtime/mgc.go

runtime.maingcenableschedinit 等关键函数中添加了 print() 调试日志:

func main() {
    print("runtime.main: entered\n")
    ...
    print("runtime.main: mainStarted, about to newm(sysmon)\n")
    ...
    print("runtime.main: gcenable done\n")
    ...
}

func gcenable() {
    print("gcenable: entered\n")
    ...
}

这些日志在 OHOS VPN 沙盒中通过 dup2 重定向到调试文件,帮助定位 Go runtime 初始化崩溃的确切位置。生产环境中可以移除。


十、完整修改文件清单

# 文件 修改类型 核心改动
1 src/runtime/tls_arm64.h 宏定义 新增 TLSG_IS_VARIABLEMUSL_TLS
2 src/runtime/tls_arm64.s 汇编重写 load_g/save_g 两级间接寻址;新增 getTLSg;tls_g 改 NOPTR DATA;新增 tls_g2
3 src/runtime/asm_arm64.s 汇编修改 硬编码 MRS 指令;cgo_init 传 tls_g2 参数;移除 GOOS_android 条件
4 src/runtime/race_arm64.s 汇编修改 load_g 宏增加 MUSL_TLS 条件分支;tls_g GLOBL 仅 darwin/windows 用 TLSBSS
5 src/runtime/sys_linux_arm64.s 汇编修改 clone 增加 tls 参数(x3);子线程调用 save_g;返回值偏移调整
6 src/runtime/os_linux.go Go 修改 CLONE_SETTLS;cocoAllocateTLS;clone 签名增加 tls;newosproc0 适配;physPageSize 兜底
7 src/runtime/proc.go Go 修改 调试 print 日志
8 src/runtime/mgc.go Go 修改 调试 print 日志
9 src/runtime/runtime1.go Go 修改 c-shared argv 空指针防护
10 src/runtime/cgo/gcc_fatalf.c C 重写 fprintf(stderr) → dlopen OH_Log_Print
11 src/runtime/cgo/gcc_libinit.c C 修改 fprintf + abort → fatalf()
12 src/runtime/cgo/gcc_linux_arm64.c C 重写 inittls 完全重写(TSD 探测、g-slot 分配、两级指针链);x_cgo_init 增加 tls_g2 参数;threadentry TLS 修复

十一、依赖关系图

12 个修改之间存在严格的依赖关系,不能单独使用:

                    tls_arm64.h (MUSL_TLS, TLSG_IS_VARIABLE)
                         │
          ┌──────────────┼──────────────────┐
          │              │                  │
          ▼              ▼                  ▼
    tls_arm64.s    race_arm64.s      asm_arm64.s
  (load_g/save_g   (load_g 宏       (rt0_go MRS
   两级间接)        MUSL_TLS 分支)    cgo_init tls_g2)
          │              │                  │
          │              │                  ▼
          │              │           gcc_linux_arm64.c
          │              │          (inittls 探测+设置
          │              │           x_cgo_init tls_g2
          │              │           threadentry TLS)
          │              │                  │
          └──────┬───────┘                  │
                 │                          │
                 ▼                          │
          sys_linux_arm64.s                  │
        (clone + CLONE_SETTLS               │
         子线程 save_g)                      │
                 │                          │
                 ▼                          │
          os_linux.go                        │
        (cocoAllocateTLS                    │
         newosproc clone+tls)               │
                 │                          │
                 └──────────┬───────────────┘
                            │
                            ▼
                   gcc_fatalf.c ──→ gcc_libinit.c
                  (hilog 替代 fprintf)  (调用 fatalf)
                            │
                   runtime1.go (argv 防护)
                   proc.go / mgc.go (调试日志)
                   os_linux.go (physPageSize)

核心链路:tls_arm64.h 定义宏 → tls_arm64.s 实现两级寻址 → gcc_linux_arm64.cinittls 设置偏移量和 g-slot → sys_linux_arm64.s + os_linux.go 确保新线程也有正确的 TLS。任何一环缺失都会导致崩溃。


十二、编译与验证

12.1 编译命令

$goRoot = "C:\Users\mateng\go-ohos"   # 定制 Go,不是标准 Go!
$ndkShort = "D:\DevTools\Huawei\DEVECO~1\sdk\default\OPENHA~1\native"

$env:GOROOT = $goRoot
$env:CGO_ENABLED = "1"
$env:GOOS = "linux"
$env:GOARCH = "arm64"
$env:CC = "$ndkShort\llvm\bin\clang.exe"
$env:CGO_CFLAGS = "--target=aarch64-linux-ohos --sysroot=$ndkShort\sysroot -D__OHOS__=1 -D__MUSL__ -fPIC -Wno-error"
$env:CGO_LDFLAGS = "-target aarch64-linux-ohos -Wno-error --sysroot=$ndkShort\sysroot -lace_napi.z -lc -ldl"
$env:CGO_CFLAGS_ALLOW = ".*"
$env:CGO_LDFLAGS_ALLOW = ".*"

go build -a -tags="with_gvisor,ohos" -buildmode=c-shared `
  -ldflags="-extldflags '--sysroot=$ndkShort\sysroot --target=aarch64-linux-ohos -Wl,--dynamic-list=dynamic_list.txt napi_reg.o'" `
  -o libcocoproxy.so

12.2 必需的后续步骤

  1. patch-so.ps1:将 _rt0_arm64_linux_lib 首指令改为 RET,将 g_rt0_go_offset 占位符替换为实际地址
  2. 复制 .so:到 entry/libs/arm64-v8a/entry/src/main/cpp/libs/arm64-v8a/
  3. DevEco Studio:构建 HAP 并部署

12.3 验证方法

# 抓取设备日志,确认 NAPI 模块加载成功
hdc shell hilog | grep -i "cocoproxy\|napi\|runtime\.main"

# 期望看到:
#   runtime.main: entered
#   runtime.main: gcenable done
#   inittls: tls_g=112 tsd_off=...
#   [NAPI] startTun called

十三、对 Go 官方的影响与展望

这些修改本质上是让 Go runtime 支持一种新的 TLS 模型——musl emulated TLS。目前的实现是通过硬编码 MUSL_TLS 宏在 Linux 平台强制启用,这不适合合入 Go 官方源码。

更合理的做法是在构建时自动检测目标 libc 类型:

// 理想方案(未实现)
// +build musl

// 或通过 CGO_CFLAGS 中的 -D__MUSL__ 自动判断

目前 Go 社区对 musl Linux 的支持主要通过 Alpine Linux 容器测试,但这些环境通常使用 glibc 兼容层或标准 musl TLS(非 emulated),与 HarmonyOS 的 musl 实现仍有差异。随着 HarmonyOS 生态发展,Go 官方可能需要正式考虑 OHOS 目标平台的支持。


十四、结语

为 HarmonyOS 定制 Go 工具链是一项深入操作系统底层的工作。12 个文件的修改看似不多,但每一个都经历了反复的崩溃→分析→修复循环。最关键的洞察是:musl emulated TLS 的存储模型与 glibc 有本质差异,Go runtime 的一级间接寻址假设在 musl 下完全不成立

这个问题的隐蔽性在于:它只在运行时崩溃,编译完全正常;只在特定环境(VPN 沙盒 + musl)下触发,标准 Linux 和 Android 上都正常。调试手段极其有限——没有 stderr、没有 coredump、VPN 沙盒中甚至 mprotect 都被禁用。最终能解决,靠的是对 ARM64 汇编和 TLS 机制的深入理解,以及大量的试错和日志分析。

希望这篇文章能帮助其他在 HarmonyOS 上使用 Go 的开发者少走弯路。

发表回复

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

 桂ICP备15001694号-3