网络编程没那么神秘,就像打电话——买手机、插卡、拨号、通话、挂断,五个动作而已。只不过这里换成 C 语言的几个函数调用。
开篇:先建立一个直觉
在学习具体代码之前,先把自己想象成一家外卖店老板,你就容易理解 TCP 通信到底在干嘛了:
| 外卖店场景 | 对应的网络编程概念 | 大白话 |
| 安装一部外卖专用电话 | socket() | 创建通信工具 |
| 印在名片上的电话号码 | bind() 绑定 IP + 端口 | 对外公布你的联系方式 |
| 坐在店里等电话响 | listen() | 进入"等待接单"模式 |
| 客人打电话进来,你拿起话筒 | accept() | 接受连接,建立通话 |
| 客人拨号打给店里 | connect() | 客户端主动发起连接 |
| 双方对话:说 / 听 | write() 发送 / read() 接收 | 数据收发 |
| 挂断电话 | close() | 关闭连接 |
店老板(服务器):被动等电话,永远开着店门。
客人(客户端):主动打电话,吃完就挂。
搞懂了"谁主动、谁被动"这个核心问题,后面的代码都是围绕这 7 个动作展开的。
第一部分:两个必须提前搞懂的"反人类"细节
在写代码之前,有两个概念如果不讲清楚,你会在 bind() 那里卡半天。
第1步:认识 struct sockaddr_in —— 它就一张"快递单"
你要寄快递,必须填一张单子:收件地址、收件人、电话。网络通信也一样——你得告诉操作系统"我要通过哪个 IP、哪个端口通信"。这个"快递单"就是 sockaddr_in 结构体。
#include <netinet/in.h>
struct sockaddr_in {
sa_family_t sin_family; // 1. 地址类型:填 AF_INET,表示 IPv4
in_port_t sin_port; // 2. 端口号:必须用 htons() 转换(见下文)
struct in_addr sin_addr; // 3. IP 地址:里面只有一个字段 s_addr
char sin_zero[8]; // 4. 填充占位,全填 0 就行
};
一张表看懂每个字段怎么填:
| 字段 | 该填什么 | 为什么这样填 |
| sin_family | AF_INET | 表示用 IPv4 地址(如 192.168.1.1) |
| sin_port | htons(8080) | 端口号 8080,但必须转成"网络字节序" |
| sin_addr.s_addr | INADDR_ANY 或 inet_addr("127.0.0.1") | INADDR_ANY = 监听本机所有 IP;127.0.0.1 = 只在本机测试 |
| sin_zero | 不用管,memset 置 0 即可 | 历史遗留的填充位 |
第2步:字节序 —— "大端小端"到底是什么鬼
通俗类比:有的国家写日期是"年-月-日",有的是"月-日-年"。同一个日期,写法不同就会乱套。计算机内部存数字也一样——不同 CPU 架构存多字节数字的顺序不同,分为"大端"和"小端"两种派系。
网络通信为了保证全世界机器都能互相听懂,强制规定:网络传输统一用大端序(Network Byte Order)。所以你的程序在把端口号、IP 地址传给网络函数之前,必须转换成大端。
记忆口诀:凡是填进 sockaddr_in 端口和 IP 字段的值,统统用 htons() / htonl() 包一层。
| htons() | Host to Network Short | 16 位端口号转换 | htons(8080) |
| htonl() | Host to Network Long | 32 位 IP 转换 | htonl(INADDR_ANY) |
| ntohs() | Network to Host Short | 反向转换(读对方端口时用) | ntohs(client_addr.sin_port) |
| ntohl() | Network to Host Long | 反向转换(读对方 IP 时用) | 略 |
⚠️ 新手最容易踩的坑:直接把 8080 赋值给 sin_port,不调用 htons()。编译通过、运行不报错,但死活连不上——因为你的 8080 被对方机器当成一个天文数字的端口号了。
第3步:sockaddr 和 sockaddr_in 的"套娃"关系
bind()、connect()、accept() 这些函数要求的参数类型是 struct sockaddr *(通用地址结构)。但我们实际填的是 struct sockaddr_in(IPv4 专用)。怎么办?强制类型转换即可:
// 我们实际用的是 sockaddr_in
struct sockaddr_in server_addr;
// 传给 bind 时强行转成 sockaddr *
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
你会在几乎所有网络代码里看到这种 (struct sockaddr *) 的写法。记住口诀:写的时候用 _in,传的时候强转。
第二部分:服务器端 —— 手把手写出 Echo 服务器
目标:写一个服务器程序,客户端连上来发什么,服务器就原样回什么。
第1步:创建文件并写代码
在终端输入以下命令创建源文件:
# 进入你的学习目录(根据实际情况修改)
cd ~/linux-learning
# 创建源文件
nano tcp_server.c
将以下完整代码粘贴进去,然后按 Ctrl+O 保存,Ctrl+X 退出:
#include <stdio.h> // printf, perror
#include <stdlib.h> // exit
#include <string.h> // memset, strlen
#include <unistd.h> // read, write, close
#include <sys/socket.h> // socket, bind, listen, accept
#include <netinet/in.h> // sockaddr_in, INADDR_ANY, htons
#include <arpa/inet.h> // inet_ntoa
#define PORT 8080 // 服务器端口号,可以改成别的
#define BUFFER_SIZE 1024 // 收发缓冲区大小
int main() {
int server_fd, client_fd; // server_fd=监听套接字, client_fd=与客户端通信的套接字
struct sockaddr_in server_addr, client_addr; // 服务器和客户端的地址信息
socklen_t client_len = sizeof(client_addr); // accept 需要的长度参数
char buffer[BUFFER_SIZE]; // 数据缓冲区
int n; // read 的返回值(读到多少字节)
// === 第1步:创建套接字(相当于买一部电话) ===
// AF_INET=IPv4, SOCK_STREAM=TCP协议, 0=自动选择
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) { // 返回 -1 表示创建失败
perror("socket"); // 打印系统错误信息
exit(1); // 直接退出
}
// === 第2步:填写服务器地址信息(相当于印名片) ===
memset(&server_addr, 0, sizeof(server_addr)); // 先把整块内存清零
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_port = htons(PORT); // 端口号,必须 htons 转换
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听本机所有网卡
// === 第3步:绑定地址(相当于公布电话号码) ===
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind"); // 最常见失败原因:端口被占用
close(server_fd); // 先关掉套接字再退出
exit(1);
}
// === 第4步:开始监听(相当于坐在店里等电话响) ===
// 第二个参数 5 表示最多允许 5 个连接在排队等待
if (listen(server_fd, 5) < 0) {
perror("listen");
close(server_fd);
exit(1);
}
printf("服务器启动,正在监听端口 %d …\\n", PORT);
printf("提示:打开另一个终端运行 ./tcp_client 来连接\\n");
// === 第5步:循环接受客户端连接 ===
while (1) {
// accept 会阻塞在这里,直到有客户端连上来
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept");
continue; // 失败了就继续等下一个
}
// 打印客户端的 IP 和端口
printf("有客户端连接进来了: %s:%d\\n",
inet_ntoa(client_addr.sin_addr), // 把二进制 IP 转成可读字符串
ntohs(client_addr.sin_port)); // 把网络字节序端口转回来
// === 第6步:收发数据(Echo 回显) ===
// 循环读取客户端发来的数据
while ((n = read(client_fd, buffer, sizeof(buffer) – 1)) > 0) {
buffer[n] = '\\0'; // 手动加字符串结束符,防止 printf 越界
printf("收到: %s", buffer);
// 原样写回给客户端
write(client_fd, buffer, n);
}
// 循环结束:要么 n==0(客户端主动断开),要么 n<0(出错)
if (n < 0) {
perror("read");
} else {
printf("客户端断开连接\\n");
}
// === 第7步:关闭客户端套接字 ===
close(client_fd);
}
// 理论上不会执行到这里(上面是死循环),但写上是好习惯
close(server_fd);
return 0;
}
第2步:逐行理解每个函数在干什么
下面这张表是整个服务器端的"骨架"。把它打印出来贴在显示器旁边,写代码时对照着看:
| 操作步骤 | 调用的函数 | 它在干什么(大白话) | 关键参数 |
| 第1步 | socket(AF_INET, SOCK_STREAM, 0) | 买一部"电话" | SOCK_STREAM = TCP,SOCK_DGRAM = UDP |
| 第2步 | 填充 sockaddr_in | 填快递单:IP + 端口 | htons(PORT) 必须加 |
| 第3步 | bind() | 把电话号码公布出去 | 端口被占用会失败 |
| 第4步 | listen(server_fd, 5) | 坐下来等电话 | 5 = 最多同时排队 5 个连接 |
| 第5步 | accept() | 接起电话,拿到一个"通话线路" | 返回一个新的 fd,专门跟这个客户端聊 |
| 第6步 | read() / write() | 通话:听对方说话 / 回话 | 和文件读写一模一样 |
| 第7步 | close(client_fd) | 挂断这通电话 | 先关客户端 fd,最后关服务器 fd |
⚠️ 新手最容易搞混的地方:server_fd 和 client_fd 是两个不同的东西。server_fd 只负责"接电话"(监听),从不传输数据。client_fd 是 accept 返回的,专门用来和某一个客户端收发数据。把它们想象成"总机"和"分机"就不会搞混了。
第三部分:客户端 —— 手把手写出 Echo 客户端
第1步:创建文件并写代码
创建客户端源文件:
# 创建客户端源文件
nano tcp_client.c
将以下代码粘贴进去,保存退出:
#include <stdio.h> // printf, fgets, perror
#include <stdlib.h> // exit
#include <string.h> // memset, strlen, strncmp
#include <unistd.h> // read, write, close
#include <sys/socket.h> // socket, connect
#include <netinet/in.h> // sockaddr_in, htons
#include <arpa/inet.h> // inet_pton
#define PORT 8080 // 连接哪个端口(必须和服务器一致)
#define BUFFER_SIZE 1024 // 收发缓冲区大小
int main() {
int sock; // 客户端只有一个套接字就够了
struct sockaddr_in server_addr; // 服务器的地址信息
char send_buf[BUFFER_SIZE]; // 发送缓冲区
char recv_buf[BUFFER_SIZE]; // 接收缓冲区
int n;
// === 第1步:创建套接字 ===
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
perror("socket");
exit(1);
}
// === 第2步:填写目标服务器的地址 ===
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT); // 端口号,必须 htons
// inet_pton:把 "127.0.0.1" 这种人类可读的 IP 转成二进制格式
// 127.0.0.1 是"本机环回地址",意思就是连自己这台电脑
if (inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr) <= 0) {
perror("inet_pton"); // 失败通常因为 IP 地址格式写错了
close(sock);
exit(1);
}
// === 第3步:连接服务器(相当于拨号) ===
// connect 会触发 TCP 三次握手,阻塞直到连接成功或超时
if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("connect"); // 最常见失败原因:服务器没启动
close(sock);
exit(1);
}
printf("已连接到服务器 127.0.0.1:%d\\n", PORT);
printf("现在可以输入文字发送给服务器了。输入 quit 退出。\\n");
// === 第4步:循环输入并收发数据 ===
while (1) {
printf("> "); // 提示符
fflush(stdout); // 强制刷新,确保 > 立即显示
// 从键盘读取一行输入
fgets(send_buf, sizeof(send_buf), stdin);
// 输入 "quit" 就退出
if (strncmp(send_buf, "quit", 4) == 0) {
break;
}
// 发送给服务器
write(sock, send_buf, strlen(send_buf));
// 接收服务器的回显
n = read(sock, recv_buf, sizeof(recv_buf) – 1);
if (n <= 0) {
if (n == 0)
printf("服务器关闭了连接\\n");
else
perror("read");
break;
}
recv_buf[n] = '\\0'; // 手动加结束符
printf("回显: %s", recv_buf);
}
// === 第5步:关闭套接字 ===
close(sock);
return 0;
}
第2步:客户端流程速查表
| 操作步骤 | 调用的函数 | 它在干什么 | 和服务器有什么区别 |
| 第1步 | socket() | 创建套接字 | 和服务器一样 |
| 第2步 | 填 sockaddr_in | 指定目标服务器地址 | 服务器填自己的 IP,客户端填对方的 IP |
| 第3步 | connect() | 主动发起连接 | 客户端独有,服务器不需要这步 |
| 第4步 | write() / read() | 收发数据 | 和服务器一样 |
| 第5步 | close() | 关闭连接 | 和服务器一样 |
⚠️ 关键区别:客户端不需要 bind() 和 listen() 和 accept()。客户端只需 socket() → connect() → 收发 → close()。操作系统会自动给客户端分配一个临时端口。
第四部分:编译运行 —— 亲眼看到两台"虚拟电话"通话
第1步:编译两个程序
# 编译服务器(-o 后面是输出的可执行文件名)
gcc -o tcp_server tcp_server.c
# 编译客户端
gcc -o tcp_client tcp_client.c
⚠️ 如果提示 gcc: command not found,说明没装编译器。执行:sudo apt install gcc(Ubuntu/Debian)或 sudo yum install gcc(CentOS)。
第2步:打开两个终端窗口
终端1(服务器端):
./tcp_server
# 你会看到:
# 服务器启动,正在监听端口 8080 …
# 提示:打开另一个终端运行 ./tcp_client 来连接
终端2(客户端):
./tcp_client
# 你会看到:
# 已连接到服务器 127.0.0.1:8080
# 现在可以输入文字发送给服务器了。输入 quit 退出。
# >
第3步:开始对话测试
在客户端终端输入:
> hello world
# 回显: hello world
> 你好,网络编程
# 回显: 你好,网络编程
> quit
# (程序退出)
同时观察服务器终端,会显示:
有客户端连接进来了: 127.0.0.1:xxxxx
收到: hello world
收到: 你好,网络编程
客户端断开连接
⚠️ 常见问题:如果客户端显示 connect: Connection refused,99% 是因为服务器没启动。先确认终端1里 ./tcp_server 还在运行。
第五部分:8 个核心函数速记卡
把这 8 个函数死记硬背下来,TCP 编程就掌握了 80%:
// 1. 创建套接字 — 买电话
int socket(int domain, int type, int protocol);
// domain: AF_INET(IPv4) 或 AF_INET6(IPv6)
// type: SOCK_STREAM(TCP) 或 SOCK_DGRAM(UDP)
// protocol: 通常填 0,让系统自动选择
// 2. 绑定地址 — 公布电话号码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 服务器必调,客户端通常不用(connect 时系统自动分配)
// 3. 开始监听 — 坐下等电话
int listen(int sockfd, int backlog);
// backlog: 等待队列最大长度,测试时填 5 就行
// 4. 接受连接 — 接起电话
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 返回一个新的 fd,专门和这个客户端通信
// 原 sockfd 继续监听新连接
// 5. 发起连接 — 拨号
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 客户端独有。成功返回 0,失败返回 -1
// 6. 读取数据 — 听对方说话
ssize_t read(int fd, void *buf, size_t count);
// 返回 0 表示对方关闭了连接(挂电话)
// 返回 -1 表示出错
// 7. 发送数据 — 对对方说话
ssize_t write(int fd, const void *buf, size_t count);
// 注意:TCP 是流式协议,一次 write 不一定对应一次 read
// 8. 关闭连接 — 挂电话
int close(int fd);
// 触发 TCP 四次挥手
第七部分:常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
| bind: Address already in use | 上一次运行的服务器还没完全释放端口(TCP 有 TIME_WAIT 状态,通常等 1-2 分钟) | ①等几分钟再试;②或用 setsockopt 设置 SO_REUSEADDR;③换个端口号 |
| connect: Connection refused | 服务器没启动,或端口号不一致 | ①确认 ./tcp_server 在另一个终端正在运行;②检查客户端和服务器的 PORT 宏是否一致 |
| connect: Network is unreachable | IP 地址写错了,或网络不通 | ①先用 ping 127.0.0.1 测试本机环回是否正常;②检查 IP 是否有拼写错误 |
| 客户端输入中文,服务器显示乱码 | 两端字符编码不一致 | 确保两端终端都使用 UTF-8 编码。read/write 不修改字节内容,乱码通常是显示问题 |
| 客户端发了 100 字节,服务器 read 只读到 50 字节 | TCP 是流式协议,没有消息边界。一次 write 的数据可能被拆成多次 read | 应用层需要自己制定协议:①固定长度;②长度前缀;③特殊分隔符(如换行 \\n) |
| accept 返回 -1,errno 显示 EINTR | 系统调用被信号中断 | 通常加 if (errno == EINTR) continue; 即可 |
| 编译时报 undefined reference to … | 没有链接对应的库 | Socket 函数在标准 C 库中,不需要额外 -l。但如果是多线程版本需要加 -lpthread |
| perror: socket: Too many open files | 文件描述符用完了(打开了太多连接没关) | ①检查是否每个 client_fd 都正确 close;②用 ulimit -n 查看并提高限制 |
作业(Day 22-23 巩固练习)
知识关系图
socket() ──── 创建通信端点
│
├── 服务器路线:bind() → listen() → accept() → read()/write() → close()
│
└── 客户端路线:connect() → write()/read() → close()
核心概念:
sockaddr_in ──── 地址"快递单"
htons/htonl ──── 字节序转换(大端统一)
TCP 三次握手 ──── connect() 连接成功时已完成
TCP 四次挥手 ──── close() 触发
网络编程无非就是"连线、说话、挂电话"的循环。今天你亲手写出了一个完整的 TCP 通信程序——这一步跨越了"单机编程"到"网络编程"的分水岭。多敲几遍代码,让这些函数形成肌肉记忆,你会发现它真的没那么神秘。
网硕互联帮助中心





评论前必须登录!
注册