杰瑞科技汇

C语言Socket编程怎么入门?新手如何快速掌握网络通信?

目录

  1. 预备知识
    • 什么是 Socket?
    • 网络基础回顾 (IP, Port, TCP/UDP)
    • 开发环境准备
  2. 核心 Socket 函数 (TCP 示例)
    • socket(): 创建套接字
    • bind(): 绑定地址和端口
    • listen(): 开始监听 (服务端)
    • accept(): 接受连接 (服务端)
    • connect(): 发起连接 (客户端)
    • send() / recv(): 发送和接收数据
    • close(): 关闭套接字
  3. 实战:一个简单的 Echo 服务器/客户端
    • 服务端代码 (server.c)
    • 客户端代码 (client.c)
    • 如何编译和运行
  4. 错误处理
    • 为什么 perror()strerror(errno) 是你的好朋友
  5. 从 TCP 到 UDP
    • UDP 的特点
    • UDP 核心函数 (sendto, recvfrom)
    • 一个简单的 UDP 示例
  6. 高级主题
    • 多路 I/O 复用: select(), poll(), epoll() (Linux)
    • 非阻塞 I/O
    • 套接字选项 (setsockopt)
  7. 总结与最佳实践

预备知识

什么是 Socket?

你可以把 Socket (套接字) 想象成一个通信的“端点”,它是一个由操作系统提供的编程接口,允许程序通过网络发送和接收数据,两个程序通过网络进行通信,就像是两座建筑通过一个标准的邮局系统(Socket API)互相邮寄信件。

C语言Socket编程怎么入门?新手如何快速掌握网络通信?-图1
(图片来源网络,侵删)

网络基础回顾

  • IP 地址: 网络中设备的唯一地址,168.1.100 (IPv4) 或 2001:db8::1 (IPv6),它标识了网络上的“哪一台机器”。
  • 端口号: 在一台机器上,可能有多个程序在进行网络通信,端口号用于区分这些程序,它标识了“这台机器上的哪个程序”,Web 服务通常使用 80 端口,HTTPS 使用 443 端口,范围是 0-65535,0-1023 是系统保留端口。
  • TCP (Transmission Control Protocol):
    • 面向连接: 在传输数据前,必须先建立一个连接(三次握手)。
    • 可靠传输: 通过确认、重传、排序等机制确保数据无差错、不丢失、不重复且按序到达。
    • 全双工通信: 连接建立后,双方可以同时进行数据的发送和接收。
    • 面向字节流: 应用层看到的数据是一个无结构的字节流。
  • UDP (User Datagram Protocol):
    • 无连接: 不需要建立连接,直接发送数据包(称为数据报)。
    • 不可靠传输: 不保证数据包的顺序、不保证不丢失、不保证不重复。
    • 高效: 协议开销小,传输速度快。
    • 面向数据报: 应用层以数据报的形式处理数据。

开发环境准备

  • 操作系统: Linux 是学习 Socket 编程的最佳平台,因为其 API 最标准,本教程的示例代码和命令均基于 Linux。
  • 编译器: GCC (GNU Compiler Collection)。
  • 网络工具: netstatss 用于查看网络连接状态。

核心 Socket 函数 (TCP 示例)

我们将以最常用的 TCP 流套接字 (SOCK_STREAM) 为例来介绍核心函数。

socket(): 创建套接字

这是使用 Socket API 的第一步,告诉操作系统你想要一个通信端点。

#include <sys/socket.h>
int socket(int domain, int type, int protocol);
  • domain (地址域): 指定使用的协议族。
    • AF_INET: IPv4 地址。
    • AF_INET6: IPv6 地址。
    • AF_UNIX: 用于同一台机器上的进程间通信 (IPC)。
  • type (套接字类型):
    • SOCK_STREAM: 提供面向连接的、可靠的字节流服务 (TCP)。
    • SOCK_DGRAM: 提供无连接的、不可靠的数据报服务 (UDP)。
  • protocol (协议): 通常设为 0,让系统自动根据 domaintype 选择。

返回值: 成功返回一个套接字文件描述符(一个非负整数),失败返回 -1。

bind(): 绑定地址和端口

这个函数将一个 IP 地址和端口号与套接字关联起来,对于服务端来说,这是必须的,它告诉客户端:“请连接到这个地址和这个端口上”。

C语言Socket编程怎么入门?新手如何快速掌握网络通信?-图2
(图片来源网络,侵删)
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd: socket() 返回的套接字描述符。
  • addr: 一个指向 sockaddr 结构体的指针,这是一个通用的地址结构,根据 domain 不同,需要使用不同的结构体,并通过强制转换传入。
    • 对于 AF_INET,使用 struct sockaddr_in
  • addrlen: addr 结构体的长度。

struct sockaddr_in 结构体:

#include <netinet/in.h>
#include <arpa/inet.h> // for inet_addr
struct sockaddr_in {
    short            sin_family;   // 地址族, AF_INET
    unsigned short   sin_port;     // 端口号, 需要用 htons() 转换
    struct in_addr  sin_addr;     // IP 地址, in_addr 是一个结构体
    unsigned char    sin_zero[8]; // 填充字段,保持与 sockaddr 结构体大小一致
};
// in_addr 结构体
struct in_addr {
    in_addr_t s_addr; // 32位 IPv4 地址
};

重要: 端口号和 IP 地址在赋值给 sin_portsin_addr.s_addr 时,必须从主机字节序转换到网络字节序,使用 htons() (host to network short) 和 inet_addr() (将点分十进制字符串转为网络字节序的 IP)。

listen(): 开始监听

bind() 之后,服务端调用 listen() 来准备接受客户端的连接请求,它会让套接字进入“被动”模式,等待客户端的连接。

#include <sys/socket.h>
int listen(int sockfd, int backlog);
  • sockfd: 已绑定的套接字描述符。
  • backlog: 指定在调用 accept() 之前,内核可以排队的最大连接请求数。

返回值: 成功返回 0,失败返回 -1。

C语言Socket编程怎么入门?新手如何快速掌握网络通信?-图3
(图片来源网络,侵删)

accept(): 接受连接

当客户端发起连接后,服务端的 listen() 会感知到。accept() 会从连接队列中取出一个已完成的连接,并返回一个新的套接字描述符,专门用于与这个客户端通信,原来的监听套接字继续用于接受新的连接。

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd: 处于监听状态的套接字描述符。
  • addr: 用于保存客户端的地址信息,如果不需要,可以设为 NULL
  • addrlen: 一个指向 addr 长度的指针。addrNULL,这里也设为 NULL

返回值: 成功返回一个新的套接字描述符(用于通信),失败返回 -1。

connect(): 发起连接

客户端调用 connect() 来主动向服务端发起连接请求。

#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd: 客户端创建的套接字描述符。
  • addr: 指向服务端 sockaddr_in 结构体的指针,包含了服务端的 IP 和端口。
  • addrlen: addr 结构体的长度。

返回值: 成功返回 0,失败返回 -1。

send() / recv(): 发送和接收数据

连接建立后,客户端和服务端都使用这两个函数在连接上传输数据。

#include <sys/socket.h>
// 发送数据
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// 接收数据
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • sockfd: 用于通信的套接字描述符(服务端是 accept() 返回的,客户端是 connect() 使用的)。
  • buf: 发送/接收数据的缓冲区。
  • len: 缓冲区的长度。
  • flags: 通常设为 0。

返回值:

  • 成功时,send() 返回实际发送的字节数,recv() 返回实际接收到的字节数。
  • 如果返回值是 0,表示对方已经关闭了连接。
  • 如果返回值是 -1,表示发生了错误。

close(): 关闭套接字

当通信结束时,调用 close() 来关闭套接字,释放相关资源。

#include <unistd.h>
int close(int fd);

实战:一个简单的 Echo 服务器/客户端

这个例子中,客户端发送一条消息,服务器收到后,将原消息发送回客户端。

服务端代码 (server.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[BUFFER_SIZE] = {0};
    // 1. 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }
    // 2. 设置套接字选项,允许地址重用
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }
    // 3. 绑定地址和端口
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口
    address.sin_port = htons(PORT);       // 转换为网络字节序
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    // 4. 开始监听
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }
    printf("Server listening on port %d...\n", PORT);
    // 5. 接受连接
    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
        perror("accept");
        exit(EXIT_FAILURE);
    }
    printf("Client connected: %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
    // 6. 读取客户端消息
    int valread = read(new_socket, buffer, BUFFER_SIZE);
    printf("Client message: %s\n", buffer);
    // 7. 将消息回写给客户端
    send(new_socket, buffer, valread, 0);
    printf("Echo message sent back to client.\n");
    // 8. 关闭套接字
    close(new_socket);
    close(server_fd);
    return 0;
}

客户端代码 (client.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
    int sock = 0;
    struct sockaddr_in serv_addr;
    char *hello = "Hello from client";
    char buffer[BUFFER_SIZE] = {0};
    // 1. 创建套接字
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        printf("\n Socket creation error \n");
        return -1;
    }
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    // 将 IPv4 地址从文本转换为二进制形式
    if (inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0) {
        printf("\nInvalid address/ Address not supported \n");
        return -1;
    }
    // 2. 连接服务端
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        printf("\nConnection Failed \n");
        return -1;
    }
    printf("Connected to server.\n");
    // 3. 发送消息
    send(sock, hello, strlen(hello), 0);
    printf("Hello message sent\n");
    // 4. 读取服务端回发的消息
    int valread = read(sock, buffer, BUFFER_SIZE);
    printf("Server echo: %s\n", buffer);
    // 5. 关闭套接字
    close(sock);
    return 0;
}

如何编译和运行

  1. 保存代码: 将上面的代码分别保存为 server.cclient.c

  2. 编译:

    # 编译服务端
    gcc server.c -o server
    # 编译客户端
    gcc client.c -o client
  3. 运行:

    • 首先启动服务端 (它会一直等待连接):
      ./server

      你会看到输出: Server listening on port 8080...

    • 然后启动客户端 (在另一个终端中):
      ./client

      客户端会连接到服务端,并输出:

      Connected to server.
      Hello message sent
      Server echo: Hello from client
    • 服务端终端 也会输出:
      Client connected: 127.0.0.1:xxxx
      Client message: Hello from client
      Echo message sent back to client.

错误处理

在 Socket 编程中,几乎所有的函数都可能失败。检查每一个函数的返回值 是一个必须养成的良好习惯。

当函数失败时,全局变量 errno 会被设置成一个错误码,你可以使用 perror() 函数来打印一个描述性的错误信息。

#include <stdio.h>
#include <errno.h> // 需要 errno 头文件
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
    perror("socket() failed"); // 会打印 "socket() failed: [具体的错误描述,如 Address already in use]"
    exit(EXIT_FAILURE);
}

strerror(errno) 也可以将错误码转换为字符串。


从 TCP 到 UDP

UDP 编程比 TCP 简单,因为它没有连接的概念。

主要区别

  1. 创建套接字: 类型为 SOCK_DGRAM
  2. 服务端:
    • 不需要 listen()accept()
    • 使用 bind() 来绑定端口,然后直接进入循环,用 recvfrom() 接收数据。
  3. 客户端:
    • 不需要 connect()
    • 每次发送数据时,都需要在 sendto() 中指定目标地址和端口。
    • 接收数据时使用 recvfrom(),它会告诉你数据来自哪里。

核心函数

// 发送数据到指定地址
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
                const struct sockaddr *dest_addr, socklen_t addrlen);
// 从任意地址接收数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                  struct sockaddr *src_addr, socklen_t *addrlen);

一个简单的 UDP 示例 (服务端)

// udp_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8081
#define BUFFER_SIZE 1024
int main() {
    int sockfd;
    char buffer[BUFFER_SIZE];
    struct sockaddr_in servaddr, cliaddr;
    // 1. 创建 UDP 套接字
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }
    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));
    // 2. 绑定地址和端口
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(PORT);
    if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    printf("UDP Server listening on port %d...\n", PORT);
    int len, n;
    len = sizeof(cliaddr);
    // 3. 循环接收数据
    while (1) {
        n = recvfrom(sockfd, (char *)buffer, BUFFER_SIZE, MSG_WAITALL, (struct sockaddr *)&cliaddr, &len);
        buffer[n] = '\0';
        printf("Client : %s\n", buffer);
        sendto(sockfd, (const char *)buffer, n, MSG_CONFIRM, (const struct sockaddr *)&cliaddr, len);
        printf("Echo message sent.\n");
    }
    close(sockfd);
    return 0;
}

高级主题

多路 I/O 复用 (select())

当一个服务器需要同时处理多个客户端连接时,为每个连接都创建一个线程或进程是不现实的,I/O 复用技术允许你监视多个套接字,并知道其中任何一个是否“就绪”(即可读、可写或出现异常)。

select() 是最古老、最广泛支持的 I/O 复用函数。

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

工作流程:

  1. 创建一个 fd_set 集合(read_fds)。
  2. 使用 FD_ZERO() 清空集合。
  3. 使用 FD_SET() 将你关心的套接字(server_fd, client_fd1, client_fd2)添加到集合中。
  4. 调用 select(),它会阻塞,直到集合中的任何一个套接字就绪,或者超时。
  5. select() 返回后,你需要再次遍历所有套接字,使用 FD_ISSET() 检查哪些套接字在集合中(即哪些套接字就绪了)。
  6. 处理就绪的套接字(从可读的套接字 recv() 数据)。
  7. 在下一次循环前,重新设置 fd_set 集合。

非阻塞 I/O

默认情况下,套接字是阻塞的,这意味着如果一个操作(如 read())不能立即完成,调用进程将被挂起,直到操作完成。

可以通过 fcntl() 将套接字设置为非阻塞模式,在非阻塞模式下,如果操作不能立即完成,函数会立即返回一个错误,并将 errno 设置为 EAGAINEWOULDBLOCK

这通常与 I/O 复用技术结合使用,例如在一个循环中不断尝试 read(),直到 select() 告诉你这个套接字“可读”。

套接字选项 (setsockopt)

setsockopt 用于设置套接字的属性或选项。

  • SO_REUSEADDR: 允许在 close() 后立即重用相同的地址和端口,避免了 TIME_WAIT 状态带来的问题。
  • SO_RCVBUF / SO_SNDBUF: 设置接收和发送缓冲区的大小。
  • TCP_NODELAY: 禁用 Nagle 算法,对于需要低延迟的应用(如实时游戏)很有用。

总结与最佳实践

  • 从 TCP 开始: TCP 更可靠,更容易理解,适合大多数应用场景。
  • 检查返回值: 永远不要忽略 Socket 函数的返回值,并妥善处理错误。
  • 使用 perror(): 它是调试 Socket 程序的利器。
  • 字节序转换: 记住在使用 IP 和端口时,始终使用 htons(), ntohs(), inet_addr() 等函数进行主机字节序和网络字节序的转换。
  • 关闭套接字: 当通信结束时,记得调用 close() 释放资源,防止资源泄露。
  • 理解阻塞 vs. 非阻塞: 阻塞 I/O 简单但效率低,非阻塞 I/O 配合 I/O 复用是构建高性能服务器的关键。

这份教程为你提供了 C 语言 Socket 编程的坚实基础,你可以尝试扩展这些示例,

  • 让服务器能同时处理多个客户端(使用 select() 或多线程)。
  • 实现一个简单的聊天室。
  • 尝试使用更高级的 I/O 复用技术,如 poll()epoll (Linux)。
分享:
扫描分享到社交APP
上一篇
下一篇