Linux网络篇——TCP(一)之手搓TCP,并实现词典和远程命令

牢蒯的源码在下面的仓库里,各位大佬可以自行取用,古法编程,难免有bug,大佬们可在评论区指出错误。

https://gitee.com/laotian666/linux_storage.git


一、背景

项目实现基于一套手写C++ TCP服务器,服务器具备两大业务能力:英文词典翻译 、远程命令执行 。聚焦TCP网络编程核心知识点 ,讲解基于回调+lambda方式实现远程命令执行的完整调度逻辑、白名单校验、popen执行流程与安全风险。

服务器两个业务:

  1. 词典翻译:客户端发送英文单词,服务端查询本地字典返回中文释义。
  2. 远程命令执行:客户端发送shell命令,服务端经过白名单安全校验后执行命令,把命令执行结果通过TCP返回给客户端。

二、TCP服务端标准四步流程

TCP服务端固定四大系统调用:socket → bind → listen → accept。

2.1 socket 创建套接字

复制代码
socket(AF_INET, SOCK_STREAM, 0);
  1. AF_INET:IPv4协议族。
  2. SOCK_STREAM:代表TCP,面向字节流、可靠、面向连接。
  3. 返回值:文件描述符,监听套接字listen_fd 。失败返回-1。

⭐关键点 :listen_fd的职责只负责等待客户端连接,不做数据收发 。真正和客户端读写数据,是accept返回的fd。

2.2 bind绑定地址端口

bind把socket文件描述符和IP+端口进行绑定。

  1. 结构体sockaddr_in是IPv4地址结构体,包含协议族、端口、IP地址。
  2. 网络字节序问题 :网络通信全部使用大端字节序 。主机是小端,必须调用htons()把端口转为网络字节序,忘记转换会直接通信失败。
  3. INADDR_ANY:代表本机所有网卡,客户端可以通过本机任意IP访问服务。
  4. 传入参数需要强转成通用结构体struct sockaddr*。
  5. bind失败常见原因:端口被占用、权限不足。

2.3 listen 设置监听状态

复制代码
listen(listen_fd, backlog);
  1. 让socket从主动套接字变成被动监听套接字,准备接收客户端连接。
  2. backlog参数 :内核连接队列的大小,存放已经完成三次握手、还没有被accept取走的连接 。 ❗重要误区:backlog不是最大客户端连接数量。backlog过小,高并发场景新连接会被内核直接拒绝。

2.4 accept获取新连接

复制代码
accept(listen_fd, (struct sockaddr*)&peer, &len);
  1. accept是阻塞调用:没有客户端连接到来时,线程卡在accept不返回。
  2. 返回值:新socket文件描述符conn_fd ,后续所有和该客户端的read/write全部使用这个fd。
  3. 输出参数:peer结构体拿到客户端的IP、端口信息。
  4. len是输入输出参数,调用前必须赋值为sockaddr_in结构体大小,否则获取地址会出错。
  5. 返回-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返回值三种情况

  1. n > 0:读到n字节有效数据。TCP是字节流,没有报文边界,一次read不一定读完客户端一次发送的完整消息,会产生粘包问题。
  2. n == 0:对端正常关闭连接(客户端close) ,此时服务端必须调用close(conn_fd)释放文件描述符。
  3. n < 0:读取出错,网络异常,需要关闭连接。

⚠️字节流特性坑点: TCP不会保留消息边界。客户端发送多条数据,服务端read读取的字节是流式拼接。测试代码没有处理粘包,仅适合简单测试;生产环境必须自定义协议,例如换行分隔、长度头协议 。 补充细节:读取数据后手动执行buffer[n] = '\0',把读到的二进制缓冲区手动转为C风格字符串,供后续业务回调函数解析使用。

close关闭文件描述符

  1. close(fd)会触发TCP四次挥手。
  2. 文件描述符是系统有限资源,连接处理完毕必须close,否则会出现文件描述符泄露,服务运行一段时间后无法新建连接。
  3. 多进程场景: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()创建子进程处理业务。

  1. fork之后父子进程会复制所有文件描述符,listen_fd、conn_fd引用计数+1。
  2. 子进程必须close(listen_fd),子进程不需要监听,避免引用计数不释放。
  3. 父进程必须close(conn_fd),父进程只负责accept,不需要读写客户端。
  4. 问题:子进程退出会产生僵尸进程 ,父进程需要waitpid回收。
  5. 二次fork技巧:fork之后子进程再fork一次,子进程立刻exit;孙子进程执行业务逻辑,孙子进程变成孤儿,由1号进程自动回收,避免僵尸。

进程开销:进程虚拟地址空间完全独立,创建进程开销大,不适合上万连接场景。

5.3 每连接一线程(多线程)版本(本项目采用)

每accept一个客户端,pthread_create创建一个新线程,专门处理这个客户端的读写业务。

  1. pthread_create的入口函数必须是static静态函数 ,不携带this指针;对象数据通过void*参数传入。
  2. ⚠️传参大坑:不能传递栈上局部变量,子线程执行时栈变量已经销毁,会出现野指针;必须new堆上结构体,线程内部业务结束后delete释放。
  3. pthread_detach:线程分离,线程退出自动回收资源,不需要外部调用pthread_join。
  • 缺点 :大量客户端同时连接,会创建大量线程,每个线程占用栈内存,受操作系统线程数量上限限制,无法支持超高并发。

5.4 线程池版本(短任务)

预先创建固定数量工作线程,accept拿到连接,把处理任务投递任务队列。

⚠️重要限制:线程池适合短任务。如果业务是长连接while循环读写,不适合丢入线程池,会快速耗尽线程池全部工作线程。

四种并发模型对比

  1. 单进程:串行执行,无法同时服务多客户端。
  2. fork多进程:进程隔离性强,资源开销巨大。
  3. 一连接一线程:开发简单,长连接数量多的时候资源压力大。
  4. 线程池:适合短请求,长连接场景受限。
  5. epoll IO多路复用:工业级高并发方案,单线程就可以处理上万连接。

六、长连接服务的处理逻辑

本项目TCP服务是长连接模式:客户端连接建立之后,不关闭连接,可以反复发送多条请求,直到客户端主动断开。

复制代码
while(true)
{
    read客户端数据
    业务回调函数处理
    write返回结果
}
  1. 客户端不close,连接会一直保持。
  2. 服务端一次连接生命周期内,循环多次read/write。
  3. 当read返回0,代表客户端关闭,服务端break循环,close释放fd。

对比短连接:一次请求一次响应,立刻close,每次业务都要重新建立三次握手。
✅回调设计亮点:网络层和业务解耦。TCP网络层只负责read读取数据、write回写结果;具体业务逻辑(词典/远程命令)全部交给外部传入的回调函数 。项目使用lambda表达式 捕获业务对象,或者std::bind绑定成员函数,作为回调传入TcpServer。 执行链路:read拿到缓冲区字符串 → 调用回调_func(lambda封装的Command::Execute) → Execute函数执行命令、生成结果字符串 → 网络层拿到返回字符串,write发送回客户端。

七、两大业务模块实现思路

7.1 词典翻译业务

  1. 服务启动时读取本地Dictionary.txt字典文件,解析英文:中文格式键值,加载到哈希表。
  2. TCP读到客户端字符串,在哈希表查找单词。
  3. 将查询结果通过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 严重安全风险(重点)

  1. 命令注入漏洞 popen底层会调用 /bin/sh -c去解析命令字符串。如果不做白名单过滤,客户端可控输入直接传入popen,攻击者可以拼接; | && ||等shell元字符,执行任意系统命令。 举例:客户端输入 ls ; rm -rf ~,分号会分隔两条命令,服务端会连续执行ls和删除命令。本项目的IsSafeCommand白名单只是基础防护,不能做到绝对安全。
  2. 权限风险:服务器进程是什么用户,命令就以该用户权限执行。如果服务以root运行,攻击者一旦绕过白名单,可以完全控制服务器。
  3. 资源风险:客户端发送恶意阻塞命令,会造成popen子进程卡死,线程长时间阻塞,耗尽服务线程。

✅安全开发建议:

  • 生产环境尽量避免使用popen/system。
  • 如果业务必须执行命令,白名单只匹配完整命令,严格禁止拼接用户输入参数;更安全方案是使用execve系列函数直接执行程序,绕过shell解析,杜绝命令注入。

八、TCP网络编程常见坑点总结

  1. 字节流无边界:read返回的字节数不确定,没有报文分割,产生粘包问题。
  2. 文件描述符泄露:异常分支忘记close fd,服务运行一段时间无法建立新连接。
  3. 字节序遗忘转换:端口IP忘记htons,网络通信数据错乱。
  4. inet_ntoa线程不安全,多线程环境禁止使用。
  5. fork之后fd引用计数机制,父子进程不close对应fd会导致连接无法真正断开。
  6. accept返回-1不要直接exit,只是单次连接失败,服务器需要继续运行。
  7. pthread_create传入栈局部变量,子线程拿到野指针,必须堆分配参数。
  8. 阻塞IO下,如果客户端卡死不发数据,read会一直阻塞当前线程。
  9. 远程命令执行坑:popen必须pclose回收,不要fclose;白名单只是基础防护,依然存在被绕过的风险;回调捕获对象要注意生命周期,防止lambda回调时对象已经析构。
  10. 缓冲区字符串处理坑:read读取二进制后,手动添加\0结束符,否则字符串读取越界。

九、补充:TCP三次握手、四次挥手简要回顾

  1. 三次握手 :客户端发起connect,内核完成三次握手 ,完成的连接放入listen的内核队列;accept系统调用只是把已经握手完成的连接从内核取出到用户态。accept不负责三次握手!
  2. 四次挥手 :一方调用close,发送FIN报文。read返回0代表收到对方FIN报文。双方都close之后连接才完整释放。
  3. TIME_WAIT状态:主动关闭方最后状态,持续2MSLS,保证对方收到ACK应答。服务端大量TIME_WAIT一般是服务端主动close连接。

十、拓展思考

  1. 如果要支持上万客户端长连接,一连接一线程模型为什么不行?应该选用什么技术?

    答案:每个线程占有栈内存,操作系统存在线程数量上限。改用epoll IO多路复用 + 非阻塞IO。

  2. read返回0代表什么?是不是代表写失败?

    read返回0是对端关闭连接;write返回-1才代表写错误。

  3. listen的backlog是最大支持客户端数量吗?

    不是,backlog只是内核全连接队列大小。

  4. 为什么fork之后父子进程都要close对应fd?

    文件描述符存在引用计数,计数不为0,不会发送FIN报文,连接不会断开。

  5. 本项目通过lambda+回调实现网络和业务解耦,这种写法有什么好处?

    网络框架TcpServer不需要关心是词典业务还是命令执行业务,只负责收发TCP数据;新增业务只需要新增回调,不需要修改网络层代码,符合开闭原则。

  6. 使用popen+白名单实现远程命令执行,白名单可以彻底杜绝命令注入吗?

    不能。白名单属于应用层过滤,如果过滤逻辑有漏洞依然可能被绕过;最安全方案是不经过shell,直接使用execve执行程序。

相关推荐
FlightYe1 小时前
linux系统编程(十七):内存管理基础
android·linux·ffmpeg·音视频·android runtime
严同学正在努力1 小时前
PostgreSQL 数据库安装部署教程(Linux 多发行版 + Windows + Docker 全覆盖)
linux·数据库·postgresql
科技小登1 小时前
CC攻击防护哪家好?Web安全加速、WAF与Bot管理同入口方案对比
网络·安全·web安全
loulanyue_1 小时前
密度定律驱动端侧AGI跃迁,用物理级解耦破解自主网络攻击之困:迈向安全可控的通用智能——读清华大学刘知远教授CNCC2026网络生态治理论坛演讲实录
网络·安全
oyzz1202 小时前
CentOS Stream 9 配置静态 IP 口令是多少
linux·tcp/ip·centos
Lzg_na2 小时前
IGMP 协议详解:从背景到报文的完整学习指南
网络
爱研究的小梁2 小时前
从无人车配送走向集群联动作业,通信如何扛住协同压力?
网络·网络协议·机器人·信息与通信
化雨成烟2 小时前
linux基础命令2
linux·运维·服务器
运维踩坑日记2 小时前
OpenStack 核心服务实战笔记
linux·运维·服务器·笔记·openstack