Socket编程入门:从概念到实战
Socket编程,说白了就是让两台计算机能互相"说话"。我刚开始接触网络编程时,总觉得这东西很神秘——数据怎么就从一台机器跑到另一台机器上了?后来才明白,Socket就是操作系统提供的一个"门",程序通过这个门把数据扔出去,或者从外面接进来。
今天咱们就把Socket的底裤扒干净,从概念到类型,再到编程流程和常用API,一步到位。
什么是Socket?
Socket这个词直译是"插座",我觉得挺形象的。你想啊,插座是电器和电网之间的接口,Socket就是应用程序和网络之间的接口。
从技术角度说,Socket是操作系统提供的一个抽象层。它封装了TCP/IP协议族的复杂性,让我们可以用文件描述符的方式操作网络通信。嗯,这里要注意:在Linux世界里,一切皆文件,Socket也不例外。
核心理解:Socket = IP地址 + 端口号 + 协议。这三者唯一确定了一个网络通信的端点。
我在项目中遇到过不少新手,上来就问"Socket和TCP是什么关系?"其实很简单:Socket是编程接口,TCP是传输协议。你用Socket这个工具,底层走的是TCP还是UDP,由你创建Socket时指定的类型决定。
Socket的两种类型
Socket类型主要分两种:流式Socket和数据报Socket。说白了就是一个面向连接,一个无连接。
| 特性 | 流式Socket (SOCK_STREAM) | 数据报Socket (SOCK_DGRAM) |
|---|---|---|
| 底层协议 | TCP | UDP |
| 连接性 | 面向连接 | 无连接 |
| 数据可靠性 | 可靠,保证到达 | 不可靠,可能丢包 |
| 数据边界 | 无边界(字节流) | 有边界(数据报) |
| 典型应用 | HTTP、FTP、SSH | DNS、视频直播、游戏 |
你想想看,流式Socket就像打电话——先拨号建立连接,然后双方你一句我一句地聊,顺序不乱,数据完整。数据报Socket就像寄明信片——写好地址扔邮筒里,对方能不能收到、什么时候收到,你没法保证。
我的建议:如果你做文件传输、网页服务这类要求数据完整的应用,用流式Socket。如果是视频直播、在线游戏这类可以容忍少量丢包但要求低延迟的场景,数据报Socket更合适。
Socket编程流程
Socket编程的流程其实很固定,我把它总结为"服务端五步走,客户端三步走"。
服务端流程
- 创建Socket — 用socket()函数
- 绑定地址 — 用bind()函数,把IP和端口绑到Socket上
- 监听连接 — 用listen()函数,告诉内核可以接受连接了
- 接受连接 — 用accept()函数,阻塞等待客户端连上来
- 收发数据 — 用read()/write()或recv()/send()
客户端流程
- 创建Socket — 同样用socket()
- 连接服务端 — 用connect()函数,指定服务端的IP和端口
- 收发数据 — 和服务端一样
我曾经犯过一个低级错误:服务端忘了调用bind()就直接listen()了。结果listen()返回成功,但客户端怎么也连不上。查了半天才发现,没绑定地址时内核会随机分配端口,客户端连的当然不是我想要的端口。嗯,这种坑踩过一次就记住了。
注意:accept()函数会创建一个新的Socket用于和客户端通信,原来的监听Socket继续等待其他连接。很多人刚开始搞混这一点。
常用API函数详解
下面这几个函数是Socket编程的"基本功",我建议你背下来。
1. socket() — 创建Socket
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
// 示例:创建TCP Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 示例:创建UDP Socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
参数说明:
- domain:协议族,AF_INET表示IPv4,AF_INET6表示IPv6
- type:Socket类型,SOCK_STREAM流式,SOCK_DGRAM数据报
- protocol:协议,传0表示自动选择
2. bind() — 绑定地址
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 示例
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
serv_addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
避坑指南:我曾经把端口号直接写成8080,没调用htons()做字节序转换。结果绑定的端口完全不对。记住:网络字节序是大端,主机字节序可能是小端,必须用htons()/htonl()转换。
3. listen() — 监听连接
#include <sys/socket.h>
int listen(int sockfd, int backlog);
// 示例:最大等待队列长度为5
listen(sockfd, 5);
backlog参数指定了连接请求队列的最大长度。如果客户端连接请求来得太快,超过这个数,多余的会被拒绝。
4. accept() — 接受连接
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 示例
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(sockfd, (struct sockaddr*)&cli_addr, &cli_len);
accept()会阻塞,直到有客户端连接上来。返回的connfd就是和客户端通信的专用通道。
5. connect() — 连接服务端
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 示例
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
inet_pton(AF_INET, "192.168.1.100", &serv_addr.sin_addr);
connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
6. 数据收发函数
// TCP收发
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
// UDP收发
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
send()和recv()是TCP专用的,因为TCP是面向连接的,不需要每次指定对方地址。而UDP的sendto()和recvfrom()每次都要带上地址信息。
Socket编程核心流程图
下面这张图把整个流程串起来了,你一看就明白。
一个完整的TCP回射服务器示例
光说不练假把式。下面这个例子虽然简单,但包含了Socket编程的所有核心步骤。
/* 服务端代码:tcp_echo_server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int listen_fd, conn_fd;
struct sockaddr_in serv_addr, cli_addr;
socklen_t cli_len = sizeof(cli_addr);
char buffer[BUFFER_SIZE];
// 1. 创建Socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
// 2. 绑定地址
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
serv_addr.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind");
exit(1);
}
// 3. 监听
if (listen(listen_fd, 5) < 0) {
perror("listen");
exit(1);
}
printf("服务器已启动,监听端口 %d...\n", PORT);
// 4. 接受连接
conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_len);
if (conn_fd < 0) {
perror("accept");
exit(1);
}
printf("客户端已连接\n");
// 5. 收发数据
while (1) {
memset(buffer, 0, BUFFER_SIZE);
int n = recv(conn_fd, buffer, BUFFER_SIZE - 1, 0);
if (n <= 0) {
printf("客户端断开连接\n");
break;
}
printf("收到: %s", buffer);
send(conn_fd, buffer, n, 0);
}
close(conn_fd);
close(listen_fd);
return 0;
}
/* 客户端代码:tcp_echo_client.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int sock_fd;
struct sockaddr_in serv_addr;
char buffer[BUFFER_SIZE];
// 1. 创建Socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0) {
perror("socket");
exit(1);
}
// 2. 连接服务端
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
if (connect(sock_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("connect");
exit(1);
}
printf("已连接到服务器\n");
// 3. 收发数据
while (1) {
printf("请输入消息: ");
fgets(buffer, BUFFER_SIZE, stdin);
send(sock_fd, buffer, strlen(buffer), 0);
memset(buffer, 0, BUFFER_SIZE);
recv(sock_fd, buffer, BUFFER_SIZE - 1, 0);
printf("服务器回射: %s", buffer);
}
close(sock_fd);
return 0;
}
运行方法:先在一个终端编译运行服务端,再在另一个终端编译运行客户端。客户端输入什么,服务端就回射什么。
总结
Socket编程说白了就是"创建、绑定、监听、接受、连接、收发、关闭"这七个动作的排列组合。流式Socket用TCP,数据报Socket用UDP,选哪个看你的业务场景。
我个人习惯在写Socket程序时,每一步都检查返回值。别嫌麻烦,网络编程里90%的bug都是因为没检查返回值导致的。你想想看,bind()失败了你还继续往下走,那后面的accept()能成功才怪。
好了,这一章的内容就到这儿。代码拿回去跑一跑,有问题随时翻回来看看这张流程图。