2020301918-os/实验3-v0.2.md
2022-10-02 12:54:35 +08:00

20 KiB
Raw Blame History

实验三 进入保护模式
谷建华
2022-09-29 v0.2

实验目的

  1. 学习x86保护模式编程,以及如何从实模式进入到保护模式.
  2. 学习elf文件的组成和格式、加载elf文件格式的kernel.
  3. 学习汇编与C之间的相互调用.
  4. 学习二进制文件与elf文件之间的区别.

实验预习内容

  1. 保护模式之前需要做的准备工作.
  2. ELF文件格式.
  3. 应用程序二进制接口.

实验内容以及步骤

2022年试点班需要完成1、2、3三个实验,提高实验想做就做不强求

  1. 编译构建硬盘启动盘,并挂载到虚拟机上,证实执行流交给了kernel (1) 修改kernel.asm,使之能够在虚拟机终端中显示kernel字符串(注意,这个时候bios中断不能使用了). (2) 通过make编译,生成a.img硬盘镜像. (3) 通过make run运行虚拟机并挂载a.img作为硬盘启动盘,观察并记录现象.

  2. 通过修改initkernel.c的代码,解析kernel.binelf文件并结合elf文档阅读,回答下面问题: (1) 凭什么知道kernel.bin是一个elf文件 (2) 凭什么知道kernel.bin运行的环境的指令集为i386 (3) kernel.bin一共被加载了几个程序段?加载的段在内存中是从什么地址开始?加载的长度有多长?每个段的读/写/可执行标志是怎么设置的?

  3. 通过修改kprintf.asm,完成kprintf的函数的汇编实现,函数调用的参数如下: (1) 第0个参数为从终端中的第几个字符开始输出,第0个字符是终端0行0列,第1个字符是0行1列,第80个字符是1行0列,以此类推. (2) 第1个参数为需要格式化输出的字符串,传进去的参数实际是这个字符串的首地址. (3) 再之后的参数根据第1个参数的字符串决定,每次调用kprintf函数后首先默认输出黑底白字的字符,接下来几条是需要实现的.

    • %c在终端输出参数的对应的ASCII字符值参数保证范围在0~127之间,在输出字符后输出的位置往下移动一位.
    • %f更改之后输出的字符的前景色为参数的对应值(参数保证范围在0~15之间,颜色码对应值)
    • %b更改之后输出的字符的背景色为参数的对应值(参数保证范围在0~15之间,颜色码对应值)
    • 其余字符则按照其ASCII码输出保证字符串不会出现%、换行符、退格符等乱七八糟的字符),在输出字符后输出的位置往下移动一位.
    • start.c中存放一组测试样例,效果已经写明可以大家可以用于测试.

    (4) 在start.c中接入cmatrix.c中的启动函数cmatrix_start,如果编写正确,会在终端中看到以下效果

    (5) (自我提高内容,自己想做就做,不用写进报告,禁止内卷)增添一个新参数%s,对应的参数是一个结构体,结构体的格式如下:

    struct color_char{
         u8 foreground;//输出字符的前景色(0~15)
         u8 background;//输出字符的背景色(0~15)
         u8 print_char;//输出字符的ASCII码(0~127)
         u16 print_pos;//输出字符在终端中的位置
    };
    

    这种输出方式不影响接下来%c输出的前景色、背景色和输出在终端中的位置,相当于是一个独立的输出方式.检验你程序的正确性可以调用make tests命令生成专属的测试kernel.

  4. 简单的nm工具制作(自我提高内容,自己想做就做,不用写进报告,禁止内卷 (1) 通过分析elf文件的文件头,找到elf文件中的符号表,输出每个符号对应的符号名、符号地址、大小和类型. (2) 这个操作可以在我们的内核中写,也可以自己独立写一个C/Cpp程序,如何检验自己的程序是否正确,可以通过nmreadelf -s检查.

  5. 创建FAT32格式的硬盘启动盘自我提高内容,自己想做就做,不用写进报告,禁止内卷 (1) 之前硬盘启动盘的文件系统格式为FAT12,这次需要将FAT12格式变成FAT32格式,这需要熟读官方文档,了解FAT32的文件格式以及处理方式. (2) 修改boot/loader代码实现正确加载. (3) 编译制作硬盘启动盘,启动虚拟机并挂载硬盘启动盘,观察并记录现象.

实验总结

  1. 在loader阶段都完成了哪些主要功能X86系统是如何进入保护模式的在进入保护模式之前需要完成哪些准备工作
  2. C语言和汇编语言是如何互相调用的参数如何传递

实验参考

进入保护模式

在进入了loader后,终于可以不受512字节的代码限制.进入loader之后我们需要干的一件事是进入保护模式.

为什么要进入保护模式?这个时候这个问题就自然而然问出来了,这不是实模式待着好好地嘛,去啥保护模式.正常情况是这样的,对于我们写的程序来说实模式貌似挺够我们用的,但是如果稍微写个大一点的程序呢?这个时候一个噩梦般的事情发生了:段偏移的寻址方式太弱了,虽说1MB的寻址能力挺强的,但是对于一个固定段,它的偏移寻址能力只有64KB大,只需要128个boot程序般大的程序文件会超过偏移寻址能力,这对程序员来说是极为头疼的,写程序逻辑本身就很麻烦了,还要关心寻址,这程序谁爱写谁写.

而保护模式解决了这个寻址问题,它扩展了段偏移寻址模式的定义,将偏移范围支持到4GB大,刚好是一个int能够表达的范围,这不爽死XD,所以进入保护模式还是很有必要的.

保护模式是怎么扩展段偏移寻址模式的定义?这帮硬件工程师商量来商量去最后给出了一种方式gdt(global descriptor table)全局描述符.全局描述符抽象来看是一个数据结构,这个数据结构不能说是整齐规整,也可以说是东拼西凑,具体可以到Orange教科书中查询相关的数据结构细节.与实模式的段地址不同,保护模式的“段地址”就是gdt的基地址,而gdt能够表示偏移寻址的范围最多能到4GB,这样就能够支持4GB的寻址.

回忆实模式,它的基址是一个地址值,很容易用一个寄存器进行存储,但是保护模式的段地址是一个数据结构的基地址,而且由于段寄存器的历史遗留问题仍然只有16位,不能填入32位数据,所以硬件工程师就搞出一条命令lgdt,由于我们在一个固定缓冲区按顺序存储一些gdt,亦叫gdt表数组,只需要通过lgdt指令告诉硬件gdt表的起始地址和长度就能让硬件提前分析gdt表,这样我们只需要将段寄存器改成gdt相对于gdt表的偏移量亦称选择子就可以轻松表示.(改成这种方式还能够控制权限,不过本篇不谈)

在进入保护模式之前,根据硬件厂商的手册,我们需要做三件事

  1. 加载gdt表
  2. 打开地址线A20
  3. cr0第0位置位

这三件事做完之后,通过一个32位长指令的长跳转跳转到SelectorFlatC:LABEL_PM_START后,我们执行环境从16位变成了32位,从实模式跳到了保护模式.其中SelectorFlatC是希望cs变成的gdt选择子,LABEL_PM_START是指希望跳到的地址(不受段偏移限制了).

在进入了保护模式后,就可以忘掉落后的段偏移访存方式了,能够更加舒服地写汇编程序了.但惊喜不止于此,我们还能够通过C语言实现我们想要的功能,想必看完那么多汇编大家肯定不想再读汇编了,苦日子终于熬到头了,可以用C了,C比汇编可好读太多了.

汇编与C之间的交互

在进入了C语言的部分之后就需要面临一个问题,如何进行汇编与C之间的交互这个问题放平时可能想都不会想,大一开始的C语言课程可能根本不会让你意识到程序背后本质可以抽象成一条条汇编指令的执行,就如大家写的汇编程序一样,所以C语言代码本身可以具象为汇编代码,C语言中的函数本身就是一条label标签,这样就可以通过jmp和call进行函数跳转/调用将执行流交给C语言程序.

但是这么说依然很抽象,你依然会很难理解一个在C语言中的函数调用具体发生了什么,一个带参数的函数跳转是怎么执行的,中间经历了什么?

  1. 导入/导出符号

如何让汇编代码与C代码进行相互调用,首先需要了解什么是符号,符号说人话就是函数名,全局变量名等.我们在编写汇编代码的时候会写一些标签,这些标签成了我们跳转的目标,也可以成为我们访存的目标,从某种意义上,符号就是这些标签.

先解决第一个问题,如何在汇编中调用C函数/全局变量,对于汇编来说,这个符号是外部的,所以需要引入这个符号,引入到代码中,这样编译器就知道这个就是外部的符号,在跳转/访存的时候就知道这个符号是从哪里来的了.虽然在汇编文件编译的时候不知道确切地址,在链接的时候就能够知道了.

;nasm汇编程序
extern foo ; 这个时候通过引入foo符号实现函数的跳转
call foo   ; 通过调用foo函数实现打印

//C程序
#include<stdio.h>
void foo()
{
    printf("114514 1919810");
}

然后解决第二个问题,如何在C中调用汇编中的函数/全局变量,对于汇编来说,这个符号不仅是汇编文件用,外部也要用,所以汇编文件就需要将符号导出,告诉外界,它们想要的符号在汇编文件里,这样在链接的时候其他文件也知道这个符号在哪.

;nasm汇编程序
global foo ; 导出foo符号
foo:
    ...    ; 这里是foo函数的实现

global foofoo ; 导出foofoo符号
foofoo dd 0 ; 这个是个全局变量

//C程序
void foo(); //这里类似汇编里面的extern,如果下面没有foo函数的实现就将foo这个函数的符号导入进来
extern int foofoo; //将foofoo这个全局符号导入进来

void bar()
{
    foo(); //这样就能够调用foo函数
    foofoo=1;//这样就能够使用foofoo变量
}

这样汇编与C语言代码之间的相互调用就完成了.如果你好奇原理是什么,可以看第3节符号表.

  1. 参数传递

刚才解决的是C语言函数与汇编函数相互调用的问题,但是假设有这么个情况,这个C语言函数是一个带输入参数的函数,比如int foo(int a, int b),这个时候如果光调用call指令可不好使,a,b参数就被吞了,下面的foo函数拿着混乱的参数大搞破坏,这肯定是我们所不希望的.

那么对于foo函数,它还有两个参数a,b,那么如何将a,b“告诉”子函数,对于i386指令集规定了参数传递方式对于所有参数,按照从右往左的顺序压栈,通过栈传递中间参数.

对于上面这个例子来说,nasm汇编在调用C函数之前需要先将b压栈,然后将a压栈,最后才是调用call指令跳转到foo函数的标签处,C函数同样遵循这个规范,所以会去栈里寻找a,b两个变量的值,离栈顶越近的元素在函数定义的时候参数越靠左.

这种从右往左的压栈方式其实很好,比如printf,我们不知道要输入多少个参数,但是会根据其中的字符串中的信息%d,%s判断需要多少个参数,然后在栈中找就可以将传入的参数拿出.在这次的实验中大家需要实现一个类似printf的函数,来深刻理解这种传参方式.

通过压栈就能解决参数传递的问题,这种传递方式本质叫应用程序二进制接口,在第4节中有介绍.

  1. 符号表

可能大家都忘完了大一学的大计基和C程序,这个时候就要需要回忆一下一个C语言程序的编译过程预处理、编译、汇编、链接.相信大家在大一的时候把这一过程当文科一样背,现在可能基本记不清这几步具体发生了什么.前面三个步骤还能勉强理解理解,这个链接可能是真的难以理解.

假设有若干个可重定位文件(.o文件),那么可以使用GNU的工具ld对这些可重定位文件进行链接成一个可执行文件.

//1.c程序内容
void foo()
{
    int bar=114514;
}
int main()
{
    foo();
}
//2.c程序内容
void foo()
{
    int bar=1919810;
}

将两个程序的内容用gcc(附上-c参数)命令编译成两个可重定位文件,然后尝试将两个可重定位文件用ld命令链接,然后你就会发现一个神奇的现象.

$ gcc -c 1.c && gcc -c 2.c
$ ld 1.o 2.o -o test
ld: 2.o: in function `foo':
2.c:(.text+0x0): multiple definition of `foo'; 1.o:1.c:(.text+0x0): first defined here
ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000

你会发现不让你链接,原因是foo被两次定义了.那么原因是什么它凭什么知道foo被两次定义了我们作为写程序的人肯定知道被两次定义了,但是机器怎么知道?这就涉及到符号表了.

每一个可重定位文件里面存放着一张符号表,它存放所有符号信息.那么如何获取符号信息GNU里面有一个工具nm能够获取对应的符号信息.

$ nm 1.o
0000000000000000 T foo
0000000000000012 T main
$ nm 2.o
0000000000000000 T foo

看到这个的时候,哦,确实有两个foo的符号,所以在链接的时候会发生符号冲突.符号表是用于链接时定位的,在可重定位文件里,可能某个跳转语句的目的地址还是未知的,在链接成可执行文件时才能具体填入地址.

在大一上C程序的时候大家可能不知道有nm命令,所以对链接的学习是迷迷糊糊的(再加上考试是不会考这个的,这个考了不挂一片人),所以可能仍不会extern static等相关关键词的语义.在做这节实验的时候可以尝试结合nm复习这些关键词的语义,这会对你对链接的理解有很大帮助.

  1. 应用程序二进制接口(Application Binary Interface)

ABI本质上是一种约定,它约束外部函数调用的参数规范,每种指令集都有它自己的一套调用规范,类似64-x86,risc-V指令集首先用的是寄存器传参再是栈传参,而32位的i386指令集用的是栈传参.所以只要按照ABI规范写就一定能够正确调用外部函数.

目前我们的OS用的是32位i386指令集,采取的传参方式是栈传参,就是在调用函数之前将所有参数从右往左依次压入栈中,最后才是函数跳转,不管是调用者还是被调用者都遵循ABI,这样就可以实现正确的函数调用.

但是实际上有那么多种ABI,我们肯定背不下那么多ABI规范,但是有一种最笨也是最有效的方式就是:读汇编,读汇编,读汇编.因为C语言程序本质上也可以抽象成一个汇编程序,而汇编程序是最基础的,它每条指令的执行会按部就班地执行,而且每句话都是实打实地,不会骗你半点,当你觉得你实现的汇编很对但是链接进去执行错误的时候最好的方式就是反汇编/gdb.

那么如何反汇编呢GNU也给了我们一个工具objdump,通过调用objdump -d kernel.bin | less命令就可以将kernel.bin这个可执行elf文件也可以是可重定位文件反汇编,然后就可以愉悦地查看汇编代码了,通过查看实际的汇编代码是很好的,它完全反应了程序指令运行行为,对着它研究ABI准没错.

当学会了ABI,大家可能就觉得C语言可能也那么神秘,你也可以独立从一个C语言翻译成一段汇编代码,只是需要读文档+花费逝量时间即可.

二进制文件和ELF文件

在进入了保护模式之后剩下loader要做的事就是加载kernel,毕竟loader是一个32位与16位代码混合的二进制文件,有实模式寻址的约束,不太适合扩展32位代码当内核,不如新启一个程序文件作为kernel.

而这次加载kernel文件并不能像加载loader文件一样直接加载到指定位置然后跳过去,可以通过file命令查看两个文件类型:

$ file loader.bin | tr ',' '\n'
loader.bin: 
 code offset 0x1c7+3
 OEM-ID "ForrestY"
 root entries 224
 sectors 2880 (volumes <=32 MB)
 sectors/FAT 9
 sectors/track 18
 serial number 0x0
 label: "OrangeS0.02"
 FAT (12 bit)
$ file kernel.bin | tr ',' '\n'
kernel.bin: ELF 32-bit LSB executable
 Intel 80386
 version 1 (SYSV)
 statically linked
 with debug_info
 not stripped

loader.bin本质上是一个二进制文件,不过带有引导信息沿用了boot的引导信息所以会被识别成一个文件系统,所以可以通过跳转跳到文件开头就可以进行loader的执行.

而kernel.bin本质上是一个elf可执行文件,什么是elf可执行文件

ELF文件格式简介

试想一个简单的场景,你写的代码有代码数据和普通数据,这两部分数据是不对等的,对于代码数据你希望它不会被改动,所以对这部分数据不应该附上写权限,普通数据应该附上写权限.很明显这两部分数据不应该混在一块,而是应该分成两段存放.而且每一段都有它希望被加载到的地址,整个一个elf文件的入口地址也要指定……这些东西是需要一个数据结构进行管理,维护每一个段所需求的信息,然后在文件开头维护每个段的信息以及整个程序的信息等,这个数据结构就叫ELF文件格式.如果你比较熟悉内核,熟悉execve系统调用,那么这里会让你对ELF文件格式有更深刻的理解如果不熟悉别听,绝对听晕).

ELF维护了若干个程序段,每个程序段存放了相对应的数据,通过对数据的迁移到指定地址,再跳到入口地址就能实现elf文件的加载执行.

在知道ELF是啥之后,怎么解析ELF文件,将ELF文件的程序数据迁移就是个问题,讲理论概念谁不会讲,但是在具体做的时候经常会怀疑人生,理论突然就不起用了,那么这个时候该怎么办?读文档,对于ELF文件格式,linux里面有写一个专门关于它的文档,可以通过调用man elf指令进行查看ELF里面的细节,可以结合initkernel.c代码与文档一起阅读,分析其中的具体含义(不要惧怕阅读英语,你们从小学开始学的英语不是仅用来考试的).需要注意的是man elf中是不会给出参数的具体值的,需要阅读/usr/include/elf.h头文件获取具体的值.

当然解析这些玩意肯定折磨人,不过我们万能的GNU又给我们提供了一个工具readelf,当你想分析一个elf文件格式的时候,通过调用readelf -a获取所有信息,readelf -l获取程序头信息,当你觉得你的C程序分析错的时候,用readelf能够及时比对.

加载kernel

虽然说起来ELF格式很复杂,但是由于kernel没啥权限需求,实际上加载的时候的代码没几行,实际上就遍历所有程序头,然后根据每个程序头的要求将文件数据加载到指定的位置,最后用一个跳转跳入kernel的入口地址.

在进入了kernel了之后,我们真正有意思的内核就来了,大家会逐渐实现一些有趣的机制,并用这些有趣的机制实现很多有趣的玩意.

显存

进入保护模式后,实模式下能够使用的BIOS中断就不能用了,那么如何往终端打印字符就成了一个难事,不过好在天无绝人之路,在内存的[0xB8000, 0xC0000)处给了我们一个能够往那黑漆漆的终端写字符的途径,我们的终端只有25行*80列,一共2000个字符,每个字符由两字节表示,相当于终端显示的是显存[0xB8000, 0xB8000 + 4000)这部分的内容,这两字节低字节表示ASCII字符内容,高字节字符分成两部分,低4位为前景色,高四位为背景色,颜色码与实验一中的颜色码一致具体可以到Orange书中7.2.1节中查看).

在保护模式中,显存选择子对应的段就是显存的内存.所以在loader中可以通过gs段寄存器对显存进行存取字符,这个可比中断高效多了.

; 段描述符
LABEL_DESC_VIDEO:       Descriptor       0B8000h,               0ffffh, DA_DRW                       | DA_DPL3  ; 显存首地址

; 段选择子
SelectorVideo       equ LABEL_DESC_VIDEO    - LABEL_GDT + SA_RPL3

; 将选择子加载到gs段寄存器
mov gs, SelectorVideo
mov ah, 00fh; 黑底白字
mov al, 041h; 'A'
mov word [gs:0 * 2] ax; 往0行0列写入一个黑底白字的字符A
mov word [gs:1 * 2] ax; 往0行1列写入一个黑底白字的字符A
mov word [gs:80 * 2] ax; 往1行0列写入一个黑底白字的字符A