前情提要
上一节我们实现了用户程序,现在的用户程序还是一个函数来模拟的,后面我们会把编译好的用户程序放在硬盘里,通过硬盘加载。
众所周知,用户程序使用系统服务是通过 0x80
中断进行的,也只能通过中断进入高优先级,中断结束再返回用户态,这样就实现了用户程序除非调用系统服务,否则就只能在用户态。系统服务是操作系统准备好的,并不会对操作系统造成危害
一、系统调用实现框架
一个系统功能调用分为两部分,一部分是暴露给用户进程的接口函数,它属于用户空间,此部分只是用户进程使用系统调用的途径,只负责发需求。另一部分是与之对应的内核具体实现,它属于内核空间,此部分完成的是功能需求,就是我们一直所说的系统调用子功能处理函数。
实现思路
1、用中断门实现系统调用,效仿Linux用0x80号中断作为系统调用的入口。
2、在IDT中安装0x80号中断对应的描述符,在该描述符中注册系统调用对应的中断处理例程。
3、建立系统调用子功能表syscall_table,利用eax寄存器中的子功能号在该表中索引相应的处理函数。
4、用宏实现用户空间系统调用接口_syscall,最大支持3个参数的系统调用,故只需要完成_syscall[0-3]。寄存器传递参数,eax为子功能号,ebx保存第1个参数,ecx保存第2个参数,edx保存第3个参数。
1.1、添加0x80中断
// interrupt.c
// 系统调用接口
extern uint32_t syscall_handler(void);/*初始化中断描述符表*/
static void idt_desc_init(void) {put_str("----idt_desc_init begin!\n");for (int i = 0; i < IDT_DESC_CNT; i++) {make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]); }/* 单独处理 0x80 system call 系统调用 */make_idt_desc(&idt[0x80], IDT_DESC_ATTR_DPL3, syscall_handler); put_str("----dt_desc_init done!\n");
}
在初始化中断描述符时,初始化 0x80
中断的描述符,其中特权级为3,调用函数为 syscall_handler
,这个函数是汇编实现的,我们看一下
;; 0x80 号中断
[bits 32]
extern syscall_table
section .text
global syscall_handler ;0x80的中断处理程序
syscall_handler:; 保存上下文环境push 0 ; 压入0,这里占的位置是err_code,错误码push dspush espush fspush gspushad ; PUSHAD指令压入32位寄存器,其入栈顺序是:EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI push 0x80 ; 压入0x80,这里占的位置是vec_no,中断号; 为系统调用子功能传递参数push edx ; 系统调用中第3个参数push ecx ; 系统调用中第2个参数push ebx ; 系统调用中第1个参数; 调用子功能处理函数call [syscall_table + eax*4]add esp, 12 ; 跨过上面的三个参数; 将call调用后的返回值存放到eaxmov [esp + 8*4], eaxjmp intr_exit ; 中断返回
单纯的这样的看是有点看不懂的,我们结合一下调用的汇编
// lib/user/syscall.c
/* 无参数的系统调用 */
#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; \
})
从无参数的系统调用,到三个参数的系统调用。从这里我们就可以串联起来系统的调用过程了。
1.2、实现getpid调用
我们先实现一个系统调用,gitpid(),用来获取当前正在运行的系统调用的pid。
/* 0号调用:返回当前任务的pid */
uint32_t sys_getpid(void) {return running_thread()->pid;
}enum SYSCALL_NR {SYS_GETPID, // 0号调用:获取当前线程的pid
};/* 初始化系统调用,也就是将syscall_table数组中绑定好确定的函数 */
void syscall_init(void) {put_str("syscall_init begin!\n");syscall_table[SYS_GETPID] = sys_getpid;put_str("syscall_init done!\n");
}
可以看到,这里是将内核中的 sys_getpid
函数地址给了函数数组 syscall_table
,由于是0号调用,所以这里也是在数组的0号位置。
然后给用户端留下相应的调用接口‘
/* 返回当前任务pid */
uint32_t getpid() {return _syscall0(SYS_GETPID);
}
用户程序调用这个接口就可以返回用户的进程pid。
我们来分析一下实现过程
1.3、对getpid的分析
首先用户程序执行 _syscall0(SYS_GETPID)
这个宏定义,SYS_GETPID
就是子功能号,这里为0。
把这个宏展开
int retval;
asm volatile ("int $0x80" // 触发系统调用: "=a" (retval) // 将系统调用的返回值存储在 retval 中: "a" (0) // 使用系统调用号 0: "memory" // 内联汇编可能会影响内存,因此需要指定 memory 操作数
);
return retval;
就是触发系统调用,并将返回值传递给 EAX
,这里通过内联汇编将 EAX
存到内存 retval
中,进入中断时传入中断子功能号 0
。
这样我们就进入中断了,进入中断以后执行 syscall_handler
函数,首先是保存上下文环境
push 0 ; 压入0,这里占的位置是err_code,错误码
push ds
push es
push fs
push gs
pushad ; PUSHAD指令压入32位寄存器,其入栈顺序是:EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI
push 0x80 ; 压入0x80,这里占的位置是vec_no,中断号
如果有其他参数的话会保存在 EBX,ECX,EDX
中。
push edx ; 系统调用中第3个参数
push ecx ; 系统调用中第2个参数
push ebx ; 系统调用中第1个参数
子功能处理函数保存在 syscall_table
数组中,保存的位置就是子功能号,拿到处理函数的地址,直接跳过去执行。
call [syscall_table + eax*4]
跨过保存在 EBX,ECX,EDX
中的三个参数
add esp,12
现在就剩下一个问题了,我们执行函数拿到的返回值保存在了 EAX
,怎么将 EAX
返回呢。这里使用了一种巧妙的方式,中断就得中断返回,中断返回就需要将之前入栈保存的寄存器值再回复,这里我们直接通过这个机会,把栈中EAX的值给他换了,中断返回后EAX中不就是返回值嘛
mov [esp + 8*4], eax
jmp intr_exit
1.4、仿真
可以看到,用户程序成功的获得了自己的pid。这里有个问题就是用户程序还不能自己在控制台输出,因为控制台输出是对硬件的控制,需要0特权级,很简单我们也加一个系统调用。
二、实现用户打印程序
2.1、实现write函数
write是将字符写入到文件中,标准输出也是文件,但是这里我们还没有实现文件系统,所以先暂且直接输出到控制台。
/* 1号调用:把buf中count个字符写到文件描述符fd指向的文件中 */
uint32_t sys_write(char* str) {console_put_str(str);return strlen(str);
}/* 初始化系统调用,也就是将syscall_table数组中绑定好确定的函数 */
void syscall_init(void) {put_str("syscall_init begin!\n");syscall_table[SYS_GETPID] = sys_getpid;syscall_table[SYS_WRITE] = sys_write;put_str("syscall_init done!\n");
}
暴露给用户的接口是
/* 打印字符串str */
uint32_t write(char* str) {return _syscall1(SYS_WRITE, str);
}
这里使用的宏就是带一个参数的宏了,这个宏和上面的分析是一样的。
现在就实现了用户的打印程序,先看一下行不行。
没有任何问题。
2.2、实现printf
在实际使用C语言的过程中,printf函数是可变参数的,这一点以我之前的C语言水平确实是没有理解,按理说,C语言又不能像C++一样实现参数重载,他怎么做到可变参数的,这一点将在这一节说中讲解。
实际上,不管是多少参数,一个函数调用的过程就是先将函数的参数从右到左压栈,我们看一下printf的定义
uint32_t printf(const char* format, ...);
第一个参数一定是一个字符串,这个字符串可以有 %
也可以没有,但是既然我们的 %
个数是和后面的参数个数一致的。那我们就可以自己从栈中找到压入的参数啊。
首先看一个宏
#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
单看这个宏定义很难看懂,还是结合实际代码
/* 格式化输出字符串format */
uint32_t printf(const char* format, ...) {va_list args; // char* argsva_start(args, format); // 使args指向format // args = (char*) &format;char buf[1024] = { 0 }; // 用于存储拼接后的字符串vsprintf(buf, format, args); // 按照format格式输出到bufva_end(args); // 销毁指针 // args = NULLreturn write(buf); // 写入控制台
}
其中第一行就是申明一个指针,第二行将这个指针指向的传入的字符串的指针。也就是说,这是一个二级指针了。
然后我们看 vsprintf
/* 将参数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_charbreak;case 'o':arg_int = va_arg(ap, int);itoa(arg_int, &buf_ptr, 8);index_char = *(++index_ptr); // 跳过格式字符并更新index_charbreak;}}return strlen(str);
}
这个函数对不同的控制字进行了替换,替换成了我们可变参数提供的值。
可以看一下不同进制转换的函数
const char cache[16] = "0123456789ABCDEF";
/* 将整型转换成字符(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);}*((*buf_ptr_addr)++) = cache[m];}
这样就实现了一个可变参数的打印函数,针对用户,既然可以针对用户,那么内核一定是可以的。
/* 格式化输出字符串,内核使用 */
uint32_t printk(const char* format, ...) {va_list args;va_start(args, format); // 使args指向formatchar buf[1024] = { 0 }; // 用于存储拼接后的字符串vsprintf(buf, format, args); // 按照format格式输出到bufva_end(args); // 销毁指针return sys_write(buf); // 写入控制台
}
2.3、仿真
结束语
本节我们实现了 0x80
中断,并且让用户也可以打印自己的字符串。下一节我们将对我们的内存管理做出改进。也就是实现C语言中 malloc
和 free
函数。
老规矩,本节的代码地址:https://github.com/lyajpunov/os