一、网络通信的本质:跨主机的进程间通信
我们常说的 “网络通信”,本质上是跨主机的进程间通信(IPC)。
本地进程通信可以用管道、消息队列、共享内存,但这些方式都依赖本机内核,无法跨越网络。而 Socket(套接字)是 POSIX 标准定义的通用 IPC 方案:它既支持本地进程通信(Unix 域套接字),也支持跨网络的进程通信,这也是 Socket 逐渐替代 System V IPC 的核心原因。
通信的最终目的是把数据送达对方主机上的目标进程,而非仅仅送达主机。因此网络寻址需要两层标识:
1. 为什么不用 PID 标识通信进程?
PID 是操作系统给进程分配的本地编号,无法直接用于网络寻址,核心原因有三点:
- 并非所有进程都需要网络通信,PID 是全进程通用标识,用它做网络寻址会造成资源浪费;
- 解耦合设计:PID 是操作系统领域的概念,端口号是网络协议栈的概念,二者独立演化,互不影响。如果操作系统 PID 机制变更,网络协议无需跟着修改;
- 粒度不匹配:一个进程可以同时建立多个网络连接(比如浏览器同时打开多个网页),需要多个独立的通信端点,单个 PID 无法区分这些连接。
2. 套接字地址与套接字的区别
- 套接字地址(Socket Address):IP地址 + 端口号,在全网范围内唯一标识一个网络通信端点。
- 套接字(Socket):操作系统内核提供的抽象对象,对应一个文件描述符。它包含了发送缓冲区、接收缓冲区、协议状态、地址信息等完整资源,是进程与协议栈交互的 “句柄”。
简单理解:套接字地址是 “门牌号”,套接字是 “房子本身”,进程通过操作套接字这个 “房子” 来收发数据。
3. 协议分层与 Socket 编程的边界
网络协议栈按照分层思想设计,自上而下依次是应用层、传输层、网络层、数据链路层、物理层。Socket 编程的核心交互边界在传输层:
- 我们编写的应用层代码,通过 Socket 接口调用传输层(TCP/UDP)提供的能力;
- 传输层、网络层、数据链路层的封装与解封装,都由操作系统内核自动完成,对应用层透明。
和软件分层设计思想一致:上层只需要调用下层提供的标准接口,无需关心下层的内部实现。
二、传输层核心协议:TCP 与 UDP
传输层负责端到端的进程间通信,最核心的两个协议是 TCP 和 UDP,二者设计目标完全不同,对应不同的业务场景。
1. 核心特性对比
表格
| 连接属性 | 有连接 | 无连接 |
| 传输模型 | 面向字节流 | 面向数据报 |
| 可靠性 | 可靠传输(差错校验、重传、有序) | 不可靠传输(不保证送达、不保证顺序) |
| 首部开销 | 20~60 字节 | 固定 8 字节 |
| 流量 / 拥塞控制 | 支持 | 不支持 |
| 通信形式 | 一对一全双工 | 一对一、一对多(广播 / 多播) |
2. 核心概念深度辨析
(1)有连接 vs 无连接
- 有连接:通信前必须先通过三次握手建立连接,双方在内核中保存对端的地址、协议状态等信息。类似打电话:必须先拨号、对方接通,才能对话,全程知道对方是谁。
- 无连接:通信前不需要建立关系,每次发送数据都直接指定目标地址,内核不保存对端状态。类似寄快递:每次包裹都写清收件地址,快递员只负责这一次投递,不需要提前和收件人建立约定。
(2)面向字节流 vs 面向数据报
这是编程层面最核心的差异,本质是消息边界问题:
- 面向字节流:数据是一串没有边界的字节流,内核不区分消息边界。发送端多次发送的数据,接收端可能一次性全部读出;发送端一次发送的数据,接收端也可能分多次读出。这就是 TCP 编程中必须处理的 “粘包 / 拆包” 问题。
- 面向数据报:每个数据报是一个独立的消息,有明确的边界。发送端调用一次sendto发送一个数据报,接收端调用一次recvfrom就会完整读出这个数据报,不会合并也不会拆分。
(3)可靠 vs 不可靠
这是协议特性,没有优劣之分,只看场景是否匹配:
- 可靠传输:TCP 通过确认应答、超时重传、序号排序、差错校验等机制,保证数据有序、无差错、不丢失地送达对方。代价是协议复杂、开销更高、延迟更大。
- 不可靠传输:UDP 只负责把数据报发出去,不确认对方是否收到,不保证顺序,也不重传。好处是协议简单、开销极低、延迟小,且不会因为网络拥塞限制发送速率。
3. 适用场景
- TCP 适用场景:对数据准确性要求高的场景,比如文件传输、网页浏览、远程登录、支付接口等。
- UDP 适用场景:对实时性要求高、允许少量丢包的场景,比如直播、语音通话、在线游戏、DNS 查询等。
三、网络字节序:跨主机通信的统一约定
1. 大小端的本质
计算机中多字节数据(比如 32 位 int、16 位 short)在内存中的存储顺序有两种模式:
- 大端模式(Big Endian):数据的高字节存储在内存低地址,低字节存储在内存高地址;
- 小端模式(Little Endian):数据的低字节存储在内存低地址,高字节存储在内存高地址。
二者没有性能优劣之分,只是硬件设计的不同选择。x86 架构 CPU 普遍采用小端模式,部分嵌入式、网络设备采用大端模式。
2. 网络字节序标准
如果不同主机的字节序不同,直接传输数据会导致接收端解析错误。因此 TCP/IP 协议栈明确规定:所有在网络中传输的数据,必须使用大端字节序,也叫网络字节序。
主机本地存储的字节序称为主机字节序,发送数据时需要从主机字节序转为网络字节序,接收数据时再转回主机字节序。
3. 标准转换接口
Socket 库提供了 4 个常用转换函数,命名规则统一:h代表 host(主机),n代表 net(网络),l代表 32 位长整数,s代表 16 位短整数:
#include <arpa/inet.h>
// 主机字节序 → 网络字节序
uint32_t htonl(uint32_t hostlong); // 转换32位数据(用于IP地址)
uint16_t htons(uint16_t hostshort); // 转换16位数据(用于端口号)
// 网络字节序 → 主机字节序
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
对于点分十进制字符串格式的 IP 地址(如192.168.1.1),推荐使用可移植的inet_pton和inet_ntop进行转换,支持 IPv4/IPv6,替代老旧的inet_addr、inet_ntoa:
// 字符串IP → 网络字节序整数
int inet_pton(int af, const char *src, void *dst);
// 网络字节序整数 → 字符串IP
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
四、Socket 编程接口的设计思想
1. 统一接口的设计:sockaddr 的 “多态”

Socket 需要支持多种地址族:IPv4(AF_INET)、IPv6(AF_INET6)、本地 Unix 域(AF_UNIX)等。如果每种地址族都设计一套独立的 API,接口会非常冗余。
设计者采用了类似面向对象的 “多态” 思想:
- 定义一个通用的地址结构体struct sockaddr,所有 API 的地址参数都用这个通用类型;
- 不同地址族有自己的具体结构体(IPv4 对应sockaddr_in,IPv6 对应sockaddr_in6,本地对应sockaddr_un);
- 所有具体结构体的前 16 位都是地址族类型字段,内核拿到地址后,先读取前 2 字节判断地址类型,再做对应的类型转换。
使用时我们填充具体的地址结构体,调用 API 时强转为struct sockaddr*即可。


2. IPv4 地址结构体详解
#include <netinet/in.h>
struct sockaddr_in {
sa_family_t sin_family; // 地址族,固定填AF_INET,占2字节
in_port_t sin_port; // 16位端口号,网络字节序
struct in_addr sin_addr; // 32位IP地址,网络字节序
unsigned char sin_zero[8]; // 填充字段,保持与sockaddr大小一致
};
struct in_addr {
uint32_t s_addr;
};
3. 文件描述符的抽象本质
Socket 创建成功后会返回一个整数文件描述符(fd),它和普通文件的文件描述符共用同一个文件描述符数组。这也是 Linux “一切皆文件” 设计思想的体现:
- 进程可以像操作普通文件一样,用read/write读写 Socket;
- 也可以用select、poll、epoll等 IO 多路复用接口统一管理。
五、UDP Socket 编程实战:Echo 回显程序
UDP 是无连接协议,编程逻辑比 TCP 简单,非常适合入门。我们实现一个完整的 Echo 程序:客户端发送数据,服务端收到后原封不动回发给客户端。
1. 核心 API 详解
(1)创建套接字:socket
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
- domain:地址族,IPv4 填AF_INET,IPv6 填AF_INET6,本地通信填AF_UNIX;
- type:套接字类型,UDP 填SOCK_DGRAM,TCP 填SOCK_STREAM;
- protocol:协议编号,一般填 0,由内核根据前两个参数自动选择对应协议。
- 返回值:成功返回非负文件描述符,失败返回 – 1 并设置errno。
(2)绑定地址:bind
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 作用:将套接字与一个本地 IP + 端口号绑定,之后发往该地址的数据会被投递到这个套接字的接收缓冲区。
- sockfd:socket 返回的文件描述符;
- addr:填充好的本地地址结构体;
- addrlen:地址结构体的长度。
- 返回值:成功返回 0,失败返回 – 1。
注意:服务端必须显式 bind 固定端口,这样客户端才能知道往哪个端口发数据。
(3)接收数据:recvfrom
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
- 作用:从套接字接收一个数据报,同时获取发送方的地址。
- buf:接收数据的缓冲区;
- len:缓冲区大小;
- flags:接收标志,默认填 0 表示阻塞接收;
- src_addr:输出参数,返回数据发送方的地址;
- addrlen:输入输出参数,传入缓冲区大小,返回实际地址长度。
- 返回值:成功返回实际接收的字节数,失败返回 – 1;对端关闭连接时返回 0。
(4)发送数据:sendto
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
- 作用:向指定地址发送一个数据报。
- dest_addr:目标接收方的地址;
- 其余参数与 recvfrom 对应。
- 返回值:成功返回实际发送的字节数,失败返回 – 1。
2. 服务端实现逻辑
服务端的核心是 “被动接收”,流程为:创建套接字 → 绑定固定端口 → 循环接收数据 → 回显数据。
关键技巧:INADDR_ANY
绑定服务端地址时,IP 字段填INADDR_ANY(值为 0),表示监听本机所有网卡的 IP 地址。只要是发往该端口的数据,无论目标是哪个网卡 IP,都会被这个套接字接收。这是服务端的标准写法,避免手动填写本机 IP 的麻烦。
完整服务端代码
// server.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
namespace bksw {
class UdpServer {
private:
int _sockfd;
uint16_t _port;
public:
UdpServer(uint16_t port) : _sockfd(-1), _port(port) {
// 1. 创建UDP套接字
_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0) {
LOG(Loglevel::FATAL) << "创建socket失败: " << strerror(errno);
exit(1);
}
LOG(Loglevel::INFO) << "创建socket成功, fd=" << _sockfd;
// 2. 填充服务端地址
struct sockaddr_in local;
memset(&local, 0, sizeof(local)); // 结构体清零,避免脏数据
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有IP
// 3. 绑定地址
if (::bind(_sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
LOG(Loglevel::FATAL) << "绑定端口失败: " << strerror(errno);
exit(1);
}
LOG(Loglevel::INFO) << "服务端启动成功,监听端口: " << _port;
}
void run() {
char buff[1024];
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
while (true) {
// 接收客户端数据
ssize_t size = ::recvfrom(_sockfd, buff, sizeof(buff)-1, 0,
(struct sockaddr*)&client_addr, &len);
if (size < 0) {
LOG(Loglevel::ERROR) << "接收数据失败: " << strerror(errno);
continue;
}
buff[size] = '\\0';
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, ip_str, sizeof(ip_str));
uint16_t client_port = ntohs(client_addr.sin_port);
LOG(Loglevel::INFO) << "收到来自[" << ip_str << ":" << client_port
<< "]的数据: " << buff;
// Echo回显:把数据原封不动发回给客户端
ssize_t send_size = ::sendto(_sockfd, buff, size, 0,
(struct sockaddr*)&client_addr, len);
if (send_size < 0) {
LOG(Loglevel::ERROR) << "回显发送失败: " << strerror(errno);
}
}
}
~UdpServer() {
if (_sockfd >= 0) {
close(_sockfd);
}
}
};
}
// server.cpp
#include "server.hpp"
int main(int argc, char** argv) {
if (argc != 2) {
std::cerr << "用法: " << argv[0] << " <端口号>" << std::endl;
return 1;
}
uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
bksw::UdpServer server(port);
server.run();
return 0;
}
3. 客户端实现逻辑
客户端的核心是 “主动发送”,流程为:创建套接字 → 填充服务端地址 → 循环发送数据 → 接收回显。
关键知识点:客户端的隐式绑定
客户端通常不需要显式调用bind():第一次调用sendto()时,内核会自动给未绑定的套接字分配一个临时端口(短暂端口),完成隐式绑定。
这样设计的原因:
- 客户端是主动发起方,服务端只需要能回包即可,不需要提前知道客户端端口;
- 由内核统一分配端口,避免手动管理导致的端口冲突;
- 一个 UDP 套接字可以给多个不同的服务端发送数据,无需为每个服务端单独绑定端口。
完整客户端代码
// client.cpp
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main(int argc, char** argv) {
if (argc != 3) {
std::cerr << "用法: " << argv[0] << " <服务端IP> <服务端端口>" << std::endl;
return 1;
}
// 填充服务端地址
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[2])));
if (inet_pton(AF_INET, argv[1], &server_addr.sin_addr) <= 0) {
std::cerr << "无效的IP地址" << std::endl;
return 1;
}
// 创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "创建socket失败: " << strerror(errno) << std::endl;
return 1;
}
std::string input;
char recv_buff[1024];
socklen_t addr_len = sizeof(server_addr);
while (true) {
std::cout << "请输入# ";
std::getline(std::cin, input);
if (input.empty()) continue;
// 发送数据到服务端
ssize_t send_size = sendto(sockfd, input.c_str(), input.size(), 0,
(struct sockaddr*)&server_addr, addr_len);
if (send_size < 0) {
std::cerr << "发送失败: " << strerror(errno) << std::endl;
continue;
}
// 接收服务端回显
ssize_t recv_size = recvfrom(sockfd, recv_buff, sizeof(recv_buff)-1, 0,
nullptr, nullptr); // 不需要对端地址可填nullptr
if (recv_size < 0) {
std::cerr << "接收回显失败: " << strerror(errno) << std::endl;
continue;
}
recv_buff[recv_size] = '\\0';
std::cout << "服务端回显: " << recv_buff << std::endl;
}
close(sockfd);
return 0;
}
六、进阶扩展:从 Echo 到群聊系统
在 Echo 程序的基础上,我们可以很容易扩展出群聊功能:服务端保存所有连接过的客户端地址,每当收到一个客户端的消息,就把消息转发给所有已记录的客户端。
1. 单线程版本实现思路
2. 核心注意点
- UDP 没有 “连接” 的概念,客户端是否在线无法直接感知,需要设计心跳机制定期清理离线客户端;
- 单线程模式下,转发操作会阻塞接收流程,客户端较多时会有延迟;
- 可以扩展多线程版本:一个线程负责接收消息,放入消息队列;多个工作线程负责转发消息,提升并发能力。
3. 功能扩展的通用思路
Socket 编程的核心优势在于传输层与业务层解耦:底层的 UDP 收发逻辑不变,只修改上层业务处理逻辑,就能实现不同的功能 ——
- 把回显改成翻译逻辑,就是翻译服务器;
- 把回显改成计算逻辑,就是远程计算器;
- 把单播转发改成广播,就是群聊系统。



