目录
为 Bare-Metal (裸机)编程:编译、链接与加载
1. 从一个例子说起
2. 操作系统上的 C 程序
2.1. 编译
2.2 链接
2.3. 加载
警告:大量的细节
3. Bare-Metal 上的 C 程序
3.1. 编译
3.2. 链接
_start 里没有 movabs?
3.3. 加载
3.3.1 CPU Reset
3.3.2. Firmware: 加载 Master Boot Record
3.3 Boot Loader: 解析并加载 ELF 文件
3.4 _start: 初始化 64-bit Long Mode
把它们都忘了吧!
3.3.4. Bare-Metal 上的程序
最后,操作系统
第一章 为 Bare-Metal (裸机)编程:编译、链接与加载
1. 从一个例子说起
面对 bare-metal 的时候,怎样让 C 程序运行起来:
- 为了使程序能运行,当然需要经过编译链接的过程。就假设最简单的情况:生成静态链接的 ELF 格式的二进制文件好了。
- 二进制文件假设代码、数据存在于地址空间的指定位置。那么是谁来完成这件事?
main
在二进制文件中的地址是不固定的。是谁调用的main()
?- 我们需要自己动手实现各种库函数,那
printf
(输出到屏幕),malloc
(动态分配内存)又是如何实现的?
// say.c
void putch(char ch);
int putchar(int ch);void say(const char *s) {for (; *s; s++) {
#ifdef __ARCH__putch(*s); // AbstractMachine,没有 libc,调用 TRM API 打印字符
#elseputchar(*s); // 操作系统,调用 libc 打印字符
#endif}
}
// main.c
void say(const char *s);
int main() {say("hello\n");
}
以下完整的流程是操作系统上 (hosted) 和 bare-metal 上共同的:
main.c -> 编译 (gcc -c) -> a.o -+ \say.c -> 编译 (gcc -c) -> b.o -> 链接 (ld) -> a.out -> 加载 (loader)
2. 操作系统上的 C 程序
2.1. 编译
我们使用 gcc 把源代码编译成可重定位的二进制文件:
$ gcc -c -O2 -o main.o main.c
$ gcc -c -O2 -o say.o say.c
$ file say.o main.o
say.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
- 使用的是 `gcc` 编译器(GNU Compiler Collection)
- `-c` 参数告诉 `gcc` 只编译源文件,而不进行链接操作。
- `-O2` 参数表示使用级别为 2 的优化,用于提高程序的性能。
- `-o` 参数指定输出的目标文件名。
- 在这个命令执行完成后,得到了两个目标文件:`main.o` 和 `say.o`。
- 使用 `file` 命令可以查看文件的类型。
- `ELF` 表示该文件采用的是 ELF文件格式,
- `LSB relocatable` 表示该文件是可以被动态链接的目标文件,
- `x86-64` 表示该文件适用于 x86-64 架构的处理器,
- `version 1 (SYSV)` 表示该文件遵循 System V ABI规范。
- `not stripped` 表示该文件没有被剥离(strip),即包含了调试符号等信息。
在 ELF 格式中,除了实际的机器码以外,还包含了一些额外的信息,例如符号表、重定位信息、调试信息等。这些信息对于程序的调试、优化和概述非常有用。
其中,调试信息用于帮助程序员理解程序的执行过程,它包括源代码的行号、变量名、函数名等信息。在编译和链接完成后,经过 strip 工具处理 ELF 文件时,可以去除这些调试信息,从而减小程序文件的大小。去除调试信息后,目标文件的标记会变为 `stripped`。
`not stripped` 表示一个 ELF 目标文件没有被 strip 处理,仍然包含了调试信息。
“relocatable” 的含义是虽然生成了指令序列,但暂时还不确定它们在二进制文件中的位置。我们可以查看生成的指令序列:
$ objdump -d main.o
0000000000000000 <main>:0: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 7 <main+0x7>7: 48 83 ec 08 sub $0x8,%rspb: e8 00 00 00 00 callq 10 <main+0x10>10: 31 c0 xor %eax,%eax12: 48 83 c4 08 add $0x8,%rsp16: c3 retq
- `objdump` 是一个可查看目标文件、可执行文件以及动态链接库中的代码和符号表的命令行工具。它可以打印出目标文件中各个部分的汇编代码,可以方便地进行分析和调试。
- `-d` 参数表示打印目标文件的汇编代码。
- `main.o` 是要进行查看的目标文件名称。
- 机器指令,每一行的开头是指令的地址,后面是指令本身。
- 0: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi 这个指令是一个 `lea` 指令,它将地址 0x0(即下一个指令所在地址)加上偏移量 0x0,得到的值存入 `%rdi` 寄存器中。
- `sub $0x8,%rsp` 指令将栈顶指针减去 8 个字节,为调用函数做准备;
- `callq 10 <main+0x10>` 指令调用一个函数。
可以看到 relocatable 的代码从 0 开始编址;因为 main
并不知道 say
的代码在何处,所以虽然生成了 opcode 为 0xe8
的 call
指令 (对应 say("...")
的函数调用),但没有生成跳转的偏移量 (say.c
中向 putchar
的调用也生成同样的 call
指令):
b: e8 00 00 00 00 callq 10 <main+0x10>
类似的,say
的第一个参数 (通过 %rdi
寄存器传递) 是通过如下 lea
指令获取的,它的位置同样暂时没有确定:
0: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 7 <main+0x7>
2.2 链接
gcc 链接:
$ gcc main.o say.o
$ ./a.out
hello
如果直接使用 ld
命令链接,则会报错:
$ ld main.o say.o
ld main.o say.o
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000b0
say.o: In function `say':
say.c:(.text+0x15): undefined reference to `putchar'
首先,我们的程序没有入口 (_start
),其次,我们链接的对象中没有 putchar
函数。我们可以给 gcc 传递额外的参数,查看 ld
的选项:
gcc -Wl,--verbose main.o say.o
这个命令将 `main.o` 和 `say.o` 这两个目标文件进行链接,生成可执行文件。
`-Wl,--verbose` 参数,它的作用是将链接器的详细信息输出到终端。`-Wl` 表示将参数传递给链接器(ld),
`--verbose` 表示将链接器的详细信息输出到终端。
这个命令执行后,会输出大量的信息,包括链接器的参数、搜索路径、引用的库文件等等。
最终,这个命令会生成可执行文件 `a.out`,可以通过 `./a.out` 命令来执行它。
你会发现链接的过程比想象中复杂得多。用以下简化了的命令可以得到可运行的 hello 程序:
$ ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 \/usr/lib/x86_64-linux-gnu/crt1.o \/usr/lib/x86_64-linux-gnu/crti.o \main.o say.o -lc \/usr/lib/x86_64-linux-gnu/crtn.o
$ ./a.out
hello
二进制文件要运行在操作系统上,就必须遵循操作系统的规则,调用操作系统提供的 API 完成加载。加载器也是代码的一部分,当然应该被链接进来。链接文件的具体解释:
ld-linux-x86-64.so
负责动态链接库的加载,没有它就无法加载动态链接库 (libc)。crt*.o
是 C Runtime 的缩写,即 C 程序运行所必须的一些环境,例如程序的入口函数_start
(二进制文件并不是从main
开始执行的!)、atexit
注册回调函数的执行等。-lc
表示链接 glibc。
链接后得到一个 ELF 格式的可执行文件:
$ file a.out
a.out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0, not stripped
$ objdump -d a.out
...
0000000000400402 <main>:400402: 55 push %rbp400403: 48 89 e5 mov %rsp,%rbp400406: 48 8d 3d c7 00 00 00 lea 0xc7(%rip),%rdi # 4004d4 <_IO_stdin_used+0x4>40040d: b8 00 00 00 00 mov $0x0,%eax400412: e8 07 00 00 00 callq 40041e <say>400417: b8 00 00 00 00 mov $0x0,%eax40041c: 5d pop %rbp40041d: c3 retq 000000000040041e <say>:40041e: 55 push %rbp40041f: 48 89 e5 mov %rsp,%rbp400422: 48 83 ec 10 sub $0x10,%rsp400426: 48 89 7d f8 mov %rdi,-0x8(%rbp)40042a: eb 16 jmp 400442 <say+0x24>40042c: 48 8b 45 f8 mov -0x8(%rbp),%rax400430: 0f b6 00 movzbl (%rax),%eax400433: 0f be c0 movsbl %al,%eax400436: 89 c7 mov %eax,%edi400438: e8 83 ff ff ff callq 4003c0 <putchar@plt>40043d: 48 83 45 f8 01 addq $0x1,-0x8(%rbp)400442: 48 8b 45 f8 mov -0x8(%rbp),%rax400446: 0f b6 00 movzbl (%rax),%eax400449: 84 c0 test %al,%al40044b: 75 df jne 40042c <say+0xe>40044d: 90 nop40044e: c9 leaveq 40044f: c3 retq
...
2.3. 加载
使用 ./a.out
运行我们的程序,流程大致如下 :
- Shell 接收到命令后,在操作系统中使用
fork()
创建一个新的进程。 - 在子进程中使用
execve()
加载a.out
。操作系统内核中的加载器识别出a.out
是一个动态链接文件,做出必要的内存映射,从ld-linux-x86-64.so
的代码开始执行,把动态链接库映射到进程的地址空间中,然后跳转到a.out
的_start
执行,初始化 C 语言运行环境,最终开始执行main
。 - 程序运行过程中,如需进行输入/输出等操作 (如 libc 中的
putchar
),则会使用特殊的指令 (例如 x86 系统上的int
或syscall
) 发出系统调用请求操作系统执行。典型的例子是printf
会调用write
系统调用,向编号为1
的文件描述符写入数据。
gdb 为我们提供了 starti
指令,可以在程序执行第一条指令时就停下:
gdb
是 GNU Debugger 的缩写,是 Linux 和其他 Unix 系统下的一个强大的调试工具。它可以帮助程序员定位程序的错误,并深入了解程序的执行过程。
$ gdb a.out
GNU gdb (Ubuntu 8.1-0ubuntu3.2) 8.1.0.20180409-git
...
(gdb) starti # 启动程序,并在第一条指令上暂停
Starting program: /tmp/a/a.out Program stopped.
0x00007ffff7dd6090 in _start () from /lib64/ld-linux-x86-64.so.2
(gdb) bt f # backtrace full,打印堆栈信息
#0 0x00007ffff7dd6090 in _start () from /lib64/ld-linux-x86-64.so.2library_path = 0x0version_info = 0any_debug = 0_dl_rtld_libname = {name = 0x0, next = 0x0, dont_free = 0}relocate_time = 0_dl_rtld_libname2 = {name = 0x0, next = 0x0, dont_free = 0}start_time = 0tls_init_tp_called = falseload_time = 0audit_list = 0x0preloadlist = 0x0__GI__dl_argv = 0x0_dl_argc = 0audit_list_string = 0x0_rtld_global = {_dl_ns = {{_ns_loaded = 0x0, _ns_nloaded = 0,...
#1 0x0000000000000001 in ?? ()
No symbol table info available.
...
操作系统的加载器完成了 ld-linux-x86-64.so.2
的加载,并给它传递了相应的参数。我们可以查看此时的进程信息 (这些内存都是操作系统加载的):
(gdb) info inferiors # 打印进程/线程信息Num Description Executable
* 1 process 18137 /tmp/hello/a.out
(gdb) !cat /proc/18137/maps # 打印进程的内存信息
00400000-00401000 r-xp 00000000 08:02 3538982 /tmp/hello/a.out
00600000-00602000 rw-p 00000000 08:02 3538982 /tmp/hello/a.out
7ffff7dd5000-7ffff7dfc000 r-xp 00000000 08:02 4985556 /lib/x86_64-linux-gnu/ld-2.27.so
7ffff7ff7000-7ffff7ffa000 r--p 00000000 00:00 0 [vvar]
7ffff7ffa000-7ffff7ffc000 r-xp 00000000 00:00 0 [vdso]
7ffff7ffc000-7ffff7ffe000 rw-p 00027000 08:02 4985556 /lib/x86_64-linux-gnu/ld-2.27.so
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
如果我们在 _start
设置断点,会发现此时已经加载完成:
(gdb) b _start # breakpoint 设置断点
Breakpoint 1 at 0x4003d0
(gdb) c # continue 继续执行
Continuing.Breakpoint 1, 0x00000000004003d0 in _start ()
(gdb) !cat /proc/18137/maps
00400000-00401000 r-xp 00000000 08:02 3538982 /tmp/hello/a.out
00600000-00601000 r--p 00000000 08:02 3538982 /tmp/hello/a.out
00601000-00602000 rw-p 00001000 08:02 3538982 /tmp/hello/a.out
7ffff79e4000-7ffff7bcb000 r-xp 00000000 08:02 4985568 /lib/x86_64-linux-gnu/libc-2.27.so
7ffff7bcb000-7ffff7dcb000 ---p 001e7000 08:02 4985568 /lib/x86_64-linux-gnu/libc-2.27.so
7ffff7dcb000-7ffff7dcf000 r--p 001e7000 08:02 4985568 /lib/x86_64-linux-gnu/libc-2.27.so
7ffff7dcf000-7ffff7dd1000 rw-p 001eb000 08:02 4985568 /lib/x86_64-linux-gnu/libc-2.27.so
7ffff7dd1000-7ffff7dd5000 rw-p 00000000 00:00 0
7ffff7dd5000-7ffff7dfc000 r-xp 00000000 08:02 4985556 /lib/x86_64-linux-gnu/ld-2.27.so
7ffff7fde000-7ffff7fe0000 rw-p 00000000 00:00 0
7ffff7ff7000-7ffff7ffa000 r--p 00000000 00:00 0 [vvar]
7ffff7ffa000-7ffff7ffc000 r-xp 00000000 00:00 0 [vdso]
7ffff7ffc000-7ffff7ffd000 r--p 00027000 08:02 4985556 /lib/x86_64-linux-gnu/ld-2.27.so
7ffff7ffd000-7ffff7ffe000 rw-p 00028000 08:02 4985556 /lib/x86_64-linux-gnu/ld-2.27.so
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
地址空间中已有 a.out
, libc, 堆区、栈区等我们熟悉的东西,libc 的 _start
完成初始化后会调用 main()
。这真是一段漫长的旅途!
3. Bare-Metal 上的 C 程序
对于 AbstractMachine 上的程序,我们需要一个 Makefile(文本文件),就能把 hello 程序编译到 bare-metal 执行:
NAME := hello
SRCS := main.c say.c
include $(AM_HOME)/Makefile.app
这个 Makefile 主要包含了以下几个部分:
1. `NAME := hello`:这一行定义了程序的目标名为 `hello`。
2. `SRCS := main.c say.c`:这一行定义了程序的源文件,有 `main.c` 和 `say.c` 两个文件。
3. `include $(AM_HOME)/Makefile.app`:这一行包含了指定的外部 Makefile 模板文件,`$(AM_HOME)` 是一个变量,表示 Makefile 模板文件所在的路径。它通常由环境变量或者 Makefile 内部变量定义。
Makefile.app 文件通常包含了一些通用的规则和变量定义,帮助简化 Makefile 的编写。它可能包含编译器的参数、链接器的参数、头文件搜索路径等信息,以及一些常用的规则,如编译、链接、打包等操作。
使用 `make` 命令执行 Makefile,可以根据规则生成目标程序。
在终端中执行 make -nB ARCH=x86_64-qemu
可以查看完整的编译、链接到 x86-64 的过程 (不实际进行编译)。
3.1. 编译
把 .c
文件翻译成可重定位的二进制目标文件 (.o
)。这一步对于有无操作系统来说差别并不大,最主要的区别是在 bare-metal 是 “freestanding” 的运行环境,没有办法调用依赖于操作系统的库函数
编译器 (gcc) 提供了选项帮我们生成不依赖操作系统的目标文件,例如对 -ffreestanding
(-fno-hosted
) 选项的文档:
Assert that compilation targets a freestanding environment. This implies
-fno-builtin
. A freestanding environment is one in which the standard library may not exist, and program startup may not necessarily be at "main". The most obvious example is an OS kernel. This is equivalent to-fno-hosted
.
使用 `-ffreestanding` 选项会在编译时假定在所编译的目标环境下可能没有系统标准库,
当使用 `-ffreestanding` 选项时,编译器对于 C 语言标准的实现有一些要求。这包括:
- 在 Freestanding 环境下,必须实现标准 C 库的 `<float.h>`、`<iso646.h>`、`<limits.h>`、`<stdalign.h>`、`<stdarg.h>`、`<stdbool.h>`、`<stddef.h>`、`<stdint.h>` 和 `<stdnoreturn.h>` 等头文件中的所有函数声明和宏定义。
- 在 Freestanding 环境下,必须提供 `<stddef.h>` 中定义的 `size_t` 和 `ptrdiff_t` 类型的适当定义。
- 在 Freestanding 环境下,必须实现位运算和字节操作函数(例如 `memcpy()` 和 `memset()`)。
- 在 Freestanding 环境下,可执行文件可以包含名称不为 `main()` 的入口函数。
通过在编译时使用 `-ffreestanding` 选项,可以让编译器在编译过程中忽略对标准库的依赖,以便编写适用于嵌入式系统和操作系统内核等不支持标准库的环境的程序。
3.2. 链接
使用的链接命令是:
$ ld -melf_x86_64 -N -Ttext-segment=0x00100000 -o build/hello-x86_64-qemu.o \main.o say.o am-x86_64-qemu.a klib-x86_64-qemu.a
只链接了 main.o
, say.o
和必要的库函数 (AbstractMachine 和 klib;在这个例子中,我们甚至可以不链接 klib 也能正常运行)。使用的链接选项:
-melf_x86_64
:指定链接为 x86_64 ELF 格式;-N
:标记.text
和.data
都可写,这样它们可以一起加载 (而不需要对齐到页面边界),减少可执行文件的大小;-Ttext-segment=0x00100000
:指定二进制文件应加载到地址0x00100000
。
使用 readelf
命令查看 hello-x86_64-qemu.o
文件的信息:
$ readelf -a build/hello-x86_64-qemu.o
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64Data: 2's complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: EXEC (Executable file)Machine: Advanced Micro Devices X86-64Version: 0x1Entry point address: 0x100100Start of program headers: 64 (bytes into file)Start of section headers: 65584 (bytes into file)...
其中的 program headers 描述了需要加载的部分:加载这个文件的加载器需要把文件中从 0xb0
(Offset) 开始的 0x29ac
字节 (FileSiz
) 加载到内存的 0x1000b0
虚拟/物理地址 (VirtAddr/PhysAddr),内存中的大小 0x23f98
字节 (MemSiz
,超过 FileSiz
的内存清零),标志为 RWE (可读、可写、可执行)。
Program Headers:Type Offset VirtAddr PhysAddrFileSiz MemSiz Flags AlignLOAD 0x00000000000000b0 0x00000000001000b0 0x00000000001000b00x00000000000029ac 0x0000000000023f98 RWE 0x20GNU_STACK 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000000000 0x0000000000000000 RWE 0x10
实际上,这个程序可以直接在操作系统上被运行!如果你试着用 gdb 调试它,会发现程序从 _start
(0x100100
) 开始执行,但在执行了若干条指令后,在 movabs %eax,0xb900001000
时发生了 Segmentation Fault(分段故障)。
_start
里没有movabs
?x86-64 AbstractMachine 上程序的入口在
start64.S
文件中:.code32 .globl _start _start:movl $(PDPT_ADDR | PTE_P | PTE_W), %eaxcmpl (PML4_ADDR), %eaxje .long_mode_initmovl $(PDPT_ADDR | PTE_P | PTE_W), %eaxmovl %eax, (PML4_ADDR) ...
这是一段 32-bit 代码——AbstractMachine 的加载器并没有进入 64-bit 模式;而
hello-x86_64-qemu.o
直接在操作系统上执行时,将代码解析为 64-bit 汇编,因此错误解析了 32-bit 指令,访问非法内存产生 Segmentation Fault。
只不过因为运行环境不同,执行到系统指令时,属于非法操作 crash 了。我们需要在 bare-metal 上加载它。所以我们会创建 hello-x86_64-qemu
的镜像文件:
( cat abstract-machine/am/src/x86/qemu/boot/mbr \head -c 1024 /dev/zero \cat build/hello-x86_64-qemu.o ) \> /tmp/hello/build/hello-x86_64-qemu
镜像文件是由 512 字节的 “MBR”、1024 字节的空白 (用于存放 main
函数的参数) 和 hello-x86_64-qemu.o
组成的。用 file
类型可以识别出它:
$ file hello-x86_64-qemu
hello-x86_64-qemu: DOS/MBR boot sector
3.3. 加载
我们在 QEMU 全系统模拟器中运行完整的镜像 hello-x86_64-qemu
(包含 hello-x86_64-qemu.o
)。如果用一些特别的选项,就能近距离观察模拟器的执行:
$ qemu-system-x86_64 -S -s -serial none -nographic hello-x86_64-qemu
其中:
-S
在模拟器初始化完成 (CPU Reset) 后暂停-s
启动 gdb 调试服务器,可以使用 gdb 调试模拟器中的程序-serial none
忽略串口输入/输出-nographics
不启动图形界面
我们可以在终端里启动一个 monitor (Online Judge 就处于这个模式)。在这里,我们就可以直接调试整个 QEMU 虚拟机了!
3.3.1 CPU Reset
就像 NEMU(一个开源的指令集模拟器) 一样调试虚拟机,我们使用 info registers
查看 CPU Reset 后的寄存器状态。
QEMU 2.11.1 monitor - type 'help' for more information
(qemu) info registers
EAX=00000000 EBX=00000000 ECX=00000000 EDX=00000663
ESI=00000000 EDI=00000000 EBP=00000000 ESP=00000000
EIP=0000fff0 EFL=00000002 [-------] CPL=0 II=0 A20=1 SMM=0 HLT=0
ES =0000 00000000 0000ffff 00009300
CS =f000 ffff0000 0000ffff 00009b00
SS =0000 00000000 0000ffff 00009300
DS =0000 00000000 0000ffff 00009300
FS =0000 00000000 0000ffff 00009300
GS =0000 00000000 0000ffff 00009300
LDT=0000 00000000 0000ffff 00008200
TR =0000 00000000 0000ffff 00008b00
GDT= 00000000 0000ffff
IDT= 00000000 0000ffff
CR0=60000010 CR2=00000000 CR3=00000000 CR4=00000000
DR0=0000000000000000 DR1=0000000000000000 DR2=0000000000000000 DR3=0000000000000000
DR6=00000000ffff0ff0 DR7=0000000000000400
EFER=0000000000000000
FCW=037f FSW=0000 [ST=0] FTW=00 MXCSR=00001f80
FPR0=0000000000000000 0000 FPR1=0000000000000000 0000
FPR2=0000000000000000 0000 FPR3=0000000000000000 0000
FPR4=0000000000000000 0000 FPR5=0000000000000000 0000
FPR6=0000000000000000 0000 FPR7=0000000000000000 0000
XMM00=00000000000000000000000000000000 XMM01=00000000000000000000000000000000
XMM02=00000000000000000000000000000000 XMM03=00000000000000000000000000000000
XMM04=00000000000000000000000000000000 XMM05=00000000000000000000000000000000
XMM06=00000000000000000000000000000000 XMM07=00000000000000000000000000000000
(qemu)
CPU Reset 后的状态涉及很多琐碎的硬件细节,这也是大家感到为 bare-metal 编程很神秘的原因。不过简单来讲,我们关心的状态只有两个:
%cr0 = 0x60000010
,最低位PE
-bit 为 0,运行在 16-bit 模式 (现在 CPU 的行为就像 8086)%cs = 0xf000
,%ip = 0xfff0
,相当于 PC 指针位于0xffff0
你甚至可以检查上面打印出的状态和手册的一致性:
CPU Reset 后,我们的计算机系统就是一个状态机,按照 “每次执行一条指令” 的方式工作。
3.3.2. Firmware: 加载 Master Boot Record
位于 0xffff0
的代码以内存映射的方式映射到只读的存储器 (固件,firmware,也称为 BIOS) 中。固件代码会进行一定的计算机状态检查 (比如著名的 “Keyboard not found, press any key to continue 检测不到键盘,需要插上键盘并按下任意键才能继续启动电脑...”)。如果我们在 gdb 中使用 target remote localhost:1234
连接到 qemu (默认端口为 1234),就可以开始单步调试固件代码。
在比较简单的 Legacy BIOS Firmware (Legacy Boot) 模式,固件会依次扫描系统中的存储设备 (磁盘、优盘等,Boot Order 通常可以设置),然后将第一个可启动磁盘的前 512 字节 (主引导扇区, Master Boot Record, MBR) 加载到物理内存的 0x7c00
地址。
今天的 Firmware 有了 UEFI 标准,能更好地提供硬件的抽象、支持固件应用程序;UEFI 加载器也不再仅仅加载是 512 字节的 MBR,而是能加载任意 GPT 分区表上的 FAT 分区中存储的应用。今天的计算机默认都通过 UEFI 引导。
从容易理解的角度,我们编写操作系统时,依然从 Legacy Boot 启动,你只需要知道,在 x86 系统上,AbstractMachine 和 Firmware 的约定是磁盘镜像的前 512 字节将首先会被加载到物理内存中执行。
我们可以通过 gdb 连接到已经启动 (但暂停) 的 qemu 模拟器:
$ gdb
(gdb) target remote localhost:1234
Remote debugging using localhost:1234
0x000000000000fff0 in ?? ()
(gdb) b *0x7c00
Breakpoint 1 at 0x7c00
(gdb) c
Continuing.Breakpoint 1, 0x0000000000007c00 in ?? ()
(gdb) x/16i $pc
=> 0x7c00: cli 0x7c01: xor %eax,%eax0x7c03: mov %eax,%ds0x7c05: mov %eax,%es0x7c07: mov %eax,%ss...
使用 layout asm
进行指令级的调试 (调试 16-bit code 时 disassembler 会遇到一些小麻烦,但调试的基本功能是没问题的;借助其他工具如 nasm 可以正确查看代码)。
3.3 Boot Loader: 解析并加载 ELF 文件
在 gdb 中看到的 0x7c00
地址的指令序列是我们的 boot loader 代码,存储在磁盘的前 512 字节。x86-64 的 AbstractMachine 在这 512 字节内完成 ELF 文件的加载。这部分代码位于 am/src/x86/qemu/boot
,由一部分 16-bit 汇编 (start.S
),主要部分如下:
.code16
.globl _start
_start:clixorw %ax, %axmovw %ax, %dsmovw %ax, %esmovw %ax, %sslgdt gdtdescmovl %cr0, %eaxorl $CR0_PE, %eaxmovl %eax, %cr0ljmp $GDT_ENTRY(1), $start32.code32
start32:movw $GDT_ENTRY(2), %axmovw %ax, %dsmovw %ax, %esmovw %ax, %ssmovl $0xa000, %espcall load_kernel
这段代码 (不需要理解) 就是做一些必要的处理器设置,切换到 32-bit 模式,设置初始的栈顶指针 (0xa000
),然后跳转到 32-bit C 代码 load_kernel
执行。load_kernel
位于 main.c
(有简化):
只需要知道我们编写的这段代码会被编译链接,然后被放置在磁盘的 MBR,从而被固件自动加载执行。在 load_elf64
中,我们根据 ELF 格式的规定将文件内容载入内存,然后跳转到 ELF 文件的入口,就算完成了 “hello 的加载”。
3.4 _start
: 初始化 64-bit Long Mode
此时我们的 hello (以及未来的 “操作系统”) 代码已经开始执行了,不过此时还不能立即执行 main
函数——我们还处于 32-bit 模式。am/src/x86/qemu/start64.S
的代码会完成最后的设置,比较重要的是启动分页 (正确设置四级页表)、切换到 x86-64 Long Mode,然后进入以下 64-bit 代码执行:
.code64
_start64:movw $0, %axmovw %ax, %dsmovw %ax, %esmovw %ax, %ssmovw %ax, %fsmovw %ax, %gsmovq $MAINARG_ADDR, %rdipushq $0jmp _start_c
之后,我们就进入了 C 代码的世界;但此时并未完成所有的初始化,在 trm.c
中的代码还要完成一系列硬件/运行环境的初始化:
void _start_c(char *args) {if (!boot_record()->is_ap) {// 第一个处理器__am_bootcpu_init();stack_switch_call(stack_top(&CPU->stack), // 切换到 percpu 的栈;思考题:为什么??call_main, // 执行 call_main(args)(uintptr_t)args);} else {// 其他处理器__am_othercpu_entry();}
}void __am_bootcpu_init() {heap = __am_heap_init(); // 获得物理内存大小__am_lapic_init(); // 初始化中断控制器__am_ioapic_init();__am_percpu_init(); // 其他处理器相关的初始化
}
最后,完成堆栈切换,然后调用 call_main
函数:
static void call_main(const char *args) {halt(main(args));
}
Say hello 的 main 此时才正式开始执行
把它们都忘了吧!
3.3.4. Bare-Metal 上的程序
操作系统内核的源代码 (若干
.c
文件和 AbstractMachien API 库) 经过 (刚才描述的、漫长的) 编译和链接生成二进制文件,然后被保存到存储设备中,由加载器加载运行。从原理上说,操作系统不过如此。