絡編程從入門到排錯指南)
最近把03.25那次在Linux上用C做socket網(wǎng)絡通信的完整過程整理了出來。當時的需求不算復雜就是寫一個TCP服務端和客戶端讓兩臺機器能穩(wěn)定地互發(fā)消息中間踩了幾個很典型的坑包括端口被占用、客戶端連接被拒、數(shù)據(jù)讀到一半就斷開之類。今天把整個思路、代碼、調試過程和排查經(jīng)驗都寫成文章給準備上手Linux下C網(wǎng)絡編程的朋友做參考。先說這個內容適合誰看。如果你是剛學完C語言基礎想搞清楚socket()、bind()、listen()這些函數(shù)到底怎么串起來的本文可以直接抄作業(yè)。如果你已經(jīng)在寫簡單的socket程序但老遇到莫名其妙的問題后面幾節(jié)排查思路也會有幫助。文章里的代碼都是完整可編譯的工具鏈只要一臺Linux環(huán)境加gcc就夠了。1.1 什么是socket以及為什么推薦LinuxC的組合很多初學者第一次看到socket這個英文單詞都會懵它本意是“插座”。網(wǎng)絡通信里的socket你可以理解成在一臺機器上開了個“網(wǎng)絡插座”另一臺機器通過這個插座把數(shù)據(jù)遞進來、遞出去。操作系統(tǒng)對內提供文件描述符對外提供網(wǎng)絡數(shù)據(jù)收發(fā)所以你讀socket和讀普通文件都差不多核心都是用read和write或者send/recv這套系統(tǒng)調用。為什么教程和面試題都喜歡用Linux加C因為Linux下socket幾乎是最干凈的系統(tǒng)調用接口沒有Windows那一堆初始化流程也不用擔心運行庫兼容問題。C語言又能直接操作底層結構體比如struct sockaddr_in你清楚看到IP和端口是怎么填入內存的這比Java、Python的封裝版更容易建立正確的網(wǎng)絡編程心智模型。我用過不少高級語言去封裝socket最直觀的感受是用Python寫網(wǎng)絡通信很快但一旦出現(xiàn)TCP半關閉、粘包、字節(jié)序這類底層問題時還是得回到C的視角去分析。這也是為什么我強烈建議新手至少用C完整寫一遍TCP通信把三次握手怎么從代碼里體現(xiàn)、緩沖區(qū)怎么讀、關閉連接時兩端狀態(tài)如何變化全都摸清楚后面學什么框架都輕松。1.2 網(wǎng)絡編程要搞清楚的三個地基概念在寫代碼之前有三個概念必須先理清IP、端口、協(xié)議類型。IP地址相當于城市地址端口相當于具體門牌號。一臺服務器有IP但上面可能同時跑了一堆服務Web用80、SSH用22、數(shù)據(jù)庫用3306。bind()要綁定的就是“本機IP 端口”這個組合??蛻舳讼脒B接服務器也必須指定“目標IP 目標端口”缺一個都連不上。協(xié)議類型這邊最常用的是SOCK_STREAM和SOCK_DGRAM對應TCP和UDP。TCP像打電話先撥號、對方接聽、確認彼此都在然后說一句聽一句掛斷也知道雙方結束。UDP像發(fā)快遞單里的明信片扔進郵筒就完事對方?jīng)]收到你也不知道收件順序也可能亂。你需要實時性還是可靠性直接決定選哪個。還有個很容易被忽略的基礎就是字節(jié)序。網(wǎng)絡上統(tǒng)一用大端序傳輸數(shù)據(jù)而x86機器是小端序所以填端口和IP時要用htons()和htonl()轉換。我第一次寫服務端沒加htons()結果本機自己連自己沒問題換到另一臺機器去連就完全不通排錯排了半天查出來就是字節(jié)序沒轉。這個細節(jié)后面還會再展開。2.1 環(huán)境準備gcc安裝與防火墻端口檢查寫C socket不需要什么重型IDE命令行加文本編輯器就夠了。我習慣在Ubuntu或者CentOS這類發(fā)行版上操作先確認gcc是不是裝好了。gcc --version沒裝的話Ubuntu/Debian用apt install gccCentOS/RHEL系用yum install gcc。裝好之后寫代碼用vim、nano都可以。我這里使用vim因為習慣問題你完全可以用任何順手編輯器。還有一件容易忽略的事如果你在云服務器上跑服務端不僅本地要開端口防火墻也要放行對應端口。比如服務端監(jiān)聽8888端口在CentOS上要執(zhí)行firewall-cmd --permanent --add-port8888/tcp firewall-cmd --reload如果是在本機做實驗Linux默認防火墻大概率不會攔截localhost上的socket但養(yǎng)成檢查防火墻的習慣能省去后面聯(lián)調時“代碼沒報錯但客戶端死活連不上”的困擾。2.2 核心API速查從socket到close的調用鏈條這組函數(shù)是TCP通信的骨干。我整理成表格你可以先存著后面看代碼時對照理解函數(shù)作用關鍵參數(shù)/返回值socket()創(chuàng)建套接字AF_INET指定IPv4SOCK_STREAM指定TCP返回文件描述符bind()給服務端套接字綁定IP與端口地址結構體指針失敗返回-1常見原因是端口被占用listen()服務端進入監(jiān)聽狀態(tài)第二個參數(shù)是連接隊列長度常見寫法5或128accept()從隊列里取一個客戶端連接返回新的套接字描述符后續(xù)收數(shù)據(jù)用這個新fdconnect()客戶端發(fā)起連接需填寫服務器IP、端口的sockaddr_insend()/recv()TCP可靠收發(fā)數(shù)據(jù)相比write/read多了flags參數(shù)通常傳0read()/write()從套接字讀寫返回值為讀到/寫入的字節(jié)數(shù)0表示對端關閉close()關閉一個套接字關閉后fd不可再使用這些函數(shù)之間的調用順序很像流水線服務端先socket()再bind()固定身份然后listen()表示接受外部連接最后進入循環(huán)accept()處理客戶端。客戶端只需要socket()和connect()連接成功就能收發(fā)數(shù)據(jù)了。所有返回值為負數(shù)的系統(tǒng)調用都需要立刻檢查絕不能忽略。我在練習時最喜歡犯的錯就是漏掉錯誤分支導致程序跑到一半崩潰回頭檢查代碼才發(fā)現(xiàn)某個關鍵函數(shù)根本沒判斷失敗。perror()會直接打印錯誤描述一行就能定位問題。3.1 服務端實現(xiàn)流程與代碼解析下面是一份最簡單的單連接TCP服務端循環(huán)接收客戶端消息并固定回一句“Hello from server”。#include stdio.h #include string.h #include stdlib.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8888 #define MAX_MSG 1024 int main() { int server_fd, client_fd; struct sockaddr_in server_addr, client_addr; socklen_t addr_len sizeof(client_addr); char buf[MAX_MSG]; server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket); exit(1); } memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); if (bind(server_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(bind); close(server_fd); exit(1); } if (listen(server_fd, 5) 0) { perror(listen); close(server_fd); exit(1); } printf(Server listening on port %d...\n, PORT); while (1) { client_fd accept(server_fd, (struct sockaddr*)client_addr, addr_len); if (client_fd 0) { perror(accept); continue; } printf(Client connected: %s:%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); ssize_t n read(client_fd, buf, MAX_MSG - 1); if (n 0) { perror(read); close(client_fd); continue; } buf[n] 0; printf(Received: %s\n, buf); write(client_fd, Hello from server\n, 18); close(client_fd); } close(server_fd); return 0; }逐段解析一下。socket(AF_INET, SOCK_STREAM, 0)創(chuàng)建的是IPv4的TCP套接字第三個參數(shù)協(xié)議填0表示讓系統(tǒng)自動選擇TCP。bind里的INADDR_ANY是個宏展開就是0.0.0.0表示監(jiān)聽本機所有網(wǎng)卡。如果你只想讓本機訪問可以改成inet_addr(127.0.0.1)這樣外部網(wǎng)卡就不會暴露這個服務。listen(server_fd, 5)的第二個參數(shù)是隊列長度也就是允許有多少客戶端連接排隊等待accept()處理。實際項目中會更長但做演示5就夠了。accept()一返回client_fd就是和這個客戶端綁定的新套接字。這里有個細節(jié)要強調后面收發(fā)數(shù)據(jù)必須用client_fd而不是server_fd。server_fd始終負責繼續(xù)接收新連接如果搞混了通信就會出錯。inet_ntoa(client_addr.sin_addr)把二進制的IP轉成點分十進制字符串打印出來方便觀察客戶端來源。read()讀取的是TCP流返回字節(jié)數(shù)可能比一次請求少也可能多粘了幾條消息這就是TCP流式協(xié)議的特點。演示場景里一次請求很小所以直接按一次讀取處理。真正的項目要處理粘包我后面會在第4節(jié)講。3.2 客戶端實現(xiàn)流程與代碼解析客戶端比服務端簡單很多主要就是三步創(chuàng)建套接字、發(fā)起連接、收發(fā)數(shù)據(jù)。#include stdio.h #include string.h #include stdlib.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8888 #define MAX_MSG 1024 int main(int argc, char *argv[]) { int sock_fd; struct sockaddr_in server_addr; char buf[MAX_MSG]; if (argc ! 2) { fprintf(stderr, Usage: %s server_ip\n, argv[0]); exit(1); } sock_fd socket(AF_INET, SOCK_STREAM, 0); if (sock_fd 0) { perror(socket); exit(1); } memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(PORT); if (inet_pton(AF_INET, argv[1], server_addr.sin_addr) 0) { perror(inet_pton); exit(1); } if (connect(sock_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(connect); exit(1); } printf(Connected to server %s:%d\n, argv[1], PORT); write(sock_fd, Hello from client, 17); ssize_t n read(sock_fd, buf, MAX_MSG - 1); if (n 0) { buf[n] 0; printf(Server replied: %s\n, buf); } close(sock_fd); return 0; }inet_pton是“點分十進制字符串轉網(wǎng)絡字節(jié)序二進制IP”的推薦函數(shù)比老式的inet_addr更穩(wěn)妥。connect()的第三個參數(shù)是服務器地址結構體的大小別小看這個參數(shù)填錯就會返回Invalid argument??蛻舳瞬恍枰猙ind()因為系統(tǒng)會在connect()時自動分配一個臨時端口給本地套接字。你甚至可以用getsockname()查看自動分配到的端口不過演示代碼用不到不多說。3.3 編譯運行與本地聯(lián)調把服務端代碼保存為server.c客戶端保存為client.c分別編譯gcc -o server server.c gcc -o client client.c先啟動服務端./server正常會輸出Server listening on port 8888...再開一個終端運行客戶端./client 127.0.0.1客戶端會打印Connected to server 127.0.0.1:8888和Server replied: Hello from server服務端會打印收到消息和客戶端IP端口。如果不想寫客戶端程序也可以用系統(tǒng)自帶工具測試。telnet 127.0.0.1 8888連上去輸入一句話服務端照樣能打印出來?;蛘哂胣c 127.0.0.1 8888一樣可以模擬TCP連接。這些工具在排錯時特別方便能幫你把問題定位在“server代碼問題”還是“client代碼問題”上。4.1 從單線程到多路復用fork、select、epoll選型上面這份代碼有個明顯的瓶頸同一時間只能服務一個客戶端。它循環(huán)accept()后立刻阻塞在read()上如果一個客戶端連上就一直不給服務器發(fā)數(shù)據(jù)后來的客戶端就算連上了也排不進隊列表現(xiàn)就是卡死。解決思路主要有三種第一種是多進程或多線程。服務端每accept()到一個連接就fork()一個子進程去處理父進程繼續(xù)accept()。代碼簡單直觀但連接多了以后進程/線程開銷大上下文切換也會吃掉性能。適合連接數(shù)量少、需求簡單的小工具。第二種是IO多路復用比如select()。它一次監(jiān)聽多個fd哪個fd可讀就往哪個fd讀。select支持數(shù)量有限制通常和FD_SETSIZE相關一般默認1024而且每次調用都要重新設置集合效率不高。適合教學和中等規(guī)模連接。第三種是epollLinux下高性能網(wǎng)絡的標配。但epoll編程復雜度高涉及epoll_create、epoll_ctl、epoll_wait狀態(tài)管理需要很清晰。如果只是想學基礎先弄明白select的理念再升級到epoll會更平滑。我給一個select的簡化思路把server_fd和一堆client_fd放進fd_set調用select(max_fd1, ...)阻塞等待。返回后遍歷所有fd發(fā)現(xiàn)哪個fd可讀就讀哪個。這樣單線程也能同時維護多個連接而且不會互相阻塞。面試題里經(jīng)常讓對比這三種方案你只要記住多進程最直觀select最簡單epoll性能最強。4.2 三大高頻錯誤Address already in use、Connection refused、Broken pipe寫socket代碼下面這幾個報錯我可以說每個人都見過至少一次。第一個是Address already in use。調試時我經(jīng)常CtrlC殺掉服務端然后立刻重啟結果bind()就報這個錯。原因是端口還處于TIME_WAIT狀態(tài)要等幾十秒才能完全釋放。解決辦法是在socket()和bind()之間設置端口復用int opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));加完這行重啟服務端就不會被“上一縷靈魂”擋住了。這個設置在生產(chǎn)環(huán)境幾乎都是必加的否則發(fā)版重啟一次就得等一分鐘特別難受。第二個是Connection refused。客戶端報這個錯基本可以斷定連接請求根本沒到服務端進程面前。排查順序很簡單先確認服務端是否在運行再看端口對不對接著檢查防火墻有沒有放行。三個都沒問題再看INADDR_ANY和客戶端連接的IP是否匹配。有一次我把服務端綁到了127.0.0.1然后客戶端用內網(wǎng)IP去連結果一直Connection refused就是這個原因。第三個是Broken pipe。服務端已經(jīng)關閉了連接客戶端還在往這個socket上寫數(shù)據(jù)系統(tǒng)就會向進程發(fā)送SIGPIPE信號。這個信號的默認行為是終止進程。所以網(wǎng)絡服務端程序常用signal(SIGPIPE, SIG_IGN)忽略它然后靠send()/write()的返回值判斷連接已經(jīng)斷開再做清理工作。還見過一種情況是客戶端正常結束后服務端繼續(xù)read()返回0。read()返回0不代表出錯而是表示對端已經(jīng)正常關閉了寫端。服務端代碼必須處理這個分支及時close()否則fd會一直堆積最終把進程的文件描述符耗盡。4.3 C語言網(wǎng)絡細節(jié)字節(jié)序、返回值檢查和TCP邊界前面提過字節(jié)序這里再具體演示一下。在x86機器上整數(shù)的小端存儲是這樣的數(shù)字0x1234實際內存是34 12。而網(wǎng)絡傳輸要求大端即內存順序是12 34。所以填端口必須寫server_addr.sin_port htons(PORT);htons是“host to network short”的縮寫。IP地址因為是32位用htonl()。如果不轉換本機直接回環(huán)訪問時因為雙方都是小端可能碰巧能通但跨機器就會出大問題。這也是我前面說的那個坑。返回值檢查再強調一遍所有socket相關函數(shù)幾乎都返回標準確認型函數(shù)socket、bind、listen、connect返回值小于0就是失敗數(shù)據(jù)型函數(shù)read、recv、write、send返回值是實際字節(jié)數(shù)可能為0。我見過不少人的代碼只檢查了前者的負值卻忽略了后者為0的情況導致程序進入死循環(huán)。一個健壯的通訊循環(huán)必須對n 0和n 0分別處理。TCP是個字節(jié)流協(xié)議沒有消息邊界。客戶端發(fā)送“Hello”和“World”服務端一次read()可能讀到“HelloWorld”也可能只讀到“Hell”。這就是所謂的粘包問題。解決方案通常是在消息前加固定長度的包頭里面存消息長度服務端先讀完包頭再按長度讀完整包。這是網(wǎng)絡編程進階會遇到的第一個硬骨頭建議你后面專門研究。5.1 三件套調試工具gdb、strace、netstat寫socket最容易發(fā)生的問題是“代碼不報錯但行為不對”。這時候光靠printf效率太低得用工具。gdb是最基礎的進程調試器。比如服務端卡在accept()可以用gdb -p 進程號掛上去執(zhí)行bt看調用?;蛘遡nfo threads看線程狀態(tài)。gdb還能對正在運行的程序加斷點不過實際生產(chǎn)環(huán)境很少這樣干因為會打斷線上服務。strace是我非常喜歡的一個系統(tǒng)調用跟蹤工具。它能打印進程執(zhí)行了哪些系統(tǒng)調用以及每個調用的參數(shù)和返回值。比如懷疑accept()沒收到連接執(zhí)行strace -f -p 服務端進程ID就能看到accept是否在阻塞等待或者read是否返回了-1。網(wǎng)絡問題十有八九都能從系統(tǒng)調用層面看穿。netstat和更新的ss用來查看當前端口狀態(tài)。我排錯的第一步永遠是ss -antlp | grep 8888輸出里能看到LISTEN狀態(tài)說明服務端正常監(jiān)聽。能看到ESTABLISHED說明客戶端已經(jīng)連上。如果服務端程序已啟動但這里查不到端口那多半就是bind()或防火墻的問題。5.2 新手避坑速查表我把平時答疑時遇到的高頻問題整理成一張表可以直接對照?,F(xiàn)象常見原因排查/解決bind報Address already in use端口還在TIME_WAIT加SO_REUSEADDR或等一會兒再啟動connect報Connection refused服務端沒起來/端口不對/監(jiān)聽在別的IP查ss -antlp確認監(jiān)聽地址是否為0.0.0.0客戶端能連上但收不到數(shù)據(jù)服務端read/write邏輯錯誤strace跟蹤服務端或加日志打印并發(fā)一高就出錯單線程accept串行處理改用多線程或select/epoll程序莫名退出SIGPIPE信號觸發(fā)默認終止signal(SIGPIPE, SIG_IGN)數(shù)據(jù)多包亂序/半包TCP字節(jié)流無邊界消息加包頭按長度接收自己連自己能通別人連不上綁定了127.0.0.1監(jiān)聽INADDR_ANY或0.0.0.0這張表也是我自己的排查清單碰到問題先查表能省很多時間。5.3 避坑之外的實操體會最后分享一點我自己的感受。03.25那天調試時踩得最深的一個坑是在服務器上寫完服務端本地Windows上用客戶端去連結果怎么都連不上。前后看了所有常規(guī)原因都沒用最后才發(fā)現(xiàn)是云平臺的安全組只開了22和80端口8888從來沒放行。所以從這個經(jīng)歷里我總結出一個固定順序代碼先在同一臺機器自測通再跨機器測試跨機器測試不通先查防火墻和安全組再查代碼。還有一個小技巧調試時用nc -vz 127.0.0.1 8888測試端口是否開放。它能快速告訴你端口通不通輸出來斷點進一步排查。這個小命令在面試和日常運維里也很常用建議順手記住。如果你正好也在學習Linux下的C socket網(wǎng)絡編程可以從這份代碼開始自己試著改成多客戶端并發(fā)或者加上簡單的包頭協(xié)議。每改一次你都會對TCP通信有多一層理解。