socket
端口号
网络间通信的本质就是进程间通信。
在Linux上,标识一个进程通常用 pid。在网络通信中,为了标识不同主机上的进程通常用:端口号 + IP
端口号(port)是传输层协议的内容:
- 端口号是一个2字节16位的整数
- 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理
- 通过 IP地址 + 端口号能够标识网络上的某一台主机的某一个进程
- 一个端口号只能被一个进程占用
这里需要注意一个点就是:一个端口号只能被一个进程占用, 但是一个端口号不能被多个进程绑定
这也是端口号为什么能够标识一个进程的缘由。
开头提到网络通信的本质就是不同主机之间进程的通信,为了能让两个主机之间的进程能够找到对方,每个进程之间都会拥有自己的 端口号 + IP。
通常将发送信息的一端称为:源端口号、源IP;接收信息的一端称为:目的端口号、目的IP
在了解端口号是什么后,再来简单了解一下 UDP、TCP传输协议。
TCP、UDP协议
TCP(Transmission Control Protocol 传输控制协议)是一种有连接的、提供可靠传输、面向字节流的传输层协议;
UCP(Transmission Control Protocol 用户数据报协议)是一种无连接的、不可靠传输、面向数据报的传输层协议;
socket套接字网络编程是基于这两个传输协议进行数据传输的,对此在这里先了解其中的概念即可,在后续的博客中我会具体描述其中的细节。
网络字节序
大小端问题
数据存储到内存是有顺序之分的,也就是字节序的问题
- 大端存储:数据的高位字节存储在内存的低地址端,而数据的低位字节则存放在内存的高地址端
- 小端模式:数据的高位字节存储在内存的高地址端,而数据的低位字节则存放在内存的低地址端
举个例子:有这样的数据 0x11223344,其中 11 是最高位有效字节,44 是最低位有效字节。
不同的主机存储数据的方式是有差异的,但是数据的发送和数据的接收方式是不变的。
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
针对上面的问题,TCP/IP协议规定:网络数据流应采用大端字节序
不管这台主机是大端机还是小端机,只要按照这个TCP/IP规定的网络字节序来发送/接收数据,就可以避免网络字节序的问题。
在linux中,系统调用接口提供了一些函数接口用做网络字节序和主机字节序的转换。
需要用到头文件:#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);
上面有两组关于网络与主机间字节序转换的接口,看到函数名字是不是很头大。其实理解记忆会更好一些:
就拿第一组来记忆:
htons
这里的 h 表示 host,就联想到主机;to 表示方向;n 表示 network,联想到网络;s 表示 short 短整型。这个函数的功能是将16位的短整数从主机字节序转换为网络字节序
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回
上面的接口会在接下来经常用到,需要理解性记忆!
sockaddr_in结构体
在socket套接字网络编程中,提供了一个通用数据结构:struck sockaddr
。
sockaddr结构体的设计相对通用,它内部只包含了一个地址族 sa_family
和一个用于存储具体地址信息的字节数组 sa_data
。
struck sockaddr
结构体将 sa_data
字段将地址和端口信息混合在一起,且其格式不直观,使用起来也很不方便。
对此,引出了struck sockaddr_in
和 struck sockaddr_in6
结构体:
开头提到,若要对两台间不同的进程进行通信需要知道对方的端口号和IP地址。由于sock套接字编程,经常涉及地址信息的指定和传输。在网络编程中,C语言提供了一个结构体 struct sockaddr_in
,专门对IPv4地址和端口号进行了封装。
下面是 struck sockaddr_in
结构体成员:
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 是一个宏:
这个宏作用就是定义一个类型为sa_family_t
变量,这个变量由传入的 sa_prefix 与 family 字符连接形成。
在往下细看可以看到 sa_family_t 其实是一个 无符号短整型的整数。总得来说,这个成员其实就是一个地址族,一般设置为IPv4地址族(AF_INET)。 -
in_port_t sin_port
:sin_port 是一个无符号短整型类型的变量,用于存储端口号
-
struct in_addr sin_addr
:是一个结构体,但是其内部并没有很复杂的内容
in_addr_t
类型是一个无符号整形,其功能就是用于存储IP地址的。
关于 struct sockaddr_in
我们只需要掌握以上的点就行了。
在C语言中还提供了一个结构体 struct sockaddr_un
,这个结构体用于Unix域协议。Unix域协议允许在同一台计算机上的不同进程之间通过文件系统维护的虚拟数据传输域进行通信,而无需通过网络。大白话就是用于本机通信的。
在socket编程中,socket接口参数接收的结构体类型都是 struck sockaddr*
。
对此,在每次使用struct sockaddr_in
传参时,我们都需要将struct sockaddr_in*
指针强转为 struck sockaddr*
地址转换函数
一般的传入IP地址要么是字符串,要么就是无符号整数。在不同的运用场景都需要进行相互转换,对此C语言提供了一些函数转化接口,在使用这些函数时需要包含以下头文件:
#include <sys/socket.h>
、#include <netinet/in.h>
、#include <arpa/inet.h>
字符串转in_addr的函数
下面是将点分十进制ip字符串转成无符号整形的函数:
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
in_addr转字符串的函数
下面是将点分十进制 ip 地址无符号整形转成字符串的函数:
char *inet_ntoa(struct in_addr in);
接下来开始介绍关于socket套接字编程的主要接口,在使用这些系统调用接口时,需要包含4个头文件:
#include <sys/types.h>
、#include <sys/socket.h>
、#include <netinet/in.h>
、#include <arpa/inet.h>
刚开始接触这些接口会感觉头大,但是一般在使用 套接字时都会用到。可以说就是套路,使用时无脑加入即可!
socket 创建文件描述符(UDP/TCP)
- socket 接口用于创建一个新的套接字,成功创建返回一个文件描述符
int socket(int domain, int type, int protocol);
参数介绍:
- domain:指定协议族,常用的协议簇有两个(
AF_INET
、AF_INET6
)分别表示IPV4地址、IPV6地址。- type:指定套接字的类型,套接字类型常用的也是两个(
SOCK_STREAM
、SOCK_DGRAM
)分别指TCP协议和UCP协议- protocol:指定了使用的特定协议,一般情况下都是由OS自动选择,设置为 0 即可!
- 返回值:成功创建返回一个文件描述符;失败返回 -1 错误码被设置。
示例:
int listensock_ = socket(AF_INET, SOCK_STREAM, 0); // 使用IPV4、TCP协议
if (listensock_ < 0)
{
std::cerr << "create socket error" << std::endl;
exit(-1);
}
bind绑定端口号(UDP/TCP)
- bind接口是将一个套接字与特定的IP地址和端口号关联起来,使得网络层能够识别并正确地将接收到的数据包转发给该套接字
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
参数介绍:
- socket:传入已经打开的套接字文件描述符
- address:指向 sockaddr 结构体的指针,用于要绑定的网络地址信息
- address_len:sockaddr 结构体的大小
- 返回值:绑定成功返回0,失败返回-1,错误码被设置
下面需要区分一下关于服务端与客服端,端口绑定的规则。
服务端 Server 和 客户端 Client 都有属于自己的端口号,都需要进行端口号的绑定。但是,客户端的端口号是不需要我们手动进行绑定的,这个工作一般交给OS来进行。与客户端的不同是,服务端的端口号是需要我们手动进行绑定操作的。
这是因为在一台主机上,不仅仅只有一个客户端的应用。例如:手机上的应用有 QQ、淘宝、抖音…等等,这些都是客户端。前面提到过一个端口号被只能被一个进程绑定。客户端运行起来就是一个进程,当其中的一个客户端端口号被绑定后其他客户端是使用不了的。总不能说等一个客户端不用了再切换到另一个客户端使用,这样的效率是很低下的。由此,客户端端口号通常是由OS来绑定。置于服务端就不用多说了,服务端就是用于被访问的,由此,服务端的端口号要指明,进行手动绑定.
那么问题来了,客户端什么时候进行端口号绑定呢?
客户端创建了套接字后,在第一次调用系统调用接口时,就会自己进行绑定操作!
示例:服务端端口号绑定
// bind绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local)); // 将结构体的内容清零
local.sin_family = AF_INET;
local.sin_port = htons(port_); //将主机字节序转换为网络字节序
local.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0任意的网络IP都可以接收
if (bind(listensock_, (struct sockaddr *)&local, sizeof(local)) < 0)
{
// 绑定失败
std::cerr << "bind socket error" << std::endl;
exit(-1);
}
绑定端口号时,需要对 struct sockaddr_in
结构体进行赋值处理。这里的IP地址使用了 INADDR_ANY
这个宏,表示服务器能够接收来自本机任何网络接口的连接。
知道了套接字的创建与端口号的绑定就可以实现UDP服务了,这是因为UDP是无连接的、面向数据包的服务,不用考虑连接的问题。接下来要介绍的接口都是有关TCP服务的接口:
listen监听套接字(TCP)
- listen监听:将套接字设置为监听模式,等待客户端的连接
int listen(int socket, int backlog);
参数介绍:
- sockfd:传入想要监听的套接字文件描述符,这个文件描述符是必须被绑定过端口号的!
- 指定了内核应该为相应套接字排队的最大连接请求数,一般设置适当的值即可
- 返回值:监听成功返回0;失败返回-1,错误码被设置
示例:
if (listen(listensock_, 32) < 0)
{
std::cerr << "listen socket error" << std::endl;
exit(-1);
}
accept接收请求(TCP)
- accept:服务器在监听状态下,接收到客户端的连接请求
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
参数介绍:
- socket:传入已经打开的套接字文件描述符
- address:指向 sockaddr 结构体的指针,用于存储连接客户端的地址信息
- address_len:sockaddr 结构体的大小
- 返回值:接收连接成功返回一个新的文件描述符;失败返回 -1,错误码被设置
这里解释一下关于返回一个新的文件描述符,举个浅显易懂的例子:
好比去广场的饭店吃饭,每家餐馆在前台都会有一个服务员在门口招揽客人。恰好此时看中了一家餐馆,此时到餐馆门口后,这个服务员就会引领你到饭店对应的位置坐下。然后叫第二个服务员过来招呼正坐在位置的你,此时,他的工作完已经完成。继续回到门口继续招揽新的客人。对于上餐和餐桌的其他服务由第二个服务员全程负责。
上面的例子中,门口的服务员就是通过socket创建的文件描述符,而正在服务餐桌上客人的服务员就是 accept连接成功返回新的文件描述符!如果一家餐厅只有一个服务员的话,这个服务员要引领客人,又是招呼客人的话。那么,新来的客人就会被冷落招呼不到位。
listen监听 与 accept连接 也是如此,若要实现多个客户端能够连接到服务端的话就要有多个新的文件描述符!这也是为什么在连接成功后会返回一个新的文件描述符,是为了后续工作做准备!
接口示例:
// 用于获取客户端的ip+端口号
struct sockaddr_in client;
socklen_t len = sizeof(client);
memset(&client, 0, len);
// 获取连接
int sock = accept(listensock_, (struct sockaddr *)&client, &len); // 这个sock是用来与客户端进行通信的
if (sock < 0)
{
//接收连接失败,可能是客户端没有建立连接,对此无需对服务端做任何动作
std::cerr << "accept socket err" << std::endl;
}
connect建立连接(TCP)
- connect :是用于建立网络连接的接口,它的作用是使客户端与服务器建立连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数介绍:
- socket:传入已经打开的套接字文件描述符
- address:指向 sockaddr 结构体的指针,用于存储连接服务端的地址信息
- address_len:sockaddr 结构体的大小
- 返回值:建立连接成功返回0;失败返回 -1,错误码被设置
一般的客户端在连接到服务端时,都是需要知道服务端的端口号与目的IP的。对此需要用到 struct sockaddr_in
结构体
客户端连接服务端示例:
./tcpclient 127.0.0.1 8080
int main(int args, char *argv[])
{
if (args != 3)
{
usage(argv[0]);
exit(USAGE_ERR);
}
std::string serverip = argv[1];
uint16_t serverport = atoi(argv[2]); // 字符串转整形
// 创建套接字
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
std::cerr << "socket error : " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
// 客户端需要bind,但是,bind由OS来操作,不需要用户来处理
// 客户端不需要listen、accept
// 但是需要connect,建立客户端与服务器之间连接的系统调用
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
inet_aton(serverip.c_str(), &(server.sin_addr)); // 字符串转整形
// 建立连接
int cnt = 5;
while (connect(sock, (struct sockaddr *)&server, sizeof(server)) != 0)
{
sleep(1);
std::cout << "正在给你尝试重连,重连次数还有: " << cnt-- << std::endl;
if (cnt <= 0)
break;
}
if (cnt <= 0)
{
std::cerr << "连接失败..." << std::endl;
//关闭文件描述符
close(sock);
exit(-1);
}
//业务处理...
return 0;
}
封装套接字接口
基于以上的介绍的接口,为了方便接口的调用,可以将其封装成类。当然,这里封装的是以TCP服务为目的:
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
static const int gbacklog = 32;
static const int defaultfd = -1;
enum
{
SOCKET_ERR = 1, //创建套接字错误
BIND_ERR, //绑定错误
LISTEN_ERR, //监听错误
ACCEPT_ERR, //获取连接错误
};
class Sock
{
public:
Sock(): _sock(defaultfd)
{}
// 获取套接字文件描述符
void Socket()
{
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0)
{
std::cerr << "create socket error" << std::endl;
exit(SOCKET_ERR);
}
}
// 绑定
void Bind(const uint16_t &port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY; // 以任意的IP都可以接收
if (bind(_sock, (struct sockaddr *)&local, sizeof local) < 0)
{
std::cerr << "bind socket error" << std::endl;
exit(BIND_ERR);
}
}
// 监听
void Listen()
{
if (listen(_sock, gbacklog) < 0)
{
std::cerr << "listen socket error" << std::endl;
exit(LISTEN_ERR);
}
}
// 接收连接
int Accept(std::string *clientip, uint16_t *clientpor)
{
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
int sock = accept(_sock, (struct sockaddr *)&temp, &len);
if (sock < 0)
{
std::cerr << "accept socket err" << std::endl;
}
else
{
*clientip = inet_ntoa(temp.sin_addr); // 字符串转整形地址
*clientpor = ntohs(temp.sin_port);
}
return sock;
}
// 发送连接
int Connect(const std::string &serverip, const uint16_t &serverport)
{
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());
return connect(_sock, (struct sockaddr *)&server, sizeof(server));
}
int getFd(){return _sock;} //获取连接成功后的文件描述符
~Sock()
{
if (_sock != defaultfd)
close(_sock); //关闭文件描述符
}
private:
int _sock;
};
以上就是该篇文章的所有内容啦!喜欢的老铁可以点点赞+收藏,感谢大家的观看。