文章目錄
- 套接字
- socket 地址
- 通用 socket 地址
- 專用 socket 地址
- 網絡字節序與主機字節序
- 地址轉換
- TCP/UDP 連接中常用的 socket 接口
套接字
什么是套接字?
所謂 套接字 (Socket) ,就是對網絡中 不同主機 上的應用進程之間進行雙向通信的端點的抽象。
UNIX/Linux下一切皆文件。 socket
就是可讀、可寫、可控、可關的文件描述符。socket
最開始的含義是一個 (IP地址,端口) 對 (IP, port)
。唯一地表示了使用 TCP 通信的一端。
為什么要用到套接字?
我們知道,數據鏈路層、網絡層、傳輸層協議是在內核中實現的。而 socket
就是 操作系統 提供給 應用程序 通過 系統調用 訪問這些 協議服務 的一組 API
。socket
不但可以訪問內核中 TCP/IP
協議棧,而且訪問其他網絡協議棧。
socket
定義的API
提供哪些功能?
- 將 應用程序數據 從 用戶緩沖區 中復制到
TCP/UDP
內核發送緩沖區 ,將發送數據交付內核。 - 從
TCP/UDP
內核接收緩沖區 中復制數據到 用戶緩沖區 ,以讀取數據。 - 幫助 應用程序 修改 內核中各層協議的某些頭部信息 或 其他數據結構,從而精確地控制底層通信行為。(如:通過
setsockopt函數
來設置IP 數據報
在網絡上的存活時間)
socket
的主要 API
都定義在 sys/socket.h
頭文件中。Linux
提供了一套定義在 netdb.h
頭文件中的網絡信息 API
,以實現 主機名 和 IP地址 之間的轉換,以及服務名稱和端口號之間的轉換。
socket 地址
存儲 socket
的地址信息的數據結構有三種:sockaddr
、 sockaddr_in
和 sockaddr_un
。我們將 sockaddr
稱為 通用 socket 地址 ,將 后兩者 稱為 專用 socket 地址 。這樣劃分的意義在于:在使用時,我們可以選擇自己所需要的結構,通信時再將我們所使用的結構強轉為 sockaddr
,這樣就能保證數據格式的一致。
通用 socket 地址
sockaddr 的定義如下:
#include<bits/socket.h>
struct sockaddr
{sa_family_t sa_family; // 地址族類型(sa_family_t)的變量。char sa_data[14]; // 存放 socket 地址值。
};
地址族類型通常與協議族類型對應:
協議族 | 地址族 | 描述 | 地址值含義和長度 |
---|---|---|---|
PF_UNIX | AF_UNIX | UNIX本地協議族 | 文件的路徑名,長度可達108字節 |
PF_INET | AF_INET | TCP/IPv4協議族 | 16 bit 端口號和 32 bit IPv4 地址,共6字節 |
PF_INET6 | AF_INET6 | TCP/IPv6協議族 | 16 bit 端口號,32 bit 流標識,128 bit IPv6 地址,32 bit 范圍ID,共26字節 |
宏 PF_*
和 AF_*
都定義在 bits/socket.h
頭文件中,且有完全相同的值,因此二者經常混用。
然而,通用的 sockaddr
對于各個協議族而言適用性并不好—— sa_data
把 目標IP地址 和 端口信息 混在一起了。因此,Linux
為各個協議族提供了專門的 socket
地址結構體。
拓展: 由上表易知,14
字節的 sa_data
無法容納多數協議族的地址值。因此,Linux
定義了一個新的通用 socket
地址結構體:
#include<bits/socket.h>
struct sockaddr_storage
{sa_family_t safamily;unsigned long int __ss_align;char __ss_padding[128-sizeof(__ss_ag=lign)];
};
這個結構體不僅提供了足夠大的空間用于存放地址值,而且是內存對齊的(這是 __ss_align
成員的作用)。
專用 socket 地址
sockaddr_un 的定義如下:
UNIX 本地域協議族 使用 sockaddr_un
地址結構體:
#include<sys/un.h>
struct sockaddr_un
{sa_family_t sin_family; // 地址族:AF_UNIXchar sun_path[108]; // 文件路徑名
};
sockaddr_in 的定義如下:
TCP/IP協議族 中 IPv4
使用 sockaddr_in
地址結構體:
struct sockaddr_in
{sa_family_t sin_family; /* 地址族:AF_INET */u_int16_t sin_port; /* 端口號,要用網絡字節序表示 */struct in_addr sin_addr; /* IPv4地址結構體 */char sin_zero; /* 不使用 */
};struct in_addr
{u_int32_t s_addr; /* 32位 IPv4 地址,要用網絡字節序表示 */
};
可以清楚看到,該結構體解決了 sockaddr
的缺陷,把 port
和 addr
分開儲存在兩個變量中。sockaddr_in
和 sockaddr
長度一樣,都是 16
個字節,即 占用的內存大小是一致的 ,因此可以互相轉化。二者是并列結構,指向 sockaddr_in
結構的指針也可以指向 sockaddr
。
sockaddr
和 sockaddr_in
是 Linux網絡編程中最常用的 socket
結構體,sockaddr_in
用于 socket
定義和賦值;sockaddr
用于函數參數。 一般先把 sockaddr_in
變量賦值后,強制類型轉換后傳入 參數為 sockaddr
的函數。
sockaddr_in6 的定義如下:
TCP/IP協議族 中 IPv6
使用 sockaddr_in6
地址結構體:
struct sockaddr_in6
{sa_family_t sin6_family; /* 地址族:AF_INET6 */u_int16_t sin6_port; /* 端口號,要用網絡字節序表示 */u_int32_t sin6_flowinfo; /* 流信息,應設置為0 */struct in6_addr sin6_addr; /* IPv6地址結構體 */u_int32_t sin6_scope_id; /* scope ID,尚處于實驗階段 */
};struct in6_addr
{unsigned char sa_addr[16]; /* 128位 IPv6 地址,要用網絡字節序表示 */
};
網絡字節序與主機字節序
在使用網絡協議的編程中,在兩臺使用不同存儲模式(大端/小端)的主機之間傳遞數據時,往往會產生歧義。解決問題的方法是:
- 發送端總是把要發送的數據轉化成大端字節序數據后再發送
- 接收端根據自己的字節序決定是否將傳送過來的數據進行轉換(自身模式為小端則轉換,為大端則不轉換)
因此將 大端字節序 稱為 網絡字節序 ,小端字節序 稱為 主機字節序 。
Linux
提供了如下 4
個函數來完成 主機字節序 和 網絡字節序 之間的轉換:
#include<netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostlong);
unsigned long int ntotl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);/* h代表主機字節序,n代表網絡字節序,l代表長整型,s代表短整型 */
上述函數中,長整型函數 通常用來轉換 IP地址 ,短整型函數 用來轉換 端口號 。
地址轉換
記錄日志時,我們習慣用 可讀性好的字符串 來表示 IP地址;編程時,我們往往更需要以 整數(二進制數) 形式表示 IP地址。而這種頻繁切換的需求需要通過函數來滿足:
#include <arpa/inet.h>
in_addr_t inet_addr(const char *strptr);
// 將點分十進制的 字符串IP地址 轉換為網絡字節序的 整數IP地址 ,失敗時返回INADDR_NONE
char *inet_ntoa(struct in_addr in);
// 將網絡字節序的 整數IP地址 轉換為點分十進制的 字符串IP地址,該函數內部用一個靜態變量存儲轉化結果
// 函數的返回值指向該靜態內存,因此 inet_ntoa 是不可重入的。
int inet_aton(const char *cp, struct in_addr *inp);
// 將點分十進制的 字符串IP地址 轉換為網絡字節序的 整數IP地址(與addr的區別它會認為如255.255.255.255這類特殊地址有效),成功返回1,失敗返回0。
in_addr_t inet_network(const char *cp);
// 將點分十進制的 字符串IP地址 轉換為主機字節序的 整數IP地址
struct in_addr inet_makeaddr(in_addr_t net, in_addr_t host);
in_addr_t inet_lnaof(struct in_addr in);
in_addr_t inet_netof(struct in_addr in);/* 同時適用于 IPv4 和 IPv6: */
int inet_pton(int af, const char* src, void* dst);
// 將 字符串 表示的IP地址src(用點分十進制表示的IPv4地址或用十六進制字符表示的IPv6地址)轉換成 網絡字節序整數 表示的IP地址
// 轉換結果存在dst指向的內存中。af指定地址族(AF_INET 或 AF_INET6),成功返回1、失敗返回0并設置errno。
const char* inet_ntop(int af, const void* src, char* dst, socklen_t cnt);
// 將 網絡字節序的整數IP地址 轉換為 點分十進制的字符串IP地址,成功返回目標存儲單元的地址、失敗返回NULL并設置errno。
// cnt指定目標存儲單元的大小,通過下面兩個宏來指定大小:
#include<netinet/in.h>
#define INET_ADDRSTRLEN 16 // 用于 IPv4
#define INET6_ADDRSTRLEN 46 // 用于 IPv6
不可重入的 inet_ntoa 函數
struct in_addr addr1,addr2;
ulong l1,l2;
l1 = inet_addr("1.1.1.1");
l2 = inet_addr("127.0.0.1");
memcpy(&addr1, &l1, 4);
memcpy(&addr2, &l2, 4);
char *cp1 = inet_ntoa(addr1);
char *cp2 = inet_ntoa(addr2);
cout << "address 1: " << cp1 << endl;
cout << "address 2: " << cp2 << endl;
輸出結果:
我們會發現,由于 inet_ntoa
的返回值指向一個函數內部的靜態內存,因此最后一次傳入的參數會掩蓋之前的參數。
TCP/UDP 連接中常用的 socket 接口
1. 創建 socket
#include<sys/types.h>
#include<sys/socket.h>
int socket(int domain, int type, int protocol);
domain:底層協議族
type:服務類型
/* 服務類型主要有 */
// SOCK_STREAM服務(流服務),對于 TCP/IP 協議族而言,表示傳輸層使用 TCP 協議。
// SOCK_UGRAM服務(數據報),對于 TCP/IP 協議族而言,表示傳輸層使用 UDP 協議。
/* 拓展 */
// Linux內核版本2.6.17起,type可以是上述兩種類型與下面兩個標志的 與值
// SOCK_NONBLOCK 將新創建的 socket 設置為非阻塞的。
// SOCK_CLOEXEC 用 fork 調用創建子進程時在子進程中關閉該 socket
// 2.6.17版本前,文件描述符的這兩個屬性都需要使用額外的系統調用(如 fcntl)來設置。
protocol:通常是唯一的(前兩個參數已經完全確定了它的值),大部分情況下被設為0,表使用默認協議。返回值:系統調用成功返回一個 socket 文件描述符,失敗返回 -1 并設置 errno。
2. 命名/綁定 socket
創建 socket
時,我們給他指定了地址族,但是 并未指定使用該地址族中哪個具體地址 。 將一個 socket
與具體的地址綁定稱為給 socket
命名。
- 我們通常要給服務器命名
socket
,因為只有命名后客戶端才知道如何連接服務器。 - 客戶端不需要命名
socket
,它通常使用操作系統自動分配的socket
地址。
#include<sys/types.h>
#include<sys/socket.h>
int bind(int sockfd, const struct sockaddr* my_addr, sklen_t addrlen);
// 將 my_addr 所指的 socket 地址分配給未命名的 sockfd 文件描述符,addrlen 指出該地址的長度。
// 成功返回0、失敗返回-1并設置errno。
// 其中兩種常見errno:
// EACCES:被綁定的地址是受保護的地址,僅超級用戶能夠訪問。如:普通用戶將 socket 綁定到知名服務端口(端口號0~1023)。
// EADDRINUSE:被綁定的地址正在使用中。如:將 socket 綁定到一個處于 TIME_WAIT 狀態的 socket 地址。
3. 監聽 socket
socket
被命名之后,還不能立馬接受客戶連接,需要使用系統調用來創建一個 監聽隊列 以存放待處理的客戶連接(同時有 全連接 和 半連接 ):
#include<sys/socket.h>
int listen(int sockfd, int backlog);
// sockfd 指定被監聽的 socket;backlog 提示內核監聽隊列的最大長度(常設為5),超過 backlog+1 則不受理新的客戶連接,客戶端也收到 ECONNREFUSED 錯誤信息。
// Linux內核2.2版本之前,backlog 指的是所有處于 半連接狀態(SYN_RCVD)和 完全鏈接狀態(ESTABISHED)的 socket 總上限。
// 2.2版本之后,它中表示處于 完全連接狀態 的 socket 的上限。半連接狀態 的 socket 上限值由 /proc/sys/ipv4/tcp_max_syn_backlog 內核參數定義。
// 成功返回0;失敗返回-1并設置errno
4. 接受連接
accept
可以從 全連接隊列 中接受一個客戶連接,但 accept
只是從監聽隊列中取出連接,而不關心連接處于何種狀態(ESTABLISHED
或 CLOSE_WAIT
),更不關心任何網絡狀態的變化(取出的客戶端可能掉線了)。
#include <sys/types,h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 將 addr 所指的 socket 地址分配給執行過 listen 系統調用的 socket 文件描述符,地址長度為 addrlen。
// 成功時返回一個新的 連接socket,唯一地標識被接受的這個連接,服務器可通過讀寫該 socket 與被接受連接對應的客戶端通信;失敗返回-1并設置errno。
5. 發起連接
服務器 通過 listen
系統調用來 被動 接受連接,客戶端 通過 connect
系統調用來 主動 與服務器建立連接。
#include<sys/types.h>
#include<sys/socket.h>
int connetct(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
// sockfd:socket 系統調用返回的文件描述符。
// serv_addr:處于服務器監聽隊列的客戶端 socket 地址
// 成功返回0,sockfd 唯一標識這個鏈接,客戶端可以通過讀寫 sockfd 與服務器通信;失敗返回-1并設置errno。
其中兩種常見的errno是:
- ECONNREFUSED:目標端口不存在,連接被拒絕。服務器發送給客戶端一個復位報文段(seq=0),客戶端不必回復復位報文段,應關閉連接或重新連接。
- ETIMEDOUT:連接超時。進行若干次重連,每次重連超時時間都增加一倍。
6.斷開連接
關閉連接就是關閉連接對應的 socker
。 可以通過 close
系統調用來關閉文件描述符。
#include<unistd.h>
int close(int fd);
// fd:待關閉的socket
值得一提的是, close
并非立即關閉一個鏈接,而是將 fd
的引用計數減 1
。當 fd
的引用計數為 0
時,才真正關閉連接。多進程程序中,一次 fork
系統調用默認將使父進程中打開的 socket
的引用計數加 1
,因此必須在 父子進程中都 對該 socket
執行 close
調用才能關閉連接。
如果無論如何都要立刻終止連接,可以使用 shutdown
系統調用:
#include<sys/socket.h> // 從隸屬頭文件可以看出,它是專門為網絡編程設計的
int shutdown(int sockfd, int howto);
// sockfd:待關閉的 socket。
// howto:shutdown 的行為。
// 成功返回0,失敗返回-1并設置errno。
howto
可選值有:
可選值 | 含義 |
---|---|
SHUT_RD | 關閉 sockfd 上讀的這一半。應用程序不能再執行讀操作,并且該 socket 接收緩沖區中的數據都被丟棄。 |
SHUT_WR | 關閉 sockfd 上寫的這一半。sockfd 的發送緩沖區中的數據會在真正關閉連接之前全部發送出去,應用程序不可再執行寫操作。這種情況下,連接處于半關閉狀態。 |
SHUT_RDWR | 同時關閉 sockfd 上的讀和寫 |
close
與 shutdown
最大的不同是,close
關閉連接時只能將 socket
上的讀和寫同時關閉,而 shutdown
可以分別(或同時)關閉。
7. 數據讀寫
對文件的讀寫 write
和 read
同樣適用于 socke
,但 socket
還有專門用于 socket數據讀寫
的系統調用:
/* 用于TCP流數據讀寫的系統調用 */
#include<sys/types>
#include<sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// recv 讀 sockfd 上的數據,send 往 sockfd 上寫數據。
// buf 和 len 分別指定緩沖區的位置和大小。
// flags 為數據收發提供額外控制,通常為0。
// 成功時返回讀/寫的數據長度,失敗返回-1并設置errno。
// recv 成功時返回的長度可能小于 len,因此可能要多次調用 recv 以讀取完整數據;recv 返回 0 意味著 通信對方已經關閉連接。/* UDP數據讀寫 */
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
// buf 和 len 參數分別指定讀/寫緩沖區的位置和大小
// 由于 UDP 通信沒有連接的概念,所以我們每次讀取數據都需要獲取發送端的 socket 地址,即 src_addr 所指向的內容。
// dest_addr 指定接收端的 socket 地址。
/* 上述兩個系統調用也可以用于面向連接(STREAM)的 socket 的數據讀寫,只需將最后兩個參數都設置為 NULL *//* 兼容 TCP流數據 和 UDP數據報 的數據讀寫 */
#include<sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);struct msghdr
{void* msg_name; // socket 地址,對于TCP協議,需設置為NULL。socklen_t msg_namelen; // socket 地址的長度struct iovec* msg_iov; // 分散內存塊,詳情見下文。int msg_iovlen; // 分散內存塊的數量void* msg_control; // 指向輔助數據的起始位置socklen_t msg_controllen; // 輔助數據的大小int msg_flags; // 復制函數中的 flags 參數,recvmsg 還會在調用過程中將某些更新后的標志設置到 msg_flags 中。
}struct iovec
{void *iov_base; // 內存起始地址size_t iov_len; // 這塊內存長度
}
對于 recvmsg 而言,數據將被讀取并存放在 msg_iovlen 塊分散的內存中,這種操作被稱為 分散讀(scatter read)。
對于 sendmsg 而言,分散內存中的數據將被一并發送,這稱為 集中寫(gather write)。
flags
可選值:
選項名 | 含義 | send | recv |
---|---|---|---|
MSG_CONFIRM | 指示數據鏈路層持續監聽對方的回應,直到得到答復。僅能用于 SOCK_DGRAM 和 SOCK_RAW 類型的 socket。 | Y | N |
MSG_DONTROUTE | 不查看路由表,直接將數據發送給本地局域網絡內的主機。用于發送者確定目標主機就在本網絡。 | Y | N |
MSG_DONTWAIT | 對 socket 的此次操作將是非阻塞的 | Y | Y |
MSG_MORE | 告訴內核應用程序還有更多數據要發送,內核將 超時等待 新數據寫入 TCP 發送緩沖區后一并發送。防止 TCP 發送過多小報文段,提高傳輸效率。 | Y | N |
MSG_WAITALL | 讀操作僅在讀取到指定數量的字節后才返回 | N | Y |
MSG_PEEK | 窺探讀緩存中的數據,本次讀操作不會清除這些數據。 | N | Y |
MSG_OOB | 發送或接收緊急數據(帶外數據) | Y | Y |
MSG_NOSIGNAL | 往讀端關閉的管道或 socket 連接中讀寫數據時不引發 SIGPIPE 信號 | Y | N |
flags
參數只對 send
和 recv
的當前調用生效,而后面講到的 setsockopt
系統調用會永久地修改 socket
某些屬性。
7.1 帶外數據
實際應用中,通常無法預知 帶外數據 何時到來,幸運的是 Linux
內核檢測到 TCP
緊急標志(URG)時,將通知應用程序有帶外數據需要接受。通知方法有兩種:
I/O復用
產生的異常事件SIGURG
信號
但應用程序接到通知后也只知道有帶外數據要來,解決的時間的不確定性,但仍不知道帶外數據在數據流中的具體位置。想要知道具體位置可以通過如下系統調用:
#include<sys/socket.h>
int sockatmark(int fockfd);
sockatmark
判斷 sockfd
是否處于帶外標記,即下一個被讀取到的數據是否是帶外數據。若是返回 1
,此時就可以用帶 MSG_OBB
標志的 recv
調用來接收帶外數據;若不是返回 0
。
8. 地址信息函數
我們可以知道一個連接 socket 的 本端socket地址 ,以及 遠端socket地址 。
#include<sys/socket.h>
int getsockname(int sockfd, struct sockaddr* address, socklen_t* address_len); // 獲取 sockfd 對應的socket地址
int getpeername(int sockfd, struct sockaddr* address, socklen_t* address_len); // 遠端socket地址
// 將獲得的地址存于 address 指定的內存中,地址長度存于 address_len 中。
// 如果實際 socket 地址長度大于 address 所指內存區的大小,那么該 socket地址 將被截斷。
// 成功返回0,失敗返回-1并設置errno。
9. 總結
用一張圖總結 TCP三次握手
過程中對 socket接口
的使用。