1. 程式人生 > >網路程式設計——原始套接字實現原理

網路程式設計——原始套接字實現原理

目錄

1. 基礎知識

 1.1、概述

1.2、鏈路層原始套接字

 1.3、網路層原始套接字

2、原始套接字的實現

2.1  原始套接字報文收發流程

2.2鏈路層原始套接字的實現

    2.2.1  套接字建立

2.2.2  報文接收

2.2.3  報文傳送

2.2.4  其它

 2.3  網路層原始套接字的實現

2.3.1  套接字建立

2.3.2  報文接收

2.3.3  報文傳送

2.3.4  其它

3、應用及注意事項

3.1  使用鏈路層原始套接字

3.2  使用網路層原始套接字

3.3  網路診斷工具使用原始套接字


1. 基礎知識

 1.1、概述


          原始套接字(SOCK_RAW)可以用來自行組裝IP資料包,然後將資料包傳送到其他終端。也就是說原始套接字是基於IP資料包的程式設計(SOCK_PACKET是基於資料鏈路層的程式設計)。協議棧的原始套接字從實現上可以分為“鏈路層原始套接字”和“網路層原始套接字”兩大類。本節主要描述各自的特點及其適用範圍。

鏈路層原始套接字可以直接用於接收和傳送鏈路層的MAC幀,在傳送時需要由呼叫者自行構造和封裝MAC首部。而網路層原始套接字可以直接用於接收和傳送IP層的報文資料,在傳送時需要自行構造IP報文頭(取決是否設定IP_HDRINCL選項)。另外,必須在管理員許可權下才能使用原始套接字。
            原始套介面提供了普通TCP和UDP socket不能提供的3個能力: 
            (1)程序使用raw socket 可以讀寫ICMP、IGMP等分組。

這個能力還使得使用ICMP或IGMP構造的應用程式能夠完全作為使用者程序處理,而不必往核心中新增額外程式碼。 
             (2)大多數核心只處理IPv4資料報中一個名為協議的8位欄位的值為1(ICMP)、2(IGMP)、6(TCP)、17(UDP)四種情況。然而該欄位的值還有許多其他值。程序使用raw socket 就可以讀寫那些核心不處理的IPv4資料報了。因此,可以使用原始套接字定義使用者自己的協議格式。 
            (3)通過使用raw socket ,程序可以使用IP_HDRINCL套介面選項自行構造IP頭部。這個能力可用於構造特定型別的TCP或UDP分組等。

 

1.2、鏈路層原始套接字


        鏈路層原始套接字呼叫socket()函式建立。第一個引數指定協議族型別為PF_PACKET,第二個引數type可以設定為SOCK_RAW或SOCK_DGRAM,第三個引數是協議型別(該引數只對報文接收有意義)。協議型別protocol不同取值的意義具體見表1所示:

 socket(PF_PACKET, type, htons(protocol))

        a)引數type設定為SOCK_RAW時,套接字接收和傳送的資料都是從MAC首部開始的。在傳送時需要由呼叫者從MAC首部開始構造和封裝報文資料。type設定為SOCK_RAW的情況應用是比較多的,因為某些專案會使用到 自定義的二層報文型別。

 socket(PF_PACKET, SOCK_RAW, htons(protocol))

        b)引數type設定為SOCK_DGRAM時,套接字接收到的資料報文會將MAC首部去掉。同時在傳送時也不需要再手動構造MAC首部,只需要從IP首部(或ARP首部,取決於封裝的報文型別)開始構造即可,而MAC首部的填充由核心實現的。若對於MAC首部不關心的場景,可以使用這種型別,這種用法用得比較少。 

 socket(PF_PACKET, SOCK_DGRAM, htons(protocol)) 

表1protocol不同取值

 1.3、網路層原始套接字


         建立面向連線的TCP和建立面向無連線的UDP套接字,在接收和傳送時只能操作資料部分,而不能對IP首部或TCP和UDP首部進行操作。如果想要操作IP首部或傳輸層協議首部,就需要呼叫如下socket()函式建立網路層原始套接字。第一個引數指定協議族的型別為PF_INET,第二個引數為SOCK_RAW,第三個引數protocol為協議型別(不同取值的意義見表2)。產品線有使用OSPF和RSVP等協議,需要使用這種型別的套接字。
    a)傳送報文       網路層原始套接字接收到的報文資料是從IP首部開始的,即接收到的資料包含了IP首部, TCP/UDP/ICMP等首部, 以及資料部分。
    b)傳送報文        網路層原始套接字傳送的報文資料,在預設情況下是從IP首部之後開始的,即需要由呼叫者自行構造和封裝TCP/UDP等協議首部。這種套接字也提供了傳送時從IP首部開始構造資料的功能,通過setsockopt()給套接字設定上IP_HDRINCL選項,就需要在傳送時自行構造IP首部。

表2 protocol不同取值

 

2、原始套接字的實現


        本節主要首先介紹鏈路層和網路層原始套接字報文的收發總體流程,再分別對兩類套接字的建立、接收、傳送等具體實現細節進行介紹。


2.1  原始套接字報文收發流程


圖1 原始套接字接收流程
         如上圖1所示為鏈路層和網路層原始套接字的收發總體流程。網絡卡驅動收到報文後在軟中斷上下文中由netif_receive_skb()處理,匹配是否有註冊的鏈路層原始套接字,若匹配上就通過skb_clone()來克隆報文,並將報文交給相應的原始套接字。對於IP報文,在協議棧的ip_local_deliver_finish()函式中會匹配是否有註冊的網路層原始套接字,若匹配上就通過skb_clone()克隆報文並交給相應的原始套接字來處理。
注意:這裡只是將報文克隆一份交給原始套接字,而該報文還是會繼續走後續的協議棧處理流程。 


圖2  原始套接字傳送流程
        如圖2所示,鏈路層原始套接字的傳送,直接由套接字層呼叫packet_sendmsg()函式,最終再呼叫網絡卡驅動的傳送函式。網路層原始套接字的傳送實現要相對複雜一些,由套接字層呼叫inet_sendmsg()->raw_sendmsg(),再經過路由和鄰居子系統的處理後,最終呼叫網絡卡驅動的傳送函式。若註冊了ETH_P_ALL型別套接字,還需要將外發報文再收回去。 


2.2鏈路層原始套接字的實現


    2.2.1  套接字建立


    呼叫socket()函式建立套接字的流程如下,鏈路層原始套接字最終由packet_create()建立。
sys_socket()->sock_create()->__sock_create()->packet_create()
    當socket()函式的第三個引數protocol為非零的情況下,會呼叫dev_add_pack()將鏈路層套接字packet_sock的packet_type結構鏈到ptype_all連結串列或ptype_base連結串列中。

 

void dev_add_pack(struct packet_type *pt)
{
        ……
        if (pt->type == htons(ETH_P_ALL)) {
                 netdev_nit++;
                 list_add_rcu(&pt->list, &ptype_all);
         } else {
                  hash = ntohs(pt->type) & 15;
                 list_add_rcu(&pt->list, &ptype_base[hash]);
          }
         ……
}


    當protocol為ETH_P_ALL時,會將套接字加入到ptype_all連結串列中。如圖3所示,這裡建立了兩個鏈路層原始套接字。
  

圖3 ptype_all連結串列 

    當protocol為其它非0值時,會將套接字加入到ptype_base連結串列中。如圖3所示,協議棧本身也需要註冊packet_type結構,圖中淺色的兩個packet_type結構分別是IP協議和ARP協議註冊的,其處理函式分別為ip_rcv()和arp_rcv()。圖中另外3個深色的packet_type結構則是鏈路層原始套接字註冊的,分別用於接收型別為ETH_P_IP、ETH_P_ARP和0x0810型別的報文。
 
 

圖4 ptype_base連結串列 
 
 

2.2.2  報文接收


網絡卡驅動程式接收到報文後,在軟中斷上下文由netif_receive_skb()處理。首先會逐個遍歷ptype_all連結串列中的packet_type結構,若滿足條件“(!ptype->dev || ptype->dev == skb->dev)”,即套接字未繫結或者套接字繫結網口與skb所在網口匹配,就增加報文引用計數並交給packet_rcv()函式處理(若使用PACKET_MMAP收包方式則由tpacket_rcv()函式處理)。
網絡卡驅動->netif_receive_skb()->deliver_skb()->packet_rcv()/tpacket_rcv()
    以非PACKET_MMAP收包方式為例進行說明,packet_rcv()函式中比較重要的程式碼片段如下。當報文skb到達packet_rcv()函式時,其skb->data所指的資料是不包含MAC首部的,所以對於type為非SOCK_DGRAM(即SOCK_RAW)型別,需要將skb->data指標前移,以便資料部分可以包含MAC首部。最後將skb放到套接字的接收佇列sk->sk_receive_queue中,並喚醒使用者態程序來讀取套接字中的資料。

 

 ……
if (sk->sk_type != SOCK_DGRAM) //即SOCK_RAW型別
      skb_push(skb, skb->data - skb->mac.raw);
……
__skb_queue_tail(&sk->sk_receive_queue, skb);
sk->sk_data_ready(sk, skb->len); //喚醒程序讀取資料
……


PACKET_MMAP收包方式的實現有所不同,tpacket_rcv()函式將skb->data拷貝到與使用者態mmap對映的共享記憶體中,最後喚醒使用者態程序來讀取資料。由於報文的內容已存放在核心空間和使用者空間共享的緩衝區中,使用者態可以直接讀取以減少資料的拷貝,所以這種方式效率比較高。
    上面介紹了報文接收在軟中斷的處理流程。下面以非PACKET_MMAP收包方式為例,介紹使用者態讀取報文資料的流程。使用者態recvmsg()最終呼叫skb_recv_datagram(),如果套接字接收佇列sk->sk_receive_queue中有報文就取skb並返回。否則呼叫wait_for_packet()等待,直到核心軟中斷收到報文並喚醒使用者態程序。
sys_recvmsg()->sock_recvmsg()->…->packet_recvmsg()->skb_recv_datagram()


2.2.3  報文傳送


使用者態呼叫sendto()或sendmsg()傳送報文的核心態處理流程如下,由套接字層最終會呼叫到packet_sendmsg()。
sys_sendto()->sock_sendmsg()->__sock_sendmsg()->packet_sendmsg()->dev_queue_xmit()
    該函式比較重要的函式片段如下。首先進行引數檢查及skb分配,再呼叫驅動程式的hard_header函式(對於乙太網驅動是eth_header()函式)來構造報文的MAC頭部,此時的skb->data是指向MAC首部的,且skb->len為MAC首部長度(即14)。對於建立時指定type為SOCK_RAW型別套接字,由於在傳送時需要自行構造MAC頭部,所以將skb->tail指標恢復到MAC首部開始的位置,並將skb->len設定為0(即不使用核心構造的MAC首部)。接著再呼叫memcpy_fromiovec()從skb->tail的位置開始拷貝報文資料,最終呼叫網絡卡驅動的傳送函式將報文傳送出去。
注:如果建立套接字時指定type為SOCK_DGRAM,則使用核心構造的MAC首部,使用者態傳送的資料中不含MAC頭部資料。

……
res = dev->hard_header(skb, dev, ntohs(proto), addr, NULL, len); //構造MAC首部
if (sock->type != SOCK_DGRAM) {
        skb->tail = skb->data; //SOCK_RAW型別
        skb->len = 0;
}
……
err = memcpy_fromiovec(skb_put(skb,len), msg->msg_iov, len); //拷貝報文資料
……
err = dev_queue_xmit(skb); //傳送報文
…… 


2.2.4  其它


a) 套接字的繫結
鏈路層原始套接字可呼叫bind()函式進行繫結,讓packet_type結構dev欄位指向相應的net_device結構,即將套接字繫結到相應的網口上。如2.2.2節報文接收的描述,在接收時如果套介面有繫結就需要進一步確認當前skb->dev是否與繫結網口相匹配,只有匹配的才會將報文上送到相應的套接字。
sys_bind()->packet_bind()->packet_do_bind()
b)套接字選項
以下是比較常用的套接字選項
PACKET_RX_RING:用於PACKET_MMAP收包方式設定接收環形佇列
PACKET_STATISTICS:用於讀取收包統計資訊
c)資訊檢視
鏈路層原始套接字的資訊可通過/proc/net/packet進行檢視。如下為圖2和圖3中建立的原始套接字的資訊,可以檢視到建立時指定的協議型別、是否繫結網口、已使用的接收快取大小等資訊。這些資訊對於分析和定位問題有幫助cat /proc/net/packet
1.    
1.    sk RefCnt Type Proto Iface R Rmem User Inode
2.    ffff810007df8400 3 3 0810 0 1 0 0 1310
3.    ffff810007df8800 3 3 0806 0 1 0 0 1309
4.    ffff810007df8c00 3 3 0800 0 1 560 0 1308
5.    ffff810007df8000 3 3 0003 0 1 560 0 1307
6.    ffff810007df3800 3 3 0003 0 1 560 0 1306

 
2.3  網路層原始套接字的實現

2.3.1  套接字建立


     如圖5所示,在IPV4協議棧中一個傳輸層協議(如TCP,UDP,UDP-Lite等)對應一個inet_protosw結構,而inet_protosw結構中又包含了proto_ops結構和proto結構。網路子系統初始化時將所有的inet_protosw結構hash到全域性的inetsw[]陣列中。proto_ops結構實現的是從與協議無關的套介面層到協議相關的傳輸層的轉接,而proto結構又將傳輸層對映到網路層。
  

圖5  inetsw[]陣列結構 
    呼叫socket()函式建立套接字的流程如下,網路層原始套接字最終由inet_create()建立。
sys_socket()->sock_create()->__sock_create()->inet_create()
    inet_create()函式除用於建立網路層原始套接字外,還用於建立TCP、UDP套接字。首先根據socket()函式的第二個引數(即SOCK_RAW)在inetsw[]陣列中匹配到相應的inet_protosw結構。並將套接字結構的ops設定為inet_sockraw_ops,將套接字結構的sk_prot設定為raw_prot。然後對於SOCK_RAW型別套接字,還要將inet->num設定為協議型別,以便最後能呼叫proto結構的hash函式(即raw_v4_hash())。
        

 

……
sock->ops = answer->ops; //將socket結構的ops設定為inet_sockraw_ops
answer_prot = answer->prot;
……
if (SOCK_RAW == sock->type) { //SOCK_RAW型別的套接字,設定inet->num
        inet->num = protocol;
        if (IPPROTO_RAW == protocol) //protocol為IPPROTO_RAW的特殊處理,
                inet->hdrincl = 1; 後續在報文傳送時會再講到
}
……
if (inet->num) {
         inet->sport = htons(inet->num);
         sk->sk_prot->hash(sk); //呼叫raw_v4_hash()函式將套接字鏈到raw_v4_htable中
}
…… 


經過如上操作後,相應的套接字結構sock會通過raw_v4_hash()函式鏈到raw_v4_htable連結串列中,網路層原始套接字報文接收時需要使用到raw_v4_htable。如圖6所示,共建立了3個網路層原始套接字,協議型別分別為IPPROTO_TCP、IPPROTO_ICMP和89。 
 

圖6  raw_v4_htable連結串列 

2.3.2  報文接收


    網絡卡驅動收到報文後在軟中斷上下文由netif_receive_skb()處理,對於IP報文且目的地址為本機的會由ip_rcv()最終呼叫ip_local_deliver_finish()函式。ip_local_deliver_finish()主要功能的程式碼片段如下,先根據報文的L4層協議型別hash值在圖5中的raw_v4_htable表中查詢是否有匹配的sock。如果有匹配的sock結構,就進一步呼叫raw_v4_input()處理網路層原始套接字。不管是否有原始套接字要處理,該報文都會走後續的協議棧處理流程。即會繼續匹配inet_protos[]陣列,根據L4層協議型別走TCP、UDP、ICMP等不同處理流程。

……
hash = protocol & (MAX_INET_PROTOS - 1); //根據報文協議型別取hash值
raw_sk = sk_head(&raw_v4_htable[hash]); //在raw_v4_htable中查詢
……
if (raw_sk && !raw_v4_input(skb, skb->nh.iph, hash)) //處理原始套接字
……
if ((ipprot = rcu_dereference(inet_protos[hash])) != NULL) { //匹配inet_protos[]陣列
        ……
        ret = ipprot->handler(skb); //呼叫傳輸層處理函式
         ……
 } else { //如果在inet_protos[]陣列中未匹配到,則釋放報文
         ……
         kfree_skb(skb);
 }
 …… 


如圖7所示的inet_protos[]陣列,每項由net_protocol結構組成。表示一個協議(包括傳輸層協議和網路層附屬協議)的接收處理函式集,一般包括一個正常接收函式和一個出錯接收函式。圖中TCP、UDP和ICMP協議的接收處理函式分別為tcp_v4_rcv()、udp_rcv()和icmp_rcv()。如果在inet_protos[]陣列中未配置到相應的net_protocol結構,報文就會被丟棄掉。比如OSPF報文(協議型別為89)在inet_protos[]陣列中沒有相應的項,核心會將其丟棄掉,這種報文只能提供網路層原始套接字接收到使用者態來處理。

圖7  inet_protos[]陣列結構  
 網路層原始套接字的總體接收流程如下,最終會將skb掛到相應套接字上,並喚醒使用者態程序讀取報文資料。
網絡卡驅動->netif_receive_skb()->ip_rcv()->ip_rcv_finish()->ip_local_deliver()->ip_local
_deliver_finish()->raw_v4_input()->raw_rcv()->raw_rcv_skb()->sock_queue_rcv_skb()

……
skb_queue_tail(&sk->sk_receive_queue, skb); //掛到接收佇列
if (!sock_flag(sk, SOCK_DEAD))
        sk->sk_data_ready(sk, skb_len); //喚醒使用者態程序5.    ……


       上面介紹了報文接收在軟中斷的處理流程,下面介紹使用者態程序讀取報文是如何實現的。使用者態的recvmsg()最終會呼叫raw_recvmsg(),後者再呼叫skb_recv_datagram。如果套接字接收佇列sk->sk_receive_queue中有報文就取skb並返回。否則呼叫wait_for_packet()等待,直到核心軟中斷收到報文並喚醒使用者態程序。
sys_recvmsg()->sock_recvmsg()->…->sock_common_recvmsg()->raw_recvmsg()
 

2.3.3  報文傳送
 

使用者態呼叫sendto()或sendmsg()傳送報文的核心態處理流程如下,最終由raw_sendmsg()進行傳送。
sys_sendto()->sock_sendmsg()->__sock_sendmsg()->inet_sendmsg()->raw_sendmsg()
    此函式先進行一些引數合法性檢測,然後呼叫ip_route_output_slow()進行選路。選路成功後主要執行如下程式碼片段,根據inet->hdrincl是否設定走不同的流程。raw_send_hdrinc()函式表示使用者態傳送的資料中需要包含IP首部,即由呼叫者在傳送時自行構造IP首部。如果inet->hdrincl未置位,表示核心會構造IP首部,即呼叫者傳送的資料中不包含IP首部。不管走哪個流程,最終都會經過ip_output()->ip_finish_output()->…->dev_queue_xmit()將報文交給網絡卡驅動的傳送函式傳送出去。

……
if (inet->hdrincl) { //呼叫者要構造IP首部
        err = raw_send_hdrinc(sk, msg->msg_iov, len,
                              rt, msg->msg_flags);
} else {
        …… //由核心構造IP首部
       err = ip_push_pending_frames(sk);
}
……


   注:inet->hdrincl置位表示使用者態傳送的資料中要包含IP首部,inet->hdrincl在以下兩種情況下被置位。
    a). 給套接字設定IP_HDRINCL選項
          setsockopt (sockfd, IPPROTO_IP, IP_HDRINCL, &val, sizeof(val))
    b). 呼叫socket()建立套接字時,第三個引數指定為IPPROTO_RAW,見2.3.1節。
          socktet(PF_INET, SOCK_RAW, IPPROTO_RAW)

2.3.4  其它


a) 套接字繫結
若原始套接字呼叫bind()綁定了一個地址,則該套介面只能收到目的IP地址與繫結地址相匹配的報文。核心的具體實現是raw_bind(),將inet->rcv_saddr設定為繫結地址。在原始套接字接收時,__raw_v4_lookup()在設定了inet->rcv_saddr欄位的情況下,會判斷該欄位是否與報文目的IP地址相同。
sys_bind()->inet_bind()->raw_bind()
b) 資訊檢視
網路層原始套接字的資訊可通過/proc/net/raw進行檢視。如下為圖5所建立的3個網路層原始套接字的資訊,可以檢視到建立套接字時指定的協議型別、繫結的地址、傳送和接收佇列已使用的快取大小等資訊。這些資訊對於分析和定位問題有幫助。
1.    cat /proc/net/raw
2.    sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
3.    1: 00000000:0001 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 1323 2 ffff8100070b2380
4.    6: 00000000:0006 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 1322 2 ffff8100070b2080
5.    89: 00000000:0059 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 1324 2 ffff8100070b2680

 

3、應用及注意事項

3.1  使用鏈路層原始套接字


注意事項:
a)       儘量避免建立過多原始套接字,且原始套接字要儘量繫結網絡卡。因為收到每個報文除了會將其分發給繫結在該網絡卡上的原始套接字外,還會分發給沒有繫結網絡卡的原始套接字。如果原始套接字較多,一個報文就會在軟中斷上下文中分發多次,造成處理時間過長。
b)      發包和收包儘量使用同一個原始套接字。如果發包與收包使用兩個不同的原始套接字,會由於接收報文時分發多次而影響效能。而且用於傳送的那個套接字的接收佇列上也會快取報文,直至達到接收佇列大小限制,會造成記憶體洩露。
c)       若只接收指定型別二層報文,在呼叫socket()時指定第三個引數的協議型別,而最好不要使用ETH_P_ALL。因為ETH_P_ALL會接收所有型別的報文,而且還會將外發報文收回來,這樣就需要做BPF過濾,比較影響效能。
 

3.2  使用網路層原始套接字


注意事項:
a)       由於IP報文的重組是在網路層原始套接字接收流程之前執行的,所以該原始套接字不能接收到UDP和TCP的分組資料。
b)      若原始套接字已由bind()綁定了某個本地IP地址,那麼只有目的IP地址與繫結地址匹配的報文,才能遞送到這個套介面上。
c)       若原始套接字已由connect()指定了某個遠地IP地址,那麼只有源IP地址與這個已連線地址匹配的報文,才能遞送到這個套介面上。
 

3.3  網路診斷工具使用原始套接字


很多網路診斷工具也是利用原始套接字來實現的,經常會使用到的有tcpdump, ping和traceroute等。
tcpdump
該工具用於截獲網口上的報文流量。其實現原理是建立ETH_P_ALL型別的鏈路層原始套接字,讀取和解析報文資料並將資訊顯示出來。
ping
該工具用於檢查網路連線。其實現原理是建立網路層原始套接字,指定協議型別為IPPROTO_ICMP。檢測方構造ICMP回射請求報文(型別為ICMP_ECHO),根據ICMP協議實現,被檢測方收到該請求報文後會響應一個ICMP回射應答報文(型別為ICMP_ECHOREPLY)。然後檢測方通過原始套接字讀取並解析應答報文,並顯示出序號、TTL等資訊。
traceroute
該工具用於跟蹤IP報文在網路中的路由過程。其實現原理也是建立網路層原始套接字,指定協議型別為IPPROTO_ICMP。假設從A主機路由到D主機,需要依次經過B主機和C主機。使用traceroute來跟蹤A主機到D主機的路由途徑,具體步驟如下,在每次探測過程中會顯示各節點的IP、時間等資訊。
a)       A主機使用普通的UDP套接字向目的主機發送TTL為1(使用套介面選項IP_TTL來修改)的UDP報文;
b)      B主機收到該UDP報文後,由於TTL為1會拒絕轉發,並且向A主機發送code為ICMP_EXC_TTL的ICMP報文;
c)       A主機用建立的網路層原始套接字讀取並解析ICMP報文。如果ICMP報文code是ICMP_EXC_TTL,就將UDP報文的TTL增加1並回到步驟a)繼續進行探測;如果ICMP報文的code是ICMP_PROT_UNREACH,表示UDP報文到達了目的地。
              A主機―>B主機―>C主機―>D主機