lab1 实验报告 [BUAA-OS]
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 工具链编译链接过程:
- 编写一个简单的程序
hello.c
,内容如下:
1 |
|
- 只进行预处理,生成预处理后的代码:
1 | gcc -E hello.c -o hello.i |
查看预处理后的代码,结果如下:
1 | ... //前略 |
可以看到,预处理后的代码将 #include <stdio.h>
替换为了 stdio.h
文件的内容,并且将 printf
函数声明为 extern
,表示该函数在其他地方定义,说明并没有 printf
函数的具体实现。
- 只编译不链接,生成汇编代码并进行反汇编:
1 | gcc -c hello.c |
查看反汇编后的代码,结果如下:
1 | hello.o: 文件格式 elf64-x86-64 |
可以注意到,call
指令的地址并没有被填入具体的地址,而是用 00 00 00 00
表示,说明 printf
函数的具体实现仍然不在程序中。
- 编译并链接,生成可执行文件并进行反汇编:
1 | gcc hello.c -o hello |
查看反汇编后的代码,结果如下:
1 |
|
我们注意到,call
指令的地址已经被填入具体的地址1050
,也就是被标记为printf@plt
的地址,说明printf
函数的实现就在这个地址处。
MIPS 交叉编译工具链编译过程
- 只进行预处理
1 | mips-linux-gnu-gcc -E hello.c -o hello.i |
结果同上。
- 只进行编译,生成汇编代码并进行反汇编
1 | mips-linux-gnu-gcc -c hello.c |
结果如下:
1 | hello.o: 文件格式 elf32-tradbigmips |
- 生成可执行文件并进行反汇编
1 | mips-linux-gnu-gcc hello.c -o hello |
结果如下:
1 | ... // 前略 |
可以注意到,二者的区别在于gp
、v0
、t9
寄存器的值不同,使得最终jalr t9
跳转的地址不同,完成了printf
函数的调用。
关于objdump
的参数
使用objdump --help
可以查看objdump
的参数,结果如下:
1 | > objdump --help |
我们在反汇编时,使用其中的-DS
参数,-D
表示将所有段反汇编,-S
表示将源代码与反汇编代码混合显示,这样我们就可以看到汇编代码对应的源代码了。
Thinking 1.2 readelf 程序
尝试使用我们编写的readelf程序,解析之前在target目录下生成的内核ELF文件。
1 | > cd tools/readelf && make |
也许你会发现我们编写的
readelf
程序是不能解析readelf
文件本身的,而我们刚才介绍的系统工具readelf
则可以解析,这是为什么呢?(提示:尝试使用readelf-h
,并阅读tools/readelf
目录下的Makefile
,观察readelf
与hello
的不同)
运行readelf -h
指令,发现readelf
和hello
的ELF文件头信息如下:
1 | > readelf -h readelf |
1 | > readelf -h hello |
阅读toools/readelf/Makefile
1 | %.o: %.c |
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
启动入口地址),将控制权转交给内核。
以上是真实的启动过程,而我们的实验中使用了QEMU模拟器,支持直接加载ELF格式的内核,提供了引导启动的功能,因而简化了stage1和stage2,只需要将内核加载到内存中,跳转到内核入口地址即可,因此内核入口地址可以不是上电启动地址,只要能正确跳转即可。
1 | // in kernel.lds |
QEMU模拟器通过kernel.lds
控制各节的加载地址,使得程序各节调整到指定地址,再通过规定的ENTRY(_start)
函数,使程序从_start
函数开始执行,从而实现了内核的引导启动。
1 | // in init/start.S |
内核的入口函数_start
中,将栈指针sp
指向KSTACKTOP
,正确跳转到mips_init
函数。
Part2. 难点分析
1. 理解操作系统启动的过程
- 这点在理论课上已经有过讲解,但实验中使用了QEMU模拟器对启动过程进行了模拟和简化,这要求我们对启动过程有更深入的理解,才能在实验中正确地引导内核启动。更重要的是,我们需要在理解
bootloader
的基础之上,学习QEMU模拟器的运行流程,才能看懂源代码。 - QEMU模拟器通过
kernel.lds
控制各节的加载地址,使得程序各节调整到指定地址,也就是指导书中解释的 LinkerScript,它的核心作用在于明确指定内核各部分在内存中的位置,从而使得内核各部分能够正确地被加载到内存中,确保不同类型的数据分区正确排布,为内核启动与后续运行创造稳定的内存布局。例如,LinkerScript 允许开发者针对不同平台的 ABI 进行定制;分离代码和数据(.text 和 .data),确保安全性和稳定性。
2. 理解ELF源代码中结构体和指针的使用
我们主要关注了两个结构体:Elf32_Ehdr 和 Elf32_Shdr。Elf32_Ehdr 是 ELF 文件的总体描述,包含了文件的魔数、类型、目标平台、入口点、程序头和节头表的位置以及数量等全局信息。 Elf32_Shdr 则详细描述了 ELF 文件中各个节的具体属性和位置,如节名、类型、大小、内存加载地址、文件偏移、对齐要求等,为链接、加载和调试提供了关键信息。
注意在读取 ELF 文件时,需要通过指针来访问和解析 ELF 文件中的各个部分,在Lab1中,我们通过指针访问 ELF 文件头、节头表等。这里存在指针类型的转换,为的是能正确地访问 ELF 文件中的数据,确保程序能够正确地解析和加载 ELF 文件。
1 | typedef struct { |
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_list
、va_start()
、va_arg()
和va_end()
等宏来实现参数的解析和打印。
下面详细说明 printk
函数的实现以及变长参数(variadic arguments)的原理和使用。
- 顶层函数
printk
它负责收集格式字符串和变长参数,并调用vprintfmt
进行解析和格式化。 - 变长参数的使用
通过va_list
、va_start
、va_arg
、va_end
宏实现。它允许函数动态读取传入的多个参数,每次调用va_arg
都能根据指定类型返回下一个参数。 - 整体流程
用户通过printk
输出格式化信息,printk
使用变长参数宏将参数整理成列表,再由vprintfmt
解析格式字符串,最终通过回调函数outputk
将字符逐一输出到内核控制台(实际写入内存映射的 I/O 地址)。
printk 函数实现概述
我们在Lab1中实现的输出函数 printk
实现思路可以分为两层:
-
顶层接口
printk
是一个可变长参数函数,其原型为:1
void printk(const char *fmt, ...);
这里的
...
表示该函数可以接受任意数量的额外参数。printk
主要做两件事:- 利用 C 语言标准库提供的可变长参数宏(
va_list
、va_start
、va_end
等)将参数组织成一个变量列表; - 将格式字符串、可变参数列表以及一个用于输出字符的回调函数
outputk
传递给底层的格式化输出函数vprintfmt
。
- 利用 C 语言标准库提供的可变长参数宏(
-
格式化输出实现
底层的vprintfmt
函数负责解析格式字符串(例如%d
、%x
、%s
等),提取对应的参数,并调用辅助函数(如print_char
、print_str
、print_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
清理变量参数列表。
- 用户调用
理解可变长参数的原理
在 C 语言中,可变长参数允许函数接受不固定数量的参数。实现这一功能主要依赖于 <stdarg.h>
中的几个宏:
-
va_list 定义了一个类型,用来存储变长参数列表的信息。可以理解为一个栈堆中指向参数列表当前位置的指针。
-
va_start(va_list ap, lastarg) 用于初始化
va_list
类型的变量ap
。第二个参数lastarg
是函数参数列表中最后一个固定参数(在printk
中为fmt
)。该宏根据lastarg
的位置确定变长参数列表的起始位置。 -
va_arg(va_list ap, type) 每调用一次
va_arg
,就会返回当前参数,并将内部指针移动到下一个参数的位置。调用时需要指定参数的类型,比如int
或long
。 -
va_end(va_list ap) 用于清理
va_list
对象。
Part3. 实验体会
通过本次实验,我深入理解了计算机系统的启动过程,对于 QEMU 模拟器模拟的计算机启动过程有了更直观的认识。同时,Lab1帮助我们掌握ELF文件的结构与具体功能实现,并通过对 printk
函数的实现进行训练。完成Lab1决不能仅仅是完成填空,而必须阅读并理解源代码,知晓其背后的原理。