从 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

编译器中有一个 “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,可以看到这个共享库中有哪些 symbol(.gnu_version)、提供哪些 symbol(.gnu_version_d)和依赖哪些 symbol(.gnu_version_r),及其 glibc 版本号。

注意到,新版本的 GLIBC 提供当前版本号及更早版本号的 symbol,但老版本的 GLIBC 绝不可能提供更新版本号的 symbol。因此,即使这张表中提出了诸多需要的版本,只要运行环境中的 GLIBC 版本够新,就能够运行程序,符合我们对”向后兼容“的默认判断。这套给符号打上版本标签的机制允许 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 了。想知道到底是哪些符号(函数、变量)需要版本 2.4 或 2.34,可以通过 readelf -Ws my_first_hps | grep GLIBC 列出符号表:

  2: 00000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.4 (3)
     3: 00000000     0 FUNC    GLOBAL DEFAULT  UND abort@GLIBC_2.4 (3)
     4: 00000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.34 (2)
    83: 00000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.34
    91: 00000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.4
   101: 00000000     0 FUNC    GLOBAL DEFAULT  UND abort@GLIBC_2.4

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

readelf -l my_first_hps,输出包括

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

它的含义是程序需要运行环境中的 Interpreter 帮忙动态链接才能运行 (这里 Interpreter 的含义是动态链接器,和 Python Interpreter 一类的解释器含义相去甚远)。它指定了 /lib/ld-linux-armhf.so.3 作为 Interpreter。

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

附录:更新

所以最初的报错到底应该怎么解决?

第一种方式:在编译时加入 -static 选项,静态链接到 glibc 库,以可执行文件 main 更大为代价换取兼容性。

arm-none-linux-gnueabihf-gcc main.c -o main -static

将 main 拷贝到 DE1-SoC 上,可以成功运行。

第二种方式:换用一个 2016 年左右的交叉编译器,这样它编译出的 ELF 文件不会要求运行环境中有将来的、高版本 glibc。


Posted

in

by

Tags:

Comments

Leave a Reply

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