12.程序员的自我修养---系统调用与API

12.1 系统调用介绍
	系统调用是应用程序(运行库也是应用程序的一部分)与操作系统内核之间的接口,它决定了应用程序是如何与内核打交道的。
	Windows 系统是完全基于 dll 机制的,它通过与 dll 堆系统调用进行了包装,形成了所谓的 Windows API。应用程序
	所能看到的 Windows 系统的最底层的接口就是 Windows API。

	12.1.1 什么是系统调用
		在现代的操作系统里,程序运行的时候,本身是没有权限访问多少系统资源的。由于系统有限的资源有可能被多个不同的应用程序同时访问,
	  因此,如果不加以保护的话,那么各个应用程序难免产生冲突。所以现代操作系统都将可能产生冲突的系统资源保护起来,阻止应用程序直接访问。
	  这些资源包括文件,网络,IO,各个设备等。举个例子,无论是 Windows 还是 Linux 下,程序员都没有机会去擅自访问磁盘的某扇区上面的
	  数据,而必须通过文件系统;也不能擅自修改任意文件,所有的这些操作都必须经由操作系统所规定的方式来进行。
	    此外,有一些行为,应用程序不借助操作系统是无法办到或者不能有效办到的。例如,如果我们要让程序等待一段时间,不借助操作系统的唯一办法
	  就是使用这样的代码:
	  	int i;
	  	for (i = 0; i < 1000000; ++i) ;
	  	这样实现等待的确可以勉强达到目的,但是在等待的时候会白白浪费cpu时间,造成系统资源的浪费,最大的问题是,它将随着计算机性能的变化而消耗
	  不同的时间,比如在 100MHz 的 cpu 里,这段代码需要耗费1秒,而在 1000MHz 的 cpu 里,可能只需要 0.1 秒,因此用这段代码来实现定时并不是
	  很好的办法。使用操作系统提供的定时器将会更加方便并且有效,因为在任何硬件上,代码执行的效果是一样的。

	    可见,没有操作系统的帮助,应用程序的执行可谓寸步难行。为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些必须由操作系统支持
	  的行为,每个操作系统都会提供一套接口,以供应用程序使用。这些接口往往通过中断来实现,比如,Linux 使用 0x80 号中断作为系统调用的入口,Windows
	  采用 0x2E 号中断作为系统调用的入口。

	    操作系统覆盖的功能很广,有程序运行所必须的支持,例如创建/退出进程和线程,进程内存管理,也有对系统资源的访问,例如文件,网络,进程间通信,
	  硬件设备的访问,也可能有对图形界面的操作的支持,例如 Windows 下的 GUI 机制。

	    系统调用既然作为一个接口,而且是非常重要的接口,它的定义将十分重要。因为所有的应用程序都依赖于系统调用,那么,首先系统调用必须有明确的定义,即
	  每个调用的含义,参数,行为都需要有严格而清晰的定义,这样应用程序(运行库)才可以正确的使用它;其次它必须保持稳定和向后兼容,如果某次系统更新导致系统
	  调用接口发生改变,新的系统调用接口与之前版本完全不同,这是无法想象的,因为所有之前能正常运行的程序都将无法使用。所以操作系统的系统调用往往从一开始
	  定义后就基本不做改变,而仅仅是增加新的调用接口,以保持向后兼容。

	    不过对于 Windos 来讲,系统调用实际上不是它与应用程序的最终接口,而是 API,所以上面这段对系统调用的描述同样适用于 Windows API,我们也暂时可以把
	  API 与系统调用等同起来。

	12.1.2 Linux 系统调用
		在 x86 下,系统调用由 0x80 中断完成,各个通用寄存器用于传递参数,EAX 寄存器用于表示系统调用的接口号,比如 EAX=1表示退出进程(exit);EAX=2表示创建
	  进程(fork);EAX=3 表示读取文件或IO(read);EAX=4 表示写文件或者IO(write)等,每个系统调用都对应于内核源码中的一个函数,它们都是以 'sys_'开头的,比如 
	  exit 调用对应内核中的 sys_exit 函数。当系统调用返回时,EXA 又作为调用结果的返回值。
	    我们可以绕过 glibc的 fopen,fread,fclose 打开读取和关闭文件,而直接使用 open,read,close 来实现文件的读取,使用 write 向屏幕输出字符串。不过由于
	  绕过了 glibc 的文件读取机制,所以所有位于 glibc 中的缓冲,按行读取文件文件等这些机制都没有了,读取的就是文件的原始数据。当然很多时候我们系统获得更高的
	  文件读写性能,直接绕过 glibc 使用系统调用也是一个比较好的方法。
	    man 2 read

	 12.1.3 系统调用的弊端
	 	系统调用完成了应用程序和内核交流的工作,因此理论上只需要系统调用就可以完成一些程序,但是:理论总是成立的,事实上,包括Linux,大部分操作系统的系统调用都
	 有2个特点:
	 	1.使用不便 : 操作系统提的系统调用接口往往过于原始,程序员要了解很多与操作系统相关的细节。如果没有很好的包装,使用起来不方便。
	 	2.各个操作系统之间系统调用不兼容。首先 Windows 系统和 Linux 系统之间的系统调用就基本完全不同,虽然它们的内容很多都一样,但是定义和实现方式不大一样。
	 	  即使是同系列的操作系统的系统调用都不一样,比如Linux和 Unix就不相同。

	 	为了解决这个问题,'万能法则'又可以发挥它的作用了。'解决计算机的问题可以通过增加层来实现',于是运行库挺身而出,它作为系统调用与程序之间的一个抽象层可以保持
	  这样的特点:
	  	1.使用简单。因为运行库本身就是语言级别的,它一般都设计相对比较友好
	  	2.形式统一。运行库有它的标准,叫做标准库,凡是所有遵守这个标准的运行库理论上都是互相兼容的,不会随着操作系统或者编译器的变化而变化。

	  	运行库将不同的操作系统的系统调用包装为统一固定的接口,使得同样的代码,在不同的操作系统下都可以直接编译,并产生一致的效果。这就是源码级别上的可移植性。
	  	但运行库也有运行库的缺点,比如C语言的运行库为了保证多个平台之间能够互相通用,于是它只能取各个平台之间功能的交集。比如Linux和 Windows 都支持文件读写,
	  那么运行库就可以有哦文件读写的功能;但是 Windows 原始支持图形与用户交互系统,而Linux确是不支持的,那么 CRT 就只能把这部分功能省去了。因此,一旦程序用
	  到哪些 CRT 之外的接口,程序就很难保持各个平台之间的兼容性了。
12.2 系统调用原理
	12.2.1 特权级与中断
		现代的 cpu 常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也据此有2种特权级别,分别为用户模式和内核模式,也被称为
	  用户态和内核态。由于有多种特权模式的存在,操作系统就可以让不同的代码运行在不同的模式上,以限制他们的权利,提高稳定性和安全性。普通应用
	  程序运行在用户态的模式下,诸多操作将受到限制,这些操作包括硬件设备,开关中断,改变特权模式等。

	    一般来说,运行在高级特权的代码将自己降级到低特权是允许的,但反过来低特权的代码将自己提示至高特权级则不是轻易就能进行的,否则特权级的
	  作用就有名无实了。在将低特权级的环境转换为高特权级时,必须使用一种较为受控和安全的形式,以防止低特权模式的代码破坏高特权模式代码的执行。

	    系统调用是运行在内核态的,而应用程序基本都是运行在用户态的。用户态的程序如何运行内核态的代码呢?操作系统一般是通过中断(interrupt) 
	  从用户态切换到内核态。什么是中断呢?中断是一个硬件或软件发出的请求,要求 cpu 暂停当前的工作转手去处理更重要的事情。举个例子,当你在编辑
	  文本文件的时候,键盘上的键不断的按下,cpu 如何获知这一点呢?一种方法称为轮询(poll),即 cpu 每隔一段时间(几十到几百毫秒)去询问键盘是否
	  有键被按下,但除非用户是疯狂打字员,否则大部分的轮询行为得到的都是 '没有键被按下的' 回应,这样操作就被浪费掉了。另外一种方法是 cpu 不去
	  理财键盘,当键盘上有键被按下时,键盘上的芯片发送一个信号给 cpu,cpu 接收到信号之后就知道键盘被按下了,然后再去询问键盘别按下的是那一个。
	  这样的信号就是一个中断。	

	    中断一般具有2个属性,一个称谓中断号(从0开始),一个称谓中断处理程序(interrupt service routine,IS)。不同的中断具有不同的中断号,而
	  同一个中断处理程序一一对应一个中断号。在内核中,有一个数组称谓中断向量表(interrupt vector table),这个数组的第n项包含了指向第n号中断
	  的中断处理程序的指针。当中断到来时,cpu 会暂停当前执行的代码,根据中断的中断号,在中断向量表中查找到对应的中断处理程序,并调用它。中断
	  处理程序执行完之后,cpu 会继续执行之前的代码。

	    通常意义上,中断有2种类型,一种称为硬件中断,这种中断来自于硬件的异常或其他事件的发生,如电源掉电,键盘被按下等。另外一种称为软件中断,
	  软件中断通常是一条指令(i386 下是 int),带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行其中的中断处理程序。例如在 i386下,
	  int 0x80 这条指令会调用第 0x80 号中断的处理程序。
	    由于中断号很有限的,操作系统不会舍得用一个中断号来对应一个系统调用,而更倾向于用一个或者少数几个中断号来对应所有的系统调用。例如,i386下 
	  Windows 里绝大多数系统调用都是由 int 0x2e 来触发的,而 Linux 则使用 int 0x80 来触发所有的系统调用。对于同一个中断号,操作系统如何知道哪一个
	  系统调用要被调用呢?和中断一样,系统调用都有一个系统调用号,就像身份标识一样来表明是哪一个系统调用,这个系统调用号通常就是系统调用在系统调用表中的
	  位置,例如 Linux 里 fork 的系统调用号是 2.这个系统调用号在执行 int 指令前会被放置在某个固定的寄存器里,而对应的中断代码会取得这个系统调用号,并
	  且调用正确的函数。以 Linux 的 int 0x80 为例,系统调用号由 exa 来传入的。用户将系统调用号放入 eax,然后使用 int 0x80 调用中断,中断服务程序就可以
	  从 eax 里取得系统调用号,进而调用对应的函数。

    12.2.2 基于 int 的 Linux 的经典系统调用实现
    	用户代码 => fork() => eax=2 int 0x80 => 中断 => 中断向量表 => 查找(0x80) => 0x80 中断服务程序(system_call) => 调用 => 系统调用表 
      => 查表(eax=2) => 系统调用(sys_fork) => 用户代码

      1.触发中断
      	int main()
      	{
      		fork();
      	}
      	fork 函数是一个对系统调用 fork 的封装,可以用下列宏来定义它:
      	_syscall0(pid_t, fork);
      	_syscall0 是一个宏函数,用于定义一个没有参数的系统调用的封装。它的第一个参数为这个系统调用的返回值类型,这里为 pid_t,是一个Linux自定义类型,代表
     进程的id。_syscall0()的第二个参数是系统调用的名称,_syscall0() 展开之后会形成一个与系统调用名称同名的函数。
       #define _syscall0(type, name)
       type name(void)
       {
       	long __res;
       	__asm__ volatile ("int $0x80"
       		: "=a" (__res)
       		: "0" (__RN__##name));
       	__syscall_return(type,__res);
       }

       对于 syscall(pid_t,fork),上面的宏展开为:
       pid_t fork(void)
       {
       	long __res;
       	__asm__ volatile ("int $0x80"
       		: "=a" (__res)
       		: "0" (__NR_fork));

       	__syscall_return(pid_t,__res);
       }

       1.首先 __asm__ 是一个 gcc 关键字,表示接下来要嵌入汇编代码。volatile 关键字告诉gcc 对这段代码不进行任何优化;
       2.__asm__ 的第一个参数是一个字符串,代码汇编代码的文本。这里的汇编代码只有一句: int $0x80,这就是要调用 0x80号中断;
       3.'=a'(__res) 表示用 eax(a表示 eax)输出返回数据并储存在 __res 里。
       4. '0' (__NR_##name) 表示 __NR_##name 为输入,'0' 指示由编译器选择和输入相同的寄存器(即 eax)来传递参数;

       更直观一点,可以把这段汇编改为更为可读的格式:
       main -> fork
       pid_t fork(void)
       {
       	long __res;
       	$eax = __NR_fork
       	int $0x80
       	__res = $exa
       	__syscall_return(pid_t, __res);
       }
       __NR_fork 是一个宏,表示 fork 系统调用的调用号。而 __syscall_return 是另外一个宏。定义如下:
       #define __syscall_return(type, res)
       do {
       	if ((unsigned long) (res) >= (unsigned long)(-125)) {
       		errno = -(res);
       		res = -1;
       	}
       	return (type)(res);
       } while (0)
       这个宏用于检查系统调用的返回值,并把它相应的转化为 C 语言的 errno 错误码。在 Linux 里,系统调用使用返回值传递错误码,如果返回值为负数,那么
     表明调用失败,返回值的绝对值就是错误码。而在c语言里则不然,c语言里的大多数函数都以返回 -1 表示调用失败,而将出错信息存储在一个名为 errno 的全局
     变量(在多线程中,errno 存储于 TLS 中)里。__syscall_return 就负责将系统调用返回的信息存储在 errno 中。

       可见,如果系统调用有1个参数,那么参数通过 EBX 来传入。x86 下 Linux 支持的系统调用至多6个,分别使用6个寄存器传递,它们分别是 EBX,ECX,EDx,ESI,
     EDI和 EBP。
       当用户调用某个系统调用的时候,实际是执行了以上一段汇编代码。cpu 执行到 ini $0x80 时,会保存现场以便恢复,接着会将特权状态切换到内核态。然后 cpu
     便会查找中断向量表中的第 0x80 号元素。

       2.切换堆栈
       	在实际执行中断向量表中的第 0x80 号元素所对应的函数之前,cpu 首先还要进行栈的切换。在 Linux 中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数
      调用,互补干扰。但在应用程序调用 0x80 号中断时,程序的执行流程从用户态切换到内核态,这是程序的当前栈必须也相应的从用户栈切换到内核栈。从中断处理函数中返回时,
      程序的当前栈还要从内核栈切换回用户栈。
        所谓的当前栈,指的是 ESP 的值所在的栈空间。如果 ESP 的值位于用户栈的范围内,那么程序的当前栈就是用户栈,反之亦然。此外,寄存器 SS 的值还应用指向当前栈所在的
      页。所以,将当前栈由用户态切换为内核态的实际行为就是:
      	1.保存当前的 ESP,SS 的值
      	2.将 ESP,SS的值设置为内核栈的相应值
      	反过来,将当前栈由内核切换为用户栈含的实际行为则是:
      	1.恢复原来的 ESP,SS的值
      	2.用户态的 ESP和 SS的值保存在哪里呢?实际上是内核栈上。这一行为是由 i386的中断指令自动由硬件完成的。
      	
      	当 0x80 号中断发生的时候,cpu 除了切入内核态之外,还会自动完成下列几件事:
      	1.找到当前进程的内核栈(每个进程都有自己的内核栈)
      	2.在内核栈中依次压入用户态的寄存器SS,ESP,EFLAGS,CS,EIP

      	而当内核从系统调用中返回的时候,必须调用 iret 指令来回到用户态,iret 指令则会从内核栈里弹出寄存器SS,ESP,EFLAGS,CS,EIP 的值,使得栈恢复到用户态的状态。

      3.中断处理程序
      	在 int 指令合理的切换了栈之后,程序的流程就切换到了中断向量表中记录的 0x80 号中断处理程序。在 Linux 内部的 i386 中断服务流程如下:
      		中断:int x => 中断向量表 => x=0x80, x=0x00(除0),x=0x14(缺页),x=0x02(硬件驱动) => system_call => exa=1 => sys_exit
      	main -> fork -> int 0x80 -> system_call -> system_call_table -> sys_fork()

      	Q : 内核里以 sys 开头的系统调用函数是如何从用户那里获得参数的
      	A : 我们知道用户调用系统调用时,根据系统调用参数数量的不同,依次将参数放入 EBX,ECX,EDX,ESI,EDI 和 EBP 这6个寄存器中传递。

    12.2.3 Linux 的新型系统调用机制
    	sysenter 和 sysexit

    	ldd /usr/bin/ls
		linux-gate.so.1 =>  (0x00007ffebff8c000)
		libselinux.so.1 => /lib64/libselinux.so.1 (0x00007fa071ddc000)
		libcap.so.2 => /lib64/libcap.so.2 (0x00007fa071bd7000)
		libacl.so.1 => /lib64/libacl.so.1 (0x00007fa0719cd000)
		libc.so.6 => /lib64/libc.so.6 (0x00007fa07160c000)
		libpcre.so.1 => /lib64/libpcre.so.1 (0x00007fa0713ab000)
		libdl.so.2 => /lib64/libdl.so.2 (0x00007fa0711a6000)
		/lib64/ld-linux-x86-64.so.2 (0x00007fa07200f000)
		libattr.so.1 => /lib64/libattr.so.1 (0x00007fa070fa1000)
		libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fa070d85000)

		我们可以看到 linux-gate.so.1 没有与任何实际的文件相对应。那么这个共享库是做什么的呢?答案正是 Linux 用于支持新型系统调用的 '虚拟' 共享库。
	  linux-gate.so.1 并不存在实际的文件,它只是操作系统生成的一个虚拟动态共享库(virtual dynamic shared library, VDSO)。这个库总是被加载在地址
	  0xffffe000 的位置上。我们可以通过 Linux 的 proc 文件系统来查看一个可执行程序的内存映像。
	  cat /proc/self/maps

12.1 系统调用介绍

12.2 系统调用原理

12.3 Windows API
    

猜你喜欢

转载自blog.csdn.net/enlyhua/article/details/85094776