Lab1实验报告

Part1. 思考题

Thinking 1.1 编译和 objdump

尝试分别使用实验环境中的原生 x86 工具链(gcc、ld、readelf、objdump 等)和 MIPS 交叉编译工具链(带有mips-linux-gnu- 前缀,如 mips-linux-gnu-gcc、mips-linux-gnu-ld),重复其中的编译和解析过程,观察相应的结果,并解释其中向objdump传入的参数的含义。

首先通过阅读附录中的编译链接详解,了解编译链接的过程,尝试使用原生 x86 工具链和 MIPS 交叉编译工具链分别编译链接程序。

原生 x86 工具链编译链接过程:
  1. 编写一个简单的程序 hello.c,内容如下:
1
2
3
4
5
6
#include <stdio.h>
int main()
{
printf("Hello, World!\n");
return 0;
}
  1. 只进行预处理,生成预处理后的代码:
1
gcc -E hello.c -o hello.i

查看预处理后的代码,结果如下:

1
2
3
4
5
6
7
8
9
10
... //前略
extern int printf (const char *__restrict __format, ...);
... //后略
# 2 "hello.c" 2

# 2 "hello.c"
int main () {
printf("hello world!\n");
return 0;
}

可以看到,预处理后的代码将 #include <stdio.h> 替换为了 stdio.h 文件的内容,并且将 printf 函数声明为 extern,表示该函数在其他地方定义,说明并没有 printf 函数的具体实现。

  1. 只编译不链接,生成汇编代码并进行反汇编:
1
2
gcc -c hello.c
objdump -DS hello.o > think1.txt

查看反汇编后的代码,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
hello.o:     文件格式 elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # f <main+0xf>
f: 48 89 c7 mov %rax,%rdi
12: b8 00 00 00 00 mov $0x0,%eax
17: e8 00 00 00 00 call 1c <main+0x1c> //没有填入具体的地址
1c: b8 00 00 00 00 mov $0x0,%eax
21: 5d pop %rbp
22: c3 ret
... //后略

可以注意到,call 指令的地址并没有被填入具体的地址,而是用 00 00 00 00 表示,说明 printf 函数的具体实现仍然不在程序中。

  1. 编译并链接,生成可执行文件并进行反汇编:
1
2
gcc hello.c -o hello
objdump -DS hello > think2.txt

查看反汇编后的代码,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

hello: 文件格式 elf64-x86-64

...//前略
Disassembly of section .plt.sec:

0000000000001050 <printf@plt>:
1050: f3 0f 1e fa endbr64
1054: ff 25 76 2f 00 00 jmp *0x2f76(%rip) # 3fd0 <printf@GLIBC_2.2.5>
105a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
... // 略

0000000000001149 <main>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 48 8d 05 ac 0e 00 00 lea 0xeac(%rip),%rax # 2004 <_IO_stdin_used+0x4>
1158: 48 89 c7 mov %rax,%rdi
115b: b8 00 00 00 00 mov $0x0,%eax
1160: e8 eb fe ff ff call 1050 <printf@plt>
1165: b8 00 00 00 00 mov $0x0,%eax
116a: 5d pop %rbp
116b: c3 ret
...//后略

我们注意到,call 指令的地址已经被填入具体的地址1050,也就是被标记为printf@plt的地址,说明printf函数的实现就在这个地址处。

MIPS 交叉编译工具链编译过程
  1. 只进行预处理
1
mips-linux-gnu-gcc -E hello.c -o hello.i

结果同上。

  1. 只进行编译,生成汇编代码并进行反汇编
1
2
mips-linux-gnu-gcc -c hello.c
mips-linux-gnu-objdump -DS hello.o > think1.txt

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
hello.o:     文件格式 elf32-tradbigmips

Disassembly of section .text:

00000000 <main>:
0: 27bdffe0 addiu sp,sp,-32
4: afbf001c sw ra,28(sp)
8: afbe0018 sw s8,24(sp)
c: 03a0f025 move s8,sp
10: 3c1c0000 lui gp,0x0 <-- gp = 0
14: 279c0000 addiu gp,gp,0
18: afbc0010 sw gp,16(sp)
1c: 3c020000 lui v0,0x0 <-- v0 = 0
20: 24440000 addiu a0,v0,0
24: 8f820000 lw v0,0(gp)
28: 0040c825 move t9,v0 <-- t9 = v0
2c: 0320f809 jalr t9
30: 00000000 nop
34: 8fdc0010 lw gp,16(s8)
38: 00001025 move v0,zero
3c: 03c0e825 move sp,s8
40: 8fbf001c lw ra,28(sp)
44: 8fbe0018 lw s8,24(sp)
48: 27bd0020 addiu sp,sp,32
4c: 03e00008 jr ra
50: 00000000 nop
...
  1. 生成可执行文件并进行反汇编
1
2
mips-linux-gnu-gcc hello.c -o hello
mips-linux-gnu-objdump -DS hello > think2.txt

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
... // 前略
00400650 <main>:
400650: 27bdffe0 addiu sp,sp,-32
400654: afbf001c sw ra,28(sp)
400658: afbe0018 sw s8,24(sp)
40065c: 03a0f025 move s8,sp
400660: 3c1c0043 lui gp,0x43 <-- gp = 0x43
400664: 279c8010 addiu gp,gp,-32752
400668: afbc0010 sw gp,16(sp)
40066c: 3c020040 lui v0,0x40 <-- v0 = 0x40
400670: 24440720 addiu a0,v0,1824
400674: 8f828024 lw v0,-32732(gp)
400678: 0040c825 move t9,v0 <-- t9 = v0
40067c: 0320f809 jalr t9
400680: 00000000 nop
400684: 8fdc0010 lw gp,16(s8)
400688: 00001025 move v0,zero
40068c: 03c0e825 move sp,s8
400690: 8fbf001c lw ra,28(sp)
400694: 8fbe0018 lw s8,24(sp)
400698: 27bd0020 addiu sp,sp,32
40069c: 03e00008 jr ra
4006a0: 00000000 nop
...

可以注意到,二者的区别在于gpv0t9寄存器的值不同,使得最终jalr t9跳转的地址不同,完成了printf函数的调用。

关于objdump的参数

使用objdump --help可以查看objdump的参数,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
> objdump --help
用法:objdump <选项> <文件>
显示来自目标 <文件> 的信息。
至少必须给出以下选项之一:
-a, --archive-headers Display archive header information
-f, --file-headers Display the contents of the overall file header
-p, --private-headers Display object format specific file header contents
-P, --private=OPT,OPT... Display object format specific contents
-h, --[section-]headers Display the contents of the section headers
-x, --all-headers Display the contents of all headers
-d, --disassemble Display assembler contents of executable sections
-D, --disassemble-all Display assembler contents of all sections
--disassemble=<sym> Display assembler contents from <sym>
-S, --source Intermix source code with disassembly
--source-comment[=<txt>] Prefix lines of source code with <txt>
-s, --full-contents Display the full contents of all sections requested
-Z, --decompress Decompress section(s) before displaying their contents
-g, --debugging Display debug information in object file
-e, --debugging-tags Display debug information using ctags style
-G, --stabs Display (in raw form) any STABS info in the file

我们在反汇编时,使用其中的-DS参数,-D表示将所有段反汇编,-S表示将源代码与反汇编代码混合显示,这样我们就可以看到汇编代码对应的源代码了。

Thinking 1.2 readelf 程序

尝试使用我们编写的readelf程序,解析之前在target目录下生成的内核ELF文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
> cd tools/readelf && make
> ./readelf ../../target/mos
0:0x0
1:0x80020000
2:0x80021930
3:0x80021948
4:0x80021960
5:0x0
6:0x0
7:0x0
8:0x0
9:0x0
10:0x0
11:0x0
12:0x0
13:0x0
14:0x0
15:0x0
16:0x0
17:0x0
18:0x0

也许你会发现我们编写的 readelf 程序是不能解析 readelf 文件本身的,而我们刚才介绍的系统工具 readelf 则可以解析,这是为什么呢?(提示:尝试使用 readelf-h,并阅读 tools/readelf 目录下的 Makefile,观察 readelfhello 的不同)

运行readelf -h指令,发现readelfhello的ELF文件头信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
> readelf -h readelf
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: DYN (Position-Independent Executable file)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x1180
程序头起点: 64 (bytes into file)
Start of section headers: 14488 (bytes into file)
标志: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
> readelf -h hello
ELF 头:
Magic: 7f 45 4c 46 01 01 01 03 00 00 00 00 00 00 00 00
类别: ELF32
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - GNU
ABI 版本: 0
类型: EXEC (可执行文件)
系统架构: Intel 80386
版本: 0x1
入口点地址: 0x8049750
程序头起点: 52 (bytes into file)
Start of section headers: 707128 (bytes into file)
标志: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 8
Size of section headers: 40 (bytes)
Number of section headers: 30
Section header string table index: 29

阅读toools/readelf/Makefile

1
2
3
4
5
6
7
8
9
10
11
12
13
%.o: %.c
$(CC) -c $<

.PHONY: clean

readelf: main.o readelf.o
$(CC) $^ -o $@

hello: hello.c
$(CC) $^ -o $@ -m32 -static -g

clean:
rm -f *.o readelf hello

Makefile中生成hello的命令是$(CC) $^ -o $@ -m32 -static -g,其中的编译链接选项:

  • $(CC): 这是一个Makefile中的变量,通常表示C编译器(默认是gcc)。
  • $^: 表示所有的依赖文件(即hello.c)。
  • -o $@: 指定输出文件名,$@表示目标文件(即hello)。
  • -m32: 指定生成32位的可执行文件。
  • -static: 静态链接,生成的可执行文件会将所有依赖的库静态链接进去,独立运行时不依赖动态库。
  • -g: 生成调试信息,用于调试工具gdb。

可以看到,readelf的类别是ELF64,而hello的类别是ELF32,在生成可执行文件时就已规定,因此hello是一个32位的可执行文件,我们编写的readelf使用的数据类型都是32位,因而只能解析32位的可执行文件,而它本身是一个64位的可执行文件,所以无法解析自己。

Thinking 1.3

在理论课上我们了解到,MIPS体系结构上电时,启动入口地址为0xBFC00000(其实启动入口地址是根据具体型号而定的,由硬件逻辑确定,也有可能不是这个地址,但一定是一个确定的地址),但实验操作系统的内核入口并没有放在上电启动地址,而是按照内存布局图放置。思考为什么这样放置内核还能保证内核入口被正确跳转到?(提示:思考实验中启动过程的两阶段分别由谁执行。)

操作系统启动包括两个阶段,由bootloader执行,负责初始化硬件环境,找到并读取内核,加载到内存中执行,跳转到内核入口地址,将控制权转交给系统内核:

  • stage1: 在 ROM 或 FLASH 中加载,初始化基本的硬件设备,包括watchdogtimer、中断、时钟、内存等,为stage2准备 RAM 空间,再将stage2代码拷贝到 RAM 中,设置堆栈,跳转到stage2的入口函数,此时,CPU 已经从 ROM/FLASH 切换到 RAM 运行,并开始执行 stage2 的代码。
  • stage2: 在 RAM 中运行,负责初始化内核所需其他硬件,读取内核镜像到 RAM 中并设置对应参数,最后跳转到内核入口函数(即0xBFC00000启动入口地址),将控制权转交给内核。

loader

以上是真实的启动过程,而我们的实验中使用了QEMU模拟器,支持直接加载ELF格式的内核,提供了引导启动的功能,因而简化了stage1和stage2,只需要将内核加载到内存中,跳转到内核入口地址即可,因此内核入口地址可以不是上电启动地址,只要能正确跳转即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// in kernel.lds
OUTPUT_ARCH(mips)

ENTRY(_start)

SECTIONS {
. = 0x80020000;
.text : { *(.text) }
.data : { *(.data) }
bss_start = .;
.bss : { *(.bss) }
bss_end = .;
. = 0x80400000;
end = . ;
}

QEMU模拟器通过kernel.lds控制各节的加载地址,使得程序各节调整到指定地址,再通过规定的ENTRY(_start)函数,使程序从_start函数开始执行,从而实现了内核的引导启动。

1
2
3
4
5
6
// in init/start.S
.text
EXPORT(_start)
...
la sp, KSTACKTOP // 0x8040 0000
j mips_init

内核的入口函数_start中,将栈指针sp指向KSTACKTOP,正确跳转到mips_init函数。

Part2. 难点分析

1. 理解操作系统启动的过程

  • 这点在理论课上已经有过讲解,但实验中使用了QEMU模拟器对启动过程进行了模拟和简化,这要求我们对启动过程有更深入的理解,才能在实验中正确地引导内核启动。更重要的是,我们需要在理解bootloader的基础之上,学习QEMU模拟器的运行流程,才能看懂源代码。
  • QEMU模拟器通过kernel.lds控制各节的加载地址,使得程序各节调整到指定地址,也就是指导书中解释的 LinkerScript,它的核心作用在于明确指定内核各部分在内存中的位置,从而使得内核各部分能够正确地被加载到内存中,确保不同类型的数据分区正确排布,为内核启动与后续运行创造稳定的内存布局。例如,LinkerScript 允许开发者针对不同平台的 ABI 进行定制;分离代码和数据(.text 和 .data),确保安全性和稳定性。

2. 理解ELF源代码中结构体和指针的使用

我们主要关注了两个结构体:Elf32_EhdrElf32_ShdrElf32_Ehdr 是 ELF 文件的总体描述,包含了文件的魔数、类型、目标平台、入口点、程序头和节头表的位置以及数量等全局信息。 Elf32_Shdr 则详细描述了 ELF 文件中各个节的具体属性和位置,如节名、类型、大小、内存加载地址、文件偏移、对齐要求等,为链接、加载和调试提供了关键信息。
注意在读取 ELF 文件时,需要通过指针来访问和解析 ELF 文件中的各个部分,在Lab1中,我们通过指针访问 ELF 文件头、节头表等。这里存在指针类型的转换,为的是能正确地访问 ELF 文件中的数据,确保程序能够正确地解析和加载 ELF 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
typedef struct {
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;
...
typedef struct {
Elf32_Word sh_name; /* Section name */
Elf32_Word sh_type; /* Section type */
Elf32_Word sh_flags; /* Section flags */
Elf32_Addr sh_addr; /* Section addr */
Elf32_Off sh_offset; /* Section offset */
Elf32_Word sh_size; /* Section size */
Elf32_Word sh_link; /* Section link */
Elf32_Word sh_info; /* Section extra info */
Elf32_Word sh_addralign; /* Section alignment */
Elf32_Word sh_entsize; /* Section entry size */
} Elf32_Shdr;
Elf32_Ehdr:ELF 文件头

Elf32_Ehdr 是每个 ELF 文件最开始的部分,描述了整个文件的基本属性和组织信息。其中的具体字段如下:

  • e_ident[EI_NIDENT]
    一个 16 字节的标识数组,包含文件的魔数和其他基本信息。

    • 魔数(Magic Number)
    • 其他字节包含文件的类(32位或64位)、数据编码格式、版本、操作系统 ABI 等信息。
  • e_type 表示目标文件的类型,例如:

    • ET_REL(可重定位文件),
    • ET_EXEC(可执行文件),
    • ET_DYN(共享对象),
    • ET_CORE(内核转储文件)。
  • e_machine 指明目标文件适用的处理器架构(如 x86、ARM、MIPS 等),用于告诉操作系统如何解释其中的机器码。

  • e_version ELF 文件的版本,一般为 1,表示最初的版本。

  • e_entry 程序入口点的虚拟地址。对于可执行文件来说,启动执行时处理器将从这个地址开始运行。

  • e_phoff 程序头表(Program Header Table)在文件中的偏移位置。该表描述了程序运行时需要加载到内存中的各个段(segment)。

  • e_shoff 节头表(Section Header Table)在文件中的偏移位置。节头表提供了关于文件中各个节(section)的详细信息。

  • e_flags 处理器特定的标志,这些标志通常与目标平台相关,用于控制文件的处理方式。

  • e_ehsize ELF 文件头的大小(以字节为单位),告诉系统读取文件头时应取多大的一块数据。

  • e_phentsize 程序头表中每个表项的大小(字节数),用于遍历各个段描述信息。

  • e_phnum 程序头表中表项的数量,即文件中有多少个段需要加载。

  • e_shentsize 节头表中每个表项的大小(字节数),用于遍历各个节的描述信息。

  • e_shnum 节头表中节的数量,即该 ELF 文件中总共有多少个节。

  • e_shstrndx 节头表中用于存储节名字的字符串表(Section Header String Table)的索引。利用这个字符串表,可以根据 sh_name 字段找到每个节的名称。

Elf32_Shdr:节头结构

Elf32_Shdr 用于描述 ELF 文件中的每个节(section),这些节存储了不同类型的信息,如代码、数据、符号表等。各字段含义如下:

  • sh_name 一个索引值,指向节头字符串表中的字符串,表示该节的名称。通过该名称可以判断节的用途(例如 “.text”、".data"、".bss" 等)。

  • sh_type 指定节的类型。常见类型包括:

    • SHT_PROGBITS:包含程序数据或代码的节;
    • SHT_SYMTAB:符号表;
    • SHT_STRTAB:字符串表;
    • SHT_NOBITS:不占据文件中实际存储空间的节(例如 .bss)。
  • sh_flags 标识该节的属性,如是否可写、是否可执行、是否在内存中占用空间。

  • sh_addr 指定该节在进程地址空间中的虚拟地址。对于加载到内存中的节(比如 .text、.data),该字段指出它们在内存中的起始地址。

  • sh_offset 该节在文件中的偏移量,即从文件开始到该节数据的起始位置。

  • sh_size 该节的大小(以字节为单位),表示该节包含的数据总量。

  • sh_link 该字段的意义取决于节的类型。通常用于指定与该节相关的其他节的索引。

  • sh_info 提供额外的信息,其含义依赖于节的类型。

  • sh_addralign 指定该节在内存中的对齐要求(以字节为单位)。

  • sh_entsize 如果该节包含固定大小的条目(例如符号表、重定位表),则此字段记录每个条目的大小。

3. printk()函数与变长参数

在Lab1课下中,我们实现了了一个printk()函数,它能够打印出不同类型的数据,包括字符串、整数等。printk()函数使用了变长参数列表,通过va_listva_start()va_arg()va_end()等宏来实现参数的解析和打印。
下面详细说明 printk 函数的实现以及变长参数(variadic arguments)的原理和使用。

  • 顶层函数 printk
    它负责收集格式字符串和变长参数,并调用 vprintfmt 进行解析和格式化。
  • 变长参数的使用
    通过 va_listva_startva_argva_end 宏实现。它允许函数动态读取传入的多个参数,每次调用 va_arg 都能根据指定类型返回下一个参数。
  • 整体流程
    用户通过 printk 输出格式化信息,printk 使用变长参数宏将参数整理成列表,再由 vprintfmt 解析格式字符串,最终通过回调函数 outputk 将字符逐一输出到内核控制台(实际写入内存映射的 I/O 地址)。
printk 函数实现概述

我们在Lab1中实现的输出函数 printk 实现思路可以分为两层:

  1. 顶层接口
    printk 是一个可变长参数函数,其原型为:

    1
    void printk(const char *fmt, ...);

    这里的 ... 表示该函数可以接受任意数量的额外参数。printk 主要做两件事:

    • 利用 C 语言标准库提供的可变长参数宏(va_listva_startva_end 等)将参数组织成一个变量列表;
    • 将格式字符串、可变参数列表以及一个用于输出字符的回调函数 outputk 传递给底层的格式化输出函数 vprintfmt
  2. 格式化输出实现
    底层的 vprintfmt 函数负责解析格式字符串(例如 %d%x%s 等),提取对应的参数,并调用辅助函数(如 print_charprint_strprint_num)进行数字、字符、字符串的格式化处理。最终,这些格式化后的字符数据会通过 outputk 回调函数逐字符输出到控制台。最终 printk 的调用流程如下:

    • 用户调用printk函数,如 printk("Hello %d\n", 123);
    • printk 调用 va_start 初始化参数列表,将 “Hello %d\n” 作为固定参数,后面的参数 123 记录在 ap 中;
    • 调用 vprintfmt(outputk, NULL, fmt, ap) 进行格式化输出;
    • 格式化过程中,遇到 % 符号时,会解析输出宽度、对齐方式、是否长整型等标志,并用 va_arg 依次提取相应类型的参数;
    • 格式化后的字符串最终通过 outputk 被输出,outputk 内部调用 printcharc 将字符写入特定的内存地址,实现对 QEMU 控制台的输出;
    • 最后在结束时调用 va_end 清理变量参数列表。

printk调用流程

理解可变长参数的原理

在 C 语言中,可变长参数允许函数接受不固定数量的参数。实现这一功能主要依赖于 <stdarg.h> 中的几个宏:

  1. va_list 定义了一个类型,用来存储变长参数列表的信息。可以理解为一个栈堆中指向参数列表当前位置的指针。

  2. va_start(va_list ap, lastarg) 用于初始化 va_list 类型的变量 ap。第二个参数 lastarg 是函数参数列表中最后一个固定参数(在 printk 中为 fmt)。该宏根据 lastarg 的位置确定变长参数列表的起始位置。

  3. va_arg(va_list ap, type) 每调用一次 va_arg,就会返回当前参数,并将内部指针移动到下一个参数的位置。调用时需要指定参数的类型,比如 intlong

  4. va_end(va_list ap) 用于清理 va_list 对象。

Part3. 实验体会

通过本次实验,我深入理解了计算机系统的启动过程,对于 QEMU 模拟器模拟的计算机启动过程有了更直观的认识。同时,Lab1帮助我们掌握ELF文件的结构与具体功能实现,并通过对 printk 函数的实现进行训练。完成Lab1决不能仅仅是完成填空,而必须阅读并理解源代码,知晓其背后的原理。