《操作系统真象还原》第五章(2)

《操作系统真象还原》第五章(2)

本篇对应书籍第五章5.3–5.4的内容

本篇章简单介绍了 ELF 文件格式,以及如何将 C 语言程序(内核程序)编译链接拷贝到硬盘上并通过内核加载器 loader 程序加载执行。最后简单介绍了一下特权级

本篇难点:

  • ELF文件解析

第一个 C 程序

平时写程序,会用到各种库,其中的标准库一般是系统调用的封装,虽然用户程序也可以直接使用系统调用,但效果基本上还是不如直接用标准库来的好

接下来要用C语言来写内核了

C语言的设计目标是提供一种能以简易的方式编译、处理低级存储器、产生少量的机器码以及不需要任何运行环境支持便能运行的编程语言

C语言为什么能写操作系统呢?C语言是怎么控制CPU的呢?

C语言程序的生成过程是,先将源程序编译目标文件(C代码编译成汇编代码,再由汇编代码生成二进制目标文件),再将目标文件进行链接成二进制可执行文件

这个里有两个过程:编译、链接,下面以一个简单的C语言程序为例,介绍一下这个过程

main.c:

int main(){
        while(1);
        return 0;
}

使用 gcc 命令进行编译:

gcc -c -o ../main.o main.c 

-c:编译,汇编到目标代码,不进行链接,直接生成目标文件

-o:输出到文件

在这个阶段目标文件中的符号是未编址的(重定位),这个工作是在链接中进行

用 file 命令查看文件信息:

steven@steven ~/source/os/bochs/code/kernel$ file ../main.o 
../main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

当前文件是ralocatable,可重定位文件,也就是未进行链接(重定位)

用 nm 命令查看符号信息:

steven@steven ~/source/os/bochs/code/kernel$ nm ../main.o 
0000000000000000 T main

可以看到,程序中的符号(函数名、变量名)的地址尚未确定


使用 ld 命令进行链接,这么链接的好处是可以指定最终生成文件的起始虚拟地址:

ld ../main.o -Ttext 0xc0001500 -e main -o ../main.bin

-Ttext:指定程序的起始虚拟地址

-e:指定程序的起始地址,链接器默认把_start作为入口函数


在使用 gcc 编译的时候,如果没有使用 -c 参数,则会被编译器自动添加其他的运行库的代码,程序会大很多

在64位系统中编译32位程序:

gcc -m32 -c -o kernel/main.o kernel/main.c
ld -m elf_i386 kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin

dd if=/home/steven/source/os/bochs/code/kernel/kernel.bin of=/home/steven/source/os/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc

注意: gcc版本需要降级到 gcc4.x, 否则elf文件会与书上有差异, 会多出段且这些段的虚拟地址并未初始化,所以bochs运行会出错

ELF 文件格式

Windows 下是 PE 文件格式,Linux下则是 ELF文件(relocatable 文件、shared object 文件、executable 文件)

程序中最重要的部分是段(segment)和节(section),段是由节组成的,多个节经过链接就合成为段。

段和节的头信息通过一种结构来存储,就是程序头表(段头表)和节头表

ELF在链接时和运行时文件形态不一样:

在这里插入图片描述

链接后,程序运行的代码、数据等资源都在段中,本着够用就行的原则,有关节和其他内容的学习这里就不多关注了,参考资料列出了ELF解析的参考链接,那里有较为完整的内容

在这里插入图片描述

#define EI_NIDENT 16
typedef struct {
  unsigned char  e_ident[16];	//存储字符信息
  Elf32_Half e_type;			//目标文件类型
  Elf32_Half e_machine;			//elf目标体系结构
  Elf32_Word e_version;			//版本信息
  Elf32_Addr e_entry;			//操作系统运行程序时,将控制权转交到的虚拟地址
  Elf32_Off e_phoff;			//程序头表的文件偏移地址
  Elf32_Off e_shoff;			//节头表的文件偏移地址
  Elf32_Word e_flags;			//处理器相关标识
  Elf32_Half e_ehsize;			//elf header 字节大小
  Elf32_Half e_phentsize;		//程序头表每个条目的字节大小,条目的数据结构是Elf32_Phdr
  Elf32_Half e_phnum;			//程序头表中条目数量(段数量)
  Elf32_Half e_shentsize;		//节头表每个条目的字节大小
  Elf32_Half e_shnum;			//节头表中条目的数量(节数量)
  Elf32_Half e_shstrndx;		//指明string name table在节头表中的索引index
} Elf32_Ehdr;
e_ident

e_ident是一个16字节大小数组:

在这里插入图片描述

e_type

在这里插入图片描述

在这里插入图片描述

e_machine

在这里插入图片描述

Program Header

Elf32_Phdr 类似 GDT 中段描述符的作用,用来描述位于磁盘上的程序的一个段

在ELF_Header中,我们可以得到Program Header的索引地址(e_phoff)段数量(e_phnum)表项大小(e_phentsize)。然后我们来看一下Program Header中表项的结构定义:

typedef struct {
    Elf32_Word p_type;	//此段的作用类型
    Elf32_Off p_offset;	//本段在文件内的起始偏移字节
    Elf32_Addr p_vaddr;	//本段在内存中的起始虚拟地址
    Elf32_Addr p_paddr;	//仅用于与物理地址相关的系统中
    Elf32_Word p_filesz;//本段在文件中的大小
    Elf32_Word p_memsz;	//本段在内存中的大小
    Elf32_Word p_flage;	//本段相关的标识
    Elf32_Word p_align;	//本段在文件和内存中的对齐方式,如果是1或0,则不对齐,否则应该是2的整数次幂
} Elf32_phdr;
p_type
取值代表含义
00PT_NULL此数组元素未用。结构中其他成员都是未定义的。
01PT_LOAD此数组元素给出一个可加载的段,段的大小由 p_filesz 和 p_memsz 描述。文件中的字节被映射到内存段开始处。如果 p_memsz 大于 p_filesz,“剩余”的字节要清零。p_filesz 不能大于 p_memsz。可加载的段在程序头部表格中根据 p_vaddr 成员按升序排列。
02PT_DYNAMIC数组元素给出动态链接信息。
03PT_INTERP数组元素给出一个 NULL 结尾的字符串的位置和长度,该字符串将被当作解释器调用。这种段类型仅对与可执行文件有意义(尽管也可能在共享目标文件上发生)。在一个文件中不能出现一次以上。如果存在这种类型的段,它必须在所有可加载段项目的前面。
04PT_NOTE此数组元素给出附加信息的位置和大小。
05PT_SHLIB此段类型被保留,不过语义未指定。包含这种类型的段的程序与 ABI不符。
06PT_PHDR此类型的数组元素如果存在,则给出了程序头部表自身的大小和位置,既包括在文件中也包括在内存中的信息。此类型的段在文件中不能出现一次以上。并且只有程序头部表是程序的内存映像的一部分时才起作用。如果存在此类型段,则必须在所有可加载段项目的前面。
0x70000000PT_LOPROC此范围的类型保留给处理器专用语义。
0x7fffffffPT_HIPROC此范围的类型保留给处理器专用语义。
p_flages

在这里插入图片描述

将内核载入内存

这里回顾一下到目前为止的计算机开机运行的过程:

计算机启动后,运行的第一个程序是BIOS,BIOS检查、初始化硬件之后,将0盘0道1扇区(CHS方式)的主引导记录 MBR 程序加载到内存0x7C00的位置并跳转过去;

MBR(大小:512 字节) 里含有引导记录,可以选择加载指定的系统(多系统情况下),加载则是通过跳转到指定的操作系统的内核加载器程序 loader 进行,从硬盘中读取出 loader 程序到内存中并跳转过去;

内核加载器 loader 程序的工作是将内核 kernel 程序从硬盘中加载到内存中,然后将加载进来的内核程序安置到相应的虚拟内存地址,然后跳转过去执行。

就像接力棒一样,逐个交给下一棒。


到目前为止,我们的硬盘使用情况如下:

  • 第 0 扇区:MBR,大小:512字节,占用1个扇区
  • 第 2 扇区:loader,大小:1342字节,占用4个扇区

方便起见,kernel 程序就写入 第 9 扇区


到目前为止的使用的内存情况如下:

  • 0x000~0x3FF:1K,中断向量表
  • 0x400~0x4FF:256B,BIOS
  • 0x500~0x7BFF:30K,可用区域
  • 0x7C00~0x7DFF:512B,MBR被加载到此处
  • 0x7E00~9FBFF:608K,可用区域

程序执行到 loader 后,MBR的使命已经结束了,可以被覆盖抹杀掉了,仿佛 MBR 不曾存在一样

偷个懒,按书上作者提到的使用的地址走,内核文件 kernel.bin 加载到 0x70000 地址上

修改 include/boot.inc

添加如下行

KERNEL_START_SECTOR     equ 0x9
KERNEL_BIN_BASE_ADDR    equ 0x70000
KERNEL_ENTRY_POINT      equ 0xc0001500

PT_NULL equ     0

修改 loader.asm 加载内核 1

第一步,加载内核:将 kernel.bin 读取到内存中

在启用分页机制之前,将 kernel.bin 从硬盘中读取到内存中:

;------------加载 kernel------------
mov eax, KERNEL_START_SECTOR            ; kernel.bin所在的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR           ; 从硬盘读出后写入的地址
mov ecx, 200                            ; 读入的扇区数

call rd_disk_m_32                       ; 从硬盘读取文件到内存, 上面eax, ebx, ecx是参数


;------------启用 分页机制------------
; 创建页目录及页表并初始化页内存位图
call setup_page

rd_disk_m_32 函数是 MBR 程序中 rd_disk_m_16 函数的 32 位版本,实现原理差不多一样,只是用到的寄存器变成32位的了

rd_disk_m_32 程序写好加到 loader.asm 的结尾:

;----读取文件到内存----
;参数
;eax :扇区号
;ebx :待读入的地址
;ecx :读入的扇区数

rd_disk_m_32:
        mov esi ,eax    ;备份eax
        mov di ,cx      ;备份cx

		; 读写硬盘
		; 1---设置要读取的扇区数
        mov dx ,0x1f2   ;设置端口号,dx用来存储端口号的,要写入待读入的扇区数量
        mov al ,cl 
        out dx ,al      ;读取的扇区数

        mov eax ,esi    ;恢复eax


		; 2---将LBA地址存入0x1f3~0x1f6
        ; LBA 7~0位写入端口0x1f3
        mov dx ,0x1f3
        out dx ,al 

        ; LBA 15~8位写入端口0x1f4
        mov cl ,8
        shr eax ,cl     ; 逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来8位
        mov dx ,0x1f4
        out dx ,al 

        ; LBA 24~16位写入端口0x1f5
        shr eax ,cl 
        mov dx ,0x1f5
        out dx ,al 

        shr eax ,cl 
        and al ,0x0f    ; 设置lba 24~27位
        or al ,0xe0     ; 设置7~4位是1110表示LBA模式
        mov dx ,0x1f6
        out dx ,al 

		; 3---向0x1f7端口写入读命令0x20
        mov dx ,0x1f7
        mov al ,0x20
        out dx ,al

		; 4---检测硬盘状态
.not_ready:
        ; 同写入命令端口,读取时标示硬盘状态,写入时是命令
        nop
        in al ,dx
        and al ,0x88    ;第三位为1表示已经准备好了,第7位为1表示硬盘忙
        cmp al ,0x08
        jnz .not_ready

		; 5---0x1f0端口读取数据
        mov ax ,di      ; 要读取的扇区数
        mov dx ,256     ; 一个扇区512字节,一次读取2字节,需要读取256次
        mul dx          ; 结果放在ax里
        mov cx ,ax      ; 要读取的次数

        mov dx ,0x1f0
.go_on_read:
        in ax, dx
        mov [ebx], ax    ; bx是要读取到的内存地址
        add ebx, 0x02
        loop .go_on_read        ; 循环cx次
        ret

修改 loader.asm 加载内核 2

第二步,初始化内核,依据ELF规范,将内核文件中的段展开到内存中相应位置

也就是说要找个地方存放内核的映像

内存要尽量往低了选,考虑到 0x900 存放的是 loader,loader 开始部分是 GDT,且 loader 大小不超过 2000 字节,为了不覆盖到 GDT,所以内存选择是 0x900 + 2000 = 0x10d0,取个整数,就是0x1500作为映像的入口地址

初始化代码如下:在开启分页重新加载 gdt 之后,初始化 kernel 并跳转到 kernel 文件的入口

; 开启分页后, 用 gdt 新的地址重新加载
lgdt [gdt_ptr]


;;;;;;;;;;;;;;;;;;;;;;;;;;;;  此时不刷新流水线也没问题  ;;;;;;;;;;;;;;;;;;;;;;;;
; 由于一直处在32位下,原则上不需要强制刷新, 经过实际测试没有以下这两句也没问题.
; 但以防万一, 还是加上啦, 免得将来出来莫句奇妙的问题.
jmp SELECTOR_CODE:enter_kernel          ; 强制刷新流水线,更新 gdt 


enter_kernel:                           ; 进入内核
	call kernel_init
	mov esp, 0xc009f000                 ; 给栈选个高地址且不影响内存其他位置的地方
	jmp KERNEL_ENTRY_POINT

kernel_init 函数:

;-----------------   将kernel.bin中的segment拷贝到编译的地址   -----------
; 此时,kernel.bin 已经被读取到内存 KERNEL_BIN_BASE_ADDR 位置上了
kernel_init:
	xor eax, eax                                ; 清空通用寄存器
    xor ebx, ebx                                ; ebx 记录程序头表文件内偏移地址,即e_phoff
    xor ecx, ecx                                ; cx  记录程序头表中的 program header 数量
    xor edx, edx                                ; dx  记录 program header 尺寸, 即 e_phentsize
        
    mov dx, [KERNEL_BIN_BASE_ADDR + 42]         ; 偏移文件 42 字节处是 e_phentsize, 表示 program header 大小
    mov ebx, [KERNEL_BIN_BASE_ADDR + 28]        ; 偏移文件 28 字节处是 e_phoff, 表示第一个程序头在文件的偏移量

    add ebx, KERNEL_BIN_BASE_ADDR               ; 获取程序头表第一个程序头的地址(基地址 + 偏移量)
    mov cx,  [KERNEL_BIN_BASE_ADDR + 44]        ; 偏移文件 44 字节处是 e_phnum,表示程序头的数量

    .each_segment:
    	cmp byte [ebx + 0], PT_NULL             ; 若相等,则表示程序头没使用
        je  .PTNULL

        ; 为mem_cpy压入参数(从右往左)类似 memcpy(dst, src, size)
        ; 参数 size:待复制的大小
        push dword [ebx + 16]                   ; 偏移程序头 16 字节处是 p_filesz, 即本段在文件内的大小

        ; 参数 src: 源地址
        mov eax, [ebx + 4]                      ; 偏移程序头 4  字节处是 p_offset, 即本段在文件内的偏移大小
        add eax, KERNEL_BIN_BASE_ADDR           ; 加上基地址 = 物理地址
        push eax

        ; 参数 dst: 目的地址
        push dword [ebx + 8]                    ; 偏移程序头 8 字节处是 p_vaddr, 即本段在内存中的虚拟地址

        call .mem_cpy
        add esp, 12                             ; 清理栈中压入的三个参数


    .PTNULL:
    	add ebx, edx                            ; 程序头的地址 + 程序头的大小 = 下一个程序头的地址
        loop .each_segment                      ; 复制下一个程序头
        
    ret

mem_cpy 函数:

;----------  逐字节拷贝 mem_cpy(dst,src,size) ------------
; 输入:栈中三个参数(dst,src,size)
; 输出:无
;---------------------------------------------------------
.mem_cpy:
	cld                                         ; 控制进行字符串操作时esi和edi的递增方式, cld增大, sld减小
	push ebp    
	mov ebp, esp
	push ecx                                    ; rep指令用到了ecx,外层指令也用到了ecx,所以备份

	; 分析一下为什么是 8, 因为push了ebp, 所以相对应的都需要+4
	; 并且进入函数时还 push了函数返回地址, 所以再+4, 所以一共+8
	mov edi, [ebp + 8]                          ; dst
	mov esi, [ebp + 12]                         ; src
	mov ecx, [ebp + 16]                         ; size
	rep movsb                                   ; 逐字节拷贝

	; 恢复环境
	pop ecx
	pop ebp

	ret

完整代码:

%include "boot.inc"

section loader vstart=LOADER_BASE_ADDR

    LOADER_STACK_TOP equ LOADER_BASE_ADDR           ; loader在保护模式下的栈指针地址,esp

    ; 构建GDT及其内部描述符, 每个描述符8个字节, 拆分为高低各4字节(32位)
    GDT_BASE:   dd 0x00000000                       ; 第0个描述符,不可用
                dd 0x00000000

    CODE_DESC:  dd 0x0000ffff                       ; 低32位31~16位为段基址15~0位, 15~0位为段界限15~0位
                dd DESC_CODE_HIGH4

    DATA_STACK_DESC:    dd 0x0000ffff               ; 数据段(栈段)描述符
                        dd DESC_DATA_HIGH4

    VIDEO_DESC: dd 0x80000007                       ; 0xB8000 到0xBFFFF为文字模式显示内存 段界限:limit=(0xbffff-0xb8000) / 4k=0x7
                dd DESC_VIDEO_HIGH4                 ; 0xB

    GDT_SIZE:   equ $ - GDT_BASE                    ; 当前位置减去GDT_BASE的地址 等于GDT的大小
    GDT_LIMIT:  equ GDT_SIZE - 1                    ; SIZE - 1即为最大偏移量

    times 60 dq 0                                   ; 预留60个 四字型 描述符空位, 用于后续扩展
                                                    
    SELECTOR_CODE  equ (0x0001<<3) + TI_GDT + RPL0  ; 段选择子: 低3位为TI RPL状态, 其余为描述符索引值
    SELECTOR_DATA  equ (0x0002<<3) + TI_GDT + RPL0  
    SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0

    ; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
    ; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
    ; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
    total_mem_bytes dd 0

    ; gdt指针, 前2字节为gdt界限, 后4字节为gdt起始地址(共48位)
    gdt_ptr dw GDT_LIMIT
            dd GDT_BASE

    ; 人工对齐:total_mem_bytes 4字节 + gdt_ptr 6字节 + ards_buf 244字节 + ards_nr 2字节 , 共256字节
    ards_buf times 244 db 0                         ; 记录内存大小的缓冲区
    ards_nr dw 0                                    ; 记录 ARDS 结构体数量


    loader_start:

        ; -------  int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局  -------
        xor ebx, ebx                                ; 第一次调用将eb置0
        mov edx, 0x534d4150                         ; edx只赋值一次,循环体中不会改变
        mov di,  ards_buf                           ; di指向ARDS缓冲区

        .e820_mem_get_loop:                         ; 循环获取每个ARDS内存范围描述结构
            mov eax, 0x0000e820                     ; 执行int 0x15后,eax值变为0x534d4150, 所以每次执行int前都要更新为子功能号
            mov ecx, 20                             ; ARDS地址范围描述符结构大小是20字节
            int 0x15
            jc  .e820_failed_so_try_e801            ; 若cf位为1则有错误发生,尝试0xe801子功能
            add di, cx                              ; 使di增加20字节指向缓冲区中新的ARDS结构位置
            inc word [ards_nr]                      ; ARDS数量加1
            cmp ebx, 0                              ; 若 ebx 为 0 且 cf 不为 1, 这说明 ards 全部返回,当前已是最后一个
            jnz .e820_mem_get_loop                  ; 不为0则循环获取

            ; 在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量
            mov cx, [ards_nr]                       ; 遍历每一个ARDS结构体,循环次数是ARDS的数量
            mov ebx, ards_buf
            xor edx, edx                            ; 用edx记录最大值, 在这里先清零

        .find_max_mem_area:                         ; 无须判断type是否为1,最大的内存块一定是可被使用
            mov eax, [ebx]                          ; base_add_low
            add eax, [ebx+8]                        ; base_add_low + length_low = 这块ADRS容量
            add ebx, 20                             ; 指向下一块ARDS
            cmp edx, eax                            ; 找出最大,edx寄存器始终是最大的内存容量
            jge .next_ards                          ; 如果edx>=eax, 继续遍历下一块
            mov edx, eax                            ; 如果edx<=eax, 更新edx
        .next_ards:
            loop .find_max_mem_area
            jmp .mem_get_ok                         ; 获取内存容量结束


        ; ------  int 15h ax = E801h 获取内存大小,最大支持4G  ------
        ; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
        ; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G
        .e820_failed_so_try_e801:
            mov ax, 0xe801
            int 15
            jc .e801_failed_so_try88                ; 若当前e801方法失败,就尝试0x88方法

            ; 1 先算出低15M的内存, ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
            ; 乘数在eax, 即内存数量, 积高16位在edx, 低16位在eax
            mov cx, 0x400                           ; 0x400 = 1024
            mul cx
            shl edx, 16                             ; 左移16位, 将低16位放到edx高16位
            and eax, 0x0000FFFF                     ; 0x0000FFFF = 1111 1111 1111 1111, 高16位置0
            or  edx, eax                            ; 获得完整的32位积
            add edx, 0x100000                       ; edx比实际少1M, 故要加1MB
            mov esi, edx                            ; 先把低15MB的内存容量存入esi寄存器备份

            ; 2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
            xor eax, eax
            mov ax, bx
            mov ecx, 0x10000                        ; 0x10000十进制为64KB
            mul ecx                                 ; 32位乘法,默认的被乘数是eax, 积为64位, 高32位存入edx, 低32位存入eax
            add esi, eax                            ; 由于此方法只能测出4G以内的内存, 故32位eax足够了, edx肯定为0, 只加eax便可
            mov edx, esi                            ; edx为总内存大小
            jmp .mem_get_ok


        ; -----------------  int 15h ah = 0x88 获取内存大小,只能获取64M之内  ----------
        .e801_failed_so_try88:
            ; int 15后,ax存入的是以kb为单位的内存容量
            mov  ah, 0x88
            int  0x15
            jc   .error_hlt
            and  eax, 0x0000FFFF

            ; 16位乘法,被乘数是ax,积为32位, 积的高16位在dx中,积的低16位在ax中
            mov cx, 0x400                           ; 0x400等于1024, 将ax中的内存容量换为以byte为单位
            mul cx
            shl edx, 16
            or  edx, eax                            ; 把积的低16位组合到edx,为32位的积
            add edx, 0x100000                       ; 0x88子功能只会返回1MB以上的内存, 故实际内存大小要加上1MB


        .mem_get_ok:
            mov [total_mem_bytes], edx              ; 将内存换为byte单位后存入total_mem_bytes处


        ; --------------------------------- 设置进入保护模式 -----------------------------
        ; 1 打开A20 gate
        ; 2 加载gdt
        ; 3 将cr0 的 pe位(第0位)置1

        ; -----------------  打开A20  ----------------
        in al, 0x92                                 ; 端口号0x92 中的第1位变成 1 即可
        or al, 0000_0010B
        out 0x92, al

        ; -----------------  加载GDT  ----------------
        lgdt [gdt_ptr]

        ; -----------------  cr0第0位置1  ----------------
        mov eax, cr0
        or eax, 0x00000001
        mov cr0, eax


        ; -------------------------------- 已经打开保护模式 ---------------------------------------
        jmp dword SELECTOR_CODE:p_mode_start        ; 刷新流水线

        .error_hlt:                                 ; 出错则挂起
            hlt                                     ; 处理器暂停, 直到出现中断或复位信号才继续

        [bits 32]
        p_mode_start: 

            ; 初始化32位的段寄存器
            mov ax,  SELECTOR_DATA
            mov ds,  ax
            mov es,  ax
            mov ss,  ax
            mov esp, LOADER_STACK_TOP
            mov ax,  SELECTOR_VIDEO
            mov gs,  ax

            ;------------加载 kernel------------
            mov eax, KERNEL_START_SECTOR            ; kernel.bin所在的扇区号
            mov ebx, KERNEL_BIN_BASE_ADDR           ; 从硬盘读出后写入的地址
            mov ecx, 200                            ; 读入的扇区数

            call rd_disk_m_32                       ; 从硬盘读取文件到内存, 上面eax, ebx, ecx是参数


            ;------------启用 分页机制------------
            ; 创建页目录及页表并初始化页内存位图
            call setup_page

            ; gdt需要放在内核里
            ; 将描述符表地址&偏移量写入内存gdt_ptr,一会用新的地址加载
            sgdt [gdt_ptr]                          ; 取出GDT地址和偏移信息,存放在gdt_ptr这个内存位置上

            ; 视频段需要放在内核里与用户进程进行共享
            ; 将gdt描述符中视频段的段基址 + 0xc0000000
            mov ebx, [gdt_ptr + 2]                  ; 这里gdt_ptr前2字节是偏移量,后4字节是GDT基址,先选中GDT
            or dword [ebx + 0x18 + 4], 0xc0000000   ; 一个描述符8字节,0x18处是第3个段描述符也就是视频段, 修改段基址最高位为C, +4进入高4字节, 用or修改即可

            ; 将gdt的基址加上 0xc0000000 成为内核所在的地址
            add dword [gdt_ptr + 2], 0xc0000000
            add esp, 0xc0000000                     ; 将栈指针同样map到内核地址

            ; 页目录地址赋值给cr3
            mov eax, PAGE_DIR_TABLE_POS
            mov cr3, eax

            ; 打开 cr0 的 pg位 (第31位)
            mov eax, cr0
            or  eax, 0x80000000
            mov cr0, eax

            ; 开启分页后, 用 gdt 新的地址重新加载
            lgdt [gdt_ptr]


            ;;;;;;;;;;;;;;;;;;;;;;;;;;;;  此时不刷新流水线也没问题  ;;;;;;;;;;;;;;;;;;;;;;;;
            ; 由于一直处在32位下,原则上不需要强制刷新, 经过实际测试没有以下这两句也没问题.
            ; 但以防万一, 还是加上啦, 免得将来出来莫句奇妙的问题.
            jmp SELECTOR_CODE:enter_kernel          ; 强制刷新流水线,更新 gdt 


            enter_kernel:                           ; 进入内核
                call kernel_init
                mov esp, 0xc009f000                 ; 给栈选个高地址且不影响内存其他位置的地方
                jmp KERNEL_ENTRY_POINT


    ;-----------------   将kernel.bin中的segment拷贝到编译的地址   -----------
    ; 此时,kernel.bin 已经被读取到内存 KERNEL_BIN_BASE_ADDR 位置上了
    kernel_init:
        xor eax, eax                                ; 清空通用寄存器
        xor ebx, ebx                                ; ebx 记录程序头表文件内偏移地址,即e_phoff
        xor ecx, ecx                                ; cx  记录程序头表中的 program header 数量
        xor edx, edx                                ; dx  记录 program header 尺寸, 即 e_phentsize
        
        mov dx, [KERNEL_BIN_BASE_ADDR + 42]         ; 偏移文件 42 字节处是 e_phentsize, 表示 program header 大小
        mov ebx, [KERNEL_BIN_BASE_ADDR + 28]        ; 偏移文件 28 字节处是 e_phoff, 表示第一个程序头在文件的偏移量

        add ebx, KERNEL_BIN_BASE_ADDR               ; 获取程序头表第一个程序头的地址(基地址 + 偏移量)
        mov cx,  [KERNEL_BIN_BASE_ADDR + 44]        ; 偏移文件 44 字节处是 e_phnum,表示程序头的数量

        .each_segment:
            cmp byte [ebx + 0], PT_NULL             ; 若相等,则表示程序头没使用
            je  .PTNULL

            ; 为mem_cpy压入参数(从右往左)类似 memcpy(dst, src, size)
            ; 参数 size:待复制的大小
            push dword [ebx + 16]                   ; 偏移程序头 16 字节处是 p_filesz, 即本段在文件内的大小

            ; 参数 src: 源地址
            mov eax, [ebx + 4]                      ; 偏移程序头 4  字节处是 p_offset, 即本段在文件内的偏移大小
            add eax, KERNEL_BIN_BASE_ADDR           ; 加上基地址 = 物理地址
            push eax

            ; 参数 dst: 目的地址
            push dword [ebx + 8]                    ; 偏移程序头 8 字节处是 p_vaddr, 即本段在内存中的虚拟地址

            call .mem_cpy
            add esp, 12                             ; 清理栈中压入的三个参数


        .PTNULL:
            add ebx, edx                            ; 程序头的地址 + 程序头的大小 = 下一个程序头的地址
            loop .each_segment                      ; 复制下一个程序头
        
        ret


    ;----------  逐字节拷贝 mem_cpy(dst,src,size) ------------
    ; 输入:栈中三个参数(dst,src,size)
    ; 输出:无
    ;---------------------------------------------------------
    .mem_cpy:
        cld                                         ; 控制进行字符串操作时esi和edi的递增方式, cld增大, sld减小
        push ebp    
        mov ebp, esp
        push ecx                                    ; rep指令用到了ecx,外层指令也用到了ecx,所以备份

        ; 分析一下为什么是 8, 因为push了ebp, 所以相对应的都需要+4
        ; 并且进入函数时还 push了函数返回地址, 所以再+4, 所以一共+8
        mov edi, [ebp + 8]                          ; dst
        mov esi, [ebp + 12]                         ; src
        mov ecx, [ebp + 16]                         ; size
        rep movsb                                   ; 逐字节拷贝

        ; 恢复环境
        pop ecx
        pop ebp

        ret


    ; -------------   创建页目录及页表   ---------------
    setup_page:

        ; 先把页目录所占空间清 0
        mov ecx, 4096                               ; 1024 * 4 = 4096  
        mov esi, 0

        .clear_page_dir:
            mov byte [PAGE_DIR_TABLE_POS + esi], 0
            inc esi
            loop .clear_page_dir

        ; 开始创建页目录项(Page Directory Entry)
        .create_pde:
            mov eax, PAGE_DIR_TABLE_POS
            add eax, 0x1000                         ; 第一个页表的位置(仅次于页目录表,页目录表大小4KB)
            mov ebx, eax                            ; 0x00101 000, 储存到ebx为创建PTE时使用

            ; 下面将页目录项0和OxcOO都存为第一个页表的地址 ,每个页表表示4MB内存
            ; 这样Oxc03fffff(3G-3G04M)以下的地址和Ox003fffff(0-4M)以下的地址都 指向相同的页表
            ; 这是为将地址映射为内核地址做准备
            or eax,  PG_US_U | PG_RW_W | PG_P       ; 用户特权级,可读可写,存在内存
            mov [PAGE_DIR_TABLE_POS + 0x0], eax     ; 第一个目录项,0x00101 007
            mov [PAGE_DIR_TABLE_POS + 0xc00], eax   ; 第0xc00高10位0x300=768个页表占用的目录项,0xc00以上属于kernel空间
            ; 这里是把第768个目录页和第1个目录页指向同一个页表的物理地址:0x101000   
            ; 系统实际位于0~0x100000内存地址中,将系统虚拟地址0xc00000000映射到这低1M的空间内,只需要让0xc0000000的地址指向和低1M相同的页表即可

            sub eax, 0x1000
            mov [PAGE_DIR_TABLE_POS + 4092], eax    ; 使最后一个目录项指向页目录表自己的位置, 4092 = 1023 * 4

        
        ; 创建页表项(Page Table Entry)
        mov ecx, 256                                ; 1M低端内存/每页大小4K = 256
        mov esi, 0
        mov edx, PG_US_U | PG_RW_W | PG_P           ; edx中地址为0x0,属性为7,即111b

        .create_pte:
            mov  [ebx + esi * 4], edx               ; ebx = 0x00101 000, 即第一个PTE起始地址, 每个PTE = 4 byte
            add  edx, 4096                          ; edx + 4KB
            inc  esi
            loop .create_pte                        ; 低端1M内存中,物理地址=虚拟地址,这里创建了1M空间的页表项


        ; 创建内核其他页表的PDE
        mov eax, PAGE_DIR_TABLE_POS
        add eax, 0x2000                             ; eax指向第二个页表(每个页表对应一个PDE, 含有1024个页表项)
        or  eax, PG_US_U | PG_RW_W | PG_P
        mov ebx, PAGE_DIR_TABLE_POS                 
        mov ecx, 254                                ; 769~1022的所有目录项数量, 1022 - 769 + 1 = 254
        mov esi, 769                                

        .create_kernel_pde:
            mov  [ebx + esi * 4], eax
            inc  esi
            add  eax, 0x1000                        ; eax指向下一个页表
            loop .create_kernel_pde

        ret


    ;-------------------------------------------------------------------------------
	; 功能:读取硬盘n个扇区
    ; eax=LBA扇区号
	; ebx=将数据写入的内存地址
	; ecx=读入的扇区数   
    ;-------------------------------------------------------------------------------
	rd_disk_m_32:
        mov esi ,eax    ; 备份eax
        mov di ,cx      ; 备份cx

        ; 读写硬盘
        ; 1---设置要读取的扇区数
        mov dx ,0x1f2   ; 设置端口号,dx用来存储端口号的,要写入待读入的扇区数量
        mov al ,cl 
        out dx ,al      ; 读取的扇区数

        mov eax ,esi    ; 恢复eax


        ; 2---将LBA地址存入0x1f3~0x1f6
        ; LBA 7~0位写入端口0x1f3
        mov dx ,0x1f3
        out dx ,al 

        ; LBA 15~8位写入端口0x1f4
        mov cl ,8
        shr eax ,cl     ; 逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来8位
        mov dx ,0x1f4
        out dx ,al 

        ; LBA 24~16位写入端口0x1f5
        shr eax ,cl 
        mov dx ,0x1f5
        out dx ,al 

        shr eax ,cl 
        and al ,0x0f    ; 设置lba 24~27位
        or al ,0xe0     ; 设置7~4位是1110表示LBA模式
        mov dx ,0x1f6
        out dx ,al 

        ; 3---向0x1f7端口写入读命令0x20
        mov dx ,0x1f7
        mov al ,0x20
        out dx ,al

        ; 4---检测硬盘状态
        .not_ready:
            ; 同写入命令端口,读取时标示硬盘状态,写入时是命令
            nop
            in al ,dx
            and al ,0x88    ; 第三位为1表示已经准备好了,第7位为1表示硬盘忙
            cmp al ,0x08
            jnz .not_ready

        ; 5---0x1f0端口读取数据
        mov ax ,di      ; 要读取的扇区数
        mov dx ,256     ; 一个扇区512字节,一次读取2字节,需要读取256次
        mul dx          ; 结果放在ax里
        mov cx ,ax      ; 要读取的次数

        mov dx ,0x1f0

        .go_on_read:
            in ax, dx
            mov [ebx], ax    ; bx是要读取到的内存地址
            add ebx, 0x02
            loop .go_on_read        ; 循环cx次

        ret
							 

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

运行 Bochs

运行结果如下:

在这里插入图片描述
在这里插入图片描述

下一条指令长度为 2 字节,指令内容为跳转到 2 字节之前,也就是死循环,正好与代码中的while(0)一致,说明程序运行起来了!

特权级深入浅出

特权级分4个等级,0,1,2,3,数字越小特权越高,计算机在启动的时候,是在 0 特权级启动的,操作系统内核也处于 0 特权级,它要直接控制硬件,系统程序分别位于 1 ,2 两个特权级,一般是虚拟机、驱动程序等系统服务,用户程序一般在 3 特权级

代码特权转移的信息存在 TSS 结构中,通过 TSS 获取转移后的栈

代码特权转移的权限存在 CS.RPL(CPL)中,会根据目标段 DPL 权限来判断是否能转移

TSS 简介

TSS,Task State Segment,任务状态段,是一种数据结构,用于存储任务的环境

在这里插入图片描述

首先,任务是什么?任务是计算机执行的一个程序,这个程序按特权级划分可以分为用户部分和内核部分两部分,这两部分加起来才是完整的一个程序,完整的任务会经历特权级从用户级到内核级的变化。

任务是由处理器在执行,任务在特权级变换的本质是处理器当前特权级在变化,处理器处于不同特权级下,使用的是不同特权级的栈,每个特权级下都有且仅有一个栈

特权级转移分两种:

  1. 从低到高转移:可通过中断门、调用门等手段实现
  2. 从高到低转移:仅能通过调用返回实现

处理器从低特权级向高特权级转移的时候,会去查询当前任务的 TSS 数据结构,获取当前任务目标特权级的栈信息,并跳转过去,在这个过程中,跳转的时候会将当前地址记录下来存到跳转过去的栈中,从高向低转移的时候,也就直接知道如何跳转了,所以 TSS 结构只需要存储3个特权级的信息即可

TSS 是硬件支持的系统数据结构,和 GDT 一样,会被存到一个寄存器里,TSS 由 Task Register 加载

CPL、DPL简介

计算机特权级的标签体现在DPL、CPL、RPL下

CS 段选择子的 0-1 位是 RPL ,称为请求特权级,也叫当前特权级

代码段描述符中的 DPL 是 CPU 当前的特权级 CPL ,表示正在执行的代码段的特权级

当前特权级 CPL 保存在 CS 段寄存器 0-1 位的 RPL 部分,代码请求其他段的内容的时候,会检查其他段描述符中的 DPL,如果目标段的 DPL 等级比当前 CPL 高,则不能访问,如果目标段是代码段的话,则只能同级访问,

如果允许访问了,则会将目标段的 DPL 保存在当前 CS 寄存器的 RPL 里,成为当前的 CPL


之前提到的段描述符的 type 位的 C位,一致性代码段,指的是如果当前段是转移后的目标段,当前的特权级一定大于等于转移的特权级,转移后特权级不进行变化,还是转移前的特权级

代码段才有一致性非一致性区分,数据段可没有

门、调用门

门就像段描述符一样,也是一种描述符结构

四种门的结构:

在这里插入图片描述
在这里插入图片描述

门的作用:

  1. 调用门
    call和jmp指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用。call指令使用调用门可以实现向高特权代码转移,jmp指令使用调用门只能实现向平级代码转移。

  2. 中断门

    以int指令主动发中断的形式实现从低特权向高特权转移,Linux系统调用便用此中断门实现。

  3. 陷阱门

    以int3指令主动发中断的形式实现从低特权向高特权转移,一般是编译器在调试的时候用

  4. 任务门

    任务以任务状态段 TSS 为单位,用来实现任务切换,它可以借助中断或指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用 call 或 jmp 指令后接任务 门的选择子或任务 TSS 的选择子。

IO 特权级

IO 读写特权是由标志寄存器 eflags 中的 IOPL 位和 TSS 中的 IO 位图决定的,它们用来指定执行 IO 操作的最小特权级。

参考资料

ELF文件格式解析(完):https://www.52pojie.cn/thread-591986-1-1.html

(85条消息) ubantu20-安装gcc-4.4-g+±4.4_gcc4安装_IC 还在路上的博客-CSDN博客

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值