OS_lab1
Thinking 1.1
尝试分别使用实验环境中的原生 x86 工具链(gcc、ld、readelf、objdump 等)和 MIPS 交叉编译工具链(带有mips-linux-gnu- 前缀,如 mips-linux-gnu-gcc、mips-linux-gnu-ld),重复其中的编译和解析过程,观察相应的结果,并解释其中向 objdump 传入的参数的含义。
写了一个 night.c 程序作为实验对象:
1 |
|
1 | gcc -E night.c |
前面补上一大堆 typedef 、struct 和 extern
1 | gcc -c night.c |
night.o :
反汇编:
1 | objdump -DS night.o > night.s |
文件格式:elf64-x86-64
用交叉编译工具链 gcc + 反汇编出的是 elf32-tradbigmips,看起非常亲切(终于有个能看懂的了)
readelf
发现 Entry Point 不同,正常,ABI 不同,start_ 也不同;
发现连 段头/节头 的数目都不同,MIPS 少一些。
objdump 中的参数:
1 | -S # 尽量 “源代码 + 汇编混合显示”,但我没加 -g 调试模式,所以没起效 |
Thinking 1.2
• 尝试使用我们编写的 readelf 程序,解析之前在 target 目录下生成的内核 ELF 文件。
• 也许你会发现我们编写的 readelf 程序是不能解析 readelf 文件本身的,而我们刚才介绍的系统工具 readelf 则可以解析,这是为什么呢?(提示:尝试使用 readelf-h,并阅读 tools/readelf 目录下的 Makefile,观察 readelf 与 hello 的不同)
实验:
用我们编写的 readelf 文件解析 target/mos 的结果是:
1 | 0:0x0 |
而用系统工具 readelf 解析结果是(我用的是 -a ,太长了,省略部分):
1 | ELF 头: |
系统 readelf 解析我的 readelf:
我发现我的 readelf 有重定位节。
我的 readelf 自我解析 && 我的 readelf 解析系统 readelf:
解析出来俩空文件。
系统的 readelf 自我解析:
思考:
- 为什么我的
resdelf不能自我解析?注意到虽然解析输出为空但并没有报错,应该不是 “自己解析自己” 这个操作不允许。我认为是由于我们的readelf并非完全版,是针对MOS内核的(比如默认了一些取值 🤔),我们的readelf自身显然不是内核,所以解析不出来。 - 对哦,我们的
readelf全是针对 32 位的,但它自己能在宿主机运行,那肯定是 64 位的,所以解析不了。 - 系统的 “通用 readelf“ 自然是通用的。
Thinking 1.3
在理论课上我们了解到,MIPS 体系结构上电时,启动入口地址为 0xBFC00000(其实启动入口地址是根据具体型号而定的,由硬件逻辑确定,也有可能不是这个地址,但一定是一个确定的地址),但实验操作系统的内核入口并没有放在上电启动地址,而是按照内存布局图放置。思考为什么这样放置内核还能保证内核入口被正确跳转到?
(提示:思考实验中启动过程的两阶段分别由谁执行。)
- pre:两阶段由谁执行:
- CPU 固定复位到 BootLoader,这个由硬件执行。
- BootLoader 引导 CPU 跳到内核,这个由 BootLoader 执行。
- 所以为什么:
- CPU 本身不认识内核入口,但 BootLoader 认识,BootLoader 会通过 ELF 头找到和内核的契约,然后引导 CPU 跳过去。
- 欸?所以 BootLoader 也是一种
readelf?还是说它只是接受readelf解析出的结果?🤔- 关键 BootLoader 起作用的时候
readelf还不在运行链路里啊。- GPT 说 BootLoader 包含一小段和
readelf相似的逻辑,来解析 ELF 头。
- GPT 说 BootLoader 包含一小段和
- 关键 BootLoader 起作用的时候
交叉编译:
编译器生成的 目标代码 与 编译器本身 在不同的平台上运行。为什么要交叉编译?因为 QEMU 模拟的是 MIPS 平台环境,我们的 MOS 是在 MIPS 上跑的;而我们的跳板机显然是在 X86 上跑的。
1 | gcc |
QEMU 的简化版启动:
QEMU 模拟器支持直接加载 ELF 格式的内核,也就是说,QEMU 已经提供了 bootloader 的引导(启动)功能。MOS 操作系统不需要再实现 bootloader的功能。
我们在理论课上学习了相当复杂的启动流程,主要包括 ① BootLoader 引导 ② 硬件环境逐步复苏 ③ 将内核加载到内存中的指定位置 ④ CPU 跳转到内核入口执行。而 QEMU 模拟器帮我们实现了大部分,启动流程被简化为 ③④。
启动
init/start.S 中的 _start 函数是 CPU 控制权被转交给内核后执行的第一个函数,主要工作是初始化 CPU 和栈指针,为之后的内核初始化做准备。最后跳到 init.c 中的 mips_init。观察 mips_init 发现它只是一个占位的东西,还没有实现任何内核相关内容。
所以其实我们 lab1 干的事儿跟内核啥关系没有。
Makefile:
上来就是一句:.ONESHELL: 意味着我们可以不去注意 Makefile 中 “换行即换 shell“ 这件事。
1 | Lab1 |
Makefile 里的东西很多很杂,而且不乏涉及到一些与实验无关内容,我没有试图完全理解,下面挑重点描述:
1 | target_dir := target # 最终的 MOS 构建目标所在目录 |
简要阅读 Makefile 即可发现,其中存在大量 “指来指去” 的内容,化简完如下:
1 | all: $(mos_elf) |
注意这里没有像通常那样把 all 作为第一个(默认)规则,而是 clean-and-all,可能是出于方便考虑,我们无需手动清理之前生成的副产物,直接 make 就行。注意评测命令是 make && make run。
变长参数列表
比如这段代码:
1 | void printk(Const chsr *fmt, ...) { |
1 | va_list ap; // 变长参数 |
初始化变长参数:
1 | va_start(ap, fmt /* 指向 ... 前的最后一个参数 */); |
(连续多次地)使用 va_arg 来接受 … 里的参数,比如 va_arg(ap, int); 就会读到一个 int 类型的值。
1 | int a = va_arg(ap /*...*/, int /* 指定接受参数的类型 */); |
注意这里最少接受 4 个字节的参数,char 不允许。
最后:
1 | va_end(ap); |
有关为什么
printk为什么要使用回调函数,因为输出函数不只有printk,(还有printf,sprintf……),都使用vprintfmt接口,通过指定(设计)回调函数来指定(设计)输出方式。
ELF:
先看
elf.h。我对
.h文件 0 了解,所以刚看到elf.h是懵的,因为在我的认知中,struct中变量出现的先后顺序是无所谓的,但这里显然要用变量在struct中出现的顺序取对应它们在ELF中出现的顺序。总的来说,ELF 里 文件头 肯定是最先出现的,剩下的 节/段 出现(其实是指解析)的顺序取决于 ELF 头中指定的偏移量(大致上是无所谓的),【节/段 内部的顺序严格对应
elf.h中规定的顺序】【并非如此】
有问题。
P.S. 这张图我忘了是哪儿来的了,可能是从哪位学长或者学姐的博客里拿来的……反正不是我自己画的
C 语言相关
C 语言环境分为 Freestanding 和 Hosted 两种,前者有操作系统支持,后者没有。显然没有操作系统支持的 Freestanding 不可能支持 I/O 操作,所以 我们使用的 Freestanding C 语言环境 + 我们实现的 I/O 操作 ➡️ 能进行 I/O 操作的 C 语言环境。
独立环境下能使用的头文件:
1 | <float.h> |
然后我们下发的代码又给我们补了一些,比如说 string.h,里面的也是我们能直接用的。
回调函数 && printk
这四个代码一眼看上去有点乱。printk.c 和 print.c 如胶似漆(各种相互调用),print.h 和 printk.h 存在感模糊。
printk.c 的 outputk 到 print.c 里还改了个名字叫做 out,我一开始在根目录下 grep -r "out" 啥也没找着。
我后来明白为什么要改名字了。
outputk只是printk对应的输出方式(输出到 stdout),如果,我要加一个sprinfmt,我给它写一个,比方说,soutput,那soutput也要走out接口,如果还管他叫outputk就不合适了。
其实就是这样:⬇️
回调函数首先是在 print.h 中出现的:
1 | typedef void (*fmt_callback_t)(void *data, const char *buf, size_t len); |
这里命名了一个函数指针类型 fmt_callback_t,其指向的函数格式为:
1 | void somedef(void *data, const char *buf, size_t len) |
它被用在 vprintfmt 里:
1 | void vprintfmt(fmt_callback_t out, void *data, const char *fmt, va_list ap); |
这里的注释挺详细的,奈何我不懂英文。
注释:
vprintfmt是一个格式化函数,它允许使用不同的后端(即输出目标)来完成打印。它接收四个参数:
fmt_callback_t out:一个指向输出目标的函数指针,这个输出目标函数负责接收vprintfmt生成的格式化输出。
void *data:一个上下文指针,会被传递给out回调函数。它可以用来保存与具体输出目标相关的额外数据。
const char *fmt:格式字符串,和printf的格式字符串类似。
va_list ap:可变参数列表,提供要被格式化的那些参数。格式化回调函数
out接收下面这些参数:
void *data
- 就是传给
vprintfmt的那个同一个data指针。const char *buf
- 指向一个缓冲区,这个缓冲区里存放的是
vprintfmt生成好的格式化输出。size_t len
- 这个缓冲区中有多少字节有效。
注意,这个缓冲区不一定以
\0结尾,而且里面还可能包含嵌入的\0字节,因此输出目标在处理时,应该把len当作真正需要输出的长度。也就是说,这里的字符串输出并非像我们平时用的读到 ‘\0’ 自动结束,而是从 buf[0] 到 buf[len-1],输完为止。
具体表现为 printk.c 中的:
1 | /* 正 */ |
本质上就是 print.c 和 printk.c 里函数的相互调用。
这里的 *fmt 都是用户程序提供的格式化字符串,比如说 "%s, %-4d",*buf 都是整理好准备输出的字符串,比如说 "hello, 3 "。
这里还有个很有意思的事儿,先把负数取绝对值再连同负号输出,这遇到 LONG_MIN 不就炸了吗。
scancharc
lab1 没有任何涉及到输入的上层实现,但我发现 machine.c 里有一个孤零零的 scancharc,它和 printcharc 差不多,都是最底层的实现。
我仿照 outputk 写了一个 inputk:















