从进程的视角来看,网络通信就是一个主机上的进程和另外一个主机上的进程进行信息传递,因此对于操作系统而言,网络通信就是一种进程间通信的方式。
不过这种进程间通信有特殊之处:同一台主机下可以通过进程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';}}}
}
启动服务端
启动客户端
测试成功