OS LAB1 内核、启动和printf
思考题
Thinking 1.1 在阅读附录中的编译链接详解以及本章内容后,尝试分别使用实验环境中的原生 x86 工具链(gcc、ld、readelf、objdump 等)和 MIPS 交叉编译工具链(带有 mips-linux-gnu- 前缀,如 mips-linux-gnu-gcc、mips-linux-gnu-ld),重复其中的编译和解析过程,观察相应的结果,并解释其中向 objdump 传入的参数的含义。
不同工具链的比较
使用
gcc -E hello.c > ori_prep.txt
和mips-linux-gnu-gcc -E hello.c > cross_prep.txt
,获取原生 x86 工具链和 MIPS 交叉编译工具链对于hello.c
的预处理结果,并通过命令diff ori_prep.txt cross_prep.txt -y
比较两个预处理结果的不同。可以看到不同工具链使用的工具路径不同、对某些变量的定义不同等。工具路径不同:
1
2
3
4
5
6
7
8
9
10
11# 0 "hello.c" # 0 "hello.c"
# 0 "<built-in>" # 0 "<built-in>"
# 0 "<command-line>" # 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4 | # 1 "/usr/mips-linux-gnu/include/stdc-predef.h" 1 3
# 0 "<command-line>" 2 # 0 "<command-line>" 2
# 1 "hello.c" # 1 "hello.c"
# 1 "/usr/include/stdio.h" 1 3 4 | # 1 "/usr/mips-linux-gnu/include/stdio.h" 1 3
# 28 "/usr/include/stdio.h" 3 4 | # 28 "/usr/mips-linux-gnu/include/stdio.h" 3
# 1 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" | # 1 "/usr/mips-linux-gnu/include/bits/libc-header-start.h" 1
# 33 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" | # 33 "/usr/mips-linux-gnu/include/bits/libc-header-start.h" 3
# 1 "/usr/include/features.h" 1 3 4 | # 1 "/usr/mips-linux-gnu/include/features.h" 1 3变量的定义不同:
1
2
3
4
5
6typedef long unsigned int size_t; | typedef unsigned int size_t;
# ...
typedef long int __quad_t; <
typedef unsigned long int __u_quad_t; <
> __extension__ typedef long long int __quad_t;
> __extension__ typedef unsigned long long int __u_quad_t;objdump 参数
1
2
3
4
5# objdump --help
-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>-D
表示从给定文件中反汇编所有的 section(区别于-d
:反汇编特定指令机器码的 section)。-S
表示尽可能反汇编出源代码,尤其当编译的时候指定了-g 这种调试参数时,效果比较明显。隐含了-d 参数。Thinking 1.2 思考下述问题:
• 尝试使用我们编写的 readelf 程序,解析之前在 target 目录下生成的内核 ELF 文件。
• 也许你会发现我们编写的 readelf 程序是不能解析 readelf 文件本身的,而我们刚才介绍的系统工具 readelf 则可以解析,这是为什么呢?(提示:尝试使用 readelf -h,并阅读 tools/readelf 目录下的 Makefile,观察 readelf 与 hello 的不同)
1
2
3
4
5
6# Makefile
readelf: main.o readelf.o
$(CC) $^ -o $@
hello: hello.c
$(CC) $^ -o $@ -m32 -static -g由 Makefile 可知,可执行文件
hello
使用了-m32
选项,强制生成了 32 位程序;而readelf
未指明-m32
选项,默认生成了 64 位程序。使用file
指令查看可验证:1
2
3
4file readelf
readelf: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=82805d7e063d175cdba625c3a8548f736ae88ec6, for GNU/Linux 3.2.0, not stripped
file hello
hello: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, BuildID[sha1]=d35d87016cbcf9f641bc0d66a36ddfcf729f2d13, for GNU/Linux 3.2.0, with debug_info, not stripped而
readelf.c
中使用了Elf32_Ehdr
等 32 位 ELF 结构体,因此./readelf
只能正确解析 32 位 ELF 文件,当解析自身时,e_shoff
等字段偏移错误,导致无法定位节头表。Thinking 1.3 在理论课上我们了解到,MIPS 体系结构上电时,启动入口地址为 0xBFC00000(其实启动入口地址是根据具体型号而定的,由硬件逻辑确定,也有可能不是这个地址,但一定是一个确定的地址),但实验操作系统的内核入口并没有放在上电启动地址,而是按照内存布局图放置。思考为什么这样放置内核还能保证内核入口被正确跳转到?(提示:思考实验中启动过程的两阶段分别由谁执行。)
基于 MIPS 架构的 Bootloader 分为 stage1 和 stage2 两个部分。CPU 上电时,取值寄存器复位到启动入口地址(如 0xBFC00000),直接从非易失存储器(如 ROM 或 FLASH)中加载 bootloader 程序,执行 stage1 阶段任务,随后跳转到 RAM 中的 stage2 入口。在 stage2 中 bootloader 将内核镜像从存储器读到 RAM 中,并为内核设置启动参数,最后将 CPU 指令寄存器的内容设置为内核入口函数的地址,由此可以正确跳转到内核入口处,且将 CPU 控制权由 bootloader 转交给操作系统内核。
因此,CPU 上电时的启动入口地址只是用于加载 bootloader 的 ROM(或 FLASH) 地址;而内核入口由 bootloader 在 RAM 中进行内核初始化时指定(根据链接脚本
kernel.lds
)。二者并不冲突。
难点分析
实验代码主要文件(以下将实验仓库视为根目录)
/Makefile
:构建整个操作系统的顶层 Makefile,用于指导源代码形成最终的可执行文件。/kernel.lds
:/init/start.S /init/init.c
:初始化内核。start.S
文件中的_start
函数是 CPU 控制权被转交给内核后执行的第一个函数,主要工作是初始化 CPU 和栈指针,为之后的内核初始化做准备,最后跳转到init.c
文件中定义的mips_init
函数。/include/
:存放系统头文件。本章中mmu.h
头文件有一张内存布局图,填写 linker script 时需要依据其来设置相应节的加载地址。/kern/
:存放内核的主体代码。
一些 Makefile 语法
1
2$(modules): # 进入各个子目录进行 make
$(MAKE) --directory=$@$@
会被展开为当前目标的名称(即$(modules)
中的模块名,如kern
等),$(MAKE) --directory=$@
的执行效果等同于手动切换到相应目录再调用make
。1
2
3
4
5clean:
for d in $(modules); do \
$(MAKE) --directory=$$d clean; \
done; \
rm -rf *.o *~ $(mos_elf)\
代表这一行没有结束,下一行的内容和这一行是连在一起的。(注:上面代码\
不能删!否则相当于开了四个bash
终端依次输入了这四行代码。)ELF(Executable and Linkable Format)是一种可用于可执行文件、目标文件和库的文件格式。
.o
文件是 ELF 所包含的三种文件类型中的一种,称为可重定位(relocatable)文件,其他两种文件类型分别是可执行(excutable)文件和共享对象(shared object)文件。后两种文件都需要链接器对可重定位文件进行处理才能产生。
ELF 文件结构:
段头表和节头表指向了同样的地方,意味着两者只是程序数据的两种视图:
- 组成可重定位文件,参与可执行文件和可共享文件的链接。此时用节头表。
- 组成可执行文件或可共享文件,在运行时为加载器提供信息。此时用段头表。
ELF 文件内容
struct Elf32_Ehdr
ELF 头unsigned char e_ident[EI_NIDENT]
:存放魔数及其他信息,用于验证这是一个有效的 ELF 文件(不能通过文件扩展名来验证)。struct Elf32_Shdr
节头struct Elf32_Phdr
段头
ELF 文件段信息(
readelf -l <elf-file>
查看)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22Elf 文件类型为 EXEC (可执行文件)
Entry point 0x8049750
There are 8 program headers, starting at offset 52
程序头:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x001e0 0x001e0 R 0x1000
LOAD 0x001000 0x08049000 0x08049000 0x657c0 0x657c0 R E 0x1000
NOTE 0x000134 0x08048134 0x08048134 0x00044 0x00044 R 0x4
TLS 0x0989e8 0x080e09e8 0x080e09e8 0x0000c 0x0002c R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
Section to Segment mapping:
段节...
00 .note.gnu.build-id .note.ABI-tag .rel.plt
01 .init .plt .text .fini
02 .rodata .eh_frame .gcc_except_table
03 .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data .bss
04 .note.gnu.build-id .note.ABI-tag
05 .tdata .tbss
06
07 .tdata .init_array .fini_array .data.rel.ro .gotOffset
:代表该段的数据相对于 ELF 文件的偏移;VirtAddr
:代表该段最终需要被加载到内存的位置(QEMU 在加载内核时,按照内核【可执行文件】中记录的这个地址将内核中的代码、数据等加载到相应位置);FileSiz
:代表该段的数据在文件中的长度;MemSiz
:代表该段的数据在内存中应当占的大小(MemSiz
始终大于等于FileSiz
,采用填 0 法填充);Segment mapping
:表明每个段各自含有的节。
MIPS 内存布局——内核运行的正确位置
kuseg
:用户态下唯一可用的地址空间(内核态下也可用),需要使用 MMU 中的 TLB 完成虚拟地址到物理地址的转换。存取都会经过 cache。kseg0
:内核态下的可用地址,MMU 将最高位清零就得到物理地址用于访存。也就是说,这段虚拟地址被连续地映射到物理地址的低 512MB 空间。存取都会经过 cache。kseg1
:内核态下的可用地址,高三位清零得到物理地址用于访存。同样连续地映射到物理地址的低 512MB 空间。但是对这段地址的存取不经过 cache ,通常在这段地址上使用 MMIO 技术来访问外设。kseg2
:内核态下的物理地址, 需要 MMU 中的 TLB 将虚拟地址转换为物理地址。对这段地址的存取都会经过 cache。
TLB 需要操作系统进行配置管理,因而在载入内核时不能选用需要通过 TLB 的
kuseg
和kseg2
。而不经过 cache 的kseg1
通常用于访问外设。因此我们 将内核放置在kseg0
。需要注意,
kuseg
、kseg0
、kseg1
以及kuseg
和kseg2
位于不同的虚拟地址空间,但是都映射到同一个物理地址空间。不同只在于映射方式和访问权限。对于虚拟地址空间和物理地址空间的关系,下面示意图中有较为清晰的展示。Linker Script——控制内核加载地址
将内核加载到想要的内存地址依赖于 Linker Script 。在链接过程中,目标文件被看成 section 的集合,并使用节头表(section header table)来描述各个 section 的组织。换言之,section 记录了链接过程中的必要信息。其中最为重要的三个 section 为
.text
、.data
和.bss
。关于链接后的程序从何处开始执行。程序执行的第一条指令的地址称为入口地址(entrypoint)。我们的实验就在
kernel.lds
中通过ENTRY(_start)
来设置程序入口为_start
。实现
printk
C 语言的
printf
函数由 C 语言的标准库提供,而 C 语言标准库建立在操作系统基础之上。因此开发操作系统时需要自己实现printk
函数。```c // kern/machine.c void printcharc(char ch) { … ((volatile uint8_t )(KSEG1 + MALTA_SERIAL_DATA)) = ch; // 让控制台输出一个字符,实际上是对某一个内存地址写了一个字节 }
// kern/printk.c void outputk(void data, const char buf, size_t len) { // 用来输出一个字符串 for (int i = 0; i < len; i++) { printcharc(buf[i]); } }
void printk(const char *fmt, …) { va_list ap; // 定义变长参数表 va_start(ap, fmt); // 初始化变长参数表 vprintfmt(ouputk, NULL, fmt, ap); va_end(ap); // 结束使用变长参数表 }
1
2
3
4
5
6
7
8
当函数参数列表末尾有省略号时,该函数即有变长的参数表。由于需要定位变长参数表的起始位置,函数需要含有至少一个固定参数,且变长参数必须在参数表的末尾。`stdarg.h` 头文件(Linux 自定义头文件)中为处理变长参数表定义了一组宏和变量类型:`va_list`、`va_start(va_list ap, lastarg)`、`va_arg(va_list ap, 类型)`、`va_end(va_list ap)`。
```c
// lib/print.c
void vprintfmt(fmt_callback_t out, void *data, const char *fmt, va_list ap) {
...
}vprintfmt
是一个公共链接库函数,用于解析格式化字符串,通过回调函数out
完成输出。这样,实现了解析逻辑和输出逻辑的解耦,使程序更加可维护;同时printk
等上层函数可传入不同的回调函数实现不同的输出行为(例如输出到文件)。data
参数是回调函数out
需要的额外上下文信息,需要被vprintfmt
按原样传入out
,可以是输出的目标内存地址等。(可以类比面向对象语言中的设计,将out
视为 “ 继承自接口的方法实现 ” ,data
则则类似方法中的this
指针)
实验体会
- 需要弄清楚操作系统的启动流程,分为两个阶段;
- 需要掌握操作系统内核的产生过程,主要涉及内核(ELF 可执行文件)的内容、内核在内存中运行的正确位置、使用 Linker Script 指定内核在内存中的位置;
- 体会实现内核功能(如
printk
函数)的过程。
原创说明
部分内容参考了 《2025 操作系统 Lab1 串讲》以及 DeepSeek。