64位操作系统——(二)kernel


作者:王赛宇

参考列表:

  • 主要参考:《一个六十四位操作系统的设计与实现》——田雨
  • 《Using as》 ——Dean Elsner & Jay Fenlason & friends
  • nasm 用戶手册
  • 处理器startup.s 常见汇编指令…

前情提要

在第一章节中,我们学习、研读了bootloader的代码,bootloader可以被分为两个过程:

  • boot:计算机上电,自检完成后自动执行0x7c00处的boot程序,该程序被限制了大小512KB,所以它仅用于进行计算机磁盘设定以及加载、跳转到loader
  • loader:从boot处跳转而来,进入loader时计算机仍处于实模式,尽可操作1MB内存,但我们进入了Big Real Mode让计算机能够操作4GB的内存,并通过这种方法将检索到的kernel程序放置在了内存的1MB处,并且通过两步(实模式保护模式长模式)的方法进入了长模式,在长模式下cpu可以操作非常大的内存(16T),每一次切换模式,我们都使用一条长跳转语句来切换当前代码的运行状态,在跳转到长模式时,使用长跳转跳转到了内核程序所在的地址,到此为止我们完成了从bootloader模块到操作系统内核的切换,电脑的控制权交到了操作系统内核的手中。

在这一章中,我们将继续完成64位操作系统,值得一提的是,在上一章中,由于主要语言是汇编,我们(我太菜了dbq)只能以解读、copy为主,但是在这一章中,主要语言从汇编变成了C语言,我希望我们的主要任务从解读变为理解 --> 复现;下面,我们开始这一章的学习。

内核执行头程序head.S

我们还是需要写一小会汇编。

什么是内核执行头程序

我们知道,在loader的最后进行了一次长跳转,跳转到了kernel.bin的起始地址,这个起始地址实际上就是物理地址0x100000,内核头程序就是被装载到0x100000上的程序,它是内核程序的一部分,但内核执行头程序是一个汇编程序,它主要负责为操作系统创建段结构与页结构,设置某些结构的默认处理函数以及配置关键寄存器等工作。

接下来,还有一个非常重要的问题:如何将内核执行头程序装载到我们想要的地址?

这里我们需要自己去写一个链接脚本,在连接脚本中,会对不同程序的空间进行布局。这个链接脚本不是我们当前阶段需要关心的内容,我们直接使用作者提供的即可。但我们需要知道:

内核层的起始线性地址0xffff800000000000对应着物理地址0处,内核程序的起始线性地址位于0xffff800000000000 + 0x100000

定义各种表以及段结构(数据段)

这里我们还是以解读为主了:

#//=======    gdt_table.section .data // 数据段.globl gdt_table // 声明全局符号,可以被其他程序调用gdt_table:// 一次写8个Byte.quad    0x0000000000000000    /*0 NULL descriptor 00*/.quad    0x0020980000000000    /*1 KERNEL Code 64-bit Segment 08*/.quad    0x0000920000000000    /*2 KERNEL Data 64-bit Segment 10*/.quad    0x0020f80000000000    /*3 USER    Code 64-bit Segment 18*/.quad    0x0000f20000000000    /*4 USER    Data 64-bit Segment 20*/.quad    0x00cf9a000000ffff    /*5 KERNEL Code 32-bit Segment 28*/.quad    0x00cf92000000ffff    /*6 KERNEL Data 32-bit Segment 30*/.fill    10,8,0                    /*8 ~ 9    TSS (jmp one segment <7>) in long-mode 128-bit 40*/// 重复十次,每次覆盖八个字节,每个位置都填充为0
GDT_END:GDT_POINTER:
GDT_LIMIT:    .word    GDT_END - gdt_table - 1
GDT_BASE:     .quad    gdt_table//=======     idt_table.globl idt_tableidt_table:.fill 512,8,0
IDT_END:IDT_POINTER:
IDT_LIMIT:    .word    IDT_END - idt_table - 1
IDT_BASE:     .quad    idt_table//=======     TSS64_Table.globl        TSS64_TableTSS64_Table:.fill 13,8,0
TSS64_END:TSS64_POINTER:
TSS64_LIMIT:    .word    TSS64_END - TSS64_Table - 1
TSS64_BASE:     .quad    TSS64_Table

在解读之前给大家推荐一个网站,大家可以使用这个网站来搜索相应的汇编关键词:https://sourceware.org/binutils/docs/as/,本文档中的汇编知识全部来自于这个网站(需要注意的是,我们这里不再使用上一章节的nasm了,使用的是GNU提供的汇编器GAS

这里在做的事情还是和上一章最后在做的事情一样:定义了GDT表IDT表TSS64,这里的TSS64是任务状态段。他们都会被存储在内核程序的数据段中。

我们来熟悉一下里面的一些语句:

  • .section .data:这个语句的前半部分.section表示把代码分成若干段,程序被加载时会加载到不同的地址,.data代表这部分代码书写的是程序的数据段,数据段是可读可写的。
  • .globl gdt_table:首先这个语句的.globl是一个声明,这个声明是告诉汇编器,后面的符号需要被连接器用到,所以要在目标文件的符号表中标记它是一个全局符号,这里是告诉连接器gdt_table是一个全局符号,gdt_table是一个标签,他的内容在下面进行了定义
  • .quad 0x0000000000000000.quad指令类似于上一章中的db指令,都是用于填充二进制的,但是.quad指令是用于填充一个8 Byte的二进制数字的,如果你仔细一数,就发现上面的十六进制数字由16个0构成,占空间8 Byte
  • .fill 10,8,0:这个.fill的标准用法如下fill repeat, size, value,这里就表示重复十次,每次写8个字节,填充的内容是0。
  • GDT_LIMIT: .word GDT_END - gdt_table - 1:这里的.word实际上和.quad是相似的,只不过它是存储了4Byte的数据。

这里出现的所有汇编语句我们都解释一遍了,大体上来说这里是一个数据段,定义了两个表个一个段,同时使用.globl使定义的数据段成为全局符号,可以被其他程序调用。

创建并初始化页表及页表项

//=======    init page
.align 8.org    0x1000__PML4E:.quad    0x102007.fill    255,8,0.quad    0x102007.fill    255,8,0.org    0x2000__PDPTE:.quad    0x103003.fill    511,8,0.org    0x3000__PDE:.quad    0x000083.quad    0x200083.quad    0x400083.quad    0x600083.quad    0x800083.quad    0xe0000083        /*0x a00000*/.quad    0xe0200083.quad    0xe0400083.quad    0xe0600083       /*0x1000000*/.quad    0xe0800083 .quad    0xe0a00083.quad    0xe0c00083.quad    0xe0e00083.fill    499,8,0

这段代码中有两个我们不大熟悉的地方:

  • .align 8:这个地方代表我们将下一条语句进行对齐,对齐的单位是8,比如说这下语句本来会被放在结尾是5的内存中,那么加入这条语句后,就会将下条语句的地址进行对齐,对齐的方法是后移。官方给出的描述是:'.align 8'将位置计数器递增到它的8的倍数。如果位置计数器已经是8的倍数,则无需更改。

  • .org 0x3000.org表示当前节的位置计数器前进到给定的位置,如果这个位置有错,那么就忽略这条语句。官方的指南中有这样的描述:.org只能增加或不改变位置计数器;您不能使用.org将位置计数器向后移动。

    以本程序为例,这个.org 0x3000会试图将当前的代码放置在程序头部偏移0x3000的位置,那么程序头部的位置我们已经在最开始的时候说过了,就是:0xffff800000000000 + 0x100000,那么__PDE会被存放在线性地址为0xffff800000000000 + 0x100000 + 0x3000的位置,其他的也是同理。

这里定义的是页表相关的数据。此页表将线性地址0和0xffff800000000000映射为同一物理页以方便页表切换,即程序在配置页表前运行于线性地址0x100000附近,经过跳转后运行于线性地址0xffff800000000000附近。这里将前10 MB物理内存分别映射到线性地址0处和0xffff800000000000处,接着把物理地址0xe0000000开始的16 MB内存映射到线性地址0xa00000处和0xffff800000a00000处,最后使用伪指令.fill将数值0填充到页表的剩余499个页表项里。

再次进行IA-32e模式初始化

.section .text.globl _start_start:mov    $0x10,    %axmov    %ax,      %dsmov    %ax,      %esmov    %ax,      %fsmov    %ax,      %ssmov    $0x7E00,  %esp//=======    load GDTRlgdt    GDT_POINTER(%rip)//=======  load      IDTRlidt   IDT_POINTER(%rip)mov    $0x10,    %axmov    %ax,      %dsmov    %ax,      %esmov    %ax,      %fsmov    %ax,      %gsmov    %ax,      %ssmovq   $0x7E00,    %rsp//=======  load      cr3movq   $0x101000,   %raxmovq   %rax,        %cr3movq   switch_seg(%rip),    %raxpushq  $0x08pushq  %raxlretq//=======  64-bit mode codeswitch_seg:.quad  entry64entry64:movq   $0x10,    %raxmovq   %rax,     %dsmovq   %rax,     %esmovq   %rax,     %gsmovq   %rax,     %ssmovq   $0xffff800000007E00,    %rsp        /* rsp address */movq   go_to_kernel(%rip),     %rax        /* movq address */pushq  $0x08pushq  %raxlretqgo_to_kernel:.quad    Start_Kernel

这里的流程和前面完全一致,我们主要来解读汇编代码:

  • .section .text表示一个新的段,这个.text表示这里是代码段

  • _start相当于c语言的main函数,他必须被声明为globl,程序从这里开始执行

  • mov $0x10, %ax将立即数0x10赋值给寄存器%ax

  • mov %ax, %ds%ax寄存器中的值赋值给%ds

  • lgdt GDT_POINTER(%rip)这里仍然是在装载GDT表,但是寻址方式有所变化:

    Intel汇编语言格式 AT&T汇编语言格式
    RIP-Relative寻址 [rip + displacement] displacement(%rip)
  • 函数跳转:这里作者用了一个非常有意思的方法,总体来说:作者伪造了函数调用的现场,假装自己被调用了,然后再假装自己调用结束,返回了。这样cpu就会进入我们刚才push到栈中的地址,或者说是“返回到”刚才push到栈中的地址。

做了这些讲解之后,这段代码就非常好懂了:

  • 首先:初始化了一系列寄存器的值
  • 第二步:读取了GDT、IDT表,更改了cr3
  • 第三步:使用特殊的跳转方法跳转到了entry 64函数
  • 第四步:在entry 64函数中,完成进入64位操作系统的操作后,跳转到Start_Kernel函数

由于这里没有定义Start_Kernel函数,所以是无法完成跳转的

makefile

kernel的makefile,这个非常简单,我们就不多讲解了。

all: head.ohead.o: head.Sgcc -E head.S > ./build/head.sas --64 -o ./build/head.o ./build/head.sclean: rm -rf ./build/*

对于主文件夹下的makefile这里我们用到了makefile中的for循环:

BOOT_SRC_DIR=./src/boot
KERNEL_SRC_DIR=./src/kernel
SUBDIRS=$(BOOT_SRC_DIR) $(KERNEL_SRC_DIR)define \n # 定义换行符,下面要用endef
BOOT_BUILD_DIR=$(BOOT_SRC_DIR)/build
KERNEL_BUILD_DIR=$(KERNEL_SRC_DIR)/build
PROJECT_BUILD_DIR=./buildall:
# 遍历每一个文件夹,进行编译$(foreach dir, $(SUBDIRS), cd $(dir) && $(MAKE) ${\n})install:
# 将boot的bin写入到引导扇区内 echo "特别声明:不要删除boot.img,如果删除了, 请到64位操作系统书中36页寻找复原方法"dd if=$(BOOT_BUILD_DIR)/boot.bin of=$(PROJECT_BUILD_DIR)/boot.img bs=512 count=1 conv=notrunc sudo mount $(PROJECT_BUILD_DIR)/boot.img /media/ -t vfat -o loopsudo cp $(BOOT_BUILD_DIR)/loader.bin /mediasudo cp $(BOOT_BUILD_DIR)/kernel.bin /mediasyncsudo umount /media/echo 挂载完成,请进入build文件夹后输入"bochs"以启动虚拟机clean:
# 遍历每一个文件夹,进行删除$(foreach dir, $(SUBDIRS), cd $(dir) && $(MAKE) clean ${\n})

这里方法也很简单,就是遍历每一个SUBDIRS中的文件夹,进行make操作,操作完再回来,再进入下一个文件夹。

这里使用了define定义了换行符,然后使用foreach方法,对于每一个$(SUBDIRS)中的文件夹dir,我们输出的内容是:

cd $(dir) && $(MAKE) ${\n}

意思就是进入文件夹、执行make、输出换行,之所以输出换行,是因为makefile中的cd仅在当前行有效,也就是说,我们换行就相当于回到主文件夹下重新执行了,这样就可以完成循环进入所有子文件夹,执行make的操作。

内核主程序

简单回顾一下,我们在上个小节中完成了三个表的声明与数据填充,并且重新进行了IA-32e模式的初始化,在内核执行头程序的最后,跳转到了Start_Kernel函数,这个Start_Kernel函数就是这一节我们想要介绍的内核主程序的主体。一般情况下,它将负责调各个系统模块的初始化函数,在初始化结束后,他会创建出系统的第一个进程init,并且将控制权交给init进程。

当然了,在这一个小节中,我们只是写一个“假的”内核主程序,在之后的其他小节中,我们会继续完善它。这一节主要讲解怎么去编译、链接它。

写一个“假的”内核主程序

这个就很脑瘫了,我们只要写一个含有Start_Kernel函数的程序即可,这里是main.c:(终于见到亲切的高级语言了)

// 内核主程序,执行完内核执行头程序后会跳转到Start_Kernel函数中
void Start_Kernel() {while(1){;}
}

这里我们就让他原地空转(死循环),接下来我们讨论如何编译它的问题。

编译内核主程序

这里我们继续完善我们的makefile文件,在此之前我们来完整的看一下当前的文件夹结构(之前从来没介绍过,大家可能看我的makefile的时候会很懵逼):

64BIT_OS
├─ README.md : 项目描述文件
├─ build :整个项目生成的可执行空间, 在这个文件夹下执行bochs就可以直接打开生成好的虚拟机
│  ├─ .bochsrc :虚拟机的描述文件
│  └─ boot.img :虚拟机的镜像文件
├─ docs :这个文档所在的文件夹,用于存储文档以及文档中用到的图片
├─ makefile : 整个项目的make脚本
└─ src :源码目录├─ boot:bootloader模块│  ├─ boot.asm :boot│  ├─ build :bootloader模块编译生成的文件都存储在这里│  ├─ fat12.inc :被%include的东西│  ├─ loader.asm :loader│  └─ makefile :bootloader模块的makefile文件,该脚本生成的文件都放在boot\biuild文件夹下└─ kernel:内核模块├─ build :kernel模块编译生成的文件都存储在这里├─ head.S├─ main.c└─ makefile : 内核的make脚本

这里我们继续完善的是kernel/makefile。在自己写之前,我们先来了解一下一个编译器在编译一个文件的时候执行的操作,为了完整的演示整个过程,我们再写一套专门用来演示的程序:(当然,你可以不看着一部分,这一部分只是为了让你深刻理解gcc编译器在编译一段程序的完整过程中会干些什么)

演示程序

temp.h

int a = 1;
int b = 2;
int c = 3;

temp.c

#include "temp.h"int main() {a = b + 1;
}

这里我们使用gcc temp.c就会生成一个可执行文件a.out,执行a.out时什么都不会输出,因为我们没有让这个程序输出。

编译过程

  • 预编译:我们在控制台执行指令gcc -E temp.c -o temp.i表示执行预编译,并且指定输出文件为temp.i,完成后打开temp.i查看:

    # 1 "temp.c"
    # 1 "<built-in>"
    # 1 "<command-line>"
    # 1 "/usr/include/stdc-predef.h" 1 3 4
    # 1 "<command-line>" 2
    # 1 "temp.c"# 1 "temp.h" 1
    int a = 1;
    int b = 2;
    int c = 3;
    # 7 "temp.c" 2int main() {a = b + 1;
    }

    可以看到,这里生成了一个文件,这个文件的实质就是把temp.h中的内容复制了进来。

  • 汇编:继续执行gcc -S temp.i -o temp.s

            .file   "temp.c".text.globl  a.data.align 4.type   a, @object.size   a, 4
    a:.long   1.globl  b.align 4.type   b, @object.size   b, 4
    b:.long   2.globl  c.align 4.type   c, @object.size   c, 4
    c:.long   3.text.globl  mainmain:
    .LFB0:.cfi_startprocendbr64pushq   %rbp.cfi_def_cfa_offset 16.cfi_offset 6, -16movq    %rsp, %rbp.cfi_def_cfa_register 6movl    b(%rip), %eaxaddl    $1, %eaxmovl    %eax, a(%rip)movl    $0, %eaxpopq    %rbp.cfi_def_cfa 7, 8ret.cfi_endproc
    .LFE0:.size   main, .-main.ident  "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0".section        .note.GNU-stack,"",@progbits.section        .note.gnu.property,"a".align 8.long    1f - 0f.long    4f - 1f.long    5
    0:.string  "GNU"
    1:.align 8.long    0xc0000002.long    3f - 2f
    2:.long    0x3
    3:.align 8
    4:

    这里生成了我们非常眼熟的gas汇编语言。

  • 生成编译好但没有连接的文件:gcc - c temp.s -o temp.o

    这里生成的是一个二进制文件,打开它来看没啥意义

  • 链接:gcc temp.o -o temp

    $ ./temp

    当然,执行他还是啥都不会发生,但是能够执行

到此为止,我们就把整个流程走了一遍了,相信大家都大致理解了编译的过程。

应用到项目中

回想一下我们要做的事情:先编译main.c,然后再让他按照我们的想法链接,于是我们就要先编译一下main.c,即在makefile中添加:

main.o: main.c
# 编译main.c内核主程序gcc -mcmodel=large -fno-builtin -m64 -c main.c -o ./build/main.o

接下来我们指定链接脚本,并且进行链接:

system: head.o main.old -b elf64-x86-64 -o ./build/system ./build/head.o ./build/main.o -T Kernel.lds

这句的意思就是链接head.o\main.o两个文件,指定输入的文件类型是efl64-x86-64,并且指定了链接脚本是Kernel.lds

我们这里也新建一个链接脚本:

OUTPUT_FORMAT("elf64-x86-64","elf64-x86-64","elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start)
SECTIONS
{. = 0xffff800000000000 + 0x100000;.text :{_text = .;*(.text)_etext = .;}. = ALIGN(8);.data :{_data = .;*(.data)_edata = .;}.bss :{_bss = .;*(.bss)_ebss = .;}_end = .;
}

这段脚本非常简单:

  • OUTPUT_FORMAT:指定输出的文件类型(和输入一样)
  • OUTPUT_ARCH:指定输出的体系结构
  • ENTRY:指定程序的入口(开始执行的地方)
  • SECTIONS:指定了程序被加载到哪里,每个段的相对关系以及对齐位置

相应的,all也不再依赖head.o/main.o了,而是转而依赖system即可,之后还需要添加:

all: system
# 生成kernel.binobjcopy -I elf64-x86-64 -S -R ".eh_frame" -R ".comment" -O binary ./build/system ./build/kernel.bin

这里使用了objcopy生成了kernel.bin,这里我们就生成了真·内核,所以我们就把上一章中的假内核删掉就可以了。我们要做出于以下改变:

  • 将boot文件夹下的makefile中生成kernel.bin的语句和依赖删除(自己删,或者看我的源码,这里就不啰嗦了,非常简单)

  • 更改主文件夹下拷贝kernel.bin的语句(说白了就是把copy的源改一下就可以了):

    sudo cp $(KERNEL_BUILD_DIR)/kernel.bin /media
    

简单验证

我们要验证一下到此为止是否正确执行,这里我们使用反汇编指令:

objdump -D system

找到Start Kernel部分

ffff80000010400c:       48 8d 05 f5 ff ff ff    lea    -0xb(%rip),%rax        # ffff800000104008 <Start_Kernel+0x8>
ffff800000104013:       49 bb 68 11 00 00 00    movabs $0x1168,%r11
ffff80000010401a:       00 00 00
ffff80000010401d:       4c 01 d8                add    %r11,%rax
ffff800000104020:       eb fe                   jmp    ffff800000104020 <Start_Kernel+0x20>

这里就标记出了while语句的线性地址ffff800000104020

^C00156063365i[      ] Ctrl-C detected in signal handler.
Next at t=156063366
(0) [0x000000104020] 0008:ffff800000104020 (unk. ctxt): jmp .-2 (0xffff800000104020) ; ebfe
<bochs:2> r
CPU0:
rax: ffff8000_00105170 rcx: 00000000_c0000080
rdx: 00000000_00000000 rbx: 00000000_00000000
rsp: ffff8000_00007df8 rbp: ffff8000_00007df8
rsi: 00000000_00008098 rdi: 00000000_0000bd00
r8 : 00000000_00000000 r9 : 00000000_00000000
r10: 00000000_00000000 r11: 00000000_00001168
r12: 00000000_00000000 r13: 00000000_00000000
r14: 00000000_00000000 r15: 00000000_00000000
rip: ffff8000_00104020
eflags 0x00000092: id vip vif ac vm rf nt IOPL=0 of df if tf SF zf AF pf cf

RIP寄存器存放着当前指令的地址,这里指示的正是当前的跳转指令,也就是说程序已经进入了死循环,验证结果为正确。

clion

接下来我们的项目会用clion来接管,主要原因是:好看,提示比较全,当然,由于我的git配置的原因(我故意的),大家克隆下来的项目不会有任何迹象,这里我就简单介绍一下主要的方法,这里我是用的脚本来做的一键执行,因为我们的代码中需要赋权,赋权需要密码,所以我就没把握的脚本放到github上面,这里我就把我的脚本分享一下:

#!/usr/bin/env zsh
make clean
make
echo [passowrd] | sudo -S make install
make cleancd build
bochs

在clion配置中,配置run.sh作为项目运行的脚本即可。上面的password直接填写自己的密码即可。

屏幕显示

之前在loader中,我们已经设置了显示模式:模式号:0x180、分辨率:1440×900、颜色深度:32 bit。我们在屏幕上显示东西的原理就是:将现实的画面存储到内存中的帧缓存区域中,帧缓存中的每一个存储单元对应着屏幕上的一个像素点。帧缓存的起始地址是:0xe0000000,我们之前在定义页表的过程中,已经进行了两组映射:

  • 0xe00000000xffff800000a00000
  • 0xe00000000xa00000

在屏幕上显示色彩

屏幕布局(画布)

首先,我们需要了解屏幕的布局:

我们的坐标系是从左上角为原点的。

单点像素构成

我们刚才说了,这个用的是32bit的颜色,这个描述方式只有前24位是有效位,他们分别表示rgb的值(其实就是标准的rgb描述)。

#define COLOR_OUTPUT_ADDR (int *)0xffff800000a00000
#define LINE_SIZE 1440// 画点的方法,给出坐标与颜色,将其覆盖
void plot_color_point(int x, int y, char r, char g, char b);// 内核主程序,执行完内核执行头程序后会跳转到Start_Kernel函数中
void Start_Kernel() {int *addr = (int *)0xffff800000a00000;int row, col;// 根据行列进行绘图,先画20行红色for(row = 0; row < 20; row++){for(col = 0; col < LINE_SIZE; col++){plot_color_point(row, col, 0xff, 0x00, 0x00);}}for(row = 20; row < 60; row++){for(col = 0; col < LINE_SIZE; col++){plot_color_point(row, col, 0x00, 0xff, 0x00);}}for(row = 60; row < 140; row++){for(col = 0; col < LINE_SIZE; col++){plot_color_point(row, col, 0x00, 0x00, 0xff);}}for(row = 140; row < 300; row++){for(col = 0; col < LINE_SIZE; col++){plot_color_point(row, col, 0xff, 0xff, 0xff);}}while(1){;}
}/*** 在屏幕上画一个点* @author wangsaiyu@cqu.edu.cn* @param x 在第x行上面画点* @param y 在第y列上面画点* @param r 点的红色色彩值* @param g 点的绿色色彩值* @param b 点的蓝色色彩值*/
void plot_color_point(int x,int y, char r, char g, char b){int* addr = COLOR_OUTPUT_ADDR + x * LINE_SIZE +y;*((char *)addr+0)=r;*((char *)addr+1)=g;*((char *)addr+2)=b;*((char *)addr+3)=(char)0x00;
}

我们将在屏幕上画一个点的方法进行封装,我们只需要给出需要绘画的点的横纵坐标以及rgb值即可进行绘图。绘图的方法也非常简单且暴力,就是直接覆盖对应的内存区域。在主程序中,我们多次调用这个方法进行绘图,绘出了四个不同颜色的矩形,效果如下:

显示字符串

在这一小节中,我们来实现向屏幕输出单个字符以及如何向屏幕输出一个字符串。我们的目标是实现一个printk函数,能够向屏幕输出一个字符串。

一个字符如何组成

我们都知道,电脑上看到的画面是由一个一个像素点组成的,同理,我们看到的字符也是由很多的像素点组成的,在我们的系统中,一个字符占16行8列的空间,如下:

我们使用一个数组来记录每个字符的方块的内容:

unsigned char font_ascii[256][16]=
{……/*    0040    */{0x02,0x04,0x08,0x08,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x08,0x08,0x04,0x02, 0x00}, // '('{0x80,0x40,0x20,0x20,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x20,0x20,0x40,0x80, 0x00}, // ')'{0x00,0x00,0x00,0x00,0x00,0x10,0x92,0x54,0x38,0x54,0x92,0x10,0x00,0x00,0x00, 0x00}, // '*'{0x00,0x00,0x00,0x00,0x00,0x10,0x10,0x10,0xfe,0x10,0x10,0x10,0x00,0x00,0x00, 0x00}, // '+'{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x08,0x08, 0x10}, // ','{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xfe,0x00,0x00,0x00,0x00,0x00,0x00, 0x00}, // '-'{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x00, 0x00}, // '.'{0x02,0x02,0x04,0x04,0x08,0x08,0x08,0x10,0x10,0x20,0x20,0x40,0x40,0x40,0x80, 0x80}, // '/'{0x00,0x18,0x24,0x24,0x42,0x42,0x42,0x42,0x42,0x42,0x42,0x24,0x24,0x18,0x00, 0x00}, //48    '0'{0x00,0x08,0x18,0x28,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x3e,0x00, 0x00}, // '1'……
};

这里是这个数组的局部,这是一个二维数组,他的size是[256][16]font_ascii[3]就代表ASCII码为3的字符的方块内容,其中一个数字代表一列,该数字的每一位代表一个像素是否进行填充。

如何移动光标并且打印文字

在上一段中,我们讲了单个字符是如何构成的,有了这个基础,就可以来学习怎么移动光标,并且打印单个文字了。

使用一个结构体来描述这些功能

在输出字符串的时候,需要对屏幕进行操作(实际上就是对用于显示的缓冲区进行操作),那么这个时候我们就想到了去建立一个"类"来描述这个过程,我们来看一下这个cursor类需要描述哪些东西:

  • 屏幕的大小
  • 光标的位置
  • 每个字符所占的方块的尺寸
  • 操作的缓冲区的起始地址以及长度

上面列出来的就是我们的缓冲区需要描述的属性,那么下一个问题,我们的光标在当前阶段需要进行哪些操作:

  • 向后移动一位
  • 模拟制表符操作
  • 模拟回退操作
  • 模拟换行操作
  • 清空屏幕
  • 输出一个字符

这是我所想到的,我们的缓冲区需要进行的操作,经过上面的思考后,我们的"类"就有了一个雏形(当然了,C语言没法面向对象,所以我们只能用变量+函数的形式来爽一下了):(这里我构建了一个程序position.h来描述相关的信息)


#ifndef __POSITION_H__
#define __POSITION_H__struct Position
{int x_resolution; // 屏幕行数int y_resolution; // 屏幕列数int x_position; // 当前光标左上角所在位置int y_position; // 当前光标右上角所在位置int x_char_size; // 每个字符所占位置的行数int y_char_size; // 每个字符所占位置的列数unsigned int * screen_buffer_base_address; // 显示缓冲区的首地址unsigned long screen_buffer_length; // 显示缓冲区的长度
}Pos;// 函数,给出一个描述屏幕的结构体,将光标后移一位
void DoNext(struct Position * cur_position);// 函数,给出一个描述屏幕的结构体,在当前位置模拟换行操作
void DoEnter(struct Position * cur_position);// 函数,给出一个描述屏幕的结构体,在当前位置模拟Backspace退格操作
void DoBackspace(struct Position * cur_position);// 函数,给出一个描述屏幕的结构体,在当前位置模拟Tab制表符操作(制表符大小为8个空格)
void DoTab(struct Position * cur_position);// 函数,给出一个描述屏幕的结构体,清空屏幕上所有的东西,并且将光标置为(0, 0)
void DoClear(struct Position * cur_position);// 函数,给出一个描述屏幕的结构体,以及想要在当前位置输出的文字的背景颜色、文字颜色、文字格式在当前位置进行输出
void DoPrint(struct Position * cur_position,const int back_ground_color, const int font_color, const char* char_format);#endif

各种方法的实现

接下来我们在position.c中,实现这些功能即可:

#include "position.h"// 实现position中的一些函数
char* defaultFill = "                                                                ";/*** 给出当前的位置结构体,将光标移动到下一区域* @param cur_position 一个指针,指向被操作的位置结构体*/
void DoNext(struct Position* cur_position){cur_position->y_position = cur_position->y_position + cur_position->y_char_size;if (cur_position->y_position >= cur_position->y_resolution) DoEnter(cur_position); // 试探,如果错误,就直接重置
}/*** 给出当前的位置结构体,模拟回车时的操作* @param cur_position 一个指针,指向被操作的位置结构体*/
void DoEnter(struct Position * cur_position){cur_position->y_position = 0;cur_position->x_position = cur_position->x_position + cur_position->x_char_size; // 平移if (cur_position->x_position >= cur_position->x_resolution) DoClear(cur_position); // 试探,如果错误,就直接重置
}/*** 给出当前的位置结构体,将光标置为(0, 0)[暂时不实现清空屏幕的功能]* @param cur_position 一个指针,指向被操作的位置结构体*/
void DoClear(struct Position * cur_position){cur_position->x_position = 0;cur_position->y_position = 0;
}/*** 给出当前的位置结构体,以及想要在当前位置输出的文字的背景颜色、文字颜色、文字格式在当前位置进行输出* @param cur_position 一个指针,指向被操作的位置结构体* @param back_ground_color 背景颜色* @param font_color 字体颜色* @param char_format  字体样式会根据字体样式进行颜色填充*/
void DoPrint(struct Position * cur_position,const int back_ground_color, const int font_color, const char* char_format){int row, col;// 遍历每一个像素块,进行输出for(row = cur_position->x_position; row < cur_position->x_position + cur_position->x_char_size; row ++){for(col = cur_position->y_position; col < cur_position->y_position + cur_position->y_char_size; col ++){int* pltAddr = cur_position->screen_buffer_base_address + cur_position->y_resolution * row + col; // 当前方块需要覆盖像素块的缓冲区地址int groupId =  row - cur_position->x_position; //  算出来在第几个char中int memberNum = col - cur_position->y_position; // 算出来在该char中是第几位char isFont = char_format[groupId] & (1 << (cur_position->y_char_size - memberNum)); // 判断该位是否为1(*pltAddr) = isFont ? font_color : back_ground_color; }}}/*** 给出当前的位置结构体,在当前位置模拟退格键[退格键不会将回车删除(也就是说,无论怎么退格,Y值都不会变)]* @param cur_position 一个指针,指向被操作的位置结构体*/
void DoBackspace(struct Position* cur_position){// 先在当前位置画一个空格(把之前的字符覆盖掉) 画的时候背景是黑色DoPrint(cur_position, 0x00000000, 0x00000000, defaultFill);// 如果不是行的第一个,那么就减一个空位cur_position->y_position = (cur_position->y_position - cur_position->y_char_size <= 0) ? 0 : cur_position->y_position - cur_position->y_char_size;
}/*** 给出当前的位置结构体,在当前位置模拟输入一次制表符* 这里无论如何都要做一次,然后直到对齐4位为止* @param cur_position 一个指针,指向被操作的位置结构体*/
void DoTab(struct Position * cur_position){do{DoNext(cur_position);} while(((cur_position->y_position / cur_position->y_char_size) & 4) == 0);
}

这里按照惯例,来讲解一下代码,我终于摆脱了copy作者代码的阴影,开始自己创作了,下面我们来简单的讲一下这些函数:

  • DoNext:将位置向后移动一个方格,如果当前位置已经是行末,那么就调用DoEnter()

  • DoEnter:将位置移动到下一行的行初,如果移动后超出了屏幕,就调用DoClear

  • DoClear:将位置移动到左上角(暂时没有让他带有清空屏幕的功能)

  • DoPrint:输出一个字符,在执行这个函数的过程中,有几个需要讲解的点:

    • int* pltAddr = cur_position->screen_buffer_base_address + cur_position->y_resolution * row + col;:计算我们要覆盖的屏幕显示缓冲区内存地址。

    • char_format[groupId] & (1 << (cur_position->y_char_size - memberNum)):作者给出的字符表是反着的,他实际的作用如下:

      实际上就是对比当前位是否为0。

  • DoBackspace将当前的位置用黑色覆盖,然后将位置向前移动一个即可。

  • DoTab:无论何时使用TAB的时候,都会向后至少移动一格,基于这个理论,我们使用一个do-while()就可以非常容易解决了。后面写的判断,就是一个简易的膜4,对于CPU来说,按位与运算只需要一个时钟周期即可完成,而取余数运算是基于除法运算的,对于64位的cpu而言,最简单的除法操作需要64个时钟周期,就算用华莱士数+both来做优化,也需要让流水线停止等待很久,所以说,对于2^n形式的数字进行取余数,可以使用按位与的方式来判断其是否为0,这样可以极大地加快其运算速度。

对实现结果的验证

我们想要输出字符串,就直接连续调用这个函数即可

我们在main函数中,实例化一个结构体,并且调用一些打印的方法来验证一下我们的程序是否正确:

// 初始化屏幕
struct Position* myPos = &(struct Position){SCREEN_ROW_LEN, SCREEN_COL_LEN,  // 屏幕行列0, 0, // 当前光标位置CHAR_ROW_LEN, CHAR_COL_LEN, // 字符行列COLOR_OUTPUT_ADDR, sizeof(int) * SCREEN_COL_LEN * SCREEN_ROW_LEN
};DoClear(myPos);int curChar;
for(curChar = 40; curChar < 130; curChar ++){ // 输出给出的表格中的每一个字符DoPrint(myPos, 0xffffff00, 0x00000000, font_ascii[curChar]);DoNext(myPos);if (curChar % 10 == 0) {if (curChar % 20 == 0) DoBackspace(myPos);DoEnter(myPos);if (curChar % 30 == 0) DoTab(myPos);}
}

每输出10个字符输出一个换行符,每输出20个字符输出一个退格,每输出30个输出一个TAB,执行结果如下,完全无误:

输出带有格式化信息的字符串

  • 首先,我们需要来复现一下使用场景,这里使用我们常用的printf函数来做类比:

    printf("numa:%d\n", numa);
    

    我们来解读一下这个句子,首先,这个句子包括两个大的部分,前面是一个字符串,他表示我们最终需要输出的字符串,在这个字符串中,可能会有一些占位符,具体来说,这些占位符可能是基础的:%d, %s, %c等等,也有可能是%06d这种的,当然,除此之外,这个字符串中可能还有一些较为特殊的字符,比如:\n, \t, \b这三个,他们分别表示回车、制表符、回退,这三个都无法直接输出,所以我们需要进行特殊的判断。

    综上所述,我们这个输出一行字的函数可以被拆分为两个阶段:

    • 预处理:给出一个格式字符串如"numa:%d\n"以及跟随的参数numa(这个参数的数量未知),对字符串进行解析,得到一个能够直接进行输出的字符串(相当于直接把后面的参数融合到字符串中)。
    • 输出:读取预处理完毕的字符串,逐个字符进行输出,输出到屏幕上,这里的输出非常简单,只需要调用上面封装好的操作即可

格式化字符串处理

这里就属于dirty work了,我直接就是一手copy(这里主要在做的工作就是解析字符串,然后来区分其中的%d,%.3f,%*d,这种的,然后再用后面的参数填充,这里我就不讲了,直接把作者的代码贴过来就可以了),当然,贴过来之前,我还是改了一下的,因为还需要适配我们写的显示模块:

#include <stdarg.h>
#include "printk.h"
#include "lib.h"
#include "linkage.h"
#include "position.h"extern inline int strlen(char* String);/*** 给出一段字符串,将字符串转换为数字* @param s 输入的字符串* @return 返回的数字, 整数类型*/
int SkipAtoi(const char **s)
{int i=0;while (IS_DIGIT(**s))i = i*10 + *((*s)++) - '0';return i;
}/*** 根据给出的参数,格式化输出一个数字* @param str* @param num* @param base* @param size* @param precision* @param type* @return 一个字符串,格式化完成后的该数字*/
static char * number(char * str, long num, int base, int size, int precision,   int type)
{char c,sign,tmp[50];const char *digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";int i;if (type&SMALL) digits = "0123456789abcdefghijklmnopqrstuvwxyz";if (type&LEFT) type &= ~ZERO_PAD;if (base < 2 || base > 36)return 0;c = (type & ZERO_PAD) ? '0' : ' ' ;sign = 0;if (type&SIGN && num < 0) {sign='-';num = -num;} elsesign=(type & PLUS) ? '+' : ((type & SPACE) ? ' ' : 0);if (sign) size--;if (type & SPECIAL)if (base == 16) size -= 2;else if (base == 8) size--;i = 0;if (num == 0)tmp[i++]='0';else while (num!=0)tmp[i++]=digits[DO_DIV(num,base)];if (i > precision) precision=i;size -= precision;if (!(type & (ZERO_PAD + LEFT)))while(size-- > 0)*str++ = ' ';if (sign)*str++ = sign;if (type & SPECIAL)if (base == 8)*str++ = '0';else if (base==16) {*str++ = '0';*str++ = digits[33];}if (!(type & LEFT))while(size-- > 0)*str++ = c;while(i < precision--)*str++ = '0';while(i-- > 0)*str++ = tmp[i];while(size-- > 0)*str++ = ' ';return str;
}/*** 对给出的字符串进行初始化(识别占位符并且根据需求进行填入)* @param saving_buffer 格式化后的字符串将返回到buf数组中* @param format_string 一个字符串,表示需要进行格式化的字符串如:"numa : %06d !\n"* @param args 用户输入的参数,用于对前面的字符串进行填充* @return 格式化后字符串的结束位置*/
int vsprintf(char * saving_buffer,const char *format_string, va_list args)
{char * str,*s;int flags;int field_width;int precision;int len,i;int qualifier;     /* 'h', 'l', 'L' or 'Z' for integer fields */for(str = saving_buffer; *format_string; format_string++){if (*format_string != '%'){*str++ = *format_string;continue;}flags = 0;repeat:format_string++;switch(*format_string){case '-':flags |= LEFT;  goto repeat;case '+':flags |= PLUS; goto repeat;case ' ':flags |= SPACE; goto repeat;case '#':flags |= SPECIAL;   goto repeat;case '0':flags |= ZERO_PAD;  goto repeat;}/* get field width */field_width = -1;if (IS_DIGIT(*format_string))field_width = SkipAtoi(&format_string);else if (*format_string == '*'){format_string++;field_width = va_arg(args, int);if (field_width < 0){field_width = -field_width;flags |= LEFT;}}/* get the precision */precision = -1;if (*format_string == '.'){format_string++;if (IS_DIGIT(*format_string))precision = SkipAtoi(&format_string);else if (*format_string == '*'){    format_string++;precision = va_arg(args, int);}if (precision < 0)precision = 0;}qualifier = -1;if (*format_string == 'h' || *format_string == 'l' || *format_string == 'L' || *format_string == 'Z'){   qualifier = *format_string;format_string++;}switch(*format_string){case 'c':if (!(flags & LEFT))while(--field_width > 0)*str++ = ' ';*str++ = (unsigned char)va_arg(args, int);while(--field_width > 0)*str++ = ' ';break;case 's':s = va_arg(args,char *);if (!s)s = '\0';len = strlen(s);if (precision < 0)precision = len;else if (len > precision)len = precision;if (!(flags & LEFT))while(len < field_width--)*str++ = ' ';for(i = 0;i < len ;i++)*str++ = *s++;while(len < field_width--)*str++ = ' ';break;case 'o':if (qualifier == 'l')str = number(str,va_arg(args,unsigned long),8,field_width,precision,flags);elsestr = number(str,va_arg(args,unsigned int),8,field_width,precision,flags);break;case 'p':if (field_width == -1){field_width = 2 * sizeof(void *);flags |= ZERO_PAD;}str = number(str,(unsigned long)va_arg(args,void *),16,field_width,precision,flags);break;case 'x':flags |= SMALL;case 'X':if (qualifier == 'l')str = number(str,va_arg(args,unsigned long),16,field_width,precision,flags);elsestr = number(str,va_arg(args,unsigned int),16,field_width,precision,flags);break;case 'd':case 'i':flags |= SIGN;case 'u':if (qualifier == 'l')str = number(str,va_arg(args,unsigned long),10,field_width,precision,flags);elsestr = number(str,va_arg(args,unsigned int),10,field_width,precision,flags);break;case 'n':if (qualifier == 'l'){long *ip = va_arg(args,long *);*ip = (str - saving_buffer);}else{int *ip = va_arg(args,int *);*ip = (str - saving_buffer);}break;case '%':*str++ = '%';break;default:*str++ = '%';    if (*format_string)*str++ = *format_string;elseformat_string--;break;}}*str = '\0';return str - saving_buffer;
}char saving_buffer[500];/*** 给出颜色与需要输出的东西,进行输出* @param FRcolor 一个整形数字,表示想要输出的字体颜色* @param BKcolor 一个整形数字,表示想要输出的背景颜色* @param format_string 一个字符串,表示用户输出的字符串* @param ... 可变长的参数列表,表示想要填充到字符串中的参数*/
int color_printk(unsigned int FRcolor,unsigned int BKcolor,const char * format_string,...)
{int i = 0;int count = 0;int line = 0;va_list args;va_start(args, format_string);i = vsprintf(saving_buffer,format_string, args);va_end(args);for(count = 0;count < i;count++){if (saving_buffer[count] == '\n') DoEnter(&global_position);else if (saving_buffer[count] == '\b') DoBackspace(&global_position);else if (saving_buffer[count] == '\t') DoTab(&global_position);else DoPrint(&global_position, BKcolor, FRcolor, font_ascii[saving_buffer[count]]);DoNext(&global_position);}return i;
}

我们调用这个color_printk

DoEnter(&global_position);
color_printk(YELLOW,BLACK,"Hello World!");
DoEnter(&global_position);

效果如下:

关键点1:多文件共享全局变量(讲给和我一样没学好C语言的人)

作者给出的文件的写法上存在一些错误(或者说是不规范的地方),比如我们在多个地方#include"font.h"之后,就会有multiple define的错误,经过上面的讲解,相信大家也能分析出来出现这样错误的原因:

  • 文件A调用了font.h
  • 文件B也调用了font.h
  • 预编译、汇编、二进制 文件A,在这个过程中,没有任何错误,并且预编译文件A时已经复制了font.h中的内容
  • 同理,预编译B的时候也复制了font.h中的内容
  • 最后一步:链接,这个时候就不对了,A/B都定义了font_ascii这个字符数组,那怎么办呢?报错吧

好了,这样就启示我们,不要在.h文件中定义数据。那问题就来了,不在.h中定义,怎么做到#include后开箱即用呢?其实也很简单,我们需要在一个.c文件中定义想要全局使用的变量(如font_ascii),然后再在.h文件中使用关键字extern引入该变量即可,我们来看一下在这个项目中,是如何使用这个方法的:

font.c

unsigned char font_ascii[256][16] = ......; // 定义数据

font.h

#ifndef __FONT_H__
#define __FONT_H__extern unsigned char font_ascii[256][16];#endif

其实如果用户#include"font.h",实际上就是使用了关键词extern unsigned char font_ascii[256][16];,来声明要使用这个全局变量。编译的时候,实际上编译器通过extern关键词知道我们要使用 font_ascii这个字符数组,但是实际上编译器是不知道font_ascii在哪里的。但是到了链接的时候,由于我们把font.c这个文件一起加进来编译了,所以程序执行的时候能够知道font_ascii的值。

关键点2:可变长参数

作者的程序中,#include <stdarg.h>是引入了一个库,这个库的主要作用是:他可以让函数能够接收未知数量的变量,那么什么是未知数量的变量呢?

printf("%d,%d,%c\n", numa, numb, charc);

我们来分析一下printf这个函数的构成:首先是一个字符串"%d,%d,%c\n",然后后面会根据字符串中的占位符的数量跟上很多变量,也就是说:在写程序的时候不知道后面会跟多少参数,那么就可用这个库来解决这个问题,在这个程序中我们将color_printk声明成下面的样子:

int color_printk(unsigned int FRcolor,unsigned int BKcolor,const char * format_string,...)

这里的format_string就相当于printf中的字符串,后面的...就是未知的参数了,我们来看下如何使用这个库:

  • va_list args;:创建一个 va_list 类型变量
  • va_start(args, format_string):给出上一个参数的位置,将args指向第一个可变参数
  • va_arg(args, int):取出下一个int类型的参数
  • va_end(vl);:结束标志
可变长参数的原理

这里我们还是来阅读下源码:

#define va_start(AP, LASTARG)                         \(AP = ((char *) &(LASTARG) + __va_rounded_size (LASTARG)))//ap指向下一个参数,lastarg不变

这个地方,我们给出的va_list就对应这里的ap,给出的上一个参数format_string就对应着这里的LASTARG,这里的含义是:va_list的第一个元素的内存地址是上一个参数的内存地址+__va_rounded_size (LASTARG)。其中__va_rounded_size (LASTARG)

#define __va_rounded_size(TYPE)  \(((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))

那么为什么这样就能指向va_list的首地址了呢?其实非常简单,我们不妨想一下函数入栈的操作:从右到左依次入栈;也就是说这些参数在内存中是线性排列的。那就是说:只要我们知道va_list前的第一个元素的地址,那么我们就不难知道va_list的地址了。

同理,我们想找到va_list下一个元素的位置也是非常简单,我们只需要知道这次想要读取的元素占了内存中多少个字节即可。

系统异常

首先通过以往的经验来谈一下什么是异常:(谈这些的时候都是基于对mips32架构的理解,而不是x86,所以和这个项目可能有些不同):

异常可能来源于中断、陷阱、系统调用、无效指令、溢出等等等等,简而言之就是如果执行到当前出现了cpu无法解决的问题的时候就会抛出异常;这里需要特别注意的是:异常是由cpu抛出的操作系统进行处理的;在mips中,cpu检测到异常后,会将流水线停掉,将异常信息记录到cp0寄存器中,然后跳转到异常处理例程。异常处理例程是由操作系统来决定的,该例程所在内存地址是约定俗成的(固定的,必须装载在固定位置)。

异常的分类

作者给出了一些描述:

  • 错误(fault)。错误是一种可被修正的异常。只要错误被修正,处理器可将程序或任务的运行环境还原至异常发生前(已在栈中保存CS和EIP寄存器值),并重新执行产生异常的指令,也就是说异常的返回地址指向产生错误的指令,而不是其后的位置。
  • 陷阱(trap)。陷阱异常同样允许处理器继续执行程序或任务,只不过处理器会跳过产生异常的指令,即陷阱异常的返回地址指向诱发陷阱指令之后的地址。
  • 终止(abort)。终止异常用于报告非常严重的错误,它往往无法准确提供产生异常的位置,同时也不允许程序或任务继续执行,典型的终止异常有硬件错误或系统表存在不合逻辑、非法值。

当终止异常产生后,程序现场不可恢复,也无法继续执行。当错误异常和陷阱异常产生后,程序现场可以恢复并继续执行,只不过错误异常会重新执行产生异常的指令,而陷阱异常会跳过产生异常的指令。

除此之外,还有一个异常的分类表:

向量号 助记符 异常/中断描述 异常/中断类型 错误码 触发源
0 #DE 除法错误 错误 No DIV或IDIV指令
1 #DB 调试异常 错误/陷阱 No 仅供Intel处理器使用
2 NMI中断 中断 No 不可屏蔽中断
3 #BP 断点异常 陷阱 No INT 3指令
4 #OF 溢出异常 陷阱 No INTO指令
5 #BR 越界异常 错误 No BOUND指令
6 #UD 无效/未定义的机器码 错误 No UD2指令或保留的机器码
7 #NM 设备异常(FPU不存在) 错误 No 浮点指令WAIT/FWAIT指令
8 #DF 双重错误 终止 Yes(Zero) 任何异常、NMI中断或INTR中断
9 协处理器段越界(保留) 错误 No 浮点指令
10 #TS 无效的TSS段 错误 Yes 访问TSS段或任务切换
11 #NP 段不存在 错误 Yes 加载段寄存器或访问系统段
12 #SS SS段错误 错误 Yes 栈操作或加载栈段寄存器SS
13 #GP 通用保护性异常 错误 Yes 任何内存引用和保护检测
14 #PF 页错误 错误 Yes 任何内存引用
15 Intel保留,请勿使用 No
16 #MF x87 FPU错误(计算错误) 错误 No x87 FPU浮点指令或WAIT/FWAIT指令
17 #AC 对齐检测 错误 Yes(Zero) 引用内存中的任何数据
18 #MC 机器检测 终止 No 如果有错误码,其与CPU类型有关
19 #XM SIMD浮点异常 错误 No SSE/SSE2/SSE3浮点指令
20 #VE 虚拟化异常 错误 No 违反EPT
21-31 Intel保留,请勿使用
32-255 用户自定义中断 中断 外部中断或执行INT n指令

当发生异常的时候,我们就可以通过向量号来知道异常的类型,同时通过错误码来检测异常的原因。

预备知识1:IDT表&中断门

参考:《编写操作系统之路》

在前面的学习中,IDT表已经多次出现了,我们每次都在对IDT表进行定义,以及使用lidt指令对IDT表进行更新、读取,但我们根本不知道它是干什么的。它的学名叫做中段描述符表。在IDT表中保存的是一个个“门”,那么每一个门中都保存着很多信息,在这些信息中最为关键的(我们需要理解的)就是:

  • 选择子:就是段选择子
  • 偏移:在相应段中的偏移地址

这个门可以被分为:中断门、调用门、陷阱门、任务门,为了方便理解,我们从调用门开始学起。

  • 调用门:调用门非常简单,他的作用就是被调用,方法是call 调用门,在执行这样的语句的时候实际上就是在做call 选择子:偏移,这个过程就等同于调用一个函数,但是于直接调用函数不同,使用调用门可以实现从低特权级到高特权级的转移。比如说:想要从用户模式的代码B调用搞特权级的A,如果直接call,那么就会出现错误(保护模式特性),这个时候使用调用门就可以解决这个问题。
  • 中断门:中断门和调用门基本相同,唯一的不同点在于在使用中断门时,系统会自动屏蔽掉其他中断

预备知识2:特权级转换实现

参考:

  • 《编写操作系统之路》
  • 总结:特权级之间的转换
段内跳转

首先我们来看一下最简单的函数跳转(段内跳转):

; 调用函数
push eax ; 压入参数2
push ebx ; 压入参数1
call function ; 调用; 被调用的函数
function:mov ebx, [esp + 4] ; 取出参数1mov ebx, [esp + 8] ; 取出参数2...ret ; 返回

这个过程应当是大家熟悉的,我们将参数压栈的顺序和取出参数的顺序应该是相反的,这是栈的基本特征。但是同时产生了一个问题:为什么取出最后一个push进去的参数需要+4呢?

  • 这是因为我们在使用call的时候,处理器默认将下一条需要执行的指令的地址也压入了栈中,这样在执行ret结束函数时,可以直接使用栈中的地址进行跳转,来达到继续执行的目的。而这个过程对于用户来说是不可见的。

段内跳转前后的栈内存如下:

段间跳转(特权级相同)

上面所讲述的跳转方法是段内跳转的方法。下面我们来看一下段间跳转,首先来看一张图,这张图表示的是长调用且不发生特权级转移时堆栈的变化:

可以看到,唯一的区别在于长跳转的时候,不仅保存了偏移,还保存了段选择符号,以便回到不同段的代码处继续执行。

段间转换(特权级不同)

首先需要说明的是,如果需要在不同特权级的段之间进行跳转的话,那么是不可以直接使用call +地址的形式来进行跳转的。而是需要使用我们上面说的各种门描述符来进行跳转,使用的方法就是:call gate,这样的方法就能在不同特权级的代码段之间切换了。那么我们来看,如何切换:

在不同特权级下的堆栈段不同,所以每一个任务最多可能在4个特权级间转移,所以,每个任务实际上需要4个堆栈。那么我们就遇到了第一个问题:如何保存不同特权级下的堆栈?

其实解决方法非常简单,因为intel已经给我们提供好了,我们使用一个叫做TSS的表格来存储cpu当前运行时的很多状态,其中的一项就是我们这里需要存储的:不同栈的ss和esp,需要注意的是,特权级0,1的都保存在tss段 ,是只读的。只有在访问更高特权级的时候,才会创建新的堆栈,同时在调用结束的时候,相应的堆栈也会被销毁,以供下一次调用。

我们来看一下调用前后栈的变化:

返回时:

现在,我们面临着最后一个问题:中间需要的很多信息从哪里来?

其实这个问题也很简单。。我们上面不是讲了一个门嘛,门里面含有两个Byte专门用于储存目标代码段的一些属性,比如参数数量等等。

最后我们总结一下在进行特权级切换时的流程:

  1. 从门中获得目标的特权级,来让TSS知道该切换到哪个ss和esp
  2. 从TSS中读取新的ss和esp
  3. 对ss进行校验(如果不存在就会爆出异常)
  4. 暂时保存这个时候的ss:esp,也就是调用者的ss,esp
  5. 加载新的ss、esp
  6. 将刚才保存的ss、esp加载到栈中
  7. 将调用者堆栈中的参数拷贝到新的栈中,其中参数的数量由门中属性区域的Param Count来决定,最多只有31个参数
  8. 将当前调用者的cs:eip压栈。
  9. 加载门中指定的cs:eip,开始执行新的代码,这样就完成了特权级变化的跳转

虽然这个过程很复杂, 但是它的原理很简单,如果你没有看懂也没有关系,你可以去网上搜索其他人的讲解。这个过程实际上是被intel公司封装好了的,我们只需要懂它的大概原理就可以进行使用了。下面一节的内容可能与现在的内容有部分重复。

预备知识3:中断处理过程(摘自《一个六十四位操作系统的设计与实现》有部分改动)

我们观察到上面的每一种异常/中断,都对应这样一个向量,发生中断时会用到之前定义过的IDT中段描述符表,来检索相应的门描述符,然后再通过地址映射的方法,找到中断处理例程的代码段的地址,最后转移到目标地址进行执行,整个过程如下图所示:

当发生中断时,会检测产生中断的程序的特权级,并且与代码寄存器的特权级进行比较:

  • 如果中断/异常处理程序的特权级更高,则会在中断/异常处理程序执行前切换栈空间,以下是栈空间的切换过程:

    (1) 处理器会从任务状态段TSS中取出对应特权级的栈段选择子和栈指针,并将它们作为中断/异常处理程序的栈空间进行切换。在栈空间切换的过程中,处理器将自动把切换前的SS和ESP寄存器值压入中断/异常处理程序栈。

    (2) 在栈空间切换的过程中,处理器还会保存被中断程序的EFLAGS、CS和EIP寄存器值到中断/异常处理程序栈。

    (3) 如果异常会产生错误码,则将其保存在异常栈内,位于EIP寄存器之后。

  • 如果中断/异常处理程序的特权级与代码段寄存器的特权级相等。

    (1) 处理器将保存被中断程序的EFLAGS、CS和EIP寄存器值到栈中(如图4-7所示)。

    (2) 如果异常会产生错误码,则将其保存在异常栈内,位于EIP寄存器之后。

    处理器必须借助IRET指令才能从异常/中断处理程序返回。

  • 异常/中断处理的标志位使用。当处理器穿过中断门或陷阱门执行异常/中断处理程序时,处理器会在标志寄存器EFLAGS入栈后复位TF标志位,以关闭单步调试功能。(处理器还会复位VM、RF和NT标志位。)在执行IRET指令的过程中,处理器会还原被中断程序的标志寄存器EFLAGS,进而相继还原TF、VM、RF和NT等标志位。

    中断门与陷阱门的不同之处在于执行处理程序时对IF标志位(位于标志寄存器EFLAGS中)的操作。

    • 当处理器穿过中断门执行异常/中断处理程序时,处理器将复位IF标志位,以防止其他中断请求干扰异常/中断处理程序。处理器会在随后执行IRET指令时,将栈中保存的EFLAGS寄存器值还原,进而置位IF标志位。
    • 当处理器穿过陷阱门执行异常/中断处理程序时,处理器却不会复位IF标志位。

    其实,中断和异常向量同在一张IDT内,只是它们的向量号不同罢了。IDT表的前32个向量号被异常占用,而且每个异常的向量号固定不能更改,从向量号32开始被中断处理程序所用。

检测除法错误

通过上面的学习,我们已经掌握了这部分的基本原理,接下来我们通过看作者代码+自己写的方法来进行应用。

初始化IDT表格

回顾下总体的过程,在发生异常的时候,cpu会拿着异常向量在IDT表格中进行查找,将代码跳转到相应的表项记录的地址。所以我们就需要对IDT表格进行初始化,给每一个异常号分配一个异常处理例程。这个过程非常好理解。

setup_IDT:                           leaq    ignore_int(%rip),   %rdxmovq    $(0x08 << 16),    %raxmovw    %dx,    %axmovq $(0x8E00 << 32),  %rcx        addq    %rcx,   %raxmovl    %edx,   %ecxshrl    $16,    %ecxshlq    $48,    %rcxaddq    %rcx,   %raxshrq    $32,    %rdxleaq    idt_table(%rip),    %rdimov $256,   %rcx
rp_sidt:movq    %rax,   (%rdi)movq  %rdx,   8(%rdi)addq $0x10,  %rdidec %rcxjne rp_sidt

我认为,我们不需要再去单句单句的去理解这段汇编干了什么了,因为说实话。。不是很有意义,我们的重点应该放在这段代码达到了什么目的上面。简单说,这段代码改变了IDT表的值,对IDT表进行了初始化,将idt表的每一项都填上了相同的值,这个值的意思是,当前门会跳转到这个ignore_int函数中进行处理,那么他是怎么实现的呢?(我还是会讲,就是不要太在意单个语句的作用,当前阶段学x86投入产出有点低):

  • 首先,在setup_IDT中,我们定义了一个标准的门(这个门会跳转到ignore_int函数)
  • 然后,获取了IDT表的地址leaq idt_table(%rip), %rdi
  • 最后,使用循环,每次覆盖IDT表的一项,覆盖256次(这是因为IDT表格一共有256个条目【为什么有256条目?没为什么!】)
  • 每执行完一次循环,就会自减dec %rcx,直到覆盖完成后结束:jne rp_sidt

初始化TSS

我们接着回忆过程,在跳转的时候,我们需要使用TSS来保存、切换状态,那么我们在这里需要给TSS一个初始的状态(TSS也需要初始化)

setup_TSS64:leaq    TSS64_Table(%rip),    %rdxxorq    %rax,    %raxxorq    %rcx,    %rcxmovq    $0x89,   %raxshlq    $40,     %raxmovl    %edx,    %ecxshrl    $24,     %ecxshlq    $56,     %rcxaddq    %rcx,    %raxxorq    %rcx,    %rcxmovl    %edx,    %ecxandl    $0xffffff,    %ecxshlq    $16,     %rcxaddq    %rcx,    %raxaddq    $103,    %raxleaq    gdt_table(%rip),    %rdimovq    %rax,    64(%rdi)shrq    $32,     %rdxmovq    %rdx,    72(%rdi)mov     $0x40,   %axltr     %axmovq    go_to_kernel(%rip),    %rax        /* movq address */pushq   $0x08pushq   %raxlretq

这部分程序负责初始化GDT(IA-32e模式)内的TSS Descriptor,并通过LTR汇编指令把TSS Descriptor的选择子加载到TR寄存器中。

异常处理模块

其实我觉得,前面两个部分,知道有这么个东西就可以了,这里才是重要的地方。

//=======    ignore_intignore_int:cldpushq    %raxpushq    %rbxpushq    %rcxpushq    %rdxpushq    %rbppushq    %rdipushq    %rsipushq    %r8pushq    %r9pushq    %r10pushq    %r11pushq    %r12pushq    %r13pushq    %r14pushq    %r15movq     %es,     %raxpushq    %raxmovq     %ds,     %raxpushq    %raxmovq     $0x10,   %raxmovq     %rax,    %dsmovq     %rax,    %esleaq     int_msg(%rip),    %rax            /* leaq get address */pushq    %raxmovq     %rax,    %rdxmovq     $0x00000000,    %rsimovq     $0x00ff0000,    %rdimovq     $0,      %raxcallq    color_printkaddq     $0x8,    %rspLoop:jmp      Looppopq     %raxmovq     %rax,    %dspopq     %raxmovq     %rax,    %espopq     %r15popq     %r14popq     %r13popq     %r12popq     %r11popq     %r10popq     %r9popq     %r8popq     %rsipopq     %rdipopq     %rbppopq     %rdxpopq     %rcxpopq     %rbxpopq     %raxiretqint_msg:.asciz "Unknown interrupt or fault at RIP\n"

到此为止,我们就完成了对异常的监听,并且进行简单的处理(过于简单,直接输出提示,并且宕机),向main函数中加入int a = 1 / 0,获得以下结果:

完善异常处理

添加门

现在给出一段代码,这段代码可以添加一个门:

#define _set_gate(gate_selector_addr,attr,ist,code_addr)             \
do                                                                   \
{    unsigned long __d0,__d1;                                        \__asm__ __volatile__    (    "movw    %%dx,    %%ax     \n\t"    \"andq    $0x7,    %%rcx    \n\t"    \"addq    %4,      %%rcx    \n\t"    \"shlq    $32,     %%rcx    \n\t"    \"addq    %%rcx,   %%rax    \n\t"    \"xorq    %%rcx,   %%rcx    \n\t"    \"movl    %%edx,   %%ecx    \n\t"    \"shrq    $16,     %%rcx    \n\t"    \"shlq    $48,     %%rcx    \n\t"    \"addq    %%rcx,   %%rax    \n\t"    \"movq    %%rax,   %0       \n\t"                \"shrq    $32,     %%rdx    \n\t"                \"movq    %%rdx,   %1       \n\t"                \:"=m"(*((unsigned long)(gate_selector_addr))),  \"=m"(*(1 + (unsigned long *)(gate_selector_addr))),"=&a"(__d0), "=&d"(__d1)                \:"i"(attr << 8),                                \"3"((unsigned long *)(code_addr)),"2"(0x8 <<16),"c"(ist)                                    \:"memory"                                       \);                                        \
}while(0)

实际上我们不需要理解这段代码到底是怎么运作的,我们需要知道的重点是:

  • 定义的方法:define,当我们输入_set_gate的时候,实际上就是将下面的代码全都复制进来了。
  • 执行的参数:
    • gate_selector_addr:对应的IDT表的向量号
    • attr: 添加到IDT中的项目的类型(陷阱、中断、系统调用)
    • ist: 用于填充TSS中的内容
    • code_addr:跳转到的目标地址
  • 执行的结果:系统遇到中断的时候,会使用中断向量进行检索IDT表,向我们已经定义好的目标地址进行带权跳转。

对添加门的代码进行二次封装

我们在这里对添加门的代码进行二次封装:

/*** 创建陷阱门* @param n 对应的IDT表的条目号* @param ist 用于填充TSS* @param addr 跳转的目标地址,需要传递一个函数指针*/
inline void set_intr_gate(unsigned int n,unsigned char ist,void * addr)
{_set_gate(idt_table + n , 0x8E , ist , addr); //P,DPL=0,TYPE=E
}/*** 创建一个陷阱门* @param n 对应的IDT表的条目号* @param ist 用于填充TSS* @param addr 跳转的目标地址,需要传递一个函数指针*/
inline void set_trap_gate(unsigned int n,unsigned char ist,void * addr)
{_set_gate(idt_table + n , 0x8F , ist , addr); //P,DPL=0,TYPE=F
}/*** 创建一个DPL为3的陷阱门* @param n 对应的IDT表的条目号* @param ist 用于填充TSS* @param addr 跳转的目标地址,需要传递一个函数指针*/
inline void set_system_gate(unsigned int n,unsigned char ist,void * addr)
{_set_gate(idt_table + n , 0xEF , ist , addr); //P,DPL=3,TYPE=F
}/*** 创建一个DPL是0的中断门* @param n 对应的IDT表的条目号* @param ist 用于填充TSS* @param addr 跳转的目标地址,需要传递一个函数指针*/
inline void set_system_intr_gate(unsigned int n,unsigned char ist,void * addr)  //Int3Entry
{_set_gate(idt_table + n , 0xEE , ist , addr); //P,DPL=3,TYPE=E
}

这样就可以更加方便的添加各种门了。对应的门的类型已经在代码中注明。

在这里有一个小的细节,我们还是需要讲一下的,我们在当前这个gate.h文件中写了这样的代码,尽心了一些变量类型的定义与声明:

struct GDTDescribeStruct
{unsigned char a_byte[8];
};struct IDTGateStruct
{unsigned char a_byte[16];
};extern struct GDTDescribeStruct gdt_table[];
extern struct IDTGateStruct idt_table[];
extern unsigned int TSS64_Table[26];

可以看到,这里我们使用了一个gdt_table,但是我们并没有在.c文件中进行定义,这是因为我们之前在head文件中的.global之中进行了定义,定义时的代码段如下:

.section .data.globl gdt_tablegdt_table:.quad    0x0000000000000000          /*0 NULL descriptor             00*/.quad   0x0020980000000000          /*1 KERNEL  Code    64-bit  Segment 08*/.quad   0x0000920000000000          /*2 KERNEL  Data    64-bit  Segment 10*/.quad   0x0020f80000000000          /*3 USER    Code    64-bit  Segment 18*/.quad   0x0000f20000000000          /*4 USER    Data    64-bit  Segment 20*/.quad   0x00cf9a000000ffff          /*5 KERNEL  Code    32-bit  Segment 28*/.quad   0x00cf92000000ffff          /*6 KERNEL  Data    32-bit  Segment 30*/.fill   10,8,0                  /*8 ~ 9 TSS (jmp one segment <7>) in long-mode 128-bit 40*/
GDT_END:

这样的定义方法,实际上和我们上一节中说的在.c文件中声明全局变量,然后再在.h文件中使用extern关键词进行引入相同。

添加各种门

在这一节中,我们会看一下作者是如何添加各种门的

作者给了我们一种实现的方法:

#include "trap.h"void SystemInterruptVectorInit()
{set_trap_gate(0,1,DivideErrorEntry);set_trap_gate(1,1,DebugEntry);set_intr_gate(2,1,NMIEntry);set_system_gate(3,1,Int3Entry);set_system_gate(4,1,OverflowEntry);set_system_gate(5,1,BoundsEntry);set_trap_gate(6,1,UndefinedOpcodeEntry);set_trap_gate(7,1,DevNotAvailableEntry);set_trap_gate(8,1,DoubleFaultEntry);set_trap_gate(9,1,CoprocessorSegmentOverrunEntry);set_trap_gate(10,1,InvalidTSSEntry);set_trap_gate(11,1,SegmentNotPresentEntry);set_trap_gate(12,1,StackSegmentFaultEntry);set_trap_gate(13,1,GeneralProtectionEntry);set_trap_gate(14,1,PageFaultEntry);//15 Intel reserved. Do not use.set_trap_gate(16,1,x87FPUErrorEntry);set_trap_gate(17,1,AlignmentCheckEntry);set_trap_gate(18,1,MachineCheckEntry);set_trap_gate(19,1,SIMDExceptionEntry);set_trap_gate(20,1,VirtualizationExceptionEntry);//set_system_gate(SYSTEM_CALL_VECTOR,7,system_call);
}

同理,需要在trap.h之中预定义各种各样的处理函数:

#ifndef __TRAP_H__#define __TRAP_H__#include "linkage.h"
#include "printk.h"
#include "lib.h"/**/void DivideErrorEntry();void DebugEntry();void NMIEntry();void Int3Entry();void OverflowEntry();void BoundsEntry();void UndefinedOpcodeEntry();void DevNotAvailableEntry();void DoubleFaultEntry();void CoprocessorSegmentOverrunEntry();void InvalidTSSEntry();void SegmentNotPresentEntry();void StackSegmentFaultEntry();void GeneralProtectionEntry();void PageFaultEntry();void x87FPUErrorEntry();void AlignmentCheckEntry();void MachineCheckEntry();void SIMDExceptionEntry();void VirtualizationExceptionEntry();/**/void SystemInterruptVectorInit();#endif

在这里,我们定义了一些参数的偏移量:

entry.S

#include "linkage.h"R15 =    0x00
R14 =    0x08
R13 =    0x10
R12 =    0x18
R11 =    0x20
R10 =    0x28
R9  =    0x30
R8  =    0x38
RBX =    0x40
RCX =    0x48
RDX =    0x50
RSI =    0x58
RDI =    0x60
RBP =    0x68
DS  =    0x70
ES  =    0x78
RAX =    0x80
FUNC    = 0x88
ERRCODE = 0x90
RIP =    0x98
CS  =    0xa0
RFLAGS =    0xa8
OLDRSP =    0xb0
OLDSS  =    0xb8

这里定义的函数会在汇编中实现,实现方法如下:

ENTRY(DebugEntry)pushq   $0pushq %raxleaq    DoDebug(%rip),  %raxxchgq   %rax,   (%rsp)jmp   error_code

这里的ENTRY是一个宏定义,是在linkage.h之中定义的,定义方法如下:


#define SYMBOL_NAME(X)  X#define SYMBOL_NAME_STR(X) #X#define SYMBOL_NAME_LABEL(X) X##:#define ENTRY(name)      \
.global SYMBOL_NAME(name);  \
SYMBOL_NAME_LABEL(name)

这个ENTERY实际上就是一个声明全局函数的方法。

在这个叫做DebugEntry的函数的最后,会进入到error_code这个代码段,改代码段如下:

error_code:pushq %raxmovq    %es,    %raxpushq   %raxmovq    %ds,    %raxpushq   %raxxorq    %rax,   %raxpushq   %rbppushq   %rdipushq   %rsipushq   %rdxpushq   %rcxpushq   %rbxpushq   %r8pushq    %r9pushq    %r10pushq   %r11pushq   %r12pushq   %r13pushq   %r14pushq   %r15    cldmovq ERRCODE(%rsp),  %rsimovq    FUNC(%rsp), %rdx    movq    $0x10,  %rdimovq    %rdi,   %dsmovq %rdi,   %esmovq %rsp,   %rdiGET_CURRENT(%ebx)callq  *%rdxjmp    ret_from_exception

可以看到,这个代码段实际上就是把各种寄存器的状态push到了栈中,然后调用了相应的方法,调用方法结束后跳转到ret_from_exception代码段进行复原,复原的代码段定义如下:

ret_from_exception:/*GET_CURRENT(%ebx)   need rewrite*/
ENTRY(ResetFromInterrupt)jmp    RESTORE_ALL /*need rewrite*/

而RESTORE_ALL就像下面一样:它的作用也非常简单,就是把各种push进来的都pop出去即可

RESTORE_ALL:popq    %r15;popq    %r14;popq    %r13;popq    %r12;popq    %r11;popq    %r10;popq    %r9;popq    %r8;popq    %rbx;popq    %rcx;popq    %rdx;popq    %rsi;popq    %rdi;popq    %rbp;popq    %rax;movq    %rax,    %ds;popq    %rax;movq    %rax,    %es;popq    %rax;addq    $0x10,   %rsp;iretq;

所以说,在整个过程中,汇编语言执行的是:将现场压入栈中,调用C语言函数进行处理、等待函数执行完毕、恢复现场的作用。我们来看一下div的异常处理:

void DoDivideError(unsigned long rsp,unsigned long error_code)
{unsigned long * p = NULL;p = (unsigned long *)(rsp + 0x98);color_printk(RED,BLACK,"DoDivideError(0),ERROR_CODE:%#018lx,RSP:%#018lx,RIP:%#018lx\n",error_code , rsp , *p);while(1){// Endless loopcontinue;}
}

这个处理方式也是非常简单的,就是直接将错误的信息输出了。

同理,我们再来看一个:

ENTRY(InvalidTSSEntry)pushq    %raxleaq     DoInvalidTSS(%rip),    %raxxchgq    %rax,    (%rsp)jmp      error_code

这个与前面的DebugEntry有一些区别,区别在于:InvalidTSSEntry是一个有返回错误码的中断,intel的cpu直接隐式的帮助我们将错误码push到栈中了,所以我们就不需要重复push了,我们之前的时候push 0 实际上就是将error_code置为了0.

最后,我们来看一个特殊的(唯一一个特殊的):


ENTRY(NMIEntry)pushq    %raxcld;            pushq   %rax;   pushq   %raxmovq    %es,    %raxpushq   %raxmovq    %ds,    %raxpushq   %raxxorq    %rax,   %raxpushq   %rbp;       pushq   %rdi;       pushq   %rsi;       pushq   %rdx;       pushq   %rcx;       pushq   %rbx;       pushq   %r8;        pushq   %r9;        pushq   %r10;       pushq   %r11;       pushq   %r12;       pushq   %r13;       pushq   %r14;       pushq   %r15;movq   $0x10,  %rdx;   movq    %rdx,   %ds;    movq    %rdx,   %es;movq    $0, %rsimovq    %rsp,   %rdicallq   DoNMIjmp    RESTORE_ALL

#NMI不可屏蔽中断不是异常,而是一个外部中断,从而不会生成错误码。#NMI应该执行中断处理过程,这段程序最后会跳转到DoNMI函数进行处理。了解了我们处理函数的入口后,我们接下来看一下几种错误码的组成。

在继续前进之前,我们先来梳理一下现在学习的内容,我画了一张图方便大家理解:

#TS异常错误码格式

  • 段选择子:索引IDT,GDT,LDT等描述符表内的描述符
  • EXT: 如果EXT是1,就说明当前异常是在程序向外投递外部事件的过程中触发,比如说想抛出异常结果抛出异常的过程的时候异常了.
  • IDT: 如果是1,就说明段选择子记录的是IDT表中的描述符;为0说明是GDT或LDT的内容.
  • TI: 当IDT是0的时候生效, 当TI为1则说明段选择子指向LDT内的描述符,否则指向GDT的描述符.

为了将这些错误进行报告,我们这样封装即可:

/*** 无效的TSS段, 可能发生在:访问TSS段或者任务切换时(这个时候也访问TSS)* 包含一个错误码, 该错误码由五个部分组成:* - 段选择子\ TI\ IDT\ EXT _ 保留位* @param rsp 段基址* @param error_code 错误码,由段选择子\ TI\ IDT\ EXT _ 保留位五个部分组成* */
void DoInvalidTSS(unsigned long rsp,unsigned long error_code)
{unsigned long * p = NULL;p = (unsigned long *)(rsp + 0x98);printk("DoInvalidTSS(10),ERROR_CODE:%#018lx,RSP:%#018lx,RIP:%#018lx\n",error_code , rsp , *p);switch (error_code & 6) {case 2: printk("Ref to IDT");break; // IDTcase 4: printk("Ref to LDT");break; // LDTcase 0: printk("Ref to GDT");break; // GDTdefault: printk("Ref ERROR!!!");break; // 错误码出错了}printk("Segment Selector Index:%#010x\n",error_code & 0xfff8);while(1){// Endless loopcontinue;}
}

为了输出方便,我封装了printk宏,该宏自动将背景颜色设置为黑色,将字体设置为白色(他的实现与color_printk)完全相同,只不过默认了颜色参数.

#PF异常错误码格式

PF错误是页错误,在进行内存引用时可能被触发:

不同位置所代表的含义已经在上面被列举出来了,我们当前阶段只需要根据不同的情况进行输出即可。下面是处理程序:

/*** 页错误,在访问内存出错时出现, 输出相应的错误类型* @param rsp 段基址* @param error_code 错误码,由段选择子\ TI\ IDT\ EXT _ 保留位五个部分组成*/
void DoPageFault(unsigned long rsp,unsigned long error_code)
{unsigned long * p = NULL;unsigned long cr2 = 0;__asm__   __volatile__("movq %%cr2,  %0":"=r"(cr2)::"memory");p = (unsigned long *)(rsp + 0x98);printk("DoPageFault(14),ERROR_CODE:%#018lx,RSP:%#018lx,RIP:%#018lx\n",error_code , rsp , *p);// 如果是0,就说明是缺页异常, 否则就说明是页级保护异常if (!(error_code & 0x01)) printk("Page Not-Present,\t");else printk("Page is Protected!\t"); // 检查W/R位,如果为1就说明写入错误,否则读取错误if (error_code & 0x02) printk("Write Cause Fault,\t");else printk("Read Cause Fault,\t");// 检查U/S位,1代表普通用户访问时出现错误,否则超级用户访问时错误if (error_code & 0x04) printk("Fault in user(3)\t");else printk("Fault in supervisor(0,1,2)\t");// RSVD位,如果为1则说明页表的保留项引发异常if (error_code & 0x08) printk(",Reserved Bit Cause Fault\t");// I/D位,如果为1则说明获取指令时发生异常if (error_code & 0x10) printk(",Instruction fetch Cause Fault");printk("\n");printk("CR2:%#018lx\n",cr2);while(1){// Endless loopcontinue;}
}

相信到此为止,大家一定对异常处理的入口有了一定的理解了。我们的异常处理部分也到此为止了,如果大家有兴趣的话,可以在本次提交的源代码仓库中查看如何printk各种异常。当然,这样的做法是没有什么意义的,它只能让我们看到发生了什么样的异常,然后宕机,而不能进行真正的处理,我们可能需要在后面再进行真正的处理。

编译验证

编译

相信大家也有相同的体会,kernel的makefile脚本,随着文件数量的增多,变得越来越长了,为了解决这个问题,这里更改了makefile脚本,如下:

C_FILE_LIST=main position printk font gate trap
C_FILE_BUILD_GOALS=$(foreach file, $(C_FILE_LIST), ./build/$(file).o)ASM_FILE_LIST=head entry
ASM_FILE_BUILD_GOALS=$(foreach file, $(ASM_FILE_LIST), ./build/$(file).o)define \nendefall: system
# 生成kernel.binobjcopy -I elf64-x86-64 -S -R ".eh_frame" -R ".comment" -O binary ./build/system ./build/kernel.binsystem: Kernel.lds build_all_c_code build_all_asm_code
# ld -b elf64-x86-64 $(ASM_FILE_BUILD_GOALS) $(C_FILE_BUILD_GOALS) -T Kernel.lds -o ./build/systembuild_all_c_code:$(foreach file, $(C_FILE_LIST), gcc -mcmodel=large -fno-builtin -fno-stack-protector -m64 -c $(file).c -o ./build/$(file).o  ${\n})build_all_asm_code:$(foreach file, $(ASM_FILE_LIST), gcc -E $(file).S > ./build/$(file).s  ${\n} as --64 -o ./build/$(file).o ./build/$(file).s ${\n})clean: rm -rf ./build/*

这里的实现方式就是循环编译没一个文件,然后最后在一起链接。

验证

这里对main.c进行了更改,这里对异常处理函数进行了限定, 对TSS进行了读取与初始化,相应的,我们需要将head.S中读取TSS的代码删除,以避免重复读取,产生错误:

DoEnter(&global_position);// TSS段描述符的段选择子加载到TR寄存器load_TR(8);// 初始化set_tss64(0xffff800000007c00, 0xffff800000007c00, 0xffff800000007c00, 0xffff800000007c00, 0xffff800000007c00, 0xffff800000007c00,0xffff800000007c00, 0xffff800000007c00, 0xffff800000007c00, 0xffff800000007c00);SystemInterruptVectorInit(); // 初始化IDT表,确定各种异常的处理函数int a = 1 / 0;while(1){;}

最后执行结果如下:

可以看到,屏幕上已经打印出了相应的错误。

初级内存管理单元

在这一节中,我们讨论的是入门级的内存管理内容主要包括下面三个部分:

  • 获取物理内容信息
  • 统计可用物理内存页数量
  • 分配物理页

简单回顾

在开始这一节的学习之前,我们来回顾一下上一章中,操作系统启动的过程中都做了些什么:

注意我标记为黑色的部分,在loader中,我们已经对计算机的内存信息进行了检查,并且将检测的信息进行了存储,相应的代码如下:

;=======  获取内存的类型及其对应的地址范围mov ax, 1301hmov    bx, 000Fhmov    dx, 0400h       ;row 4mov   cx, 44push  axmov   ax, dsmov   es, axpop   axmov   bp, StartGetMemStructMessageint 10hmov  ebx,    0mov    ax, 0x00mov es, axmov   di, MemoryStructBufferAddr  Label_Get_Mem_Struct:mov    eax,    0x0E820mov  ecx,    20mov   edx,    0x534D4150int   15hjc   Label_Get_Mem_Failadd   di, 20inc   dword   [MemStructNumber]cmp    ebx,    0jne    Label_Get_Mem_Structjmp Label_Get_Mem_OKLabel_Get_Mem_Fail:mov  dword   [MemStructNumber],  0mov    ax, 1301hmov    bx, 008Chmov    dx, 0500h       ;row 5mov   cx, 23push  axmov   ax, dsmov   es, axpop   axmov   bp, GetMemStructErrMessageint   10hLabel_Get_Mem_OK:mov ax, 1301hmov    bx, 000Fhmov    dx, 0600h       ;row 6mov   cx, 29push  axmov   ax, dsmov   es, axpop   axmov   bp, GetMemStructOKMessageint    10h 

这里我们通过int 15h中断,将一个个内存块的信息进行保存,我们不用完整的复习int 15h的相关内容,只需知道三个关键点:

  • 一、 int 15h将内存信息保存到了 es:di,这里每次做完一次之后di都会增加20

  • 二、存储的内存信息格式如下:

    偏移 名称 意义
    0 BaseAddrLow 基地址的低 32 位
    4 BaseAddrHigh 基地址的高 32 位
    8 LengthLow ⻓度(字节)的低 32 位
    12 LengthHigh ⻓度(字节)的高 32 位
    16 Type 这个内存区域的类型

    我们可以看到,这里的信息实际上就是在用起点 + 长度的方法来描述一块内存,其中,内存类型如下:

    取值 名称 意义
    1 AddressRangeMemory 可以被OS使用的内存
    2 AddressRangeReserved 正在使用的区域或者不能系统保留不能使用的区域
    其他 未定义 各个具体机器会有不同的意义,在这里我们暂时不用关心,将它视为AddressRangeReserved即可
  • 三、这些信息被保存在0x7e00地址下

那剩下的事情就变得简单且枯燥了,我们只需要从0x7e00开始读取,一次读取20字节,20字节中包含5个整形,每个整型变量都表示一个参数。剩下的事情都会变得非常自然,我们只需要读取即可。

读取内存分布

在这里,我们新建一个文件memory.h/memory.c来描述内存相关的操作,为了描述每个内存块,我们建立一个和上面表格结构相同的结构体:

/*** 用于描述一个内存块的结构体* baseAddrHigh : baseAddrLow 拼接起来就是基地址* lengthHigh : lengthLow 拼接起来就是内存块长度* type 内存类型, 1 :可被os使用, 2 : 正在使用或不可使用 其他:没有意义*/
struct MemoryBlockE820{unsigned int baseAddrLow;unsigned int baseAddrHigh;unsigned int lengthLow;unsigned int lengthHigh;unsigned int type;
};

除此之外,我们还需要一个函数来对内存信息进行初始化,以读取存储在内存0x7e00为首的内存信息,就叫他InitMemory,我们在memory.h中对其进行声明,在memory.c中对其进行实现即可:

/*** 读取内存中存储的内存块信息* 默认读取基地址为0x7e00,这里填写线性地址* 会输出每一块内存的信息,并且输出总可用内存*/
void InitMemory(){unsigned long TotalMem = 0 ;struct MemoryBlockE820 *p = NULL;   printk("Display Physics Address MAP,Type(1:RAM,2:ROM or Reserved,3:ACPI Reclaim Memory,4:ACPI NVS Memory,Others:Undefine)\n");p = (struct MemoryBlockE820 *)0xffff800000007e00; // 将基地址对齐// 循环遍历,输出内存信息for(int i = 0;i < 32;i++){// 输出内存信息printk("Address:%#010x,%08x\tLength:%#010x,%08x\tType:%#010x\n", \p->baseAddrHigh,p->baseAddrLow,p->lengthHigh,p->lengthLow,p->type);// 统计可用内存unsigned long tmp = 0;if (p->type == 1){ // 如果内存可用tmp = p->lengthHigh;TotalMem +=  p->lengthLow;TotalMem +=  tmp  << 32;}if (p->type > 4)break;       }// 输出总可用内存printk("OS Can Used Total RAM:%#018lx\n",TotalMem);
}

这个函数并没有进行存储,而直接将信息进行了输出,我们在main中添加相应的输出,进行查看(需要注意的是,你需要把除0错误去掉,要不然会宕机),更改后的程序片段如下:

SystemInterruptVectorInit(); // 初始化IDT表,确定各种异常的处理函数InitMemory(); // 输出所有内存信息while(1){;
}

由于我们新建了一个memory,所以我们需要向makefile文件中添加相应的描述:

C_FILE_LIST=main position printk font gate trap memory
C_FILE_BUILD_GOALS=$(foreach file, $(C_FILE_LIST), ./build/$(file).o)

添加的方法非常简单,我们只需要将memory添加到C_FILE_LIST的尾部即可,可见我们之前的写法带来了极大的便利。

运行结果如下:

我们的这个虚拟机一共有0x7ff8f000的内存,换算一下大约是2G,这与我们之前做配置的时候分配的值是相近的,不完全相同是因为我们其他部分还占用了一部分的内存。

统计可用内存

在上一节中,我们的内存块的描述信息分了高低位来表示,但显然这不大合理。所以我们准备用新的方法来表示(就是把两个int合成一个long):

/*** 用于描述一个内存块的结构体* baseAddr 基地址* length 内存块长度* type 内存类型, 1 :可被os使用, 2 : 正在使用或不可使用 其他:没有意义*/
struct MemoryBlockE820{unsigned long baseAddr;unsigned long length;unsigned int type;
}__attribute__((packed));

同时,为了记录全局信息,我们使用一个全局描述表来进行存储,同样,全局描述表也是一个结构体:

/*** 全局的内存信息描述*/
struct GlobalMemoryDescriptor{struct MemoryBlockE820    e820[32];unsigned long   e820_length;
};extern struct GlobalMemoryDescriptor memory_management_struct;

相应的,我们需要对这部分的读取代码进行更改:

// 循环遍历,输出内存信息
for(int i = 0;i < 32;i++, p++){// 输出内存信息printk("Address:%#018lx\tLength:%#018lx\tType:%#010x\n", p->baseAddr,p->length,p->type);// 统计可用内存unsigned long tmp = 0;if (p->type == 1) TotalMem += p->length;// 存储内存块信息, 更新长度memory_management_struct.e820[i] = (struct MemoryBlockE820){p->baseAddr, p->length, p->type};memory_management_struct.e820_length = i;if (p->type > 4) break;
}

更改后运行结果如下:

实际上结果没有改变,因为我们只不过是换了个类型而已,内存空间是没有变的

接下来,我们要对结构体数组中的可用物理内存段进行2 MB物理页边界对齐,并统计出可用物理页的总量,为此,需要进行一些宏定义,这些宏定义我们就用作者给出的,如下:

#define PTRS_PER_PAGE    512 // 页表中条目的个数#define PAGE_OFFSET ((unsigned long)0xffff800000000000) // 内核层起始线性地址#define PAGE_1G_SHIFT    30 // 1GB = 1Byte << 30
#define PAGE_2M_SHIFT    21 // 2MB = 1Byte << 21
#define PAGE_4K_SHIFT    12 // 4kB = 1Byte << 12#define PAGE_2M_SIZE     (1UL << PAGE_2M_SHIFT) // 2M内存的大小
#define PAGE_4K_SIZE     (1UL << PAGE_4K_SHIFT) // 4K内存的大小// Mask, 类似于子网掩码
#define PAGE_2M_MASK     (~ (PAGE_2M_SIZE - 1)) // 1111..0000
#define PAGE_4K_MASK     (~ (PAGE_4K_SIZE - 1)) // 1111..0000 // 将参数addr按2 MB页的上边界对齐
#define PAGE_2M_ALIGN(addr)   (((unsigned long)(addr) + PAGE_2M_SIZE - 1) & PAGE_2M_MASK)
#define PAGE_4K_ALIGN(addr)   (((unsigned long)(addr) + PAGE_4K_SIZE - 1) & PAGE_4K_MASK)// 将内核层虚拟地址转换成物理地址
#define CONVERT_VIRTUAL_ADDRESS_TO_PHYSICAL_ADDRESS(addr)  ((unsigned long)(addr) - PAGE_OFFSET)// 将物理地址转换成内核层虚拟地址
#define CONVERT_PHYSICAL_ADDRESS_TO_VIRTUAL_ADDRESS(addr)  ((unsigned long *)((unsigned long)(addr) + PAGE_OFFSET))

各个宏的含义已经在上面的代码中定义,需要特别注意的是对齐的方法:

  • 首先有一个MASK, 这个mask和计算机网络中的子网掩码非常相似,我们可以把内存块地址的前半部分看做计算机网络中的公网ip,公网ip加上偏移,就是完整的ip地址,这里我们的mask就像是子网掩码,1标记的地方描述了当前的地址空间处于那个页中(当然这个页是由我们进行划分的)。
  • 对齐想要达到的目的就是:0xxxxxxxxxx对应的内存地址,都对应0xxxxxxx000这个内存块, 这里的对齐方式不是向下对齐,而是向上对齐,所以要先加上块大小-1再进行取余

接下来我们用这个宏来统计可用页的数量:

TotalMem = 0;for(int i = 0; i <= memory_management_struct.e820_length; i ++){if (memory_management_struct.e820[i].type != 1) continue;unsigned long start, end;start = PAGE_2M_ALIGN(memory_management_struct.e820[i].baseAddr); // 计算开头的向后对齐地址end = ((memory_management_struct.e820[i].baseAddr + memory_management_struct.e820[i].length)>> PAGE_2M_SHIFT) << PAGE_2M_SHIFT; // 计算结尾的向前对齐地址if (end <= start) continue;TotalMem += (end - start) >> PAGE_2M_SHIFT;printk("OS Can Used Total 2M PAGEs:%#010x=%010d\n",TotalMem,TotalMem);}

可以看到,我们一共有1022个可用的页,当然了,这种取页的方法存在他的局限性,比如说:每一段都要掐头去尾(因为头向后对齐,尾向前对齐),段越多,浪费的空间也就越多,但是我们还是统计出了页的信息!

分配可用的物理页

组织结构

现在我们手中掌握了1022个物理页,那么下一步就是去管理他们了。为了更好的管理内存,我们创建了如下的结构体:

/*** 描述一个页的基本信息* @param zone_struct 指向本页所属的区域结构体* @param PHY_address 页的物理地址* @param attribute 页的属性* @param reference_count 描述该页的引用次数* @param age 描述该页的创建时间*/
struct Page {struct Zone *        zone_struct;unsigned long        PHY_address;unsigned long        attribute;unsigned long        reference_count;unsigned long        age;
};
/*** 可用物理内存区域* @param pages_group 属于该zone的page的列表* @param pages_length 当前区域包含的页的数量* @param zone_start_address 本区域第一个页对齐后地址* @param zone_end_address  本区域最后一个页对齐后地址* @param zone_length 本区域经过页对齐后的地址长度* @param attribute 空间属性* @param GMD_struct 指针,指向全局描述Global_Memory_Descriptor 的实例化对象 memory_management_struct* @param page_using_count 已经使用的页数量* @param page_free_count 没有被使用的页的数量* @param total_pages_link 本区域物理页被引用次数之和*/
struct Zone {struct Page *        pages_group;unsigned long        pages_length;unsigned long        zone_start_address;unsigned long        zone_end_address;unsigned long        zone_length;unsigned long        attribute;struct GlobalMemoryDescriptor * GMD_struct;unsigned long        page_using_count;unsigned long        page_free_count;unsigned long        total_pages_link;
};

相信通过上面两个结构体的定义,大家能够理解这里内存管理的组织方式了:

其中,B代表不能被我们使用的内存,那么相应的,我们全局的内存描述也需要做出相应的变化:

/*** 全局的内存信息描述* @param e820 cpu探索内存后存储的信息* @param e820_length 记录有效的内存的长度,配合e820使用* @param bits_map 物理地址空间页映射位图* @param bits_size 物理地址空间页数量* @param bits_length 物理地址空间页映射位图长度* @param pages_struct   指向全局struct page结构体数组的指针* @param pages_size struct page结构体总数* @param pages_length struct page结构体数组长度* @param zones_struct 指向全局zone结构体数组的指针* @param zones_size zone结构体数量* @param zones_length zone数组长度* @param start_code 内核程序起始段代码地址* @param end_code 内核程序结束代码段地址* @param end_data 内核程序结束数据段地址* @param end_brk 内核程序的结束地址* @param end_of_struct 内存页管理结构的结尾地址*/
struct GlobalMemoryDescriptor{struct MemoryBlockE820    e820[32];unsigned long               e820_length;   unsigned long *   bits_map;unsigned long     bits_size;unsigned long     bits_length;struct Page *     pages_struct;unsigned long     pages_size;unsigned long     pages_length;struct Zone *     zones_struct;unsigned long     zones_size;unsigned long     zones_length;unsigned long     start_code , end_code , end_data , end_brk;unsigned long     end_of_struct;
};

初始化全局描述子存储空间

各个变量的含义已经列出,其中bits相关字段是struct page结构体的位图映射,它们是一一对应的关系。start_codeend_codeend_dataend_brk这4个成员变量,它们用于保存内核程序编译后的各段首尾地址。同时,为了获得这四个标记符的位置,我们需要使用编译器生成的全局变量:

// 标记编译器生成的区间
extern char _text;
extern char _etext;
extern char _edata;
extern char _end;

现在,有了这些结构体之后,我们需要将这些结构体的内容进行填充,首先在主函数中,修改start_codeend_codeend_dataend_brk这4个成员变量:

memory_management_struct.start_code = (unsigned long)& _text;
memory_management_struct.end_code   = (unsigned long)& _etext;
memory_management_struct.end_data   = (unsigned long)& _edata;
memory_management_struct.end_brk    = (unsigned long)& _end;

接下来我们开始初始化bits_map,在初始化前需要先改变上面初始化有效内存块信息时的条件:

// 循环遍历,输出内存信息
for(int i = 0;i < 32;i++, p++){if (p->type > 4 || p->length == 0 || p->type < 1) break;        // 输出内存信息printk("Address:%#018lx\tLength:%#018lx\tType:%#010x\n", p->baseAddr,p->length,p->type);// 统计可用内存unsigned long tmp = 0;if (p->type == 1) TotalMem += p->length;// 存储内存块信息, 更新长度memory_management_struct.e820[i] = (struct MemoryBlockE820){p->baseAddr, p->length, p->type};memory_management_struct.e820_length = i;}

这样的做法是为了截断并且剔除脏数据。完成后,再对bits_map进行初始化:

/**
* ==========初始化 全局描述子中 与 bits map 相关的成员变量 ==========*/
// 计算bits_map的首地址, 计算方法为:内核程序的结束地址向后对齐, 即: 内核地址结束后的4k向后对齐
memory_management_struct.bits_map = (unsigned long *)((memory_management_struct.end_brk + PAGE_4K_SIZE - 1) & PAGE_4K_MASK);
// 计算bits_size, 即bits_map的大小, 其大小为最后一块可用内存地址/ 每一页的大小, 即:所有内存划分为页的页数
memory_management_struct.bits_size = TotalMem >> PAGE_2M_SHIFT;
// 计算位图的内存长度
memory_management_struct.bits_length = (((unsigned long)(TotalMem >> PAGE_2M_SHIFT) + sizeof(long) * 8 - 1) / 8) & ( ~ (sizeof(long) - 1));
// 将bitsmap置位,全都置位为1
memset(memory_management_struct.bits_map,0xff,memory_management_struct.bits_length);// 输出相关信息
printk("bits_map : %d, bits_size: %d, bits_length: %d\n", memory_management_struct.bits_map, memory_management_struct.bits_size, memory_management_struct.bits_length);

其中,每一步在做什么已经在注释中写得非常清楚了,相信大家也能够模糊的了解到bits_map与页之间的关系——即bits_map与内存中的页结构一一对应。

执行结果如下:

这里大家可能存在一个疑问:为什么这里统计出来有2048页,上面统计出来有1022页?有这个疑问的人一看就是前面没仔细看

64位操作系统——(二)kernel相关推荐

  1. 在RedHat4 64位操作系统下,安装Oracle 10g

    在RedHat4 64位操作系统下,安装Oracle <?xml:namespace prefix = st1 ns = "urn:schemas-microsoft-com:offi ...

  2. Win10的64位操作系统,Visual Studio 2019配置OpenCV4.1.0

    一.Win10的64位操作系统,直接在VS官网下载VisualStudioCommunity,默认安装,安装的是VisualStudioCommunity2019: (安装的[工作负载]步骤时选的是[ ...

  3. 64位电脑mysql_Windows 64位操作系统下安装和配置MySQL

    一安装方式 MySQL安装文件分为两种,一种是MSI格式的,一种是ZIP格式的.下面来看看这两种方式: MSI格式的可以直接点击安装,按照它给出的安装提示进行安装,Windows操作系统下一般MySQ ...

  4. 32位与64位操作系统以及CPU的关系

    版权声明:转载请注明出处,共同学习. https://blog.csdn.net/SHENNONGZHAIZHU/article/details/51122684 32位和64位的区别: 从硬件看, ...

  5. 计算机是否支持64位操作系统,如何确定电脑的CPU是否支持64位操作系统

    想要确定电脑的CPU是否支持64位操作系统吗,那么如何确定电脑的CPU是否支持64位操作系统的呢?下面是学习啦小编收集整理的如何确定电脑的CPU是否支持64位操作系统,希望对大家有帮助~~ 确定电脑的 ...

  6. VB 6.0 如何在64位操作系统下运行!

    XP系统已经被停止维护很长一段时间了,但是还是有不少朋友可能还没有升级到WIN7或更高的操作系统.对于使用VB6.0作为开发工具的朋友来说,是否升级到64位操作系统,是个有点纠结的问题. 我们无外乎几 ...

  7. 在32位、64位操作系统下各数据类型所占的字节数

    点击打开链接 在32位.64位系统当中,唯一改变的是指针的长度;在32位系统当中是4个字节.64位则是8个字节.所谓的32位.64位,这个指的是寄存器的位宽. 32位平台下结果: 64位平台下结果: ...

  8. singress卸载_深信服在64位操作系统下的使用方法

    深信服在 64 位系统下验证的使用方法 随着电脑操作系统的迅速发展, 科技的不断更新, 使用深信服的用户开始面对一个崭新 的问题, 操作系统从原来的 32 位慢慢的开始向 64 位升级, 而深信服产品 ...

  9. 64 位操作系统下, Revit 如何修改代码后再次快速调试新代码

    Visual Studio (简称VS)提供了暂时中断调试,然后修改代码,接着更新代码就可以实现继续调试运行的功能.但是这个再调试过程中暂时中断修改代码的功能在64位操作系统下无法实现. 这个问题导致 ...

最新文章

  1. Oracle教程之四招提高Oracle位图索引的使用效果
  2. POJ 3748:位操作
  3. 2020年408真题_2020年408真题和参考解析
  4. JDBC连接MySQL数据库及示例
  5. Spring JSR-250 注释
  6. 8597 石子划分问题 dpdp,只考虑第一次即可
  7. Think in Java第四版 读书笔记2
  8. linux脚本能轮循吗,通过Linux定时任务实现定时轮询数据库及发送Http请求
  9. Oracle case用法
  10. APK反编译JAVA源码
  11. java网上订餐系统怎么做_基于Java的网上订餐系统
  12. java链表打印_java链表打印
  13. Ubuntu安装caj阅读器
  14. PoE供电概述:PoE交换机是如何进行供电的?
  15. [数学建模] 大数据建模五步法
  16. java 数字转英文_一个java的问题 讲输入的阿拉伯数字转换成英文
  17. pgsql timestamp without time zone > character varying解决方案
  18. 2022-2027年中国卫星遥感市场竞争态势及行业投资前景预测报告
  19. TCP协议--复位报文段
  20. h2 使用liquibase的changelog表格创建不成功

热门文章

  1. linux 程序提权,Linux提权辅助工具
  2. webp图片格式在手持设备性能测试
  3. 计算机一级考试项目符号怎么添加,【添加项目符号和编号】 项目符号可以是多选...
  4. 什么是脏读、幻读、不可重复读、可重复读
  5. 如何判断一个面试者的深度学习水平?| 文末送书
  6. 使用JDBC后千万记得关闭并释放数据库连接资源
  7. 第七章 数据类型、平面直角坐标系和复数
  8. WIN10设置用户密码
  9. 做线雕多久能恢复自然_线雕多久能恢复自然?
  10. Python 获取线程返回值获取