从LFS构建中习得的交叉编译

从LFS构建中习得的交叉编译

在2024年寒假的过程中,我构建了LFS(Linux From Scratch),其中有介绍了最基本的GNU/Linux环境的编译构建,这些的第一步即是gcc的交叉编译链的搭建,其对于开发者的重要性毋庸置疑

前言

该blog只适用于基于autoconf的构建系统,autoconf是GNU构建系统的一部分,与Automake、Libtool等工具一起被广泛用于自动化软件的构建和配置。

简单介绍交叉编译

和我们平时将本机的源代码通过编译器生成跑在本机上的可执行文件的编译器不同,交叉编译就是在一个平台上生成另一个平台上的可执行代码。例如在x86_64的windows平台利用源代码编译源代码生成arm的linux平台下的可执行文件,这种行为称之为交叉编译,而实现这种编译过程的特殊编译器成为交叉编译器(Cross Complier),与之相对我们之前常用的即是本地编译器

相关术语

对于交叉编译器有一些常用的术语

  • build: 指构建程序时使用的机器。
  • host: 指将来会运行被构建的程序的机器。
  • target: 编译器为这台机器产生代码。

对于”在x86_64的windows平台利用源代码编译源代码生成arm的linux平台下的可执行文件”这个例子来说,build为x86_64的windows,host为arm的linux,target为arm的linux。具体来说,我们需要这个最终可执行文件的源代码,以及一个交叉编译器,这个交叉编译器是运行在x86_64的,OS为windows机子上的一个可执行程序,它的作用是将这份源代码编译为在arm的linux运行的可执行程序

而本地编译器就是build,host和target三个都是相同的,是你本机这个平台的一个特例

三元组

我们上面用了一种比较口语化的说法,就是我们将这类平台称为如x86_64的windows,其实平台专业的可以用三元组去描述(基于autoconf的构建系统),形如CPU-供应商-内核-操作系统,由于供应商字段通常无关紧要,autoconf允许省略它(事实上根据我的使用相当程度上是随意取名的)。
例如x86_64-w64-mingw32-gcc即指target为x86_64的windows的编译器

上面术语与编译器的关系

搜索gcc官网,进入其中的Installation部分,选择Configuration,会看到这一步构建有相当多的参数,其中有一个部分Host, Build and Target specification具体阐释了上方所指的三个术语。没错,在gcc自身被编译之时你就可以选择这三个编译参数,就能得到相对应的编译器,而gcc几乎对现代所有的平台都进行了适配

NOTE: 这里不对GNU构建系统进行进一步的阐释,只是声明可以从gcc的源代码构建出其支持的交叉编译的平台

当然gcc只是交叉编译工具链的其中一个组成部分,其他的工具也有这样的参数,例如binutils(提供了汇编器和链接器等)

注意这里这三个参数实际上的行为,./configure脚本会根据--build--host这两个参数去决定使用什么编译器,具体来说,当--build--host相同时,为本地编译模式,使用本地编译器编译,而当--build--host不同时,则进入交叉编译模式,此时将调用--host所指向的那个编译器

--target只在gcc这种特定的源码的configure里有实际意义,它即规定编译之后的gcc的平台是什么

也就是说对于普通软件,这几个参数决定了编译出的结果是跑在哪上的,而对于gcc这类,不仅决定了跑在什么平台,还决定了用它编译的结果跑在哪上,总结下来只有这俩属性

构建过程

先来看看下面这张表格(来源于lfs中的工具链技术说明)

阶段 Build Host Target 操作描述
1 A A B 在平台A上,使用ccA构建交叉编译器cc1
2 A B B 在平台A上,使用cc1构建编译器ccB
3 B B B 在平台B上,使用ccB重新构建并测试其本身

我们来看这个过程,首先这里的A,B均指一个三元组所描述的一个平台
首先阐释一下这个表所描述的过程,我们假设平台B是一个没有编译器的平台,而A是一个有本地编译器ccA,我们的目的是让这个平台B具有编译代码的能力,也就是让它获得本地编译器ccB

第一步

看看我们做了什么,首先我们在第一步,通过使用ccA编译交叉工具链(包括gcc,binutils)的源码,通过设定上文提到的gcc构建时的参数,要求其生成参数build为A,host为A,target为B的交叉编译工具,我们将其称之为cc1,推导下来它被A上本地编译器编译,跑在A上,为B生成代码

第二步

那么我们在第二步再次利用cc1编译交叉工具链,规定configure参数build为A,host为B,target为B,就将调用这时候我们得到了ccB,推导下来它被A上(给B生成代码的)交叉编译器(即cc1)编译,跑在B上,为B生成代码,这就是B上的本地编译器

第三步

在第三步再次利用ccB编译gcc,规定configure参数build为B,host为B,target为B,就将调用这时候我们得到了ccB,推导下来它被B上本地编译器(即ccB)编译,跑在B上,为B生成代码,这就是最终验证的B上的本地编译器

更多

现在,关于交叉编译,还有更多要处理的问题:C语言并不仅仅是一个编译器;它还规定了一个标准库。在本书中,我们使用GNU C运行库,即glibc(除此之外,还有名为 “musl” 的另一种C运行库实现)。它必须为lfs目标机器使用交叉编译器cc1编译。但是,编译器本身使用一个库,实现汇编指令集并不支持的一些复杂指令。这个内部库称为libgcc,它必须链接到 glibc 库才能实现完整功能。另外,C++标准库(libstdc++)也必须链接到glibc。为了解决这个“先有鸡还是先有蛋”的问题,只能先构建一个降级的cc1,它的libgcc缺失线程和异常等功能,再用这个降级的编译器构建glibc(这不会导致glibc缺失功能),再构建libstdc++。但是这种方法构建的libstdc++会缺失一些依赖于libgcc的功能。

可以看到这是一种类似于自举(bootstrap)的过程,这些功能的实现也是如上面调整交叉编译工具链的configure参数实现的

实践

LFS

关于这一部分这里略过,lfs的手册中相关的命令都详细描述了其中的过程,而且lfs的构建的三元组理论上来说几乎是相同相容的(因为就是在本机已有的系统上新建一个全新的系统),我当时使用的宿主系统是Debian,最后我实践得到了一个真正的最小操作系统

Linux -> Windows (x86_64)

由于windows下从源码编译交叉工具链必然遭遇极其麻烦的库,链接等问题,我们略过编译构建的部分,直接使用已有的二进制gcc

1
2
3
# archlinux:
$ sudo pacman -S mingw-w64-toolchain
$ x86_64-w64-mingw32-gcc -o hello.exe hello.c

成功获得hello.exe,传到windows中,正常运行!

对于Raspberry Pi这样的也是一致的,我们使用如下的命令,可以得到运行在树莓派上的程序(需要非常注意依赖的版本问题,如glibc,相对来说更加推荐在容器中进行编译)

1
2
3
# archlinux:
$ paru arm-none-linux-gnu-gcc
$ arm-none-linux-gnu-gcc -o hello hello.c

Linux(x86_64) -> Stm32

针对这个部分我们略微多提一点,相对来说这个比较特殊,在我周围的嵌入式开发者使用两种工具,一是使用keil/stm32cubeIDE这类闭源的集成开发环境;二是使用arm-none-eabi-gcc。而作为一个开源爱好者,我们更希望使用gcc这样的开源编译器。而且最好了解arm-none-eabi-gcc这个工具的构建过程(当然你也可以像上面那样直接包管理下载交叉编译器)

一开始考虑这个问题的时候,我遇到了不小的障碍,原因是arm-none-eabi-gcc可以找到的官方资料是来源于arm官网而非来自于gcc官网,而写过嵌入式开发的朋友们都知道stm32的makefile里编译时有极其丰富编译参数,虽然不需要链接到标准库,不过链接到其他的库反而使得这个问题变得更加复杂

通过互联网搜索与询问学长们,我在一开始尝试去arm官网寻找答案
https://developer.arm.com/downloads/-/gnu-rm

我发现了网上给出最多次数的这个页面是被废弃的页面,而链接到的新页面确实给出了一些source code,但似乎由于其使用了特殊的构建工具(ABE),且教程的陈旧,导致了我无法进一步从源码构建

最终我在下面这个问题中找到了构建arm-none-eabi-gcc的解决方案,关于完整的探索过程我们按下不表,详情留到下一篇文档中继续讨论

https://stackoverflow.com/questions/72440601/arm-none-eabi-toolchain-compile-from-source

在StackOverflow的这个问题的最后一条回答中Peter Frost给出了脚本与使用说明

NOTE:需要按照回答中的方法更改nano.specs

紧接着就可以使用编译得到编译器编译stm32项目了,利用openocd烧录进入stm32,完美运行