前言
这篇文章是接上文的内容,依然是对Lab1的记录
本文主要涉及到对kernel部分的分析,重点是JOS是如何完成控制台输出函数cprintf
的,同时这一部分要开始编写一些代码了
链接器的作用
在整个JOS项目里面,链接器起到一个非常重要的作用:把代码中的符号替换为具体的地址。例如之前出现过的gdtdesc标签,以及Lab1里面就会出现的stab起始位置标签。链接器在链接的时候,会知道这些标签对应的实际地址,然后把代码里面的标签替换为实际的地址。
为了加深对于这一点的理解,我们可以来看下面这个问题:
如果把boot/Makefrag
里面链接时的text段起始地址0x7C00
改为0x7000
会发生什么事呢?
答案是,无法正常启动。因为boot.S
里面的加载GDT时,使用的gdtdesc标签的具体地址是链接器填入的,例如下图48行的指令,这里的gdtdesc会由链接器换成gdtdesc具体所在的地址。
链接器计算地址的依据就是我们传入的text段起始地址,链接器使用这个起始地址+偏移量来计算出真实地址。如果我们告诉链接器程序的起始地址是0x7000
,那么链接器就会以0x7000
为基地址来计算gdtdesc的地址,然而程序实际加载的位置是0x7C00
,所以链接器计算出的地址是错误的,所以系统就会无法正常启动。
如何实现输出的?
这里暂时跳过entry.S,因为这里面穿插了一些页表相关的内容,完整的JOS启动流程会放到Lab2的笔记部分再进行梳理。现在只需要知道:在entry.S里面开启了分页,然后跳转到了init.c
里面的i386_init
函数即可。
输出是使用cprintf
函数完成的,所以下面我们开始对cprintf
函数的分析,如下图所示。
分析发现,cprintf
通过调用vprintfmt
函数来完成输出,这个函数会按照传入的format,格式化字符串,并且调用传入的putch函数指针来把格式化完成的字符串逐个字符地输出。这个putch
函数的作用是向控制台输出一个字符,这个函数最终会调用3个输出字符的函数,分别向串口,LPT,CGA输出字符,其中串口输出就是我们在控制台里面能够看到输出的原因,CGA输出就是我们能够在QEMU窗口看到输出的原因。
串口和LPT我们暂时不细究,这里我们着重分析一下CGA输出,因为当我们在一台实际的机器上运行JOS的时候,CGA输出是能够把字符输出到我们的屏幕上面的。
通过对kern/console.c
里面的cga_putc
函数的分析,可以发现,CGA输出是通过向一块名叫crt_buf
的内存区域里面写入数据来实现的,cga_putc
函数做的事情就是维护一个crt_pos
变量,表示下一个输出字符在crt_buf
中的位置。当输出的量超过缓冲区的时候,这个函数还会把屏幕最顶上的一行清除,把整体的内容往上方移动一行,然后留出最下面的一行,这也就是下面这段代码块干的事情。
// What is the purpose of this?
if (crt_pos >= CRT_SIZE) {
int i;
memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
crt_pos -= CRT_COLS;
}
而这个crt_buf
是在cga_init
函数中初始化的,所以在i386_init
函数中,我们需要先调用cons_init
,之后才能使用cprintf
进行输出。
Exercise 3
接下来回答一下Exercise3里面的4个问题
Q1
At what point does the processor start executing 32-bit code? What exactly causes the switch from 16- to 32-bit mode?
答:是在boot.S
里面的ljmp $PROT_MODE_CSEG, $protcseg
指令之后,才正式进入32位模式执行,因为这一条指令会重新设置代码段描述符寄存器CS的值,在CS的值被更改之前,CPU依然会按照16位长度去解读指令(即使已经进入了保护模式),具体内容见这个系列文章的笔记(二)。
Q2
What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?
答:bootloader的最后一条指令是main.c
里面的((void (*)(void)) (ELFHDR->e_entry))();
。在这条指令之前,elf格式的kernel已经被加载进了内存中,所以接下来只需要跳转到elf头里面指定的程序入口地址即可。
Q3
Where is the first instruction of the kernel?
答:kernel的第一条指令地址由elf头的入口地址指定,而这个入口地址可以在链接的时候指定。kernel使用了.ld文件来配置链接地址(因为kernel需要链接器为一些符号提供地址,这个后面在做backtrace的时候会用到),查看kern/kernel.ld
可以发现,kernel的入口是一个名叫_start
的符号,在kern/entry.S
里面有这个符号的定义,如下
.globl _start
_start = RELOC(entry)
注意到这里有一个RELOC
,这其实是一个宏,其定义如下
#define RELOC(x) ((x) - KERNBASE)
这个宏的作用是把线性地址转换为物理地址,因为在进入kernel的时候,还没有开启分页,所以只能够使用物理地址来进行跳转,而在链接的时候,kernel里面的符号被赋予的地址都是线性地址,所以需要做这样一个转换。
至于为什么线性地址减去一个KERNBASE就可以得到物理地址呢?这个和JOS最初始的页表配置有关,这一点我们放到lab2再深入分析。
Q4
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?
答:使用elf头,可以逐步加载完整个elf文件。
Exercise 5
这个练习题的问题是:如果修改了boot/Makefrag
里面的链接地址,第一条会出错的指令是什么?
答:在boot.S
里面的lgdt gdtdesc
就已经出问题了,本文“链接器的作用”部分做了详细的解释,这里就不再赘述了。
Exercise 7
这个练习题要求我们先把断点断在entry.S
中的movl %eax, %cr0
指令位置,查看0x00100000
和0xf0100000
处的内容,然后执行这条指令后,再查看上述两个地址所指向的内容。
(注:打断点可以使用b entry.S:62
,其中62是这条指令的行号)
实验结果如下图所示:
执行前:
执行后:
出现这种现象的原因是这条指令开启了分页,开启分页需要设置CR0寄存器的某些位,具体代码如下所示
movl %cr0, %eax
orl $(CR0_PE|CR0_PG|CR0_WP), %eax
movl %eax, %cr0
在开启分页之前,CPU认为线性地址就等于物理地址,由于物理地址根本就没有0xf0000000
这么大,所以无法访问;在开启分页后,CPU会访问页表来把线性地址转换为物理地址,在kernel配置的页表里面,0xf0100000
这个线性地址对应的物理地址就是0x00100000
,而0x00100000
这个线性地址对应的物理地址同样也是0x00100000
,所以这两个线性地址指向的其实是同一块物理内存,因此两者查出来的值相同。
Exercise 8
这部分需要我们理解console是如何输出的,然后修改代码,使得console可以以八进制的形式打印数字。console输出的分析前文已经解释过了,这里不再赘述。
代码实现
代码在lib/printfmt.c
的vprintfmt
函数里面,找到switch代码块中%o的位置,仿照%d,把base改成8即可,具体代码如下所示
case 'o':
// Replace this with your code.
//putch('X', putdat);
//putch('X', putdat);
//putch('X', putdat);
num = getuint(&ap,lflag);
base=8;
goto number;
接下来回答一下6个问题
Q1
Explain the interface between printf.c and console.c. Specifically, what function does console.c export? How is this function used by printf.c?
答:printf.c
和console.c
之间的接口是cputchar
函数,这个函数的作用就是向控制台输出一个字符。
Q2
Explain the following from console.c
if (crt_pos >= CRT_SIZE) {
int i;
memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
crt_pos -= CRT_COLS;
}
答:这段代码的作用是当CGA输出缓冲区满了的时候,清除掉最上面的一行,为新的内容腾出空间。具体而言,可以理解为crt_buf
是一个Row*Col大小的数组,其中的每个元素都对应着屏幕上的一个位置,例如如果crt_buf[0]='a'
,那么屏幕左上角就会显示一个字母a
(注:crt_buf的数据类型是16位的,它的低8位是字符,高8位是一些控制位,后面做challenge任务的时候会提到)
crt_pos
变量记录了下一个字符在crt_buf
中的offset,也就是下一个字符该打印在屏幕上的哪个位置,例如,遇到\n的话,那么crt_pos
会指向下一行的开头位置。
这段代码就是在crt_pos
超过缓冲区大小之后,把第2行到第Row行的数据复制到第1行到第Row-1行的位置,然后把crt_pos
设置为第Row行的开始位置。
这个过程表现在屏幕上,就是所有内容上提了一行,然后继续在最下面的一行输入内容。
Q3
For the following questions you might wish to consult the notes for Lecture 2. These notes cover GCC’s calling convention on the x86.
Trace the execution of the following code step-by-step:
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);
In the call to cprintf(), to what does fmt point? To what does ap point?
- List (in order of execution) each call to cons_putc, va_arg, and vcprintf. For cons_putc, list its argument as well.
- For va_arg, list what ap points to before and after the call. For vcprintf list the values of its two arguments.
答:这部分内容涉及到了C语言的可变参数以及调用约定。在C语言中,如果在函数声明的参数列表末尾添加三个点号,就表示当前的函数接收可变参数,如下所示
(可变参数部分的内容参考了https://www.runoob.com/cprogramming/c-variable-arguments.html)
#include <stdio.h>
#include <stdarg.h>
double average(int num,...)
{
xxx
}
(这样做相当于是告诉了编译器:调用这个函数时,你不要管我传入了多少个参数,也别管参数数据类型,只要前面不可变参数传递了,就不要报错)
而可变参数的具体实现涉及到了C语言的调用约定,C语言在x86体系结构下的调用约定如下:
- 函数实参在线程栈上按照从右至左的顺序依次压栈。
- 函数结果保存在寄存器EAX/AX/AL中
- 浮点型结果存放在寄存器ST0中
- 编译后的函数名前缀以一个下划线字符(_)
- 调用者负责从线程栈中弹出实参(即清栈)
- 8比特或者16比特长的整形实参提升为32比特长。
- 受到函数调用影响的寄存器(volatile registers):EAX、ECX、EDX、ST0 – ST7、ES、GS
- 不受函数调用影响的寄存器:EBX、EBP、ESP、EDI、ESI、CS、DS
- RET指令从函数被调用者返回到调用者(实质上是读取寄存器EBP所指的线程栈之处保存的函数返回地址并加载到IP寄存器)
C语言在具体实现过程调用的时候,是通过栈来传递参数的,例如,当调用average(4, 2,3,4,5)
的时候,实际上是把4,2,3,4,5从右至左压栈。对于可变参数,如果能够知道第一个可变参数在栈里面的位置,就可以依次取出其余的可变参数。事实上,C语言的可变参数正是这样实现的。
可变参数的使用方法如下:
double average(int num,...)
{
//第一步:新建一个valist
va_list valist;
double sum = 0.0;
int i;
//第二步:使用va_start宏来初始化valist,这个宏的第二个参数是 参数列表的最后一个不可变参数
va_start(valist, num);
for (i = 0; i < num; i++)
{
//第三步:使用va_arg宏来依次访问可变参数列表,这个宏的第二个参数表示 可变参数的类型
sum += va_arg(valist, int);
}
//第四步:释放valist
va_end(valist);
return sum/num;
}
其中va_start传入最后一个不可变参数的目的就是为了定位可变参数在栈中的位置,va_arg需要传递参数类型是因为可变参数可以传递任意数据类型的参数,程序需要知道数据类型大小才能定位到下一个可变参数的位置。
所以回到这个问题,fmt指向的是栈中的一个位置,而这个位置存储着字符串的地址;ap指向的是第一个可变参数的地址。
Q4
Run the following code.
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
What is the output?
答:输出He110 World
首先,57616的十六进制是e110,构造出了Hello部分。
其次,x86体系结构是小端存储的,而在当前环境下,int的数据类型是4字节,所以如果变量i的内存地址是0x7700,那么真实的内存区域是如下所示的:
0x7700: 0x72
0x7701: 0x6c
0x7702: 0x64
0x7703: 0x00
如果以字符串的视角去解析0x7700这个地址,就会得到rld这个字符串。
所以最后的打印结果是He110 World
Q5
In the following code, what is going to be printed after ‘y=’? (note: the answer is not a specific value.) Why does this happen?
cprintf("x=%d y=%d", 3);
答:它会指向栈里面的另一些数据。这其实也是一种攻击方式,如果任由用户输入printf的format,就有可能让用户访问到栈内的其余数据,这就会导致数据泄露。
Q6
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 cprintf or its interface so that it would still be possible to pass it a variable number of arguments?
答:这种做法的问题在于,无法定位到不可变参数的位置。一种做法是固定第一个参数是一个magic number,这样的话,就可以通过遍历栈来定位到第一个参数的位置。
给输出字符染色
这部分参考了https://juejin.cn/post/7028744851805978638
之前提到了,crt_buf
每个元素大小是16位,低8位是字符,高8位是控制位,控制位的具体含义见这部分的参考文章。
这里我们只需要修改kern/printf.c
里的putch
函数,改为如下所示
static void
putch(int ch, int *cnt)
{
int color_mask=0b01100011;
ch |= (color_mask << 8);
cputchar(ch);
*cnt++;
}
最终可以达到如下图示的效果(可以对照着控制位的含义想想为什么)