近日尝试在 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 头、一个程序头表、若干个分段、最后是分段头表。它的特点是:
- 可以通过一头一尾两个表索引,具体的分段在中间
- 中间的空间未必被全部利用,可能有空余
它的布局如图:

分段中有几个我们熟悉的名字:
- .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。
Leave a Reply