从 glibc 版本错配了解 ELF 格式和 ABI

近日尝试在 x86 平台上通过 arm-none-linux-gnueabihf 交叉编译工具链构建一个 C 语言写成的 Hello World 程序,得到可执行文件 “my_first_hps”;再将其拷贝到一个专为嵌入式硬件(DE1-SoC 开发板上的 ARMv7 架构处理器即 HPS)定制的 Ubuntu 16.04 修改版 OS (Kernel Version 4.5.0)里运行。

尝试运行,报错

/arm-linux-gnueabihf/libc.so.6: version GLIBC_2.34 was not found, required by ./my_first_hps

瞎折腾的时候出现了预期之外的结果,是好事情!本着增进理解第一、折腾成果第二的原则,转而调查到底发生了什么事情。

什么是 glibc?为什么版本不匹配?

类 Unix 系统的内核传统上是 C 语言编写的,因此它暴露给用户空间的接口(即系统调用)是 C 函数。C 标准函数库(C Standard Libarary)封装了这套接口,提供更易用的接口。例如,C 标准函数库向用户提供能以格式输出的 printf 函数,封装了输出字节串的系统调用 write。用户通过 include <stdio.h> 导入 printf 函数的声明,就可以它了。

C 标准函数库的实现有诸多,包扣 glibc, musl 等等。这里出现的 glibc 即 GNU C 函数库(GNU C Library),是它完成了从标准库函数 printf 到系统调用 write 的转换。这个实现与时俱进,就产生了诸多版本。

在嵌入式 OS 中执行 ldd -version,显示 GLIBC 2.23,即当前系统的 glibc 版本低于二进制文件 my_first_hps 需求的版本。依据后向兼容性,拥有低版本 glibc 的工具生成的二进制文件,通常可以在拥有高版本 glibc 的 OS 中运行,而反之则不行。因此,my_first_hps 需要运行时拥有的 glibc 的最低版本号是由它调用的标准库函数所需求的 glibc 版本中的最高值决定的。

这个修改版 OS 的最后修改时间是 2016 年,而我编译使用的工具链是 2025 年的,glibc 版本错配也不奇怪。下一步就是看看编译器 arm-none-linux-gnueabihf 到底对 glibc 版本和运行环境提出了什么要求。

编译器构建 my_first_hps 时留下的版本信息

解释这个问题的第一步是意识到很多编译器中有一个 “sysroot” 的概念。它是一个文件夹,是编译器寻找头文件和函数库的默认位置。比如说我的编译器安装在了 .../arm-none-linux-gnueabihf/ 这个位置,经过摸索它的 “sysroot” 就相当于在 .../arm-none-linux-gnueabihf/libc/,因为

  • 头文件,比如 stdio.h,可以在 .../arm-none-linux-gnueabihf/libc/usr/include 中找到
  • 函数库,比如 libc.so.6,可以在 .../arm-none-linux-gnueabihf/libc/lib 中找到。(顺带一提,这个 6 是 glibc 的 ABI 大版本号)

编译器通过这些文件完成 Hello World C 程序的编译和链接,得到目标平台的可执行文件。运行 readelf -V libc.so.6,可以看到其中诸多函数需要的 glibc 版本号。

具体到 my_first_hps,它只调用了少数几个函数。这个可执行文件是 ELF (Executable and Linkable Format) 格式的,我们可以从中获取需要的版本信息。运行 readelf -V my_first_hps,得到

Version symbols section '.gnu.version' contains 5 entries:
 Addr: 0x0000000000010260  Offset: 0x00000260  Link: 4 (.dynsym)
  000:   0 (*local*)       1 (*global*)      3 (GLIBC_2.4)     3 (GLIBC_2.4)
  004:   2 (GLIBC_2.34)

Version needs section '.gnu.version_r' contains 1 entry:
 Addr: 0x000000000001026c  Offset: 0x0000026c  Link: 5 (.dynstr)
  000000: Version: 1  File: libc.so.6  Cnt: 2
  0x0010:   Name: GLIBC_2.4  Flags: none  Version: 3
  0x0020:   Name: GLIBC_2.34  Flags: none  Version: 2

这里需要用到的最高版本就是 2.34 了。

除此之外 ELF 格式中还留下了诸多对运行环境的要求。可以尝试:

readelf -l my_first_hps,输出包括

INTERP         0x000154 0x00010154 0x00010154 0x00019 0x00019 R   0x1
      [Requesting program interpreter: /lib/ld-linux-armhf.so.3]

它的含义是程序需要运行环境中的 Interpreter (动态解释器、动态链接器),才能运行。它指定了 /lib/ld-linux-armhf.so.3 作为 Interpreter。(顺带一提,这个 3 是hard-float ARM EABI 的版本号)

ELF 文件中包含了诸多信息,readelf 是解读 ELF 文件的有力工具。不过要用好这个工具,彻底了解它输出的信息,就需要深入了解 ELF 格式了。

深入 ELF 格式

Linux 系统中的 .o 文件(object file,目标文件)和 .so 文件(shared object file,共享库文件)以及可执行文件都遵循 ELF (Executable and Linkable Format) 格式。它有一个 ELF 头、一个程序头表、若干个分段、最后是分段头表。它的特点是:

  • 可以通过一头一尾两个表索引,具体的分段在中间
  • 中间的空间未必被全部利用,可能有空余

它的布局如图:

Surueña, CC BY-SA 3.0 https://creativecommons.org/licenses/by-sa/3.0, via Wikimedia Commons

分段中有几个我们熟悉的名字:

  • .text,存储指令,只读而可执行
  • .rodata,存储常量值,只读
  • .data,存储已初始化的全局变量和静态变量,可以读写
  • .bss,存储未初始化的全局变量

想要了解 ELF 格式的具体标准,可以参考 System V (SysV) ABI 的草案,ELF 格式是其中的一部分。说来, System V 是商业许可下 Unix 系统分支树的一个版本,它的后继是 Unixware;而 ELF 的最初发表和事实标准就是 Sys V Release 4 (SVR4) 定义的。

无论在哪个指令集下,ELF 头的格式都相对固定,它包含程序头表的地址偏移量、程序头表的表项个数和每项大小(每一项的大小和格式相同,可以将程序头表看成结构体组成数组)。

readelf -l my_first_hps (--program-headers or --segments) 可以输出程序头表中的每一项。

  • 前面提到了 “PT_INTERP” 这一项,就指明了用哪个动态链接器/解释器。
  • 还有一项是 “PT_DYNAMIC”,它表明了需要动态链接,也指向 .dynamic 分 段,这一分段存储了执行动态链接所需要的信息。

readelf -d my_first_hps (--dynamic) 可以输出动态链接分段,它的具体定义可以参考这里。从命令的输出中我们可以看见 my_first_hps 需要 libc.so.6(并不稀奇)

Dynamic section at offset 0xf10 contains 25 entries:
  Tag        Type                         Name/Value
 0x00000001 (NEEDED)                     Shared library: [libc.so.6]
 0x0000000c (INIT)                       0x102c4
 0x0000000d (FINI)                       0x103f8

深入 ABI

在前面的很多地方我们了解到,ELF 格式是 ABI 的重要部分,想要有一个全局的认识,下一步就是了解 ABI。

ABI (Application Binary Interface,应用二进制接口) 定义了二进制代码如何和操作系统、硬件和其它组件互动。ABI 是各个二进制模块之间的接口或者说合约,保证它们合起来作为一个系统能正确运行。

由于 ABI 要定义和硬件互动的合约(比如寄存器的运用),所以即使是一个操作系统,在不同的指令集上,都有一套单独的 ABI。

ABI 包括:

  • 数据类型布局 Data Type Layout
  • 二进制文件格式 Binary File Format
  • 调用约定 Calling Convension
  • 名字重整 Name Mangling
  • 异常处理(包括栈展开) Exception Control (Stack Unwinding)
  • 等等

由于 C 语言是和 Linux 内核沟通的语言桥梁,所以对于类 Unix 系统的 ABI 来说,需要规定 C 数据类型的布局。

二进制文件格式方面,对于类 Linux 系统来说是前面介绍的 ELF,对 Windows 来说是 PE。

调用约定

调用约定规定了函数调用时如何传参和传返回值(通常是通过寄存器和栈),是调用者(caller)还是被调用者(callee)负责清理栈等等。常见的例子比如:

  • x86-64 (Linux): 前六个整型/指针参数依次使用寄存器 rdi, rsi, rdx, rcx, r8, r9; 浮点数参数使用 xmm0–xmm7; 更多参数通过栈传递(16 字节对齐);RAX 和 XMM0 传递整数和浮点返回值;调用者清理栈
  • x86-64 (Windows): 前四个整型/指针参数依次使用寄存器 rcx, rdx, r8, r9 传递; 浮点数参数使用 xmm0–xmm3; 更多参数通过栈传递;RAX 和 XMM0 传递整数和浮点返回值;调用者清理栈
  • ARM: …

名字重整

名字重整(Name Mangling)是编译器为了支持重载等等功能,保证名字的唯一性而向源代码中函数、类等名称加入额外信息的操作。这种操作在 C++ 中很常见,在 C 中较为收敛,如 C++ 编译器可能会将 “sumprimes” 函数名变为 “_Z9sumprimesx”。可以通过 nm 命令列举共享库中的符号来实验观察这一点。

为了让不同编译器编译的代码能够相互协作、链接,ABI 需要规定名字重整的规则、避免出现命名冲突。即使内核是 C 写,基本没有名字重整的问题,ABI 也需要规定其它来源如用户空间的二进制文件应该如何协作,而这些文件(.so, .o, …)可能是任何编译型语言,如 C++, Rust, etc。

异常处理中的栈展开

发生异常时,我们需要不断抛弃栈顶的栈帧,知道到达适合进行异常处理的栈,这个过程称为栈展开 (Stack Unwinding)。例如,在有异常处理的编程语言中,不断进行栈展开,直到到达能捕获异常的函数。

#include <iostream>
#include <stdexcept>

struct LocalObject
{
    LocalObject(const char *name) : name_(name)
    {
        std::cout << "Constructing " << name_ << std::endl;
    }
    ~LocalObject()
    {
        std::cout << "Destructing " << name_ << std::endl;
    }

private:
    const char *name_;
};

void functionThatThrows()
{
    LocalObject obj("functionThatThrows() - LocalObject");
    throw std::runtime_error("Something went wrong!");
}

int main()
{
    LocalObject mainObj("main() - LocalObject");
    try
    {
        functionThatThrows();
        std::cout << "This line won't be executed.\n";
    }
    catch (const std::exception &e)
    {
        std::cout << "Caught exception: " << e.what() << std::endl;
    }
    return 0;
}

输出结果:

Constructing main() - LocalObject
Constructing functionThatThrows() - LocalObject
Destructing functionThatThrows() - LocalObject
Caught exception: Something went wrong!
Destructing main() - LocalObject

Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *