0x00
我已启动
0x01 加载 kernel
这里的加载有两重含义:
一是在 loader 开启分页进入虚拟地址空间后,就应该跳转到内核,由内核来接管了。但在这之前当然要先从硬盘把 kernel 读进来。
二是 kernel 是一个 ELF 文件,内核被读入到内存后,loader 还要通过分析其 elf 结构将其再加载到新的位置,所以说,内核在内存中有 两份拷贝,一份是 elf 格式的原文件 kernel.bin,另一份是 loader 解析 elf 格式的 kernel.bin 后在内存中生成的 内核映像
先说第一点,和 mbr 读入 loader 很类似,只不过是在保护模式下执行,需要注意一些细节。比如本人写出的一个bug,仍然使用了 bx 存储加载到的基地址,但 kernel 的读入地址当然大于 16 位…所以要使用 ebx。
另外为了 kernel 未来的扩展有足够的空间,将读入的原始 elf 文件放入比较高的地址处。并且原始 elf 文件在 kernel 完全加载完后被覆盖了也没关系。
再说第二点,再加载是什么意思? ELF 文件也是一种可执行文件,只不过加了一个 header 来标注一些文件信息和规范。

在 header 中记录了各个段加载的虚拟地址、大小等等,通过识别这些信息来将各个段加载到对应的虚拟地址以便于后续来执行其代码。
可能有人会问:直接跳转到这个 ELF 的代码入口点进行执行不就完了?为什么要将各个段再加载一次到对应的虚拟地址呢?ELF 文件在磁盘上的偏移地址 ≠ 程序运行的虚拟地址。其实也就是进入了虚拟地址空间后,我们的程序中使用的地址都被 cpu 认为是虚拟地址,如果不加载到指定虚拟地址,cpu 执行指令时候访问必然会出错。
那么我们编写内核时也要注意不能任凭编译器和链接器帮我们做地址规划,我们要自行规划(毕竟整个虚拟地址空间都是我们自己规划的)。不然谁知道内核代码会被指定到哪个虚拟地址?所以我们要手动编译手动链接。例如不依赖任何 glibc 库,例如指定好 kernel 程序 text 段的起始地址为 0xc0001500 。为何选择这个地址?根据此前的虚拟地址映射,这个地址对应的是物理地址 0x1500。

另外我将 loader 放在了 0x900 处。给loader也留点空间 (loader毕竟还要加载 kernel),所以 kernel 就选在 0x1500了。
对了现在加载的所谓 kernel 还只有一个 while(1); 循环
0x02 DPL | CPL | RPL

如上图所示。
还记得段描述符(GDT) 中有几位就是要用来表明某个段的特权级,也就是 DPL (Descriptor Privilege Level)。关于处理器所处于的特权级,有 cs 寄存器的最低两位也就是 CPL 位(Current Privilege Level)。段选择子中低两位则是 RPL (Request Privilege Level)。看字面意思就知道,CPL 表明当前 cpu 的特权级,RPL 表明 cpu 访问某个段时所用的请求特权级。
跳转到(比如 call / jmp)到 非一致性代码段 时, CPU 会检查目标段 DPL 是否允许你访问(要求 CPL== RPL == 目标代码段 DPL),如果允许,CPL 会被更新为目标段的 DPL也就是说, CPU 特权级限随目标段的 DPL 改变。当目标段是 一致性代码段 时(要求 CPL ≥ 目标代码段 DPL && RPL ≥ 目标代码段 DPL),CPU 并不会把 CPL 改成目标段的 DPL,而是 依从调用者的 CPL。访问数据段时,要求 CPL ≤目标数据段 DPL && RPL ≤ 目标数据段 DPL。
可能会有疑惑,为什么代码不能更高特权级执行更低特权级?因为更高特权级代码可以类比成特级厨师,更低特权级代码类比为普通大厨。特级厨师应该有能力自己做好一道菜,而非去寻求普通厨师的帮助。(毕竟高特权级代码权限更高)
注意访问数据段或者一致性代码段时 有效权限 = max(CPL, RPL) 。也就是 cpu 只接受用这两者中更低的那个特权级去访问。也就是说可以”微服私访”,但不能真的随便退位(因为访问数据段或者一致性代码段时 CPL 不会改变)。
不过,唯一一种处理器会从高特权降到低特权运行的情况:处理器从中断处理程序中返回到用户态的时候。因为此时只是从用户态碰到中断进入内核态然后恢复到用户态。
那么低特权级到高特权级呢?处理器只有通过“门结构”才能由低特权级转移到高特权级(这是 cpu 的设计)
1 2 3 4 5
| | 门类型 | 用途 | CPL/DPL 变化 | 栈切换 | 是否屏蔽中断 | 调用门 Call Gate | 跨特权调用函数 | CPL 会变为目标段 CPL | 会切换栈 | 不改变 IF | 中断门 Interrupt Gate | 外部/内部中断 | CPL 变为目标段 CPL | 会切换栈 | IF 清零(禁止中断) | 陷阱门 Trap Gate | 异常/陷阱 | CPL 变为目标段 CPL | 会切换栈 | IF 保持(允许中断) | 任务门 Task Gate | 切换任务(TSS) | CPL 随 TSS | 切换到新任务栈 | 根据 TSS 控制
|
门结构就是记录一段程序起始地址的描述符。此前说过段描述符(GDT)来描述内存段。而门描述符是用来描述一段程序。

调用门是用来实现系统调用的,但为了兼容等原因,我们平时接触的操作系统很少使用调用门实现系统调用,如 Linux 就是用中断门代替。陷阱门是供调试器用的。
现在就可以说明 RPL 存在的必要性了: 毕竟我们可能想:明明只需要 CPL 和 DPL 就可以做好特权级的比较啊。
设想如果用户程序通过某个硬盘读取的调用门,CPL 提升为 0,假设调用门调用的函数需要磁盘逻辑扇区号 LBA、内 存缓冲区所在数据段的选择子、内存缓冲区的偏移地址。然后用户提供选择子参数时候提供一个内核数据段的选择子,而此时 cpu 的 CPL 为 0,可以访问!也就是说,此时用户可以更改内核数据。
而如果加上 RPL 机制,要么选择子由操作系统提供,要么每次用户提交选择子参数,将选择子中的 RPL 设置为用户程序的 CPL,而用户程序的 CPL 在cs寄存器中,用户程序是无法修改的。这样一来自然就无法访问内核数据了。这是怎么做到的?
这个arpl 指令就可以将选择子中的 RPL 设置为用户程序的 CPL。CS_val 怎么获取的呢?则用户进入调用门时,处理器会进入 0 特权级,此时,由于是远转移,处理器会自动将段寄存器 CS 和 EIP 的值压栈,而且特权级也发生了变化,所以寄存器 SS、ESP也会压栈(要使用不同特权级的栈)。这时候自然可以从栈中获取 CS_val。
0x03 IOPL
I/O 读写操作也有特权级的限制,标志寄存器 eflags 中的 IOPL 位和 TSS 中的 IO 位 图决定。
IO 相关的指令只有在当前特权级大于等于 IOPL 时才 能执行,比如以下指令
只有 CPL ≤ IOPL 才能执行。所以其实不只是操作系统可以进行 IO 端口访问,用户进程理论上也是可以的,只是特权级不够所以做不到
IO 位图又是干什么的?任意特权级下,cpu 都可以通过 I/O 位图为相应特权级的程序开启特定的端口。位图中对应端口的值为 0 则允许访问。注意 CPL 特权级高于 IOPL 时所有端口都可访问,CPL 特权级低于 IOPL 时只有 IO 位图指定的端口可访问。
为什么要用 IO 位图呢?毕竟当要频繁使用 IO 端口时,如果每次都专门切换 cpu 特权级,开销太大了,所以设置 IO 位图来开放某些端口的访问,比如方便给驱动程序使用。
哦对了什么是 TSS 呢?TSS 是一种数据结构,每个任务都有。

TSS 是栈地址和任务状态的存储区。相关内容以后再说。
0x04 特权指令
有一些对系统影响很大的指令只允许最高特权级 ring0 使用
1 2 3
| hlt ; 直接停机 lgdt ; 加载全局 GDT popf ; pop 到 eflags 寄存器
|
等等
0x05 实现
主要就是从硬盘读取,开启分页,然后加载内核映像。也是 loader 的最终实现了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
| [bits 32] p_mode_start: 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
mov byte [gs:0x10], 'P' ; 打印提示表明顺利进入保护模式 mov byte [gs:0x11], 0x0F
; ================= 从硬盘读入 kernel mov eax, KERNEL_START_SECTOR ;kernel.bin所在的扇区号 mov ebx, KERNEL_BIN_BASE_ADDR ;从磁盘读出后,写入到ebx指定的地址 mov ecx, 200 ;读入的扇区数 call rd_disk_m_32
; ================== 准备进入虚拟地址空间 ; ==== 创建页目录及页表 call setup_page
; ==== 将描述符表地址及偏移量写入内存 gdt_ptr 后续用虚拟地址重新加载 sgdt [gdt_ptr] ; 使用sgdt更新gdt_ptr是为了确保不出错(虽然原来已经自己定义好了)
mov ebx, [gdt_ptr + 2] ;加上2是因为gdt_ptr的低2字节是偏移量,高四字节才是GDT地址 or dword [ebx + 0x18 + 4], 0xc0000000 ;段描述符高四字节的最高位是段基址的第31~24位
;将gdt的基址加上0xc0000000使其成为内核所在的高地址 add dword [gdt_ptr + 2], 0xc0000000
add esp, 0xc0000000 ;将栈指针同样映射到内核地址
;把页目录地址附给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] ;重新加载GDT
mov byte [gs:0x20], 'V' ; 打印提示表明顺利开启分页进入虚拟地址空间 mov byte [gs:0x21], 0x0F
; =============== 进入kernel运行, loader任务结束 ;这里是因为一直处于32位之下,但是为了以防万一所以还是加上一个流水线刷新 jmp SELECTOR_CODE:enter_kernel ;强制刷新流水线,更新gdt enter_kernel: call kernel_init mov esp, 0xc009f000 ; 设置内核栈 mov eax, [KERNEL_BIN_BASE_ADDR + 24] ; 取 kernel ELF头的 e_entry 即内核入口点 jmp eax ;进入内核
jmp $ ; ========= ; ========= ; ========= ; =========
; ================= 将kernel.bin中的segment拷贝到编译的地址 kernel_init: xor eax, eax xor ebx, ebx ;ebx用来记录程序头表地址 xor ecx, ecx ;cx记录程序头表中的program header 数量 xor edx, edx ;dx记录program header的尺寸,即e_phentsize
; kernel.bin与此前的 mbr 和 loader 不同,它是一个 ELF 文件而非纯二进制文件 ; typedef struct { ; unsigned char e_ident[16]; /* ELF标识 */ ; uint16_t e_type; /* 文件类型 */ ; uint16_t e_machine; /* 目标架构 */ ; uint32_t e_version; /* ELF版本 */ ; uint32_t e_entry; /* 程序入口虚拟地址 */ ; uint32_t e_phoff; /* 程序头表文件偏移 */ ; uint32_t e_shoff; /* 节头表文件偏移 */ ; uint32_t e_flags; /* 处理器相关标志 */ ; uint16_t e_ehsize; /* ELF头大小 */ ; uint16_t e_phentsize; /* 程序头表项大小 */ ; uint16_t e_phnum; /* 程序头表项数量 */ ; uint16_t e_shentsize; /* 节头表项大小 */ ; uint16_t e_shnum; /* 节头表项数量 */ ; uint16_t e_shstrndx; /* 节名字符串表索引 */ ; } Elf32_Ehdr;
; typedef struct { ; uint32_t p_type; /* 段类型 */ ; uint32_t p_offset; /* 段在文件中的偏移 */ ; uint32_t p_vaddr; /* 段的虚拟地址 */ ; uint32_t p_paddr; /* 段的物理地址(通常忽略) */ ; uint32_t p_filesz; /* 段在文件中的大小 */ ; uint32_t p_memsz; /* 段在内存中的大小 */ ; uint32_t p_flags; /* 段标志(读/写/执行) */ ; uint32_t p_align; /* 对齐 */ ; } Elf32_Phdr;
mov dx, [KERNEL_BIN_BASE_ADDR + 42] ;e_phentsize mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ;e_phoff add ebx, KERNEL_BIN_BASE_ADDR mov cx, [KERNEL_BIN_BASE_ADDR + 44] ;e_phnum
.each_segment: cmp byte [ebx + 0], PT_NULL ;若p_type等于PT_NULL,说明此program未使用 je .PTNULL ;为函数memcpyu压入参数,参数从右往左依次压入 ;函数原型类似于memcpy(dst, src, size) push dword [ebx + 16] ;program header中偏移16字节的地方是p_filesz,传入size参数 mov eax, [ebx + 4] ;p_offset add eax, KERNEL_BIN_BASE_ADDR ;此时eax就是该段的物理地址(此前我们做了内核地址的同时映射) push eax ;压入mem_cpy的第二个参数,源地址 push dword [ebx + 8] ;压入mem_cpy的第一个参数,目的地址,p_vaddr call mem_cpy add esp, 12 ;清理栈中压入的三个参数 .PTNULL: add ebx, edx ;edx为program header的尺寸,这里就是跳入下一个描述符 loop .each_segment ret
; =========== 逐字节拷贝 mem_cpy(dst, src, size) ;输入:栈中三个参数 ;输出:无 mem_cpy: cld ;控制eflags寄存器中DF = 0, 使得movsb指令向前递增拷贝 push ebp mov ebp, esp ;构造栈帧 push ecx ;rep指令用到了ecx,但ecx对于外层段的循环还有用,所以入栈备份 mov edi, [ebp + 8] ;dst mov esi, [ebp + 12] ;src mov ecx, [ebp + 16] ;size rep movsb ;逐字节拷贝,其中movs代表move string,其中源地址保存在esi,目的地址保存在edi中,其中edi和esi肯定会一直增加,而这个增加的功能由cld指令实现 ;这里的rep指令是repeat的意思,就是重复执行movsb,循环次数保存在ecx中
;恢复环境 pop ecx ;因为外层ecx保存的是程序段数量,这里又要用作size,所以进行恢复 pop ebp ret
; ========================= 从硬盘读取数据, 同 mbr.S 中读取loader使用的函数, 只不过在保护模式进行 rd_disk_m_32: ;eax = 扇区LBA地址 ;ebx = 将数据写入的内存地址 ;ecx = 读入的扇区数 ; ======== 保存参数 mov esi, eax mov edi, ecx ; ======== 设置读取的扇区数 mov dx, 0x1f2 ; 0x1f2 是硬盘控制器的扇区计数寄存器端口 mov al, cl out dx, al ; 恢复eax mov eax, esi ; ========= 将LBA地址写入端口 ; 低8位 mov dx, 0x1f3 out dx, al ; 中8位 mov dx, 0x1f4 shr eax, 8 out dx, al ; 次高8位 mov dx, 0x1f5 shr eax, 8 out dx, al ; 最高4位和设备编码(各4位) mov dx, 0x1f6 shr eax, 8 and al, 0x0f or al, 0xe0 ; 1110 表示LBA模式 out dx, al ; ========== 向0x1f7端口写入读命令 0x20 mov dx, 0x1f7 mov al, 0x20 out dx, al ; ========= 开始循环读取 mov ecx, edi ; 外层循环:扇区数 .read_sector: push ecx ; 保存外层循环索引 cx .disk_not_ready: nop mov dx, 0x1f7 in al, dx and al, 0x88 ; 10001000 cmp al, 0x08 ; 00001000 硬盘不忙且可以读写数据 jnz .disk_not_ready ; 等待当前扇区就绪 mov ecx, 0x100 ; 内层:读256个字 = 512字节, 读一个扇区 mov dx, 0x1f0 .continue_read: in ax, dx mov [ebx], ax add ebx, 2 loop .continue_read pop ecx loop .read_sector ret
|