news 2026/5/14 22:20:34

计算机网络编程———手写 TCP 服务器(一)搞懂网络编程核心 API

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
计算机网络编程———手写 TCP 服务器(一)搞懂网络编程核心 API

目录

一、TCP vs UDP —— 一句话讲清区别

二、单进程 TCP 服务器完整拆解

2.1 socket + bind —— 跟 UDP 一样

2.2 setsockopt

2.3 listen —— 把电话线插上

2.4 两个文件描述符 —— 整篇文章最重要的概念

2.5 accept —— 前台叫号

2.6 单进程版的完整流程

2.7 ⭐ read 返回值 —— 最要命的坑

三、客户端实现 —— 拨号打电话

四、源码 · 单进程 TCP 服务器完整版

makefile

Log.hpp

TcpServer.hpp

Main.cc

TcpClient.cc

五、本篇总结


最近在啃 Linux 网络编程,这篇文章是这个系列的第一篇,从最基础的单进程 TCP 服务器讲起。

这篇主要讲三件事:

  • 三个核心 API:listen、accept、read/write
  • 一个核心概念:两个文件描述符 —— listensock_ 和 sockfd
  • 一个要命的坑:read 返回 0 为什么不处理会炸

一、TCP vs UDP —— 一句话讲清区别

搞网络编程,第一步是搞清楚 TCP 和 UDP 到底有什么不一样。用生活类比是最容易理解的。

UDP 像寄信。你写好一封信,扔进邮筒,对方能不能收到、什么时候收到,你不知道也不关心。你可以同时给张三、李四、王五各寄一封,完全不冲突。

TCP 像打电话。你必须先拨号,对方接了,你们之间建立了一条专线。你在这头说,他在那头听。说完了挂电话,线路就断了。

落实到代码上,差别就在流程:

步骤UDP 服务器TCP 服务器
1socket()socket()
2bind()bind()
3listen()
4accept()
5recvfrom() / sendto()read() / write()

UDP 两步半就完事了,TCP 多了 listen 和 accept。这多出来的两个 API,就是 TCP 整个复杂度的源头。

二、单进程 TCP 服务器完整拆解

下面我拆开讲每个环节,配代码,配说明,争取你看完就能自己敲出来。

2.1 socket + bind —— 跟 UDP 一样

listensock_ = socket(AF_INET, SOCK_STREAM, 0);

注意这里的SOCK_STREAM。UDP 用的是SOCK_DGRAM(数据报,一个个独立小包裹),TCP 用的是SOCK_STREAM(流式,像水管里的水,没有边界)。

bind 的部分和 UDP 完全一样:初始化一个struct sockaddr_in,填上 IP 和端口,传进去绑。

2.2 setsockopt

int opt = 1; setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));

这行代码是防坑专用

场景:你跑着服务器,发现有个 bug,ctrl+c 停了,马上改完代码重新跑。结果报错:bind error: Address already in use

原因:操作系统在端口释放后有一个TIME_WAIT状态(几十秒到几分钟不等)。这段时间内端口被认为"还在使用中",不让绑定。

解法:SO_REUSEADDR|SO_REUSEPORT告诉内核:"哥们我知道端口还没完全释放,但我赶时间,让我先用。"

建议:开发阶段必须加。不加的话每次重启都要等几十秒

2.3 listen —— 把电话线插上

listen(listensock_, backlog); // backlog = 10

listen 做了什么?它把 socket 创建的 listensock_ 变成了监听状态,告诉操作系统:"这个 socket 可以接收客户端的连接请求了。"

backlog 是内核中连接等待队列的长度。想像一下:前台接待只能同时记住 10 个在门口排队的客人。第 11 个人来了,就得等前面有人被叫进去了才能登记。

backlog 一般设 10 左右,不用设太大。

2.4 两个文件描述符 —— 整篇文章最重要的概念

先看一段初始化代码:

class TcpServer { int listensock_; // 由 socket() 创建 uint16_t port_; std::string ip_; };

这里有个命名上的细节。在 UDP 服务器里我们管 socket 的返回值叫sockfd。但在 TCP 服务器里,作者把它命名为listensock_

为什么?

因为 TCP 服务器有两个文件描述符,各司其职。

变量谁创建的作用数量
listensock_socket()只负责监听连接请求1 个
sockfdaccept() 的返回值负责与客户端读写通信多个(每个连接一个)

用饭店类比:

  • listensock_ = 前台接待。看见客人来了,喊一声"服务员,3 号桌有客人"。
  • sockfd = 专属服务员。走过来:"您好,想吃点什么?"然后一对一服务。

listensock_ 不负责跟任何客人聊天,它就坐在前台监工。只有 accept 返回的 sockfd 才负责实际的读写通信。

常见错误:新手拿到 listensock_ 直接去 read/write,发现读不到数据。那是肯定的——listensock_ 是前台接待,不是服务员。

2.5 accept —— 前台叫号

struct sockaddr_in client; socklen_t len = sizeof(client); int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);

accept 干了三件事:

  1. 从内核的连接等待队列中取出一个已经完成三次握手的连接
  2. 创建一个新的文件描述符sockfd,专门用于和这个客户端通信
  3. 把客户端的 IP、端口等信息填到 client 结构体里

如果连接成功,accept 返回一个大于 0 的 sockfd。
如果连接失败(极少见),返回 -1。

拿到 sockfd 后,可以用inet_ntop把 IP 地址转成字符串,用ntohs把端口号转成主机字节序:

uint16_t clientport = ntohs(client.sin_port); char clientip[32]; inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); lg(Info, "get a new link, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport);

2.6 单进程版的完整流程

void StartServer() { lg(Info, "tcpserver is running..."); for (;;) { // 1. 等一个客户来 int sockfd = accept(listensock_, ...); if (sockfd < 0) { continue; // 连接失败就继续等 } // 2. 解析客户端信息(IP + 端口) // 3. 一对一服务 Service(sockfd, clientip, clientport); // 4. 服务完关闭 close(sockfd); } }

这就是单进程/单线程的全部秘密。

来了一个客户 → accept 领到桌 → Service 开始服务 → 服务完 close → 回循环顶部继续等下一个。

在服务 A 的整个过程中,B、C、D 来了,就在门口排队等着。

2.7 ⭐ read 返回值 —— 最要命的坑

void Service(int sockfd, const std::string& clientip, uint16_t clientport) { while (true) { char inbuffer[4096]; ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1); if (n > 0) { // 正常收到数据 inbuffer[n] = 0; // 手动补 '\0' // 处理并回复 write(sockfd, echo_string.c_str(), echo_string.size()); } else if (n == 0) { // 🔥 客户端退出了! lg(Info, "%s:%d quit", clientip.c_str(), clientport); break; } else { // 读取出错 lg(Warning, "read error"); break; } } }

为什么 n == 0 必须处理?

我用管道类比来解释。

你和朋友之间用一根水管通话。你在水管这头(服务器),他在那头(客户端)。

  • 正常情况:朋友往水管里倒水(write),你用杯子接(read)
  • 朋友走了:朋友把水管那头关了(关闭写端)
  • 你还举着杯子等:水管那头堵死了,你等一整天也等不到水

这时候操作系统会怎么做?

它发现你在做一个永远读不到数据的 read,认为你在浪费 CPU。它会直接把你的服务器进程 kill 掉

你试想一下:一个客户端正常退出,结果整个服务器被操作系统杀了。所有其他正在通信的客户端全部掉线。如果你的服务器是微信服务器,那就是几亿人同时掉线。

所以要主动处理 n == 0,break 退出,close(sockfd),告诉操作系统"我知道了"。操作系统看你主动关了,就不杀你了。


三、客户端实现 —— 拨号打电话

客户端的逻辑比服务器简单得多:

int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server; server.sin_family = AF_INET; server.sin_port = htons(serverport); inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr)); int n = connect(sockfd, (struct sockaddr*)&server, len);

connect —— 拨号。就像你拿起电话(sockfd),输入对方的号码(IP+端口),按拨号键。

客户端要不要 bind?

答案是:要,但你不用手动写 bind。操作系统在 connect 的时候自动给你随机分配了一个端口号。

你打开微信的时候,不需要关心"微信用哪个端口连的服务器",对吧?那是操作系统的事。同理,我们自己写的客户端也不用手动 bind。

四、源码 · 单进程 TCP 服务器完整版

makefile

all:tcpserver tcpclient tcpserver:Main.cc g++ -o $@ $^ -std=c++11 tcpclient:TcpClient.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f tcpserver tcpclient

Log.hpp

#pragma once #include <iostream> #include <string> #include <ctime> #include <cstdio> #include <cstdarg> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #define SIZE 1024 #define Info 0 #define Debug 1 #define Warning 2 #define Error 3 #define Fatal 4 #define Screen 1 #define Onefile 2 #define Classfile 3 #define LogFile "log.txt" class Log { public: Log() { printMethod = Screen; path = "./log/"; } void Enable(int method) { printMethod = method; } ~Log() {} std::string levelToString(int level) { switch(level) { case Info: return "Info"; case Debug: return "Debug"; case Warning: return "Warning"; case Error: return "Error"; case Fatal: return "Fatal"; default: return ""; } } void operator()(int level, const char* format, ...) { time_t t = time(nullptr); struct tm* ctime = localtime(&t); char leftbuffer[SIZE]; snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(), ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday, ctime->tm_hour, ctime->tm_min, ctime->tm_sec); va_list s; va_start(s, format); char rightbuffer[SIZE]; vsnprintf(rightbuffer, sizeof(rightbuffer), format, s); va_end(s); char logtxt[2 * SIZE]; snprintf(logtxt, sizeof(logtxt), "%s %s", leftbuffer, rightbuffer); printLog(level, logtxt); } void printLog(int level, const std::string& logtxt) { switch(printMethod) { case Screen: std::cout << logtxt << std::endl; break; case Onefile: printOneFile(LogFile, logtxt); break; case Classfile: printClassFile(level, logtxt); break; default: break; } } void printOneFile(const std::string& logname, const std::string& logtxt) { std::string _logname = path + logname; int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666); if(fd < 0) return; write(fd, logtxt.c_str(), logtxt.size()); close(fd); } void printClassFile(int level, const std::string& logtxt) { std::string filename = LogFile; filename += "."; filename += levelToString(level); printOneFile(filename, logtxt); } private: int printMethod; std::string path; }; Log lg;

TcpServer.hpp

#include <iostream> #include <string> #include <cstring> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include "Log.hpp" const int defaultfd = -1; const uint16_t defaultport = 8080; const std::string defaultip = "0.0.0.0"; const int backlog = 10; extern Log lg; enum { UsageError = 1, SocketError, BindError, ListenError }; class TcpServer { public: TcpServer(const uint16_t &port = defaultport, const std::string &ip = defaultip) : listensock_(defaultfd), port_(port), ip_(ip) {} void InitServer() { listensock_ = socket(AF_INET, SOCK_STREAM, 0); if (listensock_ < 0) { lg(Fatal, "create socket error"); exit(SocketError); } lg(Info, "create socket success, listensock_: %d", listensock_); int opt = 1; setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt)); struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(port_); inet_aton(ip_.c_str(), &(server.sin_addr)); socklen_t len = sizeof(server); if (bind(listensock_, (struct sockaddr *)&server, len) < 0) { lg(Fatal, "bind error"); exit(BindError); } lg(Info, "bind socket success, listensock_: %d", listensock_); if (listen(listensock_, backlog) < 0) { lg(Fatal, "listen error"); exit(ListenError); } lg(Info, "listen socket success, listensock_: %d", listensock_); } void StartServer() { lg(Info, "tcpserver is running..."); for (;;) { struct sockaddr_in client; socklen_t len = sizeof(client); int sockfd = accept(listensock_, (struct sockaddr*)&client, &len); if (sockfd < 0) { lg(Warning, "accept error"); continue; } uint16_t clientport = ntohs(client.sin_port); char clientip[32]; inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); lg(Info, "get a new link, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport); Service(sockfd, clientip, clientport); close(sockfd); } } void Service(int sockfd, const std::string& clientip, uint16_t clientport) { while (true) { char inbuffer[4096]; ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1); if (n > 0) { inbuffer[n] = 0; std::cout << "client say# " << inbuffer << std::endl; std::string echo_string = "tcpserver echo# "; echo_string += inbuffer; write(sockfd, echo_string.c_str(), echo_string.size()); } else if (n == 0) { lg(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd); break; } else { lg(Warning, "read error, sockfd: %d", sockfd); break; } } } ~TcpServer() { if (listensock_ > 0) close(listensock_); } private: int listensock_; uint16_t port_; std::string ip_; };

Main.cc

#include <iostream> #include <memory> #include "TcpServer.hpp" void Usage(const std::string str) { std::cout << "\n\tUsage: " << str << " port[1024+]\n" << std::endl; } int main(int argc, char* argv[]) { if (argc != 2) { Usage(argv[0]); exit(UsageError); } uint16_t port = std::stoi(argv[1]); std::unique_ptr<TcpServer> server(new TcpServer(port)); server->InitServer(); server->StartServer(); return 0; }

TcpClient.cc

#include <iostream> #include <string> #include <unistd.h> #include <cstring> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> void Usage(const std::string& str) { std::cout << "\n\tUsage: " << str << " serverip serverport\n" << std::endl; } int main(int argc, char* argv[]) { if (argc != 3) { Usage(argv[0]); return 0; } std::string serverip = argv[1]; uint16_t serverport = std::stoi(argv[2]); int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { std::cerr << "socket create err" << std::endl; return 1; } struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverport); inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr)); socklen_t len = sizeof(server); int n = connect(sockfd, (struct sockaddr*)&server, len); if (n < 0) { std::cerr << "connect err..." << std::endl; return 2; } std::string message; char inbuffer[4096]; while (true) { std::cout << "Please Enter# "; std::getline(std::cin, message); n = write(sockfd, message.c_str(), message.size()); if (n < 0) { std::cerr << "write err" << std::endl; break; } n = read(sockfd, inbuffer, sizeof(inbuffer) - 1); if (n > 0) { inbuffer[n] = 0; std::cout << inbuffer << std::endl; } else { break; } } close(sockfd); return 0; }

五、本篇总结

这篇我们做了三件事:

  1. 理解了 TCP 的三个核心 API:listen(插电话线)、accept(叫号)、read/write(对话)
  2. 分清了两个文件描述符:listensock_(前台接待)只负责监听,sockfd(专属服务员)负责通信
  3. 搞懂了一个最要命的坑:read 返回 0 必须处理,否则操作系统会杀进程

但也暴露了一个大问题:单进程版一次只能服务一个客户端,后面的排队等到死。

下一篇,我将尝试多进程、多线程、线程池等并发方案,让服务器真正能同时服务多个客户端。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/14 22:20:32

改进RRT机械臂运动规划【附代码】

✨ 长期致力于运动规划、路径规划、RRT算法、ROS仿真研究工作&#xff0c;擅长数据搜集与处理、建模仿真、程序编写、仿真设计。 ✅ 专业定制毕设、代码 ✅ 如需沟通交流&#xff0c;点击《获取方式》 &#xff08;1&#xff09;目标偏置与椭圆采样机制的改进RRT算法&#xff1…

作者头像 李华
网站建设 2026/5/14 22:20:19

给STM32供电别乱接!VBAT、VDDA、VSSA这些引脚到底怎么用?一个图讲清楚

STM32电源架构深度解析&#xff1a;VBAT、VDDA、VSSA引脚设计实战指南 第一次拿到STM32芯片原理图时&#xff0c;密密麻麻的电源引脚往往让人望而生畏。VDD、VDDA、VBAT、VSSA...这些看似相似的标签背后&#xff0c;隐藏着芯片稳定运行的秘密。本文将用工程师的视角&#xff0c…

作者头像 李华
网站建设 2026/5/14 22:20:16

循迹小车实战一:从电机驱动到传感器融合的闭环控制

1. 三轮小车的基础运动控制 第一次动手做循迹小车时&#xff0c;最让我头疼的就是怎么让这个小家伙乖乖按照我的想法移动。三轮小车的结构其实特别适合新手入门&#xff0c;后面两个主动轮负责驱动&#xff0c;前面一个万向轮就像购物车的轮子一样自由转向。这种设计不仅简单&a…

作者头像 李华
网站建设 2026/5/14 22:20:15

CPT Markets:监管合规体系的扎实构建

金融服务行业的复杂性决定了平台需要在多个维度上同时具备较高的水准。CPT Markets经过多年的发展&#xff0c;已经在合规、技术、服务、教育等方面形成了一套相互支撑的体系。本文从评测视角出发&#xff0c;对其综合实力进行多维度的解读&#xff0c;呈现一个具有结构感的平台…

作者头像 李华
网站建设 2026/5/14 22:20:14

树莓派电力线载波组网实战:从模块选型到图像传输

1. 电力线载波技术入门&#xff1a;为什么选择它&#xff1f; 第一次听说电力线载波技术时&#xff0c;我也觉得挺神奇的——电线不仅能供电还能传数据&#xff1f;这听起来像是科幻电影里的设定。但当我真正在水下机器人项目中使用后&#xff0c;才发现这简直是长距离通信的&q…

作者头像 李华
网站建设 2026/5/14 22:20:11

瑞德克斯平台:全球金融市场的可靠选择

瑞德克斯平台&#xff1a;全球金融市场的可靠选择在评估金融服务平台时&#xff0c;监管合规、技术能力、客户服务等维度构成了重要的观察方向。瑞德克斯平台作为业内较为活跃的服务机构&#xff0c;其在这些方面的实践具有一定的参考价值。本文将围绕评测视角&#xff0c;对其…

作者头像 李华