Skip to content

04 · 链接脚本与裸镜像:从 .o 到 512 字节 ​

您手里现在是有 .o 了,前几章一路攒下来的。可等您再往前走一步,画风就变了:链接器默认交出来的程序,住址落在了 0x401000。而 BIOS(主板上的开机固件)那边,只肯把盘上第一个扇区的 512 个字节,原样搬进内存的 0x7c00,还要求末尾两个字节是它认的 55 aa。两边的世界对不上,机器根本就不会理咱们。

您平时感觉不到这里面的落差,因为 g++ 把链接的环节藏得很好:它自带了一套默认的地址,底下还有操作系统的加载器,捧着文件头把程序摆到该在的地方。可是到了裸机上,这两样就全没了,那地址由谁来定呢?咱们自己定,拿一份只有几行的说明书,也就是行话里说的链接脚本。至于 ELF——咱们装机那章 file 出来的半成品和成品,用的都是这个格式——怎么变成 BIOS 认的那 512 个字节,靠的是 objcopy 去抽。咱们这一篇就把这两件事,从头到尾亲手做完了。

到了收尾的时候,您自己目录里会躺着一个 512 字节的裸镜像:开头的五个字节,住址是您钉的,末尾的两个字节,55 aa 是您落的。

烤进指令里的地址,是谁烙的 ​

咱们起手来写一段不能再小的代码,它只有短短的几行,咱们却要靠它从头用到尾。汇编的读法是汇编那一卷的课,您在这里只须跟着看字节:

bash
cat > tiny.s <<'EOF'
.global _start
_start:
    mov $msg, %esi
    hlt
msg:
    .ascii "hi"
EOF
as tiny.s -o tiny.o

.global 把 _start 的名字递出了门,mov 把标号 msg 的地址装进了 %esi,剩下的 hlt,是让 CPU 原地停住的。咱们这一篇不追语法,追的是地址。

咱们不写任何的说明书,把手里的 .o 直接丢给链接器:

bash
ld tiny.o -o tiny-default
objdump -d tiny-default

链接一声没吭就过了,可您看它交回来的东西:

text
0000000000401000 <_start>:
  401000:	be 06 10 40 00       	mov    $0x401006,%esi
  401005:	f4                   	hlt

0000000000401006 <msg>:
  401006:	68                   	.byte 0x68
  401007:	69                   	.byte 0x69

咱们的源文件里,其实没写过任何一个地址,可是 mov 吐出来的五个字节里,明晃晃地烙着一个 0x401006。它就是 msg 的住址:链接器把 msg 安排到了 0x401006,连答案都替咱们算好了。x86 是小端的机器,低位的字节放在低的地址上,所以 0x00401006 在字节里躺成了 06 10 40 00。等 BIOS 真的把这段字节搬到了 0x7c00 去执行,esi 里装的还是 0x401006,咱们想读的 hi,可不是住在那儿的。

那 0x401000 又是谁挑的呢?链接器肚子里,装着一份它自带的默认脚本,ld --verbose 就能把它原原本本地吐出来,足足有上百行的篇幅。咱们要找的底数就躺在里面:

text
. = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;

0x400000 是它给的底数,SIZEOF_HEADERS 是 ELF 的头占掉的字节数——不过这笔加法是算不出 0x401000 的:头部只占首页开头的一小截,真正让代码落到 0x401000 的,是脚本接着把代码段按内存页另起了一页。您在上一篇见过的入口 0x1040,是 g++ 默认开 PIE 时给的相对住址。而这里的 0x401000,是链接器自己的默认脚本钉的。这层页对齐咱们不追,您只要知道,默认的住址咱们不认:咱们自己写一份只干一件事的脚本,把住址钉到 0x7c00 的地界上:

bash
cat > base.ld <<'EOF'
ENTRY(_start)
SECTIONS
{
    . = 0x7c00;
    .text : { *(.text) }
}
EOF
ld -T base.ld tiny.o -o tiny-7c00
objdump -d tiny-7c00

脚本里其余的语法,咱们放到下一节挨个讲,眼下您只看一件事:换了住址之后,烙进 mov 里的地址,会变成什么样呢?您不妨猜一猜,再往下看您猜得准不准。

text
0000000000007c00 <_start>:
    7c00:	be 06 7c 00 00       	mov    $0x7c06,%esi
    7c05:	f4                   	hlt

0x401006 变成了 0x7c06。咱们再把脚本里的 0x7c00 换成 0x8000,重新又链了一遍:

bash
sed 's/0x7c00/0x8000/' base.ld > base8k.ld
ld -T base8k.ld tiny.o -o tiny-8k
objdump -d tiny-8k
text
00000000008000 <_start>:
    8000:	be 06 80 00 00       	mov    $0x8006,%esi
    8005:	f4                   	hlt

(两张清单咱们都裁掉了尾巴上 msg 的整整三行:标号的一行,数据的两行,省的是版面。)您把默认链接和 0x8000 的结果摆在一起:同一条 mov 的立即数有四个字节,动了手脚的只有两个,10 换成了 80,40 归成了 00。把 0x7c00 的结果也摆进来,四个字节里还剩几处不同呢?只剩中间的一个了,7c 换成了 80。咱们的代码一个字都没重写,脚本里的一个数换了,烙进指令的地址就跟着搬了家。

这一套“链接器算出来的住址”,行话里的名字叫 VMA(virtual memory address,虚拟地址)。objdump -h 能把它和它旁边的一列一起亮给咱们看:

bash
objdump -h tiny-7c00
text
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000008  0000000000007c00  0000000000007c00  00000c00  2**0
  1 .note.gnu.property 00000030  0000000000007c08  0000000000007c08  00000c08  2**3

(表里省去了每段下面的一行属性。)VMA 是链接器认定“这段代码将来运行时住哪”的地址,而它旁边的 LMA(load memory address,加载地址),说的是“装载的人,实际会把它放进内存的哪一格”。平时它们俩总是相等的,因为操作系统的加载器会捧着 ELF 的文件头,老老实实地按 VMA 去搬。可是裸机上搬东西的是 BIOS,它不看任何的文件头,动作是固定不变的:把盘上头一个扇区的 512 字节,原样地拷到 0x7c00,而后从那里起跑。所以咱们没有加载器可以指望,只好让 VMA 主动地贴上去,把两列数字凑成了一样。表里跟在 .text 后面的 .note.gnu.property,您对它留个心眼:它还会在段表里再露一次面,等咱们抽纯字节的时候,它还会跟着溜进咱们的文件里。它们俩也是允许分家的,链接脚本里有专门的说法叫 AT(),眼下的债咱们记它一笔:哪天代码要“住得高、装得低”了,咱们再来请它出山。

最小集:位置计数器、SECTIONS 与 ENTRY ​

咱们回头把 base.ld 摊开认一认:. 是管住址的游标,SECTIONS 负责的是各段怎么排,入口的事交给 ENTRY。

. 是脚本里的一枚游标,咱们读到哪一行,它就指着“下一个东西从哪个地址起”。. = 0x7c00; 是一句赤裸裸的赋值,把游标直接钉到了 0x7c00,往后的每个段,都从游标的位置往上住。

SECTIONS 里住的是一条条的输出段,长得是 名字 : { 都收谁 } 的样子。.text : { *(.text) } 读出来就是“所有输入文件里的 .text 段,一律并进输出文件的 .text,从游标的位置起步”。* 的意思是通配符,代表的是所有的输入文件。咱们这里只有一份 tiny.o,通配的魅力,要等一个工程里有几十个 .o 的时候才显出来:谁也不用点名,一句就全收了。

ENTRY(_start) 把入口符号写进了 ELF 头的一格元数据。BIOS 是不看它的,它永远从 0x7c00 的第一个字节起跑,咱们写它,是给咱们自己和调试工具留的一个记号,想说的就是“程序从这儿开始”。真正的入口约定长什么样,等咱们到了正文里亲手去搓引导代码的时候再回来讲全。

base.ld 认完了,咱们接下来把它补完整。还差的东西有两件:512 字节的身量,和末尾的签名。补出来的 tiny.ld 长这样:

makefile
ENTRY(_start)
SECTIONS
{
    . = 0x7c00;
    .text : { *(.text) }
    . = 0x7c00 + 510;
    .sig : { SHORT(0xAA55) }
}

新添的头一处是钉到 510 的游标赋值,这回钉到了离起点 510 字节的地方。.text 收尾的时候,游标才走到了 0x7c08,咱们硬把它拽到了 0x7c00 + 510,中间空出来的地界怎么办?等会儿 objcopy 当面演给您看。

新添的另一处是 .sig,一个咱们凭空捏造的输出段。它不收任何的输入段,肚子里就留了一句 SHORT(0xAA55),后面的两个字节,代码里可是谁都没写过的,是脚本亲笔落下的。

咱们把它链上,您再验一眼:

bash
ld -T tiny.ld tiny.o -o tiny.elf
objdump -h tiny.elf
text
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000008  0000000000007c00  0000000000007c00  00000c00  2**0
  1 .note.gnu.property 00000030  0000000000007c08  0000000000007c08  00000c08  2**3
  2 .sig          00000002  0000000000007dfe  0000000000007dfe  00000dfe  2**0

上表是节选过的,咱们省掉了每段下面的一行属性。.text 落在了 0x7c00,.sig 落在了 0x7dfe,您按一下计算器,0x7c00 + 510 算出来的正是 0x7dfe,游标钉的位置分毫不差。

objcopy:把 ELF 抽成一整条纯字节 ​

到这里的 tiny.elf,还是个正经的 ELF,该有的文件头、节表,它一样也不缺的。可是 BIOS 不会去解析任何的文件头,它要的,是从 0 号字节起就能执行的纯字节。咱们把抽掉包装的活,交给了 objcopy:

bash
objcopy -O binary tiny.elf tiny.bin
wc -c tiny.bin
xxd tiny.bin | head -2
text
512 tiny.bin
00000000: be06 7c00 00f4 6869 0400 0000 2000 0000  ..|...hi.... ...
00000010: 0500 0000 474e 5500 0100 01c0 0400 0000  ....GNU.........

尺寸对了。可是 hi 的后面,怎么跟了一串 04 00 00 00 20 00 00 00…?咱们统共只写了八个字节的代码,后面这些都是谁呢?

它就是上一张表里跟在 .text 后面的 .note.gnu.property,是汇编器自作主张塞进来的一小段元数据,想告诉将来读它的加载器“咱们的代码用到了哪些指令集特性”。平时的加载器懂礼貌,绕开它就是了,倒是 objcopy -O binary 不讲这些,它的做法很朴素:凡是标了“要占内存、要装进镜像”的段,它都会按 LMA 的顺序,原原本本地铺进文件里。在 BIOS 的眼里,这 512 个字节就是它拿到的全部,咱们八个字节的代码后面,平白多了一段对裸机毫无用处的元数据。

咱们得把它拦在外面。链接脚本里有个特殊的段 /DISCARD/,送进这里的段,占不了地址,也进不了产物。咱们在 .sig 那行之后、右花括号之前补上它。这一行必须住在 SECTIONS 的大括号里,落到了外面,ld 只回您一句 syntax error:

makefile
    /DISCARD/ : { *(.note*) *(.comment) *(.eh_frame*) }

.note* 把各种备注段都收了进去,.comment 是编译器留的落款,.eh_frame 是 C++ 异常展开要用的表,而咱们的裸引导里没有异常,它们统统进不了这 512 个字节。咱们重链重抽:

bash
ld -T tiny.ld tiny.o -o tiny.elf
objcopy -O binary tiny.elf tiny.bin
xxd tiny.bin | head -1
text
00000000: be06 7c00 00f4 6869 0000 0000 0000 0000  ..|...hi........

这下咱们眼前的文件,清爽了。

验收:尺寸 512,末尾 55 aa ​

最终的验收,咱们就靠三条命令,哪一条都省不了:

bash
wc -c tiny.bin
xxd tiny.bin | tail -1
xxd -s 510 tiny.bin
text
512 tiny.bin
000001f0: 0000 0000 0000 0000 0000 0000 0000 55aa  ..............U.
000001fe: 55aa                                     U.

您从两头往中间看。文件开头的五个字节,说的就是那句 mov,里面烙着的 06 7c,正是 0x7c06 的小端躺法,它和默认链接里的 06 10 40 00 出自同一条指令,只是烤进了不同的住址。文件的第 0 个字节,就是机器将来在 0x7c00 取的第一条指令。咱们再看结尾——55 aa,正好压在了第 510、511 个字节上。齐了,该在的都在,这枚镜像成了。

结尾的两个字节,是 BIOS 认盘的凭据:它要的第 510 个字节是 0x55,跟在后面的第 511 个字节是 0xAA,差了一个都不行。咱们脚本里写的是 SHORT(0xAA55),一个 16 位的数,按小端的方式躺下,低字节的 55 自然就在前头了。您要是把它写反了会怎样呢?“链接期的四桩错”最后一桩里有真例子,咱们到那儿见。

开头和结尾之间的一大片零,也值得说一句:那是 .text 收尾和 .sig 之间空出来的地界,objcopy 用零替咱们填平了。512 个字节就是这么凑齐的。

咱们还能请链接器帮着守 512 字节,给 tiny.ld 再添上一行的断言,放在 .text 的后面:

makefile
    ASSERT(SIZEOF(.text) <= 510, "code exceeds 510 bytes")

断言说的是:代码段的身长一旦超过 510,链接就当场失败了。空口说不过瘾,咱们往 tiny.s 的末尾塞上 600 字节的填充,真的把它撑爆一次:

bash
cat > fat.s <<'EOF'
.global _start
_start:
    mov $msg, %esi
    hlt
msg:
    .ascii "hi"
.space 600
EOF
as fat.s -o fat.o
ld -T tiny.ld fat.o -o fat.elf
text
/usr/bin/ld: code exceeds 510 bytes
/usr/bin/ld: section .sig LMA [0000000000007dfe,0000000000007dff] overlaps section .text LMA [0000000000007c00,0000000000007e5f]

报得干脆极了。头一行就是咱们立的那句断言,后一行是链接器自己发现的连锁问题:600 字节的填充,把 .sig 的地盘都占了。咱们换来的这句人话,可比将来一场查不出名堂的死机便宜多了。

入口、名字与签名:链接期的四桩错 ​

下面咱们从最常见的症状排起。

咱们头一个多半会碰上的,是名叫 cannot find entry symbol 的抱怨。您要是把 .global _start 那行漏了,或者标号拼错了,链接器就会回您一句:

text
/usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 0000000000007c00

它给的只是个 warning,产物照样会交给您,所以反而更容易被放过。defaulting 后面跟着的地址,是链接器拿脚本里游标钉的住址凑出来的数。咱们既然在脚本里写了 ENTRY(_start),这句 warning 就是在提醒咱们:链接器连说好的入口都没找到。

再往后您多半会遇到 undefined reference。您 call 了一个不存在的标号,汇编器当场是不拦您的,它以为您要调别的文件里的函数,客客气气地记了下来,满心以为链接的时候会有人应答,结果世上根本没这号人物:

text
/usr/bin/ld: badcall.o: in function `_start':
(.text+0x1): undefined reference to `helper'

(.text+0x1) 把出事的位置指到了字节级,咱们顺着它回去,就能找到出问题的那一句 call。

还有一种更隐蔽的:产物倒是能出来,末尾却见不着 55 aa 了。多半是咱们把钉到 510 的那行游标赋值漏了,签名段就紧贴着代码落了户。下面这个 10 字节的结果,说的是补过 /DISCARD/ 的脚本。咱们要是还没补,前面就还会拖上一段 .note 的字节:

text
00000000: be06 7c00 00f4 6869 55aa                 ..|...hiU.

它仍旧是一份合法的裸镜像,只是 BIOS 在第 510 个字节读到的是 0。典型的情况是,它直接跳过这块盘去试别的启动设备了,一个字的解释,也不留给咱们。防它的办法笨而有效:验收的三条命令,每次都是不能省的。

最后的一种动静最小:您把签名写反了。SHORT(0x55AA) 出来的尾巴是这样的:

text
000001fe: aa55                                     .U

0xAA55 是一个 16 位的数,按小端躺下的时候,低字节应该是在前的,您把数本身写成了 0x55AA,躺下来就整对反了。BIOS 验的是字节顺序,它不猜您的本意。它和上一处出的是同一个毛病,咱们验收的时候看字节,别看心里默念的那个数。

债:全量语法、ELF 的构造、模拟器真跑 ​

链接脚本的全量语法,这一篇是不铺开的。咱们把 AT() 怎么分别指定 VMA 和 LMA、MEMORY 怎么划地界、KEEP 与 PROVIDE 各管什么,统统记在了债上。今天认下的这些,拿去读真的引导链接脚本,已经够用了。ELF 文件本身的构造,文件头、节表、程序头的那些名堂,还有拿 readelf 逐段去看的活,都在链接与装载后面的课里。咱们链出来的 tiny.bin,也还没有真的在机器上跑过,拿 QEMU 起镜像、挂上 GDB 的那些活,同样在后面的课里。还有 0x7c00 这个数是怎么来的,512 字节背后的整个引导约定,都记在债上了,正文里搓引导代码的时候自然会谈起来。

回到那两个字节 ​

等咱们到了正文里亲手搓引导代码,头一版链出来的镜像,就该长得跟咱们今天做的这个一模一样:凑得整整齐齐的 512 个字节,末尾躺着咱们验过的 55 aa。

r04_protected_mode-1-gc50cfe1 · c50cfe1 · 2026-10-01