每台计算机的公网IP是唯一的,如果本地主机和对端主机要实现通信,那么对端主机的IP地址将作为该数据传输的目的IP地址。仅仅知道目的IP地址是不够的,当对端主机接受数据后需对该主机做出响应,于是对端主机就需要知道本地主机的IP地址,即源IP。
大部分网络服务使跨局域网的,期间会跳到多个路由器最终达到目的主机。
开始传输时的源MAC地址是本地主机的MAC,目的MAC是下一跳的路由器MAC。最后一跳的源MAC则是最后途径的路由器的MAC,目的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
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
这四个函数中,长整型函数通常用来转换IP地址,短整型函数用来转换端口号(当然不限于此,任何格式化的数据通过网络传输时,都应该使用这些函数来转换字节序)。
#include
#include
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)
socket 网络编程接口中表示socket地址的是结构体 socketaddr
,其定义如下:
struct sockaddr
{
sa_family_t sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};
地址族类型 sa_family_t 的实际类型为 unsigned short
常见的协议族(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 /* sizeof(struct sockaddr) */
struct sockaddr_in
{
__kernel_sa_family_t sin_family; /*地址族:AF_INET*/
u_int16_t sin_port; /*文件路径名*/
struct in_addr sin_addr; /*IPv4地址结构体,见下面*/
/* Pad to size of `struct sockaddr'. */
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -sizeof(unsigned short int) - sizeof(struct in_addr)];
};
struct in_addr
{
u_int32_t s_addr; /*IPv4地址,要用网络字节序表示*/
}
struct sockaddr_in6
{
sa_family_t sin6_family; /*地址族:AF_INET6*/
u_int16_t sin6_port; /*端口号,要用网络字节序表示*/
u_int32_t sin6_flowinfo; /*流信息,应设置为0*/
struct in6_addr sin6_addr; /*IPv6地址结构体,见下面*/
u_int32_t sin6_scope_id; /*scope ID*/
};
struct in6_addr
{
unsigned char sa_addr[16]; /*IPv6地址,要用网络字节序表示*/
};
socket不仅支持网络的进程间通信,还支持本地的进程间通信(域间套接字)。
本地域协议族的专用socket地址结构体:
#include
struct sockaddr_un
{
sa_family_t sin_family; /*地址族:AF_UNIX*/
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地址是32位的,而平时人们习惯使用可读性好的字符串来表示IP地址,比如点分十进制字符串表示IPv4地址,以及用十六进制字符串表示IPv6地址。但编程中我们需要先把他们转化为整数(二进制数)方能使用。
下面3个函数可用于用点分十进制字符串表示的IPv4地址和用络字节序整数表示的IPv4地址之间的转换:
字符串转 in_addr(网络字节序) 的函数
#include
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
#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46
inet_ntop这个函数是由调用者自己提供一个缓冲区保存结果,是线程安全的。
Linux的一个设计哲学是:一切皆文件。socket也不例外,他就是可读,可写,可控制,可关闭的文件描述符。下面的socket系统调用创建一个socket:
#include
#include
int socket(int domain, int type, int protocol);
参数
domain
:告诉系统使用的是哪一个底层协议族。对TCP/IP协议族而言,该参数应该设置为AF_INET(用于IPv4),AF_INET6(用于IPv6);对于域间套接字(本地域)该参数应该设置为AF_UNIX。其他的协议族如下,参考man手册:
type
:指定服务类型,服务类型主要有 SOCK_STREAM
服务(流服务),和 SOCK_DGRAM
服务(数据报服务)。对TCP/IP协议族而言,其值取 SOCK_STREAM 表示传输层使用TCP协议, 取 SOCK_DGRAM 表示传输层使用UDP协议。
值得指出的是,在Linux内核版本2.6.17起,type参数可以接受上述服务类型和下面两个重要标志相与的值:SOCK_NONBLOCK
和 SOCK_CLOEXEC
。前者表示将新创建的socket设为非阻塞,以及用fork调用子进程时在子进程中关闭该socket。
protocol
:该参数是在前两个参数构成的协议集合下,在选择一个具体的协议,不过这个值通常是唯一的(前面的两个参数已经完全决定了它的值)。几乎所有情况下,我们都应该把他设置为0,表示使用默认协议。返回值
成功返回一个socket文件描述符,失败返回返回-1,并设置errno。
❓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时,我们指定了地址族和数据格式,但是并未指定使用该地址族的哪个具体socket地址(sockaddr)。
将一个socket文件(文件描述符)与socket地址绑定称为给socket命名。
#include
#include
int bind(int sockfd,const structaddr* my_addr,socklen_t addrlen);
功能:bind将my_addr所指的socket地址分配给未命名的sockfd文件描述符(服务器socket地址在绑定前提前赋值完毕),addrlen参数指出socket地址的长度(因为不同协议的socket地址长度不一)。
返回值:成功返回0,失败返回-1,并设置errno。常见的errno:EACCES 和 EADDRINUSE。
socket编程接口中用于UDP数据报读写的系统调用是:
#include
#include
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的服务器端和客户端
建立的工程文件如下:
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程序
服务器的IP需绑定INADDR_ANY
(宏,值为0),表示不确定地址,或者“所有地址”、“任意地址”。
一般而言,如果你要建立网络服务器应用程序,则你要通知服务器操作系统:请在某地址 xxx.xxx.xxx.xxx上的某端口 yyyy上进行侦听,并且把侦听到的数据包发送给我。这个过程,你是通过bind()系统调用完成的。也就是说,你的程序要绑定服务器的某地址,或者说:把服务器的某地址上的某端口占为已用。
服务器有多个网卡(每个网卡上有不同的IP地址),而你的服务(不管是在udp端口上侦听,还是在tcp端口上侦听),出于某种原因:可能是你的服务器操作系统可能随时增减IP地址,也有可能是为了省去确定服务器上有什么网络端口(网卡)的麻烦 —— 可以要在调用bind()的时候,告诉操作系统:“我需要在 yyyy 端口上侦听,所有发送到服务器的这个端口,不管是哪个网卡/哪个IP地址接收到的数据,都是我处理的。”这时候,服务器程序则在0.0.0.0这个地址上进行侦听。
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define DEFAULT 8081 默认端口号8081
class UdpServer
{
private:
int port;//服务器端口号
int sockfd;//socket文件描述符
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;//3
//命名socket
struct socketaddr_in local;//IPv4结构体,记录本地IP和端口号
memset(&local,0,sizeof(local));
//给定协议
local.sin_family=AF_INET;
//给定端口号,注意由本地字节序转网络字节序
local.sin_port=htons(port);
//给定IP地址:INADDR_ANY
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;//3
return true;
}
void Start()
{
//创建读缓冲区
#define SIZE 256
char buffer[SIZE]={0};
for(;;)
{
//创建socket地址,用于接受对端的IP地址和端口号,用于传回数据
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"
//udp_server port
//INADDR_AN->0
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;
}
客户端访问服务器的IP地址为 : 127.0.0.1——是回送地址,指本地机,用来测试使用。
127.0.0.1 这个地址分配给 loopback 接口(本地环回)。loopback 是一个特殊的网络接口(可理解成虚拟网卡),用于本机中各个应用之间的网络交互。只要操作系统的网络组件是正常的,loopback 就能工作。
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
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;
}
//客户端需要port,但是为隐式绑定(bind),空闲的端口号会被赋予给socket
return true;
}
void Start()
{
//发送给对端的结构体
struct sockaddr_in peer;
memset(&peer,0,sizeof(peer));
peer.sin_family=AF_INET;//IPv4协议
peer.sin_port=htons(server_port);//发向哪个端口号
peer.sin_addr.s_addr=inet_addr(server_ip.c_str());//发向哪一个IP地址
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);//服务端的ip和端口号是已知的,recvfrom的结构体交给临时变量就好
if(size>0)
{
buffer[size]=0;
std::cout<<"client receive echo: "<<buffer<<std::endl;
}
}
}
~UdpClient()
{
if(sockfd>0)
{
close(sockfd);
}
}
};
#include "udp_client.hpp"
// ./udp_client server_ip server_port
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;
}
先执行服务器端在执行客户端,随后可从客户端传输数据了:
指令 netstat 查看网络状态
netstat -nlup
查看udp的socket程序
— end —
青山不改 绿水长流