目录
1 概述
1.1 什么是系统调用
1.2 为什么需要系统调用
2 系统调用基础设施
2.1 安装系统门
2.1.1 中断描述符
2.1.2 中断描述符安装函数
2.1.3 安装0x80系统门
2.2 系统调用中断处理函数
2.3 打开中断
3 系统调用流程分析
3.1 库函数调用系统调用封装例程
3.2 组装系统调用封装例程
3.2.1 write函数构造
3.2.2 syscall宏分析
3.3 系统调用中断处理函数
3.4 系统调用服务例程
4 实验:新增系统调用
4.1 任务目标
4.1.1 增加iam系统调用
4.1.2 增加whoami系统调用
4.2 修改流程
4.2.1 增加系统调用号
4.2.2 修改系统调用个数上限
4.2.3 修改系统调用表
4.2.4 增加系统调用实现
4.2.5 测试用例
4.3 上机验证
4.3.1 正确调用iam
4.3.2 异常调用iam
4.3.3 调用whoami
1. 操作系统是管理计算机硬件的一层软件系统,通过这层软件系统,用户可以方便、高效、安全地使用计算机
2. 为了让用户方便、高效、安全地使用计算机,操作系统提供了一组接口
3. 这些接口以函数调用的形式提供,并且是由操作系统提供的,因此称为系统调用(system call)
4. 系统调用是应用程序请求操作系统内核服务的窗口,因此自然分为用户态和内核态两个部分,
① 用户态部分称作系统调用封装例程(wrapper routine),系统调用封装例程本身是用户态函数,其实现中包含了使执行流进入内核态的操作
② 内核态部分称作系统调用服务例程,系统调用服务例程是一组内核函数,用于实现系统调用的功能
关于系统调用的概念,可以参考如下笔记
Linux应用编程基础01:Linux应用编程绪论_麦小兜的博客-CSDN博客
1. 在操作系统中,内核态和用户态之间需要实现隔离,以下图为例
① 限制控制转移
用户态代码不能通过jmp指令直接跳转到内核态区域运行
② 限制数据访问
用户态代码也不能通过mov指令访问存放在内核态区域的数据
2. X86处理器通过特权级实现内核态和用户态的隔离,具体而言
① 应用程序运行在特权级3,操作系统内核运行在特权级0
② 特权级机制将对控制转移和数据访问进行合法性检查,特权级机制的详细内容可参考如下笔记
X86汇编语言从实模式到保护模式16:特权级和特权级保护_麦小兜的博客-CSDN博客
3. 如此一来,操作系统就需要同时达到如下2个目标
① 为了保护操作系统,不能让应用程序随意进入内核执行或访问数据
② 为了向应用程序提供功能,操作系统需要让应用程序能通过内核获取服务,其中必然要在内核态执行程序
4. 为了达到上述2个目标,操作系统提供了系统调用,作为应用程序请求内核服务的唯一入口。使用系统调用作为唯一入口的好处如下,
① 提供硬件的抽象接口:应用程序无需了解硬件操作的细节
② 提高系统的安全性:内核可以在响应某个请求之前在接口级检查请求的正确性
③ 提高应用程序的可移植性:只要不同操作系统提供的这组接口相同,那么在这些操作系统之上就可以正确地编译和执行相同的程序(即可实现源代码级可移植)
说明:以Linux 0.11为例,在系统运行状态下,只有系统调用和中断两种方式可以进入内核态,而后续将看到,在X86体系结构中,系统调用也是通过中断机制实现的
说明:在X86体系结构中,系统调用基于0x80软中断实现,关于X86体系结构保护模式下中断机制的详细内容,可以参考如下笔记,本节只说明与系统调用相关的部分
X86汇编语言从实模式到保护模式18:中断和异常的处理与抢占式多任务_麦小兜的博客-CSDN博客
1. 在X86体系结构的保护模式下,通过中断描述符表IDT组织中断描述符
2. 中断描述符描述的是中断处理程序的入口地址及其属性,根据不同类型,中断描述符可以分为中断门和陷阱门,具体格式如下
在Linux中,将DPL=3的陷阱门称作系统门
3. 系统门有如下2个特点
① 通过系统门(陷阱门)进入中断处理函数时,EFLAGS寄存器的IF位保持不变,也就是不会进行关中断操作
② 系统门描述符的DPL=3,可以通过用户态触发软中断的门级检查
4. 中断描述符安装在IDT中,他们没有选择子(index + TI + RPL),索引他们的序号(index)就是中断向量
在Linux 0.11中,通过_set_gate宏组装并安装中断描述符,该宏通过内嵌汇编实现,我们对其进行分析
// gate_addr:要设置的中断描述符地址
// type:中断描述符类型,中断门或陷阱门
// dpl:中断描述符DPL
// addr:中断处理程序地址
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \ // 没有输出部分,只有输入部分
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \ // %0操作数
"o" (*((char *) (gate_addr))), \ // %1操作数
"o" (*(4+(char *) (gate_addr))), \ // %2操作数
"d" ((char *) (addr)),"a" (0x00080000)) // %3和%4操作数
/*
* 输入部分说明
* 0号操作数:约束条件为"i",表示立即数;中断描述符高4B中的低2B
* 0x8000[P=1]+(dpl<<13)[门描述符特权级级]+(type<<8)[门描述符类型]
* 1号操作数:约束条件为"o",表示内存单元
* 要设置的中断描述符地址解引用,存放中断描述符低4B,相当于汇编指令中[gate_addr]
* 2号操作数:约束条件为"o",表示内存单元
* 要设置的中断描述符地址解引用,存放中断描述符高4B,相当于汇编指令中[gete_addr + 4]
* %3号操作数:约束条件为"d",表示要求使用寄存器edx
* edx = 中断处理程序地址
* %4号操作数:约束条件为"a",表示要求使用寄存器eax
* eax = 0x00080000,内核代码段选择子
*/
/*
* 组装与安装过程
* 1. movw %%dx, %%ax
* ax = 中断处理程序地址低16位
* 2. movw %0, %%dx
* dx = [P=1] + [门描述符特权级] + [门描述符类型]
* 3. movl %%eax, %1
* [gate_addr] = 中断描述符低4B,内核代码段选择子 + 中断处理程序地址低16位
* 4. movl %%edx, %2
* [gate_addr + 4] = 中断描述符高4B,中断处理程序地址高16位 + [P=1] + [门描述符特权级] + [门描述符类型]
*/
说明1:在Linux 0.11中,通过调用_set_gate宏,构成了3个组装和安装中断描述符的宏,分别如下
① set_intr_gate:安装中断门(门类型0b1110),门描述符特权级为0
② set_trap_gate:安装陷阱门(门类型0b1111),门描述符特权级为0
③ set_system_gate:安装系统门(门类型0b1111),门描述符特权级为3
说明2:_set_gate宏中使用的idt变量就是system模块中定义的中断描述符表IDT
① IDT在汇编代码中的定义(head.s)
_idt就是256个中断描述符存储区域的线性地址
② IDT在C代码中的声明(head.h)
idt是一个数组名,也就是数组的首地址,该数组共有256个元素,每个元素8B,这点和中断描述符表的定义是匹配的
系统调用通过0x80号软中断实现,而该中断的处理函数为system_call
系统调用中断处理函数_system_call定义在kernel/system_call.s中,详细分析见下文。这里可以发现,在Linux0.11中,系统调用还是一个进程调度的时机
在系统初始化的最后阶段,将调用sti函数打开中断。至此,系统调用所需的基础设置均部署完毕
说明:sti函数使用内嵌汇编实现
下面以printf函数为例,说明整个系统调用流程
1. printf在main.c中实现,角色是一个用户态的库函数
2. printf函数首先通过可变参数列表处理函数将用户输入的格式化字符串及参数转换并打印到printbuf数组中,之后调用write函数打印到显示器
3. write函数则是一个系统调用封装例程(wrapper routine)
在lib/write.c函数中,通过_syscall3宏,构造了write函数的系统调用封装例程
syscall是一组宏,用于构造系统调用封装例程,他们根据系统调用的参数个数分类,具体分析如下
3.2.2.1 _syscall0
// type:函数返回值类型
// name:函数名
#define _syscall0(type,name) \
type name(void) \ // 构造的系统调用封装例程函数名
{ \
long __res; \
__asm__ volatile ("int $0x80" \ // 函数主体为触发0x80号软中断
// %0号操作数
// 输出部分写入__res变量,约束条件"=a",表示返回值使用eax寄存器传递
: "=a" (__res) \
// 输入部分复用%0号操作数
// 即系统调用号也使用eax寄存器传递
: "0" (__NR_##name)); \
if (__res >= 0) \ // 如果系统调用返回值 >= 0,则直接返回
return (type) __res; \
errno = -__res; \ // 如果系统调用返回值 < 0,则设置errno并返回-1
return -1; \
}
说明1:_syscall0宏使用实例
此处构造的系统调用封装例程如下,
int fork(void);
int pause(void);
int sync(void);
说明2:系统调用号
① __NR##name用于构成系统调用号,例如__NR_fork、__NR_pause
② 系统调用号定义在unistd.h中
③ 不同Linux版本提供的系统调用不同,也就会有不同的系统调用号(主要是随版本更新增加系统调用),因此系统调用封装例程中使用的系统调用号必须与目标操作系统的版本匹配
说明3:系统调用号传递规范
在_syscall系列宏中,使用eax寄存器传递系统调用号,这是需要符合体系结构与操作系统ABI的,可以通过man syscall查看
对于i386体系结构,
① 使用int 0x80指令触发系统调用(instruction)
② 使用eax寄存器传递系统调用号(syscall #)
③ 使用eax寄存器传递系统调用返回值(retval)
3.2.2.2 _syscall1
#define _syscall1(type,name,atype,a) \
type name(atype a) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
// 约束条件为"b",表示使用ebx寄存器传递参数a
: "0" (__NR_##name),"b" ((long)(a))); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
说明1:_syscall1宏使用实例
此处构造的系统调用封装例程为
int close(int fd);
说明2:系统调用参数的传递规范
在_syscall1宏中,使用ebx寄存器传递参数,这也是需要符合体系结构与操作系统ABI的,可以通过man syscall查看
可见在目前的i386体系结构中,系统调用最多可以传递6个参数,按序分别使用ebx / ecx / edx / esi / edi / ebp传递
而且从上述表格也可以看出,上述体系结构中的系统调用都是使用寄存器传递参数,而不使用栈
3.2.2.3 _syscall2
#define _syscall2(type,name,atype,a,btype,b) \
type name(atype a,btype b) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
// 使用ebx传递第1个参数,使用ecx传递第2个参数
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b))); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
说明:Linux 0.11代码中,没有对_syscall2宏的使用
3.2.2.4 _syscall3
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
// 使用ebx传递第1个参数,使用ecx传递第2个参数,使用edx传递第3个参数
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}
说明:_syscall3宏使用实例
此处构造的系统调用封装例程为
int write(int fd, const char *buf, off_t count);
上文中已经展示了系统调用中断处理函数_system_call的全貌,下面分析与系统调用相关的部分,与进程调度相关的内容,将在后续笔记中分析
; 系统调用总数
nr_system_calls = 72
; 对无效系统调用号的处理
.align 2
bad_sys_call:
movl $-1,%eax ; eax寄存器返回-1
iret ; 中断返回
_system_call:
; 判断系统调用号的有效性
; 如果系统调用号按无符号数大于nr_system_calls -1,则系统调用号无效
cmpl $nr_system_calls-1,%eax
ja bad_sys_call
; 段寄存器压栈
push %ds
push %es
push %fs
; 传递系统调用参数的寄存器压栈
pushl %edx
pushl %ecx
pushl %ebx
; 将ds & es段寄存器设置为内核数据段
movl $0x10,%edx
mov %dx,%ds
mov %dx,%es
; 将fs段寄存器设置为用户数据段
; 目的是在系统调用处理函数中可以访问用户态数据
movl $0x17,%edx
mov %dx,%fs
; 根据系统调用号,调用系统调用服务例程
call _sys_call_table(,%eax,4)
; 将系统调用服务例程返回值压栈
pushl %eax
; 通用寄存器出栈
popl %eax
popl %ebx
popl %ecx
popl %edx
; 段寄存器出栈
pop %fs
pop %es
pop %ds
; 中断返回
iret
说明1:进入系统调用中断处理函数时的栈状态
X86体系结构保护模式下中断压栈压栈情况如下图所示,其中错误代码会有部分异常压入
因为用户程序通过系统调用进入内核态时,一定会伴随着特权级的切换(从特权级3切换到特权级0),因此栈也需要随之切换。此时会将用户程序之前使用的栈段寄存器SS和栈指针ESP压入任务的内核栈中
说明2:任务内核栈的来源
X86体系结构中,任务使用的内核栈被等级在任务TSS段的SS0 & ESP0字段
说明3:_system_call中为何没有设置cs段寄存器?
① _system_call中将ds和es段寄存器设置为内核数据段
② 在通过系统门进入内核时,已经使用中断描述符中的目标代码段描述符选择子设置了cs段寄存器,而该值正是内核代码段
说明4:系统调用函数表
_system_call中使用的_sys_call_table是系统调用函数表,该表定义在include/linux/sys.h文件中
① sys_call_table是一个函数指针数组,其中的每个成员对应一个系统调用服务例程
② sys_call_table中函数指针的组织顺序,与系统调用号顺序一致
③ 此处fn_ptr定义的函数指针类型是不重要的,因为无论函数类型,函数指针都是4B(当然,这里编译时会有警告信息)
printf函数最终使用的系统调用服务例程为sys_write函数
sys_write函数中会根据文件类型调用不同的写入函数
说明:在系统调用服务例程中使用用户态数据
① 以sys_write函数为例,该函数要写入的数据存储在参数buf指向的内存中,而该内存属于用户态(特权级为3)
② 在_system_call中,已经将fs段寄存器设置为用户数据段,目的就是为了在内核态访问用户态数据
③ 以file_write函数为例,在file_write函数中会调用get_fs_byte函数获取用户态数据
④ 在include/asm/segment.h中定义了一系列通过fs段寄存器读写用户态数据的操作函数
⑤ 之所以在内核中可以通过fs段寄存器访问用户态数据,是因为用户程序通过系统调用进入内核态时,CR3寄存器并未切换,仍然指向当前任务的页表集。而内核态处于处于特权级0,可以访问用户态特权级为3的页表和页
本实验添加2个系统调用,并在编写应用程序对其进行测试
用户态函数原型如下
/*
* 功能:将字符串参数name的内容拷贝到内核中并保持
* 参数说明:要求name的长度不超过23个字符
* 返回值说明:如果name的长度不超过23个字符,返回值是拷贝的字节数;
* 如果name的长度超过23个字符,则返回-1,并设置errno为EINVAL
*/
int iam(const char *name);
5.
用户态函数原型如下
/*
* 功能:将内核中由iam系统调用保存的名字拷贝到name指向的用户地址空间中,
* 同时确保不会对name越界访问(name的大小由size指定)
* 返回值说明:如果size空间足够,返回拷贝的字节数,
* 如果size小于需要的空间,则返回-1,并设置errno为EINVAL
*/
int whoami(char *name, unsigned int size);
修改文件:include/unistd.h
修改文件:kernel/system_call.s
修改文件:include/linux/sys.h
1. 新增文件:kernel/iam_whoiam.c
将在该文件中实现sys_iam和sys_whoiam函数
2. 修改文件:kernel/Makefile
该修改用于将iam_whoiam.c文件编译到内核中
3. 实现sys_iam函数
4. 实现sys_whoiam函数
4.2.5.1 iam测试用例
说明:对于unistd.h文件的使用需要注意如下3点
① 编译应用程序时,使用的是根文件系统中/usr/include/目录下的unistd.h
② 在应用程序中使用unistd.h时,需要定义__LIBRARY__宏,否则不会包含系统调用号和syscall系列宏
③ 由于新增了系统调用,根文件系统中的unistd.h也需要增加系统调用号的宏定义
当然,也可以在应用程序中定义,只要在对syscall宏的调用之前提供系统调用号定义即可
4.2.5.2 whoami测试用例
当意图写入的字符串超长时,系统调用返回-1,且errno被设置为EINVAL(宏值为22)