文章目录
- 1. 计算机网络的背景
- 2. 认识网络协议
- 2.1 协议分层
- 2.2 OS与网络的关系
- 3. 网络传输基本流程
- 3.1 局域网通信流程
- 3.2 跨网络通信流程
- 4. Socket 编程预备
- 4.1 理解源IP地址和目的IP地址
- 4.2 端口号与Socket
- 4.3传输层的典型代表
- 4.4 网络字节序
- 5. socket 编程接口
- 5.1 介绍
- 5.2 案例演示
1. 计算机网络的背景
所谓的 “局域网” 和 “广域网” 只是一个相对的概念。
2. 认识网络协议
我们都知道,协议就是“双方”都必须遵守的约定。
那什么叫网络协议呢?
计算机生产厂商有很多,计算机操作系统也有很多,计算机网络硬件设备还是有很多。
如何让这些不同厂商之间生产的计算机能够相互顺畅的通信呢?就需要有人站出来,约定一个共同的标准,大家都来遵守,这就是网络协议。
无论是电脑、手机还是其他设备,只要它们遵循相同的网络协议,就能在网络上互相通信、交换数据。
2.1 协议分层
协议本质也是软件,在设计上为了更好的进行模块化、解耦合,也是被设计成为层状结构的。
在现实生活中,我们可以通过电话直接和朋友交流,因此我们认为“同层协议可以直接通信”;但本质上,同层协议之间没有直接通信,而是各自使用下层结构的能力,完成通信的,我们本质上是在对电话讲话。
通过上图我们发现,任何一层的变化,只要遵守协议,都不会影响其它层,所以分层可以实现解耦合,让软件维护的成本更低。
- OSI的七层模型
很多书中都讲了7层不好,其实在网络角度, OSI 定的协议7层模型其实非常完善,但是在实际操作的过程中,会话层、表示层是不可能接入到操作系统中的所以在工程实践中,最终落地的是 5 层协议。
- TCP/IP 五层(或四层)模型
TCP/IP 是一组协议的代名词,它还包括许多协议,组成了 TCP/IP 协议族。
TCP/IP 通讯协议采用了 5 层的层级结构, 每一层都呼叫它的下一层所提供的网络来完成自己的需求。
- 物理层:负责光/电信号的传递方式。比如现在以太网通用的网线(双绞 线)、 早 期以太网采用的的同轴电缆(现在主要用于有线电视)、 光纤,现在的 wifi 无线网使用 电磁波等都属于物理层的概念。 物理层的能力决定了最大传输速率、 传输距离、 抗 干扰性等。
集线器(Hub)工作在物理层。
- 数据链路层: 负责设备之间的数据帧的传送和识别。例如网卡设备的驱动、帧同步(就是说从网线上检测到什么信号算作新帧的开始)、 冲突检测(如果检测到冲突就 自动重发)、数据差错校验等工作。有以太网、令牌环网、无线 LAN 等标准。
交换机 (Switch)工作在数据链路层
.- 网络层: 负责地址管理和路由选择。 例如在 IP 协议中, 通过IP 地址来标识一台 主机,,并通过路由表的方式规划出两台主机之间的数据传输的线路(路由)。
路由器 (Router)工作在网路层.
- 传输层: 负责两台主机之间的数据传输。如传输控制协议 (TCP), 能够确保数据 可靠的从源主机发送到目标主机.
- 应用层: 负责应用程序间沟通, 如简单电子邮件传输(SMTP)、文件传输协 议(FTP)、 网络远程访问协议(Telnet) 等.
网络编程主要就是针对应用层
物理层我们考虑的比较少, 我们只考虑软件相关的内容,因此很多时候我们直接称为TCP/IP 四层模型。
- 对于一台主机,它的操作系统内核实现了从传输层到物理层的内容
- 对于一台路由器,它实现了从网络层到物理层
- 对于一台交换机,它实现了从数据链路层到物理层
- 对于集线器,它只实现了物理层
但是并不绝对,很多交换机也实现了网络层的转发;很多路由器也实现了部分传输层的内容(比如端口转发)。
- 重新理解协议
上面的内容,我们只是懂了一些基本概念,还是达不到我们的目标,下面我们再次重新理解协议和协议分层:
那么为什么要有 TCP/IP 协议?
- 首先,即便是单机,你的计算机内部,其实都是存在协议的,比如:其他设备和内存通信,会有内存协议。 其他设备和磁盘通信,会有磁盘相关的协议,比如:SATA, IDE, SCSI 等。 只不过我们感知不到罢了。 而且这些协议都在本地主机各自的硬件中,通信的成本、问题比较少。
- 其次,网络通信最大的特点就是主机之间变远了。 任何通信特征的变化,一定会带来新的问题,有问题就得解决问题,所以需要新的协议。
所以,协议产生的本质就是:解决因距离变远而产生的通信问题。
由于距离变远产生的问题多种多样,所以要将它们分类,即协议要分层。
2.2 OS与网络的关系
我们都清楚的知道,操作系统各式各样,没有标准,但是为什么不同的操作系统间能够通信呢?
那是因为不同的操作系统中,网络协议栈必须按照TCP/IP协议标准来实现,即不同操作系统使用的网络协议栈一样,那就可以通信了。
所以,操作系统与网络的关系是:操作系统内要实现网络相关的功能,即网络是操作系统的一个模块。
那么到底什么是协议呢?
既然操作系统要实现网络的功能,那OS内就会存在大量的协议,OS就要管理协议,所以协议本质就是数据结构,即结构体
。
问题: 主机 B 能识别 data, 并且准确提取 a=10, b=20, c=30 吗? 回答: 答案是肯定的!
因为双方都有同样的结构体类型 struct protocol。 也就是说,用同样的代码实现协议,用同样的自定义数据类型,天然就具有”共识“, 能够识别对方发来的数据,这不就是约定吗?
关于协议的朴素理解: 所谓协议, 就是通信双方都认识的结构化的数据类型(结构体)
3. 网络传输基本流程
3.1 局域网通信流程
首先,两台主机在同一个局域网,是否能够直接通信? - - 是的,一台主机发送的信息,在同一个局域网下的所有主机,都可以收到。
只不过在局域网上的每台主机,要有唯一的标识来保证主机的唯一性: mac 地址。
mac地址长度为 48 位,即 6 个字节。一般用 16 进制数字加上冒号的形式来表示(例如:08:00:27:03:fb:19), 在网卡出厂时就确定了,不能修改,mac 地址通常是唯一的。
以太网中,任何时刻,只允许一台机器向网络中发送数据
- 如果有多台同时发送, 会发生数据干扰, 我们称之为数据碰撞
所以,以太网就是临界资源,是互斥访问的,但是不加锁,先用,碰撞出错了再说。
- 所有发送数据的主机要进行
碰撞检测
和碰撞避免
(以保证数据读写的原子性)- 没有交换机的情况下,一个以太网就是一个碰撞域
- 局域网通信的过程中,
主机对收到的报文确认是否是发给自己的,是通过目标mac地址在数据链路层判定的,若不是,直接丢弃,上层用户感知不到。
在数据传输时,要经过每一层;其中每层都有协议,所以当我进行数据传输流程的时候,要进行封装和解包
报头部分(对应协议层的结构体字段)我们一般叫做报头;除了报头 剩下的叫做有效载荷,故报文 = 报头 + 有效载荷。
我们在明确一下不同层的完整报文的叫法,不同的协议层对数据包有不同的称谓
- 在传输层叫做段(segment),
- 在网络层叫做数据报 (datagram),
- 在链路层叫做帧(frame).
- 应用层数据通过协议栈发到网络上时,每层协议都要加上一个数据首部,称为封装。
- 首部信息中包含了一些类似于首部有多长,载荷有多长,上层协议是什么等信息。
- 数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部,根据首部中的 “上层协议字段” 将数据交给对应的上层协议处理。
从今天开始, 我们学习任何协议, 都要先宏观上建立这样的认识:
- 要学习的协议,是如何做到解包的? 只有明确了解包, 封包也就能理解
- 要学习的协议,是如何做到将自己的有效载荷, 交付给上层协议的?
3.2 跨网络通信流程
IP 地址是在 IP 协议中,用来标识网络中不同主机的地址。
- 对于 IPv4 来说, IP 地址是一个 4 字节,32 位的整数;
- 我们通常也使用 “点分十进制” 的字符串表示 IP 地址,例如 192.168.0.1;用点分割的每一个数字表示一个字节,范围是 0 - 255;
可是,你们又没有一个疑问:上面我们所说的mac地址也能唯一标识一个主机,但二者有什么区别呢?
唐僧每经过一个城池,都会询问国王下一个地方它去哪,它给国王说的是:我从长安来,到西天去。
国王根据唐僧的目的地,告诉唐僧距离当前位置最近的下一个城池。
所以:
- IP地址是最终目标,用来标识整个网络中标识唯一性。
- MAC地址是近期目标,用来标识主机在局域网中的唯一性。其受IP地址影响,根据路由器完成mac地址的切换(mac地址仅在局域网中有效)
跨网段的主机的数据传输,数据从一台计算机到另一台计算机传输过程中要经过一个或多个路由器。
为什么要去目标主机, 先要走路由器?
结合封装与解包,体现路由器解包和重新封装的特点
提炼 IP 网络的意义和网络通信的宏观流程
所以,IP网络层存在的意义:提供网络虚拟层,让世界的所有网络都是IP网络,屏蔽最底层网络的差异。
4. Socket 编程预备
4.1 理解源IP地址和目的IP地址
IP 在网络中,是用来标识主机的唯一性的,可以通过IP传输数据。
但是这里要思考一个问题: 数据传输到主机是目的吗? 不是的,因为数据是给人用的。
- 比如: 聊天是人在聊天,下载是人在下载,浏览网页是人在浏览?
- 但是人是怎么看到聊天信息的呢? 怎么执行下载任务呢? 怎么浏览网页信息呢?通过启动的 qq,迅雷,浏览器。
- 而启动的 qq,迅雷,浏览器都是进程。 换句话说,进程是人在系统中的代表,只要把数据给进程,人就相当于就拿到了数据。
所以: 数据传输到主机不是目的,而是手段。 到达主机内部,再交给主机内的进程,才是目的。
那么,源IP地址和目的IP地址是干什么的呢?- - 解决两端主机的唯一性
。
但是系统(主机)中,同时会存在非常多的进程, 当数据到达目标主机之后,怎么转发给目标进程呢?
4.2 端口号与Socket
端口号(port)是传输层协议的内容。
- 端口号是一个2字节16 位的整数
端口号用来标识一个进程
,告诉操作系统,当前的这个数据要交给哪一个进程来处理- 一个端口号只能被一个进程占用
端口号范围划分
0 - 1023
:知名端口号, HTTP、FTP、SSH 等这些广为使用的应用层协议,他们的端口号都是固定的。1024 - 65535
:操作系统动态分配的端口号,客户端程序的端口号就是由操作系统从这个范围分配的。
我们之前在学习系统编程的时候,学习了 pid 表示唯一一个进程;此处我们的端口号也是唯一表示一个进程,那么这两者之间是怎样的关系?
- 进程 PID 属于系统概念,技术上也具有唯一性,确实可以用来标识唯一的一个进程, 但是这样做, 会让系统进程管理和网络强耦合,实际设计的时候, 并没有选择这样做。
- 另外,一个进程可以绑定多个端口号, 但是一个端口号不能被多个进程绑定
传输层协议(TCP 和 UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号,就是在描述 "数据是谁发的,要发给谁"
;
那么, IP 地址 + 端口号就能够标识网络上的某一台主机的某一个进程,所以网络通信的本质是:进程间通信,网络就是它们看到的同一份资源,我们把IP 地址 + 端口号叫做Socket(套接字)
4.3传输层的典型代表
如果我们了解了系统,也了解了网络协议栈,我们就会清楚,传输层是属于内核的,那么我们要通过网络协议栈进行通信,必定调用的是传输层提供的系统调用,来进行的网络通信。
认识 TCP 协议,此处我们先对 TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识
- 传输层协议
- 有连接
可靠传输
- 面向字节流
认识 UDP 协议,此处我们也是对 UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识;
- 传输层协议
- 无连接
不可靠传输
- 面向数据报
4.4 网络字节序
我们知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,大端与小端机器通信,要怎么办呢?
所以,必须在网络中将大小端规定好!
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
- 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
- 因此,
网络数据流的地址应这样规定:先发出的数据是低地址(低权值位),后发出的数据是高地址(高权值位)
。- TCP/IP协议规定:网络数据流应采用大端字节序,即低地址处是高权值位。
- 不管这台主机是大端机还是小端机,都会按照这个 TCP/IP 规定的网络字节序来发送/接收数据;
- 如果当前发送主机是小端,就需要先将数据转成大端;否则就忽略,直接发送即可
因此,网络规定:所有发送到网络上的数据,都必须是大端的
为使网络程序具有可移植性,使同样的 C 代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
这些函数名很好记,h表示host,n表示network,l 表示32位长整数(常用来
转ip
),s表示16位短整数(常用来转port
)。
- 例如 htonl表示将 32 位的长整数从主机字节序转换为网络字节序,例如将 IP 地 址转换后准备发送。
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
- 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
5. socket 编程接口
5.1 介绍
// 创建 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、本地socket(域间socket)、原始socket。
OS为了提供一个统一的系统调用接口,设计了sockaddr 的结构体
用户具体使用什么类型的socket不管,只要你传进来的指针是sockaddr类型的;OS内部为了区分是网络还是本地,在这三个结构中,大家的前两个字节用来标识地址的类型。 (这在C++中不就是继承与多态嘛)
5.2 案例演示
下面使用一个简单的回显服务器和客户端代码,演示一些UDP中常用的接口
服务器端:
- 创建套接字
该系统调用的返回值是一个文件描述符,所以创建socket本质是创建了一个文件。
// 1. 创建UDP网络socket
int _socket = ::socket(AF_INET, SOCK_DGRAM, 0);
- 绑定端口号
在绑定socket之前,应初始化一下socket;但在上面我们说了,socket的种类很多,我们这里使用网络socket为例,所以应该先创建一个sockaddr_in类型的socket。
在初始化sockaddr_in之前,需要注意port与IP的网络字节序问题
struct sockaddr_in inetSocket;
bzero(&inetSocket,sizeof(inetSocket)); //sockaddr_in全部清零,也可用memset
inetSocket.sin_family = AF_INET;
inetSocket.sin_port = ::htons(_port); //端口-->网络字节序
inetSocket.sin_addr.s_addr = ::inet_addr(_ip.c_str()); //string -->4字节-->网络字节序
绑定
// 3. 设置套接字进入内核
int n = ::bind(_socket,(sockaddr*)&inetSocket,sizeof(inetSocket));
- 接收信息
struct sockaddr_in fromUser;
socklen_t len = sizeof(fromUser);
char buffer[1024];
int n = ::recvfrom(_socket,&buffer,sizeof(buffer),0,(sockaddr*)(&fromUser),&len);
- 发送信息给指定socket
::sendto(_socket,echoString.c_str(),echoString.size(),0,(sockaddr*)(&fromUser),len);
Server完整代码:
//Common.hpp
#pragma once
#include <stdlib.h> //exit
#define Die(code) \
do \
{ \
exit(code); \
} while (0)
#define CONVERSE(src) (struct sockaddr*)(src)
enum exitCode
{
SOCKET_ERR = 1,
BIND_ERR
};
#pragma once
#include <memory>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
#include "Common.hpp"
const static int g_socket = -1;
const static int g_port = 8080;
const static std::string g_default_ip = "127.0.0.1";
using namespace MyLogModule;
class UdpServer
{
public:
UdpServer(uint16_t port = g_port, std::string ip = g_default_ip)
: _socket(g_socket)
, _port(port)
, _ip(ip)
, _isRunning(false)
{
}
~UdpServer()
{
}
void Init()
{
// 1. 创建网络socket
_socket = ::socket(AF_INET, SOCK_DGRAM, 0);
if (_socket == -1)
{
LOG(FATAL) << "socket create fail";
Die(SOCKET_ERR);
}
LOG(NORMAL) << "file fd:" << _socket;
// 2. 向套接字中填充网络信息
struct sockaddr_in inetSocket;
bzero(&inetSocket, sizeof(inetSocket)); // sockaddr_in全部清零,也可用memset
inetSocket.sin_family = AF_INET;
inetSocket.sin_port = ::htons(_port); // 端口-->网络字节序
inetSocket.sin_addr.s_addr = ::inet_addr(_ip.c_str()); // string -->4字节-->网络字节序
// 3. 设置套接字进入内核
int n = ::bind(_socket, CONVERSE(&inetSocket), sizeof(inetSocket));
if (n == -1)
{
LOG(FATAL) << "bind fail";
Die(BIND_ERR);
}
LOG(NORMAL) << "bind success";
}
void Start()
{
if (!_isRunning)
{
_isRunning = true;
while (true)
{
struct sockaddr_in fromUser;
socklen_t len = sizeof(fromUser);
char buffer[1024];
int n = ::recvfrom(_socket, &buffer, sizeof(buffer), 0, CONVERSE(&fromUser), &len);
if (n > 0)
{
buffer[n] = 0;
// 接收成功,显示消息内容,谁发来的
uint16_t clientPort = ::ntohs(fromUser.sin_port); // 网络字节序-->端口
std::string clientIp = ::inet_ntoa(fromUser.sin_addr); // 网络字节序-->4字节-->字符串
std::string message = std::to_string(clientPort) + ":" + clientIp + "#:";
message += buffer;
LOG(DEBUG) << message;
std::string echoString = "echo say#";
echoString += buffer;
// 将消息在回显给发送方
::sendto(_socket, echoString.c_str(), echoString.size(), 0, CONVERSE(&fromUser), len);
}
}
}
_isRunning = false;
}
private:
int _socket;
int16_t _port; // 端口
std::string _ip; // ip地址
bool _isRunning;
};
客户端
#ifndef __UpdClient__hpp__
#define __UpdClient__hpp__
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
#include <memory>
#include <string>
using namespace MyLogModule;
class UdpClient
{
public:
UdpClient(std::string ip, std::string port)
{
_port = stoi(port.c_str());
_ip = ip;
}
~UdpClient()
{
}
void Start()
{
_socket = ::socket(AF_INET, SOCK_DGRAM, 0);
if (_socket == -1)
{
LOG(FATAL) << "socket create fail";
Die(SOCKET_ERR);
}
// 转换为网络字节序,并设置
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_addr.s_addr = ::inet_addr(_ip.c_str());
server.sin_port = ::htons(_port);
while(true)
{
std::string message;
std::cout << "Please enter# ";
std::getline(std::cin,message);
//客户端也要有自己的ip和port,但不需要绑定,因为OS会自动bind
//服务端要显示的bind,是因为服务器的端口号,必须稳定!必须是众所周知且不能改变轻易改变的!
::sendto(_socket,message.c_str(),message.size(),0,CONVERSE(&server),sizeof(server));
char buffer[1024];
//虽然上面已经有了sockaddr_in server,但是可能不只有一个服务器哦
struct sockaddr_in tmp;
socklen_t len = sizeof(tmp);
int n = ::recvfrom(_socket,buffer,sizeof(buffer)-1,0,CONVERSE(&tmp),&len);
if(n > 0)
{
buffer[n] = 0;
//打印服务器回写的
std::cout << buffer << std::endl;
}
}
}
private:
int _socket;
uint16_t _port;
std::string _ip;
};
#endif
clientMain.cc
#include "Common.hpp"
#include "UdpClient.hpp"
int main(int argc,char* argv[])
{
if(argc != 3)
{
std::cerr << "usage fail " << "Usage:" << argv[0] << "serverIp serverPort" << std::endl;
Die(USE_ERR);
}
//使用ip与port构建UdpClient对象的指针
std::shared_ptr<UdpClient> c_ptr = std::make_shared<UdpClient>(argv[1],argv[2]);
c_ptr->Start();
return 0;
}
至此,两主机就可完成网络通信了。
查看网络服务是否启动的命令:netstat
- u:udp
- a: all
- p:pid/program
- n:num(能显示成数字的则显示)
但是当前的程序还有一个问题,那就是如果服务器上有多张网卡,那就有多个ip地址,但是server默认的ip是127.0.0.1,这样就会导致server无法收到其它ip所搜到的信息。
因此,需要将server的ip设置为INMADDR_ANY
将server中涉及ip的位置都修改就可以了
但是上面的代码还有点不优雅,特别是涉及ip地址与端口号那里,下面我们修改一下,直接将ip与port封装成一个对象。
#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
#include "Common.hpp"
class InetAddr
{
private:
void portNet2Host()
{
_port = ::ntohs(_net_addr.sin_port);
}
void ipNet2Host()
{
// 此种方式inbuffer是局部的,不会造成问题
char inbuff[64];
_ip = ::inet_ntop(AF_INET, &_net_addr.sin_addr, inbuff, sizeof(inbuff));
}
public:
InetAddr()
{}
InetAddr(const struct sockaddr_in &addr)
: _net_addr(addr)
{
portNet2Host();
ipNet2Host();
}
// 供服务端创建自己的
InetAddr(uint16_t port)
: _port(port), _ip("")
{
_net_addr.sin_family = AF_INET;
_net_addr.sin_port = ::htons(_port);
_net_addr.sin_addr.s_addr = INADDR_ANY;
}
struct sockaddr *Getaddr() { return CONVERSE(&_net_addr); }
socklen_t Getlen() { return sizeof(_net_addr); }
uint16_t getPort() { return _port; }
std::string getIP() { return _ip; }
struct sockaddr_in getAddr() { return _net_addr; }
~InetAddr()
{}
private:
struct sockaddr_in _net_addr;
uint16_t _port;
std::string _ip;
};
效果:
TCP接口介绍:
// 绑定端口号 (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);
listen
:函数的第二个参数,表示最多允许有 backlog 个客户端处于连接等待状态,如果接收到更多的连接请求就忽略,这里设置不会太大。
accept
:
与UDP不同的是:tcp没有recvfrom与sendto方法,它直接使用read 和write;
connect
:
- 客户端需要调用 connect()连接服务器;
- connect 和 bind 的参数形式一致,区别在于 bind 的参数是自己的地址, 而
connect 的参数是对方的地址
。