云计算百科
云计算领域专业知识百科平台

Day 22-23 TCP 聊天服务器:从零手写网络通信

网络编程没那么神秘,就像打电话——买手机、插卡、拨号、通话、挂断,五个动作而已。只不过这里换成 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 巩固练习)

  • 修改服务器,让它在收到客户端发来的 bye 时主动关闭连接,并在关闭前发送 Goodbye! 给客户端。
  • 用 fork() 改造服务器,使其能同时处理多个客户端(每个子进程负责一个客户端),参照第六部分的骨架代码。
  • 在客户端增加超时机制:如果 5 秒内没有收到服务器回显,自动退出(提示:用 setsockopt 设置 SO_RCVTIMEO)。
  • 挑战题:实现一个简易聊天室——服务器收到任何客户端的消息后,转发给所有已连接的客户端(需要维护客户端 fd 列表,可用 select 或多线程)。

  • 知识关系图

    socket() ──── 创建通信端点

    ├── 服务器路线:bind() → listen() → accept() → read()/write() → close()

    └── 客户端路线:connect() → write()/read() → close()

    核心概念:
    sockaddr_in ──── 地址"快递单"
    htons/htonl ──── 字节序转换(大端统一)
    TCP 三次握手 ──── connect() 连接成功时已完成
    TCP 四次挥手 ──── close() 触发


    网络编程无非就是"连线、说话、挂电话"的循环。今天你亲手写出了一个完整的 TCP 通信程序——这一步跨越了"单机编程"到"网络编程"的分水岭。多敲几遍代码,让这些函数形成肌肉记忆,你会发现它真的没那么神秘。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Day 22-23 TCP 聊天服务器:从零手写网络通信
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!