文章目录
- 一、 理解源IP地址和目的IP地址
- 二、 认识端口号
- 1. 理解 "端口号" 和 "进程ID"
- 2. 理解源端口号和目的端口号
- 三、 认识协议
- 1. 认识TCP协议
- 2. 认识UDP协议
- 四、 网络字节序
- 五、 socket编程接口
- 1. socket 常见API
- 2. sockaddr结构
- (1) sockaddr 结构
- (2) sockaddr_in 结构
- (3) in_addr结构
- 3. 字符串ip和整数IP的转换
- (1) 字符串IP
- (2) 整数IP
- (3) 字符串IP和整数IP相互转换的方式
- 六、 简单的UDP网络程序
- 1. UDP服务器编写
- (1) 创建套接字
- (2) 绑定bind
- (3) 收发数据
- 2. UDP客户端编写
- (1) 创建套接字
- (2)客户端的绑定问题
- (3) 客户端收发数据
- 七、 完整代码
- 服务器:
- 客户端:
- makefile
一、 理解源IP地址和目的IP地址
在IP数据报头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址.
源IP地址和目的IP地址的作用
- 源IP地址:标识了数据包的发送者的IP地址,用于回应数据包时能够找到正确的发送者。
- 目的IP地址:标识了数据包应该被发送到的接收者的IP地址,确保数据包能够到达正确的目的地。
在IP通信中,源IP地址和目的IP地址确实是非常关键的部分,它们分别标识了数据包的发送方和接收方的网络位置。然而,仅仅依靠IP地址确实不足以完成复杂的网络通信任务,尤其是在一个设备上可能运行着多个程序或服务时。
在IP网络中,数据包通过路由器和交换机等设备在网络中传输,直到到达目的IP地址所标识的设备。然而,一旦数据包到达该设备,该设备上的操作系统需要决定如何处理这个数据包。如果设备上有多个程序或服务可能接收这种类型的数据包,那么仅凭IP地址就无法确定哪个程序或服务应该处理这个数据包。
以QQ消息为例,当用户发送一条消息时,这条消息被封装成一个IP数据包。这个数据包包含了源IP地址(发送者的IP地址)、目的IP地址(接收者的IP地址)以及端口号(QQ服务器监听的端口号)。当数据包到达接收者的设备时,操作系统检查端口号,并将数据包转发给监听在该端口上的QQ程序进行处理。这样,即使接收者的设备上运行着多个程序,也能确保QQ消息被正确地交给QQ程序处理。
二、 认识端口号
为了解决仅凭IP地址无法确定哪个程序或服务应该处理这个数据包的问题,引入了端口号(Port Number)的概念。端口号是一个16位的数字,用于在IP地址的基础上进一步区分不同的服务或应用程序。每个运行在网络上的服务或应用程序都可以绑定到一个或多个特定的端口号上。这样,当数据包到达目的IP地址后,操作系统会根据数据包中的端口号来决定将这个数据包交给哪个程序或服务处理。
端口号(port)是传输层协议的内容.
- 端口号是一个2字节16位的整数;
- 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
- IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
- 一个端口号只能被一个进程占用.
ip可以表示公网内唯一一台确定的主机,端口号port可以唯一确定一台主机上的一个进程。所以通过ip+port的方式我们就可以实现两台主机上的两个进程进行网络通信。
端口号是属于某一台主机的,所以在不同的主机上端口号是可以重复的,但在同一台主机上端口号是不能重复的,一个端口号只能对应一个进程,但一个进程可以对应多个端口。
1. 理解 “端口号” 和 “进程ID”
我们之前在学习系统编程的时候, 学习了 pid 表示唯一一个进程; 此处我们的端口号也是唯一表示一个进程. 那为什么我们不直接用pid来确定一个进程反而要用端口号port呢?
进程ID(PID)是用来标识系统内所有进程的唯一性的,它是属于系统级的概念;而端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性的,它是属于网络的概念。
在一台主机中每个进程都有一个pid,但是并不是每一个进程都在网络通信,只有内些进行网络通信的进程才会有端口号。
举个例子就是,我们全国人都有身份证号码,通过身份证就可以唯一确定一个人,那学校为什么还有通过学号来唯一确定本校的一个学生呢?为什么公司还有工号来唯一确定一个员工呢?因为有身份证的不是都是学生,也不是都是员工,因此在学校或公司当中,没必要用身份证号来标识每个人的唯一性。注意我们此处没有说身份证不行,而是说用学号或者工号更好,因为学号工号还可能包含其他信息,比如入学年份等等。
所以不是用pid不行,而是在这种场景下端口号更适合而已。
另外, 一个进程可以绑定多个端口号; 但是一个端口号不能被多个进程绑定;
如何通过端口号port来找到某个进程
实际底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能够找到该端口号对应的进程。
2. 理解源端口号和目的端口号
源端口号
类比:源端口号就像是发件人(唐僧)在寄送快递时附上的自己的联系方式(比如电话号码)。这个联系方式(源端口号)对于接收方(收件人)来说并不是必需的,但对于快递公司(网络)和可能的回复(比如,如果收件人需要联系发件人确认某些信息)来说是非常重要的。
作用:
- 标识了发送数据包的程序或服务(唐僧)的端口。
- 允许接收方(如果有需要)通过相同的端口号回复发送方。
- 在某些情况下,也用于网络管理和故障排查。
目的端口号
类比:目的端口号就像是快递单上收件人的地址和联系方式(比如门牌号和电话号码)。这个信息对于快递公司(网络)来说是至关重要的,因为它决定了快递应该被送到哪里(哪个程序或服务)。
作用:
- 标识了数据包应该被发送到的程序或服务的端口。
- 确保数据包能够正确地被目标程序或服务接收和处理。
- 在一个设备上可能有多个程序或服务监听不同的端口,目的端口号帮助网络层将数据包准确地分发给正确的程序或服务。
结合唐僧送快递的例子
- 唐僧(发送者):想要寄送一份快递(数据包)给远方的朋友(接收者)。
- 源端口号:唐僧在快递单上留下了自己的联系方式(比如电话号码),这样如果收件人需要联系他,就可以通过这个联系方式找到他。
- 目的端口号:唐僧在快递单上填写了收件人的详细地址和联系方式(门牌号和电话号码),这样快递公司就能知道应该将快递送到哪里。
三、 认识协议
1. 认识TCP协议
TCP(传输控制协议)
TCP是一种面向连接的、可靠的、基于字节流的传输层通信协议。它在网络通信中扮演着至关重要的角色,特别是在需要确保数据完整性和顺序性的应用中。以下是TCP的几个关键特性:
-
传输层协议:TCP工作在OSI模型的传输层,负责在源主机和目的主机之间提供可靠的、面向连接的数据传输服务。
-
有连接:在数据交换之前,TCP会在通信双方之间建立一个连接(三次握手过程)。这个连接会一直保持到数据传输完成,或者一方发送连接释放的信号(四次挥手过程)。这种机制确保了数据的传输是在一个稳定的、可靠的环境中进行的。
-
可靠传输:TCP通过一系列机制来保证数据的可靠传输,包括序列号(用于确认收到的数据包)、确认应答(ACK)、超时重传(如果数据包在一定时间内未收到确认应答,则重新发送该数据包)、流量控制(避免发送方发送的数据量超过接收方的处理能力)和拥塞控制(减少网络中的拥塞,提高整体传输效率)。
-
面向字节流:TCP将数据视为一个无结构的字节流,而不是独立的数据包。这意味着TCP不关心应用程序发送的数据的边界,它会将接收到的数据按照顺序发送给应用程序,而应用程序需要自行处理数据的分割和重组。
2. 认识UDP协议
UDP(用户数据报协议)
UDP是一种无连接的、不可靠的、面向数据报的传输层协议。它主要用于那些对实时性要求较高,但对数据完整性和顺序性要求不高的场合。以下是UDP的几个关键特性:
-
传输层协议:与TCP一样,UDP也工作在OSI模型的传输层,但它提供的是一种不同的服务模型。
-
无连接:UDP在发送数据之前不需要在通信双方之间建立连接。发送方可以直接发送数据报给接收方,而不需要等待接收方的响应。这种机制使得UDP的通信效率较高,但也可能导致数据丢失或乱序。
-
不可靠传输:UDP不保证数据的可靠传输。它不提供确认应答、超时重传、流量控制或拥塞控制等机制。如果数据包在传输过程中丢失或损坏,UDP不会进行任何恢复操作。这种特性使得UDP适用于那些对实时性要求较高,但对数据完整性要求不高的应用,如视频流、实时游戏等。
-
面向数据报:UDP将每个发送的数据报视为一个独立的单元。它保留了数据报的边界,并在接收方以数据报的形式将数据交付给应用程序。这种机制使得UDP非常适合于传输小量数据或需要保持数据边界的场合。
四、 网络字节序
我们知道,计算机在存储数据的时候是有大小端之分的
- 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
- 小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。
对于同一个电脑来说,用一个电脑写完程序,在同一台电脑上跑是没有任何问题的,因为同一台电脑的存储方式是相同的。但是对于网络通信来说就有问题了,两台电脑在网络通信,A电脑是大端,B电脑是小端,这样接收到的数据不就和发出来的数据不一样了吗?因此国际上直接规定,网络通信按大端存储。
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
- 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
- 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
- TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
- 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
- 如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中。
- 如果发送端是大端,则可以直接进行发送。
- 如果接收端是小端,需要先将接收到数据转成小端后再进行数据识别。
- 如果接收端是大端,则可以直接进行数据识别
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
#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表示network,l表示32位长整数,s表示16位短整数。
例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ;
如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
五、 socket编程接口
1. socket 常见API
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
2. sockaddr结构
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,其中sockaddr_in结构体是用于跨网络通信的,而sockaddr_un结构体是用于本地通信的。
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。
- IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型, 16位端口号和32位IP地址.
- IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6. 这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容.
- socket API可以都用struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in*; 这样的好处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数;
此时当我们在传递在传参时,就不用传入sockeaddr_in或sockeaddr_un这样的结构体,而统一传入sockeaddr这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取sockeaddr结构头部的16位进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作。此时我们就通过通用sockaddr结构,将套接字网络通信和本地通信的参数类型进行了统一。
(1) sockaddr 结构
(2) sockaddr_in 结构
虽然socket api的接口是sockaddr, 但是我们真正在基于IPv4编程时, 使用的数据结构是sockaddr_in; 这个结构里主要有三部分信息: 地址类型, 端口号, IP地址.
(3) in_addr结构
3. 字符串ip和整数IP的转换
字符串IP和整数IP是IP地址的两种不同表现形式,它们各有优缺点,适用于不同的场景和需求。以下是对这两种形式的详细比较:
(1) 字符串IP
表现形式:
字符串IP以点分十进制的形式表示,如“192.168.1.1”。它由四个十进制数组成,每个数代表IP地址的一个字节(8位),这些数之间用点(.)分隔。
优点:
- 直观易懂:字符串IP的形式更接近于人类阅读习惯,易于理解和记忆。
- 兼容性好:在各种编程语言和系统中,字符串IP的兼容性通常更好,因为它直接反映了IP地址的原始表示方式。
- 便于处理:在处理IP地址相关的网络操作时,如DNS解析、路由选择等,字符串IP可以更容易地与这些操作集成。
缺点:
- 存储空间大:相比于整数IP,字符串IP需要更多的存储空间来保存。
- 比较效率低:在进行IP地址比较时,字符串比较通常比整数比较要慢。
(2) 整数IP
表现形式:
整数IP是将IP地址的四个字节转换为一个32位无符号整数。例如,“192.168.1.1”可以转换为整数3232235777(十六进制表示为0xC0A80101)。
优点:
- 节省空间:整数IP只需要4个字节(32位)来存储,相比于字符串IP节省了存储空间。
- 比较效率高:整数比较通常比字符串比较要快,因此在需要频繁进行IP地址比较的场景中,整数IP更有效率。
- 便于范围查询:使用整数IP可以更容易地进行范围查询,如使用SQL的BETWEEN…AND语句进行IP地址段的查询。
缺点:
- 可读性差:整数IP的形式不直观,不便于人类阅读和记忆。
- 需要转换:在需要将IP地址展示给用户或与其他系统交互时,需要将整数IP转换回字符串IP,这增加了处理的复杂性。
(3) 字符串IP和整数IP相互转换的方式
字符串转整数inet_addr函数
在编写程序的过程中,经常会遇到字符串类型的IP和整数IP相互转换的情况,此时我们可以直接调用操作系统为我们提供的函数进行转换即可。
将字符串IP转换成整数IP的函数叫做inet_addr,该函数的函数原型如下:
in_addr_t inet_addr(const char *cp);
传入一个c语言格式的字符串,然后该函数就能返回转换后的整数IP。
整数转字符串inet_ntoa函数
将整数IP转换成字符串IP的函数叫做inet_ntoa,该函数的函数原型如下:
char *inet_ntoa(struct in_addr in);
需要注意的是,传入inet_ntoa函数的参数类型是in_addr,因此我们在传参时不需要选中in_addr结构当中的32位的成员传入,直接传入in_addr结构体即可。
六、 简单的UDP网络程序
1. UDP服务器编写
(1) 创建套接字
在写udp客户端,服务器和tcp的客户端服务器基本都是固定的套路,第一次读懂程序之后可以模仿的写一些,之后自己能啥都不参照,完整的靠自己在较短时间内,得心应手的把整个程序写完就算掌握了。
写udp服务器主要有这么几件事:
1.创建套接字。
2. 绑定。
3. 接受和发送消息。
创建套接字的函数是socket函数,具体介绍如下:
int socket(int domain, int type, int protocol);
参数说明:
- domain: 创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于struct sockaddr结构的前16个位。如果是本地通信就设置为AF_UNIX,如果是网络通信就设置为AF_INET(IPv4)或AF_INET6(IPv6)。
- type:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM和SOCK_DGRAM,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM,叫做流式套接字,提供的是流式服务。
- protocol:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。
返回值说明:
- 创建套接字成功会返回一个文件描述符,失败则返回-1,同时错误码被设置
在创建套接字的时候由于我们要进行的是网络通信所以socket第一个参数设置成AF_INET,由于要进行udp传输所以第二个参数使用的是SOCK_DGRAM,第三个参数默认填0就好,会根据传入的前两个参数自动推导出最终需要使用的是哪种协议。
创建成功后会返回一个文件描述符,我们用一个成员变量sockfd_来接收。
当析构服务器时,我们可以将sockfd对应的文件进行关闭,但实际上不进行该操作也行,因为一般服务器运行后是就不会停下来的。
class UdpServer
{
public:
UdpServer():sockfd_(-1)
{}
bool IninServer()
{
//创建套接字
sockfd_=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_<0)
{
std::cerr<<"create socket error"<<std::endl;
return false;
}
std::cout<<"create socket success,sockfd: "<<sockfd_<<std::endl;
return true;
}
~UdpServer()
{
if(sockfd_>0)
{
close(sockfd_);
}
}
private:
int sockfd_;
};
此处如果创建成功的话,就把这个文件描述符输出一下,发现是3,没啥问题。因为012被标准输入,标准输出,标准错误使用了,所以输出的就是3,没问题。
(2) 绑定bind
bind函数:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
- sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
- addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
- 绑定成功返回0,绑定失败返回-1,同时错误码会被设置。
bind需要传三个参数,第一个是套接字sockfd,第二个是一个sockaddr*,前面我们讲过,这里传参的实际上还是sockaddr_in这个结构体,只不过是传参的时候要强转成sockaddr*而已。第三个是sockaddr_in的大小。
此处我们还需要说明一下为什么服务器要显示的绑定ip和端口,而客户端却不需要呢?
服务器端需要bind IP和端口的原因
-
监听端口:
- UDP服务器需要绑定到一个特定的IP地址和端口上,以便监听来自客户端的数据报。如果没有绑定,服务器将无法知道它应该在哪个端口上接收数据。
-
提供服务:
- 类似于TCP服务器,UDP服务器也在特定端口上提供服务。客户端通过向这个端口发送数据报来请求服务。绑定到特定的IP和端口使得服务器能够区分和响应来自不同客户端的请求。
-
安全性与过滤:
- 绑定到特定的IP和端口也有助于提高服务器的安全性。通过配置防火墙规则,可以限制对特定端口的访问,从而防止未经授权的访问和攻击。
客户端不需要bind IP和端口的原因
-
主动发送:
- UDP客户端是主动发送数据报的一方。它们不需要绑定到特定的IP地址和端口上,因为它们是通过sendto函数直接指定目的地址(即服务器的IP和端口)来发送数据报的。
-
内核自动处理:
- 当UDP客户端发送数据报时,如果它没有绑定到特定的端口,操作系统内核会自动为它分配一个临时端口号(称为源端口号),这个端口号用于标识发送数据报的客户端。这个过程对客户端程序员是透明的。
-
灵活性:
- UDP客户端不需要固定端口号,这增加了它们的灵活性。它们可以随时随地发送数据报,而不需要担心端口冲突或管理问题。
在绑定之前我们需要先创建一个sockaddr_in结构体,在填充对应字段之前最好先用memset清空一下。然后分别对三个字段进行填充。
填写端口的时候记得这是本地向网络中发送数据,应该先转换成大端,利用htons先转换一下。
填写ip的时候需要把字符串ip转换成数字ip,并转换成大端,inet_addr这个函数就能全部搞定。
struct sockaddr_in local;
memset(&local,'\0',sizeof(local));
local.sin_family=AF_INET;
local.sin_port=htons(port_);
local.sin_addr.s_addr=inet_addr(ip_.c_str());
const std::string default_ip="127.0.0.1";
class UdpServer
{
public:
UdpServer(uint16_t port,std::string ip=default_ip):sockfd_(-1),port_(port),ip_(ip)
{}
bool IninServer()
{
sockfd_=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_<0)
{
std::cerr<<"create socket error"<<std::endl;
return false;
}
std::cout<<"create socket success,sockfd: "<<sockfd_<<std::endl;
struct sockaddr_in local;
memset(&local,'\0',sizeof(local));
local.sin_family=AF_INET;
local.sin_port=htons(port_);
local.sin_addr.s_addr=inet_addr(ip_.c_str());
if(bind(sockfd_,(struct sockaddr*)&local,sizeof(local))<0)
{
std::cerr<<"bind error"<<std::endl;
return false;
}
std::cout<<"bind success"<<std::endl;
return true;
}
~UdpServer()
{
if(sockfd_>0)
{
close(sockfd_);
}
}
private:
int sockfd_;
uint16_t port_;
std::string ip_;
};
(3) 收发数据
UDP服务器的初始化就只需要创建套接字和绑定就行了,当服务器初始化完毕后我们就可以启动服务器了。
服务器实际上就是在周而复始的为我们提供某种服务,服务器之所以称为服务器,是因为服务器运行起来后就永远不会退出,因此服务器实际执行的是一个死循环代码。由于UDP服务器是不面向连接的,因此只要UDP服务器启动后,就可以直接读取客户端发来的数据。
recvfrom函数
UDP服务器读取数据的函数叫做recvfrom,该函数的函数原型如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
参数说明:
- sockfd:对应操作的文件描述符。表示从该文件描述符索引的文件当中读取数据。
- buf:读取数据的存放位置。
- len:期望读取数据的字节数。
- flags:读取的方式。一般设置为0,表示阻塞读取。
- src_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置。
此处服务器接收到客户端发来的信息之后,我们想要再把该消息发送回去,那就需要sendto函数了。
sendto函数
UDP客户端发送数据的函数叫做sendto,该函数的函数原型如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
参数说明:
- sockfd:对应操作的文件描述符。表示将数据写入该文件描述符索引的文件当中。
- buf:待写入数据的存放位置。
- len:期望写入数据的字节数。
- flags:写入的方式。一般设置为0,表示阻塞写入。
- dest_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入dest_addr结构体的长度。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
注意:
- 由于UDP是不面向连接的,因此我们除了获取到数据以外还需要获取到对端网络相关的属性信息,包括IP地址和端口号等。当客户端给服务器发送完信息之后,服务器就能获得客户端的ip和端口,这样服务器就可以根据这些信息向客户端发送数据。
- 在调用recvfrom读取数据时,必须将addrlen设置为你要读取的结构体对应的大小。
- 由于recvfrom函数提供的参数也是struct sockaddr类型的,因此我们在传入结构体地址时需要将struct sockaddr_in类型进行强转。
此处调用recvfrom函数读取数据,如果n>0的话说明接收到了数据,把buffer[n]赋值为’\0’,然后输出一下客户端的IP地址和端口号,把客户端发来的的数据输出出来。
然后添加字符串server get->再给客户端用sendto函数发回去。
需要注意的是,我们获取到的客户端的端口号此时是网络序列,我们需要调用ntohs函数将其转为主机序列再进行打印输出。同时,我们获取到的客户端的IP地址是整数IP,我们需要通过调用inet_ntoa函数将其转为字符串IP再进行打印输出。
const std::string default_ip = "127.0.0.1";
class UdpServer
{
public:
UdpServer(uint16_t port, std::string ip = default_ip) : sockfd_(-1), port_(port), ip_(ip)
{
}
void Start()
{
char buffer[1024];
for (;;)
{
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
socklen_t sizelen = sizeof(peer);
ssize_t n = recvfrom(sockfd_,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&sizelen);
if(n>0)
{
buffer[n]='\0';
uint64_t client_port=ntohs(peer.sin_port);
std::string client_ip=inet_ntoa(peer.sin_addr);
std::cout << client_ip << ":" << client_port << "# " << buffer << std::endl;
}
else
{
std::cerr<<"recvfrom error"<<std::endl;
}
std::string echo("server get->");
echo+=buffer;
sendto(sockfd_,echo.c_str(),echo.size(),0,(struct sockaddr*)&peer,sizeof(peer));
}
}
private:
int sockfd_;
uint16_t port_;
std::string ip_;
};
引入命令行参数
鉴于构造服务器时需要传入IP地址和端口号,我们这里可以引入命令行参数。此时当我们运行服务器时在后面跟上对应的IP地址和端口号即可。
由于云服务器的原因,后面实际不需要传入IP地址,因此在运行服务器的时候我们只需要传入端口号即可,目前我们就手动将IP地址设置为127.0.0.1。IP地址为127.0.0.1实际上等价于localhost表示本地主机,我们将它称之为本地环回,相当于我们一会先在本地测试一下能否正常通信,然后再进行网络通信的测试。
#include"UdpServer.hpp"
int main(int argc,char* argv[])
{
if(argc!=2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
}
uint16_t port=atoi(argv[1]);
UdpServer* up=new UdpServer(port);
up->IninServer();
up->Start();
return 0;
}
我们可以通过netstat命令来查看当前网络的状态,这里我们可以选择携带nlup选项。
netstat常用选项说明:
-n:直接使用IP地址,而不通过域名服务器。
-l:显示监控中的服务器的Socket。
-t:显示TCP传输协议的连线状况。
-u:显示UDP传输协议的连线状况。
-p:显示正在使用Socket的程序识别码和程序名称。
此时你就能查看到对应网络相关的信息,在这些信息中程序名称为./udpserver的那一行显示的就是我们运行的UDP服务器的网络信息。
去掉-n选项再查看,此时原本显示IP地址的地方就变成了对应的域名服务器。
其中netstat命令显示的信息中,Proto表示协议的类型,Recv-Q表示网络接收队列,Send-Q表示网络发送队列,Local Address表示本地地址,Foreign Address表示外部地址,State表示当前的状态,PID表示该进程的进程ID,Program name表示该进程的程序名称。
其中Foreign Address写成0.0.0.0:*表示任意IP地址、任意的端口号的程序都可以访问当前进程。
2. UDP客户端编写
(1) 创建套接字
第一步和服务器一样,也是创建套接字
class UdpClient
{
public:
UdpClient():sockfd_(-1)
{}
bool InitClient()
{
sockfd_=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_<0){
std::cerr<<"create sock fail!"<<std::endl;
return false;
}
return true;
}
~UdpClient()
{
if(sockfd_>0){
close(sockfd_);
}
}
private:
int sockfd_;
};
(2)客户端的绑定问题
在网络通信中,服务端和客户端确实都需要利用IP地址和端口号来定位和识别彼此,但它们在端口使用上的处理方式有显著不同。
服务端
服务端作为提供服务的主体,必须明确告知客户端其可访问的IP地址(常以域名形式出现,便于记忆)和端口号。这个端口号必须是“知名”的,意味着它在特定的服务或应用中具有广泛认知,客户端才能正确连接到服务端。服务端通过端口绑定(Port Binding)操作,将这个端口号与自身进程关联起来,确保该端口号在服务端运行期间被独占,用于接收来自客户端的连接请求。一旦绑定,除非服务端进程结束或显式解除绑定,否则其他进程无法使用该端口号。
客户端
相比之下,客户端在发起连接时也需要一个端口号,但这个端口号主要用于区分来自同一客户端机器的不同连接请求,而非作为服务端连接的目标。因此,客户端的端口号通常不需要是知名的,也不需要固定不变。客户端在发起连接时,如果未指定端口号,操作系统会自动为其分配一个临时且唯一的端口号(即所谓的“临时端口”或“动态端口”),以确保数据回送的正确性和连接的唯一性。这个自动分配的端口号仅在客户端本次会话期间有效,下次启动客户端时可能会不同,除非客户端程序明确指定了某个端口号。
总结
简而言之,服务端通过绑定一个知名且固定的端口号来接收来自客户端的连接请求,确保服务的稳定性和可访问性;而客户端则可以通过操作系统自动分配的临时端口号来发起连接,无需担心端口冲突或耗尽问题,提高了通信的灵活性和便捷性。这种设计既保证了网络通信的有序进行,又简化了客户端的配置和使用过程。
(3) 客户端收发数据
作为一个客户端,它必须知道它要访问的服务端的IP地址和端口号,因此在客户端类当中需要引入服务端的IP地址和端口号,此时我们就可以根据传入的服务端的IP地址和端口号对对应的成员进行初始化。
class UdpClient
{
public:
UdpClient(const std::string& ip,uint16_t port):sockfd_(-1),server_port_(port),server_ip_(ip)
{}
~UdpClient()
{
if(sockfd_>0)
{
close(sockfd_);
}
}
private:
int sockfd_;
uint16_t server_port_;
std::string server_ip_;
};
当客户端初始化完毕后我们就可以将客户端运行起来,由于客户端和服务端在功能上是相互补充的,既然服务器是在读取客户端发来的数据,那么客户端就应该想服务端发送数据。
sendto函数
UDP客户端发送数据的函数叫做sendto,该函数的函数原型如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
参数说明:
- sockfd:对应操作的文件描述符。表示将数据写入该文件描述符索引的文件当中。
- buf:待写入数据的存放位置。
- len:期望写入数据的字节数。
- flags:写入的方式。一般设置为0,表示阻塞写入。
- dest_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入dest_addr结构体的长度。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
注意:
由于UDP不是面向连接的,因此除了传入待发送的数据以外还需要指明对端网络>相关的信息,包括IP地址和端口号等。
由于sendto函数提供的参数也是struct sockaddr类型的,因此我们在传入结构体地址时需要将struct sockaddr_in类型进行强转。
启动客户端函数
现在客户端要发送数据给服务端,我们可以让客户端获取用户输入,不断将用户输入的数据发送给服务端,在服务端,会把客户端发来的消息添加一个字符串再重新发回来,所以我们不仅要发送数据,还得接受数据,用sendto发数据,用recvfrom接收数据。
recvfrom函数
UDP服务器读取数据的函数叫做recvfrom,该函数的函数原型如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
参数说明:
- sockfd:对应操作的文件描述符。表示从该文件描述符索引的文件当中读取数据。
- buf:读取数据的存放位置。
- len:期望读取数据的字节数。
- flags:读取的方式。一般设置为0,表示阻塞读取。
- src_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置。
需要注意的是,客户端中存储的服务端的端口号此时是主机序列,我们需要调用htons函数将其转为网络序列后再设置进struct sockaddr_in结构体。同时,客户端中存储的服务端的IP地址是字符串IP,我们需要通过调用inet_addr函数将其转为整数IP后再设置进struct sockaddr_in结构体。
class UdpClient
{
public:
UdpClient(const std::string& ip,uint16_t port):sockfd_(-1),server_port_(port),server_ip_(ip)
{}
bool InitClient()
{
sockfd_=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_<0)
{
std::cerr<<"create sock fail!"<<std::endl;
return false;
}
return true;
}
void start()
{
struct 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());
for(;;)
{
std::string message;
std::getline(std::cin,message);
sendto(sockfd_,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof(server));
char buffer[1024];
struct sockaddr_in tmp;
socklen_t socklen=sizeof(tmp);
ssize_t n = recvfrom(sockfd_,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&tmp,&socklen);
if(n>0)
{
buffer[n]='\0';
std::cout<<buffer<<std::endl;
}
}
}
~UdpClient()
{
if(sockfd_>0)
{
close(sockfd_);
}
}
private:
int sockfd_;
uint16_t server_port_;
std::string server_ip_;
};
我们引入命令行参数,在启动客户端的时候输入IP和端口号,在main函数中启动客户端。
#include "UdpClient.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 server_ip=argv[1];
uint16_t server_port=atoi(argv[2]);
UdpClient* up=new UdpClient(server_ip,server_port);
up->InitClient();
up->start();
}
需要注意的是,argv数组里面存储的是字符串,而端口号是一个整数,因此需要使用atoi函数将字符串转换成整数。然后我们就可以用这个IP地址和端口号来构造客户端了,客户端构造完成并初始化后就可以调用Start函数启动客户端了
现在服务端和客户端的代码都已经编写完毕,我们可以先进行本地测试,此时服务器没有绑定外网,绑定的是本地环回。现在我们运行服务器时指明端口号为8081,再运行客户端,此时客户端要访问的服务器的IP地址就是本地环回127.0.0.1,服务端的端口号就是8081。
客户端运行之后我们进行输入,当我们在客户端输入数据后,客户端将数据发送给服务端,此时服务端再将收到的数据打印输出,然后再把数据发送回客户端,这时我们在服务端的窗口也看到我们输入的内容,也在客户端窗口看到了返回的数据。
此时我们再用netstat命令查看网络信息,可以看到服务端的端口是8081,客户端的端口是51384。这里客户端能被netstat命令查看到,说明客户端也已经动态绑定成功了,这就是我们所谓的网络通信
七、 完整代码
服务器:
#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <cstring>
const std::string default_ip = "127.0.0.1";
class UdpServer
{
public:
UdpServer(uint16_t port, std::string ip = default_ip) : sockfd_(-1), port_(port), ip_(ip)
{
}
bool IninServer()
{
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ < 0)
{
std::cerr << "create socket error" << std::endl;
return false;
}
std::cout << "create socket success,sockfd: " << sockfd_ << std::endl;
struct sockaddr_in local;
memset(&local, '\0', sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
local.sin_addr.s_addr = inet_addr(ip_.c_str());
if (bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) < 0)
{
std::cerr << "bind error" << std::endl;
return false;
}
std::cout << "bind success" << std::endl;
return true;
}
void Start()
{
char buffer[1024];
for (;;)
{
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
socklen_t sizelen = sizeof(peer);
ssize_t n = recvfrom(sockfd_,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&sizelen);
if(n>0)
{
buffer[n]='\0';
uint64_t client_port=ntohs(peer.sin_port);
std::string client_ip=inet_ntoa(peer.sin_addr);
std::cout << client_ip << ":" << client_port << "# " << buffer << std::endl;
}
else
{
std::cerr<<"recvfrom error"<<std::endl;
}
std::string echo("server get->");
echo+=buffer;
sendto(sockfd_,echo.c_str(),echo.size(),0,(struct sockaddr*)&peer,sizeof(peer));
}
}
~UdpServer()
{
if (sockfd_ > 0)
{
close(sockfd_);
}
}
private:
int sockfd_;
uint16_t port_;
std::string ip_;
};
#include"UdpServer.hpp"
int main(int argc,char* argv[])
{
if(argc!=2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
}
uint16_t port=atoi(argv[1]);
UdpServer* up=new UdpServer(port);
up->IninServer();
up->Start();
return 0;
}
客户端:
#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <cstring>
class UdpClient
{
public:
UdpClient(const std::string& ip,uint16_t port):sockfd_(-1),server_port_(port),server_ip_(ip)
{}
bool InitClient()
{
sockfd_=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_<0)
{
std::cerr<<"create sock fail!"<<std::endl;
return false;
}
return true;
}
void start()
{
struct 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());
for(;;)
{
std::string message;
std::getline(std::cin,message);
sendto(sockfd_,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof(server));
char buffer[1024];
struct sockaddr_in tmp;
socklen_t socklen=sizeof(tmp);
ssize_t n = recvfrom(sockfd_,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&tmp,&socklen);
if(n>0)
{
buffer[n]='\0';
std::cout<<buffer<<std::endl;
}
}
}
~UdpClient()
{
if(sockfd_>0)
{
close(sockfd_);
}
}
private:
int sockfd_;
uint16_t server_port_;
std::string server_ip_;
};
#include "UdpClient.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 server_ip=argv[1];
uint16_t server_port=atoi(argv[2]);
UdpClient* up=new UdpClient(server_ip,server_port);
up->InitClient();
up->start();
}
makefile
CC=g++
.PHONY:all
all:udpserver udpclient
udpserver:ServerMain.cc
$(CC) -o $@ $^ -std=c++11
udpclient:ClientMain.cc
$(CC) -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f udpserver udpclient