mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1562 字
8 分钟
Linux 网络编程(14):IM 聊天系统——网络功能实现:TCP 协议客户端

IM 聊天系统——网络功能实现:TCP 协议客户端#

本节继续实现 IM 聊天系统的网络功能,首先分析 TCP 粘包问题的常见解决方案,并采用“先发送数据长度,再发送数据内容”的包格式。

随后完成 TCP 客户端的数据发送与循环接收,理解 offsetpackLen 在完整接收数据包时的作用;最后开始搭建 TCP 服务端,梳理监听套接字、已连接套接字以及连接接收线程之间的关系。

一、粘包问题的解决方案选择#

几种常见方案:

方案特点本项目是否适合
短连接一条连接只传一条消息,之后断开IM 消息发送频繁,反复连接代价大,不适合
固定包长每个包长度固定聊天消息长短差异大,会浪费空间,不适合
特殊分隔符在消息结尾增加约定标记用户输入中可能出现同样标记,处理复杂
先发长度,再发数据接收方先知道数据体长度,再按长度读取适合本项目,采用

本项目的数据包格式#

┌──────────────────────┬──────────────────────────────┐
│ 数据长度:sizeof(int) │ 数据内容:len 字节 │
└──────────────────────┴──────────────────────────────┘

发送顺序必须固定:

  1. 发送 len
  2. 发送 data 中的 len 个字节。

接收顺序也必须完全一致:

  1. 先接收数据长度;
  2. 根据长度申请空间;
  3. 循环接收,直到该包的数据体全部收完。

二、TCP 客户端发送数据#

关键语句解释#

send(m_sock, (char*)&len, sizeof(int), 0);
  • send() 要求缓冲区参数是 char*,因此把 &len 强制转换成 (char*)
  • 这里发送的是变量 len 本身在内存中的字节,发送大小为 sizeof(int)
  • TCP 客户端只有一个与服务器建立连接的套接字,因此使用成员变量 m_sock
send(m_sock, data, len, 0);
  • 第二次发送数据体。
  • 数据长度必须写 len
  • 不能写 sizeof(data),因为函数参数 data 是指针,sizeof(data) 得到的是指针本身的大小,不是实际数据长度。

为什么客户端暂时没有使用参数 to#

TCP 客户端的 m_sock 已经通过 connect() 与服务器建立了连接,所以数据只能沿着这条连接发送给服务器,不需要再次指定目标。

接口中保留 to,是因为 INet 同时统一了 UDP、TCP 客户端和 TCP 服务端的发送接口。

三、TCP 客户端接收数据#

1. 先接收长度#

nRecvNum = recv(m_sock, (char*)&packLen, sizeof(int), 0);

接收成功后,packLen 就表示当前数据包的数据体长度。随后按照该长度申请空间:

char* pack = new char[packLen];

2. 为什么数据体不能只调用一次 recv()#

假设应用层要接收 2000 字节,底层可能将数据拆成多个 TCP/IP 报文。常见以太网 MTU 1500 字节说明:扣除 IP/TCP 首部后,单段数据常见约为 1460 字节。

因此第一次 recv() 可能只得到 1460 字节,剩余 540 字节稍后才到达:

一个应用层数据包:2000 字节
第一次 recv:1460 字节
第二次 recv: 540 字节
合计: 2000 字节

如果接收方只调用一次 recv(),剩余 540 字节会留在 TCP 接收缓冲区中。下一轮程序又先接收“长度”,就可能把这 540 字节的开头误当成下一个包的长度,后续数据全部错位。

3. offsetpackLen 的作用#

变量含义接收过程中的变化
packLen当前包还剩多少字节没有接收每收到一段,就减去 nRecvNum
offset当前包已经累计接收多少字节每收到一段,就加上 nRecvNum

每次接收的位置和剩余空间分别是:

pack + offset
packLen

每次成功接收后更新:

offset += nRecvNum;
packLen -= nRecvNum;

packLen == 0 时,说明当前包已经完整接收。

4. 两层循环分别负责什么#

  • 外层 while (m_bRunning):不断接收多个数据包。
  • 内层 while (packLen > 0):保证当前一个包的数据体全部收完。

每开始接收一个新包之前,必须把:

offset = 0;

否则上一个包累计的偏移量会影响下一个包。

5. 为什么输出长度使用 offset,不能使用 packLen#

内层循环会不断执行:

packLen -= nRecvNum;

一个完整数据包接收完以后,packLen 已经变成 0。因此测试输出实际接收长度时要使用累计值 offset

cout << "tcpclient recv:" << pack << ", len:" << offset << endl;

四、开始实现 TCP 服务端#

1. 服务端初始化流程#

TcpServer::initNet() 的流程为:

加载 Winsock 库
创建 TCP 监听套接字
绑定本机 IP 和 TCP_PORT
listen() 进入监听状态
创建接受连接的线程

2. 为什么 accept() 要放在线程中#

accept() 是阻塞函数:没有客户端连接时,它会一直等待。

如果在主线程直接调用 accept(),主线程就无法继续执行其他网络功能。因此创建专门的接受连接线程,并在其中循环调用 accept(),以便连续接受多个客户端。

3. 线程函数为什么需要 pThis#

线程入口是静态成员函数,静态成员函数不能直接访问普通成员变量,所以通过 _beginthreadex() 的参数把当前对象指针 this 传进去:

m_handle = (HANDLE)_beginthreadex(nullptr, 0, &acceptThread, this, 0, nullptr);

在线程函数中再转换回来:

TcpServer* pThis = (TcpServer*)lpVoid;

之后通过 pThis->m_sockpThis->m_bRunning 访问当前 TcpServer 对象的成员。

4. 监听套接字和已连接套接字#

服务端中会同时出现两类套接字:

套接字来源用途
m_socksocket() 后进行 bind()listen()只负责监听和接受新连接
sockaccept() 的返回值代表与某个具体客户端建立的连接,用于和该客户端收发数据

不能使用监听套接字 m_sock 向某个客户端发送聊天数据,应该使用该客户端对应的已连接套接字。

五、TCP 服务端发送数据时 to 的含义#

统一接口为:

bool sendData(char* data, int len, unsigned long to);

不同协议中,to 的含义不同:

使用位置to 表示什么
UDP目标 IP,IP 决定数据发给谁
TCP 客户端已经通过 m_sock 连接服务器,暂时不用 to
TCP 服务端目标客户端对应的 SOCKET

TCP 服务端可能连接很多客户端,所以“发送给谁”由调用 sendData() 的上层决定,并把目标客户端套接字通过 to 传入。服务端不需要在 sendData() 中重新创建套接字。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Linux 网络编程(14):IM 聊天系统——网络功能实现:TCP 协议客户端
https://joyanblog.com/posts/linux-network-programming-14-im-chat-system-tcp-client/
作者
Joyan
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0

目录