牢蒯的源码在下面的仓库里,各位大佬可以自行取用,古法编程,难免有bug,大佬们可在评论区指出错误。
https://gitee.com/laotian666/linux_storage.git
一、背景
项目实现基于一套手写C++ TCP服务器,服务器具备两大业务能力:英文词典翻译、远程命令执行。聚焦TCP网络编程核心知识点,讲解基于回调+lambda方式实现远程命令执行的完整调度逻辑、白名单校验、popen执行流程与安全风险。
服务器两个业务:
- 词典翻译:客户端发送英文单词,服务端查询本地字典返回中文释义。
- 远程命令执行:客户端发送shell命令,服务端经过白名单安全校验后执行命令,把命令执行结果通过TCP返回给客户端。
二、TCP服务端标准四步流程
TCP服务端固定四大系统调用:socket→bind→listen→accept。
2.1 socket 创建套接字
socket(AF_INET, SOCK_STREAM, 0);AF_INET:IPv4协议族。SOCK_STREAM:代表TCP,面向字节流、可靠、面向连接。- 返回值:文件描述符,监听套接字listen_fd。失败返回
-1。
⭐关键点:
listen_fd的职责只负责等待客户端连接,不做数据收发。真正和客户端读写数据,是accept返回的fd。
2.2 bind绑定地址端口
bind把socket文件描述符和IP+端口进行绑定。
- 结构体
sockaddr_in是IPv4地址结构体,包含协议族、端口、IP地址。 - 网络字节序问题:网络通信全部使用大端字节序。主机是小端,必须调用
htons()把端口转为网络字节序,忘记转换会直接通信失败。 INADDR_ANY:代表本机所有网卡,客户端可以通过本机任意IP访问服务。- 传入参数需要强转成通用结构体
struct sockaddr*。 - bind失败常见原因:端口被占用、权限不足。
2.3 listen 设置监听状态
listen(listen_fd, backlog);- 让socket从主动套接字变成被动监听套接字,准备接收客户端连接。
- backlog参数:内核连接队列的大小,存放已经完成三次握手、还没有被accept取走的连接。
❗重要误区:
backlog不是最大客户端连接数量。backlog过小,高并发场景新连接会被内核直接拒绝。
2.4 accept获取新连接
accept(listen_fd, (struct sockaddr*)&peer, &len);- accept是阻塞调用:没有客户端连接到来时,线程卡在accept不返回。
- 返回值:新socket文件描述符conn_fd,后续所有和该客户端的
read/write全部使用这个fd。 - 输出参数:peer结构体拿到客户端的IP、端口信息。
- len是输入输出参数,调用前必须赋值为
sockaddr_in结构体大小,否则获取地址会出错。 - 返回
-1代表获取连接失败,服务器不能直接exit退出,需要continue继续等待下一个连接。
✅务必区分两个fd
- listen_fd:监听套接字,全局只需要1个,只用来accept。
- conn_fd:每一个客户端对应一个,专门用来收发数据。
三、TCP数据读写:read & write
拿到accept返回的conn_fd,就可以和客户端双向通信。
read(conn_fd, buf, size); write(conn_fd, data, len);read返回值三种情况
n > 0:读到n字节有效数据。TCP是字节流,没有报文边界,一次read不一定读完客户端一次发送的完整消息,会产生粘包问题。n == 0:对端正常关闭连接(客户端close),此时服务端必须调用close(conn_fd)释放文件描述符。n < 0:读取出错,网络异常,需要关闭连接。
⚠️字节流特性坑点: TCP不会保留消息边界。客户端发送多条数据,服务端read读取的字节是流式拼接。测试代码没有处理粘包,仅适合简单测试;生产环境必须自定义协议,例如换行分隔、长度头协议。 补充细节:读取数据后手动执行
buffer[n] = '\0',把读到的二进制缓冲区手动转为C风格字符串,供后续业务回调函数解析使用。
close关闭文件描述符
close(fd)会触发TCP四次挥手。- 文件描述符是系统有限资源,连接处理完毕必须close,否则会出现文件描述符泄露,服务运行一段时间后无法新建连接。
- 多进程场景:
fork之后,父子进程都会复制一份fd,引用计数+1;只有引用计数归零,才会真正发送FIN断开连接。
四、sockaddr_in地址转换:网络字节序和主机字节序
sockaddr_in内部存储全部是网络大端字节序;程序打印、业务逻辑使用的是主机小端。
| 接口 | 作用 | 注意 |
|---|---|---|
| htons | host to network short:端口主机转网络 | 端口是16位 |
| ntohs | network to host short:端口网络转主机 | |
| inet_pton | 字符串IP → 网络二进制IP | ✅线程安全,推荐 |
| inet_ntop | 网络二进制IP → 字符串IP | ✅线程安全,推荐 |
❌高危避坑:
inet_ntoa()绝对不可以在多线程下使用!它返回静态全局缓冲区地址,多线程并发调用内容会被覆盖,造成IP错乱。生产代码全部改用inet_ntop。
五、TCP服务器并发模型演进
服务端accept拿到客户端连接之后,如何同时服务多个客户端?一共有4种典型方案。
5.1 单进程版本(串行)
accept拿到连接之后,在主线程循环read/write处理业务。
- 缺点:同一时刻只能服务一个客户端。一个客户端阻塞,其他全部无法接入。
- 适用场景:仅学习演示,生产几乎不用。
5.2 多进程版本
accept得到conn_fd之后,调用fork()创建子进程处理业务。
- fork之后父子进程会复制所有文件描述符,
listen_fd、conn_fd引用计数+1。 - 子进程必须close(listen_fd),子进程不需要监听,避免引用计数不释放。
- 父进程必须close(conn_fd),父进程只负责accept,不需要读写客户端。
- 问题:子进程退出会产生僵尸进程,父进程需要
waitpid回收。 - 二次fork技巧:fork之后子进程再fork一次,子进程立刻exit;孙子进程执行业务逻辑,孙子进程变成孤儿,由1号进程自动回收,避免僵尸。
进程开销:进程虚拟地址空间完全独立,创建进程开销大,不适合上万连接场景。
5.3 每连接一线程(多线程)版本(本项目采用)
每accept一个客户端,pthread_create创建一个新线程,专门处理这个客户端的读写业务。
- pthread_create的入口函数必须是static静态函数,不携带this指针;对象数据通过
void*参数传入。 - ⚠️传参大坑:不能传递栈上局部变量,子线程执行时栈变量已经销毁,会出现野指针;必须new堆上结构体,线程内部业务结束后delete释放。
pthread_detach:线程分离,线程退出自动回收资源,不需要外部调用pthread_join。
- 缺点:大量客户端同时连接,会创建大量线程,每个线程占用栈内存,受操作系统线程数量上限限制,无法支持超高并发。
5.4 线程池版本(短任务)
预先创建固定数量工作线程,accept拿到连接,把处理任务投递任务队列。
⚠️重要限制:线程池适合短任务。如果业务是长连接while循环读写,不适合丢入线程池,会快速耗尽线程池全部工作线程。
四种并发模型对比
- 单进程:串行执行,无法同时服务多客户端。
- fork多进程:进程隔离性强,资源开销巨大。
- 一连接一线程:开发简单,长连接数量多的时候资源压力大。
- 线程池:适合短请求,长连接场景受限。
- epoll IO多路复用:工业级高并发方案,单线程就可以处理上万连接。
六、长连接服务的处理逻辑
本项目TCP服务是长连接模式:客户端连接建立之后,不关闭连接,可以反复发送多条请求,直到客户端主动断开。
while(true) { read客户端数据 业务回调函数处理 write返回结果 }- 客户端不close,连接会一直保持。
- 服务端一次连接生命周期内,循环多次read/write。
- 当
read返回0,代表客户端关闭,服务端break循环,close释放fd。
对比短连接:一次请求一次响应,立刻close,每次业务都要重新建立三次握手。
✅回调设计亮点:网络层和业务解耦。TCP网络层只负责
read读取数据、write回写结果;具体业务逻辑(词典/远程命令)全部交给外部传入的回调函数。项目使用lambda表达式捕获业务对象,或者std::bind绑定成员函数,作为回调传入TcpServer。 执行链路:read拿到缓冲区字符串→ 调用回调_func(lambda封装的Command::Execute) → Execute函数执行命令、生成结果字符串 → 网络层拿到返回字符串,write发送回客户端。
七、两大业务模块实现思路
7.1 词典翻译业务
- 服务启动时读取本地
Dictionary.txt字典文件,解析英文:中文格式键值,加载到哈希表。 - TCP读到客户端字符串,在哈希表查找单词。
- 将查询结果通过
write返回给客户端,查无结果返回标记字符串。
7.2 远程命令执行业务(完整执行链路)
7.2.1 整体调用流程(结合代码图逻辑)
7.2.2 核心接口说明
popen():创建管道,fork子进程调用shell执行命令,把进程标准输出重定向到管道,返回FILE*流,程序可以用fgets读取命令输出。pclose():等待子进程退出、回收僵尸子进程、释放管道资源,必须成对调用。IsSafeCommand白名单函数:是本项目的安全防护手段,预先配置允许执行的命令列表,只有命中白名单的命令才放行,缓解命令注入风险。
补充:默认情况下
popen只能捕获标准输出stdout;想要同时拿到命令报错信息,需要shell重定向cmd 2>&1,将标准错误合并到标准输出。
7.2.3 严重安全风险(重点)
- 命令注入漏洞
popen底层会调用 /bin/sh -c去解析命令字符串。如果不做白名单过滤,客户端可控输入直接传入popen,攻击者可以拼接; | && ||等shell元字符,执行任意系统命令。举例:客户端输入
ls ; rm -rf ~,分号会分隔两条命令,服务端会连续执行ls和删除命令。本项目的IsSafeCommand白名单只是基础防护,不能做到绝对安全。 - 权限风险:服务器进程是什么用户,命令就以该用户权限执行。如果服务以root运行,攻击者一旦绕过白名单,可以完全控制服务器。
- 资源风险:客户端发送恶意阻塞命令,会造成popen子进程卡死,线程长时间阻塞,耗尽服务线程。
✅安全开发建议:
- 生产环境尽量避免使用popen/system。
- 如果业务必须执行命令,白名单只匹配完整命令,严格禁止拼接用户输入参数;更安全方案是使用
execve系列函数直接执行程序,绕过shell解析,杜绝命令注入。
八、TCP网络编程常见坑点总结
- 字节流无边界:read返回的字节数不确定,没有报文分割,产生粘包问题。
- 文件描述符泄露:异常分支忘记close fd,服务运行一段时间无法建立新连接。
- 字节序遗忘转换:端口IP忘记htons,网络通信数据错乱。
inet_ntoa线程不安全,多线程环境禁止使用。- fork之后fd引用计数机制,父子进程不close对应fd会导致连接无法真正断开。
- accept返回
-1不要直接exit,只是单次连接失败,服务器需要继续运行。 - pthread_create传入栈局部变量,子线程拿到野指针,必须堆分配参数。
- 阻塞IO下,如果客户端卡死不发数据,
read会一直阻塞当前线程。 - 远程命令执行坑:popen必须pclose回收,不要fclose;白名单只是基础防护,依然存在被绕过的风险;回调捕获对象要注意生命周期,防止lambda回调时对象已经析构。
- 缓冲区字符串处理坑:read读取二进制后,手动添加
\0结束符,否则字符串读取越界。
九、补充:TCP三次握手、四次挥手简要回顾
- 三次握手:客户端发起connect,内核完成三次握手,完成的连接放入listen的内核队列;
accept系统调用只是把已经握手完成的连接从内核取出到用户态。accept不负责三次握手! - 四次挥手:一方调用close,发送FIN报文。
read返回0代表收到对方FIN报文。双方都close之后连接才完整释放。 - TIME_WAIT状态:主动关闭方最后状态,持续2MSLS,保证对方收到ACK应答。服务端大量TIME_WAIT一般是服务端主动close连接。
十、拓展思考
如果要支持上万客户端长连接,一连接一线程模型为什么不行?应该选用什么技术?
答案:每个线程占有栈内存,操作系统存在线程数量上限。改用epoll IO多路复用 + 非阻塞IO。
read返回0代表什么?是不是代表写失败?
read返回0是对端关闭连接;write返回-1才代表写错误。
listen的backlog是最大支持客户端数量吗?
不是,backlog只是内核全连接队列大小。
为什么fork之后父子进程都要close对应fd?
文件描述符存在引用计数,计数不为0,不会发送FIN报文,连接不会断开。
本项目通过lambda+回调实现网络和业务解耦,这种写法有什么好处?
网络框架TcpServer不需要关心是词典业务还是命令执行业务,只负责收发TCP数据;新增业务只需要新增回调,不需要修改网络层代码,符合开闭原则。
使用popen+白名单实现远程命令执行,白名单可以彻底杜绝命令注入吗?
不能。白名单属于应用层过滤,如果过滤逻辑有漏洞依然可能被绕过;最安全方案是不经过shell,直接使用execve执行程序。