03 · 目标三重奏:host、target 与 freestanding
两幕现场您八成都见过。头一幕的教程开口闭口都是“装交叉工具链”,满天飞的都是 host、target、freestanding,却没有人给您定义,您照着抄是能跑的,却不知道自己复制的是什么。另一幕就更疼了:您兴冲冲想试试裸机,拿咱们手头的 hello.cpp 一编,crt、_start 一串名字糊了一脸,每个字单看都是认识的,连起来却就是看不懂的。
往后您再听到有人甩“交叉编译”“裸机编译”,就不用点头装懂了:这一章咱们把 host、target、freestanding 的门牌都摸一遍,其中一个的开关,咱们还要真的扳一回。
host:编译器自己住在哪
host 说的是编译器住的地方。在咱们的机器上,g++ 就住在 x86_64 的 Linux 里,它的默认世界观也由此而来:您敲 g++,它默认编出来的东西就跟自己住隔壁。隔壁是什么样呢?同样的系统、同一套 libc、同样有人负责加载。这个默认咱们可以让它自己报出来:
g++ -dumpmachinex86_64-pc-linux-gnu这就是它的名片,读出来是:什么架构、哪家打的包、什么系统、哪套 C 库的约定。厂商字段里的 pc 如今只是历史留下的一道印子,您不用管它。名片上写的,就是它对“给谁编”的默认回答。
target:这趟是给谁编
target 说的是产物要去住的机器。-dumpmachine 报出来的,正是默认的 target:跟 host 是一模一样的。像这样“编给自己隔壁”的活,行话里的名字叫 native,咱们前两章的全部实验,走的都是 native。
那 cross(交叉)呢?等 target 和 host 换成了两台机器,它就来了:您在 x86_64 的电脑上,给一块 ARM 的板子编代码,指令集、字节序、调用约定、libc 的讲究,就可能全都对不上了。很多教程一上来就给咱们开方子,让咱们装一套交叉编译器,防的是把宿主的老习惯,带给一个不认它的目标。担忧在它的场景里是真的。
咱们的取舍也要讲清楚:主线咱们用系统编译器加开关。理由其实只有一条:咱们最终的 target 跟 host 同为 x86_64,指令集也是同一套的,真正要挡的只剩一条默认假设:“底下有操作系统”。挡它的开关,就是接下来出场的第三个词。这里头是没有优劣的:真到了给别的架构编内核的那天,系统编译器就送不过去了,另装一套就成了正路。
freestanding:底下没有操作系统
一对词管的是两种世界。hosted 的世界里有人铺路:加载器把程序都摆得好好的,libc 守在自己的原地,负责迎接 main 的是 crt 文件。freestanding 的世界没底:下面是没有操作系统的,咱们什么都得靠自己。
这件事居然有个可以直接观测的开关。-dM -E 是让预处理把内置的宏都吐出来,咱们拿它看两个世界:
g++ -dM -E -x c++ /dev/null | grep -w __STDC_HOSTED__
g++ -ffreestanding -dM -E -x c++ /dev/null | grep -w __STDC_HOSTED__#define __STDC_HOSTED__ 1
#define __STDC_HOSTED__ 0咱们就这么轻轻一翻,1 真的变成了 0。这一下的分量可不轻:1 读的是“底下有人”,0 读的是“底下没底”,翻动世界的,正是 -ffreestanding 的开关。它改的是编译期的假设:编译器从此就不再假设标准库齐全了,也不再理所当然地拿 main 当中心。至于链接的那一侧,它是一个字都不管的。
链接那一侧的开关就是 -nostdlib。它一挥手的工夫,两样东西就下了岗:一样是 crt 的启动文件,入口符号 _start 从此没了提供的人。另一样是标准库的一家,往后 puts 再喊也没人应了。咱们不空口说,拿咱们的 hello.cpp 真试一枪:
g++ -nostdlib hello.cpp -o hello_nostdlib/usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 0000000000001020
/usr/bin/ld: /tmp/cci1H9RP.o: in function `main':
hello.cpp:(.text+0xf): undefined reference to `puts'
collect2: error: ld returned 1 exit status咱们不修它,咱们读它。头一行的 warning,报的是头一样下岗的后果:入口没了人。第二行的 undefined reference,您在上一章报错清单的最后一桩里见过,它报的是另一样下岗的后果,标准库没了人。第三行出来报信的还是 collect2,老熟人了。同一份 hello.cpp 的命却是两样的:正常编出来的那个 hello 活蹦乱跳,添了 -nostdlib 的那个,就倒在了链接这道,被关掉的两样,就是全部的差别了。
常见的误会是把 -ffreestanding 当成连编译都过不去的拦路虎,咱们拿它真试一枪:
g++ -ffreestanding -c hello.cpp -o fs.o
file fs.ofs.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped过了,而且交回来的还是咱们熟悉的那个半成品。所以 -ffreestanding 挡不倒编译这道工序:翻译代码这件事本来就不需要底下有什么,这一枪里倒下的又是链接那一道。上一章的四道工序图,在这里正好又用上了一回。
裸地上,三样东西没人递
咱们现在回头看 -nostdlib 报错的头一行,它其实剧透了裸机世界的头一条真相:链接器是按名字找入口的。在正常的世界里,main 干的是配角的活:真正的入口,是一个叫 _start 的符号,它的来历,是 crt 塞进来的。咱们拿装机那天编出来的 hello 验货:
nm hello | grep -E '(_start|main)'
readelf -h hello | grep 'Entry point'0000000000004018 B __bss_start
0000000000004008 D __data_start
0000000000004008 W data_start
w __gmon_start__
U __libc_start_main@GLIBC_2.34
0000000000001139 T main
0000000000001040 T _start
Entry point address: 0x1040您对一下地址:程序的入口是 0x1040,那里面住着的就是 _start。清单里的另外几位,是 crt 和 libc 留下的路标,咱们眼下不用逐个点名。main 住的地方是 0x1139,它只是一个晚点被叫到的普通函数。_start 干的活,是替 main 铺路的:摆栈、请 libc 初始化、请 main 出场,全都归它包办的。头一样没人递的,就是入口的铺路活——裸地上是没有 crt 的,铺路的活,就得咱们自己来接了,入口叫什么名字、住在哪个地址,将来是链接脚本说了算的,那是下一篇的正题。
第二样没人递的,是整个的运行时:栈谁来摆、全局对象的构造谁来调,正常世界里全是别人替您干完了的。后果是摸得着的:裸地上开机的瞬间,栈指针里是个没保证的值,头一条 call 就可能踩了空。全局对象的下场还要更惨,它们开局就是一块没被构造过的字节,构造函数是从头到尾都没人去调的。咱们想接手,就得自己顶到入口的位置上亲手把这一切铺开。
第三样是最直观的:没有 printf。您想在屏幕上开个口,那个口的一砖一瓦,都得咱们自己砌。
债:什么时候真的要另装一套
什么时候系统编译器加开关也到不了呢?目标机的指令集跟宿主不同的时候,想换一套 libc 的时候,还有工具链需要跟着内核独立发布的时候。真到了那一步,另装一套交叉工具链才是正经的路子。-dumpmachine 背后那套机器选择的细节、specs 的深水区,咱们不展开。构建系统怎么把多个目标组织起来的事,正文里搭构建的章节会讲,咱们折进债里。
三个词落位
-nostdlib 那一枪打出来的东西,咱们摆出来:编译这道是照旧能过的,倒下的是链接。在报告上签字的就是三个名字——host 说的是谁在编,target 说的是编给谁,freestanding 说的是底下有没有人。三个名字咱们都摸过门牌,真翻过开关的只有 freestanding 一个,1 变 0 的那一下,就是全部的边界。
-nostdlib 那一枪还留了一个没答的问题:编出来的东西放在哪、从哪里起跑?裸机上没有加载器替您摆,这一问的答案要咱们自己写,答案就写在下一篇的链接脚本里。至于在裸地上把 C++ 活下去的门道,那是 C++ 那一卷的活。