完成setup后,操作系统的代码都被读入到从0地址开始的地方,还创建了一些初始的结构,如mem_map(管理内存的数据结构)、GDT、IDT等。而我们的应用程序都放在了内存的上端。
最终,内存的下方放置的为系统代码和数据、上方放置的为应用程序,这样子一个结构情况。
(1)什么是操作系统接口?
系统调用(接口表现为函数调用,又由系统提供)
(2)操作系统接口连接谁?
连接操作系统和应用软件
(3)如何连接?
C语言程序
其中,printf
是包装了write
(系统调用函数)之后的函数。
POSIX是统一的接口
(1)假设用户程序内使用printf()
函数;
(2)根据lib
下的_syscalln()
和include/unistd.h
下的模板,对printf()
函数进行宏定义展开;
(3)调用展开后的函数,触发80中断,将kernel
下的system_call
对应的IDT表中的DPL设为3,从而让用户程序可获取system_call
地址作为IP。然后,再设置CS=8,使其对应的CPL=0,从而让用户可以进入内核态;
(4)在system_call
函数中,会使用从include/unistd.h
中获得的存入eax
的值,来查询include/linux/sys.h
中sys_call_table
表里对应的系统调用函数;
(5)使用对应的系统调用函数处理数据后,将结果存入eax
并返回给用户程序。
操作系统实现系统调用的基本过程
(1)应用程序调用库函数(API);
(2)API 将系统调用号存入 EAX,然后通过中断调用使系统进入内核态;
(3)内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
(4)系统调用完成相应功能,将返回值存入 EAX,返回到中断处理函数;
(5)中断处理函数返回到 API 中;
(6)API 将 EAX 返回给应用程序。
整个过程中主要通过EAX传递数值。
在通常情况下,调用系统调用和调用一个普通的自定义函数在代码上并没有什么区别,但调用后发生的事情有很大不同。
调用自定义函数是通过call
指令直接跳转到该函数的地址,继续运行。
调用系统调用,是调用系统库中为该系统调用编写的一个接口函数,叫 API(Application Programming Interface)。API 并不能完成系统调用的真正功能,它要做的是去调用真正的系统调用,过程是:
(1)把系统调用的编号存入 EAX;
(2)把函数参数存入其它通用寄存器;
(3)触发 0x80 号中断(int 0x80)。
linux-0.11 的 lib
目录下有一些已经实现的 API。Linus 编写它们的原因是在内核加载完毕后,会切换到用户模式下,做一些初始化工作,然后启动 shell
。而用户模式下的很多工作需要依赖一些系统调用才能完成,因此在内核中实现了这些系统调用的 API。
通过硬件实现内核的数据不能被随意的调用,不能随意的jmp。否则,操作系统就会不安全。
系统调用实际上提供了一种进入内核的手段。
将内存分割成内核段
和用户段
两个区域。内核态可以访问任何数据,但用户态不能访问内核数据。只有当前的指令大于或等于目标的特权级,这条指令才被允许执行。
不论是内核段
还是用户段
都需要通过段寄存器进行访问,主要使用了两个段寄存器CPL
和DPL
来实现不同权限的控制。其中CPL
存放在CS中,DPL
存放在GDT
中。当想访问其他段时,会从GDT中查询目标段的DPL来和当前所执行段CS中的CPL进行对比。若合法则允许访问,若不合法则不允许访问。
保护模式中最重要的一个思想就是通过分级把代码隔离了起来
,不同的代码在不同的级别 ,使大多数情况下都只和同级代码发生关系。 在保护模式下,对一个段的描述则包括3方面因素:[Base Address, Limit, Access],它们加在一起被放在一个64-bit长的数据结构中,被称为段描述符。
Intel 的80286以上的CPU可以识別4个特权级(或特权层) ,0级到3级
。数值越大特权越小
。一般用把系统内核放在0级,系统的其他服务程序位于1、2级,3级则是应用软件。一般情况下代码都在自己的级别下做自己 的工作,同一级别之间可以相互访问,而一般是不允许不同级别的代码间随意访问的。但有时候不同级别的程序之间一定要访问,比如系统的接口函数等,必须能够使得应用程序能够随意调用。0
表示内核态,3
表示用户态。
DPL(Descriptor Privilege Level):描述符特权
用于描述目标内存段(要跳转访问的目标段)的特权级。 存储在描述符中的权限位,用于描述代码的所属的特权等级,也就是代码本身真正的特权级。一个程序可以使用多个段(Data,Code,Stack)也可以只用一个code段等。正常的情况下,当程序的环境建立好后,操作系统已经初始化好了DPL,段描述符都不需要改变——当然DPL也不需要改变,因此每个段的DPL值是固定。DPL在GDT中,一个GDT表的表项用于描述一段内存。OS中区域无论是数据段还是代码段,GDT表中对应的DPL均为0。
CPL(Current Privilege Level):当前任务特权
用于描述当前的执行内存段的特权级。 它的特权级是3
,表示用户态
。
中断是进入内核的唯一方法,该方法通过硬件来实现。因此,如果用户程序
想要进入内核,就需要包含一段int指令
的代码,这段代码由库函数实现,由宏来展开成一段汇编代码。进入内核之后,操作系统就会写中断处理过程,来获取想调程序的编号
。然后,操作系统会根据编号执行相应的代码。
在include/linux/sys.h
中
#define __NR_write 4
在lib/close.c
中
#define __LIBRARY__
#include
_syscall1(int, close, int, fd)
在include/unistd.h
中
#define _syscall1(type,name,atype,a) \
type name(atype a) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a))); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
将_syscall1(int,close,int,fd)
进行宏展开,可以得到:
int close(int fd)
{
long __res;
__asm__ volatile ("int $0x80"
: "=a" (__res)
: "0" (__NR_close),"b" ((long)(fd)));
if (__res >= 0)
return (int) __res;
errno = -__res;
return -1;
}
格式:
#define _syscall1(定义的函数类型, 定义的函数名, 入口参数类型, 入口参数名)
#define 来定义宏。该命令允许把一个名称指定成任何所需的文本。 在定义了宏之后,无论宏名称出现在源代码的何处,预处理器都会把它用定义时指定的文本替换掉。
#与 ## 是俩个特殊符号,# 表示将一个宏参数变成一个字符串,## 表把俩个字符串粘在一起。惯例将宏名称每个字母采用大写,这有助于区分宏与一般的变量。
0或空表示使用与相应输出一样的寄存器。
a表示使用eax,并编号%0。
拓展资料:
宏函数
C语言宏的定义和宏的使用方法(#define)
内嵌汇编学习
C语言的内嵌汇编
嵌入汇编程序规定把输出和输入寄存器统一按顺序编号,顺序是从输出寄存器序列从左到右从上到下以 “%0” 开始,分别记为 %0 、 %1 、 …%9 。因此,输出寄存器的编号是 %0
(这里只有一个输出寄存器),输入寄存器前一部分 (__NR_##name)
的编号是%1
,而后部分((long)(a)))
的编号1%
。
用宏定义_syscall3
来调用展开。在实现过程中,先将宏__NR_write
(一个系统调用号)存入eax
,将参数fd
存入ebx
,*buf
存入ecx
,count
存入edx
。
输入完参数后,就执行int 0x80
指令,在内核态根据获取到的参数,去执行相应的系统调用函数。执行完内核态程序后,再把eax
中的值置给__res
。最后根据__res
的值,决定执行return (type)__res
或return -1
,返回int write()
的返回值。
int 0x80
触发后,接下来就是内核的中断处理了。上述的int 0x80
的执行过程,实际上就是从IDT表里面取出中断处理函数,然后跳到对应位置去执行。中断处理函数执行完后,再回来把eax
赋给__res
。
首先,了解一下 0.11 处理 0x80 号中断的过程。
在内核初始化时,主函数(在 init/main.c
中,Linux 实验环境下是 main()
,Windows 下因编译器兼容性问题被换名为 start()
)调用了 sched_init()
初始化函数:
void main(void)
{
// ……
time_init();
sched_init();
buffer_init(buffer_memory_end);
// ……
}
sched.c
是内核中有关任务(进程)调度管理的程序,其中包括有关调度的基本函数(sleep_on()、wakeup()、schedule()等)以及一些简单的系统调用函数(比如getpid())。系统时钟中断处理过程中调用的定时函数do_timer()也被放置在本程序中。
sched_init()
在kernel/sched.c
中定义为:
void sched_init(void)
{
// ……
set_system_gate(0x80,&system_call);
}
将0x80
传递给了n
,system_call
函数的地址传递给了addr
。
set_system_gate
是个宏,在include/asm/system.h
中定义为:
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)
其中n
表示中断号,addr
表示地址。
然后,set_system_gate
又调用了_set_gate
这个宏。&idt[n]
(idt是一个全局标量,它是IDT表的起始地址,用n
来找到80号中断对应的表项)会传递给gate_addr
参数。addr
就表示上述的地址,15
和3
分别传到了type
和dpl
。
这段代码主要是初始化IDT表,然后再根据中段指令去查表,跳转到对应地址进行执行。
对应IDT结构
| 处理函数入口点偏移=addr=&system_call | p | 3 | 01110 | |
对应CS:IP结构
| 段选择符=0x0008 | 处理函数入口点偏移=addr=&system_call |
此时DPL=3,而CS=8,IP=&system_call,其中CS的最后两位为0,即CPL=0。
_set_gate
的定义是:
设置门描述符宏
// 根据参数的中段或异常处理过程地址addr、门描述符类型type和特权级信息dpl,设置位于地址 gate_addr 处的门描述符。
// 注意:下面“偏移”值是相对于内核代码或数据段来说的。
// %0 — 由dpl,type组合成的类型标志字;%1 — 描述符低4字节地址
// %2 — 描述符高4字节地址; %3 — edx(程序偏移地址addr); %4 — eax(高字中含有段选择符)
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \ // 将偏移地址低字与选择符组合成描述符低4字节(eax)。
"movw %0,%%dx\n\t" \ // 将类型标志字与偏移高字组合成描述符高4字节(edx)。
"movl %%eax,%1\n\t" \ // 分别设置门描述符的低4字节和高4字节。
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \ // 1111<<13 — 1 1110 0000 0000 0000、0011<<8 — 0011 0000 0000
"o" (*((char *) (gate_addr))), \ // gate_addr — 0x80
"o" (*(4+(char *) (gate_addr))), \
"d" ((char *) (addr)),"a" (0x00080000)) // addr — &system_call
参数:
gate_addr
描述符地址:指定了描述符所处的物理内存地址;
type
描述符类型域值:指明所需设置的描述符类型,type=14(0x0E)表示中断门描述符,type=15(0x0F)表示陷阱门描述符;
dpl
描述符特权级:对应描述符格式中的DPL(Descriptor Privilege Level);
addr
偏移地址:是描述符对应的中断处理过程的32位偏移地址
注:因为中断处理过程属于内核段代码,所以它们的段选择符值均为0x0008(在eax寄存器高字中指定)。
虽然代码看起来挺麻烦,但实际上很简单,就是填写 IDT(中断描述符表),将 system_call
函数地址写到 0x80
对应的中断描述符中,也就是在中断 0x80
发生后,自动调用函数 system_call
。
详细过程
int 0x80
需要根据IDT表找到中断处理函数,然后调到那里去执行,处理完之后再回来,再去执行把eax
赋值给__res
操作。
在初始化的时候,int 0x80
需要通过system_call
来进行处理。该处理通过中断处理门来实现,核心是初始化好IDT。一旦初始化完毕后,后续再遇到80中断时,就直接从IDT中取出相应的中断处理函数(system_call
),然后调到对应地方去执行。
movl %%eax, %1
是将eax
赋给了%1
("o"(*((char*)(gate_addr)))
)。
最后实现将addr
=&system_call
组装到了处理函数入口点偏移,把dpl
=3组装到了DPL,将0x0008
组装到了段选择符。所以现在,CS=8,IP=&system_call。
因为当CS=8时,CS的最后两位CPL就等于00。
总结
在初始化的时候将80号中断的DPL设为3,故意让用户态程序能够进来。进来之后,CPL就会根据CS=8,其中CPL=0,进入到内核态。执行完内核态中代码后,CS的最后两位又会被设置为3,又变成了用户态的东西。
拓展资料:
什么是调用门?
中断描述符(IDT)、任务门、中断门、陷阱门
system.h
中定义了设置或修改描述符/中断门等的嵌入式汇编宏。其中,函数move_to_user_mode()
是用于内核在初始化结束时人工切换(移动)到初始进程(任务0)去执行,即从特权级0代码转移到特权级3的代码中去运行。
使用这种方法进行控制权的转移是由CPU保护机制造成的。CPU允许低级别(例如特权级3)的代码通过调用门或中断、陷阱门来调用或转移到高级别的代码中运行,但反之则不允许。因此内核采用了这种模式IRET返回低级别代码的方法。
!……
! # 这是系统调用总数。如果增删了系统调用,必须做相应修改
nr_system_calls = 72
!……
.globl system_call
.align 2
system_call:
! # 检查系统调用编号是否在合法范围内
cmpl \$nr_system_calls-1,%eax # 调用好如果超出范围就在eax中置-1并对出
ja bad_sys_call
push %ds # 保存原段寄存器值
push %es
push %fs
# 一个系统调用最多可带3个参数,也可不带参数
pushl %edx # 存放第3个参数
pushl %ecx # 存放第2个参数
! # push %ebx,%ecx,%edx,是传递给系统调用的参数
pushl %ebx # 存放第1个参数
! # 让ds, es指向GDT,内核地址空间
movl $0x10,%edx
mov %dx,%ds
mov %dx,%es # ds,es指向内核数据段(全局描述符表中数据段描述符)。
movl $0x17,%edx
! # 让fs指向LDT(局部数据段,局部描述符表中数据段描述符),用户地址空间。指向执行本次系统调用的用户程序的数据段。
mov %dx,%fs
call sys_call_table(,%eax,4) # 间接调用指定功能C函数。调用地址=[sys_call_table + %eax * 4],其中sys_call_table[]是一个指针数组,在include/linux/sys.h中
pushl %eax # 把系统调用返回值入栈
# 查看当前任务的运行状态。如果不在就绪状态(state≠0),则去执行调度程序。
# 如果该任务在就绪状态,但时间片已用完(counter=0),则也去执行调度程序。
movl current,%eax # 取当前任务(进程)数据结构地址-> eax。
cmpl $0,state(%eax) # state
jne reschedule
cmpl $0,counter(%eax) # counter
je reschedule
system_call
用 .globl
修饰为其他函数可见。Windows 实验环境下会看到它有一个下划线前缀,这是不同版本编译器的特质决定的,没有实质区别。
call sys_call_table(,%eax,4) 之前的代码主要实现一些压栈保护,修改段选择子为内核段。
call sys_call_table(,%eax,4) 之后的代码是看看是否需要重新调度,这些都与本实验没有直接关系,此处只关心 call sys_call_table(,%eax,4)
这一句。
注: 每个函数都是4个字节(32位),所以乘上4。
显然,sys_call_table
一定是一个函数指针数组的起始地址,它定义在 include/linux/sys.h
中:
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read, sys_write, ...
call _sys_call_table(,%eax,4)
汇编寻址方法它实际上是:call sys_call_table + 4 * %eax
,其中 eax 中放的是系统调用号(可看作该函数的数组下标),即 __NR_xxxxxx
。sys_call_table
就是基址(是一个函数表)。4
表示每个系统调用对应的函数占四个字节(32位)。当要查找sys_write
(位于第5个函数)时,会设置 eax=4(数组下标从0开始)。
将edx
置为10,然后将dx
和es
都置为10。而都置为10
的原因是,10
的最后两位也是0。
其中,iret
对应的处理函数就放在第四个位置上。
(1)用户调用printf
函数(库函数)。
(2)然后printf
会展开宏定义_syscall3
,查找unistd.h
中对应的系统调用号,将其传入eax
中,等其余寄存器存储好其余参数后,触发int 0x80
中断,调用set_system_gate
函数。此时为用户态,调用set_system_gate
后,会将system_call
在IDT中的DPL
设为3,从而让用户程序可以使用其对应地址作为IP。之后,再将CS:IP中的CPL
设为0,让用户程序可进入内核态。
(3)进入内核态中,调用system_call
来根据eax
查找表system_call_table
中对应的系统调用函数。
(4)根据已在unisted.h
获得的宏定义__NR_write
=4,再调用call sys_call_table(,%eax,4)
指令,即call sys_call_table + 4 * %eax
也就是call sys_write
,来调用目标函数进行执行。
(5)执行完后,将结果存入eax
中,返回到_syscall3
中宏定义的函数,作为其返回值。
用户态中CPL
=3,内核态中DPL
=0。用户程序不能随意的进入内核中调用,要想进去用户必须要先设置系统调用号,通过中断int 0x80
,才能通过接口调用内核程序。想要“穿过”内核的方式是将DPL
也设置为和CPL
相同的数,一旦“穿过”去之后,CPL
就被置为0。在_system_call
里面通过移动查表就会调用sys_whoami
,然后就会跑到内核中真正的sys_whoami()
函数调用。
在linux-0.11/include/unisted.h
中添加系统调用编号,格式__NR__xxxxx
。
注: 在 0.11 环境下编译 C 程序,包含的头文件都在 /usr/include
目录下。如果只在这里修改会报错,后面会说明。
在system_call.s
中增加了两个系统调用,所以将系统调用总数nr_system_calls
更改为74。
这是系统调用总数。如果增删了系统调用,必须做相应修改。
在include/linux/sys.h
中,找到fn_ptr sys_call_table[]
,在里面添加sys_whoami
和sys_iam
这两个函数引用。同时,在上面也添加extern int sys_whami()
和extern int sys_iam()
。
注: 函数在 sys_call_table
数组中的位置必须和 unisted.h
中的__NR_xxxxxx
的值对应上。
在kernel
中创建who.c
并修改
#include
#include
#include
#include
static char str[24];
static unsigned long len;
int sys_iam(const char* name) {
int i, j;
char tmp[24];
for(i = 0; i < 24; i++) {
tmp[i] = get_fs_byte(name + i);
if(tmp[i] == 0) break;
}
len = i + 1;
// clear str
for(j = 0; j < 24; j++) {
str[j] = 0;
}
if(i == 24) {
printk("Length over 23! Please enter again!\n");
return -(EINVAL);
}
// copy
for(i = 0; tmp[i] != 0; i++) {
str[i] = tmp[i];
}
return len;
}
int sys_whoami(char* name, unsigned int size) {
int i;
if(size < len) {
return -(EINVAL);
}
for(i = 0; i < size && str[i] != 0; i++) {
put_fs_byte(str[i], name + i);
}
put_fs_byte(0, name + i);
return len;
}
注: 不能在for循环里定义int i
否则make all
时会报错。
实验内容要求系统调用API在参数不合理时返回-1
并置errno
为EINVAL
。从下面的宏展开可知,errno
是一个存在于用户空间的全局变量,其值是系统调用处理程序返回值的负值,所以系统调用服务例程在参数不合理时应写成return -(EINVAL)
。当传进来的字符串过长,需要return -(EINVAL)。
#define _syscall1(type,name,atype,a) \
type name(atype a) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a))); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
#define _syscall2(type,name,atype,a,btype,b) \
type name(atype a,btype b) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b))); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
Makefile 在代码树中有很多,分别负责不同模块的编译工作。我们要修改的是kernel/Makefile
中的OBJS
和Dependencies
中的内容:
OBJS
处加上who.o
在# Despendencies
下添加who.s who.o: who.c ../include/linux/kernel.h ../include/unistd.h
然后在linux-0.11
下使用make all
就能自动把 who.c
加入到内核中了。
想使用系统调用iam
和whoami
,就需要有相应的系统调用API。在 /usr/include/unistd.h
中,有linus预先写好的系统调用API宏模板 _syscalln()
,其中n表示的是系统调用的参数个数。
#define _syscall1(type,name,atype,a) \
type name(atype a) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a))); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
#define _syscall2(type,name,atype,a,btype,b) \
type name(atype a,btype b) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b))); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
在oslab目录下编写iam.c
,whoami.c
iam.c
/* 有它,_syscall1 等才有效。详见unistd.h */
#define __LIBRARY__
/* 有它,编译器才能获知自定义的系统调用的编号 */
#include "unistd.h"
#include
_syscall1(int, iam, char*, name);
int main(int argc, char* argv[]) {
if(argc <= 1){
printf("input error\n");
return -1;
}
iam(argv[1]);
return 0;
}
whoami.c
#define __LIBRARY__
#include "unistd.h"
#include
_syscall2(int, whoami, char*, name, unsigned int, size);
char name[24] = {};
int main(int argc, char* argv[]) {
whoami(name, 24);
printf("%s\n", name);
return 0;
}
注: 不能用//
来注释,否则在linux0.11中会报错。
然后,将这两个文件以挂载的方式实现宿主机与虚拟机操作系统的文件共享,在 oslab 目录下执行以下命令挂载hdc目录到虚拟机操作系统上。
sudo ./mount-hdc
再通过以下命令将上述两个文件拷贝到虚拟机linux-0.11操作系统/usr/root/
目录下,命令在oslab/
目录下执行:
cp iam.c whoami.c hdc/usr/root
卸载
sudo umount hdc
可以直接在 Linux 0.11 环境下用 vi 编写(别忘了经常执行“sync”以确保内存缓冲区的数据写入磁盘),也可以在 Ubuntu 或 Windows 下编完后再传到 Linux 0.11 下。无论如何,最终都必须在 Linux 0.11 下编译。编译命令是:
gcc -o iam iam.c
gcc -o whoami whoami.c
gcc 的 “-Wall” 参数是给出所有的编译警告信息,“-o” 参数指定生成的执行文件名是 iam。
出现报错,原因是:
之前修改的unistd.h
没有加载到linux-0.11
中,需要打开挂载后,进入hdc/usr/include
中去修改unistd.h
。
再用gcc编译,若无提示信息, 则编译成功
参考资料:
操作系统实验(二)——系统调用
超详细!操作系统实验三 系统调用(哈工大李治军)
哈工大-操作系统实验-李治军-实验2:系统调用