深入理解TCP協議及其源代碼


TCP三次握手理論

三次握手過程

第一次握手

客戶端A向服務端B發出連接請求,同步位SYN=1,初始序列seq=x,連接請求報文段不能攜帶數據,但是要消耗一個序號,這時客戶端A進入SYN-SENT(同步已發送狀態)

第二次握手

服務端B收到請求報文段之后,向A發送后確認。將同部位SYN和確認位都置為1,確認序號ack=x+1,同時自己選擇一個初始序號seq=y。連接接收報文也不能攜帶數據,但是也要消耗一個序號,這時服務端進入SYN-RCVD(同步收到狀態)

第三次握手

A收到B的確認時候要給B一個確認。確認報文段的確認位ACK=1,確認號ack=y+1,自己的序號seq=x+1。這時,TCP連接已經建立,客戶端進入ESTABLISHED(已建立連接狀態)。B收到A發出的確認報文之后也進入已建立連接狀態

狀態轉換

在TCP連接建立的過程中會涉及到狀態的轉換,下面以有限狀態機的形式給出在連接建立、數據傳送和連接終止時所發生事件之間是如何轉換的。

三次握手源代碼分析

經過前一次實驗,我們調研了socket相關的系統調用,了解了在服務器端與客戶端socket建立、連接、通信以及終止過程中所涉及到的系統調用。

其中,在服務器端相繼調用__sys_socket__sys_bind__sys_listen內核函數后,服務器進入監聽狀態,即打開端口被動地等待客戶端連接。當客戶端調用__sys_connect發出連接時,這就是我們今天所探討地tcp三次握手過程的開始了。

那么,在TCP三次握手這個過程中所涉及到的調用過程是怎樣的呢?

創建Socket

socket(AF_INET, SOCK_DGRAM, IPPROTO_TCP)  

調用接口SYSCALL_DEFINE3(socket)

其中涉及兩步關鍵操作:sock_create()sock_map_fd()

retval = sock_create(family, type, protocol, &sock);  
if (retval < 0)  
	goto out;  
retval = sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));  
if (retval < 0)  
	goto out_release;  

sock_create()用於創建socket,sock_map_fd()將之映射到文件描述符,使socket能通過fd進行訪問。

sock_create()中,與我們之前所分析的socket其他API處理邏輯一樣,它直接調用了相對應的內核處理函數。

sock_create() -> __sock_create()

而我們從__sock_create()代碼可以看到創建包含兩步:sock_alloc()pf->create()

sock_alloc()分配了sock內存空間並初始化inode;pf->create()初始化了sk。

int __sock_create(struct net *net, int family, int type, int protocol,
			 struct socket **res, int kern)
{
	int err;
	struct socket *sock;
	const struct net_proto_family *pf;

	/*
	 *      Check protocol is in range
	 */
	if (family < 0 || family >= NPROTO)
		return -EAFNOSUPPORT;
	if (type < 0 || type >= SOCK_MAX)
		return -EINVAL;

	/* Compatibility.

	   This uglymoron is moved from INET layer to here to avoid
	   deadlock in module load.
	 */
	if (family == PF_INET && type == SOCK_PACKET) {
		pr_info_once("%s uses obsolete (PF_INET,SOCK_PACKET)\n",
			     current->comm);
		family = PF_PACKET;
	}

	err = security_socket_create(family, type, protocol, kern);
	if (err)
		return err;

	/*
	 *	Allocate the socket and allow the family to set things up. if
	 *	the protocol is 0, the family is instructed to select an appropriate
	 *	default.
	 */
	sock = sock_alloc();
	if (!sock) {
		net_warn_ratelimited("socket: no more sockets\n");
		return -ENFILE;	/* Not exactly a match, but its the
				   closest posix thing */
	}

	sock->type = type;

#ifdef CONFIG_MODULES
	/* Attempt to load a protocol module if the find failed.
	 *
	 * 12/09/1996 Marcin: But! this makes REALLY only sense, if the user
	 * requested real, full-featured networking support upon configuration.
	 * Otherwise module support will break!
	 */
	if (rcu_access_pointer(net_families[family]) == NULL)
		request_module("net-pf-%d", family);
#endif

	rcu_read_lock();
	pf = rcu_dereference(net_families[family]);
	err = -EAFNOSUPPORT;
	if (!pf)
		goto out_release;

	/*
	 * We will call the ->create function, that possibly is in a loadable
	 * module, so we have to bump that loadable module refcnt first.
	 */
	if (!try_module_get(pf->owner))
		goto out_release;

	/* Now protected by module ref count */
	rcu_read_unlock();

	err = pf->create(net, sock, protocol, kern);
	if (err < 0)
		goto out_module_put;

	/*
	 * Now to bump the refcnt of the [loadable] module that owns this
	 * socket at sock_release time we decrement its refcnt.
	 */
	if (!try_module_get(sock->ops->owner))
		goto out_module_busy;

	/*
	 * Now that we're done with the ->create function, the [loadable]
	 * module can have its refcnt decremented
	 */
	module_put(pf->owner);
	err = security_socket_post_create(sock, family, type, protocol, kern);
	if (err)
		goto out_sock_release;
	*res = sock;

	return 0;

out_module_busy:
	err = -EAFNOSUPPORT;
out_module_put:
	sock->ops = NULL;
	module_put(pf->owner);
out_sock_release:
	sock_release(sock);
	return err;

out_release:
	rcu_read_unlock();
	goto out_sock_release;
}
EXPORT_SYMBOL(__sock_create);

這里,關於sock_alloc的過程我們不再過多涉及,重點內容是分析TCP三次握手的過程。

客戶端流程

經過上一次實驗中對客戶端hello函數的分析,我們最終得出結論:

執行hello命令時,客戶端所經歷的流程為connect() -> send() -> recv()

那么,按照這一過程我們對源碼進行分析。


發送SYN報文,向服務器發起tcp連接

connect(fd, servaddr, addrlen);
	-> SYSCALL_DEFINE3() 
	-> sock->ops->connect() == inet_stream_connect (sock->ops即inet_stream_ops)
	-> tcp_v4_connect()

其中,tcp_v4_connect()的結構圖如下:

在該函數中,首先查找到達[daddr, dport]的路由項。

要注意的是由於是作為客戶端調用,創建socket后調用connect,因而saddr, sport都是0,同樣在未查找路由前,要走的出接口oif也是不知道的,因此也是0。在查找完路由表后(注意不是路由緩存),可以得知出接口,但並未存儲到sk中。

因此插入的路由緩存是特別要注意的:它的鍵值與實際值是不相同的,這個不同點就在於oif與saddr,鍵值是[saddr=0, sport=0, daddr, dport, oif=0],而緩存項值是[saddr, sport=0, daddr, dport, oif]。

tmp = ip_route_connect(&rt, nexthop, inet->inet_saddr,  
						RT_CONN_FLAGS(sk), sk->sk_bound_dev_if,  
						IPPROTO_TCP,  
						inet->inet_sport, usin->sin_port, sk, 1);  
if (tmp < 0) {  
	if (tmp == -ENETUNREACH)  
		IP_INC_STATS_BH(sock_net(sk), IPSTATS_MIB_OUTNOROUTES);  
	return tmp;  
}  

通過查找到的路由項,對inet進行賦值,可以看到,除了sport,都賦予了值,sport的選擇復雜點,因為它要隨機從未使用的本地端口中選擇一個。

if (!inet->inet_saddr)  
	inet->inet_saddr = rt_rt_src;   
inet->inet_rcv_addr = inet->inet_saddr;  
……  
inet->inet_dport = usin->sin_port;  
inet->inet_daddr = daddr;  

狀態從CLOSING轉到TCP_SYN_SENT,這個在之前的TCP的狀態轉移圖中闡述過了。

tcp_set_state(sk, TCP_SYN_SENT);  

插入到bind鏈表中。

err = inet_hash_connect(&tcp_death_row, sk); //== > __inet_hash_connect()  

當snum==0時,表明此時源端口沒有指定,此時會隨機選擇一個空閑端口作為此次連接的源端口。low和high分別表示可用端口的下限和上限,remaining表示可用端口的數。

注意這里的可用只是指端口可以用作源端口,其中部分端口可能已經作為其它socket的端口號在使用了,所以要循環1~remaining,直到查找到空閑的源端口。

if (!snum) {  
	inet_get_local_port_range(&low, &high);  
	remaining = (high - low) + 1;  
	……  
	for (i = 1; i <= remaining; i++) {  
		……// choose a valid port  
	}  
}  

下面來看下對每個端口的檢查,即//choose a valid port部分的代碼。

這里要先了解下tcp的內核表組成,udp的表內核表udptable只是一張hash表,tcp的表則稍復雜,它的名字是tcp_hashinfo,在tcp_init()中被初始化,這個數據結構定義如下(省略了不相關的數據):

struct inet_hashinfo {  
	struct inet_ehash_bucket *ehash;  
	……  
	struct inet_bind_hashbucket *bhash;  
	……  
	struct inet_listen_hashbucket  listening_hash[INET_LHTABLE_SIZE]  
					____cacheline_aligned_in_smp;  
};  

從定義可以看出,tcp表又分成了三張表ehash, bhash, listening_hash。

其中ehash, listening_hash對應於socket處在TCP的ESTABLISHED, LISTEN狀態,bhash對應於socket已綁定了本地地址。

三者間並不互斥,如一個socket可同時在bhash和ehash中,由於TIME_WAIT是一個比較特殊的狀態,所以ehash又分成了chain和twchain,為TIME_WAIT的socket單獨形成一張表。

回到剛才的代碼,現在還只是建立socket連接,使用的就應該是tcp表中的bhash。首先取得內核tcp表的bind表 – bhash,查看是否已有socket占用:

  • 如果沒有,則調用inet_bind_bucket_create()創建一個bind表項tb,並插入到bind表中,跳轉至goto ok代碼段;
  • 如果有,則跳轉至goto ok代碼段。
    進入ok代碼段表明已找到合適的bind表項(無論是創建的還是查找到的),調用inet_bind_hash()賦值源端口inet_num。
for (i = 1; i <= remaining; i++) {  
	port = low + (i + offset) % remaining;  
	head = &hinfo->bhash[inet_bhashfn(net, port, hinfo->bhash_size)];  
	……  
	inet_bind_bucket_for_each(tb, node, &head->chain) {  
		if (net_eq(ib_net(tb), net) && tb->port == port) {  
			if (tb->fastreuse >= 0)  
				goto next_port;  
			WARN_ON(hlist_empty(&tb->owners));  
			if (!check_established(death_row, sk, port, &tw))  
				goto ok;  
			goto next_port;  
		}  
	}  
		
	tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep, net, head, port);  
	……  
	next_port:  
		spin_unlock(&head->lock);  
}  
		
ok:  
	……  
inet_bind_hash(sk, tb, port);  
	……  
	goto out;  

在獲取到合適的源端口號后,會重建路由項來進行更新:

err = ip_route_newports(&rt, IPPROTO_TCP, inet->inet_sport, inet->inet_dport, sk);  

函數比較簡單,在獲取sport前已經查找過一次路由表,並插入了key=[saddr=0, sport=0, daddr, dport, oif=0]的路由緩存項;現在獲取到了sport,調用ip_route_output_flow()再次更新路由緩存表,它會添加key=[saddr=0, sport, daddr, dport, oif=0]的路由緩存項。這里可以看出一個策略選擇,查詢路由表->獲取sport->查詢路由表,為什么不是獲取sport->查詢路由表的原因可能是效率的問題。

if (sport != (*rp)->fl.fl_ip_sport ||  
				dport != (*rp)->fl.fl_ip_dport) {  
	struct flowi fl;  
		
	memcpy(&fl, &(*rp)->fl, sizeof(fl));  
	fl.fl_ip_sport = sport;  
	fl.fl_ip_dport = dport;  
	fl.proto = protocol;  
	ip_rt_put(*rp);  
	*rp = NULL;  
	security_sk_classify_flow(sk, &fl);  
	return ip_route_output_flow(sock_net(sk), rp, &fl, sk, 0);  
}  

write_seq相當於第一次發送TCP報文的ISN,如果為0,則通過計算獲取初始值,否則延用上次的值。在獲取完源端口號,並查詢過路由表后,TCP正式發送SYN報文,注意在這之前TCP狀態已經更新成了TCP_SYN_SENT,而在函數最后才調用tcp_connect(sk)發送SYN報文,這中間是有時差的。

if (!tp->write_seq)  
	tp->write_seq = secure_tcp_sequence_number(inet->inet_saddr,  
									inet->inet_daddr,  
									inet->inet_sport,  
									usin->sin_port);  
inet->inet_id = tp->write_seq ^ jiffies;  
err = tcp_connect(sk);  

客戶端調用tcp_connect()發送SYN報文。

該函數的結構如下圖所示。

幾步重要的代碼如下:

tcp_connect_init()中設置了tp->rcv_nxt=0,tcp_transmit_skb()負責發送報文,其中seq=tcb->seq=tp->write_seqack_seq=tp->rcv_nxt

tcp_connect_init(sk);  
tp->snd_nxt = tp->write_seq;  
……  
tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);  

收到服務端的SYN+ACK,發送ACK

tcp_rcv_synsent_state_process()

此時已接收到對方的ACK,狀態變遷到TCP_ESTABLISHED。最后發送對方SYN的ACK報文。

tcp_set_state(sk, TCP_ESTABLISHED);  
tcp_send_ack(sk);  

服務器端流程

如之前replyhi命令執行時分析的一樣,我們了解到服務端所經歷的流程如下:

bind() -> listen() -> accept() -> recv() -> send()

bind() -> inet_bind()

bind操作的主要作用是將創建的socket與給定的地址相綁定,這樣創建的服務才能公開的讓外部調用。當然對於socket服務器的創建來說,這一步不是必須的,在listen()時如果沒有綁定地址,系統會選擇一個隨機可用地址作為服務器地址。

一個socket地址分為ip和port,inet->inet_saddr賦值了傳入的ip,snum是傳入的port,對於端口,要檢查它是否已被占用,這是由sk->sk_prot->get_port()完成的(這個函數前面已經分析過,在傳入port時它檢查是否被占用;傳入port=0時它選擇未用的端口)。如果沒有被占用,inet->inet_sport被賦值port,因為是服務監聽端,不需要遠端地址,inet_daddr和inet_dport都置0。

注意bind操作不會改變socket的狀態,仍為創建時的TCP_CLOSE。

snum = ntohs(addr->sin_port);  
……  
inet->inet_rcv_saddr = inet->inet_saddr = addr->sin_addr.s_addr;  
if (sk->sk_prot->get_port(sk, snum)) {  
	inet->inet_saddr = inet->inet_rcv_saddr = 0;  
	err = -EADDRINUSE;  
	goto out_release_sock;  
}  
……  
inet->inet_sport = htons(inet->inet_num);  
inet->inet_daddr = 0;  
inet->inet_dport = 0;  

listen() -> inet_listen()

listen操作開始服務器的監聽,此時服務就可以接受到外部連接了。在開始監聽前,要檢查狀態是否正確,sock->state == SS_UNCONNECTED確保仍是未連接的socket,sock->type == SOCK_STREAM確保是TCP協議,old_state確保此時狀態是TCP_CLOSE或TCP_LISTEN,在其它狀態下進行listen都是錯誤的。

if (sock->state != SS_UNCONNECTED || sock->type != SOCK_STREAM)  
	goto out;  
old_state = sk->sk_state;  
if (!((1 << old_state) & (TCPF_CLOSE | TCPF_LISTEN)))  
	goto out;  

如果已是TCP_LISTEN態,則直接跳過,不用再執行listen了,而只是重新設置listen隊列長度sk_max_ack_backlog,改變listen隊列長也是多次執行listen的作用。如果還沒有執行listen,則還要調用inet_csk_listen_start()開始監聽。

inet_csk_listen_start()變遷狀態至TCP_LISTEN,分配監聽隊列,如果之前沒有調用bind()綁定地址,則這里會分配一個隨機地址。

if (old_state != TCP_LISTEN) {  
	err = inet_csk_listen_start(sk, backlog);  
	if (err)  
		goto out;  
}  
sk->sk_max_ack_backlog = backlog;  

accept()

accept所涉及的調用棧比較深:accept() -> sys_accept4() -> inet_accept() -> inet_csk_accept()

但accept()實際要做的事件並不多,它的作用是返回一個已經建立連接的socket(即經過了三次握手)。

這個過程是異步的,accept()並不親自去處理三次握手過程,而只是監聽icsk_accept_queue隊列,當有socket經過了三次握手,它就會被加到icsk_accept_queue中,所以accept要做的就是等待隊列中插入socket,然后被喚醒並返回這個socket。

而三次握手的過程完全是協議棧本身去完成的。換句話說,協議棧相當於寫者,將socket寫入隊列,accept()相當於讀者,將socket從隊列讀出。這個過程從listen就已開始,所以即使不調用accept(),客戶仍可以和服務器建立連接,但由於沒有處理,隊列很快會被占滿。

if (reqsk_queue_empty(&icsk->icsk_accept_queue)) {  
	long timeo = sock_rcvtimeo(sk, flags & O_NONBLOCK);  
	……  
	error = inet_csk_wait_for_connect(sk, timeo);  
	……  
}  
		
newsk = reqsk_queue_get_child(&icsk->icsk_accept_queue, sk);  

協議棧向隊列中加入socket的過程就是完成三次握手的過程,客戶端通過向已知的listen fd發起連接請求,對於到來的每個連接,都會創建一個新的sock,當它經歷了TCP_SYN_RCV -> TCP_ESTABLISHED后,就會被添加到icsk_accept_queue中。

而監聽的socket狀態始終為TCP_LISTEN,保證連接的建立不會影響socket的接收。

接收客戶端發來的SYN,發送SYN+ACK

所涉及到的內核函數為:tcp_v4_do_rcv()

tcp_v4_do_rcv()是TCP模塊接收的入口函數。其主要結構如下圖所示。

客戶端發起請求的對象是listen fd,所以sk->sk_state == TCP_LISTEN,調用tcp_v4_hnd_req()來檢查是否處於半連接,只要三次握手沒有完成,這樣的連接就稱為半連接,具體而言就是收到了SYN,但還沒有收到ACK的連接。

所以對於這個查找函數,如果是SYN報文,則會返回listen的socket(連接尚未創建);如果是ACK報文,則會返回SYN報文處理中插入的半連接socket。其中存儲這些半連接的數據結構是syn_table,它在listen()調用時被創建,大小由sys_ctl_max_syn_backlog和listen()傳入的隊列長度決定。

此時是收到SYN報文,tcp_v4_hnd_req()返回的仍是sk,調用tcp_rcv_state_process()來接收SYN報文,並發送SYN+ACK報文,同時向syn_table中插入一項表明此次連接的sk。

if (sk->sk_state == TCP_LISTEN) {
    struct sock *nsk = tcp_v4_hnd_req(sk, skb);   	
    if (!nsk)   		
        goto discard;   	
    if (nsk != sk) {   		
        if (tcp_child_process(sk, nsk, skb)) {
            rsk = nsk;   			
            goto reset;   		
        }   		
        return 0;   	
    }   
}  
TCP_CHECK_TIMER(sk);   
if (tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) {
    rsk = sk;   	
    goto reset;  
}  

tcp_rcv_state_process()處理各個狀態上socket的情況。下面是處於TCP_LISTEN的代碼段,處於TCP_LISTEN的socket不會再向其它狀態變遷,它負責監聽,並在連接建立時創建新的socket。實際上,當收到第一個SYN報文時,會執行這段代碼,conn_request() => tcp_v4_conn_request。

case TCP_LISTEN:  
……  
	if (th->syn) {  
		if (icsk->icsk_af_ops->conn_request(sk, skb) < 0)  
			return 1;  
		kfree_skb(skb);  
		return 0;  
	}  

tcp_v4_conn_request()中注意兩個函數就可以了:tcp_v4_send_synack()向客戶端發送了SYN+ACK報文,inet_csk_reqsk_queue_hash_add()將sk添加到了syn_table中,填充了該客戶端相關的信息。這樣,再次收到客戶端的ACK報文時,就可以在syn_table中找到相應項了。

if (tcp_v4_send_synack(sk, dst, req, (struct request_values *)&tmp_ext) || want_cookie)  
	goto drop_and_free;  
inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);  

接收客戶端發來的ACK

tcp_v4_do_rcv()

具體過程與收到SYN報文相同,不同點在於syn_table中已經插入了有關該連接的條目,tcp_v4_hnd_req()會返回一個新的sock: nsk,然后會調用tcp_child_process()來進行處理。在tcp_v4_hnd_req()中會創建新的sock,下面詳細看下這個函數。

if (sk->sk_state == TCP_LISTEN) {  
	struct sock *nsk = tcp_v4_hnd_req(sk, skb);  
	if (!nsk)  
		goto discard;  
	if (nsk != sk) {  
		if (tcp_child_process(sk, nsk, skb)) {  
			rsk = nsk;  
			goto reset;  
		}  
		return 0;  
	}  
}  

tcp_v4_hnd_req()

之前已經分析過,inet_csk_search_req()會在syn_table中找到req,此時進入tcp_check_req()

struct request_sock *req = inet_csk_search_req(sk, &prev, th->source, iph->saddr, iph->daddr);  
if (req)  
	return tcp_check_req(sk, skb, req, prev);  

tcp_check_req()

syn_recv_sock() -> tcp_v4_syn_recv_sock()會創建一個新的sock並返回,創建的sock狀態被直接設置為TCP_SYN_RECV,然后因為此時socket已經建立,將它添加到icsk_accept_queue中。

狀態TCP_SYN_RECV的設置可能比較奇怪,按照TCP的狀態轉移圖,在服務端收到SYN報文后變遷為TCP_SYN_RECV,但看到在實現中收到ACK后才有了狀態TCP_SYN_RECV,並且馬上會變為TCP_ESTABLISHED,所以這個狀態變得無足輕重。

實際上,這樣做的原因是因為listen和accept返回的socket是不同的,而只有真正連接建立時才會創建這個新的socket,在收到SYN報文時新的socket還沒有建立,就無從談狀態變遷了。這里同樣是一個平衡的存在,你也可以在收到SYN時創建一個新的socket,代價就是無用的socket大大增加了。

child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL);  
if (child == NULL)  
	goto listen_overflow;  
inet_csk_reqsk_queue_unlink(sk, req, prev);  
inet_csk_reqsk_queue_removed(sk, req);  
inet_csk_reqsk_queue_add(sk, req, child);  

tcp_child_process()

如果此時sock: child被用戶進程鎖住了,那么就先添加到backlog中__sk_add_backlog(),待解鎖時再處理backlog上的sock;如果此時沒有被鎖住,則先調用tcp_rcv_state_process()進行處理,處理完后,如果child狀態到達TCP_ESTABLISHED,則表明其已就緒,調用sk_data_ready()喚醒等待在isck_accept_queue上的函數accept()。

if (!sock_owned_by_user(child)) {  
	ret = tcp_rcv_state_process(child, skb, tcp_hdr(skb), skb->len);  
	if (state == TCP_SYN_RECV && child->sk_state != state)  
		parent->sk_data_ready(parent, 0);  
} else {  
	__sk_add_backlog(child, skb);  
}  

tcp_rcv_state_process()處理各個狀態上socket的情況。

下面是處於TCP_SYN_RECV的代碼段,注意此時傳入函數的sk已經是新創建的sock了(在tcp_v4_hnd_req()中),並且狀態是TCP_SYN_RECV,而不再是listen socket,在收到ACK后,sk狀態變遷為TCP_ESTABLISHED,而在tcp_v4_hnd_req()中也已將sk插入到了icsk_accept_queue上,此時它就已經完全就緒了,回到tcp_child_process()便可執行sk_data_ready()。

case TCP_SYN_RECV:  
	if (acceptable) {  
		……  
		tcp_set_state(sk, TCP_ESTABLISHED);  
		sk->sk_state_change(sk);  
		……  
		tp->snd_una = TCP_SKB_CB(skb)->ack_seq;  
		tp->snd_wnd = ntohs(th->window) << tp->rx_opt.snd_wscale;  
		tcp_init_wl(tp, TCP_SKB_CB(skb)->seq);   
		……  
}  

總結

最后,以圖示來展示TCP三次握手涉及的過程。

運行追蹤分析

首先,啟動MenuOS系統,並使用gdb連接進行調試。

命令

break inet_init
break sock_create
break __sys_connect
break __sys_accept4
break tcp_v4_connect
break tcp_connect_init
break tcp_connect
break tcp_transmit_skb
break tcp_v4_do_rcv
break tcp_rcv_state_process

設置完斷點,我們使用continue命令來看看是否可以捕獲到這些斷點。

continue 

我們首先捕獲到的是inet_init斷點。如我們之前分析的一樣,該函數所完成的功能是TCP/IP協議棧的初始化,將所有的基礎協議添加進來,因此在socket初始化之前需要先運行加載該函數。


繼續向下運行。

這一次我們所捕獲到三次__sys_socket,關於這一點我在上一篇博客中已經分析過這種情況了,此時進行的是socket的初始化過程,完成的是對網卡的配置與啟動。

另外,由於sys_socketcall()是內核為socket設置的總入口,這里我們沒有對__sys_socket設置斷點,因此只捕獲到sys_socketcall()

接着,與我們之前的實驗一樣,在第一次gdb調試界面停止時我們輸入replyhi命令,輸入命令之后,gdb調試繼續。並且再次捕獲斷點時可以看到我們捕獲到了__sys_socket,即調用了該內核處理函數完成了我們服務器端socket的創建。

接着往下執行,我們捕獲到了__sys_accept4,在qemu虛擬機中輸入hello命令,來創建客戶端socket。


好了,之前所捕獲的系統調用都是連接建立之前所使用到的。

下面我們將捕獲TCP三次連接相關的系統調用。

首先,我們捕獲到__sys_connect,這一函數我們在之前的實驗中已經提到過,它表示客戶端已經准備好與服務器連接,將要發出連接請求。

我們所捕獲到的下一個是tcp_v4_connect,這就是我們今天所分析的TCP三次握手相關的內容。經過之前的分析,我們知道它所做的工作是調用IP層的服務並構造SYN,准備發送SYN的事宜。

接着,我們捕獲到了三次tcp_connect斷點,經過之前的分析內容,我們知道該函數所做的是構建一個新的SYN並將其發送出去。

那么,為什么這里會出現三次調用呢?

仔細看gdb調試中所給出斷點的詳細信息,可以發現這三次調用具體所做的內容都不同。為了搞清它們具體做了什么,我們可以查看一下tcp_connect源碼

/* Build a SYN and send it off. */
int tcp_connect(struct sock *sk)
{
	struct tcp_sock *tp = tcp_sk(sk);
	struct sk_buff *buff;
	int err;

	tcp_call_bpf(sk, BPF_SOCK_OPS_TCP_CONNECT_CB, 0, NULL);

	if (inet_csk(sk)->icsk_af_ops->rebuild_header(sk))
		return -EHOSTUNREACH; /* Routing failure or similar. */

	tcp_connect_init(sk);

	if (unlikely(tp->repair)) {
		tcp_finish_connect(sk, NULL);
		return 0;
	}

	buff = sk_stream_alloc_skb(sk, 0, sk->sk_allocation, true);
	if (unlikely(!buff))
		return -ENOBUFS;

	tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);
	tcp_mstamp_refresh(tp);
	tp->retrans_stamp = tcp_time_stamp(tp);
	tcp_connect_queue_skb(sk, buff);
	tcp_ecn_send_syn(sk, buff);
	tcp_rbtree_insert(&sk->tcp_rtx_queue, buff);

	/* Send off SYN; include data in Fast Open. */
	err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
	      tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
	if (err == -ECONNREFUSED)
		return err;

	/* We change tp->snd_nxt after the tcp_transmit_skb() call
	 * in order to make this packet get counted in tcpOutSegs.
	 */
	WRITE_ONCE(tp->snd_nxt, tp->write_seq);
	tp->pushed_seq = tp->write_seq;
	buff = tcp_send_head(sk);
	if (unlikely(buff)) {
		WRITE_ONCE(tp->snd_nxt, TCP_SKB_CB(buff)->seq);
		tp->pushed_seq	= TCP_SKB_CB(buff)->seq;
	}
	TCP_INC_STATS(sock_net(sk), TCP_MIB_ACTIVEOPENS);

	/* Timer for repeating the SYN until an answer. */
	inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
				  inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
	return 0;
}
EXPORT_SYMBOL(tcp_connect);

嗯,這下我們就明白了。

原來,第一次調用我們進入tcp_connect這個函數,表示我們將使用該內核函數來完成客戶端SYN的發送;

在第二次調用中,我們所執行的是tcp_connect_init(sk)這段代碼,即完成初始化的工作,構建我們將要發送的SYN

最后一次調用中,我們將構造好的SYN發送給服務器,向服務器發出TCP連接請求,而這里所使用的系統調用就是tcp_transmit_skb(),也就是我們之前沒有捕獲到的斷點。


繼續。

這次我們捕獲到了tcp_v4_do_rcvtcp_rcv_state_process這兩個函數。

在之前的服務器端流程分析中,我們知道tcp_v4_do_rcv所完成的工作是接收客戶端發來的SYN並發送SYN+ACK。后一個內核函數則是按照TCP狀態機將此時服務器的狀態修改為SYN-RCVD

同樣的,客戶端在收到該消息之后,調用該內核函數來發送ACK消息,並修改客戶端的狀態。

最后,服務器接收到來自客戶端的ACK消息,完成三次握手過程,修改狀態為ESTABLISHED

至於如何區分這些操作,我們的依據就是調試信息中的套接字指針sk以及skb信息,根據這些信息我們就能夠分辨出哪些是客戶端的操作,哪些是服務器的操作。

此后,服務器端就准備開始與客戶端之間的數據收發過程。

繼續向下,這次我們捕獲到tcp_write_xmit,它所完成的是將消息傳遞出去,而此時在Menuos系統中可以看到收發消息的列表,同樣證明此時客戶端與服務器完成了TCP三次連接,開始與服務器進行消息的發送與接收。

Ok,以上內容就是本次博客分享的全部了,如果有幫助到你,請給我點個推薦吧!


參考鏈接

tcp三次握手源碼解析(服務端角度)

tcp_v4_connect函數

linux源碼

tcp三次握手過程分析


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM