從進程的視角來看,網絡通信就是一個主機上的進程和另外一個主機上的進程進行信息傳遞,因此對于操作系統而言,網絡通信就是一種進程間通信的方式。
不過這種進程間通信有特殊之處:同一臺主機下可以通過進程ID來標識一個唯一的進程,兩個進程通過進程ID來相互識別,可是對于不同主機上的兩個進程而言,知道對方進程ID并沒有什么意義,因此需要另外一種方式來相互識別:IP+端口號(port)
其中IP用于在局域網內確定唯一一臺主機,而規定一個端口號只能被一個進程占用,通過IP+端口號的方式,兩個進程就能識別對方并進行通信。
端口號
1.是傳輸層協議的內容,用來標識一臺主機中的進程
2.是一個2字節,16比特位的整數
3.一個端口號只能被一個進程占用,一個進程可以占用多個端口號
4.IP地址+端口號能夠表示網絡上的某臺主機的一個進程
端口號范圍
0 - 1023: 知名端口號, HTTP, FTP, SSH 等這些廣為使用的應用層協議, 他們的端口號都是固定的.
1024 - 65535: 操作系統動態分配的端口號. 客戶端程序的端口號, 就是由操作系統從這個范圍分配的.
Socket API
操作系統將網絡通信抽象為?Socket(套接字),socket是一個五元組標識,?完整定義為:
[協議, 源IP, 源Port, 目標IP, 目標Port]
(例如?[TCP, 192.168.1.2:54321, 93.184.216.34:80]
)。
在Linux下,socket通過一個文件描述符sockfd來進行描述和管理,每個sockfd對應一個socket,進而對應一個IP+port,由于每個進程有獨立的文件描述符表,這使得該文件描述符sockfd被一個進程獨享,印證了上文IP+Port確定一臺主機上的唯一進程,這同時意味著我們可以用文件IO的方式來進行網絡通信,當然這里我們還是循序漸進,先來介紹socket的相關接口:
socket主要接口依賴于頭文件:
#include <sys/socket.h>
創建一個sockfd:
int socket(int domain, int type, int protocol);
domain: 指定通信協議族,例如: AF_INET: IPv4 網絡協議。 AF_INET6: IPv6 網絡協議。 AF_UNIX: 本地進程間通信。
type: 指定套接字類型,例如: SOCK_STREAM: 面向連接的流式套接字(如 TCP)。 SOCK_DGRAM: 無連接的數據報套接字(如 UDP)。 SOCK_RAW: 原始套接字,用于底層協議訪問(不常用)。
protocol: 指定具體協議,通常設置為?0,表示使用默認協議。例如: 對于 SOCK_STREAM,默認是 TCP。 對于 SOCK_DGRAM,默認是 UDP。
返回值為sockfd
將一個sockfd與一個端口號進行綁定?
int bind(int socket, const struct sockaddr *address,socklen_t address_len);
(1)參數 sockfd ,需要綁定的socket。
(2)參數 addr ,一個存放目的地址和目的端口號的結構體,需要進行初始化。
(3)參數 addrlen ,表示 addr 結構體的大小
(4)返回值:成功則返回0 ,失敗返回-1,錯誤原因存于 errno 中。如果綁定的地址錯誤,或者端口已被占用,bind 函數一定會報錯,否則一般不會返回錯誤。
sockaddr
這個sock_addr就是內核態中的socket從用戶態中獲取地址族和目標IP+port的方式:
struct sockaddr
{ sa_family_t sin_family;//地址族char sa_data[14]; //14字節,包含套接字中的目標地址和端口信息
};
不難發現該數據結構有一個缺陷:端口號和ip地址混在一塊了,這明顯不方便我們進行初始化
為了解決此問題,又定義了sockaddr_in,依賴于頭文件:
#include<netinet/in.h>
所以實踐中,我們會先定義一個sockaddr_in來完成初始化,再將其指針強轉為sockaddr*用于綁定
這里還有一個十分重要的細節問題:網絡字節序
sin_port和sin_addr都必須是網絡字節序(大端模式,低地址高字節),一般可視化的數字都是主機字節序(小端模式,低地址低字節)。
這里用一個簡單的例子來幫助理解:
對于數據 0x12345678,假設從地址0x4000開始存放,在大端和小端模式下,存放的位置分別為:
總之,我們給sin_port和sin_addr進行初始化時,要進行字節序的轉換,這里需要用到兩個函數:
htons()作用是將端口號由主機字節序轉換為網絡字節序的整數值。(host to net)
inet_addr()作用是將一個IP字符串轉化為一個網絡字節序的整數值,用于sockaddr_in.sin_addr.s_addr。
需要頭文件:
#include <arpa/inet.h>
是不是有些復雜,我們實際演示一下:
創建并綁定一個UDP協議的socket:
int port=8080;
char ip[16]=192.168.1.0;int server_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (server_fd < 0)
{perror("socket creation failed");return 1;
}struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(ip);
//推薦這么寫:
//server_add.sin_addr.s_addr=htonl(INADDR_ANY);
server_addr.sin_port = htons(port); if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr))
{perror("bind failed");close(server_fd);return 1;
}
上述接口是UDP和TCP公用的,下面先介紹一下這兩個協議的特點,再分別介紹各自的接口
UDP
核心思想:?“盡最大努力交付” (Best Effort)。簡單、快速、無連接。
關鍵特性:
無連接: 發送數據之前不需要預先建立連接。每個數據包(稱為數據報)都是獨立處理的。
不可靠 :?不保證數據報一定能到達目的地,不保證按發送順序到達,也不保證數據報只到達一次。可能發生丟失、重復、亂序。
面向報文:?對應用層交下來的報文,添加 UDP 首部后就直接交給網絡層 IP。接收方 UDP 對 IP 層交上來的 UDP 數據報,去除首部后就原封不動地交付給上層應用進程。應用程序需要自己處理報文邊界。
無擁塞控制:?UDP 本身不會根據網絡狀況調整發送速率。如果發送太快導致網絡擁堵,UDP 包會被大量丟棄,但它本身不會主動慢下來。
首部開銷小:?UDP 首部固定為 8 字節。
支持單播、多播、廣播:?非常靈活。
緩沖區
UDP 沒有真正意義上的發送緩沖區,調用 sendto 會直接交給內核, 由內核將數 據傳給網絡層協議進行后續的傳輸動作;
UDP 具有接收緩沖區,但是這個接收緩沖區不能保證收到的 UDP 報的順序和發送 UDP 報的順序一致;
如果緩沖區滿了, 再到達的 UDP 數據就會被丟棄;
UDP 的 socket 既能讀, 也能寫, 這個概念叫做全雙工
UDP報頭
UDP在內核中通過sk_buff進行管理,其中有一個指向報文起始位置的指針data,由于UDP的報頭是固定長度8B,所以只需將data指針移動8B即可
UDP雖然不可靠,但是會保證內容的正確性,其中UDP校驗和就是一種檢驗UDP數據包是否有誤的校驗機制,它通過某種算法將UDP數據包中的所有數據進行計算然后存儲在報頭字段中,以便確保接收方在收到數據包后進行校驗。如果校驗失敗的話,就直接把這個數據包丟棄
我們注意到, UDP協議首部中有一個16位的最大長度. 也就是說一個UDP能傳輸的數據最大長度是64K(包含UDP首部). ? 然而64K在當今的互聯網環境下, 是一個非常小的數字.如果我們需要傳輸的數據超過64k,就需要在應用層手動的分包, 多次發送并在接收端手動拼裝(面向數據報)?
send和recvfrom
由于UDP傳輸數據是面向報文的,因此我們不能直接用面向字節流的文件IO接口,而是需要使用函數recvfrom()來接收數據,使用send()來發送數據
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
- 第一個參數sockfd:正在監聽端口的套接口文件描述符,通過socket獲得
- 第二個參數buf:接收緩沖區,往往是使用者定義的數組,該數組裝有接收到的數據
- 第三個參數len:接收緩沖區的大小,單位是字節
- 第四個參數flags:填0即可
- 第五個參數src_addr:指向發送數據的主機地址信息的結構體,也就是我們可以從該參數獲取到數據是誰發出的
- 第六個參數addrlen:表示第五個參數所指向內容的長度
- 返回值:成功:返回接收成功的數據長度
- 失敗: -1
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
- 第一個參數sockfd:正在監聽端口的套接口文件描述符,通過socket獲得
- 第二個參數buf:發送緩沖區,往往是使用者定義的數組,該數組裝有要發送的數據
- 第三個參數len:發送緩沖區的大小,單位是字節
- 第四個參數flags:填0即可
- 第五個參數dest_addr:指向接收數據的主機地址信息的結構體,也就是該參數指定數據要發送到哪個主機哪個進程
- 第六個參數addrlen:表示第五個參數所指向內容的長度
- 返回值:成功:返回發送成功的數據長度
- 失敗: -1
通過UDP協議進行通信的流程如下:
下面我們對上述內容進行一個小實踐,實現一個客戶端的本地回顯
目標:客戶端向服務端發送內容,服務器將這些內容再發給客戶端,客戶端打印接收到的內容
客戶端
#include<iostream>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string.h>int main(int argc,char* argv[])
{std::string server_ip=argv[1];uint16_t server_port=std::stoi(argv[2]);int sockfd=socket(AF_INET,SOCK_DGRAM,0);if(sockfd<0){std::cout<<"創建套接字失敗\n";}sockaddr_in server;memset(&server,0,sizeof(server));server.sin_family=AF_INET;server.sin_port=htons(server_port);server.sin_addr.s_addr=inet_addr(server_ip.c_str());while(true){std::cout<<"請輸入:";std::string line;std::getline(std::cin,line);sendto(sockfd,line.c_str(),line.size(),0,(sockaddr*)&server,sizeof(server));sockaddr_in tmp;socklen_t len=sizeof(tmp);char buffer[1024]={0};int ret=recvfrom(sockfd,buffer,sizeof(buffer)-1,0,(sockaddr*)&tmp,&len);if(ret>0){buffer[ret]=0;std::cout<<buffer<<'\n';}}
}
服務端.hpp
#include"Log.h"
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<strings.h>static const int gdefaultsockfd=-1;class UdpServer
{
public:UdpServer(std::string& ip,uint16_t port):sockfd(gdefaultsockfd),ip(ip),port(port){}~UdpServer()=default;void init(){sockfd=socket(AF_INET,SOCK_DGRAM,0);if(sockfd<0){LOG(LogLevel::FATAL)<<"創建套接字失敗";exit(1);}LOG(LogLevel::INFO)<<"創建套接字成功";sockaddr_in local;bzero(&local,sizeof(local));local.sin_family=AF_INET;local.sin_port=htons(port);local.sin_addr.s_addr=inet_addr(ip.c_str());int n=bind(sockfd,(sockaddr*)&local,sizeof(local));if(n<0){LOG(LogLevel::FATAL)<<"套接字綁定失敗";exit(2);}LOG(LogLevel::INFO)<<"套接字綁定成功";}void start(){is_running=true;while(is_running){char buffer[1024];buffer[0]=0;sockaddr_in peer;socklen_t len=sizeof(peer);ssize_t n=recvfrom(sockfd,buffer,sizeof(buffer),0,(sockaddr*)&peer,&len);if(n>0){buffer[n]=0;std::string echo="server echo#";echo+=buffer;sendto(sockfd,echo.c_str(),echo.size(),0,(sockaddr*)&peer,len);}}}void stop(){is_running=false;}
private:int sockfd;//套接字文件描述符uint16_t port;//端口號std::string ip;//ip地址bool is_running=false;
};
服務端.cc
#include"udp_server.hpp"
#include<memory>int main(int argc,char* argv[])
{std::string ip=argv[1];uint16_t port=std::stoi(argv[2]);std::unique_ptr<UdpServer> udp_server=std::make_unique<UdpServer>(ip,port);udp_server->init();udp_server->start();return 0;
}
分別啟動客戶端和服務端:
服務端
客戶端
測試成功
TCP
Tcp的特性相對于udp較為復雜,因此我們本次只會進行概括性地描述并介紹幾個接口:
有連接,需要客戶端向服務的發起連接請求
可靠傳輸
面向字節流
監聽客戶端的連接請求
int listen(int sockfd, int backlog);
- 功能:將套接字設置為監聽狀態,準備接受客戶端的連接請求。
- 參數:
sockfd
:已綁定的套接字描述符。backlog
:指定等待連接隊列的最大長度。
- 返回值:成功返回 0,失敗返回 -1。
連接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 參數:
sockfd
:套接字描述符。addr
:指向服務器的地址結構體。addrlen
:addr
?結構體的長度。
- 返回值:成功返回 0,失敗返回 -1。
從已完成連接隊列中取出一個連接,并創建一個新的套接字與客戶端進行通信
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 參數:
sockfd
:監聽套接字描述符。addr
:用于存儲客戶端的地址信息。addrlen
:用于指定?addr
?結構體的長度。
- 返回值:成功返回一個新的套接字描述符用于與客戶端通信,失敗返回 -1。
你可能會疑惑我不是已經通過socket創建了一個sockfd嗎,為什么這里還要創建新的sockfd,其實對于Tcp而言,socket創建的sockfd只是用于接收用戶連接請求的,而真正與用戶通信則是通過accept創建的sockfd進行的,這是因為Tcp通信只能一對一進行,即一個端到端通信占用一個sockfd,要想實現一個服務端與多個客戶端進行通信,就需要建立多個sockfd
而由于Tcp是面向字節流進行傳輸的,因此我們可以直接使用文件IO的接口來讀取和寫入數據
下面是Tcp版本的客戶端回顯:
服務端
#include"inet_addr.hpp"
#include"Log.h"
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<strings.h>static const int gdefaultsockfd=-1;
static const int gbacklog=8;class TcpServer
{
public:TcpServer(std::string& ip,uint16_t port=8080):sockfd(gdefaultsockfd),ip(ip),port(port){}~TcpServer()=default;void init(){sockfd=socket(AF_INET,SOCK_STREAM,0);if(sockfd<0){LOG(LogLevel::FATAL)<<"創建套接字失敗";exit(1);}LOG(LogLevel::INFO)<<"創建套接字成功";InetAddr local(port);int n=bind(sockfd,local.Addr(),local.Length());if(n<0){LOG(LogLevel::FATAL)<<"套接字綁定失敗";exit(2);}LOG(LogLevel::INFO)<<"套接字綁定成功";if(listen(sockfd,gbacklog)!=0){LOG(LogLevel::FATAL)<<"監聽套接字失敗";exit(3);}}void start(){is_running=true;while(is_running){sockaddr_in peer;socklen_t len=sizeof(peer);int sock_fd=accept(sockfd,(sockaddr*)&peer,&len); if(sock_fd<0){LOG(LogLevel::FATAL)<<"接收失敗";}InetAddr clientaddr(peer);HandlerIO(sock_fd,clientaddr);}}void stop(){is_running=false;}
private:void HandlerIO(int fd,InetAddr& client){char buffer[1024];while(true){buffer[0]=0;auto n=read(fd,buffer,sizeof(buffer)-1);if(n>0){buffer[n]=0;std::string echo_string = "server echo: ";echo_string += buffer;LOG(LogLevel::INFO) << buffer;auto m = write(fd,echo_string.c_str(),echo_string.size());}}close(fd);}private:int sockfd;//套接字文件描述符uint16_t port;//端口號std::string ip;//ip地址bool is_running=false;
};
客戶端
#include"inet_addr.hpp"
#include<iostream>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string.h>
#include<unistd.h>int main(int argc,char* argv[])
{std::string server_ip=argv[1];uint16_t server_port=std::stoi(argv[2]);int sockfd=socket(AF_INET,SOCK_STREAM,0);if(sockfd<0){std::cerr<<"創建套接字失敗\n";exit(1);}InetAddr server(server_port,server_ip);if(connect(sockfd,server.Addr(),server.Length())!=0){std::cerr<<"連接失敗\n";exit(2);}while(true){std::cout<<"請輸入:";std::string line;std::getline(std::cin,line);auto n=write(sockfd,line.c_str(),line.size());if(n>=0){char buffer[1024];auto m=read(sockfd,buffer,sizeof(buffer)-1);if(m>0){buffer[m]=0;std::cout<<buffer<<'\n';}}}
}
啟動服務端
啟動客戶端
測試成功