1. 网络通信
在学习网络编程之前,回想之前提出的一个问题:将数据从主机A发送到主机B,是网络通信的目的吗?
当然不是。 用户通信是两台主机进行通信,代表用户进行通信的是启动的程序,而启动的程序是什么?是进程! 因此进程代表用户进行通信,只要把数据交给进程,就相当于用户拿到了数据。
IP解决网络层问题,保证数据包可以通过IP协议到达目标主机。数据包到达主机的网络层后,还是需要继续向上解包,交付给特定的进程。
数据传输到主机不是目的,而是手段。到达主机内部,再交给主机内的进程,才是目的。
但是,系统中同时会存在多个进程,当数据到达目标主机之后,怎么转发给目标进程?这就要在网络通信中,在主机内部标识进程的唯一性。
源IP和目标IP地址可以保证主机之间进行通信,但无法区分主机内的不同进程。
1.1 port
为了标识主机内进程的唯一性,因此引入了端口号。
端口号(port)是传输层协议的内容 ,是一个2字节16位的无符号整数 (范围0-65535)。端口号用来标识一个进程,告诉操作系统当前的这个数据要交给哪一个进程来处理。因此我们写代码时需要和指定的 port 进行关联。
port 的范围:
0 ~ 1023:知名端口号,HTTP,FTP,SSH 等这些广使用的应用层协议,他们的端口号都是固定的
1024 ~ 65535:操作系统动态分配的端口号。客户端程序的端口号就是由操作系统从这个范围分配的
示例: 两台主机使用QQ聊天:
- 主机A启动QQ进程A,绑定端口号 portA
- 主机B启动QQ进程B,绑定端口号 portB
进程A向进程B发送报文时,需要标明:
- srcPort = portA(源端口)
- dstPort = portB(目的端口)
- srcIP = IPA(源IP)
- dstIP = IPB(目的IP)
根据之前所学,该报文一定会转发到主机B。主机B检查报文的dstIP与自己的IP地址相符,确认是发给自己的。但数据传输不是目的,该报文需要交给主机B中的特定进程 ------通过报文中的 dstPort 来确定主机B的哪个进程接收该报文。
结论:跨网络通信本质是两个不同主机的进程之间的通信!
既然是进程间的通信,根据操作系统中的知识来理解网络通信:从冯·诺依曼体系 的视角看:网络通信要么是从网络中接收数据,要么是向网络发送数据,这不就是IO 吗?在Linux中一切皆文件 ,那么网络也是文件 。所以进程读写网络的过程就是读写文件的过程,而文件对应的动作不就是IO吗?
进程不是有 pid 来标识唯一性吗?为什么还需要端口号 port?
理由一:解耦系统与网络
pid 属于系统概念 ,如果传输层使用pid将数据路由给进程,从技术上可以实现,但这会在设计上带来重大缺陷------网络通信和进程管理两个模块产生强耦合。
耦合度的增加会使得系统与网络彼此互相干扰。因此,网络方面为了和系统方面做有效的区分,网络使用端口号来标识进程在网络中的唯一性。
类比: 在学校我们都有学号,用学号标识唯一性。我们不是有身份证号吗?为什么在学校不使用身份证号?工作时有工号,为什么不使用身份证号?如果这样,不就将社会通用标识与特定领域标识产生强耦合了吗?
理由二:区分网络进程与非网络进程
pid每个进程都要有,但不是所有进程都要进行网络通信。
- 社会每个人都有身份证号,但不是所有人都是某个学校的学生。
- 如果使用pid标识,哪些进程需要网络通信、哪些不需要,会混淆。
端口号是对进程是否需要进行网络通信的强标识------有端口号的就是需要进行网络通信的,反之则不需要。
关系总结:
- IP地址 → 标识互联网中唯一的一台主机
- Port → 标识该主机上唯一的一个网络进程
1.2 操作系统如何转发数据给进程
问题1:OS是如何将收到的数据转发给特定进程的?即OS如何找到进程?
多个进程的PCB可以链入到很多数据结构中。在OS中存在一张映射表,以端口号作为键值(key)。进程绑定端口号时,会按照自己绑定的端口号将PCB链入到指定的位置。
OS根据进程的port,通过哈希表来找到进程的PCB,从而找到进程。
问题2:找到进程之后,如何将网络数据交给进程?
OS会不断接收报文,OS中会存在大量的报文:有的报文积压在一起还未处理;有的报文在网络层;有些报文在传输层
OS需要管理这些报文。如何管理?先描述,再组织!
为了管理这些报文,OS内会有核心数据结构:
- 一段缓冲区来保存报文数据
- 一个结构体来描述报文的元信息(源IP、目的IP、源端口、目的端口、协议类型、长度、状态等)
Linux下一切皆文件,网卡也是文件。网卡需要被打开,如何标识网卡被打开了?即如何标识文件被打开?fd文件描述符。
因此网卡一定会有自己的文件描述符fd,必然会有一个struct file结构体,file结构体内保存着属性信息和临时缓冲区。
数据交付流程:
- 从网络中接收到的数据
- 根据目的端口号找到进程
- 找到进程的文件描述符表
- 得到对应的fd(文件描述符)
- 找到对应的文件缓冲区
- 将数据拷贝到缓冲区中
如此一来,就将网络数据交给了进程。进程通过 read 系统调用从fd读取数据,就像读取普通文件一样。
1.3 socket 套接字
总结:
- IP地址用来标识互联网中唯一的一台主机
- Port用来标识该主机上唯一的一个网络进程
既然如此,那么IP + Port 就可以标识互联网中唯一一个进程 。我们将 IP + Port 叫做套接字(Socket)。
通信时,本质是两个互联网进程代表人来进行通信。{srcIP, srcPort, dstIP, dstPort} 这样的四元组就能唯一标识互联网中的两个通信端点。
网络通信的本质就是Socket之间的通信 ,也就是进程间的通信。
1.4 传输层协议与系统调用
根据之前所讲,已经理解了OS和协议栈之间的联系。我们就会清楚,传输层是属于内核的 ,那么我们要通过网络协议栈进行通信,必定调用的是传输层提供的系统调用,来进行网络通信。
传输层有两种核心协议:UDP 和TCP。
先对两种协议有一个大概的认识:
| 特性 | TCP协议 | UDP协议 |
|---|---|---|
| 连接性 | 有连接 | 无连接 |
| 可靠性 | 可靠传输 | 不可靠传输 |
| 数据形式 | 面向字节流 | 面向数据报 |
| 应用场景 | 大部分场景(文件传输、网页浏览、邮件等) | 实时音视频、DNS查询、游戏等 |
有连接 vs 无连接:
- 有连接:通信之前需要建立连接。以打电话为例:拨通对方号码,对方接听,建立连接后才能通话。
- 无连接:无需前置动作,直接发送数据。以发送邮件为例:邮件直接进入对方邮箱,无需实时建立通道。
日常通信本质上就这两种方式:无连接 和有连接。
可靠传输 vs 不可靠传输:
- 可靠传输 :传输时出现丢包会自动重传,确保数据完整到达。
- 不可靠传输 :丢包后不重传,尽力而为,不保证到达。
重要理解: 需要将其理解成协议的特点,而不是协议的优缺点。
- 只要协议是可靠的,就意味着需要做更多的动作(确认、重传、排序、流量控制等),开销更大。
- 只要协议是不可靠的,就意味着它更加轻量化,延迟更低,效率更高。
优点和缺点是相互伴生的------没有绝对的好坏,只有场景适配。
面向字节流 vs 面向数据报:
- 面向字节流 :数据像水流一样连续,没有边界,无法区分数据包的格式和边界。发送方写100字节,接收方可能分两次读到50字节,也可能一次读到100字节。
- 面向数据报 :数据是一个一个独立的单元,有明确边界。发送方发一个数据报,接收方必须作为一个整体接收,要么收到完整的数据报,要么收不到。
1.5 网络字节序
我们已经知道:内存中的多字节数据有大端和小端 之分;磁盘文件中的多字节数据有大端小端之别。网络数据流同样有大端小端之分。
地址有低地址和高地址之分,数据有低权值位和高权值位之别。
- 小端存储:低权值位存在低地址,高权值位存在高地址
- 大端存储):低权值位存在高地址,高权值位存在低地址
记忆口诀:小小小(小端:低权值位 → 低地址)。
为什么会存在大小端? 与局域网协议有很多种是一个原因------历史原因和厂商差异。不同CPU架构(x86、ARM、MIPS等)选择了不同的字节序,没有统一标准。
不同机器之间的大小端不一样,势必会影响网络通信。在网络通信中,并不关心单台机器是大端还是小端。
发送数据时,按照统一规则:先发出的数据是低地址,后发出的数据是高地址。数据是这样发送的,也是这样接收的。
由于主机的不同,如果各自按自己的字节序解读,最终读取数据会有差异,产生问题。
可能的解决方案(已被否定): 发送时标记主机是大端还是小端,接收方匹配后决定是否转换。
- 缺陷:增加网络成本,需要额外字段表示大小端,增加处理复杂度。
TCP/IP协议的规定(实际采用):网络数据流应采用大端字节序,即低地址高字节。
不管这台主机是大端机还是小端机,都会按照TCP/IP规定的网络字节序来发送/接收数据:
- 如果当前发送主机是小端 ,需要先将数据转成大端,再发送
- 如果当前发送主机是大端 ,直接发送,无需转换
结论:所有发送到网络上的数据,都必须是大端的!
1.6 字节序转换函数
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,系统提供了库函数做网络字节序和主机字节序的转换。
cpp
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong); // host to network long (32位)
uint16_t htons(uint16_t hostshort); // host to network short (16位)
uint32_t ntohl(uint32_t netlong); // network to host long (32位)
uint16_t ntohs(uint16_t netshort); // network to host short (16位)
函数名解析:
- h = host(主机字节序)
- n = network(网络字节序)
- l = long(32位长整数,IPv4地址)
- s = short(16位短整数,端口号)
行为:
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换,然后返回
- 如果主机是大端字节序 ,这些函数不做转换,将参数原封不动返回
编程规范:
- 发送数据前:主机字节序 → 网络字节序(htonl/htons)
- 接收数据后:网络字节序 → 主机字节序(ntohl/ntohs)
特别注意:
- 端口号(16位)必须用 htons/ntohs
- IPv4地址(32位)必须用 htonl/ntohl
- 字符串形式的IP地址需要先转成整数(inet_addr等函数内部已处理字节序)
2. Socket 通用结构体
2.1 sockaddr
Socket 常用API
cpp
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 开始监听 socket (TCP, 服务器)
int listen(int sockfd, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
可以发现,常见的API中都包含 struct sockaddr *addr 结构体指针。
Socket 套接字通信 分成多种:网络通信 → INET socket (IPv4/IPv6);本地通信 → Unix 域套接字(Unix Domain Socket),类似于命名管道,但功能更强大。

问题: 存在不同的通信场景,难道要为每种场景设置不同的系统调用吗?
后果: 这样会给用户造成使用上的麻烦,代码无法通用,学习成本剧增。
解决方案: 设计一套统一的软件接口,兼容底层不同的套接字。
统一的两个维度:统一方法 ------ 所有场景使用相同的API(socket/bind/listen/accept/connect等);统一参数 ------ 设计通用的 struct sockaddr 结构体作为"基类"。
如何统一参数?将不同的socket地址参数封装成各自的结构体,在这些结构体之上,再设计一种通用的结构体 struct sockaddr。
struct sockaddr 的核心设计:
cpp
struct sockaddr {
sa_family_t sa_family; // 16位地址族/协议族(AF_INET, AF_UNIX等)
char sa_data[14]; // 14字节填充,实际内容由具体协议决定
};
具体地址结构体:
网络通信 - IPv4:
cpp
struct sockaddr_in {
sa_family_t sin_family; // AF_INET
in_port_t sin_port; // 16位端口号(网络字节序)
struct in_addr sin_addr; // 32位IP地址(网络字节序)
char sin_zero[8]; // 填充,使大小与sockaddr相同
};
本地通信 - Unix域:
cpp
struct sockaddr_un {
sa_family_t sun_family; // AF_UNIX
char sun_path[108]; // 本地文件路径名
};
如果要实现网络通信或本地通信:
- 填充具体的地址结构体(sockaddr_in 或 sockaddr_un)
- 强制类型转换为 struct sockaddr * 传递给API
- API内部根据 sa_family 字段判断类型,调用相应的处理逻辑
示例代码:
cpp
// 网络通信设置
struct sockaddr_in addr;
addr.sin_family = AF_INET; // 设置地址族
addr.sin_port = htons(8080); // 设置端口(转网络字节序)
addr.sin_addr.s_addr = INADDR_ANY; // 绑定所有可用IP
// 强制类型转换,传递给统一API
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
// 本地通信设置
struct sockaddr_un addr_un;
addr_un.sun_family = AF_UNIX;
strcpy(addr_un.sun_path, "/tmp/mysocket");
// 同样的API,同样的转换方式
bind(sockfd, (struct sockaddr *)&addr_un, sizeof(addr_un));
这本质上就是多态的思想:
| C++概念 | Socket实现 |
|---|---|
| 基类 | struct sockaddr |
| 派生类 | struct sockaddr_in(网络)、struct sockaddr_un(本地) |
| 基类指针 | struct sockaddr *addr |
| 虚函数/类型识别 | sa_family 字段 + 运行时判断 |
| 派生类对象 | 填充好的 sockaddr_in 或 sockaddr_un |
函数中的 struct sockaddr *addr 就是"基类指针",指向不同的"派生类对象"。
API内部通过检查 sa_family 字段(AF_INET、AF_UNIX等),就知道实际传入的是哪种地址类型,从而调用相应的处理逻辑。
优势:
- 用户层:一套API,统一的使用方式
- 内核层:根据地址族分发到不同的协议处理模块
- 扩展性:新增通信类型时,只需添加新的地址结构体和地址族常量,API保持不变
2.2 Socket 编程
1. UDP
先讲UDP套接字,因为它简单、轻量化、无连接,只关心发数据,适合理解网络编程的基本模型。
1. socket
"Linux下一切皆文件" ,网络也是文件。进行网络通信时,首先需要打开网络文件。
系统调用:socket
cpp
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
功能: 创建能进行网络通信的端点(套接字)
参数 domain:含义:地址族/协议族(Address Family);常用值:AF_INET(IPv4)、AF_INET6(IPv6)、AF_UNIX(本地)
参数 type:含义:套接字类型(服务类型);常用值:SOCK_STREAM(面向字节流/TCP)、SOCK_DGRAM(面向数据报/UDP)
参数 protocol :具体协议,通常设为0,让系统根据domain和type自动选择
UDP配置: domain = AF_INET,type = SOCK_DGRAM
返回值: 成功返回非负的文件描述符 ,失败返回**-1**并设置errno。
2. IP地址格式转换
网络的IP有两种表现形式:
点分十进制字符串 :如 "192.168.1.1",给用户看,直观易读
4字节二进制 (网络字节序):32位整数,内核使用,高效处理
4字节ip转成点字符串ip ------ 调用 inet_addr, in_addr_t inet_addr(const char *cp);点字符串ip 转成 4字节ip ------ 调用 inet_ntoa,char *inet_ntoa(struct in_addr in)。
4字节ip转成字符串型ip的方式:inet_ntoa(_address.sin_addr)。
线程安全版本:inet_ntop(AF_INET, &(_address.sin_addr), ipstr, sizeof(ipstr))。
3. sockaddr_in结构体
头文件: <netinet/in.h>、<arpa/inet.h>、<sys/socket.h>
源码结构:
cpp
struct sockaddr_in {
__SOCKADDR_COMMON (sin_); // 展开为:sa_family_t sin_family(16位地址族)
in_port_t sin_port; // 16位端口号(网络字节序)
struct in_addr sin_addr; // 32位IP地址(网络字节序)
unsigned char sin_zero[...]; // 填充,使大小与struct sockaddr相同(16字节)
};
struct in_addr {
in_addr_t s_addr; // 32位整数,uint32_t
};
为什么已经有了AF_INET,结构体里还要有sin_family?
创建套接字时 ,AF_INET的作用:告知OS"我要创建网络套接字"
填充地址结构体时 ,AF_INET的作用:在多态体系 下,让API函数辨别"这是网络通信地址"
_SOCKADDR_COMMON(sin) 是一个宏:
cpp
#define __SOCKADDR_COMMON(sa_prefix) sa_family_t sa_prefix##family
// 展开后:sa_family_t sin_family
sa_family_t本质是unsigned short int(16位无符号整数)。
4. bind
绑定地址:bind
将IP地址和端口号设置到内核中,与套接字关联。
cpp
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能: 对 socket 命名 ,即绑定IP + Port
参数 sockfd:socket()返回的文件描述符
参数 addr:指向 sockaddr 结构体的指针(实际传入 sockaddr_in 并强制转换)
参数 addrlen:地址结构体的长度(sizeof(struct sockaddr_in))
返回值: 成功返回0 ,失败返回**-1**并设置errno。
能否绑定公网IP?不可以直接绑定云服务器的公网IP。云服务器的公网IP是禁止显式绑定 的(出于安全和架构考虑),但不代表不能使用------数据包到达服务器后,内核会正确路由。
推荐做法:任意地址绑定(INADDR_ANY)
cpp
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0,监听所有可用网络接口
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
优势: 无论数据从哪个网卡(公网IP、内网IP)进入,只要目标端口匹配,都能接收。
5. recvfrom
获取对方发来的数据,最关心的是:1.信息本身;2.信息的发送方(对方的IP+Port)
接收数据:recvfrom
UDP是无连接的,每次接收数据都需要知道数据来源。
cpp
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数 sockfd:套接字描述符
参数 buf:接收缓冲区
参数 len:缓冲区长度
参数 flags:接收方式。0表示阻塞读取(没有数据就等待)
参数src_addr:输出参数,获取发送方的地址信息(sockaddr_in结构)
参数addrlen:输入输出参数,传入结构体大小,返回实际地址长度
返回值: 实际读取到的字节数;失败返回-1;对端关闭返回0(UDP通常不会)。
6. sendto
发送数据,最关心的是:1.发送什么数据;2.发送给谁(目标IP+port);3.怎么发送给对方(通过已创建的套接字).
发送数据:sendto
UDP发送数据时,每次都需要指定目的地。
cpp
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数 sockfd:套接字描述符
参数 buf:发送缓冲区
参数 len:数据长度
参数 flags: 0表示阻塞发送
参数dst_addr:目标地址(sockaddr_in结构,包含对方IP和端口)
参数addrlen:地址结构体长度
返回值: 成功返回实际发送的字节数(UDP可能小于请求长度);失败返回-1。
7. 本地环回地址和netstat
本地环回地址:127.0.0.1
- 特点: 数据不会发送到物理网络 ,只在本机协议栈内部循环
- 用途: 本地测试、网络代码调试、进程间通信
查看系统与 socket 相关的信息,指令:netstat。常用组合:netstat -anup。
相关选项:
选项 -a:显示所有socket(监听和非监听)
选项 -n:以数字形式显示地址和端口(不解析域名)
选项 -u:只显示UDP相关内容
选项 -p:显示进程信息和PID(需要权限)
选项 -t:只显示TCP相关内容
选项 -l:只显示监听状态的socket

有些进程的名字我们看不到,是因为权限不够,需要提权。

可以看到 sshd 绑定了22号端口,之所以可以使用xshell远程登陆云服务器,是因为启动了绑定22号端口的网络服务。
云服务器的端口默认是关闭的 (防火墙/安全组策略)。开启方法: 在云服务提供商的安全组/防火墙 设置中,添加入站规则,开放特定端口(如8080/UDP)。
8. UDP服务器设计:多客户端管理
服务端会收到多个客户端 的消息,需要将所有客户端管理起来。核心思想:"先描述,再组织"。
转发服务器设计:
- 管理在线客户端: 用数据结构(如unordered_map<sockaddr_in, ClientInfo>)保存所有在线用户的地址信息
- 转发逻辑: 接收任意客户端的消息;将消息广播/转发给所有其他在线客户端
- 并发优化:主线程: 负责recvfrom接收数据(IO);线程池: 处理转发任务(计算);将接收到的数据包装成任务,push到线程池队列;工作线程从队列取任务,执行sendto转发给所有在线用户
如下图所示:

架构优势:
- 解耦接收和发送: 接收不受发送延迟影响
- 并发处理: 多个转发任务并行执行
可扩展: 易于添加业务逻辑(如消息记录、用户认证)
网络通信不仅限于Linux之间 :Linux ↔ Linux ;Linux ↔ Windows 。Windows的UDP Socket API与Linux基本一致(都遵循BSD Socket标准)。
2. TCP
1. 相关接口
监听连接:listen
TCP是面向连接的 ,在通信之前,客户端需要向服务器发送连接请求。因此,TCP服务器需要处于一种监听(listen)状态,随时准备接受新连接。
cpp
#include <sys/socket.h>
int listen(int sockfd, int backlog);
功能: 将套接字设置为被动监听模式,使其能够等待客户端连接请求的到来。
参数 sockfd:socket()创建的套接字描述符
参数 backlog:全连接队列的最大长度
注意:backlog不是并发连接数的上限,而是内核等待accept的连接队列长度。
之前创建套接字成功返回的文件描述符和这里获取套接字连接成功返回的文件描述符之间存在什么联系?
Listen sockfd:监听套接字,专门用于接受新连接,不参与数据传输,服务器运行期间一直存在
IO sockfd:连接套接字,专门用于数据传输(read/write),代表一条具体连接,随连接建立而创建,随连接关闭而销毁
一个IO socket代表一条独立的TCP连接。服务器可能同时有多个IO socket(通过多线程/多进程/IO多路复用管理)。
接受连接:accept
cpp
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
功能: 从全连接队列中获取一个已完成连接的套接字。
参数 sockfd:监听套接字(listen socket)
参数 addr:输出参数,获取客户端的地址信息(IP+Port)
参数 addrlen: 输入输出参数,传入结构体大小,返回实际地址长度
返回值:成功返回新的文件描述符(IO socket),失败返回-1。
客户端连接:connect
cpp
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能: 向目标服务器发起连接请求,并自动绑定本地地址。
返回值: 成功返回0,失败返回-1。
connect做的两件事:
- 隐式绑定本地地址(如果尚未绑定,自动分配临时端口)
- 向目标服务器发起三次握手,建立TCP连接
TCP数据传输:read/write 或 recv/send
TCP socket支持全双工通信,有两套接口:
标准IO接口(推荐):
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
Socket专用接口:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
区别: recv/send的flags参数可以设置特殊选项,但通常设为0,行为与read/write一致。
netstat 指令
采用 netstat 指令查看当前系统中的 socket 链接信息。常用组合:netstat -antp。
选项解释:
选项 -a:显示所有socket(监听和非监听)
选项 -n:以数字形式显示地址和端口(不解析域名)
选项 -p:显示进程信息和PID(需要权限)
选项 -t:只显示TCP相关内容

常见状态:
- LISTEN:监听状态(服务器等待连接)
- SYN_SENT:已发送连接请求(客户端)
- SYN_RECV:收到连接请求(服务器)
- ESTABLISHED:连接已建立,可以数据传输
- TIME_WAIT:连接已关闭,等待确保对方收到ACK

在 netstat 输出中看到两条 ESTABLISHED 记录(客户端视角和服务器视角),这是因为测试时客户端和服务器在同一台机器上。
- 1964900/./server_tc → 服务器视角:与客户端的连接
- 1964911/./client_tc → 客户端视角:与服务器的连接
真实场景下,客户端和服务器在不同机器上,各自只能看到自己的那条连接记录。
2. TCP 缓冲区
tcp/udp属于传输层。对于任何一个网络socket,在进行网络通信时,得到一个文件描述符 sockfd,来进行读写,对于每个文件描述符,tcp 在它的底层都会为其提供发送缓冲区和接收缓冲区。
数据从主机A 发送到 主机B的示意图:

发送流程:
- 用户调用write,将数据从用户空间拷贝到内核的发送缓冲区
- write立即返回(成功时返回拷贝的字节数)
- TCP协议栈自主控制:何时发送、发多少、重传、拥塞控制等
接收流程:
- 对方数据到达,TCP协议栈将数据放入接收缓冲区
- 用户调用read,将数据从内核接收缓冲区拷贝到用户空间
核心结论:read和write本质都是拷贝函数。
接收缓冲区无数据 → read阻塞,发送缓冲区已满 → write阻塞。
以主机A发送、主机B接收为例:
- 生产者: 用户write → 发送缓冲区;网络接收 → 接收缓冲区
- 消费者: TCP协议栈发送 → 网络;用户read → 接收缓冲区
这就是典型的生产-消费模型 ,用户与内核通过缓冲区进行同步。
缓冲区在内核中的位置
进程的 task_struct中,存在指针指向 struct files_struct,其中有一个成员变量 struct file *fd_array 文件描述表,struct file 结构体中存在一个成员变量void *private_data,它指向 struct socket 结构体,该结构体中存在 struct sk_buff_head sk_receive_queue 和 struct sk_buff_head sk_write_queue,它俩就是接收和发送缓冲区。socket 结构体中还存在一个指针 struct file *file,指回它所在的文件。
结构关系如下所示:
task_struct (进程)
└── files_struct
└── fd_array[ ] (文件描述符表)
└── struct file (文件对象)
└── private_data → struct socket (套接字核心)
├── sk_receive_queue (接收缓冲区)
├── sk_write_queue (发送缓冲区)
└── file → 指回 struct file (双向关联)
粘包问题
用户调用 write 将数据拷贝到发送缓冲区后就返回了,但OS不保证一次 write 对应一次网络发送,也不保证一次read对应一个完整应用层报文。
- 可能"一个write"被拆成多个TCP段发送
- 可能"多个write"被合并成一个TCP段发送
- 接收方read时,可能读到不完整报文,或多个报文粘连
这就是TCP的粘包/拆包问题。解决方案:TCP是面向字节流 的,不维护消息边界,保证数据完整性必须由上层(应用层)完成。
3. 序列化与反序列化
定义:
- 序列化(Serialization): 将结构化数据(结构体/对象)转换为字符串/字节流
- 反序列化(Deserialization): 将字符串/字节流还原为结构化数据
必要性**:** 网络传输只能发送字节流,必须将内存中的数据结构线性化。
好处**:** 让应用层代码具有良好的扩展性 和跨语言兼容性。
Jsoncpp是一个用于处理JSON数据的C++库。它提供了将JSON数据序列化为字符串以及从字符串反序列化为C++数据结构的功能。Jsoncpp是开源的,广泛用于各种需要处理JSON数据的C++项目中。
JSON 中最重要的数据类型:Json::Value。
序列化的方法:
1. 使用Json::Value 的 toStyledString 方法
将 Json::Value 对象直接转换为格式化的JSON字符串
示例代码:
cpp
#include <iostream>
#include <jsoncpp/json/json.h>
#include <string>
int main()
{
Json::Value root;
root["name"] = "zhangsan";
root["sex"] = "nan";
root["age"] = 18;
std::string s = root.toStyledString(); // 将以上三个转成一个字符串
std::cout << s << std::endl;
return 0;
}
示例结果:

这是一行字符串,只不过它们之间的分割符\n,替换成换行了。
2. 使用 Json::FastWriter
示例代码:
cpp
#include <iostream>
#include <jsoncpp/json/json.h>
#include <string>
int main()
{
Json::Value root;
root["name"] = "zhangsan";
root["sex"] = "nan";
root["age"] = 18;
Json::FastWriter writer; // 序列化
std::string s = writer.write(root);
std::cout << s << std::endl;
return 0;
}
示例结果:

- 使用 Json::StreamWriter
提供了更多的定制选项,如缩进、换行符等。
示例代码:
cpp
#include <iostream>
#include <jsoncpp/json/json.h>
#include <string>
#include <sstream>
int main()
{
Json::Value root;
root["name"] = "zhangsan";
root["sex"] = "nan";
root["age"] = 18;
Json::StreamWriterBuilder stream;
std::unique_ptr<Json::StreamWriter> swriter(stream.newStreamWriter());
std::stringstream ss;
swriter->write(root, &ss);
std::cout << ss.str() << std::endl;
return 0;
}
示例结果:

使用Json进行复杂结构的构建:
cpp
#include <iostream>
#include <jsoncpp/json/json.h>
#include <string>
#include <sstream>
int main()
{
Json::Value root1;
root1["name"] = "zhangsan";
root1["sex"] = "nan";
root1["age"] = 18;
Json::Value root2;
root2["math"] = 99;
root2["C"] = 87;
root2["Chinese"] = 80;
Json::Value root;
root["who"] = root1;
root["grade"] = root2;
Json::StreamWriterBuilder stream;
std::unique_ptr<Json::StreamWriter> swriter(stream.newStreamWriter());
std::stringstream ss;
swriter->write(root, &ss);
std::cout << ss.str() << std::endl;
return 0;
}
示例结果:

反序列化的方法
使用 Json::Reader:提供详细的错误信息和位置,方便调试
示例代码:
cpp
int main()
{
std::string json_string = "{\"name\":\"张三\", \"age\":30, \"city\":\"北京\"}";
std::cout << json_string << std::endl;
Json::Reader reader;
Json::Value root;
bool parseok = reader.parse(json_string, root); // 将文件流解释成Json::Value对象
// 反序列化成功,使用结构化数据接收
std::string name = root["name"].asString(); // 提取字符串
int age = root["age"].asInt(); // 提取整型
std::string city = root["city"].asString();
std::cout << name << " " << age << " " << city << std::endl;
return 0;
}
示例结果:

2.3 其它
telnet
网络通信的大原则: 一个资源不用了,一定要尽早释放。
- fd是有限资源,每个进程有上限(ulimit -n查看)
- 连接关闭后,及时close IO socket
- 服务器退出时,close listen socket
工具推荐: telnet可用于快速测试TCP服务器
在机器中存在一个工具 ------ telnet 它可以远程以客户端的形式向服务器发送指定的数据。指令为:telnet 服务器的ip port。
连接成功后,按ctrl + ] 进入到云服务器逻辑中,唤起 ctrl+],输入quit 退出。
地址转化函数
inet_ntoa
函数原型:char *inet_ntoa(struct in_addr in)
功能:将4字节网络字节序IP转换为点分十进制字符串
致命缺陷:
- 返回结果存放在静态存储区(全局唯一缓冲区)
- 第二次调用会覆盖第一次的结果
- 非线程安全,多线程环境下会产生数据竞争
- 不可重入,信号处理函数中不可使用
内存管理: inet_ntoa这个函数返回了一个char*,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果。那么是否需要调用者手动释放呢? 不需要手动释放(静态区),但风险更大。
inet_addr
函数原型:in_addr_t inet_addr(const char *cp);
功能: 将点分十进制字符串IP转换为4字节网络字节序整数
最推荐使用的两个地址转化函数:inet_pton 和 inet_ntop
头文件:#include <arpa/inet.h>
inet_ntop(网络转主机,二进制 → 字符串)
inet_ntop 函数原型:const char *inet_ntop(int af, const void *restrict src, char dst[restrict .size], socklen_t size);
参数 af:协议家族:AF_INET(IPv4)或 AF_INET6(IPv6)
参数 src:输入参数,指向struct in_addr或struct in6_addr
参数 dst:输出缓冲区,由调用者提供
参数 size:输出缓存区的大小
返回值: 成功返回dst指针,失败返回NULL。
inet_pton(主机转网络,字符串 → 二进制)
inet_pton 函数原型:int inet_pton(int af, const char *restrict src, void *restrict dst)
参数af:协议家族:AF_INET或AF_INET6
参数 src:输入的字符串风格IP(如"192.168.1.1")
参数 dst:输出缓冲区,指向struct in_addr或struct in6_addr
返回值:
- 1:成功转换
- 0:输入格式无效(不是合法IP字符串)
- -1:af参数不支持,设置errno
从此往后,所有地址转换统一使用:
- inet_pton:字符串IP → 二进制IP(配置读取、用户输入处理)
- inet_ntop:二进制IP → 字符串IP(日志输出、显示给用户)