一、TCP/IP 协议栈与 Socket 编程基础
1.1 TCP/IP 分层模型
TCP/IP 是现代互联网的核心协议栈,其官方标准为四层结构,自下而上分别为网络接口层、网际层(IP 层)、传输层、应用层。教学场景中常结合 OSI 七层模型将其扩展为五层结构,即把网络接口层拆分为数据链路层与物理层,该拆分仅为教学简化,并非 TCP/IP 协议族的标准分层。
各层核心职责如下:
- 应用层:面向业务逻辑,由 HTTP、FTP、DNS 等具体协议组成,负责定义数据的业务语义与交互规则。
- 传输层:提供端到端的进程间通信能力,核心协议为 TCP(面向连接、可靠字节流)与 UDP(无连接、不可靠数据报)。
- 网络层:负责跨网络的路由选择与寻址,核心协议为 IP 协议,实现主机到主机的数据包投递。
- 数据链路层 + 物理层:负责相邻节点间的帧封装、差错校验与物理介质上的比特传输。
协议栈遵循 "下层为上层提供透明传输服务" 的设计原则:发送端数据自上而下传递时,每一层都会为上层数据添加本层协议首部(数据链路层还会添加尾部校验),形成对应层的协议数据单元(PDU);接收端数据自下而上传递时,逐层剥去对应首部,最终将业务数据交付给应用层,该过程称为数据的封装与解封装。
1.2 Socket 接口的本质
read/write、recvfrom/sendto等接口是操作系统内核暴露给应用层的系统调用,是应用进程操作内核网络协议栈的通用编程入口,而非仅能操作传输层:原始套接字(SOCK_RAW)可直接操作网络层 IP 协议,PF_PACKET类型套接字可直接操作数据链路层帧数据。
应用层开发者无需关心内核协议栈的实现细节,只需通过 Socket 接口以类似操作文件的方式读写数据;接口之下,内核会自动完成数据封装、流量控制、丢包重传、路由转发等复杂工作。
从内核视角看,Socket 是一个抽象的通信端点:每个 Socket 对应内核中的struct socket结构体,并关联一个struct file实例;文件描述符(fd)是进程文件描述符表的索引,指向该file实例。这是 Linux "一切皆文件" 设计思想的体现,但需注意:Socket 并非普通文件,lseek、ftruncate等文件专属操作对套接字无效,仅支持通用的读写与关闭接口。
二、TCP 套接字的内核机制:缓冲区与读写语义
2.1 收发缓冲区与全双工通信

每个 TCP 套接字在内核创建时,都会分配两片独立的内核缓冲区:
- 发送缓冲区:暂存应用层写入、尚未被内核协议栈封装发出的数据。
- 接收缓冲区:暂存内核从网络中收到、尚未被应用层读取的数据。
两片缓冲区相互独立、互不干扰,因此 TCP 可以在同一时刻双向传输数据,这是 TCP 全双工特性的底层支撑机制。缓冲区的大小可通过setsockopt调整,也受内核全局参数tcp_wmem、tcp_rmem的约束。
2.2 write/read 系统调用的真实行为
应用层调用的读写接口,本质是用户态内存与内核态缓冲区之间的内存拷贝,其行为与业务直觉存在显著差异:
- write 操作 :将用户态内存中的数据拷贝到内核发送缓冲区。调用成功返回仅代表数据已进入内核缓冲区,不代表数据已发送到网络,更不代表对端应用已接收。当发送缓冲区剩余空间不足时,会出现 "短写" 现象:
write仅写入部分数据,返回已写入的字节数(大于 0 但小于预期长度),而非报错。 - read 操作 :将内核接收缓冲区中的数据拷贝到用户态内存。阻塞模式下,若缓冲区为空则线程挂起等待;非阻塞模式下则立即返回
EAGAIN错误。
2.3 连接建立过程与内核连接队列
TCP 三次握手过程中,内核会维护两个独立的队列,与数据收发缓冲区无关联:
- 半连接队列(SYN 队列) :服务端收到客户端 SYN 报文后,连接处于
SYN_RCVD状态,暂存于此队列。其最大长度由内核参数/proc/sys/net/ipv4/tcp_max_syn_backlog控制,SYN 洪水攻击正是通过耗尽该队列实现拒绝服务,防御手段为启用tcp_syncookies。 - 全连接队列(accept 队列) :完成三次握手后,连接移入此队列,等待
accept()系统调用取出。
关于listen函数的backlog参数,在 Linux 2.2 及以上内核中,其仅控制全连接队列的最大长度,且实际上限为min(backlog, net.core.somaxconn),若backlog超过系统somaxconn,内核会自动截断为系统上限值。当全连接队列满时,内核默认丢弃客户端的第三次握手 ACK 报文,触发客户端重传;也可通过tcp_abort_on_overflow参数修改为直接回复 RST 复位连接。
accept()的作用是从全连接队列中取出一个已就绪的连接,返回一个全新的已连接套接字;该套接字自带独立的收发缓冲区,用于后续与客户端的数据通信。
三、应用层协议设计核心问题
3.1 序列化与反序列化
网络传输的本质是字节序列,而应用层通常以结构体、对象等结构化形式组织数据。直接传输内存中的结构体存在四大核心问题,跨平台、跨语言通信时尤为突出:
- 字节序差异:不同 CPU 架构存在大端 / 小端字节序区别,直接传输多字节数值会出现解析错误。
- 内存对齐差异:不同编译器、不同语言、不同编译选项下,结构体的内存对齐规则不同,内存布局不一致。
- 数据类型长度差异 :不同平台下
int、long等基础类型的字节长度可能不同。 - 指针成员不可传输:结构体中若包含指针,直接拷贝内存仅会传输指针地址,而对端进程地址空间完全独立,该地址无任何意义。
因此跨端通信时,必须先将结构化数据转换为统一格式的字节序列,这一过程称为序列化 ;接收方将字节序列还原为结构化数据,称为反序列化。
主流序列化方案可分为两大类,核心对比如下:
| 类型 | 代表方案 | 核心优点 | 核心缺点 | 典型适用场景 |
|---|---|---|---|---|
| 文本序列化 | JSON、XML、自定义文本 | 跨语言兼容好、人类可读、调试成本低 | 编解码性能差、数据冗余度高 | 对外开放接口、调试友好的轻量场景 |
| 二进制序列化 | Protobuf、Thrift、自定义二进制 | 编解码效率高、数据体积小、格式严谨 | 不可读、需要 IDL 或协议定义 | 内部高性能通信、RPC 框架、高并发场景 |
3.2 TCP 字节流特性与粘包问题
TCP 是面向字节流的协议,其核心特性是:不维护应用层报文边界,只保证字节按序、无差错送达。内核只关心缓冲区中有多少字节可用,不理解也不关心应用层 "一条消息" 的逻辑边界。
由此产生 "粘包" 现象:发送方连续发送的多条消息,可能被内核合并后发出;接收方一次read可能读到多条完整消息,也可能只读到半条消息(半包)。粘包是 TCP 字节流特性的必然结果,不属于程序 Bug。
粘包的成因同时存在于收发两端:
- 发送端:Nagle 算法会自动合并小数据包批量发送以减少网络报文数量;即使关闭 Nagle 算法,若应用层写入速度快于内核发送速度,发送缓冲区也会累积多个报文。
- 接收端:内核将收到的数据存入接收缓冲区,应用层读取时机不确定,可能一次读取到多个已到达的报文,也可能只读取到部分报文。
关闭 Nagle 算法仅能减少发送端粘包,无法解决接收端粘包,因此粘包问题必须由应用层协议通过界定消息边界来解决。
3.3 粘包问题的主流解决方案
业界主流的消息边界界定方案有三种,各有优劣与适用场景:
- 固定长度法:每条消息长度固定,长度不足时用填充字节补齐。实现最简单,但灵活性极差,带宽浪费严重,仅适用于报文长度恒定的特定场景。
- 分隔符法 :用特殊字符或字符序列(如
\r\n、换行符)作为消息边界。文本类协议广泛使用,但正文中若出现分隔符需要做转义处理,且解析需逐字节扫描,性能较差。 - 长度前缀法:在每条消息头部的固定位置,存放消息体的长度值。接收方先读取长度字段,再按长度读取完整报文。该方案精确高效,是工业界最主流的方案,也是本文实现采用的方案。工业级实现中通常使用固定字节数的二进制长度字段(如 4 字节大端整数),比文本格式的长度字段解析效率更高、格式更严谨。
四、模块化 TCP 网络编程实现
本文以简易计算器服务为例,采用面向对象思想与模块化设计实现 TCP 服务器与客户端,包含地址封装、Socket 封装、线程封装、日志、应用层协议、服务端与客户端七大模块。
4.1 整体设计
- 应用层采用长度前缀 + 文本正文的协议格式,解决 TCP 粘包问题;
- 服务器采用
per-connection per-thread模型,每个连接分配一个独立线程处理业务; - 采用 RAII 思想封装底层系统细节,上层业务只需关注协议解析与业务逻辑。
4.2 地址封装模块(TcpAddr.hpp)
对sockaddr_in进行 RAII 封装,屏蔽原生 Socket 地址的类型转换与字节序转换细节,采用线程安全的inet_pton/inet_ntop替代过时的非线程安全函数,提供统一的地址操作接口。
#pragma once
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
#include <cstring>
#include <stdexcept>
#define SOCK_CAST(addr) reinterpret_cast<struct sockaddr*>(addr)
namespace InaddrModule {
class Inaddr {
struct sockaddr_in _sin;
public:
Inaddr(const std::string& ip, uint16_t port) {
memset(&_sin, 0, sizeof(_sin));
_sin.sin_family = AF_INET;
if (inet_pton(AF_INET, ip.c_str(), &_sin.sin_addr) <= 0) {
throw std::invalid_argument("无效的IP地址格式");
}
_sin.sin_port = htons(port);
}
explicit Inaddr(uint16_t port) {
memset(&_sin, 0, sizeof(_sin));
_sin.sin_family = AF_INET;
_sin.sin_addr.s_addr = htonl(INADDR_ANY);
_sin.sin_port = htons(port);
}
Inaddr() { memset(&_sin, 0, sizeof(_sin)); }
struct sockaddr_in* get() { return &_sin; }
const struct sockaddr_in* get() const { return &_sin; }
socklen_t size() const { return sizeof(_sin); }
std::string get_ip() const {
char buf[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &_sin.sin_addr, buf, sizeof(buf));
return std::string(buf);
}
uint16_t get_port() const {
return ntohs(_sin.sin_port);
}
};
}
4.3 Socket 封装模块(Socket.hpp)
抽象 Socket 基类,封装 TCP 套接字的创建、绑定、监听、接受连接、读写、关闭等操作。
#pragma once
#include <unistd.h>
#include <sys/socket.h>
#include <string>
#include <memory>
#include <cerrno>
#include "TcpAddr.hpp"
#include "Log.hpp"
#include "Common.hpp"
namespace SocketModule {
using namespace InaddrModule;
// 循环写,解决TCP短写问题
static ssize_t writen(int fd, const char* buf, size_t len) {
size_t total = 0;
while (total < len) {
ssize_t n = ::write(fd, buf + total, len - total);
if (n < 0) {
if (errno == EINTR) continue; // 被信号中断,重试
return -1;
}
total += n;
}
return static_cast<ssize_t>(total);
}
class Socket {
public:
Socket() = default;
virtual ~Socket() = default;
virtual void create() = 0;
virtual void bind(uint16_t port) = 0;
virtual void listen(int backlog) = 0;
virtual std::shared_ptr<Socket> accept() = 0;
virtual std::string read() = 0;
virtual void write(const std::string&) = 0;
virtual void close() = 0;
virtual void build_server(uint16_t port, int backlog) = 0;
};
class TcpSocket : public Socket {
int _fd = -1;
Inaddr _peer_addr;
void set_fd(int fd) { _fd = fd; }
public:
TcpSocket() = default;
TcpSocket(const Inaddr& peer, int fd) : _peer_addr(peer), _fd(fd) {}
~TcpSocket() override {
if (_fd >= 0) {
::close(_fd);
_fd = -1;
}
}
// 禁止拷贝,允许移动
TcpSocket(const TcpSocket&) = delete;
TcpSocket& operator=(const TcpSocket&) = delete;
void build_server(uint16_t port, int backlog) override {
create();
bind(port);
listen(backlog);
}
void create() override {
_fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (_fd < 0) {
LOG(bksw::Loglevel::ERROR) << "socket创建失败, errno=" << errno;
exit(EXITCODE::SOCKET_ERROR);
}
// 开启地址复用,解决TIME_WAIT导致的bind失败
int opt = 1;
setsockopt(_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
LOG(bksw::Loglevel::INFO) << "socket创建成功, fd=" << _fd;
}
void bind(uint16_t port) override {
Inaddr addr(port);
int ret = ::bind(_fd, SOCK_CAST(addr.get()), addr.size());
if (ret < 0) {
LOG(bksw::Loglevel::ERROR) << "bind失败, errno=" << errno;
exit(EXITCODE::BIND_ERROR);
}
LOG(bksw::Loglevel::INFO) << "bind成功, 端口=" << port;
}
void listen(int backlog) override {
int ret = ::listen(_fd, backlog);
if (ret < 0) {
LOG(bksw::Loglevel::ERROR) << "listen失败, errno=" << errno;
exit(EXITCODE::LISTEN_ERROR);
}
LOG(bksw::Loglevel::INFO) << "listen成功, backlog=" << backlog;
}
std::shared_ptr<Socket> accept() override {
Inaddr peer;
socklen_t len = peer.size();
int cfd = ::accept(_fd, SOCK_CAST(peer.get()), &len);
if (cfd < 0) {
if (errno != EINTR) {
LOG(bksw::Loglevel::ERROR) << "accept失败, errno=" << errno;
}
return nullptr;
}
LOG(bksw::Loglevel::INFO) << "新连接接入: " << peer.get_ip() << ":" << peer.get_port();
return std::make_shared<TcpSocket>(peer, cfd);
}
std::string read() override {
char buff[1024];
ssize_t n = ::read(_fd, buff, sizeof(buff));
if (n == 0) {
LOG(bksw::Loglevel::INFO) << _peer_addr.get_ip() << " 连接正常关闭";
return "";
} else if (n < 0) {
if (errno != EINTR) {
LOG(bksw::Loglevel::ERROR) << "读取失败, errno=" << errno;
}
return "";
}
return std::string(buff, n);
}
void write(const std::string& str) override {
ssize_t n = writen(_fd, str.c_str(), str.size());
if (n < 0) {
LOG(bksw::Loglevel::ERROR) << "发送数据失败";
}
}
void close() override {
if (_fd >= 0) {
::close(_fd);
_fd = -1;
}
}
int get_fd() const { return _fd; }
};
}
4.4 线程封装模块(Thread.hpp)
对原生pthread进行 RAII 封装,支持绑定任意函数与参数,自动管理线程生命周期。
#ifndef _THREAD_HPP
#define _THREAD_HPP
#include <pthread.h>
#include <functional>
#include <memory>
#include <string>
#include <stdexcept>
namespace bksw {
enum class ThreadStatus {
NEW = 0,
RUNNING,
STOPPED
};
class Thread {
std::string _name;
pthread_t _tid = 0;
ThreadStatus _status = ThreadStatus::NEW;
bool _detached = false;
static void* run(void* arg) {
auto* task = static_cast<std::function<void()>*>(arg);
try {
(*task)();
} catch (const std::exception& e) {
// 捕获线程异常,避免进程终止
// 实际项目中可接入日志系统
} catch (...) {}
delete task;
return nullptr;
}
public:
Thread() = default;
explicit Thread(std::string name) : _name(std::move(name)) {}
~Thread() = default;
// 禁止拷贝
Thread(const Thread&) = delete;
Thread& operator=(const Thread&) = delete;
template<typename Func, typename... Args>
int start(Func&& func, Args&&... args) {
if (_status == ThreadStatus::RUNNING) return -1;
auto* task = new std::function<void()>(
std::bind(std::forward<Func>(func), std::forward<Args>(args)...)
);
int ret = pthread_create(&_tid, nullptr, run, task);
if (ret != 0) {
delete task; // 创建失败释放内存,避免泄漏
return ret;
}
_status = ThreadStatus::RUNNING;
if (_detached) {
pthread_detach(_tid);
}
return 0;
}
int join() {
if (_status != ThreadStatus::RUNNING || _detached) return -1;
int ret = pthread_join(_tid, nullptr);
if (ret == 0) _status = ThreadStatus::STOPPED;
return ret;
}
int detach() {
if (!_detached && _status == ThreadStatus::RUNNING) {
pthread_detach(_tid);
}
_detached = true;
return 0;
}
ThreadStatus get_status() const { return _status; }
};
}
#endif
4.5 日志模块(Log.hpp)
采用策略模式实现控制台 / 文件两种日志输出,支持分级日志与时间戳。
#pragma once
#include <sstream>
#include <iostream>
#include <string>
#include <memory>
#include <filesystem>
#include <unistd.h>
#include <ctime>
#include <fcntl.h>
#include "mutex.hpp"
namespace bksw {
#define LOG(level) bksw::global_log(level, __FILE__, __LINE__)
#define LOG_TO_FILE() bksw::global_log.UseFileLogStrategy()
#define LOG_TO_CONSOLE() bksw::global_log.UseConsoleLogStrategy()
constexpr const char* PATH_SEP = "/";
constexpr const char* DEFAULT_LOG_DIR = "./Log/";
constexpr const char* DEFAULT_LOG_FILE = "log.txt";
class LogStrategy {
public:
virtual void output(const std::string& message) = 0;
virtual ~LogStrategy() = default;
protected:
mutex _mutex;
};
class ConsoleLogStrategy : public LogStrategy {
public:
void output(const std::string& msg) override {
std::lock_guard<mutex> lock(_mutex);
std::cout << msg << std::endl;
}
};
class FileLogStrategy : public LogStrategy {
int _fd = -1;
public:
FileLogStrategy(const std::string& dir = DEFAULT_LOG_DIR,
const std::string& filename = DEFAULT_LOG_FILE) {
if (!std::filesystem::exists(dir)) {
std::filesystem::create_directories(dir);
}
std::string full_path = std::string(dir) + filename;
_fd = open(full_path.c_str(), O_APPEND | O_WRONLY | O_CREAT, 0644);
}
void output(const std::string& msg) override {
std::lock_guard<mutex> lock(_mutex);
write(_fd, msg.c_str(), msg.size());
write(_fd, "\n", 1);
}
~FileLogStrategy() override {
if (_fd >= 0) close(_fd);
}
};
enum class Loglevel {
INFO, DEBUG, WARNING, ERROR, FATAL
};
static std::string level_to_string(Loglevel level) {
switch(level) {
case Loglevel::INFO: return "INFO";
case Loglevel::DEBUG: return "DEBUG";
case Loglevel::WARNING: return "WARNING";
case Loglevel::ERROR: return "ERROR";
case Loglevel::FATAL: return "FATAL";
default: return "UNKNOWN";
}
}
static std::string get_current_time() {
time_t now = time(nullptr);
struct tm tm_buf;
localtime_r(&now, &tm_buf);
char buf[64];
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_buf);
return std::string(buf);
}
class Log {
std::shared_ptr<LogStrategy> _strategy;
std::string _log_path;
bool _is_console;
class LogStream {
std::stringstream _ss;
Log& _owner;
public:
LogStream(Loglevel level, const std::string& file, int line, Log& owner)
: _owner(owner) {
_ss << '[' << get_current_time() << ']'
<< '[' << level_to_string(level) << ']'
<< '[' << getpid() << ']'
<< '[' << file << ':' << line << "] - ";
}
template<typename T>
LogStream& operator<<(T&& t) {
_ss << t;
return *this;
}
~LogStream() {
_owner._strategy->output(_ss.str());
}
};
public:
Log(const std::string& path = std::string(DEFAULT_LOG_DIR) + DEFAULT_LOG_FILE,
bool console = true)
: _log_path(path), _is_console(console) {
if (_is_console) {
_strategy = std::make_shared<ConsoleLogStrategy>();
} else {
size_t idx = _log_path.rfind(PATH_SEP);
std::string dir, file;
if (idx == std::string::npos) {
dir = "./";
file = _log_path;
} else {
dir = _log_path.substr(0, idx + 1);
file = _log_path.substr(idx + 1);
}
_strategy = std::make_shared<FileLogStrategy>(dir, file);
}
}
void UseConsoleLogStrategy() {
_strategy = std::make_shared<ConsoleLogStrategy>();
_is_console = true;
}
void UseFileLogStrategy() {
size_t idx = _log_path.rfind(PATH_SEP);
std::string dir, file;
if (idx == std::string::npos) {
dir = "./";
file = _log_path;
} else {
dir = _log_path.substr(0, idx + 1);
file = _log_path.substr(idx + 1);
}
_strategy = std::make_shared<FileLogStrategy>(dir, file);
_is_console = false;
}
LogStream operator()(Loglevel level, std::string file, int line) {
return LogStream(level, std::move(file), line, *this);
}
};
// 全局日志实例,定义在Log.cpp中
extern Log global_log;
}
4.6 应用层协议模块(Protocol.hpp)
采用长度前缀法界定消息边界,正文采用空格分隔的文本格式,包含请求与响应两类报文。
#pragma once
#include <iostream>
#include <sstream>
#include <string>
#include <functional>
#include <stdexcept>
constexpr const char* MSG_SEP = "\r\n";
constexpr size_t SEP_LEN = 2;
// 计算请求报文
class Request {
int _x = 0;
int _y = 0;
char _op = 0;
public:
Request() = default;
Request(int x, int y, char op) : _x(x), _y(y), _op(op) {}
// 序列化:长度前缀 + 正文
std::string Serialize() const {
std::stringstream body;
body << _x << " " << _y << " " << _op << MSG_SEP;
std::string body_str = body.str();
size_t body_len = body_str.size();
std::stringstream header;
header << body_len << MSG_SEP;
return header.str() + body_str;
}
// 反序列化:仅解析正文部分
bool Deserialize(const std::string& body) {
size_t p1 = body.find(' ');
if (p1 == std::string::npos) return false;
size_t p2 = body.find(' ', p1 + 1);
if (p2 == std::string::npos) return false;
try {
_x = std::stoi(body.substr(0, p1));
_y = std::stoi(body.substr(p1 + 1, p2 - p1 - 1));
_op = body[p2 + 1];
} catch (...) {
return false;
}
return true;
}
int X() const { return _x; }
int Y() const { return _y; }
char Op() const { return _op; }
};
// 计算响应报文
class Response {
int _result = 0;
int _code = 0; // 0表示成功,非0表示错误
public:
Response() = default;
Response(int res, int code) : _result(res), _code(code) {}
std::string Serialize() const {
std::stringstream body;
body << _result << " " << _code << MSG_SEP;
std::string body_str = body.str();
size_t body_len = body_str.size();
std::stringstream header;
header << body_len << MSG_SEP;
return header.str() + body_str;
}
bool Deserialize(const std::string& body) {
size_t p1 = body.find(' ');
if (p1 == std::string::npos) return false;
size_t p2 = body.find(' ', p1 + 1);
if (p2 == std::string::npos) return false;
try {
_result = std::stoi(body.substr(0, p1));
_code = std::stoi(body.substr(p1 + 1, p2 - p1 - 1));
} catch (...) {
return false;
}
return true;
}
int Result() const { return _result; }
int Code() const { return _code; }
};
using HandlerFunc = std::function<Response(const Request&)>;
class Protocol {
HandlerFunc _handler;
public:
explicit Protocol(HandlerFunc func) : _handler(std::move(func)) {}
Response Process(const std::string& full_msg) const {
// 解析长度前缀,提取正文
size_t sep_pos = full_msg.find(MSG_SEP);
if (sep_pos == std::string::npos) {
return {0, -1};
}
size_t body_len = 0;
try {
body_len = std::stoul(full_msg.substr(0, sep_pos));
} catch (...) {
return {0, -1};
}
size_t body_start = sep_pos + SEP_LEN;
if (full_msg.size() < body_start + body_len) {
return {0, -1};
}
std::string body = full_msg.substr(body_start, body_len);
Request req;
if (!req.Deserialize(body)) {
return {0, -1};
}
return _handler(req);
}
};
4.7 服务器端实现
采用 Meyers 单例模式封装服务器,使用长度前缀法正确处理粘包,每个连接启动一个分离线程处理业务。
// server.hpp
#pragma once
#include <iostream>
#include <string>
#include <memory>
#include <signal.h>
#include "Socket.hpp"
#include "Thread.hpp"
#include "Protocol.hpp"
namespace TcpCalServer {
using namespace SocketModule;
using namespace bksw;
class Server {
std::unique_ptr<Socket> _listen_sock;
Protocol _protocol;
Server(HandlerFunc func, uint16_t port, int backlog)
: _protocol(std::move(func)) {
// 忽略SIGPIPE,防止向已关闭连接写数据导致进程崩溃
signal(SIGPIPE, SIG_IGN);
_listen_sock = std::make_unique<TcpSocket>();
_listen_sock->build_server(port, backlog);
}
public:
// Meyers单例,线程安全
static Server& get_instance(HandlerFunc func, uint16_t port, int backlog = 10) {
static Server instance(std::move(func), port, backlog);
return instance;
}
void run() {
LOG(Loglevel::INFO) << "计算器服务器启动成功,开始监听连接";
while (true) {
auto conn = _listen_sock->accept();
if (!conn) continue;
auto* th = new Thread();
th->start(&Server::handle_connection, this, conn);
th->detach();
}
}
private:
void handle_connection(std::shared_ptr<Socket> conn) {
std::string recv_buffer;
while (true) {
std::string chunk = conn->read();
if (chunk.empty()) break; // 连接关闭
recv_buffer += chunk;
// 长度前缀法解粘包
while (true) {
size_t sep_pos = recv_buffer.find(MSG_SEP);
if (sep_pos == std::string::npos) break;
size_t body_len = 0;
try {
body_len = std::stoul(recv_buffer.substr(0, sep_pos));
} catch (...) {
recv_buffer.clear();
break;
}
size_t total_len = sep_pos + SEP_LEN + body_len;
if (recv_buffer.size() < total_len) break; // 半包,等待更多数据
// 提取完整报文并处理
std::string full_msg = recv_buffer.substr(0, total_len);
Response rep = _protocol.Process(full_msg);
conn->write(rep.Serialize());
// 移除已处理报文
recv_buffer.erase(0, total_len);
}
}
}
Server(const Server&) = delete;
Server& operator=(const Server&) = delete;
};
}
cpp
// tcpserver.cpp
#include <iostream>
#include "server.hpp"
#include "Cal.hpp" // 计算器业务逻辑实现
int main(int argc, char** argv) {
if (argc != 2) {
std::cerr << "用法: ./server 端口号" << std::endl;
return 1;
}
uint16_t port = std::stoi(argv[1]);
Cal calculator;
auto& srv = TcpCalServer::Server::get_instance(
[&calculator](const Request& req) -> Response {
return calculator.Execute(req);
}, port
);
srv.run();
return 0;
}
4.8 客户端实现
复用地址封装逻辑,实现完整的粘包处理与循环写入.
// tcpclient.cpp
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
#include <signal.h>
#include "TcpAddr.hpp"
#include "Protocol.hpp"
#include "Log.hpp"
// 循环写
static ssize_t writen(int fd, const char* buf, size_t len) {
size_t total = 0;
while (total < len) {
ssize_t n = ::write(fd, buf + total, len - total);
if (n < 0) {
if (errno == EINTR) continue;
return -1;
}
total += n;
}
return static_cast<ssize_t>(total);
}
int main(int argc, char** argv) {
if (argc != 3) {
std::cerr << "用法: ./client 服务器IP 端口号" << std::endl;
return 1;
}
signal(SIGPIPE, SIG_IGN);
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return 1;
}
InaddrModule::Inaddr server_addr(argv[1], std::stoi(argv[2]));
if (connect(fd, SOCK_CAST(server_addr.get()), server_addr.size()) < 0) {
perror("connect");
close(fd);
return 1;
}
std::cout << "连接服务器成功" << std::endl;
std::string recv_buffer;
char buff[1024];
while (true) {
int x, y;
char oper;
std::cout << "请输入 x: "; std::cin >> x;
std::cout << "请输入 y: "; std::cin >> y;
std::cout << "请输入运算符(+ - * /): "; std::cin >> oper;
Request req(x, y, oper);
std::string msg = req.Serialize();
if (writen(fd, msg.c_str(), msg.size()) < 0) {
std::cerr << "发送数据失败,连接断开" << std::endl;
break;
}
// 循环读取直到拿到完整响应
Response rep;
bool got_response = false;
while (!got_response) {
ssize_t n = read(fd, buff, sizeof(buff));
if (n <= 0) {
std::cerr << "连接断开" << std::endl;
close(fd);
return 1;
}
recv_buffer.append(buff, n);
size_t sep_pos = recv_buffer.find(MSG_SEP);
if (sep_pos == std::string::npos) continue;
size_t body_len = 0;
try {
body_len = std::stoul(recv_buffer.substr(0, sep_pos));
} catch (...) {
recv_buffer.clear();
break;
}
size_t total_len = sep_pos + SEP_LEN + body_len;
if (recv_buffer.size() < total_len) continue;
std::string full_msg = recv_buffer.substr(0, total_len);
// 提取正文并反序列化
std::string body = full_msg.substr(sep_pos + SEP_LEN, body_len);
rep.Deserialize(body);
recv_buffer.erase(0, total_len);
got_response = true;
}
if (rep.Code() == 0) {
LOG(bksw::Loglevel::INFO) << "计算结果: " << rep.Result();
} else {
LOG(bksw::Loglevel::ERROR) << "计算失败,错误码: " << rep.Code();
}
}
close(fd);
return 0;
}
五、守护进程机制与服务器后台化
常规终端启动的服务器会随 SSH 连接断开而终止,无法满足服务长期运行的需求。守护进程(Daemon Process,俗称精灵进程)是脱离控制终端、在后台独立运行的服务进程,是后端服务的标准运行形态。理解守护进程的实现原理,需要先掌握 Linux 三级进程组织模型、伪终端内核机制与终端信号管控逻辑。
5.1 进程组织层级:进程组与会话
Linux 系统中,进程以「进程 → 进程组 → 会话」的三级层级化方式组织,该层级结构是终端作业控制、信号批量管理、守护进程实现的核心基础。
5.1.1 进程组的定义与核心特性
进程组(Process Group)是一个或多个具有关联关系的进程的集合,核心作用是实现信号的批量投递 与终端资源的统一管理,对应 Shell 中的「作业(Job)」概念。
每个进程组拥有唯一的进程组标识号 PGID,其数值与进程组组长(Group Leader) 的 PID 完全相等,组长进程是创建该进程组的首个进程,核心特征为 PID == PGID。
进程组具备以下核心规则:
- 创建与继承 :进程通过
setpgid()系统调用可将自身或同会话内的子进程加入指定进程组,或创建全新进程组;fork()创建的子进程默认继承父进程的进程组。 - 生命周期:进程组不会因组长进程退出而立即消亡,只有当组内最后一个进程退出或转移至其他进程组后,该进程组才会被内核销毁,PGID 保持不变。
- 会话约束:同一进程组内的所有进程必须隶属于同一个会话,不允许跨会话迁移进程组。
- 信号批量投递 :信号可按进程组批量发送,例如
kill -PGID可向组内所有进程同时发送信号。Shell 中的一条管道命令(如ls | grep test | wc -l)包含多个进程,Shell 会将它们放入同一个进程组,作为一个作业统一启停。
5.1.2 会话的定义与控制终端绑定
会话(Session)是一个或多个进程组的集合 ,是 Linux 最高层级的进程组织单位,通常与一个控制终端(Controlling Terminal) 绑定,对应用户的一次完整登录交互周期。
每个会话拥有唯一的会话标识号 SID,其数值与会话首进程(Session Leader) 的 PID 完全相等,特征为 PID == SID,会话首进程是调用 setsid() 系统调用创建会话的进程。
会话的核心机制如下:
- 终端绑定规则:一个会话最多只能关联一个控制终端;反之,一个控制终端也只能归属一个会话。终端的输入、终端硬件产生的信号,仅作用于该会话内部。
- 前后台进程组划分 :每个拥有控制终端的会话,同一时间只能有一个前台进程组,其余进程组均为后台进程组。终端输入的独占权、终端信号的接收权,仅归前台进程组所有。
- 生命周期管理 :当控制终端断开连接(如 SSH 掉线、终端窗口关闭)时,内核会向会话内的所有进程发送
SIGHUP信号,实现登录退出时的批量资源回收。
5.1.3 登录会话的建立流程:bash 作为会话首进程
bash 作为用户登录后的交互 Shell,天然是会话的首进程与默认前台进程组组长,其完整建立流程由登录守护进程完成:
- 系统级守护进程(SSH 场景下为
sshd、本地图形化场景下为终端模拟器)接收用户登录请求后,调用fork()创建子进程; - 该子进程调用
setsid()系统调用,创建全新会话,自身成为会话首进程;同时自动创建新进程组,自身成为进程组组长; - 子进程打开终端设备,将其设置为当前会话的控制终端;
- 子进程完成用户身份降权后,通过
exec()系统调用加载并启动 bash 程序。 由于exec()仅替换进程的代码段与数据段,不改变 PID、PGID、SID 等身份属性,bash 完整继承了会话首进程的身份,成为该会话的第一个用户态交互进程。
5.2 伪终端机制与终端访问控制
SSH 远程登录场景下,服务器没有连接物理终端硬件,此时系统通过伪终端(Pseudo Terminal, PTY) 软件模拟真实终端的全部行为,为 Shell 与业务进程提供兼容的终端接口。
5.2.1 伪终端对与 /dev/pts 文件系统
伪终端以成对的形式存在,称为「伪终端对」,包含两个功能互补的双向字符设备,行为与物理终端完全一致,进程无法感知二者区别。
- 主设备(PTY Master):面向终端模拟器 / 守护进程的控制端。持有主设备的进程可通过写入主设备模拟用户键盘输入,通过读取主设备获取终端输出内容。主设备没有公开的设备文件节点,仅以文件描述符的形式存在于持有它的进程中。
- 从设备(PTY Slave):面向业务进程的终端接口。Shell、vim 等用户进程将从设备视为标准终端,读取从设备即可获取终端输入,写入从设备即可输出内容。
伪终端对与普通管道的核心差异在于:主从设备之间存在一层终端行规程(Line Discipline),内置了回显、行编辑、特殊字符转信号、终端窗口尺寸控制等完整的终端语义,这是纯字节流管道不具备的能力。
/dev/pts 是 devpts 伪文件系统的挂载点,所有文件均由内核动态生成于内存中,不占用磁盘存储空间,目录内仅包含两类文件:
-
ptmx(伪终端主设备多路复用器) 该文件是主设备号 5、次设备号 2 的字符设备,是系统中所有伪终端对的统一创建入口。当进程打开/dev/ptmx时,内核会分配一个未使用的终端编号,生成对应的从设备文件,并返回主设备的文件描述符。 该文件权限通常设置为000(无任何读写权限),属于安全设计:Linux 中 root 用户不受文件权限位限制,可正常打开该设备创建伪终端;普通用户无法直接打开,避免非授权用户滥用伪终端进行权限逃逸或信息窃取。 -
数字编号文件(如
/dev/pts/0) 每个数字文件对应一个活跃的伪终端从设备,文件名即为终端编号,也是该设备的次设备号,主设备号固定为 136(符合 UNIX 98 伪终端标准)。每新建一个终端会话就会生成一个对应编号的文件,会话关闭后由内核自动回收删除。 从设备默认权限为crw--w----,所有者为当前登录用户,所属组为tty组,仅允许所有者读取终端输入,防止其他用户窃取键盘输入内容。
挂载点(Mount Point)是 Linux 目录树中的一个普通目录,它是外部文件系统接入系统统一目录树的 "接口目录"。 将一个文件系统挂载到该目录后,访问这个目录就等价于访问被挂载的文件系统内部的所有内容;原目录里的文件会被临时隐藏,直到卸载(umount)后恢复。
- 为什么需要 "挂载" 这个设计
Linux 遵循「一切皆文件」的设计哲学,所有文件、设备、资源都统一组织在一棵以 / 为根的目录树中。但系统的文件来源是多样的:
-
磁盘上的 ext4/xfs 分区
-
U 盘、光盘等外部存储
-
proc、sysfs、devpts 这类内核虚拟出来的伪文件系统(完全在内存中,不占磁盘)
这些文件系统各自独立、结构不同,不能直接拼进根目录。挂载就是把这些独立的文件系统,"挂接" 到根目录树的某个目录节点上,让用户可以通过统一的路径访问它们。
-
挂载的本质效果
-
挂载前:挂载点只是一个普通的空目录,里面的文件都存在于根目录所在的磁盘分区上。
-
执行 mount 操作后:内核将目标文件系统的根目录,映射到这个挂载点目录上。用户
cd进这个目录,看到的就不再是原磁盘分区的内容,而是被挂载文件系统的内部内容。 -
卸载(umount)后:挂载点恢复为普通目录,原目录的内容重新可见。
-
结合你熟悉的
/dev/pts举例
/dev/pts 就是一个典型的挂载点,对应 devpts 伪文件系统:
-
devpts是内核维护的、完全存在于内存中的文件系统,用来动态生成伪终端从设备文件; -
系统启动时,内核执行挂载操作,把
devpts文件系统挂载到/dev/pts这个目录上; -
所以你执行
ll /dev/pts看到的0、ptmx这些文件,都不是磁盘里存的,而是内核实时生成的内存文件; -
你可以用
mount | grep devpts命令看到这条挂载记录。
再举一个更直观的例子:插入 U 盘后,执行 mount /dev/sdb1 /mnt/usb,其中 /mnt/usb 就是挂载点。访问 /mnt/usb 目录,就是直接读写 U 盘里的文件。
- 挂载点的关键特性
-
挂载点必须是已经存在的目录,不能是文件;
-
理论上可以挂载到任意目录,不局限于
/mnt、/media; -
同一个文件系统可以挂载到多个挂载点(多入口访问);
-
挂载点目录下原本的文件会被 "覆盖隐藏",不会被删除,卸载后恢复。
5.2.2 伪终端的内核缓冲队列与数据流
每一对伪终端都会由 Linux 内核的 tty 子系统在内核空间中维护独立的缓冲队列,队列不属于任何用户态进程,由内核统一管理,用户进程只能通过系统调用间接操作。该设计与第二章 TCP 套接字的收发缓冲区思想一致,均为内核态与用户态之间的数据缓冲层。
每套缓冲结构包含两个独立的环形队列:
- 输入队列(读队列):缓存待被业务进程读取的终端输入数据;
- 输出队列(写队列):缓存业务进程写入的终端输出数据。
以 SSH 场景为例,完整的双向数据流如下:
- 输入链路(键盘→业务进程) :本地键盘输入由操作系统捕获后,经 SSH 客户端加密通过网络传输至服务器;服务器对应的 sshd 子进程接收解密后,调用
write()写入自身持有的伪终端主设备;数据进入内核空间,经过行规程处理(回显复制、特殊字符解析、行编辑处理)后,存入对应从设备的输入队列;最终由前台进程组的进程调用read()读取。 - 输出链路(业务进程→屏幕) :服务器业务进程调用
write()写入标准输出 / 标准错误,数据进入从设备的输出队列;内核自动将输出队列的数据同步转发至配对主设备的可读缓冲区;sshd 子进程读取主设备数据,加密后通过网络回传至本地 SSH 客户端,最终渲染至终端窗口。
进程的标准输入(fd 0)、标准输出(fd 1)、标准错误(fd 2)通常都指向同一个从设备文件,但不会出现字节流紊乱,核心保障机制有三点:
- 读写通道物理隔离:终端是字符流设备,无文件位置指针,读操作仅消费输入队列,写操作仅追加输出队列,不存在指针偏移冲突;输入回显是行规程主动复制的结果,不属于通道串扰。
- 输入侧独占机制:通过前台进程组规则,仅前台进程可读取终端输入,从根源避免多进程争抢输入导致的数据拆分。
- 输出侧原子性保障 :输出队列遵循 FIFO 规则,单次写入长度不超过
PIPE_BUF(Linux 下为 4096 字节)时,内核保证写入操作的原子性,数据不会被其他进程的写入穿插。
5.2.3 前后台进程组的终端权限管控
同一个会话内,只能有一个前台进程组,其余均为后台进程组。二者的终端访问权限差异由内核终端驱动直接管控,并非 Shell 主动限制。
读取权限管控 :内核在终端输入队列的读取出口处做了强制校验,当进程调用 read() 尝试读取控制终端时:
- 内核获取当前进程的进程组 ID(PGID);
- 将其与终端设备记录的前台进程组 ID 做比对;
- 若匹配,则判定为前台进程,正常从输入队列返回数据;
- 若不匹配,则判定为后台进程,不返回任何数据,直接向该进程所在的整个进程组发送
SIGTTIN信号。SIGTTIN信号的默认行为是暂停进程执行,直到进程被移至前台、收到SIGCONT信号恢复后,才能重新获得终端读取权限。
写入权限管控 :与读取的严格限制不同,Linux 默认允许后台进程向终端写入输出,仅当用户通过 stty tostop 开启终端选项后,后台进程写入才会触发 SIGTTOU 信号被暂停。因此常规场景下,后台进程的输出可以正常打印到终端屏幕,仅可能出现多进程并发输出的行级交错。
Shell 的作业控制功能,本质就是通过系统调用管理进程组与终端的前台归属关系:
- 前台命令启动时,bash 调用
setpgid()将新命令进程放入独立进程组,再调用tcsetpgrp()将其设为终端前台进程组,bash 自身退居后台等待; - 后台命令(
&后缀)启动时,bash 仅将命令放入独立进程组,不修改终端前台归属; fg命令调用tcsetpgrp()将指定后台进程组设为前台,同时发送SIGCONT恢复进程运行;- Ctrl+C / Ctrl+Z 等终端快捷键,由终端行规程识别后,直接向前台进程组的所有进程统一发送
SIGINT或SIGTSTP信号,实现批量终止或暂停。
5.2.4 终端断开的终止链路:SIGHUP 信号传递
SSH 连接断开时,服务器端的伪终端主设备会被关闭,内核检测到终端载波丢失,会触发完整的信号传递链路:
- 内核向控制终端所属会话的首进程(通常为 bash)发送
SIGHUP信号; - bash 作为会话首进程与作业管理者,收到
SIGHUP后,会向其管理的所有作业进程组转发SIGHUP信号; - 进程收到
SIGHUP信号后默认执行终止操作。 这就是 "关闭 SSH 终端后服务器进程自动退出" 的根本原因:进程并非被网络断开直接杀死,而是通过会话→终端的信号机制被批量终止。
5.3 守护进程的核心设计思路
让服务器脱离终端、长期运行的核心方法是:创建新的独立会话,彻底脱离原控制终端,从根源上规避终端断开的信号影响。
POSIX 提供了 setsid() 系统调用,调用该函数的进程会创建一个新会话并成为会话首进程,同时创建一个新进程组并成为组长。但该调用有一个强制前提:调用进程不能是当前进程组的组长,否则调用失败。这是为了防止组长进程创建新会话后,原进程组出现 PGID 与进程 PID 冲突的异常。
因此守护进程的基础实现流程为:
- 父进程 fork 出子进程,父进程直接退出;
- 子进程不是原进程组组长,满足
setsid()调用前提,调用setsid()创建新会话,彻底脱离原控制终端; - 重定向标准输入、输出、错误到
/dev/null,避免终端 IO 操作报错。
5.4 标准守护进程的工程实现
实现规范步骤
- 第一次 fork,父进程退出,子进程继续,保证子进程不是原进程组组长;
- 子进程调用
setsid(), 创建新会话、新进程组,彻底脱离原控制终端; - 第二次 fork,父进程(会话首进程)退出,子进程继续。此时子进程不再是会话首进程,永远无法重新打开控制终端,彻底断绝与终端的关联;
- 调用
umask(0)清除文件权限掩码,避免继承父进程的权限掩码导致文件权限不符合预期; - 调用
chdir("/")将工作目录切换到根目录,避免占用可卸载文件系统,同时消除对启动目录的依赖; - 关闭所有继承的文件描述符,将标准输入、标准输出、标准错误重定向到
/dev/null。
- 第一次 fork:为了能调用 setsid,脱离原终端;
- 第二次 fork:为了放弃会话首进程身份,永远不能再重新获取终端
这里要解释一下:Linux规定只有会话首进程有权限为会话打开一个终端,也就是在挂载点(/dev/pts设置一个新的设备文件)
标准实现代码
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <stdlib.h>
int daemonize(int nochdir, int noclose) {
pid_t pid = fork();
if (pid < 0) return -1;
if (pid > 0) exit(0); // 父进程退出
// 第一步:创建新会话,脱离原控制终端
if (setsid() < 0) return -1;
// 第二步:第二次fork,彻底脱离控制终端,禁止重新打开
pid = fork();
if (pid < 0) return -1;
if (pid > 0) exit(0);
// 清除文件权限掩码
umask(0);
// 切换工作目录
if (!nochdir) {
chdir("/");
}
// 关闭并重定向标准文件描述符
if (!noclose) {
int fd = open("/dev/null", O_RDWR);
if (fd < 0) return -1;
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
if (fd > STDERR_FILENO) {
close(fd);
}
}
return 0;
}
Linux 系统也提供了标准库函数 daemon(int nochdir, int noclose),参数与功能与上述实现一致:
nochdir:为 0 时将工作目录切换到根目录,为 1 时不切换;noclose:为 0 时关闭并重定向标准文件描述符,为 1 时不处理。
概念边界澄清
- 守护进程必然是孤儿进程(父进程退出后被 init 进程收养),但孤儿进程不等于守护进程。孤儿进程仅表示父进程已终止,可能仍关联控制终端;守护进程的核心特征是脱离控制终端、独立会话、长期后台运行。
- 前台 / 后台是针对拥有控制终端的会话内进程组的划分,守护进程已脱离控制终端,不存在前台、后台的属性。
使用时只需在服务器 main 函数初始化阶段调用 daemonize(0, 0),即可将服务器转为守护进程运行。
六、总结与扩展方向
本文系统梳理了 TCP 网络编程的核心原理,从协议栈分层、Socket 内核机制到应用层协议设计,层层递进地解释了网络通信的底层逻辑;并通过 C++ 模块化编程实现了完整的 TCP 计算器服务,覆盖了地址封装、套接字管理、线程封装、日志系统、协议设计等工程核心模块,修复了短写、信号安全、线程安全等常见工程缺陷。最后深入讲解了守护进程的实现原理,修正了进程组、会话、伪终端相关的常见认知误区,给出了符合标准的工程化实现。