说明:本系列为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文件规定了文件的编译规则可以由下图表示,这是一个典型的静态链接的过程:

loader.s
loader.o
kernal.cpp
kernal.o
mykernal.bin
linker.ld

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文件看作是程序头表描述的段的集合,一个段可能包含多个节,节头表可选。
  • 如果是共享文件,则两者都含有。
    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)
Alt
图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所示。

Alt
图2.2 GDTR结构示意图

在由实模式向保护模式转化的过程中,GDTR通过lgdt指令进行初始化。
(3)段选择子(Selector)

上面介绍了段描述符记录的段的基本信息,GDT记录的全局可用的段的信息,那么每个应用程序如何访问自己的内存段呢?——通过段选择子(Selector)。段选择子可以理解为是一个GDT中的索引,但与索引又略有不同。段选择子的结构如图2.3所示。

Alt

图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确保文件可以正常执行。

Logo

鸿蒙生态一站式服务平台。

更多推荐