在 StackOverflow 上流传着这样一份用 Python 获取网卡 IPv4 地址的神秘代码。
1 | import socket |
但是,很少有人知道这段代码是如何工作的。本文将为你揭开这段代码的神秘面纱。
Python socket
Python 的 socket 模块提供了有关网络接口的底层控制方法。socket.socket
函数会创建一个新的 socket 对象。它的用法如下:
1 | socket.socket(family=AF_INET, type=SOCK_STREAM, proto=0, fileno=None) |
第一个参数 family
指定了网络地址的类型。最常用的值有两个:默认值 AF_INET
对应 IPv4,AF_INET6
是 IPv6。
第二个参数 type
代表了传输层协议的类型。默认值 SOCK_STREAM
是我们熟知的 TCP 协议,而 SOCK_DGRAM
则对应 UDP 协议。
因此,s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
的含义是,创建一个使用 IPv4 网络和 UDP 协议的 socket 对象 s
。
为了获取网卡的 IP 地址,创建 TCP socket 或是 UDP socket 是没有差别的。由于 socket.AF_INET
和 socket.SOCK_STREAM
都是 socket.socket
函数的默认参数,所以这一行实际上可以简写成
1 | s = socket.socket() |
socket 对象 s
创建后,可以通过 s.fileno()
获取这个 socket 的文件描述符(file descriptor)。
另外,代码中的 socket.inet_ntoa
函数把一个 4 字节 IP 地址(即 struct in_addr
)转化成点分十进制的可读形式。
现在,我们可以推断出,从 fcntl.ioctl
到 [20:24]
这一大段内容,是用来获取网卡对应的 4 字节 IP 地址的。
fcntl 与 ioctl
fcntl
与 ioctl
是 UNIX/Linux 系统中用于文件控制和 I/O 控制的两个系统调用。Python 在此基础上进行了封装。函数 ioctl
的用法如下:
fcntl.ioctl(fd, request, arg=0, mutate_flag=True)
参数 fd
是我们想控制的文件的文件描述符。在 UNIX/Linux 系统中,I/O 设备也用文件来表示,因此这里需要传入 socket 的文件操作符。
第二个参数 request
是我们想进行的操作。这个操作由一个预定义的 32 位整数表示。代码中使用的 0x8915
在 /usr/include/linux/sockios.h
文件中定义,它对应的符号是 SIOCGIFADDR
,我们正是通过这一操作来取得 IPv4 地址。想要查询 ioctl
支持的所有操作,可以在命令行中输入 man ioctl_list
。
第三个参数 arg
是操作所需的参数,这通常是一个 32 位整数或一段二进制内容。根据文档(man netdevice
),使用 SIOCGIFADDR
时需要传入的参数是结构体 struct ifreq
。struct ifreq
的定义位于 /usr/include/net/if.h
1 | struct ifreq { |
在我的计算机上,IFNAMSIZ
的值是 16,即网卡名称最长为 15 字节(第16个字节必须是 \0
用来表示字符串的结尾),而 struct ifreq
的大小是 40 字节。对于 SIOCGIFADDR
来说,只有前 16 个字节 ifr_name
是有意义的,后面的值都可以设定为 0x00
。
操作 SIOCGIFADDR
返回的结果也是 struct ifreq
结构体。其中,网卡的 IPv4 地址信息包含在 struct sockaddr ifr_addr
结构体内。这个 4 字节的 IP 地址位于 struct ifreq
结构体 20-23 字节处。所以我们会看到,fcntl.ioctl
返回的结果后面有 [20:24]
——只需要把这 4 个字节拿去转换就可以了。
到现在为止,如果我们可以正确生成 40 字节的 struct ifreq
结构体,就可以通过 ioctl
拿到 IPv4 的地址。生成结构体需要用到 Python 的 struct
模块。
Python struct 与 unicode string
Python 的 struct
模块用于生成和解析二进制内容。struct.pack
的用法如下:
1 | struct.pack(fmt, v1, v2, ...) |
这个函数比较像 printf
,第一个参数用于设定格式,后续的参数用于填充内容。
struct.pack('256s', ifname[:15])
用 ifname 的前 15 个字节填充了一个 256 字节的二进制空间,未指定内容的空间会用字节 0x00
填充。事实上,由于 struct ifreq
的大小只有 40 字节,将 256s
换成 40s
也能得到期望的 struct ifreq
结构体。
最后我们来讲一讲字符串的问题。Python 2 是不区分 str
和 bytes
的,所以 ifname
这个字符串可以直接拿来当一组字节用。代码中的 ifname[:15]
是一种防御性的措施,即只保留前 15 个字节。如果确信用户的输入合法,直接使用 ifname
也可以。但是在 Python 3 中,由于字符串不能隐式地当作一组字节用,所以需要额外的转换,具体来说就是把
1 | # Python 2 |
变成
1 | # Python 3 |
其中 utf-8
是字符串 ifname
的编码方法。
自学之道
看到这里,你可能回想,如果今后遇到类似的代码,应该如何分析它背后的原理呢?技法不外乎两条,一是多看文档,二是多读源码。
本文例子中使用的 Python 函数,在官方文档中有详细的表述。细心阅读之后,不难搞清使用每个函数的意图,并进一步推断该函数的参数需满足的条件,以及返回值的形态。
当涉及到 ioctl
等 UNIX/Linux 系统调用的时候,仅仅依靠阅读文档(而且这种文档可能不太容易找到)是不能完全掌握代码意图的。涉及到具体逻辑,参数与返回值,结构体内容与大小等细节问题时,就需要研读源码,探寻蛛丝马迹。研读源码时,要有的放矢,先用搜索缩小范围,再逐行精读。有时,甚至需要通过动手实验(例如用 sizeof
查看结构体大小)来尝试发现新的线索。
看文档与读源码,都是需要很多耐心的工作。剖析代码可能动辄需要几个小时的时间,但当真相大白之时,相信你能够感受到那豁然开朗的快乐。