宠文网

深度探索Linux操作系统

宠文网 > 科普学习 > 深度探索Linux操作系统

2.2 构建工具链

书籍名:《深度探索Linux操作系统》    作者:王柏生
    《深度探索Linux操作系统》章节:2.2 构建工具链,宠文网网友提供全文无弹窗免费在线阅读。!


虽然构建的目标系统是运行在IA32体系架构上的,但是我们不能使用宿主系统的工具链,否则可能会导致目标系统依赖宿主系统。在编译程序时,如果使用了宿主系统的链接器,那么链接器将在宿主系统的文件系统中寻找依赖的动态库,这势必会导致目标系统中的程序链接宿主系统的某些库,从而导致目标系统依赖宿主系统。其直观表现就是程序在编译时可能会顺利通过,但是当在目标系统中运行时,却可能出现未定义符号的错误。

除了上述的依赖问题外,目标系统使用的工具链的各个组件的版本,通常不同于宿主系统,因此,这也要求为目标系统构建一套新的工具链。

但是工具链在软件开发中占据极其重要的位置,包括编译、汇编、链接等多个组件在内的任一组件的问题都可能导致程序执行时出现问题,如执行效率低下,甚至带来安全问题。因此,在实际应用中,很多时候我们都是直接使用已经构建好的工具链,这类工具链一般都被广泛使用,所以在某种意义上其正确性是被实践检验过的,但是也有缺点,就是没有针对具体的硬件平台进行优化。因此,有时我们也会借助某些辅助工具,针对我们的特定硬件,进行配置优化,“半自动”地为目标系统构建编译工具链。

在现实中,完全手工构建工具链的机会并不多,很多时候我们可能都是使用别人已经构建好的。但是,工具链中包含的组件可以说是除了操作系统内核之外的最底层的系统软件,无论是对理解操作系统,还是对开发程序来说,都有着重要的意义。即使永远不需自己手工编译工具链,但是了解工具链的构建过程,也可以帮助更高效灵活地运用已有的工具链,可以在多个现成的工具链中进行更好的选择,也有助于进行“半自动”地构建工具链。



2.2.1 GNU工具链组成


编译过程分为4个阶段,分别是:编译预处理、编译、汇编以及链接。每个阶段都涉及了若干工具,GNU将这些工具分别包含在3个软件包中:Binutils、GCC、Glibc。

◆  Binutils:GNU将凡是与二进制文件相关的工具,都包括在软件包Binutils中。Binutils就是Binary  utilities的简写,其中主要包括生成目标文件的汇编器(as),链接目标文件的链接器(ld)以及若干处理二进制文件的工具,如objdump、strip等。但是也不是Binutils中的所有的工具都是处理二进制文件的,比如处理文本文件的预编器(cpp)也包含在其中。

◆  GCC:GNU将编译器包含在GCC中,包括C编译器、C++编译器、Fortran编译器、Ada编译器等。为简单起见,在本章中我们只构建C/C++编译器。GCC中还提供了C++的启动文件。

◆  Glibc:C库包含在Glibc中。除了C库外,动态链接器(dynamic  loder/linker)也包含在这个包中。另外这个包中还提供C的启动文件。事实上,有很多C库的实现,比如适用于Linux桌面系统的Glibc、EGlibc、uClibc;在嵌入式系统上,可以使用EGlibc或者uClibc;对于没有操作系统的系统,也就是所说的freestanding  enviroment,可以选择newlib、dietlibc,或者根本就不用C库。

除了这三个软件包外,工具链中还需要包括内核头文件。用户空间中的很多操作需要借助内核来完成,但是通常用户程序不必直接和内核打交道,而是通过更易用的C库。C库中的很大一部分函数是对内核服务的封装。在某种意义上,内核头文件可以看作是内核与C库之间的协议。因此,构建C库之前,需要首先在工具链中安装内核头文件。



2.2.2 构建工具链的过程

针对我们的具体情况,目标系统与宿主系统都是基于IA32体系架构的,所以一种方式是利用宿主的编译工具链来构建一套独立于宿主系统的自包含的本地编译工具链;另外一种方式就是构建一套交叉编译工具链。在本章中,我们采用交叉编译的方式构建工具链,主要原因是:

◆  Linux的主要应用的场景之一是嵌入式领域,嵌入式设备中存在多种不同的体系架构,受限于嵌入式设备的性能和内存等,像编译链接这种工作都在工作站或PC上进行。因此,使用交叉编译的方法,对读者进行嵌入式开发更有益处。

◆  再者,采用交叉编译的方式相对更有助于读者理解链接过程及文件系统的组织。所以,虽然我们的宿主系统和目标系统都是基于IA32的,但是我们利用交叉编译的方式构建编译工具链。

如果读者没有嵌入式开发的相关经验,也不必担心,交叉编译与本地编译本质上并无区别。交叉编译就是在目标机器与宿主机器体系结构不同时使用的编译方法。无论是本地编译还是交叉编译,工具链的各个组件均是运行在工作站或者PC上,只不过对于本地编译,我们编译出的程序运行在本地系统上,或者至少是运行在与宿主系统相同的体系架构的机器上。而对于交叉编译,编译出的程序是运行在目标机器上的。

如图2-7所示,如果目标机器与宿主系统相同均为IA32架构,那么就使用宿主机器上的本地编译工具链,编译出的二进制代码对应的也是IA架构的指令。如果目标机器是其他的体系结构的,比如ARM,那么就需要使用宿主系统上的交叉编译工具链,编译出的二进制代码对应的也是目标体系架构的指令,如ARM指令。

图 2-7 本地编译和交叉编译

GNU将编译器和C库分开放在两个软件包里,好处是比较灵活,在工具链中可以选择不同的C库,比如Glibc、uClibc等。但是,也带来了编译器和C库的循环依赖问题:编译C库需要C编译器,但是C编译器也依赖C库。虽然理论上编译器不应该依赖C库,C编译器只负责将源代码翻译为汇编代码,但是事实并非如此:

◆  C编译器需要知道C库的某些特性,以此来决定支持哪些特性。所以,为了支持某些特性,C编译器依赖C库的头文件。

◆  C++的库和编译器需要C库支持,比如异常处理部分和栈回溯部分。

◆  GCC不仅包含编译器,还包含一些库,这些库通常依赖C库。

◆  C编译器本身也会使用C库的一些函数。

但是,幸运的是,C99标准定义了两种运行环境,一种是"hosted  environment",针对的是具有操作系统的环境,程序一般是运行在操作系统之上的,因此这个操作系统不仅是内核,还包括外围的C库,对于程序来说,就是一个"hosted  environment"。另外一种是"freestanding  environment",就是程序不需要额外环境的支持,直接运行在裸机(bare  metal)上,比如Linux内核,以及一些运行在没有操作系统的裸板上的程序,不再依赖操作系统内核和C库,所有的功能都在单个程序的内部实现。

针对这两种运行环境,C99标准分别定义了两种实现:一种称为"hosted  implementation",支持完整的C标准,包括语言标准以及库标准;另外一种是"freestanding  implementation",这种实现方式支持完整的语言标准,但是只要求支持部分库标准。C99标准要求"hosted  implementation"支持"freestanding  implementation",通常是通过向编译器传递参数来控制编译器采用哪种方式进行编译。

通常"hosted  implementation"的实现包含编译器(比如GCC)和C库(比如Glibc)。而"freestanding  implementation"的实现通常只包含编译器,如GCC,最多再加上一个简单的库,比如典型的newlib。但是如果没有newlib的支持,GCC自己也可以自给自足。

"freestanding  implementation"的实现,恰恰解决了我们提到的GCC和Glibc的循环依赖问题。我们可以先编译一个仅支持"freestanding  implementation"的GCC,因为在这种情况下,不需要C库的支持。但是"freestanding  implementation"的GCC却可以编译Glibc,因为Glibc也是一个自包含的,完全自给自足。事实上,Glibc中也有小部分地方使用了GCC的代码,但是这不会带来依赖的麻烦,因为GCC一定是在Glibc之前编译的。

在编译目标系统的C库,甚至是编译GCC中包含的目标系统上的库时,都需要链接器,因此,Binutils是编译器和C库共同依赖的。索性Binutils几乎没有任何依赖,只需要利用宿主系统的工具链构建一套交叉Binutils即可。

另外值得一提的是内核头文件。在Linux系统上,在编译C库前需要安装目标系统的内核头文件,从某种意义上讲,内核头文件就是C库和内核之间的一个协议(Protocol)。而且,C库会根据内核头文件检查内核提供了哪些特性,以及需要在C库层面模拟哪些内核没有提供的服务。

综上所述,我们可以按照如下步骤构建工具链:

1)构建交叉Binutils,包括汇编器as、链接器ld等。

2)构建临时的交叉编译器(仅支持freestanding)。

3)安装目标系统的内核头文件。

4)构建目标系统的C库。

5)构建完整的交叉编译器(支持hosted和freestanding)。

最后提醒读者注意一点,上面的目标平台也是IA32的,并且使用的C库是Glibc。如果是其他平台的,或者是用了不同的C库,编译过程可能会略有差异,比如为了使最终编译C库的编译器具有更多的特性,有的编译过程将使用freestanding编译器编译一个简化版的hosted编译器,然后用这个hosted的GCC再编译Glibc等。但是,无论如何,交叉编译的关键还是构建freestanding的编译器。freestanding的编译器解决了鸡和蛋的问题(即GCC和Glibc的循环依赖),一旦解决了鸡和蛋的问题后,其他的问题就都迎刃而解。



2.2.3 准备工作

1.新建普通用户vita

为了避免误操作给宿主系统带来灾难性的后果,在编译过程中我们使用普通用户,避免不小心使用新编译的某些库覆盖宿主系统的库。我们新建一个普通用户vita:



我们还要新建一个组vita,用户vita属于这个组。参数"-m"表示创建vita用户的属主目录,默认是/home/vita;"-s/bin/bash"表示使用bash  shell;"-g  vita"表示将vita加入组vita。

在某些情况下,我们可能需要使用vita执行一些超级用户才有权限执行的命令,因此,我们让vita成为sudoers,在/etc/sudoers.d目录下添加一个文件vita,其内容如下:



2.建立工作目录

为了便于管理,我们需要建立一个工作目录,这个目录可以建立在任一目录下。笔者使用了一个单独的分区,并且挂载在/vita目录下。在该目录下,建立相应的工作目录的方法如下:



其中,source目录中存放的是源代码;build目录用作编译;cross-tools目录保存交叉编译工具。因为在整个编译过程中,编译器将被编译两次,所以cross-gcc-tmp用来保存临时的freestanding编译器,避免这个临时的freestanding编译器污染最后的工具链。编译好的目标机器上的文件安装在sysroot目录下,sysroot目录相当于目标系统的根文件系统。另外,我们使用chown更改这些目录的属主和属组,使vita用户有权限使用这些目录及目录下的文件。

3.定义环境变量

为了简化编译过程中的一些命令,我们需要定义一些环境变量。同时为了避免每次切换到vita用户时,都需要手动重新定义,我们将其定义在/home/vita/.bashrc中。



如果使用的是中文环境的操作系统,那么为了避免不必要的麻烦,要在环境中将其设置英文环境,即上面的unset  LANG,因为,在中文环境下,有些工具,比如readelf,在输出ELF文件的信息时,多此一举地将很多英文翻译为了中文,可能给有些脚本处理工具带来一些麻烦。

为了使后面的构建过程可以找到的交叉编译工具,我们将安装交叉编译工具的目录添加到环境变量PATH中,包括临时的GCC存储的目录。注意一点,临时的GCC存储的目录一定要在最终正式的工具链目录的后面,确保安装最终的交叉编译器后,在编译时将优先使用最终的交叉编译器。

Binutils、GCC以及Glibc的配置脚本中均包含三个配置参数:HOST、BUILD和TARGET,这三个配置参数的值均是大致形如ARCH-VENDOR-OS三元组的组合。在编译前,可以通过配置选项设定这几个参数的值。如果配置时不显示指定这几个参数,编译脚本将自动探测编译所在的机器的相关值。

读者可以通过查看变量MACHTYPE,或者查看编译时配置过程的结果,确定机器的三元组。以笔者的机器为例,该值为i686-pc-linux-gnu,表示机器的CPU型号为i686,vendor为none,操作系统为linux-gnu。



如果HOST的值和TARGET的值相同,那么编译脚本就构建本地编译工具。只有当HOST和TARGET的值不同时,编译脚本才构建交叉编译工具。因此,虽然目标平台也是x86架构的,但是为了使用交叉编译的方式,我们在配置时故意显示设置TARGET参数为i686-none-linux-gnu,如此,TARGET就会与编译脚本自行检测到的HOST(对于笔者的机器来说,即i686-pc-linux-gnu)不同,从而构建交叉编译工具。读者可根据自己的具体环境进行调整,总之,要使TARGET与HOST不同。为了方便在编译时设置配置参数,因此我们定义了环境变量BUILD、HOST和TARGET。

4.切换到vita用户

准备工作完成后,我们使用如下命令切换到vita用户:



注意,这里我们切换到vita用户使用的是"su-"而不是"su"。后者只是切换了身份,shell的环境仍然是原用户的shell环境,而前者将shell环境也切换到了vita。



2.2.4 构建二进制工具

Binutils包含各种用来操作二进制目标文件的工具,其中包括GNU汇编器as和链接器ld,处理静态库的工具ar和ranlib,系统程序员常用的objdump、readelf、nm、strings、stip等。

Binutils推荐使用单独的目录进行编译:



下面介绍各个配置参数的意义。

◆  --prefix=$CROSS_TOOL:通过参数--prefix指定安装脚本将编译好的二进制工具安装到保存交叉编译工具链的$CROSS_TOOL目录下。

◆  --target=$TARGET:因为没有显示指定参数--host和--build,所以编译脚本将自动探测HOST和BUILD的值。对于笔者的机器来说,探测到的HOST和BUILD值相同,都为i686-pc-linux-gnu。在前面设置环境变量时,我们故意将环境变量TARGET的值设置i686-none-linux-gnu,与HOST自动探测的值不同,因此,编译脚本据此判断这是在构建交叉编译工具链,继而将指导宿主系统的工具链编译“运行在本机,但是最后编译链接的程序/库是运行在$TARGET上”的交叉二进制工具。

◆  --with-sysroot=$SYSROOT:我们通过参数--with-sysroot告诉链接器,目标系统的根文件系统放置在$SYSROOT目录下,链接时到$SYSROOT目录下寻找相关的库。

配置完成后,使用如下命令编译并安装:



Binutils将二进制工具安装在$CROSS_TOOL/bin目录下,这里不浪费篇幅一一列举各个工具的具体功能了,读者可以使用man进行查看。

除了安装二进制工具外,Binutils还安装了链接脚本,安装目录是:



其中elf_i386.x用于IA32上ELF文件的链接,elf_i386.xbn、elf_i386.xc等分别对应ld使用不同的链接参数时使用的链接脚本,如果使用了"-N"参数,那么ld使用链接脚本elf_i386.xbn。

Binutils在$CROSS_TOOL/i686-none-linux-gnu/bin目录下也安装了一些二进制工具,这些是编译器内部使用的,我们不必关心,其实这个目录下的工具与$CROSS_TOOL/bin目录下的工具完全相同,只是名称不同而已。



2.2.5 编译freestanding的交叉编译器

正如我们前面讨论的,因为编译器和C库之间循环依赖的问题,我们需要找到一个办法解决这个鸡和蛋的问题。幸运的是,C编译器提供了一个freestanding的实现,即一个不依赖C库的编译器。那么如何编译一个freestanding的编译器呢?

GCC提供了一个编译选项--with-newlib。这是一个让人困惑的C库参数,因为newlib本身就是一套C库的实现,所以容易让人误解为工具链中使用的C库是newlib,而不是其他的C库。事实上,在构建交叉编译器时,其有着特殊的意义,文件configure.ac中的注释解释得很清楚。



注释中说明,在构建交叉编译器且尚未安装C库头文件的情况下,需要定义变量inhibit_libc。一旦定义了该变量,将去掉libgcc库中对C库的一切依赖,转而使用GCC内部的实现。如下面的代码片段:



我们看到,如果没有定义inhibit_libc,则libgcc库中可能会包含link.h,而这恰恰是glibc提供的头文件。

换句话说,我们可以通过将变量inhibit_libc赋值为true,告诉GCC编译为freestanding实现。但是,遗憾的是,GCC并没有暴露一个直观的配置选项供配置时设置这个变量,相反需要通过另外相关的变量来控制变量inhibit_libc的值。

再次回顾文件gcc-4.7.2/gcc/configure.ac中的关于定义inhibit_libc的条件语句部分,if中的条件如下:

1)如果是交叉编译且未设置--with-sysroot,或者设置了--with-newlib。

2)没有设置--with-headers。

对于条件1),因为我们使用了sysroot的方式,所以要满足第一个条件,就需要设置--with-newlib。对于条件2),因为我们没有指定头文件,所以自然成立。

看了前面的讨论,相信读者就比较清楚--with-newlib的意义了,使用--with-newlib并不是强行指定GCC使用newlib实现的C库。我们无从考究参数--with-newlib的出处,但是因为newlib的初衷就是作为freestanding环境中的C库,或许这个参数的名称来源于此。

下面,我们开始编译用于freestanding环境的gcc编译器,首先解开源码包:



GCC依赖包括浮点计算、复数计算的几个数学库GMP、MPFR和MPC。可以先单独编译这些库,然后通过GCC的配置选项如--with-mpc、--with-mpfr、--with-gmp告知GCC这几个库的位置。也可以将这几个库的源码解压到GCC的源码目录下,在编译时,GCC会自动探测并编译。这里我们采用后者;



GCC要求在单独的目录编译,因此我们创建编译目录gcc-build,配置如下:



下面介绍各个配置参数的意义。

◆  --prefix=$CROSS_GCC_TMP:freestanding的GCC与最终的hosted的GCC还是有些差别的,这里的freestanding的GCC只是一个临时的GCC,并不会用作最终的交叉编译器。所以,为了避免污染最后的工具链,这里将freestanding的GCC安装在一个临时的目录$CROSS_GCC_TMP中。

◆  --target=$TARGET:与在Binutils中指定参数--target同样的道理,告诉编译脚本构建的预处理器、编译器等是运行在本机上的,但是最后编译的程序或库是运行在目标体系结构$TARGET上的,即构建交叉编译器。

◆  --with-sysroot=$SYSROOT:配置参数--with-sysroot告诉GCC目标系统的根文件系统存放在$SYSROOT目录下,编译时到$SYSROOT目录下查找目标系统的头文件以及库。

◆  --enable-languages=c:编译C库只需要C编译器,所以这个临时的freestanding编译器只支持C编译器。而且像C++编译器,即使想编译也是有心无力,因为其依赖目标系统的C库,所以目前也没有条件进行编译。

◆  --disable-shared:除了编译器外,软件包GCC中也包含有一个运行时库libgcc。该库主要包括一些目标处理器不支持的数学运算、异常处理,以及一些小的比较复杂的便利函数。在默认情况下,会既编译libgcc的静态库版本,也编译动态库版本。但是动态库与静态库不同,加载器在加载动态库后需要进行一些初始化,比如初始化变量,而这些相关的代码是在C库的启动文件中实现的,包括crt1.o、crti.o等,因此,编译libgcc的动态版本时将会链接启动文件。但是此时目标机器的C库尚未编译,链接将发生类似“找不到crt1.o文件”的错误。因此,这里通过配置选项--disable-shared告诉编译脚本不要编译libgcc的动态库,仅编译静态库。

◆  --with-mpfr-include和--with-mpfr-lib:对于MPFR这个库,其目录结构与GCC的默认设定有一些差异,因此我们需要明确指定,否则编译时会报找不到libmpfr的错误。这就是配置时指定配置选项--with-mpfr-include和--with-mpfr-lib的原因。

另外我们还通过形如--disable-xxx这样的参数禁止了一些库的编译,也关闭了编译器的一些特性,因为目前这个freestanding的交叉编译器根本不需要这些特性,我们只需要一个基本的能够将C库中的代码翻译为目标机器的指令这样一个基本的编译器即可。而且,最重要的是,某些库和特性中可能会依赖C库,因此,临时的freestanding编译器不支持不必要的特性,也不编译不必要的库。

编译完成后,使用如下命令进行安装:



在使用--disable-shared禁止编译libgcc的动态库后,GCC的编译脚本将不再编译库libgcc_eh.a。但是后面编译Glibc时,Glibc将链接libgcc_eh.a,Glibc的Thread  cancellation使用了GCC中的异常处理部分的实现,这里eh就是exception  handling的缩写。我们可以直接修改Glibc中的Makeconfig文件,或者通过建一个指向libgcc.a的符号链接libgcc_eh.a来解决这个问题。因为libgcc.a中包含libgcc_eh.a所包含的全部内容。我们采用后者来解决这个问题。



2.2.6 安装内核头文件

应用程序很少直接通过内核提供的接口使用内核提供的服务,而通常都是用C库使用内核提供的服务。C库的主要内容之一是对内核服务的封装。以系统调用_exit为例:



Glibc中使用的系统调用号__NR_exit_group和__NR_exit都是在内核中定义的。因此,在编译目标系统的C库之前,我们首先需要安装内核头文件。

首先解压内核源码,并清理内核。



我们可以通过变量ARCH指出目标系统的架构,在默认情况下,make将自动探测宿主系统的架构,并认为目标系统的架构与宿主系统的架构相同。对于IA32来说,其ARCH值是i386。另外,在安装前,还需要对内核头文件进行一些合法化检查。



完成安装后,我们可以看到的内核定义的系统调用号在文件unistd_32.h中。Glibc就可以包含该头文件,并使用诸如__NR_exit等宏定义。



2.2.7 编译目标系统的C库

作为Linux操作系统中最底层的API,几乎运行于Linux操作系统上的任何程序都会依赖于C库。Glibc除了封装Linux内核所提供的系统服务外,也提供了C标准规定的必要功能的实现,如字符串处理、数学计算等。

在Ubuntu12.10中,系统默认安装的awk是mawk,我们需要另外安装gawk,因为mawk与Glibc中使用的awk脚本在兼容上有一些问题。



解压源码,并打开修复编译错误的patch。



Glibc要求在单独的目录编译,我们新建目录glibc-build用来编译Glibc。



下面介绍各个配置参数的意义。

◆  --host=$TARGET:注意这里与Binutils和GCC编译时指定的是target参数不同,Glibc指定的是host参数,但这里host的值是$TARGET,也就是说C库运行所在的host是$TARGET。换句话说,就是告诉刚刚编译的交叉编译器、汇编器、链接器等编译一个运行在$TARGET平台的C库。

◆  --enable-kernel=3.7.4:除非是制作发行版,需要一个兼容更早内核的C库,否则我们没有必要向后兼容较早版本的内核,因为这样只会降低C库的效率,包括增加C库的体积,甚至影响运行速度。本书构建的系统使用的内核版本为3.7.4,因此,C库只支持3.7.4及以后版本的内核就可以了。当然,如果这个C库运行在早于3.7.4版本的内核上,将报类似于"FATAL:kernel  too  old"的致命错误,拒绝运行。

◆  --enable-add-ons:编译C库源码目录下全部的add-on,如libidn、nptl。

◆  --with-headers=$SYSROOT/usr/include:告诉编译脚本内核头文件所在的目录。

◆  libc_cv_forced_unwind=yes和libc_cv_c_cleanup=yes:Glibc中的NPTL将检测C编译器对线程的支持,而freestanding的GCC是不支持线程的,因此,我们这里欺骗一下Glibc中的NPTL,告诉它编译器是支持线程的,采用的方法是设置这样两个参数。

◆  libc_cv_ctors_header=yes:临时的freestanding的C编译器不支持启动代码与构造函数支持,因此,这里我们再次欺骗一下Glibc,人为地告诉Glibc编译器是支持启动代码的,也是支持构造函数的。

配置完成后,进行编译安装。我们通过指定参数install_root为$SYSROOT,将C库安装到$SYSROOT,即/vita/sysroot目录下。



下面介绍一下Glibc安装的主要文件。

(1)C库

Glibc除了将最基本、最常用的函数封装在libc中外,又将功能相近的一些函数封装到一些子库里,比如将线程相关函数封装在libpthread中,将与加密算法相关的函数封装在libcrypt中,等等。

Glibc除了安装库文件本身外,还建立了符号链接,包括:

◆  动态链接时使用的共享库符号链接。其命名格式一般为:libLIBRARY_NAME.so.MAJOR_REVISION_VERSION。

◆  开发时使用的共享库的符号链接。其命名格式一般为:libLIBRARY_NAME.so。

比如数学库的共享库及其符号链接如下:



其中,libm-2.15.so是数学库的共享库本身,libm.so.6是运行时使用的符号链接,libm.so是编译链接时使用的符号链接。Glibc将运行时使用的库安装在$SYSROOT/lib目录下,其中包括共享库文件本身及动态链接器需要的符号链接。将开发时使用的库安装在$SYSROOT/usr/lib目录下,包括开发时需要的符号链接及静态库等。

(2)动态链接器

Glibc亦提供了加载共享库的工具——动态加载器。2.15版的Glibc提供的动态加载器为ld-2.15.so,其符号链接是ld-linux.so.2,也安装在$SYSROOT/lib目录下。

(3)头文件

Glibc为应用程序的开发提供了头文件,安装在$SYSROOT/usr/include目录下。

(4)工具

Glibc也提供了一些可执行的便利工具,这类工具一般安装在sbin、usr/bin、usr/sbin目录下,比如用来转换文件字符编码的工具iconv,在usr/lib/gconv目录下安装了工具iconv使用的进行字符编码转换的各种库(如支持GB18030的GB18030.so),如果不打算在目标系统上转换文件的字符编码,完全不必安装该工具。另外还有比如查看共享库依赖的工具ldd,创建共享库缓存以提高共享库搜索效率的ldconfig程序等。

除此之外,usr目录下还有支持国际化、时区设置需要的文件等。

(5)启动文件

Glibc提供了启动文件,包括crt1.o、crti.o、crtn.o等,这类文件在编译链接时将被链接器链接到最后的可执行文件中,Glibc将其安装在$SYSROOT/usr/lib目录下。



2.2.8 构建完整的交叉编译器

现在目标系统的C库已经构建完成,我们有条件编译完整的编译器了。进入GCC的编译目录,清除临时编译的文件,重新配置GCC,与第一阶段的配置并无本质区别,但是把第一阶段禁掉的一些特性打开了。



注意,这次是编译最终的交叉编译器,所以安装在$CROSS_TOOL目录下,而不是$CROSS_GCC_TMP目录下。虽然GCC支持多种编译器,但是我们只需要C和C++编译器。另外,我们要求编译器支持posix线程。

在配置完成后,使用如下命令编译并安装:



最终的交叉编译器安装的主要文件如下:

(1)驱动程序

GCC安装的最主要的是交叉编译器的驱动程序,包括i686-none-linux-gnu-gcc、i686-none-linux-gnu-g++等。

(2)目标系统的库和头文件

GCC中也包含了一些用于目标系统的运行时库及头文件,它们安装在$CROSS_TOOL/i686-none-linux-gnu目录下。在该目录下,子目录lib存放包括目标系统的运行时库以及供目标系统编译程序使用的静态库,子目录include下包含开发目标系统上的程序需要的C++头文件。

(3)helper  program

前面我们提到,gcc仅仅是一个驱动程序,它将调用具体的程序完成具体的任务,这些程序被GCC安装在libexec目录下,典型的有编译器cc1,链接过程调用的collect2等。

libexec与sbin/bin目录下存放的可执行文件的一个区别是:sbin/bin目录下的可执行文件一般是用户使用的;而libexec目录下的可执行文件一般是由某个程序或工具使用的,所以一般称为"helper  program"。

(4)freestanding实现文件

前面我们提到,C99标准定义了两种实现方式:一种称为"hosted  implementation",支持全部C标准,包括语言标准以及库标准;另外一种是"freestanding  implementation"。在lib目录下的头文件即为"freestanding  implementation"实现标准要求的头文件。

(5)启动文件

与C++相关的启动文件在GCC中,包括crtbegin.o、crtend.o等。

讨论完C库和编译器后,我们看到,无论是C库,还是GCC都各自安装了头文件、运行库,GCC还安装了一些内部使用的可执行程序。那么在编译程序时,GCC是怎么找到这些文件的呢?答案就是GCC内部定义的两个环境变量LIBRARY_PATH和COMPILER_PATH。GCC会根据用户的一些配置参数,包括--target、--with-sysroot等设置这些环境变量的值。我们可以在编译程序时,使用参数"-v"查看这两个变量的值。



比如库的搜索路径,根据LIBRARY_PATH的定义,显然,既包括GCC安装的库的路径/vita/cross-tool/i686-none-linux-gnu/lib,又包括Glibc安装的库的路径/vita/sysroot/usr/lib。



2.2.9 定义工具链相关的环境变量

GNU  Make使用了一些隐示的预定义变量,并且这些变量都有对应的默认值。如CC代表编译器,默认值是程序cc,这也是为什么Linux各个发行版中一般都有一个符号连接"cc"指向真正的编译器的原因。再比如AR代表汇编器,默认值为ar。读者可以使用下面的命令输出make的数据库,进一步查看make数据库中的信息,比如查看交叉编译环境中的编译器。



这些隐示的预定义变量可以通过环境变量覆盖,或者在makefile中显示重新定义。为了避免在编译每一个软件包时,都需要显示指定使用我们构建的交叉工具链,我们在环境变量中定义编译过程使用的相关变量。我们将相关变量定义在/home/vita/.bashrc中,确保在每次切换到vita用户时,这些变量定义自动生效。



在后面安装编译程序时,一般我们均通过给make传递变量DESTDIR指定make将它们安装到目标系统的根文件系统下,即$SYSROOT目录下。为了避免每次都需要指定DESTDIR变量,我们也在.bashrc中定义这个变量。



为了使设置生效,定义变量后需要退出并重新切换到vita用户。

注意,如果需要重新构建交叉编译工具链,在构建前,要注释掉这一节的变量定义,在构建完成工具链后再重新启用这里的变量定义。



2.2.10 封装“交叉”pkg-config

在GNU中大部分的软件都使用Autoconf配置,Autoconf通常借助工具pkg-config去获取将要编译的程序依赖的共享库的一些信息,比如库的头文件存放在哪个目录下,共享库存放在哪个目录下以及链接哪些共享库等,我们将其称为库的元信息。通常,这些信息都被保存在一个以软件包的名称命名,并以".pc"作为扩展名的文件中。而pkg-config会到特定的目录下寻找这些pc文件,一般而言,其首先搜索环境变量PKG_CONFIG_PATH指定的目录,然后搜索默认路径,一般是/usr/lib/pkgconfig、/usr/share/pkgconfig、/usr/local/lib/pkgconfig等。显然,使用环境变量PKG_CONFIG_PATH不能满足我们的要求。因为在交叉编译环境中,我们是不能允许正在编译的程序链接到宿主系统的库上的,也就是说,我们除了告诉pkg-config到目标系统的文件系统中寻找外,还要禁止它搜索默认的宿主系统的路径。而另外一个环境变量PKG_CONFIG_LIBDIR可以满足我们这个需求,一旦设置了PKG_CONFIG_LIBDIR,其将取代pkg-config默认的搜索路径。因此,在交叉编译时,这两个变量的设置如下:



注意 如果需要重新构建交叉编译工具链,在构建前,也需要注释掉此处的变量定义,在构建完成工具链后再重新启用这里的变量定义。

除了pkg-config寻找pc文件的搜索路径需要调整外,从pc文件中获取的cflags和libs也需要追加sysroot作为前缀。因此,这里我们包装一下host系统的pkg-config,将为交叉编译定制的pkg-config放在$SYSROOT/bin下。



并为pkg-config增加执行权限:



下面是宿主系统自身的pkg-config获得的libmount库的--cflags和--libs:



下面是经过我们包装的pkg-config得的libmount库的--cflags和--libs:



显然,经过我们包装的pkg-config不再到宿主系统的文件系统下寻找依赖的库,而是到目标系统的根文件系统下去寻找依赖的共享库及头文件等。



2.2.11 关于使用libtool链接库的讨论

GNU中的大部分软件包都使用libtool处理库的链接。通常,大部分的软件在包发布时都已经包含了libtool所需的脚本工具等。但是如果一旦准备使用autoconf、automake重新生成编译脚本,且这些脚本中包含了libtool提供的M4宏,则需要安装libtool。可使用如下命令安装libtool。



在交叉编译环境中使用libtool处理库的链接时,依然还有个不大不小的问题,如同pkg-config的麻烦一样,如果使用宿主系统的libtool,那么编译库时生成的库的la文件中,记录库本身安装的位置以及依赖库的安装位置的路径将依然指向宿主系统的根文件系统,比如一个典型的la文件:



而实际上,目标系统的根文件系统在$SYSROOT下。显然,如果使用libtool链接,将会找错库的安装位置。

我们可以修改宿主系统的libtool,使其在交叉编译环境下能够创建合适的la文件;或者直接修改la文件,将类似"/usr/lib/*"的路径调整为"$SYSROOT/usr/lib/*";或者如pkg-config一样,封装一个libtool。但是我们采用更简单的方式,使用如下命令将la文件删除:



删除库的la文件后,链接相应的库时将不再使用libtool去寻找库的位置,而是依靠链接器去寻找库的位置。虽然libtool不建议这样做,但这样做最简单,且不容易发生错误,因此,后续我们采用这种方法。



2.2.12 启动代码

启动代码是工具链中C库和编译器都提供了的重要部分之一,但是由于应用程序员很少接触它们,因此非常容易引起程序员的困惑,所以我们特将其单独列出,使用一点篇幅加以讨论。

不知读者是否留意过这个问题:无论是在DOS下、Windows下,还是在Linux操作系统下,程序员使用C语言编程时,几乎所有程序的入口函数都是main,这是因为启动代码的存在。在"hosted  environment"下,应用程序运行在操作系统之上,程序启动前和退出前需要进行一些初始化和善后工作,而这些工作与"hosted  environment"密切相关,并且是公共的,不属于应用程序范畴的事情,这些应用程序员无需关心。更重要的一点是,有些初始化动作需要在main函数运行前完成,比如C++全局对象的构造。有些操作是不能使用C语言完成的,必须要使用汇编指令,比如栈的初始化。于是编译器和C库将它们抽取出来,放在了公共的代码中。

这些公共代码被称为启动代码,其实不只是程序启动时,也包括在程序退出时执行的一些代码,我们统称它们为启动代码,并将启动代码所在的文件称为启动文件。对于C语言来说,Glibc提供启动文件。显然,对于C++语言来说,因为启动代码是和语言密切相关的,所以其启动代码不在C库中,而由GCC提供。这些启动文件以"crt"(可以理解为C  RunTime的缩写)开头、以".o"结尾。

我们查看可执行程序hello的入口函数:



根据ELF的头可见,可执行文件hello的入口地址为0x80482f0。但该地址对应的函数是main吗?



结果显然让我们很失望,可执行文件的入口不是我们熟悉的main函数,而是一个陌生的_start函数,而且凭我们的职业直觉,这个函数的定义很像汇编语言的函数名。我们再来看一下可执行文件hello的代码段的起始地址:



根据代码段的起始地址可见,hello的代码段的最开头的函数确实是函数_start,而不是我们熟悉的main函数。那么main函数在哪里呢?



我们做个减法运算:



也就是说,在代码段中,偏移268字节处才是main函数的代码,代码段的前268字节都是启动代码,当然,程序启动时的启动代码不仅限于这268字节,因为函数_start中可能还会调用C库中的一些函数。

如果用户的程序中,没有明确指明使用自己定义的启动代码,那么链接器将自动使用C库和C编译器中提供的启动代码。链接器将函数"_start"作为ELF文件的默认入口函数。函数_start的相关代码如下:



_start函数先作了一些初始化,接着就是调用__libc_start_main压栈参数,包括程序进入main函数之前的初始化函数__libc_csu_init、退出时可能执行的善后函数__libc_csu_fini以及main函数的参数,最后调用__libc_start_main。



进入函数__libc_start_main后,将调用函数__libc_csu_init等初始化函数进行各种初始化操作、准备程序运行环境,最后才进入我们熟知的main函数。

函数_start包含在启动文件crt1.o中。根据启动文件crt1.o的符号表也可看出这一点。



通过前面的简要分析,我们直观地感受到了所谓“启动代码”的意义。函数_start才是第一个从"hosted  environment"进入到应用程序时运行的第一个函数,是名副其实的入口函数。从系统的角度看,main函数与普通函数无异,并不是什么真正的入口函数,main只是程序员的入口函数。因此,通过更改启动代码,这个程序员的入口函数也完全可以使用其他的函数名称而不是什么main,比如MFC中就不用main这个名字。

在链接时,gcc使用内置的spec文件来控制链接的启动文件。编译时,可以通过给gcc传递参数-specs=file来覆盖gcc内置的spec文件。我们可以传递参数-dumpspec来查看gcc内置的spec文件规定链接时链接哪些启动文件:



当然,编译时也可以根据实际情况传递参数如-nostartfiles、-nostdlib、-ffreestanding等给链接器,告诉链接器不要链接系统中提供的启动代码,而是使用自己程序中提供的。

最后,让我们以一个小例子,结束本章。回顾上面的函数__libc_start_main,在其调用main函数前,启动代码中的函数__libc_start_main将调用init函数,而_start传递给__libc_start_main的init函数指针指向的是_libc_csu_init:



根据函数可见,__libc_csu_init将先后调用段".preinit_array"、".init_array"中包含的函数指针指向的函数。因此,如果打算在程序执行main函数前或者在动态库被加载时做点什么,那么我们可以定义一个函数,并告诉链接器将函数指针存储到段".preinit_array"或".init_array"中。示例代码如下:



我们通过关键字"__attribute__((section(".init_array")))"指定链接器将函数myinit的地址放置到段".init_array"中,那么在库libfoo被加载时,函数myinit会被__libc_csu_init调用。

使用如下命令编译并运行程序:



根据程序bar的输出可见,函数myinit在进入函数main之前就被调用了