Linux C++ TCP Socket 零基础入门(超详细代码注释 + 原理讲解)
·
前言
本文专注讲解 TCP 网络编程核心知识点,包含 Socket 是什么、TCP 通信流程、服务端 / 客户端代码示例、新手必懂的细节(端口、流协议、线程安全、循环发送等)。
本文特点:
一、什么是 Socket?(套接字)
Socket 就是程序用于网络通信的 “接口” 或 “工具”。
你可以把它理解为:两台设备之间通信的 “电话”。
- 想要发送数据 → 必须创建 Socket
- 想要接收数据 → 必须创建 Socket
- Linux 下 Socket 本质就是一个 int 类型文件描述符
- 基于 TCP 的 Socket 叫 流式 Socket
- 基于 UDP 的 Socket 叫 数据报 Socket
本文只讲 TCP Socket。
二、什么是 TCP?
TCP 是一种可靠的、面向连接的传输层协议。
特点:
- 必须先建立连接,才能通信
- 保证数据不丢失、不乱序、不重复
- 适合传输敏感、重要的数据
- 类似 “打电话”,必须接通才能说话
三、什么是端口?为什么必须用端口?
端口 = 程序在操作系统里的唯一编号(门牌号)。
一台电脑会同时运行很多程序:
- 浏览器
- 微信
- SSH
- 你的服务端程序
数据到达电脑后,系统必须通过端口号判断数据交给哪个程序。
端口范围:
- 0~1023:系统保留端口
- 1024~65535:自定义可用
我们常用 9999,只是一个空闲、好记、不冲突的端口。
规则:服务端与客户端必须使用同一个端口才能通信。
四、TCP 服务端标准流程(固定 6 步)
所有 TCP 服务端都遵循这个流程:
- 创建 socket
- 设置端口复用(避免重启报错)
- 绑定 IP + 端口
- 监听(listen)
- 等待客户端连接(accept)
- 收发数据(send /recv)
五、TCP 服务端代码示例(超详细注释 + 讲解)
#include <iostream> // 输入输出头文件,用于打印日志
#include <unistd.h> // Linux 系统调用头文件,用于 close() 关闭文件描述符
#include <arpa/inet.h> // TCP/IP 协议族头文件,包含所有 socket 相关函数
#include <cstring> // 字符串操作头文件,用于 memset() 清空缓冲区
using namespace std;
int main() {
// ==========================================
// 第一步:创建 TCP 套接字
// ==========================================
// socket() 函数参数说明:
// AF_INET: 使用 IPv4 协议
// SOCK_STREAM: 使用流式套接字(TCP)
// 0: 自动选择对应协议
// 返回值:成功返回文件描述符(int 数字),失败返回 -1
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
cerr << "创建 socket 失败!" << endl;
return -1;
}
// ==========================================
// 第二步:设置端口复用(新手必加!)
// ==========================================
// 为什么要加这一步?
// 如果程序异常退出,端口可能会被系统占用几十秒(TIME_WAIT状态)
// 加上这行代码,可以让端口立即被复用,重启程序不会报错 "Address already in use"
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// ==========================================
// 第三步:准备地址结构体并绑定
// ==========================================
// sockaddr_in 是专门用于 IPv4 的地址结构体
sockaddr_in addr{};
addr.sin_family = AF_INET; // 固定:使用 IPv4 协议
// htons() 函数:主机字节序转网络字节序
// 为什么要转?因为不同电脑存储数字的方式可能不同(大端/小端)
// 网络传输必须统一使用“网络字节序”(大端序)
addr.sin_port = htons(9999);
// INADDR_ANY: 监听本机所有网卡(不管用哪个 IP 都能连接)
addr.sin_addr.s_addr = INADDR_ANY;
// bind() 函数:把 socket 和 IP+端口 绑定在一起
// 就像把“电话”和“房间号”绑定
bind(server_fd, (sockaddr*)&addr, sizeof(addr));
// ==========================================
// 第四步:开始监听
// ==========================================
// listen() 函数:让 socket 进入监听状态,等待客户端连接
// 第二个参数 1:表示允许排队等待的最大客户端数量(这里设为 1)
listen(server_fd, 1);
cout << "服务端已启动,监听端口 9999,等待客户端连接..." << endl;
// ==========================================
// 第五步:阻塞等待客户端连接
// ==========================================
// accept() 函数:会阻塞程序,直到有客户端连接上来
// 就像“守在电话旁,等电话响”
// 返回值:成功返回一个新的文件描述符,专门用于和这个客户端通信
sockaddr_in client_addr{};
socklen_t len = sizeof(client_addr);
int client_fd = accept(server_fd, (sockaddr*)&client_addr, &len);
cout << "客户端已连接!" << endl;
// ==========================================
// 第六步:接收与发送数据(回显服务器)
// ==========================================
char buf[1024]; // 缓冲区,用于存储接收到的数据
while (true) {
memset(buf, 0, sizeof(buf)); // 先清空缓冲区,防止上次的数据残留
// recv() 函数:接收数据
// 参数:客户端 fd、缓冲区、缓冲区大小、标志位(0 表示默认)
// 返回值:成功返回接收到的字节数,返回 0 表示客户端断开,返回 -1 表示出错
int n = recv(client_fd, buf, sizeof(buf)-1, 0);
if (n <= 0) {
cout << "客户端断开连接" << endl;
break;
}
cout << "收到客户端消息:" << buf << endl;
// send() 函数:发送数据
// 这里我们做一个简单的“回显服务器”:把收到的消息原封不动发回去
send(client_fd, buf, n, 0);
}
// ==========================================
// 第七步:关闭连接,释放资源
// ==========================================
close(client_fd); // 关闭客户端连接
close(server_fd); // 关闭服务端监听
return 0;
}
六、TCP 客户端标准流程(固定 4 步)
- 创建 socket
- 连接服务端(connect)
- 收发数据
- 关闭 socket
七、TCP 客户端代码示例(超详细注释 + 讲解)
#include <iostream>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
using namespace std;
int main() {
// ==========================================
// 第一步:创建 socket
// ==========================================
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
cerr << "创建 socket 失败!" << endl;
return -1;
}
// ==========================================
// 第二步:准备服务端地址并连接
// ==========================================
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(9999); // 必须和服务端端口一致!
// inet_pton() 函数:把字符串形式的 IP 地址转换成网络能识别的格式
// "127.0.0.1" 是本机回环地址(表示连接自己这台电脑)
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
// connect() 函数:主动连接服务端
// 就像“主动拨打电话”
if (connect(sock, (sockaddr*)&addr, sizeof(addr)) < 0) {
cerr << "连接服务端失败!请确认服务端已启动。" << endl;
close(sock);
return -1;
}
cout << "成功连接到服务端!" << endl;
// ==========================================
// 第三步:发送数据
// ==========================================
string msg = "Hello TCP Server!我是客户端。";
// c_str() 函数:把 C++ string 转换成 C 风格的 char* 字符串
send(sock, msg.c_str(), msg.size(), 0);
cout << "已发送消息:" << msg << endl;
// ==========================================
// 第四步:接收服务端的回显
// ==========================================
char buf[1024] = {0}; // 初始化缓冲区全为 0
recv(sock, buf, sizeof(buf)-1, 0);
cout << "收到服务端回显:" << buf << endl;
// ==========================================
// 第五步:关闭连接
// ==========================================
close(sock);
return 0;
}
八、TCP 是流协议 → 必须循环发送
为什么要循环发送?
TCP 是字节流协议,不保证一次 send 发完所有数据。
比如你要发 100 字节,可能只发出去 40 字节就返回了(网络拥塞、缓冲区满等原因)。
如果不循环发送,剩下的 60 字节就丢了!
循环发送函数代码(超详细注释)
// send_all 函数:保证数据一定完整发送
// 参数:
// fd: 客户端 socket 文件描述符
// data: 要发送的数据指针
// len: 要发送的总字节数
// 返回值:成功返回 true,失败返回 false
bool send_all(int fd, const char* data, int len) {
int total = 0; // 记录已经发送了多少字节
// 循环发送,直到全部发完
while (total < len) {
// send() 函数:尝试发送剩下的数据
// data + total: 从上次发送结束的位置继续发
// len - total: 还剩下多少字节没发
int n = send(fd, data + total, len - total, 0);
if (n <= 0) {
// 发送失败(可能是客户端断开连接、网络出错)
return false;
}
total += n; // 累计已发送的字节数
}
return true; // 全部发送完成
}
九、多线程发送必须加锁(线程安全)
为什么要加锁?
如果多个线程同时调用 send 发送数据:
- 线程 A 刚发一半:
[线程1] 温度25 - 线程 B 突然插进来:
[线程2] 湿度60 - 客户端收到的就是乱码:
[线程1] 温度25[线程2] 湿度60
加锁代码示例(超详细注释)
#include <mutex> // 互斥锁头文件
// 定义一个全局互斥锁
mutex send_mtx;
// 线程安全的发送函数
void safe_send(int client_fd, string msg) {
// std::lock_guard:自动加锁、自动解锁的“智能锁”
// 构造时自动加锁,析构时(函数结束时)自动解锁
// 不用手动写 lock() 和 unlock(),绝对不会忘记解锁!
lock_guard<mutex> lock(send_mtx);
// 现在只有拿到锁的线程才能执行下面的代码
// 其他线程必须在外面等
send_all(client_fd, msg.c_str(), msg.size());
}
十、为什么要把 Socket 封装成类?
直接写代码的缺点:
- 重复代码多
- 逻辑混乱
- 不方便复用
- 每次都要写十几行 socket、bind、listen
封装成类的好处:
- 外部只需 3 行代码
- 逻辑清晰
- 可跨项目使用
- 隐藏复杂细节
简单封装示例
class TcpServer {
public:
TcpServer(int port) : port_(port), server_fd_(-1), client_fd_(-1) {}
~TcpServer() {
if (client_fd_ >= 0) close(client_fd_);
if (server_fd_ >= 0) close(server_fd_);
}
// 启动服务
bool start() {
server_fd_ = socket(AF_INET, SOCK_STREAM, 0);
// ... 省略 bind、listen、accept 代码 ...
return true;
}
// 发送消息
bool sendMessage(string msg) {
lock_guard<mutex> lock(mtx_);
return send_all(client_fd_, msg.c_str(), msg.size());
}
private:
int port_;
int server_fd_;
int client_fd_;
mutex mtx_;
};
使用时只需 3 行:
TcpServer server(9999);
server.start();
server.sendMessage("hello");
十一、全局指针的作用(跨线程访问对象)
问题场景:
- TCP 对象在 main 函数里创建
- 消费者线程是单独的函数
- 普通情况下:线程函数无法直接访问 main 里的局部变量
解决方案:全局指针
// 1. 定义一个全局指针
TcpServer* g_tcp = nullptr;
int main() {
// 2. 创建 TCP 对象
TcpServer server(9999);
server.start();
// 3. 把对象地址赋值给全局指针
g_tcp = &server;
// ... 启动线程 ...
return 0;
}
// 在线程函数中
void thread_func() {
// 4. 通过全局指针调用发送函数
if (g_tcp) {
g_tcp->sendMessage("data");
}
}
一句话总结:全局指针 = 跨线程共享对象。
十二、新手最常见 4 个问题
1. 端口被占用
现象: 启动服务端报错 Address already in use原因: 程序异常退出,端口未释放解决: 加上 setsockopt 设置端口复用
2. 客户端连不上
现象: 客户端报错 连接失败原因: IP 或 端口不匹配解决: 确保客户端与服务端端口一致,IP 地址正确
3. 数据发送不完整
现象: 客户端只收到一半数据原因: TCP 是流协议解决: 使用循环发送 send_all
4. 多线程发送数据乱码
现象: 收到的数据拼接混乱原因: 多线程并发发送冲突解决: 加互斥锁
十三、知识点总结(简洁清晰)
- Socket 是网络通信工具
- TCP 是可靠连接协议
- 端口是程序的唯一标识
- 服务端流程:socket → bind → listen → accept
- 客户端流程:socket → connect → send/recv
- TCP 是流协议,必须循环发送
- 多线程发送必须加锁保证安全
- 封装类让代码更简洁、可复用
- 全局指针用于跨线程访问对象
更多推荐

所有评论(0)