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编程核心流程。你照着这个写,基本不会跑偏。
这张图把服务端和客户端的流程分开了。你注意看,服务端多了一个 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编程入门就讲到这里。代码不多,但都是核心。你照着写一遍,跑通了,就算入门了。
可可精品资料库