预备知识
源IP与目的IP
每台计算机的公网IP是唯一的,如果本地主机和对端主机要实现通信,那么对端主机的IP地址将作为该数据传输的目的IP地址。仅仅知道目的IP地址是不够的,当对端主机接受数据后需对该主机做出响应,于是对端主机就需要知道本地主机的IP地址,即源IP。
源MAC地址和目的MAC地址
大部分网络服务使跨局域网的,期间会跳到多个路由器最终达到目的主机。
开始传输时的源MAC地址是本地主机的MAC,目的MAC是下一跳的路由器MAC。最后一跳的源MAC则是最后途径的路由器的MAC,目的MAC是对端主机的MAC。
因此数据的网络地址是有两套地址的:
- 源IP地址和目的IP地址,这两个地址在数据传输过程中基本是不会发生变化的(存在一些特殊情况,比如在数据传输过程中使用NET技术,其源IP地址会发生变化,但至少目的IP地址是不会变化的)。
- 源MAC地址和目的MAC地址,这两个地址是一直在发生变化的,因为在数据传输的过程中路由器不断在进行解包和重新封装。
端口号
一个端口号唯一标识一台主机的某一进程。
IP标识公网上唯一的一台主机,端口号又是用来识别主机上的唯一进程,IP+端口号可实现标识全网内的唯一进程,达成网络传输点到点服务。
-
端口号与PID 两者都是唯一标识一台主机上的某一进程,那两者有何区别呢? 一个主机上存在多个进程,但不是所有进程都会执行网络传输。执行网络请求的进程需要使用端口号来标识唯一性,所以端口号是面向网络服务的。而PID是标识当前主机所以进程的唯一性,面向操作系统服务。二者是不同层面表示进程唯一性的表达机制,如同公民在社会使用身份证号标识唯一性,在单位使用工号表达唯一性。 -
源段端口号和目的端口号 两台主机进行通信,只有对端主机的IP地址只能够帮我们找到在网络中对端的主机,但是我们还需要找到主机中提供相应服务的进程,这个进程可以通过对端进程绑定的端口号找到,也就是目的端口号。 同样对端主机也需要给发送方响应,通过源IP地址找到发送方的那一台主机,找到主机还是不够的,还需要找到对端主机是哪一个进程发起了请求,响应方需要通过发起请求的进程绑定的端口号找到该进程,也就是源端口号,然后就可以进行响应。
- 源端口号: 发送方主机的服务进程绑定的端口号,保证接收方能够找到对应的服务
- 目的端口号: 接收方主机的服务进程绑定的端口号,保证发送方能够找到对应的服务
socket通信的本质: 跨网络的进程间通信。从上面可以看出,网络通信就是两台主机上的进程在进行通信。
主机字节序与网络字节序
现代CPU的累加器依次能装载至少4个字节(考虑32位机),即一个整数。那么这四个字节在内存中的排列顺序将影响它被累加器装载成的整数的值,这就是字节序问题。字节序分为大端字节序(big endian)和小端字节序(little endian)。
- 大端字节序:整数的高位字节存储在内存的低地址处。
- 小端字节序:整数的高位字节存储在内存的高地址处。
void byteorder()
{
union
{
short value;
char union_bytes[sizeof(short)];
}test;
test.value = 0x0102;
if ((test.union_bytes[0] == 1)&& (test.union_bytes[1] == 2))
{
printf("big endian\n");
}
else if((test.union_bytes[0] == 2) && (test.union_bytes[1] == 1))
{
printf("little endian\n");
}
else
{
printf("unknown\n");
}
}
当格式化的数据在两台使用不同字节序的主机之间直接传递时,接收端必然错误的解释之。
解决问题的办法是:TCP/IP的协议规定网络数据流采用大端字节序。
所以发送端总是采用大端字节序,所以接收端可以根据自身采用的字节序决定是否对接受到的数据进行转换(小端机转换,大端机不转)。
因此大端字节序也称为网络字节序,他给所有接收数据的主机提供了一个正确解释收到的格式化数据的保证
Linux提供了如下的库函数做网络字节序和主机字节序的转换:
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
- h 表示host(主机),n表示net(网络),l表示long(32位),s表示short(16位);
- htonl 表示将32位长整型从主机字节序转换为网络字节序;
- 如果主机是小端字节序,这些函数将参数做转换后返回;
- 主机是大端字节序,函数不做转换,将参数原封不动返回。
这四个函数中,长整型函数通常用来转换IP地址,短整型函数用来转换端口号(当然不限于此,任何格式化的数据通过网络传输时,都应该使用这些函数来转换字节序)。
socket 地址的数据类型和相关函数
socket常见API
#include <sys/types.h>
#include <sys/socket.h>
- 创建通信端点并返回 socket 文件描述符 (TCP/UDP,客户端+服务器)
int socket(int domain, int type, int protocol);
int bind(int sockfd, const struct sockaddr *address, socklen_t address_len);
int listen(int sockfd,int backlog);
int accept(int sockfd,const struct sockaddr *address, socklen_t address_len)
int connect(int sockfd,const struct sockaddr *address, socklen_t address_len)
sockaddr 结构
socket 网络编程接口中表示socket地址的是结构体 socketaddr ,其定义如下:
struct sockaddr
{
sa_family_t sa_family;
char sa_data[14];
};
地址族类型 sa_family_t 的实际类型为 unsigned short
- sa_family 成员是地址族类型的变量,地址族类型与协议族类型对应。
- sa_data成员用于存放socket地址值,不同协议的地址值具有不同的含义和长度。
常见的协议族(protocol family,也称domain)与对应的地址族如表所示:
协议族 | 地址族 | 描述 | 地址值含义和长度 |
---|
PF_UNIX | AF_UNIX | UNIX本地域协议族 | 文件的路径名,长度可达到108字节 | PF_INET | AF_INET | TCP/IPv4协议族 | 16bit端口号和32bitIPv4地址,共6字节 | PF_INET6 | AF_INET6 | TCP/IPv6协议族 | 16bit端口号和32bit流标识,128bit IPv6地址,32bit范围ID,共26字节 |
宏PF_*,AF_*都定义在 bits/socket.h 头文件中,且后者与前者有完全相同的值,所以二者通常混用。
Linux为各个协议族提供了专门的socket地址结构体:
在进行跨网络通信时我们需要传递端口号和IP地址,因此提供网络专用socket结构体。 IPv4和IPv6的地址格式定义在 netinet/in.h 中,IPv4地址使用 sockaddr_in 结构体表示,包括16位端口号和32位IP地址,IPv6地址用 sockaddr_in6 结构体表示,包括16位端口号,128位IP地址。
#define __SOCK_SIZE__ 16
struct sockaddr_in
{
__kernel_sa_family_t sin_family;
u_int16_t sin_port;
struct in_addr sin_addr;
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -sizeof(unsigned short int) - sizeof(struct in_addr)];
};
struct in_addr
{
u_int32_t s_addr;
}
struct sockaddr_in6
{
sa_family_t sin6_family;
u_int16_t sin6_port;
u_int32_t sin6_flowinfo;
struct in6_addr sin6_addr;
u_int32_t sin6_scope_id;
};
struct in6_addr
{
unsigned char sa_addr[16];
};
socket不仅支持网络的进程间通信,还支持本地的进程间通信(域间套接字)。
本地域协议族的专用socket地址结构体:
#include <sys/un.h>
struct sockaddr_un
{
sa_family_t sin_family;
char sun_path[108];
};
所有专用的socket地址类型的变量在实际使用时都需要转化为通用socket地址类型sockaddr(强制转换即可)。
其实sockaddr 和 sockaddr_in 之间的转化很容易理解,因为他们开头一样,内存大小也一样,但是sockaddr和sockaddr_in6之间的转换就有点让人搞不懂了,其实你有可能被结构所占的内存迷惑了,这几个结构在作为参数时基本上都是以指针的形式传入的,我们拿函数bind()为例,这个函数一共接收三个参数,第一个为监听的文件描述符,第二个参数是sockaddr*类型,第三个参数是传入指针原结构的内存大小,所以有了后两个信息,无所谓原结构怎么变化,因为他们的头都是一样的,也就是u_int_16 sa_family,那么我们也能根据这个头做处理。
bind,accept,connect这些socket函数的参数应该设计成 void* 类型以便接受各种类型的指针,但是 socketAPI 的实现在于ANSI C标准化,那是还没有void*,因此这些函数的参数都用 struct sockaddr*类型来表示:
struct sockaddr_in servaddr;
bind(listen_fd,(struct sockaddr*)&servaddr,sizeof(servaddr));
IP地址转换函数
上面谈到IP地址是32位的,而平时人们习惯使用可读性好的字符串来表示IP地址,比如点分十进制字符串表示IPv4地址,以及用十六进制字符串表示IPv6地址。但编程中我们需要先把他们转化为整数(二进制数)方能使用。
下面3个函数可用于用点分十进制字符串表示的IPv4地址和用络字节序整数表示的IPv4地址之间的转换:
字符串转 in_addr(网络字节序) 的函数
#include<arpa/inet.h>
in_addr_t inet_addr(const char* strptr);
int inet_aton(const char* cp,struct in_addr* inp);
int inet_pton(int family,const char* strptr,void *addrptr);
-
inet_addr函数 将点分十进制字符串的IPv4地址转为网络字节序IPv4地址,失败返回INADDR_NONE(-1)。 缺陷:那就是当IP是255.255.255.255时,这个函数会认为这是个无效的IP地址 -
inet_aton函数 完成和 inet_addr 同样的功能,但是将转化结果存储在参数 inp 指向的结构体中。 成功返回1,失败返回0。 inet_aton函数和上面这个函数的区别就是在于他认为255.255.255.255是有效的,他不会冤枉这个看似特殊的IP地址。对了,inet_aton函数返回的是网络字节序的IP地址。 -
inet_pton函数 功能和前两个函数一样,并且它同时适用于IPv4地址和IPv6地址。 其将字符串表示的IP地址src(点分十进制字符串表示的IPv4地址或用十六进制字符串表示的IPv6地址)转换成用网络字节序整数表示的IP地址,并把地址存储于addptr指向的内存中。 其中family参数指定地址族,可以是 AF_INET 或者 AF_INET6 。 成功返回1,失败返回0并设置errno。
in_addr 转字符串的函数
char* inet_ntoa(struct in_addr in);
const char* inet_ntop(int family,const void* src,char* dst,socklen_t cnt);
-
inet_ntoa 函数 将网络字节序整数表示的IPv4地址转为用点分十进制字符串表示的IPv4地址。 🚩注意:该函数内使用一个静态变量存储存储转化结果,函数的返回值指向该静态内存,这样第二次调用的结果会覆盖掉上一次的结果。 -
inet_ntop 函数 前三个参数与inet_pton的参数相同,最后一个参数cnt指定目标存储单元的大小,下面的两个宏帮我们指定大小(分别用于IPv4和IPv6): #include<netinet/in.h>
#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46
inet_ntop这个函数是由调用者自己提供一个缓冲区保存结果,是线程安全的。
创建 socket —— socket函数
Linux的一个设计哲学是:一切皆文件。socket也不例外,他就是可读,可写,可控制,可关闭的文件描述符。下面的socket系统调用创建一个socket:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
?socket底层做了什么
每个进程都有一个进程控制块PCB(task_struct ),其中有个指针指向了结构体 struct files_struct ,该结构体中存有一张文件描述符表 fd_array (数组),前三个下标指向了标准输入,标准输出和标准错误流。第一个创建的文件(包含socket),分配到的第一个下标将会是3。
每一个 struct file 结构体包含文件的信息(属性,操作函数以及文件缓冲区等),属性由 struct inode 结构体维护,struct file_operations 包含了处理文件的函数指针,文件缓冲区对一般的文件是磁盘,而网络传输文件则是网卡。
Socket 是和应用程序一起创建的。
应用程序中有一个 socket 组件,在应用程序启动时,会调用 socket 申请创建Socket,协议栈会根据应用程序的申请创建Socket:首先分配一个Socket所需的内存空间,这一步相当于是为控制信息准备一个容器,但只有容器并没有实际作用,所以你还需要向容器中放入控制信息;如果你不申请创建Socket所需要的内存空间,你创建的控制信息也没有地方存放,所以分配内存空间,放入控制信息缺一不可。至此Socket的创建就已经完成了。
Socket创建完成后,会返回一个Socket文件描述符给应用程序,这个描述符相当于是区分不同Socket的号码牌。根据这个描述符,应用程序在委托协议栈收发数据时就需要提供这个描述符。
命名 socket —— bind函数
创建socket时,我们指定了地址族和数据格式,但是并未指定使用该地址族的哪个具体socket地址(sockaddr)。
将一个socket文件(文件描述符)与socket地址绑定称为给socket命名。
- 🚩在服务器程序中,我们通常要命名socket,显式地将端口号和IP地址赋值给socket地址,然后与socket进行绑定。因为只有在命名后客户端才知道如何连接服务器(相当于服务器开一个socket空间,把空间在网络中的位置告知想要来访问的客户)。
- 🚩在客户端程序中,通常不需要命名socket,操作系统会隐式地分配socket地址并自动绑定。它的端口号将是随机的空闲端口。当然也可以显式命名socket,但是固定端口号可能在此之前会被其他进程占用,不推荐。
- 函数声明
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd,const structaddr* my_addr,socklen_t addrlen);
UDP 数据读写 —— recvfrom、sendto 函数
socket编程接口中用于UDP数据报读写的系统调用是:
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd,void* buf,size_t len,int flags,struct sockaddr* src_addr,socklen_t* addrlen);
ssize_t sendto(int sockfd,const void* buf,size_t len,int flags,const struct sockaddr* dest_addr,socklen_t addlen);
recvfrom 读取 sockfd上 的数据,buf 为指定的读缓冲区的位置(需程序员提前预留好空间),len 为该读缓冲区的大小,但是这个读缓冲区不能保证收到的UDP报的顺序和发送UDP的顺序一致。如果缓冲区满了,再到达的UDP数据就会被丢弃。因为UDP通信没有连接的概念,所以我们每次读取数据都需要获取发送端的socket地址,即通过输出型参数 src_addr 获取socket地址(对端的IP和端口号),addrlen 参数则指定该socket地址的长度。
sendto往 sockfd上 写入数据,buf和len参数分别指定写缓冲区的位置和大小。dest_addr 参数指定接收端的socket地址,addrlen 参数则指定该地址的长度。
flag 参数为数据收发提供额外的控制,他可以和下方所示的选项中的一个和几个进行逻辑或:
recvfrom sendto的flag含义和recv和send相同,后者为TCP收发数据的函数。
recvfrom的成功时返回实际读取到的数据的长度,他可能小于我们期望的长度len,因此我们可能要多次调用recv,才能读取到完整数据。recvfrom返回值为0,意味着通信双方已经关闭了连接。recvfrom出错时返回-1,并设置errno。
sendto成功时返回实际写入的数据的长度,失败则返回-1,并设置errno。
UDP 服务端与客户端的实现
我们分别使用两个类来对服务端和客户端进行封装
构建UDP具体的通信流程如下:
- 编写服务器端UDP程序,bind是一个必不可少的步骤,告诉操作系统服务器的该业务想从哪个端口获取数据。
- 客户端作为通信的发起者,并没有bind操作,系统会自动帮我们选择空闲端口往外发送数据,并记录下该端口号信息。当然并不是说不能主动bind,但我们不推荐,因为实际运行起来时,我们绑定端口号未必是空闲的。
我会在下文分别实现基于UDP的服务器端和客户端
建立的工程文件如下:
Makefile文件如下:
CC=g++
.PHONY:all
all:udp_client udp_server
udp_server:udp_server.cc
$(CC) -o $@ $^ -std=c++11
udp_client:udp_client.cc
$(CC) -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf udp_client udp_server
我们先实现一个单纯传递数据的UDP程序
UDP服务端实现
服务器的IP需绑定INADDR_ANY (宏,值为0),表示不确定地址,或者“所有地址”、“任意地址”。
一般而言,如果你要建立网络服务器应用程序,则你要通知服务器操作系统:请在某地址 xxx.xxx.xxx.xxx上的某端口 yyyy上进行侦听,并且把侦听到的数据包发送给我。这个过程,你是通过bind()系统调用完成的。也就是说,你的程序要绑定服务器的某地址,或者说:把服务器的某地址上的某端口占为已用。
服务器有多个网卡(每个网卡上有不同的IP地址),而你的服务(不管是在udp端口上侦听,还是在tcp端口上侦听),出于某种原因:可能是你的服务器操作系统可能随时增减IP地址,也有可能是为了省去确定服务器上有什么网络端口(网卡)的麻烦 —— 可以要在调用bind()的时候,告诉操作系统:“我需要在 yyyy 端口上侦听,所有发送到服务器的这个端口,不管是哪个网卡/哪个IP地址接收到的数据,都是我处理的。”这时候,服务器程序则在0.0.0.0这个地址上进行侦听。
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/wait.h>
#define DEFAULT 8081 默认端口号8081
class UdpServer
{
private:
int port;
int sockfd;
public:
UdpServer(int _port=DEFAULT):port(_port),socket(-1)
{}
bool InitUdpServer()
{
sockfd=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd<0)
{
std::cerr<<"socket error"<<std::endl;
return false;
}
std::cout<<"socket create success,sockfd:"<<sockfd<<std::endl;
struct socketaddr_in local;
memset(&local,0,sizeof(local));
local.sin_family=AF_INET;
local.sin_port=htons(port);
local.sin_addr.s_addr=INADDR_ANY;
if(bind(sockfd,(struct sockaddr*)&local,sizeof(local))<0)
{
std::cerr<<"bind false"<<std::endl;
return false;
}
std::cout<<"bind success,sockfd:"<<sockfd<<std::endl;
return true;
}
void Start()
{
#define SIZE 256
char buffer[SIZE]={0};
for(;;)
{
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
ssize_t size=recvfrom(sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
if(size>0)
{
buffer[size]=0;
int _port=ntohs(peer.sin_port);
std::string _ip=inet_ntoa(peer.sin_addr);
std::cout<<_ip<<":"<<_port<<"#"<<buffer<<std::endl;
std::string echo_msg;
echo_msg="server get->";
echo_msg+=buffer;
sendto(sockfd,echo_msg.c_str(),echo_msg.size(),0,(struct sockaddr*)&peer,len);
}
else
{
std::cerr<<"recvfrom error"<<std::endl;
}
}
}
~UdpServer()
{
if(sockfd>0)
{
close(sockfd);
}
}
};
#include "udp_server.hpp"
int main(int argc,char* argv[])
{
if(argc!=2)
{
std::cerr<<"Usage:"<<argv[0]<<" port"<<std::endl;
return 1;
}
int port=atoi(argv[1]);
UdpServer* server=new UdpServer(port);
server->InitUdpServer();
server->Start();
return 0;
}
UDP客户端实现
客户端访问服务器的IP地址为 : 127.0.0.1——是回送地址,指本地机,用来测试使用。
127.0.0.1 这个地址分配给 loopback 接口(本地环回)。loopback 是一个特殊的网络接口(可理解成虚拟网卡),用于本机中各个应用之间的网络交互。只要操作系统的网络组件是正常的,loopback 就能工作。
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
class UdpClient
{
private:
int sockfd;
std::string server_ip;
int server_port;
public:
UdpClient(std::string _ip,int _port):server_ip(_ip),server_port(_port)
{}
bool InitUdpClient()
{
sockfd=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd<0)
{
std::cerr<<"socket error"<<std::endl;
return false;
}
return true;
}
void Start()
{
struct sockaddr_in peer;
memset(&peer,0,sizeof(peer));
peer.sin_family=AF_INET;
peer.sin_port=htons(server_port);
peer.sin_addr.s_addr=inet_addr(server_ip.c_str());
std::string msg;
for(;;)
{
std::cout<<"Please input# ";
std::cin>>msg;
sendto(sockfd,msg.c_str(),msg.size(),0,(struct sockaddr*)&peer,sizeof(peer));
char buffer[128];
struct sockaddr_in tmp;
socklen_t len=sizeof(tmp);
ssize_t size=recvfrom(sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&tmp,&len);
if(size>0)
{
buffer[size]=0;
std::cout<<"client receive echo: "<<buffer<<std::endl;
}
}
}
~UdpClient()
{
if(sockfd>0)
{
close(sockfd);
}
}
};
#include "udp_client.hpp"
int main(int argc,char* argv[])
{
if(argc!=3)
{
std::cerr<<"Usage:"<<argv[0]<<" server_ip server_port"<<std::endl;
return 1;
}
std::string ip=argv[1];
int port =atoi(argv[2]);
UdpClient* client=new UdpClient(ip,port);
client->InitUdpClient();
client->Start();
return 0;
}
功能测试
先执行服务器端在执行客户端,随后可从客户端传输数据了:
— end —
青山不改 绿水长流
|