一、Socket 通信过程
在 Linux 系统中,socket 是一种特殊的文件描述符,用于在网络中的不同主机间或者同一台主机中的不同进程间进行双向通信。它是通信链路的端点,可以看作是网络通信的接口。Socket 通信过程主要分为以下几个步骤:
1. 创建 Socket(socket()):
首先需要调用 socket()
函数来创建一个 socket。这个函数会返回一个 socket 文件描述符,该描述符将用于其他所有的网络通信函数。创建 socket 时需要指定通信的类型(通常是 TCP 或 UDP)、通信协议(IP)和其他相关参数。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
2. 绑定地址到 Socket(bind()):
接下来,如果是服务器端,需要将一个地址(IP 地址和端口号)绑定到 socket 上。这样客户端就知道应该连接到哪个地址。
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
3. 监听连接(listen()):
对于服务器端应用程序来说,下一步是监听网络上的连接请求。这可以通过 listen()
函数实现。
listen(sockfd, backlog);
4. 接受连接(accept()):
当客户端请求连接时,服务器需要接受连接。采用 accept()
函数可以接受来自客户端的连接请求,并返回一个新的 socket 文件描述符,用于后续的通信。
int new_socket = accept(sockfd, (struct sockaddr *)&addr, (socklen_t*)&addrlen);
5. 发起连接(connect()):
对于客户端应用程序,使用 connect()
函数发起对远程服务器的连接请求。
connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
6. 数据传输(send() / recv() 或 write() / read()):
一旦连接建立,数据就可以在 socket 之间传输。可以使用 send()
和 recv()
函数,或者 write()
和 read()
函数来进行数据的发送和接收。
send(sockfd, data, datalen, 0);
recv(sockfd, buffer, buflen, 0);
7. 断开连接(close()):
最后,当通信结束时,应当使用 close()
函数来关闭 socket,释放资源。
close(sockfd);
这是一个高层次的概述,实际的实现可能会根据 TCP、UDP、IPv4、IPv6 等不同设置有所不同。此外,还有各种 flag 和选项可以设置,比如非阻塞模式、超时设置等,都会影响 socket 的行为。在多线程或多进程的服务器中,还会用到一些额外的技术来高效地处理多个连接。
二、详解创建 Socket(socket())
创建 socket 是网络编程中的第一步,它是网络通信过程的起点。在 Linux 中,我们使用 socket()
系统调用来创建一个 socket 描述符。创建 socket 实际上是在请求操作系统开启网络 I/O 端口。
socket()
函数的原型
定义在头文件 <sys/types.h>
和 <sys/socket.h>
中,函数定义如下:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
函数参数详解:
- domain: 指定 socket 所使用的协议族 (例如 AF_INET 用于 IPv4, AF_INET6 用于 IPv6, AF_UNIX 用于本地通信)。
- type: 指定 socket 类型,常见的有 SOCK_STREAM(流式socket,通常用于 TCP)和 SOCK_DGRAM(数据报式socket,通常用于 UDP)。
- protocol: 指定在 domain 和 type 的基础上的具体协议。通常设为 0 表示选择默认协议 (例如,在 AF_INET 下使用 SOCK_STREAM 则默认使用 TCP 协议)。
调用 socket()
函数后,它会返回一个整数值 —— socket 文件描述符。如果成功创建,返回的描述符将作为后续所有基于该 socket 的操作的句柄。如果创建失败,返回 -1,并能通过 errno
获取具体的失败原因。
创建 TCP socket 的简单例子(IPv4):
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h> // for close
int main() {
int sockfd;
// 创建 socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 检查是否成功创建
if (sockfd == -1) {
perror("Failed to create socket");
exit(EXIT_FAILURE);
}
printf("Socket created successfully with fd: %d\n", sockfd);
// 使用结束后关闭 socket
close(sockfd);
return 0;
}
在这个例子中,我们创建了一个用于 IPv4 网络通信的流式 socket(即 TCP socket)。创建完后,我们检查了返回的文件描述符,看是否创建成功。如果创建不成功,使用 perror
打印出错误信息。创建成功后,可以使用返回的文件描述符来配置 socket,例如通过 bind
、`listen`、`accept`、`connect` 等系统调用进行进一步的网络操作。使用完毕后,用 close()
系统调用关闭文件描述符,清理资源。
三、详解绑定地址到 Socket(bind())
函数 bind()
在 socket 编程中的作用是将一个本地地址绑定到指定的 socket 文件描述符上。对于 TCP 服务器来说,这一步骤是必需的,因为它定义了服务的地址和端口,客户端需要这些信息来能够连接到服务器。对于 UDP 协议,`bind()` 用于指定一个端口用于监听传入的数据报。
下面是 bind()
函数的一般用法和详细解释:
原型
在 <sys/socket.h>
头文件中,`bind()` 函数的原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd
:socket 文件描述符,由之前调用 socket()
函数返回。
- addr
:指向 sockaddr
结构体或其子类型(如 sockaddr_in
用于 IPv4 地址)的指针,持有你想要绑定给 socket 的地址信息。
- addrlen
:`addr` 指针指向的地址的字节长度。
参数
关于参数的详细解释如下:
1. sockfd
:这是调用 socket()
函数时得到的 socket 描述符,它代表一个 socket 实例。
2. addr
:这是一个指向 sockaddr
结构体的指针,它定义了要绑定的地址包括 IP 地址和端口号。在实际编程中,通常使用特定于协议的地址结构体(如 IPv4 的 sockaddr_in
),并且在传递给 bind()
之前转换为 sockaddr
类型的指针。
3. addrlen
:这表示 addr
结构体的大小,确保 bind()
函数可以正确地读取或解释地址结构体中的信息。
地址结构体(IPv4 为例)
对于 IPv4 地址,`sockaddr_in` 结构体的定义如下:
struct sockaddr_in {
short sin_family; // 地址族(如 AF_INET)
unsigned short sin_port; // 端口号(使用网络字节顺序)
struct in_addr sin_addr; // IP 地址
char sin_zero[8]; // 填充 0 以保持结构体大小与 sockaddr 一致
};
在使用时,你通常会填充 sockaddr_in
结构体,然后将其转换为 sockaddr
结构类型的指针传递给 bind()
函数。
使用示例
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
int main() {
// 创建 socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 创建 sockaddr_in 结构体来指定地址和端口
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr)); // 初始化结构体
addr.sin_family = AF_INET; // 指定地址族
addr.sin_port = htons(8080); // 指定端口号
addr.sin_addr.s_addr = INADDR_ANY; // 使用任意可用地址或指定具体 IP
// 绑定 socket 到地址和端口
if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
// 错误处理
close(sockfd);
return -1;
}
// 其他操作...
// 关闭 socket
close(sockfd);
return 0;
}
在上面的代码中,`sockfd` 是一个有效的 socket 描述符,`addr` 结构体填充了所需的 IP 地址和端口信息,然后通过 bind()
函数与 sockfd
绑定。常量 INADDR_ANY
用于绑定到所有可用的接口上,端口号由 htons()
函数转换为网络字节顺序。
如果 bind()
调用成功,则返回 0;如果失败,则返回 -1 并设置全局变量 errno
以指示错误。常见的错误代码包括 EACCES
(表示没有权限绑定指定端口)、`EADDRINUSE`(指定的地址或端口已在使用中)、`EBADF`(非法的文件描述符)等。
四、详解监听连接(listen())
listen()
函数在 Socket 编程中用于将一个未连接的 socket 转换成一个被动的监听 socket,指示内核应当接受指向该 socket 的连接请求。这个函数特别适用于服务器端的 socket,在客户端尝试建立连接之前,服务器需要明确准备好接受连接。
listen()
函数的原型:
int listen(int sockfd, int backlog);
参数说明:
- sockfd
:这是 socket()
函数调用成功后返回的文件描述符。它代表一个打开的 socket,服务器将通过该 socket 接收客户端的连接请求。
- backlog
:这个参数指定了内核中未处理连接请求的最大数量。具体来说,这个参数定义了内核应该为相应 socket 队列的最大长度。当队列满时,会拒绝新的连接请求。不同的系统对这个数值的限制不同,当你设置的值超过系统最大值时,系统会将其调整为最大值。
函数行为:
- 当 listen()
调用成功时,这个 socket 将可以接收连接请求,通常紧接着会使用 accept()
函数来等待和响应实际的连接请求。
- 当 listen()
调用失败时,它会返回 -1
,并设置全局变量 errno
来指明错误原因。
在实际编程中,你通常需要先通过 socket()
创建一个 socket,再通过 bind()
函数将其绑定到本地地址和端口,最后使用 listen()
函数来开始监听连接请求。
下面是这个步骤的示例代码:
#include <sys/types.h>
#include <sys/socket.h>
int main() {
int sockfd;
struct sockaddr_in addr;
// 创建 socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
// 错误处理
}
// 初始化地址结构体
addr.sin_family = AF_INET; // 使用 IPv4
addr.sin_port = htons(8080); // 设置端口号 (e.g., 8080)
addr.sin_addr.s_addr = INADDR_ANY; // 监听所有地址
// 绑定 socket 到指定的地址和端口
if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
// 错误处理
}
// 开始监听连接请求
if (listen(sockfd, 10) == -1) { // 设置 backlog 为 10
// 错误处理
}
// ... 接下来可以接受连接和处理数据
return 0;
}
当服务器调用 listen()
之后,操作系统会处理所有到来的网络连接请求。如果接受请求的队列被填满,则新的连接请求可能会被忽略或者被拒绝,这取决于操作系统如何处理这种情况。通常,服务器会在多线程或者异步的环境下运行,来高效地处理多个客户端的连接。
五、详解接受连接(accept())
accept()
函数在服务器端用于接受客户机的连接请求。在调用 accept()
之前,服务器应该已经使用 socket()
创建好一个套接字(socket),然后使用 bind()
将其绑定到一个本地地址和端口上,最后用 listen()
设置此套接字为监听模式,准备接收客户端发起的连接请求。
函数原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- sockfd
:这是调用 socket()
函数时返回的套接字文件描述符,并且已经通过 bind()
和 listen()
函数绑定地址并且监听。
- addr
:这是一个指向 sockaddr
结构的指针,该结构用于返回连接方的协议地址(即客户端的地址)。在实际应用中,这通常指向一个 sockaddr_in
或 sockaddr_in6
结构(根据是 IPv4 还是 IPv6)。
- addrlen
:这是一个指向 socklen_t
类型变量的指针,在调用 accept()
之前,`*addrlen` 应该被设置为 addr
指向结构的大小。函数返回后,`*addrlen` 被设置为实际的地址长度。
调用 accept()
函数时,如果存在待处理的连接请求,它会创建一个新的已连接套接字,并从队列中移除该请求。`accept()` 返回一个新的文件描述符来指代这个连接。这个新的文件描述符完全独立于原来监听的 sockfd
,应该用于后续的数据发送和接收操作。原来的 sockfd
仍然保持打开着,可以继续用于接受其他连接请求。
如果 addr
和 addrlen
非空,`accept()` 会在 addr
指向的结构中填充连接客户端的地址和端口信息,而 addrlen
会被设置为该结构体的实际长度。如果你对客户端的地址不感兴趣,可以将这两个参数设置为 NULL
。
在默认情况下,`accept()` 是阻塞的,这意味着如果没有客户端连接请求,调用 accept()
的程序将会挂起,直到有一个连接请求到来。如果想要 accept()
在没有连接请求时不阻塞程序,可以将监听套接字设置为非阻塞模式。
还要注意的是,`accept()` 可以在多线程或者多进程的服务器中被调用,以便同时处理多个连接请求。此时,必须确保对于每个连接都会有一个独立的线程或进程负责处理。
当 accept()
成功时,会返回一个非负的文件描述符用于操作这个连接。如果出错,则返回 -1,并设置 errno
以指示错误类型。
六、详解发起连接(connect())
connect()
函数在客户端用于建立与服务器端的连接。连接建立后,客户端和服务器就可以开始通信。该函数定义在 <sys/types.h>
和 <sys/socket.h>
头文件中,通常用于 SOCK_STREAM 类型的 socket(如 TCP 连接)。
函数原型:
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
- sockfd: 调用 socket()
函数时返回的文件描述符,代表了客户端用于尝试连接的本地 socket。
- addr: 指向 struct sockaddr
结构体的指针,该结构体包含了目标服务器的地址和端口信息。对于不同的地址类型(如 IPv4、IPv6),该结构体有不同的具体实现,分别为 struct sockaddr_in
和 struct sockaddr_in6
。
- addrlen: addr
结构体的长度,用字节为单位。
函数返回值:
- 返回 0,表示连接成功。
- 返回 -1,表示连接失败,并设置 errno
以指示错误类型。
实际使用中,需要先准备好服务器的地址信息:
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET; // AF_INET 表示 IPv4
server_addr.sin_port = htons(port); // 设置服务器端口,并转化为网络字节序
server_addr.sin_addr.s_addr = inet_addr("服务器 IP 地址"); // 将 IP 地址转化为网络字节序
之后,可以调用 connect()
函数尝试建立连接:
int status = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (status == -1) {
perror("connect failed");
// 错误处理
// 根据 errno 的不同值处理不同的错误情况
}
当 connect()
函数调用成功端,此时 socket 已经与服务器建立了 TCP 连接,可以通过 read/write 或 send/recv 函数进行数据传输。
在阻塞模式下(默认情况),如果连接无法立即建立,`connect()` 会阻塞当前线程直到连接完成,或者发生错误(如超时)。可以通过将 socket 设置为非阻塞来改变这个行为。
如果是非阻塞 socket 的话,`connect()` 函数调用可能立即返回 -1,并且 errno
设置为 EINPROGRESS
,表示连接正在进行中。此时,你可以使用 select()
、`poll()` 或 epoll()
等函数来检测连接是否成功,或者使用 getsockopt()
来检查 socket 的 SO_ERROR
选项来获取实际的错误代码。
七、详解数据传输(send() / recv() 或 write() / read())
数据传输是 Socket 编程中数据在建立的连接上进行读取和发送的过程。在 TCP 套接字中,这个过程是可靠的,保证数据按序到达;在 UDP 套接字中,则是不可靠的,数据可能会丢失或者顺序被改变。下面详细介绍 TCP 套接字的数据传输函数。
TCP 数据传输
对于 TCP 协议(`SOCK_STREAM`)的套接字,`send()` 和 recv()
函数经常被用来发送和接收数据。
1. send() 函数:
send()
函数用于向 TCP 连接的另一端发送数据。
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
- sockfd
是要发送数据的套接字文件描述符。
- buf
是指向要发送数据的缓冲区的指针。
- len
是要发送数据的字节数。
- flags
通常设置为0;其它可选值可以提供额外的控制,如 MSG_DONTROUTE
。
这个函数返回值是实际发送的字节数,或者在出错时返回-1。
2. recv() 函数:
recv()
函数用于从 TCP 连接的另一端接收数据。
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- sockfd
是要接收数据的套接字文件描述符。
- buf
是指向用于接收数据的缓冲区的指针。
- len
是缓冲区的大小,即最大可接收的字节数。
- flags
同样通常设置为0,提供特定控制如 MSG_PEEK
(预览数据而不移除队列中的数据)等。
recv()
返回实际接收到的字节数,如果连接已经正常关闭,则返回0;出错时返回-1。
UDP 数据传输
虽然 send()
和 recv()
函数同样可以用于 UDP 协议(`SOCK_DGRAM`)的套接字,但是对于 UDP 更常用的是 sendto()
和 recvfrom()
函数,因为 UDP 具有连接无关的特征,没有固定的连接状态。
1. sendto() 函数:用于发送数据到特定的地址。
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
2. recvfrom() 函数:用于从特定地址接收数据。
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
write() 和 read() 函数
write()
和 read()
是更一般的 POSIX 文件操作函数,也可以用于 socket 数据传输,这是因为在Unix和类Unix系统中,一切皆文件。
1. write() 函数:
用于向文件描述符写入数据,它可以用于 TCP socket 和文件的写操作。
ssize_t write(int fd, const void *buf, size_t count);
2. read() 函数:
用于从文件描述符读取数据,同样适用于 TCP socket 和读取普通文件。
ssize_t read(int fd, void *buf, size_t count);
在处理socket传输时,一个重要的实践是适当的错误处理和重试机制。网络传输可能会因为各种原因导致发送和接收不完全,因此编写健壮的代码需要处理这些问题,如使用循环确保所有数据都发送/接收成功,或处理丢包和超时情况。
八、详解断开连接(close())
在 Linux socket 编程中,`close()` 函数用于关闭一个已经打开的 socket 连接。当你完成了数据的发送和接收,不再需要这个 socket 时,应当关闭它以释放系统资源。
在 TCP 连接中,`close()` 函数的执行将启动 TCP 连接的终止过程,通常称为四次挥手(four-way handshake)。详细来说,`close()` 会使得执行了关闭操作的一方(通常是客户端或服务器端的应用程序)发送一个 FIN(结束)信号,以通知另一方它已经完成了发送数据。
四次挥手的过程如下:
1. 第一次挥手:关闭操作的一方发送一个 FIN 包给对方,表明它已经没有数据要发送了。
2. 第二次挥手:接收到 FIN 包的一方会发送一个 ACK (确认) 包作为响应。
3. 第三次挥手:接收 FIN 的一方在完成它自己的数据发送后,会发送一个自己的 FIN 包。
4. 第四次挥手:最初发送 FIN 的原始关闭方收到这个 FIN 包后,发送一个 ACK 包作为最终确认,然后关闭这个连接。
一旦这个过程完成,连接就被完全关闭了。然而,对于原始调用 close()
函数的一方来说,这个函数通常会立即返回,不会等待整个四次挥手过程结束。
另外,需要注意以下几点:
- 半关闭(Half-close):TCP 提供的是全双工的服务,意味着数据可以在两个方向上同时传输。调用 close()
表示应用层不再发送数据,但是依然可以接收数据直到收到对方的 FIN 包。
- TIME_WAIT 状态:在 TCP 连接完全关闭后,关闭端口的一方(通常是发起连接的客户端)会进入 TIME_WAIT 状态。在这个状态下,端口需要等待一段时间(通常是 2MSL,其中 MSL 是最大报文生存时间)以确保对方收到了最后一个 ACK 包。这个机制确保了在相同的端口上快速重用连接是安全的,并且处理了可能在网络中延迟的旧数据包。
- 引用计数:在某些系统中,当多个进程或线程共享同一个 socket 文件描述符时,这个描述符会有一个引用计数。在所有拥有这个文件描述符的进程或线程中,`close()` 需要被调用相应次数才能真正关闭连接。
- shutdown()
函数:此外,还有一个 shutdown()
函数可以用来关闭连接。与 close()
不同的是,`shutdown()` 允许你执行部分关闭,即关闭读和/或写的一部分。这对于实现半关闭非常有用。
关闭 socket 连接时还需要处理可能产生的异常和错误,比如网络中断或对端突然关闭连接等情况。正确地管理 socket 的关闭操作对于保持应用程序和系统稳定性是非常重要的。