这篇文章讲了 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> #include<stdio.h> #include<stdlib.h> #include<string.h> #include<stdint.h> #include<arpa/inet.h>
int main(){ int lfd = socket(AF_INET, SOCK_STREAM, 0); if(lfd == -1) { perror("socket failed"); return -1; }
struct sockaddr_in saddr; saddr.sin_family = AF_INET; saddr.sin_port = htons(10000); saddr.sin_addr.s_addr = INADDR_ANY;
int bind_res = bind(lfd, (struct sockaddr*)&saddr, sizeof(saddr)); if(bind_res == -1){ close(lfd); perror("bind failed"); return -1; }
int listen_res = listen(lfd, 128); if(listen_res == -1){ close(lfd); perror("listen failed"); return -1; }
struct sockaddr_in caddr; socklen_t caddr_len = sizeof(caddr);
int cfd = accept(lfd, (struct sockaddr*)&caddr, &caddr_len); if(cfd == -1){ close(lfd); perror("accept failed"); return -1; }
char client_ip[INET_ADDRSTRLEN]; socklen_t client_ip_len = sizeof(client_ip); inet_ntop(AF_INET, &caddr.sin_addr.s_addr, client_ip, client_ip_len); uint16_t client_port = ntohs(caddr.sin_port);
printf("客户端的地址:%s, 端口:%d\n", client_ip, client_port);
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){ printf("客户端断开连接...\n"); break; } else{ perror("read failed"); return -1; } }
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> #include<stdio.h> #include<stdlib.h> #include<arpa/inet.h> #include<string.h>
int main(){ int cfd = socket(AF_INET, SOCK_STREAM, 0); if(cfd == -1){ perror("socket failed"); return -1; }
struct sockaddr_in saddr; saddr.sin_family = AF_INET; saddr.sin_port = htons(10000); int res = inet_pton(AF_INET, "192.168.43.125", &saddr.sin_addr.s_addr); if(res == -1){ close(cfd); perror("IP convert failed"); return -1; }
int con_res = connect(cfd, (struct sockaddr*)&saddr, sizeof(saddr)); if(con_res == -1){ close(cfd); perror("connect failed"); return -1; }
int number = 0; while(1){ char buffer[1024]; snprintf(buffer, sizeof(buffer), "连接服务器成功...%d", number++); int write_res = write(cfd, buffer, strlen(buffer)+1); 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{ close(cfd); perror("read failed"); return -1; }
sleep(1); }
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
返回值:
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)
返回值:
sockaddr数据结构(简化)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| struct sockaddr{ sa_family_t sa_family; char sa_data[14]; };
struct sockaddr_in { sa_family_t sin_family; AF_INET in_port_t sin_port; struct in_addr sin_addr; char sin_zero[8]; };
struct in_addr { uint32_t s_addr; };
|
bind()函数实际初始化时会创建sockaddr_in的结构体,基于这个结构体进行数据的初始化,然后再将sockaddr_in进行强制类型转换,转换成sockaddr类型。
为什么要分两个结构体?
sockaddr 是通用接口,保证兼容性
网络编程的核心函数(bind()/connect()/accept() 等)需要支持多种网络协议(IPv4、IPv6、Unix 域套接字等),如果为每种协议都设计一套函数(比如 bind_ipv4()、bind_ipv6()),会导致接口混乱。
因此设计了通用的 sockaddr 结构体作为函数参数类型,通过 sa_family 字段区分不同协议,保证函数接口的统一性。
sockaddr_in 是专用结构体,提升易用性
sockaddr 的 sa_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> #include <arpa/inet.h> #include <unistd.h> #include <stdio.h>
int main() { int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { perror("socket error"); return -1; } struct sockaddr_in server_addr; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(8080); server_addr.sin_addr.s_addr = INADDR_ANY; memset(server_addr.sin_zero, 0, sizeof(server_addr.sin_zero)); 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; in_port_t sin6_port; uint32_t sin6_flowinfo; struct in6_addr sin6_addr; uint32_t sin6_scope_id; };
struct in6_addr { uint8_t s6_addr[16]; };
|
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)
返回值:
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() 中的 addr 和 addrlen),传出参数是函数向外部返回数据的 “通道”(补充函数返回值的不足 —— 比如 accept() 既要返回通信套接字,又要返回客户端地址,就需要传出参数)。
代码示例
1 2 3 4 5 6 7
| struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);
printf("实际客户端地址长度:%zu\n", addr_len);
|
accept4可以 一步创建非阻塞客户端 fd(第四个参数可以设置)
connect函数
1 2 3 4 5
| #include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
|
参数:
- sockfd: 通信的文件描述符, 通过调用socket()函数就得到了
- addr: 存储了要连接的服务器端的地址信息: iP 和 端口,这个IP和端口也需要转换为大端然后再赋值
- addrlen: addr指针指向的内存的大小 sizeof(struct sockaddr)
返回值:
为什么客户端调用 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()):
1 2
| close(conn_fd); close(listen_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(), 从内核读数据
- 数据如何进入到内核程序猿不需要处理, 数据进入到通信的文件描述符的读缓冲区中
- 数据进入到内核, 必须使用通信的文件描述符, 将数据从读缓冲区中读出即可