一、预备知识
1.1 理解源IP地址和目的IP地址
在IP数据报的头部中,有两个IP地址,分别叫做源IP地址和目的IP地址。
源IP地址和目的IP地址是网络通信中常用的两个概念,他们代表了通信中的两个节点。
源IP地址是指发起通信的节点的IP地址,它标识了通信的发送方。在网络通信中,源IP地址用于识别数据包的来源,以便目的节点能够将恢复发送给正确的位置。
目的IP地址是指接收通信的节点的IP地址,它标识了通信的接收方。在网络通信中,目的IP地址用于指定数据包的目的地,以便网络设备能够将数据包传递到正确的位置。
1.2 认识端口号
- 我们上网无非只有两个动作:1.把远处的数据拉取到本地;2.把本地的数据发送到远端
- 大部分的网络通信方式都是用户触发的,在计算机中,我们使用进程来表示用户:客户端服务,服务器服务
- 把数据发送到目标主机中,不是目的,是手段;真正的目的是将数据发送到目标主机中的某一个服务(进程)
- 网络通信的本质:其实是进程在帮我们进行网络通信,无论对于客户端还是服务器
- IP(唯一的一台主机) + port(该主机上的唯一的一个进程) = 互联网中唯一的一个进程
- 客户端进程 = 客户端IP + 客户端port = 客户端是互联网中唯一的一个进程;服务器进程 = 服务器IP + 服务器port = 服务器是互联网中唯一的一个进程
网络通信的本质实际上是进程间的通信。
端口号是传输层协议的内容:
- 端口号是一个2字节16位的整数
- 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理
- IP地址 + 端口号能够标识网络上的某一个主机的某一个进程
- 一个端口号只能被一个进程占用
1.3 理解端口号和进程ID
我们之前在学习系统编程的时候,学习了pid标识唯一一个进程,此处我们的端口号也是唯一的表示一个进程。
我们可不可以用进程ID来代替端口号?不可以,因为pid是随机改变的,每一次调用的时候,pid的值都会进行改变,但是常用的端口号是不会进行改变的。在操作系统中,每一个进程都有pid,但不是每一个进程都有port。
最后,一个进程可以对应多个端口,而一个端口只能对应一个进程,不能被多个进程绑定。
1.4 理解源端口号和目的端口号
传输层协议(TCP和UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号,就是在描述“数据是谁发的,要发给谁“。
1.5 认识TCP协议
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
1.6 认识UDP协议
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
1.7 网络字节序
我们在C语言学习中,内存中的多字节数据相对于内存地址由大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端和小端之分。网络数据流同样有大端和小端之分,那么如何定义网络数据流的地址呢?
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出
- 接收主机把从网络中接收到字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序中保存的
- 因此,网络数据流的地址应这样规定,先发出的数据是低地址,后发出的地址是高地址
- 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位的长整数从主机字节序中转换为网络字节序
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换,然后返回
- 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回
二、socket编程接口
2.1 socket 常见的 API
2.2 sockaddr 结构
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,比如IPv4和IPv6,以及之后的UNIX Domain Socket,然而,各种网络协议的地址并不相同。
- IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址使用sockaddr_in结构体表示,包括16位端口号和32位IP地址。
- IPv4和IPv6类型分别定义为常数AF_INET、AF_INET6。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。
- socket API可以都用struct sockaddr* 类型表示,在使用的时候需要强制类型转换成sockaddr_in;这样的好处是程序的通用性,可以接收IPv4、IPv6以及UNIX Domain Socket各种了信息港的sockaddr结构体指针作为参数。
2.3 socket 结构体结构
2.3.1 sockaddr 结构
struct sockaddr
{
__SOCKADDR_COMMON (sa_);
char sa_addr[14];
};
2.3.2 sockaddr_in 结构
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port;
struct in_addr sin_addr;
unsigned char sin_zero[sizeof(struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr_t)];
};
2.3.3 in_addr 结构
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
三、简单的UDP网络程序
3.1 服务器:
3.1.1 初始化服务器
3.1.1.1 创建套接字
在服务端中,我们需要先来创建一个套接字(文件描述符),这一步是必须创建的,在创建套接字之前,我们先来学习一下socket函数。
3.1.1.1.1 socket函数
函数的原型:
函数的功能:
用于创建套接字
函数的参数:
- Domain:该参数表示套接字使用的协议簇,协议簇在:Linux/socket.h“中详细的定义,常用的协议簇有:
- AF_UNIX(本地通信)
- AF_INET(TCP/IP——IPv4)
- AF_INET6(TCP/IP——IPv6)
- type:该参数指的是套接字了信息港,常用的类型是:
- SOCK_STREAM(TCP流)
- SOCK_DGRAM(UDP数据报)
- SOCK_RAW(原始套接字)
- protocol:该参数在使用时,一般都置为0,就是说在已经确定套接字使用的协议簇和类型时,这个参数的值就为0。但是有时候创建初始套接字时,在domain参数未知情况下,即使不清楚协议类型时,protocol参数可以用来确定协议的种类。
函数的返回值:
- 当套接字创建成功时,返回套接字的文件描述符
- 失败返回“-1”,错误代码则写入“errno”中
因此,我们根据socket函数,我们可以写出一下代码:
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0) // 如果创建套接字失败
{
exit(1);
}
// 如果创建套接字成功之后,我们需要继续进行
3.1.1.2 填充struct sockaddr_in结构体
我们在绑定服务器之前,我们需要知道服务器的IP地址和端口号,因为需要让客户端知道服务端的位置。我们在用户栈中创建出struct sockaddr_in结构体,在创建结构体之后,我们需要进行清零操作。在清零操作的时候,我们可以了解一个新函数:bzero函数。之后我们需要了解一个结构体内的类型:总共有四个类型。之后,我们需要填充这四个类型的变量。
3.1.1.2.1 bzero函数
函数的原型:
函数的功能:该函数可以将内存块(字符串)中的前n个字节清零
函数的参数:
- s:指向所要清零的内存(字符串)的地址的指针
- n:该参数为需要清零的字节数
3.1.1.2.2 struct sockaddr_in结构体类型
在这个结构体类型中,总共有四种类型:sin_addr,sin_family,sin_port,sin_zero。在这四种类型中,我们可以进行一一对应。
- sin_addr:对应的是32位IP地址,我们需要将字符串类型的IP地址转换为四字节整形的IP地址,我们需要将其填充进行结构体中
- sin_family:对应的是我们使用哪种协议簇,在Linux中,我们一般使用AF_INET
- sin_port:对应的是16位端口号(使用网络字节序)
- sin_zero:是为了让sockaddr与sockaddr_in两种数据结构保持大小相同而保留的空字节
但是,我们知道,现在是在用户栈中创建的结构体类型,这一步只是填充结构体类型,我们还没有进行绑定操作。
在套接字通信的时候,我们需要将服务器的IP地址和端口号进行过绑定,但是,我们需要先进行获取端口号和IP地址。在获取端口号和IP地址时,我们需要关心网络字节序,因为这些数据是要发送到网络中,我们也需要通过网络来进行接收这些数据。在基础知识中,我们讲解了有关网络字节序的知识,知道网络中的存储方式是大端存储,所以我们需要将主机序列转换成网络序列。
3.1.1.2.3 如何将字符串类型的IP地址转换为四字节整形类型的IP地址?
首先来看,如何将四字节整形类型的IP地址转换为字符串类型的IP地址:我们可以使用一个结构体来创建IP地址,结构体类型中的内容是四个整形类型的整数,之后,我们将四字节整形的IP地址进行强制类型转换,然后利用结构体中的成员,然后利用将整形转换成字符串的函数进行转换,然后将四个子串进行拼接起来。
其次,如何将字符串类型的IP地址转换为四字节整形类型的IP地址:我们需要使用substr函数将字符串进行剪切出四个子串,然后将四个子串转换为整形类型,最后将四个整形组合成IP的结构体。
但是,在系统中,我们不需要这么麻烦进行转换,系统中会提供一些接口来帮助我们完成这些任务:
inet_addr函数
函数的原型:
函数的功能:
将点分十进制字符串类型的IP地址转换成四字节整形的IP地址,并转换成网络字节序列
函数的参数:
- cp:指向所要转换的字符串的IP地址的指针
函数的返回值:
- 如果正确执行将返回一个无符号长整数型数
- 如果传入的字符串不是一个合法的IP地址,将返回INADDR_NONE
inet_ntoa函数
函数的原型:
函数的功能:
将网络地址转换为点分十进制格式的字符串类型
函数的参数:
- in:存放有IP地址的结构体类型
函数的返回值:
- 该函数的返回值是一个字符串,这个字符串是点分十进制的IP地址。
在上述工作完成后,我们就可以进行填充sockaddr_in结构体类型了,代码如下:
struct sockaddr_in local; // 在用户栈上创建
bzero(&local, sizeof local); // 将结构体清空
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = inet_addr(_ip.c_str());
3.1.1.3 绑定服务器
因为服务器是要被客户端进行访问,所以我们需要知道服务器的IP地址和端口号,这样我们客户端就可以根据IP地址和端口号来访问服务器。
bind函数
函数的原型:
函数的功能:
服务端用于将把用于通信的地址和端口号绑定到socket上。
函数的参数:
- sockfd:需要绑定的socket
- addr:需要传递addr_in结构体,存放服务端用于通信的地址和端口
- addrlen:表示addr结构体的大小
函数的返回值:
- 成功则返回0 ,失败返回-1,错误原因存于 errno 中。
- 如果绑定的地址错误,或者端口已被占用,bind 函数一定会报错,否则一般不会返回错误。
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof local);
if(n < 0)
{
exit(1);
}
3.1.2 启动服务器
根据常识,我们可以得知,服务器是需要死循环的:服务器会一直运行,直到管理者不想运行。所以在启动服务器中,我们需要使用死循环来进行。
3.1.2.1 接收数据
我们服务器在接收数据时,我们需要知道是哪一个客户端发送数据给服务器的,还要了解一下接收函数。因此,我们需要一个缓冲区来存储我们所接收到的数据,创建一个struct sockaddr_in结构体来存储客户端的IP地址和端口号。
recvfrom函数
函数的原型:
函数的功能:
recvfrom()
函数是一个系统调用,用于从套接字接收数据。该函数通常与无连接的数据报服务(如 UDP)一起使用,但也可以与其他类型的套接字使用。与简单的recv()
函数不同,recvfrom()
可以返回数据来源的地址信息。函数的参数:
- sockfd:一个已经打开的套接字的文件描述符
- buf:一个指针,指向用于存放接收到的数据的缓冲区
- len:期望收到的数据的大小,缓冲区的大小(以字节为单位)
- flags:控制接收行为的标志。通常可以设置为0,但以下是一些可用的标志:
- MSG_WAITALL:尝试接收全部请求的数据,函数可能会阻塞,直到收到所有数据。
- MSG_PEEK:查看即将接收的数据,但不从套接字缓冲区中删除它。
- 其他一些标志还可以影响函数的行为,但在大多数常规应用中很少使用
- src_addr:输出型参数。一个指针,指向一个sockaddr结构,用于保存发送数据的源地址
- addrlen:输出型参数。一个值。它应该设置为src_addr缓冲区的大小。当recvform()函数返回时,该值会被修改为实际地址的长度(以字节为单位)。
函数的返回值:
- 在成功的情况下,recvfrom()函数返回接收到的字节数
- 如果没有数据可读或者套接字已经关闭,那么返回值为0
- 出错时,返回-1,并设置全局变量errno以指示错误类型
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof peer;
ssize_t n = recvfrom(_sockfd, buffer, sizeof buffer - 1, 0,
(struct sockaddr*)&peer, &len);
if(n > 0)
{
buffer[n] = 0;
}
3.1.2.2 发回数据
在接收数据后,我们可以收到客户端发送的数据,和客户端的IP地址和端口号。我们可以利用sendto函数将接收到的数据发送回去。
sendto函数
函数的原型:
函数的功能:
sendto()
函数是一个系统调用,用于发送数据到一个指定的地址。它经常与无连接的数据报协议,如UDP,一起使用。不像send()
函数只能发送数据到一个预先建立连接的远端,sendto()
允许在每次发送操作时指定目的地址。函数的参数:
- sockfd:一个已经打开的套接字的文件描述符
- buf:一个指针,指向要发送的数据的缓冲区
- len:要发送数据的大小(以字节为单位)
- flags:控制发送行为的标志。通常可以设置为0,一些可用的标志包括:
- MSG_CONFIRM:在数据报协议下告诉网络层该数据已经被确认了
- MSG_DONTROUTE:不查找路由,数据报将只发送到本地网络
- 其他标志可以影响函数的行为,但再大多数常规应用中很少使用
- dest_addr:指向sockaddr结构的指针,该结构包含目标地址和端口信息
- addrlen:dest_addr缓冲区的大小(以字节为单位)
函数的返回值:
- 成功时,sendto()返回实际发送的字节数
- 出错时,返回-1,并设置全局变量errno以指示错误类型
sendto(_sockfd, buffer, strlen(buffer) - 1, 0, (struct socket *)&peer, len);
3.1.2.3 一些知识点
3.1.2.3.1 netstart指令
netstat指令:-anup 查看网络服务,选项:a:表示all,所有网络服务显示出来;u:表示的是UDP;n:number,能把能显示出数字,全部显示出数字;p:表示进行process,选项顺序无关。
3.1.2.3.2 本地循环地址
127.0.0.1 本地环回,可以实现本地通信,常用于进行代码测试。我们在绑定服务器的IP地址时,不可以绑定一个确定的公网IP,因为在一个计算机上会有好几个不同的IP地址,我们可以根据不同的IP地址访问同一个端口,所以,我们强烈不推荐绑定公网IP,因此,我们最好绑定0地址。
3.3 将服务器代码进行拼接(完整代码)
#pragma once
#include <iostream>
#include <string>
#include <errno.h>
#include <cstring>
#include <stdlib.h>
#include <strings.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include "Log.hpp"
#include "InetAddr.hpp"
// 设置错误码
enum
{
SOCKET_ERROR = 0,
USAGE_ERROR
};
const static int defaultfd = -1;
class UdpServer
{
public:
UdpServer(uint16_t port)
: _sockfd(defaultfd), _port(port), _isrunning(false)
{
}
// 初始化服务器 需要IP地址和端口号
void initUdpServe()
{
// 文件描述符 套接字类型 TCP/UDP
// 1. 创建对应的套接字——必须要做的
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0) // 如果打印失败
{
LOG(FATAL, "error: %s, error_num: %d\n", strerror(errno), errno);
exit(SOCKET_ERROR);
}
LOG(INFO, "socket success, socket: %d\n", _sockfd);
// 2.0 填充sockaddr_in结构体
// 用户栈上
struct sockaddr_in local; // 系统提供的数据类型,local是一个变量,在用户栈上开辟空间
bzero(&local, sizeof local); // 进行清零操作
local.sin_family = AF_INET;
local.sin_port = htons(_port); // port要经过网络传输到对面,先到网络,将主机序列转换为网络序列
// 字符串风格的IP地址转换为点分十进制的IP地址
// 主机序列转换为网络序列
// in_addr_t inet_addr(const char* cp);
// local.sin_addr.s_addr = inet_addr(_ip.c_str()); // IP地址 198.128.0.3 将字符串的形势转换为4字节的IP地址
local.sin_addr.s_addr = INADDR_ANY;
// 2. 绑定服务器 ———— 端口号和IP地址
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0) // 绑定失败
{
LOG(FATAL, "error: %s, error_num: %d\n", strerror(errno), errno);
exit(SOCKET_ERROR);
}
LOG(INFO, "socket bind success\n");
}
// 启动服务器
void Start()
{
// 一直运行,直到管理者不想运行.服务器都是死循环
// UDP是面向数据报的协议
_isrunning = true;
while (true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof peer;
// 1. 我们需要先收到数据
ssize_t n = recvfrom(_sockfd, buffer, sizeof buffer - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
InetAddr addr(peer);
buffer[n] = 0;
LOG(DEBUG, "get message from [%s : %d]: %s\n", addr.Ip().c_str(), addr.Port(), buffer);
}
// 2. 我们将收到的数据发回
// sendto ---
sendto(_sockfd, buffer, strlen(buffer), 0, (struct sockaddr *)&peer, len);
}
_isrunning = false;
}
~UdpServer()
{
}
private:
int _sockfd; // 创建的套接字,文件描述符
uint16_t _port; // 服务器所用的端口号
// std::string _ip; // 暂时这样写,这个地方不是必须的
bool _isrunning;
};
3.2 客户端:
客户端的代码就比较简单了,有两个步骤:首先创建套接字,然后直接进行通信。
3.2.1 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
}
3.2.2 直接进行通信
在直接通信之前,我们需要思考一个小知识点:(客户端需不需要绑定端口号)客户端需不需要显式地绑定端口号?在进行服务器端的编写时,我们需要将服务器与端口号进行绑定。客户端需要绑定,但不是显式地绑定。当UDP客户端首次发送数据时,操作系统会自动地绑定。一个端口号只能与一个进程相关联。
发送数据:
我们需要先将服务端的IP地址和端口号填充到sockaddr_in结构体中,然后直接进行通信:
std::string message;
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(serverip.c_str());
std::string message;
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(serverip.c_str());
接收数据:
struct sockaddr_in peer;
socklen_t len = sizeof peer;
char buffer[1024];
ssize_t n = recvfrom(sockfd, buffer, sizeof buffer - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
buffer[n] = 0;
std::cout << "server echo#" << buffer << std::endl;
}
3.2.3 完整的代码
#include <iostream>
#include <cstring>
#include <stdio.h>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
void Usage(std::string proc)
{
std::cout << "Usage: \n\t"
<< proc << "local_ip local_port\n"
<< std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
// 服务器的ip地址和端口号是需要客户端知道的
std::string serverip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr << "socket error" << std::endl;
}
// 2. 客户端要不要bind?客户端是要绑定的,因为客户端要有自己的IP和port,要不要显示的bind
// 客户端不需要用显示的绑定,如何bind;当UDP客户端首次发送数据时,OS会自动的绑定
// 什么时候绑定的? 首次发送数据时,需要进行绑定
// 一个端口号只能与一个进程相关联
std::string message;
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(serverip.c_str());
// 2. 直接进行通信
while (true)
{
std::cout << "Place enter:";
std::getline(std::cin, message);
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof server);
struct sockaddr_in peer;
socklen_t len = sizeof peer;
char buffer[1024];
ssize_t n = recvfrom(sockfd, buffer, sizeof buffer - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
buffer[n] = 0;
std::cout << "server echo#" << buffer << std::endl;
}
}
return 0;
}
四、服务器中接收的数据是谁发送的
#pragma once
#include <iostream>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
class InetAddr
{
private:
void GetAddress(std::string *ip, uint16_t *port)
{
*port = ntohs(_addr.sin_port);
*ip = inet_ntoa(_addr.sin_addr);
}
public:
InetAddr(const struct sockaddr_in &addr) : _addr(addr)
{
GetAddress(&_ip, &_port);
}
std::string Ip()
{
return _ip;
}
uint16_t Port()
{
return _port;
}
~InetAddr()
{
}
private:
struct sockaddr_in _addr;
std::string _ip;
uint16_t _port;
};