课程 mit-6.828 从 2019 年开始,对 xv6 OS 的实现采用了 RISC-V instruction set architecture(ISA)。最后一份基于 x86 ISA 的实现是 2018 年的课程,本文便是基于这个版本撰写的。

关于 xv6 在 PC 上的部署,可参考我的另一篇文章:https://www.yuque.com/terencexie/geekartt/xv6-deploy-notes

Materials of xv6

xv6-code-book 给人的第一眼感觉似乎是代码满天飞,感觉让人眼花缭乱摸不着头脑。但事实却是,xv6-code-book 的组织有着极为精巧的设计,并在第一页(重点是右半部分,下图为「右半部分」的截图)详细论述了其设计结构:
image.png
可以看到,这里提供了一份按照 topic 划分的 file list,每个 file 前的 number 是 sheet number,而非 code-line number。

后续的每张代码页,保证了每页仅包含 100 行代码。于是,sheet number 同 code line number 便有了一个对应关系:Notes on xv6 based on x86 ISA - 图2 .

如此,我们便能快速定位 xv6-public-rev11.pdf 中对代码的引用。例如,cli (9112)cli 表示 clear interrupt flag(if),用于关闭 interrupt)可以很快被定位到 sheet number 为 91。又根据上图 sheet number 同 file 的关系可知,它是 topic booltloader 下的 bootasm.S 文件的内容。

上图对应的文本为:

  1. # basic headers
  2. 01 types.h
  3. 01 param.h
  4. 02 memlayout.h
  5. 02 defs.h
  6. 04 x86.h
  7. 06 asm.h
  8. 07 mmu.h
  9. 09 elf.h
  10. 09 date.h
  11. # entering xv6
  12. 10 entry.S
  13. 11 entryother.S
  14. 12 main.c
  15. # locks
  16. 15 spinlock.h
  17. 15 spinlock.c
  18. # processes
  19. 17 vm.c
  20. 23 proc.h
  21. 24 proc.c
  22. 30 swtch.S
  23. 31 kalloc.c
  24. # system calls
  25. 32 traps.h
  26. 32 vectors.pl
  27. 33 trapasm.S
  28. 33 trap.c
  29. 35 syscall.h
  30. 35 syscall.c
  31. 37 sysproc.c
  32. # file system
  33. 38 buf.h
  34. 39 sleeplock.h
  35. 39 fcntl.h
  36. 40 stat.h
  37. 40 fs.h
  38. 41 file.h
  39. 42 ide.c
  40. 44 bio.c
  41. 46 sleeplock.c
  42. 47 log.c
  43. 49 fs.c
  44. 58 file.c
  45. 60 sysfile.c
  46. 66 exec.c
  47. # pipes
  48. 67 pipe.c
  49. # string operations
  50. 69 string.c
  51. # low-level hardware
  52. 70 mp.h
  53. 72 mp.c
  54. 73 lapic.c
  55. 76 ioapic.c
  56. 77 kbd.h
  57. 78 kbd.c
  58. 79 console.c
  59. 83 uart.c
  60. # user-level
  61. 84 initcode.S
  62. 84 usys.S
  63. 85 init.c
  64. 85 sh.c
  65. # bootloader
  66. 91 bootasm.S
  67. 92 bootmain.c
  68. # link
  69. 93 kernel.ld

topic list

  1. basic headers
  2. entering xv6
  3. locks
  4. processes
  5. system calls
  6. file system
  7. pipes
  8. string operations
  9. low-level hardware
  10. user-level
  11. bootloader
  12. link

除了这份 topic list,xv6-code-book 还提供了一份基于 keyword 的 inverted index。例如:

  1. swtch 2658
  2. 0374 2428 2466 2657 2658

表示 swtch 这个 keyword 被定义于 codeline 2658(如上可知,它在 processes topic 下的 proc.c 文件中),在 codeline 0374 2428 2466 2657 2658 中被分别使用。

这项功能在具备全局搜索功能的 IDE 上当然无足轻重,但如果你手头上没有电脑或者电脑上没有 IDE,而此时又想通过阅读 xv6 来打发时间,那么,这份 keyword inverted index 就是相当顺手的工具。


xv6 entry point

由下图和 xv6-public-rev11.pdfAppendix B 可知,BIOS program 被单独存储于 motherboard 上的一块硬件,如 EEPROM(Electrically Erasable and Programmable ROM)or a flash-memory,计算机启动时会自动直接读取这块硬件的 program 来运行。

image.png

BIOS 完成硬件的基本检测后,会直接将 boot disk 中 boot sector 的内容(first 512-byte sector of the boot disk),即 boot loader 的内容,加载到 memory 的 0x7c00 的位置,并将 CPU 的 PC register 设置为 0x7c00

显然,根据 CPU 的正常运行流程,它的下一条 instruction 就是读取 0x7c00 位置的内容开始运行,即:读取 boot loader 的内容来运行。

对于 xv6-public 工程来讲,bootasm.S 文件所存放的便是 bootloader 的内容。那么,按照如上论述,bootasm.S 文件必须被放于 disk first sector 中(或 memory 的 0x7c00 位置上),才能让 CPU 在 BIOS 加载完成后首先运行它。

于是,必须将 bootasm.S 放于 qemu 的 disk first sector 中。

Remarks

  • .S 文件是 assembly code file。值得注意的一个小细节是,.S file 不同于 .s file,前者是可以包含 preprocess 的代码的,例如上述 bootasm.S 文件包含 #include 语句。而 .s file 是不可以包含 preprocess 的代码。
  • 虽然 xv6-rev11 的 ISA 是 x86,但 x86 却有各种各样的 assembler,如:MASMNASMGASGNU Assembler)、as86、TASM、a86、Terse 等。在 xv6 中是使用 GNU assembler 来撰写 .S 文件的。GNU assembler 最好的介绍无疑是 CSAPP 的 Chapter 3。另外还可参考的文章是:x86 Assembly Language ProgrammingGNU Assembler Examples .
  • 如同 assembler 各式各样,x86 的 object file 的类型也是各式各样,如:
    • Linux 下的 ELF, COFF, Win32, OMF, a.out 文件;
    • FreeBSD 的 a.out;
    • 以及 rdf, IEEE-695, as86 等文件格式。
  • 更多关于 x86 assembly 的系统讨论,可参考文章:x86 Assembly Notes
  • 关于 boot loader:
    • 当 boot loader 启动时,processor 其实是处于类似 Intel 8088 的 real mode 状态。所以 boot loader 的主要作用便是:逐步切换到 modern OS 的 protected mode。
    • 切换的方式是:通过将 kernel 从 disk 加载到 memory、并将 control 转交给 kernel。

关于 bootstrap

bootasm.S

根据 xv6 项目根目录的 Makefile 可知:

  1. bootblock: bootasm.S bootmain.c
  2. $(CC) $(CFLAGS) -fno-pic -O -nostdinc -I. -c bootmain.c
  3. $(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c bootasm.S
  4. $(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o
  5. $(OBJDUMP) -S bootblock.o > bootblock.asm
  6. $(OBJCOPY) -S -O binary -j .text bootblock.o bootblock
  7. ./sign.pl bootblock
  8. kernel: $(OBJS) entry.o entryother initcode kernel.ld
  9. $(LD) $(LDFLAGS) -T kernel.ld -o kernel entry.o $(OBJS) -b binary initcode entryother
  10. $(OBJDUMP) -S kernel > kernel.asm
  11. $(OBJDUMP) -t kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > kernel.sym
  12. xv6.img: bootblock kernel
  13. dd if=/dev/zero of=xv6.img count=10000
  14. dd if=bootblock of=xv6.img conv=notrunc
  15. dd if=kernel of=xv6.img seek=1 conv=notrunc
  16. qemu: fs.img xv6.img
  17. $(QEMU) -serial mon:stdio $(QEMUOPTS)

make qemu 的依赖关系中,对 bootblock 的定义有:$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o ,即:让其从 0x7C00 开始 start。

并且,bootblock.o 是由 bootasm.obootmain.o 两个文件共同生成的,因此后面才能从 bootasm.o 中访问到 bootmain() 的代码。

概述

  • bootasm.S 文件首先调用 cli 指令(cli 表示 clear interrupt flag(if),用于关闭 interrupt)用于关闭 BIOS 所开启的 interrupts。
  • bootasm.S 文件的末尾(9168),通过 call bootmain 语句调用了 bootmain.c 的代码。
  • bootmain.c 的主要工作,则是将 xv6 OS kernel 的 executable file 放入到 second sector,进而加载进 memory 使之运行。

0、当 processor 处于类似于 Intel 8088 的 real mode 时,它有 8 个 16-bit 的 general register。

但 processor 所处理的 memory address 却是 20-bit 的。其中,segment register(real mode 下的 memory 为 segmentation model,而不是 paging model,下文会论述原因)有 4 类:

  • %cscode segment, instruction fetch
  • %dsdata segment, data reads/writes
  • %esextra segment, for far pointer addressing like video memory and such
  • %ssstack segment, stack reads/writes

用于提供 process 寻址的必要数值。

process 的寻址(reference a memory address)方式:将其中一个 segment register 的值乘以16(即:在末尾 4 位的零),再同 offset 做 bit 相加(此时 offset 的 bit 恰好填补末尾 4 位零的位置),得到作为最终的 memory address。

History
IBM PC Architecture - Intel 8088 “base” architecture 可以看出,IBM 的 bus 是 20-bit 与 CPU Intel 8088 16-bit 的矛盾,导致了 segment 寻址的出现。即:对于 CPU 来讲,它只能给出 16-bit 的地址,于是将其作为 offset,再利用 register 作为 segment 的 index,于是可以通过 segment:offset 的方式来取到特定的地址:
image.png
由上图可以看出,在 segment:offset 中,segment 标识的是一个 segment 的起始位置,从这个起始位置后的具体寻址,是由 CPU 的 16-bit(以 10-bit 作为一个单位的拆分,则:16-bit = (6-bit) + (10-bit) = (2^6) KB = 64KB)的 offset 给出的。

※1、bootasm.S 文件首先调用 cli 来禁止处理 interrupt,是因为在此之前是由 BIOS 来处理 interrupt。而此时 BIOS 已经不再运行、由 boot loader 程序接管,而它并不支持 interrupt 的处理。

此时,若继续允许「由不再运行的 BIOS 所处理的」interrupt,是不合适的、也是危险的。于是便直接将其禁止。等到 kernel 被加载到 memory 时,便可以再次允许 interrupt。

2、在 bootstrap 阶段,由于 kernel 还未被加载进 memory,此时 memory 的管理方式还是较为原始的 segment(参考:x86 memory segment,从 Intel 8086 开始引入) 方式(boot loader 不支持 paging hardware),即:(segmentselector, offset) pair,即:_segment:offset,而不是 paging 的方式。
image.png
根据上图 logical address(segment:offset)、linear address、physical address 的关系,可以看到:

  • logical address ==> linear address:
    • 通过 segment:offset 生成 linear address,由 segmentation hardware 来完成。
  • linear address ==> physical address:
    • 如果 hardware 还支持 paging,则通过 paging hardware 将上述 linear address 转换为 physical address;
    • 否则直接将上述 linear address 作为 physical address。

由于 boot loader 不支持 paging hardware、仅支持 segmentation hardware,则它会直接将 logical address 转换的 linear address 直接作为 physical address,即:「linear address == physical address」。

为了简单,xv6 将 logical address 到 linear address 的转换函数设置为「恒等映射」,即:直接将 logical address 作为 linear address,即:「logical address == linear address」。

再根据前面 bootstrap 阶段的等式「linear address == physical address」,则:xv6 在 bootstrap 阶段会直接将 logical address 作为 physical address,即:「physical address == linear address == logical address」。

显然,当 kernel 接管控制时,唯一有意思的 translation 便是 linear address 到 physical address 的转换,它需要通过 OS 的 paging translation 来实现。

※3、由于 BIOS 并不保证 %ds , %es , %ss 的值,因而其首要任务便是将它们初始化,如:设置 %ax 为 0,再将这个 0 copy 到 %ds , %es , %ss (9115-9118)。而 %cs 通常用于 instruction fetch,便不对其做初始化。

※4、virtual segment:offset 有可能会指代 21-bit physical address,但 Intel 8088 仅支持 20-bit 的 memory address,会自动 discard top bit。

例如:对于以 segment:offset 形式存在的 virtual address 0xffff:0xffff 来讲,它的 physical address 应该是:0xffff + 0xffff = 0x10ffef ,但由于 discard top bit 的关系,其 physical address 便只能是 0x0ffef

很多早期的 software 便依赖于这种 ignore 21st address 的机制。例如,IBM 提供了 hardware 兼容性的机制:可根据 keyboard controller’s output port 的 2nd bit 的值,来设置是否 ignore 21st address bit。如果是 0,则清除 21st bit;如果 2nd bit 是 1,则保留。

因此,boot loader 需要根据 keyboard controller’s output 在 port 0x64 , 0x60 上的值(通过对 keyboard 的 IO)来判断是否允许 ignore 21st address bit(9120-9136)。

5、通常来讲,segment register 在 protected mode 下的用法应如下图所示:
image.png
segment:offset 中的 segment_selector 作为 segment descriptor table 的 index,图中的 index 为 16(注意到 0、8、16 表示的是 GDT/LDT(Global Descriptor Table/Local Descriptor Table)表格的下边界,所以 index 16 恰好对应到 blue node 的位置)。

Ref:

GDT, Global Descriptor Table, is used to define the characteristics of the various memory areas used during program execution, including the base address, the size and access privileges like executability and writability.

These memory areas are called segments in Intel terminology. (From Wikipedia) Segment is a term for memory management in Intel x86 architecture, which is also used collaboratively with paging mechanism.

LDT, Local Descriptor Table, acts similar to GDT, which also saves segments descriptor. The main differences between GDT and LDT is:

  1. GDT have only one copy in system while LDT can have many,
  2. GDT may not changed during execution which LDT often changes when task switches,
  3. entry of LDT is save in GDT.

Entries in GDT and LDT have the same structure.


Ref:

A Local Descriptor Table (LDT) is like theGlobal Descriptor Tablein that it holdsSegmentdescriptors for access to memory. The difference is that every Task/thread can have its own LDT, and the OS can change the LDT Register (LDTR) on every Task switch. That means that every program can have its own list of memory Segment descriptors, and keep them private from other programs:

  • The Code, Data and Heap segments can be private in the LDT - separate from other programs, but available to this program;
  • Each Task/thread within this program can have its own Stack in the LDT, and yet still be able to access the above Segments;
  • Sharing within the program is automatic: just ‘know’ the correct Descriptor reference;
  • If another program (with another LDT) was to attempt to access one of these Segments, it would access its_own_LDT’s Segment rather than the target Segment.

The Local Descriptor Table is a list of descriptors for the current program. It is distinct from the GDT in a number of respects:

  • It cannot hold many types of descriptors;
  • The GDT holds a reference to the LDT (the Base and Limit of the table in memory);
  • It is only accessible by Tasks/threads with the same Local Descriptor Table Register (LDTR) value.

每一条 table entry 包含三部分的内容:

  • 「base」:base address
  • 「limit」:maximum virtual address
  • 「permission」:permission flag bits for the segment

也即是,通过 selector 找到 ,然后通过 offset + <base_address> 来生成这个 segment 的 linear address,它在 kernel 中的 permission 由 字段来提供。

xv6 其实是忽略了整个 segment 的转换过程,直接使用了 paging hardware。通过将 base 设置为 0,将 limit 设置为整个 memory size(4G),也即是完全将 logical address 直接映射到了 linear address。

bootmain.c

bootmain.c 依赖 kernel ,根据 Makefile 可知,后者由 kernel.ld 来定义 link 的过程(关于 ld 的语法,可参考:https://sourceware.org/binutils/docs/ld/index.html)。

bootmain.c 的入口是 void bootmain(void) 方法,其内容包括:

  1. 通过 readseg((uchar *) elf, 4096, 0) 读取 kernel ELF 文件(在 compile 阶段生成、并在 qemu 的启动参数 _QEMUOPTS = -drive file=fs.img,index=1,media=disk,format=raw -drive _**_file=xv6.img_**_,index=0,media=disk,format=raw -smp $(CPUS) -m 512 $(QEMUEXTRA)_ 中作为 _file=xv6.img_ 出现,所以能够在 runtime 阶段直接读取到对应的 disk 部分);
    1. ELF header 的定义是严格按照 ELF header specification 来定义的。
      1. elfhdrproghdr 对应数据结构中的各字段的意思,都可在此文档中找到。
    2. 读取 ELF file 的 1st page 到 scratch space(xv6 设定这块 scratch space 是从 0x10000 开始;在 Appendix B 的 exercise 部分也提到,这个 magic number 是一个 arbitrary location,具有一定的危险性),用于 user data 的暂存。
    3. 通过 elfhdrmagic 字段是否等于 ELF_MAGIC 来确认是否是 ELF 文件(_elfhdr__ELF_MAGIC_ 均定义于 _elf.h_ 文件中,而 _elf.h_ 所使用到的类型,如 _uint_, _ushort_ 定义于 _types.h_ 文件中)。
  2. 使用 readseg 读取 program segment;
  3. 使用 rep stosb 初始化 memory;
  4. 通过 entry() 调用 kernel entry point(通过 __start_ symbol 来定义,对 xv6 来讲它的值是 _0x10000c_),它被定义于 entry (1044)。

  1. // 端口 I/O 描述
  2. inb, inw, inl,
  3. outb, outw, outl,
  4. inb_p, inw_p, inl_p,
  5. outb_p, outw_p, outl_p,
  6. insb, insw, insl,
  7. outsb, outsw, outsl,

This family of functions is used to do low level port input and output

  1. The out* functions do port output;
  2. the in* functions do port input;
  3. the _p-suffix functions pause until the I/O completes
  4. the b-suffix functions are byte-width;
  5. the w-suffix functions word-width;
  6. the l-suffix functions long-width.
  • The “inb”, “inw” and “inl” commands perform an input (read) operation on the given I/O port, and print the result.
  • The “outb”, “outw” and “outl” commands perform an output (write) operation to the given I/O port, sending the given data. Note that the order of the parameters is ADDRESS DATA.
  • The size of the operation is selected according to the suffix, with b meaning byte, w meaning word (16 bits) and l meaning long (32 bits).

通过 bootmain.c 的代码,xv6 将 kernel 从 disk 加载到 memory(加载到 0x10000 地址;在 kernel starts 的这一阶段,paging hardware is not enabled;此时,由 xv6 对 segment 映射的简化知 virtual memory 是等同于 physical memory 的 ),并从 entry.S 文件中的 entry(1044) 开始执行。

entry.S

上一步 boot loader 将 xv6 kernel 加载到 0x100000 地址:

  • 没有选择 0x80100000 是因为在一些 small machine 上没有如此高位的地址;
  • 没有选择 0x0 是因为在 0xa000:0x100000 包含很多 I/O devices。

entry.S 中所做的事情主要是:

  • 打开 page size extension for 4M pages
  • 设置 page directory
  • 打开 paging
  • 设置 stack pointer
  • jump to main()mov $main, %eaxjmp *%eax

main() 则是定义于 main.c 文件的 main() 函数。

总结:boot 的完整流程

总结起来,整个 startup 的过程是:

  • BIOS 将 1st sector of disk 的 bootblock 代码(包含 bootasm.Sbootmain.o 的代码),直接加载到 hardcode 的位置 0x7c00 (从这里算起的 512-byte area,即:0x27c00 - 1),并将 PC register 设置为 0x7c00 ,以便下一条指令从 0x7c00 执行。
  • **bootasm.S** 的最后通过 call bootmain 语句调用了 **bootmain.c** 中的 bootmain() 方法。
  • bootmain.c 将 kernel ELF 的内容从 disk 写入到 memory(由 0x10000 位置开始写入,这是 xv6 设置的一个 arbitrary location,属于 magic number),并通过语句 entry = (void (*)(void)) (elf->entry); entry();也即是:其调用位置由 kernel ELF File Header 的 e_entry 字段提供)来调用 **entry.S** 中的 entry 部分。
    • bootblock 存放于 disk 的第一个 sector,而 kernel ELF 在 disk 上的保存位置从第二个 sector 开始。
    • 由 QEMUOPTS 具体给出对应的 disk 是什么:_file=xv6.img_
    • 根据传统,ELF 格式中 File Header 的 _entry_ 字段由 __start_ 这个 symbol 给出,它恰好定义于 _entry.S_ 中,所以执行流程自然就跳转到 entry.S 的 entry 部分。
    • 编译完成 kernel ELF 后,可通过 _nm kernel | grep _start_ 来查看 __start_ 对应的值:_0010000c T _start_ ,以验证上述说法。
  • entry 部分做完开启 paging、设置 stack pointer 后,最终通过 mov $main, %eaxjmp *%eax 跳转到 **main.c** 文件的 main() 函数。
    • 由 kernel ELF 在 Makefile 中的定义 $(LD) $(LDFLAGS) -T kernel.ld -o kernel entry.o $(OBJS) -b binary initcode entryother 可知,entry.S 已经同 kernel 部分通过 ld 链接到了一起,所以 mov $main, %eax 中是可以找到 symbol main 的具体 memory address 的。

First Allocation/Execution

创建 first address space

image.png
image.png

在上述的 boot load 的过程中,entry.S 顺道做了对 first address space 的分配工作:

  • entry page table(entrypgdir)定义于 main.c 中,
  • 将 entrypgdir 的地址保存于 register %cr3 中(此时为 physical address,因为还未开启 paging,所以设置为 virtual address 没有意义)。
  • 为开启 paging,通过设置 register %cr0CR0_PG flag 来实现:orl $(CR0_PG|CR0_WP), %eax .
  • 虽然开启了 paging,但此时的 instruction 依旧在 low address(低于 kernel base 的 address)工作。但下一步,执行流程需要到 kernel C code 的部分。而 C code 部分是需要在 high address 执行 virtual memory address 的。所以在切换之前,需要让 instruction 的执行切换到 high address。
    • 设置 %esp 到高位:movl $(stack + KSTACKSIZE), %esp (因为 stack 的使用,完全是相对于 %esp 的取值而定的。所以,如果将 %esp 的值切换到了 high address,那么自然,后续对 stack 的使用便都是相对于这个 high address 了)。
    • 使用 indirect jump 到 main 方法。否则,PC-relative 的 direct jump 会让 main 的执行出现在 low address 部分。
    • 如此,便完成了「low address 代码执行」到「high address 代码执行」的切换。

创建 first process

(这部分的内容,可见 xv6 book Code: the first address space 部分)

完成 boot load 的部分后,xv6 的执行流程便从 main.cmain() 开始了。在 main() 方法中,首先完成了 device 和 subsystem 的初始化。

之后开始最重要的工作:创建第一个 user process(对应到 main.cmain() 中,尾部语句 userinit(); 的调用)。

image.png

userinit(); 定义于 proc.c 中,其工作是:

  • 调用 allocproc 方法,扫描 proc table 中的 unused slot。
  • 如果未找到,直接返回 0.
  • 如果找到,则:
    • 修改 state 为 EMBRYO,以此标识为 used
    • 分配 pid
    • 为这个 unused slot 分配 kernel stack
  • ……
  • 调用 setupkvm() 方法(switch to kernel VM?),用于为 process 创建如 Figure 1-2 那样 layout 的 page table。
  • 设置这个新获得的 p->state 为 RUNNABLE,以标准 first process 已经准备完成。

执行 first process

  • main.c#main() 调用完 userinit() 后,调用 mpmain() 方法:通过调用 scheduler 来启动 first process running。
  • ……

First system call exec

  • initcode.S 中调用了 exec 这个 system call:movl $SYS_exec, %eaxint T_SYSCALL

Memory Layout

物理地址的对应内容
image.png
由 32-bit 的 memory 被 16-digit 表示,则有:2^32 = (2^4)^8 = (16)^8,即:8-bit 的 16-digit 才能完整表示 32-bit 的 memory,所以,其起始位置的值应该是:0x00000000 (8位)。

  1. 0x00100000-0x00400000 xv6 OS (not filled)
  2. 0x000A0000-0x00100000 device
  3. # --- Low Memory ---
  4. 0x00010000-0x00011000 kernel ELF header (4096 bytes)
  5. 0x00007c00-0x00007d00 boot loader code (512 bytes)
  6. 0x00000000-0x00007c00 boot loader stack