北航os操作系统lab1

Lab1——内核、启动和 PRINTF
本人此时已经有点累死了,写的好像不是很容易懂o(╥﹏╥)o
以下内容主要来自指导书、教程配套视频、lmh学长直播
知识梳理
真实的操作系统中,我们需要 Bootloader 完成硬件的初始化以及将内核从硬盘载入 RAM 中,并为内核设置启动参数,将CPU指令寄存器的内容设置为内核入口函数的地址,等等一系列操作。
进行实验的硬件仿真平台 QEMU 已经提供了 bootloader 的引导功能,所以启动被简化为:加载ELF格式内核到内存,之后跳转到内核的入口。
我们已经非常清楚一个 c 语言程序的运行过程,我们可否将内核也理解为一个类似的程序?启动内核的过程,实际上也是程序编译、链接、运行的过程。
编译
内核的很多程序文件已经给我们了,我们可以通过 Makefile 了解编译流程
其实这里的 make 包括编译和链接。
以下是打乱顺序的 Makefile
1 | #Makefile |
运行 clean 和 all 两个命令,clean 即清理一下战场,主要来看 all
1 | #Makefile |
我们可以理解 make all 即构建./target目录下的可执行文件mos,现在我们来看看如何构建 $(target)($(target) = $(mos_elf))
1 | #Makefile |
这实际工作是一个链接,我们先分析其依赖对象,等编译过之后,我们还会回过头来看这个链接的
先看 $(target_dir):
1 | #Makefile |
-p是递归创建。当创建1/2/3的时候,不加-p则默认存在1/2只用在1/2目录中创建1/2/3,加上-p可以递归的创建./1/2/3$@指的是要构建的对象
本句话翻译一下就是: mkdir -p target,即递归的创建target目录
再看 $(modules)
1 | #Makefile |
我们依旧先看依赖文件,主要是一些编译内核的工具
1 | #Makefile |
这里完成了三个任务:给 CC 赋值,给 CFLAGS 赋值,运行了子目录中的make
前两个赋值内容在 include.mk 中,如下:
1
2
3
4
5
6
7
8#include.mk:
CROSS_COMPILE ?= mips-linux-gnu- #实际使用的编译器和链接器等工具的前缀,交叉编译器的具体位置
#交叉编译指的是编译器生成的目标代码运行的平台和编译器运行的平台不同的编译过程。在我们的实验中,编译器在常见的x86_64 平台上运行,生成的MOS内核在MIPS平台上运行,故属于交叉编译。
#?= 是指如果这个变量还没有被定义过,就给它赋这个设定的值;如果这个变量已经有值了,那么这行代码就会被忽略,变量保持原有的值不变。
CC := $(CROSS_COMPILE)gcc
CFLAGS += --std=gnu99 -$(ENDIAN) -G 0 -mno-abicalls -fno-pic \
-ffreestanding -fno-stack-protector -fno-builtin \
-Wa,-xgot -Wall -mxgot -mno-fix-r4000 -march=4kc运行子目录中的make
$(MAKE)是 Makefile中的一个递归变量,主要作用于在子目录中调用另一个 Makefile$@指的是要构建的对象这里指的就是进入到 tool 文件夹中,运行 tool 文件中的 Makefile 的 Make 命令
1
2
3
4
5
6
7
8
9
10
11
12
13#tool/Makefile
include include.mk
all: $(targets)
%: %.c
$(CC) $(CFLAGS) -o $@ $^
# %是通配符
# $^是所有的依赖对象
clean:
rm -rf $(targets)这里
include.mk是 tool 中的 include.mk,而不是主文件夹中的,如下1
2
3
4
5
6
7
8
9
10
11
12
13
14#tool/include.mk
lab-ge = $(shell [ "$$(echo $(lab)_ | cut -f1 -d_)" -ge $(1) ] && ec ho true)
ifeq ($(call lab-ge,3), true)
targets += bintoc
endif
ifeq ($(call lab-ge,5), true)
targets += fsformat
endif
# call调用一个函数
# shell执行一个shell命令
# cut将输入按给定的字符分割,此处是_
# 比较其后指定的两个字符串是否相等可以看到对于 lab1
$(target)什么也没有
了解完依赖文件,我们再回过头来看$(modules):
1 | #Makefile |
这里即一个一个调用了./lib、./init、./kern中的 Makefile,运行了 make 命令
./lib中:
1 | # ./lib/Makefile |
此时,make的时候还是运行make all
%部分相当于一个模版,是不会被当作第一个构建的
./kern中:
1 | # ./kern/Makefile |
1 | # ./kern/include.mk |
./init中:
1 | # ./init/Makefile |
此时我们便运行完了$(modules)的构建,也就完成了$(mos_elf)的依赖文件构建,让我们回到$(mos_elf)
1 | #Makefile |
这其实执行的是链接操作(我们在之前已经完成了所有的汇编!),所以让我们来走进链接吧。
链接
依旧从$(mos_elf)的构建开始展开
1 | #Makefile |
其使用了 include.mk 文件
1 | #include.mk |
-T $(link_script):LinkerScript 中记录了各个节应该如何映射到段,以及各个段应该被加载到的位置。通过这个选项,可以让链接器按照 kernel.lds 中的链接。
这句话即是 使用$(link_scrpt)将$(objects)链接,输出到$(mos_elf)位置
节是什么?段是什么?LinkerScript是什么?让我们一一介绍。
ELF
ELF 的定义
ELF(Executable and Linkable Format) 是 Unix 上常用的一种目标文件格式,用于链接和运行,其分为三种:
- 编译生成 .o 文件就是ELF所包含的三种文件类型中的一种,称为可重定位(relocatable)文件
- 链接生成的可执行 (executable) 文件,需要链接器对可重定位文件进行处理才能生成。
- 共享对象 (shared object) 文件,也需要链接器对可重定位文件进行处理才能生成。
ELF 的结构

- ELF头,包括程序的基本信息,比如体系结构和操作系统,同时也包含了节头表和段头表相对文件的偏移量(offset)。
- 段头表(或程序头表,programheadertable),主要包含程序中各个段(segment)的信息, 段的信息需要在运行时刻使用。
- 节头表(sectionheadertable),主要包含程序中各个节(section)的信息,节的信息需要 在程序编译和链接的时候使用。
- 段头表中的每一个表项,记录了该段数据载入内存时的目标位置等,记录了用于指导应用 程序加载的各类信息。
- 节头表中的每一个表项,记录了该节程序的代码段、数据段等各个段的内容,主要是链接 器在链接的过程中需要使用。
节是用于链接的,段是用于运行的,段是由节组成的,两者的大致关系如下:(图来自课件)


可以通过./tools/readelf/elf.h 了解:
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
30
31
32typedef 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 */
//ELF文件头的大小(单位是字节)
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 */
//节头字符串编号
//字符串表本身也成为了 ELF 文件里的一个节(Section),里面存放有节的名字,其也有节头,e_shstrndx 即其在节头表中的索引(编号)(说明是表中第几个节头)。
} Elf32_Ehdr;节头表:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22typedef 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 */
//节的文件内偏移(相对于ELF文件头而言)
Elf32_Word sh_size; /* Section size */
//节的大小(以字节计算)
Elf32_Word sh_link; /* Section link */
//节头表索引链接
Elf32_Word sh_info; /* Section extra info */ //info:信息
//额外信息
Elf32_Word sh_addralign; /* Section alignment */
//地址对齐
Elf32_Word sh_entsize; /* Section entry size */
//此节头表表项的大小
} Elf32_Shdr;段头表:
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
30
31typedef struct {
Elf32_Word p_type; /* Segment type */
//段的类型
//类型见下方,当是1,即PT_LOAD时,整个段要加载进入内存中
Elf32_Off p_offset; /* Segment file offset */
//段的文件内偏移(相对于ELF文件头而言)
Elf32_Addr p_vaddr; /* Segment virtual address */
//段的虚拟地址
Elf32_Addr p_paddr; /* Segment physical address */
//段的物理地址
Elf32_Word p_filesz; /* Segment size in file */
//段在ELF文件中的大小
Elf32_Word p_memsz; /* Segment size in memory */
//段在内存中的大小
Elf32_Word p_flags; /* Segment flags */
//段的标志位
Elf32_Word p_align; /* Segment alignment */
//地址对齐
} Elf32_Phdr;段在ELF文件中的大小和内存中的大小并不一样!这需要一些程序的基础知识。
一个程序本质上都是由 bss 节、data 节、text 节三个组成的。
- .bss:未初始化的全局变量,静态内存分配
- .data:数据段,已初始化的全局变量,静态内存分配
- .text:代码段,程序执行代码。大小在程序运行前确定,通常只读。也可能包含一些只读的常数变量,例如字符串等等
.test 和 .data 都在可执行文件中,系统从可执行文件中加载,而 .bss 不在可执行文件中,由系统初始化。
内存中比ELF文件大的部分,都会初始化为0。
LinkerScript
运行的时候需要将内核加载在内存正确的位置上,这时我们需要 LinkerScript。首先,我们先了解一下 MIPS 内存布局。
MIPS 内存布局

kuseg0x00000000-0x7FFFFFFF(2 GB):这一段是用户态下唯一可用的地址空间(内核态下也可使用这段地址空间),大小为2GB,也就是MIPS约定的用户内存空间。需要通过 MMU(Memory Management Unit)中的 TLB 进行虚拟地址到物理地址的变换。对这段 地址的存取都会通过cache。kseg00x80000000-0x9FFFFFFF(512 MB):这一段是内核态下可用的地址,MMU 将地址的最高位清零(&0x7fffffff) 就得到物理地址用于访存。也就是说,这段的虚拟地址被连续 地映射到物理地址的低512MB空间。对这段地址的存取都会通过cache。kseg10xA0000000-0xBFFFFFFF(512 MB):与 kseg0 类似,这段地址也是内核态下可用的地址,MMU将虚拟地址的高三位清零(& 0x1fffffff) 就得到物理地址用于访存。这段虚拟地址也被连续地映射到物理地址的低 512 MB 空间。但是对这段地址的存取不通过 cache,往往在这段地址上使用MMIO(Memory-Mapped I/O)技术来访问外设。kseg20xC0000000-0xFFFFFFFF(1GB):这段地址只能在内核态下使用并且需要MMU中TLB将虚拟地址转换为物理地址。对这段地址的存取都会通过cache。
TLB 需要操作系统管理,但是我们正在载入操作系统内核,所以不可以使用。而 kseg1 是不经过cache的,一般来说,利用MMIO访问外设时才会使用kseg1。所以我们内核放在 kseg0。
cache 初始化前 bootloader 会放在 kseg1。kseg1中的 bootloader 在载入内核前会进行 cache 初始化工作。此后 kseg0 就可以被使用。
于是,让我们把内核放在正确的位置上吧!
LinkerScript
即 kernel.lds 文件,其主要任务是告知程序开始执行的虚拟地址,同时告知编译器应该如何把节放在什么位置。
不需要放入内核的节,一般默认位置是0。
1 | /*kernel.lds*/ |
运行
把内核放入正确位置之后,我们即可运行 make run
1 | #Makefile |
即用QEMU运行我们刚刚编译链接生成的 ./target/mos
我们刚刚已经了解到运行是从 _start 开始,这个在哪,其实在我们编译的./init/start.S中,这CPU控制权转交给内核后执行的第一个函数,初始化了CPU和栈指针
1 | /*./init/start.S*/ |
1 | //./init/start.S |
其中调用了./kern/machine.c 中的halt()实现了停止整个系统。
同时还调用了./kern/printk.c 中的printk()函数
printk
目前,标答中 printk 无法正确打印 LONG_MIN。不知道后面会不会改。
./kern/printk.c 中的printk()函数:
1 | //./kern/printk.c |
参数中有 … 可见其不一般。这里使用了变长参数表。
变长的参数表,至少需要一个参数定位参数表的起始位置。参数是在栈上放置的,具体的实现依赖平台的规定。如果按照右边参数先入 栈的顺序,有:(图来源于配套PPT)

va_list 是变长参数表的变量类型
va_start(va_list ap, lastarg),用于初始化变长参数表的宏(lastarg是该函数最后一个命名的形式参数,即 … 前的最后一个参数)
va_arg(va_list ap,类型) 用于取变长参数表下一个参数的宏 (第二个参数是这次获取的参数的类型)
va_end(ap) 结束使用变长参数表的宏
使用变长参数需要注意:
变长参数并不知道参数有几个,所以可能会出现越界访问
取变长参数表下一个参数必须要指定参数类型
其中,在变长参数传递参数的时候
char、short会以int的传递所以
char a = va_arg(va_list ap,char)是违规的我们可以
char a = (char)va_arg(va_list ap,int)来读取一个 char 变量
printk()函数调用了vprintfmt(outputk, NULL, fmt, ap);
vprintfmt 定义是void vprintfmt(fmt_callback_t out, void *data, const char *fmt, va_list ap) {}。其主要是解析作用,如面对printk("The number is %-ld", 1000)。vprintfmt 会将没有%的部分直接传给 out 函数输出,有%号的部分按照格式符要求生成相应字符串,再交给 out 函数输出。(格式符的原型为: %[flags][width][length]<specifier>)
vprintfmt 定义中 out 是回调函数,data 是其上下文(是 out 函数的参数),即 vprintfmt 只负责解析,out 函数负责输出,out 函数可以是输出到控制台,也可以是输出到指定文件中,此时指定文件的地址即是 data。out 输出到控制台的函数如下:
1 | //./kern/printk.c |
其调用了printcharc()
1 | //./kern/machine.c |
printcharc()对某一个内存地址写了一个字节,从而实现输出。
最终我们通过 make && make run 之后,就可以获得一个
1 | init.c: mips_init() is called |
接下来是实验报告部分!
Lab1 实验报告
思考题
Thinking 1.1
在阅读附录中的编译链接详解以及本章内容后,尝试分别使用实验环境中的原生 x86 工具链(gcc、ld、readelf、objdump 等)和 MIPS 交叉编译工具链(带有 mips-linux-gnu- 前缀,如 mips-linux-gnu-gcc、mips-linux-gnu-ld),重复其中的编译和解析过程,观察相应的结果,并解释其中向objdump传入的参数的含义。
针对hello.c
1 |
|
使用实验环境中的原生 x86 工具链
只进行预处理(通过-E选项),而不编译
1
2gcc -E hello.c > hello.c
cat hello.c输出结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22...
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;
typedef signed char __int8_t;
typedef unsigned char __uint8_t;
typedef signed short int __int16_t;
typedef unsigned short int __uint16_t;
typedef signed int __int32_t;
typedef unsigned int __uint32_t;
typedef signed long int __int64_t;
typedef unsigned long int __uint64_t;
...
int main() {
puts("Hello, world!");
return 0;
}只编译而不链接
1
2
3
4gcc -g -c hello.c
objdump --section=.text --disassemble=main --source\
hello.o > test1
cat test1其中 objdump命令中
--disassemble表示反汇编所有代码;--source表示显示汇编代码与源代码的对应关系。输出结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21hello.o: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
#include <stdio.h>
int main() {
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
puts("Hello, world!");
8: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # f <main+0xf>
f: 48 89 c7 mov %rax,%rdi
12: e8 00 00 00 00 call 17 <main+0x17>
return 0;
17: b8 00 00 00 00 mov $0x0,%eax
}
1c: 5d pop %rbp
1d: c3 ret可见 puts 地址的位置上被填写了一串0
进行链接,也就是正常地编译出可执行文件
1
2
3gcc -g -static -o hello hello.c
objdump --disassemble --source hello>test2
cat test2其中 objdump命令中
--section=.text表示仅处理.text 节的内容;--disassemble=main表示仅反汇编 main 符号的代码;--source表示显示汇编代码与源代码的对应关系。输出结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23hello1: 文件格式 elf64-x86-64
...
Disassembly of section .text:
...
00000000004018b5 <main>:
#include <stdio.h>
int main() {
4018b5: f3 0f 1e fa endbr64
4018b9: 55 push %rbp
4018ba: 48 89 e5 mov %rsp,%rbp
puts("Hello, world!");
4018bd: 48 8d 05 4c d7 07 00 lea 0x7d74c(%rip),%rax # 47f010 <__rseq_flags+0xc>
4018c4: 48 89 c7 mov %rax,%rdi
4018c7: e8 64 34 00 00 call 404d30 <_IO_puts>
return 0;
4018cc: b8 00 00 00 00 mov $0x0,%eax
}
4018d1: 5d pop %rbp
4018d2: c3 ret
4018d3: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
4018da: 00 00 00
4018dd: 0f 1f 00 nopl (%rax)call后面已经不再是一串0了。 那里已经被填入了一个地址。
MIPS 交叉编译工具链
只进行预处理(通过-E选项),而不编译
1
2mips-linux-gnu-gcc -E hello.c > hello.c
cat hello.c输出结果:
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...
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;
typedef signed char __int8_t;
typedef unsigned char __uint8_t;
typedef signed short int __int16_t;
typedef unsigned short int __uint16_t;
typedef signed int __int32_t;
typedef unsigned int __uint32_t;
__extension__ typedef signed long long int __int64_t;
__extension__ typedef unsigned long long int __uint64_t;
...
int main() {
puts("Hello, world!");
return 0;
}只编译而不链接
1
2
3mips-linux-gnu-gcc -g -c hello.c
mips-linux-gnu-objdump --section=.text --disassemble=main --source hello.o > test1
cat test1其中 objdump命令中
--disassemble表示反汇编所有代码;--source表示显示汇编代码与源代码的对应关系。输出结果:
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
30
31
32
33hello.o: 文件格式 elf32-tradbigmips
Disassembly of section .text:
00000000 <main>:
#include <stdio.h>
int 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
14: 279c0000 addiu gp,gp,0
18: afbc0010 sw gp,16(sp)
puts("Hello, world!");
1c: 3c020000 lui v0,0x0
20: 24440000 addiu a0,v0,0
24: 8f820000 lw v0,0(gp)
28: 0040c825 move t9,v0
2c: 0320f809 jalr t9
30: 00000000 nop
34: 8fdc0010 lw gp,16(s8)
return 0;
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 nopluiaddiulw指令都进行着关于0的运算,t9 寄存器中是0。可见 puts 地址的位置上被填写了一串0进行链接,也就是正常地编译出可执行文件
1
2
3mips-linux-gnu-gcc -g -static -o hello hello.c
mips-linux-gnu-objdump --section=.text --disassemble=main --source hello>test2
cat test2其中 objdump命令中
--section=.text表示仅处理.text 节的内容;--disassemble=main表示仅反汇编 main 符号的代码;--source表示显示汇编代码与源代码的对应关系。输出结果:
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
30
31
32
33hello: 文件格式 elf32-tradbigmips
Disassembly of section .text:
00400790 <main>:
#include <stdio.h>
int main() {
400790: 27bdffe0 addiu sp,sp,-32
400794: afbf001c sw ra,28(sp)
400798: afbe0018 sw s8,24(sp)
40079c: 03a0f025 move s8,sp
4007a0: 3c1c004a lui gp,0x4a
4007a4: 279c8f80 addiu gp,gp,-28800
4007a8: afbc0010 sw gp,16(sp)
puts("Hello, world!");
4007ac: 3c020046 lui v0,0x46
4007b0: 24443a00 addiu a0,v0,14848
4007b4: 8f828060 lw v0,-32672(gp)
4007b8: 0040c825 move t9,v0
4007bc: 041103c8 bal 4016e0 <_IO_puts>
4007c0: 00000000 nop
4007c4: 8fdc0010 lw gp,16(s8)
return 0;
4007c8: 00001025 move v0,zero
}
4007cc: 03c0e825 move sp,s8
4007d0: 8fbf001c lw ra,28(sp)
4007d4: 8fbe0018 lw s8,24(sp)
4007d8: 27bd0020 addiu sp,sp,32
4007dc: 03e00008 jr ra
4007e0: 00000000 nopt9 寄存器已经不再是一串0了。 那里已经被填入了一个地址。
Thinking 1.2
思考下述问题:
- 尝试使用我们编写的 readelf 程序,解析之前在 target 目录下生成的内核 ELF 文件。
- 也许你会发现我们编写的 readelf 程序是不能解析 readelf 文件本身的,而我们刚 才介绍的系统工具 readelf 则可以解析,这是为什么呢?(提示:尝试使用readelf -h,并阅读 tools/readelf 目录下的 Makefile,观察 readelf 与 hello 的不同
尝试使用我们编写的 readelf 程序,解析之前在 target 目录下生成的内核 ELF 文件。
命令如下:
1
2make
./tools/readelf/readelf ./target/mos结果如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
190:0x0
1:0x80020000
2:0x80021a70
3:0x80021a88
4:0x80021aa0
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 -h readelf的结果如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20ELF 头:
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我们可以看到 readelf 是 64 位的。
在 tools/readelf 目录下的 Makefile 中,我们可以看到 readelf 与 hello 的不同:
1
2
3
4
5
6
7
8%.o: %.c
$(CC) -c $<
readelf: main.o readelf.o
$(CC) $^ -o $@
hello: hello.c
$(CC) $^ -o $@ -m32 -static -g可以看到,我们通过
-m32创建的 hello 是 32位的,而 readelf 默认创建是 64 位的。而我们的 readelf 只能解析32位的。经查询资料发现,系统工具 readelf 可以解析32位和64位的ELF文件格式。
我们的线上实验中提供了可以解析64位的 readelf ,尝试用其解析,结果如下:
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
30
31
32
33
34./readelf64 readelf64
section_count=31
[0] name="" offset=0x0 size=0
[1] name=".interp" offset=0x318 size=28
[2] name=".note.gnu.property" offset=0x338 size=48
[3] name=".note.gnu.build-id" offset=0x368 size=36
[4] name=".note.ABI-tag" offset=0x38c size=32
[5] name=".gnu.hash" offset=0x3b0 size=40
[6] name=".dynsym" offset=0x3d8 size=456
[7] name=".dynstr" offset=0x5a0 size=246
[8] name=".gnu.version" offset=0x696 size=38
[9] name=".gnu.version_r" offset=0x6c0 size=64
[10] name=".rela.dyn" offset=0x700 size=216
[11] name=".rela.plt" offset=0x7d8 size=288
[12] name=".init" offset=0x1000 size=27
[13] name=".plt" offset=0x1020 size=208
[14] name=".plt.got" offset=0x10f0 size=16
[15] name=".plt.sec" offset=0x1100 size=192
[16] name=".text" offset=0x11c0 size=962
[17] name=".fini" offset=0x1584 size=13
[18] name=".rodata" offset=0x2000 size=182
[19] name=".eh_frame_hdr" offset=0x20b8 size=68
[20] name=".eh_frame" offset=0x2100 size=288
[21] name=".init_array" offset=0x2d60 size=8
[22] name=".fini_array" offset=0x2d68 size=8
[23] name=".dynamic" offset=0x2d70 size=496
[24] name=".got" offset=0x2f60 size=160
[25] name=".data" offset=0x3000 size=16
[26] name=".bss" offset=0x3010 size=16
[27] name=".comment" offset=0x3010 size=43
[28] name=".symtab" offset=0x3040 size=1224
[29] name=".strtab" offset=0x3508 size=743
[30] name=".shstrtab" offset=0x37ef size=282
symtab_index=28
Thinking 1.3
在理论课上我们了解到,MIPS体系结构上电时,启动入口地址为0xBFC00000 (其实启动入口地址是根据具体型号而定的,由硬件逻辑确定,也有可能不是这个地址,但一定是一个确定的地址),但实验操作系统的内核入口并没有放在上电启动地址,而是按照内存布局图放置。思考为什么这样放置内核还能保证内核入口被正确跳转到? (提示:思考实验中启动过程的两阶段分别由谁执行。)
在真实的 MIPS 硬件体系结构中,CPU 刚上电时,确实会强制将 PC(程序计数器)指向 0xBFC00000 这个固定的物理硬件地址并开始执行指令。但是,放在这个地址上的代码并不是操作系统内核,而是 Bootloader(启动引导程序,类似 PC 上的 BIOS)。
- 第一阶段(Bootloader 执行):CPU 上电后执行位于 0xBFC00000 的 Bootloader。Bootloader 会进行基本的硬件初始化,然后负责将存储在磁盘或 Flash 中的操作系统内核镜像,按照既定的内存布局图,加载到主存的正确位置中。
- 第二阶段(内核执行): Bootloader 将内核加载到内存后,会通过一条跳转指令,把 CPU 的控制权(PC 寄存器)直接指向操作系统内核的入口地址(即我们在 kernel.lds 中定义的 _start)。此时,内核才真正开始运行。
因此,内核本身不需要硬编码放置在 0xBFC00000,它只需要静静地待在它该在的内存位置,等待 Bootloader 来跳转到它即可。
进行实验的硬件仿真平台 QEMU 已经提供了 bootloader 的引导功能,所以启动被简化为:加载ELF格式内核到内存,QEMU 直接读取 ELF 文件头中记录的入口地址(Entry Point,即 _start 的地址),并把模拟 CPU 的 PC 寄存器直接设置为该地址。因此,实验操作系统的内核入口并没有放在上电启动地址,也能保证内核入口被正确跳转到。
难点分析
- 首先,我觉得非常难以理解的是
./Makefile,其干的事情又多又杂,无数依赖文件相互嵌套,陌生语法也很多,需要理很久 - 第二,我觉得 printk 函数也很难理解,其需要很多个函数的调用,且函数很长,c语言已经忘得差不多了,同时其还涉及变长参数和回调函数的理解
实验体会
lab1 给我的感觉就是东西很多很杂,感觉是操作系统的整个基础,掌握又非常的重要。感觉指导书是按照“面向对象”的逻辑去写的,但是我感觉“面向过程”的逻辑会更好理解一些。对实验平台的使用还是不是很熟练,这么多函数调用的情况下,我竟然忘记使用 grep 什么的了。在这里说一下实验过程中要注意的地方吧。
课下
Exercise 1.1
c语言中,把一个指针和一个整数相加时,编译器会自动乘以指针所指类型的大小。
所以
sh_table = ehdr->e_shoff + ehdr是错误的,其相当于ehdr + (e_shoff * sizeof(Elf32_Ehdr))GCC 编译器作为扩展,默认允许对 void * 做加减并将其当做 1 字节处理,除此之外,按字节移动的指针类型还有 char *等等
Elf32_Off sh_offset是节的文件内偏移,是相对于ELF文件头而言,段同理。
Exercise 1.3
- 栈的空间是高地址向低地址延伸的
- 跳转到 mips_init 函数,直接用
j就可以了,不用jal,因为其不需要返回
Exercise 1.4
- 我当时的第一想法是,直接利用
switch-case中的default处理非%的字符,发现这个完全行不通,因为当输入”I see u”的时候会落入到case 's'和case 'u'中,导致va_arg()访问越界
课上-exam
进行一个题目的照搬。
题目背景
符号表(.symtab 节)是 ELF 文件中的一个重要节,记录了程序中的符号信息(如函数名、变量名等等),在链接和调试等过程中都有很重要的作用。因此,本题要求你实现一个简化版的 readelf64,输出 ELF 文件中所有节头的相关信息,并找出符号表(.symtab 节)对应节头在节头表中的下标。
课下练习中,我们已经实现过一个简化版 readelf,用于解析 32-bit little-endian ELF 文件 并输出节头地址。本题在此基础上进一步扩展。出于实用性考虑,要求同学们解析 64-bit little-endian ELF 文件。
需要注意的是,64 位 ELF 的整体解析思路与 32 位版本基本一致,主要差别在于所使用的数据结构和部分字段类型不同。更具体地说,本题中最直接的变化是:原先使用的 Elf32_ 系列结构体/变量,需要相应替换为 Elf64_ 系列结构体/变量。其余区别同学们在本题中无需考虑。
在 ELF 文件中,节头表中的每一个表项都描述了一个节(section)的信息,包括该节在文件中的偏移、大小、类型等。每个节头表表项都对应一个 Elf64_Shdr 结构体。通过解析节头表,我们可以获得 ELF 文件中所有节的基本信息。
本题要求你实现一个简化版的 readelf64,输出:
- 该 ELF 文件中的 总节数(即节头表表项数量);
- 符号表(
.symtab节)对应节头 在节头表中的下标; - 每一节的 名称(后文讲解获取方法)、文件偏移(
sh_offset)和大小(sh_size)。
相关知识说明
A. ELF64 文件头(Elf64_Ehdr *ehdr,与 32 位版本相对应)
ELF 文件开头为 ELF 文件头(ELF Header),它描述了这个 ELF 文件的整体布局信息。
它的作用是:告诉我们后续该去哪里找 节头表、节名称字符串表 等关键信息。
在本题中,你只需要 重点关注该结构体的以下字段:
e_shoff:节头表在 文件中的偏移;e_shnum:节头表表项的数量;e_shstrndx:节名称字符串表 的索引(即,其对应节头在节头表中的下标, 相关知识说明 C 中会展开讲解)
对应到实现中,可以通过 e_shoff 和 e_shnum 拿到与遍历节头表,用 e_shstrndx 定位 节名称字符串表。
B. 节头表与节头结构体(Elf64_Shdr *sh_table,与 32 位版本相对应)
节头表(Section Header Table,代码中对应 sh_table),可以理解为 “全体节的目录”。
在本题中,可以直接将节头表视为由 Elf64_Shdr 构成的数组,其作用是:按顺序记录 ELF 文件中每一个节(section)的关键信息,便于程序遍历和查询。
节头表里的每一个表项都对应一个 Elf64_Shdr 结构体(也称为节头)。若将节头表 sh_table 视为数组,则节头包括:sh_table[0], sh_table[1], … … , sh_table[e_shnum-1]。也就是说,每个 Elf64_Shdr 结构体描述一个节的信息。
它们的作用是告诉我们每个节的名字、内容在文件中的位置、大小等信息。
本题 重点关注该结构体的以下字段:
sh_name:节名称在 节名称字符串表内容字符串 中的 偏移;sh_offset:该节在 文件中的偏移;sh_size:该节的大小。
注意:sh_name 不是字符串指针,而是一个 偏移量。要想获得节名,需要先找到 节名称字符串表内容字符串。
对应到实现时,你需要遍历所有 Elf64_Shdr,利用 sh_name 解析节名,而后输出节名及该节的 sh_offset 和 sh_size。
C. 节名称字符串表
节名称字符串表(通常为 .shstrtab 节),本身也是 ELF 文件中的一个节。
它的内容本质上是一个由 \0 分隔的字符串池,存放所有节名。
例如,其内容字符串可能类似(不含双引号 "):
1 | "\0.text\0.data\0.bss\0.symtab\0.strtab\0.shstrtab\0..." |
它的作用是:配合每个节头里的 sh_name 偏移,解析出真实节名。
以上述节名称字符串表为例,若某个节头的 sh_name 为 1,则该节的名称为 .text;若 sh_name 为 13,则该节的名称为 .bss。
那么如何找到节名称字符串表,又如何借此获取某一节的节名称呢?
注意,ELF 文件头中的 e_shstrndx 表示 节名称字符串表 对应 节头 在 节头表 中的下标。
由此,给出获取某一节 节名称 的过程:
- 根据
e_shoff找到节头表(Elf64_Shdr *sh_table = ((char *)binary + ehdr->e_shoff)); - 取出下标为
e_shstrndx的那个节头(Elf64_Shdr *shstrShdr = &sh_table[ehdr->e_shstrndx]); - 通过该节头的
sh_offset(内容在文件中的偏移),找到 节名称字符串表 的 内容字符串 的位置,存到char *shstrtab(请大家参考 1. 中e_shoff的用法,完成相关代码); - 对于某一节(若其对应节头为
shdr),其节名可由char *sectionName = shstrtab + shdr->sh_name得到。
题目要求
本题要求你阅读 tools/readelf64 目录下文件,并补全代码。
重点查看以下文件:
elf64_mini.h:给出了本题涉及的若干 ELF64 相关结构体/变量的定义供参考;readelf64.c:本题需要补全的核心代码。
你需要在 tools/readelf64/readelf64.c 中补全代码,完成以下功能:
- 读取 ELF64 文件头;
- 定位 节头表;
- 通过 节名称字符串表 解析 节头的名称;
- 遍历所有节头,输出每一节的:
- 序号(从
0开始) - 节名称(
section_name) - 文件偏移
sh_offset - 大小
sh_size
- 序号(从
- 在遍历过程中,找到名字为
.symtab的节,并输出它在节头表中的序号(从0开始计算)。
输出格式
要求你的程序输出格式如下:
1 | section_count=<count> |
其中:
- 第一行
<count>为节头总数,即节头表表项的数量; - 最后一行
<index>为名称恰好等于.symtab的节头序号; - 从第二行起,
[i]表示第i个节头的信息;<offset>为该节的sh_offset,以小写十六进制输出,带0x前缀,不补前导零;<size>为该节的sh_size,按十进制输出;- 即使某个节名称为空字符串,也应正常输出。
输出节头信息的格式串请严格使用:
1 | "[%d]\tname=\"%s\"\toffset=0x%lx\tsize=%lu\n" |
进行一个答案的照搬
1 |
|
课上-extra
题目背景
在操作系统内核中实现类似于 scanf 的格式化输入功能时,我们需要处理来自外部设备(如键盘、串口)的字符流。由于我们缺乏庞大的标准 C 库,你需要亲手实现一个精简版的格式化解析函数。
本题需要精准实现标准格式控制符(%c、%u、%s)对字符流的“吞吐行为”。 由于在解析变长数据(如读取数字和字符串)时,程序通常需要多读一个不属于该格式的字符才知道当前读取已经结束。因此,如何妥善保管这个“多读出来”的字符,不让它被丢弃,使得下一个格式符能继续正常解析,是本次实现的关键。
核心解析规则
你需要实现以下三种格式说明符。请严格遵循它们对“前导空白符”和“结束边界”的处理逻辑:
空白符定义:空格
' '、水平制表符\t、换行符\n、回车符\r。
%c(单字符)- 前导空白:绝不跳过。它会严格读取当前的下一个字符,无论是不是空白符。
- 结束边界:读取 1 个字符后立即结束。
- 状态要求:由于字符已被
%c实体消耗,你需要强制要求系统在下一次解析时重新去读取新字符(即将缓冲区置为无效)。
%u(无符号十进制整数)- 前导空白:必须跳过所有的前导空白符。
- 解析行为:匹配连续的数字(支持跳过前导加号
+,如+10解析为10)。如果遇到的第一个非空白字符就不是数字或+,直接赋值为0并结束。 - 结束边界:遇到第一个非数字字符时停止。
- 状态要求:导致停止的那个“非数字字符”必须原封不动地留在缓冲区中,等待下一个格式符处理。
- 数值范围:[0 , 2147483647) (小于
int范围)
%s(标准字符串)- 前导空白:必须跳过所有的前导空白符。
- 解析行为:连续读取非空白字符,并将其存入指针指向的内存中。
- 结束边界:遇到第一个空白符或
\0时停止,并在末尾自动补充\0封口。 - 状态要求:导致停止的那个“空白符”必须原封不动地留在缓冲区中,等待下一个格式符处理。
- 保证 0<字符串长度<20
你的任务(Task List)
请按顺序执行以下修改任务:
任务 1:添加头文件声明
在 include/printk.h 中加入以下声明(要加到#define _printk_h_和#endif /* _printk_h_ */的内部):
1 | // include/printk.h |
在 include/print.h 中加入以下声明(要加到#define _print_h_和#endif的内部):
1 | // include/print.h |
任务 2:实现顶层包装函数
在 kern/printk.c 中添加以下代码,实现字符流获取与 scan 的包装:
1 | // kern/printk.c |
任务 3:完成核心解析状态机
在 lib/print.c 中添加并补全以下代码。
注:骨架代码已经提供了单字节缓冲区 ch 和有效标志位 ch_valid 的定义,请利用它们完成【核心解析规则】中要求的行为逻辑。
1 | // lib/print.c |
进行一个答案的照搬
1 | // kern/printk.c |
1 | // lib/print.c |
