
🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录
- 前言
- [一、TCP 与 UDP 的第一处区别](#一、TCP 与 UDP 的第一处区别)
- [二、TCP 服务器需要哪些接口](#二、TCP 服务器需要哪些接口)
-
- [2.1 `socket`:创建流式套接字](#2.1
socket:创建流式套接字) - [2.2 `bind`:绑定固定服务器端口](#2.2
bind:绑定固定服务器端口) - [2.3 `listen`:把 Socket 设置成监听状态](#2.3
listen:把 Socket 设置成监听状态) - [2.4 使用 `telnet` 验证监听状态](#2.4 使用
telnet验证监听状态)
- [2.1 `socket`:创建流式套接字](#2.1
- [三、`accept` 到底做了什么](#三、
accept到底做了什么) -
- [3.1 连接并不是由 `accept` 建立的](#3.1 连接并不是由
accept建立的) - [3.2 三个参数的输入输出方向](#3.2 三个参数的输入输出方向)
- [3.3 为什么返回一个新的文件描述符](#3.3 为什么返回一个新的文件描述符)
- [3.1 连接并不是由 `accept` 建立的](#3.1 连接并不是由
- [四、TCP 字节流怎样读取](#四、TCP 字节流怎样读取)
-
- [4.1 `read` 的三种返回情况](#4.1
read的三种返回情况) - [4.2 `write` 不保证一次写完](#4.2
write不保证一次写完)
- [4.1 `read` 的三种返回情况](#4.1
- [五、完整的单进程 Echo Server](#五、完整的单进程 Echo Server)
-
- [5.1 工程结构](#5.1 工程结构)
- [5.2 `Common.hpp`](#5.2
Common.hpp) - [5.3 `InetAddr.hpp`](#5.3
InetAddr.hpp) - [5.4 `TcpServer.hpp`](#5.4
TcpServer.hpp) - [5.5 `TcpServer.cc`](#5.5
TcpServer.cc)
- [六、TCP 客户端](#六、TCP 客户端)
-
- [6.1 `connect` 的目标是服务器地址](#6.1
connect的目标是服务器地址) - [6.2 `TcpClient.cc`](#6.2
TcpClient.cc) - [6.3 Makefile 与运行](#6.3 Makefile 与运行)
- [6.1 `connect` 的目标是服务器地址](#6.1
- 七、单进程版本的问题
- 总结
前言
前面已经用 UDP 写过回显、字典和聊天室。UDP 创建好套接字以后,可以直接通过 sendto 和 recvfrom 收发数据;TCP 不一样,它是面向连接的字节流协议,正式通信前必须先建立连接。
因此,TCP 服务器比 UDP 多出两个关键步骤:
text
socket -> bind -> listen -> accept -> read/write
客户端则是:
text
socket -> connect -> read/write
本文先完成最基础的单进程 Echo Server。客户端发送什么,服务器就回显什么。代码虽然不复杂,但会把监听套接字、通信套接字、连接队列以及 accept 的输入输出参数全部串起来。
一、TCP 与 UDP 的第一处区别
UDP 是无连接、面向数据报的。应用程序创建 UDP Socket,填写目标地址以后就可以发送数据,每次发送都有自己的数据报边界。
TCP 是面向连接、面向字节流的。服务器不能创建 Socket 后立刻调用 read 等待任意客户端数据,而要先把 Socket 设置成监听状态,等待客户端发起连接。连接建立以后,服务器再取得一个专门与该客户端通信的文件描述符。
现阶段可以先把 TCP 服务端理解为两个阶段:
- **建立连接:**监听客户端,取出内核已经建立好的连接;
- **通信:**通过新的文件描述符与某个客户端进行一对一读写。
监听和通信不是同一件事。监听套接字负责获取新连接,accept 返回的通信套接字才负责收发业务数据。
二、TCP 服务器需要哪些接口
2.1 socket:创建流式套接字
TCP 和 UDP 都使用 socket 创建网络文件描述符,区别主要在第二个参数:
cpp
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
AF_INET:IPv4 地址族;SOCK_STREAM:面向字节流的 Socket,对应 TCP;0:根据地址族和类型选择默认协议。
调用成功返回非负文件描述符,失败返回 -1 并设置 errno。
从应用层看,Socket 仍然通过文件描述符访问,所以建立连接后可以使用 read、write、recv、send 等接口收发数据。
2.2 bind:绑定固定服务器端口
服务器必须提供稳定入口,因此要把监听 Socket 绑定到本地端口:
cpp
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = htonl(INADDR_ANY);
int ret = bind(listen_sock,
reinterpret_cast<sockaddr*>(&local),
sizeof(local));
绑定 INADDR_ANY 后,凡是发往本机任意 IPv4 接口、目标端口匹配的连接请求,都可以由该监听 Socket 处理。
端口要使用 htons 转成网络字节序。地址结构必须先清零,避免未赋值字段中残留随机数据。
2.3 listen:把 Socket 设置成监听状态
cpp
int listen(int sockfd, int backlog);
listen 不负责接收业务数据,它只是把已经绑定地址的 Socket 转成监听 Socket。
可以把监听状态理解为餐厅门口一直有人接待新客人。客户进店后,门口的人仍然要继续等待下一位客人,真正点餐和上菜由其他服务人员完成。
backlog 和连接等待队列有关,但不能简单理解为"服务器最多只能连接 backlog 个客户端"。它描述的是内核连接队列长度相关参数,最终行为还会受到系统配置影响。学习阶段先使用 8 或 16 即可。

监听成功以后,可以查看 TCP 监听状态:
bash
netstat -ntapl
-n:直接显示数字地址和端口;-t:只看 TCP;-a:显示全部 Socket;-p:显示进程信息;-l:只看监听状态。

2.4 使用 telnet 验证监听状态
只要 TCP 服务器已经进入监听状态,客户端就可以尝试建立连接。telnet 可以用来做最基础的连通性测试:
bash
telnet www.baidu.com 80
连接成功后,可以手动输入一份最简单的 HTTP 请求:
text
GET / HTTP/1.1
Host: www.baidu.com


同样,也可以使用 telnet 127.0.0.1 8888 测试自己写的服务器。

这里能证明的是目标地址和端口可以建立 TCP 连接,并不能证明应用层协议一定正确。若服务端要求特定报文格式,连接成功以后仍可能因为请求格式错误而主动断开。
三、accept 到底做了什么
3.1 连接并不是由 accept 建立的
函数原型如下:
cpp
int accept(int sockfd,
struct sockaddr* addr,
socklen_t* addrlen);
客户端发起连接后,TCP 三次握手主要由内核协议栈完成。即使应用程序暂时没有调用 accept,只要连接队列仍能容纳这条连接,客户端也可能看到连接已经建立。
accept 的作用是:从监听 Socket 对应的已完成连接队列中取出一条连接,为应用程序返回一个新的通信文件描述符。

所以不能把 accept 理解成"三次握手函数"。三次握手完成后,连接先由内核管理;应用程序调用 accept 后,才拿到可以读写这条连接的 fd。
3.2 三个参数的输入输出方向
第一个参数是监听 Socket,属于输入参数:
cpp
accept(listen_sock, ...);
第二个参数是客户端地址缓冲区,属于输出参数。调用者先准备空间,accept 成功后把对端 IP 和端口写进去:
cpp
sockaddr_in peer{};
第三个参数是值---结果参数:
cpp
socklen_t len = sizeof(peer);
- 调用前,
len表示调用者提供的地址缓冲区大小; - 调用后,内核把实际写入的地址长度返回到
len中。
完整调用:
cpp
int sockfd = accept(
listen_sock,
reinterpret_cast<sockaddr*>(&peer),
&len);
3.3 为什么返回一个新的文件描述符
监听 Socket 只负责接收新连接。如果它既负责等待连接,又负责和某个客户端长期通信,那么服务器处理当前客户端时就无法及时接待其他客户端。
所以内核保留监听 fd 的职责不变,每成功取出一条连接,就返回一个新的通信 fd:
text
listen_sock:等待并获取新连接
sockfd:与某个客户端进行read/write
餐厅门口的接待人员仍然站在门口,新客人进入以后,由店内服务员负责后续服务。接待和服务的角色分开,服务器才能继续扩展并发能力。
一个 TCP 服务器通常只有少量监听 Socket,却可能同时拥有大量通信 Socket。每个 accept 返回值对应一条已经建立的连接。
四、TCP 字节流怎样读取
4.1 read 的三种返回情况
连接建立以后,可以直接调用 read:
cpp
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
返回值需要分三种情况处理:
n > 0:本次读到n个字节;n == 0:对端已经正常关闭连接,当前方向读到"文件结尾";n < 0:读取失败,需要检查errno。

当 n > 0 且内容按文本处理时,可以补字符串结束符:
cpp
buffer[n] = '\0';
前提是读取长度传入 sizeof(buffer) - 1,确保还留有一个字节。
read 返回 0 不代表"暂时没数据",而是对端已关闭连接。暂时没有数据时,阻塞 Socket 会让线程睡眠等待。
4.2 write 不保证一次写完
基础回显示例经常直接调用:
cpp
write(sockfd, message.data(), message.size());
但 write 的返回值表示本次实际写入的字节数,不承诺一定等于请求长度。更稳妥的做法是封装循环发送:
cpp
bool WriteAll(int sockfd, const std::string& message) {
std::size_t offset = 0;
while (offset < message.size()) {
ssize_t n = write(sockfd,
message.data() + offset,
message.size() - offset);
if (n > 0) {
offset += static_cast<std::size_t>(n);
continue;
}
if (n < 0 && errno == EINTR) {
continue;
}
return false;
}
return true;
}

这里还没有处理 TCP 消息边界。TCP 是字节流,一次 write 不一定对应接收端的一次 read。当前回显程序只用于观察基本接口,真正的业务协议要在后面的应用层协议部分解决粘包、拆包和报文边界。
五、完整的单进程 Echo Server
5.1 工程结构
text
tcp_echo_v1/
├── Common.hpp
├── InetAddr.hpp
├── TcpServer.hpp
├── TcpServer.cc
├── TcpClient.cc
└── makefile
5.2 Common.hpp
cpp
#pragma once
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdint>
#include <cstring>
#include <stdexcept>
#include <string>
enum ExitCode {
OK = 0,
USAGE_ERR,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR,
CONNECT_ERR,
FORK_ERR
};
class NoCopy {
public:
NoCopy() = default;
NoCopy(const NoCopy&) = delete;
NoCopy& operator=(const NoCopy&) = delete;
virtual ~NoCopy() = default;
};
服务器对象持有文件描述符,不希望被随意复制。删除拷贝构造和赋值,可以避免两个对象误持有同一个监听 fd。
5.3 InetAddr.hpp
cpp
#pragma once
#include "Common.hpp"
class InetAddr {
public:
explicit InetAddr(uint16_t port) {
std::memset(&addr_, 0, sizeof(addr_));
addr_.sin_family = AF_INET;
addr_.sin_port = htons(port);
addr_.sin_addr.s_addr = htonl(INADDR_ANY);
}
InetAddr(const std::string& ip, uint16_t port) {
std::memset(&addr_, 0, sizeof(addr_));
addr_.sin_family = AF_INET;
addr_.sin_port = htons(port);
if (inet_pton(AF_INET, ip.c_str(), &addr_.sin_addr) != 1) {
throw std::invalid_argument("invalid IPv4 address");
}
}
explicit InetAddr(const sockaddr_in& addr)
: addr_(addr) {}
const sockaddr* AddrPtr() const {
return reinterpret_cast<const sockaddr*>(&addr_);
}
sockaddr* AddrPtr() {
return reinterpret_cast<sockaddr*>(&addr_);
}
socklen_t Length() const {
return sizeof(addr_);
}
std::string Ip() const {
char buffer[INET_ADDRSTRLEN] = {0};
inet_ntop(AF_INET, &addr_.sin_addr, buffer, sizeof(buffer));
return buffer;
}
uint16_t Port() const {
return ntohs(addr_.sin_port);
}
std::string StringAddr() const {
return Ip() + ":" + std::to_string(Port());
}
private:
sockaddr_in addr_{};
};
inet_ntop 的第四个参数必须是目标字符数组的大小。不要误写成 sizeof(sockaddr_in),否则缓冲区长度与实际对象不对应。
5.4 TcpServer.hpp
cpp
#pragma once
#include "Common.hpp"
#include "InetAddr.hpp"
#include <iostream>
class TcpServer : public NoCopy {
public:
explicit TcpServer(uint16_t port)
: port_(port) {}
bool Init() {
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock_ < 0) {
std::cerr << "socket error: " << strerror(errno) << std::endl;
return false;
}
InetAddr local(port_);
if (bind(listen_sock_, local.AddrPtr(), local.Length()) < 0) {
std::cerr << "bind error: " << strerror(errno) << std::endl;
return false;
}
if (listen(listen_sock_, 8) < 0) {
std::cerr << "listen error: " << strerror(errno) << std::endl;
return false;
}
return true;
}
void Run() {
for (;;) {
sockaddr_in peer_addr{};
socklen_t len = sizeof(peer_addr);
int sockfd = accept(
listen_sock_,
reinterpret_cast<sockaddr*>(&peer_addr),
&len);
if (sockfd < 0) {
if (errno == EINTR) {
continue;
}
std::cerr << "accept error: " << strerror(errno) << std::endl;
continue;
}
InetAddr peer(peer_addr);
std::cout << "new connection: "
<< peer.StringAddr() << std::endl;
Service(sockfd, peer);
close(sockfd);
}
}
~TcpServer() override {
if (listen_sock_ >= 0) {
close(listen_sock_);
}
}
private:
static bool WriteAll(int sockfd, const std::string& message) {
std::size_t offset = 0;
while (offset < message.size()) {
ssize_t n = write(sockfd,
message.data() + offset,
message.size() - offset);
if (n > 0) {
offset += static_cast<std::size_t>(n);
} else if (n < 0 && errno == EINTR) {
continue;
} else {
return false;
}
}
return true;
}
static void Service(int sockfd, const InetAddr& peer) {
char buffer[1024];
for (;;) {
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
std::cout << peer.StringAddr()
<< " say# " << buffer << std::endl;
std::string response = "server echo# ";
response.append(buffer, static_cast<std::size_t>(n));
if (!WriteAll(sockfd, response)) {
break;
}
} else if (n == 0) {
std::cout << peer.StringAddr()
<< " quit" << std::endl;
break;
} else if (errno != EINTR) {
std::cerr << "read error: "
<< strerror(errno) << std::endl;
break;
}
}
}
private:
uint16_t port_;
int listen_sock_ = -1;
};
这就是最基础的单进程版本。它一次只处理一个通信 fd,当前客户端不断开,程序就不会回到下一轮 accept。这个问题留到下一篇解决。
5.5 TcpServer.cc
cpp
#include "TcpServer.hpp"
#include <iostream>
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return USAGE_ERR;
}
uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
TcpServer server(port);
if (!server.Init()) {
return SOCKET_ERR;
}
server.Run();
return OK;
}
六、TCP 客户端
6.1 connect 的目标是服务器地址
客户端不需要 listen 和 accept。它要做的是填写服务器地址,然后主动发起连接:
cpp
int connect(int sockfd,
const struct sockaddr* addr,
socklen_t addrlen);
bind 和 connect 都接收地址结构,但含义不同:
bind绑定自己的本地地址;connect指定准备连接的远端地址。


客户端也需要本地 IP 和端口,但通常不显式调用 bind。第一次 connect 时,操作系统根据路由选择本地地址,并分配临时端口。
6.2 TcpClient.cc
cpp
#include "Common.hpp"
#include "InetAddr.hpp"
#include <iostream>
bool WriteAll(int sockfd, const std::string& message) {
std::size_t offset = 0;
while (offset < message.size()) {
ssize_t n = write(sockfd,
message.data() + offset,
message.size() - offset);
if (n > 0) {
offset += static_cast<std::size_t>(n);
} else if (n < 0 && errno == EINTR) {
continue;
} else {
return false;
}
}
return true;
}
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: " << argv[0]
<< " server_ip server_port" << std::endl;
return USAGE_ERR;
}
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
std::cerr << "socket error: " << strerror(errno) << std::endl;
return SOCKET_ERR;
}
InetAddr server(argv[1],
static_cast<uint16_t>(std::stoi(argv[2])));
if (connect(sockfd, server.AddrPtr(), server.Length()) < 0) {
std::cerr << "connect error: " << strerror(errno) << std::endl;
close(sockfd);
return CONNECT_ERR;
}
for (;;) {
std::string request;
std::cout << "Please Enter# ";
if (!std::getline(std::cin, request)) {
break;
}
if (!WriteAll(sockfd, request)) {
break;
}
char buffer[1024];
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
std::cout << buffer << std::endl;
} else {
break;
}
}
close(sockfd);
return OK;
}
6.3 Makefile 与运行
makefile
.PHONY: all clean
all: tcpclient tcpserver
tcpclient: TcpClient.cc
g++ -o $@ $^ -std=c++17
tcpserver: TcpServer.cc
g++ -o $@ $^ -std=c++17
clean:
rm -f tcpclient tcpserver
编译并启动:
bash
make
./tcpserver 8888
另一个终端运行客户端:
bash
./tcpclient 127.0.0.1 8888
七、单进程版本的问题
程序执行顺序如下:
- 服务器阻塞在
accept; - 客户端 A 连接后,
accept返回 A 的通信 fd; - 主线程进入
Service(A),不断读取 A; - A 不退出,
Service就不返回; - 客户端 B 即使完成握手,也只能留在内核队列中等待;
- A 退出后,主线程才回到下一轮
accept。
这不是 accept 只能连接一个客户端,而是应用程序把"接收新连接"和"服务当前连接"放在同一条执行流中,自己把主线程堵住了。
下一篇会让主进程只负责 accept,把每个通信 fd 交给独立子进程,从而同时服务多个客户端。
总结
本文完成了 TCP 最基础的一条链路:
- 服务器通过
socket、bind、listen建立监听 Socket; - 三次握手主要由内核完成,
accept负责取出已建立连接; - 监听 fd 负责获取连接,通信 fd 负责
read/write; read == 0表示对端已经关闭连接;- 客户端使用
connect主动连接服务器; - 单进程版本一次只能服务一个长连接,结构正确但没有并发能力。
完整系列代码可在仓库中查看:Socket 编程代码。
往期博客分享
【Linux网络】UDP 服务器怎么写?从 socket、bind 到 recvfrom/sendto,手写完整回显服务器