这篇文章讲了 socket 编程的基础流程,包括客户端和服务端的交互过程

socket编程

socket

通信测试代码

服务端

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
#include<unistd.h>      // 提供read/write/close等系统调用
#include<stdio.h> // 标准输入输出
#include<stdlib.h> // 标准库(exit等)
#include<string.h> // 字符串操作(memset等)
#include<stdint.h> // 整型类型(uint16_t等)
#include<arpa/inet.h> // 网络编程(inet_ntop/htons等)

int main(){
// 1. 创建监听套接字(lfd):IPv4 + 流式TCP + 默认协议
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1) {
perror("socket failed");
return -1;
}

// 2. 初始化服务端地址结构体:绑定10000端口 + 监听所有网卡
struct sockaddr_in saddr;
saddr.sin_family = AF_INET; // IPv4协议
saddr.sin_port = htons(10000); // 服务端端口(转网络字节序)
saddr.sin_addr.s_addr = INADDR_ANY; // 监听本机所有IP(0.0.0.0)

// 3. 绑定:将监听套接字与服务端地址绑定
int bind_res = bind(lfd, (struct sockaddr*)&saddr, sizeof(saddr));
if(bind_res == -1){
close(lfd); // 绑定失败,释放已创建的lfd
perror("bind failed");
return -1;
}

// 4. 监听:将lfd设为监听状态,最大等待连接数128
int listen_res = listen(lfd, 128);
if(listen_res == -1){
close(lfd); // 监听失败,释放lfd
perror("listen failed");
return -1;
}

// 5. 准备接收客户端地址信息:存储客户端的IP/端口
struct sockaddr_in caddr;
socklen_t caddr_len = sizeof(caddr);

// 6. 阻塞等待客户端连接:成功返回通信套接字(cfd)
int cfd = accept(lfd, (struct sockaddr*)&caddr, &caddr_len);
if(cfd == -1){
close(lfd); // 接收失败,释放lfd
perror("accept failed");
return -1;
}

// 7. 解析客户端IP和端口(网络字节序转主机字节序)
char client_ip[INET_ADDRSTRLEN]; // INET_ADDRSTRLEN是IPv4地址字符串最大长度(16)
socklen_t client_ip_len = sizeof(client_ip);
inet_ntop(AF_INET, &caddr.sin_addr.s_addr, client_ip, client_ip_len); // 二进制IP转字符串
uint16_t client_port = ntohs(caddr.sin_port); // 网络端口转主机端口

printf("客户端的地址:%s, 端口:%d\n", client_ip, client_port);

// 8. 循环收发数据:回显客户端发送的内容
while(1){
char buffer[1024];
memset(buffer, 0, sizeof(buffer)); // 清空缓冲区
int read_res = read(cfd, buffer, sizeof(buffer)); // 从客户端读数据

if(read_res > 0){ // 成功读取到数据
printf("客户端:%s\n", buffer);
write(cfd, buffer, read_res); // 回显给客户端
}
else if(read_res == 0){ // 客户端关闭连接(read返回0)
printf("客户端断开连接...\n");
break; // 退出循环,关闭连接
}
else{ // read失败(返回-1)
perror("read failed");
return -1;
}
}

// 9. 释放资源:先关通信套接字,再关监听套接字
close(lfd);
close(cfd);

return 0;
}

客户端

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
#include<unistd.h>      // 提供read/write/close/sleep等系统调用
#include<stdio.h> // 标准输入输出
#include<stdlib.h> // 标准库(exit等)
#include<arpa/inet.h> // 网络编程(inet_pton/htons等)
#include<string.h> // 字符串操作(memset/strlen等)

int main(){
// 1. 创建客户端套接字(cfd):IPv4 + 流式TCP + 默认协议
int cfd = socket(AF_INET, SOCK_STREAM, 0);
if(cfd == -1){
perror("socket failed");
return -1;
}

// 2. 初始化服务端地址结构体:指定要连接的服务端IP和端口
struct sockaddr_in saddr;
saddr.sin_family = AF_INET; // IPv4协议
saddr.sin_port = htons(10000); // 服务端端口(转网络字节序)
// 将字符串IP(192.168.43.125)转为二进制网络字节序IP
int res = inet_pton(AF_INET, "192.168.43.125", &saddr.sin_addr.s_addr);
if(res == -1){ // 注意:inet_pton返回-1是失败,返回0是IP格式错误
close(cfd); // 转换失败,释放套接字
perror("IP convert failed");
return -1;
}

// 3. 发起连接:向服务端10000端口发起TCP连接
int con_res = connect(cfd, (struct sockaddr*)&saddr, sizeof(saddr));
if(con_res == -1){
close(cfd); // 连接失败,释放套接字
perror("connect failed");
return -1;
}

// 4. 循环收发数据:每秒发送带计数的字符串,接收服务端回显
int number = 0;
while(1){
char buffer[1024];
// 格式化字符串:"连接服务器成功...0/1/2..."
snprintf(buffer, sizeof(buffer), "连接服务器成功...%d", number++);
// 发送数据:strlen(buffer)+1 包含末尾的\0,确保服务端能识别字符串结束
int write_res = write(cfd, buffer, strlen(buffer)+1);//不能用 sizeof(),这里需要具体写入的大小,sizeof()会直接返回1024
if(write_res == -1){
close(cfd);
perror("write failed");
return -1;
}

// 清空缓冲区,准备接收服务端回显
memset(buffer, 0, sizeof(buffer));
int read_res = read(cfd, buffer, sizeof(buffer));
if(read_res > 0){ // 成功接收回显数据
printf("服务端:%s\n", buffer);
}
else if(read_res == 0){ // 服务端关闭连接
printf("服务器断开了连接...\n");
break;
}
else{ // read失败
close(cfd);
perror("read failed");
return -1;
}

sleep(1); // 每秒发送一次
}

// 5. 释放资源:关闭客户端套接字
close(cfd);
return 0;
}

socket函数

1
2
3
#include <sys/socket.h>

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

返回值:

  • 成功:可用于通信的文件描述符
  • 失败:-1

bind函数

1
2
3
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

参数:

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

返回值:

  • 成功返回0,失败返回-1

sockaddr数据结构(简化)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 通用的地址结构体(sockaddr) 
struct sockaddr{
sa_family_t sa_family; // 地址族(AF_INET/AF_INET6/AF_UNIX等)
char sa_data[14]; // 存放具体地址数据(不同协议格式不同)
};

// IPv4专用地址结构体(sockaddr_in)
struct sockaddr_in {
sa_family_t sin_family; // 地址族,固定为IPv4
AF_INET in_port_t sin_port; // 端口号(网络字节序)
struct in_addr sin_addr; // IPv4地址(32位)
char sin_zero[8]; // 填充字段,使总大小和sockaddr一致
};

// IPv4地址的具体结构
struct in_addr {
uint32_t s_addr; // 32位IPv4地址(网络字节序)
};

bind()函数实际初始化时会创建sockaddr_in的结构体,基于这个结构体进行数据的初始化,然后再将sockaddr_in进行强制类型转换,转换成sockaddr类型。

为什么要分两个结构体?

  • sockaddr 是通用接口,保证兼容性

    网络编程的核心函数(bind()/connect()/accept() 等)需要支持多种网络协议(IPv4、IPv6、Unix 域套接字等),如果为每种协议都设计一套函数(比如 bind_ipv4()bind_ipv6()),会导致接口混乱。

    因此设计了通用的 sockaddr 结构体作为函数参数类型,通过 sa_family 字段区分不同协议,保证函数接口的统一性。

  • sockaddr_in 是专用结构体,提升易用性

    sockaddrsa_data 是一个字节数组,直接操作它需要手动拆分 / 拼接端口和 IP(比如前 2 字节是端口,后 4 字节是 IP),极易出错且可读性差。

    sockaddr_in 把端口、IP 拆分成独立的字段,开发者可以直接赋值(比如 sin_port = htons(8080)sin_addr.s_addr = inet_addr("127.0.0.1")),大幅降低编程难度。

  • 强制类型转换的前提:内存布局兼容

    sockaddr_in 的总大小(16 字节)和 sockaddr 完全一致:

    • sin_family 对应 sa_family(地址族)

    • sin_port + sin_addr + sin_zero 刚好填充 sa_data[14] 的 14 字节

      因此强制转换时,内存数据不会错位,函数内部可以根据 sa_family 字段,把 sockaddr* 再转回对应协议的专用结构体处理。

代码示例

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
#include <sys/socket.h> //提供套接字编程的基础接口
#include <netinet/in.h> //提供IPv4/IPv6 专用的地址结构体和协议常量。
#include <arpa/inet.h> //提供IP 地址格式转换的工具函数。
#include <unistd.h> //提供Unix/Linux 系统的基础系统调用
#include <stdio.h>

int main() {
// 1. 创建IPv4套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket error"); return -1;
}

// 2. 初始化IPv4专用结构体(sockaddr_in)
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET; // 明确是IPv4协议
server_addr.sin_port = htons(8080); // 端口号(转网络字节序)
server_addr.sin_addr.s_addr = INADDR_ANY; // 绑定所有本地IP
memset(server_addr.sin_zero, 0, sizeof(server_addr.sin_zero)); // 填充0

// 3. 强制转换为通用结构体(sockaddr)传入bind
int bind_result = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));

if (bind_result == -1) {
perror("bind error");
close(sockfd); return -1;
}
printf("bind success, port 8080\n"); close(sockfd);

return 0;
}

补充: IPv6 对应的专用结构体是 sockaddr_in6,同样需要转换为 sockaddr 传入函数,逻辑和 IPv4 完全一致。

sockaddr_in6结构体定义(简化)

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <netinet/in.h> // 必须包含这个头文件才能使用 
struct sockaddr_in6 {
sa_family_t sin6_family; // 地址族,固定为 AF_INET6(IPv6)
in_port_t sin6_port; // 端口号(16位,网络字节序)
uint32_t sin6_flowinfo; // 流信息(IPv6流标签,一般设为0)
struct in6_addr sin6_addr; // IPv6地址(128位)
uint32_t sin6_scope_id; // 作用域ID(用于本地链路地址,如%eth0)
};

// IPv6地址的具体结构
struct in6_addr {
uint8_t s6_addr[16]; // 16个字节(128位)存储IPv6地址,比如::1(本地回环)
};

listen函数

1
2
// 给监听的套接字设置监听
int listen(int sockfd, int backlog);

参数:

  • sockfd: 文件描述符, 可以通过调用socket()得到,在监听之前必须要绑定 bind()
  • backlog: 同时能处理的最大连接要求,之前最大值为128(内核中被写死了)。Linux 内核 2.4.21 之后backlog 不再是被写死 128。现在多传1024(muduo、nginx、redis 等高性能服务器均采用),足够大,能应对高并发突发连接,不超过 somaxconn 的默认最大值(通常 4096)

返回值:

  • 函数调用成功返回0,调用失败返回 -1

accept函数

1
2
// 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的)		
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数:

  • sockfd: 监听的文件描述符
  • addr: 传出参数, 里边存储了建立连接的客户端的地址信息
  • addrlen: 传入传出参数,用于存储addr指向的内存大小

返回值:

  • 函数调用成功,得到一个文件描述符, 用于和建立连接的这个客户端通信,调用失败返回 -1

注意: 如果传普通 int* 而非 socklen_t* → 可能内存越界(socklen_t 是系统定义的无符号整数类型,通常是 32 位);

这个函数是一个阻塞函数,当没有新的客户端连接请求的时候,该函数阻塞;当检测到有新的客户端连接请求时,阻塞解除,新连接就建立了,得到的返回值也是一个文件描述符,基于这个文件描述符就可以和客户端通信了。

传出参数:你给函数一个 “空容器”,函数往里面填充数据,最终把结果 “输出” 给你(比如 accept() 中的 addraddrlen),传出参数是函数向外部返回数据的 “通道”(补充函数返回值的不足 —— 比如 accept() 既要返回通信套接字,又要返回客户端地址,就需要传出参数)。

代码示例

1
2
3
4
5
6
7
// IPv4场景 
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr); // 输入:结构体大小

int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len); // 输出:addr_len 变为实际的客户端地址长度(IPv4下还是16,IPv6下会变)

printf("实际客户端地址长度:%zu\n", addr_len);

accept4可以 一步创建非阻塞客户端 fd(第四个参数可以设置)

connect函数

1
2
3
4
5
#include <sys/socket.h>

// 成功连接服务器之后, 客户端会自动随机绑定一个端口
// 服务器端调用accept()的函数, 第二个参数存储的就是客户端的IP和端口信息
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

参数:

  • sockfd: 通信的文件描述符, 通过调用socket()函数就得到了
  • addr: 存储了要连接的服务器端的地址信息: iP 和 端口,这个IP和端口也需要转换为大端然后再赋值
  • addrlen: addr指针指向的内存的大小 sizeof(struct sockaddr)

返回值:

  • 连接成功返回0,连接失败返回-1

为什么客户端调用 connect() 时不需要先调用 bind()

connect() 会触发操作系统自动为客户端套接字完成「隐式绑定」 —— 分配随机可用的端口和合适的本地 IP,完全满足客户端通信需求,手动 bind() 反而画蛇添足。

收数据read()/recv()函数

1
2
3
// 接收数据
ssize_t read(int sockfd, void *buf, size_t size);
ssize_t recv(int sockfd, void *buf, size_t size, int flags);

参数:

  • sockfd: 用于通信的文件描述符, accept() 函数的返回值
  • buf: 指向一块有效内存, 用于存储接收是数据
  • size: 参数buf指向的内存的容量
  • flags: 特殊的属性, 一般不使用, 指定为 0

返回值:

  • 大于0:实际接收的字节数
  • 等于0:对方断开了连接
  • -1:接收数据失败了

如果连接没有断开,接收端接收不到数据,接收数据的函数会阻塞等待数据到达,数据到达后函数解除阻塞,开始接收数据,当发送端断开连接,接收端无法接收到任何数据,但是这时候就不会阻塞了,函数直接返回0。

1
2
3
// 发送数据的函数
ssize_t write(int fd, const void *buf, size_t len);
ssize_t send(int fd, const void *buf, size_t len, int flags);

参数:

  • fd: 通信的文件描述符, accept() 函数的返回值
  • buf: 传入参数, 要发送的字符串
  • len: 要发送的字符串的长度
  • flags: 特殊的属性, 一般不使用, 指定为 0

返回值:

  • 大于0:实际发送的字节数(如果内核缓冲区快写满了,就可能小于len)
  • -1:发送数据失败了

关闭套接字:close()/shutdown()函数

作用:释放套接字资源,shutdown() 更灵活(可单向关闭)。

1
2
3
4
5
6
#include<unistd.h>

// 完全关闭(常用)
int close(int fd);
// 定向关闭
int shutdown(int sockfd, int how);

参数(shutdown()

  • howSHUT_RD(关读)、SHUT_WR(关写)、SHUT_RDWR(全关)。

    示例

1
2
close(conn_fd); // 关闭通信fd
close(listen_fd); // 关闭监听fd

UDP 通信核心函数(补充)

UDP 是无连接协议,无需 listen()/accept()/connect(),核心用这两个函数:

1. sendto():发送数据到指定服务器

1
2
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);

2. recvfrom():接收数据并获取发送方地址

1
2
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);

两类文件描述符

  • 监听的文件描述符

    • 只需要有一个不负责和客户端通信, 负责检测客户端的连接请求, 检测到之后调用accept就可以建立新的连接
    • 通信的文件描述符负责和建立连接的客户端通信
      如果有N个客户端和服务器建立了新的连接, 通信的文件描述符就有N个,每个客户端和服务器都对应一个通信的文件描述符
  • 文件描述符对应的内存结构:

    • 一个文件文件描述符对应两块内存, 一块内存是读缓冲区, 一块内存是写缓冲区
    • 读数据: 通过文件描述符将内存中的数据读出, 这块内存称之为读缓冲区
    • 写数据: 通过文件描述符将数据写入到某块内存中, 这块内存称之为写缓冲区
  • 监听的文件描述符:

    • 客户端的连接请求会发送到服务器端监听的文件描述符的读缓冲区中
    • 读缓冲区中有数据, 说明有新的客户端连接
    • 调用accept()函数, 这个函数会检测监听文件描述符的读缓冲区
      • 检测不到数据, 该函数阻塞
      • 如果检测到数据, 解除阻塞, 新的连接建立
  • 通信的文件描述符:

    • 客户端和服务器端都有通信的文件描述符
    • 发送数据:调用函数 write() / send(),数据进入到内核中
      • 数据并没有被发送出去, 而是将数据写入到了通信的文件描述符对应的写缓冲区中
      • 内核检测到通信的文件描述符写缓冲区中有数据, 内核会将数据发送到网络中
    • 接收数据: 调用的函数 read() / recv(), 从内核读数据
      • 数据如何进入到内核程序猿不需要处理, 数据进入到通信的文件描述符的读缓冲区中
      • 数据进入到内核, 必须使用通信的文件描述符, 将数据从读缓冲区中读出即可