🌏博客主页:PH_modest的博客主页
🚩当前专栏:Linux跬步积累
💌其他专栏:
🔴 每日一题
🟡 C++跬步积累
🟢 C语言跬步积累
🌈座右铭:广积粮,缓称王!
一、预备知识
理解源IP地址和目的IP地址
每台主机都有自己的IP地址,如果一个台主机想要对另外一台主机发送数据,那么该主机就需要知道对端主机的IP地址,也就是目的IP地址。但是仅仅知道目的IP地址是不够的,对端主机收到数据之后,需要对其作出响应,需要知道是谁给他发送的数据,因此我们还需要知道发送方的IP地址,也就是源IP地址。因此一个传输的数据当中应该包含源IP地址和目的IP地址,源IP地址表示是谁发送的数据,目的IP地址表示发送给谁。
在数据传输之前,会先自顶向下贯穿网络协议栈完成数据的封装,其中在网络层封装的报头中就包含了源IP地址和目的IP地址。
理解源MAC地址和目的MAC地址
大部分数据传输都是跨局域网的,数据传输过程中会经过很多路由器,最终才能到达对端主机。
源MAC地址和目的MAC地址是在数据链路层被封装在报头中的,MAC地址只在当前局域网内有效,因为每经过一个路由器,就会将链路层的报头去掉,然后重新封装一个报头,此时的源MAC地址和目的MAC地址就发生了变化。
上图中主机1向主机2发送数据过程中,源MAC地址和目的MAC地址的变化过程。
时间轴 | 源MAC地址 | 目的MAC地址 |
---|---|---|
刚开始 | 主机1的MAC地址 | 路由器A的MAC地址 |
经过路由器A后 | 路由器A的MAC地址 | 路由器B的MAC地址 |
经过路由器B后 | 路由器B的MAC地址 | 路由器C的MAC地址 |
经过路由器C后 | 路由器C的MAC地址 | 主机2的MAC地址 |
小结
数据传输的过程中是有两套地址的:
- 源IP地址和目的IP地址,用来确定发送方的主机以及接受方的主机,这两个地址一般是不会变的;
- 源MAC地址和目的MAC地址,这两个地址是一直变化的,因为经过路由器之后会进行解包和重新封装;
端口号
端口号(port)是传输层协议的内容:
- 端口号是一个2字节16位的整数;
- 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理;
- IP地址 + 端口号能够表示网络上某一台主机的某一个进程;
- 一个端口号只能被一个进程占用;
- 数据在传输层进行封装时,就会添加上对应的源端口号和目的端口号。
IP地址可以标识唯一的一台主机,端口号(port)可以表示一台主机上唯一的一个进程,因此IP + port = 网络上某一台主机的某一个进程。
理解源端口号和目的端口号
两台主机进行通信的目的不仅仅是将数据发送给对端主机,而是为了访问对端主机上的某个服务。
当我们知道了IP地址和MAC地址,就可以将数据发送给对端主机,但是如何再将数据发送给对端主机上的某个进程呢?那么我们还需要知道端口号,当我们知道源端口号和目的端口号之后,就可以知道是哪台主机的哪个进程发送的数据,以及发给哪台主机的哪个进程。这样就可以实现跨网络的进程间通信了。
理解“端口号”和“进程ID”
进程ID(PID)是用来标识系统内所有进程的唯一性,它属于系统级别的概念;而端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性,它是属于网络级别的概念。这样设计就是为了避免将系统功能和网络功能直接进行强耦合,避免一个模块改变后影响另一个模块。
比如我们每个人都有自己的身份证号码,身份证号码就可以标识我们的唯一性了,那么为什么在学校还要有学号,在公司还要有工号呢?
因为不是每个人都是学生,也不是每个人都在同一家公司,所以需要在不同场景下在设定对应的编号来表示唯一性。
所以每一个进程都有pid,但不是每个进程都有port。
socket通信的本质
-
我们上网无非就两种动作:
- 把远端数据拉取到本地(I)
- 把本地数据发送到远端(O)
-
大部分的网络通信行为都是用户触发的,在计算机中,谁表示用户呢?进程!!!
-
我们将数据发送到目标主机,这不是目的,而是手段。真正的目的是把数据交给这个主机上的某个服务(进程)。
-
服务端与客户端进行通信
==>服务端进程和客户端进程进行通信
==>服务端进程 = 服务端IP + 服务端port
客户端进程 = 客户端IP + 客户端port
==>服务端是互联网中唯一的一个进程
客户端是互联网中唯一的一个进程
==>唯一的找到彼此
-
所以网络通信的本质,其实就是进程间通信!两个进程间需要通信,前提条件就是要找到公共资源,这个公共资源就是网络!
我们之后要写的服务器就是应用层的代码,然后调用传输层提供的系统调用,即操作系统提供的网络系统调用。
认识TCP协议和UDP协议
TCP协议
TCP协议叫做传输控制协议(Transmission Control Protocol),TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。
TCP协议是面向连接的,如果两台主机想要进行通信,就必须先建立连接,当连接建立之后才能进行数据传输。TCP协议是保证可靠性的协议,当传输过程中如果发生了丢包、乱序等情况,TCP协议都有对应的解决办法。
UDP协议
UDP协议叫做用户数据报协议(User Datagram Protocol),UDP协议是一种无序建立连接的、不可靠的、面向数据报的传输层通信协议。
UDP协议进行通信时无需建立连接,两台主机如果想要进行数据传输,直接将数据发送给对端主机即可,但这意味着UDP协议是不可靠的,如果传输过程中发生了丢包、乱序等情况,UDP协议是不知道的,无法解决。
为什么UDP协议不可靠还会一直存在?
TCP协议可靠,那么也就意味着底层的实现更加复杂,接口会更加多一点,使用成本比较高;而UDP虽然不可靠,但一定更加简单,使用起来更加方便。
所以采用TCP协议还是UDP协议取决于上层的应用场景。如果应用场景严格要求数据在传输过程中的可靠性,此时我们就必须采取TCP;如果应用场景允许出现少量的丢包,那么优先使用UDP,因为足够简单。
网络字节序
计算机在存储数据时,是有大小端的概念的:
- 大端存储:数据的低字节内容存储在高地址处,数据的高字节内容存储在低地址处。
- 小端存储:数据的低字节内存存储在低地址处,数据的高字节内容存储在高地址处。
为了保证数据能够正常被获取,网络字节序列统一以大端为主。
为了使网络程序具有可移植性,使用样的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编程接口
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);
sockaddr结构
socket编程,是有不同种类的,有的是专门用来进行本地通信的(Unix socket),有的是用来专门进行跨网络通信的(inet socket),有的是用来进行网络管理的(raw socket)。为了能让用户减少使用的成本,我们就需要把所有的接口进行统一,于是就使用C语言进行编写,要统一这个接口还需要统一数据类型,于是就有了struct sockaddr
。
通过struct sockaddr
来管理struct sockaddr_in
和struct sockaddr_un
,struct sockaddr_in
结构体是用来跨网络通信的,而struct sockaddr_un
结构体是用来本地通信的。
为了让网络通信和本地通信能够使用同一套函数接口,于是就出现了sockaddr
结构体,这三个结构体都不相同,但是这三个结构体的头部字段都是16位地址类型,当我们想进行网络通信时,就传递AF_INET
,想进行本地通信,就传递AF_UNIX
。这样就可以通过sockaddr
来管理网络通信和本地通信了。
三、简单的UDP网络程序
socket函数
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
功能:
- 创建套接字的通信。
参数:
- domain:选择协议的类型,如果是本地通信就是
AF_UNIX
;网络通信就是AF_INET
。 - type:创建套接字时的服务类型。如果是UDP,我们采用
SOCK_DGRAM
,叫做用户数据报服务;如果是TCP,我们采用SOCK_STREAM
,叫做流式套接字,提供的是流式服务。 - protocol:套接字的协议类别,可以指明为TCP或者UDP,一般设置为0即可。
返回值:
- 创建成功返回一个文件描述符,所以当我们使用socket创建套接字成功时,就相当于我们打开了网卡(网络文件)。
- 创建失败返回-1,同时错误码会被设置。
使用示例:
int sockfd = socket(AF_INET,SOCK_DGRAM,0);
bind函数
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
功能:
- 将文件和网络关联起来。
参数:
- sockfd:文件描述符。
- addr:网络相关的属性信息,类似于一个结构体,里面包含了:协议家族、IP地址、端口号。
- addrlen:结构体addr的长度
返回值:
- 成功返回0。
- 失败返回-1,设置错误码。
使用示例:
int n = bind(_sockfd,(struct sockaddr*)&local,sizeof(local));
bzero函数
#include <strings.h>
void bzero(void *s, size_t n);
功能:
- 用于将一块内存空间清零,通常用于初始化内存。
参数:
- s:指向要清零的内存区域的指针。
- n:要清零的字节数。
返回值:空。
使用示例:
struct sockaddr_in local;
bzero(&local,sizeof(local));
inet_addr函数
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);
功能:
- 将字符串风格的点分十进制的IP地址转成4字节IP
- 将主机序列转成网络序列
参数:
- 目标字符串,也就是字符串类型的IP地址
返回值:
- in_addr_t类型
使用示例:
local.sin_addr.s_addr = inet_addr(_ip.c_str());
代码初步演示
UdpServer.hpp
#pragma once
#include<iostream>
#include<cstdlib>
#include<strings.h>
#include<string>
#include<unistd.h>
#include<sys/types.h>
#include<sys/socket.h>
#include <netinet/in.h>
#include<arpa/inet.h>
//echo server -> client -> server
enum
{
SOCKET_ERROR = 1,
BIND_ERROR,
USAGE_ERROR,
};
const static int defaultfd = -1;
class UdpServer
{
public:
UdpServer(const std::string &ip,uint16_t port):_sockfd(defaultfd),_ip(ip),_port(port),_isrunning(false)
{
}
void InitServer()
{
//1. 创建udp socket 套接字 —— 必须要做的 —— 可以理解成将网卡打开了
_sockfd = socket(AF_INET,SOCK_DGRAM,0);
if(_sockfd < 0<