简单内核实现笔记 part 2

完善内核

调用约定

调用约定主要体现在以下三方面:

  1. 参数的传递方式,参数是存放在寄存器中还是栈中
  2. 参数的传递顺序,是从左到右传递还是从右到左传递
  3. 是调用者保存寄存器环境还是被调用者保存

有如下常见的调用约定,我们主要关注cdecl、stdcall、thiscall即可

cdecl是默认c的调用约定,调用者将所有参数从右向左入栈,被调用者清理参数所占栈空间,举个例子

1
2
int subtract(int a, int b); // 被调用者
int sub = subtract(3,2); // 调用者

调用者汇编如下

1
2
3
push 2
push 3
call subtract

被调用者汇编如下

1
2
3
4
5
6
7
push ebp               ; 备份ebp
mov esp, ebp ; esp赋值给ebp
mov eax, [ebp + 0x8] ; 偏移8字节处为第一个参数a
add eax, [ebp + 0xc] ; 偏移0xc字节处是第二个参数b
mov esp, ebp ; 本句可有可无
pop ebp ; 恢复ebp
ret 8 ; 函数返回时esp+8,被调用函数清理栈中参数

进入subtract函数时栈中的布局如下

stdcall是微软Win32 API的标准,调用者将所有参数从右向左入栈,并且调用者清理参数所占栈空间,还是上面的例子,调用者汇编如下

1
2
3
4
push 2
push 3
call subtract
add esp, 8 ; 调用者清理栈

被调用者汇编如下

1
2
3
4
5
6
7
push ebp               ; 备份ebp
mov esp, ebp ; esp赋值给ebp
mov eax, [ebp + 0x8] ; 偏移8字节处为第一个参数a
add eax, [ebp + 0xc] ; 偏移0xc字节处是第二个参数b
mov esp, ebp ; 本句可有可无
pop ebp ; 恢复ebp
ret ; 直接返回

thiscall则在C++中非静态成员函数的默认调用约定,其主要区别是ecx会多保存一个this指针指向操作的对象。

系统调用

为了更加理解系统调用,在后面会更频繁的结合C和汇编进行操作,下面做一个实验,分别用三种方式调用write函数,模拟下面C调用库函数的过程

1
2
3
4
5
#include<unistd.h>
int main(){
write(1,"hello,world\n",4);
return 0;
}

模拟代码syscall_write.S如下

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
section .data
str_c_lib: db "C library says: hello world!", 0xa ; 0xa为换行符
str_c_lib_len equ $-str_c_lib

str_syscall: db "syscall says: hello world!", 0xa
str_syscall_len equ $-str_syscall

section .text
global _start
_start:
; ssize_t write(int fd,const void *buf,size_t count);
; 方法一:模拟C语言中系统调用库函数write
push str_c_lib_len
push str_c_lib
push 1

call my_write
add esp, 12

; 方法二:系统调用
mov eax, 4 ; 系统调用号
mov ebx, 1 ; fd
mov ecx, str_syscall ; buf
mov edx, str_syscall_len ; count
int 0x80

; 退出程序
mov eax, 1 ; exit()
int 0x80

; 下面模拟write系统调用
my_write:
push ebp
mov esp, ebp
mov eax, 4
mov ebx, [ebp + 8] ; fd
mov ecx, [ebp + 0xc] ; buf
mov edx, [ebp + 0x10] ; count
int 0x80
pop ebp
ret

运行结果如下

既然我们用汇编模拟了C中的write函数,下面就用C结合汇编进行第二个实验

C_with_S_c.c

1
2
3
4
5
6
extern void asm_print(char*,int);
void c_print(char* str) {
int len=0;
while(str[len++]); // 循环求出长度len,以'\0'结尾
asm_print(str, len);
}

C_with_S_S.S

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
section .data
str: db "asm_print hello world!", 0xa, 0 ; 0xa为换行符,0为结束符
str_len equ $-str

section .text
extern c_print
global _start
_start:
push str
call c_print
add esp, 4

; 退出程序
mov eax, 1 ; exit()
int 0x80

; 下面模拟write系统调用
global asm_print
asm_print:
push ebp
mov ebp, esp
mov eax, 4
mov ebx, 1
mov ecx, [ebp + 8] ; str
mov edx, [ebp + 0xc] ; len
int 0x80
pop ebp
ret

其调用关系如下图

编译过程如下所示

实现打印函数

对于字符的打印主要是对显卡端口的操作,所以是用汇编实现,这里新键一个lib目录,里面添加一个头文件,主要申请一些数据结构信息,来自Linux源码

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef _LIB_STDINT_H_
#define _LIB_STDINT_H_

typedef signed char int8_t;
typedef signed short int int16_t;
typedef signed int int32_t;
typedef signed long long int int64_t;
typedef unsigned char uint8_t;
typedef unsigned short int uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long int uint64_t;

#endif //!_LIB_STDINT_H_

再新建一个user目录和一个kernel目录,我们的print实现代码就在kernel目录下的print.S,这个函数比较复杂,处理流程如下

  1. 备份寄存器现场
  2. 获取光标坐标值,光标坐标值是下一个可打印字符的位置
  3. 获取待打印的字符
  4. 判断字符是否为控制字符,如回车、换行、退格符需要特殊处理
  5. 判断是否需要滚屏
  6. 更新光标坐标值,使其指向下一个打印字符的位置
  7. 恢复寄存器现场,退出

首先需要知道光标和字符的区别,它们之间没有任何关系,光标位置保存在光标寄存器中,可以手动维护,这就需要参考书中的显卡寄存器索引(P264),我们需要操作CRT控制数据寄存器中索引为0x0E的Cursor Location High Register和索引为0x0F的Cursor Location Low Register分别用来储存光标坐标的高8位和低8位。访问CRT寄存器,需要首先往端口地址为0x3D4寄存器写入索引,然后再从端口0x3D5的数据寄存器读写数据,另外一些特殊字符需要特殊处理,其中还会涉及到滚屏的处理,我们的屏幕是80*25大小的,步骤如下:

  1. 将第1~24行搬到0~23行,覆盖第0行
  2. 将24行也就是最后一行用空格覆盖,看起来像新的一行
  3. 光标移动到第24行行首
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
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0

[bits 32]
section .text
; ----------------- put_char -----------------
; 把栈中的一个字符写入光标所在处
; --------------------------------------------
global put_char ; 全局变量,外部可调用
put_char:
pushad ; 备份环境
; 保证gs中为正确的视频段选择子
; 为保险起见,每次打印时都为gs赋值
mov ax, SELECTOR_VIDEO ; 不能直接把立即数送入段寄存器
mov gs, ax

; 获取当前光标位置,25个字符一行,一共80行,从0行开始
; 先获得高8位
mov dx, 0x03d4 ; 索引寄存器
mov al, 0x0e ; 用于提供光标位置的高8位
out dx, al
mov dx, 0x03d5 ; 通过读写数据端口0x3d5来获得或设置光标位置
in al, dx ; 得到了光标位置的高8位
mov ah, al

; 在获取低8位光标
mov dx, 0x3d4
mov al, 0x0f
out dx, al
mov dx, 0x3d5
in al, dx
; 将16位完整的光标存入bx
mov bx, ax
; 下面这行是在栈中获取待打印的字符
mov ecx, [esp + 36] ; pushad压入4x8=32字节
; 加上主函数4字节返回地址
cmp cl, 0xd ; 回车CR是0x0d,换行LF是0x0a
jz .is_carriage_return
cmp cl, 0xa
jz .is_line_feed

cmp cl, 0x8 ; BS(backspace)的asc码是8
jz .is_backspace
jmp .put_other

.is_backspace:
;;;;;;;;;;;;;;;;;; 对于backspace的一点说明 ;;;;;;;;;;;;;;;;;;
; 当为 backspace 时,光标前移一位
; 末尾添加空格或空字符0
dec bx
shl bx, 1 ; 光标左移一位等于乘2
; 表示光标对应显存中的偏移字节
mov byte [gs:bx], 0x20 ; 将待删除的字节补为0或空格皆可
inc bx
mov byte [gs:bx], 0x07
shr bx, 1
jmp .set_cursor
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
.put_other:
shl bx, 1 ; 光标位置用2字节表示,将光标值乘2
; 表示对应显存中的偏移字节
mov [gs:bx], cl ; ASCII字符本身
inc bx
mov byte [gs:bx], 0x07 ; 字符属性
shr bx, 1 ; 恢复老的光标值
inc bx ; 下一个光标值
cmp bx, 2000
jl .set_cursor ; 若光标值小于2000,表示未写到显存的最后,则去设置新的光标值
; 若超出屏幕字符数大小(2000)则换行处理
.is_line_feed: ; 是换行符LF(\n)
.is_carriage_return: ; 是回车符
; 如果是CR(\r),只要把光标移到行首就行了
xor dx, dx ; dx是被除数的高16位,清0
mov ax, bx ; ax是被除数的低16位
mov si, 80 ; 效访Linux中\n表示下一行的行首
div si ; 这里\n和\r都处理为下一行的行首
sub bx, dx ; 光标值减去除80的余数便是取整
; 以上4行处理\r的代码
.is_carriage_return_end: ; 回车符CR处理结束
add bx, 80
cmp bx, 2000
.is_line_feed_end: ; 若是LF(\n),将光标移+80便可
jl .set_cursor
; 屏幕行范围是0~24,滚屏的原理是将屏幕的第1~24行搬运到第0~23行,再将第24行用空格填充
.roll_screen: ; 若超出屏幕大小,开始滚屏
cld
mov ecx, 960 ; 2000-80=1920个字符要搬运,共1920*2=3820字节
; 一次搬4字节,共3840/4=960次
mov esi, 0xc00b80a0 ; 第一行行首
mov edi, 0xc00b8000 ; 第0行行首
rep movsd

; 将最后一行填充为空白
mov ebx, 3840 ; 最后一行首字符的第一个字节偏移=1920*2
mov ecx, 80 ; 一行是80字符(160字节),每次清空1字符(2字节),一行需要移动80次

.cls:
mov word [gs:ebx], 0x0720 ; 0x0720是黑底白字的空格键
add ebx, 2
loop .cls
mov bx, 1920 ; 将光标值重置为1920,最后一行的首字符

.set_cursor:
; 将光标设为bx值
; 1.先设置高8位
mov dx, 0x03d4 ; 索引寄存器
mov al, 0x0e ; 用于提供光标位置的高8位
out dx, al
mov dx, 0x03d5 ; 通过读写数据端口0x3d5来获得或设置光标位置
mov al, bh
out dx, al

; 2.再设置低8位
mov dx, 0x3d4
mov al, 0x0f
out dx, al
mov dx, 0x03d5
mov al, bl
out dx, al
.put_char_done:
popad
ret

头文件print.h

1
2
3
4
5
#ifndef __LIB_KERNEL_PRINT_H // 如果没有__LIB_KERNEL_PRINT_H宏则编译下面的代码
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci); // 这里是8位无符号整型,为了和之前参数存放在cl寄存器长度吻合
#endif

下面测试代码main.o

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "print.h"
void main(void){
put_char('k');
put_char('e');
put_char('r');
put_char('n');
put_char('e');
put_char('l');
put_char('\n');
put_char('T');
put_char('h');
put_char('u');
put_char('n');
put_char('d');
put_char('e');
put_char('e');
put_char('\b');
put_char('r');
while(1);
}

目前为止的目录结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.
├── boot
│   ├── include
│   │   └── boot.inc
│   ├── loader.bin
│   ├── loader.S
│   ├── mbr.bin
│   └── mbr.S
├── kernel
│   ├── kernel.bin
│   ├── main.c
│   └── main.o
└── lib
├── kernel
│   ├── print.h
│   └── print.S
├── stdint.h
└── user

编译需要用到的几条命令,目录不同会有变化

1
2
3
4
sudo nasm -f elf -o print.o print.S
sudo gcc -m32 -I /home/guang/soft/kernel/lib/kernel -c -o main.o main.c
sudo ld -m elf_i386 -Ttext 0xc0001500 -e main -o kernel.bin main.o /home/guang/soft/kernel/lib/kernel/print.o
sudo dd if=./kernel.bin of=/home/guang/soft/bochs-2.6.2/bin/hd60M.img bs=512 count=200 seek=9 conv=notrunc

显示结果如下

下面把put_char函数封装起来,put_str通过put_char来打印以0字符结尾的字符串,思想就是循环打印直到0结束

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
; --------------------------------------------
; put_str通过put_char来打印以0字符结尾的字符串
; 输入:栈中参数为打印的字符串
; 输出:无
; --------------------------------------------
global put_str
put_str:
; 此函数用到ebx和ecx,先备份
push ebx
push ecx
xor ecx, ecx
mov ebx, [esp + 0xc] ; 栈中得到待打印字符串的地址
.goon:
mov cl, [ebx]
cmp cl, 0 ; 如果处理到了字符串尾,跳到结束处返回
jz .str_over
push ecx ; 为put_char函数传递参数
call put_char ; 循环调用put_char实现打印字符串
add esp, 4
inc ebx ; ebx指向下一个字符
jmp .goon

.str_over:
pop ecx
pop ebx
ret

print.h中增加一行申明

1
2
3
4
5
6
#ifndef __LIB_KERNEL_PRINT_H // 如果没有__LIB_KERNEL_PRINT_H宏则编译下面的代码
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci); // 这里是8位无符号整型,为了和之前参数存放在cl寄存器长度吻合
void put_str(char* message);
#endif

main.c对其进行调用测试

1
2
3
4
5
#include "print.h"
void main(void){
put_str("Welcome to kernel\n");
while(1);
}

测试结果如下

前面是实现对字符的打印,下面需要增加对整数的打印,逐位处理,A~F再单独处理,再增加对高位多余0的处理,详情见注释

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
;--------------------   将小端字节序的数字变成对应的ascii后,倒置   -----------------------
;输入:栈中参数为待打印的数字
;输出:在屏幕上打印16进制数字,并不会打印前缀0x,如打印10进制15时,只会直接打印f,不会是0xf
;------------------------------------------------------------------------------------------

global put_int
put_int:
pushad
mov ebp, esp
mov eax, [ebp + 4*9] ; call的返回地址占4字节再加上pushad的8个四字节
mov edx, eax
mov edi, 7 ; 指定在put_int_buffer中初始的偏移量
mov ecx, 8 ; 32位数字中,十六进制数字的位数是8个
mov ebx, put_int_buffer

; 将32位数字按照十六进制的形式从低位到高位逐个处理
; 共处理8个十六进制数字
.16based_4bits: ; 每4位二进制是16进制数字的1位
; 遍历每一位十六进制数字
and edx, 0x0000000F ; 解析十六进制数字的每一位
; and与操作后,edx只有低4位有效
cmp edx, 9 ; 数字0~9和a~f需要分别处理成对应的字符
jg .is_A2F
add edx, '0' ; ASCII码是8位大小。add求和操作后,edx低8位有效
jmp .store
.is_A2F:
sub edx, 10 ; A~F减去10所得到的差,再加上字符A的
; ASCII码,便是A~F对应的ASCII码
add edx, 'A'
; 将每一位数字转换成对应的字符后,按照类似“大端”的顺序存储到缓冲区put_int_buffer
; 高位字符放在低地址,低位字符要放在高地址,这样和大端字节序类似,只不过咱们这里是字符序.
.store:
; 此时dl中应该是数字对应的字符的ASCII码
mov [ebx + edi], dl
dec edi
shr eax, 4
mov edx, eax
loop .16based_4bits

; 现在put_int_buffer中已全是字符,打印之前
; 把高位连续的字符去掉,比如把字符000123变成123
.ready_to_print:
inc edi ; 此时edi退减为-1(0xffffffff),加上1使其为0
.skip_prefix_0:
cmp edi, 8 ; 若已经比较第9个字符了
; 表示待打印的字符串为全0
je .full0
; 找出连续的0字符,edi作为非0的最高位字符的偏移
.go_on_skip:
mov cl, [put_int_buffer + edi]
inc edi
cmp cl, '0'
je .skip_prefix_0 ; 继续判断下一位字符是否为字符0(不是数字0)
dec edi ; edi在上面的inc操作中指向了下一个字符
; 若当前字符不为'0',要使edi减1恢复指向当前字符
jmp .put_each_num

.full0:
mov cl, '0' ; 输入的数字为全0时,则只打印0
.put_each_num:
push ecx ; 此时cl中为可打印的字符
call put_char
add esp, 4
inc edi ; 使edi指向下一个字符
mov cl, [put_int_buffer + edi] ; 获取下一个字符到cl寄存器
cmp edi, 8
jl .put_each_num
popad
ret

print.h增加一行put_int的申明注释,main.c中增加测试代码即可,测试结果如下所示

中断

中断的存在极大提高了计算机的效率,可分为外部中断和内部中断。

外部中断的中断源为某个硬件,CPU为中断信号提供了两条信号线分别是INTRNMI,如下图所示,从INTR引脚收到的中断都是不影响系统运行的,可以随时处理,不会影响到CPU的执行。也称为可屏蔽中断。可以通过eflag中的IF位将所有这些外部中断屏蔽

image-20200526104424200

内部中断可分为软中断和异常

软中断

顾名思义是软件主动发起的中断,不受eflags中的IF位的影响,有如下指令:

  • “int 8位立即数”,通过它进行系统调用
  • int3,int和3之间无空格,用于调试
  • into,中断溢出指令,当OF位也为1时,触发4号中断
  • bound,检查数组索引越界指令,越界时触发5号中断
  • ud2,未定义指令,触发6号中断

异常

异常是指令执行期间CPU内部产生的错误引起的,也不受eflags中的IF位的影响,按照轻重程度分为三种

  1. Fault,也称故障。属于可被修复的一种类型,当发生此类异常时,CPU将机器状态恢复到异常之前的状态 ,之后调用中断处理程序,通常都能够被解决。缺页异常就属于此种异常
  2. Trap,也称陷阱。此异常通常在调试中。
  3. Abort,也称终止。程序发生了此类异常通常就无法继续执行下去,操作系统会将此程序从进程表中去除。

中断描述符表

中断描述符表是保护模式下用于存储中断处理程序入口的表,当CPU接受到一个中断时,需要根据该中断的中断向量号在此表中检索对应的描述符,在该描述符中找到中断处理程序的起始地址,然后执行中断处理程序,这和之前段描述符非常类似,类比学习即可。

实模式下用于中断处理程序入口的表叫做中断向量表(IVT),保护模式下则是中断描述符表(IDT)。

IVT在实模式下位于0~0x3ff共1024个字节,又知IVT可容纳256个中断向量,故每个中断向量用4字节描述;对比IVT,IDT表地址不受限制,在哪里都可以,每个描述符用8字节描述。这里主要讨论IDT,在IDT中描述符称之为门,也就是之前介绍过的门,这里再区别一下门和段描述符

  • 段描述符中描述的是一片内存区域
  • 门描述符描述的是一段代码,除调用门外,任务门、中断门、陷阱门都可以存在于中断描述符中

IDT位置不固定,故CPU找到它需要通过一个寄存器IDTR,如下图,其中0~15位是表界限,也就是IDT大小减一,第16~47位是IDT的基地址,和之前的GDTR是一个原理

image-20200526104424200

16位的表界限范围是0~0xffff,即64KB,可容纳的描述符个数是64KB/8=8K=8192个。特别注意的是GDT中的第0个段描述符是不可用的,但IDT却无此限制,第0个门描述符也是可用的,处理器只支持256个中断,即0~254,中断描述符中其他的描述符不可用,还需要注意的是门描述符中的P位,构建IDT时需要将其置为0,表示门描述符的中断处理程序不在内存中。加载IDTR需要用到lidt指令,用法是lidt 48位内存数据

中断的处理过程总结如下

  1. 处理器根据中断向量号定位中断门描述符
  2. 处理器进行特权级检查
  3. 执行中断处理程序

image-20200526104424200

中断发生之后需要执行中断处理程序,该中断处理程序是通过中断门描述符中保存的代码段选择子和段内偏移找到的,这个时候就需要重新加载段寄存器,也就是说需要在栈中保存一些寄存器信息(CS:EIP、eflags等),保证中断之后执行的流程正确,当特权级变化的时候,压栈如下图所示

image-20200526104424200

图A、B:在发生中断是通过特权级的检测,发现需要向高特权级转移,所以要保存当前程序栈的SS和ESP的值,在这里记为ss_old, esp_old,然后在新栈中压入当前程序的eflags寄存器。

图C、D:由于要切换目标代码段,这种段间转移,要对CS和EIP进行备份,同样将其存入新栈中。某些异常会有错误码,用来标识异常发生在哪个段上,对于有错误码的情况,要将错误码也压入栈中。

当特权级没有变化的时候,就不需要压入旧栈的SS和EIP

image-20200526104424200

返回的时候通过指令 iret 完成,iret 指令会从栈顶依次弹出EIP、CS、EFLAGS,根据特权级的变化还有ESP、SS。但是该指令并不验证数据的正确性,而且他从栈中弹出数据的顺序是不变的,也就是说,在有error_code的情况下,iret返回时并不会主动跳过这个数据,需要我们手动进行处理。

编写中断处理程序

下面通过操作8259A芯片实现第一个中断处理程序,关于8259A相关信息参考书中P311内容,本质上是一个可编程中断控制器,处理流程如下,init_all负责初始化所有设备及结构体,然后调用idt_init初始化中断相关内容,内部分别调用了pic_initidt_desc_init实现,其中pic_init初始化8259A,idt_desc_init负责对中断描述符IDT表进行初始化,最后再对IDT表进行加载

image-20200526104424200

我们需要进行以下几个步骤

  1. 用汇编语言实现中断处理程序
  2. 创建中断描述符表IDT,安装中断处理程序
  3. 用内联汇编实现端口I/O函数(对端口的读写操作)
  4. 设置8259A

新添加中断后的文件树如下所示,build中是生成后的文件,device中存放的是为了提高中断频率对8253计数器的操作,kernel中新加的interrupt是对中断初始化的主要文件

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
.
├── boot
│   ├── include
│   │   └── boot.inc
│   ├── loader.bin
│   ├── loader.S
│   ├── mbr.bin
│   └── mbr.S
├── build
│   ├── init.o
│   ├── interrupt.o
│   ├── kernel.bin
│   ├── kernel.o
│   ├── main.o
│   ├── print.o
│   └── timer.o
├── device
│   ├── timer.c
│   └── timer.h
├── kernel
│   ├── global.h
│   ├── init.c
│   ├── init.h
│   ├── interrupt.c
│   ├── interrupt.h
│   ├── kernel.S
│   └── main.c
└── lib
├── kernel
│   ├── io.h
│   ├── print.h
│   ├── print.o
│   └── print.S
├── stdint.h
└── user

8 directories, 26 files

编译比较麻烦,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//编译c程序,生成目标文件,这里需要关闭栈保护并指定32位程序
sudo gcc -m32 -fno-stack-protector -I lib/kernel/ -c -o build/timer.o device/timer.c
sudo gcc -m32 -fno-stack-protector -I lib/kernel -I lib/ -I kernel -c -fno-builtin -o build/init.o kernel/init.c
sudo gcc -m32 -fno-stack-protector -I lib/kernel -I lib/ -I kernel -c -fno-builtin -o build/main.o kernel/main.c
sudo gcc -m32 -fno-stack-protector -I lib/kernel -I lib/ -I kernel -c -fno-builtin -o build/interrupt.o kernel/interrupt.c

//编译汇编
sudo nasm -f elf -o build/print.o lib/kernel/print.S
sudo nasm -f elf -o build/kernel.o kernel/kernel.S

//链接,在build目录下
sudo ld -m elf_i386 -Ttext 0xc0001500 -e main -o kernel.bin main.o init.o interrupt.o print.o kernel.o timer.o

//写入img
sudo dd if=./kernel.bin of=/home/guang/soft/bochs-2.6.2/bin/hd60M.img bs=512 count=200 seek=9 conv=notrunc

运行结果如下,这里我为了效果演示注释了interrupt.c文件中general_intr_handler函数的最后三行打印中断号的部分,结果如下

image-20200526104424200

取消注释后,效果如下

image-20200526104424200

内存管理系统

在编写内存管理系统之前需要做一些其他的准备工作

Makefile和断言

为了更好的对kernel进行编译,这里使用makefile来操作,makefile具体的知识点就不单独列举了,感兴趣的小伙伴可以自己查阅资料,和作者不同的是这里我是x64的系统,新增了一些编译选项并且把ubantu的终端修改为了bash,具体如下

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
BUILD_DIR = ./build
ENTRY_POINT = 0xc0001500
AS = nasm
CC = gcc
LD = ld
LIB = -I lib/ -I lib/kernel/ -I lib/user/ -I kernel/ -I device/
ASFLAGS = -f elf
CFLAGS = -m32 -fno-stack-protector -Wall $(LIB) -c -fno-builtin -W -Wstrict-prototypes \
-Wmissing-prototypes
LDFLAGS = -m elf_i386 -Ttext $(ENTRY_POINT) -e main -Map $(BUILD_DIR)/kernel.map
OBJS = $(BUILD_DIR)/main.o $(BUILD_DIR)/init.o $(BUILD_DIR)/interrupt.o \
$(BUILD_DIR)/timer.o $(BUILD_DIR)/kernel.o $(BUILD_DIR)/print.o \
$(BUILD_DIR)/debug.o

############## c代码编译 ###############
$(BUILD_DIR)/main.o: kernel/main.c lib/kernel/print.h \
lib/stdint.h kernel/init.h
$(CC) $(CFLAGS) $< -o $@

$(BUILD_DIR)/init.o: kernel/init.c kernel/init.h lib/kernel/print.h \
lib/stdint.h kernel/interrupt.h device/timer.h
$(CC) $(CFLAGS) $< -o $@

$(BUILD_DIR)/interrupt.o: kernel/interrupt.c kernel/interrupt.h \
lib/stdint.h kernel/global.h lib/kernel/io.h lib/kernel/print.h
$(CC) $(CFLAGS) $< -o $@

$(BUILD_DIR)/timer.o: device/timer.c device/timer.h lib/stdint.h\
lib/kernel/io.h lib/kernel/print.h
$(CC) $(CFLAGS) $< -o $@

$(BUILD_DIR)/debug.o: kernel/debug.c kernel/debug.h \
lib/kernel/print.h lib/stdint.h kernel/interrupt.h
$(CC) $(CFLAGS) $< -o $@

############## 汇编代码编译 ###############
$(BUILD_DIR)/kernel.o: kernel/kernel.S
$(AS) $(ASFLAGS) $< -o $@
$(BUILD_DIR)/print.o: lib/kernel/print.S
$(AS) $(ASFLAGS) $< -o $@

############## 链接所有目标文件 #############
$(BUILD_DIR)/kernel.bin: $(OBJS)
$(LD) $(LDFLAGS) $^ -o $@

.PHONY : mk_dir hd clean all

# ubantu中需要将dash修改为bash运行
# ls -al /bin/sh若结果为/bin/sh -> dash
# 执行sudo dpkg-reconfigure dash选择No即可
mk_dir:
if [[ ! -d $(BUILD_DIR) ]];then mkdir $(BUILD_DIR);fi

hd:
dd if=$(BUILD_DIR)/kernel.bin \
of=/home/guang/soft/bochs-2.6.2/bin/hd60M.img \
bs=512 count=200 seek=9 conv=notrunc

clean:
cd $(BUILD_DIR) && rm -f ./*

build: $(BUILD_DIR)/kernel.bin

all: mk_dir build hd

为了调试方便我们新增加了断言(ASSERT),其核心思想是若断言通过则什么都不做,若不通过则用循环实现等待,打印错误信息,具体内容见debug.cdebug.h,在main.c中对其进行测试

1
2
3
4
5
6
7
8
9
10
#include "print.h"
#include "init.h"
#include "debug.h"

void main(void) {
put_str("Welcome to TJ's kernel\n");
init_all();
ASSERT(1==2); // 测试断言
while(1);
}

主目录下用sudo make all编译之后,测试断言运行效果如下所示

image-20200526104424200

字符串函数实现

在lib目录下用string.c实现对字符串的一些操作函数,比较好理解就不多解释了,代码如下

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
#include "string.h"
#include "global.h"
#include "debug.h"

/* 将dst_起始的size个字节置为value */
void memset(void* dst_, uint8_t value, uint32_t size) {
ASSERT(dst_ != NULL);
uint8_t* dst = (uint8_t*)dst_;
while(size--)
{
*dst++ = value;
}
}

/* 将src_起始的size个字节复制到dst_ */
void memcpy(void* dst_, const void* src_, uint32_t size) {
ASSERT(dst_ != NULL && src_ != NULL);
uint8_t* dst = (uint8_t*)dst_;
const uint8_t* src = src_;
while(size--)
{
*dst++ = *src++;
}
}

/* 连续比较以地址a_和地址b_开头的size个字节,若相等则返回0,若a_大于b_返回+1,否则返回-1 */
int memcmp(const void* a_, const void* b_, uint32_t size) {
const char* a = a_;
const char* b = b_;
ASSERT(a != NULL && b != NULL);

while(size--) {
if(*a != *b) {
return *a > *b ? 1 : -1;
}
a++;
b++;
}
return 0;
}

/* 将字符串从src_复制到dst_,'0'为截至条件 */
char* strcpy(char* dst_, const char* src_) {
ASSERT(dst_ != NULL && src_ != NULL);
char* r = dst_; // 用来返回目的字符串起始地址
while((*dst_++ = *src_++));
return r;
}

/* 返回字符串长度 */
uint32_t strlen(const char* str) {
ASSERT(str != NULL);
const char* p = str;
while(*p++);
return (p - str - 1);
}

/* 比较两个字符串,若a_中的字符大于b_中的字符返回1,相等时返回0,否则返回-1. */
int8_t strcmp (const char* a, const char* b) {
ASSERT(a != NULL && b != NULL);
while (*a != 0 && *a == *b) {
a++;
b++;
}
/* 如果*a小于*b就返回-1,否则就属于*a大于等于*b的情况。在后面的布尔表达式"*a > *b"中,
* 若*a大于*b,表达式就等于1,否则就表达式不成立,也就是布尔值为0,恰恰表示*a等于*b */
return *a < *b ? -1 : *a > *b;
}

/* 从左到右查找字符串str中首次出现字符ch的地址(不是下标,是地址) */
char* strchr(const char* str, const uint8_t ch) {
ASSERT(str != NULL);
while(*str != 0)
{
if(*str == ch)
{
return (char*)str; // 需要强制转化成和返回值类型一样,否则编译器会报const属性丢失,下同.
}
str++;
}
return NULL;
}

/* 从后往前查找字符串str中首次出现字符ch的地址(不是下标,是地址) */
char* strrchr(const char* str, const uint8_t ch) {
ASSERT(str != NULL);
const char* last_char = NULL;
/* 从头到尾遍历一次,若存在ch字符,last_char总是该字符最后一次出现在串中的地址(不是下标,是地址)*/
while (*str != 0) {
if (*str == ch) {
last_char = str;
}
str++;
}
return (char*)last_char;
}

/* 将字符串src_拼接到dst_后,将回拼接的串地址 */
char* strcat(char* dst_, const char* src_) {
ASSERT(dst_ != NULL && src_ != NULL);
char* str = dst_;
while (*str++);
--str; // 别看错了,--str是独立的一句,并不是while的循环体
while((*str++ = *src_++)); // 当*str被赋值为0时,此时表达式不成立,正好添加了字符串结尾的0.
return dst_;
}

/* 在字符串str中查找指定字符ch出现的次数 */
uint32_t strchrs(const char* str, uint8_t ch) {
ASSERT(str != NULL);
uint32_t ch_cnt = 0;
const char* p = str;
while(*p != 0) {
if (*p == ch) {
ch_cnt++;
}
p++;
}
return ch_cnt;
}

BITMAP实现

位图用于实现资源管理,相当于一张表,表中为1表示占用,为0表示空闲,之后我们将其用来管理内存,我们在前面的基础之上实现BITMAP,在lib/kernel目录下新增bitmap.hbitmap.c,代码如下,bitmap结构比较简单,只有两个成员:指针bits和位图的字节长度btmp_bytes_len

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef __LIB_KERNEL_BITMAP_H
#define __LIB_KERNEL_BITMAP_H
#include "global.h"
#define BITMAP_MASK 1
struct bitmap {
uint32_t btmp_bytes_len;
/* 在遍历位图时,整体上以字节为单位,细节上是以位为单位,所以此处位图的指针必须是单字节 */
uint8_t* bits;
};

void bitmap_init(struct bitmap* btmp);
bool bitmap_scan_test(struct bitmap* btmp, uint32_t bit_idx);
int bitmap_scan(struct bitmap* btmp, uint32_t cnt);
void bitmap_set(struct bitmap* btmp, uint32_t bit_idx, int8_t value);
#endif

下面的一些函数主要是对位图的一些操作函数,还是比较容易看懂的,其中较为核心的函数是bitmap_scan

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
#include "bitmap.h"
#include "stdint.h"
#include "string.h"
#include "print.h"
#include "interrupt.h"
#include "debug.h"

/* 将位图btmp初始化 */
void bitmap_init(struct bitmap* btmp) {
memset(btmp->bits, 0, btmp->btmp_bytes_len);
}

/* 判断bit_idx位是否为1,若为1则返回true,否则返回false */
bool bitmap_scan_test(struct bitmap* btmp, uint32_t bit_idx) {
uint32_t byte_idx = bit_idx / 8; // 向下取整用于索引数组下标
uint32_t bit_odd = bit_idx % 8; // 取余用于索引数组内的位
return (btmp->bits[byte_idx] & (BITMAP_MASK << bit_odd));
}

/* 在位图中申请连续cnt个位,成功则返回其起始位下标,失败返回-1 */
int bitmap_scan(struct bitmap* btmp, uint32_t cnt) {
uint32_t idx_byte = 0; // 用于记录空闲位所在的字节
/* 先逐字节比较,蛮力法 */
while (( 0xff == btmp->bits[idx_byte]) && (idx_byte < btmp->btmp_bytes_len)) {
/* 1表示该位已分配,所以若为0xff,则表示该字节内已无空闲位,向下一字节继续找 */
idx_byte++;
}

ASSERT(idx_byte < btmp->btmp_bytes_len);
if (idx_byte == btmp->btmp_bytes_len) { // 若该内存池找不到可用空间
return -1;
}

/* 若在位图数组范围内的某字节内找到了空闲位,
* 在该字节内逐位比对,返回空闲位的索引。*/
int idx_bit = 0;
/* 和btmp->bits[idx_byte]这个字节逐位对比 */
while ((uint8_t)(BITMAP_MASK << idx_bit) & btmp->bits[idx_byte]) {
idx_bit++;
}

int bit_idx_start = idx_byte * 8 + idx_bit; // 空闲位在位图内的下标
if (cnt == 1) {
return bit_idx_start;
}

uint32_t bit_left = (btmp->btmp_bytes_len * 8 - bit_idx_start); // 记录还有多少位可以判断
uint32_t next_bit = bit_idx_start + 1;
uint32_t count = 1; // 用于记录找到的空闲位的个数

bit_idx_start = -1; // 先将其置为-1,若找不到连续的位就直接返回
while (bit_left-- > 0) {
if (!(bitmap_scan_test(btmp, next_bit))) { // 若next_bit为0
count++;
} else {
count = 0;
}
if (count == cnt) { // 若找到连续的cnt个空位
bit_idx_start = next_bit - cnt + 1;
break;
}
next_bit++;
}
return bit_idx_start;
}

/* 将位图btmp的bit_idx位设置为value */
void bitmap_set(struct bitmap* btmp, uint32_t bit_idx, int8_t value) {
ASSERT((value == 0) || (value == 1));
uint32_t byte_idx = bit_idx / 8; // 向下取整用于索引数组下标
uint32_t bit_odd = bit_idx % 8; // 取余用于索引数组内的位

/* 一般都会用个0x1这样的数对字节中的位操作,
* 将1任意移动后再取反,或者先取反再移位,可用来对位置0操作。*/
if (value) { // 如果value为1
btmp->bits[byte_idx] |= (BITMAP_MASK << bit_odd);
} else { // 若为0
btmp->bits[byte_idx] &= ~(BITMAP_MASK << bit_odd);
}
}

内存管理

根据之前的铺垫,为了实现内存中用户和内核的区分,我们用位图实现对内存使用情况的记录,我们将物理内存划分为用户内存池和内核内存池,一页为4KB大小。

内核在申请空间的时候,先从内核自己的虚拟地址池中分配好虚拟地址再从内核物理地址池中分配物理内存,最后在内核自己的页表中将这两种地址建立好映射关系,内存就分配完成。

对用户进程来说,它向操作系统申请内存时,操作系统先从用户进程自己的虚拟地址分配虚拟地址,在从用户物理内存池中分配空闲的物理内存,用户物理内存池是被所有用户进程所共享的。最后在用户进程自己的页表中将这两种地址建立好映射关系。

image-20200526104424200

实现在kernel目录下新建memory.cmemory.h,虚拟内存池结构和物理内存池结构如下,物理内存多了一个记录大小的pool_size,因为虚拟地址是连续的4GB空间,相对而言空间非常大,而物理地址是有限的,所以不存在对虚拟地址大小的记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct virtual_addr
{
struct bitmap vaddr_bitmap;
uint32_t vaddr_start;
};

struct pool
{
struct bitmap pool_bitmap; // 内存池的位图结构
uint32_t phy_addr_start;
uint32_t pool_size;
};

struct pool kernel_pool, user_pool; // 生成内核内存池和用户内存池
struct virtual_addr kernel_vaddr; // 此结构用来给内核分配虚拟地址

在前面创建页目录和页表的时候,我们将虚拟地址 0xc0000000~0xc00fffff 映射到了物理地址 0x0~0xfffff,0xc0000000 是内核空间的起始虚拟地址,这 1MB 空间做的对等映射。为了看起来使内存连续,所以这里内核堆空间的开始地址从 0xc0100000 开始,在之前的设计中,0xc009f000 为内核主线程的栈顶,0xc009e000 将作为主线程的 PCB 使用,那么在低端1MB的空间中,就只剩下0xc009a000~0xc009dfff4 * 4KB的空间未使用,所以位图的地址就安排在 0xc009a000 处,这里还剩下四个页框的大小,所能表示的内存大小为512MB

1
2
#define MEM_BITMAP_BASE 0xc009a000
#define K_HEAP_START 0xc0100000

关键初始化函数如下,主要实现对内核池与用户池在物理内存中的平均分配

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
// 初始化内存池
static void mem_pool_init(uint32_t all_mem)
{
put_str(" mem_pool_init start\n");

// 目前只初始化了低端1MB的内存页表,也就是256个页表
uint32_t page_table_size = PG_SIZE * 256;

uint32_t used_mem = page_table_size + 0x100000;

uint32_t free_mem = all_mem - used_mem;
uint16_t all_free_pages = free_mem / PG_SIZE;

uint16_t kernel_free_pages = all_free_pages / 2;
uint16_t user_free_pages = all_free_pages - kernel_free_pages;

// 内核的位图大小,在位图中,1bit表示1页
uint32_t kbm_length = kernel_free_pages / 8;
uint32_t ubm_length = user_free_pages / 8;

// 内核内存池的起始地址
uint32_t kp_start = used_mem;

// 用户内存池的起始地址
uint32_t up_start = kp_start + kernel_free_pages * PG_SIZE;

kernel_pool.phy_addr_start = kp_start;
user_pool.phy_addr_start = up_start;

kernel_pool.pool_size = kernel_free_pages * PG_SIZE;
user_pool.pool_size = user_free_pages * PG_SIZE;

kernel_pool.pool_bitmap.btmp_bytes_len = kbm_length;
user_pool.pool_bitmap.btmp_bytes_len = ubm_length;

kernel_pool.pool_bitmap.bits = (void*)MEM_BITMAP_BASE;
user_pool.pool_bitmap.bits = (void*)(MEM_BITMAP_BASE + kbm_length);

// 输出内存信息
put_str(" kernel_pool_bitmap_start:");
put_int((int)kernel_pool.pool_bitmap.bits);
put_str(" kernel_pool_phy_addr_start:");
put_int(kernel_pool.phy_addr_start);
put_str("\n");
put_str(" user_pool_bitmap_start:");
put_int((int)user_pool.pool_bitmap.bits);
put_str(" user_pool_phy_addr_start:");
put_int(user_pool.phy_addr_start);
put_str("\n");

// 将位图置0
bitmap_init(&kernel_pool.pool_bitmap);
bitmap_init(&user_pool.pool_bitmap);

kernel_vaddr.vaddr_bitmap.btmp_bytes_len = kbm_length;
kernel_vaddr.vaddr_bitmap.bits = (void*)(MEM_BITMAP_BASE + kbm_length + ubm_length);

kernel_vaddr.vaddr_start = K_HEAP_START;
bitmap_init(&kernel_vaddr.vaddr_bitmap);
put_str(" mem_pool_init done\n");
}

void mem_init()
{
put_str("mem_init start\n");

// 物理内存的大小放在地址0xb00处
uint32_t mem_bytes_total = *((uint32_t*)0xb00);

mem_pool_init(mem_bytes_total);

put_str("mem_init done\n");
}

写入makefile文件,编译运行效果如下,我们还没有实现对任意内存申请的函数,这里只是先将内存池进行了初始化,内核物理内存池所用的位图地址在0xc009a000,内存池中第一块物理页地址是0x200000

image-20200526104424200

接下来就是实现对内存的分配,首先复习一下32位虚拟地址的转换过程:

  1. 高 10 位是页目录项 pde 的索引,用于在页目录表中定位 pde ,细节是处理器获取高 10 位后自动将其乘以 4,再加上页目录表的物理地址,这样便得到了 pde 索引对应的 pde 所在的物理地址,然后自动在该物理地址中,即该 pde 中,获取保存的页表物理地址。
  2. 中间 10 位是页表项 pte 索引,用于在页表中定位 pte 。细节是处理器获取中间 10 位后自动将其乘以 4,再加上第一步中得到的页表的物理地址,这样便得到了 pte 索引对应的 pte 所在的物理地址,然后自动在该物理地址 (该 pte) 中获取保存的普通物理页的物理地址。
  3. 低 12 位是物理页内的偏移 ,页大小是 4KB, 12 位可寻址的范围正好是 4KB,因此处理器便直接把低 12 位作为第二步中获取的物理页的偏移量,无需乘以 4。用物理页的物理地址加上这低 12 位的和便是这 32 位虚拟地址最终落向的物理地址。

比如访问虚拟地址0x00c03123,拆分步骤如下

1
2
3
4
0x00c03123 => 16进制
0000 0000 1100 0000 0011 0001 0010 0011 => 2进制
0000000011 0000000011 000100100011 => 重新组合为 10+10+12
pde 3 pte 3 偏移 123

整个过程如下图所示

image-20200526104424200

32位地址在上面转换之后则落向物理地址,内存分配的过程:

  1. 在虚拟内存池中申请n个虚拟页
  2. 在物理内存池中分配物理页
  3. 在页表中添加虚拟地址与物理地址的映射关系

接下来就是一步一步在memory文件中增加函数

在虚拟内存池中申请n个虚拟页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 在pf表示的虚拟内存池中申请pg_cnt个虚拟页,
* 成功则返回虚拟页的起始地址, 失败则返回NULL */
static void* vaddr_get(enum pool_flags pf, uint32_t pg_cnt) {
int vaddr_start = 0, bit_idx_start = -1;
uint32_t cnt = 0;
if (pf == PF_KERNEL) { //若为内核内存池
bit_idx_start = bitmap_scan(&kernel_vaddr.vaddr_bitmap, pg_cnt); // 扫描虚拟地址池
if (bit_idx_start == -1) { // 返回-1则退出
return NULL;
}
while(cnt < pg_cnt) {
bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 1); // 循环逐位置一
}
vaddr_start = kernel_vaddr.vaddr_start + bit_idx_start * PG_SIZE; // 将bit_idx_start转换为虚拟地址
} else {
// 用户内存池,将来实现用户进程再补充
}
return (void*)vaddr_start; // 返回指针
}

在物理内存池中分配物理页

这个函数比较关键,主要是对位图的扫描和记录,然后根据位图索引返回分配的物理地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在m_pool指向的物理内存池中分配一个物理页
static void *palloc(struct pool *m_pool)
{
int bit_idx = bitmap_scan(&m_pool->pool_bitmap, 1);
if(bit_idx == -1)
{
return NULL;
}

bitmap_set(&m_pool->pool_bitmap, bit_idx, 1);
uint32_t page_phyaddr = bit_idx * PG_SIZE + m_pool->phy_addr_start;

return (void*)page_phyaddr;
}

在页表中添加虚拟地址与物理地址的映射关系

再次复习一下32位虚拟地址到物理地址的转换,我们后面实现pde和pte访问就是用的这个原理

  1. 首先通过高10位的pde索引,找到页表的物理地址
  2. 其次通过中间10位的pte索引,得到物理页的物理地址
  3. 最后把低12位作为物理页的页内偏移,加上物理页的物理地址,即为最终的物理地址

下面是通过虚拟地址访问pte和pde的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 得到虚拟地址vaddr对应的pte指针*/
uint32_t* pte_ptr(uint32_t vaddr) {
/* 先访问到页表自己 + \
* 再用页目录项pde(页目录内页表的索引)做为pte的索引访问到页表 + \
* 再用pte的索引做为页内偏移*/
uint32_t* pte = (uint32_t*)(0xffc00000 + \ // 最后一个页目录项保存的是页目录表物理地址,高十位指向最后一个页目录表项
// 也就是第1023个pde,换算成十进制就是0x3ff再移到高10位就是0xffc00000
((vaddr & 0xffc00000) >> 10) + \
PTE_IDX(vaddr) * 4);
return pte;
}

/* 得到虚拟地址vaddr对应的pde的指针 */
uint32_t* pde_ptr(uint32_t vaddr) {
/* 0xfffff是用来访问到页表本身所在的地址 */
uint32_t* pde = (uint32_t*)((0xfffff000) + PDE_IDX(vaddr) * 4);
return pde;
}

m_pool处申请物理页的函数

1
2
3
4
5
6
7
8
9
10
11
12
/* 在m_pool指向的物理内存池中分配1个物理页,
* 成功则返回页框的物理地址,失败则返回NULL */
static void* palloc(struct pool* m_pool) {
/* 扫描或设置位图要保证原子操作 */
int bit_idx = bitmap_scan(&m_pool->pool_bitmap, 1); // 找一个物理页面
if (bit_idx == -1 ) {
return NULL;
}
bitmap_set(&m_pool->pool_bitmap, bit_idx, 1); // 将此位bit_idx置1
uint32_t page_phyaddr = ((bit_idx * PG_SIZE) + m_pool->phy_addr_start); // page_phyaddr用于保存分配的物理页地址
return (void*)page_phyaddr;
}

添加虚拟地址与物理地址的映射函数

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
/* 页表中添加虚拟地址_vaddr与物理地址_page_phyaddr的映射 */
static void page_table_add(void* _vaddr, void* _page_phyaddr) {
uint32_t vaddr = (uint32_t)_vaddr, page_phyaddr = (uint32_t)_page_phyaddr;
uint32_t* pde = pde_ptr(vaddr);
uint32_t* pte = pte_ptr(vaddr);

/************************ 注意 *************************
* 执行*pte,会访问到空的pde。所以确保pde创建完成后才能执行*pte,
* 否则会引发page_fault。因此在*pde为0时,*pte只能出现在下面else语句块中的*pde后面。
* *********************************************************/
/* 先在页目录内判断目录项的P位,若为1,则表示该表已存在 */
if (*pde & 0x00000001) { // 页目录项和页表项的第0位为P,此处判断目录项是否存在
ASSERT(!(*pte & 0x00000001));

if (!(*pte & 0x00000001)) { // 只要是创建页表,pte就应该不存在,多判断一下放心
*pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1); // US=1,RW=1,P=1
} else { //应该不会执行到这,因为上面的ASSERT会先执行。
PANIC("pte repeat");
*pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1); // US=1,RW=1,P=1
}
} else { // 页目录项不存在,所以要先创建页目录再创建页表项.
/* 页表中用到的页框一律从内核空间分配 */
uint32_t pde_phyaddr = (uint32_t)palloc(&kernel_pool);

*pde = (pde_phyaddr | PG_US_U | PG_RW_W | PG_P_1);

/* 分配到的物理页地址pde_phyaddr对应的物理内存清0,
* 避免里面的陈旧数据变成了页表项,从而让页表混乱.
* 访问到pde对应的物理地址,用pte取高20位便可.
* 因为pte是基于该pde对应的物理地址内再寻址,
* 把低12位置0便是该pde对应的物理页的起始*/
memset((void*)((int)pte & 0xfffff000), 0, PG_SIZE);

ASSERT(!(*pte & 0x00000001));
*pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1); // US=1,RW=1,P=1
}
}

malloc_page函数负责申请虚拟地址并分配物理地址、建立映射,大致步骤如下

  1. 通过vaddr_get在虚拟内存池中申请虚拟地址
  2. 通过palloc在物理内存池中申请物理页
  3. 通过page_table_add将以上两步得到的结果在页表中映射
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
/* 分配pg_cnt个页空间,成功则返回起始虚拟地址,失败时返回NULL */
void* malloc_page(enum pool_flags pf, uint32_t pg_cnt) {
ASSERT(pg_cnt > 0 && pg_cnt < 3840); //15MB来限制,pg_cnt < 15*1024*1024/4096 = 3840页
/*********** malloc_page的原理是三个动作的合成: ***********
1通过vaddr_get在虚拟内存池中申请虚拟地址
2通过palloc在物理内存池中申请物理页
3通过page_table_add将以上得到的虚拟地址和物理地址在页表中完成映射
***************************************************************/
void* vaddr_start = vaddr_get(pf, pg_cnt);
if (vaddr_start == NULL) {
return NULL;
}

uint32_t vaddr = (uint32_t)vaddr_start, cnt = pg_cnt;
struct pool* mem_pool = pf & PF_KERNEL ? &kernel_pool : &user_pool; // 内核池还是用户池

/* 因为虚拟地址是连续的,但物理地址可以是不连续的,所以逐个做映射*/
while (cnt-- > 0) {
void* page_phyaddr = palloc(mem_pool);
if (page_phyaddr == NULL) { // 失败时要将曾经已申请的虚拟地址和物理页全部回滚,在将来完成内存回收时再补充
return NULL;
}
page_table_add((void*)vaddr, page_phyaddr); // 在页表中做映射
vaddr += PG_SIZE; // 下一个虚拟页
}
return vaddr_start;
}

最后一个函数负责在物理内存池中申请pg_cnt页内存

1
2
3
4
5
6
7
8
/* 从内核物理内存池中申请pg_cnt页内存,成功则返回其虚拟地址,失败则返回NULL */
void* get_kernel_pages(uint32_t pg_cnt) {
void* vaddr = malloc_page(PF_KERNEL, pg_cnt);
if (vaddr != NULL) { // 若分配的地址不为空,将页框清0后返回
memset(vaddr, 0, pg_cnt * PG_SIZE);
}
return vaddr;
}

最后我们在main.c中添加测试代码,申请三个页并打印其虚拟地址

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "print.h"
#include "init.h"
#include "memory.h"
int main(void) {
put_str("Welcome to TJ's kernel\n");
init_all();
void* addr = get_kernel_pages(3);
put_str("\n get_kernel_page start vaddr is ");
put_int((uint32_t)addr);
put_str("\n");
while(1);
return 0;
}

运行效果如下,期中最上面的红框表示虚拟地址起始地址,对照第二个红框的对应关系,第三个红框中为7是因为我们申请了三个页,第三位都为1,位图的变化和预期相符合。

image-20200526104424200