XV6 的第一个应用程序是 init 进程,其前身是 initcode.S 代码。其他进程可以从磁盘中读入, 例如 sh、wc 等。需要注意,这些程序不属于内核代码。
在 shell 执行磁盘上的外部命令时,将通过 sys_exec() 系统调用而执行内核函数 exec()。第一个进程 init 开始是内核代码自带的,但它很快就执行磁盘上的 init 程序,也是利用 exec() 完成的。
需要给 exec() 提供可执行文件名以及相应的命令行参数,函数成功返回值为 0,出错返回 -1。
exec() 需要建立进程的虚存影像(即进程页表),包括用户空间和内核空间:setupkvm() 创建内核部分页表、由 allocuvm() 和 loaduvm() 创建用户态的内存影像。
exec.c#L24 根据传入的文件名调用 namei() 查找对应的索引节点 ip,然后 exec.c#L32 检查对应的文件是否符合 ELF 可执行文件格式,即是否包含 ELF_MAGIC。
exec.c#L41 根据可执行文件的程序头表,逐项扫描遍历,对于类型为 ELF_PROG_LOAD 的段将会分配内存完成装载。
对于每一个需要装入的段,先 allocuvm() 分配足够的空间并建立页表映射,然后通过
loaduvm() 从磁盘再读入该段的内容。实际上利用 readelf -h _ls 检查 _ls 可执行文件,发现它只有一个需要装载的段,这与普通 Linux 可执行文件分成代码和数据两个可装载段的安排不同。
完成可执行文件的段的载入过程后,继续分配两个页,第一个页为不可访问页,第二个页作为用户堆栈。此时的进程空间安排为:
代码 - 数据 - bss - 禁止访问页 - 用户堆栈
exec.c#L71 将命令行参数字符串逐个压入到用户堆栈中,并压入假的返回地址 0xffffffff 和参数个数 argc,最后调整堆栈指针。
exec.c#L96 将对新进程 PCB 中有关进程空间的信息进行填写,通过 switchuvm() 完成页表的切换,从而完成进程空间的切换。此时新进程的入口地址 proc->tf->eip 设置为可执行文件的入口地址(变量 elf.entry)。
init 是系统的第一个进程。
initcode.S 是 init 进程的前身,该程序功能就是通过 exec 系统调用来执行磁盘上的 init 程序。
init.c 与正常的 Linux 程序不同,其命令行参数 argv[] 不是外部传入的,而是直接写在代码中的。 程序开始处显示打开 console 控制台设备作为 0 号文件,然后执行两次 dup(0) 使得 1 号、2 号文件也是 console 设备,它们分别对应 “标准输入”、“标准输出” 和 “标准出错” 文件。也就是说,后续的输入输出操作都是控制台的 IO。后面我们会看到控制台的输入是键盘或串口, 输出是 CGA 显卡或串口。
从 init.c 的代码可以看出,init 进程是一个无限循环,创建 sh 进程并等待 sh 结束,如果 sh 进程结束,则 for 的下一次迭代会再次创建 sh。我们在 XV6 终端中用 Ctrl + p 查看当前 sh 进程号为 2,用 kill 2 命令将 sh 进程撤销,将会看到 init 执行 for 循环下一次迭代,打印出 init: starting sh,此时再用 Ctrl + p 查看发现一个新的 sh(进程号不同)。
这是 XV6 的 shell 程序,它的主函数在 sh.c#L144。
首先打开三个标准文件:标准输入、标准输出和标准出错文件,其文件描述符分别为 0、 1、2。但是其代码是打开文件 0 / 1 / 2 / 3 然后再关闭文件 3,读者可以尝试修改一下避免打开再关闭文件造成的时间浪费。
然后是一个循环,不断读入命令行的命令并执行,除了 cd 命令单独处理外,其他的命令(含内部命令)由 runcmd() 完成。其中 parsecmd() 将会把用户输入的命令字符串解析,并填写通用命令数据对象 cmd 结构体中的命令类型。
XV6 中的其他命令数据对象结构体 (execmd、redircmd、pipecmd、listcmd、backcmd)的第一个成员就是通用数据对象 cmd 的全部成员(仅有一个)。
虽然关于命令字符串的分析、重定向、管道、多命令列表等问题的处理非常繁杂,但是其最核心和重要的却是 exec()。
usertests.c 用于测试 XV6 的基本功能,涵盖进程管理、内存管理和文件管理等方面,可以作为 XV6 系统编成的典范,通过分析这些测试代码可以了解到系统的运行概貌。usertests.c 大约有 1800 行源代码,根据需要自行阅读。
从 ELF 文件可以看出 ls 程序是从 0 地址(Entry point address)开始运行代码的,这个与 Linux 上的可执行文件并不相同。
# readelf -h _ls
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x0
Start of program headers: 52 (bytes into file)
Start of section headers: 14124 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 2
Size of section headers: 40 (bytes)
Number of section headers: 16
Section header string table index: 15
如下信息所示,_ls 可执行文件只有一个 ELF 段需要装入,而且代码和数据合并在一个段,因此其属性必须是可读 R、可写 W 和可执行 E。如果查看 ls 程序的 ELF 文件,可以发现该程序没有 .data 节,也就是说没有可以读写的全局变量。
➜ xv6-expansion git:(dev) readelf -l _sh
Elf file type is EXEC (Executable file)
Entry point 0x0
There are 2 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000080 0x00000000 0x00000000 0x01872 0x018f0 RWE 0x20
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10
Section to Segment mapping:
Segment Sections...
00 .text .rodata .eh_frame .data .bss
01
XV6 没有实现 C 语言标准库,因此也不能使用相应的头文件,但是它实现了一个最基本的用户态库 ULIB。Makefile 中关于 ULIB 生成规则为 ULIB = ulib.o usys.o printf.o umalloc.o,包 含了基本的打印输出、系统调用的用户态函数等。严格来说 ULIB 并不是库(例如 Unix / Linux 中的 *.a 或 *.so),它只是若干函数组成的可重定位目标文件,最终通过静态链接进 XV6 的可执行文件中。
用户态的内存管理是通过 umalloc.c 实现的,用户态发出的各个内存分配请求,都使用一个 header 联合体来管理,具体包括起点指针和空间大小。而 header 联合体是直接嵌入在内存空闲区内部的。因此用户态的内存分配并不是以任意字节大小进行分配的,而是按照 sizeof(header) 的整数倍进行分配的,umalloc.c#L69 就是为了求得规整后的长度,以 header 大小计算的长度 nunits。
进程首次执行 malloc() 时,首先对 *freep 和 base 进行初始化。再分配出去之后,可用空间的起点应该要刨除 sizeof(Header) 之后的位置,同理回收的时候给出的地址指针要回退 sizeof(Header) 才能获得 Header 的位置。分配过程是在空闲区的链表中扫描,找到合适的空闲区,并修改链表。如果扫描后未能发现足够大的空间,则使用 morecore() 对堆进行扩展,最终将调用 sbrk() 系统调用。
用户态代码要进行系统调用时通过 ulib 库中的代码,它们是通过 usys.S 经过变异后生成的 usys.o 并链接到 ulib.o 中的。usys.S 中定义了用户代码调用 fork()、open()、read() 等系统调用的 C 函数入口,这些入口函数内部将进一步使用 int 汇编指令通过软中断机制进入内核的系统调用处理函数。
ulib.c 是一些通用的函数,例如内存拷贝、字符串比较等操作。如果读者需要为 XV6 代码进行增强,那么一些比较通用的函数可以放到这个文件中并进入到 ulib.o,或者放到独立的 C 文件中最终进入到 ULIB 对象中。
printf.c 是用户态代码调用的打印函数,注意区分与内核代码使用的 cprintf()、panic() 等函数。 用户态输出代码的核心是 putc() 函数,它可以向文件(例如控制台的显示器)中写入一个字节, 其他输出函数建立在 putc() 之上。