AbstractMachine: 抽象计算机

news/2025/3/21 0:56:51/

目录

为 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 程序运行起来:

  1. 为了使程序能运行,当然需要经过编译链接的过程。就假设最简单的情况:生成静态链接的 ELF 格式的二进制文件好了。
  2. 二进制文件假设代码、数据存在于地址空间的指定位置。那么是谁来完成这件事?
  3. main 在二进制文件中的地址是不固定的。是谁调用的 main()
  4. 我们需要自己动手实现各种库函数,那 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
  1. 使用的是 `gcc` 编译器(GNU Compiler Collection)
  2. `-c` 参数告诉 `gcc` 只编译源文件,而不进行链接操作。
  3. `-O2` 参数表示使用级别为 2 的优化,用于提高程序的性能。
  4. `-o` 参数指定输出的目标文件名。
  5. 在这个命令执行完成后,得到了两个目标文件:`main.o` 和 `say.o`。
  6. 使用 `file` 命令可以查看文件的类型。
  7. `ELF` 表示该文件采用的是 ELF文件格式,
  8. `LSB relocatable` 表示该文件是可以被动态链接的目标文件,
  9. `x86-64` 表示该文件适用于 x86-64 架构的处理器,
  10. `version 1 (SYSV)` 表示该文件遵循 System V ABI规范。
  11. `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   
  1. `objdump` 是一个可查看目标文件、可执行文件以及动态链接库中的代码和符号表的命令行工具。它可以打印出目标文件中各个部分的汇编代码,可以方便地进行分析和调试。
  2. `-d` 参数表示打印目标文件的汇编代码。
  3. `main.o` 是要进行查看的目标文件名称。
  4. 机器指令,每一行的开头是指令的地址,后面是指令本身。
  5. 0:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi       这个指令是一个 `lea` 指令,它将地址 0x0(即下一个指令所在地址)加上偏移量 0x0,得到的值存入 `%rdi` 寄存器中。
  6.  `sub $0x8,%rsp` 指令将栈顶指针减去 8 个字节,为调用函数做准备;
  7. `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.osay.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 库) 经过 (刚才描述的、漫长的) 编译和链接生成二进制文件,然后被保存到存储设备中,由加载器加载运行。从原理上说,操作系统不过如此。


http://www.ppmy.cn/news/846567.html

相关文章

详解Java虚拟机

资料来源&#xff1a;尚硅谷宋红康JVM全套教程&#xff08;详解java虚拟机&#xff09;_哔哩哔哩_bilibili 1.JVM与Java体系结构 1.1. 前言 如果我们把核心类库的API比做数学公式的话&#xff0c;那么Java虚拟机的知识就好比公式的推导过程。 计算机系统体系对我们来说越来越远…

[CUDA]随笔一

简介 一、 CUDA是什么 CUDA是一种编程语言,全称"Compute Unified Device Architecture",简称"CUDA",中文翻译为计算统一设备架构编程语言. 其出现是为了从串行编程转向并行编程,我们通常使用的线程操作都是利用CPU去进行并行操作,而CUDA是面向GPU进行编程…

【在互联网中保护自己】

近日&#xff0c;某高校毕业生在校期间窃取学校内网数据&#xff0c;收集全校学生个人隐私信息的新闻引发了人们对互联网生活中个人信息安全问题的再度关注。在大数据时代&#xff0c;算法分发带来了隐私侵犯&#xff0c;在享受消费生活等便捷权利的同时&#xff0c;似乎又有不…

汉仪字体mac版 v2014.7

汉仪字体是目前国内最优秀的中文字体之一&#xff0c;现在针对苹果mac系统推出了 汉仪字体mac版 &#xff0c;该字体库内含汉仪菱心体简、汉仪秀英体简、汉仪大黑简、汉仪中黑简、汉仪综艺体简等多款常用的中文字体&#xff0c;能够适用于mac电脑中绝大部分的软件&#xff0c…

好租写字楼字体加密

好租写字楼字体加密反爬破解 前面说到一篇字体加密文章&#xff0c;https://blog.csdn.net/weixin_38927522/article/details/119385859 你说巧不巧&#xff0c;工作中又遇到一个字体加密的反爬。 基本思路&#xff1a;常见的字体加密解决方案就是通过 下载字体进行关系映射…

Spring源码系列-第2章-后置工厂处理器和Bean生命周期

第2章-后置工厂处理器和Bean生命周期 后置工厂处理器属于后置处理器&#xff0c;后置处理器是Spring最核心的部分&#xff0c;Spring几乎所有的附加功能全由它完成。 什么是BeanPostProcessor&#xff1f; public interface BeanPostProcessor {/*** Apply this {code BeanPos…

python装饰器在接口自动化测试中的应用

目录 装饰器 函数的一些特性 简单的装饰器 语法糖 带参数的装饰器 装饰器也是可以接收参数的 类装饰器 装饰器在接口自动化测试项目中应用 在讲解装饰器在接口自动化测试项目的应用之前&#xff0c;我们先来介绍一下python装饰器到底是个什么 装饰器 说装饰器就不得不…

Qt应用程序中的 QApplication

Qt应用程序中的 QApplication QApplication类是一个Qt框架中核心的应用程序类&#xff0c;它提供了管理应用程序的框架、事件循环和系统级配置的基础。在本文中&#xff0c;我们将详细介绍QApplication类的功能和应用场景。 创建 QApplication 对象 通常&#xff0c;我们在主…