汇编语言,可以说是人类与 CPU 之间最直接的对话方式。它不像 C 语言那样高度抽象,也不像机器语言那样难以阅读。汇编语言本质上就是一种用助记符(Mnemonics)代表机器指令的语言。

而一个汇编源文件(如 .s 文件),从我们写下的那些看似晦涩的指令,到最终真正被 CPU 执行,中间要经历汇编链接加载等几个关键步骤。

对于普通应用程序,这个流程可能已经被集成开发环境(IDE)隐藏得很好了;但对于 Linux 0.11 这样的早期操作系统内核,尤其是它的引导扇区 bootsect.s,这个过程却充满了硬核的手工操作和精巧的底层设计。

今天,我们就来一场彻底的探索:从汇编文件的本质讲起,一路追踪到 Linux 0.11 的 bootsect.s 是如何被编译、链接,并最终写入软盘第一扇区的。我会用大量图解和真实代码,让你身临其境地感受那个 1991 年的手工构建时代。


一、汇编语言的本质与编译链接流程

1.1 汇编语言:人读得懂的机器指令

CPU 只认识二进制机器码,比如 1011 1000 0000 0001 可能表示“把立即数 1 放入 AX 寄存器”。这种二进制对人类来说非常不友好,难以记忆和阅读。

于是,人们发明了汇编语言。它用一些容易记忆的助记符来代表机器指令,例如 mov ax, 1 就对应那条二进制机器码。

汇编语言与机器指令通常是一一对应的(除了某些宏汇编扩展)。一个汇编源文件中的每一条指令,都会被汇编器翻译成对应的机器码。

因此,汇编语言被称为“低级语言”,它直接操作寄存器、内存地址、I/O 端口,几乎和硬件层面对话。

1.2 从汇编源码到可执行文件的流程

一个典型的汇编源文件(.s.asm)要变成可执行文件,通常需要两个主要步骤:

  1. 汇编(Assemble):汇编器将汇编语言翻译成机器码,生成目标文件(Object File,通常以 .o 为后缀)。目标文件中除了机器码,还包含符号表、重定位信息等,但还不能直接运行。
  2. 链接(Link):链接器将一个或多个目标文件以及所需的库文件合并,解析符号引用,进行地址分配和重定位,最终生成可执行文件

这个过程可以用一张 Mermaid 图表示:

汇编器 as

链接器 ld

链接器 ld

链接器 ld

汇编源文件 .s

目标文件 .o

可执行文件

其他目标文件 .o

库文件 .a

对于 C 语言程序,还会有编译(Compile)步骤,将 C 代码先翻译成汇编代码,再经过汇编和链接。但汇编语言直接跳过编译阶段,从汇编开始。

1.3 目标文件与可执行文件的区别

  • 目标文件:包含机器码,但地址是相对的,符号引用尚未解决,不能直接执行。
  • 可执行文件:所有符号都已解析,地址已经确定,操作系统加载后可以直接运行。

在 Linux 0.11 的 bootsect.s 场景中,情况又有不同:它最终需要的不是标准的可执行文件,而是一段纯二进制机器码,被 BIOS 加载到固定物理地址 0x7C00 并执行。因此,它的编译链接流程与普通程序有显著差异。


二、Linux 0.11 的构建工具链:as86 与 ld86

2.1 为什么不用 GNU as 和 ld?

现代 Linux 开发中,我们通常使用 GNU 工具链:as(GNU Assembler)和 ld(GNU Linker)。它们支持 32 位和 64 位保护模式代码,生成 ELF 格式的目标文件和可执行文件。

但 Linux 0.11 诞生于 1991 年,当时 GNU 工具链虽然已经存在,但对其支持并不完善,特别是对于 16 位实模式代码的支持。而 Linux 0.11 的引导扇区 bootsect.s 和设置程序 setup.s 必须运行在 16 位实模式下,因此需要能够生成 16 位代码的汇编器和链接器。

Linus Torvalds 选择了 as86ld86,这是由 Bruce Evans 开发的 8086/80386 汇编器和链接器,它们最初是为 Minix 操作系统设计的,支持生成 16 位和 32 位代码,语法更接近 Intel 风格,并且能够输出纯二进制格式,非常适合引导扇区这种特殊需求。

2.2 as86 汇编器的特点

  • 语法类似于 Intel 语法,操作数顺序为目的在前,源在后
  • 立即数使用 # 前缀,如 mov ax, #BOOTSEG
  • 寄存器直接写名字,如 axbx
  • 支持 16 位和 32 位代码生成(通过 .386 等伪指令);
  • 生成的目标文件格式为 Minix 的目标文件格式(a.out 变体)。

2.3 ld86 链接器的特点

  • 能够将 as86 生成的目标文件链接成 Minix 可执行文件;
  • 支持 -0 选项,生成适用于 8086 的 16 位代码;
  • 支持 -s 选项,剥离符号表;
  • 生成的可执行文件包含一个32 字节的头部,这是 Minix 可执行文件格式的一部分。

2.4 工具链对应关系

步骤 普通程序(GNU 工具链) Linux 0.11 引导扇区
汇编器 as,生成 ELF .o as86,生成 Minix .o
链接器 ld,生成 ELF 可执行文件 ld86,生成 Minix 可执行文件(含 32 字节头)
最终产物 可执行文件,由操作系统加载 纯二进制镜像(去掉头部),由 BIOS 加载

三、bootsect.s 的特殊性:引导扇区的要求

3.1 引导扇区的硬件规则

当计算机上电或复位时,BIOS 会进行硬件自检(POST),然后按照启动顺序查找可引导设备(软盘、硬盘、光盘等)。对于软盘和硬盘,引导扇区是设备的第一个扇区(512 字节)。

BIOS 检查引导扇区的条件:

  1. 扇区大小为 512 字节;
  2. 最后两个字节必须是 0x550xAA(即字 0xAA55,小端序)。
  3. 如果满足,BIOS 就会把这 512 字节加载到物理内存 0x7C00 处,然后跳转到 0x7C00 执行。

因此,bootsect.s 编译链接后必须生成一个恰好 512 字节的纯二进制文件,并且最后两个字节是 0xAA55

3.2 为什么必须是纯二进制?

引导扇区是由 BIOS 直接加载执行的,此时还没有操作系统,也没有文件系统。BIOS 只做一件事:把扇区内容原封不动地复制到内存,然后跳转。它不理解 ELF、PE 或任何可执行文件格式。

所以,bootsect.s 的最终产物必须是裸机代码——没有任何文件头、元数据,纯粹是 CPU 要执行的机器指令和数据。

这与普通程序完全不同。普通程序由操作系统加载,操作系统会解析可执行文件头,设置内存映射、栈、入口点等。而引导扇区没有这些,它只有 512 字节,一切都要自己搞定。


四、Linux 0.11 中 bootsect.s 的编译与链接

4.1 源码文件与相关定义

Linux 0.11 的 boot/bootsect.s 文件使用 as86 语法编写。我们先看它的开头和结尾部分,了解整体结构。

开头部分:

BOOTSEG  = 0x07c0
INITSEG  = 0x9000
SETUPSEG = 0x9020

.globl begtext, begdata, begbss, endtext, enddata, endbss
.text
begtext:
.data
begdata:
.bss
begbss:
.text

entry start
start:
    mov ax, #BOOTSEG
    mov ds, ax
    mov ax, #INITSEG
    mov es, ax
    mov cx, #256
    sub si, si
    sub di, di
    cld
    rep movw
    jmpi go, INITSEG

结尾部分(包含 root_dev 和 boot_flag):

.org 506
root_dev:
    .word ROOT_DEV
boot_flag:
    .word 0xAA55

这里 ROOT_DEV 是一个宏,通常在 Makefile 中定义,例如 -DROOT_DEV=0x301

4.2 编译与链接的具体命令

在 Linux 0.11 的 boot/Makefile 中,与 bootsect 相关的构建规则大致如下:

bootsect: bootsect.o
    ld86 -0 -s -o bootsect bootsect.o
    dd bs=1 if=bootsect of=Image skip=32

我们逐步解析这两条命令。

4.2.1 汇编:as86

首先,bootsect.s 需要被汇编成目标文件 bootsect.o。这一步通常由 Makefile 中的隐式规则完成,但大致命令是:

as86 -0 -a -o bootsect.o bootsect.s

参数含义:

  • -0:生成 8086 指令集(16 位);
  • -a:产生汇编列表文件(可选);
  • -o bootsect.o:指定输出目标文件。

汇编后,bootsect.o 包含了机器码、符号表等信息,但其地址是相对的,且带有 Minix 目标文件格式的头部信息。

4.2.2 链接:ld86

接下来,链接器 ld86 将目标文件链接成可执行文件:

ld86 -0 -s -o bootsect bootsect.o

参数含义:

  • -0:生成 8086 代码,与汇编器保持一致;
  • -s:剥离符号表,减小文件体积;
  • -o bootsect:输出可执行文件,名为 bootsect

链接后生成的 bootsect 文件是 Minix 可执行文件格式。它前面有一个 32 字节的头,包含入口点、段信息等。这个头对于引导扇区来说是多余的,必须去掉。

4.2.3 去除头部:dd

链接后得到的 bootsect 文件,前 32 字节是 Minix 可执行文件头。真正的机器码从偏移 32 开始。因此,我们需要跳过这 32 字节,提取纯二进制部分:

dd bs=1 if=bootsect of=Image skip=32
  • bs=1:每次复制 1 字节(为了精确跳过 32 字节);
  • if=bootsect:输入文件是 bootsect
  • of=Image:输出文件是 Image(纯二进制);
  • skip=32:跳过前 32 字节。

经过这一步,得到的 Image 文件就是纯二进制引导扇区代码。

4.3 验证大小与引导标志

引导扇区必须恰好 512 字节,且最后两个字节为 0xAA55

bootsect.s 使用了 .org 506 伪指令,将 root_dev 定位到偏移 506 处,然后 root_dev 占 2 字节(506-507),boot_flag 从偏移 508 开始,占 2 字节(508-509)。这样最后两个字节就是 510-511,正好是 boot_flag 的内容 0xAA55

但需要注意,如果代码部分超过 506 字节,.org 506 会导致汇编器报错。因此,Linux 0.11 的 bootsect.s 经过精简,确保代码和数据总计不超过 506 字节。

链接并去除头部后,我们可以检查 Image 文件大小:

ls -l Image

应该是 512 字节。如果不是,说明构建过程可能有问题。


五、将 bootsect 写入软盘第一扇区

5.1 写入物理软盘

如果有一张实际的软盘,并且系统有软驱,可以使用 dd 命令直接写入:

dd if=Image of=/dev/fd0 bs=512 count=1
  • if=Image:输入文件是纯二进制引导扇区;
  • of=/dev/fd0:输出到第一个软盘驱动器;
  • bs=512:块大小为 512 字节;
  • count=1:只复制一个块,即第一扇区。

注意:执行此操作需要 root 权限,因为直接访问设备文件。

5.2 写入软盘镜像文件

现代开发通常使用软盘镜像文件(如 boot.img)来模拟软盘。构建流程可能是:

  1. 创建一个空的 1.44MB 镜像文件;
  2. bootsect 写入镜像的第一个扇区;
  3. setup 和内核写入后续扇区。

在 Linux 0.11 的原始构建系统中,Makefile 会生成一个完整的启动镜像,通常名为 Imageboot.img。这个镜像包含引导扇区、设置程序、内核等,可以直接用模拟器或写入设备启动。

例如,在 boot/Makefile 中可能会有这样的规则:

Image: bootsect setup system
    dd bs=512 if=bootsect of=Image count=1
    dd bs=512 if=setup of=Image skip=1 seek=1 count=4
    dd bs=512 if=system of=Image skip=1 seek=5 count=...

这些 dd 命令将各个部分依次写入 Image 文件的不同偏移处,形成一个完整的磁盘镜像。

对于软盘第一扇区的写入,最简单的情况就是:

dd if=bootsect of=Image bs=512 count=1 conv=notrunc

conv=notrunc 确保不会截断 Image 文件的其余部分。

5.3 启动过程回顾

当 BIOS 从软盘启动时:

  1. BIOS 读取软盘第一个扇区(512 字节)到内存 0x7C00
  2. 检查最后两个字节是否为 0xAA55
  3. 如果是,跳转到 0x7C00 执行;
  4. bootsect 代码开始运行,它会将自己从 0x7C00 复制到 0x9000,然后继续加载 setup 和内核,最终进入保护模式。

六、深入细节:为什么链接后要跳过 32 字节?

6.1 Minix 可执行文件头

ld86 生成的可执行文件是 Minix 的 a.out 变体。它的头部结构如下(简化):

偏移 大小 含义
0 4 字节 魔数(0x0301 或其他)
4 4 字节 代码段大小
8 4 字节 数据段大小
12 4 字节 BSS 段大小
16 4 字节 入口点
20 4 字节 符号表大小
24 4 字节 未使用/其他
28 4 字节 未使用/其他

总共 32 字节。这 32 字节对于引导扇区来说没有任何用处,因为 BIOS 不会解析它,只会把它当作机器码的一部分执行(会出问题)。所以必须去掉。

dd skip=32 就是跳过这 32 字节,从纯机器码开始提取。

6.2 为什么不用 ld86 -o 直接生成纯二进制?

ld86 本身并不支持直接生成纯二进制文件,它总是输出带有 Minix 头的可执行文件。所以需要借助 dd 来剥离头部。这也是早期系统构建中常见的手工做法。


七、完整构建流程示例

让我们假设你已经下载了 Linux 0.11 源码,并在现代 Linux 系统上配置好了交叉编译环境(可能需要安装 bin86 包,其中包含 as86ld86)。

7.1 安装工具

在 Debian/Ubuntu 上:

sudo apt-get install bin86

7.2 编译 bootsect.s

进入 linux-0.11/boot 目录:

cd linux-0.11/boot

查看 Makefile 中与 bootsect 相关的部分,然后执行:

make bootsect

实际执行的命令可能是:

as86 -0 -a -o bootsect.o bootsect.s
ld86 -0 -s -o bootsect bootsect.o
dd bs=1 if=bootsect of=bootsect.bin skip=32

或者直接输出为 Image 的一部分。

7.3 检查生成的文件

ls -l bootsect bootsect.bin

bootsect 可能是 544 字节(512 + 32 头),bootsect.bin 是 512 字节。

7.4 写入软盘镜像

创建软盘镜像:

dd if=/dev/zero of=floppy.img bs=512 count=2880  # 1.44MB

写入引导扇区:

dd if=bootsect.bin of=floppy.img bs=512 count=1 conv=notrunc

之后可以继续写入 setupsystem


八、总结与思考

从汇编源码到软盘第一扇区,整个过程展现了早期操作系统开发的纯粹与硬核。

  • 汇编器将人类可读的助记符转换为机器码目标文件;
  • 链接器将目标文件组合并解析符号,生成可执行文件;
  • dd 工具则负责剥离文件头、写入原始设备,完成从“程序”到“引导扇区”的转变。

Linux 0.11 的 bootsect.s 之所以采用这样的流程,是因为它必须生成 512 字节的纯二进制引导扇区,而当时的工具链无法直接输出这种格式,只能通过手动剥离头部来实现。

这不仅是技术问题,更是一种历史缩影:在那个资源有限、工具简陋的时代,开发者们用最直接的方式与硬件对话,亲手搭建起操作系统的第一级台阶。

希望这篇文章能让你对汇编语言的本质、编译链接流程,以及 Linux 0.11 引导扇区的构建有一个透彻的理解。当你下次看到 bootsect.s 中的那些指令时,你会明白它们是如何一步步变成软盘上的 512 字节,并在开机时被 BIOS 唤醒的。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐