TCP源碼分析 - 三次握手之 connect 過程
本文主要分析 TCP 協議的實現,但由於 TCP 協議比較復雜,所以分幾篇文章進行分析,這篇主要介紹 TCP 協議建立連接時的三次握手過程。
TCP 協議應該是 TCP/IP 協議棧中最為復雜的一個協議(沒有之一),TCP 協議的復雜性來源於其面向連接和保證可靠傳輸。
如下圖所示,TCP 協議位於 TCP/IP 協議棧的第四層,也就是傳輸層,其建立在網絡層的 IP 協議。
但由於 IP 協議是一個無連接不可靠的協議,所以 TCP 協議要實現面向連接的可靠傳輸,就必須為每個 CS(Client - Server) 連接維護一個連接狀態。由此可知,TCP 協議的連接只是維護了一個連接狀態,而非真正的連接。
由於本文主要介紹 Linux 內核是怎么實現 TCP 協議的,如果對 TCP 協議的原理不是很清楚的話,可以參考著名的《TCP/IP協議詳解》。
三次握手過程
我們知道,TCP 協議是建立在無連接的 IP 協議之上,而為了實現面向連接,TCP 協議使用了一種協商的方式來建立連接狀態,稱為:三次握手
。三次握手
的過程如下圖:
建立連接過程如下:
- 客戶端需要發送一個
SYN包
到服務端(包含了客戶端初始化序列號),並且將連接狀態設置為SYN_SENT
。 - 服務端接收到客戶端的
SYN包
后,需要回復一個SYN+ACK包
給客戶端(包含了服務端初始化序列號),並且設置連接狀態為SYN_RCVD
。 - 客戶端接收到服務端的
SYN+ACK包
后,設置連接狀態為ESTABLISHED
(表示連接已經建立),並且回復一個ACK包
給服務端。 - 服務端接收到客戶端的
ACK包
后,將連接狀態設置為ESTABLISHED
(表示連接已經建立)。
以上過程完成后,一個 TCP 連接就此建立完成。
TCP 頭部
要分析 TCP 協議就免不了要了解 TCP 協議頭部,我們通過下面的圖片來介紹 TCP 頭部的格式:
下面介紹一下 TCP 頭部各個字段的作用:
源端口號
:用於指定本地程序綁定的端口。目的端口號
:用於指定遠端程序綁定的端口。序列號
:用於本地發送數據時所使用的序列號。確認號
:用於本地確認接收到遠端發送過來的數據序列號。首部長度
:指示 TCP 頭部的長度。標志位
:用於指示 TCP 數據包的類型。窗口大小
:用於流量控制,表示遠端能夠接收數據的能力。校驗和
:用於校驗數據包是否在傳輸時損壞了。緊急指針
:一般比較少用,用於指定緊急數據的偏移量(URG
標志位為1時有效)。可選項
:TCP的選項部分。
我們來看看 Linux 內核怎么定義 TCP 頭部的結構,如下:
struct tcphdr {
__u16 source; // 源端口
__u16 dest; // 目的端口
__u32 seq; // 序列號
__u32 ack_seq; // 確認號
__u16 doff:4, // 頭部長度
res1:4, // 保留
res2:2, // 保留
urg:1, // 是否包含緊急數據
ack:1, // 是否ACK包
psh:1, // 是否Push包
rst:1, // 是否Reset包
syn:1, // 是否SYN包
fin:1; // 是否FIN包
__u16 window; // 滑動窗口
__u16 check; // 校驗和
__u16 urg_ptr; // 緊急指針
};
從上面的定義可知,結構 tcphdr
的各個字段與 TCP 頭部的各個字段一一對應。
客戶端連接過程
一個 TCP 連接是由客戶端發起的,當客戶端程序調用 connect()
系統調用時,就會與服務端程序建立一個 TCP 連接。connect()
系統調用的原型如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
下面是 connect()
系統調用各個參數的作用:
sockfd
:由socket()
系統調用創建的文件句柄。addr
:指定要連接的遠端 IP 地址和端口。addrlen
:指定參數addr
的長度。
當客戶端調用 connect()
函數時,會觸發內核調用 sys_connect()
內核函數,sys_connect()
函數實現如下:
int sys_connect(int fd, struct sockaddr *uservaddr, int addrlen)
{
struct socket *sock;
char address[MAX_SOCK_ADDR];
int err;
...
// 獲取文件句柄對應的socket對象
sock = sockfd_lookup(fd, &err);
...
// 從用戶空間復制要連接的遠端IP地址和端口信息
err = move_addr_to_kernel(uservaddr, addrlen, address);
...
// 調用 inet_stream_connect() 函數完成連接操作
err = sock->ops->connect(sock, (struct sockaddr *)address, addrlen,
sock->file->f_flags);
...
return err;
}
sys_connect()
內核函數主要完成 3 個步驟:
- 調用
sockfd_lookup()
函數獲取fd
文件句柄對應的 socket 對象。 - 調用
move_addr_to_kernel()
函數從用戶空間復制要連接的遠端 IP 地址和端口信息。 - 調用
inet_stream_connect()
函數完成連接操作。
我們繼續分析 inet_stream_connect()
函數的實現:
int inet_stream_connect(struct socket *sock, struct sockaddr * uaddr,
int addr_len, int flags)
{
struct sock *sk = sock->sk;
int err;
...
if (sock->state == SS_CONNECTING) {
...
} else {
// 嘗試自動綁定一個本地端口
if (inet_autobind(sk) != 0)
return(-EAGAIN);
...
// 調用 tcp_v4_connect() 進行連接操作
err = sk->prot->connect(sk, uaddr, addr_len);
if (err < 0)
return(err);
sock->state = SS_CONNECTING;
}
...
// 如果 socket 設置了非阻塞, 並且連接還沒建立, 那么返回 EINPROGRESS 錯誤
if (sk->state != TCP_ESTABLISHED && (flags & O_NONBLOCK))
return (-EINPROGRESS);
// 等待連接過程完成
if (sk->state == TCP_SYN_SENT || sk->state == TCP_SYN_RECV) {
inet_wait_for_connect(sk);
if (signal_pending(current))
return -ERESTARTSYS;
}
sock->state = SS_CONNECTED; // 設置socket的狀態為connected
...
return(0);
}
inet_stream_connect()
函數的主要操作有以下幾個步驟:
- 調用
inet_autobind()
函數嘗試自動綁定一個本地端口。 - 調用
tcp_v4_connect()
函數進行 TCP 協議的連接操作。 - 如果 socket 設置了非阻塞,並且連接還沒建立完成,那么返回 EINPROGRESS 錯誤。
- 調用
inet_wait_for_connect()
函數等待連接服務端操作完成。 - 設置 socket 的狀態為
SS_CONNECTED
,表示連接已經建立完成。
在上面的步驟中,最重要的是調用 tcp_v4_connect()
函數進行連接操作,我們來分析一下 tcp_v4_connect()
函數的實現:
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
struct tcp_opt *tp = &(sk->tp_pinfo.af_tcp);
struct sockaddr_in *usin = (struct sockaddr_in *)uaddr;
struct sk_buff *buff;
struct rtable *rt;
u32 daddr, nexthop;
int tmp;
...
nexthop = daddr = usin->sin_addr.s_addr;
...
// 1. 獲取發送數據的路由信息
tmp = ip_route_connect(&rt, nexthop, sk->saddr,
RT_TOS(sk->ip_tos)|RTO_CONN|sk->localroute,
sk->bound_dev_if);
...
dst_release(xchg(&sk->dst_cache, rt)); // 2. 設置sk的路由信息
// 3. 申請一個skb數據包對象
buff = sock_wmalloc(sk, (MAX_HEADER + sk->prot->max_header), 0, GFP_KERNEL);
...
sk->dport = usin->sin_port; // 4. 設置目的端口
sk->daddr = rt->rt_dst; // 5. 設置目的IP地址
...
if (!sk->saddr)
sk->saddr = rt->rt_src; // 6. 如果沒有指定源IP地址, 那么使用路由信息的源IP地址
sk->rcv_saddr = sk->saddr;
...
// 7. 初始化TCP序列號
tp->write_seq = secure_tcp_sequence_number(sk->saddr, sk->daddr, sk->sport,
usin->sin_port);
...
// 8. 重置TCP最大報文段大小
tp->mss_clamp = ~0;
...
// 9. 調用 tcp_connect() 函數繼續進行連接操作
tcp_connect(sk, buff, rt->u.dst.pmtu);
return 0;
}
tcp_v4_connect()
函數只是做一些連接前的准備工作,如下:
- 調用
ip_route_connect()
函數獲取發送數據的路由信息,並且將路由信息保存到 socket 對象的路由緩存中。 - 調用
sock_wmalloc()
函數申請一個 skb 數據包對象。 - 設置
目的端口
和目的 IP 地址
。 - 如果沒有指定
源 IP 地址
,那么使用路由信息中的源 IP 地址
。 - 調用
secure_tcp_sequence_number()
函數初始化 TCP 序列號。 - 重置 TCP 協議最大報文段的大小。
- 調用
tcp_connect()
函數發送SYN包
給服務端程序。
由於 TCP三次握手
的第一步是由客戶端發送 SYN包
給服務端,所以我們主要關注 tcp_connect()
函數的實現,其代碼如下:
void tcp_connect(struct sock *sk, struct sk_buff *buff, int mtu)
{
struct dst_entry *dst = sk->dst_cache;
struct tcp_opt *tp = &(sk->tp_pinfo.af_tcp);
skb_reserve(buff, MAX_HEADER + sk->prot->max_header); // 保留所有的協議頭部空間
tp->snd_wnd = 0;
tp->snd_wl1 = 0;
tp->snd_wl2 = tp->write_seq;
tp->snd_una = tp->write_seq;
tp->rcv_nxt = 0;
sk->err = 0;
// 設置TCP頭部長度
tp->tcp_header_len = sizeof(struct tcphdr) +
(sysctl_tcp_timestamps ? TCPOLEN_TSTAMP_ALIGNED : 0);
...
tcp_sync_mss(sk, mtu); // 設置TCP報文段最大長度
...
TCP_SKB_CB(buff)->flags = TCPCB_FLAG_SYN; // 設置SYN標志為1(表示這是一個SYN包)
TCP_SKB_CB(buff)->sacked = 0;
TCP_SKB_CB(buff)->urg_ptr = 0;
buff->csum = 0;
TCP_SKB_CB(buff)->seq = tp->write_seq++; // 設置序列號
TCP_SKB_CB(buff)->end_seq = tp->write_seq; // 設置確認號
tp->snd_nxt = TCP_SKB_CB(buff)->end_seq;
// 初始化滑動窗口的大小
tp->window_clamp = dst->window;
tcp_select_initial_window(sock_rspace(sk)/2, tp->mss_clamp,
&tp->rcv_wnd, &tp->window_clamp,
sysctl_tcp_window_scaling, &tp->rcv_wscale);
...
tcp_set_state(sk, TCP_SYN_SENT); // 設置 socket 的狀態為 SYN_SENT
// 調用 tcp_v4_hash() 函數把 socket 添加到 tcp_established_hash 哈希表中
sk->prot->hash(sk);
tp->rto = dst->rtt;
tcp_init_xmit_timers(sk); // 設置超時重傳定時器
...
// 把 skb 添加到 write_queue 隊列中, 用於重傳時使用
__skb_queue_tail(&sk->write_queue, buff);
TCP_SKB_CB(buff)->when = jiffies;
...
// 調用 tcp_transmit_skb() 函數構建 SYN 包發送給服務端程序
tcp_transmit_skb(sk, skb_clone(buff, GFP_KERNEL));
...
}
tcp_connect()
函數的實現雖然比較長,但是邏輯相對簡單,就是設置 TCP 頭部各個字段的值,然后把數據包發送給服務端。下面列出 tcp_connect()
函數主要的工作:
- 設置 TCP 頭部的
SYN 標志位
為 1 (表示這是一個SYN包
)。 - 設置 TCP 頭部的序列號和確認號。
- 初始化滑動窗口的大小。
- 設置 socket 的狀態為
SYN_SENT
,可參考上面三次握手的狀態圖。 - 調用
tcp_v4_hash()
函數把 socket 添加到tcp_established_hash
哈希表中,用於通過 IP 地址和端口快速查找到對應的 socket 對象。 - 設置超時重傳定時器。
- 把 skb 添加到
write_queue
隊列中, 用於超時重傳。 - 調用
tcp_transmit_skb()
函數構建SYN包
發送給服務端程序。
注意:Linux 內核通過
tcp_established_hash
哈希表來保存所有的 TCP 連接 socket 對象,而哈希表的鍵值就是連接的 IP 和端口,所以可以通過連接的 IP 和端口從tcp_established_hash
哈希表中快速找到對應的 socket 連接。如下圖所示:
通過上面的分析,構建 SYN包
並且發送給服務端是通過 tcp_transmit_skb()
函數完成的,所以我們來分析一下 tcp_transmit_skb()
函數的實現:
void tcp_transmit_skb(struct sock *sk, struct sk_buff *skb)
{
if (skb != NULL) {
struct tcp_opt *tp = &(sk->tp_pinfo.af_tcp);
struct tcp_skb_cb *tcb = TCP_SKB_CB(skb);
int tcp_header_size = tp->tcp_header_len;
struct tcphdr *th;
...
// TCP頭部指針
th = (struct tcphdr *)skb_push(skb, tcp_header_size);
skb->h.th = th;
skb_set_owner_w(skb, sk);
// 構建 TCP 協議頭部
th->source = sk->sport; // 源端口
th->dest = sk->dport; // 目標端口
th->seq = htonl(TCP_SKB_CB(skb)->seq); // 請求序列號
th->ack_seq = htonl(tp->rcv_nxt); // 應答序列號
th->doff = (tcp_header_size >> 2); // 頭部長度
th->res1 = 0;
*(((__u8 *)th) + 13) = tcb->flags; // 設置TCP頭部的標志位
if (!(tcb->flags & TCPCB_FLAG_SYN))
th->window = htons(tcp_select_window(sk)); // 滑動窗口大小
th->check = 0; // 校驗和
th->urg_ptr = ntohs(tcb->urg_ptr); // 緊急指針
...
// 計算TCP頭部的校驗和
tp->af_specific->send_check(sk, th, skb->len, skb);
...
tp->af_specific->queue_xmit(skb); // 調用 ip_queue_xmit() 函數發送數據包
}
}
tcp_transmit_skb()
函數的實現相對簡單,就是構建 TCP 協議頭部,然后調用 ip_queue_xmit()
函數將數據包交由 IP 協議發送出去。
至此,客戶端就發送了一個 SYN包
給服務端,也就是說,TCP 三次握手
的第一步已經完成。
下一篇文章,我們將會分析 TCP 三次握手
的第二步,也就是服務端接收到客戶端發送過來的 SYN包
時對應的處理。