第12章-进一步完善内核
12.1 Linux系统调用浅析
系统调用就是让用户进程申请操作系统的帮助,让操作系统帮其完成某项工作,也就是相当于用户进程调用了操作系统的功能,因此"系统调用"准确地来说应该被称为"操作系统功能调用"。
Linux 系统调用是用中断门来实现的,通过软中断指令 int 来主动发起中断信号Linux 只占用一个中断向量号,即 0x80 ,处理器执行指令 int 0x80 时便触发了系统调用。为了让用户程序可以通过这一个中断门调用多种系统功能,在系统调用之前, Linux 在寄存器 eax中写入子功能号,例如系统调用 open 和 close 都是不同的子功能号,当用户程序通过int 0x80 进行系统调用时,对应的中断处理例程会根据eax的值来判断用户进程申请哪种系统调用。
12.2 系统调用实现
上节已经跟大伙儿介绍了 Linux 系统调用的部分实现,本节以它为范本,参照着实现自己的系统调用。
12.2.1 系统调用实现框架
一个系统功能调用分为两部分, 一部分是暴露给用户进程的接口函数,它属于用户空间,此部分只是用户进程使用系统调用的途径,只负责发需求。另一部分是与之对应的内核具体实现,它属于内核空间,此部分完成的是功能需求,就是我们一直所说的系统调用子功能处理函数。为区分这两部分,一般情况下内核空间的函数名要在用户空间函数名前加" sys_"
梳理下咱们系统调用的实现思路:
- 用中断门实现系统调用,效仿 Linux 用 0x80 号中断作为系统调用的入口
- 在 IDT 中安装 0x80 号中断对应的描述符,在该描述符中注册系统调用对应的中断处理例程。
- 建立系统调用子功能表 syscall_table ,利用 eax寄存器中的子功能号在该表中索引相应的处理函数。
- 用宏实现用户空 间系统调用接口_syscall ,最大支持 3 个参数的系统调用,故只 需要完成_syscall[0-3]。寄存器传递参数, eax为子功能号, ebx保存第 1 个参数, ecx 保存第 2 个参数, edx 保存第 3 个参数 。
12.2.2 增加0x80号中断
c
//interrupt.c
#define IDT_DESC_CNT 0x81 //目前总共支持的中断数
......
extern uint32_t syscall_handler(void);
......
/*初始化中断描述符表*/
static void idt_desc_init(void){
int i,last = IDT_DESC_CNT-1;
for(i=0;i<IDT_DESC_CNT;i++){
make_idt_desc(&idtr[i],IDT_DESC_ATTR_DPL0,inter_entry_table[i]);
}
/*单独处理系统调用,系统调用对应的中断门dpl为3,中断处理程序为单独的 syscall_handler */
make_idt_desc(&idtr[last],IDT_DESC_ATTR_DPL3,syscall_handler);
put_str(" idt_desc_init done\n");
}
12.2.3 实现系统调用
c
/*
* @Author: Adward-DYX 1654783946@qq.com
* @Date: 2024-04-24 09:37:00
* @LastEditors: Adward-DYX 1654783946@qq.com
* @LastEditTime: 2024-04-24 09:41:22
* @FilePath: /OS/chapter12/12.2/lib/user/syscall.c
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
#include "syscall.h"
/*无参数的系统调用*/
#define _syscall0(NUMBER) ({ \
int retval; \
asm volatile( \
"int $0x80" \
: "=a"(retval) \
: "a"(NUMBER) \
: "memory" \
); \
retval; \
})
/*一个参数的系统调用*/
#define _syscall1(NUMBER, ARG1) ({ \
int retval; \
asm volatile( \
"int $0x80" \
: "=a"(retval) \
: "a"(NUMBER), "b"(ARG1) \
: "memory" \
); \
retval; \
})
/*两个参数的系统调用*/
#define _syscall2(NUMBER, ARG1, ARG2) ({ \
int retval; \
asm volatile( \
"int $0x80" \
: "=a"(retval) \
: "a"(NUMBER), "b"(ARG1), "c"(ARG2) \
: "memory" \
); \
retval; \
})
/*三个参数的系统调用*/
#define _syscall3(NUMBER, ARG1, ARG2, ARG3) ({ \
int retval; \
asm volatile( \
"int $0x80" \
: "=a"(retval) \
: "a"(NUMBER), "b"(ARG1), "c"(ARG2), "d"(ARG3) \
: "memory" \
); \
retval; \
})
12.2.4 增加0x80号中断处理例程
asm
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0x80号中断;;;;;;;;;;;;;;;;;;;;;;;;;;
[bits 32]
extern syscall_table
section .text
global syscall_handler
syscall_handler:
;1. 保存上下文
push 0 ;为了统一栈中的格式,就手动压入一个0
push ds
push es
push fs
push gs
pushad ;PUSHAD指令压入32位寄存器,其入栈顺序是:EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI最先入栈
push 0x80 ;此位置压入0x80也是为了保持统一的栈格式
;2. 为系统调用子功能传入参数
push edx ;系统调用中第3个参数
push ecx ;系统调用中第2个参数
push ebx ;系统调用中第1个参数
;3. 调佣子功能处理函数
call [syscall_table + eax*4]
add esp, 12 ;跨域过上面三个参数
;4. 将call调用后的返回值存入当前内核栈的eax位置
mov [esp+8*4], eax
jmp intr_exit
12.2.5 初始化系统调用和实现sys_getpid
c
/*
* @Author: Adward-DYX 1654783946@qq.com
* @Date: 2024-04-24 10:15:41
* @LastEditors: Adward-DYX 1654783946@qq.com
* @LastEditTime: 2024-04-24 10:24:05
* @FilePath: /OS/chapter12/12.2/userprog/syscall-init.c
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
#include "stdint.h"
#include "global.h"
#include "string.h"
#include "print.h"
#include "thread.h"
#include "syscall.h"
#define syscall_nr 32
typedef void* syscall;
syscall syscall_table[syscall_nr];
/*返回当前任务的pid*/
uint32_t sys_getpid(void){
return running_thread().pid;
}
/*初始化系统调用*/
void syscall_init(void){
put_str("syscall_init start\n");
syscall_table[SYS_GETPID] = sys_getpid;
put_str("syscall_init done\n");
}
我们先在进程/线程的pcb中添加pid成员
c
//thread.h
typedef int16_t pid_t;
......
struct task_struct{
uint32_t* self_kstack; //各内核线程都用自己的内核栈
pid_t pid;
enum task_status status;
......
pcb有了这个pid这个成员,那么自然我们创建进程/线程的时候要去为这个成员赋值
c
//thread.c
struct lock pid_lock; //pid锁
.......
/*分配PID*/
static pid_t allocate_pid(void){
static pid_t next_pid = 0;
lock_acquire(&pid_lock);
next_pid++;
lock_release(&pid_lock);
return next_pid;
}
......
/*初始化线程基本信息*/
void init_thread(struct task_struct* pthread, char* name, int prio){
memset(pthread,0,sizeof(*pthread));
pthread->pid = allocate_pid();
strcpy(pthread->name,name);
......
/*初始化线程环境*/
void thread_init(){
put_str("thread_init start\n");
list_init(&thread_read_list);
list_init(&thread_all_list);
lock_init(&pid_lock);
/*将当前main函数创建为线程*/
make_main_thread();
put_str("thread_init end\n");
}
12.2.6 添加系统调用getpid
c
//syscall.h
#ifndef __USER_SYSCALL_H
#define __USER_SYSCALL_H
#include "stdint.h"
enum SYSCALL_NR{
SYS_GETPID
};
uint32_t getpid(void);
#endif // !__USER_SYSCALL_H
c
//syscall.c
......
/*返回当前任务的pid*/
uint32_t getpid(){
return _syscall0(SYS_GETPID);
}
总结下增加系统调用的步骤:
- 在 syscall.h 中的结构 enum SYSCALL_NR 里添加新的子功能号。
- 在 syscall.c 中增加系统调用的用户接口。
- 在 syscall-init.c 中定义子功能处理函数井在 syscall_table 中注册 。
12.2.7 在用户进程中的系统调用
c
#include "print.h"
#include "init.h"
#include "thread.h"
#include "interrupt.h"
#include "console.h"
#include "process.h"
#include "syscall-init.h"
#include "syscall.h"
void k_thread_a(void*);
void k_thread_b(void*);
void u_prog_a(void);
void u_prog_b(void);
int prog_a_pid = 0, prog_b_pid = 0;
int main(void) {
put_str("I am kernel\n");
init_all();
process_execute(u_prog_a, "user_prog_a");
process_execute(u_prog_b, "user_prog_b");
intr_enable();
console_put_str(" main_pid:0x");
console_put_int(sys_getpid());
console_put_char('\n');
thread_start("k_thread_a", 31, k_thread_a, "argA ");
thread_start("k_thread_b", 31, k_thread_b, "argB ");
while(1);
return 0;
}
/* 在线程中运行的函数 */
void k_thread_a(void* arg) {
char* para = arg;
console_put_str(" thread_a_pid:0x");
console_put_int(sys_getpid());
console_put_char('\n');
console_put_str(" prog_a_pid:0x");
console_put_int(prog_a_pid);
console_put_char('\n');
while(1);
}
/* 在线程中运行的函数 */
void k_thread_b(void* arg) {
char* para = arg;
console_put_str(" thread_b_pid:0x");
console_put_int(sys_getpid());
console_put_char('\n');
console_put_str(" prog_b_pid:0x");
console_put_int(prog_b_pid);
console_put_char('\n');
while(1);
}
/* 测试用户进程 */
void u_prog_a(void) {
prog_a_pid = getpid();
while(1);
}
/* 测试用户进程 */
void u_prog_b(void) {
prog_b_pid = getpid();
while(1);
}
一定要区分作为实际系统调用处理函数的sys_getpid
与作为用户程序入口的getpid
,前者是运行在内核态的,后者是用户态程序的入口去执行int 0x80
的。
12.2.6 系统调用之栈传递参数
c
/* 无参数的系统调用 */
#define _syscall0(NUMBER) ({ \
int retval; \
asm volatile ( \
"pushl %[number]; int $0x80; addl $4, %%esp" \
: "=a" (retval) \
: [number] "i" (NUMBER) \
: "memory" \
); \
retval; \
})
/* 一个参数的系统调用 */
#define _syscall1(NUMBER, ARG0) ({ \
int retval; \
asm volatile ( \
"pushl %[arg0]; pushl %[number]; int $0x80; addl $8, %%esp" \
: "=a" (retval) \
: [number] "i" (NUMBER), [arg0] "g" (ARG0) \
: "memory" \
); \
retval; \
})
/* 两个参数的系统调用 */
#define _syscall2(NUMBER, ARG0, ARG1) ({ \
int retval; \
asm volatile ( \
"pushl %[arg1]; pushl %[arg0]; " \
"pushl %[number]; int $0x80; addl $12, %%esp" \
: "=a" (retval) \
: [number] "i" (NUMBER), \
[arg0] "g" (ARG0), \
[arg1] "g" (ARG1) \
: "memory" \
); \
retval; \
})
/* 三个参数的系统调用 */
#define _syscall3(NUMBER, ARG0, ARG1, ARG2) ({ \
int retval; \
asm volatile ( \
"pushl %[arg2]; pushl %[arg1]; pushl %[arg0]; " \
"pushl %[number]; int $0x80; addl $16, %%esp" \
: "=a" (retval) \
: [number] "i" (NUMBER), \
[arg0] "g" (ARG0), \
[arg1] "g" (ARG1), \
[arg2] "g" (ARG2) \
: "memory" \
); \
retval; \
})
asm
;;;;;;;;;;;;;;;; 0x80号中断 ;;;;;;;;;;;;;;;;
[bits 32]
extern syscall_table ;如同之前我们中断处理机制中引入了C中定义的中断处理程序入口地址表一样,这里引入了C中定义的系统调用函数入口地址表
section .text
global syscall_handler
syscall_handler:
;1 保存上下文环境,为了复用之前写好的intr_exit:,所以我们仿照中断处理机制压入的东西,构建系统调用压入的东西
push 0 ; 压入0, 使栈中格式统一
push ds
push es
push fs
push gs
pushad ; PUSHAD指令压入32位寄存器,其入栈顺序是:EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI
push 0x80 ; 此位置压入0x80也是为了保持统一的栈格式
;2 从内核栈中获取cpu自动压入的用户栈指针esp的值
mov ebx, [esp + 4 + 48 + 4 + 12]
; 为系统调用子功能传入参数,由于这个函数是3个参数的用户程序系统调用入口都会使用
; 所以我们为了格式统一,直接按照最高参数数量压入3个参数, 此时ebx是用户栈指针
push dword [ebx + 12] ; 系统调用中第3个参数
push dword [ebx + 8] ; 系统调用中第2个参数
push dword [ebx + 4] ; 系统调用中第1个参数
mov edx, [ebx] ; 系统调用的子功能号
;3 调用c中定义的功能处理函数
call [syscall_table + edx*4] ; 编译器会在栈中根据C函数声明匹配正确数量的参数
add esp, 12 ; 跨过上面的三个参数
;4 将call调用后的返回值存入待当前内核栈中eax的位置,c语言会自动把返回值放入eax中(c语言的ABI规定)
mov [esp + 8*4], eax
jmp intr_exit ; intr_exit返回,恢复上下文
mov ebx, [esp + 4 + 48 + 4 + 12]
此时内核栈的栈顶位置 + 4 是跳过了压入的0x80,+ 48 是跳过 push ad 与 gs fs es ds,+ 4 是跳过了 push 0,最后 + 12是因为用户程序调用int 0x80触发软中断,然后导致特权级切换,cpu会自动向内核栈中按照顺序压入此时的用户程序执行时的ss, esp, eflag, cs, eip,跳过12字节(ss = 2, esp = 4, eflag = 4, cs = 2)就是此时用户栈的栈顶位置eip
12.3 让用户进程"说话"
12.3.1 可变参数的原理
操作系统只会在加载程序时为其分配内存,而且只分配这一次,我们把程序本身占用的内存称为静态内存 。我们把程序运行过程中额外需求的内存称为动态内存。
随着计算机的进步,操作系统开始支持堆内存管理 ,堆内存专门用于程序运行时的内存申请,因此编译器也开始支持程序在运行时动态内存申请 ,也就是编译器开始支持源码中的变长数据结构。程序中的数据结构终归有个长度,此长度要么在编译时确定,要么在运行时确定。编译时确定是指数据结构在源码编译阶段就能确定下来,说白了就是编译器必须提前知道数据结构的长度,它为此类数据结构分配的是静态内存,也就是程序被操作系统加载时分配的内存 。
函数占用的也是静态内存,因此也得提前告诉编译器自己占用的内存大小,编译器要求提供函数声明 ,声明中描述了函数参数的个数及类型,编译器用它们来计算参数所占据的栈空间 。函数并不是在堆中分配内存,因此它需要提前确定内存空间,这通常取决于参数的个数及类型大小,但编译器却允许函数的参数个数不固定(可变参数)。
既然参数是由调用者压入(c调用约定)的,调用者当然知道战中压入了几个参数,参数占用了多少空间,因此无论函数的参数个数是否固定,采用 C 调用约定,调用者都能完好地回收技空间,不必担心栈溢出等问题 。
为方便引用函数中的可变参数,编译器 gcc
的头文件 stdarg.h
中定义了 3 个宏 ,是与可变参数相关的宏:这 3 个宏v_start
、 va_end
和 va_arg
都以 va开头,表示可变参数。
v_start(ap,v)
:参数ap
是用于指向可变参数的指针变量,参数 v 是支持可变参数的函数的第 1 个参数(如对于printf
来说,参数 v 就是宇符串format
)此宏的功能是使指针ap
指向 v 的地址,它的调用必须先于其他两个宏,相当于初始化ap
指针的作用。va_arg(ap,t)
:参数ap
是用于指向可变参数的指针变量,参数 t 是可变参数的类型此宏的功能是使指针ap
指向战中下一个参数的地址并返回其值。va_end(ap)
:将指向可变参数的变量ap
置为null
,也就是清空指针变量ap
。
12.3.2 实现系统调用write
printf
函数是"格式化""输出"函数,将格式化后的信息输出到标准输出(通常是屏幕)。但它只是个外壳,真正起到"格式化"作用的是 vsprintf
函数,真正起"输出"作用的是 write
系统调用。
c
//syscall-init.c
/*打印字符串str(未实现文件系统前的版本)*/
uint32_t sys_write(char* str){
console_put_str(str);
return strlen(len);
}
/* 初始化系统调用 */
void syscall_init(void) {
put_str("syscall_init start\n");
syscall_table[SYS_GETPID] = sys_getpid;
syscall_table[SYS_WRITE] = sys_write;
put_str("syscall_init done\n");
}
c
//syscall.c
/*打印字符串str*/
uint32_t write(char* str){
return _syscall(SYS_WRITE,str);
}
12.3.3 实现printf
printf 是我们 C 语言标准输出函数,其原型是: int printf(const char *format,...);
其中format就是格式化字符串,里面包含"%类型的字符串"
c
#include "stdio.h"
#include "interrupt.h"
#include "global.h"
#include "string.h"
#include "syscall.h"
#include "print.h"
#define va_start(ap, v) ap = (va_list)&v // 把ap指向第一个固定参数v
#define va_arg(ap, t) *((t*)(ap += 4)) // ap指向下一个参数并返回其值
#define va_end(ap) ap = NULL // 清除ap
/* 将整型转换成字符(integer to ascii) */
static void itoa(uint32_t value, char** buf_ptr_addr, uint8_t base) {
uint32_t m = value % base; // 求模,最先掉下来的是最低位
uint32_t i = value / base; // 取整
if (i) { // 如果倍数不为0则递归调用。
itoa(i, buf_ptr_addr, base);
}
if (m < 10) { // 如果余数是0~9
*((*buf_ptr_addr)++) = m + '0'; // 将数字0~9转换为字符'0'~'9'
} else { // 否则余数是A~F
*((*buf_ptr_addr)++) = m - 10 + 'A'; // 将数字A~F转换为字符'A'~'F'
}
}
/* 将参数ap按照格式format输出到字符串str,并返回替换后str长度 */
uint32_t vsprintf(char* str, const char* format, va_list ap) {
char* buf_ptr = str;
const char* index_ptr = format;
char index_char = *index_ptr;
int32_t arg_int;
char* arg_str;
while(index_char) {
if (index_char != '%') {
*(buf_ptr++) = index_char;
index_char = *(++index_ptr);
continue;
}
index_char = *(++index_ptr); // 得到%后面的字符
switch(index_char) {
case 'x':
arg_int = va_arg(ap, int);
itoa(arg_int, &buf_ptr, 16);
index_char = *(++index_ptr); // 跳过格式字符并更新index_char
break;
}
}
return strlen(str);
}
/* 格式化输出字符串format */
uint32_t printf(const char* format, ...) {
va_list args;
va_start(args, format); // 使args指向format
char buf[1024] = {0}; // 用于存储拼接后的字符串
vsprintf(buf, format, args);
va_end(args);
return write(buf);
}
由于现在我们开启了页表机制,任何地址都将视作虚拟地址。我们之前编写print.S时,由于是给内核用的,所以用于与显存段打交道的地址有些是借助于内核页目录表0号项进行寻址的,现在我们将print共享给了用户进程,而用户进程无法去访问内核页目录表0号项。但是由于进程页目录表768号项与内核页目录表0号项指向同一张内核的页表(因为进程页目录表768号项就是拷贝的内核页目录表768号项)。所以我们能通过进程页目录表768号项访问原来通过内核页目录表0号项访问的地址。所以,我们需要修改print.S中的一些地址访问,将其升高3G,这样才能让原本通过内核页目录表0号项访问的地址,现在能通过进程页目录表768号项访问。
原文链接:https://blog.csdn.net/kanshanxd/article/details/131697078
asm
.roll_screen: ; 若超出屏幕大小,开始滚屏
cld
mov ecx, 960 ; 一共有2000-80=1920个字符要搬运,共1920*2=3840字节.一次搬4字节,共3840/4=960次
;mov esi, 0xb80a0
mov esi, 0xc00b80a0 ; 第1行行首
;mov edi, 0xb8000
mov edi, 0xc00b8000 ; 第0行行首
rep movsd ;rep movs word ptr es:[edi], word ptr ds:[esi] 简写为: rep movsw
修改Makefile为新增加的文件增加编译规则,编译之后,会出现如下错误:
shell
ld: build/stdio.o: in function `printf':
stdio.c:(.text+0x1ab): undefined reference to `__stack_chk_fail'
make: *** [makefile:105: build/kernel.bin] Error 1
这个错误是因为编译器在使用栈保护特性,但链接器找不到实现这一特性所需的函数__stack_chk_fail。栈保护是一种安全特性,可以检测到栈溢出。通常情况下,__stack_chk_fail函数是由编译器自动插入到代码中的,用于在检测到栈溢出时中止程序。这个函数通常包含在C库中,由于我们链接的程序没有链接C库,就会看到这个错误。
解决这个问题的一个办法是禁用栈保护特性。在编译代码时添加-fno-stack-protector选项来做到这一点。
原文链接:https://blog.csdn.net/kanshanxd/article/details/131697078
12.3.4 进一步完善printf
c
#include "stdio.h"
#include "interrupt.h"
#include "global.h"
#include "string.h"
#include "syscall.h"
#include "print.h"
#define va_start(ap, v) ap = (va_list)&v // 把ap指向第一个固定参数v
#define va_arg(ap, t) *((t*)(ap += 4)) // ap指向下一个参数并返回其值
#define va_end(ap) ap = NULL // 清除ap
/* 将整型转换成字符(integer to ascii) */
static void itoa(uint32_t value, char** buf_ptr_addr, uint8_t base) {
uint32_t m = value % base; // 求模,最先掉下来的是最低位
uint32_t i = value / base; // 取整
if (i) { // 如果倍数不为0则递归调用。
itoa(i, buf_ptr_addr, base);
}
if (m < 10) { // 如果余数是0~9
*((*buf_ptr_addr)++) = m + '0'; // 将数字0~9转换为字符'0'~'9'
} else { // 否则余数是A~F
*((*buf_ptr_addr)++) = m - 10 + 'A'; // 将数字A~F转换为字符'A'~'F'
}
}
/* 将参数ap按照格式format输出到字符串str,并返回替换后str长度 */
uint32_t vsprintf(char* str, const char* format, va_list ap) {
char* buf_ptr = str;
const char* index_ptr = format;
char index_char = *index_ptr;
int32_t arg_int;
char* arg_str;
while(index_char) {
if (index_char != '%') {
*(buf_ptr++) = index_char;
index_char = *(++index_ptr);
continue;
}
index_char = *(++index_ptr); // 得到%后面的字符
switch(index_char) {
case 's':
arg_str = va_arg(ap,char*);
strcpy(buf_ptr,arg_str);
buf_ptr+=strlen(arg_str);
index_char = *(++index_ptr);
break;
case 'c':
*(buf_ptr++) = va_arg(ap,char);
index_char = *(++index_ptr);
break;
case 'd':
arg_int = va_arg(ap,int);
/*若是负数,将其转换为正数后,在正数前面输出个负号'-'*/
if (arg_int<0)
{
arg_int = 0 - arg_int;
*buf_ptr++='-';
}
itoa(arg_int,&buf_ptr,10);
index_char = *(++index_ptr);
break;
case 'x':
arg_int = va_arg(ap, int);
itoa(arg_int, &buf_ptr, 16);
index_char = *(++index_ptr); // 跳过格式字符并更新index_char
break;
}
}
return strlen(str);
}
/* 同printf不同的地方就是字符串不是写到终端,而是写到buf中 */
uint32_t sprintf(char* buf, const char* format, ...) {
va_list args;
uint32_t retval;
va_start(args, format);
retval = vsprintf(buf, format, args);
va_end(args);
return retval;
}
/* 格式化输出字符串format */
uint32_t printf(const char* format, ...) {
va_list args;
va_start(args, format); // 使args指向format
char buf[1024] = {0}; // 用于存储拼接后的字符串
vsprintf(buf, format, args);
va_end(args);
return write(buf);
}
12.4 完善堆内存管理
12.4.1 malloc底层原理
之前我们虽然己经实现了内存管理,但显得过于粗糙,分配的内存都是以 4k大小的页框为单位的,当我们仅需要几十字节、几百字节这样的小内存块时,显然无法满足这样的需求了,为此必须实现一种小内存块的管理,可以满足任意内存大小的分配,这就是我们为实现 malloc
要做的基础工作。
arena
是很多开源项目中都会用到的内存管理概念,将一大块内存划分成多个小内存块,每个小内存块之间互不干涉,可以分别管理,这样众多的小内存块就称为arena。
arena
是个提供内在分配的数据结构,它分为两部分,一部分是元信息,用来描述自己内存池中空闲内存块数量,这其中包括内存块描述符指针(后面介绍),通过它可以间接获知本 arena 所包含内存块的规格大小,此部分占用的空间是固定的,约为 12 字节。另一部分就是内存地区域,这里面有无数的内存块,此部分占用 arena 大量的空间。我们把每个内存块命名为 mem_block,它们是内存分配粒度更细的资源,最终为用户分配的就是这其中的一个内存块。
当某一规arena中的内存块全部分配出去时,必须再增加新的同一规格的arena,由多个同一规格 的 arena 组合为一个"大的仓库",为同一规格的内存块提供货源。 既然同一类内存块可以由多个 arena 提供,为了跟踪每个arena 中的空闲内存块,分别为每一种规格的内存块建立一个内存块描述符,即 mem_block_desc,在其中记录内存块规格大小,以及位于所有同类 arena 中的空闲内存块链表.
我们为 arena 分配 1 页框也就是 4KB 大小的内存,我们已经介绍过了,每个 arena 都分为两部分,一部分是占用空间很少的元信息 ,除元信息外的剩余部分才用于内存块的划分,因此,真正用于内存块的部分不足 4kb。内存块是平均划分的,所以最大的内存块肯定要小于 2k,这里我们以 2 为底的指数方程来划分内存块,因此最大的内存块是1024 字节,也就是说对内存块规格为 1024 字节的 arena 来说,它只有 3 个内存块,每个都是 1024 字节,剩余的部分就浪费了。**咱们这里的内存块以 16 字节为起始,向上依次是 32 宇节、 64 字节、 128 字节、256字节、 512 字节、 1024 字节,总共 7 种规格,因此,内存块描述符也就这 7 种。**再次强调一下,每种 arena中只有一种规格的内存块,并不是同时包含多种规格
总结,在内存管理系统中, arena 为任意大小内存的分配提供了统一的接口,它既支持 1024 字节以下的小块内存的分配,又支持大于 1024 字节以上的大块内存**, malloc 函数实际上就是通过 arena 申请这些内存块**。arena 是个内存仓库,并不直接对外提供内存分配,只有内存块描述符才对外提供内存块,内存块描述符将同类 arena 中的空闲内存块汇聚到一起,作为某一规格内存块的分配入口。因此,内存块描述符与 arena 是一对多的关系,每个 arena 都要与唯一的内存块描述符关联起来,多个同一规格的 arena 为同一规格的内存块描述符供应内存块,它们各自的元信息中用内存块描述符指针指向同一个内存块描述符。
12.4.2 底层初始化
修改(OS/kernel/memory.h)
c
#include "list.h"
/* 内存块 */
struct mem_block {
struct list_elem free_elem;
};
/* 内存块描述符 */
struct mem_block_desc {
uint32_t block_size; // 内存块大小
uint32_t blocks_per_arena; // 本arena中可容纳此mem_block的数量.
struct list free_list; // 目前可用的mem_block链表
};
#define DESC_CNT 7 // 内存块描述符个数
修改(OS/kernel/memory.c)
c
/* 内存仓库arena元信息 */
struct arena {
struct mem_block_desc* desc; // 此arena关联的mem_block_desc
uint32_t cnt;
bool large; /* large为ture时,cnt表示的是页框数。否则cnt表示空闲mem_block数量 */
};
struct mem_block_desc
、struct mem_block
、struct arena
的关系:
struct mem_block_desc
描述了不同类型的小块,比如:4KB页面划分成不同的小块,如256个16B小块、8个512B的小块。512B的小块对应一个mem_block_desc
,而16B的小块对应另一个。block_size
就是记录这个mem_block_desc
用于描述哪种大小的小内存块,比如512或者16。blocks_per_arena
用于记录一个页面拆分成了多少个小块,比如8个或者256个。free_list
用于管理可以分配的小块,也就是用于将可以分配的小块形成链表。
struct mem_block
其实本意是用来描述这个由4KB页面二次划分而成的固定小块,但是作者为了实现更通用的管理逻辑,所以这个结构体里面只包含了一个用于管理这个空闲小块的链表节点。
struct arena
用于描述这个arena
,desc
用于指向这个管理这种arena
的mem_block_desc
结构体,cnt
的值意义取决于large
的值,如果large = true
,那么表示本arena
占用的页框数目,否则表示本arena
中还有多少空闲小内存块可用。需要注意的是,一个mem_block_desc
对应的arena
数量可不止一个,其实很好理解,当一个arena
的小内存块分配完毕,我们就要再分配一个新的页充当arena
然后划分成固定大小的小块。
修改(OS/kernel/memory.c)
c
struct mem_block_desc k_block_descs[DESC_CNT]; // 内核内存块描述符数组
//初始化管理不同种类型arena的不同mem_block_desc
void block_desc_init(struct mem_block_desc* desc_array) {
uint16_t desc_idx, block_size = 16;
for (desc_idx = 0; desc_idx < DESC_CNT; desc_idx++) {
desc_array[desc_idx].block_size = block_size;
desc_array[desc_idx].blocks_per_arena = (PG_SIZE - sizeof(struct arena)) / block_size;
list_init(&desc_array[desc_idx].free_list);
block_size *= 2; // 更新为下一个规格内存块
}
}
/* 内存管理部分初始化入口 */
void mem_init() {
put_str("mem_init start\n");
uint32_t mem_bytes_total = (*(uint32_t*)(0xb00));
mem_pool_init(mem_bytes_total); // 初始化内存池
block_desc_init(k_block_descs);
put_str("mem_init done\n");
}
内核有了管理不同类型arena的mem_block_desck
数组。我们说进程是独立分配资源的单位,进程拥有自己的独立虚拟地址空间 ,那么进程也应该拥有管理自己不同类型arena
的mem_block_desc
数组。这样进程分配内存,就去进程自己的mem_block_desc
数组中找对应的mem_block_desc
,然后通过free_list
找到空闲小块就行了。
修改(OS/thread/thread.h)为task_struct
添加u_block_desc
,这样每个task_struct
都有了这个mem_block_desc
数组
c
/*进程或线程的pcb,程序控制块*/
struct task_struct{
uint32_t* self_kstack; //各内核线程都用自己的内核栈
pid_t pid;
enum task_status status;
char name[16];
uint8_t priority; //线程优先级
uint8_t ticks; //每次在处理器上执行的时间滴答数
/*任务自上 cpu 运行后至今占用了多少 cpu 嘀嗒数,也就是此任务执行了多久**/
uint32_t elapsed_ticks;
/* general_tag的作用是用于线程在一般的队列中的结点*/
struct list_elem general_tag;
/*all_list_tag的作用是用于线程队列 thread_all_list 中的结点*/
struct list_elem all_list_tag;
uint32_t* pgdir; //进程自己的页表的虚拟地址
struct virtual_addr userprog_vaddr; //用户进程的虚拟地址
struct mem_block_desc u_block_desc[DESC_CNT]; //用户进程内存块描述符
uint32_t stack_magic; ///栈的边界标记,用于检测栈的溢出
};
但是,我们只初始化进程的mem_block_desc
数组
修改(OS/userprog/process.c)
c
/* 创建用户进程 */
void process_execute(void* filename, char* name) {
/* pcb内核的数据结构,由内核来维护进程信息,因此要在内核内存池中申请 */
struct task_struct* thread = get_kernel_pages(1);
init_thread(thread, name, default_prio);
create_user_vaddr_bitmap(thread);
thread_create(thread, start_process, filename);
thread->pgdir = create_page_dir();
block_desc_init(thread->u_block_desc); //创建mem_block_desc数组
enum intr_status old_status = intr_disable();
ASSERT(!elem_find(&thread_read_list, &thread->general_tag));
list_append(&thread_read_list, &thread->general_tag);
ASSERT(!elem_find(&thread_all_list, &thread->all_list_tag));
list_append(&thread_all_list, &thread->all_list_tag);
intr_set_status(old_status);
}
12.4.3 实现sys_malloc
c
#include "interrupt.h"
/* 返回arena中第idx个内存块的地址,原理是: 在 arena 指针指向的页框中,跳过元信息部分,即 struct arena 的大小,再用 idx 乘以该arena 中内存块大小,最终的地址便是arena 中第 idx 个内存块的首地址,最后将其转换成 mem_block 类型后返回 */
static struct mem_block* arena2block(struct arena* a, uint32_t idx) {
return (struct mem_block*)((uint32_t)a + sizeof(struct arena) + idx * a->desc->block_size);
}
/* 返回内存块b所在的arena地址 ,用于将 7 种规格的内存块转换为内存块所在的arena,由于此类内存块所在的 arena 占据 1 个完整的自然页框,所以arena 中的内存块都属于这1 页框之内*/
static struct arena* block2arena(struct mem_block* b) {
return (struct arena*)((uint32_t)b & 0xfffff000);
}
/* 在堆中申请size字节内存 */
void* sys_malloc(uint32_t size) {
enum pool_flags PF;
struct pool* mem_pool;
uint32_t pool_size;
struct mem_block_desc* descs; //用于存储mem_block_desc数组地址
struct task_struct* cur_thread = running_thread();
/* 判断用哪个内存池*/
if (cur_thread->pgdir == NULL) { // 若为内核线程
PF = PF_KERNEL;
pool_size = kernel_pool.pool_size;
mem_pool = &kernel_pool;
descs = k_block_descs;
}
else { // 用户进程pcb中的pgdir会在为其分配页表时创建
PF = PF_USER;
pool_size = user_pool.pool_size;
mem_pool = &user_pool;
descs = cur_thread->u_block_desc;
}
/* 若申请的内存不在内存池容量范围内则直接返回NULL */
if (!(size > 0 && size < pool_size)) {
return NULL;
}
struct arena* a;
struct mem_block* b;
lock_acquire(&mem_pool->lock);
/* 超过最大内存块1024, 就分配页框 */
if (size > 1024) {
uint32_t page_cnt = DIV_ROUND_UP(size + sizeof(struct arena), PG_SIZE); // 向上取整需要的页框数
a = malloc_page(PF, page_cnt);
if (a != NULL) {
memset(a, 0, page_cnt * PG_SIZE); // 将分配的内存清0
/* 对于分配的大块页框,将desc置为NULL, cnt置为页框数,large置为true */
a->desc = NULL;
a->cnt = page_cnt;
a->large = true;
lock_release(&mem_pool->lock);
//arena中可被用户使用的部分是内存池部分,也就是要跨过arena前面的元信息部分,用"(a+l)"跨过 arena 元信息,也就是跨过一个 struct arena 的大小
return (void*)(a + 1); // 跨过arena大小,把剩下的内存返回
}
else {
lock_release(&mem_pool->lock);
return NULL;
}
}
else { // 若申请的内存小于等于1024,可在各种规格的mem_block_desc中去适配
uint8_t desc_idx;
/* 从内存块描述符中匹配合适的内存块规格 */
for (desc_idx = 0; desc_idx < DESC_CNT; desc_idx++) {
if (size <= descs[desc_idx].block_size) { // 从小往大后,找到后退出
break;
}
}
/* 若mem_block_desc的free_list中已经没有可用的mem_block,
* 就创建新的arena提供mem_block */
if (list_empty(&descs[desc_idx].free_list)) {
a = malloc_page(PF, 1); // 分配1页框做为arena
if (a == NULL) {
lock_release(&mem_pool->lock);
return NULL;
}
memset(a, 0, PG_SIZE);
/* 对于分配的小块内存,将desc置为相应内存块描述符,
* cnt置为此arena可用的内存块数,large置为false */
a->desc = &descs[desc_idx];
a->large = false;
a->cnt = descs[desc_idx].blocks_per_arena;
uint32_t block_idx;
enum intr_status old_status = intr_disable();
/* 开始将arena拆分成内存块,并添加到内存块描述符的free_list中 */
for (block_idx = 0; block_idx < descs[desc_idx].blocks_per_arena; block_idx++) {
b = arena2block(a, block_idx);
ASSERT(!elem_find(&a->desc->free_list, &b->free_elem));
list_append(&a->desc->free_list, &b->free_elem);
}
intr_set_status(old_status);
}
/* 开始分配内存块 */
b = elem2entry(struct mem_block, free_elem, list_pop(&(descs[desc_idx].free_list)));
memset(b, 0, descs[desc_idx].block_size);
a = block2arena(b); // 获取内存块b所在的arena
a->cnt--; // 将此arena中的空闲内存块数减1
lock_release(&mem_pool->lock);
return (void*)b;
}
}
12.4.4 内存的释放
内存管理系统不仅能分配内存,还应该能回收内存,这是最基本的内存管理机制。 内存的使用情况都是通过位图来管理的,因此,无论内存的分配或释放,本质上都是在设置相关位图中的相应位,都是在读写位图 。 回收物理地址就是将物理内存池位图中的相应位清 0,无需将该 4kb物理页框逐宇节清 0。回收虚拟地址就是将虚拟内存池位图中的相应位清 0。分配则是相反的,也就是将位图中相应位置为1即可。
- 在虚拟地址池中分配虚拟地址,相关的函数是
vaddr_get
- 在物理内存池中分配物理地址,相关的函数是
palloc
- 在页表中完成虚拟地址到物理地址的映射,相关的函数是
page_table_add
释放内存是与分配内存相反的过程,咱们对照着设计一套释放内存的方法。
- 在物理地址池中释放物理页地址,相关的函数是
pfree
,操作的位图同palloc
。 - 在页表中去掉虚拟地址的映射,原理是将虚拟地址对应 pte 的 P 位置 0,相关的函数是
page_table_pte_remove
。 - 在虚拟地址池中释放虚拟地址,相关的函数是
vaddr_remove
,操作的位图同vaddr_get
。
只要把 pte 中的P位置为0就可以了,该位表示 pte 指向的物理页框的数据是否已在该物理页框中, CPU 只要检测到P位为0,就会认为该 pte 无效,根本不会关心 pte 所指向的物理页框的地址是否属于可访问的物理内存的范围 。总之,只要 pte 的P位为0, CPU 就认为该虚拟地址未做映射,从而达到删除虚拟地址的目的 。
c
//将物理地址pg_phy_addr回收到物理内存池,实质就是清除物理内存池中位图的位
void pfree(uint32_t pg_phy_addr) {
struct pool* mem_pool;
uint32_t bit_idx = 0;
if (pg_phy_addr >= user_pool.phy_addr_start) { // 用户物理内存池
mem_pool = &user_pool;
bit_idx = (pg_phy_addr - user_pool.phy_addr_start) / PG_SIZE;
}
else { // 内核物理内存池
mem_pool = &kernel_pool;
bit_idx = (pg_phy_addr - kernel_pool.phy_addr_start) / PG_SIZE;
}
bitmap_set(&mem_pool->pool_bitmap, bit_idx, 0); // 将位图中该位清0
}
/* 去掉页表中虚拟地址vaddr的映射,只去掉vaddr对应的pte */
static void page_table_pte_remove(uint32_t vaddr) {
uint32_t* pte = pte_ptr(vaddr);
*pte &= ~PG_P_1; // 将页表项pte的P位置0
asm volatile ("invlpg %0"::"m" (vaddr):"memory"); //更新tlb
}
//在虚拟地址池中释放以_vaddr起始的连续pg_cnt个虚拟页地址,实质就是清楚虚拟内存池位图的位
static void vaddr_remove(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt) {
uint32_t bit_idx_start = 0, vaddr = (uint32_t)_vaddr, cnt = 0;
if (pf == PF_KERNEL) { // 内核虚拟内存池
bit_idx_start = (vaddr - kernel_vaddr.vaddr_start) / PG_SIZE;
while(cnt < pg_cnt) {
bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 0);
}
}
else { // 用户虚拟内存池
struct task_struct* cur_thread = running_thread();
bit_idx_start = (vaddr - cur_thread->userprog_vaddr.vaddr_start) / PG_SIZE;
while(cnt < pg_cnt) {
bitmap_set(&cur_thread->userprog_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 0);
}
}
}
/* 释放以虚拟地址vaddr为起始的cnt个物理页框 */
void mfree_page(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt) {
uint32_t pg_phy_addr;
uint32_t vaddr = (int32_t)_vaddr, page_cnt = 0;
ASSERT(pg_cnt >=1 && vaddr % PG_SIZE == 0);
pg_phy_addr = addr_v2p(vaddr); // 获取虚拟地址vaddr对应的物理地址
/* 确保待释放的物理内存在低端1M+1k大小的页目录+1k大小的页表地址范围外 */
ASSERT((pg_phy_addr % PG_SIZE) == 0 && pg_phy_addr >= 0x102000);
/* 判断pg_phy_addr属于用户物理内存池还是内核物理内存池 */
if (pg_phy_addr >= user_pool.phy_addr_start) { // 位于user_pool内存池
vaddr -= PG_SIZE;
while (page_cnt < pg_cnt) {
vaddr += PG_SIZE;
pg_phy_addr = addr_v2p(vaddr);
/* 确保物理地址属于用户物理内存池 */
ASSERT((pg_phy_addr % PG_SIZE) == 0 && pg_phy_addr >= user_pool.phy_addr_start);
/* 先将对应的物理页框归还到内存池 */
pfree(pg_phy_addr);
/* 再从页表中清除此虚拟地址所在的页表项pte */
page_table_pte_remove(vaddr);
page_cnt++;
}
/* 清空虚拟地址的位图中的相应位 */
vaddr_remove(pf, _vaddr, pg_cnt);
}
else { // 位于kernel_pool内存池
vaddr -= PG_SIZE;
while (page_cnt < pg_cnt) {
vaddr += PG_SIZE;
pg_phy_addr = addr_v2p(vaddr);
/* 确保待释放的物理内存只属于内核物理内存池 */
ASSERT((pg_phy_addr % PG_SIZE) == 0 && \
pg_phy_addr >= kernel_pool.phy_addr_start && \
pg_phy_addr < user_pool.phy_addr_start);
/* 先将对应的物理页框归还到内存池 */
pfree(pg_phy_addr);
/* 再从页表中清除此虚拟地址所在的页表项pte */
page_table_pte_remove(vaddr);
page_cnt++;
}
/* 清空虚拟地址的位图中的相应位 */
vaddr_remove(pf, _vaddr, pg_cnt);
}
}
12.4.5 实现sys_free
现在,我们实现与arena模型分配机制对应的回收机制,并将之前的页回收封装进入,直接实现统一的内存回收系统调用sys_free
c
/*回收内存ptr*/
void sys_free(void* ptr){
ASSERT(ptr!=NULL);
if(ptr!=NULL){
enum pool_flags PF;
struct pool* mem_pool;
/*判断是线程还是进程*/
if(running_thread()->pgdir == NULL){
ASSERT((uint32_t)ptr >= K_HEAP_START);
PF = PF_KERNEL;
mem_pool = &kernel_pool;
}else{
PF = PF_USER;
mem_pool = &user_pool;
}
lock_acquire(&mem_pool->lock);
struct mem_block* b = ptr;
struct arena* a = block2arena(b);
//把mem_block转换为arena,获取元信息
ASSERT(a->large == 0 || a->large == 1);
if(a->desc == NULL && a->large == true){ //大于1024的内存
mfree_page(PF,a,a->cnt);
}else{ //小于1024的内存
/*先将内存块回收到free_list*/
list_append(&a->desc->free_list, &b->free_elem);
/*在判断此arena中的内存块是否都是空闲,如果是就释放arena*/
if(++a.cnt == a->desc->blocks_per_arena){
uint32_t block_idx;
for(block_idx=0;block_idx<a->desc->blocks_per_arena;block_idx++){
struct mem_block* b = arena2block(a,block_idx);
ASSERT(elem_find(&a->desc->free_list,&b->free_elem));
list_remove(&b->free_elem);
}
mfree_page(PF,a,1);
}
}
lock_release(&mem_pool->lock);
}
}
12.4.6 实现系统调用malloc和free
修改/userprog/syscall-init.c
c
/* 初始化系统调用 */
void syscall_init(void) {
put_str("syscall_init start\n");
syscall_table[SYS_GETPID] = sys_getpid;
syscall_table[SYS_WRITE] = sys_write;
syscall_table[SYS_MALLOC] = sys_malloc;
syscall_table[SYS_FREE] = sys_free;
put_str("syscall_init done\n");
}
修改/userprog/syscall.c
c
/* 申请size字节大小的内存,并返回结果 */
void* malloc(uint32_t size) {
return (void*)_syscall1(SYS_MALLOC, size);
}
/* 释放ptr指向的内存 */
void free(void* ptr) {
_syscall1(SYS_FREE, ptr);
}
修改/userprog/syscall.h
c
/*
* @Author: Adward-DYX 1654783946@qq.com
* @Date: 2024-04-24 11:09:17
* @LastEditors: Adward-DYX 1654783946@qq.com
* @LastEditTime: 2024-04-28 13:56:42
* @FilePath: /OS/chapter12/12.3/lib/user/syscall.h
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
#ifndef __LIB_USER_SYSCALL_H
#define __LIB_USER_SYSCALL_H
#include "stdint.h"
enum SYSCALL_NR {
SYS_GETPID,
SYS_WRITE,
SYS_MALLOC,
SYS_FREE
};
uint32_t getpid(void);
uint32_t write(char* str);
void* malloc(uint32_t size);
void free(void* ptr);
#endif