3. 创建Socket:socket()函数详解、协议族选择、套接字类型选择、错误处理
好,咱们正式开始写网络程序的第一步——创建Socket。
很多人觉得这步就是调个函数,没啥好讲的。但我见过太多新手,甚至工作一两年的同事,在这上面栽跟头。说白了,socket()就像你打电话前要先买个手机,选对运营商,插对SIM卡。一步错,后面全白搭。
3.1 socket()函数——一切的起点
先看函数原型,这是POSIX标准定义的:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
三个参数,一个返回值。简单吧?但每个参数背后都有讲究。
返回值:成功返回一个非负整数(文件描述符),失败返回-1。嗯,这里要注意,返回的不是指针,是个整数。我刚开始学的时候老想着它是不是返回个结构体指针,后来才明白,Unix里一切皆文件,Socket也不例外。
核心要点:socket()返回的文件描述符,本质上是一个整数索引,指向内核中为该Socket分配的数据结构。后续所有操作(bind、listen、connect、send、recv)都靠这个索引。
3.2 协议族(domain)——选对圈子
第一个参数domain,也叫协议族。它决定了Socket工作在哪个"网络世界"里。
| 协议族 | 宏定义 | 说明 |
|---|---|---|
| IPv4 | AF_INET | 最常用,32位地址,互联网主力 |
| IPv6 | AF_INET6 | 128位地址,未来趋势 |
| 本地通信 | AF_UNIX 或 AF_LOCAL | 同一台机器上的进程通信,不走网卡 |
| 原始协议 | AF_PACKET | 直接操作数据链路层,抓包工具常用 |
我个人习惯,99%的场景用AF_INET。为什么?因为现在互联网还是IPv4的天下。IPv6虽然好,但很多老旧系统和中间件支持得并不完美。
这里有个坑:AF_INET和PF_INET。很多老代码里写的是PF_INET,这两个值在Linux上是一样的,但语义不同。AF代表"地址族",PF代表"协议族"。规范上说socket()第一个参数应该用PF_xxx,但实际大家全用AF_xxx。我建议你统一用AF_INET,省得别人看代码时犯嘀咕。
3.3 套接字类型(type)——决定通信方式
第二个参数type,说白了就是选"打电话"还是"寄信"。
- SOCK_STREAM:流式套接字,对应TCP。可靠、有序、面向连接。就像打电话——你先拨号,对方接了才能说话,说完挂断。
- SOCK_DGRAM:数据报套接字,对应UDP。不可靠、无序、无连接。就像寄明信片——扔进邮筒就不管了,丢不丢、到不到、顺序乱不乱,全看运气。
- SOCK_RAW:原始套接字。直接操作IP层或更底层的数据包。一般写网络工具、安全工具才会用到。
你想想看,写个Web服务器,肯定用SOCK_STREAM。写个视频直播,延迟敏感、丢包能忍,那就用SOCK_DGRAM。我在项目中遇到过有人用UDP传数据库日志,结果丢了几条关键记录,排查了一整天才发现是UDP丢包了。嗯,选错类型,后果很严重。
小技巧:Linux 2.6.17之后,可以在type参数后面按位或上SOCK_NONBLOCK和SOCK_CLOEXEC。比如 socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0),一步到位创建非阻塞Socket,省得后面再调fcntl。我现在的项目里全这么写,少一行代码就少一个bug。
3.4 协议(protocol)——通常填0
第三个参数protocol,通常填0。意思是让内核根据前两个参数自动选择协议。
比如你写了AF_INET + SOCK_STREAM,内核就知道你要用TCP。写了AF_INET + SOCK_DGRAM,内核就知道你要用UDP。
什么时候需要显式指定?用原始套接字的时候。比如你想抓ICMP包,就得写成:
int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
这里IPPROTO_ICMP是6,告诉内核我只关心ICMP协议的数据。否则内核不知道你要抓哪种原始包。
3.5 错误处理——别让程序静默死掉
socket()失败时返回-1,并设置errno。最常见的错误码有这么几个:
| errno | 含义 | 常见原因 |
|---|---|---|
| EACCES | 权限不足 | 创建原始套接字需要root权限 |
| EAFNOSUPPORT | 协议族不支持 | 内核没编译对应协议模块 |
| EINVAL | 参数无效 | type或protocol组合不对 |
| EMFILE | 进程文件描述符耗尽 | 没关Socket,泄漏了 |
| ENFILE | 系统文件描述符耗尽 | 整个系统的文件句柄用光了 |
| ENOBUFS | 内核缓冲区不足 | 内存不够或资源泄漏 |
| EPROTONOSUPPORT | 协议不支持 | 指定的协议在内核中未实现 |
我曾经在一个高并发服务器上遇到过EMFILE错误。查了半天,发现是某个模块创建了Socket之后忘记close()。你想想看,进程默认只能开1024个文件描述符,泄漏几个就满了。从那以后,我写任何Socket代码,第一件事就是写好错误处理和资源释放。
正确的写法应该是这样:
#include <stdio.h>
#include <sys/socket.h>
#include <errno.h>
#include <string.h>
int main() {
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1) {
fprintf(stderr, "socket() failed: %s (errno=%d)\n",
strerror(errno), errno);
return -1;
}
// 使用Socket...
// 记得关闭
close(sock);
return 0;
}
看到没?用strerror(errno)把错误码转成人类能读懂的字符串。别只打印个"error -1",调试的时候你会疯掉的。
警告:千万不要忽略socket()的返回值!我见过有人写 int sock = socket(AF_INET, SOCK_STREAM, 0); 然后直接往下用,完全不检查。如果socket()失败了,sock是-1,后面bind、listen全都会操作无效文件描述符,程序崩溃得莫名其妙。
3.6 一张图看懂Socket创建流程
下面这张图,把整个知识体系串起来了。我建议你多看几遍,把每个分支都记在脑子里。
从图里能看出来,socket()的决策路径其实很清晰:先选网络世界(IPv4还是IPv6),再选通信方式(流式、数据报还是原始),最后协议填0让内核自动匹配。每一步选错,后面都是坑。
3.7 实战建议
最后,给你几个我多年积累下来的建议:
- 永远检查返回值。socket()失败不检查,后面所有操作都是白费。我见过最离谱的bug,就是socket()返回-1,但代码没检查,结果bind()报"无效参数",排查了两天才发现是socket创建失败了。
- 用perror或strerror打印错误。别只打印个数字,没人记得住errno 13是什么意思。打印"Permission denied"一目了然。
- 创建完Socket立刻想好怎么关。我习惯在函数开头就写好close(sock)的清理逻辑,或者用goto统一出口。防止中间return忘了关。
- 非阻塞Socket一步到位。如果你确定要用非阻塞模式,直接在socket()的type参数里加上SOCK_NONBLOCK。少调一次fcntl,少一个出错机会。
好了,socket()的方方面面就讲到这里。记住,这是所有网络程序的基石。地基打不牢,楼盖得再高也得塌。