文章目录
- 一、预备知识
- 1.1 理解IP地址和端口号
- 1.2 认识TCP协议和UDP协议
- 1.3 网络字节序
- 1.4 socket编程接口和sockaddr结构
- 二、封装 UdpSocket
一、预备知识
1.1 理解IP地址和端口号
众所周知,每台主机都有一个IP地址。而主机和主机之间通信,也需要依赖IP地址。源IP地址指的就是发送数据包的那个电脑的IP地址, 目的IP地址就是想要发送到的那个电脑的IP地址。
IP地址可以帮一个主机找到要通信的目的主机,但是单单有IP地址,不能实现真正的通信。因为,主机之间通信的本质,是两个主机上搭载的软件之间的通信。
每台主机上都会有各种不同的软件,而IP地址只能帮我们确定一台主机,那么我们该如何确定主机上真正参与通信的软件呢?
答:端口号合一帮助我们更好地标识服务端和客户端进程的唯一性。
端口号(port)是传输层协议的内容:
- 端口号是一个2字节16位的整数
- 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理
- IP地址 + 端口号能够标识网络上的某一台主机的某一个进程
- 一个端口号只能被一个进程占用
综上所述,网络通信的本质就是进程(不同主机上的进程)间通信!
下面对进程间通信和网络通信做一个对比:
首先,进程间通信的前提是,必须让不同的进程看到同一份资源。而对于网络通信来说,这个“资源”就是网络。
其次,进程间通信的本质就是在做I/O操作。网络通信也是这样,无非就是要把自己的数据发出去,或者是要接收他人发出来的数据。
为了保证主机上进程的唯一性,我们可以采用PID,为什么还要用端口号呢?
答:单单就技术来讲,利用PID来实现两台主机上进程之间的通信时可以的,但是仍然使用端口号的目的是为了实现“解耦”,避免未来PID改变时,会影响网络通信的情况。
另外,当客户端要与服务器进行通信时,必须保证客户端每次都能找到服务器,也就是说,服务器的唯一性(IP+port)不能随意改变,而PID本身有很多不确定因素,所以不能用它来进行网络通信。
最后,不是所有的进程都要提供网络通信服务或请求,但是每个进程都需要有PID。
底层的操作系统是如何根据端口号找到指定的进程的?
答案是,在操作系统中,维护了一张哈希表,这张哈希表中每一个元素的key值就是一个端口号,value值就是指定进程对应的PCB的地址。所以,就可以根据这张表,通过端口号找到目标PCB,也就可以进行数据交换了。
传输层协议(TCP和UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号. 就是在描述 “数据是谁发的, 要发给谁”
在网络通信过程中,除了发送数据之外,还要把自己的IP和port发给对方,因为对方要根据IP+port发回数据。也就是说,在发送数据的时候,一定会多发送一些除通信数据之外的数据,而这部分多出来的数据是以协议的形式呈现的。
1.2 认识TCP协议和UDP协议
TCP(Transmission Control Protocol):
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
UDP(User Datagram Protocol):
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
这篇文章中,不对这两个协议做过多的解释,后面会有新的文章针对二者做出详细说明。
注意,这里的“可靠”和“不可靠”是中性词,没有好坏之分。
实现可靠传输的代价往往更高,这体现在代码实现和维护上面;而不可靠传输则不需要考虑这些东西。在实际应用中,我们往往需要根据实际情况,选择更适合的协议,不能在说法上“厚此薄彼”。
1.3 网络字节序
众所周知,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
- 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
- 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
- TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
- 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
- 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);//将32位整数从主机序列转为网络序列
uint16_t htons(uint16_t hostshort);//将16位整数从主机序列转为网络序列
uint32_t ntohl(uint32_t netlong);//将32位整数从网络序列转为主机序列
uint16_t ntohs(uint16_t netshort);//将16位整数从网络序列转为主机序列
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
1.4 socket编程接口和sockaddr结构
上文中说,IP+port可以标识网络中唯一一个进程,而实际上IP+port就叫做套接字(socket)。
为了支持网络编程,操作系统提供了一系列接口,如下:
// 创建 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);
可以注意到,上面的接口中,除了第一个之外,每一个接口的参数中都包含一个结构体。这个结构体是什么呢?
其实网络通信中的套接字种类常见的有三种----网络套接字、原始套接字和unix域间套接字。网络套接字主要支持网络中跨主机之间的通信,同时也支持本地通信;而unix域间套接字只支持本地通信(会了网络套接字自然就会了这个);而原始套接字可以绕过传输层,直接访问底层个各种数据(抓包等的实现就是利用原始套接字)。
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX DomainSocket. 然而, 各种网络协议的地址格式并不相同
为了支持不同的套接字通信方式,就必须有不同的接口。但是,接口的设计者不想设计出各种不同的接口,所以设计出了一种sockaddr结构,可以通过不同的参数完成不同的功能。
例如:
很多网络编程函数诞生早于IPv4协议,那时候都使用的是sockaddr结构体,为了向前兼容,现在sockaddr退化成了(void *)的作用,传递一个地址给函数,至于这个函数是sockaddr_in还是其他的,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。
IPv4的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位端口号和32位IP地址,但是sock API的实现早于ANSI C标准化,那时还没有void *类型,因此这些像bind 、accept函数的参数都用struct sockaddr *类型表示,在传递参数之前要强制类型转换一下
二、封装 UdpSocket
开始写代码之前,先来认识几个接口:
第一个参数domain代表域(网络通信还是本地通信);第二个参数代表套接字提供的能力类型(流式服务/数据报服务);第三个参数代表使用的协议(一般默认为0)。而返回值是一个文件描述符(失败则返回-1)。
绑定套接字需要的接口:
绑定成功返回0,否则返回-1。
来看一下第三个参数的内部结构:
/* Structure describing an Internet socket address. */
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr)];
};
对于其中的__SOCKADDR_COMMON,如下:
typedef unsigned short int sa_family_t;
#define __SOCKADDR_COMMON(sa_prefix) \
sa_family_t sa_prefix##family
可以看到,这里用到了宏的双#,它的意思是字符串合并,也就是将接收到的参数和family合并为一个字符串。
而对于in_addr:
/* Internet address. */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
它就是IP地址,一个32位的整数。
而我们经常见到的IP地址一般都是点分十进制的样子,而这里的确实一个四字节的数字。原因在于前者的可读性较强,但是在网络通信中,最主要的目的不是为了可读性,而是为了写代码方便。所以采用整数的形式,在OS内部会有一个特殊的结构体,将其转换为字符串。
另外,为了将字符串形式的IP地址转为整数,还需要使用一个接口:
这里再补充一个概念:
服务器的本质就是一个死循环,因此它是一个常驻内存的进程。我们熟知的另一个死循环的软件就是操作系统。
封装后的udp(udpServer.hpp)如下:
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <strings.h>
#include <cstdlib>
#include <cerrno>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
namespace Server
{
using namespace std;
static const string defaultIp = "0.0.0.0";
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR
};
class udpServer
{
public:
udpServer(const uint16_t &port, const string ip = defaultIp)
: _port(port), _ip(ip), _sockfd(-1)
{
}
void initServer()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
cerr << "socket error:" << errno << strerror(errno) << endl;
exit(SOCKET_ERR);
}
// 2.绑定port,ip
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()); // 将IP转成整数并做字节序转换
local.sin_addr.s_addr=htonl(INADDR_ANY);//任意地址bind,服务器的真实写法
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n == -1)
{
cerr << "bind error:" << errno << strerror(errno) << endl;
exit(BIND_ERR);
}
}
void start()
{
for (;;)
{
sleep(1);
}
}
~udpServer()
{
}
private:
uint16_t _port;
string _ip;
int _sockfd; // 文件描述符
};
}
udpServer.cc内容如下:
#include "udpServer.hpp"
#include <memory>
using namespace std;
using namespace Server;
static void Usage(string proc)
{
cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
uint16_t port = atoi(argv[1]);
// string ip = argv[1];
std::unique_ptr<udpServer> usvr(new udpServer(port));
usvr->initServer();
usvr->start();
return 0;
}
运行结果如下:
其中出现的警告信息,是我们还没有编写的客户端,暂时不用在意。可以看到,服务器端是可以正常运行的。
下面再来完善服务器内部的工作,先来看一个接口:
该接口用于接收数据。
补充后的start函数内容如下:
void start()
{
char buffer[1024];
for (;;)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
buffer[s] = 0;
string clientip = inet_ntoa(peer.sin_addr); // 1.网络序列转主机序列 2.整数IP地址转换为点分十进制
uint16_t clientport = ntohs(peer.sin_port);
string message = buffer;
cout << clientip << "[" << clientport << "]# " << message << endl;
}
}
}
下面再来完善客户端代码,先来看一个接口:
该接口用于发送数据。
udpClient.hpp内容如下:
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <strings.h>
#include <cstdlib>
#include <cerrno>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
namespace Client
{
using namespace std;
class udpClient
{
public:
udpClient(const string &serverip, const uint16_t &serverport)
: _serverip(serverip), _serverport(serverport), _sockfd(-1), _quit(false)
{
}
void intiClient()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
cerr << "socket error: " << errno << strerror(errno) << endl;
exit(2);
}
cout << "socket successfully" << " : " << _sockfd << endl;
// 2.client必须要bind,但是不需要显示地(程序员自己实现)bind,由操作系统自动形成端口进行bind
}
void run()
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(_serverip.c_str());
server.sin_port = htons(_serverport);
string message;
while (!_quit)
{
cout << "Please Enter# ";
cin >> message;
sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));
}
}
~udpClient()
{
}
private:
int _sockfd;
string _serverip;
uint16_t _serverport;
bool _quit;
};
}
udpClient.cc内容如下:
#include "udpClient.hpp"
#include <memory>
using namespace std;
using namespace Client;
static void Usage(string proc)
{
cout << "\nUsage:\n\t" << proc << "server_ip server_port\n\n";
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
string serverip = argv[1];
uint16_t serverport = atoi(argv[2]);
unique_ptr<udpClient> ucli(new udpClient(serverip, serverport));
ucli->intiClient();
ucli->run();
return 0;
}
Makefile内容如下:
cc=g++
.PHONY:all
all:udpClient udpServer
udpClient:udpClient.cc
$(cc) -o $@ $^ -std=c++11
udpServer:udpServer.cc
$(cc) -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f udpClient udpServer
运行结果如下:
可以看到,程序正常运行。并且,由于Client的端口号是由操作系统自动生成的,所以两次连接后得到的端口号不一样。
本篇完,青山不改,绿水长流!