实验题目: Lab1
附录1:
附录2:
实验目的:
操作系统是一个软件,也需要通过某种机制加载并运行它。
在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。
为此,我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader,为启动操作系统ucore做准备。
lab1提供了一个非常小的bootloader和ucore OS,整个bootloader执行代码小于512个字节,这样才能放到硬盘的主引导扇区中。
通过分析和实现这个bootloader和ucore OS,读者可以了解到:
- 计算机原理
- CPU的编址与寻址: 基于分段机制的内存管理
- CPU的中断机制
- 外设:串口/并口/CGA,时钟,硬盘
- Bootloader软件
- 编译运行bootloader的过程
- 调试bootloader的方法
- PC启动bootloader的过程
- ELF执行文件的格式和加载
- 外设访问:读硬盘,在CGA上显示字符串
- ucore OS软件
- 编译运行ucore OS的过程
- ucore OS的启动过程
- 调试ucore OS的方法
- 函数调用关系:在汇编级了解函数调用栈的结构和处理过程
- 中断管理:与软件相关的中断处理
- 外设管理:时钟
实验环境
操作系统: Ubuntu 20.04 LTS
虚拟机: Hyper-V
依赖: build-essential git qemu-system-x86 gdb make diffutils lib32z1
实验内容及操作步骤
lab1中包含一个bootloader和一个OS。这个bootloader可以切换到X86保护模式,能够读磁盘并加载ELF执行文件格式,并显示字符。
而这lab1中的OS只是一个可以处理时钟中断和显示字符的幼儿园级别OS。
练习一:理解通过make生成执行文件的过程
1. 操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
先了解关于 Make 的用法:
target ... : prerequisites ...
command
...
...
target即一个目标文件(object file、执行文件、label)。
prerequisites就是,要生成那个target所需要的文件或是目标。
command也就是make需要执行的命令(任意的shell命令)。
这是一个文件的依赖关系,也就是说,target这一个或多个的目标文件依赖于prerequisites中的文件,其生成规则定义在command中。如果prerequisites中有一个以上的文件比target文件要新,那么command所定义的命令就会被执行。
首先找到相对应的文件 Makefile
发现注释里面的一段创建 ucore.img
的代码:
# create ucore.img
UCOREIMG := $(call totarget,ucore.img)
$(UCOREIMG): $(kernel) $(bootblock)
$(V)dd if=/dev/zero of=$@ count=10000
$(V)dd if=$(bootblock) of=$@ conv=notrunc
$(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc
$(call create_target,ucore.img)
注意到第四行**$(UCOREIMG): $(kernel) $(bootblock)
**,可知要创建 ucore.img ,先得创建 kernel 跟 bootblock 。
这里是结合 make_v 分析得到的。
先找到生成 kernel 的代码:
# create kernel target
kernel = $(call totarget,kernel)
$(kernel): tools/kernel.ld
$(kernel): $(KOBJS)
@echo + ld $@
$(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
@$(OBJDUMP) -S $@ > $(call asmfile,kernel)
@$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)
$(call create_target,kernel)
根据 make_v 中对于 kernel 部分的分析可知:该代码是编译 libs 和 kern 目录下所有的 .c 和 .S 文件,生成 .o 文件,并链接得到 bin/kernel 文件。
然后是生成 bootblock 的代码:
# create bootblock
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))
bootblock = $(call totarget,bootblock)
$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
@echo + ld $@
$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
@$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)
@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)
$(call create_target,bootblock)
发现里面有提及到 sign ,找到相关代码:
# create 'sign' tools
$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)
根据 make_v 中对于 bootblock 部分的分析可知:该代码是编译 boot 目录下所有的 .c 和 .S 文件,生成 .o 文件,并链接得到 bin/bootblock.out 文件;
利用 bin/sign 工具将 bin/bootblock.out 文件转化为 512 字节的 bin/bootblock 文件,并将 bin/bootblock 的最后两个字节设置为 **0x55AA ** 。
字节设置在 tools/sign.c 代码中:
// line 31 buf[510] = 0x55; buf[511] = 0xAA; FILE *ofp = fopen(argv[2], "wb+"); size = fwrite(buf, 1, 512, ofp); if (size != 512) { fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size); return -1; }
然后就是生成 ucore.img,包括:
- 创建大小为 10000 个块的 ucore.img,初始化为 0,每个块为 512 字节
- 把 bootblock 的内容写到第一个块
- 从第二个块开始写 kernel 的内容
2.一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
注意到 bin.bootlock 是根据 sign 规范生成的,而这边的规范则来源于 tools/sign.c 代码中:
// line 31
buf[510] = 0x55;
buf[511] = 0xAA;
FILE *ofp = fopen(argv[2], "wb+");
size = fwrite(buf, 1, 512, ofp);
if (size != 512) {
fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
return -1;
}
有两个要点:
- 最后两个字节为 0x55AA
- 大小为 512 字节
练习二:使用qemu执行并调试lab1中的软件
1. 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
修改 tools/gdbinit:
set architecture i8086
target remote :1234
然后执行命令:
make debug
# 下面为 gdb 中执行
si
x /2i $pc
得到如下结果:
2. 在初始化位置0x7c00设置实地址断点,测试断点正常。
修改 tools/gdbinit:
set architecture i8086
target remote :1234
b* 0x7c00
c
然后执行命令:
make debug
# 下面为 gdb 中执行
x/2i $pc
得到如下结果:
3. 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
在上面的基础上(改写tools/gdbinit),执行命令:
make debug
# 下面为 gdb 中执行
x /10i $pc
得到结果:
查看boot/bootasm.S文件:
// Line 13:
.globl start
start:
.code16 # Assemble for 16-bit mode
cli # Disable interrupts
cld # String operations increment
# Set up the important data segment registers (DS, ES, SS).
xorw %ax, %ax # Segment number zero
movw %ax, %ds # -> Data Segment
movw %ax, %es # -> Extra Segment
movw %ax, %ss # -> Stack Segment
查看obj/bootblock.asm文件:
# Line 10:
.globl start
start:
.code16 # Assemble for 16-bit mode
cli # Disable interrupts
7c00: fa cli
cld # String operations increment
7c01: fc cld
# Set up the important data segment registers (DS, ES, SS).
xorw %ax, %ax # Segment number zero
7c02: 31 c0 xor %eax,%eax
movw %ax, %ds # -> Data Segment
7c04: 8e d8 mov %eax,%ds
movw %ax, %es # -> Extra Segment
7c06: 8e c0 mov %eax,%es
movw %ax, %ss # -> Stack Segment
7c08: 8e d0 mov %eax,%ss
从上面结果可以看出bootasm.S文件代码和bootblock.asm是一样的,单步跟踪代码也是跟前两者一样的。
4. 自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
修改 tools/gdbinit:
set architecture i8086
target remote :1234
然后执行命令:
make debug
# 下面为 gdb 中执行
b *0x7c12
x /2i $pc
得到如下结果:
说明断点正常。
练习3:分析bootloader进入保护模式的过程。
首先查看 lab1/boot/bootasm.S 代码,根据分析可知该过程有如下几个阶段:
- 清理环境
- 开启A20
- 进入保护模式
- 通过将cr0寄存器PE位(第0位)置1开启了保护模式
- 通过长跳转更新cs的基地址
- 设置段寄存器,并建立堆栈
- 转到保护模式完成,进入 bootmain 方法
练习4:分析bootloader加载ELF格式的OS的过程
bootloader 是如何读取硬盘扇区的?
读取扇区的流程,根据指导书[1]的内容可知有以下步骤:
等待磁盘准备好
发出读取扇区的命令
等待磁盘准备好
把磁盘扇区数据读到指定内存
通过查看 boot/bootmain.c ,对比上面步骤来进行分析,发现其中的函数:
- waitdisk
- readsect
- readseg
应该对应的就是等待磁盘准备、读取扇区、读取内存。
读取扇区
// Line 43:
/* readsect - read a single sector at @secno into @dst */
static void
readsect(void *dst, uint32_t secno) {
// wait for disk to be ready
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, secno & 0xFF);
outb(0x1F4, (secno >> 8) & 0xFF);
outb(0x1F5, (secno >> 16) & 0xFF);
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// wait for disk to be ready
waitdisk();
// read a sector
insl(0x1F0, dst, SECTSIZE / 4);
}
可以看到等待磁盘准备部分用的是**waitdisk()函数,而发出命令则对应指导书[1:1]所给的 LBA 模式,读取到指定内存则用的是insl()**函数。
先分析waitdisk()。
磁盘IO地址和对应功能
第6位:为1=LBA模式;0 = CHS模式 第7位和第5位必须为1
IO地址 功能 0x1f0 读数据,当0x1f7不为忙状态时,可以读。 0x1f2 要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区 0x1f3 如果是LBA模式,就是LBA参数的0-7位 0x1f4 如果是LBA模式,就是LBA参数的8-15位 0x1f5 如果是LBA模式,就是LBA参数的16-23位 0x1f6 第0~3位:如果是LBA模式就是24-27位 第4位:为0主盘;为1从盘 0x1f7 状态和命令寄存器。操作时先给命令,再读取,如果不是忙状态就从0x1f0端口读数据
等待磁盘准备
// Line 36:
/* waitdisk - wait for disk ready */
static void
waitdisk(void) {
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}
里面的0x1F7
根据指导书[1:2]可知对应的是状态和命令寄存器。
读取到内存
// Line 63:
/* *
* readseg - read @count bytes at @offset from kernel into virtual address @va,
* might copy more than asked.
* */
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
uintptr_t end_va = va + count;
// round down to sector boundary
va -= offset % SECTSIZE;
// translate from bytes to sectors; kernel starts at sector 1
uint32_t secno = (offset / SECTSIZE) + 1;
// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.
for (; va < end_va; va += SECTSIZE, secno ++) {
readsect((void *)va, secno);
}
}
可以看到 readseg() 简单包装了 readsect() ,可以从设备读取任意长度的内容。
bootloader 是如何加载ELF格式的OS的?
ELF格式,根据指导书[2]的内容可知其定义:
struct elfhdr {
uint magic; // must equal ELF_MAGIC
uchar elf[12];
ushort type;
ushort machine;
uint version;
uint entry; // 程序入口的虚拟地址
uint phoff; // program header 表的位置偏移
uint shoff;
uint flags;
ushort ehsize;
ushort phentsize;
ushort phnum; //program header表中的入口数目
ushort shentsize;
ushort shnum;
ushort shstrndx;
};
通过查看 boot/bootmain.c ,对比上面步骤来进行分析,发现其中的函数**bootmain()**所做的便是对于 ELF 格式的读取跟操作:
// Line 85:
/* bootmain - the entry of bootloader */
void
bootmain(void) {
// 读取 ELF 的头部
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
// 判断是否是合法的 ELF 文件
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}
struct proghdr *ph, *eph;
// 将 ELF 描述表的头地址存在 ph
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
// 按照描述表将 ELF 文件数据载入内存
for (; ph < eph; ph ++) {
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
// 根据 ELF 头部储存的入口信息,找到内核入口
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
/* do nothing */
while (1);
}
简言之:
- 从硬盘读了8个扇区数据到内存
0x10000
处,并把这里强制转换成elfhdr
使用; - 校验
e_magic
字段; - 根据偏移量分别把程序段的数据读取到内存中
练习5:实现函数调用堆栈跟踪函数
先看看labcodes/lab1/kern/debug/kdebug.c里面需要添加的代码:
// Line 291:
void
print_stackframe(void) {
/* LAB1 YOUR CODE : STEP 1 */
/* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);
* (2) call read_eip() to get the value of eip. the type is (uint32_t);
* (3) from 0 .. STACKFRAME_DEPTH
* (3.1) printf value of ebp, eip
* (3.2) (uint32_t)calling arguments [0..4] = the contents in address (uint32_t)ebp +2 [0..4]
* (3.3) cprintf("\n");
* (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.
* (3.5) popup a calling stackframe
* NOTICE: the calling funciton's return addr eip = ss:[ebp+4]
* the calling funciton's ebp = ss:[ebp]
*/
}
根据注释我们可以知道要做的任务:
调用read_ebp()来拿到ebp的值,类型是uint32_t
调用read_eip()来拿到eip的值,类型是uint32_t
从0到栈帧深度
- 打印ebp,eip
- 把在(uint32_t)ebp +2 [0…4]中的内容,赋给调用参数的[0…4]
- 直接cprintf(“\n”);
- 调用print_debuginfo(eip-1),打印C调用的函数名,行号等
- 弹出调用的栈帧
注意:调用函数返回的addr的eip = ss:[ebp+4],ebp = ss:[ebp]
目标输出:
ebp:0x00007b28 eip:0x00100992 args:0x00010094 0x00010094 0x00007b58 0x00100096 kern/debug/kdebug.c:305: print_stackframe+22 ebp:0x00007b38 eip:0x00100c79 args:0x00000000 0x00000000 0x00000000 0x00007ba8 kern/debug/kmonitor.c:125: mon_backtrace+10 ebp:0x00007b58 eip:0x00100096 args:0x00000000 0x00007b80 0xffff0000 0x00007b84 kern/init/init.c:48: grade_backtrace2+33 ebp:0x00007b78 eip:0x001000bf args:0x00000000 0xffff0000 0x00007ba4 0x00000029 kern/init/init.c:53: grade_backtrace1+38 ebp:0x00007b98 eip:0x001000dd args:0x00000000 0x00100000 0xffff0000 0x0000001d kern/init/init.c:58: grade_backtrace0+23 ebp:0x00007bb8 eip:0x00100102 args:0x0010353c 0x00103520 0x00001308 0x00000000 kern/init/init.c:63: grade_backtrace+34 ebp:0x00007be8 eip:0x00100059 args:0x00000000 0x00000000 0x00000000 0x00007c53 kern/init/init.c:28: kern_init+88 ebp:0x00007bf8 eip:0x00007d73 args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 <unknow>: -- 0x00007d72 –
根据注释参考指导书[3]感觉可以直接开写了:
// Line 291: void print_stackframe(void) { // read_ebp() && read_eip 直接看函数调用就行 uint32_t ebp = read_ebp(); uint32_t eip = read_eip(); // loop int i,j; for(i = 0,i < STACKFRAME_DEPTH && ebp != 0;i++){ // format 仿照 print_kerninfo() 写的 cprintf("ebp:0x%08x",ebp); cprintf("eip:0x%08x",eip); cprintf("args:"); // entry args uint32_t *args= (uint32_t*)ebp +2; // inner loop for(j = 0; j < 4; j++){ cprintf("0x%08x",args[j]); } cprintf("\n"); print_debuginfo(eip-1); // pop 更新数据 eip = ((uint32_t *)ebp)[1]; ebp = ((uint32_t *)ebp)[0]; } }
写完之后跑一下看看:
可以看到是符合题意的。
练习6:完善中断初始化和处理
1. 中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
根据指导书[4]中关于中断描述符表的描述,发生中断后,x86根据终端去IDT中查找相应的描述符,并跳转到描述符指向的中断处理例程执行。
进行中断初始化和处理,就相当于设置IDT中的描述符和描述符指向的中断处理例程。
下面的图是 IDT 的描述结构:
从labcodes/lab1/kern/mm/mmu.h的代码中,我们可以找到段的定义。
// Line 49:
/* Gate descriptors for interrupts and traps */
struct gatedesc {
unsigned gd_off_15_0 : 16; // low 16 bits of offset in segment
unsigned gd_ss : 16; // segment selector
unsigned gd_args : 5; // # args, 0 for interrupt/trap gates
unsigned gd_rsv1 : 3; // reserved(should be zero I guess)
unsigned gd_type : 4; // type(STS_{TG,IG32,TG32})
unsigned gd_s : 1; // must be 0 (system)
unsigned gd_dpl : 2; // descriptor(meaning new) privilege level
unsigned gd_p : 1; // Present
unsigned gd_off_31_16 : 16; // high bits of offset in segment
};
总的加起来是64位也就是8字节,即中断向量表的一个表项占用8字节,其中2-3字节是段选择子,0-1字节和6-7字节拼成位移,两者联合便是中断处理程序的入口地址。
2. 请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
先看看要求填的代码:
/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
/* LAB1 YOUR CODE : STEP 2 */
/* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
* All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
* __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
* (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
* You can use "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
* (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
* Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
* (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
* You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
* Notice: the argument of lidt is idt_pd. try to find it!
*/
}
不难发现注释里面的:
You can use "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
you can use SETGATE macro to setup each item of IDT
the argument of lidt is idt_pd.
翻了翻代码,找到了SETGATE的实现:
labcodes/lab1/kern/mm/mmu.h : Line 71
#define SETGATE(gate, istrap, sel, off, dpl) { \
(gate).gd_off_15_0 = (uint32_t)(off) & 0xffff; \
(gate).gd_ss = (sel); \
(gate).gd_args = 0; \
(gate).gd_rsv1 = 0; \
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).gd_s = 0; \
(gate).gd_dpl = (dpl); \
(gate).gd_p = 1; \
(gate).gd_off_31_16 = (uint32_t)(off) >> 16; \
}
以及相关宏定义:
/* global segment number */
#define SEG_KTEXT 1
#define SEG_KDATA 2
#define SEG_UTEXT 3
#define SEG_UDATA 4
#define SEG_TSS 5
/* global descriptor numbers */
#define GD_KTEXT ((SEG_KTEXT) << 3) // kernel text
#define GD_KDATA ((SEG_KDATA) << 3) // kernel data
#define GD_UTEXT ((SEG_UTEXT) << 3) // user text
#define GD_UDATA ((SEG_UDATA) << 3) // user data
#define GD_TSS ((SEG_TSS) << 3) // task segment selector
#define DPL_KERNEL (0)
#define DPL_USER (3)
#define KERNEL_CS ((GD_KTEXT) | DPL_KERNEL)
#define KERNEL_DS ((GD_KDATA) | DPL_KERNEL)
#define USER_CS ((GD_UTEXT) | DPL_USER)
#define USER_DS ((GD_UDATA) | DPL_USER)
idt_init函数的功能是初始化IDT表。
IDT表中每个元素均为门描述符,记录一个中断向量的属性,包括中断向量对应的中断处理函数的段选择子/偏移量、门类型(是中断门还是陷阱门)、DPL等。
因此,初始化IDT表实际上是初始化每个中断向量的这些属性。
有了以上的铺垫,代码便能比较轻松的写出来了。
/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
extern uintptr_t __vectors[];
int i;
for(i=0; i<sizeof(idt)/sizeof(struct gatedesc); i++){
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
lidt(&idt_pd);
}
3. 请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
看看labcodes/lab1/kern/trap/trap.c中的相关代码:
case IRQ_OFFSET + IRQ_TIMER:
/* LAB1 YOUR CODE : STEP 3 */
/* handle the timer interrupt */
/* (1) After a timer interrupt, you should record this event using a global variable (increase it), such as ticks in kern/driver/clock.c
* (2) Every TICK_NUM cycle, you can print some info using a funciton, such as print_ticks().
* (3) Too Simple? Yes, I think so!
*/
break;
注释翻译如下:
- 计时器中断后,您应该使用全局变量记录该事件(增加),例如,kern / driver / clock.c中的滴答声
- 在每个TICK_NUM周期中,您都可以使用诸如print_ticks()之类的函数来打印一些信息。
- 太简单了?我想也是!
嘛,根据注释写还是不难的。
case IRQ_OFFSET + IRQ_TIMER:
ticks++;
if(ticks%TICK_NUM==0){
print_ticks();
}
break;
写完跑了看看:
完工!
实验结果及分析
经过阅读文档以及网上查阅资料,能够完成前面六个练习,但对于第七个练习还是感觉有心无力,实验结果已经在上面的实验过程分析中有所包含,便不再提及。
收获与体会
经过这次实验,对于操作系统 ucore 的启动有了更加深入的认识,了解到了诸如 ELF 等之前没有了解的或者对于之前了解过的磁盘读取更为深入,感觉对于整个操作系统有了更为清晰客观的认识。