sk_buff封裝和解封裝網絡數據包的過程詳解

可以說sk_buff結構體是Linux網絡協議棧的核心中的核心,幾乎所有的操作都是圍繞sk_buff這個結構體進行的,它的重要性和BSD的mbuf類似(看過《TCP/IP詳解 卷2》的都知道),那麼sk_buff是什麼呢?
       sk_buff就是網絡數據包本身以及針對它的操作元數據。
       想要理解sk_buff,最簡單的方式就是憑着自己對網絡協議棧的理解封裝一個直到以太層的數據幀並且成功發送出去,個人認爲這比看代碼/看文檔或者在網上搜資料強多了。當然,網上已經有了大量的這方面的文章,但是我認爲很多都太複雜了,它們都細化到了sk_buff結構體的每一個指針字段,並且還都畫出了圖,但一般都逃不過《深入理解Linux網絡技術內幕》這本書的圈子。試想,如果以後內核版本升級了,字段新增了或者名字變了,怎麼辦?這些文章包括那本經典的《ULN》還能有幫助嗎?
       因此,本文絕不深入到sk_buff的細節,但是相信這種簡單的方式可以讓自己在多年以後早已忘了什麼是Linux協議棧的情況下,瞬間理解Linux是如何通過sk_buff封裝數據包的。我們從網絡的分層模型開始。

網絡分層模型

這是一切的本質。網絡被設計成分層的,所以網絡的操作就可以稱作一個“棧”,這就是網絡協議棧的名稱的由來。在具體的操作上,數據包最終形成的過程就是一層一層封裝的過程,在棧上形成一段連續的數據,我們可以稱作是一層一層的push操作。同樣的,數據包的解封裝的過程,則可以認爲是一層一層的pop操作。

sk_buff的操作

要想形成一個最終的數據包,即以太幀(不考慮其它的鏈路層)。要進行以下的操作:
1.分配一個skb結構體
2.分配數據包的數據區
3.在skb數據區定位應用層起始位置
4.拷貝數據到應用層(假設應用層協議沒有在socket接口之上被封裝)
5.在skb數據區定位傳輸層起始位置
6.設置傳輸層頭部字段
7.在skb數據區定位IP層起始位置
8.設置IP層頭部字段
9.在skb數據區定位以太層起始位置
10.設置以太頭部字段
可以看出基本的模式,即“定位/設置”兩步驟操作,有點區別的是應用層操作,這是因爲應用層的操作一般都是在socket接口之上完成的。但是既然本文講述的是skb的通用操作,就不再區分這個了。

skb的核心操作

在上面一小節,我們展示了skb的封裝邏輯,但是具體到接口層面,就涉及到了skb的核心操作。

1.分配skb

這個是由alloc_skb完成的,完成同一任務的接口形成一個接口族,但是alloc_skb是最基本的接口。

       該alloc_skb接口完成兩件事,即分配skb結構體以及skb數據包緩衝區,設置初始值。size參數表示skb的數據包緩衝區的大小,這個大小包括所有層的總和。如果該函數成功返回,那麼就相當於你已經有了一個大小爲size的空數據包緩衝區以及操作該數據包緩衝區的skb元數據。如下圖所示:




2.初始定位(skb_reserve)

skb的逐層封裝的關鍵在於寫指針的定位,即這一層從哪個位置開始寫。從協議封裝的壓棧形象來看,這個定位應該是順序有規律的。初始定位十分重要,後面的定位就是例行公事了。初始定位當然是定位到應用層的末端,從這裏開始,逐層將協議頭push到skb的數據包緩衝區。初始定位圖示如下:




3.拷貝應用層數據(skb_push/copy)

當skb分配好了之後,需要將協議“棧”的位置定位在數據包的“最低處”,這是初始定位,這樣纔可以把每一層的數據或者協議頭push到棧上,這個操作由skb_reserve來完成。應用層數據已經在socket之上封裝好了,那麼就把skb的數據包緩衝區寫指針定位到應用數據的開始處,此時的寫指針在應用層緩衝區的末尾,因此需要使用skb_push操作將寫指針定位到應用層開始處,這等於說壓入了應用層棧幀。
       skb_push接口是將一個協議棧幀壓入協議棧的接口,它返回一個position,該position就是skb數據包的寫指針,告訴調用者,這裏開始按照你的封裝邏輯封裝數據包,寫多少字節呢?由skb_push的參數n指示。應用層的壓棧操作如下圖所示:




將應用層棧幀壓入協議棧之後,就可以在寫指針位置開始,往後連續寫n字節的應用層數據了,一般而言,這些數據來自socket。

4.設置傳輸層頭部

和應用層的操作類似,這次需要把傳輸層棧幀壓入協議棧中,如下圖所示:




接下來就可以愉快地在skb_push返回的位置設置傳輸層頭部了,UDP,TCP,就看你對傳輸層的理解了。設置傳輸層頭部其實就是在skb_push返回的位置開始寫數據,寫入的長度由skb_push的參數指定,即n。

5.設置IP層頭部

和應用層以及傳輸層操作類似,這次需要把IP層的棧幀壓入協議棧中,如下圖所示:




接下來就可以愉快地在skb_push返回的位置設置IP層頭部了,如何設置,就看你對IP層的理解了。由於只是演示skb如何封裝,因此沒有涉及IP層相當重要的IP路由過程。

6.設置以太幀頭部

這個就不說了,和上述的類似…如下圖所示:




到此爲止,我封裝了一個完整的以太幀,可以直接通過dev_queue_xmit發送的那種。一路下來,你會發現,skb數據包緩衝區以“壓棧(push)”的方式逐漸被填充,每一層,都是通過skb_push接口壓入一個棧幀,返回寫指針,然後按照該層的協議邏輯從寫指針開始寫入棧幀長度的數據。
       在skb_push返回的那一刻,一個棧幀被壓入了協議棧,然後該棧幀還仍未被寫入數據,也就是說還沒有完成封裝過程,具體的封裝過程由調用者自己實現。
       skb_push導致了skb數據包緩衝區寫指針位置的前推,連帶的改變了好幾個變量,首先數據包的長度增加了n個字節,其次縮小了headroom的空間,然後通過reset_XXX_header的調用,skb記住了某層協議頭在數據包中的位置(這點特別重要!比如在TSO/UFO的情況下,網卡驅動需要協議頭的位置信息,用以計算校驗值,所以雖然skb不記住協議頭的位置,一個數據包也能完成封裝,但是對於協議棧的完整實現而言,卻是不正確的做法,畢竟網卡計算校驗碼已經成了一種事實上的標準[即便它違背了嚴格的分層原則!])

7.在應用數據後面追加PADDING

目前爲止,從最後的圖示上可以看到,在skb數據包緩衝區中,還有兩塊區域沒有使用,一個headroom,一個是tailroom,這些是幹什麼用的呢?作爲一個練習的例子,由於存在某種對齊原則,在封裝完成後,我需要在數據包的最後追加一些填充,或者說我需要在最前面加一個前導碼,或者最常見的,我要在數據包的最後加一個糾錯碼,此時應該怎麼辦呢?

       這個時候就需要headroom或者tailroom了,以在數據包最後追加數據爲例,請看下圖:




實際上,skb_put的操作就是,在數據包的末尾追加數據。至於說headroom如何使用,我就不多說了,其實還是skb_push,headroom有什麼用呢?前導碼,X over Y封裝,不一而足。

實際的例子

下面我給出一個實際的例子,封裝一個以太幀,然後發送出去:
    skb = alloc_skb(1500, GFP_ATOMIC);
skb->dev = dev;
// 例行填充skb元數據
<span class="hljs-comment">/* 保留skb區域 */</span>
skb_reserve (skb, <span class="hljs-number">2</span> + <span class="hljs-keyword">sizeof</span>(<span class="hljs-keyword">struct</span> ethhdr) +
        <span class="hljs-keyword">sizeof</span>(<span class="hljs-keyword">struct</span> iphdr) +
        <span class="hljs-keyword">sizeof</span>(<span class="hljs-keyword">struct</span> udphdr) +
        <span class="hljs-keyword">sizeof</span>(app_data));

<span class="hljs-comment">/* 構造數據區 */</span>
p = skb_push(skb, <span class="hljs-keyword">sizeof</span>(app_data));
memcpy(p, &amp;app_data[<span class="hljs-number">0</span>], <span class="hljs-keyword">sizeof</span>(app_data));

p = skb_push(skb, <span class="hljs-keyword">sizeof</span>(<span class="hljs-keyword">struct</span> udphdr));
udphdr = (<span class="hljs-keyword">struct</span> udphdr *)p;  
<span class="hljs-comment">// 填充udphdr字段,略</span>
skb_reset_transport_header(skb);

<span class="hljs-comment">/* 構造IP頭 */</span>
p = skb_push(skb, <span class="hljs-keyword">sizeof</span>(<span class="hljs-keyword">struct</span> iphdr));
iphdr = (<span class="hljs-keyword">struct</span> iphdr*)p;
<span class="hljs-comment">// 填充iphdr字段,略</span>
skb_reset_network_header(skb);

<span class="hljs-comment">/* 構造以太頭 */</span>
p = skb_push(skb, <span class="hljs-keyword">sizeof</span>(<span class="hljs-keyword">struct</span> ethhdr));
ethhdr = (<span class="hljs-keyword">struct</span> ethhdr*)p;
<span class="hljs-comment">// 填充ethhdr字段,略</span>
skb_reset_mac_header(skb);

<span class="hljs-comment">/* 發射 */</span>
dev_queue_xmit(skb);


解封裝的過程和封裝的過程相反,解封裝的過程是協議棧棧幀逐層pop的過程,但是Linux協議棧並沒有用棧的術語來定義接口名字,而是使用了push的反義詞,即pull來定義的,skb_pull就是核心接口,和skb_push嚴格相對。我就不再一一畫圖了。

按照接口編碼而不是按照實現編碼

這好像是Effective C++裏面的一條,同樣也適合於skb的操作場景。典型的就是“如何讓skb記住IP層協議頭,傳輸層協議頭,mac頭的位置”,接口是:
skb_reset_mac_header
skb_reset_network_header
skb_reset_transport_header
調用時機爲skb_push返回的當時。曾幾何時,我按照下面的方式設置了協議頭的位置:
    /* 構造IP頭 /
p = skb_push(skb, sizeof(struct iphdr));
iphdr = (struct iphdr)p;
// 填充iphdr字段,略
//skb_reset_network_header(skb);
skb->network_header = p;
有錯嗎?咋一看是沒錯的,但是卻報錯了:
protocol 0008 is buggy, dev eth2
這是怎麼回事?原因就在於skb紀錄的協議頭位置是錯誤的!難道以上的設置skb的network_header字段的方式有何不妥嗎?當然不妥!這就是沒有按照接口編碼的惡果。
       原因在於,系統設置skb的network_header字段的方式有兩種,通過一個宏來識別:NET_SKBUFF_DATA_USES_OFFSET。也就是說,可以通過相對於skb的head指針的偏移來定位協議頭的位置,也可以通過絕對地址來定位,具體使用哪一種取決於系統有沒有定義NET_SKBUFF_DATA_USES_OFFSET宏,以上的skb->network_header = p明顯是通過絕對地址來定位的,一旦系統定義了NET_SKBUFF_DATA_USES_OFFSET宏,肯定就不對了。既然宏定義在編譯期確定,那麼通過定義接口就可以在編譯期唯一確定一種實現,程序員不必在乎是否定義了NET_SKBUFF_DATA_USES_OFFSET宏,這就是通過接口編程的益處。如果基於skb的實現來編程,你不得不針對所有的情況編寫好幾套實現,而以上錯誤的實現只是其中一種,而且還用錯了場景!這是多麼痛的領悟!
       NET_SKBUFF_DATA_USES_OFFSET宏是一個細節問題,如果使用接口編程便不必關注這個細節,否則你就必須搞清楚系統爲何這麼設計,即便這並不是你所關注的!爲何呢?
       由於指針的長度大小在32位系統和64位系統中是不一樣的,所以按理說skb中的指針型的元數據大小也會不同,且64位系統的將會是32位系統的兩倍,爲了平滑掉這個差別,使元數據大小一致,就必須讓64位系統的對應指針類型變爲4個字節,而這是不可能的。因此在64位系統中,使用偏移來定位元數據,而偏移的類型爲固定不變的unsigned int,即4個字節。爲了支持上述說法,skb中加入了一個新的層次,即定義了一種新的數據類型sk_buff_data_t,該類型在編譯期確定:
#if BITS_PER_LONG > 32
#define NET_SKBUFF_DATA_USES_OFFSET 1
#endif

#ifdef NET_SKBUFF_DATA_USES_OFFSET
typedef unsigned int sk_buff_data_t;
#else
typedef unsigned char *sk_buff_data_t;
#endif
節約空間之外,對於和大小相關的操作,接口實現也更加統一。這就是細節,而這些細節並不是玩網絡協議棧的人所要關注的,不是嗎?這完全是系統實現的層面,和業務邏輯是無關的。

爲何未竟全功

本文講述到此爲止。事實上,sk_buff還有更多的,相當多的細節,但是不能再一一描述了,因爲那樣就違背了本文一開始的初衷,即用最簡單的方式揭露本質,如果一一描述了,那麼本文將成爲一個文檔而非一篇感悟,時隔多年以後,相信自己也不會看下去的。
       關於sk_buff還有超級多的內容,僅僅結構體裏面豐富字段的含義就夠折騰好久的了,加上它如何配合Linux各層協議的實現,內容就更加豐富了。不過最基本的,就是本文講述的,你得知道數據是怎樣塞到一個skb並封裝成一個可以被網卡實際發送的數據包的。好了,基本就是這些。最後我來總結一下本文提到的幾個接口:
alloc_skb:分配一個skb;
skb_reserver:寫指針向後移動到一個位置p,確定爲數據包尾部,自始,寫指針開始從該位置前移封裝數據包;
skb_push:寫指針前移n,更新數據包長度,從它返回的位置可以寫n個字節數據-即封裝n字節的協議;
skb_put:寫指針移動到數據包尾部,返回尾部指針,可以從此位置寫n字節數據,同時更新尾指針和數據包長度;


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章