实验要求:
- Socket API编程接口之上可以编写基于不同网络协议的应用程序;
- Socket接口在用户态通过系统调用机制进入内核;
- 内核中将系统调用作为一个特殊的中断来处理,以socket相关系统调用为例进行分析;
- socket相关系统调用的内核处理函数内部通过“多态机制”对不同的网络协议进行的封装方法;
请将Socket API编程接口、系统调用机制及内核中系统调用相关源代码、 socket相关系统调用的内核处理函数结合起来分析,并在X86 64环境下Linux5.0以上的内核中进一步跟踪验证。
实验环境:vmware 15.5下的ubuntu16.04虚拟机
基于内核:linux 5.0.1
内核编译方式:x86-64
内核位置:
~/kernel/linux-5.0.1
一、linux socket编程接口
说白了Socket是应用层与TCP/IP协议族通信的中间软件抽象层, 它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
针对套接字的系统数据结构:
1)、套接字API里有个函数socket,它就是用来创建一个套接字。套接字设计的总体思路是,单个系统调用就可以创建任何套接字,因为套接字是相当笼统的。一旦套接字创建后,应用程序还需要调用其他函数来指定具体细节。例如调用socket将创建一个新的描述符条目:
2)、虽然套接字的内部数据结构包含很多字段,但是系统创建套接字后,大多数字段还没有填写。应用程序创建套接字后在该套接字可以使用之前,必须调用其他的过程来填充这些字段。
3)、文件描述符和文件指针的区别:
文件描述符:在Linux系统中打开文件就会获得文件描述符,它是个很小的正整数。每个进程在PCB(Process Control Block)中保存着一份文件描述符表,文件描述符就是这个表的索引,每个表项都有一个指向已打开文件的指针。
文件指针:C语言中使用文件指针做为I/O的句柄。文件指针指向进程用户区中的一个被称为FILE结构的数据结构。FILE结构包括一个缓冲区和一个文件描述符。而文件描述符是文件描述符表的一个索引,因此从某种意义上说文件指针就是句柄的句柄(在Windows系统上,文件描述符被称作文件句柄)。
基本的SOCKET接口函数
在生活中,A要电话给B,A拨号,B听到电话铃声后提起电话,这时A和B就建立起了连接,A和B就可以讲话了。等交流结束,挂断电话结束此次交谈。 打电话很简单解释了这工作原理:“open—write/read—close”模式。
服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。
这些接口的实现都是内核来完成。具体如何实现,可以看看linux的内核
socket()函数
socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
正如可以给fopen的传入不同参数值,以打开不同的文件。创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:
- protofamily:即协议域,又称为协议族(family)。常用的协议族有,AF_INET(IPV4)、AF_INET6(IPV6)、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
- type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等(socket的类型有哪些?)。
- protocol:故名思意,就是指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议(这个协议我将会单独开篇讨论!)。
注意:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。
当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。
bind()函数
正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。
函数的三个参数分别为:
- sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
- addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,如ipv4对应的是:
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
ipv6对应的是:
struct sockaddr_in6 {
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* Scope ID (new in 2.4) */
};
struct in6_addr {
unsigned char s6_addr[16]; /* IPv6 address */
};
Unix域对应的是:
#define UNIX_PATH_MAX 108
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};
- addrlen:对应的是地址的长度。
通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。
网络字节序与主机字节序
主机字节序就是我们平常说的大端和小端模式:不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。
引用标准的Big-Endian和Little-Endian的定义如下:
a) Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
b) Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。
网络字节序:4个字节的32 bit值以下面的次序传输:首先是0~7bit,其次8~15bit,然后16~23bit,最后是24~31bit。
这种传输次序称作大端字节序。 由于TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种次序,因此它又称作网络字节序。
字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,一个字节的数据没有顺序的问题了。
所以:在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序,而不要假定主机字节序跟网络字节序一样使用的是Big-Endian。
由于这个问题曾引发过血案!公司项目代码中由于存在这个问题,导致了很多莫名其妙的问题,所以请谨记对主机字节序不要做任何假定,
务必将其转化为网络字节序再赋给socket。
listen()、connect()函数
如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。
accept()函数
TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
参数sockfd参数sockfd就是上面解释中的监听套接字,这个套接字用来监听一个端口,当有一个客户与服务器连接时,它使用这个一个端口号,而此时这个端口号正与这个套接字关联。当然客户不知道套接字这些细节,它只知道一个地址和一个端口号。参数addr这是一个结果参数,它用来接受一个返回值,这返回值指定客户端的地址,当然这个地址是通过某个地址结构来描述的,用户应该知道这一个什么样的地址结构。如果对客户的地址不感兴趣,那么可以把这个值设置为NULL。参数len如同大家所认为的,它也是结果的参数,用来接受上述addr的结构的大小的,它指明addr结构所占有的字节个数。同样的,它也可以被设置为NULL。
如果accept成功返回,则服务器与客户已经正确建立连接了,此时服务器通过accept返回的套接字来完成与客户的通信。
注意:
accept默认会阻塞进程,直到有一个客户连接建立后返回,它返回的是一个新可用的套接字,这个套接字是连接套接字。
此时我们需要区分两种套接字,
监听套接字: 监听套接字正如accept的参数sockfd,它是监听套接字,在调用listen函数之后,是服务器开始调用socket()函数生成的,称为监听socket描述字(监听套接字)
连接套接字:一个套接字会从主动连接的套接字变身为一个监听套接字;而accept函数返回的是已连接socket描述字(一个连接套接字),它代表着一个网络已经存在的点点连接。
一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。
自然要问的是:为什么要有两种套接字?原因很简单,如果使用一个描述字的话,那么它的功能太多,使得使用很不直观,同时在内核确实产生了一个这样的新的描述字。
连接套接字socketfd_new 并没有占用新的端口与客户端通信,依然使用的是与监听套接字socketfd一样的端口号
read()、write()等函数
万事具备只欠东风,至此服务器与客户已经建立好连接了。可以调用网络I/O进行读写操作了,即实现了网络中不同进程之间的通信!网络I/O操作有下面几组:
- read()/write()
- recv()/send()
- readv()/writev()
- recvmsg()/sendmsg()
- recvfrom()/sendto()
read函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。如果错误为EINTR说明读是由中断引起的,如果是ECONNREST表示网络连接出了问题。
write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数。失败时返回-1,并设置errno变量。 在网络程序中,当我们向套接字文件描述符写时有俩种可能。1)write的返回值大于0,表示写了部分或者是全部的数据。2)返回的值小于0,此时出现了错误。我们要根据错误类型来处理。如果错误为EINTR表示在写的时候出现了中断错误。如果为EPIPE表示网络连接出现了问题(对方已经关闭了连接)。
其它的我就不一一介绍这几对I/O函数了,具体参见man文档或者baidu、Google,下面的例子中将使用到send/recv。
close()函数
在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
close一个TCP socket的缺省行为是把该socket标记为已关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。
注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
二、系统调用机制
系统调用概述
计算机系统的各种硬件资源是有限的,在现代多任务操作系统上同时运行的多个进程都需要访问这些资源,为了更好的管理这些资源进程是不允许直接操作的,所有对这些资源的访问都必须有操作系统控制。也就是说操作系统是使用这些资源的唯一入口,而这个入口就是操作系统提供的系统调用(System Call)。在linux中系统调用是用户空间访问内核的唯一手段,除异常和陷入外,他们是内核唯一的合法入口。
一般情况下应用程序通过应用编程接口API,而不是直接通过系统调用来编程。在Unix世界,最流行的API是基于POSIX标准的。
操作系统一般是通过中断从用户态切换到内核态。中断就是一个硬件或软件请求,要求CPU暂停当前的工作,去处理更重要的事情。比如,在x86机器上可以通过int指令进行软件中断,而在磁盘完成读写操作后会向CPU发起硬件中断。
中断有两个重要的属性,中断号和中断处理程序。中断号用来标识不同的中断,不同的中断具有不同的中断处理程序。在操作系统内核中维护着一个中断向量表(Interrupt Vector Table),这个数组存储了所有中断处理程序的地址,而中断号就是相应中断在中断向量表中的偏移量。
一般地,系统调用都是通过软件中断实现的,x86-32系统上的软件中断由int $0x80指令产生,而128号异常处理程序就是系统调用处理程序system_call(),它与硬件体系有关,在entry.S中用汇编写。x86-64系统上的软件中断通过syscall指令产生,接下来就来看一下Linux下系统调用具体的实现过程。
为什么需要系统调用
linux内核中设置了一组用于实现系统功能的子程序,称为系统调用。系统调用和普通库函数调用非常相似,只是系统调用由操作系统核心提供,运行于内核态,而普通的函数调用由函数库或用户自己提供,运行于用户态。
一般的,进程是不能访问内核的。它不能访问内核所占内存空间也不能调用内核函数。CPU硬件决定了这些(这就是为什么它被称作“保护模式”)。
为了和用户空间上运行的进程进行交互,内核提供了一组接口。透过该接口,应用程序可以访问硬件设备和其他操作系统资源。这组接口在应用程序和内核之间扮演了使者的角色,应用程序发送各种请求,而内核负责满足这些请求(或者让应用程序暂时搁置)。实际上提供这组接口主要是为了保证系统稳定可靠,避免应用程序肆意妄行,惹出大麻烦。
系统调用在用户空间进程和硬件设备之间添加了一个中间层。该层主要作用有三个:
-
它为用户空间提供了一种统一的硬件的抽象接口。比如当需要读些文件的时候,应用程序就可以不去管磁盘类型和介质,甚至不用去管文件所在的文件系统到底是哪种类型。
-
系统调用保证了系统的稳定和安全。作为硬件设备和应用程序之间的中间人,内核可以基于权限和其他一些规则对需要进行的访问进行裁决。举例来说,这样可以避免应用程序不正确地使用硬件设备,窃取其他进程的资源,或做出其他什么危害系统的事情。
-
每个进程都运行在虚拟系统中,而在用户空间和系统的其余部分提供这样一层公共接口,也是出于这种考虑。如果应用程序可以随意访问硬件而内核又对此一无所知的话,几乎就没法实现多任务和虚拟内存,当然也不可能实现良好的稳定性和安全性。在Linux中,系统调用是用户空间访问内核的惟一手段;除异常和中断外,它们是内核惟一的合法入口。
API/POSIX/C库的区别与联系
一般情况下,应用程序通过应用编程接口(API)而不是直接通过系统调用来编程。这点很重要,因为应用程序使用的这种编程接口实际上并不需要和内核提供的系统调用一一对应。
一个API定义了一组应用程序使用的编程接口。它们可以实现成一个系统调用,也可以通过调用多个系统调用来实现,而完全不使用任何系统调用也不存在问题。实际上,API可以在各种不同的操作系统上实现,给应用程序提供完全相同的接口,而它们本身在这些系统上的实现却可能迥异。
在Unix世界中,最流行的应用编程接口是基于POSIX标准的,其目标是提供一套大体上基于Unix的可移植操作系统标准。POSIX是说明API和系统调用之间关系的一个极好例子。在大多数Unix系统上,根据POSIX而定义的API函数和系统调用之间有着直接关系。
Linux的系统调用像大多数Unix系统一样,作为C库的一部分提供如下图所示。C库实现了 Unix系统的主要API,包括标准C库函数和系统调用。所有的C程序都可以使用C库,而由于C语言本身的特点,其他语言也可以很方便地把它们封装起来使用。
从程序员的角度看,系统调用无关紧要,他们只需要跟API打交道就可以了。相反,内核只跟系统调用打交道;库函数及应用程序是怎么使用系统调用不是内核所关心的。
关于Unix的界面设计有一句通用的格言“提供机制而不是策略”。换句话说,Unix的系统调用抽象出了用于完成某种确定目的的函数。至干这些函数怎么用完全不需要内核去关心。区别对待机制(mechanism)和策略(policy)是Unix设计中的一大亮点。大部分的编程问题都可以被切割成两个部分:“需要提供什么功能”(机制)和“怎样实现这些功能”(策略)。
区别
api是函数的定义,规定了这个函数的功能,跟内核无直接关系。而系统调用是通过中断向内核发请求,实现内核提供的某些服务。
联系
一个api可能会需要一个或多个系统调用来完成特定功能。通俗点说就是如果这个api需要跟内核打交道就需要系统调用,否则不需要。
程序员调用的是API(API函数),然后通过与系统调用共同完成函数的功能。
因此,API是一个提供给应用程序的接口,一组函数,是与程序员进行直接交互的。
系统调用则不与程序员进行交互的,它根据API函数,通过一个软中断机制向内核提交请求,以获取内核服务的接口。
并不是所有的API函数都一一对应一个系统调用,有时,一个API函数会需要几个系统调用来共同完成函数的功能,甚至还有一些API函数不需要调用相应的系统调用(因此它所完成的不是内核提供的服务)。
系统调用的实现原理
基本机制
linux系统中64位汇编和32位汇编的系统调用主要有以下不同:
(1)系统调用号不同.比如x86中sys_write是4,sys_exit是1;而x86_64中sys_write是1, sys_exit是60。linux系统调用号实际上定义在/usr/include/asm/unistd_32.h和/usr/include/asm/unistd_64.h中。
(2)系统调用所使用的寄存器不同,x86_64中使用与eax对应的rax传递系统调用号,但是 x86_64中分别使用rdi/rsi/rdx传递前三个参数,而不是x86中的ebx/ecx/edx。
(3)系统调用使用“syscall”而不是“int 80”。
前文已经提到了64位Linux下的系统调用是通过syscall实现的,但是我们知道操作系统会有多个系统调用,而对于同一个中断号是如何处理多个不同的系统调用的?最简单的方式是对于不同的系统调用采用不同的中断号,但是中断号明显是一种稀缺资源,Linux显然不会这么做;还有一个问题就是系统调用是需要提供参数,并且具有返回值的,这些参数又是怎么传递的?也就是说,对于系统调用我们要搞清楚两点:
- 系统调用的函数名称转换。
- 系统调用的参数传递。
首先看第一个问题。实际上,Linux中每个系统调用都有相应的系统调用号作为唯一的标识,内核维护一张系统调用表,sys_call_table,表中的元素是系统调用函数的起始地址,而系统调用号就是系统调用在调用表的偏移量。在x86上,系统调用号是通过eax寄存器传递给内核的。比如fork()的实现:
用户空间的程序无法直接执行内核代码。它们不能直接调用内核空间中的函数,因为内核驻留在受保护的地址空间上。如果进程可以直接在内核的地址空间上读写的话,系统安全就会失去控制。所以,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,这样内核就可以代表应用程序来执行该系统调用了。
通知内核的机制是靠软件中断实现的。首先,用户程序为系统调用设置参数。其中一个参数是系统调用编号。参数设置完成后,程序执行“系统调用”指令。x86-32系统上的软中断由int产生,x86-64系统上的软中断由syscall产生。这个指令会导致一个异常:产生一个事件,这个事件会致使处理器切换到内核态并跳转到一个新的地址,并开始执行那里的异常处理程序。此时的异常处理程序实际上就是系统调用处理程序。它与硬件体系结构紧密相关。
新地址的指令会保存程序的状态,计算出应该调用哪个系统调用,调用内核中实现那个系统调用的函数,恢复用户程序状态,然后将控制权返还给用户程序。系统调用是设备驱动程序中定义的函数最终被调用的一种方式。
从系统分析的角度,linux的系统调用涉及4个方面的问题。
响应函数sys_xxx
响应函数名以“__sys_”开头,后跟该系统调用的名字。
例如
系统调用
fork()
的响应函数是sys_fork()
(见Kernel/fork.c
),
exit()
的响应函数是sys_exit()
(见kernel/fork.
)。
系统调用表与系统调用号——数组与下标
文件~/kernel/linux-5.0.1/arch/sh/include/uapi/asm/unistd_64.h为每个系统调用规定了唯一的编号。
gedit ~/kernel/linux-5.0.1/arch/sh/include/uapi/asm/unistd_64.h
可以看到,linux 5.0.1 x86-64位下的系统调用号共有394个,与socket有关的如下图:
用表格总结如下:
系统调用 | 描述 |
---|---|
socketcall | socket系统调用 |
socket | 建立socket |
bind | 绑定socket到端口 |
connect | 连接远程主机 |
accept | 响应socket连接请求 |
send | 通过socket发送信息 |
sendto | 发送UDP信息 |
sendmsg | 参见send |
recv | 通过socket接收信息 |
recvfrom | 接收UDP信息 |
recvmsg | 参见recv |
listen | 监听socket端口 |
select | 对多路同步I/O进行轮询 |
shutdown | 关闭socket上的连接 |
getsockname | 取得本地socket名字 |
getpeername | 获取通信对方的socket名字 |
getsockopt | 取端口设置 |
setsockopt | 设置端口参数 |
sendfile | 在文件或端口间传输数据 |
socketpair | 创建一对已联接的无名socket |
假设用name表示系统调用的名称,那么系统调用号与系统调用响应函数的关系是:以系统调用号_NR_name
作为下标,可找出系统调用表sys_call_table
(见linux-5.0.1/arch/sh/kernel/syscalls_64.S )中对应表项的内容,它正好是该系统调用的响应函数sys_name
的入口地址。
系统调用表sys_call_table
记录了各sys_name
函数在表中的位置。有了这张表,就很容易根据特定系统调用在表中的偏移量,找到对应的系统调用响应函数的入口地址。系统调用表共256项,余下的项是可供用户自己添加的系统调用空间。
在Linux中,每个系统调用被赋予一个系统调用号。这样,通过这个独一无二的号就可以关联系统调用。当用户空间的进程执行一个系统调用的时候,这个系统调用号就被用来指明到底是要执行哪个系统调用。进程不会提及系统调用的名称。
系统调用号相当关键,一旦分配就不能再有任何变更,否则编译好的应用程序就会崩溃。Linux有一个“未实现”系统调用sys_ni_syscall()
,它除了返回一ENOSYS
外不做任何其他工作,这个错误号就是专门针对无效的系统调用而设的。
因为所有的系统调用陷入内核的方式都一样,所以仅仅是陷入内核空间是不够的。因此必须把系统调用号一并传给内核。在x86-64
上,系统调用号是通过rax
寄存器传递给内核的。在陷人内核之前,用户空间就把相应系统调用所对应的号放入eax
中了。这样系统调用处理程序一旦运行,就可以从rax
中得到数据。其他体系结构上的实现也都类似。
内核记录了系统调用表中的所有已注册过的系统调用的列表,存储在sys_call_table
中。它与体系结构有关,一般在entry.s
中定义。这个表中为每一个有效的系统调用指定了惟一的系统调用号。sys_call_table
是一张由指向实现各种系统调用的内核函数的函数指针组成的表: system_call()
函数通过将给定的系统调用号与NR_syscalls
做比较来检查其有效性。如果它大于或者等于NR syscalls
,该函数就返回一ENOSYS
。否则,就执行相应的系统调用。
进程的系统调用命令转换为syscall中断的过程
宏定义_syscallN()
见(arch/x86/include/asm/unisted.h
)用于系统调用的格式转换和参数的传递。N取0~5之间的整数。
参数个数为N的系统调用由_syscallN()负责格式转换和参数传递。系统调用号放入rax寄存器,启动syscall后,规定返回值送rax寄存器。
系统调用功能模块的初始化
x86-64位系统:start_kernel --> trap_init --> cpu_init --> syscall_init
void syscall_init(void) { wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS); wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64); ...
系统调用的正常执行
用户态程序发起系统调用,对于x86-64位程序应该是直接跳到entry_SYSCALL_64;
64位的系统调用服务例程:
SYM_CODE_START(entry_SYSCALL_64) ... /* IRQs are off. */ movq %rax, %rdi movq %rsp, %rsi call do_syscall_64 /* returns with IRQs disabled */ * [do_syscall_64](https://github.com/torvalds/linux/blob/ab851d49f6bfc781edd8bd44c72ec1e49211670b/arch/x86/entry/common.c#L282)
#ifdef CONFIG_X86_64 __visible void do_syscall_64(unsigned long nr, struct pt_regs *regs) { ... if (likely(nr < NR_syscalls)) { nr = array_index_nospec(nr, NR_syscalls); regs->ax = sys_call_table[nr](regs); ... } #endif
系统调用表的初始化
64位下的sys_call_table 数组都是由如下目录下的代码初始化的。
/linux-5.0.1/arch/x86/entry/entry_64.S
内核如何为各种系统调用服务
当进程需要进行系统调用时,必须以C语言函数的形式写一句系统调用命令。该命令如果已在某个头文件中由相应的_syscallN()
展开,则用户程序必须包含该文件。当进程执行到用户程序的系统调用命令时,实际上执行了由宏命令_syscallN()展开的函数。系统调用的参数由各通用寄存器传递,然后执行syscall,以内核态进入入口地址system_call
。
内核如何为系统调用的参数传递参数
除了系统调用号以外,大部分系统调用都还需要一些外部的参数输人。所以,在发生异常的时候,应该把这些参数从用户空间传给内核。最简单的办法就是像传递系统调用号一样把这些参数也存放在寄存器里。在x86-64系统上,rdi, rsi, rdx, r10,r8,r9按照顺序存放前六个参数。需要六个以上参数的情况不多见,此时,应该用一个单独的寄存器存放指向所有这些参数在用户空间地址的指针。
给用户空间的返回值也通过寄存器传递。在x86-64系统上,它存放在rax寄存器中。接下来许多关于系统调用处理程序的描述都是针对x86-64版本的。基本上,所有体系结构的实现都很类似。
说了这么多关于linux系统调用机制的知识,不如来两张图清晰地说明32位和64位下系统调用的详细过程:
32 位的系统调用:
64 位的系统调用:
三、socket相关系统调用的内核处理函数跟踪分析
本次的socket系统调用内核处理函数的跟踪分析基于上次构建的Menu OS系统,即通过在Menu OS系统上运行TCP客户端/服务器程序,然后用gdb设置断点来跟踪分析socket内核处理函数。
还是先跑起来我们的Menu OS系统,以调试模式运行:
qemu-system-x86_64 -kernel linux-5.0.1/arch/x86/boot/bzImage -initrd rootfs.img -s -S -append nokaslr
在跟踪socket系统调用内核处理函数之前,先设置之前讲的x86-64位下的系统调用过程,看行不行得通:
ok,四个断点都出现了,再次验证了x86-64位系统下的系统调用初始化过程:start_kernel --> trap_init --> cpu_init --> syscall_init,接下来若系统调用正常执行,会直接跳到entry_SYSCALL_64,如下图设置断点:
这便是linux x86-64位系统调用的通用过程,由于本次实验主要分析socket系统调用及内核处理函数的源码,所以对这四个通用的系统调用便不再分析了,下面主要通过设置断点跟踪socket相关函数:
查看Menu OS根目录rootfs.img的主要组成源文件main.c
在开始跟踪之前有必要看看main.c中的服务端replyhi和客户端hello是如何通过socket通信的,打开main.c,由于程序较长,现主要给出socket通信的主要部分:
cd ~/kernel/menu gedit main.c
1 #include"syswrapper.h" 2 #define MAX_CONNECT_QUEUE 1024 3 int Replyhi() 4 { 5 char szBuf[MAX_BUF_LEN] = "\0"; 6 char szReplyMsg[MAX_BUF_LEN] = "hi\0"; 7 InitializeService(); 8 while (1) 9 { 10 ServiceStart(); 11 RecvMsg(szBuf); 12 SendMsg(szReplyMsg); 13 ServiceStop(); 14 } 15 ShutdownService(); 16 return 0; 17 } 18 19 int StartReplyhi(int argc, char *argv[]) 20 { 21 int pid; 22 /* fork another process */ 23 pid = fork(); 24 if (pid < 0) 25 { 26 /* error occurred */ 27 fprintf(stderr, "Fork Failed!"); 28 exit(-1); 29 } 30 else if (pid == 0) 31 { 32 /* child process */ 33 Replyhi(); 34 printf("Reply hi TCP Service Started!\n"); 35 } 36 else 37 { 38 /* parent process */ 39 printf("Please input hello...\n"); 40 } 41 } 42 43 int Hello(int argc, char *argv[]) 44 { 45 char szBuf[MAX_BUF_LEN] = "\0"; 46 char szMsg[MAX_BUF_LEN] = "hello\0"; 47 OpenRemoteService(); 48 SendMsg(szMsg); 49 RecvMsg(szBuf); 50 CloseRemoteService(); 51 return 0; 52 }
可以看到Replyhi里调用了先后调用了 InitializeService()、ServiceStart()、RecvMsg()、SendMsg()、ServiceStop()、shutdownService();hello先后调用了OpenRemoteService()、SendMsg()、RecvMsg()、CloseRemoteService();那这些函数里面又调用了什么socket接口呢,再看看头文件 syswrapper.h里面定义了什么吧:
gedit syswrapper.h
#define PORT 5001 #define IP_ADDR "127.0.0.1" #define MAX_BUF_LEN 1024 /* private macro */ #define PrepareSocket(addr,port) \ int sockfd = -1; \ struct sockaddr_in serveraddr; \ struct sockaddr_in clientaddr; \ socklen_t addr_len = sizeof(struct sockaddr); \ serveraddr.sin_family = AF_INET; \ serveraddr.sin_port = htons(port); \ serveraddr.sin_addr.s_addr = inet_addr(addr); \ memset(&serveraddr.sin_zero, 0, 8); \ sockfd = socket(PF_INET,SOCK_STREAM,0); #define InitServer() \ int ret = bind( sockfd, \ (struct sockaddr *)&serveraddr, \ sizeof(struct sockaddr)); \ if(ret == -1) \ { \ fprintf(stderr,"Bind Error,%s:%d\n", \ __FILE__,__LINE__); \ close(sockfd); \ return -1; \ } \ listen(sockfd,MAX_CONNECT_QUEUE); #define InitClient() \ int ret = connect(sockfd, \ (struct sockaddr *)&serveraddr, \ sizeof(struct sockaddr)); \ if(ret == -1) \ { \ fprintf(stderr,"Connect Error,%s:%d\n", \ __FILE__,__LINE__); \ return -1; \ } /* public macro */ #define InitializeService() \ PrepareSocket(IP_ADDR,PORT); \ InitServer(); #define ShutdownService() \ close(sockfd); #define OpenRemoteService() \ PrepareSocket(IP_ADDR,PORT); \ InitClient(); \ int newfd = sockfd; #define CloseRemoteService() \ close(sockfd); #define ServiceStart() \ int newfd = accept( sockfd, \ (struct sockaddr *)&clientaddr, \ &addr_len); \ if(newfd == -1) \ { \ fprintf(stderr,"Accept Error,%s:%d\n", \ __FILE__,__LINE__); \ } #define ServiceStop() \ close(newfd); #define RecvMsg(buf) \ ret = recv(newfd,buf,MAX_BUF_LEN,0); \ if(ret > 0) \ { \ printf("recv \"%s\" from %s:%d\n", \ buf, \ (char*)inet_ntoa(clientaddr.sin_addr), \ ntohs(clientaddr.sin_port)); \ } #define SendMsg(buf) \ ret = send(newfd,buf,strlen(buf),0); \ if(ret > 0) \ { \ printf("send \"hi\" to %s:%d\n", \ (char*)inet_ntoa(clientaddr.sin_addr), \ ntohs(clientaddr.sin_port)); \ }
一步步分析总结得出基于TCP的replyhi和hello的通信过程:即replyhi先后调用了linux socket接口中的socket()、bind()、listen()、accept()、recv()、send()、close();hello先后调用了socket()、connect()、send()、recv()、close(),完美,这不就是基于TCP的C/S通信编程吗?口说无凭,实践是检验真理的唯一标准,现在一起用gdb追踪分析这些socket接口系统调用相应的内核处理函数吧!
用GDB追踪分析socket接口内核处理函数
先跑起来我们部署好TCP通信的Menu OS系统:
qemu-system-x86_64 -kernel linux-5.0.1/arch/x86/boot/bzImage -initrd rootfs.img -append nokaslr -s //这次不加S,即让系统先跑,不用一开始暂停
打开一个新的命令行,用gdb连接Menu OS服务器,端口1234,开始调试Menu OS系统:
gdb file linux-5.0.1/vmlinux target remote:1234
设置断点:由于不知道用的哪个send和recv内核处理函数,所以多设置两个关于send、recv的断点;
b __sys_socket
b __sys_bind
b __sys_listen
b __sys_connect
b __sys_accept4
b __sys_recvmsg
b __sys_sendmsg
b __sys_recvfrom
b __sys_sendto
b __sys_shutdown
查看断点:
info breakpoints
如图所示:
现在在gdb中按c运行Menu OS,然后在Menu OS中打开服务器,即输入replyhi:
可以看到服务器运行到第一个断点__sys_socket函数处停止,在gdb中输入list或 l 可以看到函数源代码,继续往下运行:
replyhi一直运行到__sys_accept4处停止,等待Menu OS继续运行,这时候服务端已经调用__sys_accept4,准备好接受客户端的连接请求了,现在需要在Menu OS中输入hello打开客户端,如下图:
很好,gdb又捕捉到了__sys_socket断点,只不过这次是客户端hello的__sys_socket内核处理函数,go on!
追踪完毕,发现后续hello客户端发出请求连接__sys_connect,与服务器建立连接,然后客户端、服务器互发消息,即调用了__sys_recvfrom和__sys_sendto,通过以上的跟踪发现简直与TCP下的C/S socket通信如出一辙,如果想看相应的内核处理函数,只需在断点处输入list即可,比如__sys_socket源码如下:(其他函数便不再一一给出了,可以自己去看)
int __sys_socket(int family, int type, int protocol) 1327 { 1328 int retval; 1329 struct socket *sock; 1330 int flags; 1331 (gdb) 1332 /* Check the SOCK_* constants for consistency. */ 1333 BUILD_BUG_ON(SOCK_CLOEXEC != O_CLOEXEC); 1334 BUILD_BUG_ON((SOCK_MAX | SOCK_TYPE_MASK) != SOCK_TYPE_MASK); 1335 BUILD_BUG_ON(SOCK_CLOEXEC & SOCK_TYPE_MASK); 1336 BUILD_BUG_ON(SOCK_NONBLOCK & SOCK_TYPE_MASK); 1337 1338 flags = type & ~SOCK_TYPE_MASK; 1339 if (flags & ~(SOCK_CLOEXEC | SOCK_NONBLOCK)) 1340 return -EINVAL; 1341 type &= SOCK_TYPE_MASK; (gdb) 1342 1343 if (SOCK_NONBLOCK != O_NONBLOCK && (flags & SOCK_NONBLOCK)) 1344 flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK; 1345 1346 retval = sock_create(family, type, protocol, &sock); 1347 if (retval < 0) 1348 return retval; 1349 1350 return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK)); 1351 }
总结:
本次的实验带领大家学习了linux socket编程接口的机制以及其背后的系统调用和内核函数处理过程;之前我们都是直接用这些socket接口进行网络程序设计,殊不知其背后隐藏着诸多“秘密”,原来操作系统为我们当了免费苦力,这个时候情不自禁地佩服写OS内核的前辈,正是有了OS才让网络编程变得如此简单,相信大家做完这个实验后都能对socket网络编程及背后的系统调用有着更深一步的理解,知识无界,望大家保持好奇心,继续探索~~~