套接字通信

1.基本概念

1.1 局域网和广域网

局域网:局域网将一定区域内的各种计算机、外部设备和数据库连接起来形成计算机通信的私有网络。、

  • 局域网的主要特点是传输速度快、延迟低、成本较低且安全性高。

广域网:又称广域网、外网、公网。是连接不同地区局域网或城域网计算机通信的远程公共网络

  • 广域网相对于局域网来说,传输速度较慢,延迟较高。

1.2 IP和端口

IP: 是分配给网络上每个设备的唯一标识符,用于在网络中进行通信。

  • IPv4

    • IPv4使用32位整数来表示一个IP地址,这相当于4个字节。通常,这个32位的地址被分为四部分,每部分8位(1字节),并以点分十进制格式表示。
    • 最小的IPv4地址是 0.0.0.0,而最大的是 255.255.255.255
    • 鉴于IPv4采用32位地址,理论上总共可以提供 ​个不同的地址
  • IPv6

    • IPv6使用128位整数来表示一个IP地址,等同于16个字节
    • Pv6地址写作8组,每组4个十六进制数字,各组之间用冒号分隔,例如 2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b
    • 由于IPv6采用128位地址长度,所以它可以提供的地址总数为

端口: 用于定位主机上的特定进程。

  • 端口号是一个16位的无符号整数(unsigned short),其取值范围是0到65535(​)

  • 只有那些涉及网络通信的进程才需要绑定端口。如果一个进程不需要与网络上的其他设备进行通信,那么它就不必绑定端口。

  • 一个端口可以被重复使用吗?

    在任何给定时间,一个具体的端口只能由一个进程独占使用。这意味着在同一时刻,多个进程不能同时监听同一个端口以接收数据。然而,一旦某个进程完成了对特定端口的使用并释放了它,该端口就可以被新的进程重新绑定和使用。此外,在一些情况下,如TCP连接结束后,可能会有一段等待时间(TIME_WAIT状态),在此期间端口暂时不可用,以确保网络上的数据包不会被错误地路由到新建立的连接上。

1.3 网络分层模型

image-20250325150840912

  • 应用层:直接为用户提供服务,负责处理特定的应用程序细节。

  • 表示层:将数据转换为兼容格式进行传输,包括加密、压缩等操作。

  • 会话层:管理不同机器上进程之间的对话,控制对话连接的建立和终止。
  • 传输层:确保端到端的数据可靠传输,提供错误检测和恢复功能。主要协议有TCP和UDP。
  • 网络层:负责数据包的路由选择和转发,决定数据如何从源地址到达目的地址。IP协议工作在此层。
  • 数据链路层:在不可靠的物理连接上提供可靠的数据传输,处理错误检测和纠正。以太网协议工作于此层。
  • 物理层:定义了硬件设备的标准,如电压水平、线缆类型、针脚布局等,以及如何通过物理媒介传输比特流。

2.socket编程

2.1 字节序

字节序: 大于一个字节类型的数据在内存中的存放顺序,也就是说对于单字符来说是没有字节序问题的,字符串是单字符的集合,因此字符串也没有字节序问题。

  • 主机字节序 (小端): 数据的低位字节存储到内存的低地址位, 数据的高位字节存储到内存的高地址位

  • 网络字节序 (大端): 数据的低位字节存储到内存的高地址位, 数据的高位字节存储到内存的低地址位

    注:套接字通信过程中操作的数据都是大端存储的,包括:接收/发送的数据、IP地址、端口。

2.2 大小端转换

主机字节序的IP地址转换为网络字节序:

1
int inet_pton(int af, const char *src, void *dst); 

af: 地址族(IP地址的家族包括ipv4和ipv6)协议

  • AF_INET: ipv4格式的ip地址
  • AF_INET6: ipv6格式的ip地址

src: 传入参数

dst: 传出参数, 函数调用完成, 转换得到的大端整形IP被写入到这块内存中

返回值:成功返回1,失败返回0或者-1

将大端的整形数, 转换为小端的点分十进制的IP地址 :

1
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

af: 地址族(IP地址的家族包括ipv4和ipv6)协议

  • AF_INET: ipv4格式的ip地址
  • AF_INET6: ipv6格式的ip地址

src: 传入参数, 这个指针指向的内存中存储了大端的整形IP地址

dst: 传出参数, 存储转换得到的小端的点分十进制的IP地址

size: 修饰dst参数的, 标记dst指向的内存中最多可以存储多少个字节

返回值:

  • 成功: 指针指向第三个参数对应的内存地址, 通过返回值也可以直接取出转换得到的IP字符串
  • 失败: NULL

2.3 sockaddr

sockaddr: 一个通用的、协议无关的套接字地址结构。

1
2
3
4
struct sockaddr {
sa_family_t sa_family; // 地址族协议, 比如 IPv4 (AF_INET)
char sa_data[14]; // 端口(2字节) + IP地址(4字节) + 填充(8字节)
};

sockaddr_in: 是专门针对 IPv4 协议设计的套接字地址结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t; // 端口类型
typedef uint32_t in_addr_t; // IP 地址类型
typedef unsigned short int sa_family_t; // 地址族类型
#define __SOCKADDR_COMMON_SIZE (sizeof(unsigned short int))

struct sockaddr_in {
sa_family_t sin_family; // 地址族协议: AF_INET
in_port_t sin_port; // 端口, 2 字节 -> 大端
struct in_addr sin_addr; // IP 地址, 4 字节 -> 大端
unsigned char sin_zero[8]; // 填充 8 字节
};
struct in_addr {
in_addr_t s_addr; // IP 地址,4 字节
};

2.4 套接字函数

1
2
// 创建一个套接字
int socket(int domain, int type, int protocol);

domain: 使用的地址族协议

  • AF_INET: 使用IPv4格式的ip地址
  • AF_INET6: 使用IPv6格式的ip地址

type:

  • SOCK_STREAM: 使用流式的传输协议
  • SOCK_DGRAM: 使用报式(报文)的传输协议

protocol: 一般写0即可, 使用默认的协议

  • SOCK_STREAM: 流式传输默认使用的是tcp
  • SOCK_DGRAM: 报式传输默认使用的udp

函数的返回值是一个文件描述符,通过这个文件描述符可以操作内核中的某一块内存,网络通信是基于这个文件描述符来完成的。

2.5 bind

1
2
// 将文件描述符和本地的IP与端口进行绑定   
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数:

  • sockfd: 监听的文件描述符, 通过socket()调用得到的返回值
  • addr: 传入参数, 要绑定的IP和端口信息需要初始化到这个结构体中,IP和端口要转换为网络字节序
  • addrlen: 参数addr指向的内存大小, sizeof(struct sockaddr)

3.TCP通信流程

image-20250325161611700

image-20250325161638441

在tcp的服务器端, 有两类文件描述符:

  • 监听的文件描述符

    • 只需要有一个
    • 不负责和客户端通信, 负责检测客户端的连接请求, 检测到之后调用accept就可以建立新的连接
  • 通信的文件描述符

    • 负责和建立连接的客户端通信
    • 如果有N个客户端和服务器建立了新的连接, 通信的文件描述符就有N个,每个客户端和服务器都对应一个通信的文件描述符

基于tcp的服务器端通信代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// server.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main()
{
// 1. 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket");
exit(0);
}

// 2. 将socket()返回值和本地的IP端口绑定到一起
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000); // 大端端口
// INADDR_ANY代表本机的所有IP, 假设有三个网卡就有三个IP地址
// 这个宏可以代表任意一个IP地址
// 这个宏一般用于本地的绑定操作
addr.sin_addr.s_addr = INADDR_ANY; // 这个宏的值为0 == 0.0.0.0
// inet_pton(AF_INET, "192.168.237.131", &addr.sin_addr.s_addr);
int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("bind");
exit(0);
}

// 3. 设置监听
ret = listen(lfd, 128);
if(ret == -1)
{
perror("listen");
exit(0);
}

// 4. 阻塞等待并接受客户端连接
struct sockaddr_in cliaddr;
int clilen = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &clilen);
//(struct sockaddr*)&cliaddr:用于存储客户端的地址信息(IP 地址和端口号)。
if(cfd == -1)
{
perror("accept");
exit(0);
}
// 打印客户端的地址信息
char ip[24] = {0};
printf("客户端的IP地址: %s, 端口: %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ip, sizeof(ip)),
ntohs(cliaddr.sin_port));

// 5. 和客户端通信
while(1)
{
// 接收数据
char buf[1024];
memset(buf, 0, sizeof(buf));
int len = read(cfd, buf, sizeof(buf));
if(len > 0)
{
printf("客户端say: %s\n", buf);
write(cfd, buf, len);
}
else if(len == 0)
{
printf("客户端断开了连接...\n");
break;
}
else
{
perror("read");
break;
}
}
close(cfd);
close(lfd);
return 0;
}

基于tcp通信的客户端通信代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// client.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main()
{
// 1. 创建通信的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1)
{
perror("socket");
exit(0);
}

// 2. 连接服务器
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000); // 大端端口
inet_pton(AF_INET, "192.168.237.131", &addr.sin_addr.s_addr);

int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("connect");
exit(0);
}

// 3. 和服务器端通信
int number = 0;
while(1)
{
// 发送数据
char buf[1024];
sprintf(buf, "你好, 服务器...%d\n", number++);
write(fd, buf, strlen(buf)+1);

// 接收数据
memset(buf, 0, sizeof(buf));
int len = read(fd, buf, sizeof(buf));
if(len > 0)
{
printf("服务器say: %s\n", buf);
}
else if(len == 0)
{
printf("服务器断开了连接...\n");
break;
}
else
{
perror("read");
break;
}
sleep(1); // 每隔1s发送一条数据
}

close(fd);

return 0;
}

4.补充

4.1 什么是 Socket?

Socket 可以看作是一个应用程序与网络之间的接口,它定义了如何进行数据发送和接收的方式。

4.2 Socket 的类型有哪些?

  • 流式套接字(SOCK_STREAM):基于 TCP 协议,面向连接,可靠。
  • 数据报套接字(SOCK_DGRAM):基于 UDP 协议,无连接,不可靠。

4.3 如何创建一个 Socket?

创建一个 Socket需要指定所使用的地址族和协议类型

1
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
  • AF_INET:IPv4 地址族。
  • SOCK_STREAM:流式套接字。
  • 第三个参数通常为 0,表示使用默认协议。

4.4 bind 函数的作用是什么?

将 Socket 绑定到指定的 IP 地址和端口号。

4.5 listen 函数的作用是什么?

将 Socket 设置为监听状态(它并不会阻塞程序,而是告诉操作系统:“我现在准备好了,可以开始接受客户端的连接请求了。”)

4.6 accept 函数的作用是什么?

进入阻塞状态等待接受客户端的连接请求,并返回一个新的 Socket 文件描述符用于通信。

4.7 connect 函数的作用是什么?

客户端调用该函数向服务器发起连接请求。

4.8 服务器中的两个文件描述符各自的作用?

服务器上分别有用于监听的文件描述符和用于通信的文件描述符,其各自有两个缓冲区,读缓冲区和写缓冲区。

监听的文件描述符所对应的读缓冲区用于接收客户端的连接请求,当调用accept()时会检测监听的文件描述符的读缓冲区是否有请求数据,如果有请求数据accept()会解除阻塞和对应的客户端建立连接。

通信的文件描述符的读缓冲区用于存储客户端所发送的通信数据,调用read()(没有数据则阻塞)可以把数据从读缓冲区读出来,write()把数据再写入写缓冲区(缓冲区满则阻塞),然后内核会将写缓冲区的数据发送给对应的客户端的读缓冲区。

4.9 read/recv write/send区别?