文章目录
预备知识
源IP地址和目的IP地址
在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址.
每台计算机都有一个唯一的IP地址,如果一台主机上的数据要传输到另一台主机.那么对端主机的IP地址就应该作为该数据传输时的目的IP地址 但是仅仅知道目的IP地址是不够的,因为当对端主机收到该数据后,**对端主机还需要对当前发送数据的主机做出响应,**因此对端主机也需要发送数据给当前主机,此时对端主机就必须知道该主机的IP地址
因此一个传输的数据当中应该涵盖其源IP地址和目的IP地址
- 目的IP地址表明该数据传输的目的地,源IP地址作为对端主机响应时的目的IP地址
在数据进行传输之前,会先自顶向下贯穿网络协议栈完成数据的封装,其中在网络层封装的IP报头当中就涵盖了源IP地址和目的IP地址
总结:源IP地址:确定了一个报文从哪来 目的IP地址:确定了一个报文到哪去,能够指导报文该如何进行路径选择
源MAC地址和目的MAC地址
大部分数据的传输都是跨局域网的,因此:数据在传输过程中会经过若干个路由器,最终才能到达对端主机
例子:
其中:源MAC地址和目的MAC地址是包含在链路层的报头当中的,MAC地址实际只在当前局域网内有效
因此当数据跨网络到达另一个局域网时,其源MAC地址和目的MAC地址就需要发生变化,因此当数据达到路由器时,路由器会将该数据当中链路层的报头去掉,然后再重新封装一个报头,此时该数据的源MAC地址和目的MAC地址就发生了变化
例如上图中的主机1向主机2发送数据的过程中,数据的源MAC地址和目的MAC地址的变化过程如下
因此数据在传输的过程中是有两套地址:
- 一套是源IP地址和目的IP地址,这两个地址在数据传输过程中基本是不会发生变化的
- 另一套就是源MAC地址和目的MAC地址,这两个地址是一直在发生变化的,因为在数据传输的过程中路由器不断在进行解包和重新封装
源端口号和目的端口号
我们要知道:两台主机之间通信的目的不仅仅是为了将数据发送给对端主机,而是为了访问对端主机上的某个服务
- 比如我们在用百度搜索引擎进行搜索时,不仅仅是想将我们的请求发送给对端服务器,而是想访问对端服务器上部署的百度相关的搜索服务
socket通信的本质:
现在通过IP地址和MAC地址已经能够将数据发送到对端主机了,但实际我们是想将数据发送给对端主机上的某个服务进程,此外,数据的发送者也不是主机,而是主机上的某个进程
- 比如当我们用浏览器访问数据时,实际就是浏览器进程向对端服务进程发起的请求
网络通信的本质就是跨网络的两台主机之间的进程间通信
- 比如逛淘宝和刷抖音的动作,实际就是手机上的淘宝进程和抖音进程在和对端服务器主机上的淘宝服务进程和抖音服务进程之间在进行通信
可以把整个网络看作是一个大操作系统,所有的网络上网行为就都可以看作是这个大操作系统内进行的进程间通信
因此进程间通信的方式除了管道、消息队列、信号量、共享内存等方式外,还有socket套接字,只不过前者是不跨网络的,而后者是跨网络的
进程具有独立性,进程间通信的前提是先让不同的进程看到同一份资源,而网络通信的临界资源就是网络
端口号的理解
端口号(port)的作用实际就是唯一标识一台主机上的一个进程
- 端口号是传输层协议的内容
- 端口号是一个2字节16位的整数
- 端口号用来标识一个进程**,告诉操作系统,当前的这个数据要交给哪一个进程来处理**
- 一个端口号只能被一个进程占用
在两台主机上,可能会同时存在多个正在进行跨网络通信的进程
1)因此当数据到达对端主机后,必须要通过某种方法找到该主机上对应的服务进程,然后将数据交给该进程处理
2)而当该进程处理完数据后还要对发送端进行响应,因此对端主机也需要知道,是发送端上的哪一个进程向它发送的数据请求
由于IP地址能够唯一标识公网内的一台主机,而端口号能够唯一标识一台主机上的一个进程
因此用IP地址+端口号port就能够唯一标识网络上的某一台主机的唯一的一个进程
源port和目的port
当数据在传输层进行封装时,就会添加上对应源端口号和目的端口号的信息
- 这时通过源IP地址+源端口号就能够在网络上唯一标识发送数据的进程
- 通过目的IP地址+目的端口号就能够在网络上唯一标识接收数据的进程
此时就实现了跨网络的进程间通信
注意事项:
端口号是隶属于某台主机的,所以端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的进程的端口号不能重复
由于端口号的作用实际就是唯一标识一台主机上的一个进程,所以:一个进程可以绑定多个端口号,但是一个端口号不能被多个进程同时绑定
“端口号port” 和 “进程ID”
二者的概念区别
- 端口号(port)的作用唯一标识一台主机上的某个进程,
- 进程ID(PID)的作用也是唯一标识一台主机上的某个进程
那么问题来了:为什么进行网络通信时为什么不直接用PID来代替port呢
- 就可以类比:每个人都有自己的身份证号,但是在学校后,为什么给我们提供学号,而不是直接使用身份证号呢
1)进程ID(PID)是用来标识系统内所有进程的唯一性的,它是属于系统级的概念,端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性的,它是属于网络的概念
2)一台机器上可能会有大量的进程,但并不是所有的进程都要进行网络通信,可能有很大一部分的进程是不需要进行网络通信的本地进程,此时PID虽然也可以标识这些网络进程的唯一性,但在该场景下就不太合适了
3)在不同的场景下可能需要不同的编号来标识某种事物的唯一性,因为这些编号更适合用于该场景
底层如何通过port找到对应进程
采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能够找到该端口号对应的进程
网络协议栈是贯穿整个体系结构的,在应用层、操作系统层和驱动层各有一部分,当我们使用系统调用接口实现网络数据通信时,不得不面对的协议层就是传输层,而传输层最典型的两种协议就是TCP协议和UDP协议
认识TCP/UDP协议
TCP协议叫做传输控制协议(Transmission Control Protocol)
UDP协议叫做用户数据报协议(User Datagram Protocol)
协议名称 | 所处网络栈的层 | 特点 |
---|---|---|
TCP | 传输层 | 有连接、可靠传输、面向字节流 |
UDP | 传输层 | 无连接、不可靠传输、面向数据报 |
何为可靠,何为不可靠?
TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输.其次,TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法
UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的
既然UDP协议不可靠,为什么还要存在UDP协议呢? 直接全都使用TCP协议不是更好嘛?
TCP协议对应的缺点:
- 因为TCP协议是一种可靠的传输协议,但这一定意味着TCP协议在底层需要做更多的工作,因此TCP协议底层的实现是比较复杂的
UDP存在的意义:
- UDP协议虽然是一种不可靠的传输协议,但这一定意味着UDP协议在底层不需要做过多的工作,因此UDP协议底层的实现一定比TCP协议要简单,UDP协议虽然不可靠,但是它能够快速的将数据发送给对方,虽然在数据在传输的过程中可能会出错
编写网络通信代码时具体采用TCP协议还是UDP协议,完全取决于上层的应用场景:
- 如果应用场景严格要求数据在传输过程中的可靠性,此时我们就必须采用TCP协议
- 如果应用场景允许数据在传输出现少量丢包,那么我们肯定优先选择UDP协议,因为UDP协议足够简单
例如:
网站在设计网络通信算法时,会同时采用TCP协议和UDP协议,
- 当网络流畅时就使用UDP协议进行数据传输,
- 当网速不好时就使用TCP协议进行数据传输
此时就可以动态的调整后台数据通信的算法
网络字节序
学C语言的时候我们就知道,计算机在存储数据时是有大小端的概念的,大于一个字节数据的存储需要考虑字节序的问题
- 大端模式:数据中低字节的内容放在内存的高地址处
- 小端模式:数据中低字节的内容放在内存的低地址处
例如:
如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的
网络中的大小端问题
如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的
小例子:两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机
发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获 ‘【、取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的
此时由于发送端和接收端采用的分别是小端存储和大端存储,此时对于内存地址从低到高为11223344
的序列,发送端按小端的方式识别出来是0x44332211
,而接收端按大端的方式识别出来是0x11223344
,此时接收端识别到的数据与发送端原本想要发送的数据就不一样了,这就是由于大小端的偏差导致数据识别出现了错误
如何解决这个问题呢?
由于我们不能保证通信双方存储数据的方式是一样的,因此网络当中传输的数据必须考虑大小端问题
TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节,无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据
发送端 | 处理方法 |
---|---|
小端 | 先将数据转成大端,然后再发送到网络当中 |
大端 | 可以直接进行发送 |
接收端 | 处理方法 |
---|---|
小端 | 先将接收到数据转成小端后再进行数据识别 |
大端 | 可以直接进行数据识别 |
所以对于上述的例子就是:
1)发送端是小端机,因此在发送数据前需要先将数据转成大端,然后再发送到网络当中
2)由于接收端是大端机,因此接收端接收到数据后可以直接进行数据识别,此时接收端识别出来的数据就与发送端原本想要发送的数据相同了
大小端的转化工作谁来完成?
由操作系统来完成的,因为该操作属于通信细节, 不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址
**网络字节序采用的是大端,而主机字节序一般采用的是小端,**那为什么网络字节序不采用小端呢
-
TCP在Unix时代就有了,以前Unix机器都是大端机,因此网络字节序也就采用的是大端,但之后人们发现用小端能简化硬件设计,所以现在主流的都是小端机,但协议已经不好改了
-
大端序更符合现代人的读写习惯
网络字节序与主机字节序之间的转换
为使网络程序具有可移植性**,使同样的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); // 网络 转 主机 短整型
含义解释:
1.h
表示host n
表示network l
表示32位长整数 s
表示16位短整数
- 例如:htonl表示将32位长整数从主机字节序转换为网络字节序
2.如果主机是小端字节序,则这些函数将参数做相应的大小端转换然后返回
3.如果主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回
Socket编程
sockaddr结构
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信,在进行跨网络通信时我们需要传递端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in
结构体和sockaddr_un
结构体
sockaddr_in
结构体是用于跨网络通信的sockaddr_un
结构体是用于本地通信的
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockaddr
结构体,该结构体与sockaddr_in
和sockaddr_un
的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族
这样做的好处:
- 当我们在传参时,就不用传入
sockaddr_in
或sockaddr_un
这样的结构体,而统一传入sockaddr
这样的结构体
2)在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取sockaddr
结构头部的16位进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作
3)此时我们就通过使用sockaddr
结构,将套接字网络通信和本地通信的参数类型进行了统一
注意事项:
我们在进行网络通信时,定义的还是sockaddr_in
这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为sockaddr
为什么没有用
void*
代替struct sockaddr*
类型呢?
实际上这样也是可以的!将这些函数的struct sockaddr*
参数类型改为void*
,然后此时在函数内部也可以直接指定提取头部的16个比特位进行识别,最终也能够判断是需要进行网络通信还是本地通信, 那为什么不用void*呢?
- 因为设计出这一套网络接口的时候C语言还不支持
void*
,于是就设计出了sockaddr
这样的解决方案 - 系统接口是所有上层软件接口的基石,系统接口是不能轻易更改的,所以void*支持以后,也没有将它改回来,否则引发的后果是不可想的,这也就是为什么现在依旧保留
sockaddr
结构的原因
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);
简单的UDP网络程序
服务器server
服务端创建套接字:
socket函数
int socket(int domain, int type, int protocol);
参数说明:
domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型(指定通信所用到的协议家族)
- 该参数就相当于
struct sockaddr
结构的前16个位 - 如果是本地通信就设置为
AF_UNIX
,如果是网络通信就设置为AF_INET
(IPv4)或AF_INET6
(IPv6),一般TCP/UDP使用AF_INET
即可
type:创建套接字时所需的服务类型(指定具体的通信协议)
- 其中最常见的服务类型是
SOCK_STREAM
和SOCK_DGRAM
- 如果是基于UDP的网络通信,我们采用的就是
SOCK_DGRAM
叫做用户数据报服务 - 如果是基于TCP的网络通信,我们采用的就是
SOCK_STREAM
,叫做流式套接字,提供的是流式服务
protocol:创建套接字的协议类别
- 可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认
- 此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议
返回值说明
套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置
注意事项:
1.socket函数属于系统调用接口
- 网络协议栈是分层的,按照TCP/IP四层模型来说,自顶向下依次是应用层、传输层、网络层和数据链路层
- 而我们现在所写的代码都叫做用户级代码,也就是说我们是在应用层编写代码,因此我们调用的实际是下三层的接口,而传输层和网络层都是在操作系统内完成的,
2.socket函数是被进程所调用的
- socket这个函数是被程序调用的,但并不是被程序在编码上直接调用的,而是程序编码形成的可执行程序运行起来变成进
- 当这个进程被CPU调度执行到socket函数时,然后才会执行创建套接字的代码,也就是说socket函数是被进程所调用的
socket的底层原理
1.socket函数是被进程所调用的,而每一个进程在系统层面上都有一个进程地址空间PCB(task_struct
)、文件描述符表(files_struct
)以及对应打开的各种文件
2.当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file
结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array
数组当中下标为3的位置
3.此时fd_array
数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户
4.每一个struct file
结构体中包含的就是对应打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区等
- 文件对应的属性在内核当中是由
struct inode
结构体来维护的 - 而文件对应的操作方法实际就是一堆的函数指针(比如
read*
和write*
),在内核当中就是由struct file_operations
结构体来维护的 - 文件缓冲区对于打开的普通文件来说对应的一般是磁盘,但对于现在打开的“网络文件”来说,这里的文件缓冲区对应的就是网卡
5.对于一般的普通文件来说,当用户通过文件描述符将数据写到文件缓冲区,然后再把数据刷到磁盘上就完成了数据的写入操作
而对于现在socket函数打开的“网络文件”来说,当用户将数据写到文件缓冲区后,操作系统会定期将数据刷到网卡里面,而网卡则是负责数据发送的,因此数据最终就发送到了网络当中
实例:
当我们在进行初始化服务器创建套接字时,就是调用socket函数创建套接字,创建套接字时我们需要填入的协议家族就是AF_INET
(IPV4),因为我们要进行的是网络通信,而我们是UDP通信,所以需要的服务类型就是SOCK_DGRAM
,第三个参数设为0即可,默认
#include<iostream>
#include<sys/types.h>
#include<cerrno>
#include<sys/socket.h>
int main()
{
int sock = socket(AF_INET,SOCK_DGRAM,0);
if(sock<0)
{
std::cerr<<"socket create error:"<<errno<<std::endl;
return 1;
}
std::cout << sock<<std::endl;
return 0;
}
运行程序后可以看到套接字是创建成功的,对应获取到的文件描述符就是3,这也很好理解,因为0、1、2默认被标准输入流、标准输出流和标准错误流占用了,此时最小的未被利用的文件描述符就是3
绑定服务端
作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来
所以做的第二件事情就是 绑定 ->bind函数
如何理解"绑定"这个词语
作为服务器,一定要客户端知道自身的IP和端口,否则无法建立连接,但云服务器不允许绑定固有IP,必须使用宏INADDR_ANY
让系统自动为我们绑定
绑定实际上就是将文件和网络关联起来,
bind函数
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明
sockfd
:绑定的文件的文件描述符
- 就是我们创建套接字时返回的文件描述符
addr
: 是一个结构体,需要我们填入当前自身网络相关的属性信息: 包括协议家族,IP地址,端口号等
addrlen
: 传入的addr结构体的长度,用sizeof求即可
返回值说明
绑定成功返回0,绑定失败返回-1,同时错误码会被设置
因为现在是跨网络通信,所以用的是sockaddr_in结构体
struct sockaddr_in
在绑定时需要将网络相关的属性信息填充到一个结构体当中,然后将该结构体作为bind函数的第二个参数进行传入,这实际就是struct sockaddr_in
结构体
用指令在
/usr/include
目录下查找该结构查看该结构
此时我们就可以查看该结构的定义:
需要注意的是,struct sockaddr_in
属于系统级的概念,不同的平台接口设计可能会有点差别
所以struct sockaddr_in
其包含三个成员:
- sin_family:表示协议家族
- sin_port:表示端口号,是一个16位的整数
uint16类型
- sin_addr:表示IP地址,是一个32位的整数
uint32类型
其它的字段不需要处理,但是最好在填充上述三个成员之前对该结构体变量里面的内容进行清空
in_addr的结构
in_addr结构体中的s_addr成员用来表示一个IPv4的IP地址. 其实就是一个32位的整数
注意:
1)套接字创建完毕后我们就需要进行绑定了,但在绑定之前我们需要先定义一个struct sockaddr_in
结构,将对应的网络属性信息填充到该结构当中 ,由于该结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中
2)在发送到网络之前需要将端口号设置为网络序列
- 我们需要使用前面说到的htons函数将端口号转为网络序列
local.sin_port = htons(port);
3)如果用的是字符串IP,此时需要调用inet_addr
函数将字符串IP转换成整数IP,然后再将转换后的整数IP进行设置
- 即:
local.sin_addr.s_addr = inet_addr("42.192.12.123")
但是我们推荐使用宏:INADDR_ANY
, 作用是:不关心数据从哪个IP上来的,只要它访问的是我的这个端口,都要把数据给我
local.sin_addr.s_addr = INADDR_ANY;//INADDR_ANY实际就是被#defind定义为0x0000000
4)当网络属性信息填充完毕后,由于bind函数提供的是通用参数类型struct sockaddr*,因此在传入结构体地址时还需要将struct sockaddr_in*
强转为struct sockaddr*
类型后再进行传入
实例
struct sockaddr_in local;
memset(&local, '\0', sizeof(local));//先将内容清空
local.sin_family = AF_INET; // 协议家族
local.sin_port = htons(port); // 端口 ->将端口号转为网络序列htnos
local.sin_addr.s_addr = INADDR_ANY; // IP地址
if (bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "bind error: " << errno << std::endl;
exit(2);
}
关于字符串IP和整数IP
IP的展示形式有两种:
1)字符串IP
- 类似于
192.168.111.123
这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址
2)整数IP
- IP地址在进行网络传输时所用的形式,用一个32位的整数来表示IP地址
为什么要存在整数IP
好处1:节省空间
如果我们在网络传输时直接以基于字符串的点分十进制IP的形式进行IP地址的传送,那么此时一个IP地址至少就需要15个字节,但实际并不需要耗费这么多字节
IP地址实际可以划分为四个区域,其中每一个区域的取值都是0~255,而这个范围的数字只需要用8个比特位就能表示,因此我们实际只需要32个比特位就能够表示一个IP地址
例如:
其中这个32位的整数的每一个字节对应的就是IP地址中的某个区域,我们将IP地址的这种表示方法称之为整数IP,此时表示一个IP地址只需要4个字节
因为采用整数IP的方案表示一个IP地址只需要4个字节,并且在网络通信也能表示同样的含义,因此在网络通信时就没有用字符串IP而用的是整数IP,因为这样能够减少网络通信时数据的传送
整数IP和字符串IP相互转换
在进行字符串IP和整数IP的转换时,我们不需要自己编写转换逻辑,系统已经为我们提供了相应的转换函数,我们直接调用即可
inet_addr
函数
in_addr_t inet_addr(const char *cp);
作用:将字符串IP转换成整数IP ,我们只需传入待转换的字符串IP,该函数返回的就是转换后的整数IP
inet_ntoa
函数
char *inet_ntoa(struct in_addr in);
作用:将整数IP转换成字符串IP
注意:传入inet_ntoa函数的参数类型是struct in_addr
运行服务器
UDP服务器的初始化就只需要创建套接字和绑定就行了,当服务器初始化完毕后我们就可以启动服务器了
1)服务器实际上就是在周而复始的为我们提供某种服务,服务器之所以称为服务器,是因为服务器运行起来后就永远不会退出,因此服务器实际执行的是一个死循环代码
2)UDP服务器是不面向连接的,因此只要UDP服务器启动后,就可以直接读取客户端发来的数据
recvfrom函数
recvfrom
函数
服务器读取数据的函数叫做recvfrom
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数解析:
- sockfd:对应操作的文件描述符, 表示从该文件描述符索引的文件当中读取数据
- buffer: 读取数据的存放位置
- length: 期望读取数据的字节数
- flags:读取的方式, 一般设置为0,表示阻塞读取
- address:**对端网络相关的属性信息,**包括协议家族、IP地址、端口号等
- address_len:期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数
返回值:
读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置
函数使用注意事项:
1.UDP是不面向连接的,因此我们除了获取到数据以外还需要获取到对端网络相关的属性信息,包括IP地址和端口号等
2.在调用recvfrom读取数据时,必须将addrlen设置为你要读取的结构体对应的大小
3.由于recvfrom函数提供的参数也是struct sockaddr*
类型的,因此我们在传入结构体地址时需要将struct sockaddr_in*
类型进行强转
4.recvfrom
函数的参数sockaddr
及addr_len
是输出型参数,用来接收发送方的sockaddr结构体,获取发送方的信息
使用例子:
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr*)&peer, &len);
if (s > 0)
{
buf[s] = 0;//最后位置置\0
std::cout << "server # " << std::endl << buf << std::endl;
}
else
{
std::cerr << "recvfrom error" << std::endl;
}
sendto函数
发送数据的函数叫做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,同时错误码会被设置
函数注意事项:
1)UDP不是面向连接的,因此除了传入待发送的数据以外还需要指明对端网络相关的信息,包括IP地址和端口号等
2)由于sendto函数提供的参数也是struct sockaddr*
类型的,因此我们在传入结构体地址时需要将struct sockaddr_in*
类型进行强转
3)sendto
的参数sockaddr
及addr_len
是输入行参数,用来传输自身的sockaddr结构体,让接收方获取自身的信息
引入命令行参数
因为构造服务器时需要传入IP地址和服务器的端口号,我们这里可以引入命令行参数,此时当我们运行服务器时在后面跟上对应的IP地址和端口号即可
但是由于云服务器的原因,后面实际不需要传入IP地址,我们使用了INADDR_ANY宏进行了绑定,因此在运行服务器的时候我们只需要传入端口号即可
- 如果我们的IP地址为
127.0.0.1
,实际上等价于localhost表示本地主机,我们将它称之为本地环回
我们将来是这么启动服务的:./udp_server 端口号
,所以命令行中有两个参数
// ./udp_server 端口号
//argv[1]才是端口号
int main(int argc, char* argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;//打印使用说明
return 1;
}
//....
}
需要注意的是,agrv数组里面存储的是字符串,而端口号是一个整数,因此需要使用atoi函数将字符串转换成整数才可以使用int port = atoi(argv[1])
popen函数和pclose函数
#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
popen:底层原理是管道,底层会创建子进程,执行command的内容, 执行完成后,以文件的方式返回,让你读取文件
- fork创建子进程,再使用pipe实现双方通信,让父进程拿到的结果是通过文件的方式去拿
popen参数分析
- command:你要执行的内容
- type:以什么方式打开返回的这个文件
返回值:
函数执行成功,popen返回的是文件指针,否则返回-1
小例子:
#include<cstdio>
#include<string>
#include<iostream>
using namespace std;
int main()
{
string str;
while(getline(cin,str)) //输入命令,因为有些命令带空格,所以不能使用cin输入,要使用getline输入
{
FILE* fp = popen(str.c_str(),"r");
char buff[1024] = {0};
string ans;
while(fgets(buff,sizeof(buff),fp) != NULL)
{
ans+=buff;
}
pclose(fp);
cout <<"执行结果:"<<endl;
cout << ans <<endl;
}
return 0;
}
服务器server.cc
简易版本:
#include <iostream>
#include <string>
#include <cerrno>//引入错误码
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
const uint16_t port = 8080;//自定义端口号为8080
int main()
{
//1. 创建套接字->相当于打开网络文件,返回的文件描述符是3!sock=3
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if(sock < 0)
{
std::cerr << "socket create error: " << errno << std::endl;
return 1;
}
//2. 给该服务器绑定端口和ip(这里的ip要特殊处理)
struct sockaddr_in local;
//填充字段
local.sin_family = AF_INET; //协议家族
local.sin_port = htons(port);//端口号:将端口号转成网络序列
//INADDR_ANY:作用是:不关心数据从哪个IP上来的,只要它访问的是我的这个端口,都要把数据给我
local.sin_addr.s_addr = INADDR_ANY;//IP地址:
//绑定
if(bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "bind error : " << errno << std::endl;
return 2;
}
//3. 提供服务,所有的网络服务都是死循环
bool quit = false;
#define NUM 1024
char buffer[NUM];
while(!quit)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
recvfrom(sock, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
std::cout << "client# " << buffer << std::endl;
std::string echo_hello = "hello";
sendto(sock, echo_hello.c_str(), echo_hello.size(), 0, (struct sockaddr*)&peer, len);
}
return 0;
}
提高版本:
当服务端收到客户端发来的数据后,除了在服务端进行打印以外,服务端可以调用sento函数将收到的数据重新发送给对应的客户端
需要注意的是,服务端在调用sendto函数时需要传入客户端的网络属性信息,但服务端现在是知道客户端的网络属性信息的,因为服务端在此之前就已经通过recvfrom函数获取到了客户端的网络属性信息,
//udp_server.cc
#include <iostream>
#include <vector>
#include <string>
#include <cstring>
#include <cerrno>
#include<stdlib.h>
#include<cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// const uint16_t PORT = 8080; //网络服务进程端口不可轻易修改
const int BUFFER_SIZE = 1024; //读写缓冲区大小
void Usage(std::string proc) //使用指南
{
std::cout << "Usage: \n\t" << proc << " port" << std::endl;
exit(1);
}
//我们将来这么启动服务: ./udp_server 服务器的端口号port
int main(int argc, char* argv[])
{
if (argc != 2)
{
Usage(argv[0]);//打印使用指南
exit(1);
}
uint16_t port = atoi(argv[1]);//argv[1]就是我们输入的端口号 argv[1]是字符串 atoi:转为整数
std::cout << "Hello UdpServer" << std::endl;
//1. 创建套接字,打开网络文件
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
std::cerr << "socket error: " << errno << std::endl;
exit(1);
}
//2. 绑定IP和端口
struct sockaddr_in local;
local.sin_family = AF_INET; // 协议家族
local.sin_port = htons(port); // 端口 - htons 主机转网络短整型(小端转大端)
// 云服务器不允许显式绑定IP,防止单主机多IP的情况
local.sin_addr.s_addr = INADDR_ANY; // IP地址
if (bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "bind error: " << errno << std::endl;
exit(2);
}
//3. 提供服务
bool quit = false;
while (!quit)
{
struct sockaddr_in peer; // 远端地址
socklen_t len = sizeof(peer); // 远端地址结构体大小
char buf[BUFFER_SIZE] = { 0 }; // 缓冲区
// 接受数据
ssize_t cnt = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr*)&peer, &len);
if (cnt > 0)
{
// 打印接收对端发来的数据
buf[cnt] = 0;//置\0
std::cout << "client# " << buf << std::endl;
FILE* fp = popen(buf, "r"); //把buf看作命令,执行其对于的内容,然后把输出结果以文件的形式返回
std::string res;
//fgets按行读取
while (fgets(buf, sizeof(buf), fp) != NULL)
{
res += buf;
}
pclose(fp);
// 发送数据
std::string reply = std::string(buf);
sendto(sock, reply.c_str(), reply.size(), 0, (struct sockaddr*)&peer, len);
}
else
{
std::cerr << "recvfrom error" << std::endl;
}
}
return 0;
}
此时就等待对端发送数据了
虽然现在客户端代码还没有编写,但是我们可以通过netstat
命令来查看当前网络的状态,这里我们可以选择携带nlup选项
netstat
命令
netstat
常用选项说明:
- -n:直接使用IP地址,而不通过域名服务器
- -l:显示监控中的服务器的Socket
- -t:显示TCP传输协议的连线状况
- -u:显示UDP传输协议的连线状况
- -p:显示正在使用Socket的程序识别码和程序名称
其中:Proto
表示协议的类型,Recv-Q
表示网络接收队列,Send-Q
表示网络发送队列,Local Address
表示本地地址,Foreign Address
表示外部地址,State
表示当前的状态,PID
表示该进程的进程ID,Program name
表示该进程的程序名称
Foreign Address
写成0.0.0.0:*
表示任意IP地址、任意的端口号的程序都可以访问当前进程,
客户端client
0)引入命令行参数
客户端时需要传入对应服务端的IP地址和端口号,我们这里也可以引入命令行参数,当我们运行客户端时直接在后面跟上对应服务端的IP地址和端口号
即:我们是这样启动客户端的: ./udp_client server_ip server_port
所以命令行中有3个参数,如果不满足,就打印使用说明
**注意:**argv数组里面存储的是字符串,而端口号是一个整数,因此需要使用atoi函数将字符串转换成整数,然后再将主机序转化为网络序才能使用这个端口号:server.sin_port = htons(atoi(argv[2]))
1)现在客户端要发送数据给服务端,我们可以让客户端获取用户输入,不断将用户输入的数据发送给服务端,
2)客户端创建套接字时选择的协议家族也是AF_INET
,需要的服务类型也是SOCK_DGRAM
注意:与服务端不同的是,客户端在初始化时只需要创建套接字就行了,而不需要进行显示的绑定操作
为什么服务端一定需要绑定
a.因为服务器就是为了给别人提供服务的,因此服务器必须要让别人知道自己的IP地址和端口号
b.IP地址一般对应的就是域名,而端口号一般没有显示指明过,因此服务端的端口号一定要是一个众所周知的端口号,并且选定后不能轻易改变,否则客户端是无法知道服务端的端口号的,这就是服务端要进行绑定的原因
c.只有绑定之后这个端口号才真正属于自己,因为一个端口只能被一个进程所绑定,服务器绑定一个端口就是为了独占这个端口
为什么客户端需要绑定,但是不需要显示的绑定
a.客户端实际上需要端口号,因为套接字编程网络通信需要端口, 由于是网络通信,通信双方都需要找到对方,因此服务端和客户端都需要有各自的IP地址和端口号,只不过服务端需要进行端口号的绑定,而客户端不需要
b.如果客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端使用,就是这个客户端没有启动,这个端口号也无法分配给别人,并且如果这个端口号被别人使用了,那么这个客户端就无法启动了
c.客户端的端口只要保证唯一性就行了,因此客户端端口可以动态的进行设置,并且客户端的端口号不需要我们来设置,当我们调用类似于sendto这样的接口时,操作系统会自动给当前客户端获取一个唯一的端口号,
也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要我们的端口号没有被耗尽,客户端就永远可以启动
创建套接字,不需要绑定
// 1. 创建套接字.打开网络文件
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
std::cerr << "socket error : " << errno << std::endl;
return 1;
}
3)作为一个客户端,它必须知道它要访问的服务端的IP地址和端口号
需要注意的是,客户端中存储的服务端的端口号此时是主机序列,我们需要调用htons函数将其转为网络序列后再设置进struct sockaddr_in
结构体,同时,客户端中存储的服务端的IP地址是字符串IP,我们需要通过调用inet_addr函数将其转为整数IP后再设置进struct sockaddr_in
结构体
//例子:
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(atoi(argv[2]));
server.sin_addr.s_addr = inet_addr(argv[1]);
4)由于客户端和服务端在功能上是相互补充的,既然服务器是在读取客户端发来的数据,那么客户端就应该向服务端发送数据
std::cout << "MyShell $ ";
char line[1024];
fgets(line, sizeof(line), stdin);//读取字符放到line
sendto(sock, line, strlen(line), 0, (struct sockaddr*)&server, sizeof(server));
client.cc
当客户端发完数据给服务端后,由于服务端还会将该数据重新发给客户端,因此客户端发完数据后还需要调recvfrom来读取服务端发来的响应数据
在客户端调用recvfrom函数接收服务端发来的响应数据时,客户端同时也需要读取服务端与网络相关的各种信息,虽然客户端早已知道服务端的网络信息了,此时服务端的网络信息已经不重要了,但还是建议不要把参数设置为空,这样可能会出问题,所以我们还是用一个临时变量将服务端的网络信息读取一下,
//udp_client.cc
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/socket.h>
#include<stdlib.h>
#include<stdio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
const int BUFFER_SIZE = 1024; //读写缓冲区大小
void Usage(std::string proc)//打印使用说明
{
std::cout << "Usage: \n\t" << proc << " server_ip, server_port" << std::endl;
}
//我们将来是这样启动程序的:/UdpClient server_ip server_port
int main(int argc, char* argv[])
{
// 获取命令行参数
if (argc != 3)//如果不是3个参数
{
Usage(argv[0]);//打印使用说明
exit(0);
}
std::cout << "Hello UdpClient" << std::endl;
//1. 创建套接字,打开网络文件
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
std::cerr << "socket error: " << errno << std::endl;
exit(1);
}
//客户端进行网络通信必须要有IP地址和端口,但不需要显式绑定指定的端口
//由系统随机分配,防止端口被占用
//2. 使用服务
// 服务端地址结构体
struct sockaddr_in server;
server.sin_family = AF_INET; // 协议家族
server.sin_addr.s_addr = inet_addr(argv[1]); // IP地址-需要将字符串IP转为整数IP
server.sin_port = htons(atoi(argv[2])); // 端口号:先将字符串->主机序整数->网络字节序
//请求服务
bool quit = false;
while (!quit)
{
char msg[1024];
std::cout << "MyShell: ";
fgets(msg, sizeof(msg), stdin);//从键盘上读取内容
//把msg的内容发送数据给服务器
sendto(sock, msg, strlen(msg), 0, (struct sockaddr*)&server, sizeof(server));
struct sockaddr_in tmp; //tmp仅用于传参,无实际意义
socklen_t len = sizeof(tmp);
char buf[BUFFER_SIZE] = { 0 };
//接收客户端传回来的数据
ssize_t cnt = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr*)&tmp, &len);
if (cnt > 0)
{
buf[cnt] = 0;//置\0
std::cout << "server # " << std::endl << buf << std::endl;
}
else
{
std::cerr << "recvfrom error" << std::endl;
}
}
return 0;
}
效果展示
此时我们运行服务器时指明端口号为8081,再运行客户端,此时客户端要访问的服务器的IP地址就是127.0.0.1
,表示:本地环回 , 服务端的端口号就是8081
注意:使用popen输入的命令必须有效,否则会弹出信息:command not fount
, 有些命令是不能被popen执行的
此时用netstat
命令查看网络信息,可以看到服务端的端口是8081,客户端的端口是36377,这里客户端能被netstat
命令查看到,说明客户端也已经动态绑定成功了,这就是我们所谓的网络通信
关于INADDR_ANY
由于云服务器的IP地址是由对应的云厂商提供的,这个IP地址并不一定是真正的公网IP,这个IP地址是不能直接被绑定的
如果需要让外网访问,此时我们需要bind 0,系统当当中提供的一个INADDR_ANY
,这是一个宏值,它对应的值就是0
local.sin_addr.s_addr = INADDR_ANY; //绑定INADDR_ANY