Linux 内核预备知识:浅析 offsetof 宏以及新手的所思所想

时间:2022-03-26 09:50:14

最近一头扎进了 Linux 内核的学习中,对于我这样一个没什么 C 语言基础的新生代 Java 农民工来说实在太痛苦了。Linux 内核的学习,需要的基础知识太多太多了:C 语言、汇编语言、数据结构与算法、操作系统原理、计算机组成原理、计算机体系结构。在囫囵吞枣补完一些计算机基础知识后,还是在一开始就被一个小小的 offsetof 宏搞晕了。

offsetof 宏

先来看看offsetof宏是什么,这是定义在 <linux/stddef.h>中的一个宏,用来计算一个 struct 结构体中某个成员相对于结构体首地址的偏移量。这是一个很有用的宏,因为 Linux 内核的数据结构大量用了嵌入式的结构体(什么是嵌入式结构体,可以参考 <linux/list.h> 的巧妙设计,这个以后再讲)。

// offsetof 宏的定义
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)

当看到这个东西完全傻眼了,size_t 是啥东东,((TYPE *)0) 又是啥东东,这个 0 又是什么鬼?特别看到后面是访问一个成员,我去,这不是 Java Farmer 眼中的 NPE 吗?因为这个宏展开后没见任何一个结构体的实例。-_-! 于是上网搜索一番。

size_t 基本知道了就是代表一个整数类型,只是为了程序的可移植、效率等原因定义成这样,具体解释可以看《为什么size_t重要?》这篇文章。

至于 &((TYPE *)0)->MEMBER) 这段代码,简单来说就是取 TYPE 类型的结构体里名字为 MEMBER 的成员的地址,是相对 0 的地址(0 就是 TYPE 结构体的首地址)。C 语言里指针就是个无符号整数,所有 0 也可以转成一个 TYPE 类型的指针,那么不写 0 行吗?答案是肯定的,但算偏移量需要后面再减去首地址值,例如((size_t) (&((TYPE *)1000)->MEMBER)-1000),这样也行,但是,这就有点多此一举了。

另外,很重要的一点:这样算偏移地址仅仅是从逻辑计算上来写计算的表达式,实际上程序运行时是不会发生任何计算,而是编译器直接就能取到这个地址偏移量,因而也不会有任何的访存操作。下面从一个例子可以证明:

1、先写个 C 测试程序

#include <stdio.h>

// 定义一个取偏移量的宏
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER) // 定义一个结构体
struct my_struct {
int a,b,c;
struct my_struct *next; // 后面我要算这个成员的偏移量
}; // main 函数很简单就是输出偏移量的值
void main() {
printf("offsetof next=%d\n", offsetof(struct my_struct, next));
}

编译,运行,最后输出的结果是:offsetof next=16,为什么是16?next 前面有三个 int 类型的成员,各占 4 字节,那 next 应该是从 12 开始,其实这要看编译的是 64 位还是 32 位,因为笔者的机器是 64 位的 Redhat,而 gcc 编译选项没加 -m32,所以编译出来的程序自然是 64 位的了,因此 next 指针是 8 个字节,要 8 字节对齐的话,自然不能从 12 开始,要从 16 开始。整个结构体的长度是 24 字节(即 sizeof(struct my_struct) = 24)。

2、第二部再将上面的 C 代码编译成汇编看看,指令是怎么执行的

/**
* 以 . 开头的行我们不用管它,都是些编译器生成的东西,只看汇编指令即可
**/
.file "mymain.c"
.section .rodata
.LC0:
.string "offsetof next=%d\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $16, %esi /* printf 的第二个参数,看,这里没有任何计算,编译时就知道偏移量是 16,直接存到 esi 寄存器作为 printf 函数的实参 */
movl $.LC0, %edi /* printf 的第一个参数,就是上面的字符串常量 */
movl $0, %eax
call printf /* 调用 printf 函数,要说明的是,在 x86-64 结构体系中,有 6 个寄存器是可以用于传参的(这里用了 esi 和 edi),多于 6 的其余就压栈,也就是上面 rsp 所指的栈顶 */
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
.section .note.GNU-stack,"",@progbits

好,到这里,从上面汇编指令可以看到,offsetof 宏展开后就是一个 16 这个值,编译器直接就优化算好了,所有汇编指令仅仅是为了调用 printf 函数所做的压栈保护现场,传参,弹栈恢复现场这些指令。当然,上面说的是在 x86 结构体系下的指令结构。

小结

Linux 是一个非常庞大的系统,几乎涵盖了所有计算机基础知识。学习 Linux 内核是非常艰巨的,不但需要非常牢固的计算机基础,还需要想象力,大局观。学习了一个月,总结几点经验:

1、基础知识要时时温习,“温故知新”。每次看都会有不同的感悟和理解。像这篇学习笔记就是对基础知识的温故,从汇编指令角度看编译器对宏展开做的工作。永远不要相信网上一些什么视频教程说的不需要什么基础,学习 Linux 内核需要的基础知识太多了,而且这些视频也不必要浪费时间看,浪费钱买,都是一些二手知识;也永远不要相信什么“一文读懂xxx”这类的文章,同样是一些二手知识,是不是发现看这些文章很容易就忘了?掌握知识从来没有捷径。

基础、基础,基础才是最重要的,计算机技术发展了这么多年,以及近些年来火起来的什么大数据,AI,其实都不是什么新东西,本质还是那些计算机基础知识原理和数学

  基础知识脱节,没可能入门 Linux 内核,不要说入门,入窗户都不可能。所以想学 Linux 内核,从基础知识开始,无论基础有多差,只要肯下功夫,不成问题,这些基础知识包括:

  • 计算机组成原理:站在抽象的层次理解计算机的工作原理,CPU 如何取指执行(这个可以说是现代计算机工作的本质),内存如何工作,高速缓存如何工作,中断的原理,外设如何协同并行工作等等;

  • C 语言:这个不用说了,肯定最重要的,C 语言玩得溜,可以省大量时间;

  • 数据结构及算法:Linux 里可以说是各种数据结构和算法的大杂烩,你能想到的里面都有,同样这个玩得溜,可以省大量时间;

  • 汇编语言(计算机体系结构):汇编其实很简单,没什么好学的,这是要与某一个结构体系紧密结合(基本都 x86 最熟吧),不用强记(记也记不住),只要混个脸熟就好,需要用的时候查手册即可,主要是结构体系的原理,高速缓存、缓存一致,流水线原理;

  • 操作系统原理:理论指导实践,有了理论,才容易形成蓝图。而学习 Linux 内核只是实践。

2、大局观,抓主线,虽然 Linux 内核代码将近 800MB,其实大部分不怎么需要看。网上很多教程,其实都不怎么好,要么泛泛而谈,要么讲些过时的(很多将0.11版的内核,个人觉得没啥价值,纯属浪费时间),要么一下子就从某一结构体系讲起,初学者很容易被绕晕,还有些直接就从怎么自己写一个操作系统开始,我们要学的是 Linux 内核,一开始讲这些个人觉得没学会走路就学飞;不可否认,讲这些教程的人也许很牛,但个人认为不是一个好老师。所以:

  • 我们学 Linux 的目的是什么,不同的人有不同的需求,像 Java 过来的新生代农民工,应该着重学习 Linux 内核的设计哲学,例如 kernel 是如何能像我们 Java 面向对象一样,与各种结构体系(arch)完美适配的,设计的哲学,这些都是网上那些视频没讲的。再进一步就是细致到进程管理、内存管理、磁盘这些怎么管理,学会这些,那些老喜欢被问的什么 kafka 原理啊、零拷贝啊这些简直就是小菜。作为 Javaer,工作的环境就是 Linux 内核,因此,Linux 太重要了,能学多深就学多深。

  • 要多想象,根据上面的基础知识,想象,爱因斯坦也说过,想象力比知识跟重要。所以,我们在学习 Linux 内核时要多想象,猜测,带着问题去学,验证;

  • Linux 是一个巨复杂的系统,Javaer 更应该学习的是如何应对复杂系统的方法;

  • 上面三点个人才觉得是一个工程师最有价值的地方,这些工程师才是工匠。

3、多动手,搭建环境学习源码,多编写代码验证,特别是从 Java 转过来的。“纸上得来终觉浅,绝知此事要躬行”。

4、由于笔者也是刚刚才开始学 Linux 内核不久,水平有限,有不正确的地方多多交流,不胜感激。