鸿蒙OpenSSL 1.1.1升级到3.5.x部分设备偶现SIGILL问题回溯

4 阅读8分钟

前言

最近在维护 HarmonyOS 的 SSH 相关库时,将依赖的 OpenSSL 从 1.1.1 升级到了 3.5.0,本以为是个常规的版本升级,结果却遇到了一个诡异的崩溃:程序在 FFRT 异步线程中执行加密操作时,偶现 SIGILL(非法指令)崩溃! 💥

更奇怪的是,崩溃指令是 ARM SVE2 的 xar 指令,但设备的 CPU 明明不支持 SVE2!OpenSSL 的 CPU 特性检测怎么会出错?🤔

经过一番深入排查,我发现这是 OpenSSL 的 SIGILL 探测机制与 HarmonyOS FFRT 框架的一次"不兼容碰撞"。这个问题非常隐蔽,值得分享给大家。


第一章:问题现象 - 崩溃来得猝不及防

1.1 崩溃日志分析

升级 OpenSSL 后,程序在连接 SSH 服务器时偶现崩溃,日志如下:

Reason:Signal:SIGILL(ILL_ILLOPC)@000000000000000000
Fault thread info:
Tid:19402, Name:OS_FFRT_2_12

调用栈(关键部分):
#00 pc 000000000028a570 libcrypto.so.3        ← 崩溃点
#01 pc 0000000000517934 libcrypto.so.3
#02 pc 00000000005ac4c4 libcrypto.so.3
#09 pc 0000000000372c1c libcrypto.so.3(EVP_DecryptUpdate+652)
#12 pc 000000000005ce64 libssh.so.4           ← SSH 解密数据
#25 pc 0000000000035508 libssh.so.4(ssh_connect+672)
#28 pc 0000000000075c04 libace_napi.z.so(AsyncWorkCallback+488)
#30 pc 00000000000b3114 libffrt.so(UVTask::ExecuteImpl+228) ← FFRT异步任务

关键信息:

  1. 崩溃信号:SIGILL(ILL_ILLOPC) - 非法操作码
  2. 崩溃线程:OS_FFRT_2_12 - HarmonyOS 的 FFRT 异步任务线程
  3. 崩溃位置:libcrypto.so.3 的偏移 0x28a570
  4. 业务场景:SSH 连接时

1.2 反汇编分析:揪出元凶

使用 llvm-objdump 查看崩溃地址的指令:

llvm-objdump -d -C --start-address=0x28a550 --stop-address=0x28a5f0 libcrypto.so.3
0000000000289cc0 <ChaCha20_ctr32_sve>:
  28a560: 04a10000      add     z0.s, z0.s, z1.s         ← SVE指令
  28a564: 04a50084      add     z4.s, z4.s, z5.s
  28a568: 04a90108      add     z8.s, z8.s, z9.s
  28a56c: 04ad018c      add     z12.s, z12.s, z13.s
  28a570: 04703403      xar     z3.s, z3.s, z0.s, #16    ← 💥 崩溃点!
  28a574: 04703487      xar     z7.s, z7.s, z4.s, #16
  ...

真相大白:

  • 崩溃函数:ChaCha20_ctr32_sve() - ChaCha20 加密算法的 SVE 优化版本
  • 崩溃指令:0x04703403 xar z3.s, z3.s, z0.s, #16
  • 指令类型:SVE2 专属指令(异或 + 循环移位)

问题来了:为什么 CPU 不支持 SVE2,程序却执行了 SVE2 指令?


第二章:深入探查 - OpenSSL 的 CPU 特性检测

2.1 OpenSSL 的 CPU 检测机制

回到 armcap.c 文件,这是 OpenSSL 检测 ARM CPU 特性的核心代码。

三种检测方式

OpenSSL 3.x 在 ARM 平台上支持三种检测方式:

// 方式1: Apple 平台 - 使用 sysctl
#if defined(__APPLE__)
    OPENSSL_armcap_P |= sysctl_query("hw.optional.armv8_2_sha512", ARMV8_SHA512);
}

// 方式2: Android/Linux - 使用 getauxval()
#elif defined(OSSL_IMPLEMENT_GETAUXVAL)
    if (getauxval(OSSL_HWCAP2) & OSSL_HWCAP2_SVE2)
        OPENSSL_armcap_P |= ARMV8_SVE2;
}

// 方式3: 其他平台 - SIGILL 探测
#else
    OPENSSL_armcap_P |= arm_probe_for(_armv8_sve2_probe, ARMV8_SVE2);
}
#endif

我们的环境使用哪种方式?

因为 OpenSSL 没有专门适配 OHOS 使用 getauxval(),所以退回到了 SIGILL 探测方式 —— 而这正是问题的根源!

2.2 SIGILL 探测机制:聪明但危险

原理剖析

SIGILL 探测的思路很"聪明":尝试执行一条 SVE2 指令,如果 CPU 不支持就会触发 SIGILL 信号,捕获后返回"不支持"。

// 探测包装函数
static unsigned int arm_probe_for(void (*probe)(void), volatile unsigned int value)
{
    if (sigsetjmp(ill_jmp, 1) == 0) {  // 设置信号跳转点
        probe();                        // 执行测试指令(可能触发 SIGILL)
        return value;                   // 没有 SIGILL → 返回 value(支持)
    } else {
        return 0;                       // SIGILL → 返回 0(不支持)
    }
}

// SVE2 探测汇编代码(crypto/armv8-sve2-test.S)
.globl  _armv8_sve2_probe
_armv8_sve2_probe:
    .inst   0x04e03400      // xar z0.d, z0.d, z0.d  ← SVE2指令
    ret

正常流程:

┌──────────────────────────────────────────────────┐
│ _armv8_sve2_probe()                              │
│   └─> 执行 xar 指令                               │
│        └─> CPU 触发 SIGILL                        │
│             └─> ill_handler() 被调用              │
│                  └─> siglongjmp(ill_jmp, sig)    │
│                       └─> sigsetjmp 返回非0       │
│                            └─> 返回 0(不支持)    │
└──────────────────────────────────────────────────┘

为什么在 OHOS 环境下失败?

这里需要理解 OpenSSL 的初始化时机:

# if defined(__GNUC__) && __GNUC__>=2
void OPENSSL_cpuid_setup(void) __attribute__ ((constructor));
# endif

__attribute__((constructor))OPENSSL_cpuid_setup() 成为构造函数,会在:

  1. 动态库加载时自动执行
  2. 在任何其他代码之前执行(包括 main 函数)
  3. 由动态链接器触发执行

在我们的场景中:

libssh.so 加载
  └─> libcrypto.so.3 被依赖加载
       └─> 动态链接器执行 OPENSSL_cpuid_setup()
            └─> 执行 SIGILL 探测

问题根源:

OHOS 的 FFRT(Function Flow Runtime)框架 在执行任务时可能会发生协程切换,把任务调度到另一个线程上,而不同线程可能运行在不同的 CPU 核心:

  1. 主线程加载 SO 时:在大核(支持 SVE2)上执行 SIGILL 探测,检测到 CPU 支持 SVE2
  2. FFRT 执行任务时:任务被调度到小核(不支持 SVE2)执行
  3. 运行加密代码时:尝试执行 SVE2 指令,触发 SIGILL 崩溃

这就是典型的大小核异构 CPU 导致的 CPU 特性不一致问题

异常流程图:

❌ 异常流程(FFRT 跨核调度):
┌──────────────────────────────────────────────────┐
│ 主线程(大核)                                      │
│   └─> 加载 libcrypto.so.3                        │
│        └─> OPENSSL_cpuid_setup()                 │
│             └─> _armv8_sve2_probe() 在大核执行    │
│                  └─> 探测成功,标记支持 SVE2 ✓    │
├──────────────────────────────────────────────────┤
│ FFRT 任务(小核)                                  │
│   └─> SSH 连接任务被调度到小核                     │
│        └─> 执行 ChaCha20_ctr32_sve()             │
│             └─> 尝试执行 xar 指令(SVE2)         │
│                  └─> 小核不支持 SVE2              │
│                       └─> 💥 SIGILL 崩溃          │
└──────────────────────────────────────────────────┘

第三章:解决方案

强制使用 getauxval()

解决方案很明确:确保编译时正确定义 __OHOS__ 宏,让 OpenSSL 使用可靠的 getauxval() 方式。

getauxval() 会返回内核检测到的所有核心共有的 CPU 特性,因此不会受到大小核调度的影响。

适配 OHOS

armcap.c 中添加 OHOS 平台的适配代码:

# elif defined(__OHOS__)
#  include <sys/auxv.h>
#  define OSSL_IMPLEMENT_GETAUXVAL
#  pragma message("OHOS: Using getauxval for CPU capability detection")

验证是否生效

编译后查看日志,应该看到:

OHOS: Using getauxval for CPU capability detection

第四章:深度理解 - Constructor 与信号处理

4.1 Constructor 的执行时机

void OPENSSL_cpuid_setup(void) __attribute__((constructor));

这个属性让函数在非常早的时机执行:

进程启动流程:
1. 加载器启动
2. 加载所有依赖库(递归)
3. ⭐ 执行所有库的 .init_array(constructor)
4. 执行 main() 函数前的初始化
5. 执行 main()

潜在问题

在 constructor 中执行的代码:

  • 不能依赖其他库的初始化状态
  • 不能假设信号处理器已正确设置
  • 不能使用线程局部存储(可能未初始化)
  • 在动态加载场景下可能在异步线程执行

4.2 SIGILL 探测的局限性

SIGILL 探测本质上是一种 Hack 手段,依赖于:

  1. 信号处理器能正常工作

    sigaction(SIGILL, &ill_act, &ill_oact);  // 必须成功设置
    
  2. setjmp/longjmp 机制正常

    sigsetjmp(ill_jmp, 1);  // 保存上下文
    siglongjmp(ill_jmp, sig);  // 恢复上下文
    
  3. 信号不会被屏蔽或重定向

    sigprocmask(SIG_SETMASK, &ill_act.sa_mask, &oset);
    

4.3 为什么 getauxval() 更可靠?

getauxval() 读取的是 内核提供的辅助向量(Auxiliary Vector),这是内核在进程启动时传递给用户空间的 CPU 信息:

// 内核在 execve() 时构建 aux vector
AT_HWCAP   = CPU 硬件特性位(由内核检测,所有核心共有特性)
AT_HWCAP2  = 扩展特性位

核心优势:

  • 由内核直接提供,100% 可靠
  • 无需信号处理,无副作用
  • 返回所有核心的共有特性,不受线程调度影响
  • 性能开销极小(一次系统调用)

第五章:排查工具与技巧

5.1 必备工具

LLVM 工具链

# 查看符号表
llvm-nm libcrypto.so.3 | grep ChaCha20

# 反汇编特定函数
llvm-objdump -d -C --symbol=ChaCha20_ctr32_sve libcrypto.so.3

# 查看段信息
llvm-readelf -S libcrypto.so.3

地址转符号

# 方法1: addr2line(简单)
llvm-addr2line -e libcrypto.so.3 0x28a570

# 方法2: objdump(详细)
llvm-objdump -d -C --start-address=0x28a550 --stop-address=0x28a5f0 libcrypto.so.3

第六章:总结与最佳实践

6.1 问题回顾

这次崩溃的根本原因链:

编译时未定义 __OHOS__
  ↓
OpenSSL 退回到 SIGILL 探测方式
  ↓
libcrypto.so 在主线程(大核)中被加载
  ↓
OPENSSL_cpuid_setup() 在大核中执行
  ↓
SIGILL 探测机制在大核检测到 CPU 支持 SVE2
  ↓
全局变量标记:CPU 支持 SVE2 ✓
  ↓
FFRT 将任务调度到小核执行
  ↓
小核尝试执行 SVE2 指令
  ↓
💥 SIGILL 崩溃(小核不支持 SVE2)

关键教训: 在大小核异构 CPU 平台上,不能使用基于信号探测的方式检测 CPU 特性,必须使用内核提供的 getauxval() API。


总结

这次问题排查让我深刻体会到:

  1. 版本升级不只是换个版本号 📦
    OpenSSL 3.x 的 CPU 检测机制比 1.1.1 复杂得多,需要仔细评估平台兼容性

  2. SIGILL 探测在大小核架构下不可靠 ⚔️
    看似聪明的 Hack 手段,在异构 CPU 平台上会产生误判

  3. 优先使用系统 API 🎯
    对于 CPU 特性检测这类关键功能,应该依赖内核提供的标准 API,而不是自己探测

希望这篇文章能帮助遇到类似问题的同学快速定位和解决!如果你也在做 HarmonyOS 的底层适配工作,欢迎交流~ 🤝


参考资料