嵌入式工程师的 GCC
GCC,即 GNU 编译器套件,是几乎每位嵌入式工程师都会使用的工具,即使是那些不以 Linux 为目标的工程师也是如此。自 1987 年发布以来,GCC 支持人类已知的每种处理器,是软件工程领域的巨擘。由于其普及性和易用性,GCC 并未获得应有的赞誉。
在嵌入式项目中,GCC 还能出色地完成另一项技巧:交叉编译,而且毫无怨言。只需调用编译器,正确的事情就会发生。在底层,GCC 是一个非常复杂的工具,有很多旋钮可以调整,以微调编译和链接过程;本文着眼于如何构建 GCC 交叉编译器,检查 GCC 用于编译程序的过程,并分享一些提高生产力的技巧和窍门。
在启动嵌入式项目时,需要的第一个工具是交叉编译器,这是一种编译器,它生成旨在在与代码生成发生的机器不同的机器上工作的代码。有时,可以获得预构建的交叉编译器(来自商业或非商业来源),从而无需从源代码构建;但是,有些项目要求所有工具都必须可从源代码重新创建。无论出于何种原因需要构建 GCC,都有几种不同的方法来构建交叉编译器。
最简单的方法可能是使用 crosstool 项目,该项目由 Dan Kegel 创建并托管在 www.kegel.com/crosstool。使用此项目涉及下载源代码并制作预先提供的文件之一,将正确的参数馈送到构建编译器的脚本中。支持的平台和软件版本矩阵可以在 www.kegel.com/crosstool/crosstool-0.43/buildlogs 中找到,选择标记为工作的内容将在几个小时内生成编译器。crosstool 将下载正确的软件,甚至是补丁,以使软件在目标平台上工作。但是,如果项目需要支持备用 C 库,则 crosstool 将变得更加难以使用。
由于许多开发人员希望使用 uClibc(C 库的较小实现),因此幸运的是,该项目具有类似于 crosstool 的东西,称为 buildroot,位于 buildroot.uclibc.net。作为奖励,buildroot 除了构建交叉编译器外,还可以用于基于相关的 BusyBox 项目为板构建根文件系统。用户使用类似于内核配置的过程配置 buildroot 运行,以准备构建。此项目没有像 crosstool 那样的已知工作配置图表,因此找到工作配置可能很困难。
最后,对于不喜欢在事情不顺利时费力研究别人的构建脚本的人来说,手动构建交叉编译器并不像人们预期的那样令人生畏。以下步骤概述了该过程,其中 $TARGET 是目标处理器,$INSTALLAT 是编译器构建后将驻留的目录
1. 下载并构建 binutils
$ tar xzf binutils-<version>.tar.gz $./binutils-<version>/configure --target=$TARGET --prefix=$INSTALLAT $ make ; make install
2. 将板内核中的 include 和 asm 复制到安装目录
$ mkdir $INSTALLAT/include $ cp -rvL $KERNEL/include/linux $KERNEL/include/asm $INSTALLAT/include
3. 下载并构建引导 GCC。此时,最好在自己的目录中构建引导 GCC,而不是在其解压缩的目录中
$ tar xzf gcc-<version>.tar.gz $ mkdir ~/$TARGET-gcc ; cd ~/$TARGET-gcc $../gcc-<version>/configure --target=$TARGET --prefix=$INSTALLAT --with-headers=$INSTALLAT/include --enable-languages="c" -Dinhibit_libc $ make all ; make install
4. 使用引导编译器下载并构建 glibc(或备用 libc)。与 GCC 一样,库的构建在源代码树外部配置和 make 时效果最佳
$ tar xzf glibc-<version> --target=$TARGET --prefix=$INSTALLAT --enable-add-ons --disable-sanity-checks $ CC=$INSTALLAT/bin/$TARGET-gcc make $ CC=$INSTALLAT/bin/$TARGET-gcc make install
5. 构建最终 GCC。引导编译器是为了构建 C 库而构建的。现在,可以构建 GCC 以在使用交叉编译的 C 库时构建自己的程序
$ cd ~/$TARGET-gcc $../gcc-<version>/configure --target=$TARGET --prefix=$INSTALLAT --with-headers=$INSTALLAT/include --enable-languages="c" $ make all ; make install
在此过程结束时,新构建的交叉编译器将位于 $INSTALLAT/bin 中。对于那些需要特殊配置的 GCC 的人来说,一种常用的策略是使用 crosstool 或 buildroot 下载和修补源文件,然后中断该过程。此时,用户应用其他补丁并使用所需的配置设置构建组件。
在离开本节之前,嵌入式工程师经常会问一个问题,即以 Pentium 机器为目标,并在本质上相同的台式机上进行开发。在这种情况下,交叉编译器是必要的吗?答案是肯定的。为此配置构建交叉编译器可以将构建环境和库依赖项与用于构建源代码的开发机器隔离开来。由于台式机系统每年可能会更改多个版本,并且并非所有团队成员都可能使用相同的版本,因此拥有用于编译嵌入式项目的一致环境对于消除与构建配置相关的缺陷的可能性至关重要。
编译和链接应用程序所需的程序集合称为工具链,而编译器 GCC 只是其中一部分。完整的工具链由三个独立的部分组成:binutils、特定于语言的标准库和编译器。值得注意的是,调试器不在其中,调试器通常与工具链一起提供,但不是必要的组件。
binutils(二进制实用程序)执行以适合目标机器的方式操作文件的繁重工作。工具链的关键部分(例如链接器和汇编器)驻留在 binutils 项目中,而不是 GCC 项目的一部分。
隐藏在 binutils 项目内部的是另一个巧妙的软件,BFD 库,从技术上讲,它是一个单独的项目。BFD,二进制描述符库(实际首字母缩写展开后太粗俗,不适合本出版物),为对象文件提供了一个抽象、一致的接口,例如处理地址重定位、符号转换和字节顺序等细节。由于 BFD 提供的功能,大多数需要读取或操作目标二进制文件的工具都驻留在 binutils 项目中,以便最好地利用 BFD 提供的功能。
记录在案,binutils 包含以下程序
addr2line:给定一个带有调试信息的二进制文件和一个地址,返回该地址的行和文件。
ar:一个用于创建代码存档的程序,代码存档是对象文件的集合。
c++filt:反混淆符号。对于类和重载,链接器不能依赖底层语言来提供唯一的符号名称。c++filt 会将 _ZN5pointC1ERKS_ 转换为可读的内容。调试时的恩赐。
gprof:根据在启用分析的情况下运行代码时收集的数据生成报告。
nlmconv:将对象文件转换为 Netware 可加载模块 (NLM)。如果您曾经使用过 NLM,您可能会竖起衣领,并在终端上看到 ABEND 时畏缩。这里提到它是因为 nlmconv 很少(即使曾经)与工具链一起分发。
nm:给定一个对象文件,列出符号,例如公共部分中的符号。
objcopy:将文件从一种格式转换为另一种格式,在嵌入式文件中用于从 ELF 二进制文件生成 S-Records。
objdump/readelf:读取并打印二进制文件中的信息。readelf 执行相同的功能;但是,它只能处理 ELF 格式的文件。
ranlib:ar 的补充。生成存档中公共符号的索引以加速链接时间。用户可以通过使用 ar -s 获得相同的效果。
size:打印二进制文件的各个组件的大小。
strings:从二进制文件中提取字符串,执行正确的目标主机字节顺序转换。它通常被用作懒人查看二进制文件链接到哪些库的方式,因为 ldd 不适用于交叉编译的程序strings <binary> | grep lib.
strip:从文件中删除符号或节,通常是调试信息。
C 语言规范仅包含 32 个关键字,或多或少,具体取决于编译器对语言的实现。与 C 类似,大多数语言都有标准库的概念,该标准库提供常见的操作,例如字符串操作,以及文件系统和内存的接口。C 中发生的大部分编程都涉及与 C 库的交互。因此,项目中大部分代码不是由工程师编写的,而是由标准库提供的。选择一个设计为小的标准库会对项目的最终大小产生重大影响。
大多数嵌入式工程师选择使用 C 库而不是标准 GNU C 库(也称为 glibc)来节省资源。glibc 专为可移植性和兼容性而设计,因此,它包含针对嵌入式系统上未遇到或可以牺牲的情况的代码。一个例子是库版本之间缺乏二进制兼容性。尽管 glibc 很少在发布后破坏接口,但嵌入式标准库会毫不犹豫地这样做。
表 1 概述了最常用的 C 库,以及每个库的优缺点。
这些组件仅执行生成可执行文件所需的一小部分工作。预处理器(对于支持这种概念的语言)在编译器本身之前运行,在编译器将输入转换为目标机器的机器代码之前执行文本转换。在编译过程中,编译器执行用户指定的优化并生成解析树。解析树被转换为汇编代码,汇编器使用该输入来制作对象文件。如果用户想要生成可执行二进制文件,则对象文件将传递给链接器以生成可执行文件。
在查看了工具链中的所有组件之后,以下部分逐步介绍了 GCC 在将 C 源文件编译为二进制文件时所采取的过程。该过程首先调用 GCC,其中包含要编译的文件和一个参数,该参数指定要存储到 thebinary 的输出
armv5l-linux-gcc file1.c file2.c -o thebinary
GCC 实际上是一个驱动程序,它调用底层编译器和 binutils 来生成最终可执行文件。通过查看输入文件的扩展名并使用内置于编译器的规则,GCC 确定要运行哪些程序以及以什么顺序构建输出。要查看编译文件时会发生什么,请添加 -### 参数
armv5l-linux-gcc -### file1.c file2.c -o thebinary
这会在控制台上产生大量的虚拟输出。为了使此示例更具可读性,大部分输出已被裁剪,从而节省了无数的虚拟树。出现的第一个信息描述了编译器的版本以及它是如何构建的——当被询问“GCC 是否在禁用 thumb-interworking 的情况下构建?”时,这是非常重要的信息
Target: armv5l-linux Configured with: <the contents of a autoconf command line> Thread model: posix gcc version 4.1.0 20060304 (TimeSys 4.1.0-3)
在输出工具的状态后,编译过程开始。每个源文件都使用 cc1 编译器进行编译,cc1 编译器是目标架构的“真正的”编译器。编译 GCC 时,它被配置为将某些参数传递给 cc1
"/opt/timesys/toolchains/armv5l-linux/libexec/gcc/ ↪armv5l-linux/4.1.0/cc1.exe" "-quiet" "file1.c" ↪"-quiet" "-dumpbase" "file1.c" "-mcpu=xscale" ↪"-mfloat-abi=soft" "-auxbase" "file1" "-o" ↪"/cygdrive/c/DOCUME~1/GENESA~1.TIM/LOCALS~1/Temp/ccC39DVR.s"
现在汇编器接管并将文件转换为目标代码
"/opt/timesys/toolchains/armv5l-linux/lib/gcc/ ↪armv5l-linux/4.1.0/../../../../armv5l-linux/bin/as.exe" ↪"-mcpu=xscale" "-mfloat-abi=soft" "-o" ↪"/cygdrive/c/DOCUME~1/GENESA~1.TIM/LOCALS~1/Temp/ccm4aB3B.o" ↪"/cygdrive/c/DOCUME~1/GENESA~1.TIM/LOCALS~1/Temp/ccC39DVR.s"
命令行上的下一个文件 file2.c 也会发生同样的事情。命令行与 file1.c 的命令行相同,但输入和输出文件名不同。
编译后,collect2 执行链接步骤并查找在程序的“main”部分之前调用的初始化函数(称为构造函数,但不是面向对象意义上的)。collect2 将这些函数收集在一起,创建一个临时源文件,编译该文件并将其链接到程序的其余部分
"/opt/timesys/toolchains/armv5l-linux/libexec/gcc/ ↪armv5l-linux/4.1.0/collect2.exe" "--eh-frame-hdr" ↪"-dynamic-linker" "/lib/ld-linux.so.2" "-X" "-m" ↪"armelf_linux" "-p" "-o" "binary" "/opt/timesys/ ↪toolchains/armv5l-linux/lib/gcc/armv5l-linux/ ↪4.1.0/../../../../armv5l-linux/lib/crt1.o" ↪"/opt/timesys/toolchains/armv5l-linux/lib/gcc/ ↪armv5l-linux/4.1.0/../../../../armv5l-linux/lib/crti.o" ↪"/opt/timesys/toolchains/armv5l-linux/lib/gcc/ ↪armv5l-linux/4.1.0/crtbegin.o" ↪"-L/opt/timesys/toolchains/armv5l-linux/lib/ ↪gcc/armv5l-linux/4.1.0" "-L/opt/timesys/ ↪toolchains/armv5l-linux/lib/gcc/armv5l-linux/ ↪4.1.0/../../../../armv5l-linux/lib" ↪"/cygdrive/c/DOCUME~1/GENESA~1.TIM/LOCALS~1/ ↪Temp/ccm4aB3B.o" "/cygdrive/c/DOCUME~1/ ↪GENESA~1.TIM/LOCALS~1/Temp/cc60Td3s.o" ↪"-lgcc" "--as-needed" "-lgcc_s" "--no-as-needed" ↪"-lc" "-lgcc" "--as-needed" "-lgcc_s" "--no-as-needed" ↪"/opt/timesys/toolchains/armv5l-linux/lib/ ↪gcc/armv5l-linux/4.1.0/crtend.o" "/opt/timesys/ ↪toolchains/armv5l-linux/lib/gcc/armv5l-linux/ ↪4.1.0/../../../../armv5l-linux/lib/crtn.o"
这里还有一些值得指出的巧妙之处
1. 这是指定在目标平台上运行程序时要调用的动态链接器的选项
"-dynamic-linker" "/lib/ld-linux.so.2"
在 Linux 平台上,动态链接的程序实际上是通过运行动态加载器来加载的,使自己成为链接器的参数,链接器负责将库加载到内存中并修复引用。如果此程序不在目标机器上的同一位置,则程序将无法运行,并显示“无法执行程序”错误消息。目标上的链接器错位至少会困住每个嵌入式开发人员一次。
2. 这些文件包含程序员入口点(通常是 main,但您也可以更改它)之前的代码,并处理全局变量的初始化、打开标准文件句柄、制作漂亮的参数数组和其他内务处理函数
crtbegin.o
crt1.o
crti.o
3. 同样,这些文件包含上次返回后的代码,例如关闭文件和其他内务处理工作。与之前的项目一样,这些文件在 GCC 构建期间进行交叉编译
crtend.o
crtn.o
就是这样!在此过程结束时,输出是一个程序,可以准备在目标平台上执行。
回想一下,GCC 是一个驱动程序,它知道要调用哪个程序来构建特定的输出,这引出了一个问题,“它是如何知道的?”内置于 GCC 中的此信息保存在“specs”中。要查看 specs,请使用 -dumpspecs 参数运行 GCC
armv5l-linux-gcc -dumpspecs
控制台将填充数百行输出。spec 文件格式经过多年的发展,计算机比人更容易阅读。每一行都包含有关给定工具要使用哪些参数的说明。从之前的示例中,考虑汇编器的命令行(为提高可读性而删除了路径名)
"<path>/as.exe" "-mcpu=xscale" "-mfloat-abi=soft" ↪"-o" "<temppath>/ccm4aB3B.o" "<temppath>/ccC39DVR.s"
编译器在汇编器的 specs 中具有以下内容
*asm: %{mbig-endian:-EB} %{mlittle-endian:-EL} %{mcpu=*:-mcpu=%*} ↪%{march=*:-march=%*} %{mapcs-*:-mapcs-%*} ↪%(subtarget_asm_float_spec) ↪%{mthumb-interwork:-mthumb-interwork} ↪%{msoft-float:-mfloat-abi=soft} ↪%{mhard-float:-mfloat-abi=hard} %{mfloat-abi=*} ↪%{mfpu=*} %(subtarget_extra_asm_spec)
此行使用了一些下面解释的熟悉结构。充分讨论 spec 文件的细节将需要一系列文章本身。
*asm:此行告诉 GCC 以下行将覆盖 asm 工具的内部规范。
%{mbig-endian:-EB}:模式 %{symbol:parameter} 表示如果符号传递给 GCC,则将其替换为 parameter;否则,这会扩展为空字符串。在我们的示例中,参数 -mfloat-abi=soft 是这样添加的。
%(subtarget_extra_asm_spec):评估 spec 字符串 %(specname)。这可能会产生一个空字符串,就像我们的情况一样。
大多数用户不需要修改其编译器的 spec 文件;但是,经常继承项目的工程师需要让 GCC 识别文件的非标准扩展名。例如,汇编器源文件的扩展名可能是 .arm;在这种情况下,GCC 将不知道要执行什么,因为它没有该文件扩展名的规则。在这种情况下,您可以创建一个包含以下内容的 spec 文件
.arm: @asm
以下技巧和窍门应该(如果尚未)存储在与 GCC 合作的工程师的备忘单上。
强制 GCC 使用备用 C 库
armv5l-linux-gcc -nostdlib -nostdinc -isystem ↪<path to header files> -L<path to c library> ↪-l <c library file>
这告诉 GCC 忽略它所知道的关于在哪里查找头文件和库的所有内容,而是使用您告诉它的内容。大多数备用 C 库都提供一个执行此功能的脚本;但是,某些项目不能使用包装器脚本,而在其他时候,当试验库的多个版本时,直接指定此信息的灵活性和控制是必要的。
混合汇编器/源代码输出
armv5l-linux-gcc -g program.c -o binary-program armv5l-linux-objdump -S binary-program
这是查看 GCC 针对输入代码生成的确切内容的最佳方式。使用几种不同的优化设置进行编译可以显示编译器针对给定优化所做的工作。由于嵌入式开发推动了处理器支持的范围,因此能够查看生成的汇编器代码有助于证明 GCC 对该处理器的支持存在缺陷。此外,工程师可以使用它来验证在指定特定于处理器的优化时是否生成了正确的指令。
列出预定义的宏
armv5l-linux-gcc -E -dM - < /dev/null
对于进行移植来说,这是一个非常宝贵的工具,它可以清楚地了解将自动设置哪些 GCC 宏以及它们的值。这将不仅显示标准宏,还会显示为目标架构设置的所有宏。保留此输出并将其与较新版本的 GCC 进行比较,可以在代码由于更改而无法编译或运行时节省数小时的工作时间。
列出依赖项
armv5l-linux-gcc -M program.c
形式上,此命令为命令行上的每个文件创建一个单独的 make 规则,显示所有依赖项。当尝试跟踪与源文件正在使用的头文件相关的问题以及跟踪与强制 GCC 使用备用 C 库相关的问题时,输出是必不可少的。深度嵌套的头文件在任何重要的 C 项目中都是不可避免的且非常有用,并且在尝试调试时可能会耗费数小时。使用 -MM 而不是 -M 将仅显示非系统依赖项——当问题仅存在于项目文件中时,这可以减少有用的噪音。
显示内部步骤
armv5l-linux-gcc -### program.c
本文已使用此命令使 GCC 显示内部发生哪些步骤来构建程序。当程序未正确编译或链接时,使用 -### 是查看 GCC 正在执行操作的最快途径。每个命令都位于其自己的行上,并且可以单独运行,因此
armv5l-linux-gcc -### program.c &> compile-commands
GCC 是一种表面上强大而复杂的工具。开发人员创建了软件,该软件以最少的用户信息“做正确的事情”。由于它运行良好,用户经常忘记花时间学习 GCC 的功能。本文只是触及了表面;最好的建议是阅读文档并每天投入少量时间来学习此工具始终可以做超出预期的更多事情。
资源
uClibc,GNU C 库的替代品,针对大小进行了优化:www.uclibc.org。
dietlibc,GNU C 的另一个替代品,该组中最小的:www.fefe.de/dietlibc。
NewLib,Red Hat 支持的最小 C 库项目:sourceware.org/newlib。
GCC Internals——关于 GCC 的内部结构和构造的信息;它写得非常好,对于那些好奇 GCC 如何工作的人来说是一个很好的指南:gcc.gnu.org/onlinedocs/gccint。
binutils——特定于架构的工具,可为开发铺平道路:www.gnu.org/software/binutils。
info gcc,从您的命令行提供关于 GCC 与最终用户相关的方面的深入信息。
crosstool,一种用于构建 GCC 交叉编译器的工具,现在是执行此操作的规范方法,非常易于使用:www.uclibc.org。
GCC 权威指南,作者 Bill von Hagen——一本涵盖如何使用 GCC 各个方面的好书。
Gene Sally 在过去的七年中一直从事嵌入式 Linux 的各个方面的工作,并且是 LinuxLink Radio 的联合主持人,LinuxLink Radio 是最受欢迎的嵌入式 Linux 播客。可以通过 gene.sally@timesys.com 联系 Gene。