C++手写操作系统学习笔记(一)——操作系统引导和安全模式
说明:本系列为B站课程《使用c++编写操作系统》及其原版视频的学习笔记,环境为VScode连接本地虚拟机(Virtualbox)上Ubantu系统。
C++手写操作系统学习笔记(一)
说明:本系列为B站课程《使用c++编写操作系统》及其原版视频的学习笔记,环境为VScode连接本地虚拟机(Virtualbox)上Ubantu系统。
1.文件格式
1.kernal.cpp: 操作系统内核部分。
2.loader.s: 为kernal.cpp指明地址等信息的汇编程序
3.linker.ld: 程序代码(.s和.c)源文件会经过预编译、编译、汇编、链接最后生成目标可执行文件,.ld文件是作用在链接过程。作用是:
1.合并各个.obj文件的section,合并符号表,进行符号解析;
2.符号地址重定位;
3.生成可执行文件(kernal.bin)
由于最终需要生成一个的文件(kernal.bin的二进制文件),所以需要linker.ld文件将kernal.o和loader.o链接起来。
4.Makefile: 描述了整个工程的编译、连接等规则。其中包括:工程中的哪些源文件需要编译以及如何编译、需要创建哪些库文件以及如何创建这些库文件、如何最后产生我们想要的可执行文件。总之,makefile文件记录了工程中所有文件的编译规则实现自动化编译。
2.Makefile编写
1.Makefile编写基本规则:
目标文件:源文件
(TAB)命令
注意:每个命令前都要进行Tab缩进而非空格。
2.Makefile文件:
# 1.$代表变量,变量超过一个字符需要()。\
2.$@ 表示目标文件。\
$? 上个命令的退出状态,或函数的返回值。\
$^ 表示所有的依赖文件\
$< 表示第一个依赖文件
GPPRAMS = -m32 -fno-use-cxa-atexit -fleading-underscore -fno-exceptions -fno-builtin -nostdlib -fno-rtti
ASPARAMS = --32
LDPARAMS = -melf_i386
#这里规定了不同文件的类型,-m32,--32分别指定C语言与汇编生成32位文件,ld链接生成ELF可执行文件。
objects = loader.o kernel.o
%.o: %.cpp
g++ $(GPPRAMS) -o $@ -c $<
# -o $@ 与 -c $<各为一个整体,语句中有-c存在,就决定了这行命令只编\
译不链接,-o $@代表指定编译生成的.o按照$@取名,而$@本身代表编译后生\
成的.o,所以可以省略-o $@。
%.o: %.s
as $(ASPARAMS) -o $@ $<
mykernel.bin: linker.ld $(objects)
ld $(LDPARAMS) -T $< -o $@ $(objects) -no-pie
install:mykernel.bin
sudo cp $< /boot/mykernel.bin
clean:
rm -rf *.o *.out *.bin *.iso iso
Makefile文件规定了文件的编译规则可以由下图表示,这是一个典型的静态链接的过程:
3.loader.s编写
1.操作系统引导:指计算机利用CPU运行特定程序,通过程序识别硬盘,识别硬盘分区,识别硬盘分区上的操作系统,最后通过程序启动操作系统。
2.GNU GRUB(GRand Unified Bootloader简称“GRUB”)是一个来自GNU项目的多操作系统启动程序。GRUB是多启动规范的实现,它允许用户可以在计算机内同时拥有多个操作系统,并在计算机启动时选择希望运行的操作系统。GRUB可用于选择操作系统分区上的不同内核,也可用于向这些内核传递启动参数。本项目采用GRUB作为bootloader。
3.汇编: 程序中以 . 开头的名称并不是指令的助记符,不会被翻译成机器指令,而是给汇编器一些特殊指示,称为汇编指示或伪操作,由于它不是真正的指令所以加个“伪”字。.section指示把代码划分成若干个段(Section),程序被操作系统加载执行时,每个段被加载到不同的地址,操作系统对不同的页面设置不同的读、写、执行权限。程序中没有定义数据,则该段是空的。
- .data段保存程序的数据,是可读可写的,相当于C程序的全局变量。本程序中没有定义数据,
- .set 给一个全局变量或局部变量赋值
- .text段保存代码,是只读和可执行的
- .long 指示声明变量占用空间,占32位
- .global 用来让一个符号对链接器可见,可以供其他链接对象模块使用。
- .extern XXXX 说明xxxx为外部函数,调用的时候可以遍访所有文件找到该函数并且使用它。
- .bss:为初始化的全局变量
.set MAGIC,0x1badb002
.set FLAGS, (1<<0 | 1<<1)
.set CHECKSUM, -(MAGIC + FLAGS)
.section .multiboot
.long MAGIC
.long FLAGS
.long CHECKSUM
;grub启动规则:
;一个魔术块:包含了魔法数[0x1BADB002],是多引导项头结构的定义值。
;一个标志块:我们不关心这个块的内容,我们简单设定为0。
;一个校检块:校检块,魔术块和标志块的数值的总和必须是0。
.section .text
.extern _kernelMain
.extern _callConstructors ;kernel.cpp中定义的构造函数和操作系统主函数
.global loader
loader:
mov $kernel_stack, %esp ;汇编调用C函数时要使用栈
call _callConstructors ;constructor:构造函数 ,是一种特殊的方法。主要用来在创建对
;象时初始化对象, 即为对象成员变量赋初始值,
push %eax
push %ebx ;push接受来自kernelMain的两个参数:multiboot_structrue,magicnumber
call _kernelMain
_stop:
cli
hlt
jmp _stop
;cli :将IF置0,屏蔽掉“可屏蔽中断”,当可屏蔽中断到来时CPU不响应,继续执行原指令
;hlt:本指令是处理器“暂停”指令。
;jmp _stop : 命令跳转指令
;_stop:确保操作系统进入循环不会退出
.section .bss
.space 2*1024*1024 ;留出2M空间给kernel_stack
kernel_stack: ;定义kernel_stack栈顶位置
4.kernel.cpp编写
在loader.s中,指定了kernel.cpp中函数的入口,下面编写操作系统内核程序:
1.构造函数
typedef void (*constructor)()
extern "C" constructor start_ctors;
extern "C" constructor end_ctors;
/*
这里使用函数指针:constructor代表着一种类型,这种类型可以定义一个指向返回值为void,且没有参数的函
数的指针,start_ctors和end_ctors在linker.ld中分别指向构造函数的起始和末尾
*/
extern "C" void callConstructors(){
for(constructor* i = &start_ctors;i!=end_ctors;i++)
(*i)();
}
/*
初始化对象
*/
2.主函数及printf的简单实现
构造函数结束后即可进入操作系统的主函数kernelMain,这里让kernelMain执行printf函数,即在屏幕上输出字符,但目前操作系统中没有任何内容,自然也不会有printf这样的库函数,所以首先用一个比较简单的方法实现printf函数的功能。
void printf(char* str){
unsigned short* VideoMemory = (unsigned short*)0xb8000;
for(int i=0;str[i];i++){
VideoMemory[i]=VideoMemory[i] & 0xff00 |str[i];
}
}
//0xb8000内存地址是显示器地址,在这里写数据可以直接输出到屏幕上。
extern "C" void kernelmain(void* multiboot_structrue,\
unsigned int magicnumber) //这里传入两个与grub有关的参数
{
printf("hello world");
while(1);
}
5.linker.ld编写
1. linker.ld的作用是连接——一个收集,组织程序所需的不同代码和数据的过程,以便程序可以被装入和执行,正如在Makefile中指定的,将形成后缀名.bin的二进制文件,在Linux中通常是一个ELF文件。
2. ELF文件的作用: 可以从不同的角度来看待elf格式的文件:
- 如果用于编译和链接(可重定位文件),则编译器和链接器将把elf文件看作是节头表描述的节的集合,程序头表可选。
- 如果用于加载执行(可执行文件),则加载器则将把elf文件看作是程序头表描述的段的集合,一个段可能包含多个节,节头表可选。
- 如果是共享文件,则两者都含有。
注意:
- .text:被编译程序的机器代码
- .rodata:诸如printf中的格式串的只读数据
- .data: 已初始化全局变量
- .bss:未初始化全局变量
3.linker.ld文件、
ENTRY(loader)
OUTPUT_FORMAT(elf32-i386)
OUTPUT_ARCH(i386:i386)
/*定义入口地址,输出格式,和输出文件的体系结构*/
SECTIONS
{
. = 0x0100000; /*把定位器符号置为0x10000 (若不指定, 则该符号的初始值为0).*/
.text :
{
*(.multiboot)
*(.text*)
*(.rodata)
}
/*将所有(*符号代表任意输入文件)输入文件的section合并成一个.text section, 该
section的地址由定位器符号的值指定, 即0x10000.*/
.data :{
_start_ctors = .;
KEEP(*(.init_array));
KEEP(*(SORT_BY_INIT_PRIORITY(.init_array)));
_end_crots = .;
/*确保构造函数被调用*/
*(.data)
}
.bss :{
*(.bss)
}
/DISCARD/ :{
*(.fini_array*)
*(.comment)
}
}
注意:ld文件的语法:每个段名后面的冒号与段名之间必须要有一个空格,比如:
.bss :{*(.bss)}
6.小结
至此,我们已经编写了运行一个简单的操作系统的全部内容,在makefile中设置.iso文件,同时在虚拟机中继续下载Virtualbox,使得我们的操作系统可以运行。
makefile:
# 制作启动工具 执行make mykernel.iso
mykernel.iso:mykernel.bin
mkdir iso
mkdir iso/boot
mkdir iso/boot/grub
cp $< iso/boot/
echo 'set timeout=0' > iso/boot/grub/grub.cfg
echo 'set default=0' >> iso/boot/grub/grub.cfg
echo '' >> iso/boot/grub/grub.cfg
echo 'menuentry "my os" {' >> iso/boot/grub/grub.cfg
echo 'multiboot /boot/mykernel.bin' >> iso/boot/grub/grub.cfg
echo 'boot' >> iso/boot/grub/grub.cfg
echo '}' >> iso/boot/grub/grub.cfg
grub-mkrescue --output=$@ iso
rm -rf iso
#.iso文件:一种容器格式,用于保存光盘(CD或DVD)上用于存储程序、\
电影和其他多媒体内容的文件系统。一个.iso文件可刻录到新光盘上,也\
可直接从硬盘上存储和使用,ISO是用于CD和DVD的格式标准。将操作系统\
(OS)CD作为ISO存储在计算机上非常方便映像。
run: mykernel.iso
virtualboxvm --startvm "my os" &
#确保可以在终端直接开启virtualbox运行操作系统,只需执行make run即可直接运行操作系统。
最后,在虚拟机终端中编译,链接我们已经写好的程序,由于makefile文件的存在,这一步非常简单:
make mykernel.bin
make mykernel.iso
make run
在此之前的调试过程中,我们也可以手动在虚拟机中配置我们的操作系统,具体做法是:
在windows下的virtualbox中:设置–>系统–>处理器–>启用嵌套(可能需要在终端中启用设置),在虚拟机中下载virtualbox–>新建虚拟机–>设备–>选择启动盘为.iso文件
printf
另外,对printf进行小小的改进:使其支持换行功能以及可以多次调用printf函数:
void printf(char* str){
static int16_t* VideoMemory = (int16_t*)0xb8000;
static uint8_t x=0,y=0; //定义全局变量x,y,代表光标在屏幕中的位置(整个屏幕长80宽25)
for(int i=0;str[i];i++){
switch(str[i]){
'\n':y++;
x=0;
break;
default:
VideoMemory[80*y+x]=VideoMemory[80*y+x] & 0xff00 |str[i];
x++;
break;
}
if(x >= 80){
y++;
x=0;
}
if(y >=25){
for(y=0;y<25;y++)
for(x=0;x<80;x++)
VideoMemory[80*y+x]=VideoMemory[80*y+x] & 0xff00 | ' ';
x=0;
y=0;
}//如果屏幕被写满,则用 ' ' 重写整个屏幕。
}
}
7.GDT实现
1.与硬件交流
操作系统的首要任务是与硬件交流,为此在c++与汇编的联合编译中需要精确定义数据每一种数据类型,新建文件types.h:
#ifndef __TYPES_H //预处理命令(宏定义、文件包含、条件编译)中的“条件编译”。头文件编写规范,防止被重复定义
#define __TYPES_H
typedef char int8_t;
typedef unsigned char uint8_t;
typedef short int16_t;
typedef unsigned short uint16_t;
typedef int int32_t;
typedef unsigned int uint32_t;
typedef long long int int64_t;
typedef unsigned long long int uint64_t;
#endif
完成后,即可将kernel.cpp中unsigned short改为uint_16;unsigned int改为uint32_t。
2.安全模式和GDT(全局描述符表:Global Descriptor Table)
实模式是操作系统发展初期的产物,由于存在安全缺陷和寻址范围过小的硬伤,所以出现了保护模式。现代操作系统会在加载期间就直接进入保护模式,本质上是一种有关寻址的摸式:
以下为转载:[link]*(https://blog.csdn.net/qq_45577173/article/details/127430424)
保护模式下的段与实模式下的段不同,保护模式下的段的长度是可变的,而实模式下段的长度是不可变的,因为实模式下引入段的概念是为了更好的让只有16位寄存器的8086CPU去访问20位的地址空间,而非出于保护的目的。
保护模式下的重要数据结构
(1)描述符(Descriptor)
图2.1 段描述符(Descriptor)
段描述符记录的是每个段的基址、界限、属性信息。如图2.1所示,每个段描述符占8个字节,段描述符由三大主要部分组成:段界限(20位)、段基址(32位)、段属性(12位)。(由于历史遗留问题他们都被分开存放)。段界限记录的是段内偏移地址的最大值,段基址记录的是该段的起始物理地址,段属性记录的是段的特权级、描述符类型等信息。
(2)全局描述符表(Global Descriptor Table,GDT)
每个段描述符记录的是一个内存段的信息, 多个段描述符就构成了一个段描述符表(其实就是一个存放段描述符的数组)。全局描述符表(GDT)记录的是系统中所有可用的段的描述符。它存放在物理内存中,那么,操作系统是如何知道GDT的存在在哪呢?——通过特殊的硬件GDTR(Global Descriptor Table Register)。支持保护模式的CPU需要具有一个特殊的硬件GDTR。GDTR是一个48位的寄存器,它记录着GDT的起始地址以及界限。具体结构如2.2所示。
图2.2 GDTR结构示意图
在由实模式向保护模式转化的过程中,GDTR通过lgdt指令进行初始化。
(3)段选择子(Selector)
上面介绍了段描述符记录的段的基本信息,GDT记录的全局可用的段的信息,那么每个应用程序如何访问自己的内存段呢?——通过段选择子(Selector)。段选择子可以理解为是一个GDT中的索引,但与索引又略有不同。段选择子的结构如图2.3所示。
图2.3 段选择子结构示意图
段选择子占2个字节,其中RPL(Request Privilege Level)记录的是请求特权级,即以什么样的权限去访问段。
TI(Table Indicator)记录的是该段位于GDT还是LDT(Local Descriptor Table,与GDT类似)。描述符索引记录的是访问的段在描述符表(GDT or LDT)中的位置(相对偏移量)。值得注意的是,在启动保护模式之后段寄存器(CS、DS、ES等等)存储的就不再是段的物理基址而是段选择子。了解这一点很重要!!但是我们使用汇编编程时无需完成段选择子到段基址的转换,因为从段选择子到段基址的转换由硬件自动完成。
3.用C++编写GDT
1. 首先创建gdt.h,定义了GDT类:
gdt.h:
#include "types.h"
#ifndef __GDT_H
#define __GDT_H
class GDT{
public:
class SegmentDescriptor
{
private:
uint16_t limit_lo;
uint16_t base_lo;
uint8_t base_hi;
uint8_t type;
uint8_t flags_limit_hi;
uint8_t base_vhi; //base:基址,limit:最大寻址范围,type:访问权限
SegmentDescriptor(uint32_t base, uint32_t limit_lo,uint8_t type);
public:
uint32_t Base();
uint32_t Limit(); //每个段描述符占8个字节,段描述符由三大主要部分组成:段界限(20位)、段基址(32位)、段属性(12位)
}__attribute__((packed)); //告诉编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐,
SegmentDescriptor nullSegmentDescriptor;
SegmentDescriptor unusedSegmentDescriptor;
SegmentDescriptor codeSegmentSDescriptor;
SegmentDescriptor dataSegmentDescriptor; //定义四种描述符(原版这里命名似乎有问题)
public:
GDT();
~GDT();//构造函数和析构函数
uint16_t CodeSegmentSelector();
uint16_t DataSegmentSelector(); //获取代码段和数据段
};
#endif
2. 在gdt.cpp中,编写GDT类中的具体函数内容。
#include "gdt.h"
#include "types.h"
GDT::GDT()
:nullSegmentDescriptor(0,0,0),
unusedSegmentDescriptor(0,0,0),
codeSegmentSDescriptor(0,64*1024*1024,0x9A),//标识符0x9a含义:代码段,最高权限,可读,不可访问,32位保护
dataSegmentDescriptor(0,64*1024*1024,0x92)
{
uint32_t i[2];
i[0] = (uint32_t)this;
i[1] = sizeof(GDT) << 16;
//调用汇编,命令:lgdt,操作:p(加载地址)
asm volatile("lgdt (%0)": : "p" (((uint8_t*) i)+2));//由于之前左移了两字节:所以这里计算地址时要加2.
//在由实模式向保护模式转化的过程中,GDTR通过lgdt指令进行初始化。i[2]是GDT描述符(GDT descriptor),
//其中i[0](前16位)表示gdt大小-1,后32位表示偏移地址,
}
GDT::~GDT(){}
uint16_t GDT::DataSegmentSeletor()
{
return (uint8_t*)&dataSegmentDescriptor - (uint8_t*)this;
}
uint16_t GDT::CodeSegmentSeletor()
{
return (uint8_t*)&codeSegmentDescriptor - (uint8_t*)this;
}
//得到段内偏移,获取代码段和数据段在内存中的实际位置
GDT::SegmentDescriptor::SegmentDescriptor(uint32_t base,uint32_t limit, uint8_t type)//每实例化一个段描述符时执行
{
uint8_t* target = (uint8_t*)this;
if(limit <= 65535) // 当内存寻址能力小于16位地址线寻址能力,无需进入保护模式
{
target[6] = 0x40;
}
else
{
if((limit&0xfff) != 0xfff){
limit = (limit >> 12)-1; //如果后12位全1,将发生越界
}else{
limit = (limit >> 12);
}
target[6] = 0xc0;
}
target[0] = limit & 0xff;
target[1] = (limit >> 8) & 0xff;
target[6] |= (limit >> 16) & 0XFF;
target[2] = base & 0xff;
target[3] = (base>>8) & 0xff;
target[4] = (base>>16) & 0xff;
target[7] = (base>>24) & 0xff;
target[5] = type; //target[]0~7分别对应存储了上图descriptor的8个字节。
}
uint32_t GDT::SegmentDescriptor::Base()
{
uint8_t* target = (uint8_t*)this;
uint32_t result = target[7];
result = (result << 8) + target[4];
result = (result << 8) + target[3];
result = (result << 8) + target[2];
return result;
}
uint32_t GDT::SegmentDescriptor::Limit()
{
uint8_t* target = (uint8_t*)this;
uint32_t result = target[6]& 0xf;
result = (result << 8) + target[1];
result = (result << 8) + target[0];
if((target[6] & 0xc0) == 0xc0){
result = (result << 12) |0xfff;
}
return result;
}
//由于在描述符中,段基址(base)和段界限(limit)分两段存放,所以Base函数和Limit函数反演得到并返回完整的这两个数据
最后,在makefile文件的object对象中加入gdt.o文件,并在linux终端执行make run确保文件可以正常执行。
更多推荐
所有评论(0)