代码、内容参考来自于包括《操作系统真象还原》、《一个64位操作系统的设计与实现》以及《ORANGE’S:一个操作系统的实现》。
Linux系统调用是用中断门来实现的,通过软中断指令int来主动发起中断信号,中断向量号0x80,处理器执行指令int 0x80时便触发了系统调用。为了让用户程序可以通过这一个中断门调用多种系统功能,在系统调用之前,Linux在寄存器eax中写入子功能号,例如系统调用open和close都是不同的子功能号,当用户程序通过int 0x80 进行系统调用时,对应的中断处理例程会根据eax的值来判断用户进程申请哪种系统调用。
仿照linux,我们的系统调用的步骤如下:
- 用中断门实现系统调用,效仿Linux用0x80号中断作为系统调用的入口。
- 在 IDT 中安装 0x80号中断对应的描述符,在该描述符中注册系统调用对应的中断处理例程。
- 建立系统调用子功能表syscall_table,利用eax寄存器中的子功能号在该表中索引相应的处理函数。
- 用宏实现用户空间系统调用接口_syscall,最大支持3个参数的系统调用,故只需要完成_syscall[0-3]。寄存器传递参数,eax为子功能号,ebx保存第 1 个参数,ecx 保存第 2 个参数,edx 保存第 3 个参数。

首先我们要修改 interrupt.c, 在其中安装 0x80 对应的中断描述符
修改/kernel/interrupt.c,主要修改3处
#define IDT_DESC_CNT 0x81 //目前共支持的中断数
extern uint32_t syscall_handler(void);
/*初始化中断描述符表*/
static void idt_desc_init(void){
int i, lastindex = IDT_DESC_CNT - 1;
for(i = 0; i < IDT_DESC_CNT; i++){
make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]); //IDT_DESC_DPL0在global.h定义的
}
// 单独处理系统调用, 系统调用对应的中断内 DPL 为 3, 因为要在用户进程中执行 int 指令来调用该描述符
// 中断处理程序为单独的 syscall_handler
make_idt_desc(&idt[lastindex], IDT_DESC_ATTR_DPL3, syscall_handler);
put_str(" idt_desc_init done\n");
}将宏IDT_DESC_CNT修改为0x81,这表示我们最大支持0x81个中断,即0~0x80,0x80 是我们系统调用对应的中断向量。声明了外部函数syscall_handler,我们将在kernel. S中定义它,syscall_handler就是系统调用对应的中断入口例程。
在后面的idt_desc_init函数中,make_idt_desc(&idt[lastindex], IDT_DESC_ATTR_DPL3, syscall_handler)增加了0x80号中断向量对应的中断描述符,在描述符中注册的中断处理例程为syscall_handler。这里要注意的是记得给此描述符的dpl指定为用户级IDT_DESCATTR_DPL3,若指定为0级,则在3级环境下执行int指令会产生GP异常。
用户进程可以通过调用宏_syscall[0-6]进行系统调用,它的核心就是用内联汇编传参并触发中断。
在目录 lib/user/下,新建syscall.c
#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; \
})我们打算最多支持3个参数的系统调用,它们是syscall[0-3]
Linux中是用宏定义了一个函数,这里直接用大括号完成的。
这里使用了大括号,大括号中最后一个语句的值会作为大括号代码块的返回值,而且要在最后一个语句后加分号(;),否则编译时会报错。
对应lib/user/syscall.h如下:
#ifndef __LIB_USER_SYSCALL_H
#define __LIB_USER_SYSCALL_H
#include "stdint.h"
enum SYSCALL_NR {
SYS_GETPID
};
uint32_t getpid(void);
#endif
修改/kernel/kernel.S,在里面安装中断向量0x80对应的中断处理程序,syscall_handler定义在这里。
直接在kernel.S最后VECTOR 0x2f, ZERO后面添加进去
;-------------- 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, ECS, 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 ; 跳过上面的 3 个参数
; 4. 将 call 调用后的返回值存入待当前内核栈中 eax 的位置
mov [esp + 8 * 4], eax ; 跨过 0x80, pushad 的 eax 后的寄存器(7个)共8个字节, 即为eax的值的位置
; 覆盖了原 eax, 之后 popad 之后返回用户态, 用户进程便得到了系统调用函数的返回值
jmp intr_exit ; intr_exit 返回, 恢复上下文先声明外部数据结构syscall_table,syscall_table是个数组,数组成员是系统调用中子功能对应的处理函数(以后在新文件中定义),这里我们用子功能号在此数组中索引子功能号对应的处理函数。
接下来是中断例程syscall_handler的定义,为了复用intr_exit,此例程的前半部分和其他中断例程一样,push 0压入了中断错误码0。
然后保存任务的上下文
push 0x80显式压入了中断号0x80。不过push 0和push 0x80只是占位用,无所谓内容。不过还是为了意义统一,分别压入了错误码和中断向量号。push edx、ecx和ebx是为子功能函数准备参数,由于只支持 3 个参数的系统调用,故只压入了三个参数,按照 C调用约定,最右边的参数先入栈,因此先把edx中的第3个参数入栈,其次是ecx中的第2个参数、ebx中的第1个参数。注意,这里我们不管具体系统调用中的参数是几个,一律压入3个参数,子功能处理函数都有自己的原型声明,声明中包括参数个数及类型,编译时编译器会根据函数声明在栈中匹配出正确数量的参数,进入函数体后,根据C调用约定,栈顶的4字节(32位系统,下同)是函数的返回地址,往上(高地址的栈底方向)的4字节是第1个参数,再往上的4字节便是第2个参数,依此类推。在函数体中,编译器生成的取参数指令是从栈顶往上(跨过栈顶的返回地址,向高地址方向)获取参数的,参数个数是通过函数声明事先确定好的,因此并不会获取到错误的参数,从而保证了多余的参数用不上,因此,尽管我们压入了3个参数,但对于那些参数少于3个的函数也不会出错,而我们也只是浪费了一点点栈空间。
寄存器eax中是系统调用子功能号,用它在数组syscall_table中索引对应的子功能处理函数。 syscall_table中存储的是函数地址,每个成员是4字节大小,因此call [syscall_table + eax * 4]要用eax*4做syscall_table 的偏移量,这样代码”call [syscall_table + eax*4]”便去调用子功能处理函数。
调用之后, “add esp, 12”跨过这三个参数的地址。
根据二进制编程接口ABI约定,寄存器eax用来存储返回值。经过上面”call [syscall_table + eax*4]”的call函数调用,如果有返回值的话,eax 的值已经变成了返回值(如果没有返回值也没关系,编译器会保证函数返回后 eax的值不变),此时我们要把返回值传给用户进程,但是从内核态退出时,要从内核栈中恢复寄存器上下文,这会将当前 eax 的返回值覆盖,所以我们需要把寄存器 eax的值回写到内核栈中用于保存eax的内存处,这样从内核返回时,popd指令也只是用该返回值重新覆盖一次eax 寄存器,返回到用户态时,用户进程便获取到了系统调用函数的返回值。所以通过”mov [esp + 8*4], eax”实现,此行代码就是将返回值写到了栈(此时是内核栈)中保存eax的那个内存空间,[esp+8*4],这是寄存器相对寻址,esp就是当前栈顶,8*4就是相对栈顶,往栈中高地址方向的偏移量,其实把8*4拆分成(1+7)*4更好,其中的1是指上面的push 0x80所占的4字节,另外的7是指pushad指令会将eax最先压入,故要跨过7个4字节,总共是8个4字节,即[esp+8*4]是对应栈中eax的“藏身之所”。最后通过”jmp intr_exit”从中断出口函数intr_exit返回,这里是复用intr_exit,没有变化。
接下来就是要定义调用子功能数组 syscall_table,用它来存放不同子功能对应的处理函数。往syscall_table中注册处理函数这项工作是在初始化系统调用时完成的。 现在定义个子功能处理函数。 要实现的第一个系统调用是getpid,getpid的功能是获取任务自己的pid,getpid是给用户进程使用的接口函数,它在内核中对应的处理函数是sys_getpid。
/userprog/syscall-init.c
#include "syscall-init.h"
#include "syscall.h"
#include "stdint.h"
#include "print.h"
#include "thread.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");
}对应/userprog/syscall-init.h如下:
#ifndef __USERPROG_SYSCALLINIT_H #define __USERPROG_SYSCALLINIT_H #include "stdint.h" void syscall_init(void); uint32_t sys_getpid(void); #endif
syscall_nr表示最大支持的系统调用子功能个数, 其值为32,typedef自定义syscall类型为空指针void*,syscall是数组syscall_table的元素类型,也就是syscall_table为函数指针数组。sys_getpid的实现很简单,就是将当前任务peb中的pid返回。
初始化系统调用函数syscall_init,就是为数组syscall_table赋值,这里用到了SYS_GETPID,它是个枚举型数值,表示系统调用子功能号,目前其值为0,定义在lib/user/syscall.h中。
lib/user/syscall.h
#ifndef __LIB_USER_SYSCALL_H
#define __LIB_USER_SYSCALL_H
#include "stdint.h"
enum SYSCALL_NR {
SYS_GETPID
};
uint32_t getpid(void);
#endif在syscall.h中主要定义了枚举结构enum SYSCALL_NR,此结构用来存放系统调用子功能号, 目前里面只有SYS_GETPID,默认值为0,以后再增加新的系统调用后还需要把新的子功能号添加到此结构中。
接下来要考虑实现系统调用接口了,这里要实现的接口是getpid。
在/lib/user/syscall.c加入以下函数,其余没有什么变化:
/* 返回当前任务pid */
uint32_t getpid() {
return _syscall0(SYS_GETPID);
}
下面补上为任务分配pid相关的代码。
先在/thread/thread.h的结构体加上pid属性
/*进程或线程的pcb,程序控制块*/
struct task_struct{
uint32_t* self_kstack; //各内核线程都用自己的内核栈
pid_t pid;
enum task_status status;
uint8_t priority; //线程优先级
char name[16];
uint8_t ticks; //每次在处理器上的嘀咕数
uint32_t elapsed_ticks; //此任务自上CPU运行至今用了多少CPU嘀咕数,也就是任务运行了多久
struct list_elem general_tag; //用于线程在一般队列中的节点
struct list_elem all_list_tag; //用于线程队列thread_all_list中的结点
uint32_t* pgdir; //进程自己页表的虚拟地址,如果是线程则为NULL
struct virtual_addr userprog_vaddr; //用户进程的虚拟地址
uint32_t stack_magic; //栈的边界标记,用于检测栈的溢出
};pid的类型是int16_t,同样定义在/thread/thread.h
typedef int16_t pid_t;
其余没有变化
修改/thread/thread.c
#include "sync.h"
struct lock pid_lock;
static pid_t allocate_pid(void) {
static pid_t next_pid = 0; // next_pid 会不断自增
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(); // 为线程分配 pid
strcpy(pthread->name,name);
if(pthread == main_thread)
pthread->status = TASK_RUNNING;
else
pthread->status = TASK_READY;
/*self_kstack是线程自己在内核态下使用的栈顶地址*/
pthread->self_kstack = (uint32_t*)((uint32_t)pthread + PG_SIZE);
pthread->priority = prio;
pthread->ticks = prio;
pthread->elapsed_ticks = 0;
pthread->pgdir = NULL;
pthread->stack_magic = 0x19870916;
}/*初始化线程环境*/
void thread_init(void){
put_str("thread_init start\n");
list_init(&thread_ready_list);
list_init(&thread_all_list);
lock_init(&pid_lock);
/*将当前main函数创建为线程*/
make_main_thread();
put_str("thread_init done\n");
}先加入struct lock pid_lock,这是定义了pid锁,pid必须是唯一的,此锁用来在分配pid时实现互斥,避免为不同的任务分配重复的pid。
添加allocate_pid函数用来分配pid,这里用静态全局变量next_pid的值作为pid,next_pid初始为0,其加1后的结果为新线程的pid,因此第1个任务的pid为1 (Linux中pid为0的任务是init,将来咱们实现任务init后也要把它的pid分配 为1),之后任务的pid会自增。
修改init_thread函数,添加pthread->pid = allocate_pid(),分配pid是在线程创建后的初始化期间进行的,因此函数allocate_pid是在init_thread函数中使用。
修改thread_init函数,加入lock_init(&pid_lock),pid_lock的类型是struct lock,在使用前要初始化。
然后需要在/kernel/init.c加入系统调用参数化
#include "init.h"
#include "print.h"
#include "interrupt.h"
#include "timer.h"
#include "memory.h"
#include "thread.h"
#include "console.h"
#include "keyboard.h"
#include "tss.h"
#include "syscall-init.h"
/* 负责初始化所有模块 */
void init_all() {
put_str("init_all\n");
idt_init(); // 初始化中断
mem_init(); // 初始化内存管理系统
thread_init(); // 初始化线程相关结构
timer_init(); // 初始化 PIT
console_init(); // 初始化终端
keyboard_init(); // 键盘初始化
tss_init(); // tss 初始化
syscall_init(); // 初始化系统调用
}
现在我们完成了系统调用的实现,并且添加了第一个系统调用getpid。
增加系统调用的步骤:
- 在 syscall.h 中的结构 enum SYSCALL_NR里添加新的子功能号。
- 在syscall.c中增加系统调用的用户接口。
- 在syscall-init.c中定义子功能处理函数并在syscall_table中注册。
现在我们测试一下
修改main.c
#include "print.h"
#include "init.h"
#include "debug.h"
#include "memory.h"
#include "../thread/thread.h"
#include "interrupt.h"
#include "../device/console.h"
#include "../userprog/process.h"
#include "../userprog/syscall-init.h"
#include "../lib/user/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() {
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);
}创建两个用户进程u_prog_a和u_prog_b,调用getpid()来获得自己的pid,目前尚未实现为用户进程打印字符的系统调用,先让内核线程帮着把用户进程pid打印出来,也就是在内核线程k_thread_a和k_thread_b 中分别输出变量 prog_a_pid 和 prog_b_pid。
对应makefile加上需要编译的文件
执行结果如下:
可以看到正常获取pid号

这里注意一下:
上面多输出了process_executeuser_prog_a和process_executeuser_prog_b
这是因为我在userprog/process.c的process_execute函数里加入了
console_put_str("process_execute");
console_put_str(name);
console_put_str("\n");仅仅为了调试,查看进程执行时机而已。
实际不应该在process_execute函数里加入console_put_str,也不正确,会有bug。
在后面实现完文件系统后,我删除了,不过在那之前一直会有输出。
参考
郑钢著操作系统真象还原
田宇著一个64位操作系统的设计与实现
丁渊著ORANGE’S:一个操作系统的实现


