MIT6.828操作系统工程实验学习笔记(三)

本文详细介绍了JOS项目中链接器的作用,特别是如何处理符号地址,以及cprintf函数的工作原理,包括其调用的vprintfmt和putch函数。此外,还探讨了如何在CGA输出中实现控制台字符颜色的变化。
摘要由CSDN通过智能技术生成

前言

这篇文章是接上文的内容,依然是对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指令位置,查看0x001000000xf0100000处的内容,然后执行这条指令后,再查看上述两个地址所指向的内容。
(注:打断点可以使用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.cvprintfmt函数里面,找到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.cconsole.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体系结构下的调用约定如下:

  1. 函数实参在线程栈上按照从右至左的顺序依次压栈。
  2. 函数结果保存在寄存器EAX/AX/AL中
  3. 浮点型结果存放在寄存器ST0中
  4. 编译后的函数名前缀以一个下划线字符(_)
  5. 调用者负责从线程栈中弹出实参(即清栈)
  6. 8比特或者16比特长的整形实参提升为32比特长。
  7. 受到函数调用影响的寄存器(volatile registers):EAX、ECX、EDX、ST0 – ST7、ES、GS
  8. 不受函数调用影响的寄存器:EBX、EBP、ESP、EDI、ESI、CS、DS
  9. 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++;
}

最终可以达到如下图示的效果(可以对照着控制位的含义想想为什么)
在这里插入图片描述

  • 23
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值