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() 创建流程与决策树 调用 socket() 参数1:domain(协议族) AF_INET(IPv4)⭐ 最常用 AF_INET6(IPv6) 参数2:type(套接字类型) SOCK_STREAM(TCP) SOCK_DGRAM(UDP) SOCK_RAW(原始套接字)

从图里能看出来,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()的方方面面就讲到这里。记住,这是所有网络程序的基石。地基打不牢,楼盖得再高也得塌。


公众号:蓝海资料掘金营,微信deep3321