详解 Python 获取网卡 IP 地址的黑魔法

在 StackOverflow 上流传着这样一份用 Python 获取网卡 IPv4 地址的神秘代码。

1
2
3
4
5
6
7
8
9
10
11
12
import socket
import fcntl
import struct

def get_ip_address(ifname):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
return socket.inet_ntoa(fcntl.ioctl(
s.fileno(),
0x8915,
struct.pack('256s', ifname[:15]))[20:24])

get_ip_address('eth0')

但是,很少有人知道这段代码是如何工作的。本文将为你揭开这段代码的神秘面纱。


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_INETsocket.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

fcntlioctl 是 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 ifreqstruct ifreq 的定义位于 /usr/include/net/if.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct ifreq {
char ifr_name[IFNAMSIZ]; /* Interface name */
union {
struct sockaddr ifr_addr;
struct sockaddr ifr_dstaddr;
struct sockaddr ifr_broadaddr;
struct sockaddr ifr_netmask;
struct sockaddr ifr_hwaddr;
short ifr_flags;
int ifr_ifindex;
int ifr_metric;
int ifr_mtu;
struct ifmap ifr_map;
char ifr_slave[IFNAMSIZ];
char ifr_newname[IFNAMSIZ];
char *ifr_data;
};
};

在我的计算机上,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 是不区分 strbytes 的,所以 ifname 这个字符串可以直接拿来当一组字节用。代码中的 ifname[:15] 是一种防御性的措施,即只保留前 15 个字节。如果确信用户的输入合法,直接使用 ifname 也可以。但是在 Python 3 中,由于字符串不能隐式地当作一组字节用,所以需要额外的转换,具体来说就是把

1
2
3
# Python 2

struct.pack('256s', ifname[:15])

变成

1
2
3
# Python 3

struct.pack('256s', bytes(ifname[:15], 'utf-8'))

其中 utf-8 是字符串 ifname 的编码方法。


自学之道

看到这里,你可能回想,如果今后遇到类似的代码,应该如何分析它背后的原理呢?技法不外乎两条,一是多看文档,二是多读源码。

本文例子中使用的 Python 函数,在官方文档中有详细的表述。细心阅读之后,不难搞清使用每个函数的意图,并进一步推断该函数的参数需满足的条件,以及返回值的形态。

当涉及到 ioctl 等 UNIX/Linux 系统调用的时候,仅仅依靠阅读文档(而且这种文档可能不太容易找到)是不能完全掌握代码意图的。涉及到具体逻辑,参数与返回值,结构体内容与大小等细节问题时,就需要研读源码,探寻蛛丝马迹。研读源码时,要有的放矢,先用搜索缩小范围,再逐行精读。有时,甚至需要通过动手实验(例如用 sizeof 查看结构体大小)来尝试发现新的线索。

看文档与读源码,都是需要很多耐心的工作。剖析代码可能动辄需要几个小时的时间,但当真相大白之时,相信你能够感受到那豁然开朗的快乐。