本次實驗我們將以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: