1. TCP 协议特点
1.1 TCP 核心特性
- 面向连接:需要三次握手建立连接
- 可靠传输:通过序号、确认、重传等机制保证
- 流量控制:通过滑动窗口控制发送速率
- 拥塞控制:防止网络过载
- 全双工通信:双方可同时发送和接收数据
1.2 TCP vs UDP
| 特性 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接 | 无连接 |
| 可靠性 | 可靠 | 不可靠 |
| 传输效率 | 较低 | 较高 |
| 数据边界 | 字节流 | 数据报 |
| 适用场景 | 文件传输、Web、邮件 | 视频流、DNS、广播 |
2. TCP Socket 编程模型
2.1 服务器端流程
cpp
socket() → bind() → listen() → accept() → recv()/send() → close()
为什么在TCP通信中服务器处理完客户端请求后要关闭对应的sockfd
在TCP通信中,服务器处理完客户端请求后关闭对应的sockfd,主要是基于以下几个重要原因:
1. 资源管理
- 文件描述符限制:每个sockfd占用一个文件描述符,系统资源有限
- 内存占用:每个连接都需要维护内核缓冲区、TCP控制块等数据结构
- 防止泄露:如果不关闭,会造成资源泄漏,最终导致服务器无法接受新连接
2.并发与安全考虑
- 防止占用过多资源:长时间不关闭会耗尽服务器资源
- 避免僵尸连接:已失效但未关闭的连接占用资源
- 安全隔离:及时关闭减少被攻击的窗口期
3.不关闭的风险
- 文件描述符耗尽:达到系统上限后无法创建新连接
- 内存泄漏:内核资源持续占用
- 端口耗尽:大量TIME_WAIT状态的连接
- 客户端困惑:客户端不知道何时响应结束
2.2 客户端流程
cpp
socket() → connect() → send()/recv() → close()
3. 核心函数详解
socket和bind函数前面UDP部分已经讲解,此处不再赘述
注意:因为TCP是面向字节流的,而文件也是面向字节流的,所以TCP可以像读写文件一样用read/write 在网络上收发数据
listen
listen()用于将套接字设置为监听模式,等待客户端连接请求。
cpp
#include <sys/socket.h>
int listen(int sockfd, int backlog);
listen()声明sockfd 处于监听状态, 并且最多允许有backlog 个客户端处于连接等待状态, 如果接收到更多的连接请求就忽略, 这里设置不会太大(一般是5)
参数详解
- sockfd
- 通过socket()创建的套接字描述符
- 必须是 SOCK_STREAM 或 SOCK_SEQPACKET 类型(TCP套接字)
- 必须先调用bind()绑定地址和端口
- backlog
定义内核中等待连接队列的最大长度,控制同时等待处理的连接数。
返回值
- 成功:返回 0
- 失败:返回 -1,并设置 errno
accept
accept() 用于 TCP 服务器接受客户端的连接请求。
cpp
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
• 三次握手完成后, 服务器调用accept()接受连接
• 如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来
参数说明
- sockfd:监听套接字描述符(通过 socket()创建,bind()绑定,listen() 监听)
- addr:指向 struct sockaddr的指针,用于接收客户端地址信息,addr 是一个传出参数,accept()返回时传出客户端的地址和端口号,如果给addr 参数传NULL,表示不关心客户端的地址
- addrlen:addrlen 参数是一个传入传出参数, 传入的是调用者提供的, 缓冲区addr 的长度以避免缓冲区溢出问题, 传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)
返回值
- 成功:返回一个新的套接字描述符,用于与客户端通信
- 失败:返回 -1,并设置 errno
理解accept返回值:饭店拉客例子
让我用一个饭店(餐厅)比喻来解释accept() 的返回值:
比喻场景:
想象一个餐厅:
- 餐厅大门 = 监听套接字(server_fd)
- 服务员A = 在门口专门迎接客人(调用 accept())
- 餐桌 = 客户端套接字(client_fd)
- 客人 = 客户端连接
具体过程
1. 餐厅准备阶段(服务器启动)
cpp
// 餐厅开业,装好大门
int server_fd = socket(); // 安装餐厅大门
// 在大门上挂上门牌号(地址和端口)
bind(server_fd, ...); // 挂上"XX路888号"门牌
// 打开大门,开始营业
listen(server_fd, 10); // 打开大门,允许最多10个客人排队等待
2. 迎接客人阶段(调用 accept())
服务员A站在门口,等待客人:
cpp
int client_fd = accept(server_fd, ...);
情况A:有客人来了(连接到达)
cpp
客人(客户端) → 来到餐厅门口 → 服务员A迎接
服务员A:
1. 记录客人信息(获取客户端地址)
2. **为客人分配一张餐桌**(创建新的套接字)
3. **把餐桌号交给另一个服务员B**(返回 client_fd)
4. **自己回到门口继续迎接**(服务器继续 accept)
结果:
- server_fd (餐厅大门) → 保持不变,继续接受新客人
- client_fd (餐桌号) → 给服务员B,专门服务这个客人
关键点:
- accept() 返回值 client_fd = 餐桌号
- 服务员B用这个餐桌号为这个客人点菜、上菜(读写数据)
- 服务员A继续在门口迎接其他客人
情况B:没有客人(阻塞模式)
cpp
服务员A站在门口,一直等待...
没有客人时,他就一直等(阻塞)
情况C:餐厅打烊(关闭服务器)
cpp
老板说:关门!
服务员A不再等待(accept 失败)
多客人场景(并发处理)
cpp
while (1) {
// 服务员A在门口迎接(阻塞等待)
int table_number = accept(restaurant_door, ...);
if (table_number > 0) {
// 来了一个客人,分配餐桌table_number
// 方案1:一个服务员服务一桌(多进程/多线程)
if (fork() == 0) {
// 新服务员专门服务这桌客人
serve_customer(table_number);
close(table_number); // 客人走了,收拾餐桌
exit(0);
}
// 方案2:一个服务员跑多桌(IO多路复用)
// 记录这个餐桌,稍后统一服务
add_to_waiting_list(table_number);
}
}
返回值对比表
| 饭店比喻 | 网络编程 | 说明 |
|---|---|---|
| 餐厅大门 | server_fd | 永远不变,专门迎接新客人 |
| 餐桌号 | client_fd | 每个客人一张桌子,用完回收 |
| 服务员A | accept()调用 | 专门在门口迎接,不服务客人 |
| 服务员B | 工作进程/线程 | 用餐桌号服务特定客人 |
| 客人信息表 | struct sockaddr | 记录客人联系方式(IP、端口) |
重要特性理解
1. 为什么需要两个套接字?
cpp
// 错误理解:用大门直接和客人交流 ❌
read(server_fd, buffer, ...); // 错误!大门不能用来点菜
// 正确做法:用餐桌和客人交流 ✅
read(client_fd, buffer, ...); // 正确!在餐桌上点菜
2. 大门(server_fd)的作用
- 只负责迎接新客人(accept())
- 不参与具体服务(不读写数据)
- 一个餐厅只有一个大门 ,但可以有很多餐桌
3. 餐桌(client_fd)的生命周期
cpp
// 客人来了,安排餐桌
int table = accept(door, ...); // 分配餐桌
// 在餐桌上服务
write(table, "菜单", ...); // 给客人菜单
read(table, order, ...); // 接收点单
// 客人走了,清理餐桌
close(table); // 收拾桌子,等待下个客人
总结要点
- server_fd 像餐厅大门:永远不变,专门接受新连接
- accept() 返回值像餐桌号:每个客人一个,用完回收
- 大门不能直接服务客人:必须通过餐桌(client_fd)通信
connect
connect()用于建立套接字连接。
cpp
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
• 客户端需要调用connect()连接服务器;
• connect 和bind 的参数形式一致, 区别在于bind 的参数是自己的地址, 而connect 的参数是对方的地址
参数说明
| 参数 | 说明 |
|---|---|
| sockfd | 套接字描述符,由 socket()创建 |
| addr | 指向目标服务器地址结构的指针 |
| addrlen | 地址结构的大小 |
返回值
- 成功: 返回 0
- 失败: 返回 -1,并设置 errno
4.实践
read
cpp
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
read()用于从文件描述符读取数据。
参数说明
| 参数 | 说明 |
|---|---|
| fd | 文件描述符(file descriptor) |
| buf | 存放读取数据的缓冲区指针 |
| count | 请求读取的字节数 |
返回值
| 返回值 | 含义 |
|---|---|
| >0 | 实际读取的字节数 |
| 0 | 到达文件末尾(EOF) |
| -1 | 出错,错误码在 errno中 |
重要特性
1. 阻塞与非阻塞模式
- 默认情况下,read() 是阻塞的
- 对于普通文件,通常读取请求的数据量
- 对于管道、套接字等,可能读取少于请求的数据量
- 可以设置 O_NONBLOCK 标志使 read() 非阻塞
2. 特殊文件描述符的行为
| 文件描述符 | 行为特点 |
|---|---|
| 普通文件 | 通常读取请求的字节数,除非到达文件末尾 |
| 终端设备 | 通常按行读取(直到换行符) |
| 管道/套接字 | 读取可用数据,可能少于请求字节数 |
| 目录 | 错误,errno=EISDIR |
注意事项
- 返回值可能小于请求字节数:即使没有错误,read 也可能返回少于count 的字节
- 信号中断:如果 read() 被信号中断,会返回 -1 并设置 errno=EINTR
- 文件位置偏移:对于可定位的文件,read() 会从当前文件偏移开始读取,并更新偏移
- 原子性:普通文件的读取是原子的(对于管道和套接字则不一定)
与标准I/O的区别
| 区别 | read() | fread() |
|---|---|---|
| 特性 | 系统调用 | 标准库函数 |
| 缓冲 | 无缓冲 | 带缓冲 |
| 性能 | 系统调用开销大 | 减少系统调用次数 |
| 控制粒度 | 底层控制 | 高级接口 |
| 可移植性 | POSIX标准 | C标准 |
read()返回值在TCP 特有的行为
| 情况 | read()返回值 | 含义 |
|---|---|---|
| 正常情况 | >0 | 实际读取的字节数 |
| 连接正常关闭 | 0 | 对端调用了 close()(收到 FIN) |
| 出错 | -1 | 设置 errno指示具体错误 |
| 非阻塞+无数据 | -1 | errno=EAGAIN 或EWOULDBLOCK |
核心问题:消息边界
TCP 是字节流协议,没有消息边界:
- 发送方调用多次 write() 发送的数据,接收方可能一次read() 就全部收到
- 发送方一次 write() 发送的数据,接收方可能需要多次read() 才能收完
write
write()用于写入数据到文件描述符
cpp
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
参数说明
| 参数 | 说明 |
|---|---|
| fd | 文件描述符(文件、管道、套接字等) |
| buf | 要写入的数据缓冲区 |
| count | 要写入的字节数 |
返回值
- 成功:返回实际写入的字节数(可能小于 count)
- 失败:返回 -1,并设置 errno
重要特性
1. 可能部分写入
write() 不保证一次性写入所有数据,实际写入的字节可能少于请求的字节。
2. 原子性
对于普通文件,小于 PIPE_BIF(通常 4096 字节)的写入是原子的:
- 多个进程同时写入不会交错
- 但对于网络套接字,没有这样的保证
3. 文件偏移
写入从当前文件偏移开始,完成后偏移量增加实际写入的字节数。
write在TCP中的重要特性
1. 阻塞行为
- 默认情况下,套接字是阻塞的
- 如果发送缓冲区已满,write()会阻塞直到有空间
- 可以使用fcntl()设置为非阻塞模式
2. TCP保证
- TCP保证数据的可靠传输
- 数据会按发送顺序到达
- 自动处理重传、流量控制等
3. 缓冲区管理
- 内核有发送缓冲区
- write()成功只表示数据已复制到内核缓冲区
- 不保证对端已收到
popen
popen()是 Linux C 标准库中的一个函数,用于创建管道并执行 shell 命令
cpp
#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
参数说明
- command: 要执行的 shell 命令字符串
- type :
- "r": 读取子进程的输出(父进程读取,子进程写入)
- "w": 向子进程输入数据(父进程写入,子进程读取)
返回值
- 成功:返回文件流指针
- 失败:返回 NULL
fgets
fgets是 C 语言标准库中用于从文件流读取字符串的函数
cpp
char *fgets(char *str, int n, FILE *stream);
参数说明
- str:指向字符数组的指针,用于存储读取的数据
- n:要读取的最大字符数(包括终止空字符)
- strem:文件流指针(如 stdin、文件指针等)
返回值
- 成功:返回 str 指针
- 失败或到达文件末尾:返回 NULL
功能特点
- 安全读取:相比 gets,fgets 更安全,会限制读取长度
- 保留换行符:如果读取到换行符,会将其存入字符串
- 自动添加终止符:读取结束后会自动添加 '\0'
- 遇到以下情况停止 :
- 读取了 n-1 个字符
- 遇到换行符
- 到达文件末尾
代码
cpp
.PHONY:all
all: command_client command_server
command_client:CommandClient.cpp
g++ -o $@ $^ -std=c++17
command_server:CommandServer.cpp
g++ -o $@ $^ -std=c++17 -lpthread
.PHONY:clean
clean:
rm -f command_client command_server
cpp
#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <string>
#include "Logger.hpp"
using namespace std;
#define Conv(addr) ((struct sockaddr *)&addr)
class InetAddr
{
private:
void Net2Host()
{
_port = ntohs(_addr.sin_port);
_ip = inet_ntoa(_addr.sin_addr);
//LOG(LogLevel::DEBUG) << _ip << _port;
}
void Host2Net()
{
memset(&_addr, 0, sizeof(_addr));
_addr.sin_family = AF_INET;
_addr.sin_port = htons(_port);
_addr.sin_addr.s_addr = inet_addr(_ip.c_str());
}
public:
//默认ip为INADDR_ANY(0.0.0.0)
InetAddr(uint16_t port,const string ip="0.0.0.0")
: _ip(ip),
_port(port)
{
Host2Net();
}
InetAddr(struct sockaddr_in &addr)
{
_addr = addr;
Net2Host();
}
struct sockaddr *Addr()
{
return Conv(_addr);
}
string IP()
{
return _ip;
}
uint16_t Port()
{
return _port;
}
bool operator==(const InetAddr &addr)
{
return _ip == addr._ip && _port == addr._port;
}
socklen_t Length()
{
return sizeof(_addr);
}
string ToString()
{
return _ip + "-" + to_string(_port);
}
~InetAddr()
{
}
private:
// 网络风格地址
struct sockaddr_in _addr;
// 主机风格地址
string _ip;
uint16_t _port;
};
cpp
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
using namespace std;
class Command
{
private:
bool IsSafe(const std::string &cmd)
{
for(auto &c : _command_white_list)
{
if(cmd == c)
{
return true;
}
}
return false;
}
public:
Command()
{
//简单实现,只给一些常用的命令白名单
_command_white_list.push_back("ls -a -l");
_command_white_list.push_back("ls -a");
_command_white_list.push_back("ls -l");
_command_white_list.push_back("cat test.txt");
_command_white_list.push_back("touch touch.txt");
_command_white_list.push_back("whoami");
_command_white_list.push_back("who");
_command_white_list.push_back("pwd");
}
string Exec(const string &cmd)
{
if(!IsSafe(cmd))
{
return "该命令不被允许\n";
}
string result;
FILE *fp = popen(cmd.c_str(), "r");
if(fp == NULL)
{
result = cmd + " exec error";
}
else
{
char buffer[1024];
while(fgets(buffer, sizeof(buffer), fp) != nullptr)
{
result += buffer;
}
pclose(fp);
}
return result;
}
~Command(){}
private:
vector<string> _command_white_list;
};
cpp
#pragma once
#include"InetAddr.hpp"
#include<functional>
#include"MyErrno.hpp"
#include<pthread.h>
using namespace std;
static const int gfd = -1;//文件描述符初始化为-1
static const int gbacklog = 5;//默认backlog设置为5
//static const int gport = 8080;//默认端口号是8080
using callback_t=function<string(const string&)>;
//服务器只负责IO问题
class CommandServer
{
private:
void HandlerIO(int client_fd,InetAddr client_addr)
{
char buffer[1024];
while(true)
{
buffer[0]=0;//缓冲区清空
//保护缓冲区并设置缓冲区最后一个字符为'\0'
ssize_t n=read(client_fd,buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n]=0;
LOG(LogLevel::INFO)<<client_addr.ToString()<<" say:"<<buffer;
// 约定:你给我发过来的是命令字符串!ls -a -l touch XX
string result=_cb(buffer);
//将处理后的结果写回client_fd
write(client_fd,result.c_str(),sizeof(result));
}
else if(n==0)
{
//连接正常关闭
LOG(LogLevel::INFO)<<client_addr.ToString()<<"quit,me too,close client_fd"<<client_fd;
break;
}
else
{
LOG(LogLevel::WARNING) << "read client "
<< client_addr.ToString() << " error, client_fd : " << client_fd;
break;
}
}
close(client_fd);// 一定要关闭
}
public:
CommandServer(callback_t cb,uint16_t port,int server_fd = gfd)
:_cb(cb)
,_port(port)
,_server_fd(server_fd)
{
}
void Init()
{
//1.创建套接字
_server_fd=socket(AF_INET,SOCK_STREAM,0);
if(_server_fd<0)
{
LOG(LogLevel::FATAL)<<"creat sockfd error";
exit(SOCKET_CREATE_ERR);
}
LOG(LogLevel::INFO)<<"creat sockfd success,server_fd:"<<_server_fd;
//2.bind
InetAddr server(_port);
if(bind(_server_fd,server.Addr(),server.Length())!=0)
{
LOG(LogLevel::FATAL)<<"bind sockfd error";
exit(SOCKET_BIND_ERR);
}
LOG(LogLevel::INFO)<<"bind sockfd success";
//3.将套接字设置为监听模式,等待客户端连接请求
// 一个tcp server,listen()之后,服务器已经算是运行了
if(listen(_server_fd,gbacklog)!=0)
{
LOG(LogLevel::FATAL)<<"listen socket error";
exit(SOCKET_LISTEN_ERR);
}
LOG(LogLevel::INFO)<<"listen socket success";
}
class ThreadData
{
public:
ThreadData(int clientfd,CommandServer *self,InetAddr& client_addr)
:_sockfd(clientfd),
_self(self),
_addr(client_addr)
{
}
~ThreadData()
{}
//私有的 在类外不能访问
int _sockfd;
CommandServer *_self;
InetAddr _addr;
};
//成员函数有this指针,所以该执行函数应设置为静态
//静态成员函数无法访问正常成员函数,所以要封装ThreadData
static void* Routine(void *args)
{
ThreadData *td=(ThreadData*)args;
pthread_detach(pthread_self());
//HandlerIO
td->_self->HandlerIO(td->_sockfd,td->_addr);
delete td;
return nullptr;
}
void Start()
{
while(true)
{
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
int client_fd=accept(_server_fd,(struct sockaddr*)&peer,&len);
if(client_fd<0)
{
LOG(LogLevel::FATAL)<<"accept client error";
continue;
}
InetAddr client(peer);
LOG(LogLevel::INFO)<<"accept success,client_fd:"<<client_fd<<" client addr:"<<client.ToString();
pthread_t tid;
ThreadData *td=new ThreadData(client_fd,this,client);
pthread_create(&tid,nullptr,Routine,(void*)td);
}
}
~CommandServer()
{
}
private:
int _server_fd;//专门用于监听
uint16_t _port;
callback_t _cb;
};
cpp
#include"CommandServer.hpp"
#include"Command.hpp"
void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " localport" << std::endl;
}
int main(int argc,char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t serverport = std::stoi(argv[1]);
EnableConsoleLogStrategy();
//定义一个对象,不传参的时候,是不需要括号的
//否则编译器解析 codobj 就不是 对象,而是一个函数名
//Command cmdobj();error
Command cmdobj;
unique_ptr<CommandServer> tsvr = make_unique<CommandServer>(
[&cmdobj](const string &cmd) -> string
{
return cmdobj.Exec(cmd);
},
serverport);
tsvr->Init();
tsvr->Start();
return 0;
}
cpp
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "Command.hpp"
#include "InetAddr.hpp"
#include "MyErrno.hpp"
using namespace std;
void Usage(std::string proc)
{
cerr << "Usage: " << proc << " serverip serverport" << endl;
}
// ./tcp_client serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(0);
}
string serverip = argv[1];
uint16_t serverport = stoi(argv[2]);
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
cerr << "create client sockfd error" << endl;
exit(SOCKET_CREATE_ERR);
}
// 向目标服务器发起连接请求 !
InetAddr server(serverport, serverip);
if (connect(sockfd, server.Addr(), server.Length()) != 0)
{
perror("connect");
cerr << "connect server error" << endl;
exit(SOCKET_CONNECT_ERR);
}
cout << "connect " << server.ToString() << " success" << endl;
while (true)
{
cout << "my input: ";
string line;
getline(cin, line);
ssize_t n = write(sockfd, line.c_str(), line.size());
if (n >= 0)
{
char buffer[1024];
ssize_t m = read(sockfd, buffer, sizeof(buffer) - 1);
if (m > 0)
{
buffer[m] = 0;
cout << buffer;
}
}
}
return 0;
}
