4、Socket编程入门:Socket类型、Socket函数族、地址结构、字节序转换

4.1 先聊聊Socket到底是什么

说实话,我刚学网络编程那会儿,觉得Socket这玩意儿特别玄乎。什么「套接字」?听着像电工用的工具。其实说白了,Socket就是操作系统提供的一个编程接口,让你能在两个程序之间收发数据。

你可以把它想象成一个「网络插座」。一端插上你的程序,另一端插上另一个程序,数据就在这根「线」上跑。我习惯把Socket比作电话——你拿起听筒(创建Socket),拨号(连接),通话(收发数据),挂断(关闭)。

核心概念:Socket是应用层与传输层之间的抽象层。它屏蔽了底层TCP/IP协议的复杂性,让你只需要调用几个函数就能完成网络通信。

4.2 Socket的三种类型

Socket不是只有一种。根据通信方式的不同,主要分三类。我在项目中踩过不少坑,都是因为选错了类型。

类型 名称 特点 典型协议
SOCK_STREAM 流式套接字 面向连接、可靠、有序、无边界 TCP
SOCK_DGRAM 数据报套接字 无连接、不可靠、无序、有边界 UDP
SOCK_RAW 原始套接字 直接操作IP层,可自定义协议 ICMP、自定义

SOCK_STREAM 用得最多。你想想看,HTTP、FTP、SSH,哪个不是基于TCP?它保证数据按顺序到达,丢了还会重传。我做过一个文件传输工具,用的就是这种类型——文件少一个字节都不行。

SOCK_DGRAM 就随意多了。发出去就不管了,速度快但不靠谱。适合视频直播、DNS查询这类场景。丢几帧画面无所谓,但延迟高了可不行。

SOCK_RAW 比较特殊。它让你直接操作IP数据包。我曾经用它写过一个简单的ping程序,绕过了系统自带的ICMP处理逻辑。嗯,这里要注意,原始套接字需要root权限。

4.3 Socket函数族——你只需要记住这几个

Socket编程的函数其实不多。我列个清单,你照着用就行。

// 创建Socket
int socket(int domain, int type, int protocol);

// 绑定地址
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// 监听(服务端)
int listen(int sockfd, int backlog);

// 接受连接(服务端)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

// 连接(客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// 收发数据
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);

// 关闭
int close(int fd);

你看,核心函数就这几个。服务端走的是 socket → bind → listen → accept → recv/send → close。客户端走的是 socket → connect → send/recv → close。套路非常固定。

我的习惯:每次调用socket函数后,一定要检查返回值。返回-1就是出错了,用 perror() 或 strerror(errno) 看看具体原因。很多新手一上来就忽略错误检查,结果程序莫名其妙崩溃。

4.4 地址结构——别被它吓到

地址结构是Socket编程里最容易让人头晕的部分。我刚开始学的时候,看到 struct sockaddr_in 里一堆字段,直接懵了。其实没那么复杂。

// IPv4地址结构
struct sockaddr_in {
    sa_family_t    sin_family;   // 地址族,AF_INET
    in_port_t      sin_port;     // 端口号,网络字节序
    struct in_addr sin_addr;     // IP地址
    char           sin_zero[8];  // 填充字节
};

// 通用地址结构(用于函数参数)
struct sockaddr {
    sa_family_t sa_family;       // 地址族
    char        sa_data[14];     // 地址数据
};

为什么要有两个结构?因为 bind()、connect() 这些函数设计时用的是通用类型 struct sockaddr *。你实际用的是 sockaddr_in,但传参时要强转一下。说白了就是C语言没有继承,只能这么搞。

我记得有一次,同事把 sin_family 写成了 AF_INET6,但实际用的是IPv4地址。结果bind一直失败,查了半天才发现是地址族不匹配。这种低级错误,犯一次就记住了。

4.5 字节序转换——大端小端,别搞反了

字节序问题,说白了就是「数据在内存里怎么排列」。有的机器把高位字节放前面(大端),有的放后面(小端)。x86架构就是小端,而网络协议规定用大端。

所以,你在给 sin_port 和 sin_addr 赋值时,必须转换成网络字节序。否则数据传出去,对方收到的是乱码。

#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);

我习惯这么记:h 代表 host(主机),n 代表 network(网络),s 代表 short(16位),l 代表 long(32位)。htons 就是「主机转网络,16位」。

注意:字符串形式的IP地址(如"192.168.1.1")不能直接赋值给 sin_addr。需要用 inet_pton() 或 inet_aton() 转换。我曾经见过有人直接写 addr.sin_addr.s_addr = "192.168.1.1",编译能过,运行直接崩。

4.6 一张图看懂Socket编程全流程

下面这张图,是我自己总结的Socket编程核心流程。你照着这个写,基本不会跑偏。

Socket编程核心流程 服务端 客户端 socket() 创建套接字 bind() 绑定地址和端口 listen() 监听连接 accept() 接受连接 recv() / send() 收发数据 close() 关闭连接 socket() 创建套接字 connect() 连接服务器 send() / recv() 收发数据 close() 关闭连接 TCP三次握手 数据流 注意:客户端通常不需要 bind(),系统会自动分配端口

这张图把服务端和客户端的流程分开了。你注意看,服务端多了一个 bind() 和 listen(),客户端直接 connect() 就行。为什么客户端不用 bind?因为客户端是主动连接方,系统会自动给它分配一个临时端口。

4.7 一个完整的例子

光说不练假把式。我给你写一个最简单的TCP服务端和客户端。代码不长,但五脏俱全。

// 服务端代码(server.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);
    char buffer[1024] = {0};

    // 1. 创建Socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket failed");
        exit(1);
    }

    // 2. 绑定地址
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(8888);

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind failed");
        close(server_fd);
        exit(1);
    }

    // 3. 监听
    if (listen(server_fd, 5) == -1) {
        perror("listen failed");
        close(server_fd);
        exit(1);
    }

    printf("Server listening on port 8888...\n");

    // 4. 接受连接
    client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
    if (client_fd == -1) {
        perror("accept failed");
        close(server_fd);
        exit(1);
    }

    // 5. 接收数据
    recv(client_fd, buffer, sizeof(buffer), 0);
    printf("Received: %s\n", buffer);

    // 6. 关闭
    close(client_fd);
    close(server_fd);
    return 0;
}
// 客户端代码(client.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    int sock_fd;
    struct sockaddr_in server_addr;
    char *message = "Hello, Server!";

    // 1. 创建Socket
    sock_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (sock_fd == -1) {
        perror("socket failed");
        exit(1);
    }

    // 2. 设置服务器地址
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);

    // 3. 连接服务器
    if (connect(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("connect failed");
        close(sock_fd);
        exit(1);
    }

    // 4. 发送数据
    send(sock_fd, message, strlen(message), 0);
    printf("Message sent\n");

    // 5. 关闭
    close(sock_fd);
    return 0;
}

测试方法:先在一个终端运行 ./server,再开另一个终端运行 ./client。服务端会打印出 "Received: Hello, Server!"。如果报错,八成是端口被占用了,换个端口试试。

4.8 避坑指南

最后,我把自己踩过的坑总结一下。你遇到这些问题时,心里就有数了。

  • 端口被占用:程序退出后,端口可能还处于 TIME_WAIT 状态。等一两分钟,或者用 setsockopt() 设置 SO_REUSEADDR。
  • 字节序搞反:端口号用 htons(),IP地址用 htonl() 或 inet_pton()。别混了。
  • 地址结构清零:用 memset() 把 sockaddr_in 清零,避免残留数据导致奇怪的问题。
  • 忽略错误返回值:每个函数调用都要检查返回值。我见过太多人因为没检查 accept() 返回值,导致程序在压力测试下崩溃。

我曾经在一个项目里,因为忘了调用 bind() 就直接 listen(),结果系统随机分配了一个端口。客户端连不上,查了半天才发现问题。这种低级错误,犯一次就长记性了。

好了,Socket编程入门就讲到这里。代码不多,但都是核心。你照着写一遍,跑通了,就算入门了。

可可精品资料库