详解 container_of 宏
1911
2022.01.09
2022.01.12
发布于 未知归属地

(container_of这个宏在内核中非常重要。
并且在 linux内核代码 里面使用频率非常的高。)

container_of 作用

通过结构体成员变量地址获取这个结构体的地址。


这就带来了我们的第一个问题:

Q:为什么这个宏有很重要,它有什么作用?

A:Linux 内核主要是由大量C语言实现的,而面对庞大的 Linux内核, C语言虽然没有“语言”级的 抽象 概念。

但是在实际的逻辑层面,Linux 内核的C代码也是使用了 面向对象的思想 在做编程,也就是使用到大量的 分层/抽象/封装 等。而这样做的目的就是,可以让我们的内核 兼容性 更好,适配更多的功能和设备。

具体到Linux的内核代码,就是对数据结构体进行了多次封装,往往一个结构体里面嵌套多层结构体,来达到 分层/抽象/封装
也就是说,在 Linux内核代码 中不同层次的 子系统或模块,使用的是不同封装程度的结构体,这也是 C 语言的面向对象思想。(关注词:结构体)

分层/抽象/封装 的好处显而易见,但这样做的同时也增加了代码的复杂度
(分层/抽象/封装的好处:将复杂的东西通过 分层/抽象/封装 来简化,使得不熟悉的人和很久没使用后的自己能够很快的熟悉操作,使得一件完整的事情在操作中可以按层级推进、减少出错,以及快速定位责任归属。)

理解上面这些,问题的答案就很好理解了。
在Linux内核开发中,经常会有某个函数的 入参 是某个结构体的成员变量
然而在这个函数中,可能还会用到此结构体的 其它成员变量,那这个时候怎么办?
这就要用到 container_of 。通过它,我们可以首先找到结构体的首地址,然后再通过结构体指针来访问该结构其它成员变量了。
换句话说,我们进到了 分层/抽象/封装 的内部了后,我们需要一个介质(container_of)来帮我们 “” 到 分层/抽象/封装 的起点来使用到 分层/抽象/封装 的“好处”,因为我们在内部是没办法使用到。
(有点像先知道了二叉树的一个子节点,想访问另一个子节点,那一定只能通过父节点来访问,那么第一步就是先“”到父节点)

明白了这些我们仔细来拆分一下这个宏是怎么做到的。


0 container_of 内核源代码

先看一下源代码:

Linux kernel continer_of宏

#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)

#define container_of(ptr, type, member) ({			\
	const typeof(((type *)0)->member) * __mptr = (ptr);	\
	(type *)((char *)__mptr - offsetof(type, member)); })

我们可以把 container_of 拆分成如果下知识点的组合:

container_of = ({}) + const + typeof + (type *)0 + (char *) + offsetof + 结构体数据对齐

image.png

接下来我们一个一个的讲解。


1 ({}) 表达式

先看一下在 C/C++语言中的应用场景:

  • 执行顺序:({exp1; exp2; exp3}) 由({}) 包裹 了3个表达式;
    这3个表达式,会按 从左到右 的顺序来执行。
  • 返回值:i = ({exp1; exp2; exp3}) 按从左到右的顺序执行到 最后一个 表达式,({})返回 最后一个表达式的结果。

如:

printf("test=%d\n", ({int i = 1; ++i; i += 3;}) );

输出:
test=5

一个小问题:
如代码是这样,最终输出什么?

void test(void) {
    printf("test ({})\n");
}

printf("test=%d\n", ({int i = 1; ++i; test();}) );

2 const

使用 const 前缀声明指定类型的 常量(固定值,在程序执行期间不会改变),如下所示:

const type variable = value;
C语言样例
const     int     var    =    0;
  ^        ^       ^          ^
  |        |       |          |
关键字   数据类型   变量名      变量值

3 typeof

提到typeof,就不得不提一下 GUN C、 ANSI C 这2个标准。

  • GNU C标准:是linux 自己制作了一个C语言标准。所以一般 GUN c 一般只用于 Linux 下的应用。
  • ANSI C标准:是 ANSI 美国国家标准协会对C做的标准。而后来 ANSI C标准 被国际标准协会接收成为 标准C。所以 ANSI C 和 标准C 是一个概念。

在 linux 中也支持 标准C 的,因为 标准C 是可以跨平台;返过来 GUN C 不具有通用的跨平台性。

而其中 typeof 就是 GUN C 中的标准之一,它的作用是:

用来获取一个变量或表达式的类型。也就是 typeof 的参数有两种形式:表达式 或 类型。

如:

GUN C typeof演示
int a = 1;
typeof(a) b = 2; // 根据 a 的类型,来定义 b 的类型
// 等价于 int b = 2;
int fun(void);
typeof(f()) c = 3; // 根据 fun函数的返回值类型,来定义 c 的类型
// 等价于 int c = 3;

使用 typeof 带来的好处就是,我们不用通过 “人工” 返查代码来知道某个变量的类型来定义,新变量的类型。(特别是那种指针指来指去,返查的时候,很容易被看 “飞”)
直接使用 typeof 来告诉编译器,“我要使用某个变量的类型来定义新的变量,你去给我查一下,这个变量的类型是啥?”。
也就是把工作量 转移 到了编译器。


4 (type *)0

首先明确一下,type 是通过 container_of 传进来的一个 类型

那么 “(type *)0” ---扩展成---> “(struct xx *) 0”
作用就是:强制类型转换,将 0 转换为 (type *) 类型。
用于求结构体 成员变量偏移量

Q:为什么是 0 ?
A:这里使用 0,就是人为设置了一个 “起点”,方便人为可控。(也就是参照物)
(先知道它的作用,后面配合 结构体 的知识再详细展开讲)


5 (char *)

这玩意儿不就是 强制类型转换 么?

是的,这个是强制类型转换,但是它还有一个作用:

设置 运算单位

啥意思?
char 类型占 1个字节,那么 char * 指针指向的类型也就是 一个字节。 指针做 “+1 -1” 等数学操作的单位就是 1个字节 移动
short 类型占 2个字节,那么 short * 指针指向的类型也就是 二个字节。 指针做 “+1 -1” 等数学操作的单位就是 2个字节 移动
int 类型占 4个字节,那么 int * 指针指向的类型也就是 四个字节。 指针做 “+1 -1” 等数学操作的单位就是 4个字节 移动

(这个需要和后面结构体知识一起使用,先知道一下它的作用)


6 offsetof

这又是一宏,会用我们上面说到的知识点,定义如下:

offsetof 宏
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)

offsetof 宏的作用:

用于获取结构体内某个变量偏移长度

我们可以把 offsetof 分成三部分来理解

  1. (size_t):强制转换成 size_t 类型,而 size_t 是 unsigned int/long 的别名(根据不同体系结构,会不同)。也就是说 (size_t) 的作用就是把变量 强制转换 成一个正整数
// size_t 的定义在: /include/linux/types.h
typedef __kernel_size_t		size_t;

// __kernel_size_t 定义在 xxx/include/xxx/posix_types.h
// 不同的体系 32 / 64 会有不同
typedef unsigned long          __kernel_size_t;
typedef unsigned int           __kernel_size_t;
  1. & :取地址。 结合 (size_t) & 也就是把一个地址强制转换成一个 正整数
  2. (TYPE *)0)->MEMBER : 前面已经提到过,以 0 为“起点”做指针,返回结构体内的变量。

把这三部分结合起来理解就是:

以 0 来制成一个TYPE结构体指针,并访问成员 MEMBER;
& 获取成员 MEMBER 的地址;该地址大小在数值上就是 MEMBER 在结构体 TYPE 中的 偏移量
再通过 强制转换 来把偏移量转化成 数字,用于 数字计算


小结1

明白了上面这些小的知识点,我们整体来看一下,就比较好理解了

C continer_of
// ptr: 结构体中 member 的指针(地址)
// type: 表示结构体类型 struct xxx
// member: 表示结构体 struct xxx 中的成员
// 返回结构体的首地址
#define container_of(ptr, type, member) ({			\ // 宏声明
    // 把 ptr 指针转换成一个 人为可控 类型相同的指针
	const typeof(((type *)0)->member) * __mptr = (ptr);	\ 
    // 用新定义的 指针地址 减去 member变量 在结构体的 偏移量 ,来获取结构体的首地址
	(type *)((char *)__mptr - offsetof(type, member)); })

(注:该代码是没有对 member 是否存在做判断;可以试一下,传一个不存在的变量会怎么样)

continer_of宏,是由多个知识点组合而成的复合宏结构。
本质是通过 指针 的回溯移动来找出想要的 (指针)。
而 移动 的依据,是来自 结构的定义(静态绑定关系)。

到这,continer_of 的知识已经基本讲完。
(以下内容比较基础,大家选择性观看)


7 结构的定义

我看基本上讲 container_of 的文章都没有提到这点。如果关于这点有什么不对,不好的地方,欢迎大家留言讨论。

该知识点可以说是整体 container_of 功能的基座,没有这个理论的基础是不可能会有 container_of。

指针代表的是地址,那在结构体又是怎么通过 -> 符知道确切的地址啦?

是来自结构体,对所有变量的内存占用大小,默认的约定。啥意思?

先看一份代码:

C 结构大小查看
#include <stdio.h>

struct X {
    char a;
    int b;
    char c;
};

struct Y {
    char a;
    char c;
    int b;
};

int main() {
    printf("X=%ld\n", sizeof(struct X));
    printf("Y=%ld\n", sizeof(struct Y));
    return 0;
}

打印结果:

X=12
Y=8

两个问题:

  1. 为什么相同的结构体,只是变量的顺序变了,它的内存占用大小也就变了?
  2. 而 sizeof 为什么知道他们大小?

两个问题的原因是一个,也就是 结构体自然对齐
(关于为什么会有结构体对齐的问题,不在本次讨论范围。简单说一下是为了提高CPU的运行效率,空间换时间。如果有感觉兴趣的朋友可以深入了解一下)

7.1 结构对齐

结构体(复合数据结构)对齐是计算机对要处理的数据 排列 的约定。通过这个约定,我们可以快速的定位到结构体内每一个变量的 位置(也就是地址)。

如:

相对地址
struct X { // 定义 struct X 的起始地址是 0 的话
    char a; // a 的地址就是 0x00000000
    int b; // 因为结构对齐的原因, b 的地址就是 0x00000004
    char c; // c 的地址就是 0x00000008
};

通过 结构对齐 ,我们是可以知道 c 到 a 的 相对 地址距离 是 8;
而我们手上是有 c 的 绝对地址,那么把 c 的绝对地址 移动 相对结构体头的 距离,就能得到 结构体头 的绝对地址。

   X->c      -        c->a        =         X
    |c的绝对地址    -    c与a的相对地址    =  结构体X的绝对首地址

一个小问题,为什么可以通过 减法 就可以算出结构体头的绝对地址?
因为绝对地址,本质是由多个 相对地址 叠加的结果。


感谢阅读

评论 (8)