├── BUAA OS实验笔记之Lab1.md ├── BUAA OS实验笔记之Lab2.md ├── BUAA OS实验笔记之Lab3.md ├── BUAA-OS实验笔记之Lab4.md ├── BUAA-OS实验笔记之Lab5.md ├── BUAA-OS实验笔记之Lab6.md ├── README.md └── guide-book.pdf /BUAA OS实验笔记之Lab1.md: -------------------------------------------------------------------------------- 1 | ## 一、总前言 2 | 操作系统是一门重课,我并不知晓自己是否做好了准备。“在这样的情况下就开始写文章,是否太着急了?” 我这样想,不知道对这门课自己是否有写文章的水平,也不知道自己是否会半途而废。 3 | 4 | 但我还是决定开始,并不是因为有什么十足的信心,而是希望这一系列文章能帮助自己更深入的理解操作系统的知识,在讲解的过程中发现自己的不足。我希望这系列能持续下去,希望未来的自己看到结果时能够满意;希望他人也能从中得到收获。 5 | 6 | 7 | ## 二、进入操作系统 8 | ### (1)操作系统的启动 9 | 操作系统的 boot 过程是一个复杂繁琐的过程,从 bios 从上电后的启动地址开始执行,初始化硬件,读取磁盘的主引导记录,跳转到 bootloader;到加载内核程序,跳转到操作系统入口。这一整个过程难以详述…… 10 | 11 | 不过幸好在本实验中,这些都不是问题,因为我们所使用的 GXemul 模拟器不会去执行上述环节,它可以直接加载 ELF 格式内核。也就是说,我们的操作系统实验是从跳转到操作系统入口开始的。 12 | 13 | 14 | ### (2)内核的入口和内存布局 15 | 所以,哪里是操作系统入口?内核入口的设置在 kernel.lds 中,这是一个链接器脚本,用于帮助链接器确定最终生成的文件的组织形式。 16 | 17 | 我们看一下该文件的开头。 18 | ```c 19 | /* 20 | * Set the architecture to mips. 21 | */ 22 | OUTPUT_ARCH(mips) 23 | 24 | /* 25 | * Set the ENTRY point of the program to _start. 26 | */ 27 | ENTRY(_start) 28 | ``` 29 | 其中 `OUTPUT_ARCH(mips)` 设置了最终生成的文件采用的架构,对于 MOS 来说就是 mips。而 `ENTRY(_start)` 便设置了程序的入口函数。因此 MOS 内核的入口即 `_start`。这是一个符号,对应的是 init/start.S 中的 30 | ```c 31 | .text 32 | EXPORT(_start) 33 | .set at 34 | .set reorder 35 | /* disable interrupts */ 36 | mtc0 zero, CP0_STATUS 37 | /* omit... */ 38 | ``` 39 | 40 | `EXPORT` 是一个宏,该宏将符号设置为全局符号,这样才对链接器可见。 41 | ```c 42 | #define EXPORT(symbol) \ 43 | .globl symbol; \ 44 | symbol: 45 | ``` 46 | 47 | 现在让我们回到 kernel.lds,原来其中还定义了其他内容。 48 | ```c 49 | SECTIONS { 50 | /* Exercise 3.10: Your code here. */ 51 | 52 | /* fill in the correct address of the key sections: text, data, bss. */ 53 | /* Exercise 1.2: Your code here. */ 54 | . = 0x80010000; 55 | .text : { *(.text) } 56 | .data : { *(.data) } 57 | .bss : { *(.bss) } 58 | 59 | bss_end = .; 60 | . = 0x80400000; 61 | end = . ; 62 | } 63 | ``` 64 | 65 | 这一部分是用来设置程序中的段位置的,它将`.text` `.data` `.bss` 段设置在以 `0x8001 0000` 为开始的地址空间中。另外它还设置了 `bss_end` 和 `end` 符号的地址,这将在之后的实验中起作用。 66 | 67 | 这些设置的依据是什么呢?实际上只是人为的规定。在裸机上,我们事先规定好了不同区域的内存用于何种功能。内存布局图可在 include/mmu.h 中找到 68 | ```c 69 | /* 70 | o 4G -----------> +----------------------------+------------0x100000000 71 | o | ... | kseg2 72 | o KSEG2 -----> +----------------------------+------------0xc000 0000 73 | o | Devices | kseg1 74 | o KSEG1 -----> +----------------------------+------------0xa000 0000 75 | o | Invalid Memory | /|\ 76 | o +----------------------------+----|-------Physical Memory Max 77 | o | ... | kseg0 78 | o KSTACKTOP-----> +----------------------------+----|-------0x8040 0000-------end 79 | o | Kernel Stack | | KSTKSIZE /|\ 80 | o +----------------------------+----|------ | 81 | o | Kernel Text | | PDMAP 82 | o KERNBASE -----> +----------------------------+----|-------0x8001 0000 | 83 | o | Exception Entry | \|/ \|/ 84 | o ULIM -----> +----------------------------+------------0x8000 0000------- 85 | o | User VPT | PDMAP /|\ 86 | o UVPT -----> +----------------------------+------------0x7fc0 0000 | 87 | o | pages | PDMAP | 88 | o UPAGES -----> +----------------------------+------------0x7f80 0000 | 89 | o | envs | PDMAP | 90 | o UTOP,UENVS -----> +----------------------------+------------0x7f40 0000 | 91 | o UXSTACKTOP -/ | user exception stack | BY2PG | 92 | o +----------------------------+------------0x7f3f f000 | 93 | o | | BY2PG | 94 | o USTACKTOP ----> +----------------------------+------------0x7f3f e000 | 95 | o | normal user stack | BY2PG | 96 | o +----------------------------+------------0x7f3f d000 | 97 | a | | | 98 | a ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | 99 | a . . | 100 | a . . kuseg 101 | a . . | 102 | a |~~~~~~~~~~~~~~~~~~~~~~~~~~~~| | 103 | a | | | 104 | o UTEXT -----> +----------------------------+------------0x0040 0000 | 105 | o | reserved for COW | BY2PG | 106 | o UCOW -----> +----------------------------+------------0x003f f000 | 107 | o | reversed for temporary | BY2PG | 108 | o UTEMP -----> +----------------------------+------------0x003f e000 | 109 | o | invalid memory | \|/ 110 | a 0 ------------> +----------------------------+ ---------------------------- 111 | o 112 | */ 113 | ``` 114 | 同时在该头文件中,还定义了一些和内存相关的宏常量和宏函数。在本次实验中用到的有 115 | ```c 116 | #define KSTACKTOP (ULIM + PDMAP) 117 | ``` 118 | 119 | ## 三、内核初始化 120 | 现在我们已经进入到 `_start` 函数中了。这一部分内容不多。在 init/start.S 中只有这些内容。 121 | ```c 122 | .text 123 | EXPORT(_start) 124 | .set at 125 | .set reorder 126 | /* disable interrupts */ 127 | mtc0 zero, CP0_STATUS 128 | 129 | /* hint: you can reference the memory layout in include/mmu.h */ 130 | /* set up the kernel stack */ 131 | /* Exercise 1.3: Your code here. (1/2) */ 132 | la sp, KSTACKTOP 133 | 134 | /* jump to mips_init */ 135 | /* Exercise 1.3: Yoiur code here. (2/2) */ 136 | j mips_init 137 | ``` 138 | 139 | `.text` 表示一下内容都是可执行的汇编指令。`.set at` 设置允许汇编器使用 `at` 寄存器。`.set reorder` 设置允许汇编器进行指令重排。`mtc0 zero, CP0_STATUS` 正如注释所言,停用了中断。这些并不重要。 140 | 141 | 更重要的是本实验中需要填写的部分。首先,我们需要初始化 `sp` 寄存器的地址。`sp` 用于实现栈帧,是完成函数调用的基础。通过查看内存布局图,我们可以得知内核的栈处在 `0x8040 0000` 以下的位置。 142 | 143 | 可是,我们不应该将 `sp` 初始化到栈底所在的位置吗?为什么加载地址所用的符号名称为 `KSTACKTOP`?这是因为 `sp` 是低地址增长的,所以其栈底地址就在“顶”了。 144 | 145 | 另外还需要注意一点,这里只能使用 `la` 指令设置地址。因为 `0x8040 0000` 数值超出了立即数所能表达的范围,不能使用 `lui`、`li` 等指令。 146 | 147 | 最后,`j mips_init` 是一条跳转语句,跳转到的符号是一个 c 语言函数,定义在 init/init.c 中。记得第一次看到 c 语言和汇编相互调用的时候,感到十分惊奇。 148 | ```c 149 | void mips_init() { 150 | printk("init.c:\tmips_init() is called\n"); 151 | /* omit... */ 152 | } 153 | ``` 154 | 这个函数在本实验中几乎毫无内容,因此本实验中内核的程序便到此为止了。 155 | 156 | 最后还需要说明一点,跳转到 `mips_init` 使用的是 `j` 而非 `jal`。这是因为按照操作系统的设计,根本不存在 mips_init 函数返回的情况。 157 | 158 | ## 四、printk 的实现 159 | GXemul 的调试只有汇编码,打桩调试又成了大多数时候的手段。为此实验贴心地让我们在最开始就实现一个类 `printf` 函数(这当然是假的,主要目的是提供一个输出评测的方式)。 160 | 161 | 在 kern/printk.c 中有 `printk` 的定义 162 | ```c 163 | void printk(const char *fmt, ...) { 164 | va_list ap; 165 | va_start(ap, fmt); 166 | vprintfmt(outputk, NULL, fmt, ap); 167 | va_end(ap); 168 | } 169 | ``` 170 | 和 `printf` 一样,这是一个具有边长参数的函数。`va_list ap` 是变长参数列表。`va_start` 和 `va_end` 是用来初始化和结束变长参数列表的宏。真正重要的只有第三条语句 `vprintfmt(outputk, NULL, fmt, ap);` 171 | 172 | 值得注意的是这条语句的第一个参数。这是一个回调函数,其定义同在kern/printk.c。是一个输出字符串的函数。其中 `printcharc` 一定是一个输出单个字符的函数。 173 | ```c 174 | void outputk(void *data, const char *buf, size_t len) { 175 | for (int i = 0; i < len; i++) { 176 | printcharc(buf[i]); 177 | } 178 | } 179 | ``` 180 | 181 | 更深入一层,在 kern/console.c 中有 `printcharc` 的定义,这样就完全到达底层了。输出字符本质上是向一个地址写入该字符所对应的数值。在同一个文件中还有读取字符的函数,是读取同一个地址的数值作为字符。 182 | ```c 183 | void printcharc(char ch) { 184 | *((volatile char *)(KSEG1 + DEV_CONS_ADDRESS + DEV_CONS_PUTGETCHAR)) = ch; 185 | } 186 | ``` 187 | 188 | 让我们回到 `printk`。不,应该是进入到 `vprintfmt`。这个函数定义在 lib/print.c。是需要我们填空的函数。首先看到一些定义的变量。我们需要设置这些变量的数值。 189 | ```c 190 | void vprintfmt(fmt_callback_t out, void *data, const char *fmt, va_list ap) { 191 | char c; 192 | const char *s; 193 | long num; 194 | 195 | int width; 196 | int long_flag; // output is long (rather than int) 197 | int neg_flag; // output is negative 198 | int ladjust; // output is left-aligned 199 | char padc; // padding char 200 | ``` 201 | 202 | 接着是一个循环,很明显是用来处理 `fmt` 字符串并按照格式进行输出的。这里我们翻一下指导书,可以找到格式的定义。 203 | ```text 204 | %[flags][width][length] 205 | ``` 206 | 207 | 在编写代码前仔细思考一下: 208 | - flags 有三种情况,`-`、`0` 或没有 209 | - width 只可能出现数字 210 | - length 只有两种情况 `l` 或没有。 211 | 212 | 我们很容易可以写出 for 循环中的代码。需要注意这里使用了回调函数 `out` 进行输出。 213 | 214 | ```c 215 | /* scan for the next '%' */ 216 | /* Exercise 1.4: Your code here. (1/8) */ 217 | const char * p = fmt; 218 | while (*p != '%' && *p != '\0') { 219 | p++; 220 | } 221 | 222 | /* flush the string found so far */ 223 | /* Exercise 1.4: Your code here. (2/8) */ 224 | out(data, fmt, p - fmt); 225 | fmt = p; 226 | 227 | /* check "are we hitting the end?" */ 228 | /* Exercise 1.4: Your code here. (3/8) */ 229 | if (*fmt == '\0') { 230 | break; 231 | } 232 | 233 | /* we found a '%' */ 234 | /* Exercise 1.4: Your code here. (4/8) */ 235 | fmt++; 236 | 237 | /* check format flag */ 238 | /* Exercise 1.4: Your code here. (5/8) */ 239 | ladjust = 0; 240 | padc = ' '; 241 | if (*fmt == '-') { 242 | ladjust = 1; 243 | fmt++; 244 | } else if (*fmt == '0') { 245 | padc = '0'; 246 | fmt++; 247 | } 248 | 249 | /* get width */ 250 | /* Exercise 1.4: Your code here. (6/8) */ 251 | width = 0; 252 | while ('0' <= *fmt && *fmt <= '9' && *fmt != '\0') { 253 | width *= 10; 254 | width += *fmt - '0'; 255 | fmt++; 256 | } 257 | 258 | /* check for long */ 259 | /* Exercise 1.4: Your code here. (7/8) */ 260 | long_flag = 0; 261 | if (*fmt == 'l') { 262 | long_flag = 1; 263 | fmt++; 264 | } 265 | ``` 266 | 267 | 之后根据 specifier 判断输出类型。不同的输出类型有不同的函数。感兴趣的可以深入研究。算法比较基本。 268 | 269 | 最后还有一个输出 `%d` 类型的部分需要填写。唯一有不同的地方是需要根据正负号设置 `print_num` 的 `neg_flag` 参数。 270 | 271 | ## 五、编写 readelf 工具 272 | 本实验的还有一个和内核关系不大的内容,需要自己编写一个读取 elf 文件头的工具。该程序的相关代码在 tools/readelf 文件夹中。 273 | 274 | 程序的入口在 tools/readelf/main.c 文件中。`main` 函数首先判断参数是否合法,随后 275 | 1. 打开文件 276 | 2. 获取文件大小 277 | 3. 将文件内容读取到内存中 278 | 4. 调用 `readelf` 函数进行处理 279 | ```c 280 | int main(int argc, char *argv[]) { 281 | if (argc < 2) { 282 | fprintf(stderr, "Usage: %s \n", argv[0]); 283 | return 1; 284 | } 285 | 286 | FILE *fp = fopen(argv[1], "rb"); 287 | if (fp == NULL) { 288 | perror(argv[1]); 289 | return 1; 290 | } 291 | 292 | if (fseek(fp, 0, SEEK_END)) { 293 | perror("fseek"); 294 | goto err; 295 | } 296 | int fsize = ftell(fp); 297 | if (fsize < 0) { 298 | perror("ftell"); 299 | goto err; 300 | } 301 | 302 | char *p = malloc(fsize + 1); 303 | if (p == NULL) { 304 | perror("malloc"); 305 | goto err; 306 | } 307 | if (fseek(fp, 0, SEEK_SET)) { 308 | perror("fseek"); 309 | goto err; 310 | } 311 | if (fread(p, fsize, 1, fp) < 0) { 312 | perror("fread"); 313 | goto err; 314 | } 315 | p[fsize] = 0; 316 | 317 | return readelf(p, fsize); 318 | err: 319 | fclose(fp); 320 | return 1; 321 | } 322 | ``` 323 | 值得注意的是异常处理使用到了 `goto` 语句。 324 | 325 | `readelf` 函数是功能的主要实现函数,也是我们需要补全的部分。首先,将传入的 `binary` 指针转换为 elf 格式结构体的指针。c 语言中通过结构体实现二进制内容的划分。 326 | ```c 327 | int readelf(const void *binary, size_t size) { 328 | Elf32_Ehdr *ehdr = (Elf32_Ehdr *)binary; 329 | ``` 330 | 331 | 随后判断文件是否是 elf 格式。 332 | ```c 333 | // Check whether `binary` is a ELF file. 334 | if (!is_elf_format(binary, size)) { 335 | fputs("not an elf file\n", stderr); 336 | return -1; 337 | } 338 | ``` 339 | 340 | 其中 `is_elf_format` 函数通过文件大小和魔数来进行判断 341 | ```c 342 | int is_elf_format(const void *binary, size_t size) { 343 | Elf32_Ehdr *ehdr = (Elf32_Ehdr *)binary; 344 | return size >= sizeof(Elf32_Ehdr) && ehdr->e_ident[EI_MAG0] == ELFMAG0 && 345 | ehdr->e_ident[EI_MAG1] == ELFMAG1 && ehdr->e_ident[EI_MAG2] == ELFMAG2 && 346 | ehdr->e_ident[EI_MAG3] == ELFMAG3; 347 | } 348 | ``` 349 | 350 | 回到 `readelf` 函数,我们希望读取节表(section table)的内容。首先要确定节表的位置、节表头的数量和大小。`Elf32_Ehdr` 结构体中有 `e_shoff` 用来记录节表位置相对于 elf 整体地址的偏移量。另有 `e_shnum`、`e_shentsize` 分别表示节表的数量和大小。 351 | ```c 352 | // Get the address of the section table, the number of section headers and the size of a 353 | // section header. 354 | const void *sh_table; 355 | Elf32_Half sh_entry_count; 356 | Elf32_Half sh_entry_size; 357 | /* Exercise 1.1: Your code here. (1/2) */ 358 | sh_table = binary + ehdr->e_shoff; 359 | sh_entry_count = ehdr->e_shnum; 360 | sh_entry_size = ehdr->e_shentsize; 361 | ``` 362 | 363 | 之后我们遍历所有的节表,每个节表头的地址由节表头地址加上多个节表头的大小得到 `sh_table + i * sh_entry_size`。我们将其转化为节表头结构体的指针,获取该节表头所对应的节的地址 `addr = shdr->sh_addr`。最后输出结果。 364 | ```c 365 | // For each section header, output its index and the section address. 366 | // The index should start from 0. 367 | for (int i = 0; i < sh_entry_count; i++) { 368 | const Elf32_Shdr *shdr; 369 | unsigned int addr; 370 | /* Exercise 1.1: Your code here. (2/2) */ 371 | shdr = (Elf32_Shdr*)(sh_table + i * sh_entry_size); 372 | addr = shdr->sh_addr; 373 | 374 | printf("%d:0x%x\n", i, addr); 375 | } 376 | 377 | return 0; 378 | } 379 | ``` 380 | 381 | 在其中唯一需要明确的是,节表存储了节的相关信息,但并不是节本身。如下的图片就很好地说明了节表和节的关系。节是文件中间的部分,而节表的位置则在文件的最后,section header table 的位置。程序表的内容也与节表类似。 382 | -------------------------------------------------------------------------------- /BUAA OS实验笔记之Lab2.md: -------------------------------------------------------------------------------- 1 | ## 一、Lab2 前言 2 | 这篇文章应该是我目前写过的文章中长度排行前几的了。Lab2 的内容着实繁多,不仅是分页内存管理本身的理论和实现细节颇多;操作系统的基本知识和注意事项也占据了很大的篇幅。后者在不理解的情况下实在会对本次实验产生许多困惑。本人也是在逐步地探索之后才得以有了较多的认识——当然,这一认识或许也只是片面的。 3 | 4 | 本文逐函数、逐代码地讲解了 Lab2 中新增的内容。主要在于内核初始化中关于内存的部分以及分页内存管理的实现。在本文中,关于链表宏和虚拟/物理内存的辨析也占据了比较多的内容。 5 | 6 | 7 | 8 | ## 二、内核初始化(续) 9 | 在 Lab1 中,我们的内核初始化过程只进行了一部分。因为 Lab1 中 `mips_init` 函数几乎没有任何功能。在 Lab2 中,我们会继续推进这一过程。 10 | 11 | 在 Lab2 中,我们会建立操作系统的内存管理机制。具体来说,我们会在 `mips_init` 中调用三个函数 `mips_detect_memory`、`mips_vm_init` 和 `page_init`。这三个函数会分别完成探测内存、初始化虚拟地址和初始化页的工作。接下来我们会分别介绍这三个函数。 12 | 13 | Lab2 中 `mips_init` 的结构如下: 14 | ```c 15 | void mips_init() { 16 | printk("init.c:\tmips_init() is called\n"); 17 | 18 | // lab2: 19 | mips_detect_memory(); 20 | mips_vm_init(); 21 | page_init(); 22 | 23 | while (1) { 24 | } 25 | } 26 | ``` 27 | 28 | ### (1)探测内存 29 | `mips_detect_memory` 的作用是获取总物理内存大小,并根据物理内存计算分页数。 30 | > 注意!是物理内存 31 | 32 | ```c 33 | void mips_detect_memory() { 34 | /* Step 1: Initialize memsize. */ 35 | memsize = *(volatile u_int *)(KSEG1 | DEV_MP_ADDRESS | DEV_MP_MEMORY) 36 | ``` 37 | 第一步中的这条语句似乎使人困惑。为什么这样就可以获得物理内存大小了呢?我们可以查看一下 `DEV_MP_ADDRESS` 和 `DEV_MP_MEMORY` 所在的头文件。它们定义在 include/driver/dev_mp.h 中。 38 | 39 | 恰好 include/driver 目录下有一个 README,其中提到 40 | ```text 41 | The files in this directory describe the devices found in GXemul's "test 42 | machines". These machines do not match any real-world machines, but they 43 | have devices that are similar to those found in real machines: 44 | 45 | omit... 46 | 47 | o) mp (dev_mp): 48 | A multiprocessor inter-processor communication device. 49 | It also contains other useful functionality, such as retrieving 50 | the amount of "physical" RAM installed in the emulated machine. 51 | 52 | omit... 53 | ``` 54 | 这就说明 dev_mp.h 中的信息应该是关于 multiprocessor inter-processor communication device (mp) 的相关信息的。这是 GXemul 定义的虚拟设备。mp 能够检索(retrive)RAM 数量(?amount)。这就足够了。 55 | 56 | 再看 dev_mp.h。通过注释我们可以得知,这个头文件中定义的 `DEV_MP_ADDRESS` 是(mp 设备)默认的物理基地址。而 `DEV_MP_MEMORY` 是物理基地址到 “设备寄存器” 的偏移量。 57 | ```c 58 | /* 59 | * Default (physical) base address and length: 60 | */ 61 | 62 | #define DEV_MP_ADDRESS 0x11000000ULL 63 | #define DEV_MP_LENGTH 0x00000100ULL 64 | 65 | // omit... 66 | 67 | /* 68 | * Offsets from the base address to reach the MP device' registers: 69 | */ 70 | 71 | #define DEV_MP_MEMORY 0x0090 72 | 73 | // omit... 74 | ``` 75 | 76 | 现在我们就可以解释 `DEV_MP_ADDRESS | DEV_MP_MEMORY` 的含义了。这表示一个物理地址,该地址正好对应 mp 设备的 `MEMORY` 寄存器。其中存储了物理内存大小的相关的信息。 77 | 78 | 但是在代码中,我们只能使用虚拟地址进行访存。因此我们需要得到该物理地址对应的虚拟地址。这时候 `KSEG1` 就派上用场了。根据指导书我们知道,kseg1 段的虚拟地址转换为物理地址只需要将最高 3 位置 0,不通过 TLB,同时也不通过 cache。实际上,kseg1 段就是为访问外设准备的。 79 | 80 | 我们将物理地址 `DEV_MP_ADDRESS | DEV_MP_MEMORY` 转换为虚拟地址 `KSEG1 |`,指明该地址指向的是一个无符号整数 `(volatile u_int *)`,最后取出该位置的值 `memsize = *`。这就是这一条语句的含义。 81 | 82 | 话说回来,在 Lab1 的 `printcharc` 函数中我们也遇到过类似的写法:`KSEG1 + DEV_CONS_ADDRESS + DEV_CONS_PUTGETCHAR`。但是当时没有进一步说明。本使用中的使用的是按位或而非加法,也进一步加深了代码的迷惑程度。 83 | 84 | > 我在这里需要插一句,因为我不知道要把这部分内容放到哪里。 85 | > 86 | > 根据指导书可以知道,kseg1 段位于 `0xa0000000~0xbfffffff`,映射的物理地址为 `0x00000000~0x1fffffff`。同样 kseg0 段位于 `0x80000000~0x9fffffff`,但映射的物理地址也为 `0x00000000~0x1fffffff`。这是否出错了呢?其实不是。 87 | > 88 | > kseg0 和 kseg1 中两个不同的虚拟地址,其实就对应同一个物理地址。区分 kseg0 和 kseg1 的目的,就在于区分是否使用 cache。 89 | > 90 | > 同样的,其实 kuseg 映射的物理地址也和 kseg0 和 kseg1 相同。你可能会想:“不对呀,kuseg 的虚拟地址空间明显大于 kseg0 和 kseg1。怎么可能映射到同样的物理地址空间?” 其实解决问题的关键就在于页表。这在本篇文章的后面会详细讲解。 91 | 92 | 终于分析完第一条语句了。`mips_detect_memory` 中剩下的部分就比较简单了。 93 | ```c 94 | /* Step 2: Calculate the corresponding 'npage' value. */ 95 | /* Exercise 2.1: Your code here. */ 96 | npage = memsize / BY2PG; 97 | 98 | printk("Memory size: %lu KiB, number of pages: %lu\n", memsize / 1024, npage); 99 | } 100 | ``` 101 | 在这部分中,我们将总物理内存大小除以页大小,得到总页数。并调用 Lab1 中编写的 `printk` 输出相关信息。需要注意的是 `BY2PG` 是一个宏,定义在 include/mmu.h 中。根据注释可以得知这表示一页的字节数。因此除以 `BY2PG` 即可得到总页数。 102 | ```c 103 | #define BY2PG 4096 // bytes to a page 104 | ``` 105 | 106 | ### (2)初始化虚拟地址 107 | 接着我们考虑 `mips_vm_init` 函数。这个函数将申请一部分空间用作页控制块。页控制块是 `struct Page` 类型的结构体。每一个页控制块对应一个物理页。 108 | 109 | `struct Page` 的结构很简单,只不过因为使用了链表宏(链表宏会在之后讲解),导致不容易理解。如下是展开后的 `struct Page` 110 | ```c 111 | struct Page { 112 | struct { 113 | struct Page *le_next; 114 | struct Page **le_prev; 115 | } pp_link; 116 | u_short pp_ref; 117 | }; 118 | ``` 119 | 120 | 其中只有一个用于表示链表前后节点的结构体 `pp_link`;以及用于引用计数,反映页的使用情况的 `pp_ref`。 121 | 122 | 这么简单的结构是如何映射到物理页的呢?其实也很简单。在 include/pmap.h 中我们可以得知,所有的页控制块都保存在一个数组中 `extern struct Page *pages`(这也是我们将要在`mips_vm_init`申请的数组)。 123 | 124 | 通过指针减法,可以得到对应的页控制块是第几个页 125 | ```c 126 | static inline u_long page2ppn(struct Page *pp) { 127 | return pp - pages; 128 | } 129 | ``` 130 | 131 | 一个物理页有确定的大小,我们将第几个页乘以物理页大小即可得到对应物理页的基地址。`page2pa` 表示 “page to physical addresss” 的意思。 132 | ```c 133 | // in mmu.h 134 | #define PGSHIFT 12 135 | 136 | // in pmap.h 137 | static inline u_long page2pa(struct Page *pp) { 138 | return page2ppn(pp) << PGSHIFT; 139 | } 140 | ``` 141 | 142 | 同样的,还有反过程。通过物理地址获取对应的页控制块。`PPN` 宏获取物理地址对应的页数,`pa2page` 根据该页数求对应的页控制块。 143 | ```c 144 | // in mmu.h 145 | #define PPN(va) (((u_long)(va)) >> 12) 146 | 147 | // in pmap.h 148 | static inline struct Page *pa2page(u_long pa) { 149 | if (PPN(pa) >= npage) { 150 | panic("pa2page called with invalid pa: %x", pa); 151 | } 152 | return &pages[PPN(pa)]; 153 | } 154 | ``` 155 | 156 | 这样页控制块就大概讲清楚了。让我们回到 `mips_vm_init`。其中真正重要的只有第一句。在这一句中,我们调用了 `alloc` 函数申请了 `npage` 个 `struct Page` 大小的内存。并以 `BY2PG`(页的大小)进行对齐。同时将申请的内存中内容初始化为 0。这到底是什么意思?还是让我们看一下 `alloc` 的定义。 157 | ```c 158 | void mips_vm_init() { 159 | /* Allocate proper size of physical memory for global array `pages`, 160 | * for physical memory management. Then, map virtual address `UPAGES` to 161 | * physical address `pages` allocated before. For consideration of alignment, 162 | * you should round up the memory size before map. */ 163 | pages = (struct Page *)alloc(npage * sizeof(struct Page), BY2PG, 1); 164 | printk("to memory %x for struct Pages.\n", freemem); 165 | printk("pmap.c:\t mips vm init success\n"); 166 | } 167 | ``` 168 | 169 | 在内核启动阶段,我们还没有什么像样的内存申请方式。因此只能自己写一个。在 `alloc` 函数中,我们首先定义了两个变量。 170 | ```c 171 | void *alloc(u_int n, u_int align, int clear) { 172 | extern char end[]; 173 | u_long alloced_mem; 174 | ``` 175 | 176 | `alloced_mem` 是在程序中表示已分配内存的变量。`end` 是一个外部定义的变量,我们可以在 kernel.lds 中找到其对应的值。这在 Lab1 中也有所提及。需要注意的是,这里使用的是虚拟地址。查看内存分布表可知,此虚拟地址位于 kseg0 中。我们将内核的代码与数据结构都存储到 kseg0。接下来的内容都是在 kseg0 中进行。 177 | ```c 178 | . = 0x80400000; 179 | end = . ; 180 | ``` 181 | 182 | 回到 `alloc`。第一步,我们先对 `freemem` 的值进行初始化。`freemem` 是用来表示可用内存地址的全局变量。 183 | ```c 184 | if (freemem == 0) { 185 | freemem = (u_long)end; // end 186 | } 187 | ``` 188 | 189 | 接着,我们把 `freemem` 以参数 `align` 对齐。这样接下来我们分配的内存才能从能被参数 `align` 整除的地址开始。 190 | ```c 191 | /* Step 1: Round up `freemem` up to be aligned properly */ 192 | freemem = ROUND(freemem, align); 193 | ``` 194 | 195 | `ROUND` 宏定义在 include/types.h 中。只能对齐 2 的整数幂。主要原理是将低位抹零。`ROUND` 宏还有一个对应的宏 `ROUNDDOWN`。前者向上对齐,后者向下对齐。 196 | ```c 197 | /* Rounding; only works for n = power of two */ 198 | #define ROUND(a, n) (((((u_long)(a)) + (n)-1)) & ~((n)-1)) 199 | #define ROUNDDOWN(a, n) (((u_long)(a)) & ~((n)-1)) 200 | ``` 201 | 202 | 在 `align` 中,我们现在确定已分配空间的上界,继续分配参数 `n` 个字节的内存。 203 | ```c 204 | /* Step 2: Save current value of `freemem` as allocated chunk. */ 205 | alloced_mem = freemem; 206 | 207 | /* Step 3: Increase `freemem` to record allocation. */ 208 | freemem = freemem + n; 209 | ``` 210 | 211 | 如果需要清零,则使用 `memset` 函数清零。接着返回 `alloced_mem` 的地址。 212 | ```c 213 | /* Step 4: Clear allocated chunk if parameter `clear` is set. */ 214 | if (clear) { 215 | memset((void *)alloced_mem, 0, n); 216 | } 217 | 218 | /* Step 5: return allocated chunk. */ 219 | return (void *)alloced_mem; 220 | } 221 | ``` 222 | 223 | 值得注意的是中间还有一个类似于 `assert` 的语句 224 | ```c 225 | // Panic if we're out of memory. 226 | panic_on(PADDR(freemem) >= memsize); 227 | ``` 228 | 229 | 需要说明的是其中用到的的 `PADDR` 宏。这个宏将 kseg0 中的虚拟地址转化为物理地址。`ULIM` 是 kseg0 的基地址,因此 `a - ULIM` 等价于最高三位抹零。`PADDR` 还有一个对应宏 `KADDR`,将物理地址转换为 kseg0 中的内核虚拟地址,只不过是将减号改为加号。 230 | ```c 231 | #define PADDR(kva) \ 232 | ({ \ 233 | u_long a = (u_long)(kva); \ 234 | if (a < ULIM) \ 235 | panic("PADDR called with invalid kva %08lx", a); \ 236 | a - ULIM; \ 237 | }) 238 | ``` 239 | 240 | `memsize` 是物理内存的大小,当物理地址大于 `memsize` 时也就说明其超出了内存。 241 | 242 | 好了,现在 `alloc` 也讲解完了。你可能会想:“这分配了什么,不是就直接返回了个指针么?” 确实。说白了内存你本就可以随意使用,申请内存不过是为了避免内存使用冲突的机制罢了。 243 | 244 | 当讲完了 `alloc` 后,`mips_vm_init` 的内容也就明了了。我们创建了一个 `struct Page` 的数组,大小为 `npage`。 245 | 246 | ### (3)初始化页 247 | 在 `page_init` 中,我们将对 `mips_vm_init` 中申请的数组内容进行初始化,并维护一个存储所有空闲页的链表。 248 | 249 | 在正式开始之前,需要介绍一下在 include/queue.h 中定义的双向链表宏。通过使用宏,我们在 c 语言中实现了泛型。 250 | 251 | 为了使用链表,我们需要定义两个结构 `LIST_HEAD` 和 `LIST_ENTRY`。前者表示链表头或链表本身的类型,后者表示链表中元素的类型。通过宏定义可知,`LIST_HEAD(name, type)` 表示创建一个元素类型为 `type` 的链表,这个链表类型名为 `name`。`LIST_ENTRY(type)` 表示创建一个类型为 `type` 的链表元素。 252 | ```c 253 | #define LIST_HEAD(name, type) \ 254 | struct name { \ 255 | struct type *lh_first; /* first element */ \ 256 | } 257 | 258 | #define LIST_ENTRY(type) \ 259 | struct { \ 260 | struct type *le_next; /* next element */ \ 261 | struct type **le_prev; /* address of previous next element */ \ 262 | } 263 | ``` 264 | 265 | 我们在 include/pmap.h 中就定义了元素为 `Page`,类型名为 `Page_list` 的链表。可以注意到 `struct Page` 的原始定义中包含了链表元素类型 `Page_LIST_entry_t` 266 | ```c 267 | LIST_HEAD(Page_list, Page); 268 | typedef LIST_ENTRY(Page) Page_LIST_entry_t; 269 | 270 | struct Page { 271 | Page_LIST_entry_t pp_link; /* free list link */ 272 | 273 | u_short pp_ref; 274 | }; 275 | 276 | extern struct Page_list page_free_list; 277 | ``` 278 | 279 | include/queue.h 中也定义了一些链表操作,因为原理相似,在这里只介绍 `LIST_INSERT_AFTER(listelm, elm, field)`。这个函数也是我们需要填写的。 280 | ```c 281 | #define LIST_INSERT_AFTER(listelm, elm, field) \ 282 | /* Exercise 2.2: Your code here. */ \ 283 | do { \ 284 | if ((LIST_NEXT((elm), field) = LIST_NEXT((listelm), field)) != NULL) \ 285 | LIST_NEXT((listelm), field)->field.le_prev = &LIST_NEXT((elm), field); \ 286 | LIST_NEXT((listelm), field) = (elm); \ 287 | (elm)->field.le_prev = &LIST_NEXT((listelm), field); \ 288 | } while (0) 289 | ``` 290 | 291 | 对于代码的第一行,我们是容易理解的。在这两行中,我们先让 `elm` 的下一个元素指向 `listelm` 的下一个元素。若下一个元素不是 `NULL`,则还需要将这下一个元素的前一个元素设置为 `elm`。 292 | ```c 293 | if ((LIST_NEXT((elm), field) = LIST_NEXT((listelm), field)) != NULL) \ 294 | LIST_NEXT((listelm), field)->field.le_prev = &LIST_NEXT((elm), field); \ 295 | ``` 296 | 297 | 但这里出现了问题,为什么第二行使用的是 `&LIST_NEXT((elm), field)` 而非 `&elm`?重新看一下 `LIST_ENTRY` 的定义,可以发现对 le_prev 的注释是 `/* address of previous next element */`。(前一个(下一个元素))的地址,也就是说本来 `le_prev` 的地址就是上一个元素的 `le_next` 的地址。这样做有什么意义呢?叶gg说这样方便定义头指针。因为 `LIST_HEAD` 和 `LIST_ENTRY` 不是同一个类型,如果 `le_prev` 的类型是 `struct type *`,那么头结点也必须是 `type` 类型,这会浪费一个指针大小的空间。 298 | 299 | > 有些文章可能会认为这个链表无法直接访问前节点,其实这应该是错的(因为如果是单向链表,那么根本没必要设计 `le_prev`)。 300 | 301 | 这样的话,代码的第三四行也可以理解了。`listelm` 的下一个元素是 `elm`。`elm` 的 `le_prev` 的值是前一个元素,`listelm` 的 `le_next` 的地址。 302 | ```c 303 | LIST_NEXT((listelm), field) = (elm); \ 304 | (elm)->field.le_prev = &LIST_NEXT((listelm), field); \ 305 | ``` 306 | 307 | 现在我们理解了链表宏的含义,可以回来看 `page_init` 函数了。我们想一下接下来要做什么。我们有物理内存,并将其划分成了许多的页,这些页的信息通过页控制块保存在 `pages` 数组中。可是现在页控制块还没有被设置,具体来说,我们还没有明确哪些页是可用的,哪些页是已经被使用的。因此接下来我们要做到就是将页划分成可用和不可用的,并将可用的页控制块放入 `page_free_list` 中(这样想要申请新的页,只需要取出该链表的头结点即可)。 308 | 309 | 第一步,我们初始化链表(实际上只是将头结点的指针值设为 `NULL`)。 310 | ```c 311 | void page_init(void) { 312 | /* Step 1: Initialize page_free_list. */ 313 | /* Hint: Use macro `LIST_INIT` defined in include/queue.h. */ 314 | /* Exercise 2.3: Your code here. (1/4) */ 315 | LIST_INIT(&page_free_list); 316 | ``` 317 | 318 | 然后我们确定已使用内存的最大地址,为了适配页的大小,需要进行对齐 319 | ```c 320 | /* Step 2: Align `freemem` up to multiple of BY2PG. */ 321 | /* Exercise 2.3: Your code here. (2/4) */ 322 | freemem = ROUND(freemem, BY2PG); 323 | ``` 324 | 325 | 接着,我们需要将已使用的页的引用数设为 1,表示页已经被使用。首先我们计算有多少已使用的页,我们先使用 `PADDR` 将 `freemem` 转换为物理地址,接着使用 `PPN` 获取该地址属于第几个页表。使用一个循环将前 `usedpage` 个页控制块的 `pp_ref` 设置为 1。 326 | ```c 327 | /* Step 3: Mark all memory below `freemem` as used (set `pp_ref` to 1) */ 328 | /* Exercise 2.3: Your code here. (3/4) */ 329 | u_long usedpage = PPN(PADDR(freemem)); 330 | 331 | for (u_long i = 0; i < usedpage; i++) { 332 | pages[i].pp_ref = 1; 333 | } 334 | ``` 335 | 336 | 最后,我们将剩下的页控制块的 `pp_ref` 设置为 0,并将这些页控制块插入到 `page_free_list` 中。 337 | ```c 338 | /* Step 4: Mark the other memory as free. */ 339 | /* Exercise 2.3: Your code here. (4/4) */ 340 | for (u_long i = usedpage; i < npage; i++) { 341 | pages[i].pp_ref = 0; 342 | LIST_INSERT_HEAD(&page_free_list, &pages[i], pp_link); 343 | } 344 | } 345 | ``` 346 | 347 | ## 三、页式内存管理 348 | 当我们使用 kuseg 地址空间的虚拟地址访问内存时,我们会通过 TLB 将其转换为物理地址。当 TLB 中查询不到对应的物理地址时,就会发生 TLB Miss 异常。这时将跳转到异常处理函数,执行 TLB 重填。在 Lab2,我们的代码还未启用异常处理,因此无法真正运行页式内存管理机制,但是代码中已经定义了 TLB 重填函数。我们将从此开始解读 MOS 中的页式内存管理。 349 | 350 | > 注意,页式内存管理部分各类函数杂糅在一起。水平有限,以调用过程叙述时实在难以保证行文结构,因此小节题目不一定完全概括小节内容 351 | 352 | ### (1)TLB 重填 353 | TLB 的重填过程由 kern/tlb_asm.S 中的 `do_tlb_refill` 函数完成。该函数是汇编实现的。首先,定义了一个字的变量,标签为 `tlb_refill_ra` 354 | ```c 355 | .data 356 | tlb_refill_ra: 357 | .word 0 358 | ``` 359 | 360 | 接着是代码部分。首先我们使用 `NESTED` 定义函数标签。`NESTED` 与 `LEAF` 宏相对应。前者表示非叶函数,后者表示叶函数。 361 | ```c 362 | .text 363 | NESTED(do_tlb_refill, 0, zero) 364 | ``` 365 | 366 | 我们希望汇编尽可能少,因此希望 `do_tlb_refill` 只做必要的处理,随后调用 c 函数进一步处理。因此首先我们设置参数。第一个参数是 `BadVAddr` 寄存器的值,即发生 TLB Miss 的虚拟地址;第二个参数是 `EntryHi` 寄存器的 6-11 位。即当前进程的 ASID。 367 | ```c 368 | mfc0 a0, CP0_BADVADDR 369 | mfc0 a1, CP0_ENTRYHI 370 | srl a1, a1, 6 371 | andi a1, a1, 0b111111 372 | ``` 373 | 374 | 接着我们调用 c 函数 `_do_tlb_refill`(这个函数会在后面说明)。注意这里存储了原来的 `ra` 寄存器值。 375 | ```c 376 | sw ra, tlb_refill_ra 377 | jal _do_tlb_refill 378 | lw ra, tlb_refill_ra 379 | ``` 380 | 381 | `_do_tlb_refill` 会返回虚拟地址对应的页表项。我们将该返回值存入 `EntryLo`,并将 `EntryHi` 和 `EntryLo` 的值写入 TLB。 382 | ```c 383 | mtc0 v0, CP0_ENTRYLO0 384 | // See Chapter 6-8 385 | nop 386 | /* Hint: use 'tlbwr' to write CP0.EntryHi/Lo into a random tlb entry. */ 387 | /* Exercise 2.10: Your code here. */ 388 | tlbwr 389 | 390 | jr ra 391 | END(do_tlb_refill) 392 | ``` 393 | 394 | 这样就完成了 TLB 重填。跳回到正常程序后,此前产生异常的虚拟地址就可以通过 TLB 访问内存了。 395 | 396 | 接着我们详细深入 `_do_tlb_refill`,这个函数在 kern/tlbex.c 中。正如 hints 所说,在这个函数中,我们会不断查找虚拟地址对应的页表项,如果未找到,则试图申请一个新的页表项。最终返回申请到的页表项的内容。 397 | ```c 398 | Pte _do_tlb_refill(u_long va, u_int asid) { 399 | Pte *pte; 400 | /* Hints: 401 | * Invoke 'page_lookup' repeatedly in a loop to find the page table entry 'pte' associated 402 | * with the virtual address 'va' in the current address space 'cur_pgdir'. 403 | * 404 | * **While** 'page_lookup' returns 'NULL', indicating that the 'pte' could not be found, 405 | * allocate a new page using 'passive_alloc' until 'page_lookup' succeeds. 406 | */ 407 | 408 | /* Exercise 2.9: Your code here. */ 409 | while (page_lookup(cur_pgdir, va, &pte) == NULL) { 410 | passive_alloc(va, cur_pgdir, asid); 411 | } 412 | 413 | return *pte; 414 | } 415 | ``` 416 | 417 | ### (2)页的查找 418 | 接着我们详细讨论 `_do_tlb_refill` 中所使用的函数。 419 | 420 | `page_lookup` 函数在 kern/pmap.c 中定义。这个函数用于查找虚拟地址对应的页控制块及页表项。函数的参数是页目录的(虚拟)基地址,想要转换的虚拟地址和用于返回对应页表项的指针。值得注意的是,`_do_tlb_refill` 调用该函数时页目录基地址参数使用的是全局变量 `cur_pgdir`。可是这个全局变量并没有任何被赋值。这也是在 Lab2 中页式内存管理无法使用的一个原因。 421 | 422 | 我们继续看 `page_lookup` 的内容。其中首先调用了另一个函数 `pgdir_walk`。这个函数会获取想要转换的虚拟地址对应的(二级)页表项地址,通过 `pte` 返回。其中第三个参数 `create` 表示若未找到对应页表是否创建新的页表,此处为 0 表示不创建。 423 | ```c 424 | struct Page *page_lookup(Pde *pgdir, u_long va, Pte **ppte) { 425 | struct Page *pp; 426 | Pte *pte; 427 | 428 | /* Step 1: Get the page table entry. */ 429 | pgdir_walk(pgdir, va, 0, &pte); 430 | ``` 431 | 432 | 接着 `page_lookup` 检查是否获取到对应的页表项,未获取到返回 `NULL` 433 | ```c 434 | /* Hint: Check if the page table entry doesn't exist or is not valid. */ 435 | if (pte == NULL || (*pte & PTE_V) == 0) { 436 | return NULL; 437 | } 438 | ``` 439 | 440 | 如果获取到,我们找到页表项对应的页控制块,并返回。 441 | ```c 442 | /* Step 2: Get the corresponding Page struct. */ 443 | /* Hint: Use function `pa2page`, defined in include/pmap.h . */ 444 | pp = pa2page(*pte); 445 | if (ppte) { 446 | *ppte = pte; 447 | } 448 | 449 | return pp; 450 | } 451 | ``` 452 | 453 | > 需要注意这里有一个容易引起困惑的地方,`pte` 是虚拟地址对应的页表项的地址,`*pte` 是页表项的内容。我们知道页表项中除了物理地址之外还存储有其他信息。怎么就把其当做物理地址传入 `pa2page` 函数了呢? 454 | > 455 | > 让我们回到 `pa2page` 就知道了。在 `pa2page` 中我们通过 `PPN` 获取物理地址对应的第几页,而 `PPN` 是通过右移 12 位实现的。这样我们就将页表项中低位的用于表示权限等信息的内容消去,而只剩下页数了。(或许也正是因为 `*pte` 的低位无用,才将其用作其他内容。) 456 | 457 | 接着我们考察 `pgdir_walk` 函数,这个函数也在 kern/pmap.c 中定义。并且是需要我们填写的函数。如前所述,这个函数要实现查找对应虚拟地址对应的(二级)页表项,并根据 `create` 参数的设置在未找到二级页表时创建二级页表。 458 | 459 | 首先,我们根据虚拟地址确定对应的页目录项的地址。 460 | ```c 461 | static int pgdir_walk(Pde *pgdir, u_long va, int create, Pte **ppte) { 462 | Pde *pgdir_entryp; 463 | struct Page *pp; 464 | 465 | /* Step 1: Get the corresponding page directory entry. */ 466 | /* Exercise 2.6: Your code here. (1/3) */ 467 | pgdir_entryp = pgdir + PDX(va); 468 | ``` 469 | 470 | 其中使用了 `PDX` 宏。这个宏定义在 include/mmu.h 中。用于获取虚拟地址的 22-31 位的数值,这是虚拟地址对应的页目录项相对于页目录基地址的偏移。 471 | ```c 472 | #define PDX(va) ((((u_long)(va)) >> 22) & 0x03FF) 473 | ``` 474 | 475 | 随后我们判断该页目录项是否有效。如果无效,判断是否需要创建新的二级页表。如需要则使用 `page_alloc` 函数申请一个物理页,并设置虚拟地址对应页目录项的内容 `*pgdir_entryp = page2pa(pp) | PTE_D | PTE_V`,使其与该物理页关联。 476 | ```c 477 | if (!(*pgdir_entryp & PTE_V)) { 478 | if (create) { 479 | if (page_alloc(&pp) != 0) { 480 | return -E_NO_MEM; 481 | } 482 | pp->pp_ref++; 483 | *pgdir_entryp = page2pa(pp) | PTE_D | PTE_V; 484 | ``` 485 | 486 | `page_alloc` 函数是一个简单的函数,用于从 `page_free_list` 中抽取第一个空闲的页控制块,将页控制块对应的物理内存作为分配的内存。将该内存初始化为 0。唯一需要注意的是 `page2kva`。此函数实际上只是 `KADDR(page2pa(pp))`。 487 | ```c 488 | int page_alloc(struct Page **new) { 489 | /* Step 1: Get a page from free memory. If fails, return the error code.*/ 490 | struct Page *pp; 491 | /* Exercise 2.4: Your code here. (1/2) */ 492 | if (LIST_EMPTY(&page_free_list)) { 493 | return -E_NO_MEM; 494 | } 495 | pp = LIST_FIRST(&page_free_list); 496 | 497 | LIST_REMOVE(pp, pp_link); 498 | 499 | /* Step 2: Initialize this page with zero. 500 | * Hint: use `memset`. */ 501 | /* Exercise 2.4: Your code here. (2/2) */ 502 | memset((void *)page2kva(pp), 0, BY2PG); 503 | 504 | *new = pp; 505 | return 0; 506 | } 507 | ``` 508 | 509 | 回到 `pgdir_walk`,如果不需要创建,则直接返回 510 | ```c 511 | } else { 512 | *ppte = NULL; 513 | return 0; 514 | } 515 | } 516 | ``` 517 | 518 | 函数中剩下的流程中,二级页表必然存在了。我们获取二级页表的虚拟基地址,并找到虚拟地址 `va` 对应的二级页表项,返回。 519 | ```c 520 | /* Step 3: Assign the kernel virtual address of the page table entry to '*ppte'. */ 521 | /* Exercise 2.6: Your code here. (3/3) */ 522 | Pte *pgtable = (Pte *)KADDR(PTE_ADDR(*pgdir_entryp)); 523 | *ppte = pgtable + PTX(va); 524 | 525 | return 0; 526 | } 527 | ``` 528 | 529 | 需要注意这里我们使用了两个宏来获取二级页表基地址。第一个宏 `PTE_ADDR` 定义在 include/mmu.h 中。它返回页目录项对应的二级页表的基地址。实际上就是将页目录项内容的低 12 位抹零。如果是新申请的物理页作为二级页表,则该值实际上等于 `page2pa(pp)`。另一个宏 `KADDR` 将物理地址转换为 kseg0 的虚拟地址,不用细说。 530 | ```c 531 | #define PTE_ADDR(pte) ((u_long)(pte) & ~0xFFF) 532 | ``` 533 | 534 | 与 `PDX` 类似,`PTX` 宏返回虚拟地址 12-21 位的数值,`pgtable` 二级页表基地址加上偏移得到虚拟地址 `va` 对应的二级页表项。 535 | 536 | ### (3)页的申请 537 | `pgdir_walk` 的内容我们已经分析完成,现在 `page_lookup` 函数也没有需要讲解的部分了。接下来我们分析 `_do_tlb_refill` 中的 `passive_alloc`。 538 | 539 | `passive_alloc` 定义在 kern/tlbex.c 中。这是一个用于为虚拟地址申请物理页的函数。它的参数是:想要关联物理地址的虚拟地址、页目录的基地址和标识进程的 asid。 540 | ```c 541 | static void passive_alloc(u_int va, Pde *pgdir, u_int asid) { 542 | ``` 543 | 544 | 函数一开头就是好几条检查地址是否非法的判断语句,这里就不列出了。接下来的内容是,函数通过 `page_alloc` 申请一个物理页,并试图通过 `page_insert` 建立物理页和虚拟地址的联系。 545 | ```c 546 | panic_on(page_alloc(&p)); 547 | panic_on(page_insert(pgdir, asid, p, PTE_ADDR(va), PTE_D)); 548 | } 549 | ``` 550 | 551 | `page_alloc` 已经在之前介绍过了,现在介绍 `page_insert`。这个函数定义在 kern/pmap.c 中。是我们需要补完的函数。 552 | 553 | 该函数首先调用 `pgdir_walk`,试图获取当前虚拟地址对应的二级页表项。 554 | ```c 555 | int page_insert(Pde *pgdir, u_int asid, struct Page *pp, u_long va, u_int perm) { 556 | Pte *pte; 557 | 558 | /* Step 1: Get corresponding page table entry. */ 559 | pgdir_walk(pgdir, va, 0, &pte); 560 | ``` 561 | 562 | 如果确实获得了虚拟地址对应的二级页表项,并且是有效的,那么判断该页表项对应的物理页是否就是 `va` 想要映射的物理页(通过比较页控制块)。如果不一样,那么调用 `page_remove` 移除虚拟地址到原有的页的映射。`page_remove` 将在后续说明。 563 | ```c 564 | if (pte && (*pte & PTE_V)) { 565 | if (pa2page(*pte) != pp) { 566 | page_remove(pgdir, asid, va); 567 | ``` 568 | 569 | 如果相同,说明虚拟地址已经映射到了对应的物理页。这时我们只需要更新一下页表项的权限 `*pte = page2pa(pp) | perm | PTE_V`。为了保证对页表的修改都能反映到 TLB 中,我们要调用 `tlb_invalidate` 函数将原有的关于 `va` 和 `asid` 的 TLB 表项清除。`tlb_invalidate` 将在后面说明。 570 | ```c 571 | } else { 572 | tlb_invalidate(asid, va); 573 | *pte = page2pa(pp) | perm | PTE_V; 574 | return 0; 575 | } 576 | } 577 | ``` 578 | 579 | 程序执行 `page_insert` 的后续语句时,一定不存在虚拟地址 `va` 到页控制块对应的物理页的映射。于是接下来,我们就要建立这样的映射。首先我们还是要调用 `tlb_invalidate` 清除原有内容。 580 | ```c 581 | /* Step 2: Flush TLB with 'tlb_invalidate'. */ 582 | /* Exercise 2.7: Your code here. (1/3) */ 583 | tlb_invalidate(asid, va); 584 | ``` 585 | 586 | 随后再调用一次 `pgdir_walk`,只不过这次 `create=1`。这将获得 `va` 对应的二级页表项 587 | ```c 588 | /* Step 3: Re-get or create the page table entry. */ 589 | /* If failed to create, return the error. */ 590 | /* Exercise 2.7: Your code here. (2/3) */ 591 | if (pgdir_walk(pgdir, va, 1, &pte) != 0) { 592 | return -E_NO_MEM; 593 | } 594 | ``` 595 | 596 | 最后,我们只需要建立二级页表项到物理页的联系即可。我们只需修改二级页表项的内容,修改为物理页的物理地址和权限设置即可。同时不要忘记递增页控制块的引用计数。 597 | ```c 598 | /* Step 4: Insert the page to the page table entry with 'perm | PTE_V' and increase its 599 | * 'pp_ref'. */ 600 | /* Exercise 2.7: Your code here. (3/3) */ 601 | *pte = page2pa(pp) | perm | PTE_V; 602 | pp->pp_ref++; 603 | 604 | return 0; 605 | } 606 | ``` 607 | 608 | ### (4)页的移除 609 | 结束了 `page_insert` 的说明,让我们重新拾起按下不表的 `page_remove` 和 `tlb_invalidate`。我们首先考察 `page_remove`,此函数定义在 kern/pmap.c 中。用于取消虚拟地址 `va` 到物理页的映射。 610 | 611 | 首先该函数调用 `page_lookup` 查找与 `va` 和 `asid` 映射的物理页。如果不存在这样的页,则直接返回 612 | ```c 613 | void page_remove(Pde *pgdir, u_int asid, u_long va) { 614 | Pte *pte; 615 | 616 | /* Step 1: Get the page table entry, and check if the page table entry is valid. */ 617 | struct Page *pp = page_lookup(pgdir, va, &pte); 618 | if (pp == NULL) { 619 | return; 620 | } 621 | ``` 622 | 623 | 如果存在,则调用 `page_decref` 以递减该页的引用数。当引用数等于零时,将该物理页重新放入未使用页的链表。因为对页表进行了修改,需要调用 `tlb_invalidate` 确保 TLB 中不保留原有内容。 624 | ```c 625 | /* Step 2: Decrease reference count on 'pp'. */ 626 | page_decref(pp); 627 | 628 | /* Step 3: Flush TLB. */ 629 | *pte = 0; 630 | tlb_invalidate(asid, va); 631 | return; 632 | } 633 | ``` 634 | 635 | `page_decref` 定义如下,该函数和 `page_free` 都定义在 kern/pmap.c 中。这两个函数不需要讲解。 636 | ```c 637 | void page_free(struct Page *pp) { 638 | assert(pp->pp_ref == 0); 639 | /* Just insert it into 'page_free_list'. */ 640 | /* Exercise 2.5: Your code here. */ 641 | LIST_INSERT_HEAD(&page_free_list, pp, pp_link); 642 | } 643 | 644 | void page_decref(struct Page *pp) { 645 | assert(pp->pp_ref > 0); 646 | 647 | /* If 'pp_ref' reaches to 0, free this page. */ 648 | if (--pp->pp_ref == 0) { 649 | page_free(pp); 650 | } 651 | } 652 | ``` 653 | 654 | 还剩 `tlb_invalidate` 函数需要说明。这个函数主要用于调用另一个汇编函数 `tlb_out`。`tlb_invalidate` 将参数 `asid` 和 `va` 结合在了一起,传入 `tlb_out`。实际上这个结合在一起的参数就是 `EntryHi` 寄存器的结构。 655 | ```c 656 | void tlb_invalidate(u_int asid, u_long va) { 657 | tlb_out(PTE_ADDR(va) | (asid << 6)); 658 | } 659 | ``` 660 | 661 | 我们再考察 `tlb_out` 的内容。这部分定义在 kern/tlb_asm.S 中。首先可知,`tlb_out` 是一个叶函数。 662 | ```c 663 | LEAF(tlb_out) 664 | ``` 665 | 666 | 函数在一开始将原有的 `EnryHi` 寄存器中的值保存,并将传入的参数设置为 `EnryHi` 新的值。然后根据新的值查找 TLB 表项。 667 | ```c 668 | set noreorder 669 | mfc0 t0, CP0_ENTRYHI 670 | mtc0 a0, CP0_ENTRYHI 671 | nop 672 | /* Step 1: Use 'tlbp' to probe TLB entry */ 673 | /* Exercise 2.8: Your code here. (1/2) */ 674 | tlbp // 这条指令根据 EntryHi 中的 Key,查找 TLB 中对应的表项,将该项的索引存入 Index 寄存器 675 | 676 | nop 677 | ``` 678 | 679 | 随后将 `Index` 寄存器中的查询结果存储到 `t1` 寄存器,如果结果小于 0,说明未找到对应的表项,跳转到 NO_SUCH_ENTRY,不需要进行清零操作。 680 | ```c 681 | mfc0 t1, CP0_INDEX 682 | .set reorder 683 | bltz t1, NO_SUCH_ENTRY 684 | ``` 685 | 686 | 这里分别将 `EntryHi` 和 `EntryLo` 设置为 0。并将内容写入对应的表项,实现清零。 687 | ```c 688 | .set noreorder 689 | mtc0 zero, CP0_ENTRYHI 690 | mtc0 zero, CP0_ENTRYLO0 691 | nop 692 | 693 | tlbwi 694 | ``` 695 | 696 | 最后,恢复进入函数时 `EntryHi` 存储的值,函数返回。 697 | ```c 698 | .set reorder 699 | 700 | NO_SUCH_ENTRY: 701 | mtc0 t0, CP0_ENTRYHI 702 | 703 | j ra 704 | END(tlb_out) 705 | ``` 706 | 707 | 这样就完成了 Lab2 中所有涉及到的代码的讲解。 708 | -------------------------------------------------------------------------------- /BUAA OS实验笔记之Lab3.md: -------------------------------------------------------------------------------- 1 | 2 | ## 一、Lab3 前言 3 | 不知道为什么,虽然写 Lab3 所用的时间比 Lab2 少,但这次的笔记居然比 Lab2 长。我认为可能是因为自己在本篇文章中讲了更多和实验本身无关的东西。不过既然讲了,应该也会对进一步认识操作系统起到一些作用吧。希望本篇文章不会显得太啰嗦。 4 | 5 | 6 | ## 二、内核初始化(再续) 7 | Lab2 中,我们在内核初始化阶段初始化了虚拟内存的相关信息,Lab3 中我们要继续这一过程。本次实验中我们会完成进程控制的初始化。 8 | 9 | ### (1)再度 mips_init 10 | 我们查看 Lab3 中 init/init.c 的 `mips_init` 函数的内容变化。与 Lab2 相比,其中多调用了如下的方法 `env_init`、`ENV_CREATE_PRIORITY`、`kclock_init` 和 `enable_irq`。 11 | ```c 12 | 13 | void mips_init() { 14 | printk("init.c:\tmips_init() is called\n"); 15 | 16 | // lab2: 17 | mips_detect_memory(); 18 | mips_vm_init(); 19 | page_init(); 20 | 21 | // lab3: 22 | env_init(); 23 | 24 | // lab3: 25 | ENV_CREATE_PRIORITY(user_bare_loop, 1); 26 | ENV_CREATE_PRIORITY(user_bare_loop, 2); 27 | 28 | // lab3: 29 | kclock_init(); 30 | enable_irq(); 31 | while (1) { 32 | } 33 | } 34 | ``` 35 | 36 | 其中 `env_init` 用于进程控制的初始化,`ENV_CREATE_PRIORITY` 手工创建了两个进程,`kclock_init` 和 `enable_irq` 设置了时钟中断并启用了中断。后两者将分别在第三和四节介绍。本届只介绍前者。 37 | 38 | ### (2)进程管理的数据结构 39 | 40 | 让我们深入在 kern/env.c 中的 `env_init`,在该函数中,首先初始化了两个列表 41 | ```c 42 | void env_init(void) { 43 | int i; 44 | /* Step 1: Initialize 'env_free_list' with 'LIST_INIT' and 'env_sched_list' with 45 | * 'TAILQ_INIT'. */ 46 | /* Exercise 3.1: Your code here. (1/2) */ 47 | LIST_INIT(&env_free_list); 48 | TAILQ_INIT(&env_sched_list); 49 | 50 | ``` 51 | 52 | 这两个列表,准确来说是一个链表和一个尾队列(此类型的定义可以在 include/queue.h 中找到),其中的元素都是同一类型。我们可以在 include/env.h 中找到 53 | ```c 54 | LIST_HEAD(Env_list, Env); 55 | TAILQ_HEAD(Env_sched_list, Env); 56 | ``` 57 | 58 | 其中 `Env` 是如下的结构体,它被称为进程控制块(Process Control Block,PCB),其中保存了一个进程所拥有的不同的资源。或许因为是附属于进程,为进程提供支持的结构,就像是进程所处的环境一样,因此这里类型名为Env(ironment)。 59 | ```c 60 | struct Env { 61 | struct Trapframe env_tf; // Saved registers 62 | LIST_ENTRY(Env) env_link; // Free list 63 | u_int env_id; // Unique environment identifier 64 | u_int env_asid; // ASID 65 | u_int env_parent_id; // env_id of this env's parent 66 | u_int env_status; // Status of the environment 67 | Pde *env_pgdir; // Kernel virtual address of page dir 68 | TAILQ_ENTRY(Env) env_sched_link; 69 | u_int env_pri; 70 | // Lab 4 IPC 71 | u_int env_ipc_value; // data value sent to us 72 | u_int env_ipc_from; // envid of the sender 73 | u_int env_ipc_recving; // env is blocked receiving 74 | u_int env_ipc_dstva; // va at which to map received page 75 | u_int env_ipc_perm; // perm of page mapping received 76 | 77 | // Lab 4 fault handling 78 | u_int env_user_tlb_mod_entry; // user tlb mod handler 79 | 80 | // Lab 6 scheduler counts 81 | u_int env_runs; // number of times been env_run'ed 82 | }; 83 | ``` 84 | 85 | 进程控制块中内容众多,不好一一解释。因此这里只稍微介绍一下和本次实验有关的字段。 86 | - `env_link`、`env_sched_link`:根据类型就可以看出,这两个字段就是存储链表信息的字段 87 | - `env_tf`:此字段用于在陷入内核时保存当前进程所处状态的相关信息,比如寄存器的值、pc寄存器中的地址等等。它的类型名为 `Trapframe`,与栈帧(stack frame)类似,都是存储一段信息的结构,只不过此类型存储的是发生在陷入内核(trap)时的信息罢了。`Trapframe` 定义在 include/trap.h 中 88 | ```c 89 | struct Trapframe { 90 | /* Saved main processor registers. */ 91 | unsigned long regs[32]; 92 | 93 | /* Saved special registers. */ 94 | unsigned long cp0_status; 95 | unsigned long hi; 96 | unsigned long lo; 97 | unsigned long cp0_badvaddr; 98 | unsigned long cp0_cause; 99 | unsigned long cp0_epc; 100 | }; 101 | ``` 102 | - `env_id`:此字段是进程的标识符,每个进程都唯一 103 | - `env_status`:此字段中存储进程的当前状态,包括空闲、阻塞和可运行 104 | ```c 105 | #define ENV_FREE 0 106 | #define ENV_RUNNABLE 1 107 | #define ENV_NOT_RUNNABLE 2 108 | ``` 109 | - `env_asid`:表示进程的 asid,用于 tlb 中 110 | - `env_pgdir`:存储了当前进程拥有的页目录的虚拟地址 111 | - `env_pri`:表示当前进程的优先级 112 | 113 | 在本实验接下来的代码中,我们都会使用或看到这些字段。 114 | 115 | 现在让我们回到 `env_init`。看一下我们初始化的两个数据结构的名称。`env_free_list` 表示其中存储了所有空闲的进程控制块,`env_sched_list` 则意味着该列表用于组织进程的调度(schedule)。 116 | 117 | ### (3)map_segment 函数 118 | 我们查看 `env_init` 的后续内容。首先,我们将所有的进程控制块都插入 `env_free_list` 中。并且标记所有块都为 `ENV_FREE`。这里插入时顺序反向,这只是指导书的要求而已。 119 | ```c 120 | for (i = NENV - 1; i >= 0; i--) { 121 | LIST_INSERT_HEAD(&env_free_list, envs + i, env_link); 122 | envs[i].env_status = ENV_FREE; 123 | } 124 | ``` 125 | 126 | 在 `env_init` 中我们还需要做一件事,就是创建一个 “模板页目录”,设置该页将 pages 和 envs (即所有页控制块和所有进程控制块的内存空间)分别映射到 `UPAGES` 和 `UENVS` 的空间中。并且在后续进程创建新的页目录时,也要首先复制模板页目录中的内容。这样做的目的是使得用户程序也能够通过 `UPAGES` 和 `UENVS` 的用户地址空间获取 `Page` 和 `Env` 的信息。 127 | 128 | 我们首先调用 `page_alloc` 申请一个页 129 | ```c 130 | struct Page *p; 131 | panic_on(page_alloc(&p)); 132 | p->pp_ref++; 133 | ``` 134 | 135 | 该页即 “模板页目录”,我们把它的地址存储到全局变量 `base_pgdir` 中 136 | ```c 137 | base_pgdir = (Pde *)page2kva(p); 138 | ``` 139 | 140 | 最后我们调用 `map_segment` 函数,该函数在指定的页目录中创建虚拟地址空间 `[va, va+size)` 到物理地址空间 `[pa, pa+size)` 的映射。并设置其权限(在这里我们设置其为只读,因为不希望用户程序修改内核空间的内容)。 141 | ```c 142 | map_segment(base_pgdir, 0, PADDR(pages), UPAGES, ROUND(npage * sizeof(struct Page), BY2PG), 143 | PTE_G); 144 | map_segment(base_pgdir, 0, PADDR(envs), UENVS, ROUND(NENV * sizeof(struct Env), BY2PG), 145 | PTE_G); 146 | } 147 | ``` 148 | 149 | 这样 `env_init` 函数就结束了。我们初始化了两个列表;将所有进程控制块插入空闲进程表;创建了一个 “模板页目录” 并将一部分内核空间的内容映射到用户空间。 150 | 151 | 接着我们考察 `map_segment` 函数。可以认为该函数是一个广义的 `page_insert`。它的功能是,在页目录 `pgdir` 中,将虚拟地址空间 `[va, va+size)` 映射到到物理地址空间 `[pa, pa+size)`,并赋予 `perm` 权限。 152 | ```c 153 | static void map_segment(Pde *pgdir, u_int asid, u_long pa, u_long va, u_int size, u_int perm) { 154 | ``` 155 | 156 | 它的实现也很简单,就是通过循环不断调用 `page_insert` 创建一个页大小的 `va` 到 `pa` 的映射,直到达到期望的 `size` 大小 157 | ```c 158 | for (int i = 0; i < size; i += BY2PG) { 159 | /* 160 | * Hint: 161 | * Map the virtual page 'va + i' to the physical page 'pa + i' using 'page_insert'. 162 | * Use 'pa2page' to get the 'struct Page *' of the physical address. 163 | */ 164 | /* Exercise 3.2: Your code here. */ 165 | page_insert(pgdir, asid, pa2page(pa + i), va + i, perm); 166 | } 167 | } 168 | ``` 169 | 170 | 这样进程控制的初始化就完成了。 171 | 172 | ## 三、进程的创建 173 | ### (1)再再度 mips_init 174 | 我们再回到 `mips_init` 函数。进程控制初始化完成后,我们又调用 `ENV_CREATE_PRIORITY` 宏创建了两个进程。 175 | ```c 176 | // lab3: 177 | ENV_CREATE_PRIORITY(user_bare_loop, 1); 178 | ENV_CREATE_PRIORITY(user_bare_loop, 2); 179 | ``` 180 | 181 | 该宏定义在 include/env.h 中。在同一个文件夹中还有另一个类似的宏 `ENV_CREATE`,相当于把 `ENV_CREATE_PRIORITY` 中的 `y` 设为 1。 182 | ```c 183 | #define ENV_CREATE_PRIORITY(x, y) \ 184 | ({ \ 185 | extern u_char binary_##x##_start[]; \ 186 | extern u_int binary_##x##_size; \ 187 | env_create(binary_##x##_start, (u_int)binary_##x##_size, y); \ 188 | }) 189 | 190 | ``` 191 | 192 | 我们可以看出宏中定义了两个外部引用变量 `binary_##x##_start` 和 `binary_##x##_size`,对于 `mips_init` 中的使用来说即 `binary_user_bare_loop_start` 和 `binary_user_bare_loop_size`。接着我们调用了 `env_create` 函数。很明显该函数用于创建一个进程。 193 | 194 | ### (2)一种神奇的操作 195 | `env_create` 定义在 kern/env.c 中。在介绍该函数的内容之前,我们可以解释一下该函数的参数。 196 | ```c 197 | struct Env *env_create(const void *binary, size_t size, int priority) { 198 | ``` 199 | 200 | 首先,`const void *binary` 是一个二进制的数据数组,该数组的大小为 `size_t size`,实际上此二进制数据即我们想要创建的进程的程序。最后一个参数 `int priority` 表示我们想要设置的进程的优先级,对应 `Env` 中的 `env_pri` 字段。 201 | 202 | 你可能会想,“不对呀,我们从哪里读入的程序?” 确实,我们根本没有进行磁盘操作。我们还没有实现文件系统,我们所 “加载” 的程序实际上是被一同编译到内核中的一段 ELF 格式的数据。这段数据中存在标签 `binary_user_bare_loop_start` 和 `binary_user_bare_loop_size`,所以我们才可以只通过引用外部变量的形式就 “加载” 了程序文件。 203 | 204 | 将 ELF 文件转化为 c 数组一同编译进内核程序的过程似乎较为复杂。本人也并不像如此深入此与操作系统无关的技术。但是经过简单的探索,我们还是可以了解到实现这一过程的程序的源代码为 tools/bintoc.c。此代码实现了一个程序,可以读取某一 ELF 文件的二进制内容,将其转化为一个 c 语言源代码文件。bintoc.c 的代码片段如下: 205 | ```c 206 | size_t n = fread(binary, sizeof(char), size, bin); 207 | assert(n == size); 208 | fprintf(out, 209 | "unsigned int binary_%s_%s_size = %d;\n" 210 | "unsigned char binary_%s_%s_start[] = {", 211 | prefix, bin_file, size, prefix, bin_file); 212 | for (i = 0; i < size; i++) { 213 | fprintf(out, "0x%x%c", binary[i], i < size - 1 ? ',' : '}'); 214 | } 215 | ``` 216 | 217 | 另外,我们可以在 user/bar/Makefile 中找到如下内容 218 | ```makefile 219 | INCLUDES := -I../../include 220 | 221 | %.b.c: %.b 222 | $(tools_dir)/bintoc -f $< -o $@ -p user_bare 223 | ``` 224 | 225 | 这些内容就足以使人察觉 “将 ELF 文件转化为 c 数组一同编译进内核程序” 的方法了。 226 | 227 | ### (3)进入 env_alloc 函数 228 | 说了这么多没用的,我们还是回到 `env_create` 吧。首先,该函数通过调用 `env_alloc` 申请了一个新的空闲进程控制块。 229 | ```c 230 | struct Env *e; 231 | /* Step 1: Use 'env_alloc' to alloc a new env. */ 232 | /* Exercise 3.7: Your code here. (1/3) */ 233 | env_alloc(&e, 0); 234 | ``` 235 | 236 | `env_alloc` 与 `page_alloc` 类似,都从空闲块列表中取出一个空闲块。但是 `env_alloc` 的后续处理更加复杂。首先 `env_alloc` 中同样取出一个空闲块。 237 | ```c 238 | int env_alloc(struct Env **new, u_int parent_id) { 239 | int r; 240 | struct Env *e; 241 | 242 | /* Step 1: Get a free Env from 'env_free_list' */ 243 | /* Exercise 3.4: Your code here. (1/4) */ 244 | if (LIST_EMPTY(&env_free_list)) { 245 | return -E_NO_FREE_ENV; 246 | } 247 | e = LIST_FIRST(&env_free_list); 248 | ``` 249 | 250 | 接着我们调用 `env_setup_vm` 初始化进程控制块的用户地址空间。也就是为进程控制块创建对应的二级页表。 251 | ```c 252 | /* Step 2: Call a 'env_setup_vm' to initialize the user address space for this new Env. */ 253 | /* Exercise 3.4: Your code here. (2/4) */ 254 | if ((r = env_setup_vm(e)) != 0) { 255 | return r; 256 | } 257 | ``` 258 | 259 | ### (4)env_setup_vm 与页表自映射 260 | `env_setup_vm` 函数值得关注一下,我们查看一下该函数的内容。首先我们申请一个物理页作为页目录。可以看到我们这里设置了 `Env` 中 `env_pgdir` 字段的值 261 | ```c 262 | static int env_setup_vm(struct Env *e) { 263 | struct Page *p; 264 | try(page_alloc(&p)); 265 | /* Exercise 3.3: Your code here. */ 266 | p->pp_ref++; 267 | e->env_pgdir = (Pde *)page2kva(p); 268 | ``` 269 | 270 | 值得讲解的在后面。是否还记得我们之前花大篇幅讲解的 “模板页目录”?现在正在创建二级页表,我们可以将 “模板页目录” 中的内容复制到当前进程的页目录中。我们复制了 `UTOP` 到 `UVPT` 的虚拟地址空间对应的页表项。这就是我们之前在 “模板页目录” 中映射的区域。 271 | ```c 272 | memcpy(e->env_pgdir + PDX(UTOP), base_pgdir + PDX(UTOP), 273 | sizeof(Pde) * (PDX(UVPT) - PDX(UTOP))); 274 | ``` 275 | 276 | `env_setup_vm` 函数还没有结束。在最后我们还执行了这样的语句 277 | ```c 278 | e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V; 279 | return 0; 280 | } 281 | ``` 282 | 283 | 我们将 `UVPT` 虚拟地址映射到页目录本身的物理地址,并设置只读权限。这样的话,页目录中的项所对应的,就不只是二级页表,还包含有一个一级页表,也就是页目录自身。这就是自映射。我们在 Lab2 时从指导书上学到了关于自映射的理论。但到了 Lab3 才把它用代码表示了出来。 284 | 285 | 所以,自映射有什么用?指导书上的表达有些模糊不清,虽然我也没有信心能够表述明白,但我还是试一下吧。 286 | 287 | >假设现在我们用比 `UVPT` 地址高一些的地址 `va` 进行访存,那么我们会取到那些信息呢?首先,这个地址会经过页目录,`PDX(va)` 的结果和 `UVPT` 相同,我们进入到索引对应的二级页表……不对,还是页目录自身! 288 | > 289 | >好的,我们在页目录中重新来一遍,这次通过 `PTX(va)` 计算索引,结果就不一定还是页目录项了。我们找到了一个物理页,取出了其中的数据。可是等等,这个物理页却不再是一般的物理页了,而是作为二级页表的物理页。 290 | > 291 | >另外假如我们恰好取得的 `PTX(va)` 值与 `PDX(va)` 相同,那么我们绕了两圈,最终还是处在页目录之中,我们取得的数据也是页目录中的内容。 292 | > 293 | >这样我们就可以明白自映射的作用了。它在用户内存空间中划分出一部分,使得用户可以通过访问这部分空间得到二级页表以及页目录中的数据。 294 | > 295 | >在 include/mmu.h 的内存分布图中,我们可以看出 `UVPT` 以上的 4kb(1024 个页表的大小)空间被标记为 `User VPT`。VPT 或为 virtual page table(虚拟页表)的意思。 296 | 297 | ### (5)进程控制块的初始化 298 | 我们回到 `env_alloc` 函数。此后的内容是初始化新申请的进程控制块。我们要设置一些字段的值后才算完成了进程控制块的申请。 299 | 300 | 一些内容和后续的实验有关,我们先不考虑,我们考虑本次试验中涉及的部分。这里我们设置了 `env_id`、`env_parent_id` 和 `env_asid` 的值。其中 `env_parent_id` 设置为 `env_alloc` 的参数。而 `env_id` 和 `env_asid` 因为需要不重复,所以通过两个函数分别申请。 301 | ```c 302 | e->env_user_tlb_mod_entry = 0; // for lab4 303 | e->env_runs = 0; // for lab6 304 | /* Exercise 3.4: Your code here. (3/4) */ 305 | e->env_id = mkenvid(e); 306 | e->env_parent_id = parent_id; 307 | if ((r = asid_alloc(&e->env_asid)) != 0) { 308 | return r; 309 | } 310 | ``` 311 | 312 | `mkenvid` 的内容较为简单,只是通过一个函数内的静态变量实现不重复的。 313 | ```c 314 | u_int mkenvid(struct Env *e) { 315 | static u_int i = 0; 316 | return ((++i) << (1 + LOG2NENV)) | (e - envs); 317 | } 318 | ``` 319 | 320 | 而 `asid_alloc` 则是设置了一个 `asid_bitmap` 用来管理 asid 的分配情况,这是因为 asid 是有限的(2^6=64)。当所有的 asid 都被分配以后,应该返回异常。 321 | ```c 322 | static uint32_t asid_bitmap[NASID / 32] = {0}; // 64 323 | 324 | /* Overview: 325 | * Allocate an unused ASID. 326 | * 327 | * Post-Condition: 328 | * return 0 and set '*asid' to the allocated ASID on success. 329 | * return -E_NO_FREE_ENV if no ASID is available. 330 | */ 331 | static int asid_alloc(u_int *asid) { 332 | for (u_int i = 0; i < NASID; ++i) { 333 | int index = i >> 5; 334 | int inner = i & 31; 335 | if ((asid_bitmap[index] & (1 << inner)) == 0) { 336 | asid_bitmap[index] |= 1 << inner; 337 | *asid = i; 338 | return 0; 339 | } 340 | } 341 | return -E_NO_FREE_ENV; 342 | } 343 | ``` 344 | 345 | 继续 `env_alloc` 函数,在最后,我们设置进程的 `status` 寄存器和 `sp` 寄存器的值。 346 | ```c 347 | /* Step 4: Initialize the sp and 'cp0_status' in 'e->env_tf'. */ 348 | // Timer interrupt (STATUS_IM4) will be enabled. 349 | e->env_tf.cp0_status = STATUS_IM4 | STATUS_KUp | STATUS_IEp; 350 | // Keep space for 'argc' and 'argv'. 351 | e->env_tf.regs[29] = USTACKTOP - sizeof(int) - sizeof(char **); 352 | 353 | /* Step 5: Remove the new Env from env_free_list. */ 354 | /* Exercise 3.4: Your code here. (4/4) */ 355 | LIST_REMOVE(e, env_link); 356 | 357 | *new = e; 358 | return 0; 359 | } 360 | ``` 361 | 362 | 将`status` 寄存器的值设置为 `STATUS_IM4 | STATUS_KUp | STATUS_IEp`,表示响应 4 号中断,是用户状态且开启中断。所有的通用寄存器状态在 `Trapframe` 中存储在 `regs` 数组中,其中第 29 号寄存器为 `sp` 寄存器。这一点可从 include/asm/regdef.h 中得知 363 | ```c 364 | #define sp $29 /* stack pointer */ 365 | ``` 366 | 367 | 在未执行的情况下,用户程序的 sp 寄存器应该处于栈顶 `USTACKTOP` 的位置。但为了给程序的 `main` 函数的参数 `argc` 和 `argv` 留出空间,需要减去 `sizeof(int) + sizeof(char **)` 的大小。 368 | 369 | 这样我们就完成了对 `env_alloc` 函数的讲解。 370 | 371 | ### (6)加载 ELF 文件 372 | 经历了这么多,我们都快忘记最开始我们的目标,`env_create` 函数了。让我们接着 `env_alloc` 之后的内容。现在对于调用 `env_alloc` 得到的新进程控制块,我们设置它的优先级以及状态。 373 | ```c 374 | /* Step 2: Assign the 'priority' to 'e' and mark its 'env_status' as runnable. */ 375 | /* Exercise 3.7: Your code here. (2/3) */ 376 | e->env_pri = priority; 377 | e->env_status = ENV_RUNNABLE; 378 | ``` 379 | 380 | 最后,我们调用 `load_icode` 为进程加载 ELF 程序,同时使用 `TAILQ_INSERT_HEAD` 宏将进程控制块加入到调度队列中 381 | ```c 382 | /* Step 3: Use 'load_icode' to load the image from 'binary', and insert 'e' into 383 | * 'env_sched_list' using 'TAILQ_INSERT_HEAD'. */ 384 | /* Exercise 3.7: Your code here. (3/3) */ 385 | load_icode(e, binary, size); 386 | TAILQ_INSERT_HEAD(&env_sched_list, e, env_sched_link); 387 | 388 | return e; 389 | } 390 | ``` 391 | 392 | 我们就差最后一步就完成了 “进程的创建” 这一节的内容了,现在我们要分析 `load_icode` 中加载二进制镜像功能的实现。函数名中 icode 似乎指 image code 的意思。`load_icode` 同样在 kern/env.c 中。 393 | 394 | 首先该函数调用了 `elf_from` 函数从二进制数据中读取了页表信息。 395 | ```c 396 | static void load_icode(struct Env *e, const void *binary, size_t size) { 397 | /* Step 1: Use 'elf_from' to parse an ELF header from 'binary'. */ 398 | const Elf32_Ehdr *ehdr = elf_from(binary, size); 399 | if (!ehdr) { 400 | panic("bad elf at %x", binary); 401 | } 402 | ``` 403 | 404 | `elf_from` 函数定义在 lib/elfloader.c 中。只是简单地对二进制数据做类型转换,并检查是否确为 ELF 文件头。 405 | ```c 406 | const Elf32_Ehdr *elf_from(const void *binary, size_t size) { 407 | const Elf32_Ehdr *ehdr = (const Elf32_Ehdr *)binary; 408 | if (size >= sizeof(Elf32_Ehdr) && ehdr->e_ident[EI_MAG0] == ELFMAG0 && 409 | ehdr->e_ident[EI_MAG1] == ELFMAG1 && ehdr->e_ident[EI_MAG2] == ELFMAG2 && 410 | ehdr->e_ident[EI_MAG3] == ELFMAG3 && ehdr->e_type == 2) { 411 | return ehdr; 412 | } 413 | return NULL; 414 | } 415 | ``` 416 | 417 | 接着在 `load_icode` 中使用了一个宏 `ELF_FOREACH_PHDR_OFF` 来遍历所有的程序头表 418 | ```c 419 | /* Step 2: Load the segments using 'ELF_FOREACH_PHDR_OFF' and 'elf_load_seg'. 420 | * As a loader, we just care about loadable segments, so parse only program headers here. 421 | */ 422 | size_t ph_off; 423 | ELF_FOREACH_PHDR_OFF (ph_off, ehdr) { 424 | ``` 425 | 426 | 这个宏定义在 include/elf.h 中。很容易看出作用 427 | ```c 428 | #define ELF_FOREACH_PHDR_OFF(ph_off, ehdr) \ 429 | (ph_off) = (ehdr)->e_phoff; \ 430 | for (int _ph_idx = 0; _ph_idx < (ehdr)->e_phnum; ++_ph_idx, (ph_off) += (ehdr)->e_phentsize) 431 | ``` 432 | 433 | 在循环中,取出对应的程序头,如果其中的 `p_type` 类型为 `PT_LOAD`,说明其对应的程序需要被加载到内存中。我们调用 `elf_load_seg` 函数来进行加载 434 | ```c 435 | Elf32_Phdr *ph = (Elf32_Phdr *)(binary + ph_off); 436 | if (ph->p_type == PT_LOAD) { 437 | // 'elf_load_seg' is defined in lib/elfloader.c 438 | // 'load_icode_mapper' defines the way in which a page in this segment 439 | // should be mapped. 440 | panic_on(elf_load_seg(ph, binary + ph->p_offset, load_icode_mapper, e)); 441 | } 442 | } 443 | ``` 444 | 445 | 在 `load_icode` 函数的最后,我们将进程控制块中 trap frame 的 epc cp0 寄存器的值设置为 ELF 文件中设定的程序入口地址 446 | ```c 447 | /* Step 3: Set 'e->env_tf.cp0_epc' to 'ehdr->e_entry'. */ 448 | /* Exercise 3.6: Your code here. */ 449 | e->env_tf.cp0_epc = ehdr->e_entry; 450 | } 451 | ``` 452 | 453 | 这样 `load_icode` 部分也完成了。让我们回过头查看一下 `elf_load_seg` 函数。该函数定义在 lib/elfloader.c 中。作用是根据程序头表中的信息将 `bin` 中的数据加载到指定位置。值得关注该函数的参数,`elf_mapper_t map_page` 是一个回调函数,用于将数据映射到虚拟地址所在的页上;`void *data` 则是回调函数中使用的参数。 454 | ```c 455 | int elf_load_seg(Elf32_Phdr *ph, const void *bin, elf_mapper_t map_page, void *data) { 456 | u_long va = ph->p_vaddr; 457 | size_t bin_size = ph->p_filesz; 458 | size_t sgsize = ph->p_memsz; 459 | u_int perm = PTE_V; 460 | if (ph->p_flags & PF_W) { 461 | perm |= PTE_D; 462 | } 463 | ``` 464 | 465 | `elf_mapper_t` 定义在 include/elf.h 中。此类型的函数接受数据要加载到的虚拟地址 `va`,数据加载的起始位置相对于页的偏移 `offset`,页的权限 `prem`,所要加载的数据 `src` 和要加载的数据大小 `len`。当然还有 `data`,但这个让我们留到后面。 466 | ```c 467 | typedef int (*elf_mapper_t)(void *data, u_long va, size_t offset, u_int perm, const void *src, 468 | size_t len); 469 | ``` 470 | 471 | 在 `elf_load_seg` 中,我们首先需要处理要加载的虚拟地址不与页对齐的情况。我们将最开头不对齐的部分 “剪切” 下来,先映射到内存的页中。 472 | ```c 473 | int r; 474 | size_t i; 475 | u_long offset = va - ROUNDDOWN(va, BY2PG); 476 | if (offset != 0) { 477 | if ((r = map_page(data, va, offset, perm, bin, MIN(bin_size, BY2PG - offset))) != 478 | 0) { 479 | return r; 480 | } 481 | } 482 | ``` 483 | 484 | 接着我们处理数据中间完整的部分。我们通过循环不断将数据加载到页上。 485 | ```c 486 | /* Step 1: load all content of bin into memory. */ 487 | for (i = offset ? MIN(bin_size, BY2PG - offset) : 0; i < bin_size; i += BY2PG) { 488 | if ((r = map_page(data, va + i, 0, perm, bin + i, MIN(bin_size - i, BY2PG))) != 0) { 489 | return r; 490 | } 491 | } 492 | ``` 493 | 494 | 最后我们处理段大小大于数据大小的情况。在这一部分,我们不断创建新的页,但是并不向其中加载任何内容。 495 | ```c 496 | /* Step 2: alloc pages to reach `sgsize` when `bin_size` < `sgsize`. */ 497 | while (i < sgsize) { 498 | if ((r = map_page(data, va + i, 0, perm, NULL, MIN(bin_size - i, BY2PG))) != 0) { 499 | return r; 500 | } 501 | i += BY2PG; 502 | } 503 | return 0; 504 | } 505 | ``` 506 | 507 | 这节的最最后,我们查看一下在本次实验中使用的回调函数 `load_icode_mapper`。根据 `load_icode` 中 `elf_load_seg` 传入的参数可知,此时我们的 `data` 为要加载程序镜像的进程对应的进程控制块。 508 | ```c 509 | panic_on(elf_load_seg(ph, binary + ph->p_offset, load_icode_mapper, e)); 510 | ``` 511 | 512 | `load_icode_mapper` 定义在 kern/env.c 中。在函数的一开始,我们就将 `data` 还原为进程控制块 513 | ```c 514 | static int load_icode_mapper(void *data, u_long va, size_t offset, u_int perm, const void *src, 515 | size_t len) { 516 | struct Env *env = (struct Env *)data; 517 | struct Page *p; 518 | int r; 519 | ``` 520 | 521 | 我们想要将数据加载到内存,首先需要申请物理页。调用 `page_alloc` 函数申请空闲页 522 | ```c 523 | /* Step 1: Allocate a page with 'page_alloc'. */ 524 | /* Exercise 3.5: Your code here. (1/2) */ 525 | if ((r = page_alloc(&p)) != 0) { 526 | return r; 527 | } 528 | ``` 529 | 530 | 接着,如果存在需要拷贝的数据,则将该数据复制到新申请的页所对应的内存空间中。我们使用 `page2kva` 获取页所对应的内核虚拟地址。另外注意这里需要考虑 `offset`。 531 | ```c 532 | /* Step 2: If 'src' is not NULL, copy the 'len' bytes started at 'src' into 'offset' at this 533 | * page. */ 534 | // Hint: You may want to use 'memcpy'. 535 | if (src != NULL) { 536 | /* Exercise 3.5: Your code here. (2/2) */ 537 | memcpy((void *)(page2kva(p) + offset), src, len); 538 | } 539 | ``` 540 | 541 | 最后我们调用 `page_insert` 将虚拟地址映射到页上。为了区别不同进程的相同虚拟地址,我们需要附加 asid 信息,asid 保存在进程控制块中,这也是我们需要将进程控制块传入回调函数的原因。 542 | ```c 543 | /* Step 3: Insert 'p' into 'env->env_pgdir' at 'va' with 'perm'. */ 544 | return page_insert(env->env_pgdir, env->env_asid, p, va, perm); 545 | } 546 | ``` 547 | 548 | 到此为止,我们终于完成了进程创建的流程。在这一过程中,我们申请了新的进程控制块,初始化了该控制块的虚拟内存管理机制以及 trap frame 等其他信息。并将程序镜像加载到了该进程独占的虚拟内存空间中。 549 | 550 | 但是到目前为止,我们的进程还未运行起来,还不是动态的程序;仅是在内存空间中的一些有组织的数据而已。在接下来的小节中,我们会让进程运行起来。 551 | 552 | ## 四、异常处理 553 | ### (1)中断的初始化 554 | 让我们在这一次实验中最后一次查看一下 `mips_init` 函数。在该函数的最后是一个死循环。这样的程序要如何退出呢?看似在这种情况下,其他程序都不能执行。这似乎是正确的,如果在没有开启中断的情况下。 555 | 556 | 然而,我们在死循环之前调用了两个函数 `kclock_init`,`enable_irq`。这两个函数是跳出死循环,实现进程运行的关键。 557 | 558 | 让我们深入 `kclock_init`,这是一个用汇编编写的函数,定义在 kern/kclock.S 中。该函数的作用是启用时钟。使其以 200Hz 的频率触发时钟中断。 559 | ```c 560 | LEAF(kclock_init) 561 | li t0, 200 // the timer interrupt frequency in Hz 562 | 563 | /* Write 't0' into the timer (RTC) frequency register. 564 | * 565 | * Hint: 566 | * You may want to use 'sw' instruction and constants 'DEV_RTC_ADDRESS' and 567 | * 'DEV_RTC_HZ' defined in include/drivers/dev_rtc.h. 568 | * To access device through mmio, a physical address must be converted to a 569 | * kseg1 address. 570 | * 571 | * Reference: http://gavare.se/gxemul/gxemul-stable/doc/experiments.html#expdevices 572 | */ 573 | /* Exercise 3.11: Your code here. */ 574 | sw t0, (KSEG1 | DEV_RTC_ADDRESS | DEV_RTC_HZ) 575 | 576 | jr ra 577 | END(kclock_init) 578 | ``` 579 | 580 | 这个函数的内容很简单,只是将数值 200 存入内存中的某一地址。地址 `KSEG1 | DEV_RTC_ADDRESS | DEV_RTC_HZ` 的形式我们已经在 Lab1 和 Lab2 遇到过类似的了。唯一需要注意的是这里汇编的写法,直接将一个表达式作为汇编指令的参数。如果事先未见过这种写法似乎很难想到。 581 | 582 | 但是现在 cpu 依旧无法收到时钟异常。如果还记得 Lab1 中关于 `_start` 函数的内容,应该可以记得我们在最开始便屏蔽了中断 583 | ```c 584 | EXPORT(_start) 585 | .set at 586 | .set reorder 587 | /* disable interrupts */ 588 | mtc0 zero, CP0_STATUS 589 | ``` 590 | 591 | 现在我们要中断使能,实现该功能的函数即 `enable_irq`,位于 kern/env_asm.S 中。irq 为 interrupt request 的缩写。 592 | ```c 593 | LEAF(enable_irq) 594 | li t0, (STATUS_CU0 | STATUS_IM4 | STATUS_IEc) 595 | mtc0 t0, CP0_STATUS 596 | jr ra 597 | END(enable_irq) 598 | ``` 599 | 600 | 可以看到该函数的内容与 `_start` 中的指令相对应。这里我们为 cp0 寄存器 `status` 设置了值 `STATUS_CU0 | STATUS_IM4 | STATUS_IEc`。如果对 `status` 寄存器还有印象,应该能记得在 “进程控制块的初始化” 一节中我们设置了 trap frame 中的 `status` 寄存器的值。那时我们似乎已经设置了 4 号中断(4 号中断就是时钟中断),也使能了中断,为何现在还要设置? 601 | 602 | 因为那时我们只是设置了进程开始运行时,`status` 的状态;而还未设置当前的内核初始化环境中的状态。当系统切换到用户态,运行进程的时候,我们会用进程的状态覆盖之前的状态。现在设置 `status` 寄存器后,我们才能通过时钟中断进行上下文切换。也就是说,`enable_irq` 只是开启了内核初始化程序的中断,而开启中断的唯一目的就是通过时钟中断切换到第一个进程。 603 | 604 | 另外请注意,在之前我们设置的值为 `STATUS_IEp`,而此处为 `STATUS_IEc`,为什么两者位置却同样表示中断使能?实际上这来自于用户态和内核态的区别,具体可见实验指导书。 605 | 606 | ### (2)异常处理流程 607 | 中断是一种异常,当产生时钟中断时,cpu 就将执行异常处理流程。具体来说,当异常产生时,cpu 就会自动跳转到虚拟地址 `0x80000080` 处(特别的,当在用户态产生 TLB miss 异常时,会跳转到 `0x80000000`),从此处执行程序。这一程序应该完成异常的处理,并使 cpu 返回正常程序。 608 | 609 | 对于 MOS 来说,此处实现了一个异常分发函数,根据异常的不同类型选择不同的异常处理函数。此函数在 init/entry.S 中。 610 | ```c 611 | .section .text.tlb_miss_entry 612 | tlb_miss_entry: 613 | j exc_gen_entry 614 | 615 | .section .text.exc_gen_entry 616 | exc_gen_entry: 617 | SAVE_ALL 618 | /* Exercise 3.9: Your code here. */ 619 | mfc0 t0, CP0_CAUSE 620 | andi t0, 0x7c 621 | lw t0, exception_handlers(t0) 622 | jr t0 623 | ``` 624 | 625 | `tlb_miss_entry` 用于处理 TLB miss 异常,但实际上就是跳转到 `exc_gen_entry`。而在 `exc_gen_entry` 中,我们首先使用了一个宏 `SAVE_ALL`,该宏定义了一大段指令,用于将所有的寄存器值存储到栈帧中。这样我们便保存了异常发生时的上下文。唯一需要注意的是,在 `SAVE_ALL` 中,我们将栈帧的初始位置设置为 `KSTACKTOP`(之前的 `sp` 位置保存在 `TF_REG29(sp)`)。 626 | ```c 627 | // clang-format off 628 | .macro SAVE_ALL 629 | .set noreorder 630 | .set noat 631 | move k0, sp 632 | .set reorder 633 | bltz sp, 1f 634 | li sp, KSTACKTOP 635 | .set noreorder 636 | 1: 637 | subu sp, sp, TF_SIZE 638 | sw k0, TF_REG29(sp) 639 | mfc0 k0, CP0_STATUS 640 | sw k0, TF_STATUS(sp) 641 | // omit... 642 | sw $0, TF_REG0(sp) 643 | sw $1, TF_REG1(sp) 644 | sw $2, TF_REG2(sp) 645 | sw $3, TF_REG3(sp) 646 | // omit... 647 | ``` 648 | 649 | 接下来我们获取 `cause` 寄存器的值,取其 2-6 位,这部分对应异常码,用于区别不同的异常。 650 | ```c 651 | mfc0 t0, CP0_CAUSE 652 | andi t0, 0x7c 653 | ``` 654 | 655 | 接下来的这一部分,我们从 `exception_handlers` 数组中取出异常码对应的处理函数,并跳转到该异常处理函数。 656 | ```c 657 | lw t0, exception_handlers(t0) 658 | jr t0 659 | ``` 660 | 661 | 其中 `exception_handlers` 定义在 kern/traps.c 中。该数组是一个函数数组,其中每个元素都是异常码对应的异常处理函数。此数组称为异常向量组。 662 | ```c 663 | extern void handle_int(void); 664 | extern void handle_tlb(void); 665 | extern void handle_sys(void); 666 | extern void handle_mod(void); 667 | extern void handle_reserved(void); 668 | 669 | void (*exception_handlers[32])(void) = { 670 | [0 ... 31] = handle_reserved, 671 | [0] = handle_int, 672 | [2 ... 3] = handle_tlb, 673 | #if !defined(LAB) || LAB >= 4 674 | [1] = handle_mod, 675 | [8] = handle_sys, 676 | #endif 677 | }; 678 | ``` 679 | 680 | 需要注意的有两点。 681 | - 第一是此数组的定义似乎语法很奇怪。此语法是 GNU C 的扩展语法,`[first ... last] = value` 用于对数组上某个区间上元素赋同一个值。 682 | - 第二是 `exc_gen_entry` 中我们直接将 `andi t0, 0x7c` 的结果作为索引。这里需要注意一个地址 4 字节。 683 | 684 | 最后,`tlb_miss_entry` 和 `exc_gen_entry` 还未被放在 `0x80000000` 和 `0x80000080` 处。我们需要在 kernel.lds 中添加内容,将这两个标签固定在特定的地址位置。 685 | ```c 686 | SECTIONS { 687 | /* Exercise 3.10: Your code here. */ 688 | . = 0x80000000; 689 | .tlb_miss_entry : { 690 | *(.text.tlb_miss_entry) 691 | } 692 | 693 | . = 0x80000080; 694 | .exc_gen_entry : { 695 | *(.text.exc_gen_entry) 696 | } 697 | 698 | // omit... 699 | } 700 | ``` 701 | 702 | ### (3)异常处理函数在哪里定义? 703 | 如果你想了解一下不同异常的异常处理函数,可能会发现自己根本找不到 `handle_tlb`、`handle_mod` 等函数的定义。实际上这些函数都定义在 kern/genex.S 中。 704 | 705 | 当然,`handle_int` 的定义我们可以很直接地找到,此函数与中断有关,因此我们放到后面。 706 | 707 | 请关注位于该文件开头的宏 `BUILD_HANDLER`,构建处理函数。我们可以看到该宏有两个参数,`exception` 和 `handler`。在该宏中,我们定义了一个 `handle_\exception` 的函数,该函数调用 `\handler` 函数。返回后再调用 `ret_from_exception`(并且不返回?!)。 708 | ```c 709 | .macro BUILD_HANDLER exception handler 710 | NESTED(handle_\exception, TF_SIZE, zero) 711 | move a0, sp 712 | jal \handler 713 | j ret_from_exception 714 | END(handle_\exception) 715 | .endm 716 | ``` 717 | 718 | 让我们先不关注对 `ret_from_exception` 的调用。通过查看该宏的定义,应该可以理解 `handle_tlb` 是在哪里定义的了。我们可以在 genex.S 的最后看到如下语句。这就是异常处理函数的定义。 719 | ```c 720 | BUILD_HANDLER tlb do_tlb_refill 721 | 722 | #if !defined(LAB) || LAB >= 4 723 | BUILD_HANDLER mod do_tlb_mod 724 | BUILD_HANDLER sys do_syscall 725 | #endif 726 | 727 | BUILD_HANDLER reserved do_reserved 728 | ``` 729 | 730 | 我们可以看到一个似曾相识的名字 `do_tlb_refill`。这个函数可以说是 Lab2 的核心。在 Lab2 的测试中我们只是模拟 tlb 的重填,而在 Lab3 中,我们终于将该函数实际应用了。 731 | 732 | 其他的异常处理函数与本次实验无关,因此在本篇文章中就不考虑了。 733 | 734 | ### (4)ret_from_exception 函数 735 | 当异常处理完成后,我们便希望能返回到正常的程序中。`ret_from_exception` 便用于从异常处理程序中返回,除了使用 `BUILD_HANDLER` 创建的处理函数,`handle_int` 的处理过程中也使用了 `ret_from_exception`,这我们留到下一节介绍。 736 | 737 | 现在我们分析 `ret_from_exception` 函数。该函数定义在 kern/genex.S 中。首先该函数调用了一个宏 `RESTORE_SOME`,用于还原栈帧中通过调用 `SAVE_ALL` 保存的(部分)上下文。该宏和 `SAVE_ALL` 一样定义在 include/stackframe.h 中,不再详细介绍。 738 | ```c 739 | FEXPORT(ret_from_exception) 740 | RESTORE_SOME 741 | ``` 742 | 743 | 接着将 epc 寄存器的值加载到 k0 寄存器,epc 寄存器中存储有异常处理结束后的返回地址(各位应该还对 `TrapFrame` 结构体中的 `cp0_epc` 字段有印象);随后将所有栈帧中关于上下文的内容弹出。`TF_REG29(sp)` 地址中保存了 sp 寄存器在调用 `SAVE_ALL` 之前的地址。 744 | ```c 745 | lw k0, TF_EPC(sp) 746 | lw sp, TF_REG29(sp) /* Deallocate stack */ 747 | ``` 748 | 749 | 最后,跳转到 k0 中的返回地址。但在此之后还有另一条指令 `rfe`,用来从异常中恢复(恢复 `status` 寄存器,从内核态恢复到用户态)。 750 | ```c 751 | .set noreorder 752 | jr k0 753 | rfe 754 | .set reorder 755 | ``` 756 | 757 | 这样关于异常处理的部分我们就介绍完成了。 758 | 759 | ## 五、进程的调度 760 | ### (1)从 handle_int 函数继续 761 | 在操作系统中,使用时钟来划分时间片。当时钟中断发生时,就需要进行进程调度。在上一节中我们分析了 MOS 中的异常处理原理。在这一节中我们会从 `handle_int` 函数继续,讨论进程的调度机制的实现。 762 | 763 | 我们查看 `handle_int` 函数。首先经过一系列运算,从 `status` 寄存器中获得了 IM4 的值。正如前面提到过的,此值表示是否开启 4 号中断。 764 | ```c 765 | NESTED(handle_int, TF_SIZE, zero) 766 | mfc0 t0, CP0_CAUSE 767 | mfc0 t2, CP0_STATUS 768 | and t0, t2 769 | andi t1, t0, STATUS_IM4 770 | ``` 771 | 772 | 那么,如果是 4 号中断,也就是时钟中断,就转到时钟中断的处理。 773 | ```c 774 | bnez t1, timer_irq 775 | // TODO: handle other irqs 776 | ``` 777 | 778 | 在对时钟中断的处理中,我们首先将 `KSEG1 | DEV_RTC_ADDRESS | DEV_RTC_INTERRUPT_ACK` 地址的值置零。此地址中数值存储了本次中断的相关信息。清零说明我们已经完成了对中断的处理。随后我们调用 `schedule` 函数,进行进程的调度。 779 | ```c 780 | timer_irq: 781 | sw zero, (KSEG1 | DEV_RTC_ADDRESS | DEV_RTC_INTERRUPT_ACK) 782 | li a0, 0 783 | j schedule 784 | END(handle_int) 785 | ``` 786 | 787 | ### (2)调度方法 788 | `schedule` 函数位于 kern/sched.c 中。它有一个参数 `int yield`,用于表示是否强制让出当前进程的运行,了解过 java 多线程的应该对 yield 这个词有所认识。接下来就让我们深入这个函数。 789 | 790 | 首先,`schedule` 函数中存在一个静态变量 `count`,用于表示当前进程剩余的时间片。而 `curenv` 则是一个 `Env *` 类型的全局变量,用于表示当前运行的进程。 791 | ```c 792 | void schedule(int yield) { 793 | static int count = 0; // remaining time slices of current env 794 | struct Env *e = curenv; 795 | ``` 796 | 797 | 接着就是进程调度方法的主体,这也是本次实验我们需要填写的部分。实际上,注释已经将如何实现写得很清楚了。我们现在就分析一下该方法的原理。 798 | 799 | 首先我们考虑需要进行进程切换的情况,这在注释中有说明。 800 | - yield 为真时:此时当前进程必须让出 801 | - count 减为 0 时:此时分给进程的时间片被用完,将执行权让给其他进程 802 | - 无当前进程:这必然是内核刚刚完成初始化,第一次产生时钟中断的情况,需要分配一个进程执行 803 | - 进程状态不是可运行:当前进程不能再继续执行,让给其他进程 804 | ```c 805 | if (yield || count <= 0 || e == NULL || e->env_status != ENV_RUNNABLE) { 806 | ``` 807 | 808 | 首先我们考虑发生进程切换的情况。我们需要从进程调度队列中取出头部的进程控制块。这时我们需要判断一些情况。当之前的进程还是可运行的时,我们需要将其插入调度队列队尾,等待下一次轮到其执行,注意在此之前需要判断 `e` 非空。而当调度队列为空时,内核崩溃,因为操作系统中必须至少有一个进程。 809 | 810 | 这里需要着重注意的是,`env_sched_list` 要存储所有状态为 `ENV_RUNNABLE` 的进程控制块,这也包括当前正在运行的进程控制块 `curenv`。这样做主要是因为在 `env_free` 函数(这个函数在 Lab4 中介绍)和之后的实验中我们预设所有状态为 `ENV_RUNNABLE` 的进程控制块都在 `env_sched_list` 中,这样就可以毫无顾忌地使用类似 `TAILQ_REMOVE(&env_sched_list, (e), env_sched_link)` 的语句了。而假若对不在队列中的元素调用 `TAILQ_REMOVE`,则可能发生异常情况,使得列表中其他元素被一并删除。 811 | 812 | ```c 813 | if (e != NULL) { 814 | TAILQ_REMOVE(&env_sched_list, e, env_sched_link); 815 | if (e->env_status == ENV_RUNNABLE) { 816 | TAILQ_INSERT_TAIL(&env_sched_list, e, env_sched_link); 817 | } 818 | } 819 | 820 | if (TAILQ_EMPTY(&env_sched_list)) { 821 | panic("schedule: no runnable envs"); 822 | } 823 | ``` 824 | 825 | 随后,我们设定调度队列头部的进程控制块为将要运行的进程(不要在这里使用 `TAILQ_REMOVE`),将剩余时间片更新为新的进程的优先级。你可能会想:“所谓优先级就是时间片的多少?” 确实,就是这样,背后并没有什么复杂的算法。真正的操作系统中的进程优先级也仅仅是这样的作用。 826 | ```c 827 | e = TAILQ_FIRST(&env_sched_list); 828 | 829 | count = e->env_pri; 830 | } 831 | ``` 832 | 833 | 最后,不管发不发生进程切换,我们都要让 `count` 自减,表示当前进程用去了一个时间片的时间,之后我们调用 `env_run` 运行进程,来真正的消耗这一时间片的时间。这个调用位于判断语句之外,也就是说不论是否发生进程切换,都需要执行 `env_run`。 834 | ```c 835 | count--; 836 | env_run(e); 837 | } 838 | ``` 839 | 840 | ### (3)进程的运行 841 | `env_run` 是如何让进程运行的?让我们查看这个函数,它位于 kern/env.c 中。 842 | 843 | 唠叨一句无关的。在该函数的开头调用了 `pre_env_run` 用于打印评测信息。说实话有点丑。 844 | ```c 845 | void env_run(struct Env *e) { 846 | assert(e->env_status == ENV_RUNNABLE); 847 | pre_env_run(e); // WARNING: DO NOT MODIFY THIS LINE! 848 | ``` 849 | 850 | 此时全局变量 `curenv` 中还是切换前的进程控制块,我们保存该进程的上下文,将栈帧中 trap frame 的信息转换为 `Trapframe` 存储在 `env_tf` 中。至于为什么 trap frame 的信息存储在 `[KSTACKTOP - 1, KSTACKTOP)` 的范围内,参考关于 `SAVE_ALL` 宏的内容。 851 | ```c 852 | /* Step 1: 853 | * If 'curenv' is NULL, this is the first time through. 854 | * If not, we may be switching from a previous env, so save its context into 855 | * 'curenv->env_tf' first. 856 | */ 857 | if (curenv) { 858 | curenv->env_tf = *((struct Trapframe *)KSTACKTOP - 1); 859 | } 860 | ``` 861 | 862 | 接着,我们将 `curenv` 的值变为 `e` 的值,实现当前进程的切换。注释 `lab6` 的内容暂不考虑。 863 | ```c 864 | /* Step 2: Change 'curenv' to 'e'. */ 865 | curenv = e; 866 | curenv->env_runs++; // lab6 867 | ``` 868 | 869 | 之后,我们将全局变量 `cur_pgdir` 设置为当前进程对应的页目录,实现页目录的切换。各位或许还对 `cur_pgdir` 有印象,在 Lab2 的笔记中有提到。那时我说 870 | > 值得注意的是,`_do_tlb_refill` 调用该函数时页目录基地址参数使用的是全局变量 `cur_pgdir`。可是这个全局变量并没有任何被赋值。这也是在 Lab2 中页式内存管理无法使用的一个原因。 871 | 现在我们为 `cur_pgdir` 赋了值,就可以愉快地使用用户内存空间范围的虚拟地址了。 872 | ```c 873 | /* Step 3: Change 'cur_pgdir' to 'curenv->env_pgdir', switching to its address space. */ 874 | /* Exercise 3.8: Your code here. (1/2) */ 875 | cur_pgdir = curenv->env_pgdir; 876 | ``` 877 | 878 | 最后,我们调用 `env_pop_tf` 函数,根据栈帧还原进程上下文,并运行程序。 879 | ```c 880 | /* Step 4: Use 'env_pop_tf' to restore the curenv's saved context (registers) and return/go 881 | * to user mode. 882 | * 883 | * Hint: 884 | * - You should use 'curenv->env_asid' here. 885 | * - 'env_pop_tf' is a 'noreturn' function: it restores PC from 'cp0_epc' thus not 886 | * returning to the kernel caller, making 'env_run' a 'noreturn' function as well. 887 | */ 888 | /* Exercise 3.8: Your code here. (2/2) */ 889 | env_pop_tf(&curenv->env_tf, curenv->env_asid); 890 | } 891 | ``` 892 | 893 | `env_pop_tf` 函数定义在 kern/env_asm.S 中,内容较为简单。该函数将传入的 asid 值设置到 `EntryHi` 寄存器中,表示之后的虚拟内存访问都来自于 asid 所对应的进程。另外该函数将`sp` 寄存器地址设置为当前进程的 trap frame 地址,这样在最后调用 `ret_from_exception` 从异常处理中返回时,将使用当前进程的 trap frame 恢复上下文。程序也将从当前进程的 epc 中执行(epc 的值在 `load_icode` 中根据 elf 头设置为程序入口地址)。 894 | ```c 895 | LEAF(env_pop_tf) 896 | .set reorder 897 | .set at 898 | sll a1, a1, 6 899 | mtc0 a1, CP0_ENTRYHI 900 | move sp, a0 901 | j ret_from_exception 902 | END(env_pop_tf) 903 | ``` 904 | 905 | 最后,所有的寄存器都恢复成了当前进程所需要的状态,cpu 就像只知道当前进程这一个程序一样不断执行一条条指令,直到经过了一个时钟周期,又一个中断发生…… 906 | 907 | **(Lab3 完)** 908 | -------------------------------------------------------------------------------- /BUAA-OS实验笔记之Lab4.md: -------------------------------------------------------------------------------- 1 | ## 一、Lab4 前言 2 | Lab4 主要实现了系统调用,并通过系统调用实现了进程的创建和通信等操作。按照提示编写代码的难度应该不大(除非你的 Lab3 `schedule` 函数有 bug,很可惜我就是这样 `:(`),所以本次的笔记更多的讨论了一些和实验无关的代码。希望不会显得太啰嗦。 3 | 4 | 5 | 6 | ## 二、系统调用 7 | ### (1)从一个用户程序引入 8 | 在之前的几篇文章中,我们大致循着内核初始化的过程进行分析。可是在这 Lab4 中这一思路就不适用了。因为在本次实验中我们所要实现的,不过是一些由内核提供的,可供用户程序调用的接口而已。这种调用被称为系统调用。 9 | 10 | 但是为了保持文章行文的一致性,我们还是希望确定一个入口开始讲解。正好在 `mips_init` 中有这样的语句,那我们就从被创建的这个用户程序开始。 11 | ```c 12 | // lab4: 13 | // ENV_CREATE(user_tltest); 14 | ``` 15 | 16 | 这里需要插一嘴,在 Lab3 中我们就已经使用 `ENV_CREATE` 完成了一些程序的加载,可你有没有仔细看过被加载的程序的源代码是什么样的?代码在 user/bare 路径下。我们查看其中的 put_a.c 程序 17 | ```c 18 | void _start() { 19 | for (unsigned i = 0;; ++i) { 20 | if ((i & ((1 << 16) - 1)) == 0) { 21 | // Requires `e->env_tf.cp0_status &= ~STATUS_KUp;` in kernel to work 22 | *(volatile char *)0xb0000000 = 'a'; 23 | *(volatile char *)0xb0000000 = ' '; 24 | } 25 | } 26 | } 27 | 28 | ``` 29 | 30 | 你会发现这些所谓的程序并没有 `main` 函数,而是 `_start`。实际上和内核一样,我们同样在用于用户程序编译的链接器脚本中将程序入口设定为 `_start`。该脚本为 user/user.lds,其中同样有 31 | ```c 32 | ENTRY(_start) 33 | ``` 34 | 35 | 但是如果你查看一下我们将要查看的 user/tltest.c 程序,就会发现其中又是以 `main` 函数为入口了。这是为什么呢? 36 | 37 | 这是因为我们在 user/lib/entry.S 中定义了统一的 `_start` 函数。 38 | ```c 39 | .text 40 | EXPORT(_start) 41 | lw a0, 0(sp) 42 | lw a1, 4(sp) 43 | jal libmain 44 | ``` 45 | 46 | 值得注意跳转前的两条指令。这两条指令加载了 `argc` 和 `argv`。你可能还记得 Lab3 中 `env_alloc` 函数的这条语句,它将栈底的 8 个字节留给 `argc` 和 `argv`。 47 | ```c 48 | // Keep space for 'argc' and 'argv'. 49 | e->env_tf.regs[29] = USTACKTOP - sizeof(int) - sizeof(char **); 50 | ``` 51 | 52 | 在 `_start` 函数的最后,跳转到 `libmain`。这个函数定义在 user/lib/libos.c 中。在这个函数中,我们通过一个函数 `syscall_getenvid` 获取了当前进程的 envid。此函数是一个系统调用,我们留到之后讲解。之后使用宏 `ENVX` 根据 envid 获取到该 envid 对应的进程控制块相对于进程控制块数组的索引值,并取得当前进程的进程控制块。 53 | ```c 54 | void libmain(int argc, char **argv) { 55 | // set env to point at our env structure in envs[]. 56 | env = &envs[ENVX(syscall_getenvid())]; 57 | 58 | ``` 59 | 60 | 你可能没找到 `env` 和 `envs` 的定义,它们都在 user/include/lib.h 中。你可能还记得 Lab3 中我们将页控制块数组和进程控制块数组映射到用户虚拟地址空间的某一位置,这里 `envs` 就是映射到的用户虚拟地址,同理还有 `pages`。注意不要与内核空间中的 `envs` 和 `pages` 搞混。虽然它们确实表示同一物理地址的相同数据,但是用户程序无法访问内核地址空间中的 `envs` 和 `pages`,如果访问会产生异常。 61 | 62 | 另外 `env` 用于表示本用户程序的进程控制块。我们可以通过访问 `env` 获取当前进程的信息。 63 | ```c 64 | #define envs ((volatile struct Env *)UENVS) 65 | #define pages ((volatile struct Page *)UPAGES) 66 | 67 | extern volatile struct Env *env; 68 | ``` 69 | 70 | 之后,`libmain` 函数便调用了 `main` 函数 71 | ```c 72 | // call user main routine 73 | main(argc, argv); 74 | 75 | ``` 76 | 77 | 最后,当 main 函数返回后,我们调用 `exit` 函数结束进程 78 | ```c 79 | // exit gracefully 80 | exit(); 81 | } 82 | 83 | ``` 84 | 85 | `exit` 函数同样定义在 user/lib/libos.c 中。 86 | ```c 87 | void exit(void) { 88 | // After fs is ready (lab5), all our open files should be closed before dying. 89 | #if !defined(LAB) || LAB >= 5 90 | close_all(); 91 | #endif 92 | 93 | syscall_env_destroy(0); 94 | user_panic("unreachable code"); 95 | } 96 | ``` 97 | 98 | 我们不考虑 Lab5 之后才用到的内容。`exit` 函数只调用了一个 `syscall_env_destroy`,该函数也是一个系统调用,用于销毁进程,释放进程资源。这里传入了一个参数 asid = 0,表示销毁的是当前进程。在之后还会多次出现 asid = 0 表示当前进程(调用函数的进程)的用法。 99 | 100 | 值得注意,在 `syscall_env_destroy` 之后还多出一条语句 `user_panic`。此函数类似于 `panic`,不过用于表示用户程序出现了难以恢复的错误。在调用该函数打印错误信息后,就会结束该进程。 101 | ```c 102 | // user/include/lib.h 103 | void _user_panic(const char *, int, const char *, ...) __attribute__((noreturn)); 104 | #define user_panic(...) _user_panic(__FILE__, __LINE__, __VA_ARGS__) 105 | 106 | // user/lib/debug.c 107 | void _user_panic(const char *file, int line, const char *fmt, ...) { 108 | debugf("panic at %s:%d: ", file, line); 109 | va_list ap; 110 | va_start(ap, fmt); 111 | vdebugf(fmt, ap); 112 | va_end(ap); 113 | debugf("\n"); 114 | exit(); 115 | } 116 | 117 | ``` 118 | 119 | 在 `exit` 函数中出现的 `user_panic` 很明显的指出 `syscall_env_destroy` 是一个不会返回的函数。 120 | 121 | 这样,用户程序的入口的故事我们就讲完了。你现在应该就可以理解 Lab3 中使用的用户程序位于 bare 路径下的原因了。因为他们没有被 `libmain` 包裹,是赤裸裸地暴露在外运行的。 122 | 123 | 之后还是让我们看一下 user/tltest.c 的程序吧 124 | ```c 125 | int main() { 126 | debugf("Smashing some kernel codes...\n" 127 | "If your implementation is correct, you may see unknown exception here:\n"); 128 | *(int *)KERNBASE = 0; 129 | debugf("My mission completed!\n"); 130 | return 0; 131 | } 132 | 133 | ``` 134 | 135 | 这里用户程序试图向内核空间写入数据,于是就会产生异常。运行的结果如下。`do_reserved` 函数用于处理除了定义过异常处理函数的其他异常。5 号异常表示地址错误。 136 | ```c 137 | Smashing some kernel codes... 138 | If your implementation is correct, you may see unknown exception here: 139 | ... 140 | panic at traps.c:24 (do_reserved): Unknown ExcCode 5 141 | ``` 142 | 143 | 这里出现了一个新函数 `debugf`,该函数在用户程序的地位相当于内核中的 `printk`。它定义在 user/lib/debugf.c 中。为了避免这篇文章过程,不详细解释该函数,只是提一下 `debugf` 和相关函数的调用关系。这里有 `debugf -> vdebugf -> vprintfmt -> debug_output -> debug_flush -> syscall_print_cons`。最终,为了向屏幕输出字符,我们的用户程序还是使用了系统调用 `syscall_print_cons`。 144 | ```c 145 | static void debug_flush(struct debug_ctx *ctx) { 146 | if (ctx->pos == 0) { 147 | return; 148 | } 149 | int r; 150 | if ((r = syscall_print_cons(ctx->buf, ctx->pos)) != 0) { 151 | user_panic("syscall_print_cons: %d", r); 152 | } 153 | ctx->pos = 0; 154 | } 155 | 156 | static void debug_output(void *data, const char *s, size_t l) { 157 | struct debug_ctx *ctx = (struct debug_ctx *)data; 158 | 159 | while (ctx->pos + l > BUF_LEN) { 160 | size_t n = BUF_LEN - ctx->pos; 161 | memcpy(ctx->buf + ctx->pos, s, n); 162 | s += n; 163 | l -= n; 164 | ctx->pos = BUF_LEN; 165 | debug_flush(ctx); 166 | } 167 | memcpy(ctx->buf + ctx->pos, s, l); 168 | ctx->pos += l; 169 | } 170 | 171 | static void vdebugf(const char *fmt, va_list ap) { 172 | struct debug_ctx ctx; 173 | ctx.pos = 0; 174 | vprintfmt(debug_output, &ctx, fmt, ap); 175 | debug_flush(&ctx); 176 | } 177 | 178 | void debugf(const char *fmt, ...) { 179 | va_list ap; 180 | va_start(ap, fmt); 181 | vdebugf(fmt, ap); 182 | va_end(ap); 183 | } 184 | 185 | ``` 186 | 187 | 你可能会想,系统调用到底有什么用?为什么用户程序的许多操作都需要由内核经手?在我看来,系统调用,乃至其他操作系统的许多其他功能,目的都是安全性。这体现在两个方面。一是系统的安全,保证计算机不会被非法篡改;二是操作的安全,保证用户的大部分操作都不会破坏计算机系统本身。为了做到这两点,我们就需要将一些更加重要的数据结构和算法隐藏在内核中,与用户隔离。从而避免有意或无意的操作对数据造成无法挽回的破坏。 188 | 189 | ### (2)系统调用的实现 190 | 在上一小节中我们见识了一些系统调用函数,在 MOS 的提供给用户程序的库中,所有的系统调用均为 syscall_* 的形式。所以,这些函数就是内核提供给用户程序的接口吗? 191 | 192 | 答案是否定的。所有的系统调用都定义在 user/lib/syscall_lib.c 中。我们取之前看到过的系统调用函数为例 193 | ```c 194 | int syscall_print_cons(const void *str, u_int num) { 195 | return msyscall(SYS_print_cons, str, num); 196 | } 197 | 198 | u_int syscall_getenvid(void) { 199 | return msyscall(SYS_getenvid); 200 | } 201 | 202 | int syscall_env_destroy(u_int envid) { 203 | return msyscall(SYS_env_destroy, envid); 204 | } 205 | 206 | ``` 207 | 208 | 可以看到,这些函数(其实是所有的系统调用函数)都调用了 `msyscall` 一个函数。为其传入了不同数量、类型的参数。你可能已经想到了,这是一个拥有变长参数的函数。我们可以在 user/include/lib.h 中找到该函数的声明。 209 | ```c 210 | /// syscalls 211 | extern int msyscall(int, ...); 212 | ``` 213 | 214 | 你可能会想,“原来内核提供给用户程序的接口其实只有 `msyscall` 函数一个。第一个参数表示系统调用的类型,剩下的参数根据系统调用不同也有所区别。内核只对用户程序暴露这一个函数,最大程度限制了用户程序对内核的访问。” 事实是这样吗? 215 | 216 | 其实也不是。还是让我们看一看 `msyscall` 的定义吧。 217 | ```c 218 | LEAF(msyscall) 219 | // Just use 'syscall' instruction and return. 220 | 221 | /* Exercise 4.1: Your code here. */ 222 | syscall 223 | jr ra 224 | END(msyscall) 225 | ``` 226 | 227 | 该函数只有两条指令,第二条指令 `jr ra` 只是从该函数返回。真正关键的只有第一条 `syscall`。这条指令用于让程序自行产生一个异常,该异常被称为系统异常。这样进行异常处理,才进入了内核态。实际上经过了几个实验,应该明白一点,**内核态提供给用户程序的接口,只有异常**。 228 | 229 | 插一句嘴,虽然 `msyscall` 是一个拥有可变参数的函数,可我们并没有使用可变参数宏来进行处理。由此可见,所谓的可变参数,不过是编译器的特殊处理罢了。 230 | 231 | 接下来我们就要处理系统异常。还记得我们在 Lab3 中略过不讲的两个异常处理函数吗?这两个函数将在本次实验中起到大用处。这里我们先看 `do_syscall` 232 | ```c 233 | #if !defined(LAB) || LAB >= 4 234 | BUILD_HANDLER mod do_tlb_mod 235 | BUILD_HANDLER sys do_syscall 236 | #endif 237 | ``` 238 | 239 | 回忆一下 Lab3 中从进入异常到进入特定异常的处理函数中间的过程。在这中间我们使用 `SAVE_ALL` 宏保存了发生异常时的现场。对于系统异常来说,需要着重强调,我们保存了调用 `msyscall` 函数时的参数信息。对于这一点,请回忆 mips 函数调用的相关知识。 240 | 241 | `do_syscall` 函数位于 kern/syscall_all.c 中。该函数用于实现系统调用的分发和运行。 242 | 243 | 首先,我们取出用户程序 trap frame 中的 `a0` 寄存器的值。该值即调用 `msyscall` 函数时的第一个参数,确实用于表示系统调用的类型。之后我们判断 `sysno` 是否处于范围内,如果不是则 “返回”。需要注意这里返回值的设定方法。我们为 trap frame 中的 `v0` 寄存器赋值。在 mips 函数调用中,该寄存器存储返回值。 244 | ```c 245 | void do_syscall(struct Trapframe *tf) { 246 | int (*func)(u_int, u_int, u_int, u_int, u_int); 247 | int sysno = tf->regs[4]; 248 | 249 | if (sysno < 0 || sysno >= MAX_SYSNO) { 250 | tf->regs[2] = -E_NO_SYS; 251 | return; 252 | } 253 | 254 | ``` 255 | 256 | 之后,我们将 `epc` 寄存器的地址加 4。如果还记得 Lab3,应该知道 `epc` 寄存器存储了发生异常的指令地址。当完成异常处理,调用 `ret_from_exception` 从异常中返回时,也是回到 `epc` 指向的指令再次执行。这里将 `epc` 寄存器的地址加 4,意味着从异常返回后并不重新执行 `syscall` 指令,而是其下一条指令。 257 | ```c 258 | /* Step 1: Add the EPC in 'tf' by a word (size of an instruction). */ 259 | /* Exercise 4.2: Your code here. (1/4) */ 260 | tf->cp0_epc += 4; 261 | ``` 262 | 263 | 然后,我们根据 `sysno` 取得对应的系统调用函数。 264 | ```c 265 | /* Step 2: Use 'sysno' to get 'func' from 'syscall_table'. */ 266 | /* Exercise 4.2: Your code here. (2/4) */ 267 | func = syscall_table[sysno]; 268 | 269 | ``` 270 | 271 | 其中 `syscall_table` 中存储了所有的系统调用函数 272 | ```c 273 | void *syscall_table[MAX_SYSNO] = { 274 | [SYS_putchar] = sys_putchar, 275 | [SYS_print_cons] = sys_print_cons, 276 | [SYS_getenvid] = sys_getenvid, 277 | [SYS_yield] = sys_yield, 278 | [SYS_env_destroy] = sys_env_destroy, 279 | [SYS_set_tlb_mod_entry] = sys_set_tlb_mod_entry, 280 | [SYS_mem_alloc] = sys_mem_alloc, 281 | [SYS_mem_map] = sys_mem_map, 282 | [SYS_mem_unmap] = sys_mem_unmap, 283 | [SYS_exofork] = sys_exofork, 284 | [SYS_set_env_status] = sys_set_env_status, 285 | [SYS_set_trapframe] = sys_set_trapframe, 286 | [SYS_panic] = sys_panic, 287 | [SYS_ipc_try_send] = sys_ipc_try_send, 288 | [SYS_ipc_recv] = sys_ipc_recv, 289 | [SYS_cgetc] = sys_cgetc, 290 | [SYS_write_dev] = sys_write_dev, 291 | [SYS_read_dev] = sys_read_dev, 292 | }; 293 | ``` 294 | 295 | 最后,我们从用户程序的 trap frame 中取出调用 `msyscall` 时传入的参数。按照 mips 函数调用规范,函数调用的前四个参数存储在 `a0-a3` 寄存器中。更多的参数被存储在 `sp` 寄存器的对应地址中。因为我们的系统调用最多有 5 个参数,因此需要取得 arg1 到 arg5 的值。最后我们调用根据 `sysno` 取得的系统调用函数,调用该函数,将其返回值存储在 `v0` 寄存器中。 296 | ```c 297 | /* Step 3: First 3 args are stored at $a1, $a2, $a3. */ 298 | u_int arg1 = tf->regs[5]; 299 | u_int arg2 = tf->regs[6]; 300 | u_int arg3 = tf->regs[7]; 301 | 302 | /* Step 4: Last 2 args are stored in stack at [$sp + 16 bytes], [$sp + 20 bytes] */ 303 | u_int arg4, arg5; 304 | /* Exercise 4.2: Your code here. (3/4) */ 305 | arg4 = *(u_int *)(tf->regs[29]+16); 306 | arg5 = *(u_int *)(tf->regs[29]+20); 307 | 308 | /* Step 5: Invoke 'func' with retrieved arguments and store its return value to $v0 in 'tf'. 309 | */ 310 | /* Exercise 4.2: Your code here. (4/4) */ 311 | tf->regs[2] = func(arg1, arg2, arg3, arg4, arg5); 312 | } 313 | ``` 314 | 315 | 实际上我们不难看出,在 `do_syscall` 函数中我们修改了用户程序 trap frame 的值,其目的就是模拟函数调用的效果。这样在恢复现场,用户程序继续运行时,就会感觉和平常的函数调用没有不同之处。异常的处理过程,就仿佛变成了内核暴露给用户程序的接口了。可是我们还是要明确这一点,系统调用的本质,就是异常处理。 316 | 317 | ### (3)补充 env_destroy 318 | Lab3 的笔记中没有讲解 `env_destroy`,主要是因为我们确实没有用到该函数。现在我们将其封装成系统调用,作为 main 函数之后的资源回收函数,就是时候考虑该函数了。 319 | 320 | 我们使用的系统调用为 `sys_env_destroy`,可以看出只是封装了一下 `env_destroy`。 321 | ```c 322 | int sys_env_destroy(u_int envid) { 323 | struct Env *e; 324 | try(envid2env(envid, &e, 1)); 325 | 326 | printk("[%08x] destroying %08x\n", curenv->env_id, e->env_id); 327 | env_destroy(e); 328 | return 0; 329 | } 330 | 331 | ``` 332 | 333 | 那么 `env_destroy` 呢?该函数主要作用是调用了 `env_free` 函数。另外对于是当前函数被销毁的情况,我们就需要进行进程调度。 334 | ```c 335 | void env_destroy(struct Env *e) { 336 | /* Hint: free e. */ 337 | env_free(e); 338 | 339 | /* Hint: schedule to run a new environment. */ 340 | if (curenv == e) { 341 | curenv = NULL; 342 | printk("i am killed ... \n"); 343 | schedule(1); 344 | } 345 | } 346 | ``` 347 | 348 | 对于 `env_free` 函数。首先遍历所有页表项,使用 `page_remove` 删除虚拟地址到物理页的映射;另外使用 `page_decref` 释放页表和页目录本身。其中还使用 `asid_free` 释放了 asid。对页表项进行修改后,使用了 `tlb_invalidate` 将对应的项无效化。 349 | ```c 350 | void env_free(struct Env *e) { 351 | Pte *pt; 352 | u_int pdeno, pteno, pa; 353 | 354 | /* Hint: Note the environment's demise.*/ 355 | printk("[%08x] free env %08x\n", curenv ? curenv->env_id : 0, e->env_id); 356 | 357 | /* Hint: Flush all mapped pages in the user portion of the address space */ 358 | for (pdeno = 0; pdeno < PDX(UTOP); pdeno++) { 359 | /* Hint: only look at mapped page tables. */ 360 | if (!(e->env_pgdir[pdeno] & PTE_V)) { 361 | continue; 362 | } 363 | /* Hint: find the pa and va of the page table. */ 364 | pa = PTE_ADDR(e->env_pgdir[pdeno]); 365 | pt = (Pte *)KADDR(pa); 366 | /* Hint: Unmap all PTEs in this page table. */ 367 | for (pteno = 0; pteno <= PTX(~0); pteno++) { 368 | if (pt[pteno] & PTE_V) { 369 | page_remove(e->env_pgdir, e->env_asid, 370 | (pdeno << PDSHIFT) | (pteno << PGSHIFT)); 371 | } 372 | } 373 | /* Hint: free the page table itself. */ 374 | e->env_pgdir[pdeno] = 0; 375 | page_decref(pa2page(pa)); 376 | /* Hint: invalidate page table in TLB */ 377 | tlb_invalidate(e->env_asid, UVPT + (pdeno << PGSHIFT)); 378 | } 379 | /* Hint: free the page directory. */ 380 | page_decref(pa2page(PADDR(e->env_pgdir))); 381 | /* Hint: free the ASID */ 382 | asid_free(e->env_asid); 383 | /* Hint: invalidate page directory in TLB */ 384 | tlb_invalidate(e->env_asid, UVPT + (PDX(UVPT) << PGSHIFT)); 385 | 386 | ``` 387 | 388 | 最后修改进程控制块的状态为 `ENV_FREE`,将该控制块从调度队列中删除,重新放回空闲列表中。 389 | ```c 390 | /* Hint: return the environment to the free list. */ 391 | e->env_status = ENV_FREE; 392 | LIST_INSERT_HEAD((&env_free_list), (e), env_link); 393 | TAILQ_REMOVE(&env_sched_list, (e), env_sched_link); 394 | } 395 | 396 | ``` 397 | 398 | ## 二、fork 的实现 399 | ### (1)用户态异常处理的实现 400 | `fork` 是创建进程的基本方法。某一进程调用该函数后,就会创建一个该进程的复制作为调用进程的子进程。子进程也会从 `fork` 后的时刻开始执行。但会具有和父进程不同的返回值。父进程的返回值为子进程的 进程标识符 envid,而子进程的返回值为 0。当然之后我们就会知道,envid = 0 可以表示当前进程。这样的话我们也可以理解成不管在父进程还是子进程,返回值都为指示子进程的 envid。 401 | 402 | 上面的内容可能有些难以理解,指导书里给出了一段样例代码用于帮助理解,这里也贴出来 403 | ```c 404 | #include 405 | #include 406 | 407 | int main() { 408 | int var = 1; 409 | long pid; 410 | printf("Before fork, var = %d.\n", var); 411 | pid = fork(); 412 | printf("After fork, var = %d.\n", var); 413 | if (pid == 0) { 414 | var = 2; 415 | sleep(3); 416 | printf("child got %ld, var = %d", pid, var); 417 | } else { 418 | sleep(2); 419 | printf("parent got %ld, var = %d", pid, var); 420 | } 421 | printf(", pid: %ld\n", (long) getpid()); 422 | return 0; 423 | } 424 | 425 | ``` 426 | 427 | 我们还是查看一下 `fork` 函数的代码吧,它位于 user/lib/fork.c 中。`fork` 本身不是系统调用,但该函数使用了许多系统调用来完成子进程的创建。 428 | 429 | 首先我们就遇到了一个系统调用 `env_user_tlb_mod_entry`,这个系统调用的作用是设置一个 TLB Mod 异常的处理函数。TLB Mod 异常即页写入(Modify)异常,会在程序试图写入不可写入(对应页表项无 `PTE_D`)的物理页面时产生。 430 | ```c 431 | int fork(void) { 432 | u_int child; 433 | u_int i; 434 | extern volatile struct Env *env; 435 | 436 | /* Step 1: Set our TLB Mod user exception entry to 'cow_entry' if not done yet. */ 437 | if (env->env_user_tlb_mod_entry != (u_int)cow_entry) { 438 | try(syscall_set_tlb_mod_entry(0, cow_entry)); 439 | } 440 | 441 | ``` 442 | 443 | 该系统调用对应的函数为 `sys_set_tlb_mod_entry`,这个函数很简单,只是设置了进程控制块的 `env_user_tlb_mod_entry` 参数。 444 | ```c 445 | int sys_set_tlb_mod_entry(u_int envid, u_int func) { 446 | struct Env *env; 447 | 448 | /* Step 1: Convert the envid to its corresponding 'struct Env *' using 'envid2env'. */ 449 | /* Exercise 4.12: Your code here. (1/2) */ 450 | try(envid2env(envid, &env, 1)); 451 | 452 | /* Step 2: Set its 'env_user_tlb_mod_entry' to 'func'. */ 453 | /* Exercise 4.12: Your code here. (2/2) */ 454 | env->env_user_tlb_mod_entry = func; 455 | 456 | return 0; 457 | } 458 | ``` 459 | 460 | 你可能会想,为什么要在用户程序中设置异常处理函数呢?这不是应该交由内核处理吗?设置了之后又要如何使用?我们需要查看一下内核中 TLB Mod 异常的处理函数 `do_tlb_mod`,位于 kern/tlbex.c 中。 461 | 462 | 在该函数中,我们首先将 `sp` 寄存器设置到 `UXSTACKTOP` 的位置。`UXSTACKTOP` 和 `KSTACKTOP` 类似,都是在处理异常时使用的调用栈,因为我们要将异常处理交由用户态执行,因此需要在用户的地址空间中分配。另外这里添加判断语句是考虑到在异常处理的过程中再次出现异常的情况,我们不希望之前的异常处理过程的信息丢失,而希望在处理了后一个异常后再次继续处理前一个异常,于是对于 `sp` 已经在异常栈中的情况,就不再从异常栈顶开始。 463 | ```c 464 | void do_tlb_mod(struct Trapframe *tf) { 465 | struct Trapframe tmp_tf = *tf; 466 | 467 | if (tf->regs[29] < USTACKTOP || tf->regs[29] >= UXSTACKTOP) { 468 | tf->regs[29] = UXSTACKTOP; 469 | } 470 | 471 | ``` 472 | 473 | 随后我们在用户异常栈底分配一块空间,用于存储 trap frame。这里和在 `KSTACKTOP` 底使用 `SAVE_ALL` 类似。 474 | ```c 475 | tf->regs[29] -= sizeof(struct Trapframe); 476 | *(struct Trapframe *)tf->regs[29] = tmp_tf; 477 | ``` 478 | 479 | 最后我们从当前进程的进程控制块的 `env_user_tlb_mod_entry` 取出由 `syscall_set_tlb_mod_entry` 设定的当前进程的 TLB Mod 异常处理函数,“调用” 该处理函数。当然,因为处于异常处理过程中,所以这里我们采用和一般系统调用类似的处理方法。首先我们设定 `a0` 寄存器的值为 trap frame 所在的地址,接着 `sp` 寄存器自减,留出第一个参数的空间。最后设定 `epc` 寄存器的值为用户的 TLB Mod 函数的地址,使得恢复现场后跳转到该函数的位置继续执行。 480 | ```c 481 | if (curenv->env_user_tlb_mod_entry) { 482 | tf->regs[4] = tf->regs[29]; 483 | tf->regs[29] -= sizeof(tf->regs[4]); 484 | // Hint: Set 'cp0_epc' in the context 'tf' to 'curenv->env_user_tlb_mod_entry'. 485 | /* Exercise 4.11: Your code here. */ 486 | tf->cp0_epc = curenv->env_user_tlb_mod_entry; 487 | } else { 488 | panic("TLB Mod but no user handler registered"); 489 | } 490 | } 491 | ``` 492 | 493 | 应当注意,作为用户态异常处理函数的参数的 `TrapFrame * tf` 和当前进程的 `tf` 是不同的。前者依旧是产生 TLB Mod 异常时的现场;而后者则经过了修改,以便在从异常处理返回时跳转到用户态异常处理函数而非产生异常的位置。在用户态异常处理函数完成异常处理后,我们会通过前者返回产生异常的位置。 494 | 495 | 最后我们还需要注意 `sys_set_tlb_mod_entry` 中使用了 `envid2env` 函数来根据进程标识符获取对应的进程。这里出现了 envid = 0 时直接返回当前进程的实现。正是因为以 envid 作为参数的函数都会使用本函数获取对应的进程控制块,所以才会有 envid = 0 表示当前进程的说法。另外,因为 envid = 0 有特殊含义,所以生成进程标识符的函数 `mkenvid` 永远不可能生成为 0 的 envid。对于 envid 不为 0 的情况,我们通过 `ENVX` 宏获取 envid 对应的进程控制块相对于进程控制块数组的索引。`mkenvid` 的算法中,envid 的低十位就是进程控制块的索引,因此我们才可以通过 envid 获取对应的进程控制块。 496 | ```c 497 | int envid2env(u_int envid, struct Env **penv, int checkperm) { 498 | struct Env *e; 499 | 500 | /* Step 1: Assign value to 'e' using 'envid'. */ 501 | /* Hint: 502 | * If envid is zero, set 'penv' to 'curenv'. 503 | * You may want to use 'ENVX'. 504 | */ 505 | /* Exercise 4.3: Your code here. (1/2) */ 506 | if (envid == 0) { 507 | *penv = curenv; 508 | return 0; 509 | } else { 510 | e = envs + ENVX(envid); 511 | } 512 | ``` 513 | 514 | 在 `envid2env` 的后半部分,我们进行了一系列进程控制块的有效性检查。注意这里的 `e->env_id != envid`,按说我们是通过 `envid` 获取的进程控制块,怎么可能进程控制块的 `env_id` 不相同呢?这实际上考虑了这样一种情况,某一进程完成运行,资源被回收,这时其对应的进程控制块会插入回 `env_free_list` 中。当我们需要再次创建内存时,就可能重新取得该进程控制块,并为其赋予不同的 envid。这时,已销毁进程的 envid 和新创建进程的 envid 都能通过 `ENVX` 宏取得相同的值,对应了同一个进程控制块。可是已销毁进程的 envid 却不应当再次出现,`e->env_id != envid` 就处理了 `envid` 属于已销毁进程的情况。 515 | ```c 516 | if (e->env_status == ENV_FREE || e->env_id != envid) { 517 | return -E_BAD_ENV; 518 | } 519 | ``` 520 | 521 | 对于设置了 `checkperm` 的情况,我们还需要额外检查传入的 `envid` 是否与当前进程具有直接亲缘关系。由此也可以得知,对于一些操作,只有父进程对子进程具有权限。 522 | ```c 523 | /* Step 2: Check when 'checkperm' is non-zero. */ 524 | /* Hints: 525 | * Check whether the calling env has sufficient permissions to manipulate the 526 | * specified env, i.e. 'e' is either 'curenv' or its immediate child. 527 | * If violated, return '-E_BAD_ENV'. 528 | */ 529 | /* Exercise 4.3: Your code here. (2/2) */ 530 | if (checkperm && e->env_id != curenv->env_id &&e->env_parent_id != curenv->env_id) { 531 | return -E_BAD_ENV; 532 | } 533 | ``` 534 | 535 | 最后返回取得的进程控制块。 536 | ```c 537 | /* Step 3: Assign 'e' to '*penv'. */ 538 | *penv = e; 539 | return 0; 540 | } 541 | ``` 542 | 543 | ### (2)写时复制技术(Copy on Write, COW) 544 | 所以说,我们为什么需要设置 TLB Mod 的异常处理函数呢?在这里我们的目的是实现进程的写时复制。我们知道,`fork` 会根据复制调用进程来创建一个新进程。可如果每创建一个新的进程就要在内存中复制一份相同的数据,开销就太大了。所以我们可以在创建子进程时只是让子进程映射到和父进程相同的物理页。这样如果父进程和子进程只是读取其中的内容,就可以共享同一片物理空间。那么如果有进程想要修改该空间内的数据要怎么办?这时我们才需要将这块物理空间复制一份,让想要修改的进程只修改属于自己的数据。 545 | 546 | 在 MOS 中,对于可以共享的页,我们会去掉其写入(`PTE_D`)权限,为其赋予写时复制标记(`PTE_COW`),这样当共享该页面的某一个进程尝试修改该页的数据时,因为不具有 `PTE_D` 权限,就会产生 TLB Mod 异常。转到异常处理函数。根据 `fork` 中的语句可知,我们的异常处理函数为 `cow_entry`,同样位于 user/lib/fork.c 中。 547 | 548 | 首先,我们取得发生异常时尝试写入的虚拟地址位置,使用 `VPN` 宏获取该虚拟地址对应的页表项索引。之后使用 `vpt` 获取页表项的内容,通过位操作取出该页表项的权限。发生 TLB Mod 的必然是被共享的页面,因此如果不具有 `PTE_COW` 则产生一个 `user_panic`。 549 | ```c 550 | static void __attribute__((noreturn)) cow_entry(struct Trapframe *tf) { 551 | u_int va = tf->cp0_badvaddr; 552 | u_int perm; 553 | 554 | /* Step 1: Find the 'perm' in which the faulting address 'va' is mapped. */ 555 | /* Hint: Use 'vpt' and 'VPN' to find the page table entry. If the 'perm' doesn't have 556 | * 'PTE_COW', launch a 'user_panic'. */ 557 | /* Exercise 4.13: Your code here. (1/6) */ 558 | perm = vpt[VPN(va)] & 0xfff; 559 | if (!(perm & PTE_COW)) { 560 | user_panic("perm doesn't have PTE_COW"); 561 | } 562 | ``` 563 | 564 | 值得注意的是 `vpt`。从表现上来看,`vpt` 是一个 `Pte` 类型的数组,其中按虚拟地址的顺序存储了所有的页表项。从本质上看,`vpt` 是用户空间中的地址,并且正是页表自映射时设置的基地址。这样我们就不难理解为什么可以通过这取得所有列表项了。类似的还有 `vpd`,是存储页目录项的数组。 565 | ```c 566 | #define vpt ((volatile Pte *)UVPT) 567 | #define vpd ((volatile Pde *)(UVPT + (PDX(UVPT) << PGSHIFT))) 568 | 569 | ``` 570 | 571 | 如果你忘了页表自映射,可以回头看看 Lab2 和 Lab3。在 MOS 中实现页表自映射的语句位于 kern/env.c 的 `env_setup_vm` 中。 572 | ```c 573 | /* Step 3: Map its own page table at 'UVPT' with readonly permission. 574 | * As a result, user programs can read its page table through 'UVPT' */ 575 | e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V; 576 | 577 | ``` 578 | 579 | 让我们回到 `cow_entry`,接下来重新设置页的权限,再去掉 `PTE_COW`,加上 `PTE_D`。 580 | ```c 581 | /* Step 2: Remove 'PTE_COW' from the 'perm', and add 'PTE_D' to it. */ 582 | /* Exercise 4.13: Your code here. (2/6) */ 583 | perm = (perm & ~PTE_COW) | PTE_D; 584 | 585 | ``` 586 | 587 | 接着再申请一页新的物理页,该物理页对应的虚拟地址为 `UCOW`。因为处于用户程序中,所以不能使用 `page_alloc`,而需要使用系统调用。需要注意,因为 asid = 0,所以新物理页是属于当前调用进程的。 588 | ```c 589 | 590 | /* Step 3: Allocate a new page at 'UCOW'. */ 591 | /* Exercise 4.13: Your code here. (3/6) */ 592 | syscall_mem_alloc(0, (void *)UCOW, perm); 593 | ``` 594 | 595 | 还有一点值得思考,这里我们申请的物理页不在发生异常的 `va` 的位置,而是特定的 `UCOW`。之后又通过一系列映射操作将该物理页映射到 `va`。为什么要这样做呢?很简单,如果我们一开始就映射到 `va`,那么如果你还记得 `page_insert` 内容的话,就会知道这样会使原本的映射丢失,我们就不能访问原本 `va` 映射到的物理页的内容了。这样的话我们又要如何复制呢?所以要先映射到一个完全无关的地址 `UCOW`。该地址之上 `BY2PG` 大小的空间专门用于写时复制时申请新的物理页。 596 | 597 | `syscall_mem_alloc` 系统调用较为简单,只是单纯的申请物理页,并插入到对应进程中。 598 | ```c 599 | int sys_mem_alloc(u_int envid, u_int va, u_int perm) { 600 | struct Env *env; 601 | struct Page *pp; 602 | 603 | /* Step 1: Check if 'va' is a legal user virtual address using 'is_illegal_va'. */ 604 | /* Exercise 4.4: Your code here. (1/3) */ 605 | if (is_illegal_va(va)) { 606 | return -E_INVAL; 607 | } 608 | 609 | /* Step 2: Convert the envid to its corresponding 'struct Env *' using 'envid2env'. */ 610 | /* Hint: **Always** validate the permission in syscalls! */ 611 | /* Exercise 4.4: Your code here. (2/3) */ 612 | try(envid2env(envid, &env, 1)); 613 | 614 | /* Step 3: Allocate a physical page using 'page_alloc'. */ 615 | /* Exercise 4.4: Your code here. (3/3) */ 616 | try(page_alloc(&pp)); 617 | 618 | /* Step 4: Map the :allocated page at 'va' with permission 'perm' using 'page_insert'. */ 619 | return page_insert(env->env_pgdir, env->env_asid, pp, va, perm); 620 | } 621 | ``` 622 | 623 | `is_illegal_va` 函数用于判断虚拟地址是否位于用户空间中 624 | ```c 625 | static inline int is_illegal_va(u_long va) { 626 | return va < UTEMP || va >= UTOP; 627 | } 628 | ``` 629 | 630 | 回到 `cow_entry`,接下来我们将 `va` 所在物理页的内容复制到新申请的页面中。注意 `va` 可能只是随意的一个虚拟地址,不一定以 `BY2PG` 对齐,因此还需要使用 `ROUNDDOWN` 将其对齐。 631 | ```c 632 | /* Step 4: Copy the content of the faulting page at 'va' to 'UCOW'. */ 633 | /* Hint: 'va' may not be aligned to a page! */ 634 | /* Exercise 4.13: Your code here. (4/6) */ 635 | memcpy((void *)UCOW, (void *)ROUNDDOWN(va, BY2PG), BY2PG); 636 | ``` 637 | 638 | 现在我们只需要取消 `va` 到原物理页的映射,将 `va` 映射到新申请的物理页即可。因此首先,使用系统调用 `syscall_mem_map` 将当前进程的 `UCOW` 所在的物理页,作为当前进程的 `va` 地址所映射的物理页,并设定其权限即可。 639 | ```c 640 | // Step 5: Map the page at 'UCOW' to 'va' with the new 'perm'. 641 | /* Exercise 4.13: Your code here. (5/6) */ 642 | syscall_mem_map(0, (void *)UCOW, 0, (void *)va, perm); 643 | ``` 644 | 645 | 该系统调用同样是一个较为简单的函数。首先判断虚拟地址是否位于用户空间 646 | ```c 647 | int sys_mem_map(u_int srcid, u_int srcva, u_int dstid, u_int dstva, u_int perm) { 648 | struct Env *srcenv; 649 | struct Env *dstenv; 650 | struct Page *pp; 651 | 652 | /* Step 1: Check if 'srcva' and 'dstva' are legal user virtual addresses using 653 | * 'is_illegal_va'. */ 654 | /* Exercise 4.5: Your code here. (1/4) */ 655 | if (is_illegal_va(srcva) || is_illegal_va(dstva)) { 656 | return -E_INVAL; 657 | } 658 | ``` 659 | 660 | 之后取得 `srcid`,`dstid` 进程标识符所对应的进程控制块 661 | ```c 662 | /* Step 2: Convert the 'srcid' to its corresponding 'struct Env *' using 'envid2env'. */ 663 | /* Exercise 4.5: Your code here. (2/4) */ 664 | try(envid2env(srcid, &srcenv, 1)); 665 | 666 | /* Step 3: Convert the 'dstid' to its corresponding 'struct Env *' using 'envid2env'. */ 667 | /* Exercise 4.5: Your code here. (3/4) */ 668 | try(envid2env(dstid, &dstenv, 1)); 669 | 670 | ``` 671 | 672 | 从源进程的页表系统中查找 `srcva` 对应的物理页 673 | ```c 674 | /* Step 4: Find the physical page mapped at 'srcva' in the address space of 'srcid'. */ 675 | /* Return -E_INVAL if 'srcva' is not mapped. */ 676 | /* Exercise 4.5: Your code here. (4/4) */ 677 | if ((pp = page_lookup(srcenv->env_pgdir, srcva, NULL)) == NULL) { 678 | return -E_INVAL; 679 | } 680 | 681 | ``` 682 | 683 | 在目标进程的页表系统中创建 `dstva` 到该物理页的映射,同时设置权限 684 | ```c 685 | /* Step 5: Map the physical page at 'dstva' in the address space of 'dstid'. */ 686 | return page_insert(dstenv->env_pgdir, dstenv->env_asid, pp, dstva, perm); 687 | } 688 | 689 | ``` 690 | 691 | 这里因为 `page_insert` 会在要创建映射的虚拟地址已存在映射的情况下取消先前的映射,所以我们调用 `syscall_mem_map` 就同时完成了 “取消 `va` 到原物理页的映射” 和 “将 `va` 映射到新申请的物理页” 这两个操作。 692 | 693 | `UCOW` 处还存在到新申请物理页的映射,我们需要取消该映射。调用 `syscall_mem_unmap` 实现 “取消该进程中 UCOW 与物理页的映射” 操作。 694 | ```c 695 | // Step 6: Unmap the page at 'UCOW'. 696 | /* Exercise 4.13: Your code here. (6/6) */ 697 | syscall_mem_unmap(0, (void *)UCOW); 698 | 699 | ``` 700 | 701 | `syscall_mem_unmap` 只是调用 `page_remove` 取消了映射而已。 702 | ```c 703 | int sys_mem_unmap(u_int envid, u_int va) { 704 | struct Env *e; 705 | 706 | /* Step 1: Check if 'va' is a legal user virtual address using 'is_illegal_va'. */ 707 | /* Exercise 4.6: Your code here. (1/2) */ 708 | if (is_illegal_va(va)) { 709 | return -E_INVAL; 710 | } 711 | 712 | /* Step 2: Convert the envid to its corresponding 'struct Env *' using 'envid2env'. */ 713 | /* Exercise 4.6: Your code here. (2/2) */ 714 | try(envid2env(envid, &e, 1)); 715 | 716 | /* Step 3: Unmap the physical page at 'va' in the address space of 'envid'. */ 717 | page_remove(e->env_pgdir, e->env_asid, va); 718 | return 0; 719 | } 720 | 721 | ``` 722 | 723 | 最后,我们需要在异常处理完成后恢复现场,可现在位于用户态,去哪里恢复现场呢?我们需要使用系统调用 `syscall_set_trapframe`。也因此 `cow_entry` 成为了一个不返回的函数 (`__attribute__((noreturn))`)。 724 | ```c 725 | // Step 7: Return to the faulting routine. 726 | int r = syscall_set_trapframe(0, tf); 727 | user_panic("syscall_set_trapframe returned %d", r); 728 | } 729 | ``` 730 | 731 | `sys_set_trapframe` 将 trap frame 修改为传入的参数 `struct Trapframe *tf` 对应的 trap frame。这样当**从该系统调用返回**时,将返回设置的栈帧中 `epc` 的位置。对于 `cow_entry` 来说,意味着恢复到产生 TLB Mod 时的现场。 732 | ```c 733 | int sys_set_trapframe(u_int envid, struct Trapframe *tf) { 734 | if (is_illegal_va_range((u_long)tf, sizeof *tf)) { 735 | return -E_INVAL; 736 | } 737 | struct Env *env; 738 | try(envid2env(envid, &env, 1)); 739 | if (env == curenv) { 740 | *((struct Trapframe *)KSTACKTOP - 1) = *tf; 741 | // return `tf->regs[2]` instead of 0, because return value overrides regs[2] on 742 | // current trapframe. 743 | return tf->regs[2]; 744 | } else { 745 | env->env_tf = *tf; 746 | return 0; 747 | } 748 | } 749 | ``` 750 | 751 | 这样,写时复制的主要流程就结束了。 752 | 753 | ### (3)一次调用、两次返回 754 | 我们继续看 `fork` 函数。接下来的步骤是 `fork` 的关键。我们使用 `syscall_exofork` 系统调用。该系统调用的作用是复制父进程的信息,创建一个子进程。该系统调用实现了一次调用、两次返回,也是 `fork` 拥有此能力的原因。 755 | ```c 756 | /* Step 2: Create a child env that's not ready to be scheduled. */ 757 | // Hint: 'env' should always point to the current env itself, so we should fix it to the 758 | // correct value. 759 | child = syscall_exofork(); 760 | ``` 761 | 762 | 在此调用之后的一条语句,我们就通过不同返回值实现了父子进程的不同流程,对于子进程来说,我们直接返回 0。这里我们还设置了 `env` 的值为当前进程(因为复制后,`env` 原本还指向父进程)。为了取得当前进程的 envid,我们又使用了系统调用 `syscall_getenvid`。再次强调,`env` 和 `envs` 是位于用户空间的。 763 | ```c 764 | if (child == 0) { 765 | env = envs + ENVX(syscall_getenvid()); 766 | return 0; 767 | } 768 | ``` 769 | 770 | 接下来我们就分析一下 `syscall_exofork`。首先,为了创建新的进程,我们需要申请一个进程控制块。需要注意,这里 `env_alloc` 的 parent 参数为当前进程的 envid。 771 | ```c 772 | int sys_exofork(void) { 773 | struct Env *e; 774 | 775 | /* Step 1: Allocate a new env using 'env_alloc'. */ 776 | /* Exercise 4.9: Your code here. (1/4) */ 777 | try(env_alloc(&e, curenv->env_id)); 778 | ``` 779 | 780 | 接着我们复制父进程调用系统操作时的现场。 781 | ```c 782 | /* Step 2: Copy the current Trapframe below 'KSTACKTOP' to the new env's 'env_tf'. */ 783 | /* Exercise 4.9: Your code here. (2/4) */ 784 | e->env_tf = *((struct Trapframe *)KSTACKTOP - 1); 785 | 786 | ``` 787 | 788 | 需要注意这里不能使用 `curenv->env_tf`。因为 `curenv->env_tf` 存储的是进程调度,切换为其他进程之前的 trap frame。而父进程调用 `syscall_exofork` 时保存的现场,并不一定等同于`curenv->tf` 中的信息。如果你还记得 Lab3,应该明白只有一处进行了 `env_tf` 的赋值。就是在 `env_run` 函数中。 789 | ```c 790 | if (curenv) { 791 | curenv->env_tf = *((struct Trapframe *)KSTACKTOP - 1); 792 | } 793 | ``` 794 | 795 | 最后 `syscall_exofork` 将子进程的返回值设置为 0,同时设置进程的状态和优先级,最后返回新创建进程的 envid。 796 | ```c 797 | /* Step 3: Set the new env's 'env_tf.regs[2]' to 0 to indicate the return value in child. */ 798 | /* Exercise 4.9: Your code here. (3/4) */ 799 | e->env_tf.regs[2] = 0; 800 | 801 | /* Step 4: Set up the new env's 'env_status' and 'env_pri'. */ 802 | /* Exercise 4.9: Your code here. (4/4) */ 803 | e->env_status = ENV_NOT_RUNNABLE; 804 | e->env_pri = curenv->env_pri; 805 | 806 | return e->env_id; 807 | } 808 | ``` 809 | 810 | 到这里你应该能理解何为 “一次调用、两次返回” 了。一次调用指的是只有父进程调用了 `syscall_exofork`,两次返回分别是父进程调用 `syscall_exofork` 得到的返回值和被创建的子进程中设定了 `v0` 寄存器的值为 0 作为返回值。这样当子进程开始运行时,就会拥有一个和父进程不同的返回值。 811 | 812 | ### (4)子进程运行前的设置 813 | 我们已经创建了子进程,但是子进程现在还没有加入调度队列,同时父子进程虽然共享了页表,但页表项还没有设置 `PTE_COW` 位。这些我们都要进行处理。 814 | 815 | 设置 `PTE_COW` 位需要通过 `duppage` 函数。如果当前页表项具有 `PTE_D` 权限(且不是共享页面 `PTE_LIBRARY`),则需要重新设置页表项的权限。`duppage` 会对每一个页表项进行操作,因此我们需要在 `fork` 中遍历所有的页表项。相比于在内核态中,在用户态中遍历页表项更为方便。 816 | 817 | 这里我们只遍历 `USTACKTOP` 之下的地址空间,因为其上的空间总是会被共享。在调用 `duppage` 之前,我们判断页目录项和页表项是否有效。如果不判断则会在 `duppage` 函数中发生异常(最终是由 `page_lookup` 产生的)。需要注意取页目录项的方法,`vpd` 是页目录项数组,`i` 相当于地址的高 20 位,我们需要取得地址的高 10 位作为页目录的索引,因此有 `vpd[i >> 10]` 818 | ```c 819 | /* Step 3: Map all mapped pages below 'USTACKTOP' into the child's address space. */ 820 | // Hint: You should use 'duppage'. 821 | /* Exercise 4.15: Your code here. (1/2) */ 822 | for (i = 0; i < VPN(USTACKTOP); i++) { 823 | if ((vpd[i >> 10] & PTE_V) && (vpt[i] & PTE_V)) { 824 | duppage(child, i); 825 | } 826 | } 827 | ``` 828 | 829 | 接下来让我们看一下 `duppage` 函数。首先取得页表项对应的虚拟地址和权限。 830 | ```c 831 | static void duppage(u_int envid, u_int vpn) { 832 | int r; 833 | u_int addr; 834 | u_int perm; 835 | 836 | /* Step 1: Get the permission of the page. */ 837 | /* Hint: Use 'vpt' to find the page table entry. */ 838 | /* Exercise 4.10: Your code here. (1/2) */ 839 | addr = vpn << PGSHIFT; 840 | perm = vpt[vpn] & 0xfff; 841 | ``` 842 | 843 | 接着,对所有有效的页,我们都需要通过系统调用 `syscall_mem_map` 实现父进程与子进程页面的共享。特别的,对于可写的,且不是共享的页,我们还需要更新页表项的权限。在用户态,我们不能直接修改页表项,因此需要通过系统调用来实现修改。我们同样使用的是 `syscall_mem_map`。虽然之前我们使用此系统调用来进行页的共享和复制,但由于该系统调用具有新的映射会覆盖旧的映射的特点,因此可以对本来就有的关系采取重新映射,只改变权限位的设置,就可以实现权限位的修改。 844 | ```c 845 | /* Step 2: If the page is writable, and not shared with children, and not marked as COW yet, 846 | * then map it as copy-on-write, both in the parent (0) and the child (envid). */ 847 | /* Hint: The page should be first mapped to the child before remapped in the parent. (Why?) 848 | */ 849 | /* Exercise 4.10: Your code here. (2/2) */ 850 | int flag = 0; 851 | if ((perm & PTE_D) && !(perm & PTE_LIBRARY)) { 852 | perm = (perm & ~ PTE_D) | PTE_COW; 853 | flag = 1; 854 | } 855 | 856 | syscall_mem_map(0, addr, envid, addr, perm); 857 | 858 | if (flag) { 859 | syscall_mem_map(0, addr, 0, addr, perm); 860 | } 861 | } 862 | ``` 863 | 864 | 这里需要注意,父进程将页映射到子进程应该先于对自己权限的修改。如果先修改自己的权限位,则该页表就不再可写,这样的话就会发生 TLB Mod 异常,而不能实现父进程将页映射到子进程。 865 | 866 | 之后在 `fork` 中,我们同样设置子进程的 TLB Mod 异常处理函数为 `cow_entry`。 867 | ```c 868 | /* Step 4: Set up the child's tlb mod handler and set child's 'env_status' to 869 | * 'ENV_RUNNABLE'. */ 870 | /* Hint: 871 | * You may use 'syscall_set_tlb_mod_entry' and 'syscall_set_env_status' 872 | * Child's TLB Mod user exception entry should handle COW, so set it to 'cow_entry' 873 | */ 874 | /* Exercise 4.15: Your code here. (2/2) */ 875 | try(syscall_set_tlb_mod_entry(child, cow_entry)); 876 | ``` 877 | 878 | 最后,我们调用 `syscall_set_env_status` 将子进程状态设定为 `RUNNABLE` 并将其加入调度队列中。返回子进程的 envid。作为父进程 `fork` 的返回值。 879 | ```c 880 | try(syscall_set_env_status(child, ENV_RUNNABLE)); 881 | return child; 882 | } 883 | ``` 884 | 885 | `syscall_set_env_status` 较为简单,只是根据设定的状态将进程加入或移除调度队列而已。 886 | ```c 887 | int sys_set_env_status(u_int envid, u_int status) { 888 | struct Env *env; 889 | /* Step 1: Check if 'status' is valid. */ 890 | /* Exercise 4.14: Your code here. (1/3) */ 891 | if (status != ENV_RUNNABLE && status != ENV_NOT_RUNNABLE) { 892 | return -E_INVAL; 893 | } 894 | 895 | /* Step 2: Convert the envid to its corresponding 'struct Env *' using 'envid2env'. */ 896 | /* Exercise 4.14: Your code here. (2/3) */ 897 | try(envid2env(envid, &env, 1)); 898 | 899 | /* Step 4: Update 'env_sched_list' if the 'env_status' of 'env' is being changed. */ 900 | /* Exercise 4.14: Your code here. (3/3) */ 901 | if (env->env_status != ENV_NOT_RUNNABLE && status == ENV_NOT_RUNNABLE) { 902 | TAILQ_REMOVE(&env_sched_list, env, env_sched_link); 903 | } 904 | else if (env->env_status != ENV_RUNNABLE && status == ENV_RUNNABLE) { 905 | TAILQ_INSERT_TAIL(&env_sched_list, env, env_sched_link); 906 | } 907 | 908 | /* Step 5: Set the 'env_status' of 'env'. */ 909 | env->env_status = status; 910 | return 0; 911 | } 912 | ``` 913 | 914 | 这样,我们就通过 `fork` 完成了子进程的创建。 915 | 916 | ## 三、进程间通信 917 | ### (1)信息接收 918 | 在 Lab4 中,我们还需要实现进程间通信。这需要实现两个系统调用 `syscall_ipc_try_send` 和 `syscall_ipc_recv`。 919 | 920 | 调用 `syscall_ipc_recv` 后会阻塞当前进程,直到收到信息。 921 | 922 | 该调用的参数为要接收信息的虚拟地址。首先我们要检查虚拟地址是否处于用户空间。另外当 `dstva` 为 0 时表示不需要传输额外信息。 923 | ```c 924 | int sys_ipc_recv(u_int dstva) { 925 | /* Step 1: Check if 'dstva' is either zero or a legal address. */ 926 | if (dstva != 0 && is_illegal_va(dstva)) { 927 | return -E_INVAL; 928 | } 929 | ``` 930 | 931 | 接着我们设置进程控制块的字段,`env_ipc_recving` 表示进程是否正在接收信息;`env_ipc_dstva` 存储要接收信息的地址。 932 | ```c 933 | /* Step 2: Set 'curenv->env_ipc_recving' to 1. */ 934 | /* Exercise 4.8: Your code here. (1/8) */ 935 | curenv->env_ipc_recving = 1; 936 | 937 | /* Step 3: Set the value of 'curenv->env_ipc_dstva'. */ 938 | /* Exercise 4.8: Your code here. (2/8) */ 939 | curenv->env_ipc_dstva = dstva; 940 | ``` 941 | 942 | 接着我们要阻塞当前进程,将该进程从调度队列中移出 943 | ```c 944 | /* Step 4: Set the status of 'curenv' to 'ENV_NOT_RUNNABLE' and remove it from 945 | * 'env_sched_list'. */ 946 | /* Exercise 4.8: Your code here. (3/8) */ 947 | curenv->env_status = ENV_NOT_RUNNABLE; 948 | TAILQ_REMOVE(&env_sched_list, curenv, env_sched_link); 949 | 950 | ``` 951 | 952 | 最后我们将返回值设置为 0,调用 `schedule` 函数进行进程切换。 953 | ```c 954 | /* Step 5: Give up the CPU and block until a message is received. */ 955 | ((struct Trapframe *)KSTACKTOP - 1)->regs[2] = 0; 956 | schedule(1); 957 | } 958 | ``` 959 | 960 | 你可能会想,`schedule` 函数不是没有返回的吗?那这里设置的返回值保存在哪里?另外如果没有返回,该系统调用又要如何返回到用户程序中?关键在于 `env_run` 函数中。在进程切换之前,会将 trap frame 存储到 `env->env_tf` 中。当重新轮到该进程运行的时候,就会从 `env_tf` 存储的位置恢复现场。这里也重新强调了 `sys_exofork` 中为什么不能使用 `env_tf`。 961 | 962 | ### (2)信息发送 963 | 在 `sys_ipc_try_send` 中我们同样判断地址是否正确。并通过 envid 获取进程控制块。需要注意这里 `envid2env` 的 `checkperm` 参数为 0,而此前所有的参数值均为 1。这是因为进程通信不一定只在父子进程之间。 964 | ```c 965 | int sys_ipc_try_send(u_int envid, u_int value, u_int srcva, u_int perm) { 966 | struct Env *e; 967 | struct Page *p; 968 | 969 | /* Step 1: Check if 'srcva' is either zero or a legal address. */ 970 | /* Exercise 4.8: Your code here. (4/8) */ 971 | if (srcva != 0 && is_illegal_va(srcva)) { 972 | return -E_INVAL; 973 | } 974 | 975 | /* Step 2: Convert 'envid' to 'struct Env *e'. */ 976 | /* This is the only syscall where the 'envid2env' should be used with 'checkperm' UNSET, 977 | * because the target env is not restricted to 'curenv''s children. */ 978 | /* Exercise 4.8: Your code here. (5/8) */ 979 | try(envid2env(envid, &e, 0)); 980 | 981 | ``` 982 | 983 | 然后我们检查 `env_ipc_recving`,这一字段在信息接收时设置。 984 | ```c 985 | /* Step 3: Check if the target is waiting for a message. */ 986 | /* Exercise 4.8: Your code here. (6/8) */ 987 | if (!e->env_ipc_recving) { 988 | return -E_IPC_NOT_RECV; 989 | } 990 | 991 | ``` 992 | 993 | 接下来我们传输一些信息,并将 `env_ipc_recving` 重新置 0,表示接收进程已经接收到信息。 994 | ```c 995 | /* Step 4: Set the target's ipc fields. */ 996 | e->env_ipc_value = value; 997 | e->env_ipc_from = curenv->env_id; 998 | e->env_ipc_perm = PTE_V | perm; 999 | e->env_ipc_recving = 0; 1000 | 1001 | ``` 1002 | 1003 | 既然接收到了信息,那么我们就要取消接收进程的阻塞状态。 1004 | ```c 1005 | /* Step 5: Set the target's status to 'ENV_RUNNABLE' again and insert it to the tail of 1006 | * 'env_sched_list'. */ 1007 | /* Exercise 4.8: Your code here. (7/8) */ 1008 | e->env_status = ENV_RUNNABLE; 1009 | TAILQ_INSERT_TAIL(&env_sched_list, e, env_sched_link); 1010 | 1011 | ``` 1012 | 1013 | 最后,我们还需要将当前进程的一个页面共享到接收进程。只有这样,接收进程才能通过该页面获得发送进程发送的一些信息。 1014 | ```c 1015 | /* Step 6: If 'srcva' is not zero, map the page at 'srcva' in 'curenv' to 'e->env_ipc_dstva' 1016 | * in 'e'. */ 1017 | /* Return -E_INVAL if 'srcva' is not zero and not mapped in 'curenv'. */ 1018 | if (srcva != 0) { 1019 | /* Exercise 4.8: Your code here. (8/8) */ 1020 | p = page_lookup(curenv->env_pgdir, srcva, NULL); 1021 | if (p == NULL) { 1022 | return -E_INVAL; 1023 | } 1024 | 1025 | try(page_insert(e->env_pgdir, e->env_asid, p, e->env_ipc_dstva, perm)); 1026 | } 1027 | return 0; 1028 | } 1029 | 1030 | ``` 1031 | -------------------------------------------------------------------------------- /BUAA-OS实验笔记之Lab5.md: -------------------------------------------------------------------------------- 1 | ## 一、Lab5 前言 2 | 这是最长的一篇文章,可就算这么长,文中出现的代码也不过本次 Lab 中新增加的代码的一小部分。幸好完成本次实验不需要熟悉所有代码,一部分练习甚至不需要熟悉要填写的代码的前后文,只需要根据注释就可以填出很多。可是我感觉本篇文章还是有帮助的,毕竟谁也不知道 Exam 会出什么题。 3 | 4 | Lab5 主要分为四部分,分别是镜像制作工具、关于设备的系统调用、文件系统服务进程、文件操作库函数。本文对这四个方面都有所涉及,第二章主要讲镜像制作工具,第三章主要讲文件系统服务进程和文件操作库函数,最后一章讲关于设备的系统调用。 5 | 6 | 7 | ## 二、磁盘镜像 8 | ### (1)镜像制作工具 9 | 在本次实验中我们要实现一个文件系统。广义来说,一切字节序列都可以称为文件,但本次实验中我们还是主要关注在磁盘中存储的数据,将这些数据按一定的结构组织起来,就是本次实验的主要目标。 10 | 11 | 本文依旧不按照指导书中的顺序。我们先查看位于 tools 文件夹下的磁盘镜像制作工具 fsformat 的源代码,以便我们理解磁盘以及文件系统的组织结构。 12 | 13 | ### (2)磁盘数据初始化 14 | 我们查看 tools/fsformat.c 文件。找到其中的 `main` 函数。`main` 函数首先调用了 `init_disk` 用于初始化磁盘。 15 | ```c 16 | int main(int argc, char **argv) { 17 | static_assert(sizeof(struct File) == BY2FILE); 18 | init_disk(); 19 | ``` 20 | 21 | 该函数中我们要用到一个数据结构 `disk`。因此我们先考察 `disk`。`disk` 是一个数组,大小为 `NBLOCK`,每个元素是一个结构体,其中有字段 `data`,是一个 `BY2BLK` 字节大小的空间,用于存储一个磁盘块的数据。很容易得知,`NBLOCK` * `BY2BLK` = 磁盘空间大小。这样就可以理解 `disk` 起到的作用了,也就是在构筑磁盘镜像时暂时存储磁盘数据,等到构筑完成后再将 `disk` 中 `data` 的内容拼接并输出为二进制镜像文件。 22 | ```c 23 | struct Block { 24 | uint8_t data[BY2BLK]; 25 | uint32_t type; 26 | } disk[NBLOCK]; 27 | 28 | ``` 29 | 30 | > 磁盘块是对磁盘空间的逻辑划分;扇区是对磁盘空间的物理划分 31 | 32 | 另外 `Block` 结构体还有一个字段 `type`,该字段的值为如下枚举的值 33 | ```c 34 | enum { 35 | BLOCK_FREE = 0, 36 | BLOCK_BOOT = 1, 37 | BLOCK_BMAP = 2, 38 | BLOCK_SUPER = 3, 39 | BLOCK_DATA = 4, 40 | BLOCK_FILE = 5, 41 | BLOCK_INDEX = 6, 42 | }; 43 | ``` 44 | 45 | 让我们回到 `init_disk`。该函数中首先将第一个磁盘块类型设为 `BLOCK_BOOT`,表示主引导扇区。之后我们要从第三个磁盘块开始(为什么不是第二个?因为第二个磁盘块为 “超级块”,将在后面介绍),设置磁盘块的位图分配机制。在函数中我们计算了在磁盘中存储位图需要的磁盘块数量。`NBLOCK` 是磁盘块的总数,那么我们同样需要 `NBLOCK` bit 大小的位图,又因为一个磁盘块有 `BIT2BLK` bit,那么总共需要 `NBLOCK / BIT2BLK` 个磁盘块。向上取整,总共需要 `(NBLOCK + BIT2BLK - 1) / BIT2BLK` 个磁盘块来存储位图。现在我们已经将 0 到 nbitblock-1 的位图分配了用途,那么下一个空闲的磁盘块就是 `nextbno = 2 + nbitblock` 了。 46 | ```c 47 | // Step 2: Initialize boundary. 48 | nbitblock = (NBLOCK + BIT2BLK - 1) / BIT2BLK; 49 | nextbno = 2 + nbitblock; 50 | ``` 51 | 52 | 对于存储位图的磁盘块,我们要将其初始化。首先我们将这些磁盘块标记为 `BLOCK_BMAP`,表示他们用作存储位图 53 | ```c 54 | // Step 2: Initialize bitmap blocks. 55 | for (i = 0; i < nbitblock; ++i) { 56 | disk[2 + i].type = BLOCK_BMAP; 57 | } 58 | ``` 59 | 60 | 对于位图,我们设定 1 表示空闲,0 表示使用。因此我们先将所有的磁盘块数据都设定为 1。 61 | ```c 62 | 63 | for (i = 0; i < nbitblock; ++i) { 64 | memset(disk[2 + i].data, 0xff, BY2BLK); 65 | } 66 | ``` 67 | 68 | 最后如果位图不足以占用全部空间,那么我们还需要将最后一个磁盘块末位不作为位图使用的部分置 0。 69 | ```c 70 | if (NBLOCK != nbitblock * BIT2BLK) { 71 | diff = NBLOCK % BIT2BLK / 8; 72 | memset(disk[2 + (nbitblock - 1)].data + diff, 0x00, BY2BLK - diff); 73 | } 74 | ``` 75 | 76 | 我们不要忘记了第二个磁盘块,这个磁盘块会用作 “超级块”,所谓超级块,就是文件系统的起点,该磁盘块中存储了根目录文件的信息(当然还包括其他一些内容)。超级块定义在 user/include/fs.h 中。包含了用于验证文件系统的幻数 `s_magic`,磁盘的磁盘块总数 `s_nblocks` 和根目录文件节点 `s_root`。 77 | ```c 78 | struct Super { 79 | uint32_t s_magic; // Magic number: FS_MAGIC 80 | uint32_t s_nblocks; // Total number of blocks on disk 81 | struct File s_root; // Root directory node 82 | }; 83 | ``` 84 | 85 | 在本文件系统中,文件信息通过 `struct File` 结构体存储。该结构体同样定义在 user/include/fs.h 中,在本文的后面我们将其称为文件控制块。 86 | ```c 87 | struct File { 88 | char f_name[MAXNAMELEN]; // filename 89 | uint32_t f_size; // file size in bytes 90 | uint32_t f_type; // file type 91 | uint32_t f_direct[NDIRECT]; 92 | uint32_t f_indirect; 93 | 94 | struct File *f_dir; // the pointer to the dir where this file is in, valid only in memory. 95 | char f_pad[BY2FILE - MAXNAMELEN - (3 + NDIRECT) * 4 - sizeof(void *)]; 96 | } __attribute__((aligned(4), packed)); 97 | ``` 98 | 99 | 该结构体中包含了文件名 `f_name`,文件大小 `f_size`,文件类型 `f_type`,和用于存储指向存储文件内容的磁盘块的编号的数组 `f_direct`,以及指向存储 “存储指向存储文件内容的磁盘块的编号的数组” 的磁盘块的编号 `f_indirect`,还有自己所在的目录 `f_dir`。最后 `f_pad` 将文件控制块的大小填充到 `BY2FILE`,保证多个文件控制块能够填满整个磁盘。 100 | 101 | > `f_direct` 和 `f_indirect` 用一句话来表示似乎有些困难,这里再重新说明一下。文件的内容需要在磁盘中存储,这些内容分布于不同的磁盘块中,因此还需要对这些内容进行管理,也就是要再分配一个磁盘块用于存储文件控制块,该结构体中要存储哪些存储文件内容的磁盘块的地址。`f_direct` 中就存储了前 `NDIRECT` 个磁盘块的编号方便快速访问。但如果文件较大,超出了 `NDIRECT` 个磁盘块的大小的话要怎么办?这就要再分配一个磁盘块,用这个磁盘块的全部空间作为存储磁盘块编号的数组,保存那些存储了文件内容的磁盘块的编号。再使用 `f_indirect` 保存这个新分配的,作为数组的磁盘块的编号。 102 | > 103 | > 为了方便,`f_indirect` 指向的磁盘块的前 `NDIRECT` 个元素不保存编号。现在我们就可以计算在这个文件系统中,文件的最大大小了:我们可以有 `BY2BLK / 4` 个磁盘块用于存储,每个磁盘块又有 `BY2BLK` 的空间,那么总共就有 `BY2BLK**2 / 4` 的空间可以用于单一文件的存储。 104 | 105 | 在 `dist_init` 的最后,就设置了超级块的信息, 106 | ```c 107 | disk[1].type = BLOCK_SUPER; 108 | super.s_magic = FS_MAGIC; 109 | super.s_nblocks = NBLOCK; 110 | super.s_root.f_type = FTYPE_DIR; 111 | strcpy(super.s_root.f_name, "/"); 112 | } 113 | ``` 114 | 115 | 其中设置了根目录文件类型 `s_root.f_type` 为 `FTYPE_DIR`,表示该文件为目录。此宏定义在 user/include/fs.h 中。类似还有 `FTYPE_REG`。 116 | ```c 117 | // File types 118 | #define FTYPE_REG 0 // Regular file 119 | #define FTYPE_DIR 1 // Directory 120 | ``` 121 | 122 | 最后我们设置根目录的名字为 `/`,这说明该文件系统的路径是 Linux 的格式。 123 | 124 | ### (3)文件写入 125 | 在 `main` 函数的后面部分,我们不断读取命令行参数,通过 `stat` 函数(这是库函数)判断文件类型,分别调用 `write_directory` 和 `write_file` 将文件内容写入磁盘镜像中。需要注意这里传入了 `&super.s_root`,也就是根目录文件作为参数。 126 | 127 | ```c 128 | if (argc < 3) { 129 | fprintf(stderr, "Usage: fsformat [files or directories]...\n"); 130 | exit(1); 131 | } 132 | 133 | for (int i = 2; i < argc; i++) { 134 | char *name = argv[i]; 135 | struct stat stat_buf; 136 | int r = stat(name, &stat_buf); 137 | assert(r == 0); 138 | if (S_ISDIR(stat_buf.st_mode)) { 139 | printf("writing directory '%s' recursively into disk\n", name); 140 | write_directory(&super.s_root, name); 141 | } else if (S_ISREG(stat_buf.st_mode)) { 142 | printf("writing regular file '%s' into disk\n", name); 143 | write_file(&super.s_root, name); 144 | } else { 145 | fprintf(stderr, "'%s' has illegal file mode %o\n", name, stat_buf.st_mode); 146 | exit(2); 147 | } 148 | } 149 | ``` 150 | 151 | 接下来我们查看 `write_directory` 和 `write_file`。`write_directory` 用于递归地将目录下所有文件写入磁盘,而 `write_file` 则只将单一文件写入。我们首先查看 `write_directory`。 152 | 153 | 一开始只是调用库函数 `opendir` 打开了目录,不需关注。第一个关注点是调用了 `create_file` 在 `dirf` 文件下创建了新文件。 154 | ```c 155 | void write_directory(struct File *dirf, char *path) { 156 | DIR *dir = opendir(path); 157 | if (dir == NULL) { 158 | perror("opendir"); 159 | return; 160 | } 161 | struct File *pdir = create_file(dirf); 162 | 163 | ``` 164 | 165 | 在 `create_file` 函数中,遍历了 `dirf` 文件下用于保存内容(对于目录来说,内容就是文件控制块)的所有磁盘块。这里我们使用 `f_direct` 和 `f_indirect` 获取了对应磁盘块的编号。 166 | ```c 167 | struct File *create_file(struct File *dirf) { 168 | int nblk = dirf->f_size / BY2BLK; 169 | 170 | // Step 1: Iterate through all existing blocks in the directory. 171 | for (int i = 0; i < nblk; ++i) { 172 | int bno; // the block number 173 | // If the block number is in the range of direct pointers (NDIRECT), get the 'bno' 174 | // directly from 'f_direct'. Otherwise, access the indirect block on 'disk' and get 175 | // the 'bno' at the index. 176 | /* Exercise 5.5: Your code here. (1/3) */ 177 | if (i < NDIRECT) { 178 | bno = dirf->f_direct[i]; 179 | } else { 180 | bno = ((uint32_t *)(disk[dirf->f_indirect].data))[i]; 181 | } 182 | ``` 183 | 184 | 该磁盘块的空间全部用于存储文件控制块。我们再在一个磁盘块中遍历所有文件控制块,看是否有未使用的文件控制块,用该处空间表示我们新创建的文件。这样遍历的原因是可能出现中间磁盘块中文件被删除,或者最后一个磁盘块还未用满的情况。 185 | ```c 186 | // Get the directory block using the block number. 187 | struct File *blk = (struct File *)(disk[bno].data); 188 | 189 | // Iterate through all 'File's in the directory block. 190 | for (struct File *f = blk; f < blk + FILE2BLK; ++f) { 191 | // If the first byte of the file name is null, the 'File' is unused. 192 | // Return a pointer to the unused 'File'. 193 | /* Exercise 5.5: Your code here. (2/3) */ 194 | if (f->f_name[0] == NULL) { 195 | return f; 196 | } 197 | } 198 | } 199 | ``` 200 | 201 | 最后如果没有找到未使用的文件控制块,就说明所有的已分配给当前目录文件,用于存储文件控制块的磁盘块都被占满了。这时就需要调用 `make_link_block` 新申请一个磁盘块。该磁盘块中第一个文件控制块的位置就代表了新创建的文件。 202 | ```c 203 | // Step 2: If no unused file is found, allocate a new block using 'make_link_block' function 204 | // and return a pointer to the new block on 'disk'. 205 | /* Exercise 5.5: Your code here. (3/3) */ 206 | return (struct File *)(disk[make_link_block(dirf, nblk)].data); 207 | ``` 208 | 209 | `make_link_block` 很简单,就是获取下一个空闲的磁盘块,调用 `save_block_link` 为 `dirf` 目录文件添加该磁盘块。 210 | ```c 211 | int next_block(int type) { 212 | disk[nextbno].type = type; 213 | return nextbno++; 214 | } 215 | 216 | int make_link_block(struct File *dirf, int nblk) { 217 | int bno = next_block(BLOCK_FILE); 218 | save_block_link(dirf, nblk, bno); 219 | dirf->f_size += BY2BLK; 220 | return bno; 221 | } 222 | ``` 223 | 224 | `save_block_link` 函数就是将新申请的磁盘块设置到 `f_direct` 中或 `f_indirect` 对应的磁盘块中的相应位置。 225 | ```c 226 | void save_block_link(struct File *f, int nblk, int bno) { 227 | assert(nblk < NINDIRECT); // if not, file is too large ! 228 | 229 | if (nblk < NDIRECT) { 230 | f->f_direct[nblk] = bno; 231 | } else { 232 | if (f->f_indirect == 0) { 233 | // create new indirect block. 234 | f->f_indirect = next_block(BLOCK_INDEX); 235 | } 236 | ((uint32_t *)(disk[f->f_indirect].data))[nblk] = bno; 237 | } 238 | } 239 | 240 | ``` 241 | 242 | 这样 `create_file` 就完成了,让我们回到 `write_directory`。接下来为新创建的目录文件设置名字和文件类型。途中还判断了文件名是否过长。 243 | ```c 244 | struct File *pdir = create_file(dirf); 245 | strncpy(pdir->f_name, basename(path), MAXNAMELEN - 1); 246 | if (pdir->f_name[MAXNAMELEN - 1] != 0) { 247 | fprintf(stderr, "file name is too long: %s\n", path); 248 | // File already created, no way back from here. 249 | exit(1); 250 | } 251 | pdir->f_type = FTYPE_DIR; 252 | 253 | ``` 254 | 255 | 接下来的步骤很明显,需要遍历宿主机上该路径下的所有文件,如果是目录,则递归执行 `write_directory`,如果是普通文件,则执行 `write_file`,这样直到目录下所有文件都被写入镜像中。 256 | ```c 257 | for (struct dirent *e; (e = readdir(dir)) != NULL;) { 258 | if (strcmp(e->d_name, ".") != 0 && strcmp(e->d_name, "..") != 0) { 259 | char *buf = malloc(strlen(path) + strlen(e->d_name) + 2); 260 | sprintf(buf, "%s/%s", path, e->d_name); 261 | if (e->d_type == DT_DIR) { 262 | write_directory(pdir, buf); 263 | } else { 264 | write_file(pdir, buf); 265 | } 266 | free(buf); 267 | } 268 | } 269 | closedir(dir); 270 | } 271 | ``` 272 | 273 | 接下来转过头查看一下 `write_file`。首先我们同样调用 `create_file` 在 `dirf` 目录文件下创建新文件。 274 | ```c 275 | void write_file(struct File *dirf, const char *path) { 276 | int iblk = 0, r = 0, n = sizeof(disk[0].data); 277 | struct File *target = create_file(dirf); 278 | ``` 279 | 280 | 接下来函数还对 `create_file` 的结果进行了判断。这似乎只是修改代码后的一个遗留问题而已。 281 | ```c 282 | /* in case `create_file` is't filled */ 283 | if (target == NULL) { 284 | return; 285 | } 286 | ``` 287 | 288 | 我们打开宿主机上的文件,便于后面复制文件内容到镜像中 289 | ```c 290 | int fd = open(path, O_RDONLY); 291 | ``` 292 | 293 | 接着我们复制文件名。这里使用 `strrchr` 从后往前查找了 `'/'` 字符的位置,只拷贝该字符之后的内容。但是不知这里为何不与 `write_directory` 一样使用 `basename`。 294 | ```c 295 | // Get file name with no path prefix. 296 | const char *fname = strrchr(path, '/'); 297 | if (fname) { 298 | fname++; 299 | } else { 300 | fname = path; 301 | } 302 | strcpy(target->f_name, fname); 303 | ``` 304 | 305 | 接着使用 `lseek` 获取并设置文件大小,以及文件类型为普通文件 306 | ```c 307 | target->f_size = lseek(fd, 0, SEEK_END); 308 | target->f_type = FTYPE_REG; 309 | ``` 310 | 311 | 最后读取文件内容,写入镜像文件中。这里我们以 `n = sizeof(disk[0].data)` 的大小读取,但不知道为何不使用 `BY2BLK`。值得注意的是,这里我们是先向 `disk[nextbno]` 中写入了数据,之后才调用 `next_block` 申请的该磁盘块。 312 | ```c 313 | // Start reading file. 314 | lseek(fd, 0, SEEK_SET); 315 | while ((r = read(fd, disk[nextbno].data, n)) > 0) { 316 | save_block_link(target, iblk++, next_block(BLOCK_DATA)); 317 | } 318 | close(fd); // Close file descriptor. 319 | } 320 | ``` 321 | 322 | ### (4)收尾工作 323 | 完成文件的写入后,`main` 函数中还有一些收尾工作。首先是根据磁盘块的使用情况设置位图 324 | ```c 325 | flush_bitmap(); 326 | ``` 327 | 328 | 置 0 语句看似复杂,实际只是按顺序一位一位置 0 而已。 329 | ```c 330 | void flush_bitmap() { 331 | int i; 332 | // update bitmap, mark all bit where corresponding block is used. 333 | for (i = 0; i < nextbno; ++i) { 334 | ((uint32_t *)disk[2 + i / BIT2BLK].data)[(i % BIT2BLK) / 32] &= ~(1 << (i % 32)); 335 | } 336 | } 337 | ``` 338 | 339 | 最后还要根据 `disk` 生成磁盘镜像文件。使用 `finish_fs` 函数完成 340 | ```c 341 | finish_fs(argv[1]); 342 | 343 | return 0; 344 | } 345 | ``` 346 | 347 | 一直以来,超级块都没有写入 `disk`,现在我们将超级块信息写入 348 | ```c 349 | void finish_fs(char *name) { 350 | int fd, i; 351 | 352 | // Prepare super block. 353 | memcpy(disk[1].data, &super, sizeof(super)); 354 | ``` 355 | 356 | 最后我们将 `disk` 中所有的 `data` 写入一个新创建的文件中,作为磁盘镜像。 357 | ```c 358 | // Dump data in `disk` to target image file. 359 | fd = open(name, O_RDWR | O_CREAT, 0666); 360 | for (i = 0; i < 1024; ++i) { 361 | #ifdef CONFIG_REVERSE_ENDIAN 362 | reverse_block(disk + i); 363 | #endif 364 | ssize_t n = write(fd, disk[i].data, BY2BLK); 365 | assert(n == BY2BLK); 366 | } 367 | 368 | // Finish. 369 | close(fd); 370 | } 371 | ``` 372 | 373 | > `reverse_block` 用于宿主机和操作系统大小端不一致的情况,这里我们不需要考虑,就不看了。 374 | 375 | 这样我们就实现了磁盘镜像的创建。在阅读 fsformat 工具的代码的同时,我们也了解了磁盘中数据的组织形式以及文件系统的基本概念,这对我们之后了解理解文件系统的代码有很大的帮助。 376 | 377 | ## 三、文件系统 378 | ### (1)文件操作库函数 379 | 操作系统需要为用户程序提供一系列库函数来完成文件的相关操作。这些函数我们都知道,有 `open`、`read`、`write`、`close` 等。我们先以 `open` 为例查看一下文件系统操作的基本流程。 380 | 381 | `open` 函数和其他提供给用户的库函数一样,位于 user/lib,定义在 file.c 文件中。首先,该函数调用 `fd_alloc` 申请了一个文件描述符 382 | ```c 383 | int open(const char *path, int mode) { 384 | int r; 385 | 386 | // Step 1: Alloc a new 'Fd' using 'fd_alloc' in fd.c. 387 | // Hint: return the error code if failed. 388 | struct Fd *fd; 389 | /* Exercise 5.9: Your code here. (1/5) */ 390 | try(fd_alloc(&fd)); 391 | ``` 392 | 393 | 文件描述符 `struct Fd` 定义在 user/include/fd.h 中,保存了文件对应的设备 `fd_dev_id`,文件读写的偏移量 `fd_offset` 和文件读写的模式 `fd_omode`。该结构体不表现文件的物理结构,是在用户侧对文件的抽象。 394 | ```c 395 | // file descriptor 396 | struct Fd { 397 | u_int fd_dev_id; 398 | u_int fd_offset; 399 | u_int fd_omode; 400 | }; 401 | ``` 402 | 403 | `fd_alloc` 函数的功能就是遍历所有文件描述符编号(共有 `MAXFD` 个),找到其中还没有被使用过的最小的一个,返回该文件描述符对应的地址。注意通过文件描述符编号到地址我们使用了宏 `INDEX2FD`。该宏定义在 user/include/fd.h 中。通过观察相关宏的定义,我们可以得知每个文件描述符占用空间大小为 `BY2PG`,所有文件描述符位于 `[FILEBASE - PDMAP, FILEBASE)` 的地址空间中。 404 | ```c 405 | #define FILEBASE 0x60000000 406 | #define FDTABLE (FILEBASE - PDMAP) 407 | 408 | #define INDEX2FD(i) (FDTABLE + (i)*BY2PG) 409 | ``` 410 | 411 | 这里我们并没有采用任何数据结构用于表示文件描述符的分配,而是通过查看页目录项和页表项是否有效来得知文件描述符是否被使用。这是因为文件描述符并不是在用户进程中被创建的,而是在**文件系统服务进程**中创建,被共享到用户进程的地址区域中的。因此我们只需要找出一处空闲空间,将文件系统服务进程中的对应文件描述符共享到该位置即可。 412 | ```c 413 | int fd_alloc(struct Fd **fd) { 414 | u_int va; 415 | u_int fdno; 416 | 417 | for (fdno = 0; fdno < MAXFD - 1; fdno++) { 418 | va = INDEX2FD(fdno); 419 | 420 | if ((vpd[va / PDMAP] & PTE_V) == 0) { 421 | *fd = (struct Fd *)va; 422 | return 0; 423 | } 424 | 425 | if ((vpt[va / BY2PG] & PTE_V) == 0) { // the fd is not used 426 | *fd = (struct Fd *)va; 427 | return 0; 428 | } 429 | } 430 | 431 | return -E_MAX_OPEN; 432 | } 433 | ``` 434 | 435 | > MOS 采取了微内核设计,因此文件系统的大部分操作并不在内核态中完成,而是交由一个**文件系统服务进程**处理。之后我们会了解这一进程的相关内容。 436 | 437 | > `fd_alloc` 中我们先查看页目录项,如果页目录项无效,那么整个页目录项对应的地址空间就都没有进行映射,那么只需要返回 `va` 即可,因为 `va` 就是该目录项的第一个页表中的第一个页表项对应的地址,即第一个没有被使用的文件描述符的地址。 438 | 439 | 需要注意,我们这里只是返回了可以作为文件描述符的地址,还没有实际的文件描述符数据。之后我们就要获取文件描述符。在 `open` 中调用 `fsipc_open`。该函数会将 `path` 对应的文件以 `mode` 的方式打开,将该文件的文件描述符共享到 `fd` 指针对应的地址处。 440 | 441 | 该函数定义在 user/lib/fsipc.c 中。所做的工作很简单,就是通过进程间通信向文件系统服务进程发送一条消息,表示自己希望进行的操作,服务进程再返回一条消息,表示操作的结果。这样一来一回,虽然不是函数调用,却产生了函数调用的效果,很是像系统调用。 442 | 443 | `fsipc_open` 中,我们将一块缓冲区 `fsipcbuf` 视为 `struct Fsreq_open`,向其中写入了请求打开的文件路径 `req_path` 和打开方式 `req_omode`。并调用 `fsipc` 进行发送。 444 | ```c 445 | int fsipc_open(const char *path, u_int omode, struct Fd *fd) { 446 | u_int perm; 447 | struct Fsreq_open *req; 448 | 449 | req = (struct Fsreq_open *)fsipcbuf; 450 | 451 | // The path is too long. 452 | if (strlen(path) >= MAXPATHLEN) { 453 | return -E_BAD_PATH; 454 | } 455 | 456 | strcpy((char *)req->req_path, path); 457 | req->req_omode = omode; 458 | return fsipc(FSREQ_OPEN, req, fd, &perm); 459 | } 460 | 461 | ``` 462 | 463 | 缓冲区 `fsipcbuf` 是一个页面。因为其大小为 `BY2PG` 字节,又以 `BY2PG` 为基准对齐。 464 | ```c 465 | u_char fsipcbuf[BY2PG] __attribute__((aligned(BY2PG))); 466 | ``` 467 | 468 | `Fsreq_open` 定义在 user/include/fsreq.h 中,类似的还有 `Fsreq_close`、`Fsreq_map` 等等。 469 | ```c 470 | struct Fsreq_open { 471 | char req_path[MAXPATHLEN]; 472 | u_int req_omode; 473 | }; 474 | ``` 475 | 476 | 在调用 `fsipc` 时我们的第一个参数表示请求的类型。这些类型也都定义在 user/include/fsreq.h 中。 477 | ```c 478 | #define FSREQ_OPEN 1 479 | #define FSREQ_MAP 2 480 | #define FSREQ_SET_SIZE 3 481 | #define FSREQ_CLOSE 4 482 | #define FSREQ_DIRTY 5 483 | #define FSREQ_REMOVE 6 484 | #define FSREQ_SYNC 7 485 | ``` 486 | 487 | `fsipc` 函数就是简单的向服务进程发送消息,并接收服务进程返回的消息。注意这里我们通过 `envs[1].env_id` 获取服务进程的 envid,这说明服务进程必须为第二个进程。 488 | ```c 489 | static int fsipc(u_int type, void *fsreq, void *dstva, u_int *perm) { 490 | u_int whom; 491 | // Our file system server must be the 2nd env. 492 | ipc_send(envs[1].env_id, type, fsreq, PTE_D); 493 | return ipc_recv(&whom, dstva, perm); 494 | } 495 | ``` 496 | 497 | ### (2)文件系统服务进程的初始化 498 | 文件系统服务进程是一个完整的进程,有自己的 main 函数。该进程的代码都位于 fs 文件夹下。`main` 函数位于 fs/serv.c 中。我们从这里入手。 499 | 500 | 首先调用 `serve_init` 对程序进行初始化。 501 | ```c 502 | int main() { 503 | user_assert(sizeof(struct File) == BY2FILE); 504 | 505 | debugf("FS is running\n"); 506 | 507 | serve_init(); 508 | ``` 509 | 510 | 在 `serve_init` 函数中,实际进行初始化的只有 `opentab`。这是一个 `struct Open` 类型的数组,用于记录整个操作系统中所有处于打开状态的文件。`MAXOPEN` 就表示了文件打开的最大数量。 511 | ```c 512 | // Max number of open files in the file system at once 513 | #define MAXOPEN 1024 514 | 515 | // initialize to force into data section 516 | struct Open opentab[MAXOPEN] = {{0, 0, 1}}; 517 | ``` 518 | 519 | `struct Open` 定义在同一个文件中。其中保存了打开的文件 `o_file`、文件的 id `o_fileid`、文件打开的方式 `o_mode` 和文件对应的文件描述符 `o_ff`。 520 | ```c 521 | struct Open { 522 | struct File *o_file; // mapped descriptor for open file 523 | u_int o_fileid; // file id 524 | int o_mode; // open mode 525 | Filefd *o_ff; // va of filefd page 526 | }; 527 | ``` 528 | 529 | `Filefd` 是如下所示的结构体。我们在将文件描述符共享到用户进程时,实际上共享的是 `Filefd`。 530 | ```c 531 | // file descriptor + file 532 | struct Filefd { 533 | struct Fd f_fd; 534 | u_int f_fileid; 535 | struct File f_file; 536 | }; 537 | ``` 538 | 539 | 在 `serve_init` 中,我们就是对 `opentab` 进行初始化。为其中每个元素设定 `o_fileid` 和 `o_ff`。 540 | ```c 541 | void serve_init(void) { 542 | int i; 543 | u_int va; 544 | 545 | // Set virtual address to map. 546 | va = FILEVA; 547 | 548 | // Initial array opentab. 549 | for (i = 0; i < MAXOPEN; i++) { 550 | opentab[i].o_fileid = i; 551 | opentab[i].o_ff = (struct Filefd *)va; 552 | va += BY2PG; 553 | } 554 | } 555 | ``` 556 | 557 | 地址的分配是从 `va = FILEVA` 开始的,其中 `#define FILEVA 0x60000000`。每个 `Filefd` 分配一页 `BY2PG` 的大小。因此所有的 `Filefd` 存储在 `[FILEVA, FILEVA + PDMAP)` 的地址空间中。 558 | 559 | 在 `main` 函数中,之后我们又调用 `fs_init` 完成文件系统的初始化。 560 | ```c 561 | fs_init(); 562 | ``` 563 | 564 | 该函数定义在 fs/fs.c 中。其中又调用了三个函数 `read_super`、`check_write_block` 和 `read_bitmap` 565 | ```c 566 | void fs_init(void) { 567 | read_super(); 568 | check_write_block(); 569 | read_bitmap(); 570 | } 571 | ``` 572 | 573 | `read_super` 中大部分都是检查,只有如下部分值得关注。该部分读取了第一个磁盘块的内容,赋值给全局变量 `super`。 574 | ```c 575 | // Step 1: read super block. 576 | if ((r = read_block(1, &blk, 0)) < 0) { 577 | user_panic("cannot read superblock: %e", r); 578 | } 579 | 580 | super = blk; 581 | ``` 582 | 583 | 其中 `read_block` 用于读取对应磁盘块编号的磁盘块数据到内存中。我们不看前面的检查。首先使用 `diskaddr` 获取磁盘块编号应该存储到的地址。 584 | ```c 585 | int read_block(u_int blockno, void **blk, u_int *isnew) { 586 | // omit... 587 | 588 | // Step 3: transform block number to corresponding virtual address. 589 | void *va = diskaddr(blockno); 590 | ``` 591 | 592 | `diskaddr` 函数很简单。这里只是人为规定的地址,我们将 `[DISKMAP, DISKMAP+DISKMAX)` 的地址空间用作缓冲区,当磁盘读入内存时,用来映射相关的页。因为该缓冲区与内存空间是一一映射的,所以我们也可以得知实验中支持的最大磁盘大小为 `DISKMAX` = 1GB。 593 | ```c 594 | void *diskaddr(u_int blockno) { 595 | /* Exercise 5.6: Your code here. */ 596 | return DISKMAP + blockno * BY2BLK; 597 | } 598 | ``` 599 | 600 | 回到 `read_block`,接下来通过 `block_is_mapped` 判断编号对应的磁盘块是否已经被映射,如果没有,则我们需要为其分配内存,并将硬盘中的数据读入该内存空间中。最后我们设置 `*blk` 以返回读取的磁盘块数据对应的内存地址。 601 | ```c 602 | if (block_is_mapped(blockno)) { // the block is in memory 603 | if (isnew) { 604 | *isnew = 0; 605 | } 606 | } else { // the block is not in memory 607 | if (isnew) { 608 | *isnew = 1; 609 | } 610 | syscall_mem_alloc(0, va, PTE_D); 611 | ide_read(0, blockno * SECT2BLK, va, SECT2BLK); 612 | } 613 | 614 | // Step 5: if blk != NULL, assign 'va' to '*blk'. 615 | if (blk) { 616 | *blk = va; 617 | } 618 | return 0; 619 | } 620 | ``` 621 | 622 | 这一部分使用的 `block_is_mapped` 函数比较简单。和我们在 `fd_alloc` 中遇到的通过页表来判断是否被使用相同。 623 | ```c 624 | // Overview: 625 | // Check if this virtual address is mapped to a block. (check PTE_V bit) 626 | int va_is_mapped(void *va) { 627 | return (vpd[PDX(va)] & PTE_V) && (vpt[VPN(va)] & PTE_V); 628 | } 629 | 630 | // Overview: 631 | // Check if this disk block is mapped in cache. 632 | // Returns the virtual address of the cache page if mapped, 0 otherwise. 633 | void *block_is_mapped(u_int blockno) { 634 | void *va = diskaddr(blockno); 635 | if (va_is_mapped(va)) { 636 | return va; 637 | } 638 | return NULL; 639 | } 640 | ``` 641 | 642 | 如果没有映射,则我们调用 `syscall_mem_alloc` 为该地址分配一页空间,之后调用 `ide_read` 函数从磁盘中读取数据。这一部分内容属于磁盘驱动,我们会在下一章讲解。 643 | 644 | 现在让我们回到 `fs_init`。`check_write_block` 函数只是一个插入到基本流程中的测试代码,不需要考虑。我们考虑 `read_bitmap`。该函数会将管理磁盘块分配的位图读取到内存中。 645 | 646 | 这里很奇怪的使用了 `super->s_nblocks / BIT2BLK + 1` 来计算作为位图的磁盘块数量。但是对于 `super->s_nblocks` 是 `BIT2BLK` 整倍数的情况,不是会多计算一个磁盘块吗?不懂。(不过 nbitmap 多了 1 似乎也不会产生什么大的影响,因为各个磁盘块数据在内存中的空间并不重合。)之后就是调用 `read_block` 将磁盘上的数据读取到内存缓冲区中,最后设定全局变量 `bitmap` 的值为位图的首地址。 647 | ```c 648 | void read_bitmap(void) { 649 | u_int i; 650 | void *blk = NULL; 651 | 652 | // Step 1: Calculate the number of the bitmap blocks, and read them into memory. 653 | u_int nbitmap = super->s_nblocks / BIT2BLK + 1; 654 | for (i = 0; i < nbitmap; i++) { 655 | read_block(i + 2, blk, 0); 656 | } 657 | 658 | bitmap = diskaddr(2); 659 | ``` 660 | 661 | `read_bitmap` 中之后的内容都是一些检查,这里就不详细说明了。 662 | 663 | ### (3)文件系统服务进程的服务 664 | 初始化终于完成了,接下来我们调用 `serve` 以开启服务 665 | ```c 666 | serve(); 667 | ``` 668 | 669 | 很容易理解,这样的服务进程就是一个死循环。不断地调用 `ipc_recv` 以接收其他进程发来的请求,根据请求类型的不同分发给不同的处理函数进行处理,并进行回复。`serve` 函数中只需要注意一点,那就是在完成处理后需要进行系统调用 `syscall_mem_unmap` 以取消接收消息时的页面共享,为下一次接收请求做准备。 670 | ```c 671 | void serve(void) { 672 | u_int req, whom, perm; 673 | 674 | for (;;) { 675 | perm = 0; 676 | 677 | req = ipc_recv(&whom, (void *)REQVA, &perm); 678 | 679 | // All requests must contain an argument page 680 | if (!(perm & PTE_V)) { 681 | debugf("Invalid request from %08x: no argument page\n", whom); 682 | continue; // just leave it hanging, waiting for the next request. 683 | } 684 | 685 | switch (req) { 686 | case FSREQ_OPEN: 687 | serve_open(whom, (struct Fsreq_open *)REQVA); 688 | break; 689 | 690 | // omit... 691 | } 692 | 693 | syscall_mem_unmap(0, (void *)REQVA); 694 | } 695 | } 696 | ``` 697 | 698 | 这里我们只考虑 `FSREQ_OPEN` 请求的处理函数 `serve_open`。在该函数中,我们首先调用 `alloc_open` 申请一个存储文件打开信息的 `struct Open` 控制块。需要注意这里的 `ipc_send` 类似于发生错误时的返回。只不过不知为何此处不和下面调用 `file_open` 时一样有 `return` 语句。这或许是一个**错误**,不过既然此处并没有让人写代码,那还是不要擅自改动好了 699 | ```c 700 | void serve_open(u_int envid, struct Fsreq_open *rq) { 701 | struct File *f; 702 | struct Filefd *ff; 703 | int r; 704 | struct Open *o; 705 | 706 | // Find a file id. 707 | if ((r = open_alloc(&o)) < 0) { 708 | ipc_send(envid, r, 0, 0); 709 | } 710 | ``` 711 | 712 | `open_alloc` 函数用于申请一个未使用的 `struct Open` 元素。这里由于 `opentab` 和 存储在 `[FILEVA, FILEVA + PDMAP)` 中的 `Filefd` 是一一对应的关系,所以通过查看 `Filefd` 地址的页表项是否有效(第三次!!!)就可以得知 `struct Open` 元素是否被使用了。(注意这里不能查看 `opentab` 中各元素的页表项,因为 `opentab` 作为数组,占用的空间已经被分配了。) 713 | ```c 714 | int open_alloc(struct Open **o) { 715 | int i, r; 716 | 717 | // Find an available open-file table entry 718 | for (i = 0; i < MAXOPEN; i++) { 719 | switch (pageref(opentab[i].o_ff)) { 720 | case 0: 721 | if ((r = syscall_mem_alloc(0, opentab[i].o_ff, PTE_D | PTE_LIBRARY)) < 0) { 722 | return r; 723 | } 724 | case 1: 725 | opentab[i].o_fileid += MAXOPEN; 726 | *o = &opentab[i]; 727 | memset((void *)opentab[i].o_ff, 0, BY2PG); 728 | return (*o)->o_fileid; 729 | } 730 | } 731 | 732 | return -E_MAX_OPEN; 733 | } 734 | ``` 735 | 736 | 通过 `pageref` 我们可以得知某一页的引用数量。该函数定义在 user/lib/pageref.c 中,不需要多言。 737 | ```c 738 | int pageref(void *v) { 739 | u_int pte; 740 | 741 | if (!(vpd[PDX(v)] & PTE_V)) { 742 | return 0; 743 | } 744 | 745 | pte = vpt[VPN(v)]; 746 | 747 | if (!(pte & PTE_V)) { 748 | return 0; 749 | } 750 | 751 | return pages[PPN(pte)].pp_ref; 752 | } 753 | ``` 754 | 755 | 我必须解释一下 `open_alloc` 中 `switch` 的使用。首先要再次明确 `pageref` 返回的是某一页的引用数量。那么除了 0、1 以外,还可能有 2、3 等等,即物理页在不同进程间共享的情况。在我们的文件系统中,是会出现将 `Filefd` 共享到用户进程的情况,这时因为 `switch` 的 `case` 中只有 1、2,因此便会跳过这次循环。这样我们就将正在使用的文件排除在外了。 756 | 757 | 我们知道在最开始,所有的 `Filefd` 都没有被访问过,他们的引用数量为 0。只有当使用过之后,引用数量才大于 0。那么 `case 1` 表示的情况就很明显了,那就是曾经被使用过,但现在不被任何用户进程使用的文件,只有服务进程还保存着引用。这种情况的 `struct Open` 就没有被使用,因此可以被申请。但是之前使用时的 `o_fileid` 这次就不能使用了,需要更新 `opentab[i].o_fileid += MAXOPEN`,同时将对应的文件描述符 `o_ff` 的内容清零。 758 | 759 | 最后是还没有被访问过的情况,这种情况下我们先要使用系统调用 `syscall_mem_alloc` 申请一个物理页。注意申请时我们使用的权限位 `PTE_D | PTE_LIBRARY`。`PTE_LIBRARY` 表示该页面可以被共享。之后呢?我们还需要和引用数量为 1 的情况一样,将 `o_ff` 对应的空间清零,返回 `o_fileid` 和 `opentab[i]`。这里采用了一个巧妙的方法,在 `case 0` 和 `case 1` 之间没有使用 `break` 分隔,直接让 `case 0` 的执行穿透到了 `case 1` 中。 760 | 761 | 现在让我们回到 `serve_open`。接下来调用 `file_open` 来打开文件。 762 | ```c 763 | // Open the file. 764 | if ((r = file_open(rq->req_path, &f)) < 0) { 765 | ipc_send(envid, r, 0, 0); 766 | return; 767 | } 768 | ``` 769 | 770 | `file_open` 定义在 fs/fs.c 中,只是调用了 `walk_path` 而已。 771 | ```c 772 | int file_open(char *path, struct File **file) { 773 | return walk_path(path, 0, file, 0); 774 | } 775 | ``` 776 | 777 | `walk_path` 函数位于同一个文件中,主要内容就是解析路径,根据路径不断找到目录下的文件,找到最后得到的就是表示路径对应的文件的文件控制块。 778 | ```c 779 | int walk_path(char *path, struct File **pdir, struct File **pfile, char *lastelem) { 780 | char *p; 781 | char name[MAXNAMELEN]; 782 | struct File *dir, *file; 783 | int r; 784 | 785 | // start at the root. 786 | path = skip_slash(path); 787 | file = &super->s_root; 788 | dir = 0; 789 | name[0] = 0; 790 | 791 | if (pdir) { 792 | *pdir = 0; 793 | } 794 | 795 | *pfile = 0; 796 | 797 | // find the target file by name recursively. 798 | while (*path != '\0') { 799 | dir = file; 800 | p = path; 801 | 802 | while (*path != '/' && *path != '\0') { 803 | path++; 804 | } 805 | 806 | if (path - p >= MAXNAMELEN) { 807 | return -E_BAD_PATH; 808 | } 809 | 810 | memcpy(name, p, path - p); 811 | name[path - p] = '\0'; 812 | path = skip_slash(path); 813 | if (dir->f_type != FTYPE_DIR) { 814 | return -E_NOT_FOUND; 815 | } 816 | 817 | if ((r = dir_lookup(dir, name, &file)) < 0) { 818 | if (r == -E_NOT_FOUND && *path == '\0') { 819 | if (pdir) { 820 | *pdir = dir; 821 | } 822 | 823 | if (lastelem) { 824 | strcpy(lastelem, name); 825 | } 826 | 827 | *pfile = 0; 828 | } 829 | 830 | return r; 831 | } 832 | } 833 | 834 | if (pdir) { 835 | *pdir = dir; 836 | } 837 | 838 | *pfile = file; 839 | return 0; 840 | } 841 | ``` 842 | 843 | 该函数大部分工作都用于完成路径解析和异常处理,不用解释相信各位也能理解。唯一需要注意的是 `dir_lookup` 函数。该函数用于找到指定目录下的指定名字的文件。该函数也位于同一个文件中,也是我们需要填写代码的函数。 844 | 845 | 该函数本身很简单,与 tools/fsformat.c 中的 `create_file` 类似。都是获取文件的所有磁盘块,遍历其中所有的文件控制块。只不过这里需要返回指定名字的文件对应的文件控制块。 846 | 847 | ```c 848 | int dir_lookup(struct File *dir, char *name, struct File **file) { 849 | int r; 850 | // Step 1: Calculate the number of blocks in 'dir' via its size. 851 | u_int nblock; 852 | /* Exercise 5.8: Your code here. (1/3) */ 853 | nblock = dir->f_size / BY2BLK; 854 | 855 | // Step 2: Iterate through all blocks in the directory. 856 | for (int i = 0; i < nblock; i++) { 857 | // Read the i'th block of 'dir' and get its address in 'blk' using 'file_get_block'. 858 | void *blk; 859 | /* Exercise 5.8: Your code here. (2/3) */ 860 | try(file_get_block(dir, i, &blk)); 861 | 862 | struct File *files = (struct File *)blk; 863 | 864 | // Find the target among all 'File's in this block. 865 | for (struct File *f = files; f < files + FILE2BLK; ++f) { 866 | /* Exercise 5.8: Your code here. (3/3) */ 867 | if (strcmp(name, f->f_name) == 0) { 868 | *file = f; 869 | f->f_dir = dir; 870 | return 0; 871 | } 872 | } 873 | } 874 | 875 | return -E_NOT_FOUND; 876 | } 877 | ``` 878 | 879 | 唯一需要注意的是 `file_get_block` 函数。该函数用于获取文件中第几个磁盘块。其中首先调用 `file_map_block` 获取了文件中使用的第几个磁盘块对应的磁盘块编号(请注意这两者的区别,一个是相对于文件中其他部分的编号,另一个是相对于磁盘来说的编号)。 880 | ```c 881 | int file_get_block(struct File *f, u_int filebno, void **blk) { 882 | int r; 883 | u_int diskbno; 884 | u_int isnew; 885 | 886 | // Step 1: find the disk block number is `f` using `file_map_block`. 887 | if ((r = file_map_block(f, filebno, &diskbno, 1)) < 0) { 888 | return r; 889 | } 890 | ``` 891 | 892 | 在 `file_map_block` 中首先调用 `file_block_walk` 获取对应的磁盘块编号 893 | ```c 894 | int file_map_block(struct File *f, u_int filebno, u_int *diskbno, u_int alloc) { 895 | int r; 896 | uint32_t *ptr; 897 | 898 | // Step 1: find the pointer for the target block. 899 | if ((r = file_block_walk(f, filebno, &ptr, alloc)) < 0) { 900 | return r; 901 | } 902 | ``` 903 | 904 | `file_block_walk` 中用到了之前着重解释的 `f_direct` 和 `f_indirect`。容易发现此函数的与 fsformat 中的 `save_block_link` 函数结构类似。此函数我们已经在之前说明过了。需要注意的是这里当 `f_indirect` 还未申请时,我们使用了 `alloc_block` 来申请一个新的磁盘块,并使用 `read_block` 将该磁盘块数据读入内存中。 905 | ```c 906 | int file_block_walk(struct File *f, u_int filebno, uint32_t **ppdiskbno, u_int alloc) { 907 | int r; 908 | uint32_t *ptr; 909 | uint32_t *blk; 910 | 911 | if (filebno < NDIRECT) { 912 | // Step 1: if the target block is corresponded to a direct pointer, just return the 913 | // disk block number. 914 | ptr = &f->f_direct[filebno]; 915 | } else if (filebno < NINDIRECT) { 916 | // Step 2: if the target block is corresponded to the indirect block, but there's no 917 | // indirect block and `alloc` is set, create the indirect block. 918 | if (f->f_indirect == 0) { 919 | if (alloc == 0) { 920 | return -E_NOT_FOUND; 921 | } 922 | 923 | if ((r = alloc_block()) < 0) { 924 | return r; 925 | } 926 | f->f_indirect = r; 927 | } 928 | 929 | // Step 3: read the new indirect block to memory. 930 | if ((r = read_block(f->f_indirect, (void **)&blk, 0)) < 0) { 931 | return r; 932 | } 933 | ptr = blk + filebno; 934 | } else { 935 | return -E_INVAL; 936 | } 937 | 938 | // Step 4: store the result into *ppdiskbno, and return 0. 939 | *ppdiskbno = ptr; 940 | return 0; 941 | } 942 | ``` 943 | 944 | `read_block` 我们之前已经提及了,现在我们就考察一下 `alloc_block`。该函数首先调用 `alloc_block_num` 在磁盘块管理位图上找到空闲的磁盘块,更新位图并将位图写入内存 945 | ```c 946 | int alloc_block_num(void) { 947 | int blockno; 948 | // walk through this bitmap, find a free one and mark it as used, then sync 949 | // this block to IDE disk (using `write_block`) from memory. 950 | for (blockno = 3; blockno < super->s_nblocks; blockno++) { 951 | if (bitmap[blockno / 32] & (1 << (blockno % 32))) { // the block is free 952 | bitmap[blockno / 32] &= ~(1 << (blockno % 32)); 953 | write_block(blockno / BIT2BLK + 2); // write to disk. 954 | return blockno; 955 | } 956 | } 957 | // no free blocks. 958 | return -E_NO_DISK; 959 | } 960 | 961 | int alloc_block(void) { 962 | int r, bno; 963 | // Step 1: find a free block. 964 | if ((r = alloc_block_num()) < 0) { // failed. 965 | return r; 966 | } 967 | bno = r; 968 | ``` 969 | 970 | 之后 `alloc_block` 函数调用 `map_block` 将获取的编号所对应的空闲磁盘块从磁盘中读入内存。 971 | ```c 972 | // Step 2: map this block into memory. 973 | if ((r = map_block(bno)) < 0) { 974 | free_block(bno); 975 | return r; 976 | } 977 | 978 | // Step 3: return block number. 979 | return bno; 980 | } 981 | ``` 982 | 983 | `map_block` 函数申请一个页面用于存储磁盘块内容,类似的含有 `unmap_block` 函数。这两个函数较为简单,唯一需要注意的是 `unmap_block` 会将内存中对磁盘块的修改写回磁盘。 984 | ```c 985 | int map_block(u_int blockno) { 986 | // Step 1: If the block is already mapped in cache, return 0. 987 | // Hint: Use 'block_is_mapped'. 988 | /* Exercise 5.7: Your code here. (1/5) */ 989 | if (block_is_mapped(blockno)) { 990 | return 0; 991 | } 992 | 993 | // Step 2: Alloc a page in permission 'PTE_D' via syscall. 994 | // Hint: Use 'diskaddr' for the virtual address. 995 | /* Exercise 5.7: Your code here. (2/5) */ 996 | try(syscall_mem_alloc(env->env_id, diskaddr(blockno), PTE_D)); 997 | } 998 | 999 | void unmap_block(u_int blockno) { 1000 | // Step 1: Get the mapped address of the cache page of this block using 'block_is_mapped'. 1001 | void *va; 1002 | /* Exercise 5.7: Your code here. (3/5) */ 1003 | va = block_is_mapped(blockno); 1004 | 1005 | // Step 2: If this block is used (not free) and dirty in cache, write it back to the disk 1006 | // first. 1007 | /* Exercise 5.7: Your code here. (4/5) */ 1008 | if (!block_is_free(blockno) && block_is_dirty(blockno)) { 1009 | write_block(blockno); 1010 | } 1011 | 1012 | // Step 3: Unmap the virtual address via syscall. 1013 | /* Exercise 5.7: Your code here. (5/5) */ 1014 | syscall_mem_unmap(env->env_id, diskaddr(blockno)); 1015 | 1016 | user_assert(!block_is_mapped(blockno)); 1017 | } 1018 | ``` 1019 | 1020 | 最后 `free_block` 则是重新将位图对应位置置 1,表示空闲。 1021 | ```c 1022 | void free_block(u_int blockno) { 1023 | // You can refer to the function 'block_is_free' above. 1024 | // Step 1: If 'blockno' is invalid (0 or >= the number of blocks in 'super'), return. 1025 | /* Exercise 5.4: Your code here. (1/2) */ 1026 | if (super == 0 || blockno >= super->s_nblocks) { 1027 | return; 1028 | } 1029 | 1030 | // Step 2: Set the flag bit of 'blockno' in 'bitmap'. 1031 | // Hint: Use bit operations to update the bitmap, such as b[n / W] |= 1 << (n % W). 1032 | /* Exercise 5.4: Your code here. (2/2) */ 1033 | bitmap[blockno / 32] |= 1 << (blockno % 32); 1034 | } 1035 | ``` 1036 | 1037 | 我在看这一部分的时候有点想要吐槽一下,为什么 `map_block` 函数和 `read_block` 函数如此相似?`map_block` 分明完全是 `read_block` 的弱化版。后来回看 `file_block_walk` 的时候就明白原因了。在 `file_block_walk` 中我们同时使用了 `alloc_block`(其中用到 `read_block`) 和 `read_block`。其中 `alloc_block` 只是申请了一个磁盘块,但因为是新申请,所以对应地址空间中的数据没有用处,并不从磁盘中读取数据,只需要申请对应地址的物理页即可。而 `read_block` 则进行了数据的读取。如在 `file_block_walk` 中需要读取间接磁盘块中的数据来确定。 1038 | 1039 | 经过了这么艰辛的历程,我们重新回到 `file_map_block`。文件的第几个磁盘块对应的磁盘块编号现在已经被存储在了 `*ptr`。这里还考虑了未找到时再调用 `alloc_block` 申请一个磁盘块的情况。 1040 | ```c 1041 | // Step 2: if the block not exists, and create is set, alloc one. 1042 | if (*ptr == 0) { 1043 | if (alloc == 0) { 1044 | return -E_NOT_FOUND; 1045 | } 1046 | 1047 | if ((r = alloc_block()) < 0) { 1048 | return r; 1049 | } 1050 | *ptr = r; 1051 | } 1052 | ``` 1053 | 1054 | 最后将文件的第几个磁盘块对应的磁盘块编号传给 `*diskbno`,这样就找到了对应的磁盘块编号。 1055 | ```c 1056 | // Step 3: set the pointer to the block in *diskbno and return 0. 1057 | *diskbno = *ptr; 1058 | return 0; 1059 | } 1060 | ``` 1061 | 1062 | 还记得我们是从哪里调用的吗?我们返回到 `file_get_block`,现在我们已经找到了文件中第几个磁盘块对应的磁盘块编号,最后只需要调用 `read_block` 将该磁盘块的内容从磁盘中读取到内存即可。 1063 | ```c 1064 | // Step 2: read the data in this disk to blk. 1065 | if ((r = read_block(diskbno, blk, &isnew)) < 0) { 1066 | return r; 1067 | } 1068 | return 0; 1069 | } 1070 | ``` 1071 | 1072 | 之后,我们的 `dir_lookup` 函数就可以遍历目录文件下所有的文件,找到和目标文件名相同的文件了。而 `dir_lookup` 作为 `walk_path` 的重要组成部分,使 `walk_path` 完成了根据路径获取对应文件的功能。`file_open` 函数调用 `walk_path` 之后返回。我们终于又回到了 `serve_open` 函数。 1073 | 1074 | `serve_open` 接下来的内容就比较简单了,只是将 `file_open` 返回的文件控制块结构体设置到 `struct Open` 结构体,表示新打开的文件为该文件,接着设置一系列字段的值。最后调用 `ipc_send` 返回,将文件描述符 `o->o_ff` 与用户进程共享。 1075 | ```c 1076 | // Save the file pointer. 1077 | o->o_file = f; 1078 | 1079 | // Fill out the Filefd structure 1080 | ff = (struct Filefd *)o->o_ff; 1081 | ff->f_file = *f; 1082 | ff->f_fileid = o->o_fileid; 1083 | o->o_mode = rq->req_omode; 1084 | ff->f_fd.fd_omode = o->o_mode; 1085 | ff->f_fd.fd_dev_id = devfile.dev_id; 1086 | 1087 | ipc_send(envid, 0, o->o_ff, PTE_D | PTE_LIBRARY); 1088 | } 1089 | ``` 1090 | 1091 | 只需要注意 `ff->f_fd.fd_dev_id = devfile.dev_id;` 这一句,我们设置文件描述符对应的设备为 `devfile`。该变量定义在 user/lib/file.c 中 1092 | ```c 1093 | struct Dev devfile = { 1094 | .dev_id = 'f', 1095 | .dev_name = "file", 1096 | .dev_read = file_read, 1097 | .dev_write = file_write, 1098 | .dev_close = file_close, 1099 | .dev_stat = file_stat, 1100 | }; 1101 | ``` 1102 | 1103 | 其中 `struct Dev` 定义在 user/include/fd.h 中 1104 | ```c 1105 | struct Dev { 1106 | int dev_id; 1107 | char *dev_name; 1108 | int (*dev_read)(struct Fd *, void *, u_int, u_int); 1109 | int (*dev_write)(struct Fd *, const void *, u_int, u_int); 1110 | int (*dev_close)(struct Fd *); 1111 | int (*dev_stat)(struct Fd *, struct Stat *); 1112 | int (*dev_seek)(struct Fd *, u_int); 1113 | }; 1114 | ``` 1115 | 1116 | 这样看就很容易理解了。这实际上通过结构体实现了类似抽象类的功能。 1117 | 1118 | ### (4)open 的后续 1119 | 不要忘了,我们的 `open` 函数还没有结束呢。接着上一节,获取到的文件描述符与用户进程共享,那么共享到哪里了呢?如果你还记得 `fd_alloc` 函数,那么应该知道共享到了 `struct Fd *fd` 所指向的地址处。虽然我们获得了服务进程共享给用户进程的文件描述符,可文件的内容还没有被一同共享过来。我们还需要使用 `fsipc_map` 进行映射。 1120 | 1121 | 在此之前我们先做准备工作。我们通过 `fd2data` 获取文件内容应该映射到的地址 1122 | ```c 1123 | // Step 3: Set 'va' to the address of the page where the 'fd''s data is cached, using 1124 | // 'fd2data'. Set 'size' and 'fileid' correctly with the value in 'fd' as a 'Filefd'. 1125 | char *va; 1126 | struct Filefd *ffd; 1127 | u_int size, fileid; 1128 | /* Exercise 5.9: Your code here. (3/5) */ 1129 | va = fd2data(fd); 1130 | ``` 1131 | 1132 | 由定义可知,该函数为不同的文件描述符提供不同的地址用于映射。整体的映射区间为 `[FILEBASE, FILEBASE+1024*PDMAP)`。这正好在存储文件描述符的空间 `[FILEBASE - PDMAP, FILEBASE)` 的上面。 1133 | ```c 1134 | // user/lib/fd.c 1135 | void *fd2data(struct Fd *fd) { 1136 | return (void *)INDEX2DATA(fd2num(fd)); 1137 | } 1138 | 1139 | int fd2num(struct Fd *fd) { 1140 | return ((u_int)fd - FDTABLE) / BY2PG; 1141 | } 1142 | 1143 | // user/include/fd.h 1144 | #define FILEBASE 0x60000000 1145 | 1146 | #define INDEX2DATA(i) (FILEBASE + (i)*PDMAP) 1147 | ``` 1148 | 1149 | 接着我们将文件所有的内容都从磁盘中映射到内存。使用的函数为 `fsipc_map`。映射的过程和得到文件描述符的过程类似,就不详述了。 1150 | ```c 1151 | ffd = (struct Filefd *)fd; 1152 | size = ffd->f_file.f_size; 1153 | fileid = ffd->f_fileid; 1154 | 1155 | // Step 4: Alloc pages and map the file content using 'fsipc_map'. 1156 | for (int i = 0; i < size; i += BY2PG) { 1157 | /* Exercise 5.9: Your code here. (4/5) */ 1158 | try(fsipc_map(fileid, i, va + i)); 1159 | } 1160 | ``` 1161 | 1162 | > 注意这里要映射到的地址为 `va + i` 而非 `va`,使用后者在 Lab6 中加载更大文件时会出现 bug,但却可以通过 Lab5 的评测…… 1163 | 1164 | 在最后,使用 `fd2num` 方法获取文件描述符在文件描述符 “数组” 中的索引 1165 | ```c 1166 | // Step 5: Return the number of file descriptor using 'fd2num'. 1167 | /* Exercise 5.9: Your code here. (5/5) */ 1168 | return fd2num(fd); 1169 | } 1170 | ``` 1171 | 1172 | 这样,`open` 函数就终于完成了。 1173 | 1174 | ### (5)read 的实现 1175 | 文件系统的最后一部分,让我们再举 `read` 做一个例子。该函数位于 user/lib/fd.c 中。 1176 | 1177 | `read` 函数给了文件描述符序号作为参数,我们首先要根据该序号找到文件描述符,并根据文件描述符中的设备序号 `fd_dev_id` 找到对应的设备。这两个操作分别通过 `fd_lookup` 和 `dev_lookup` 实现。 1178 | ```c 1179 | int read(int fdnum, void *buf, u_int n) { 1180 | int r; 1181 | 1182 | // Similar to the 'write' function below. 1183 | // Step 1: Get 'fd' and 'dev' using 'fd_lookup' and 'dev_lookup'. 1184 | struct Dev *dev; 1185 | struct Fd *fd; 1186 | /* Exercise 5.10: Your code here. (1/4) */ 1187 | if ((r = fd_lookup(fdnum, &fd)) < 0 || (r = dev_lookup(fd->fd_dev_id, &dev)) < 0) { 1188 | return r; 1189 | } 1190 | ``` 1191 | 1192 | 因为文件描述符存储在内存中的连续空间中,所以 `fd_lookup` 函数只是根据序号找到对应的文件描述符,并且判断文件描述符是否被使用而已,这一点函数中又一次使用页表判断。 1193 | ```c 1194 | int fd_lookup(int fdnum, struct Fd **fd) { 1195 | u_int va; 1196 | 1197 | if (fdnum >= MAXFD) { 1198 | return -E_INVAL; 1199 | } 1200 | 1201 | va = INDEX2FD(fdnum); 1202 | 1203 | if ((vpt[va / BY2PG] & PTE_V) != 0) { // the fd is used 1204 | *fd = (struct Fd *)va; 1205 | return 0; 1206 | } 1207 | 1208 | return -E_INVAL; 1209 | } 1210 | ``` 1211 | 1212 | 对于 `dev_lookup`,首先我们应该了解到所有的设备都被存储到了全局变量 `devtab` 中。(这里出现了我们之前见到过的 `devfile`。`devcons` 类似。) 1213 | ```c 1214 | static struct Dev *devtab[] = {&devfile, &devcons, 1215 | #if !defined(LAB) || LAB >= 6 1216 | &devpipe, 1217 | #endif 1218 | 0}; 1219 | ``` 1220 | 1221 | 那么在 `dev_lookup` 中,我们就只是遍历该数组,找到和传入的参数 `dev_id` 相同的设备而已。 1222 | ```c 1223 | int dev_lookup(int dev_id, struct Dev **dev) { 1224 | for (int i = 0; devtab[i]; i++) { 1225 | if (devtab[i]->dev_id == dev_id) { 1226 | *dev = devtab[i]; 1227 | return 0; 1228 | } 1229 | } 1230 | 1231 | debugf("[%08x] unknown device type %d\n", env->env_id, dev_id); 1232 | return -E_INVAL; 1233 | } 1234 | ``` 1235 | 1236 | 回到 `read`,接下来我们判断文件的打开方式是否是只写,如果是那么我们就不能够进行读取,应该返回异常。 1237 | ```c 1238 | // Step 2: Check the open mode in 'fd'. 1239 | // Return -E_INVAL if the file is opened for writing only (O_WRONLY). 1240 | /* Exercise 5.10: Your code here. (2/4) */ 1241 | if ((fd->fd_omode & O_ACCMODE) == O_WRONLY) { 1242 | return -E_INVAL; 1243 | } 1244 | ``` 1245 | 1246 | 接着我们调用设备对应的 `dev_read` 函数,完成数据的读取。 1247 | ```c 1248 | // Step 3: Read from 'dev' into 'buf' at the seek position (offset in 'fd'). 1249 | /* Exercise 5.10: Your code here. (3/4) */ 1250 | r = dev->dev_read(fd, buf, n, fd->fd_offset); 1251 | 1252 | ``` 1253 | 1254 | 根据之前我们展示的 `devfile` 的定义,普通文件的读取函数为 `file_read`。该函数位于 user/lib/file.c 中,只是简单地读取被映射到内存中的文件内容而已。(还记得 `fsipc_map` 吗?) 1255 | ```c 1256 | static int file_read(struct Fd *fd, void *buf, u_int n, u_int offset) { 1257 | u_int size; 1258 | struct Filefd *f; 1259 | f = (struct Filefd *)fd; 1260 | 1261 | // Avoid reading past the end of file. 1262 | size = f->f_file.f_size; 1263 | 1264 | if (offset > size) { 1265 | return 0; 1266 | } 1267 | 1268 | if (offset + n > size) { 1269 | n = size - offset; 1270 | } 1271 | 1272 | memcpy(buf, (char *)fd2data(fd) + offset, n); 1273 | return n; 1274 | } 1275 | ``` 1276 | 1277 | 再次回到 `read`,我们读取完了内容,现在我们要更新文件的指针 `fd_offset`。在调用读取函数的时候,我们使用 `fd_offset` 确定了读取的位置( `dev_read(fd, buf, n, fd->fd_offset)`)。那么下一次读取时,就应该从还未被读取的地方读取了。更新完成后,我们返回读到的数据的字节数。这样 `read` 函数也完成了。 1278 | ```c 1279 | // Step 4: Update the offset in 'fd' if the read is successful. 1280 | /* Hint: DO NOT add a null terminator to the end of the buffer! 1281 | * A character buffer is not a C string. Only the memory within [buf, buf+n) is safe to 1282 | * use. */ 1283 | /* Exercise 5.10: Your code here. (4/4) */ 1284 | if (r > 0) { 1285 | fd->fd_offset += r; 1286 | } 1287 | 1288 | return r; 1289 | } 1290 | ``` 1291 | 1292 | ## 四、磁盘驱动 1293 | ### (1)设备读写系统调用 1294 | 我们在前面的许多 Lab 中都见到了与外部设备进行交互的代码。我们只需要在对应的物理地址位置写入或读取某些数值,就可以完成与设备的信息传递。现在我们要规范化这一过程,让用户程序也能够实现与设备的直接交互。也就是说,要实现设备读写的系统调用。 1295 | 1296 | 这一部分十分简单。根据指导书和注释我们可以得知,只需要完成对三个设备的读写即可。这三个设备是终端、磁盘和时钟。 1297 | ```c 1298 | /* 1299 | * All valid device and their physical address ranges: 1300 | * * ---------------------------------* 1301 | * | device | start addr | length | 1302 | * * -----------+------------+--------* 1303 | * | console | 0x10000000 | 0x20 | (dev_cons.h) 1304 | * | IDE disk | 0x13000000 | 0x4200 | (dev_disk.h) 1305 | * | rtc | 0x15000000 | 0x200 | (dev_rtc.h) 1306 | * * ---------------------------------* 1307 | */ 1308 | ``` 1309 | 1310 | 在内核中,我们要完成系统调用的实现。这里我们要判断内存的虚拟地址是否处于用户空间以及设备的物理地址是否处于那三个设备的范围内。如果所有检查都合法,则调用 `memcpy` 从内存向设备写入或从设备向内存读取即可,系统调用函数依旧在 kern/syscall_all.c 中实现。 1311 | ```c 1312 | int sys_write_dev(u_int va, u_int pa, u_int len) { 1313 | /* Exercise 5.1: Your code here. (1/2) */ 1314 | if (is_illegal_va_range(va, len)) { 1315 | return -E_INVAL; 1316 | } 1317 | 1318 | if ((0x10000000 <= pa && pa + len <= 0x10000020) || 1319 | (0x13000000 <= pa && pa + len <= 0x13004200) || 1320 | (0x15000000 <= pa && pa + len <= 0x15000200)) { 1321 | memcpy((void *)(KSEG1 | pa), (void *)va, len); 1322 | return 0; 1323 | } 1324 | 1325 | return -E_INVAL; 1326 | } 1327 | 1328 | int sys_read_dev(u_int va, u_int pa, u_int len) { 1329 | /* Exercise 5.1: Your code here. (2/2) */ 1330 | if (is_illegal_va_range(va, len)) { 1331 | return -E_INVAL; 1332 | } 1333 | 1334 | if ((0x10000000 <= pa && pa + len <= 0x10000020) || 1335 | (0x13000000 <= pa && pa + len <= 0x13004200) || 1336 | (0x15000000 <= pa && pa + len <= 0x15000200)) { 1337 | memcpy((void *)va, (void *)(KSEG1 | pa), len); 1338 | return 0; 1339 | } 1340 | 1341 | return -E_INVAL; 1342 | } 1343 | ``` 1344 | 1345 | 另外不要忘记在用户库函数中实现接口,用户的系统调用接口同样还在 user/lib/syscall_lib.c 1346 | ```c 1347 | int syscall_write_dev(void *va, u_int dev, u_int len) { 1348 | /* Exercise 5.2: Your code here. (1/2) */ 1349 | return msyscall(SYS_write_dev, va, dev, len); 1350 | } 1351 | 1352 | int syscall_read_dev(void *va, u_int dev, u_int len) { 1353 | /* Exercise 5.2: Your code here. (2/2) */ 1354 | return msyscall(SYS_read_dev, va, dev, len); 1355 | } 1356 | ``` 1357 | 1358 | ### (2)IDE 磁盘读写 1359 | 最后我们需要实现磁盘的读写操作。在上一章中我们就遇到了 `ide_read` 函数。该函数通过调用系统操作,实现了从磁盘中读取数据到内存中。类似的还有 `ide_write` 函数。这两个函数都是磁盘驱动。 1360 | 1361 | 在指导书中给出了操作 IDE 磁盘可能用到的地址偏移。我们只需要读写这些地址即可。 1362 | 1363 | |偏移 |效果 |数据位宽| 1364 | |:-----:|-----|:-----:| 1365 | |0x0000 |写:设置下一次读写操作时的磁盘镜像偏移的字节数 |4 字节| 1366 | |0x0008 |写:设置高 32 位的偏移的字节数 |4 字节| 1367 | |0x0010 |写:设置下一次读写操作的磁盘编号 |4 字节| 1368 | |0x0020 |写:开始一次读写操作(写 0 表示读操作,1 表示写操作) |4 字节| 1369 | |0x0030 |读:获取上一次操作的状态返回值(读 0 表示失败,非 0 则表示成功)|4 字节| 1370 | |0x4000-0x41ff |读/写:512 字节的读写缓存 |/| 1371 | 1372 | `ide_read` 和 `ide_write` 定义在 fs/ide.c 中。这两个函数都需要我们自己实现 1373 | ```c 1374 | void ide_read(u_int diskno, u_int secno, void *dst, u_int nsecs) { 1375 | u_int begin = secno * BY2SECT; 1376 | u_int end = begin + nsecs * BY2SECT; 1377 | 1378 | for (u_int off = 0; begin + off < end; off += BY2SECT) { 1379 | uint32_t temp = diskno; 1380 | /* Exercise 5.3: Your code here. (1/2) */ 1381 | syscall_write_dev(&temp, DEV_DISK_ADDRESS | DEV_DISK_ID, 4); 1382 | temp = begin + off; 1383 | syscall_write_dev(&temp, DEV_DISK_ADDRESS | DEV_DISK_OFFSET, 4); 1384 | temp = DEV_DISK_OPERATION_READ; 1385 | syscall_write_dev(&temp, DEV_DISK_ADDRESS | DEV_DISK_START_OPERATION, 4); 1386 | syscall_read_dev(&temp, DEV_DISK_ADDRESS | DEV_DISK_STATUS, 4); 1387 | if (temp == 0) { 1388 | panic_on("fail to read from disk"); 1389 | } 1390 | syscall_read_dev(dst + off, DEV_DISK_ADDRESS | DEV_DISK_BUFFER, BY2SECT); 1391 | } 1392 | } 1393 | 1394 | void ide_write(u_int diskno, u_int secno, void *src, u_int nsecs) { 1395 | u_int begin = secno * BY2SECT; 1396 | u_int end = begin + nsecs * BY2SECT; 1397 | 1398 | for (u_int off = 0; begin + off < end; off += BY2SECT) { 1399 | uint32_t temp = diskno; 1400 | /* Exercise 5.3: Your code here. (2/2) */ 1401 | syscall_write_dev(&temp, DEV_DISK_ADDRESS | DEV_DISK_ID, 4); 1402 | temp = begin + off; 1403 | syscall_write_dev(&temp, DEV_DISK_ADDRESS | DEV_DISK_OFFSET, 4); 1404 | syscall_write_dev(src + off, DEV_DISK_ADDRESS | DEV_DISK_BUFFER, BY2SECT); 1405 | temp = DEV_DISK_OPERATION_WRITE; 1406 | syscall_write_dev(&temp, DEV_DISK_ADDRESS | DEV_DISK_START_OPERATION, 4); 1407 | syscall_read_dev(&temp, DEV_DISK_ADDRESS| DEV_DISK_STATUS, 4); 1408 | if (temp == 0) { 1409 | panic_on("fail to write disk"); 1410 | } 1411 | } 1412 | } 1413 | ``` 1414 | 1415 | 这里的代码看上去复杂,实际上只实现了简单的步骤,比如对于 `ide_read`,只不过实现了 1. 设定要读的磁盘编号;2. 设定要读取的地址;3. 开始读取;4. 获取读取后状态(返回值),如果读取失败则 panic;5. 将缓冲区中的内容读到内存中。 1416 | 1417 | 这样 Lab5 就完成了。 1418 | -------------------------------------------------------------------------------- /BUAA-OS实验笔记之Lab6.md: -------------------------------------------------------------------------------- 1 | ## 一、Lab6 前言 2 | 操作系统实验的最后一篇笔记,不说什么了。本文主要讲了 Shell 的实现机制,管道通信略有说明。 3 | 4 | 5 | ## 二、Shell 程序的启动 6 | 这次我们还要回到 Init/init.c 文件。我们的 MOS 的所有实验都结束之后,`mips_init` 函数应该是这样的 7 | ```c 8 | void mips_init() { 9 | printk("init.c:\tmips_init() is called\n"); 10 | 11 | // lab2: 12 | mips_detect_memory(); 13 | mips_vm_init(); 14 | page_init(); 15 | 16 | // lab3: 17 | env_init(); 18 | 19 | // lab6: 20 | ENV_CREATE(user_icode); // This must be the first env! 21 | 22 | // lab5: 23 | ENV_CREATE(fs_serv); // This must be the second env! 24 | 25 | // lab3: 26 | kclock_init(); 27 | enable_irq(); 28 | while (1) { 29 | } 30 | } 31 | ``` 32 | 33 | 其中我们使用 `ENV_CREATE` 创建了两个用户进程。这两个进程的代码在编译时便写入了内核 ELF 文件中。其中第二个进程 `fs_serv` 就是 Lab5 中用到的文件系统服务进程;而第一个进程 `user_icode` 则是整个操作系统中除文件系统服务进程外所有进程的共同祖先进程,该进程便用于启动 Shell 进程。`user_icode` 或为 “user init code” 之意。 34 | 35 | 对应的文件位于 user/icode.c 中。首先读取 motd 文件并输出其内容。motd 即 “message of today” 的缩写。其实这一步只是为了打印欢迎信息罢了。 36 | ```c 37 | int main() { 38 | int fd, n, r; 39 | char buf[512 + 1]; 40 | 41 | debugf("icode: open /motd\n"); 42 | if ((fd = open("/motd", O_RDONLY)) < 0) { 43 | user_panic("icode: open /motd: %d", fd); 44 | } 45 | 46 | debugf("icode: read /motd\n"); 47 | while ((n = read(fd, buf, sizeof buf - 1)) > 0) { 48 | buf[n] = 0; 49 | debugf("%s\n", buf); 50 | } 51 | 52 | debugf("icode: close /motd\n"); 53 | close(fd); 54 | ``` 55 | 56 | 之后调用 `spawnl` 函数执行文件 `init.b`。`spawnl` 实际调用了 `spawn` 函数,该函数用于将磁盘中的文件加载到内存,并以此创建一个新进程。`spawn` 函数的内容让我们搁到后面,现在先来继续看 Shell 启动的过程。 57 | 58 | 执行的 `init.b` 文件的源代码在 user/init.c 中。其中前面一大部分都是测试 ELF 文件加载正确性的代码,就略过不讲了。重要的部分从这里开始。 59 | ```c 60 | int main(int argc, char **argv) { 61 | int i, r, x, want; 62 | 63 | // omit... 64 | 65 | // stdin should be 0, because no file descriptors are open yet 66 | if ((r = opencons()) != 0) { 67 | user_panic("opencons: %d", r); 68 | } 69 | ``` 70 | 71 | 首先调用了 `opencons` 打开一个终端文件。该函数位于 user/lib/console.c 中。内容很简单,只是申请了一个文件描述符,并设置 `fd->fd_dev_id = devcons.dev_id` 表示该文件属于终端文件,执行读写等等操作时使用终端提供的函数。 72 | 73 | ```c 74 | int opencons(void) { 75 | int r; 76 | struct Fd *fd; 77 | 78 | if ((r = fd_alloc(&fd)) < 0) { 79 | return r; 80 | } 81 | if ((r = syscall_mem_alloc(0, fd, PTE_D | PTE_LIBRARY)) < 0) { 82 | return r; 83 | } 84 | fd->fd_dev_id = devcons.dev_id; 85 | fd->fd_omode = O_RDWR; 86 | return fd2num(fd); 87 | } 88 | ``` 89 | 90 | 对于终端的读写操作,我们也可以看一下。以写为例,对应的函数为 `cons_write`,实际可以看出只是通过系统调用实现读写而已。 91 | ```c 92 | struct Dev devcons = { 93 | .dev_id = 'c', 94 | .dev_name = "cons", 95 | .dev_read = cons_read, 96 | .dev_write = cons_write, 97 | .dev_close = cons_close, 98 | .dev_stat = cons_stat, 99 | }; 100 | 101 | int cons_write(struct Fd *fd, const void *buf, u_int n, u_int offset) { 102 | int r = syscall_print_cons(buf, n); 103 | if (r < 0) { 104 | return r; 105 | } 106 | return n; 107 | } 108 | ``` 109 | 110 | 让我们回到 user/init.c。因为调用 `opencons` 时系统内必定没有其他文件被打卡,所以该函数申请得到的文件描述符必定为 0。之后代码中又调用 `dup` 函数申请了一个新的文件描述符 1。该文件描述符是 0 的复制。也就是说,对这两个文件描述符进行读写等文件操作,都是对同一个文件(这里是终端文件)的操作。 111 | ```c 112 | // stdout 113 | if ((r = dup(0, 1)) < 0) { 114 | user_panic("dup: %d", r); 115 | } 116 | ``` 117 | 118 | `dup` 函数位于 user/lib/fd.c 中,本属于文件系统的内容,但 Lab5 中碍于篇幅没有讲解,这里我们讲解一下。`dup` 函数在最开始做了一系列准备工作,首先是根据已有的文件描述符 `oldfdnum` 找到对应的文件描述结构体。 119 | ```c 120 | int dup(int oldfdnum, int newfdnum) { 121 | int i, r; 122 | void *ova, *nva; 123 | u_int pte; 124 | struct Fd *oldfd, *newfd; 125 | 126 | if ((r = fd_lookup(oldfdnum, &oldfd)) < 0) { 127 | return r; 128 | } 129 | ``` 130 | 131 | 之后需要注意我们调用了 `close` 函数关闭了要复制的文件描述符 `newfdnum` 原本对应的文件(如果有的话)。只有这样我们才能将 `oldfdnum` 对应的文件复制给 `newfdnum`。最后我们通过 `fd2data` 函数根据描述符得到文件内容所在的地址位置。 132 | ```c 133 | close(newfdnum); 134 | newfd = (struct Fd *)INDEX2FD(newfdnum); 135 | ova = fd2data(oldfd); 136 | nva = fd2data(newfd); 137 | ``` 138 | 139 | 之后我们要做的就是共享文件描述符和文件内容,这通过系统调用 `syscall_mem_map` 实现。 140 | 141 | 这一部分将 `oldfd` 所在的页映射到 `newfd` 所在的地址。需要注意这里的 `vpt[VPN(oldfd)] & (PTE_D | PTE_LIBRARY)` 表示为 `newfd` 设置与 `oldfd` 相同的可写可共享权限。 142 | ```c 143 | if ((r = syscall_mem_map(0, oldfd, 0, newfd, vpt[VPN(oldfd)] & (PTE_D | PTE_LIBRARY))) < 144 | 1) { 145 | goto err; 146 | } 147 | ``` 148 | 149 | 对于文件内容也一样,对于有效的页(`pte & PTE_V`),我们同样需要进行映射。全部都映射完成后,返回新的文件描述符。 150 | 151 | ```c 152 | if (vpd[PDX(ova)]) { 153 | for (i = 0; i < PDMAP; i += BY2PG) { 154 | pte = vpt[VPN(ova + i)]; 155 | 156 | if (pte & PTE_V) { 157 | // should be no error here -- pd is already allocated 158 | if ((r = syscall_mem_map(0, (void *)(ova + i), 0, (void *)(nva + i), 159 | pte & (PTE_D | PTE_LIBRARY))) < 0) { 160 | goto err; 161 | } 162 | } 163 | } 164 | } 165 | 166 | return newfdnum; 167 | ``` 168 | 169 | 很有趣的一点是这里 `dup` 函数中使用到了 `goto` 进行异常处理。当出错后,会跳转到 `err` 标签的位置,解除已经建立的映射关系。在之后我们还会多次遇到这种处理方法。这种方法的主要目的是在出错时释放已经分配的资源,优点是对多次申请不同资源的情况,可以很简洁地进行处理。这一点在 `dup` 函数中似乎不太明显,但等到了 `spawn` 函数中就可以看出来了。 170 | ```c 171 | err: 172 | syscall_mem_unmap(0, newfd); 173 | 174 | for (i = 0; i < PDMAP; i += BY2PG) { 175 | syscall_mem_unmap(0, (void *)(nva + i)); 176 | } 177 | 178 | return r; 179 | } 180 | ``` 181 | 182 | 让我们再回到 user/init.c。现在我们有了 0、1 两个文件描述符,分别表示了标准输入和标准输出。此后进程都会通过 `spawn` 或 `fork` 创建进程,这些新创建的进程也会继承两个标准输入输出,除非进程自己关闭。 183 | 184 | 最后是一个死循环,这个循环用于创建 Shell 进程。在调用 `spawnl` 创建 Shell 进程成功后,会调用 `wait` 等待 Shell 进程退出。因为处于循环中,所以当 Shell 进程退出后,又会创建一个新的 Shell 进程。 185 | 186 | ```c 187 | while (1) { 188 | debugf("init: starting sh\n"); 189 | r = spawnl("sh.b", "sh", NULL); 190 | if (r < 0) { 191 | debugf("init: spawn sh: %d\n", r); 192 | return r; 193 | } 194 | wait(r); 195 | } 196 | } 197 | ``` 198 | 199 | 其中 `wait` 定义在 user/lib/wait.c 中。只是实现了一个简单的忙等,使得调用 `wait` 的函数被阻塞,直到 `envid` 对应的进程退出才继续执行。 200 | ```c 201 | void wait(u_int envid) { 202 | volatile struct Env *e; 203 | 204 | e = &envs[ENVX(envid)]; 205 | while (e->env_id == envid && e->env_status != ENV_FREE) { 206 | syscall_yield(); 207 | } 208 | } 209 | ``` 210 | 211 | 这样 Shell 进程就启动了 212 | 213 | ## 三、Shell 原理 214 | Shell 程序需要做的事就是这样:不断读取用户的命令输入,根据命令创建对应的进程,并实现进程间的通信。我们的 Shell 进程位于 user/sh.c。 215 | 216 | 首先打印了欢迎信息,这点就略过了。紧随其后是两个比较复杂的宏 `ARGBEGIN` 和 `ARGEND`。这两个宏和其中间的部分解析了命令中的选项。对于本函数来说,就是 `-i` 和 `-x`。 217 | ```c 218 | int main(int argc, char **argv) { 219 | int r; 220 | int interactive = iscons(0); 221 | int echocmds = 0; 222 | 223 | // omit... 224 | 225 | ARGBEGIN { 226 | case 'i': 227 | interactive = 1; 228 | break; 229 | case 'x': 230 | echocmds = 1; 231 | break; 232 | default: 233 | usage(); 234 | } 235 | ARGEND 236 | ``` 237 | 238 | > `ARGBEGIN` 和 `ARGEND` 的具体内容在 include/args.h 中,但是既然注释里已经说了 `you are not expected to understand this`,那我们还是跳过吧。但是我还想插一句嘴,注释里说道,这个参数解析宏是 `simple command line parser from Plan 9`。plan 9 是贝尔实验室 40 多年前开发的超前的实验性操作系统,没想到能看到这么老的代码……这可比 Lab2 中 30 年前的 [queue.h](https://github.com/torvalds/linux/blob/master/drivers/scsi/aic7xxx/queue.h) 还要离谱。 239 | 240 | 接下来的这一部分考虑了执行 Shell 脚本的情况。如果需要执行脚本,则关闭标准输入,改为文件作为输入。 241 | ```c 242 | if (argc > 1) { 243 | usage(); 244 | } 245 | if (argc == 1) { 246 | close(0); 247 | if ((r = open(argv[1], O_RDONLY)) < 0) { 248 | user_panic("open %s: %d", argv[1], r); 249 | } 250 | user_assert(r == 0); 251 | } 252 | ``` 253 | 254 | 最后在循环中不断读入命令行并进行处理。对于交互界面,会首先输出提示符 `$`。随后读入一行命令。 255 | ```c 256 | for (;;) { 257 | if (interactive) { 258 | printf("\n$ "); 259 | } 260 | readline(buf, sizeof buf); 261 | ``` 262 | 263 | 其中 `readline` 函数会逐字符读取。需要注意当标准输入未被重定向时(即从终端进行读取时),这一过程是和用户的输入同步的。当用户输入一个字符,`read` 才会读取一个字符,否则会被阻塞。 264 | ```c 265 | void readline(char *buf, u_int n) { 266 | int r; 267 | for (int i = 0; i < n; i++) { 268 | if ((r = read(0, buf + i, 1)) != 1) { 269 | if (r < 0) { 270 | debugf("read error: %d\n", r); 271 | } 272 | exit(); 273 | } 274 | ``` 275 | 276 | 阻塞的实现也不过是一个忙等而已 277 | ```c 278 | int cons_read(struct Fd *fd, void *vbuf, u_int n, u_int offset) { 279 | int c; 280 | 281 | if (n == 0) { 282 | return 0; 283 | } 284 | 285 | while ((c = syscall_cgetc()) == 0) { 286 | syscall_yield(); 287 | } 288 | 289 | // omit... 290 | } 291 | ``` 292 | 293 | 之后,`readline` 中值得注意的是这里进行了退格的处理。所谓退格键就是 backspace 键,当按下该键后,就会在串口中产生一个退格符 `\b`。接下来我们的操作就很清楚了,只需要将之前读入的一个字符清除即可。另外对于终端显示,我们也要产生删除上一个字符的效果,这一效果可以通过向串口输出一个退格符实现 `printf("\b")`。 294 | ```c 295 | if (buf[i] == '\b' || buf[i] == 0x7f) { 296 | if (i > 0) { 297 | i -= 2; 298 | } else { 299 | i = -1; 300 | } 301 | if (buf[i] != '\b') { 302 | printf("\b"); 303 | } 304 | } 305 | ``` 306 | 307 | > 哪里来的 `printf`?我们都知道之前用户进程只使用 `debugf` 进行输出,其实 `printf` 的实现也一样,该函数位于 user/lib/fprintf.c 中,是在 Lab5 时添加的,当时我怎么没注意到。 308 | 309 | 再之后,`readline` 判断是否读到换行符,是的话则退出,完成一行命令的读入。 310 | ```c 311 | if (buf[i] == '\r' || buf[i] == '\n') { 312 | buf[i] = 0; 313 | return; 314 | } 315 | } 316 | ``` 317 | 318 | 在循环之外还对过长的命令进行了处理。在这一部分会不断读入剩下的命令,并返回空字符串。 319 | ```c 320 | debugf("line too long\n"); 321 | while ((r = read(0, buf, 1)) == 1 && buf[0] != '\r' && buf[0] != '\n') { 322 | ; 323 | } 324 | buf[0] = 0; 325 | } 326 | ``` 327 | 328 | 回到 Shell 进程的 `main` 函数。接着我们会对读入的命令进行处理,首先忽略以 `#` 开头的注释,接着在 `echocmds` 模式下输出读入的命令。这些没有什么好说的,再之后的部分才是 Shell 的重点。 329 | ```c 330 | if (buf[0] == '#') { 331 | continue; 332 | } 333 | if (echocmds) { 334 | printf("# %s\n", buf); 335 | } 336 | if ((r = fork()) < 0) { 337 | user_panic("fork: %d", r); 338 | } 339 | ``` 340 | 341 | 这里我们调用 `fork` 复制了一个 Shell 进程,执行 `runcmd` 函数来运行命令,而原本的 Shell 进程则等待该新复制的进程结束。 342 | ```c 343 | if ((r = fork()) < 0) { 344 | user_panic("fork: %d", r); 345 | } 346 | if (r == 0) { 347 | runcmd(buf); 348 | exit(); 349 | } else { 350 | wait(r); 351 | } 352 | } 353 | return 0; 354 | } 355 | ``` 356 | 357 | 对于新复制的 Shell 进程,我们查看一下 `runcmd` 函数,这个函数以及 `parsecmd` 函数是 Shell 的核心。首先调用一次 `gettoken`,这将把 `s` 设定为要解析的字符串。`gettoken` 函数似乎较为复杂,涉及到字符串的解析,就不深入了。 358 | ```c 359 | void runcmd(char *s) { 360 | gettoken(s, 0); 361 | ``` 362 | 363 | 接着我们需要调用 `parsecmd` 将完整的字符串解析。解析的参数返回到 `argv`,参数的数量返回为 `argc`。 364 | ```c 365 | char *argv[MAXARGS]; 366 | int rightpipe = 0; 367 | int argc = parsecmd(argv, &rightpipe); 368 | ``` 369 | 370 | 另外 `parsecmd` 还会返回 `rightpipe`。对于该值的理解需要从头到尾梳理一下 Shell 解析并执行命令的流程。首先为了方便讲述,我要定义三个名词。第一个是**主 Shell 进程**,即执行了 `main` 部分代码的 Shell 进程;第二个是**执行命令的 Shell 进程**,即调用 `spawn` 创建执行命令的进程;第三个是**命令进程**,即被 `spawn` 创建的进程。 371 | 372 | 那么我们就来分析一下解析和执行命令的过程。首先读到命令后,主 Shell 进程会调用 `fork` 创建一个执行命令的 Shell 进程。该进程会执行命令,创建命令进程。但问题是命令中可能出现管道 `|`,这就需要创建两个或多个命令进程。这时我们并不通过循环之类的方法多次调用 `spawn`,而是 `fork` 出一个新的执行命令的进程,由这个新的进程调用 `spawn` 根据管道右侧的命令创建命令进程;而原来的进程则根据管道左侧的命令创建。 373 | 374 | `parsecmd` 函数完成了上述流程中 “`fork` 出新的执行命令的进程” 的任务。`rightpipe` 表示的就是 `fork` 创建的执行命令的 Shell 进程的进程 id。 375 | 376 | 当然除此之外该函数还完成了其他一些工作。比如将完整的命令字符串解析为参数数组;完成标准输入输出重定向到文件;为命令中管道符号 `|` 两侧的命令创建管道并将管道重定向到标准输入输出。 377 | 378 | 根据上面的内容,我们查看一下 `parsecmd` 函数的内容。该函数会不断解析命令,直到解析到命令字符串的末尾(`gettoken` 返回值为 0)则退出。 379 | ```c 380 | int parsecmd(char **argv, int *rightpipe) { 381 | int argc = 0; 382 | while (1) { 383 | char *t; 384 | int fd, r; 385 | int c = gettoken(0, &t); 386 | switch (c) { 387 | case 0: 388 | return argc; 389 | ``` 390 | 391 | 对于解析得到的是一般单词的情况,我们为参数数组 `argv` 添加一个参数。 392 | ```c 393 | case 'w': 394 | if (argc >= MAXARGS) { 395 | debugf("too many arguments\n"); 396 | exit(); 397 | } 398 | argv[argc++] = t; 399 | break; 400 | ``` 401 | 402 | 对于输入或输出重定向的情况,我们获取下一个 token,打开对应的文件,调用 `dup` 设定 0 或 1 为该文件的文件描述符(原本的标准输入输出被 “挤掉”),并关闭原来打开的文件描述符。 403 | ```c 404 | case '<': 405 | if (gettoken(0, &t) != 'w') { 406 | debugf("syntax error: < not followed by word\n"); 407 | exit(); 408 | } 409 | // Open 't' for reading, dup it onto fd 0, and then close the original fd. 410 | /* Exercise 6.5: Your code here. (1/3) */ 411 | fd = open(t, O_RDONLY); 412 | dup(fd, 0); 413 | close(fd); 414 | break; 415 | case '>': 416 | if (gettoken(0, &t) != 'w') { 417 | debugf("syntax error: > not followed by word\n"); 418 | exit(); 419 | } 420 | // Open 't' for writing, dup it onto fd 1, and then close the original fd. 421 | /* Exercise 6.5: Your code here. (2/3) */ 422 | fd = open(t, O_WRONLY); 423 | dup(fd, 1); 424 | close(fd); 425 | break; 426 | ``` 427 | 428 | 最后是重点部分,当读到的 token 为 `|` 时,我们就需要创建一个管道,并调用 `fork` 分出一个执行命令的 Shell 进程。并将 `fork` 的返回值传给 `*rightpipe`。此后位于管道左侧的进程完成命令的解析直接返回,这样位于管道左侧的进程获得的就一定是位于管道右侧的进程的进程 id。而位于管道右侧的进程则从继续解析剩下的命令,这里通过递归调用 `parsecmd` 实现。 429 | 430 | 对于创建的管道文件,我们同样要将其重定向到标准输入输出。对于管道左侧的进程,需要重定向其标准输出;而对于管道右侧的进程,则需要重定向其标准输入。 431 | ```c 432 | case '|':; 433 | int p[2]; 434 | /* Exercise 6.5: Your code here. (3/3) */ 435 | pipe(p); 436 | *rightpipe = fork(); 437 | if (*rightpipe == 0) { 438 | dup(p[0], 0); 439 | close(p[0]); 440 | close(p[1]); 441 | return parsecmd(argv, rightpipe); 442 | } else if (*rightpipe > 0) { 443 | dup(p[1], 1); 444 | close(p[1]); 445 | close(p[0]); 446 | return argc; 447 | } 448 | break; 449 | } 450 | } 451 | 452 | return argc; 453 | } 454 | ``` 455 | 456 | 现在回到 `runcmd` 函数。首先注意此时 `runcmd` 中可能会有多个 Shell 进程并发地执行,他们都具有不同的参数数组,同时各自的 `rightpipe` 变量中都保存了位于其右侧的进程的进程 id。 457 | 458 | 首先当参数数量为 0 时,说明根本没有命令,直接返回即可。否则需要调用 spawn 函数创建对应进程,并为其传入参数。注意这里我们没有向 `spawn` 中传入 `argc` 参数,而是通过设置 `argv[argc] = 0` 来简介表示 `argv` 的大小。 459 | 460 | ```c 461 | if (argc == 0) { 462 | return; 463 | } 464 | argv[argc] = 0; 465 | 466 | int child = spawn(argv[0], argv); 467 | ``` 468 | 469 | 之后,因为我们在执行命令的 Shell 进程中打开的文件都只是为了继承给 `spawn` 创建的进程,所以此时就要关闭当前进程的所有文件 470 | ```c 471 | close_all(); 472 | ``` 473 | 474 | 调用 `spawn` 后我们处理其返回值 `child`,对于父进程,需要等待子进程,即命令进程结束后继续执行 475 | ```c 476 | if (child >= 0) { 477 | wait(child); 478 | } else { 479 | debugf("spawn %s: %d\n", argv[0], child); 480 | } 481 | ``` 482 | 483 | 最后对于存在管道通信的情况,还需要等待管道右侧的进程先结束。因为假设不等待,则在管道左侧的进程退出之后,管道右侧的进程就不能再通过管道读取信息了。 484 | ```c 485 | if (rightpipe) { 486 | wait(rightpipe); 487 | } 488 | exit(); 489 | } 490 | ``` 491 | 492 | 这样 Shell 的机制就讲完了。关于进程运行的部分我们还需要考察一下 `spawn` 函数的实现。 493 | 494 | ## 四、spawn 函数的实现 495 | `spawn` 函数位于 user/lib/spawn.c 中。还有一个可变参数的版本 `spawnl`,只是简单地调用了 `spawn` 函数。 496 | ```c 497 | int spawnl(char *prog, char *args, ...) { 498 | // Thanks to MIPS calling convention, the layout of arguments on the stack 499 | // are straightforward. 500 | return spawn(prog, &args); 501 | } 502 | ``` 503 | 504 | 接下来我们考察一下 `spawn` 函数。首先要明确 `spawn` 函数的功能是根据磁盘文件创建一个进程。那么首先要做的就是将文件内容加载到内存中。 505 | 506 | 于是我们根据传入的文件路径参数 `prog` 打开文件,并首先通过 `readn` 函数读取其文件头的信息。 507 | ```c 508 | int spawn(char *prog, char **argv) { 509 | // Step 1: Open the file 'prog' (the path of the program). 510 | // Return the error if 'open' fails. 511 | int fd; 512 | if ((fd = open(prog, O_RDONLY)) < 0) { 513 | return fd; 514 | } 515 | 516 | // Step 2: Read the ELF header (of type 'Elf32_Ehdr') from the file into 'elfbuf' using 517 | // 'readn()'. 518 | // If that fails (where 'readn' returns a different size than expected), 519 | // set 'r' and 'goto err' to close the file and return the error. 520 | int r; 521 | u_char elfbuf[512]; 522 | 523 | /* Exercise 6.4: Your code here. (1/6) */ 524 | if ((r = readn(fd, elfbuf, sizeof(Elf32_Ehdr))) < 0 || r != sizeof(Elf32_Ehdr)) { 525 | goto err; 526 | } 527 | ``` 528 | 529 | 接着我们要将文件头转换为 `Elf32_Ehdr` 结构体的格式。我们通过 `elf_from` 完成这一步骤,该函数也在 Lab3 中使用过,在转换之前检查了文件头格式的有效性。(实际上 `spawn` 函数的实现也与 Lab3 中的 `load_icode` 类似,后者各位如果还记得的话,应该知道是用于加载编译器写进操作系统的程序的。) 530 | ```c 531 | const Elf32_Ehdr *ehdr = elf_from(elfbuf, sizeof(Elf32_Ehdr)); 532 | if (!ehdr) { 533 | r = -E_NOT_EXEC; 534 | goto err; 535 | } 536 | ``` 537 | 538 | 我们从 ELF 文件头中读取了程序入口信息。(虽然不知道有什么意义,似乎又是遗留没有修改的代码。) 539 | ```c 540 | u_long entrypoint = ehdr->e_entry; 541 | ``` 542 | 543 | 接着使用系统调用 `syscall_exofork` 创建一个新的进程。注意这里不需要像 `fork` 一样创建后判断 `child` 的值来区分父子进程。因为在 `spawn` 之后的内容中我们会替换子进程的代码和数据,不会再从此处继续执行。 544 | ```c 545 | // Step 3: Create a child using 'syscall_exofork()' and store its envid in 'child'. 546 | // If the syscall fails, set 'r' and 'goto err'. 547 | u_int child; 548 | /* Exercise 6.4: Your code here. (2/6) */ 549 | child = syscall_exofork(); 550 | if (child < 0) { 551 | r = child; 552 | goto err; 553 | } 554 | ``` 555 | 556 | 随后在父进程中,调用 `init_stack` 完成子进程栈的初始化。 557 | ```c 558 | // Step 4: Use 'init_stack(child, argv, &sp)' to initialize the stack of the child. 559 | // 'goto err1' if that fails. 560 | u_int sp; 561 | /* Exercise 6.4: Your code here. (3/6) */ 562 | if ((r = init_stack(child, argv, &sp)) < 0) { 563 | goto err1; 564 | } 565 | ``` 566 | 567 | 在 `init_stack` 函数中,我们要将 `argc`、`argv` 的数据写入要初始化的进程的栈中,完成进程栈的初始化。具体来说,我们首先要按一定格式将 `argc`、`argv` 写入 `UTEMP` 页(这个地址就在 `UCOW` 的下面,用途也和 `UCOW` 那一页类似,详见 Lab4)中,再将 `UTEMP` 映射到的物理页转而映射到要初始化进程的栈的位置。 568 | 569 | 首先统计一下所有参数字符串的长度。 570 | ```c 571 | int init_stack(u_int child, char **argv, u_int *init_sp) { 572 | int argc, i, r, tot; 573 | char *strings; 574 | u_int *args; 575 | 576 | // Count the number of arguments (argc) 577 | // and the total amount of space needed for strings (tot) 578 | tot = 0; 579 | for (argc = 0; argv[argc]; argc++) { 580 | tot += strlen(argv[argc]) + 1; 581 | } 582 | ``` 583 | 584 | 计算参数所需内存是否超过了一页的大小,如果超过直接返回异常。由这部分也可以大致看出栈的结构(`argc(4)+**argv(4)+*argv[i](4*(argc+1))+sum(len(argv[i])+1)`,其中argc+1 中的 1 为表示最后一个参数的 `argv[argc] = 0`,详见 `runcmd` 函数。) 585 | ```c 586 | // Make sure everything will fit in the initial stack page 587 | if (ROUND(tot, 4) + 4 * (argc + 3) > BY2PG) { 588 | return -E_NO_MEM; 589 | } 590 | ``` 591 | 592 | 这一部分确定了字符串和指针数组的地址,并使用系统调用申请了 UTEMP 所在的页。 593 | ```c 594 | // Determine where to place the strings and the args array 595 | strings = (char *)(UTEMP + BY2PG) - tot; 596 | args = (u_int *)(UTEMP + BY2PG - ROUND(tot, 4) - 4 * (argc + 1)); 597 | 598 | if ((r = syscall_mem_alloc(0, (void *)UTEMP, PTE_D)) < 0) { 599 | return r; 600 | } 601 | ``` 602 | 603 | 这一部分复制了所有参数字符串 604 | ```c 605 | // Copy the argument strings into the stack page at 'strings' 606 | char *ctemp, *argv_temp; 607 | u_int j; 608 | ctemp = strings; 609 | for (i = 0; i < argc; i++) { 610 | argv_temp = argv[i]; 611 | for (j = 0; j < strlen(argv[i]); j++) { 612 | *ctemp = *argv_temp; 613 | ctemp++; 614 | argv_temp++; 615 | } 616 | *ctemp = 0; 617 | ctemp++; 618 | } 619 | ``` 620 | 621 | 这一部分设置了指针数组的内容 622 | ```c 623 | // Initialize args[0..argc-1] to be pointers to these strings 624 | // that will be valid addresses for the child environment 625 | // (for whom this page will be at USTACKTOP-BY2PG!). 626 | ctemp = (char *)(USTACKTOP - UTEMP - BY2PG + (u_int)strings); 627 | for (i = 0; i < argc; i++) { 628 | args[i] = (u_int)ctemp; 629 | ctemp += strlen(argv[i]) + 1; 630 | } 631 | ``` 632 | 633 | 这里代码和注释的内容似乎不符,不知为何。 634 | ```c 635 | // Set args[argc] to 0 to null-terminate the args array. 636 | ctemp--; 637 | args[argc] = (u_int)ctemp; 638 | ``` 639 | 640 | 最后设置 `argc` 的值和 `argv` 数组指针的内容 641 | ```c 642 | // Push two more words onto the child's stack below 'args', 643 | // containing the argc and argv parameters to be passed 644 | // to the child's main() function. 645 | u_int *pargv_ptr; 646 | pargv_ptr = args - 1; 647 | *pargv_ptr = USTACKTOP - UTEMP - BY2PG + (u_int)args; 648 | pargv_ptr--; 649 | *pargv_ptr = argc; 650 | ``` 651 | 652 | 返回栈帧的初始地址 653 | ```c 654 | // Set *init_sp to the initial stack pointer for the child 655 | *init_sp = USTACKTOP - UTEMP - BY2PG + (u_int)pargv_ptr; 656 | ``` 657 | 658 | 将 `UTEMP` 页映射到用户栈真正应该处于的地址,如果操作失败通过 `goto` 进行异常处理。 659 | ```c 660 | if ((r = syscall_mem_map(0, (void *)UTEMP, child, (void *)(USTACKTOP - BY2PG), PTE_D)) < 661 | 1) { 662 | goto error; 663 | } 664 | if ((r = syscall_mem_unmap(0, (void *)UTEMP)) < 0) { 665 | goto error; 666 | } 667 | 668 | return 0; 669 | 670 | error: 671 | syscall_mem_unmap(0, (void *)UTEMP); 672 | return r; 673 | } 674 | ``` 675 | 676 | 这样就完成了程序栈的初始化。让我们回到 `spawn`。接下来我们遍历整个 ELF 头的程序段,将程序段的内容读到内存中。 677 | ```c 678 | // Step 5: Load the ELF segments in the file into the child's memory. 679 | // This is similar to 'load_icode()' in the kernel. 680 | size_t ph_off; 681 | ELF_FOREACH_PHDR_OFF (ph_off, ehdr) { 682 | // Read the program header in the file with offset 'ph_off' and length 683 | // 'ehdr->e_phentsize' into 'elfbuf'. 684 | // 'goto err1' on failure. 685 | // You may want to use 'seek' and 'readn'. 686 | /* Exercise 6.4: Your code here. (4/6) */ 687 | if ((r = seek(fd, ph_off)) < 0 || (r = readn(fd, elfbuf, ehdr->e_phentsize)) < 0) { 688 | goto err1; 689 | } 690 | ``` 691 | 692 | 如果是需要加载的程序段,则首先根据程序段相对于文件的偏移得到其在内存中映射到的地址,接着就和 Lab3 的 `load_icode` 函数一样,调用 `elf_load_seg` 将程序段加载到适当的位置。 693 | ```c 694 | Elf32_Phdr *ph = (Elf32_Phdr *)elfbuf; 695 | if (ph->p_type == PT_LOAD) { 696 | void *bin; 697 | // Read and map the ELF data in the file at 'ph->p_offset' into our memory 698 | // using 'read_map()'. 699 | // 'goto err1' if that fails. 700 | /* Exercise 6.4: Your code here. (5/6) */ 701 | if ((r = read_map(fd, ph->p_offset, &bin)) < 0) { 702 | goto err1; 703 | } 704 | 705 | // Load the segment 'ph' into the child's memory using 'elf_load_seg()'. 706 | // Use 'spawn_mapper' as the callback, and '&child' as its data. 707 | // 'goto err1' if that fails. 708 | /* Exercise 6.4: Your code here. (6/6) */ 709 | if ((r = elf_load_seg(ph, bin, spawn_mapper, &child)) < 0) { 710 | goto err1; 711 | } 712 | } 713 | } 714 | close(fd); 715 | ``` 716 | 717 | 但还是要注意和 `load_icode` 的区别。此处我们设定的回调函数为 `spawn_mapper` 而非 `load_icode_mapper`。通过下面的对比可以看出,因为 `spawn_mapper` 处于用户态,所以使用了很多系统调用,同时我传入的是子进程的进程 id 而非进程控制块。 718 | ```c 719 | // user/lib/spawn.c 720 | static int spawn_mapper(void *data, u_long va, size_t offset, u_int perm, const void *src, 721 | size_t len) { 722 | u_int child_id = *(u_int *)data; 723 | try(syscall_mem_alloc(child_id, (void *)va, perm)); 724 | if (src != NULL) { 725 | int r = syscall_mem_map(child_id, (void *)va, 0, (void *)UTEMP, perm | PTE_D); 726 | if (r) { 727 | syscall_mem_unmap(child_id, (void *)va); 728 | return r; 729 | } 730 | memcpy((void *)(UTEMP + offset), src, len); 731 | return syscall_mem_unmap(0, (void *)UTEMP); 732 | } 733 | return 0; 734 | } 735 | 736 | 737 | // kern/env.c 738 | static int load_icode_mapper(void *data, u_long va, size_t offset, u_int perm, const void *src, 739 | size_t len) { 740 | struct Env *env = (struct Env *)data; 741 | struct Page *p; 742 | int r; 743 | 744 | /* Step 1: Allocate a page with 'page_alloc'. */ 745 | /* Exercise 3.5: Your code here. (1/2) */ 746 | if ((r = page_alloc(&p)) != 0) { 747 | return r; 748 | } 749 | 750 | /* Step 2: If 'src' is not NULL, copy the 'len' bytes started at 'src' into 'offset' at this 751 | * page. */ 752 | // Hint: You may want to use 'memcpy'. 753 | if (src != NULL) { 754 | /* Exercise 3.5: Your code here. (2/2) */ 755 | memcpy((void *)(page2kva(p) + offset), src, len); 756 | } 757 | 758 | /* Step 3: Insert 'p' into 'env->env_pgdir' at 'va' with 'perm'. */ 759 | return page_insert(env->env_pgdir, env->env_asid, p, va, perm); 760 | } 761 | ``` 762 | 763 | 这样就将程序加载到了新创建的进程的适当位置了。之后设定栈帧,父子进程共享 `USTACKTOP` 地址之下的数据,这一部分和 `duppage` 的操作很相似,只不过这里不共享程序部分。 764 | ```c 765 | struct Trapframe tf = envs[ENVX(child)].env_tf; 766 | tf.cp0_epc = entrypoint; 767 | tf.regs[29] = sp; 768 | if ((r = syscall_set_trapframe(child, &tf)) != 0) { 769 | goto err2; 770 | } 771 | 772 | // Pages with 'PTE_LIBRARY' set are shared between the parent and the child. 773 | for (u_int pdeno = 0; pdeno <= PDX(USTACKTOP); pdeno++) { 774 | if (!(vpd[pdeno] & PTE_V)) { 775 | continue; 776 | } 777 | for (u_int pteno = 0; pteno <= PTX(~0); pteno++) { 778 | u_int pn = (pdeno << 10) + pteno; 779 | u_int perm = vpt[pn] & ((1 << PGSHIFT) - 1); 780 | if ((perm & PTE_V) && (perm & PTE_LIBRARY)) { 781 | void *va = (void *)(pn << PGSHIFT); 782 | 783 | if ((r = syscall_mem_map(0, va, child, va, perm)) < 0) { 784 | debugf("spawn: syscall_mem_map %x %x: %d\n", va, child, r); 785 | goto err2; 786 | } 787 | } 788 | } 789 | } 790 | ``` 791 | 792 | 最后设定子进程为运行状态以将其加入进程调度队列,实现子进程的创建。 793 | ```c 794 | if ((r = syscall_set_env_status(child, ENV_RUNNABLE)) < 0) { 795 | debugf("spawn: syscall_set_env_status %x: %d\n", child, r); 796 | goto err2; 797 | } 798 | return child; 799 | ``` 800 | 801 | 在最后则是异常处理部分,可以看出在 `spawn` 函数中因为分配的资源数量较多,异常处理部分也变复杂了。这里就可以看出使用 `goto` 进行异常处理的优点了。只需要按反向的顺序写出资源的释放函数,在异常时只需要跳转到对应位置就可以穿透标签,不断释放所有的资源。这和 Lab5 中我们解释过的 `switch` 的穿透类似。 802 | ```c 803 | err2: 804 | syscall_env_destroy(child); 805 | return r; 806 | err1: 807 | syscall_env_destroy(child); 808 | err: 809 | close(fd); 810 | return r; 811 | } 812 | ``` 813 | 814 | 这样,`spawn` 函数也终于讲解完了。 815 | 816 | ## 五、管道通信的实现 817 | 最后还要讲一下管道。各位应该都清楚,管道是一种特殊的文件,它没有在磁盘中占用空间,所有的数据都存储在内存中。对于 MOS 来说,就是不需要与文件系统服务进程进行通讯,直接在内存中处理读写等等操作。 818 | 819 | 管道的相关操作位于 user/lib/pipe.c 中。创建管道的函数为 `pipe`。管道创建时总会返回两个文件描述符,用于对同一块内存的读写操作。 820 | 821 | 首先就是简单地申请文件描述符,同时申请用于表示文件内容的内存空间。另外对第二个文件描述符 `fd1` 还需要将该文件对应的内存空间映射到 `fd0` 对应的空间。这样两个文件描述符才能共享同一块物理内存。 822 | ```c 823 | int pipe(int pfd[2]) { 824 | int r; 825 | void *va; 826 | struct Fd *fd0, *fd1; 827 | 828 | /* Step 1: Allocate the file descriptors. */ 829 | if ((r = fd_alloc(&fd0)) < 0 || (r = syscall_mem_alloc(0, fd0, PTE_D | PTE_LIBRARY)) < 0) { 830 | goto err; 831 | } 832 | 833 | if ((r = fd_alloc(&fd1)) < 0 || (r = syscall_mem_alloc(0, fd1, PTE_D | PTE_LIBRARY)) < 0) { 834 | goto err1; 835 | } 836 | 837 | /* Step 2: Allocate and map the page for the 'Pipe' structure. */ 838 | va = fd2data(fd0); 839 | if ((r = syscall_mem_alloc(0, (void *)va, PTE_D | PTE_LIBRARY)) < 0) { 840 | goto err2; 841 | } 842 | if ((r = syscall_mem_map(0, (void *)va, 0, (void *)fd2data(fd1), PTE_D | PTE_LIBRARY)) < 843 | 0) { 844 | goto err3; 845 | } 846 | 847 | ``` 848 | 849 | 设定文件描述符的相关属性,最后返回。还有异常处理部分,都不再详述。 850 | ```c 851 | /* Step 3: Set up 'Fd' structures. */ 852 | fd0->fd_dev_id = devpipe.dev_id; 853 | fd0->fd_omode = O_RDONLY; 854 | 855 | fd1->fd_dev_id = devpipe.dev_id; 856 | fd1->fd_omode = O_WRONLY; 857 | 858 | debugf("[%08x] pipecreate \n", env->env_id, vpt[VPN(va)]); 859 | 860 | /* Step 4: Save the result. */ 861 | pfd[0] = fd2num(fd0); 862 | pfd[1] = fd2num(fd1); 863 | return 0; 864 | 865 | err3: 866 | syscall_mem_unmap(0, (void *)va); 867 | err2: 868 | syscall_mem_unmap(0, fd1); 869 | err1: 870 | syscall_mem_unmap(0, fd0); 871 | err: 872 | return r; 873 | } 874 | ``` 875 | 876 | 管道的数据结构实际上是一个环形队列,是通过共享内存的方式实现进程间的通信。关键的内容就在于管道的读写操作。实际上对于并发的临界区资源读写,有很多需要考虑的细节,但碍于篇幅,还是不详述了。指导书上的说明已经很详尽了,内容就到此为止吧。 877 | ```c 878 | static int _pipe_is_closed(struct Fd *fd, struct Pipe *p) { 879 | // The 'pageref(p)' is the total number of readers and writers. 880 | // The 'pageref(fd)' is the number of envs with 'fd' open 881 | // (readers if fd is a reader, writers if fd is a writer). 882 | // 883 | // Check if the pipe is closed using 'pageref(fd)' and 'pageref(p)'. 884 | // If they're the same, the pipe is closed. 885 | // Otherwise, the pipe isn't closed. 886 | 887 | int fd_ref, pipe_ref, runs; 888 | // Use 'pageref' to get the reference counts for 'fd' and 'p', then 889 | // save them to 'fd_ref' and 'pipe_ref'. 890 | // Keep retrying until 'env->env_runs' is unchanged before and after 891 | // reading the reference counts. 892 | /* Exercise 6.1: Your code here. (1/3) */ 893 | do { 894 | runs = env->env_runs; 895 | 896 | fd_ref = pageref(fd); 897 | pipe_ref = pageref(p); 898 | 899 | } while (runs != env->env_runs); 900 | 901 | return fd_ref == pipe_ref; 902 | } 903 | 904 | static int pipe_read(struct Fd *fd, void *vbuf, u_int n, u_int offset) { 905 | int i; 906 | struct Pipe *p; 907 | char *rbuf; 908 | 909 | // Use 'fd2data' to get the 'Pipe' referred by 'fd'. 910 | // Write a loop that transfers one byte in each iteration. 911 | // Check if the pipe is closed by '_pipe_is_closed'. 912 | // When the pipe buffer is empty: 913 | // - If at least 1 byte is read, or the pipe is closed, just return the number 914 | // of bytes read so far. 915 | // - Otherwise, keep yielding until the buffer isn't empty or the pipe is closed. 916 | /* Exercise 6.1: Your code here. (2/3) */ 917 | p = fd2data(fd); 918 | rbuf = (char *)vbuf; 919 | for (i = 0; i < n; i++) { 920 | while (p->p_rpos >= p->p_wpos) { 921 | if (i > 0 || _pipe_is_closed(fd, p)) { 922 | return i; 923 | } else { 924 | syscall_yield(); 925 | } 926 | } 927 | rbuf[i] = p->p_buf[p->p_rpos % BY2PIPE]; 928 | p->p_rpos++; 929 | } 930 | return n; 931 | } 932 | 933 | static int pipe_write(struct Fd *fd, const void *vbuf, u_int n, u_int offset) { 934 | int i; 935 | struct Pipe *p; 936 | char *wbuf; 937 | 938 | // Use 'fd2data' to get the 'Pipe' referred by 'fd'. 939 | // Write a loop that transfers one byte in each iteration. 940 | // If the bytes of the pipe used equals to 'BY2PIPE', the pipe is regarded as full. 941 | // Check if the pipe is closed by '_pipe_is_closed'. 942 | // When the pipe buffer is full: 943 | // - If the pipe is closed, just return the number of bytes written so far. 944 | // - If the pipe isn't closed, keep yielding until the buffer isn't full or the 945 | // pipe is closed. 946 | /* Exercise 6.1: Your code here. (3/3) */ 947 | p = fd2data(fd); 948 | wbuf = (char *)vbuf; 949 | for (i = 0; i < n; i++) { 950 | while (p->p_wpos - p->p_rpos >= BY2PIPE) { 951 | if (_pipe_is_closed(fd, p)) { 952 | return i; 953 | } else { 954 | syscall_yield(); 955 | } 956 | } 957 | p->p_buf[p->p_wpos % BY2PIPE] = wbuf[i]; 958 | p->p_wpos++; 959 | } 960 | return n; 961 | } 962 | 963 | int pipe_is_closed(int fdnum) { 964 | struct Fd *fd; 965 | struct Pipe *p; 966 | int r; 967 | 968 | // Step 1: Get the 'fd' referred by 'fdnum'. 969 | if ((r = fd_lookup(fdnum, &fd)) < 0) { 970 | return r; 971 | } 972 | // Step 2: Get the 'Pipe' referred by 'fd'. 973 | p = (struct Pipe *)fd2data(fd); 974 | // Step 3: Use '_pipe_is_closed' to judge if the pipe is closed. 975 | return _pipe_is_closed(fd, p); 976 | } 977 | ``` 978 | 979 | **(完)** 980 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BUAA-OS-2023 2 | 北航 2023 春操作系统实验课的笔记、代码。欢迎 star :) 3 | 4 | > 笔记就在 main 分支中,包含我对每次实验代码的分析整理。exercise 和 exam 的代码在其他分支中。 5 | 6 | 在我学习的过程中,感谢如下学长分享的资料。 7 | - [花叶小姐姐](https://github.com/hjc-owo/OS/tree/main) 8 | - [Cloud-Iris](https://github.com/Cloud-Iris/Iris-Library) 9 | - [rfhits](https://github.com/rfhits/Operating-System-BUAA-2021/tree/main) 10 | 11 | > 想了解更多欢迎光临[我的博客](https://wokron.github.io/)。 12 | --- 13 | **2023.10.24**: 2024 年的操作系统实验会迁移到 QEMU 上进行,同时使用 MIPS 4Kc 而不再是 R3000,以便与主流内核开发接轨。这意味着本仓库中的部分代码内容将不再适用(但其实占比很小)。不过原理性的知识总是通用的,笔记内容依然会有所帮助。 14 | 15 | **2024.02.08**: 2024 新实验环境下的最新预习教程 16 | - 《[GDB:程序的解剖术](https://wokron.github.io/posts/gdb-tutorial/)》 17 | - 《[QEMU 模拟器介绍](https://wokron.github.io/posts/qemu-introduction/)》 18 | -------------------------------------------------------------------------------- /guide-book.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wokron/BUAA-OS-2023/8c9391dbfa4c51990443db181cf5fd4a03feb5ca/guide-book.pdf --------------------------------------------------------------------------------