最近一个月已经做过了OS实验的内核启动和内存管理两个lab,也学习了OS理论课的相关知识。然而在面对实验课给出的操作系统代码时仍然感到比较茫然,对于课下测试的要求也仍有些不知所措。因此决定在此梳理一下操作系统实验的核心代码,顺便整理一下相关的知识点,以期对操作系统有一个更清晰的了解。
首先,Lab1的文件树如下:
1 . 2 ├── boot 3 │ ├── Makefile 4 │ └── start.S 5 ├── drivers 6 │ ├── gxconsole 7 │ │ ├── console.c 8 │ │ ├── dev_cons.h 9 │ │ └── Makefile 10 │ └── Makefile 11 ├── gxemul 12 │ ├── elfinfo 13 │ ├── r3000 14 │ ├── r3000_test 15 │ └── test 16 ├── include 17 │ ├── asm 18 │ │ ├── asm.h 19 │ │ ├── cp0regdef.h 20 │ │ └── regdef.h 21 │ ├── asm-mips3k 22 │ │ ├── asm.h 23 │ │ ├── cp0regdef.h 24 │ │ └── regdef.h 25 │ ├── env.h 26 │ ├── error.h 27 │ ├── kclock.h 28 │ ├── mmu.h 29 │ ├── pmap.h 30 │ ├── printf.h 31 │ ├── print.h 32 │ ├── queue.h 33 │ ├── sched.h 34 │ ├── stackframe.h 35 │ ├── trap.h 36 │ └── types.h 37 ├── include.mk 38 ├── init 39 │ ├── init.c 40 │ ├── main.c 41 │ └── Makefile 42 ├── lib 43 │ ├── Makefile 44 │ ├── printBackUp 45 │ ├── print.c 46 │ └── printf.c 47 ├── Makefile 48 ├── readelf 49 │ ├── kerelf.h 50 │ ├── main.c 51 │ ├── Makefile 52 │ ├── readelf.c 53 │ ├── testELF 54 │ └── types.h 55 └── tools 56 └── scse0_3.lds
Makefile与Linker Script
顶层就是一个Makefile和它引用的include文件,其定义了vmlinux虚拟机的编译选项和一些其他的命令,例如clean等。比较值得注意的是其中定义的linker script:
1 /* 2 * ./tools/scse0_3.lds 3 */ 4 5 OUTPUT_ARCH(mips) 6 7 ENTRY(_start) 8 9 SECTIONS 10 { 11 . = 0x80010000; 12 .text : {*(.text)} 13 .data : {*(.data)} 14 .bss : {*(.bss)} 15 16 end = . ; 17 }
linker script是指导链接器在链接时控制可执行文件地址空间布局的脚本。其中的OUTPUT_ARCH(mips)
指定了输出的程序在MIPS架构的CPU上运行,ENTRY(_start)
指定了虚拟机的入口函数为_start
(这是一个汇编函数,定义在./boot/start.S
中),而SECTIONS
将程序的各个段定位到指定的位置(.text
对应代码段,.data
对应数据段,.bss
即Block Standard by Symbol对应未初始化的全局和静态变量段)。最后的end = .
是一个普通赋值语句,其中的.
是定位器,每定位一段之后其数值自增段的长度,因此此处是将end
赋值为了0x80010000+text、data、bss三段的长度之和,在内存管理的时候这个end还有用,这里不再赘述了。除了以上的功能之外,貌似linker script中还可以进行一些更复杂的操作,不过这里没有遇到就不再过多展开了。
关于Makefile的其他细节应该没什么了,不过可以利用Makefile定义一些方便的操作,比如将运行虚拟机的那一段指令定义为make run
,可以节约些许时间。
boot的汇编部分
在vmlinux的系统启动时,首先进入_start
函数,进行设备状态初始化、创建堆栈等操作。这个汇编函数定义在./boot/start.S
中:
1 # ./boot/start.S 2 3 #include4 #include 5 #include 6 7 .data 8 .globl mCONTEXT 9 mCONTEXT: 10 .word 0 11 .globl delay 12 delay: 13 .word 0 14 .globl tlbra 15 tlbra: 16 .word 0 17 .section .data.stk 18 KERNEL_STACK: 19 .space 0x8000 20 21 .text 22 LEAF(_start) 23 24 .set mips2 25 .set reorder 26 27 /* Disable interrupts */ 28 mtc0 zero, CP0_STATUS 29 30 /* Disable watch exception. */ 31 mtc0 zero, CP0_WATCHLO 32 mtc0 zero, CP0_WATCHHI 33 34 /* disable kernel mode cache */ 35 mfc0 t0, CP0_CONFIG 36 and t0, ~0x7 37 ori t0, 0x2 38 mtc0 t0, CP0_CONFIG 39 40 /* set up stack */ 41 li sp, 0x80400000 42 li t0,0x80400000 43 sw t0,mCONTEXT 44 45 /* jump to main */ 46 jal main 47 nop 48 49 loop: 50 j loop 51 nop 52 END(_start)
在这个文件中首先定义了三个全局变量、定义了数据段与栈空间,然后便是_start
函数。_start
函数设置了CP0状态、禁用了内核缓存、设置了栈空间后跳转到了C语言的main
函数。具体的操作在代码的注释中已经标注出来了,其实都是通过写入寄存器完成的。这个文件还引入了三个与汇编有关的头文件,其中cp0regdef.h
与regdef.h
分别定义了协处理器和处理器的寄存器名称与用到的常量,可以略过。比较有趣的是asm.h
这个头文件,它定义了几个与汇编相关的函数宏:
1 /* 2 * asm.h: Assembler macros to make things easier to read. 3 */ 4 5 #include "regdef.h" 6 #include "cp0regdef.h" 7 8 /* 9 * LEAF - declare leaf routine 10 */ 11 #define LEAF(symbol) \ 12 .globl symbol; \ 13 .align 2; \ 14 .type symbol,@function; \ 15 .ent symbol,0; \ 16 symbol: .frame sp,0,ra 17 18 /* 19 * NESTED - declare nested routine entry point 20 */ 21 #define NESTED(symbol, framesize, rpc) \ 22 .globl symbol; \ 23 .align 2; \ 24 .type symbol,@function; \ 25 .ent symbol,0; \ 26 symbol: .frame sp, framesize, rpc 27 28 29 /* 30 * END - mark end of function 31 */ 32 #define END(function) \ 33 .end function; \ 34 .size function,.-function 35 36 #define EXPORT(symbol) \ 37 .globl symbol; \ 38 symbol: 39 40 #define FEXPORT(symbol) \ 41 .globl symbol; \ 42 .type symbol,@function; \ 43 symbol:
首先其定义了leaf routine与nested routine,后者是嵌套过程,前者直译是“叶过程”,叶过程不调用其他过程,而嵌套过程会调用其他过程。这两者的区别就在于是否用到堆栈,叶过程因为不嵌套调用所以用不到堆栈。之所以这样命名,也许是在类比数据结构中的叶节点,叶节点没有子树对应于叶过程没有子过程。由于其中用到了数个手册中没有的directives,实现原理暂时不明。除了这两个之外还定义了函数结束标记、全局变量与全局函数标记,这三者的实现原理在代码中的体现比较清晰。
汇编部分执行结束后跳转到的main()
定义在./init/main.c
,不过由于lab1的main()
只是打印了几句话,所以略过这一部分。在main()
中调用mips_init()
后,boot的过程就算结束了。
printf()相关部分
除了boot相关的代码,lab1的另一个比较重要的部分便是与printf()
相关的代码,并且补全printf()
相关函数也是lab1课下的任务之一,可以说这一部分是之后所有评测的基础(如果print写错的话后边就gg了)。(其实lab1还有一个readelf
子程序,这个部分的头文件注释已经很清晰了,而且与整个系统的其他部分无关,所以也略过这一部分。)
printf()函数的定义位于./lib/printf.c中,其依赖关系如下图所示:
上图中的stdarg.h
为C标准库,用于支持可变参数的接收,其他文件均包含在lab1的项目源代码中。printf.c代码如下:
1 /* 2 * ./lib/printf.c 3 */ 4 5 #include6 #include 7 #include 8 9 void printcharc(char ch); 10 11 void halt(void); 12 13 static void myoutput(void *arg, char *s, int l) { 14 int i; 15 // special termination call 16 if ((l == 1) && (s[0] == '\0')) return; 17 18 for (i = 0; i < l; i++) { 19 printcharc(s[i]); 20 if (s[i] == '\n') printcharc('\n'); 21 } 22 } 23 24 void printf(char *fmt, ...) { 25 va_list ap; 26 va_start(ap, fmt); 27 lp_Print(myoutput, 0, fmt, ap); 28 va_end(ap); 29 } 30 31 void _panic(const char *file, int line, const char *fmt, ...) { 32 va_list ap; 33 34 va_start(ap, fmt); 35 printf("panic at %s:%d: ", file, line); 36 lp_Print(myoutput, 0, (char *) fmt, ap); 37 printf("\n"); 38 va_end(ap); 39 40 for (;;); 41 }
熟悉的printf()
就定义在了这里,不过可以看到,它并没有直接实现解析格式串和打印的功能,而是定义了一个可变参数列表,并将其与符号串以及myoutput()
的函数指针一同传入了另一个lp_print()
函数中。这个lp_print()
函数也就是要我们来实现的函数。与va即variable arguments有关的定义在stdarg.h
中(如下),va_list、va_start、va_end分别对应ap(指向变参的指针)的声明、初始化与析构,每次调用va_arg(ap, type)
,从参数列表中返回一个类型为type的参数,具体的用法可以自行查阅。
1 // 2 // stdarg.h 3 // 4 // Copyright (c) Microsoft Corporation. All rights reserved. 5 // 6 // The C Standard Libraryheader. 7 // 8 #pragma once 9 #define _INC_STDARG 10 11 #include12 13 _CRT_BEGIN_C_HEADER 14 15 #define va_start __crt_va_start 16 #define va_arg __crt_va_arg 17 #define va_end __crt_va_end 18 #define va_copy(destination, source) ((destination) = (source)) 19 20 _CRT_END_C_HEADER
传的myoutput()函数指针是在./drivers/gxconsole/console.c中函数的指针,这个文件的内容如下:
1 /* 2 * ./drivers/gxconsole/console.c 3 */ 4 5 #include "dev_cons.h" 6 7 /* Note: The ugly cast to a signed int (32-bit) causes the address to be 8 sign-extended correctly on MIPS when compiled in 64-bit mode */ 9 #define PHYSADDR_OFFSET ((signed int)0x80000000) 10 11 #define PUTCHAR_ADDRESS (PHYSADDR_OFFSET + \ 12 DEV_CONS_ADDRESS + DEV_CONS_PUTGETCHAR) 13 #define HALT_ADDRESS (PHYSADDR_OFFSET + \ 14 DEV_CONS_ADDRESS + DEV_CONS_HALT) 15 16 void printcharc(char ch) { 17 *((volatile unsigned char *) PUTCHAR_ADDRESS) = ch; 18 } 19 20 void halt(void) { 21 *((volatile unsigned char *) HALT_ADDRESS) = 0; 22 } 23 24 void printstr(char *s) { 25 while (*s) printcharc(*s++); 26 }
可以看出,这个打印字符的函数,其实现方式还是向某个特定的地址写入一个字符,具体的数值定义在./drivers/gxconsole/dev_cons.h中,不再介绍了。
除了printf()外,printf.c中还定义了一个_panic()函数,panic机制是Linux系统中当内核运行出现问题时,终止系统并向屏幕打印错误日志的一种机制,这里是一种简易的实现,即先打印错误信息再进入一个死循环来实现功能。值得注意的是,这个函数在头文件中被__attribute__((noreturn))修饰为可以没有返回值,并封装成了panic()函数宏以供使用。
__attribute__((noreturn))作用:定义有返回值的函数时,而实际情况有可能没有返回值,此时编译器会报错。加上attribute((noreturn))则可以很好的处理类似这种问题。
用法:__attribute__((noreturn))
例子:
1 void __attribute__((noreturn)) onExit(); 2 3 int test(int state) { 4 if (state == 1) { 5 onExit(); 6 } else { 7 return 0; 8 } 9 }
了解了以上知识后,便可以补全print.c中的相关函数。因为涉及剧透并且这个文件中没有过于复杂或难以理解的代码,因此不在此处展开print.c的相关分析了。值得注意的是,在其对应的头文件中,定义了buffer的最大长度为80,并且没有发现当buffer长度超过最大限度时会怎样处理,因此当使用printf()打印过长的参数时,可能会出现问题。
至此,Lab1部分的代码就已经梳理得差不多了。本文没有介绍过多有关操作系统的知识(理论课和指导书已经讲解得很透彻了),而是从语言层面上分析了vmlinux小操作系统的代码,分析后感觉思路清晰了不少,希望能够对以后的学习有所帮助(并防止返校之后的课上测试受害)。