TCPIP網絡編程筆記

socket(套接字)

源於UNIX,被BSD發揚光大

  1. 實際上是進程與進程之間的通信方式,socket是進程間通信的接入點
  2. 基於BSD的POSIX標準
  3. 萬物皆文件的泛型,在Linux中可以像操作文件一樣操作socket
  4. 套接字描述符的本質是文件描述符
  5. 可以對套接字進行close,dup2,read,write,select等操作
  6. 字節序的問題,大端和小端

socket()函數

int socket(int protofamily,int type,int protocol);
  • 協議族(protocol family),一般使用PF_INET
  • 套接字類型(type)
    • SOCK_STREAM,面向連接的套接字,可以比喻成兩個工人在傳送帶的兩頭傳遞東西,是一種可靠的,按序傳遞的,基於字節的面向連接的數據傳輸方式的套接字
      • 傳輸過程中數據不會消失
      • 按序傳輸數據
      • 傳輸的數據不存在數據邊界
      • 套接字連接必須一一對應
    • SOCK_DGRAM,面向消息的套接字,可以比喻成高速傳輸的快遞,不可靠的,不按序傳輸的,以數據的高速傳輸爲目的的套接字
      • 強調快速傳輸而非傳輸順序
      • 傳輸的數據可能丟失也可能損毀
      • 數據的傳輸有數據邊界
      • 限制每次傳輸的數據大小
  • 計算機間的通信協議(protocol)
    socket()會返回一個socket描述符,類似於文件描述符,用於對socket進行操作
  • protofamily:即協議域,又稱爲協議族,它決定了socket的地址類型,在通信中必須採用對應的地址類型,如AF_INET決定了要用ipv4地址(32位的)與端口號(16位的)的組合、AF_UNIX決定了要用一個絕對路徑名作爲地址,常用的協議族有AF_INET(IPV4)、AF_INET6(IPV6)、AF_LOCAL(或稱AF_UNIX,Unix域socket)、AF_ROUTE等等
  • type:socket類型,常用的socket類型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等
  • protocol:採用的協議,用的協議有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它們分別對應TCP傳輸協議、UDP傳輸協議、STCP傳輸協議、TIPC傳輸協議
  • type和protocol不可以隨意組合的,如SOCK_STREAM不可以跟IPPROTO_UDP組合。當protocol爲0時,會自動選擇type類型對應的默認協議
  • 當我們調用socket創建一個socket時,返回的socket描述字它存在於協議族(address family,AF_XXX)空間中,但沒有一個具體的地址。如果想要給它賦值一個地址,就必須調用bind()函數,否則就當調用connect()、listen()時系統會自動隨機分配一個端口
  • inet_pton函數需要包含頭文件arpa/inet.h
  • 當使用ctrl+c結束服務器端時,再次運行服務器bind()函數會出現地址已經被使用的錯誤,它是由TCP套接字TIME_WAIT狀態引起,該狀態會在套接字關閉後保留幾分鐘,在該狀態退出後,該地址才能被重新使用,不要使用ctrl+z退出,要使用ctrl+c退出
    bind()函數
int bind(int socket,const struct sockaddr* addr,socklen_t addrlen)

bind()函數用於將地址族中的特定地址賦給socket
sockfd:即socket描述符,是socket的唯一標識
addr:指向綁定給sockfd的協議地址,這個地址結構根據地址創建socket時的地址協議族的不同而不同
addrlen:對應地址的長度
通常服務器在啓動的時候都會綁定一個衆所周知的地址(如ip地址+端口號),用於提供服務,客戶就可以通過它來接連服務器;而客戶端就不用指定,有系統自動分配一個端口號和自身的ip地址組合。這就是爲什麼通常服務器端在listen之前會調用bind(),而客戶端就不會調用,而是在connect()時由系統隨機生成一個

struct sockaddr_in {
    sa_family_t    sin_family; /* address family: AF_INET */
    in_port_t      sin_port;   /* port in network byte order */
    struct in_addr sin_addr;   /* internet address */
};

/* Internet address. */
struct in_addr {
    uint32_t       s_addr;     /* address in network byte order */
};
//ipv6對應的是: 
struct sockaddr_in6 { 
    sa_family_t     sin6_family;   /* AF_INET6 */ 
    in_port_t       sin6_port;     /* port number */ 
    uint32_t        sin6_flowinfo; /* IPv6 flow information */ 
    struct in6_addr sin6_addr;     /* IPv6 address */ 
    uint32_t        sin6_scope_id; /* Scope ID (new in 2.4) */ 
};

struct in6_addr { 
    unsigned char   s6_addr[16];   /* IPv6 address */ 
};
//Unix域對應的是: 
#define UNIX_PATH_MAX    108

struct sockaddr_un { 
    sa_family_t sun_family;               /* AF_UNIX */ 
    char        sun_path[UNIX_PATH_MAX];  /* pathname */ 
};

INADDR_ANY代表任何地址,任意地址
htonl()函數可以將一個int轉換爲IP地址需要的格式,將一個32位數從主機字節順序轉換成網絡字節順序
htons()函數將一個int轉換爲port需要的格式
listen()函數和connect()函數
作爲一個服務器,在調用socket()和bind()之後,需要調用listen()來監聽這個socket,如果此時客戶端調用connect()發出連接請求,服務端就會接受到這個請求

int listen(int sockfd,int backlog);
int connect(int sockfd,const struct sockaddr *addr,socklen_t addrlen);

listen函數的第一個參數即爲要監聽的socket描述字,第二個參數爲相應socket可以排隊的最大連接個數。socket()函數創建的socket默認是一個主動類型的,listen函數將socket變爲被動類型的,等待客戶的連接請求。
connect函數的第一個參數即爲客戶端的socket描述字,第二參數爲服務器的socket地址,第三個參數爲socket地址的長度。客戶端通過調用connect函數來建立與TCP服務器的連接。
accept()函數

int accept(int sockfd,struct sockaddr* addr,socklen_t addrlen)

sockfd:參數sockfd就是上面解釋中的監聽套接字,這個套接字用來監聽一個端口,當有一個客戶與服務器連接時,它使用這個一個端口號,而此時這個端口號正與這個套接字關聯。當然客戶不知道套接字這些細節,它只知道一個地址和一個端口號。

addr:這是一個結果參數,它用來接受一個返回值,這返回值指定客戶端的地址,當然這個地址是通過某個地址結構來描述的,用戶應該知道這一個什麼樣的地址結構。如果對客戶的地址不感興趣,那麼可以把這個值設置爲NULL。

len:如同大家所認爲的,它也是結果的參數,用來接受上述addr的結構的大小的,它指明addr結構所佔有的字節個數。同樣的,它也可以被設置爲NULL。

如果accept成功返回,則服務器與客戶已經正確建立連接了,此時服務器通過accept返回的套接字來完成與客戶的通信

accept()函數默認會阻塞進程,直到有一個客戶連接建立之後返回,它返回一個新可用的socket,這個socket是連接socket

監聽socket:監聽套接字正如accept的參數sockfd,它是監聽套接字,在調用listen函數之後,是服務器開始調用socket()函數生成的,稱爲監聽socket描述字

連接socket:一個套接字會從主動連接的套接字變身爲一個監聽套接字;而accept函數返回的是已連接socket描述字(一個連接套接字),它代表着一個網絡已經存在的點點連接

一個服務器通常只創建一個監聽socket,每個與服務器進程建立連接的客戶都有一個連接socket,完成對該客戶的服務後這個socket纔會被關閉

因爲套接字在傳輸數據時需要根據IP地址和端口號來確定目的地址,所以sockaddr_in結構體中包含了所使用的地址族,IP地址以及端口號這三個信息
地址族(Adress family)

  • IPV4,4字節
  • IPV6,16字節
    IP可以分爲網絡號與主機號,但是僅僅有主機號,只保證了信息可以準確地在計算機之間進行傳遞,但是不同的應用程序又會對應着不同的socket,這時就需要端口號了,簡單地說,端口號就是爲了區分不同套接字來設置的,因此一個端口號不能分配給不同的socket。
  • 端口號由16位構成,雖然端口號不能重複,但TCP套接字和UDP套接字不會共用端口號,所以允許重複

網絡字節序與地址變換

CPU解析數據的方式分爲大端序和小端序

  • 大端序:高位字節放到低位地址
  • 小端序(我們熟悉的順序):高位字節放到高位地址
    爲了防止網絡傳輸過程中出現差錯,網絡字節序統一爲大端序 因此當計算機收到數據時,應該以大端序解析數據;當計算機發送數據時,應該將數據轉化爲大端序
  • 字節序轉換的函數
unsigned short htons(unsigned short)
unsigned long htonl(unsigned long)
unsigned short ntohs(unsigned short)
unsigned long ntohl(unsigned long)```
上述函數名中的h指的是host,n指的是netwoek,s指的是short2字節),l指的是long4字節)

```c
in_addr_t inet_addr(const char* string)```
可以將一個點分十進制表示的IP地址轉換爲轉換成32位大端序的整數型數據,返回轉換之後的數據

```c
int inet_aton(const char* string,struct in_addr *addr)

成功轉換返回1,轉換失敗返回0;這個函數實際上是與inet_addr的作用是一樣的,不過它會自動把轉換之後的ip地址填入傳入的in_addr之中

char* inet_ntoa(struct in_addr adr);

這個函數實現的功能與上一個函數正好相反,但要注意保存返回的字符串,成功時返回轉換的字符串地址值,失敗時返回-1
服務器端在進行bind的時候,可以將IP地址設置爲INADDR_ANY,這樣可以自動獲取計算機的IP地址,若計算機被分配了多個IP地址,則只要端口號一致,就可以從不同的IP地址接收數據,服務器優先考慮INADDR_ANY的綁定方式,而客戶端中除非帶有一部分服務端的功能,否則不會採用

理解TCP和UDP

TCP套接字是面向連接的,因此又被稱爲基於流的套接字
主要的四層協議棧,數據鏈路層->IP層->TCP層(UDP層)->應用層
TCP的作用是保證數據交換過程中可以確認對方已經收到數據,並且重傳丟失的數據,那麼即便IP層不保證數據傳輸,這類通信也是可靠的。IP層只關注將數據發送出去,但不關心數據是否丟失或者順序錯誤
客戶端調用了connect函數後,發生以下情況之一纔回返回

  • 服務器端接收連接請求,這裏的服務器端接收連接不意味着服務器端調用accept函數,其實是服務器端把連接請求信息記錄到等待隊列
  • 發生斷網等異常清場而中斷連接
    服務器在創建套接字之後,調用connect函數時會給客戶端分配地址,是在操作系統中分配,更準確地說是在內核中,IP用計算機的IP,端口隨機

迭代服務器端和客戶端

  • 服務器端在同一時刻只與一個客戶端相連,並提供echo服務
  • 服務端依次向5個客戶端提供服務並退出
  • 客戶端接收用戶輸入的字符串併發送到服務器端
  • 服務器端將接受的字符串數據傳回客戶端,即echo
  • 服務器端和客戶端之間的字符串回升一直執行到客戶端輸入Q爲止
    詳情參考echo_server.c和echo_client.c,但是兩個程序會有問題,因爲基於TCP的情況下,數據傳輸是不存在數據邊界的,因此客戶端中多次調用write函數傳遞的字符串有可能一次性傳遞到服務器端,然後客戶端可能會從服務器端收到多個字符串;另外服務器端還有可能將過長的數據分成幾個數據包進行發送,客戶端在讀取到全部數據之前就調用了read函數。這些問題都是源自TCP的傳輸特性

TCP套接字中的I/O緩衝

實際上write函數調用後並非立即傳輸數據,read函數調用後也並非馬上接受數據。write函數調用瞬間先將數據放到輸出緩衝中,read函數調用之後,從輸入緩衝讀取數據

  • I/O緩衝在每個TCP套接字中單獨存在
  • I/O緩衝在創建套接字時自動生成
  • 關閉套接字也會繼續傳遞輸出緩衝中遺留的數據,但是會丟失輸入緩衝中的數據
  • 滑動窗口機制可以確保發送的數據不會超過輸入緩衝的大小

TCP內部工作原理

  • 與對方建立連接(三次握手)
    套接字是以全雙工方式工作的,可以雙向傳輸數據
    A:B你好,我有數據要傳給你,建立連接吧
    B:好,我已經準備好接收數據
    A:謝謝你受理我的請求
    A先向B發送SYN消息,然後B會給A發送SYN+ACK消息,最後A會給B發送ACK消息
  • 與對方交換數據
    超時重傳,若發送數據的主機,在一定時間內沒收到正確的ACK應答,那麼便會試着重傳,TCP套接字在發送數據後會啓動計時器以等待ACK應答,若相應計時器發生了超市則重傳
  • 與對方斷開連接(四次揮手)
    A:我希望斷開連接
    B:哦。是嗎,請稍後
    B:我也準備就緒,可以斷開連接了
    A:好的,謝謝合作

UDP

UDP在結構上比TCP更加簡潔,它不會像TCP那樣發送類似SYN和ACK的應答消息,也不會像SEQ那樣給數據分配序號,因此UDP的性能比TCP的高很多。在更重視性能而非可靠性的情況下,UDP是一種很好的選擇

UDP工作原理

TCP會在不保證可靠交付的IP層進行流控制,UDP不會進行流控制。
在保證實時傳輸的情況下,應該使用UDP
TCP比UDP慢的原因是收發數據前後進行的連接設置以及清除過程;收發數據過程中爲保證可靠性添加的流控制
UDP中的服務器端和客戶端沒有連接,它不需要再連接狀態下交換數據,因此不必調用listen()和accept()函數
在TCP中,套接字是一對一的關係,也就是除了用於守門的socket之外,每一個客戶端和服務器通信都需要獨立的socket;但是UDP不管是客戶端還是服務器端都只需要一個socket;通俗地說UDP像是郵筒通信,TCP更像是打電話,打電話之前需要雙方先建立連接,郵筒的通信則不需要,只要有目的地址

UDP的數據I/O

在TCP中,兩個套接字建立好連接之後,傳輸數據時不需要提供地址;但UDP不會保持連接狀態,每次的數據傳輸都需要添加目的地址信息

#include <sys/socket.h>
ssize_t sendto(int sock,void* buff,size_t nbytes,int flags,struct sockaddr *to,socklen_t addrlen)

成功時返回傳輸的字節數,失敗時返回-1

  • sock是用於傳輸數據的UDP套接字文件描述符
  • buff是保存待傳輸數據的緩衝
  • 可選項參數,若沒有則爲0
  • 存有目標地址的sockaddr結構體
  • 上述sockaddr的長度
#include <sys/socket.h>
ssize_t recvfrom(int sock,void *buff,size_t nbytes,int flasgs,struct sockaddr *from,socklen_t *addrlen)
  • sock是用於接收數據的UDP套接字文件描述符
  • buff是用於保存接收數據的緩衝
  • nbytes是可接收的最大長度,因此最大也無法超過buffer長度
  • flags是可選項參數,若沒有則傳入0
  • sockaddr from是用於保存發送端地址的結構體,因爲UDP中數據的發送端並不固定
  • from結構體的長度

UDP客戶端套接字的地址分配

在TCP的客戶端中,調用connect函數時會自動完成對客戶端的IP地址和端口分配,在UDP中,如果調用sentdo函數時返現尚未分配地址信息,則在首次調用sendto函數時給相應套接字自動分配IP和端口,而且此時分配的地址一直保留到程序結束爲止,此時分配的IP使用主機IP,端口號選用未使用的端口號

UDP的數據傳輸特性和調用connect函數

TCP數據傳輸中不存在邊界,表示在數據傳輸的過程中調用I/O函數的次數不具有任何意義;相反UDP數據傳輸的過程中存在數據邊界,因此輸入函數的調用次數應該與輸出函數一致

已連接的UDP套接字和未連接UDP套接字

UDP傳輸數據可以大致分爲3個過程

  1. 向UDP套接字註冊目標IP和端口號
  2. 傳輸數據
  3. 刪除UDP套接字中註冊的目標地址信息
    但有時候,UDP套接字會對同一個目標地址進行多次sendto,這時第一步和第三步操作就顯得沒必要,UDP套接字默認是沒有連接的,上面這個情況就要用到連接的UDP套接字
    創建已連接的UDP套接字需要用到connect函數,使用connect函數使一個UDP套接字成爲一個連接的UDP套接字以後,不僅可以使用sendto和recvfrom來收發數據,也可以使用read和write來收發數據

如何斷開套接字

之前使用的方法都是調用close()函數單方面斷開連接,這種方法不夠優雅,close()函數意味着完全斷開連接,完全斷開意味着無法傳輸數據,也不能接受數據
爲了應對套接字關閉過程中的問題,衍生了半關閉的概念,半關閉指的是斷開一部分連接,處於可以傳輸數據但是無法接受或者可以接收數據但無法傳輸的狀態
shutdown函數,用於半關閉

int shutdown(int sock,int howto)

sock指的是需要斷開的套接字文件描述符,howto指的是斷開方式信息,一般有以下幾項

  • SHUT_RD:斷開輸入流
  • SHUT_WR:斷開輸出流
  • SHUT_RDWR:同時斷開I/O流
    斷開輸入流,套接字無法接收數據,即使輸入緩衝收到數據也會被抹去;斷開輸出流,套接字無法傳輸數據,但如果輸入緩衝中留有未傳輸的數據則傳遞到目標主機;同時斷開IO流則輸入流和輸出流都會被斷開
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章