手写简易操作系统(十九)--实现0x80中断

本文详细介绍了如何通过0x80中断实现系统调用,包括设置中断描述符、系统调用接口设计以及用户程序如何通过这些接口调用系统功能如获取PID和打印。同时,展示了如何实现在用户态和内核态之间安全地传递参数和处理结果。
摘要由CSDN通过智能技术生成

前情提要

上一节我们实现了用户程序,现在的用户程序还是一个函数来模拟的,后面我们会把编译好的用户程序放在硬盘里,通过硬盘加载。

众所周知,用户程序使用系统服务是通过 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 ds
    push es
    push fs
    push gs
    pushad             ; 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调用后的返回值存放到eax
    mov [esp + 8*4], eax
    jmp 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特权级,很简单我们也加一个系统调用。

image-20240329171307966

二、实现用户打印程序

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

这里使用的宏就是带一个参数的宏了,这个宏和上面的分析是一样的。

现在就实现了用户的打印程序,先看一下行不行。

image-20240329172013163

没有任何问题。

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* args
    va_start(args, format);	       // 使args指向format            // args = (char*) &format;
    char buf[1024] = { 0 };	       // 用于存储拼接后的字符串
    vsprintf(buf, format, args);   // 按照format格式输出到buf
    va_end(args);                  // 销毁指针                    // args = NULL
    return 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_char
            break;

        case 'o':
            arg_int = va_arg(ap, int);
            itoa(arg_int, &buf_ptr, 8);
            index_char = *(++index_ptr); // 跳过格式字符并更新index_char
            break;
        }
    }
    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指向format
    char buf[1024] = { 0 };	       // 用于存储拼接后的字符串
    vsprintf(buf, format, args);   // 按照format格式输出到buf
    va_end(args);                  // 销毁指针
    return sys_write(buf);         // 写入控制台
}

2.3、仿真

image-20240329173358582

结束语

本节我们实现了 0x80 中断,并且让用户也可以打印自己的字符串。下一节我们将对我们的内存管理做出改进。也就是实现C语言中 mallocfree 函数。

老规矩,本节的代码地址:https://github.com/lyajpunov/os

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

LyaJpunov

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值