网络套接字
一、端口号
1. 认识端口号
实际上我们两台机器在进行通信时,是应用层在进行通信,应用层必定会推动下层和对方的上层进行通信。
其实网络协议栈中的下三层,主要解决的是数据安全可靠的送到远端机器。而用户使用应用层软件,完成数据发送和接收的。那么用户要使用软件,首先需要把这个软件启动起来!所以软件启动起来,本质就是进程!所以两台机器进行通信,本质是两台机器之上的应用层在通信,也就是两个进程之间在互相交换数据!所以网络通信的本质就是进程间通信!只不过在网络通信中的公共资源是网络,通过网络协议栈利用网络资源,让两个不同的进程看到了同一份资源!
在网络协议栈中,在传输层怎么把数据正确交给上层应用层呢?怎么知道交给哪一个应用呢?所以就要求上层应用层和传输层之间必须协商一种方案,让我们把数据准确交给上层,这个方案我们称为端口号。所以在传输层的报头中,必须要有原端口号和目的端口号,也就是根据目的端口号就可以决定这个数据的有效载荷要交给上层应用的哪一个!所以对于端口号无论对于客户端和服务端,都能唯一的标识该主机上的一个网络应用层的进程!
我们可以这样理解,其实在传输层当中,操作系统会形成一张哈希表,哈希表中的类型是 task_struct*,每一个应用层都要和该哈希表绑定端口号,本质就是根据端口号在哈希表里做哈希运算,如果该位置已经被占用了,就不能被绑定了,因为一个端口号只能被一个进程绑定;如果该位置没有被使用,就把该进程的pcb地址放在该位置上。
2. socket
因为在公网上,IP地址 能表示唯一的一台主机,端口号 port,用来标识该主机上的唯一的一个进程,所以 IP + port 就可以标识全网唯一的一个进程!那么我们在网络通信时,只需要在对应的报头上填上原IP和目的IP,原port和目的port,就可以将报文交给另一个主机的进程,这种基于 IP + port 的通信方式,我们称为 socket.
那么端口号和进程pid有什么区别呢?进程pid也能标识一台主机上的唯一进程啊?因为首先,不是所有的进程都要通信,但是所有的进程都要有pid!其次是为了使系统和网络功能解耦!
二、认识TCP协议和UDP协议
下面我们先认识一下两个传输层协议:
1. TCP协议
此处我们先对TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识;后面我们再详细讨论 TCP 的一些细节问题。
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
2. UDP协议
此处我们也是对UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识;后面再详细讨论。
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
三、网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,网络数据流同样有大端小端之分。那么如何定义网络数据流的地址呢?
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
- 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
- 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
- TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
- 不管这台主机是大端机还是小端机,都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
- 如果当前发送主机是小端, 就需要先将数据转成大端;否则就忽略,直接发送即可;
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换:
- 这些函数名很好记,h 表示 host;n 表示 network,l 表示 32 位长整数,s 表示16位短整数;
- 例如 htonl 表示将 32 位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送;
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
- 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
四、socket 编程
1. socket 常见API
// 创建 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);
2. sockaddr 结构
socket API 是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的 UNIX Domain Socket;然而,各种网络协议的地址格式并不相同
- IPv4 和 IPv6 的地址格式定义在 netinet/in.h 中,IPv4地址用 sockaddr_in 结构体表示,包括16位地址类型, 16位端口号和32位IP地址.;
- IPv4、IPv6 地址类型分别定义为常数 AF_INET、AF_INET6,这样,只要取得某种 sockaddr 结构体的首地址,不需要知道具体是哪种类型的 sockaddr 结构体,就可以根据地址类型字段确定结构体中的内容;
- socket API 可以都用 struct sockaddr* 类型表示,在使用的时候需要强制转化成 sockaddr_in;这样的好处是程序的通用性,可以接收IPv4,IPv6,以及 UNIX Domain Socket 各种类型的 sockaddr 结构体指针做为参数。
3. 编写 UDP 服务器
(1)socket()
下面我们编写一个 UDP 服务器。首先需要做的是创建套接字,使用到的接口是 socket()
:
第一个参数是我们创建的套接字的域,即使用 IPv4 的网络协议还是 IPv6 的网络协议,目前我们只需要关注这两个即可,如下图:
第二个参数表示当前 socket 对应的类型,也就是相当于这个套接字未来给我们提供什么服务,是面向字节流的还是面向用户数据报的,如下:
第三个参数表示的是协议类型,目前我们不需要传这个参数。
而返回值相当于是一个文件描述符,所以创建一个套接字的本质,在底层就相当于是打开一个文件,只不过以前的 struct file 指向的是键盘、显示器这样的设备;而现在指向的是网卡设备。
(2)bind()
创建套接字成功之后,接下来就要绑定端口号,使用到的接口是 bind()
,如下:
其中第一个参数就是创建套接字时的返回值;第二个参数是一个结构体;第三个参数是结构体的长度。但是我们在网络套接字编程的时候不用第二个参数类型的结构体,这个结构体它只是设计接口用,我们实际用的是 sockaddr_in 类型的结构体,只需要在传参的时候进行强转即可。我们可以使用 bzero() 接口将该结构体清0;
我们是要使用 bind 来让套接字和我们往该结构体中填充的网络信息要关联起来,所以我们需要想该结构体中填充对应的字段。该结构体中有如下字段:
对应下图:
其中 sin_zero 为该结构体的填充字段,也就是这些字段不用填充,当作占位符即可;sin_addr 代表 ip 地址;sin_port 代表服务器所使用的端口号;sin_family 代表该结构体对应的网络协议类型,IPv4 或者 IPv6.
因为我们在给对方发送数据的时候,我们也一定需要让对方知道我们是谁,所以我们需要将端口号携带上,发送给对方,这样对方把数据处理完,就可以给我们响应回来。所以端口号是要在网络里来回发送的,也就是需要保证我们的端口号是网路字节序列,因为该端口号是要给对方发送的。所以这里我们就需要用到主机序列转网络序列的接口,由于端口号是两个字节,所以用到的接口为 htons()
:
由于我们用户一般用的都是点分十进制字符串风格的 IP 地址,也就是 0.0.0.0 这种风格,每个点分的范围是 0~255,每个字符一个字节,远远超过结构体中要求的 32 位 ip 地址,也就是四字节。所以我们需要将该字符串类型转换为 uint32_t 的类型,那么用到的接口是 inet_addr()
,它的作用就是将字符串风格的 ip 地址转化为网络风格的 uint32_t 类型,如下图:
同端口号一样,IP 地址也需要保证是网络字节序列。那么它的返回值类型 in_addr_t 其实就是符合网路字节序列的 uint32_t 的类型。
上面我们已经把准备工作做好了,接下来我们就需要使用 bind() 接口进行绑定,本质就是把我们定义的 struct 结构体设置进内核,设置进指定的套接字内部。
(3)recvfrom()
接下来我们就需要在指定的一个套接字里获取数据内容,使用到的接口是 recvfrom()
,如下图:
第一个参数就是网络文件描述符;第二个参数和第三个参数分别表示我们提供的缓冲区和它的长度,读到的数据就会放在缓冲区中;第三个参数设为0就是默认使用阻塞方式;最后两个参数又是熟悉的结构体,由于我们需要知道这些数据是谁给我们发的,因为我们有可能也要将数据给对方返回。所以最后两个参数其实是输出型参数。
返回值成功就是对应的长度,否则就是-1,如下:
(4)sendto()
将数据发送回给对方使用到的接口为 sendto()
,如下:
参数和 recvfrom() 的参数类似,这里不再介绍了。而最后两个参数是输入型参数,我们要将数据发回给对方,首先需要知道对方是谁,而我们上面已经通过 recvfrom() 获取到了对方的结构体信息,所以直接使用该结构体信息即可。
(5)udp 服务端和客户端
其中通过使用上面的接口编写的一个简单的接收客户端的字符串信息,并进行简单的加工的 udp 服务器代码链接为:UDP.
其中 udp server 的代码如下:
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>
#include <cstring>
#include <functional>
#include "log.hpp"
using func_t = std::function<std::string(const std::string&)>;
//typedef std::function<std::string(const std::string&)> func_t;
std::string default_ip = "0.0.0.0";
uint16_t default_port = 8080;
log lg;
class UdpServer
{
public:
UdpServer(const uint16_t &port = default_port, const std::string &ip = default_ip)
: _port(port), _ip(ip), _isrunning(false), _sockfd(0)
{}
void Init()
{
// 1.创建 udp 套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0); // AF_INET == PF_INET
if (_sockfd < 0)
{
lg(Fatal, "socket create faild, sockfd: %d", _sockfd);
exit(1);
}
lg(Info, "socket create success, sockfd: %d", _sockfd);
// 2.绑定端口号
// 2.1 准备数据
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()); // 1.string -> uint32_t 2.保证uint32_t是网络序列
local.sin_addr.s_addr = htonl(INADDR_ANY);
// 2.2 开始bind
int n = bind(_sockfd, (const sockaddr *)&local, sizeof(local));
if (n < 0)
{
lg(Fatal, "bind faild, errno: %d, err message: %s", errno, strerror(errno));
exit(2);
}
lg(Info, "bind success, errno: %d, err message: %s", errno, strerror(errno));
}
void Run(func_t func)
{
_isrunning = true;
char buffer[1024];
while (_isrunning)
{
// 记录客户端发来时的结构体信息
struct sockaddr_in client;
socklen_t len = sizeof(client);
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (sockaddr *)&client, &len);
if (n < 0)
{
lg(Warning, "recvfrom error, errno: %d, err message: %s", errno, strerror(errno));
continue;
}
buffer[n] = 0;
// 对数据进行简单的加工
std::string info = buffer;
std::string echo_string = func(info);
// 发送回给对方
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (const sockaddr *)&client, len);
}
}
~UdpServer()
{
if (_sockfd > 0)
close(_sockfd);
}
private:
int _sockfd;
uint16_t _port;
std::string _ip;
bool _isrunning;
};
udp client 的代码如下:
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
void Usage(string proc)
{
cout << "\n\rUsage: " << proc << " serverip serverport\n" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(0);
}
string server_ip = argv[1];
uint16_t server_port = stoi(argv[2]);
sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
server.sin_port = htons(server_port);
socklen_t len = sizeof(server);
// client 也需要 bind,只不过不需要用户显示 bind,一般由OS自由随机选择
// 系统会在首次发送数据的时候给我们bind
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
cout << "socker error" << endl;
return 1;
}
string message;
char buffer[1024];
while(true)
{
cout << "Plase Enter@ ";
getline(cin, message);
// 发送数据
sendto(sockfd, message.c_str(), message.size(), 0, (sockaddr*)&server, len);
// 当服务器进行简单的加工处理后会发送回来,此时客户端再次获取
sockaddr_in temp;
socklen_t size = sizeof(temp);
ssize_t s = recvfrom(sockfd, buffer, 1023, 0, (sockaddr*)&temp, &len);
if(s > 0)
{
buffer[s] = 0;
cout << buffer << endl;
}
}
close(sockfd);
return 0;
}
main 函数:
#include <iostream>
#include <vector>
#include <memory>
#include <cstdio>
#include "UdpServer.hpp"
using namespace std;
void Usage(string proc)
{
cout << "\n\rUsage: " << proc << " port[1024+]\n" << endl;
}
// 处理字符串的方法
string Handler(const std::string& str)
{
string res = "Server get a message: ";
res += str;
cout << res << endl;
return res;
}
// 远程执行指令的方法
string ExcuteCommand(const string& cmd)
{
FILE* fp = popen(cmd.c_str(), "r");
if(nullptr == fp)
{
perror("popen");
return "error";
}
string result;
char buffer[4096];
while(true)
{
char* tmp = fgets(buffer, sizeof(buffer), fp);
if(tmp == nullptr) break;
result = buffer;
}
pclose(fp);
return result;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = stoi(argv[1]);
unique_ptr<UdpServer> svr(new UdpServer(port, "127.0.0.1"));
svr->Init();
svr->Run(ExcuteCommand);
return 0;
}
有关代码中的细节:
- 有关 IP 地址
云服务器禁止直接bind公网ip;bind ip 地址为0,表示的含义是任意地址绑定,这种是比较推荐的做法。当 IP 地址为 127.0.0.1 时,表示进行的是本地传输测试,不会进行跨网传输。
- 有关 port
其中 0~1023 的端口号是系统内定的端口号,一般都要有固定的应用层协议使用,例如 http:80,https:443;所以我们一般绑端口号,一般绑1024以上的。
- popen() 系统调用
popen() 是一个被封装起来的管道和子进程执行命令的应用。
它的第一个参数就是需要执行的命令,在底层它会帮我们进行 fork() 创建子进程,并让父子进程建立管道,然后让子进程把它的运行结果通过管道再返回给调用方。如果调用方想得到 command 指令的运行结果,可以通过文件指针的方式读取。第二个参数相当于是打开这个命令的方式,我们使用 “r” 即可。使用完毕后使用 pclose() 关闭该文件指针即可。
其中,我们可以使用 netstat -nlup
查看系统中所有的 udp 信息,并且把进程信息也显示出来。
我们还可以将以上代码修改成为多线程代码,链接为:多线程UDP.
4. 地址转换函数
(1)相关接口
我们只介绍基于 IPv4 的 socket 网络编程,sockaddr_in 中的成员 struct in_addr sin_addr 表示32位 的 IP 地址,但是我们通常用点分十进制的字符串表示 IP 地址,以下函数可以在字符串表示和 in_addr 表示之间转换。我们在上面的 bind() 中也使用了地址转换函数 inet_addr().
-
字符串转 in_addr 的函数:
#include <arpa/inet.h> int inet_aton(const char* strptr, struct in_addr* addrptr); in_addr_t inet_addr(const char* strptr); int inet_pton(int family, const char* strptr, void* addrptr);
-
in_addr 转字符串的函数:
char* inet_ntoa(struct in_addr inaddr); const char* inet_ntop(int family, const void* addrptr, char* strptr, size_t len);
其中 inet_pton 和 inet_ntop 不仅可以转换 IPv4 的 in_addr,还可以转换 IPv6 的 in6_addr,因此函数接口是 void* addrptr.
(2)关于 inet_ntoa
inet_ntoa 这个函数返回了一个 char*,很显然是这个函数自己在内部为我们申请了一块内存来保存 ip 的结果,那么是否需要调用者手动释放呢?
man 手册上说,inet_ntoa 函数,是把这个返回结果放到了静态存储区。这个时候不需要我们手动进行释放。
5. 编写 TCP 服务器
(1)listen()
TCP 是面向连接的,服务器一般是比较被动的,所以服务器一直处于一种等待连接到来的状态,这个工作叫做监听状态,使用到的接口是 listen()
,如下:
第一个参数为指定的套接字,通过该套接字等待新连接的到来。第二个参数我们后面再介绍,暂时设为10左右即可。返回值,成功返回0,失败返回-1.
(2)accept()
因为 TCP 是面向连接的,所以在正式通信之前,先要把连接建立起来,使用到的接口为 accept()
,该接口的作用是获取一个新的连接,如下:
第一个参数为我们刚刚设置为监听状态的套接字;后两个参数和 recvfrom()
的后两个参数一样,都是输出型参数,也就是谁给我们发的 TCP 报文,那么对应的套接字信息就会通过这两个参数返回出来。
而返回值成功返回一个文件描述符;否则返回-1;那么返回值也是一个文件描述符,我们原本也有一个文件描述符,为什么会有两个 sockfd 呢?我们该用哪个呢?其实它们分工是明确的,我们原本定义的 sockfd,即被创建的,被 bind 的,被监听的套接字,它的工作是从底层获取新的连接;而未来真正提供通信服务的,是 accept() 返回的套接字!
至此,我们可以使用 telnet
进行指定服务的一个远程连接,后面跟上 IP 地址和端口号即可;它在底层默认使用的就是 TCP.
(3)con
由于在 TCP 中,客户端是要连接服务器的,所以服务端需要有一个能够向服务器发起连接的接口,该接口为 connect()
,如下:
该接口的作用是通过指定的套接字,向指定的网络目标地址发起连接。后两个参数和 sendto() 的后两个参数一样。返回值成功返回0,失败返回-1.
TCP 客户端也需要 bind,但是和 UDP 一样,不需要显示的 bind,系统会在客户端发起 connect 的时候,进行自动随机 bind.
我们可以使用 netstat -nltp
查看系统中所有 TCP 的信息,并把进程信息显示出来。
(4)守护进程
在我们登录 Linux 的时候,Linux 系统会给我们形成一个会话,而且会为每个会话创建一个 bash 进程,这个 bash 就可以为用户提供命令行服务。每个会话中只能存在一个前台进程,但是可以存在多个后台进程,而键盘信号只能发送给前台进程。前台和后台进程的区别就是是否拥有键盘文件,它们都可以向显示器打印,而只有前台进程才能从键盘,即标准输入获取数据!
如果我们不想后台进程向显示器打印的数据影响我们,我们可以将它的打印数据重定向到文件中,例如:
其中 [1]
表示后台任务号,后面数字表示进程 PID.
而查看后台任务的指令为:jobs
,如下:
如果我们想把后台进程提到前台,可以使用 fg 任务号
,如下:
如果想把它重新放回后台,我们可以使用 ctrl + z
将该进程暂停。然后使用 bg 任务号
将该进程重新启动,如下:
接下来我们再运行几个后台进程,例如使用 sleep,方便观察 Linux 中的进程间关系,使用 ps axj | head -1 && ps axj | grep -Ei 'a.out|sleep'
查看它们的进程信息:
其中 PPID、PID 我们都认识,而 PGID 表示的是进程组ID,SID 表示 session id,即会话 id.
而系统中可能会存在多个 session,所以系统需要管理多个 session.
我们可以看到,./a.out
进程的 PID 和 PGID 是一样的,所以它就是自成进程组的。而三个 sleep 分别是三个不同的进程,但是它们的 PGID 却是同一个,而且是用管道建立的进程的第一个进程的 PID,所以它们三个自成一组,而组长是多个进程中的第一个。那么进程组和任务有什么关系呢?任务是要指派给进程组的!所以我们需要校正一下以前的说法,我们把前台进程称为前台任务,后台进程称为后台任务,因为可能某一个后台任务里面,可能会包含多个进程。但是无论有几个进程组完成对应的任务,在同一个会话内启动的,SID 是一样的!那么上面中的 SID 到底是谁呢?我们可以查看一下:
如上图,我们可以看到,它是 bash!所以就是以 bash 的 pid 去构建了一个 session!
这种后台进程会收到用户登录和退出的影响,如果我们不想受到任何用户登录和注销的影响,我们可以将进程守护进程化。什么是守护进程呢?我们把自成进程组自成会话的进程称为守护进程!那么我们该如何做到呢?下面我们认识一个接口:setsid()
,如下:
该接口的作用就是,哪个进程调用该接口,就把该进程的组ID设置为会话ID,也就是让进程独立成会话。
返回值成功返回进程的ID,否则返回-1.
注意,该接口不能由进程组的组长直接调用,那么怎么才能保证不是组长调用呢?所以我们可以使用 fork() 创建子进程调用!所以守护进程的本质,也是孤儿进程!
我们可以使用系统给我们提供的守护进程接口:daemon()
,如下:
其中第一个参数为0的话,就会更改当前工作目录为 /
,否则默认当前工作目录不变;第二个参数如果为0的话,输出重定向会到 /dev/null
中。
下面我们自己编写一个守护进程的接口:
#pragma once
#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <signal.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
void Daemon(const std::string &cwd = "")
{
// 1.忽略一些异常信号
signal(SIGCLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
signal(SIGSTOP, SIG_IGN);
// 2.将自己变成独立会话
if (fork() > 0)
exit(0);
setsid();
// 3.更改当前进程工作目录
if (!cwd.empty())
chdir(cwd.c_str());
// 4.标准输入、标准输出、标准错误重定向到/dev/null中
int fd = open("/dev/null", O_RDWR);
if (fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
}
}
(5)tcp 服务端和客户端
接下来我们结合上面所学的知识,编写一个 TCP 服务器,并将它守护进程化,代码链接:TCP+守护进程.
其中在守护进程中,我们的代码中是充满大量的打印的,而这些打印默认是向标准输出打的,也就是向显示器上打了,而对于守护进程来说,就不应该向显示器上打了,所以我们需要一个解决方案。而 Linux 中存在一种字符文件,叫做 /dev/null
,只要我们向该文件写入,都会被该文件丢弃掉,如果我们向该文件读取,什么也读取不到。所以我们只需要将所有的输出向该文件写入即可。我们也可以将打印信息写入文件中。
另外,TCP 在通信时是全双工的,也就是可以同时读写的。在底层操作系统给 TCP 提供两个缓冲区,一个发送缓冲区,一个接收缓冲区,我们在用 TCP 的同时,别人也在用,所以别人也会有上面两个缓冲区,所以当我们发送数据,是先把我们的数据拷贝到我们的 TCP 的发送缓冲区,然后通过网络会发送到对方的接收缓冲区,反过来也同理,如下图: