Socket与系统调用深度分析——网络程序设计课程第四次作业
本次实验我们将以socket为案例,从linux提供的与soocket有关的库函数逐步追踪到内核函数,以分析从用户态通过系统调用进入内核态这一过程,并分析linux内核源码中与socket有关的内核处理函数的实现。
环境:linux-5.0.1内核 ,32位系统的MenuOS
一、从用户态到内核态——系统调用的完整过程
首先,我们需要弄清楚从用户态通过系统调用进入内核态的这一完整流程。先来看一下有关这一过程的部分名词定义:
用户态:指非特权状态。在此状态下,执行的代码被硬件限定,不能进行某些操作。
内核态:是操作系统内核所运行的模式,运行在该模式的代码,可以无限制地对系统存储、外部设备进行访问
系统调用:为了让应用程序有能力访问系统资源,每个操作系统都提供了一套接口,以供应用程序使用,这就是系统调用。它规定了用户进程进入内核的具体位置。它本身并非内核函数,但它是由内核函数实现,
进入内核后,不同的系统调用会找到各自对应的内核函数(根据系统调用号),这些内核函数被称为系统调用的“服务例程”。
api:即应用程序接口 ,是程序员在用户空间下可以直接使用的函数接口。是一些预定义的函数,比如常用的read()、malloc()、free()、abs()函数等,这些函数都具有一定功能,说明了如何获得一个给定的服务,
跟内核没有必然的联系。
系统调用和api的区别:api是函数的定义,规定了这个函数的功能,跟内核无直接关系。而系统调用是通过中断向内核发请求,实现内核提供的某些服务。有时候,某些API所提供的功能会涉及到与内核空间进行交互。
那么,这类API内部会封装系统调用。而不涉及与内核进行交互的API则不会封装系统调用。
在弄清了相关概念后,我们通过下面的图来系统地、动态地了解从api到内核函数这一过程:
再从代码层面抽象地看一下应⽤程序、封装例程、系统调⽤处理程序及系统调⽤服务例程之间的关系:
用文字描述整个过程:
我们在用户态下调用xyz()(api)时,当运行到int 0x80这条中断指令时,跳转到entry_INT80_32,这是liunx系统中所有系统调用的入口点。entry_INT80_32不是一段普通的函数,它是一段汇编代码,同时,
我们将系统调用号用eax寄存器进行传递,entry_INT80_32通过系统调用号来查询对应的内核处理函数并跳转到相应的内核处理函数执行,完毕后再按顺序逐步返回到用户态。
那么,int 0x80指令是如何知到entry_INT80_32的所在之处的呢?这是在内核的初始化过程完成的。
内核的初始化完成了以下的函数调用过程:
start_kernel > trap_init > idt_setup_traps, 其中start_kernal是内核启动的入口函数,它调用了trap_init,trap_init函数中的这行代码:
SYSG(IA32_SYSCALL_VECTOR, entry_INT80_32),
使得int 0x80指令和entry_INT80_32在内核初始化的过程中完成了绑定,其内容写入idt中。在后续的执行过程中,一旦出现了int 0x80这条中断指令,就会直接跳转到entry_INT80_32去了。trap_init函数中又调用了idt_setup_traps,它完成对中断描述符表的初始化。idt将每个异常或中断向量分别与它们的处理过程联系起来
知道了这一过程,我们不妨在我们上一次实验构建完成的gdb环境中测试一下。
1.首先,我们需要在用户态下查看是否像我们所说涉及到int 0x80指令的使用。由于是在用户态下,因此我们不能像之前那样通过gdb来调试qemu模拟器中的MenuOS内核。我们需要在用户态下执行MenuOS中的程序,
即运行menu目录下的init可执行文件。我们可以用gdb调试它,也可以用objdump命令来反汇编它,查看是否像我们所说的那样具有int $0x80的汇编指令。
使用objdump反汇编init这个可执行文件并将汇编代码重定向至123.txt文件。
查看123.txt文件
确实和我们前面所说,涉及到系统调用确实会执行int $0x80的汇编指令(其实这里也可以用gdb调试汇编代码,更加有说服力)
然后我们分别给start_kernel、trap_init和idt_setup_traps打上断点后输入c指令并执行,看看是否如我们前面所言:
没有问题,gdb的追踪结果与我们描述的过程一致,即按照start_kernel>trap_init_idt_setup_traps的调用关系。
二.逐步追踪到内核函数
弄清楚了系统调用的整个流程, 我们可以开始对linux socket api进行追踪,看一下linux socket api涉及到了哪些系统调用,以及有关这些系统调用的内核处理函数是如何实现的。
1.通过查询下面的系统调用列表
我们发现与linux socket api有关的系统调用可以分为两种,第一类:所有的与socket有关的api全都使用sys_socketcall系统调用。第二类:每一个独立的socket api都对应一个单独的系统调用,
如bind对应sys_bind,listen对应sys_listen。这取决于系统环境。为了测试我们的机器上对于socket api使用的是哪种,我们分两次分别将两类的系统调用函都打上断点试试。
顺便一提,我们这里的环境为上次实验完成后的MenuOS系统,即我们已经将tcp/ip的程序集成进MenuOS中(即我们用qemu加载MenuOS系统后replyhi和hello命令已经存在)。当我们执行replyhi和hello程序时
会涉及到socket api,也就会使用到相关的系统调用。
a.给sys_bind、sys_listen打上断点
按下c继续执行:
我们发现,我们运行完了replyhi程序和hello程序并成功进行网络通信后左边的gdb却并未捕获到断点,看来整个过程并没有执行我们sys_bind和sys_listen系统调用。
b.给sys_socketall打上断点
打上断点后并执行:
这次捕获到了。
第一次捕获到sys_socketcall,对应的内核处理函数为SYSCALL_DEFINE2(socketcall, int, call, unsigned long __user *, args)
第二次捕捉到sys_socketcall,是以太网卡的启动。
第三次捕捉到sys_socketcall。
以上属于内核初始化过程中的sys_socketcall,主要都和网卡的启动有关。
继续执行下去,来看看当我们执行replyhi和hello程序时,内核处理函数具体做了什么。由于sys_socketcall系统调用对应的内核处理函数为SYSCALL_DEFINE2(socketcall, int, call, unsigned long __user *, args)
这个宏函数,我们不妨先去linux的源码中找找看它的具体代码(在net/socket.c中):
SYSCALL_DEFINE2(socketcall, int, call, unsigned long __user *, args) { unsigned long a[AUDITSC_ARGS]; unsigned long a0, a1; int err; unsigned int len; if (call < 1 || call > SYS_SENDMMSG) return -EINVAL; call = array_index_nospec(call, SYS_SENDMMSG + 1); len = nargs[call]; if (len > sizeof(a)) return -EINVAL; /* copy_from_user should be SMP safe. */ if (copy_from_user(a, args, len)) return -EFAULT; err = audit_socketcall(nargs[call] / sizeof(unsigned long), a); if (err) return err; a0 = a[0]; a1 = a[1]; switch (call) { case SYS_SOCKET: err = __sys_socket(a0, a1, a[2]); break; case SYS_BIND: err = __sys_bind(a0, (struct sockaddr __user *)a1, a[2]); break; case SYS_CONNECT: err = __sys_connect(a0, (struct sockaddr __user *)a1, a[2]); break; case SYS_LISTEN: err = __sys_listen(a0, a1); break; case SYS_ACCEPT: err = __sys_accept4(a0, (struct sockaddr __user *)a1, (int __user *)a[2], 0); break; case SYS_GETSOCKNAME: err = __sys_getsockname(a0, (struct sockaddr __user *)a1, (int __user *)a[2]); break; case SYS_GETPEERNAME: err = __sys_getpeername(a0, (struct sockaddr __user *)a1, (int __user *)a[2]); break; case SYS_SOCKETPAIR: err = __sys_socketpair(a0, a1, a[2], (int __user *)a[3]); break; case SYS_SEND: err = __sys_sendto(a0, (void __user *)a1, a[2], a[3], NULL, 0); break; case SYS_SENDTO: err = __sys_sendto(a0, (void __user *)a1, a[2], a[3], (struct sockaddr __user *)a[4], a[5]); break; case SYS_RECV: err = __sys_recvfrom(a0, (void __user *)a1, a[2], a[3], NULL, NULL); break; case SYS_RECVFROM: err = __sys_recvfrom(a0, (void __user *)a1, a[2], a[3], (struct sockaddr __user *)a[4], (int __user *)a[5]); break; case SYS_SHUTDOWN: err = __sys_shutdown(a0, a1); break; case SYS_SETSOCKOPT: err = __sys_setsockopt(a0, a1, a[2], (char __user *)a[3], a[4]); break; case SYS_GETSOCKOPT: err = __sys_getsockopt(a0, a1, a[2], (char __user *)a[3], (int __user *)a[4]); break; case SYS_SENDMSG: err = __sys_sendmsg(a0, (struct user_msghdr __user *)a1, a[2], true); break; case SYS_SENDMMSG: err = __sys_sendmmsg(a0, (struct mmsghdr __user *)a1, a[2], a[3], true); break; case SYS_RECVMSG: err = __sys_recvmsg(a0, (struct user_msghdr __user *)a1, a[2], true); break; case SYS_RECVMMSG: if (IS_ENABLED(CONFIG_64BIT) || !IS_ENABLED(CONFIG_64BIT_TIME)) err = __sys_recvmmsg(a0, (struct mmsghdr __user *)a1, a[2], a[3], (struct __kernel_timespec __user *)a[4], NULL); else err = __sys_recvmmsg(a0, (struct mmsghdr __user *)a1, a[2], a[3], NULL, (struct old_timespec32 __user *)a[4]); break; case SYS_ACCEPT4: err = __sys_accept4(a0, (struct sockaddr __user *)a1, (int __user *)a[2], a[3]); break; default: err = -EINVAL; break; } return err; }
看上去逻辑好像还比较好懂,核心就是一个switch语句,就是根据不同的call来进入不同的分支,从而调用不同的内核处理函数,如__sys_bind, __sys_listen等等内核处理函数。结合我们的replyhi的源码,
我们知道在replyhi的执行过程中,涉及到socket的建立、bind、listen、recv和send,这些不同的系统调用传给SYSCALL_DEFINE2的参数call肯定也是不同的,我们可以利用gdb的打印变量的功能每捕获到一次就打印一次call看看。
在这之前,我们先来看看replyhi和hello大概调用了几个与socket有关的系统调用(这里我们也可以用strace等工具来统计所有涉及到的系统调用):
先来看看main.c的源码,看看这其中的函数调用情况:
int Replyhi() { char szBuf[MAX_BUF_LEN] = "\0"; char szReplyMsg[MAX_BUF_LEN] = "hi\0"; InitializeService(); while (1) { ServiceStart(); RecvMsg(szBuf); SendMsg(szReplyMsg); ServiceStop(); } ShutdownService(); return 0; } int StartReplyhi(int argc, char *argv[]) { int pid; /* fork another process */ pid = fork(); if (pid < 0) { /* error occurred */ fprintf(stderr, "Fork Failed!"); exit(-1); } else if (pid == 0) { /* child process */ Replyhi(); printf("Reply hi TCP Service Started!\n"); } else { /* parent process */ printf("Please input hello...\n"); } } int Hello(int argc, char *argv[]) { char szBuf[MAX_BUF_LEN] = "\0"; char szMsg[MAX_BUF_LEN] = "hello\0"; OpenRemoteService(); SendMsg(szMsg); RecvMsg(szBuf); CloseRemoteService(); return 0; }
1.先看与replyhi有关的
调用关系为main>StartReplyhi>Replyhi,在Replyhi中按顺序调用了InitializeService,ServiceStart,RecvMsg,SendMsg和ServiceStop。
它们均定义在头文件syswrapper.h中。我们依次来看这些函数的定义:
InitializeService: #define InitializeService() PrepareSocket(IP_ADDR,PORT); InitServer();
InitializeService中又调用了PrepareSocket和InitServer,接着看:
PrepareSocket: #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); InitServer: #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);
很显然,(正常情况下)InitializeService这个宏函数依次涉及到了socket创建,bind和listen系统调用。
接下来是ServiceStart:
ServiceStart: #define ServiceStart() int newfd = accept( sockfd, (struct sockaddr *)&clientaddr, &addr_len); if(newfd == -1) { fprintf(stderr,"Accept Error,%s:%d\n", __FILE__,__LINE__); }
涉及到了accept系统调用
然后是RecvMsg:
RecvMsg: #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)); }
涉及到recv的系统调用
继续,SendMsg:
SendMsg: #define SendMsg(buf) ret = send(newfd,buf,strlen(buf),0); if(ret > 0) { printf("rely \"hi\" to %s:%d\n", (char*)inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port)); }
涉及到send的系统调用
最后,ServiceStop:
ServiceStop: #define ServiceStop() close(newfd);
涉及到close有关的系统调用
2.再看与hi有关的函数调用
main>hi,而hi中依次调用了OpenRemoteService,SendMsg,RecvMsg,CloseRemoteService。和上面一样,我们依次来看。
首先是OpenRemoteService:
OpenRemoteService: #define OpenRemoteService() PrepareSocket(IP_ADDR,PORT); InitClient(); int newfd = sockfd;
可以看到OpenRemoteService中又依次调用了PrepareSocket ,InitClient:
OpenRemoteService: #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); InitClient: #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; }
OpenRemoteService中涉及socket的创建和connect系统调用。
然后是SendMsg:这里和上面的replyhi相同涉及到send的系统调用
接下来是RecvMsg,也是同上,涉及到recv的系统调用。
最后是CloseRemoteService,同样涉及到close的系统调用。
OK,到这里我们已经弄清楚了,运行replyhi和hello的完整过程中(假定只输入依次hello并运行)按顺序共涉及到,或者说在宏函数SYSCALL_DEFINE2(socketcall, int, call, unsigned long __user *, args)
中的switch结构中按序依次进入了如下的分支:
replyhi:
case SYS_SOCKET, case SYS_BIND, case SYS_LISTEN,case SYS_ACCEPT ,case SYS_SEND
hi:
case SYS_SOCKET, case SYS_CONNECT, case SYS_SEND, case SYS_RECV
有了这个认识后,我们再在gdb中验证一下:我们还是把断点打在sys_socketcall,让gdb追踪的宏函数SYSCALL_DEFINE2。我们有两个任务要完成:
第一:看看是不是像我们想的那样,一共进入这么多次的系统调用。
第二:打印一下参数call,按照我们的设想,每一个call应该具有不同的数值以进入对应的分支。
打完断点后我们运行到replyhi(这里我们跳过了内核初始化过程中涉及到的socket系统调用)
当我们追踪到系统提示我们输入hello前,我们共捕获到了7次sys_socketcall的系统调用,其中前三次是内核初始化过程中涉及到的,不谈。
剩下的4次调用应该是replyhi程序中,socket的初始化,socket的bind,listen和accept,与我们设想的一致。
其次,我们使用print call命令看看
确实传进了不同的call值以进入不同的分支(这里我只列出了replyhi程序运行过程中的前两个系统调用),call分别为1和2。
继续执行,输入hello并一直运行到gdb处于continue…状态:
截至这里,我们共捕获到9次系统调用(除去内核初始化过程中涉及到的),也就是
replyhi端的socket创建、bind、listen、accept、send
hi端的socket创建、connect、send、recv
与我们设想的完全一致。
在搞清楚了socket系统调用的完整流程后,我们还有最后一个任务,就是看看最终的内核处理函数的源码,也就是以双下划线开头的这些函数,如__sys_socket, __sys_bind, __sys_listen等等。
我们依次给这些内核处理函数打上断点,重新用跟踪:
(进一步验证了上面我们所说的9次系统调用,因为这里我一直让replyhi和hello跑在这,所以不涉及close操作)
二.内核处理函数
从上面的图我们已经可以很清楚地知道一共使用到了哪些内核的处理函数了,他们是:
__sys_socket, __sys_bind, __sys_listen, __sys_accept4, __sys_connect, __sys_sendto和__sys_recvfrom。他们都在net/socket.c文件中。
__sys_bind的源码如下:
__sys_bind: int __sys_bind(int fd, struct sockaddr __user *umyaddr, int addrlen) { struct socket *sock; struct sockaddr_storage address; int err, fput_needed; sock = sockfd_lookup_light(fd, &err, &fput_needed); if (sock) { err = move_addr_to_kernel(umyaddr, addrlen, &address); if (!err) { err = security_socket_bind(sock, (struct sockaddr *)&address, addrlen); if (!err) err = sock->ops->bind(sock, (struct sockaddr *) &address, addrlen); } fput_light(sock->file, fput_needed); } return err; }
这里只列出__sys_bind内核处理函数,具体代码的分析可以参照下面的blog: