以前学习计算机操作系统的时候也学习过系统调用的三层机制,但是当时都是纯理论学习,没有亲身实践,很多都理解的比较模糊,这里借助老师的方法使用内嵌汇编加深理解。
系统调用
要想理解系统调用的具体含义,我们需要先了解用户态、内核态和中断三个概念。简单的来说:
在用户态下,我们可以运行用户态进程,而在内核态下,我们不仅仅可以运行用户态下的进程,还可以运行更高级别的内核态进程。如果在用户态下我们需要使用内核态下的进程,那么我们可以借助中断操作来从用户态进入内核态。
用户态进程
上述的用户态可能概念比较模糊,这里我们举个栗子:
- C
#include<stdio.h>
int main(){
int input,output,temp;
input=1;
temp=0;
output=input;
printf("%d%d",temp,output);
return 0;
}
- 内嵌汇编
#include<stdio.h>
int main(){
int input,output,temp;
input=1;
asm volatile(
"movl $0, %%eax
"
"movl %%eax, %1
"
"movl %2, %%eax
"
"movl %%eax, %0
"
:"=m"(output),"=m"(temp)
:"r"(input)
:"eax"
);
printf("%d%d",temp,output);
return 0;
}
这个例子是书上用来讲解内嵌汇编代码的写法,这里我们就不再过多讨论。首先我们观察C语言下的代码,我们发现这就是个简单的赋值操作,核心代码为:
temp=0;
output=input;
观察其汇编代码,我们得知这里使用了eax寄存器,将0和intput变量值传递给内存中的temp和output,那么我们把这个过程叫做用户态。可以看到,这里的赋值操作无需借助任何的高级权限,申明变量之后直接赋值就好了。那么什么叫做内核态?
内核态进程
这里我们依然以具体的C语言代码为例。
#include<stdio.h>
#include<time.h>
int main()
{
time_t tt;
struct tm *t;
__asm__ __volatile__(
"movl $0, %%ebx
"
"movl $0xd, %%eax
"
"int $0x80
"
"movl %%eax, %0
"
:"=m"(tt)
);
t = localtime(&tt);
printf("time:%d/%d/%d
",t->tm_year+1900,t->tm_mon+1,t->tm_mday);
return 0;
}
当你的程序需要使用到系统函数time()的时候,我们把这个系统函数以及它的执行过程叫做内核态,因为它使用了高级别指令time。当然,这里的
"movl $0, %%ebx
"
"movl $0xd, %%eax
"
"int $0x80
"
"movl %%eax, %0
"
:"=m"(tt)
语句就包含了从用户态使用系统调用这一特殊中断陷入内核态的整个过程。
中断以及系统调用
想要从用户态进入到内核态不是平白无故就能实现的,这里我们需要借助中断的力量。这里我们仅仅讨论系统调用这一特殊中断。
系统调用中断处理过程
- SAVE_ALL
当中断标志出现时,先保存用户态的cs:eip
&ss:esp
&eflags(current)
至内核栈中,然后将系统调用的中断服务程序的入口加载到cs:eip中,把当前的内核态ss:esp也加载到cpu中。这样,当前cpu的下一条指令即为中断程序的入口。在linux中使用int 0x80
语句来触发系统调用的执行,即执行中断向量0x80所对应的服务system_call
。 - restore_all & INTERRUPT_RETURN
中断结束后,执行restore_all & INTERRUPT_RETURN,此时将pop之前存储的用户态的cs:eip
&ss:esp
&eflags(current)
,从而恢复到之前的用户态中。
至此,系统调用过程就结束了。
API
对应上述的汇编代码,我们给出C语言代码
#include<stdio.h>
#include<time.h>
int main()
{
time_t tt;
struct tm *t;
tt = time(NULL);
t = localtime(&tt);
printf("time:%d/%d/%d
",t->tm_year+1900,t->tm_mon,t->tm_mday);
return 0;
}
这里tt = time(NULL);
就包含了整个内嵌汇编的所有含义,这就是API的作用。API的全称为应用程序编程接口,是一个函数定义,如同这里的time()
,其实libc函数库早已定义好它的系统调用封装例程,所以我们才可以直接拿来用,这就是我们常说的库函数。
link函数的系统调用及参数传递
了解了完整的系统调用的三层机制和API使用,我们挑选了编号为9的link库函数进行实验。
- C
#include<stdio.h>
#include<unistd.h>
int main(){
int ret;
char * oldpath = "time-asm";
char * newpath = "timetest";
ret = link(oldpath,newpath);
if(ret==0)printf("link successfully");
else printf("Unable to link the file");
return 0;
}
- 内嵌汇编
#include<stdio.h>
#include<unistd.h>
int main(){
int ret;
char * oldpath = "time-asm";
char * newpath = "timetest-asm";
asm volatile(
"movl %2, %%ecx
"
"movl %1, %%ebx
"
"movl $0x09, %%eax
"
"int $0x80
"
"movl %%eax, %0"
:"=m"(ret)
:"b" (oldpath),"c"(newpath)
);
if(ret==0)printf("link successfully
");
else printf("Unable to link the file
");
return 0;
}
执行过程如图:
这里的link函数和教材中所举的rename函数传参一样,均为两位。这里我简单说明下link函数以及ln命令。
link
首先link分为两种类型:
- 【硬连接】
硬连接指通过索引节点来进行连接。在Linux的文件系统中,保存在磁盘分区中的文件不管是什么类型都给它分配一个编号,称为索引节点号(Inode Index)。在Linux中,多个文件名指向同一索引节点是存在的。一般这种连接就是硬连接。硬连接的作用是允许一个文件拥有多个有效路径名,这样用户就可以建立硬连接到重要文件,以防止“误删”的功能。其原因如上所述,因为对应该目录的索引节点有一个以上的连接。只删除一个连接并不影响索引节点本身和其它的连接,只有当最后一个连接被删除后,文件的数据块及目录的连接才会被释放。也就是说,文件真正删除的条件是与之相关的所有硬连接文件均被删除。
图中为显示的硬链接数目。
- 【软连接】
另外一种连接称之为符号连接(Symbolic Link),也叫软连接。软链接文件有类似于Windows的快捷方式。它实际上是一个特殊的文件。在符号连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息。
如何实现这两种link?
- link函数
头文件
#include <unistd.h>
函数原型
int link (const char * oldpath,const char * newpath);
说明
link()以参数newpath指定的名称来建立一个新的连接(硬连接)到参数oldpath所指定的已存在文件。如果参数newpath指定的名称为一已存在的文件则不会建立连接。函数在执行成功时则返回0,失败时则返回-1,错误原因存于errno。 link()所建立的硬连接无法跨越不同文件系统,如果需要请改用symlink()。
此函数对应着linux中的ln 'target' 'file'
命令。 - symlink函数
头文件
#include <unistd.h>
函数原型
int symlink(const char *oldpath, const char *newpath);
说明
与link函数的返回值一致,可以跨越不同文件系统。
勘误
- 1.书中一直没有提到关于不同gcc对应不同汇编代码的问题。在书中的实验里,gcc的版本为4.*,而实际中最新版本的gcc已经升级为7.3,所以在对书中提供的内嵌汇编代码进行编译的时候总是会出错。在使用objdump命令对两种不同版本gcc生成的二进制*.o文件进行反汇编的时候,我们发现两种不同版本gcc所生成的汇编文件是不同的。因此我们在实验过程中需要使用低版本的gcc进行编译。
sudo apt install gcc-4.8
sudo apt install lib32gcc-4.8-dev
ln -s /usr/lib/gcc-4.8 /usr/lib/gccl
- 2.书中给出的代码存在一些手误。虽然后面有解析。