Linux内核第一宏

list_entry()有着内核第一宏的美称,它被设计用来通过结构体成员的指针来返回结构体的指针。现在就让我们通过一步步的分析,来揭开它的神秘面纱,感受内核第一宏设计的精妙之处。

整理分析的思路

list_entry()在内核源代码/include/linux目录下的list.h中被定义,如下:

在list_entry的定义中,我们看到出现了另外一个宏container_of。而list_entry这个宏正是通过container_of去实现的。所以我们要先进入container _of,来看看它做了什么。

container_of定义在/include/linux/kernel.h中,定义如下:

我们发现,在container_of的定义中,又出现一个新的宏offsetof。所以,在开始分析container_of之前,有必要先来搞清楚offsetof。

offsetof定义在/include/linux/stddef.h中,定义如下:

我们可以看到,在offsetof的定义中,已经没有再引入新的宏,所以,我们就以offsetof为突破口,进行分析。

正式分析

宏offsetof

单词offset的意思是偏移量,所以我们可以顾名思义一下,宏offsetof的作用可能和偏移量有关。那么,它要求谁的偏移量呢?

offsetof用于计算TYPE结构体中成员MEMBER的偏移量。

从offsetof的定义中可以看到,在&((TYPE *)0)->MEMBER中,有一个明显的强制类型转换((TYPE *)0)。在C语言中,强制类型转换有两种语法:

 1.(TYPE)var_name; //变量名形式,如(int)i;
 2.(TYPE)varlue;   //值形式,如(type*)0;

定义中使用了第二种语法,将0值强制类型转换成一个TYPE结构体的指针。通过这种强制类型转换后,TYPE结构体的地址变成了0,那么为什么要做这种转换?它的作用是什么?

其实这么做的目的只有一个,就是为了更容易拿到成员的偏移量。我们知道,结构体类型在预编译的时候,为了使CPU能够对数据快速访问和有效节省存储空间,有一个内存对齐的问题,就是结构体的每个成员在内存中的存储都要按照一定的偏移量来存储。所以会由于成员类型的不同,导致每个成员的偏移量也不尽相同,所以我们就不能一劳永逸的来给所有成员设定一个固定的偏移值。那我们想要拿到一个成员的偏移量怎么办呢?我们就把这个重任交给了编译器。我们可以指挥编译器,让它“交出”成员的偏移量。有一点我们必须清楚,编译器在预编译的时候,对每个成员的偏移量是心知肚明的,所以编译器如果想要知道某个成员的地址,它只需要用结构体的地址+成员的偏移量就可以得到该成员的地址。

举个简单的例子:以上面的图为例,如果上面结构体的地址p=1000,,成员C的偏移量(offset)是4,那成员C的地址pc就是1000+4=1004;

这个时候得到的1004是成员C的地址pc,但是我们想要的不是它的地址,而是它的偏移量,这个时候怎么办呢?最简单的办法就是,直接将结构体的地址变成0不就可以了吗?0加一个数就等于这个数本身,这样相加的结果正好就是成员的偏移量了。这就是为什么定义中要通过强制类型转换将结构体的地址变成0,举个例子:现在将结构体的地址p=0,成员C的偏移量(offset)还是4,0+4=4,得到的结果正好就是该成员的偏移量了。

所以我们让编译器执行&((TYPE *)0)->MEMBER这句话的时候,它做的就是这样一个事情,它先将type类型结构体的地址变成0,然后再去加上成员MEMBER的偏移量,0+偏移量=偏移量,所以最后得到的结果就是成员的偏移量了。内核的设计者们,正是通过这种巧妙的设计,来指挥编译器交出偏移量。

所以,当我们调用offsetof(TYPE, MEMBER)之后,就会得到成员MEMBER在TYPE结构体中的偏移量了

这里有一点值得思考的是:&((TYPE *)0)->MEMBER中,结构体的地址通过强制类型转换变成了0,我们知道0地址是留给操作系统来使用的,这里面的内容是不允许普通的程序来访问的。但是这里却将结构体地址变成了0,那直接使用0地址不会导致程序崩溃吗?

答案是程序是不会崩溃的,编译器在执行&((TYPE *)0)->MEMBER的时候,并没有真正去访问0地址中的内容,而只是将这个0值当作加法运算中的一个加数来处理。形象的说,就是编译器只是摘掉了你房间的门牌号拿来作计算,并没有开门去取放在屋子里的任何东西。它在做完加法后就走人了,屋子里的东西是完整无缺的。而之所以编译器没有进屋子取东西,是因为有“&”的存在,编译器看到有“&”,就会明白我只需要拿到地址就可以了。下面通过一个简单的例子来说明:

打印结果如下:

根据打印结果可以看到:pst->j与&(pst->j)效果是不一样的
▶pst->j 没有“&”,会访问变量中的内容,打印结果为成员变量中的内容
▶&(pst->j)有“&”,不会访问变量中的内容,只拿地址,打印结果为成员的地址

至此,offsetof的作用我们已经知道了。在container_of的定义中,使用了offsetof,也就是说,在container _of的实现中,它需要用到offsetof来得到结构体某个成员的偏移量,那container _of的作用是什么?它要偏移量有什么用呢?接下来就让我们一起进入container _of的世界吧。

宏container_of

在进入container _of的世界后,我们发现这里有两个“熟悉的陌生人”,分别是typeof和“({ })”。这两个小伙伴,我们在C语言中是见不到它们的,这是因为他们都只“生活”在GNU C编译器中。为了能让我们在认识container _of的旅程更加轻松,我们有必要花些时间来和typeof和“({ })”这两个杰出的小伙伴交个朋友,认识一下他们。

typeof

●typeof是GNU C编译器的特有关键字 ●typeof只在编译期生效,用于得到变量的类型 举个例子:

int i = 100;
typeof(i) j = i; <=> int j = i;  //这两个语句的作用是等价的,变量i的类型是int,typeof(i)就相当于拿到变量i的类型

({ }) ●({ })是GNU C编译器的语法扩展 ●({ })与逗号表达式类似,结果为最后一个语句的值 举个例子:

现在,我们已经认识了typeof和“({ })”两个小伙伴,这对我们认识container _of会有很大帮助。现在,我们可以来正式的分析container _of宏了。让我们再一次把container _of的定义搬到这里:

定义中使用了扩展语法“({ })”,前面已经说过,它的结果就是最后一个语句的值,既然这样,我们就可以直接来看最后一个语句。

(type *)( (char *)__mptr - offsetof(type,member) );

这里面有一个指针__mptr,它在第二行中被定义,类型由typeof来获得。指针 __mptr和指针ptr的值是一样的,而ptr又是宏container _of的一个参数,它是指向type结构体中成员member的一个指针,所以 __mptr也指向type结构体中成员member。为了清晰的表示这种关系,我们用一个图来表示,它们的关系如下图:

我们来看(char *)__mptr - offsetof(type,member)这句话是什么意思。通过offsetof(type,member)可以得到成员member的偏移量,也就是上图中的offset,然后用 __mptr减去offset,得到一个地址,如上图所示P,而这个地址就是结构体的地址,这样就实现了通过成员找到结构体的起始地址。__mptr前面的char*是为了进行指针运算的,以实现逐字节相减。最后通过(type *)强制类型转换为指向结构体的指针。到这里,宏container_of就真相大白了。 这里有一点值得思考的是:既然__mptr = (ptr),那为什么不直接使用传入的参数ptr去减,而是看似“多此一举”的在第二行将ptr的值赋给 __mptr,然后用 __mptr去减呢? 答案是为了对传入的参数进行一次类型安全检查。宏是在编译的时候由预处理器来进行处理的。预处理器做的是单纯的文本替换,不会进行任何的类型检查,这就有可能导致我们在编写代码的时候,由于粗心大意而造成错误。举例来说,container _of(ptr, type, member)有三个参数,如果传入ptr的时候,我们由于粗心大意,将一个错误的ptr指针传入,发现程序可能会正常运行,但是结果是错误的。这个时候为了增加代码的安全性,为了能够有一点点的类型安全的检查,所以内核的设计者们在定义container _of的时候,在定义的第二行添加了一行用于类型安全检查的代码,它会在你传入错误的指针时,弹出一个警告,这个警告告诉我们,在这个地方存在着类型不兼容的情况,这样我们在运行之前就可以再次去检查一下参数,从而避免一次BUG。 结语 至此,我们已经清楚的知道了container_of的作用了。现在我们回到最初的出发点———list _entry(),也就明白了为什么它被称作内核第一宏了。

原文发布于微信公众号 - Linux阅码场(LinuxDev)

原文发表时间:2019-09-02

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券