《操作系统真象还原》chapter12 笔记与思考

First Post:

Last Update:

本章内容只有一个:系统调用

实现的调用包括:sys_malloc、sys_free、sys_write、sys_getpid

前言

可惜的是,本书本章使用的是目前Linux已经弃用的_syscallX方式。在原版的Linux中,这种方式最多只支持6个参数,限制诸多且据本书作者说还存在安全问题(不过我查了一圈不知道具体是指什么样的安全事件)。目前记笔记时姑且这样继续,事后自己尝试的时候再试试能不能实现更加现代化一点的操作。

另外本书这节也实现了malloc和free,但其实现方式和我一直以来认知的堆管理似乎有很大的差别……考虑到实际的工程量问题,事后再尝试能否也做一些现代化的改造吧,当下先以笔记优先,姑且认同其实现方式。

系统调用

仿造Linux的操作,只使用软中断0x80来实现系统调用,过程如下:

维护一张系统调用表syscall_table,该表用于储存每个系统调用函数的地址
在IDT中注册0x80中断号对应的处理程序(称之为syscall_handler)

1
make_idt_desc(&idt[0x80], IDT_DESC_ATTR_DPL3, syscall_handler);

该处理函数根据中断调用号在系统调用表中寻址对应函数,这些函数是sys_funcname族函数,属于具体的实现函数
例如调用常规的getpid函数将引发如下操作:

  • 调用_syscall0函数传入getpid的调用号
  • 在_syscall0中向内核通过eax传参(即调用号),然后触发0x80中断
  • 中断调用处理函数syscall_handler通过调用号在调用表中寻址得到对应的函数(sys_getpid),该函数负责具体操作并返回结果

注:pid是加在task_struct也就是PCB中的

其实也没什么,单纯就是在触发中断以后进入处理函数,此时已经陷入内核,属于R0权级了,所有操作都能够正常进行了。特地为用户加一个进入内核方法罢了,只是所有方法的操作都被限制在固定范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
syscall_handler:
push 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个参数
call [syscall_table + eax*4] ; 编译器会在栈中根据C函数声明匹配正确数量的参数
add esp, 12 ; 跨过上面的三个参数
;4 将call调用后的返回值存入待当前内核栈中eax的位置
mov [esp + 8*4], eax
jmp intr_exit ; intr_exit返回,恢复上下文

printf和变长参数

1
2
3
4
5
6
7
8
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);
}

printf中通过vsprintf将输入的参数和format适配并转换成新的字符串通过write函数输出

变长参数的实现主要是出于c调用规则规定由调用者清理参数,所以调用printf函数push多少参数入栈,事后也要自己清理这些堆栈,所以不用担心这些参数淤积在栈里。

所以需要关心的问题是,如何准确的适配所有参数?答案是提供了参数模板,也就是这里的格式化字符串。

format中提供占位符来识别参数数量,有多少占位符就会用多少个参数,多的参数不会被用到,也不会留在栈里。至于少参数的情况……似乎没有高效的解决办法,即便现在2022年了,直接在C语言里直接这样写也会导致内存泄露:

1
2
int* p=0;
printf("%p\n%p", p);

(当然,真要解决也不是没办法,只是需要付出更多的开销)

至于vsprintf函数则只需要根据format来识别函数就行了:

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
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);
}

对于常规字符直接拷贝即可,遇到‘%’时根据下一个字符决定拷贝内容。

堆管理

首先简述一下本书所实现的堆管理逻辑吧:

sys_malloc:

  • 在每个任务的PCB中加入一个arena数组用于管理不同大小的chunk(其实就是GLIBC实现的Bins结构的简化版)
  • 初始化时将每个Bin中的free_list清空,然后初始化该Bin中能够存放的chunk数
  • 然后在用户实际调用malloc时,根据其所需要开辟的size选择对应的arena,然后从内核分配一个内存页,将该页按照该arena所管理的size切割成一块块chunk然后全都挂到其free_list里,最后从该链表里取出一块chunk分配给用户

sys_free:

  • 对于一些小的需求,将该chunk重新挂回free_list即可
  • 对于需要返还内存页的情况,将用户虚拟地址位图中对应位置0,然后将自己PTE中对应的页的P位置0表示其不在内存中
  • 最后把物理内存池的位图中对应内存页的flag置0,表示该页可用
  • 另外还需要刷新TLS(用于缓存页表的硬件设备)

逻辑和GLIBC有点像,不过是精简版的,个人认为这种方式虽然可行,但效率并没有GLIBC那样高。

最后,为用户提供malloc和free函数,两个函数调用sys_malloc和sys_free就算完成了。

嘛,如果到时候有机会的话可以试着实现一个看看,不过目前先就这样放着吧。本书最终的操作系统毕竟只是一个用于理解原理的精简版,所以知道真是情况以后还是省察着看吧。


插画ID:90781328