Linux网络编程
网络通信基础:核心概念解析
IP地址:网络中的定位器
源IP地址与目的IP地址的角色
IP地址是网络通信中的“家庭住址”,用于标识网络中的设备。它分为源IP地址和目的IP地址,分别标识数据的发送方和接收方。
- 源IP地址:这是发送数据的主机的IP地址。当一个数据包被发送时,源IP地址被放入IP头部,这样接收方就能知道是谁发送了这个数据包。源IP地址在IP层提供寻址和路由功能,确保数据能够在复杂的网络结构中正确地从一个主机传送到另一个主机。
- 目的IP地址:这是数据包预期到达的主机的IP地址。路由器使用目的IP地址来决定数据包应该被转发到哪个方向,直到它到达最终目的地。
举个简单的例子,这就好比你买快递时填写的地址。快递单上的“商家地址”就是源IP地址,而“收货地址”则是目的IP地址。通过这两个地址,快递公司(路由器)才能确保包裹准确无误地送到你手中。
MAC地址:局域网的身份认证
源MAC地址与目的MAC地址的功能
MAC地址是网络设备的物理地址,是全球唯一的,通常被烧录在网卡的ROM中。
- 源MAC地址:这是发送设备的物理地址。在局域网(LAN)内,源MAC地址用于确定哪个设备发送了数据帧。
- 目的MAC地址:这是数据帧预期到达的设备的物理地址。在数据帧从源主机传送到目的主机时,交换机和桥接器会使用目的MAC地址来决定如何在局域网内转发帧。
在数据传输过程中,源MAC地址就像快递的起始地址,它在数据包从原始设备发出时设定,并且在经过路由器时会被更改。路由器就像快递的中转站,它会接收数据包,然后根据目的地址(在网络中是目的IP地址)决定下一个发送目的地。
端口号:进程通信的通道
源端口与目的端口的定义与作用
在网络通信中,端口号是传输层(通常是TCP或UDP协议)用来标识通信进程的关键元素。
- 源端口:
- 定义:源端口是数据包发起者(通常是客户端进程)的端口号,表示在发送主机上,哪个进程或服务正在发送数据。
- 作用:源端口帮助接收方主机确定响应应该发送回哪里。当服务器响应客户端请求时,它会使用数据包中的源端口作为目的端口。
- 目的端口:
- 定义:目的端口是数据包预期接收者(通常是服务器进程)的端口号,表示在接收主机上,哪个进程或服务应该接收这个数据包。
- 作用:目的端口帮助网络设备和接收主机将数据包正确地转发到指定的服务或进程。
端口号的工作机制与规则
- 进程标识:端口号用来标识一台主机上的一个进程。当数据到达主机时,操作系统使用目的端口号来决定哪个进程应该接收这个数据包。
- 唯一性:在一个主机上,一个端口号在同一时间只能被一个进程使用,这确保了数据能够被正确地路由到相应的进程。
- 端口复用:不同主机上的进程可以占用相同的端口号,因为IP地址和端口号的组合才是唯一标识网络中进程的要素。
数据通信中的端口应用
在通信过程中:
- 数据封装:当数据包在传输层被封装时,源IP地址、源端口号、目的IP地址和目的端口号都会被添加到数据包的头部。
- 标识进程:源IP地址和源端口号一起标识了发送进程,而目的IP地址和目的端口号一起标识了接收进程。
端口使用的注意事项
- 端口号范围:端口号是一个16位的整数,范围从0到65535。其中,0到1023号端口是为系统或众所周知的服务分配的,称为“知名端口”。
- 端口冲突:在同一主机上,不同进程不能占用相同的端口号,否则会发生端口冲突。
端口号与进程ID:分工与协作
端口号与PID的区别与联系
端口号(port)和进程ID(PID)都可以用来标识主机上的进程,但它们的作用场景有所不同。
- 端口号:端口号是用来标识需要对外进行网络数据请求的进程的唯一性,属于网络的概念。
- 进程ID(PID):PID是用来标识系统内所有进程的唯一性,属于系统级的概念。
一台机器上可能会有大量的进程,但并不是所有的进程都要进行网络通信。因此,虽然PID可以标识网络进程的唯一性,但在网络通信场景下,使用端口号更为合适。
举个例子,每个人都有自己的身份证号,身份证号已经可以标识我们的唯一性了,但当我们到了学校还是会有学号,到了公司还是会有工号。这是因为身份证号是国家用于行政管理时用的编号,而学号和工号则是学校和公司用于管理学生和员工时用的编号。
在不同的场景下,可能需要不同的编号来标识某种事物的唯一性。底层如何通过端口号找到对应进程呢?实际上,底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系。当底层拿到端口号时,就可以直接执行对应的哈希算法,从而找到该端口号对应的进程。
网络通信的本质:跨网络的进程对话
Socket通信的核心思想
现在我们明白了,两台主机通信的目的不仅仅是把数据发送过去,而是需要访问对方主机上的某个服务。例如,我们使用百度搜索引擎时,不仅仅是想将请求发送给对端服务器,而是想访问对端服务器上部署的百度相关的搜索服务。
从IP地址和MAC地址已经能够将数据发送到对端主机,但实际我们是想将数据发送给对端主机上的某个服务进程。此外,数据的发送者也不是主机,而是主机上的某个进程。例如,当我们用浏览器访问数据时,实际就是浏览器进程向对端服务进程发起的请求。
从这里可以看出,Socket通信本质上就是两个进程之间在进行通信,只不过这里是跨网络的进程间通信。比如逛淘宝和刷抖音的动作,实际就是手机上的淘宝进程和抖音进程在和对端服务器主机上的淘宝服务进程和抖音服务进程之间在进行通信。
因此,进程间通信的方式除了管道、消息队列、信号量、共享内存等方式外,还有套接字(Socket)。前者是不跨网络的,而后者是跨网络的。
传输层协议:TCP与UDP的对比与应用
TCP协议:可靠传输的基石
TCP协议的核心特性
TCP协议,即传输控制协议(Transmission Control Protocol),是一种面向连接的、可靠的、基于字节流的传输层通信协议。它的主要特点如下:
- 面向连接:在两台主机之间进行数据传输之前,必须先建立连接。只有当连接成功建立后,数据才能开始传输。这种机制类似于打电话,双方必须先接通电话,才能开始交流。
- 可靠性:TCP协议通过一系列机制(如确认应答、超时重传、流量控制和拥塞控制)确保数据在传输过程中的可靠性。如果数据包丢失或出现乱序,TCP协议会自动检测并重新发送丢失的数据包,从而保证数据的完整性和顺序性。
TCP协议的优缺点分析
- 优点:
- 数据传输可靠,适合对数据完整性要求较高的场景,如文件传输、网页浏览等。
- 提供流量控制和拥塞控制,能够动态调整数据传输速率,避免网络拥塞。
- 缺点:
- 实现复杂,通信开销较大,因为需要进行连接建立、确认和重传等操作。
- 传输速度相对较慢,因为需要等待确认应答。
UDP协议:高效传输的选择
UDP协议的主要特点
UDP协议,即用户数据报协议(User Datagram Protocol),是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。它的主要特点如下:
- 无需建立连接:UDP协议不需要在数据传输之前建立连接,直接将数据发送给对端主机。这种机制类似于发送短信,无需等待对方回应即可发送。
- 不可靠性:UDP协议不保证数据的可靠传输。如果数据在传输过程中丢失或出现乱序,UDP协议本身不会进行任何处理。
UDP协议的优缺点分析
- 优点:
- 实现简单,传输速度快,适合对实时性要求较高的场景,如视频会议、在线游戏等。
- 无需建立连接,减少了通信开销。
- 缺点:
- 数据传输不可靠,可能会出现丢包或乱序。
- 缺乏流量控制和拥塞控制,可能导致网络拥塞。
UDP协议的应用场景
尽管UDP协议不可靠,但它在某些场景下具有不可替代的作用。例如,在实时性要求较高的应用中,如视频会议或在线游戏,少量的数据丢失是可以接受的,而快速传输比数据完整性更重要。此外,UDP协议的简单性使其在资源受限的环境中(如物联网设备)也具有广泛的应用。
字节序问题:网络通信中的数据一致性
大端模式与小端模式的区别
在计算机网络中,数据的存储和传输方式因机器架构而异。这就涉及到大小端问题,即数据的高低字节在内存中的存储顺序。
- 大端模式:数据的高字节内容保存在内存的低地址处,低字节内容保存在高地址处。
- 小端模式:数据的高字节内容保存在内存的高地址处,低字节内容保存在低地址处。
网络字节序的定义与意义
TCP/IP协议规定,网络数据流采用大端字节序,即低地址存储高字节。无论发送端和接收端是大端机还是小端机,都必须按照网络字节序发送和接收数据。这样可以避免因大小端差异导致的数据错误。
大小端转换函数的使用
为确保网络程序的可移植性,C语言提供了以下四个函数,用于在主机字节序和网络字节序之间进行转换:
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong); // 主机字节序转网络字节序(32位)
uint16_t htons(uint16_t hostshort); // 主机字节序转网络字节序(16位)
uint32_t ntohl(uint32_t netlong); // 网络字节序转主机字节序(32位)
uint16_t ntohs(uint16_t netshort); // 网络字节序转主机字节序(16位)
- htonl 和 htons:将主机字节序转换为网络字节序。
- ntohl 和 ntohs:将网络字节序转换为主机字节序。
这些函数的作用是确保数据在发送前被正确转换为网络字节序,接收后被正确转换回主机字节序。如果主机是小端字节序,这些函数会进行大小端转换;如果主机是大端字节序,则这些函数不会进行任何操作。
字节序转换示例
假设发送端是小端机,接收端是大端机。发送端需要将一个32位整数 0x11223344
发送到网络中:
- 发送端调用
htonl
函数将0x11223344
转换为大端字节序0x44332211
。 - 数据通过网络传输到接收端。
- 接收端调用
ntohl
函数将接收到的0x44332211
转换为大端字节序0x11223344
,从而正确识别数据。
网络字节序为何选择大端
关于网络字节序采用大端的原因,有以下两种常见说法:
- 历史原因:TCP协议在Unix时代诞生,而当时的Unix机器大多是大端机。因此,网络字节序采用了大端模式。尽管后来小端机逐渐成为主流,但协议已经难以更改。
- 读写习惯:大端序更符合现代人的读写习惯,例如在阅读数字时,我们习惯从高位到低位。
Socket编程实战:从接口到实现
Socket API详解
创建套接字:socket()
int socket(int domain, int type, int protocol);
- 功能:创建一个套接字。
- 参数:
domain
:指定通信协议族,如AF_INET
(IPv4)或AF_INET6
(IPv6)。type
:指定套接字类型,如SOCK_STREAM
(TCP)或SOCK_DGRAM
(UDP)。protocol
:指定协议,通常设置为0
,表示使用默认协议。
- 返回值:成功时返回套接字描述符(非负整数),失败时返回
-1
。
绑定地址:bind()
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 功能:将套接字绑定到一个本地地址和端口。
- 参数:
sockfd
:由socket()
创建的套接字描述符。addr
:指向sockaddr
结构的指针,包含地址和端口信息。addrlen
:addr
的长度。
- 返回值:成功时返回
0
,失败时返回-1
。
监听连接:listen()
int listen(int sockfd, int backlog);
- 功能:将套接字设置为监听状态(仅用于TCP服务器)。
- 参数:
sockfd
:由socket()
创建的套接字描述符。backlog
:最大连接队列长度。
- 返回值:成功时返回
0
,失败时返回-1
。
接受连接:accept()
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 功能:接受客户端的连接请求(仅用于TCP服务器)。
- 参数:
sockfd
:由socket()
创建的套接字描述符。addr
:指向sockaddr
结构的指针,用于存储客户端地址。addrlen
:addr
的长度。
- 返回值:成功时返回新的套接字描述符,失败时返回
-1
。
发起连接:connect()
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 功能:主动建立到服务器的连接(仅用于TCP客户端)。
- 参数:
sockfd
:由socket()
创建的套接字描述符。addr
:指向sockaddr
结构的指针,包含服务器地址和端口。addrlen
:addr
的长度。
- 返回值:成功时返回
0
,失败时返回-1
。
Sockaddr结构体:地址表示的核心
sockaddr
通用结构
在Socket编程中,sockaddr
结构是用于表示网络地址的核心数据结构。它有多个变体,用于不同类型的通信:
![[Pasted image 20250304153146.png]]
sockaddr
是一个通用结构,用于统一表示网络地址和本地地址。它的定义如下:
struct sockaddr {
sa_family_t sa_family; // 地址族(如AF_INET或AF_UNIX)
char sa_data[14]; // 地址数据
};
sockaddr_in
:IPv4地址结构
sockaddr_in
是 sockaddr
的变体,用于IPv4网络通信:
struct sockaddr_in {
sa_family_t sin_family; // 地址族(AF_INET)
in_port_t sin_port; // 端口号(网络字节序)
struct in_addr sin_addr; // IPv4地址
};
sockaddr_un
:本地通信结构
sockaddr_un
是 sockaddr
的变体,用于本地进程间通信(UNIX Domain Socket):
struct sockaddr_un {
sa_family_t sun_family; // 地址族(AF_UNIX)
char sun_path[108]; // 本地路径
};
sockaddr
的设计意义
为了统一网络通信和本地通信的接口,sockaddr
结构被设计为通用的地址结构。尽管 sockaddr_in
和 sockaddr_un
的具体实现不同,但它们的头部字段(如 sa_family
)是相同的。这使得Socket API可以通过 sa_family
字段识别通信类型(网络通信或本地通信),从而执行相应的操作。
在实际编程中,我们通常使用 sockaddr_in
结构体进行网络通信,但在调用API时需要将其强制转换为 sockaddr*
类型。例如:
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
socklen_t addr_len = sizeof(server_addr);
bind(sockfd, (struct sockaddr*)&server_addr, addr_len);
本地进程间通信:多样性与历史
IPC方式的多样性
本地进程间通信(IPC)有多种方式,如管道、消息队列、共享内存、信号量等。现在,Socket也支持本地通信(通过UNIX Domain Socket)。这些通信方式看似复杂且不相关,但它们的存在有其历史原因:
- 历史背景:早期,不同的实验室和组织分别研究通信机制,导致出现了多种标准,如System V和POSIX。这些标准各自定义了不同的通信方式。
- 适用场景:不同的通信方式适用于不同的应用场景。例如,管道适用于简单的父子进程通信,而共享内存适用于高性能的进程间数据共享。
为何保留sockaddr
而非void*
在设计Socket API时,C语言还不支持 void*
类型。因此,sockaddr
结构被设计为通用地址结构,以统一网络通信和本地通信的接口。即使后来C语言支持了 void*
,Socket API也没有更改,因为系统接口是所有上层软件的基石,一旦更改,可能会引发大量兼容性问题。因此,sockaddr
结构一直被保留至今。
UDP编程:无连接通信实现
UDP服务器端实现
创建套接字:socket()
int socket(int domain, int type, int protocol);
首先引入一个函数socket:
参数说明:
domain
:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于struct sockaddr结构的前16个位。如果是本地通信就设置为AF_UNIX,如果是网络通信就设置为AF_INET(IPv4)或AF_INET6(IPv6)。type
:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM和SOCK_DGRAM,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM,叫做流式套接字,提供的是流式服务。protocol
:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。
返回值说明:
- 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。
- 使用
socket(AF_INET, SOCK_DGRAM, 0)
创建 UDP 套接字。 - 参数与返回值的说明与 TCP 中的
socket()
类似,只是type
参数为SOCK_DGRAM
。
绑定地址:bind()
- 同 TCP 服务器:
绑定本地 IP 与端口,使服务器能接收发往该端口的数据。
接收数据:recvfrom()
- 原型:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
- 参数:
sockfd
:UDP 套接字。buf
:缓冲区指针,用于存储接收数据。len
:缓冲区的大小。flags
:标志位,通常为 0。src_addr
:指向结构体的指针,用于保存发送方(客户端)的地址信息。addrlen
:指向地址长度变量的指针,调用前需赋初值(通常为sizeof(struct sockaddr_in)
)。
- 返回值:
成功返回实际接收的字节数;若返回 0,表示没有数据;失败返回 -1 并设置errno
。 - 说明:
UDP 是无连接的,通过recvfrom()
可以同时获取数据和发送方地址,便于后续回复。
示例代码:
ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0,
(struct sockaddr*)&client, &len);
if(size > 0) {
buffer[size] = '\0';
// 输出客户端信息和数据
}
发送数据:sendto()
- 原型:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
- 参数:
sockfd
:UDP 套接字。buf
:指向要发送数据的缓冲区。len
:数据长度。flags
:标志位,通常为 0。dest_addr
:指向目标地址信息的结构体指针(通常为sockaddr_in
并转换为sockaddr*
)。addrlen
:目标地址结构体的长度。
- 返回值:
成功返回发送的字节数,失败返回 -1 并设置errno
。 - 说明:
用于将数据发送到指定目标,因为 UDP 无连接,每次发送时都需要提供目标地址信息。
示例代码:
string echo_msg = "server get!->" + string(buffer);
sendto(_sockfd, echo_msg.c_str(), echo_msg.size(), 0,
(struct sockaddr*)&client, len);
关闭套接字:close()
- 同 TCP 服务器中说明,用于关闭 UDP 套接字,释放系统资源。
UDP客户端实现
创建套接字:socket()
- 创建 UDP 客户端套接字,使用
socket(AF_INET, SOCK_DGRAM, 0)
。
发送数据:sendto()
- 客户端同样调用
sendto()
将数据发送到服务器。 - 需要设置服务器地址的
sockaddr_in
结构体,并将其传递给sendto()
。
接收数据:recvfrom()
- 客户端调用
recvfrom()
接收服务器回复的数据。 - 与服务器类似,通过传入一个地址结构体来接收发送者的信息(虽然在客户端这种场景下一般已知服务器地址)。
TCP编程:面向连接的通信实现
TCP服务器端实现
创建套接字:socket()
- 原型:
int socket(int domain, int type, int protocol);
- 参数:
domain
:协议族。常用值为AF_INET
(IPv4)或AF_INET6
(IPv6)。type
:套接字类型。TCP 通信使用SOCK_STREAM
(面向连接),UDP 通信使用SOCK_DGRAM
(无连接)。protocol
:一般设为 0,自动选择对应协议(例如 TCP 或 UDP)。
- 返回值:
成功返回一个新的文件描述符(非负整数),失败返回 -1 并设置errno
。 - 说明:
该函数在内核中创建一个“网络文件”,但此时还没有与具体的网络地址关联。
示例代码:
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
绑定地址:bind()
- 原型:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 参数:
sockfd
:套接字文件描述符,由socket()
返回。addr
:指向sockaddr
类型的指针,包含地址信息。通常需要将sockaddr_in
强制转换为sockaddr*
。addrlen
:地址结构体的大小,通常使用sizeof(struct sockaddr_in)
。
- 返回值:
成功返回 0,失败返回 -1,并设置errno
。 - 说明:
将套接字与本地IP地址和端口绑定,使操作系统知道该“文件”对应于哪个网络接口。
示例代码:
struct sockaddr_in local;
memset(&local, '\0', sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 转换端口到网络字节序
local.sin_addr.s_addr = INADDR_ANY; // 绑定任意IP地址
if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
设置监听:listen()
- 原型:
int listen(int sockfd, int backlog);
- 参数:
sockfd
:监听套接字,即已绑定的套接字。backlog
:连接请求队列的长度。当多个客户端同时连接时,未处理的连接将排入此队列。常设置为 5 或 10。
- 返回值:
成功返回 0,失败返回 -1,并设置errno
。 - 说明:
将套接字设为被动监听状态,等待客户端发起连接请求。
示例代码:
if (listen(_listen_sock, BACKLOG) < 0)
{
std::cerr << "listen error" << std::endl;
exit(4);
}
接受连接:accept()
- 原型:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 参数:
sockfd
:监听套接字。addr
:用于存储客户端地址信息的结构体指针。通常传入sockaddr_in
变量的地址,并进行类型转换。addrlen
:输入时存储结构体的大小,调用后返回实际地址结构的大小。
- 返回值:
成功返回一个新的套接字描述符,该套接字用于和客户端通信;失败返回 -1 并设置errno
。 - 说明:
从已完成连接的队列中取出一个连接,用于后续的数据传输。每次调用都得到一个新的套接字用于处理特定客户端。
示例代码:
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
socklen_t len = sizeof(peer);
int new_sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (new_sock < 0)
{
std::cerr << "accept error, continue next" << std::endl;
continue;
}
多进程处理:fork()
与 waitpid()
- fork()
- 原型:
pid_t fork(void);
-
说明:
创建一个新的进程(子进程),子进程为父进程的拷贝。返回值在子进程中为 0,在父进程中返回子进程的进程ID,失败返回 -1。 -
在代码中用途:
用于创建子进程/孙子进程,使得每个客户端请求可以由独立进程处理,实现并发处理。 -
双重 fork 技巧:
第一次 fork 后,在子进程中再 fork 一次,使得真正处理请求的孙子进程与父进程无关,有助于避免僵尸进程。 -
waitpid()
- 原型:
pid_t waitpid(pid_t pid, int *status, int options);
- 参数:
pid
:指定等待哪个子进程。如果为 -1 表示等待任一子进程。status
:用于存储退出状态。options
:等待选项,常用 0 表示阻塞等待。
- 返回值:
成功返回被等待进程的 PID,失败返回 -1。 - 说明:
父进程调用waitpid
等待子进程退出,防止子进程变为僵尸状态。
示例代码:
pid_t id = fork();
if (id == 0){ // 子进程
close(_listen_sock); // 子进程关闭监听套接字
if (fork() > 0){
exit(0); // 父(中间)进程直接退出
}
// 孙子进程处理请求
Service(new_sock, client_ip, client_port);
exit(0);
}
close(new_sock); // 父进程关闭为处理请求而产生的新套接字
waitpid(id, nullptr, 0); // 等待子进程结束
数据读写:read()
与 write()
- read()
- 原型:
ssize_t read(int fd, void *buf, size_t count);
-
参数:
fd
:文件描述符或套接字描述符。buf
:用于存储读取数据的缓冲区指针。count
:最多读取的字节数。
-
返回值:
成功返回实际读取的字节数;返回 0 表示对端关闭连接;失败返回 -1 并设置errno
。 -
说明:
用于从套接字中读取数据,返回实际读取的字节数,可用于判断是否需要结束处理。 -
write()
- 原型:
ssize_t write(int fd, const void *buf, size_t count);
- 参数:
fd
:文件描述符或套接字描述符。buf
:指向要写入数据的缓冲区。count
:要写入的字节数。
- 返回值:
成功返回写入的字节数;失败返回 -1 并设置errno
。 - 说明:
用于向套接字发送数据。通常与read()
结合,实现“回声服务器”或其他数据传输功能。
示例代码(回声服务):
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0){
buffer[size] = '\0';
std::cout << "msg:" << buffer << std::endl;
write(sock, buffer, size);
}
字节序与地址转换函数
- htons() 与 ntohs()
- 原型:
uint16_t htons(uint16_t hostshort);
uint16_t ntohs(uint16_t netshort);
-
说明:
htons()
将主机字节序(通常为小端)转换为网络字节序(大端);ntohs()
则将网络字节序转换回主机字节序。主要用于端口号转换。 -
inet_addr() 与 inet_ntoa()
- inet_addr()
- 原型:
- inet_addr()
in_addr_t inet_addr(const char *cp);
- **说明**:
将点分十进制的 IP 地址字符串转换为网络字节序的 `in_addr_t` 数值。
- inet_ntoa()
- 原型:
char *inet_ntoa(struct in_addr in);
- **说明**:
将 `in_addr` 结构中的 IP 地址转换为点分十进制的字符串形式,常用于打印客户端地址。
示例代码:
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
关闭连接:close()
- 原型:
int close(int fd);
- 参数:
fd
:需要关闭的文件描述符(或套接字描述符)。
- 返回值:
成功返回 0,失败返回 -1 并设置errno
。 - 说明:
关闭打开的文件或网络连接,释放系统资源。无论是服务器还是客户端,在结束通信后都需要调用该函数。
TCP客户端实现
创建套接字与初始化:socket()
- 创建套接字同服务器端,使用:
_sock = socket(AF_INET, SOCK_STREAM, 0);
说明同上。
连接服务器:connect()
- 原型:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 参数:
sockfd
:客户端创建的套接字。addr
:服务器地址信息结构体指针(通常使用sockaddr_in
并转换为sockaddr*
)。addrlen
:地址结构体的大小。
- 返回值:
成功返回 0,失败返回 -1 并设置errno
。 - 说明:
用于向服务器发起连接请求。连接成功后,客户端可以通过该套接字与服务器通信。
示例代码:
if (connect(_sock, (struct sockaddr*)&peer, sizeof(peer)) == 0){
std::cout << "connect success..." << std::endl;
Request();
} else {
std::cerr << "connect failed..." << std::endl;
exit(3);
}
数据交互:read()
与 write()
- 与服务器端说明一致:
客户端通过write()
发送用户输入的数据,再用read()
接收服务器返回的数据,实现“回声”效果。
编程注意事项与总结
错误处理
- 每个系统调用(如
socket()
,bind()
,listen()
,connect()
,read()
,write()
,sendto()
,recvfrom()
)都需要对返回值进行检查:- 返回 -1 时需要通过
errno
判断错误原因,并采取相应的处理措施(例如退出、重试等)。 - 在实际开发中,详细的错误处理有助于调试和提高程序的健壮性。
- 返回 -1 时需要通过
字节序管理
- 网络传输中需要将主机字节序转换为网络字节序(大端):
- 使用
htons()
转换端口号。 - 使用
inet_addr()
和inet_ntoa()
进行 IP 地址的转换和显示。
- 使用
多进程与资源管理
- 多进程模型:
- TCP 服务器通过
fork()
创建新进程,实现同时处理多个客户端请求。 - 采用双重 fork 可以防止产生僵尸进程,父进程通过
waitpid()
等待回收子进程资源。
- TCP 服务器通过
- 资源释放:
- 通信完成后,必须调用
close()
释放套接字资源,避免文件描述符泄露。
- 通信完成后,必须调用
数据传输与缓冲区管理
- 在调用
read()
、write()
、recvfrom()
、sendto()
时,要确保缓冲区大小正确,并注意添加字符串结束符(如'\0'
),避免缓冲区溢出或数据截断。
代码封装与扩展
- 将 TCP/UDP 客户端、服务器的功能封装在类中(如
TcpServer
,TcpClient
,UdpServer
,UdpClient
)有助于代码的结构化管理和复用。 - 在实际项目中,可以进一步增加日志记录、异常处理、以及连接管理等高级功能。