网络编程基础(1)
参考:套接字-Socket | 爱编程的大丙 (subingwen.cn)
一、相关概念
-
IP:本质是一个整形数,用于表示计算机在网络中的地址。IP协议版本有两个:IPv4和IPv6
-
IPv4
:ipv4实际上就是一个32位二进制数,使用点分十进制
进行描述,所以通常表示为四个 8 位的十进制数
,用点分隔开来,例如192.168.190.129
192.168.190.129
里面的192,168,190和129都是使用八位的二进制数来表示,也正是由于八位的二进制数来表示,所以每一个最大就是 2 8 = 256 2^8 = 256 28=256,但是我们一般是从0开始,所以是255个,因此,ipv4最大为255.255.255.255
-
IPv6
:Pv6地址是128位的二进制数,通常表示为8组十六进制数
,每组由冒号分隔,例如2001:0db8:85a3:0000:0000:8a2e:0370:7334
IPv6的表示可以省略连续的0,例如:2001:0db8:85a3:0000:0000:8a2e:0370:7334。可以简写为2001:db8:85a3::8a2e:370:7334
-
-
端口: 端口的作用是定位到主机上的某一个进程
端口也是一个整形数 unsigned short ,一个16位整形数,有效端口的取值范围是:0 ~ 65535(0 ~ 216-1)
一个端口只能给某一个进程使用,多个进程不能同时使用同一个端口
[!NOTE]
通过IP定位一台计算机主机,通过端口定位一个进程
- OSI/ISO 网络分层模型
发送方(包装):
- 应用层:HTTP HTTPS SSH等 —> 包含数据,然后传递给传输层
- 传输层:TCP UDP ----> 封装时包装源端口和目标端口,然后传递给网络层
- 网络层:IP ----> 封装时包装源IP地址和目标IP地址,然后传递给链路层
- 链路层:封装成数据帧(以太网帧),封装后发送到物理网络中传输
接收方 (解包):
- 链路层:数据包到达目标机器,目的机器得到数据包,去掉数据包的头尾,然后传递给网络层
- 网络层:确认IP报文的目标是否是自己,如果是则继续处理,如果不是则抛弃,如果数据包分片则收集全部分片,然后传递为传输层
- 传输层:检查相应的端口字段,然后传递给应用层
- 应用层:消息抵达
二、socket网络编程
- 字节序
关于字节序,在网络传递过程中,一般操作数据是使用大端存储,而我们的电脑PC机一般使用小端存储,所以在处理数据传递时,需要进行一些转换,计算机才能处理这些数据
-
Little-Endian -> 主机字节序 (小端)
-
数据的低位字节存储到内存的
低地址位
, 数据的高位字节存储到内存的高地址位
,即低位字节在前,高位字节在后
。 -
我们使用的PC机,数据的存储默认使用的是
小端
-
-
Big-Endian -> 网络字节序 (大端)
- 据的低位字节存储到内存的
高地址位
, 数据的高位字节存储到内存的低地址位
,即高位字节在前,低位字节在后
。
- 据的低位字节存储到内存的
-
套接字通信过程中操作的数据都是大端存储的,包括:接收/发送的数据、IP地址、端口。
我们规定低地址到高地址从左到右逐渐变大,低地址 —–> 高地址
高位字节和低位字节就是按照数学中的个位,百位,千位等来看,例如如下的
0x12345678
,右边的是低位,左边的是高位,与上面的低高地址相反例如:,一个32位的数字
0x12345678
在大端存储中存储为
12 34 56 78
。在小端存储中存储为
78 56 34 12
。
下面的函数描述了如何将一个字符串类型的IP地址进行大小端转换:
-
inet_pton(Presentation to Numeric):将点分十进制的IPv4地址或冒号分隔的IPv6地址转换为二进制格式。
int inet_pton(int af, const char *src, void *dst);
例如,将字符串"192.168.0.1"转换为网络字节序的二进制形式。
-
inet_ntop(Numeric to Presentation):将二进制格式的IPv4或IPv6地址转换为文本表示形式。
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
例如,将二进制形式的地址转换为字符串"192.168.0.1"。
三、TCP通信流程

-
三次握手
- 第一次握手:客户端向服务器发送连接请求
- 第二次握手:服务器向客户端发送确认和同意连接的响应
- 第三次握手:客户端向服务器发送确认响应
-
四次挥手:
-
第一次挥手:客户端发起连接终止请求
- 客户端:发送一个FIN(终止)段,表示希望关闭连接。
- FIN标志位被设置为1。
- 客户端停止发送数据,但仍可以接收来自服务器的数据。
- FIN段的序列号为客户端的最后一个数据包的序列号加1。
- 服务器:接收到客户端的FIN段。
- 服务器确认接收到客户端的FIN段,向客户端发送一个ACK(确认)段。
- ACK段的确认号为客户端的FIN段的序列号加1。
- 服务器进入**半关闭(半关闭)**状态,表示服务器仍可以向客户端发送数据,但不再接收来自客户端的数据。
- 客户端:发送一个FIN(终止)段,表示希望关闭连接。
-
**第二次挥手:服务器确认收到客户端的终止请求**
-
服务器
:发送ACK段以确认收到客户端的FIN段。
- ACK标志位被设置为1。
- ACK段的确认号为客户端的FIN段的序列号加1。
- 服务器继续发送数据,如果有未发送的数据。
-
-
**第三次挥手:服务器发起自己的连接终止请求**
- 服务器:完成数据发送后,向客户端发送一个FIN段,表示希望关闭连接。
- FIN标志位被设置为1。
- FIN段的序列号为服务器最后一个数据包的序列号加1。
- 客户端:接收到服务器的FIN段。
- 客户端确认接收到服务器的FIN段,向服务器发送一个ACK段。
- ACK段的确认号为服务器的FIN段的序列号加1。
- 客户端进入TIME_WAIT状态,等待一定的时间以确保服务器接收到ACK段。
- 服务器:完成数据发送后,向客户端发送一个FIN段,表示希望关闭连接。
-
**第四次挥手:客户端确认收到服务器的终止请求**
-
客户端
:发送ACK段以确认收到服务器的FIN段。
- ACK标志位被设置为1。
- ACK段的确认号为服务器的FIN段的序列号加1。
- 客户端进入TIME_WAIT状态,等待2个最大报文段寿命(MSL,Maximum Segment Lifetime)时间,以确保服务器接收到ACK段。
-
-
四、 服务器端通信流程
- 创建用于监听的套接字, 这个套接字是一个文件描述符
int lfd = socket();
- 将得到的监听的文件描述符和本地的IP 端口进行绑定
bind()
- 设置监听(成功之后开始监听, 监听的是客户端的连接
listen()
- 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的),没有新连接请求就阻塞
int cfd = accept();
- 通信,读写操作默认都是阻塞的
// 接收数据
read(); / recv();
// 发送数据
write(); / send();
- 断开连接, 关闭套接字
close();
在tcp的服务器端, 有两类文件描述符
- 监听的文件描述符
- 只需要有一个
- 不负责和客户端通信, 负责检测客户端的连接请求, 检测到之后调用accept就可以建立新的连接
- 通信的文件描述符
- 负责和建立连接的客户端通信
- 如果有N个客户端和服务器建立了新的连接, 通信的文件描述符就有N个,每个客户端和服务器都对应一个通信的文件描述符
示例:
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstring> // 为了使用memset
#include <iostream>
#include "wrap.h"
// 接收到的消息转换为大写,然后发送回客户端
int main() {
struct sockaddr_in server_addr, client_addr;
int server_fd = Socket(AF_INET, SOCK_STREAM, 0); // 创建socket文件描述符
// 设置服务器地址结构体
memset(&server_addr, 0, sizeof(server_addr)); // 清零
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 任何地址
server_addr.sin_port = htons(6666); // 端口6666
Bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)); // 绑定socket到服务器地址
Listen(server_fd, 10); // 监听连接
std::cout << "Server is listening on port 6666" << std::endl;
socklen_t client_len = sizeof(client_addr);
// 接受客户端连接
int client_fd = Accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
char buffer[BUFSIZ]; // BUFSIZ来自<unistd.h>,通常足够大
int bytes_read;
while ((bytes_read = read(client_fd, buffer, BUFSIZ)) > 0) { // 读取数据
buffer[bytes_read] = '\0'; // 确保字符串结束
std::cout << "Received message: " << buffer << std::endl; // 打印接收到的消息
// 将接收到的消息转换为大写
for (int i = 0; i < bytes_read; ++i) {
buffer[i] = toupper(buffer[i]);
}
// 将转换后的消息发送回客户端
// send(client_fd, buffer, bytes_read, 0);
write(client_fd, buffer, bytes_read);
}
// 关闭连接
close(client_fd);
close(server_fd);
return 0;
}
五、客户端的通信流程
- 创建一个通信的套接字
int cfd = socket();
- 连接服务器, 需要知道服务器绑定的IP和端口
connect();
- 通信
// 接收数据
read(); / recv();
// 发送数据
write(); / send();
- 断开连接, 关闭文件描述符(套接字)
close()
示例:
/**
* @brief This program demonstrates a simple TCP client that connects to a server and sends/receives messages.
*/
#include <arpa/inet.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <iostream>
#include "wrap.h"
// 创建socket,连接到服务器,发送消息,接收服务器回复
int main() {
struct sockaddr_in serverAddr;
// 创建socket
int sock = Socket(AF_INET, SOCK_STREAM, 0);
// 设置服务器地址
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(6666); // 服务器监听的端口
// 将点分十进制的IP地址转换为用于网络通信的数值格式
Inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);
// 连接到服务器
Connect(sock, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
std::cout << "Connected to the server." << std::endl;
// 发送数据
const char* message = "Hello from client";
std::cout << "Message sent" << std::endl;
// 接收服务器回复
char buffer[BUFSIZ] = {0};
while (1) {
// send(sock, message, strlen(message), 0);
write(sock, message, strlen(message));
sleep(1);
// 接收服务器回复
int bytesReceived = read(sock, buffer, sizeof(buffer));
if (bytesReceived == -1) {
std::cout << "Read failed" << std::endl;
return -1;
}
// 打印服务器回复
std::cout << "Received from server: " << buffer << std::endl;
}
// 关闭socket
close(sock);
return 0;
}
六、连接与数据交互过程
-
文件描述符对应的内存结构:
-
一个文件文件描述符对应两块内存, 一块内存是读缓冲区, 一块内存是写缓冲区
-
读数据: 通过文件描述符将内存中的数据读出, 这块内存称之为读缓冲区
-
写数据: 通过文件描述符将数据写入到某块内存中, 这块内存称之为写缓冲区
-
-
监听的文件描述符:
-
客户端的连接请求会发送到服务器端监听的文件描述符的读缓冲区中
-
读缓冲区中有数据, 说明有新的客户端连接
-
调用accept()函数, 这个函数会检测监听文件描述符的读缓冲区
-
检测不到数据, 该函数阻塞
-
如果检测到数据, 解除阻塞, 新的连接建立
-
-
-
通信的文件描述符:
- 客户端和服务器端都有通信的文件描述符
- 发送数据:调用函数 write() / send(),数据进入到内核中
- 数据并没有被发送出去, 而是将数据写入到了通信的文件描述符对应的写缓冲区中
- 内核检测到通信的文件描述符写缓冲区中有数据, 内核会将数据发送到网络中
- 接收数据: 调用的函数 read() / recv(), 从内核读数据
- 数据如何进入到内核程序猿不需要处理, 数据进入到通信的文件描述符的读缓冲区中
- 数据进入到内核, 必须使用通信的文件描述符, 将数据从读缓冲区中读出即可
该函数阻塞
- 如果检测到数据, 解除阻塞, 新的连接建立
- 通信的文件描述符:
- 客户端和服务器端都有通信的文件描述符
- 发送数据:调用函数 write() / send(),数据进入到内核中
- 数据并没有被发送出去, 而是将数据写入到了通信的文件描述符对应的写缓冲区中
- 内核检测到通信的文件描述符写缓冲区中有数据, 内核会将数据发送到网络中
- 接收数据: 调用的函数 read() / recv(), 从内核读数据
- 数据如何进入到内核程序猿不需要处理, 数据进入到通信的文件描述符的读缓冲区中
- 数据进入到内核, 必须使用通信的文件描述符, 将数据从读缓冲区中读出即可