MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言Socket编程的基础搭建

2021-06-153.0k 阅读

1. 理解 Socket 编程基础概念

1.1 什么是 Socket

Socket(套接字)起源于 20 世纪 70 年代加利福尼亚大学伯克利分校版本的 Unix,即人们所说的 BSD Unix。它是一种进程间通信(IPC)机制,主要用于网络通信。在网络编程中,Socket 提供了一种通用的方式,允许不同主机上的进程之间进行数据交换。

从本质上讲,Socket 可以看作是应用层与传输层协议之间的编程接口。它就像是在不同网络节点之间建立的一条虚拟的“通道”,应用程序通过这个“通道”发送和接收数据。在 Linux 系统中,Socket 是基于文件描述符(file descriptor)的概念实现的,这使得 Socket 编程在很大程度上与文件 I/O 操作类似,方便开发者进行操作。

1.2 网络通信模型

在深入了解 Socket 编程之前,我们需要熟悉网络通信的基本模型,其中最常用的是 OSI(Open Systems Interconnection)七层模型和 TCP/IP 四层模型。

1.2.1 OSI 七层模型

  • 物理层:负责处理物理介质上的信号传输,如电缆、光纤等,定义了电气、机械、功能和过程特性。
  • 数据链路层:将物理层接收到的信号转换为数据帧,进行错误检测和纠正,并负责将数据帧从一个节点传输到另一个直接相连的节点。常见的协议有以太网协议。
  • 网络层:负责将数据帧封装成数据包,并根据 IP 地址进行路由选择,将数据包从源主机传输到目的主机。IP 协议就处于这一层。
  • 传输层:为应用层提供端到端的可靠或不可靠的数据传输服务。主要协议有 TCP(传输控制协议)和 UDP(用户数据报协议)。TCP 提供可靠的面向连接的服务,UDP 则提供不可靠的无连接服务。
  • 会话层:负责建立、管理和终止会话,处理不同主机上应用程序之间的会话同步和协调。
  • 表示层:处理数据的表示、转换和加密等问题,确保不同系统之间能够正确理解和处理数据。例如,将数据从一种格式转换为另一种格式。
  • 应用层:为用户应用程序提供网络服务接口,如 HTTP、FTP、SMTP 等协议都处于这一层。

1.2.2 TCP/IP 四层模型

  • 网络接口层:对应 OSI 模型的物理层和数据链路层,负责处理网络物理连接和数据帧的传输。
  • 网际层:与 OSI 模型的网络层功能类似,主要处理 IP 地址和路由选择,以实现数据包在不同网络之间的传输。
  • 传输层:同样提供端到端的数据传输服务,使用 TCP 和 UDP 协议。
  • 应用层:包含了各种应用层协议,如 HTTP、FTP 等,直接为用户应用程序提供服务。

在 Socket 编程中,我们主要关注传输层和应用层之间的交互,通过 Socket 接口来调用传输层提供的服务,实现网络通信。

1.3 Socket 类型

在 Linux 系统中,Socket 主要有以下几种类型:

  • 流式套接字(SOCK_STREAM):基于 TCP 协议,提供可靠的、面向连接的字节流服务。数据按照顺序无差错地传输,适合对数据准确性和顺序要求较高的应用,如文件传输、远程登录等。
  • 数据报套接字(SOCK_DGRAM):基于 UDP 协议,提供不可靠的、无连接的数据报服务。数据以独立的数据包形式传输,不保证数据的顺序和可靠性,适合对实时性要求较高但对数据准确性要求相对较低的应用,如实时视频流、音频流传输等。
  • 原始套接字(SOCK_RAW):允许直接访问网络层和传输层协议,开发者可以自行构造 IP 数据包或其他协议数据包,用于实现一些特殊的网络功能,如网络协议分析、自定义协议开发等。但使用原始套接字需要有较高的权限。

2. Linux 下 C 语言 Socket 编程环境搭建

2.1 安装开发工具

在进行 Linux C 语言 Socket 编程之前,需要确保系统安装了必要的开发工具,如 GCC(GNU 编译器集合)和 make 工具。在大多数基于 Debian 或 Ubuntu 的系统上,可以使用以下命令安装:

sudo apt-get update
sudo apt-get install build-essential

对于基于 Red Hat 或 CentOS 的系统,可以使用以下命令:

sudo yum groupinstall "Development Tools"

安装完成后,可以通过以下命令检查 GCC 和 make 是否安装成功:

gcc --version
make --version

2.2 引入必要的头文件

在 C 语言 Socket 编程中,需要引入一些系统头文件来使用 Socket 相关的函数和数据结构。以下是一些常用的头文件:

  • <sys/socket.h>:包含了 Socket 编程的基本函数和数据结构定义,如 socket()bind()connect() 等函数,以及 sockaddr 结构体。
  • <netinet/in.h>:定义了与 Internet 地址相关的数据结构和常量,如 sockaddr_in 结构体,用于表示 IPv4 地址。
  • <arpa/inet.h>:提供了将 IP 地址在文本格式和二进制格式之间转换的函数,如 inet_pton()inet_ntop()
  • <unistd.h>:包含了一些 Unix 系统服务函数,如 close() 函数,用于关闭 Socket 文件描述符。

例如,在你的 C 程序开头可以这样引入头文件:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

3. TCP Socket 编程基础搭建

3.1 创建 Socket

在 TCP 编程中,首先要创建一个 Socket。使用 socket() 函数来完成这个任务,其函数原型如下:

int socket(int domain, int type, int protocol);
  • domain:指定 Socket 的协议域,对于 Internet 协议,通常使用 AF_INET(IPv4)或 AF_INET6(IPv6)。
  • type:指定 Socket 的类型,对于 TCP 流式套接字,使用 SOCK_STREAM
  • protocol:通常设置为 0,表示使用默认协议(对于 TCP 就是 TCP 协议本身)。

示例代码如下:

int sockfd;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    perror("Socket creation failed");
    exit(EXIT_FAILURE);
}

在上述代码中,我们创建了一个基于 IPv4 的 TCP 流式套接字。如果 socket() 函数返回 -1,则表示创建失败,通过 perror() 函数输出错误信息并退出程序。

3.2 绑定 Socket 到地址

创建 Socket 后,需要将其绑定到一个特定的地址和端口上,以便其他进程可以通过这个地址和端口与该 Socket 进行通信。使用 bind() 函数来实现绑定,其函数原型如下:

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:是通过 socket() 函数创建的 Socket 文件描述符。
  • addr:是一个指向 sockaddr 结构体的指针,用于指定要绑定的地址。在 IPv4 编程中,通常使用 sockaddr_in 结构体,然后将其强制转换为 sockaddr 类型。
  • addrlen:指定 addr 结构体的长度。

下面是一个绑定的示例代码:

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(8080);
servaddr.sin_addr.s_addr = INADDR_ANY;

if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
    perror("Bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

在上述代码中,我们首先初始化一个 sockaddr_in 结构体 servaddr,设置其协议族为 AF_INET,端口号为 8080(使用 htons() 函数将主机字节序转换为网络字节序),IP 地址为 INADDR_ANY,表示绑定到所有可用的网络接口。然后调用 bind() 函数进行绑定,如果绑定失败,输出错误信息,关闭 Socket 并退出程序。

3.3 监听连接

对于服务器端,在绑定 Socket 后,需要开始监听来自客户端的连接请求。使用 listen() 函数来实现监听,其函数原型如下:

int listen(int sockfd, int backlog);
  • sockfd:是要监听的 Socket 文件描述符。
  • backlog:指定等待连接队列的最大长度,即允许同时存在的未处理连接请求的最大数量。

示例代码如下:

if (listen(sockfd, 5) < 0) {
    perror("Listen failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

在上述代码中,我们设置等待连接队列的最大长度为 5。如果 listen() 函数返回 -1,则表示监听失败,输出错误信息,关闭 Socket 并退出程序。

3.4 接受连接

服务器端在监听后,需要接受客户端的连接请求。使用 accept() 函数来完成这个任务,其函数原型如下:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd:是正在监听的 Socket 文件描述符。
  • addr:是一个指向 sockaddr 结构体的指针,用于存储客户端的地址信息。可以为 NULL,如果需要获取客户端地址则提供有效指针。
  • addrlen:是一个指向 socklen_t 类型变量的指针,用于指定 addr 结构体的长度,调用前设置为 addr 结构体的实际长度,调用后会被更新为实际接收到的地址长度。

示例代码如下:

struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd < 0) {
    perror("Accept failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

在上述代码中,我们通过 accept() 函数接受客户端的连接请求,返回一个新的 Socket 文件描述符 connfd,用于与客户端进行通信。如果 accept() 函数返回 -1,则表示接受连接失败,输出错误信息,关闭监听的 Socket 并退出程序。

3.5 连接服务器(客户端)

在客户端,创建 Socket 后,需要连接到服务器。使用 connect() 函数来实现连接,其函数原型如下:

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:是客户端创建的 Socket 文件描述符。
  • addr:是一个指向 sockaddr 结构体的指针,用于指定服务器的地址和端口。
  • addrlen:指定 addr 结构体的长度。

示例代码如下:

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(8080);
servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");

if (connect(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
    perror("Connect failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

在上述代码中,我们初始化一个 sockaddr_in 结构体 servaddr,设置服务器的协议族为 AF_INET,端口号为 8080,IP 地址为 127.0.0.1(本地回环地址)。然后调用 connect() 函数连接到服务器,如果连接失败,输出错误信息,关闭 Socket 并退出程序。

3.6 数据传输

在服务器端和客户端建立连接后,就可以进行数据传输了。对于 TCP 流式套接字,可以使用 send()recv() 函数进行数据的发送和接收。

3.6.1 send() 函数

send() 函数用于向已连接的 Socket 发送数据,其函数原型如下:

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  • sockfd:是用于发送数据的 Socket 文件描述符。
  • buf:是一个指向要发送数据缓冲区的指针。
  • len:指定要发送数据的长度。
  • flags:通常设置为 0,表示默认行为。

3.6.2 recv() 函数

recv() 函数用于从已连接的 Socket 接收数据,其函数原型如下:

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • sockfd:是用于接收数据的 Socket 文件描述符。
  • buf:是一个指向接收数据缓冲区的指针。
  • len:指定接收缓冲区的长度。
  • flags:通常设置为 0,表示默认行为。

示例代码如下(服务器端发送数据,客户端接收数据):

  • 服务器端
const char *msg = "Hello, client!";
send(connfd, msg, strlen(msg), 0);
  • 客户端
char buffer[1024];
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0) {
    buffer[n] = '\0';
    printf("Received: %s\n", buffer);
}

在上述代码中,服务器端通过 send() 函数向客户端发送一条消息,客户端通过 recv() 函数接收消息,并将其打印出来。

3.7 关闭 Socket

在完成数据传输后,需要关闭 Socket 以释放资源。使用 close() 函数来关闭 Socket,其函数原型如下:

int close(int fd);
  • fd:是要关闭的 Socket 文件描述符。

示例代码如下:

close(sockfd);
close(connfd); // 如果有连接描述符

在上述代码中,我们分别关闭了监听的 Socket 和连接的 Socket(如果存在)。

4. UDP Socket 编程基础搭建

4.1 创建 UDP Socket

与 TCP 类似,首先要创建一个 UDP Socket。同样使用 socket() 函数,但类型设置为 SOCK_DGRAM,示例代码如下:

int sockfd;
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("Socket creation failed");
    exit(EXIT_FAILURE);
}

4.2 绑定 UDP Socket 到地址

UDP Socket 也需要绑定到一个特定的地址和端口,以便接收数据。绑定函数同样是 bind(),示例代码如下:

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(8080);
servaddr.sin_addr.s_addr = INADDR_ANY;

if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
    perror("Bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

4.3 发送和接收数据

UDP 没有连接的概念,数据的发送和接收直接使用 sendto()recvfrom() 函数。

4.3.1 sendto() 函数

sendto() 函数用于向指定的地址发送 UDP 数据报,其函数原型如下:

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
  • sockfd:是 UDP Socket 文件描述符。
  • buf:是要发送的数据缓冲区。
  • len:是要发送数据的长度。
  • flags:通常设置为 0。
  • dest_addr:是一个指向目标地址(sockaddr 结构体)的指针。
  • addrlen:是目标地址结构体的长度。

4.3.2 recvfrom() 函数

recvfrom() 函数用于从 UDP Socket 接收数据报,并获取发送方的地址,其函数原型如下:

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
  • sockfd:是 UDP Socket 文件描述符。
  • buf:是接收数据的缓冲区。
  • len:是接收缓冲区的长度。
  • flags:通常设置为 0。
  • src_addr:是一个指向 sockaddr 结构体的指针,用于存储发送方的地址。
  • addrlen:是一个指向 socklen_t 类型变量的指针,用于指定 src_addr 结构体的长度,调用后会被更新为实际接收到的地址长度。

示例代码如下(客户端发送数据,服务器端接收数据):

  • 客户端
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(8080);
servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");

const char *msg = "Hello, server!";
sendto(sockfd, msg, strlen(msg), 0, (const struct sockaddr *)&servaddr, sizeof(servaddr));
  • 服务器端
char buffer[1024];
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&cliaddr, &clilen);
if (n > 0) {
    buffer[n] = '\0';
    printf("Received from client: %s\n", buffer);
}

4.4 关闭 UDP Socket

与 TCP 一样,完成操作后需要关闭 UDP Socket,使用 close() 函数:

close(sockfd);

5. 错误处理与调试

在 Socket 编程中,错误处理非常重要。许多 Socket 函数在出错时会返回 -1,并设置 errno 变量来表示具体的错误原因。可以使用 perror() 函数来输出错误信息,如前面示例中所示。

在调试 Socket 程序时,可以使用以下方法:

  • 打印调试信息:在关键代码位置添加 printf() 语句,输出变量的值和程序执行的状态,帮助定位问题。
  • 使用调试工具:如 GDB(GNU 调试器),可以设置断点、单步执行程序,查看变量的值和程序执行流程,有助于发现逻辑错误。
  • 网络抓包工具:如 Wireshark,可以捕获网络数据包,分析网络通信过程,检查是否按照预期进行数据传输。

例如,在处理 socket() 函数返回错误时,可以这样写:

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    perror("Socket creation failed");
    printf("errno: %d\n", errno);
    exit(EXIT_FAILURE);
}

通过 printf("errno: %d\n", errno); 输出具体的错误编号,便于查阅错误手册了解详细错误原因。

6. 常见问题与解决方案

6.1 端口冲突

在绑定 Socket 到特定端口时,如果该端口已经被其他进程占用,会导致绑定失败。解决方法是选择一个未被占用的端口,或者检查并停止占用该端口的进程。可以使用 lsof -i :port 命令(port 为具体端口号)查看哪个进程占用了指定端口。

6.2 网络连接问题

如果客户端无法连接到服务器,可能是网络配置问题、防火墙阻挡或者服务器未正确监听。可以使用 ping 命令检查网络连通性,检查防火墙规则是否允许相关端口的通信,以及确保服务器的监听代码正确无误。

6.3 数据丢失或乱序(UDP 相关)

由于 UDP 是不可靠协议,可能会出现数据丢失或乱序的情况。对于数据丢失问题,可以考虑使用应用层的重传机制,如在发送数据时记录发送的数据包,若在一定时间内未收到确认,就重新发送。对于乱序问题,可以在数据包中添加序号,接收方根据序号对数据包进行排序。

通过以上对 Linux C 语言 Socket 编程基础搭建的详细介绍,包括 TCP 和 UDP Socket 的编程步骤、错误处理以及常见问题解决,相信读者对 Socket 编程有了较为深入的理解和掌握,可以在此基础上进行更复杂的网络应用开发。