Project 3: JOS lab1
bootstrap of JOS lab1.
声明:努力不再重复与uCore相同的知识栈部分。
Exercise1: Assembly familiarness.
The difference of AT&T and Intel is quite big. 至少有一个东西我经常搞混球:
AT&T: movl %eax, %ebx ;; load ebx with the value of eax. ->
Intel: mov ebx, eax ;; <-此外,还有一个比较值得学习的东西。即Inline Assembly。这玩意我是真的不怎么会,用到了再查询吧。
简单看一下资料GCC-Inline-Assembly-HOWTO
// 学习基本流程,查阅Intel手册去寻找指令的意思。
asm ("cld\n\t" // cld指令,清空EFLAGS中DF寄存器,DF为0时,字符串操作会增加ESI或EDI的值
"rep\n\t"
"stosl"
: /* no output registers */
: "c" (count), "a" (fill_value), "D" (dest)
: "%ecx", "%edi"
);RTFM一下rep的功能,可以注意到下面的细节:

上面的汇编用了这边的固定用法,因此上面的代码解读为在dest位置处用fill_value写入一共count次。并且告知GCC你的ecx和edi寄存器已经过修改,不再是valid的值。即通知GCC不要再用这一部分寄存器存放其他的值。
这说明RTFM是一个合理的方法。
Other cases:
我们再看之后的一些有趣的例子:
通过RTFM我们可以得知,由于6.828基于32位架构,这里的btsl其实表示对于long words的操作,即4 Bytes大小单位的操作。具体来说,对于Input,设置了pos,I表示其范围在0~31之间,ADDR则是存在内存中的一个数。
因此具体来说,ADDR内存对应的数的第pos位被提取出来赋值给CF,与此同时原位置被修改为1。在清楚btsl指令的情况下,gcc编译器知道内存被修改了,但可能不知道状态寄存器改变了,所以需要添上cc。
还有两个例子,尝试解读之(不一定要自己写成这样,那样感觉还是有点难度的)


就这么对照吧,当然为了实现同一个字符串copy的操作,可以用上面诸如cld等等指令的配合。
最后还有一个例子,那么大致上inline汇编就不再是个问题了。
Whenever a system call with three arguments is made, the macro shown above is used to make the call. The syscall number is placed in eax, then each parameters in ebx, ecx, edx. And finally "int 0x80" is the instruction which makes the system call work. The return value can be collected from eax.
仿照以前写cs61c的经验,准备好参数然后int $0x80中断一下就了事。
PC physical address:
JOS启动为了兼容性保留了8086机器的一部分。材料中提到,以前的BIOS是真实存在ROM之中,而现在往往放在闪存里头。
start point:
上述即为一开始启动的代码,可以看到启动位置为0xffff0,在上述框图的BIOS ROM的位置附近。
Exercise 2. Use gdb to Trace Instructions.
我们使用si进行追踪。
阅读资料:
可以看出对于CMOS的使用是8086的一个convention,也算是8086启动的时候的一个dirty knowledge. 我们简单总结一下在实模式下系统启动做了什么:检查内存和寄存器能否使用,打开CMOS内存接口,设置中断表a
进入了386模式,即保护模式之后,我们来检查一下干了什么。现在可能希望做一次console上的输出,所以先把0x10这个值分布到eax上和其余段寄存器上。
并没在后续中找到我想要的`int`指令以开启VGA输入功能,令本鼠鼠感叹。不得不说底层还是有很多dirty的知识,没有太大必要一定要掌握。直接继续看材料吧。
Part 2. The Boot Loader
在初始化了PCI总线和其余BIOS所知的主要设备后,为了启动,机器将会开始去寻找可以用于开机启动,即含有boot-loader的设备。如果找到,BIOS将会读取boot loader并对其展开控制。
PC扇区大小为512字节,是磁盘传输的最小粒度。第一个扇区被称为启动扇区,内部有boot loader组件。第一个扇区会被装载到0x7c00~0x7dff这个范围。
与现在利用CD-ROM来启动系统不同,6.828中依然使用较早的方式来启动,维持512 bytes启动扇区这一规则。
各模式简介,参考https://pdos.csail.mit.edu/6.828/2018/readings/pcasm-book.pdf
实模式:16-bit -> 20-bit memory address for physical.
16-bit 保护模式:16-bit -> 20-bit memory address for virtual. (too small)
32-bit 保护模式:添加了更大的地址空间,满足4GB,同时添加了分页的机制。
在源代码中已经有了很多的解读,做好理解感觉就足够了。
JOS启动具体的流程:
boot.S
实模式:关闭中断flag -> 段寄存器清空 -> 远古设备兼容I/O设置 -> 中断表读入 -> 修改CR0寄存器 -> 跳转进入保护模式!
保护模式:设置好数据段寄存器 -> 进入main函数 (由ASM->C)
特别的:CR0寄存器的最后一位是保护位,如果为1,则会准备进入保护模式。
main.c
看不懂具体的端口号对我这种强迫症来说确实挺难受的,chatgpt似乎明白我的苦恼。
在6.828实验1中,您需要了解的一些I/O ports包括:
串口端口(COM1): 用于与计算机串口通信。在x86架构中,COM1的端口号是0x3f8,COM2的端口号是0x2f8,COM3的端口号是0x3e8,COM4的端口号是0x2e8。
键盘端口(PS/2键盘): 用于与计算机键盘通信。在x86架构中,键盘控制器的端口号是0x60。
显示器端口(EGA/VGA显示器): 用于控制计算机显示器。在x86架构中,VGA显示器的控制器端口号为0x3d4和0x3d5,EGA显示器的控制器端口号为0x3c0和0x3c1。
对于以上提到的端口号和功能,您可以参考一些资料来了解更详细的信息。以下是一些可能有用的资源:
Bochs模拟器手册(英文版):Bochs是一个开源的x86模拟器,它的手册中包含了一些有关x86体系结构和I/O端口的信息。
6.828课程的实验指导(英文版):这是6.828课程的官方实验指导,其中包含了一些有关I/O端口的信息和代码示例。
Exercise 3: trace
I've already traced once.
After the loading the kernel, we have these codes below: this tell us where we'll jump to.
At what point does the processor start executing 32-bit code? What exactly causes the switch from 16-bit to 32-bit mode?
ljmp $PROT_MODE_CSEG, $protcseg
What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?
movw $0x1234,0x472
Where is the first instruction of the kernel?
movw $0x1234,0x472
How does the boot loader decide how many sectors it must read in order to fetch the entire kernel from disk? Where does it find this information?
from ELF. We have these codes below:
To understand this, read the following link: ESPECIALLY FOR LOADING PART.
Exercise 4: C pointer.
Skip this part.
Exercise 5: Change link address to a wrong one.
A very strange phenonmenon: even when we change the entry of the .text to 0x7c00, the initialization seems to act rightly.
BIOS会把指令装载到0x7c00这个位置,这其实是一个历史包袱,所以本质上并不会影响前述指令的执行,但链接器异常的影响依然存在,所以我们看到后续的指令执行出现了错误。
Exercise 6: Examine the memory address 0x00100000
首先,我们利用下面的命令来查看利用bootloader装载的内核信息。
可以看到ELF的起始地址是0x001000c,尝试用GDB跟踪一下。
首先,我们需要在0x001000c这个位置打一个断点,然后才能保证kernel确实已经加载完成了。
说实话,并没有太清楚这样做的意义何在。但0xc这样的偏移的存在似乎能够给人一个提示,即,即便利用linker设置了源码的进入入口,也不完全就会把这些代码设置在这个位置。
此外,在kernel.asm中我们可以看到,内核实际上被放在了一个比较高的位置上,在正式编译成内核时,似乎会添加3个字长的奇怪的代码信息,它们也不会被拿去执行(因为直接通过entry跳到了0x10000c处了,猜测是x86的一些历史遗留问题):
Exercise 7: use QEMU and GDB to trace into the JOS kernel
操作系统似乎很喜欢被链接到很高的位置上运行,这样的行为出于很多原因,其一便是给用户低地址空间自由使用的权利。注意,当我们说链接时,往往指的是虚拟内存。如果是装载,这个名词显然是指在物理内存中进行的。因此,在JOS的模型中,系统被装载在从1MB开始的空间中,但通过虚拟内存我们提供了一个假象,即Linker是把这个东东装载到虚拟内存的0xf010000处再运行内核的。
根据JOS文档,干这件事的是kern/entrypgdir.c,不过幸运的是现在我们并不需要对此程序做完全的解读,只需知道在我们开启CR0最后一位,并且进入保护模式之时,这个转换就已经发生了。
跟踪一下发现,确实如此,调整CR0后,于此同时这个东东就开始装载虚拟内存了。
把这一行,即mov %eax, %cr0注释掉,重新跟踪一遍,发现下面的结果:
在CR0没有开启保护模式下出现了这样的结局,因为并没有把原本位于物理地址0x00100000附近的指令给装载过来,那如果想要跳转过来运行的话,不出问题才怪呢。
Exercise 8: Format Printing.
We have omitted a small fragment of code - the code necessary to print octal numbers using patterns of the form "%o". Find and fill in this code fragment.
这个练习甚至不用读代码,抄一下其它case就完事了,如果要加颜色看一下ANSI就好了,总之很简单。
Explain the interface between
printf.candconsole.c. Specifically, what function doesconsole.cexport? How is this function used byprintf.c?
console.c文件为printf.c文件提供了cputchar, vprintfmt这样的函数。
根据源码分析,在完善终端输出字符串这一过程中,需要完成序列化初始化,这一部分我在代码里做了简要的注释,然而参考资料过少确实也让解读起来摸不着头脑,为什么要按照这样的顺序做更是完全不知道,不过老旧的设备也有令人觉得有趣的地方,比如读写共享寄存器作为buffer之类的玩意儿。
Parallel port部分的链接全都挂了,不过在OSDEV上能够找到简要的介绍,其流程与console.c中所示的很接近。
对于CGA port部分的解读请参考这一部分资料,大致可以猜猜看他在干什么:
对于cga_init初始化函数,首先似乎有一层判断,判断是否是常规的CGA模式还是MONO模式,之后再对照手册校准cursor位置,不过只能说大意如此,全都是坑。
在console.c中我们可以注意到cga_putc与cons_putc的交叉递归,只需要考虑几个特殊case就能想清楚这个递归的调用顺序了。这里涉及部分与换行相关的特殊字符的处理,值得学习。
Explain the following from
console.c:
解读到这里答案便是呼之欲出了,如果crt_pos大于等于页面大小CRT_SIZE,则需要准备往下继续添加了,同时把最上面一行去掉,因为这个页面已经装不下了。我们查看memmove函数,发现恰如其分地完成了这个需求:
最后一行这时候应该显示为空,所以我们用了一个for循环进行赋值。有一说一这也过于简陋了。crt_pos这时候也该后退一下。第二题结束。
我们到这里也确实完成kern/printf.c中的putch函数的口胡解读。
下面尝试解读函数vcprintf:其中调用了函数vprintfmt,我们追溯这个函数。
函数太长,本人的注释写在愿文件中,建议看一下源文件。特别的,va_list相关的操作可参考下面的资料:
具体来说,va_list相关的宏被广泛使用在非定长的函数中。在实现vprintfmt时,请参考原本printf所示的资料。令人感叹,printf居然还有这么多功能。
有上面的基础后,我们看一下习题:
Trace the execution of the following code step-by-step:
In the call to
cprintf(), to what doesfmtpoint? To what doesappoint?
fmt points to "x %d, y %x, z %d\n", ap points to the list of [x, y, z].
List (in order of execution) each call to
cons_putc,va_arg, andvcprintf. Forcons_putc, list its argument as well. Forva_arg, list whatappoints to before and after the call. Forvcprintflist the values of its two arguments.
我们追踪启动时的代码,把上面的代码放在i386_init函数内部,即可利用gdb调用查看。调用结果如下展示:cputchar确实会一个一个地把字符输出并且拼接。把这个东东跟完似乎没有太大意义,我们就跟一个吧。
在跟踪这个过程中感觉这个代码写得真漂亮啊,可惜我写不来。
第三题结束。
第四题修改一下跑一下即可,
倒是挺有黑客的意思。
In the following code, what is going to be printed after
'y='? (note: the answer is not a specific value.) Why does this happen?
通过gdb观察,y的值完全取决于ap list后一项到底是什么东东。这玩意由于不受控制,完全是个随机的东东。1632其实就是0x660。
Let's say that GCC changed its calling convention so that it pushed arguments on the stack in declaration order, so that the last argument is pushed last. How would you have to change
cprintfor its interface so that it would still be possible to pass it a variable number of arguments?
这个问题其实是个不那么trivial的问题,首先,为什么GCC调用规范要要求从最后一个参数开始往栈内压数据呢?有很多网上的答案说因为变长参数函数不知道到底有多少参数,所以要用这个方式来做。但实际上这句话只说对了一半。在我们大部分情况下使用printf这样的函数时,编译器完全可以很轻松地把参数个数搞到。
真正的应用场景其实是在我们调用动态库利用里面的函数的时候,在加载它们进入内存并与我们的文件建立链接、重定位并且运行之前,没有人知道这个函数长什么样,有几个参数。
那么,应该怎么做才能打破这个调用规范呢?我想到了一个很笨笨的方法,手动拆分掉我们的va_list,转而调用很多很多个一次只能解析一个参数的函数。也就是说,原本我们是对一个函数分配不确定的栈,现在我们用很多函数分配确定的栈空间,至于函数有多少个,我们用一个loop就好了捏。
Exercise 9: About Stack.
Determine where the kernel initializes its stack, and exactly where in memory its stack is located. How does the kernel reserve space for its stack? And at which "end" of this reserved area is the stack pointer initialized to point to?
最早内核是在文件kern/entry.S中实现内核栈的建立。具体代码如下:
其中KSTKSIZE是内核栈的大小,我们可以看到这个栈的建立与存在,其栈底为0,栈顶为bootstacktop所指向的地址。
Exercise 10: backtrace stack.
To become familiar with the C calling conventions on the x86, find the address of the test_backtrace function in obj/kern/kernel.asm, set a breakpoint there, and examine what happens each time it gets called after the kernel starts. How many 32-bit words does each recursive nesting level of test_backtrace push on the stack, and what are those words?
这个问题需要调试来得知,不过感觉效果确实不错。
我们利用断点打在函数test_backtrace之上,可以看到return address应该是0xf01000f4。与此同时,来看一下此时的函数ebp与esp信息和栈之间存储的信息。
在运行test_backtrace函数之前,栈信息尚为i386_init的函数栈信息,我们可以看到5被压入了函数栈中,但这并不重要。注意到ebp + 4其实是返回地址,打印以后发现确实如此。
进入函数后,继续查看:我们做好记号,运用之妙令人感叹。
所以可以看出来其实会有8个32-bit长度的字被存放进去了。从asm代码中可以看到此处是离开递归的入口。
虽然跟踪很好玩,但也很累人,不是特别清楚一些函数参数如何压栈,因此仍需要直接了当的方式来处理。
Exercise 11: back trace stack frame
Implement the backtrace function as specified above. Use the same format as in the example, since otherwise the grading script will be confused. When you think you have it working right, run make grade to see if its output conforms to what our grading script expects, and fix it if it doesn't. After you have handed in your Lab 1 code, you are welcome to change the output format of the backtrace function any way you like.
这个任务并不困难,直接写代码就好了。
Exercise 12: back trace stack frame
该任务主要是帮助学生们理解slabs是什么。简单在debuginfo_eip函数中春初一下相关的信息就能完成我们的目的。
Last updated
