目录
[1.1 如何通信](#1.1 如何通信)
[1.2 端口号详解](#1.2 端口号详解)
[1.3 理解套接字socket](#1.3 理解套接字socket)
[2. 网络字节序](#2. 网络字节序)
[3. socket接口](#3. socket接口)
[3.1 socket类型设计](#3.1 socket类型设计)
[3.2 socket函数](#3.2 socket函数)
[3.3 bind函数](#3.3 bind函数)
[4. UDP通信协议](#4. UDP通信协议)
[4.1 UDP服务端类](#4.1 UDP服务端类)
[4.2 Udp服务类InitServer函数](#4.2 Udp服务类InitServer函数)
[4.3 Udp服务类Start函数](#4.3 Udp服务类Start函数)
[4.4 Udp服务主函数](#4.4 Udp服务主函数)
[4.5 Udp客户端编写](#4.5 Udp客户端编写)
[4.6 运行结果](#4.6 运行结果)
往期博客粗粒度讲解TCP/IP协议的内容,包含对IP地址讲解:
1.IP地址和端口号
1.1 如何通信
不管是任何主机都是遵守TCP/IP协议,可以大致分为四层,分别是应用层、传输层、网络层和数据链路层。一般用户打开的进程在应用层。
- 当张三使用主机打开许多进程,如抖音进程,浏览器进程和微信进程。其中张三使用微信进程时,会向微信的服务器发送信息。
- 在全网中,IP地址可以用来标识主机,使一台主机具有唯一性。但是,数据不止要千里迢迢传输到微信服务器的主机上,还要将数据传输到服务器主机上特定的进程,由该进程处理数据,再返回给客户端主机。
- 因此,传输数据不是目的,而是手段,处理数据才是目的。端口号就是用来标识一台主机上的某个进程。

IP地址由IPv4和IPv6两个版本,主要讲解IPv4。IPv4版本中,IP地址是由32位二进制组成,占4个字节的整数。通常使用点分十进制表示,如:192.168.2.1。
端口号是一个2字节,16二进制组成的整数。通常一个主机上,一个端口号只能被一个进程占用。
IP地址+端口号就可以表示全网中某一台主机上的一个进程。
1.2 端口号详解
端口号是一个2字节,16二进制组成的整数。通常一个主机上,一个端口号只能被一个进程占用。
- 0 - 1023是知名端口号。HTTP,FTP,SSH,SMTP等应用层协议所占有,且它们的端口号都是固定的。
- 1024 - 65535端口号,可由操作系统动态分配给客户端程序。
当客户端通过网络发送信息给服务端,到达服务端主机的传输层。服务端主机会获取该消息的报头信息,获取端口号交给应用层中的进程。
假设操作系统会创建一个端口号哈希表,哈希表中映射了端口号和进程的pcb。操作系统获取目的端口号,就可以从哈希表中找到目的进程。进程中有文件描述符,以此找到文件缓冲区。操作系统再把收到的数据拷贝到缓冲区中,就可以交由进程处理数据。这是一种方案。

1.3 理解套接字socket
通过前面的讲述,我们知道IP+port可以表示全网中唯一的一个进程。
本质上,网络通信时两个进程代表人进行通信。而源IP地址、源端口号、目的IP地址和目的端口号,就可以标识全网中唯二的进程。
所以,我们将IP地址+port端口号,称之为socket,即套接字。
2. 网络字节序
多字节内存数据存储在内存中,会有存储顺序的问题。按照不同的存储顺序,可以分为大端字节序存储 和小端字节序存储,即大小端之分。
- 大端模式,低位字节内容保存在高地址处,高位字节内容保存在低地址处。小端模式则相反。
- 如把0x11223344写入到地址位0x1000处内存中,其中0x44是低位字节数据,0x11是高位字节数据。

源主机通常将发送缓冲区的数据按内存地址低到高的顺序发出。目标主机接收发送的数据,也是按照内存地址低到高的顺序进行接收。
- 如果大小端主机不做统一,可能发送数据是0x11223344,但是接收数据变为0x44332211,造成数据混乱。
- 所以,TCP/IP协议规定,网络数据流应采用大端低字节流,即低地址处存放高位字节内容。

上面的库函数可以做到网络字节序和主机字节序的相互转换。
- 四个库函数中,h表示主机,n表示网络,l表示32位长整数,s表示16位短整数。
- 其中htonl函数,可以将32位长整数从主机字节序转换成网络字节序,通常转换IP地址。而htons,转换的是16的短整数,用于端口号转换。
IP地址中,应用层通常以点分十进制展现,如"192.168.32.1"。但是在网络通信过程中,传输一个点分十进制的IP地址,需要十几个字符,字节数太大,所以IP地址一般会转换成一个32位的整数,大小才4字节。
3. socket接口
3.1 socket类型设计
socket套接字一般划分为三类。
- 网络socket,一般用于网络间通信。
- 本地socket,也称为unix 域间套接字,通常用于本地通信。
- 原始套接字,即raw socket,可以直接作用于网络层,不需要经过传输层的处理。
这么多套接字种类,如果各自做一套接口,必定有大量相似的代码。所以,设计者想设计一种类型,统一所有套接字种类。
如下图所示,sockaddr全称socket address,即套接字地址,是一个套接字结构体类型。
可是在C语言中不能直接支持类型的继承和多态,但是规定不管是种套接字类型,开头的字段是2字节大小的16位地址类型,表示该套接字的种类。这就是做到了类型的继承。
而在第一个地址类型字段后,sockaddr基类有14字节的数据。网络套接字sockaddr_in在16位地址类型后,还有16端口号和32位IP地址。unix域间套接字,作用于本地通信,在16地址类型后,还有108字节的路径名字段。
虽然其他套接字结构体可能与sockaddr结构体内部字段字节数不同,但是可以通过类型强制转换成sockaddr结构体类型,统一用于socket接口函数中。

下面是linux2.4.5内核源代码,sockaddr中第一字段是地址类型,而sa_family_t就是无符号16位短整数。

网络socket结构体是sockaddr_in,第二个字段也是个无符号16位短整数的端口号。第三个字段是IP地址,而in_addr结构体中,有一个无符号32位长整数。


3.2 socket函数

socket函数用于创建套接字,该函数有三个参数。第一个参数指定通信协议族,如果要进行网络通信其中AF_INET表示网络通信。
第二个参数是指定套接字协议,传输层协议有Udp和Tcp协议。
- TCP协议的特点是有连接,具有可靠性,面向字节流,全双工。
- UDP协议的特点是无连接,不可靠,面向数据报。

第三个参数一般前两个参数设置完后,可以直接传0进去。
如果调用socket函数成功,返回值是一个新的文件描述符。如果失败,会返回一个-1。这个文件描述符就可以作为接受和发送信息的缓冲区。

3.3 bind函数

bind函数是一个套接字和网络地址以及端口号绑定起来。
第一个参数是socket函数返回的文件描述符。第二个参数是sockaddr结构体指针,一般使用网络通信,会传sockaddr_in结构体指针,需要进行类型强制转换,第三个参数该结构体的字节数。
4. UDP通信协议
UDP(用户数据报协议)是一种简单的面向数据报的通信协议。下面我们写一个简单的Udp协议的通信服务,客户端向服务器发送消息,服务器接受消息并把接受的消息回显给客户端。
4.1 UDP服务端类
下面是UdpServer.hpp文件的内容,主要把服务端描述成一个UdpServer类。并且初始化一下服务端的端口号,还有其他的字段。
cpp
#ifndef __UDP_SERVER__HPP
#define __UPD_SERVER__HPP
#include <iostream>
#include <string>
#include <cstring>
#include <memory>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
const static int gsockfd = -1;
using namespace LogModule;
class UdpServer
{
public:
UdpServer(const uint16_t port = gdefaultport)
:_sockfd(gsockfd)
,_port(port)
,_isrunning(false)
{}
~UdpServer()
{
if (_sockfd > gsockfd)
::close(_sockfd);
}
private:
int _sockfd;
uint16_t _port; //服务器端口号
bool _isrunning; //服务器运行状态
};
#endif
4.2 Udp服务类InitServer函数
下面是UdpServer服务端类中的成员函数InitServer的代码,InitServer函数用于初始化Udp服务。
- 首先使用socket函数创建一个套接字,第一个参数传AF_INET,表示网络通信。第二个参数传SOCK_DGRAM,表示使用Udp协议,第三个参数默认传0即可。
- 接着,填充网络信息。定义一个sockaddr_in结构体,该结构体内部有三个字段需要填充。第一个字段表示什么通信,填AF_INET,表示网络通信。第二字段填端口号,但是得使用htons函数将主机字节序转换成网络字节序,变成大端模式。
- 第三个字段一般来说需要填写机器的IP地址,但是作为服务器,可能客户端发来的消息需要多台服务器处理不同的信息,如果服务器填写固定的IP地址,那么客户端接受服务器的消息后,只能返回给一台服务器。所以服务器的sin_addr中的s_addr一般设置INADDR_ANY。
cpp
// ......
const std::string gdefaultip = "127.0.0.1";
const static uint16_t gdefaultport = 8080;
class UdpServer
{
public:
// ......
void InitServer()
{
// 1.创建套接字
_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
std::cout << "socket:" << strerror(errno) << std::endl;
exit(1);
}
std::cout << "socket success, sockfd is: " << _sockfd << std::endl;
// 2、填充网络信息,
// 2.1 没有把socket信息,设置进如内核中,只是填充了结构体!
struct sockaddr_in loacl;
memset(&loacl, 0, sizeof(loacl));
loacl.sin_family = AF_INET;
loacl.sin_port = ::htons(_port); //要被发送给对方的,即要发送到网络中!
loacl.sin_addr.s_addr = INADDR_ANY;
//loacl.sin_addr.s_addr = ::inet_addr(_ip.c_str()); //1. 点分十进制转为整数 2. 转成网络字节序
// 2.2 bind 设置如内核中
int n = ::bind(_sockfd, (struct sockaddr*)&loacl, sizeof(loacl));
if(n < 0)
{
std::cout << "bind: "<< errno << " " <<strerror(errno) << std::endl;
exit(2);
}
std::cout << "bind success" << std::endl;
}
// ......
private:
int _sockfd;
uint16_t _port; //服务器端口号
bool _isrunning; //服务器运行状态
};
4.3 Udp服务类Start函数
下面是Udp的Start成员函数的代码,用于接收客户端的信息,并回显给客户端。
首先将服务状态设置为真。再写个死循环,一般服务端的程序是不轻易下线的。只有while结束,服务状态再设置成假。
定义一个sockaddr_in结构体变量,用于接收客户端的IP地址和端口。读取网络传过来的信息,可以使用recvfrom函数。下面的recvfrom函数原型,跟read函数类似。
- 第一个参数填socket函数创建的文件描述符。第二个参数填一个指针类型的变量,用于接受传过来的信息,我们默认传过来的是字符串。第三个参数是该指针变量指向空间的大小。第四个参数是个标记位,填写0即可。
- 第五个参数是sockaddr结构体指针类型,需要填入sockaddr_in变量,并强转成sockaddr类指针。第六个参数是类型socklen_t,socklen_t就是一个无符号的32位整数类型,里面要记录sockaddr_in类型的大小。
- 如果该函数调用成功,返回值是一个大于0的整数,表示接受信息的字节数大小。如果失败返回-1。如果返回0,表示读取到文件末尾,或者客户端已关闭

接着n大于0,表示接受到有效信息。需要在下标为n的位置加上反斜杠0。调用ntohs函数可以将网络字节序转换成主机字节序,获取端口号。且inet_ntoa函数可以将sockaddr_in中无符号整数IP地址,转换成点分十进制的字符串。

最后拼接一下字符串,使用sendto函数,转发信息。sendto函数的前四个参数跟recvfrom函数一样。
- 第五个参数需要传客户端的sockaddr_in类结构体变量,里面包含客户端的IP地址和端口号。
- 最后一个参数也是socklen_t类型参数,里面填上dest_addr变量实际的大小即可。

cpp
class UdpServer
{
public:
// ......
void Start()
{
_isrunning = true;
while(true)
{
char inbuffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer); //必须设定
ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer)-1, 0, (struct sockaddr*)(&peer), &len);
if (n > 0)
{
// 在后面字符串后面加上反斜杠0,以便于输出
inbuffer[n] = 0;
// 1.消息内容 && 2.谁发给我的
uint16_t clientport = ::ntohs(peer.sin_port);
std::string clientip = ::inet_ntoa(peer.sin_addr);
std::string clientinfo = clientip + ":" + std::to_string(clientport) + "# " + inbuffer;
std::cout << clientinfo << std::endl;
std::string echo_str = "echo# ";
echo_str += inbuffer;
::sendto(_sockfd, echo_str.c_str(), echo_str.size(), 0, (struct sockaddr*)(&peer), sizeof(peer));
}
}
_isrunning = false;
}
// ......
private:
int _sockfd;
uint16_t _port; //服务器端口号
bool _isrunning; //服务器运行状态
};
4.4 Udp服务主函数
Udp服务端只需要绑定一个端口号,所以在传命令号时,强制传两个参数。其中需要把命令行第二参数转换成整数。
然后再使用智能指针初始化服务类变量,运行InitServer函数和Start函数即可。
cpp
#include "UdpServer.hpp"
// ./server_udp localport
int main(int argc, char *argv[])
{
if(argc != 2)
{
std::cerr << "Usage: " << argv[0] << " localport" << std::endl;
return 3;
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<UdpServer> svr_uptr = std::make_unique<UdpServer>(port);
svr_uptr->InitServer();
svr_uptr->Start();
return 0;
}
4.5 Udp客户端编写
客户端主代码编写时,可以在调用该程序的命令行参数后,加上访问的IP地址和端口号。其中要把端口号转换成整数类型。
实现网络通信,第一个也是创建socket套接字。然后,再填充服务端的网络信息。
但是不需要填写自己的网络信息,并且不用bind函数进行绑定。在客户端第一次发消息时,操作系统会动态绑定端口号,而IP地址是传输层的下一层网络层。

再下来就是使用sendto函数发消息,recvfrom函数接受消息。
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Common.hpp"
// ./client_udp serverip serverport
int main(int argc, char *argv[])
{
if(argc != 3)
{
std::cerr << "Usage: " << argv[0] << " serverip serverport" << std::endl;
Die(ExitError::USAGE_ERR);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 1.创建socket
int sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr << "socket error" << std::endl;
Die(ExitError::SOCKET_ERR);
}
// 1.1 填充server信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = ::inet_addr(serverip.c_str());
// 2. clientdone
while(true)
{
std::cout << "Please Enter# ";
std::string message;
std::getline(std::cin, message);
// client必须要也要有自己的ip和端口号!但是客户端,不需要自己显示的调用bind!!
// 而是,客户端首次sendto消息的时候,由OS自动进行bind
int n = ::sendto(sockfd, message.c_str(), message.size(), 0, CONV(&server), sizeof(server));
(void)n;
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
char buffer[1024];
n = ::recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, CONV(&temp), &len);
if (n > 0)
{
buffer[n] = 0;
std::cout << buffer << std::endl;
}
}
return 0;
}
4.6 运行结果

创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!
