一文看懂socket編程
1.網路模型的設計模式
1.1 B/S模式
B/S: Browser/Server,瀏覽器/伺服器模式,在一端部署伺服器,在另外外一端使用默認配置的瀏覽器即可完成數據的傳輸。
B/S結構是隨著互聯網的發展,web出現後興起的一種網路結構模式。這種模式統一了客戶端,讓核心的業務處理在服務端完成。你只需要在自己電腦或手機上安裝一個瀏覽器,就可以通過web Server與資料庫進行數據交互
- 優點:跨平台移植性好、將系統功能實現的核心部分集中到伺服器上,簡化了系統的開發、維護和使用。
- 缺點:安全性較差,不能快取大量數據,且要嚴格遵守http協議
1.2 C/S模式
C/S: Client/Server,客戶/伺服器模式伺服器通常採用高性能的PC、工作站或小型機,並採用大型資料庫系統,如ORACLE、SYBASE、InfORMix或 SQL Server。客戶端需要安裝專用的客戶端軟體。通過將任務合理分配到Client端和Server端,降低了系統的通訊開銷,可以充分利用兩端硬體環境的優勢。
我們常用的微信、QQ等應用程式就是C/S結構。
- 優點:安全性能可以很容易保證。(因為只有兩層的傳輸,而不是中間有很多層),傳輸速度和響應速度很快、可以在客戶端本地事先快取大量數據、協議靈活。
- 缺點:需要對客戶端和發服務端開發,工作量大,用戶群固定,護成本高,發生一次升級,則所有客戶端的程式都需要改變
2.預備知識
2.1 socket套接字的概念
在linux系統下,所有資源都以文件形式存在,socket是用來表示進程間網路通訊的特殊文件類型,本質是linux內核藉助緩衝區形成的偽文件。
既然是文件,所以我們就可以使用文件描述符引用套接字,用於網路進程間的數據傳遞。
2.2 網路進程之間是如何進行通訊的
- TCP/IP協議中利用IP地址唯一標識一台主機。
- IP地址 + 埠號 唯一標識一台主機中的唯一進程。
因此,我們利用三元組(ip地址,協議,埠)就可以標識網路的進程了,網路中的進程通訊就可以利用這個標誌與其它進程進行交互。
2.3 主機位元組序和網路位元組序
學習socke地址API,我們首先要了解主機位元組序和網路位元組序。
記憶體中的多位元組數據相對於記憶體地址有大端和小端之分,例如JAVA虛擬機採用打大端位元組序,即低地址高位元組,最高有效位元組在最前面。
比如0x012345

socket地址數據結構
現代的PC大多採用小端位元組序,因此又被稱為主機位元組序,即低位元組地地址,最低有效位元組在最前面。
網路數據流同樣有大端小端之分,那麼如何定義網路數據流的地址呢?
發送主機通常將發送緩衝區中的數據按記憶體地址從低到高的順序發出,接收主機把從網路上接到的位元組依次保存在接收緩衝區中,也是按記憶體地址從低到高的順序保存,因此,網路數據流的地址應這樣規定:先發出的數據是低地址,後發出的數據是高地址。
TCP/IP協議規定,網路數據流應採用大端位元組序,即低地址高位元組,也叫做網路位元組序。
當格式化的數據在兩台使用不同位元組序的逐級之間傳遞時,如果不進行位元組序轉換,則必然會發生錯誤。
為使網路程式具有可移植性,使同樣的C程式碼在大端和小端電腦上編譯後都能正常運行,可以調用以下庫函數做網路位元組序和主機位元組序的轉換。
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
這裡的含義很明確,htol代表「host to network long」,將長整形32bit的主機位元組序轉化為網路位元組序。
如果主機是小端位元組序,這些函數將參數做相應的大小端轉換然後返回,如果主機是大端位元組序,這些函數不做轉換,將參數原封不動地返回
長整形通常用來轉換IP地址,短整型用來轉換埠號。
2.4 IP地址轉換函數
通常情況下我們用點分十進位字元串來表示IPv4地址,十六進位字元串表示IPv6地址,可讀性好,但實際使用中需要把他們轉化成二進位。記錄日誌時,則相反。
下面幾個函數分別完成這些功能。
#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
inet_pton函數將字元串src表示的IP地址(IPv4、Ipv6)轉化成網路位元組序整數表示的IP地址,並存於dst中,af指定地址族(AF_INET/AF_INET6),成功返回0,失敗返回-1並shezhierrno。
inet_ntop成功返回目標存儲單元地址,失敗返回null並設置errno。
2.5 socket地址結構

通用socket地址:
struct sockaddr {
sa_family_t sa_family; /* 地址族, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};
這個通用地址結構體不好用,更多的用的是專用socket地址結構體:sockaddr_in、sockaddr_in6
struct sockaddr_in {
sa_family_t sin_family; /* 地址族:AF_INET*/
uint16_t sin_port; /* 埠號,要用網路位元組序表示*/
struct in_addr sin_addr; /* IPv4地址*/
};
struct in_addr { /* IPv4地址,要用網路位元組序表示 */
u_int32_t s_addr;
};
struct sockaddr_in6 {
sa_family_t sin6_family; /* 地址族:AF_INET6 */
uint16_t sin6_port; /* 埠號,要用網路位元組序表示 */
uint32_t sin6_flowinfo; /* 流資訊,應設置為0 */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* scope id ,尚處於實驗階段 */
};
struct in6_addr {
unsigned char sa_addr[16]; /* IPv4地址,要用網路位元組序表示 */
};
3.套接字函數
3.1 創建一個socket
UNIX/Linux的一個哲學:所見皆文件。
創建一個socket用到下面函數:
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain:告訴系統使用哪個底層協議族
- AF_INET 這是大多數用來產生socket的協議,使用TCP或UDP來傳輸,用IPv4的地址
- AF_INET6 與上面類似,不過是來用IPv6的地址
- AF_UNIX 本地協議,使用在Unix和Linux系統上,一般都是當客戶端和伺服器在同一台及其上的時候使用。
type:
- SOCK_STREAM 這個協議是按照順序的、可靠的、數據完整的基於位元組流的連接。這是一個使用最多的socket類型,這個socket是使用TCP來進行傳輸。
- SOCK_DGRAM 這個協議是無連接的、固定長度的傳輸調用。該協議是不可靠的,使用UDP來進行它的連接。
- SOCK_SEQPACKET 該協議是雙線路的、可靠的連接,發送固定長度的數據包進行傳輸。必須把這個包完整的接受才能進行讀取。
- SOCK_RAW socket 類型提供單一的網路訪問,這個socket類型使用ICMP公共協議。(ping、traceroute使用該協議)
- SOCK_RDM 這個類型是很少使用的,在大部分的作業系統上沒有實現,它是提供給數據鏈路層使用,不保證數據包的順序
protocol:
- 傳0 表示使用默認協議。
- 返回值:
成功:返回指向新創建的socket的文件描述符,失敗:返回-1,設置errno
socket()打開一個網路通訊埠,如果成功的話,就像open()一樣返回一個文件描述符,應用程式可以像讀寫文件一樣用read/write在網路上收發數據,如果socket()調用出錯則返回-1。對於IPv4,domain參數指定為AF_INET。對於TCP協議,type參數指定為SOCK_STREAM,表示面向流的傳輸協議。如果是UDP協議,則type參數指定為SOCK_DGRAM,表示面向數據報的傳輸協議。protocol參數的介紹從略,指定為0即可。
3.2 bind函數
創建socket時,我們只指定了地址族,並未指定那個具體的socket地址。bind()函數就是將socket套接字與地址綁定。
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:socket文件描述符
- addr:構造出IP地址加埠號
- addrlen:sizeof(addr)長度返回值:
- 成功返回0,失敗返回-1, 設置errno
在linux中我們也可以使用man指令查看這些函數的埠資訊,如man bind
bind()的作用是將參數sockfd和addr綁定在一起,使sockfd這個用於網路通訊的文件描述符監聽addr所描述的地址和埠號。
struct sockaddr *是一個通用指針類型,addr參數實際上可以接受多種協議的sockaddr結構體,而它們的長度各不相同,所以需要第三個參數addrlen指定結構體的長度。如:
struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8888);
埠號一般是0-65536之間,不能超過這個值。
首先將整個結構體清零,然後設置地址類型為AF_INET,網路地址為INADDR_ANY,這個宏表示本地的任意IP地址,因為伺服器可能有多個網卡,每個網卡也可能綁定多個IP地址,這樣設置可以在所有的IP地址上監聽,直到與某個客戶端建立了連接時才確定下來到底用哪個IP地址,埠號為8888。
3.3 listen函數
socket綁定地址後,還不能馬上接受客戶連接,需要使用listen創建監聽隊列一存放待處理的客戶連接。
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
- sockfd:socket文件描述符
- backlog:提示內核監聽隊列的最大長度,默認為256,如果監聽隊列的長度超過backlog,伺服器不再受理新的客戶連接。
- 成功返回0,失敗返回-1
查看系統默認backlog
cat /proc/sys/net/ipv4/tcp_max_syn_backlog
3.4 accept函數
當客戶端發起連接請求時,伺服器調用accept()接受連接,返回一個新的連接socket文件描述符,伺服器可通過讀寫該socket來與被接受連接對應的客戶端通訊。如果伺服器調用accept()時還沒有客戶端的連接請求,就阻塞等待直到有客戶端連接上來。
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- sockfd:socket文件描述符
- addr:傳出參數,返回連接客戶端地址資訊,含IP地址和埠號
- addrlen:傳入傳出參數(值-結果),傳入sizeof(addr)大小,函數返回時返回真正接收到地址結構體的大小
- 返回值:成功返回一個新的socket文件描述符,用於和客戶端通訊,失敗返回-1,設置errno
3.5 connect函數
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:socket文件描述符
- addr:傳入參數,指定伺服器端地址資訊,含IP地址和埠號
- addrlen:傳入參數,傳入sizeof(addr)大小
- 返回值:成功返回0,失敗返回-1,設置errno
客戶端需要調用connect()連接伺服器,connect和bind的參數形式一致,區別在於bind的參數是自己的地址,而connect的參數是對方的地址。
3.6 關閉連接
關閉連接有兩種方式:close和shutdown
#include<unistd,h>
int close(int fd);
close並非總是立即關閉一個連接,而是將fd的引用計數減1,只有當fd的引用計數為0時,才真正關閉連接。多進程程式中,一次fork將使父進程中打開的socket引用計數加1。
如果無論如何都要立即終止連接,可以使用shutdown。
#include<sys/socket.h>
int shutdown( int socfd, int howto);
- howto決定了shutdown的行為,能夠設置分別關閉socket上的讀或寫,或者都關閉。而close是將讀寫全關閉。
4.簡單的C/S模型
下圖是簡單的socket模型創建流程圖,編寫程式就可以直接參考這個框架。

下圖是基於TCP協議的客戶端/伺服器程式的一般流程:

TCP協議通訊流程:
伺服器調用socket()、bind()、listen()完成初始化後,調用accept()阻塞等待,處於監聽埠的狀態,客戶端調用socket()初始化後,調用connect()發出SYN段並阻塞等待伺服器應答,伺服器應答一個SYN-ACK段,客戶端收到後從connect()返回,同時應答一個ACK段,伺服器收到後從accept()返回。
數據傳輸的過程:
建立連接後,TCP協議提供全雙工的通訊服務,但是一般的客戶端/伺服器程式的流程是由客戶端主動發起請求,伺服器被動處理請求,一問一答的方式。因此,伺服器從accept()返回後立刻調用read(),讀socket就像讀管道一樣,如果沒有數據到達就阻塞等待,這時客戶端調用write()發送請求給伺服器,伺服器收到後從read()返回,對客戶端的請求進行處理,在此期間客戶端調用read()阻塞等待伺服器的應答,伺服器調用write()將處理結果發回給客戶端,再次調用read()阻塞等待下一條請求,客戶端收到後從read()返回,發送下一條請求,如此循環下去。
如果客戶端沒有更多的請求了,就調用close()關閉連接,就像寫端關閉的管道一樣,伺服器的read()返回0,這樣伺服器就知道客戶端關閉了連接,也調用close()關閉連接。注意,任何一方調用close()後,連接的兩個傳輸方向都關閉,不能再發送數據了。如果一方調用shutdown()則連接處於半關閉狀態,仍可接收對方發來的數據。
下面給出簡單的C/S模型程式,可實現伺服器從客戶端讀字元,然後將每個字元轉換為大寫並回送給客戶端。
服務端程式:
#include <stdio.h>
#include <ctype.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#define SERV_PORT 8888
void sys_err(const char *str)
{
perror(str);
exit(1);
}
int main(int argc, char *argv[])
{
int lfd = 0, cfd = 0;
int ret, i;
char buf[BUFSIZ], client_IP[1024];
struct sockaddr_in serv_addr, clit_addr; // 定義伺服器地址結構 和 客戶端地址結構
socklen_t clit_addr_len; // 客戶端地址結構大小
serv_addr.sin_family = AF_INET; // IPv4
serv_addr.sin_port = htons(SERV_PORT); // 轉為網路位元組序的 埠號
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 獲取本機任意有效IP
lfd = socket(AF_INET, SOCK_STREAM, 0); //創建一個 socket
if (lfd == -1) {
sys_err("socket error");
}
bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));//給伺服器socket綁定地址結構(IP+port)
listen(lfd, 128); // 設置監聽上限
clit_addr_len = sizeof(clit_addr); // 獲取客戶端地址結構大小
cfd = accept(lfd, (struct sockaddr *)&clit_addr, &clit_addr_len); // 阻塞等待客戶端連接請求
if (cfd == -1)
sys_err("accept error");
printf("client ip:%s port:%d\n",
inet_ntop(AF_INET, &clit_addr.sin_addr.s_addr, client_IP, sizeof(client_IP)),
ntohs(clit_addr.sin_port)); // 根據accept傳出參數,獲取客戶端 ip 和 port
while (1) {
ret = read(cfd, buf, sizeof(buf)); // 讀客戶端數據
write(STDOUT_FILENO, buf, ret); // 寫到螢幕查看
for (i = 0; i < ret; i++) // 小寫 -- 大寫
buf[i] = toupper(buf[i]);
write(cfd, buf, ret); // 將大寫,寫回給客戶端。
}
close(lfd);
close(cfd);
return 0;
客戶端程式:
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#define SERV_PORT 8888
#define BUFFSIZE 1024
void sus_err(const char *str){
perror(str);
exit(1);
}
int main(int argc, char *argv[]){
int cfd;
char buf[BUFFSIZE];
struct sockaddr_in serv_addr; // 伺服器地質結構
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
cfd = socket(AF_INET, SOCK_STREAM, 0);
if(cfd == -1) sys_err("socket error");
int ret = connect(cfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
if(ret != 0) sys_err("socket error");
while(1){
ret = read(cfd, buf, sizeof(buf));
write(STDOUT_FILENO, buf, ret);
sleep(1);
}
close(cfd);
return 0;
}
5.錯誤處理封裝
系統調用不能保證每次都成功,必須進行錯誤處理,這樣一方面可以保證程式邏輯正常,另一方面可以迅速得到故障資訊。
為使錯誤處理的程式碼不影響主程式的可讀性,我們把與socket相關的一些系統函數加上錯誤處理程式碼包裝成新的函數,在新函數裡面處理錯誤,在主程式中就可以直接使用這些封裝過的函數,更加簡潔明了。
#ifndef __WRAP_H_
#define __WRAP_H_
void perr_exit(const char *s);
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr);
int Bind(int fd, const struct sockaddr *sa, socklen_t salen);
int Connect(int fd, const struct sockaddr *sa, socklen_t salen);
int Listen(int fd, int backlog);
int Socket(int family, int type, int protocol);
ssize_t Read(int fd, void *ptr, size_t nbytes);
ssize_t Write(int fd, const void *ptr, size_t nbytes);
int Close(int fd);
ssize_t Readn(int fd, void *vptr, size_t n);
ssize_t Writen(int fd, const void *vptr, size_t n);
ssize_t my_read(int fd, char *ptr);
ssize_t Readline(int fd, void *vptr, size_t maxlen);
#endif
具體封裝函數如下:
點擊查看程式碼
#include <stdlib.h>
#include <errno.h>
#include <sys/socket.h>
void perr_exit(const char *s)
{
perror(s);
exit(1);
}
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr)
{
int n;
again:
if ( (n = accept(fd, sa, salenptr)) < 0) {
if ((errno == ECONNABORTED) || (errno == EINTR))
goto again;
else
perr_exit("accept error");
}
return n;
}
int Bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
int n;
if ((n = bind(fd, sa, salen)) < 0)
perr_exit("bind error");
return n;
}
int Connect(int fd, const struct sockaddr *sa, socklen_t salen)
{
int n;
if ((n = connect(fd, sa, salen)) < 0)
perr_exit("connect error");
return n;
}
int Listen(int fd, int backlog)
{
int n;
if ((n = listen(fd, backlog)) < 0)
perr_exit("listen error");
return n;
}
int Socket(int family, int type, int protocol)
{
int n;
if ( (n = socket(family, type, protocol)) < 0)
perr_exit("socket error");
return n;
}
ssize_t Read(int fd, void *ptr, size_t nbytes)
{
ssize_t n;
again:
if ( (n = read(fd, ptr, nbytes)) == -1) {
if (errno == EINTR)
goto again;
else
return -1;
}
return n;
}
ssize_t Write(int fd, const void *ptr, size_t nbytes)
{
ssize_t n;
again:
if ( (n = write(fd, ptr, nbytes)) == -1) {
if (errno == EINTR)
goto again;
else
return -1;
}
return n;
}
int Close(int fd)
{
int n;
if ((n = close(fd)) == -1)
perr_exit("close error");
return n;
}
ssize_t Readn(int fd, void *vptr, size_t n)
{
size_t nleft;
ssize_t nread;
char *ptr;
ptr = vptr;
nleft = n;
while (nleft > 0) {
if ( (nread = read(fd, ptr, nleft)) < 0) {
if (errno == EINTR)
nread = 0;
else
return -1;
} else if (nread == 0)
break;
nleft -= nread;
ptr += nread;
}
return n - nleft;
}
ssize_t Writen(int fd, const void *vptr, size_t n)
{
size_t nleft;
ssize_t nwritten;
const char *ptr;
ptr = vptr;
nleft = n;
while (nleft > 0) {
if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
if (nwritten < 0 && errno == EINTR)
nwritten = 0;
else
return -1;
}
nleft -= nwritten;
ptr += nwritten;
}
return n;
}
static ssize_t my_read(int fd, char *ptr)
{
static int read_cnt;
static char *read_ptr;
static char read_buf[100];
if (read_cnt <= 0) {
again:
if ((read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
if (errno == EINTR)
goto again;
return -1;
} else if (read_cnt == 0)
return 0;
read_ptr = read_buf;
}
read_cnt--;
*ptr = *read_ptr++;
return 1;
}
ssize_t Readline(int fd, void *vptr, size_t maxlen)
{
ssize_t n, rc;
char c, *ptr;
ptr = vptr;
for (n = 1; n < maxlen; n++) {
if ( (rc = my_read(fd, &c)) == 1) {
*ptr++ = c;
if (c == '\n')
break;
} else if (rc == 0) {
*ptr = 0;
return n - 1;
} else
return -1;
}
*ptr = 0;
return n;
}
參考資料:
- 《linux高性能伺服器編程》游雙 著