Linux源码补充
参考文章
需要多久看完Linux内核源码?
内核子系统
什么是内核
在计算机科学中是一个用来管理软件发出的数据I/O(输入与输出)要求的计算机程序,将这些要求转译为数据处理的指令并交由中央处理器(CPU)及计算机中其他电子组件进行处理,是现代操作系统中最基本的部分。
它是为众多应用程序提供对计算机硬件的安全访问的一部分软件,这种访问是有限的,并由内核决定一个程序在什么时候对某部分硬件操作多长时间。
linux内核代码涉及知识点包括汇编指令、c语言、硬件组成原理、操作系统、数据结构和算法、各种外设总线、驱动、网络协议栈。
直接对硬件操作是非常复杂的。所以内核通常提供一种硬件抽象的方法,来完成这些操作。
通过进程间通信机制及系统调用,应用进程可间接控制所需的硬件资源(特别是处理器及IO设备)。
最上面是用户(或应用程序)空间。这是用户应用程序执行的地方。用户空间之下是内核空间,Linux 内核正是位于这里。
GNU C Library (glibc)也在这里。它提供了连接内核的系统调用接口,还提供了在用户空间应用程序和内核之间进行转换的机制。
内核和用户空间的应用程序使用的是不同的保护地址空间。
每个用户空间的进程都使用自己的虚拟地址空间,而内核则占用单独的地址空间。
Linux 内核可以进一步划分成 3 层。最上面是系统调用接口,它实现了一些基本的功能,例如 read 和 write。
系统调用接口之下是内核代码,可以更精确地定义为独立于体系结构的内核代码。这些代码是 Linux 所支持的所有处理器体系结构所通用的。
在这些代码之下是依赖于体系结构的代码,构成了通常称为 BSP(Board Support Package)的部分。这些代码用作给定体系结构的处理器和特定于平台的代码。
内核主要系统包括:
SCI:系统调用接口
PM:进程管理
VFS:虚拟文件系统
MM:内存 管理
Network Stack:内核协议栈
Arch:体系架构
DD:设备驱动
1.系统调用接口
SCI 层提供了某些机制执行从用户空间到内核的函数调用。这个接口依赖于体系结构,甚至在相同的处理器家族内也是如此。
SCI 实际上是一个非常有用的函数调用多路复用和多路分解服务。
在 ./linux/kernel 中可以找到 SCI 的实现,并在 ./linux/arch 中找到依赖于体系结构的部分。
2.进程管理
进程管理的重点是进程的执行。
在内核中,这些进程称为线程,代表了单独的处理器虚拟化(线程代码、数据、堆栈和 CPU 寄存器)。
在用户空间,通常使用进程 这个术语,不过 Linux 实现并没有区分这两个概念(进程和线程)。
内核通过 SCI 提供了一个应用程序编程接口(API)来创建一个新进程(fork、exec 或 Portable Operating System Interface [POSIX] 函数),停止进程(kill、exit),并在它们之间进行通信和同步(signal 或者 POSIX 机制)。
3.内存管理
内核所管理的另外一个重要资源是内存。为了提高效率,如果由硬件管理虚拟内存,内存是按照所谓的内存页方式进行管理的(对于大部分体系结构来说都是 4KB)。
Linux 包括了管理可用内存的方式,以及物理和虚拟映射所使用的硬件机制。
4.虚拟文件系统
虚拟文件系统(VFS)是 Linux 内核中非常有用的一个方面,因为它为文件系统提供了一个通用的接口抽象。VFS 在 SCI 和内核所支持的文件系统之间提供了一个交换层。
在 VFS 上面,是对诸如 open、close、read 和 write 之类的函数的一个通用 API 抽象。在 VFS 下面是文件系统抽象,它定义了上层函数的实现方式。
它们是给定文件系统(超过 50 个)的插件。文件系统的源代码可以在 ./linux/fs 中找到。
文件系统层之下是缓冲区缓存,它为文件系统层提供了一个通用函数集(与具体文件系统无关)。
这个缓存层通过将数据保留一段时间(或者随即预先读取数据以便在需要是就可用)优化了对物理设备的访问。缓冲区缓存之下是设备驱动程序 ,它实现了特定物理设备的接口。
5.网络堆栈
网络堆栈在设计上遵循模拟协议本身的分层体系结构。
回想一下,Internet Protocol (IP) 是传输协议(通常称为传输控制协议或 TCP)下面的核心网络层协议。TCP 上面是 socket 层,它是通过 SCI 进行调用的。
socket 层是网络子系统的标准 API,它为各种网络协议提供了一个用户接口。
从原始帧访问到 IP 协议数据单元(PDU),再到 TCP 和 User Datagram Protocol (UDP),socket 层提供了一种标准化的方法来管理连接,并在各个终点之间移动数据。内核中网络源代码可以在 ./linux/net 中找到。
6.设备驱动程序
Linux 内核中有大量代码都在设备驱动程序中,它们能够运转特定的硬件设备。
Linux 源码树提供了一个驱动程序子目录,这个目录又进一步划分为各种支持设备,例如 Bluetooth、I2C、serial 等。设备驱动程序的代码可以在 ./linux/drivers 中找到。
下面这个图形象的讲解了Linux内核都有哪些东西!
__init的含义
__init的本质是一个链接脚本宏,把函数放进一个名为.init.text的特殊 ELF 段。定义在include/linux/init.h:49:
#define __init __section(.init.text) __cold notrace ...它的作用:内核启动时,所有标记__init的函数只执行一次。启动完成后,内核会把.init.text和.init.data所在的整个内存区域释放掉(free),归还给系统。
为什么需要这个机制?
嵌入式或普通 PC 内核的可用内存是宝贵的。spi_init这类初始化函数只在内核启动阶段调用一次,之后永远不会再用到。如果让它的代码长期占据内存就是浪费。__init让内核能这样回收:
启动时: ├── 调用所有 __init 函数(包括 spi_init) ├── ... └── 启动完成 → free_initmem() 把 .init.text 整块释放释放的页会回到系统的伙伴分配器(buddy allocator),变成可用内存。
同类标记
init.h里定义了一整套:
| 标记 | 放入段 | 用途 |
|---|---|---|
__init | .init.text | 初始化函数代码 |
__initdata | .init.data | 初始化阶段用的全局变量 |
__initconst | .init.rodata | 初始化阶段用的常量 |
__exit | .exit.text | 模块卸载函数(仅模块编译时生效) |
比如spi_init里如果有一个静态数组只在初始化时用,可以标记为__initdata,这样它也会跟着一起被回收。
在代码中的体现
static int __init spi_init(void) ← 函数体在 .init.text { ... } postcore_initcall(spi_init); ← spi_init 的指针也被放入 .init 段注意postcore_initcall(spi_init)宏会把spi_init的函数指针放进另一个 init 段(.initcall.postcore.init),内核启动时会按等级顺序遍历这个段里的所有函数指针并调用它们。调用完spi_init后,整个.init.text+.initcall.postcore.init段都会被回收。
所以本质上:__init告诉编译器和链接器:这个函数是一次性的,用完就扔。
postcore_initcall的机制
它展开成什么
postcore_initcall(spi_init)宏在include/linux/init.h:191定义:
#define postcore_initcall(fn) __define_initcall(fn, 2)而__define_initcall在第 169 行:
#define __define_initcall(fn, id) \ static initcall_t __initcall_##fn##id __used \ __attribute__((__section__(".initcall" #id ".init"))) = fn;所以postcore_initcall(spi_init)展开后就是:
static initcall_t __initcall_spi_init2 __used __attribute__((__section__(".initcall2.init"))) = spi_init;意思:它定义了一个函数指针,指向spi_init,然后把这个指针放进一个叫.initcall2.init的 ELF 段。
内核怎么调用它
init/main.c:810定义了一张表,把每个等级的起始地址按顺序排列:
static initcall_t *initcall_levels[] __initdata = { __initcall0_start, // level 0: early __initcall1_start, // level 1: core __initcall2_start, // level 2: postcore ← spi_init 在这里 __initcall3_start, // level 3: arch __initcall4_start, // level 4: subsys __initcall5_start, // level 5: fs __initcall6_start, // level 6: device __initcall7_start, // level 7: late __initcall_end, };启动时调用链:start_kernel → ... → do_basic_setup → do_initcalls → do_initcall_level(level) → do_one_initcall(fn)。
do_initcalls就是一个逐级遍历的循环:
static void __init do_initcalls(void) { int level; for (level = 0; level < ARRAY_SIZE(initcall_levels) - 1; level++) do_initcall_level(level); }level 2 轮到的时候,就会从__initcall2_start到__initcall3_start之间取出所有函数指针依次执行。spi_init的指针就在这片地址区间里。
完整的 initcall 等级顺序
| 等级 | 宏名称 | 数值 | 典型用途 |
|---|---|---|---|
| 0 | early_initcall | — | SMP 初始化之前 |
| 1 | core_initcall | 1 | 核心子系统 |
| 2 | postcore_initcall | 2 | 依赖核心但早于 arch 的子系统,如 SPI、I2C 总线 |
| 3 | arch_initcall | 3 | 架构相关的初始化 |
| 4 | subsys_initcall | 4 | 子系统 |
| 5 | fs_initcall | 5 | 文件系统 |
| 6 | device_initcall | 6 | 设备驱动(module_init默认是这个等级) |
| 7 | late_initcall | 7 | 最后阶段 |
为什么 SPI 要选 postcore?
注释里写得清楚:
/* board_info is normally registered in arch_initcall(), * but even essential drivers wait till later * * REVISIT only boardinfo really needs static linking. the rest (device and * driver registration) _could_ be dynamically linked (modular) ... */ postcore_initcall(spi_init);- SPI 总线必须在 SPI 控制器驱动之前就绪。控制器驱动通常是
module_init(level 6),而 SPI 总线在 level 2 就注册好了,比它们早得多。 - 同样,board info 一般在
arch_initcall(level 3)注册,也需要 SPI 总线先准备好。
所以选 level 2 是刚好早于所有可能用到 SPI 的代码,又不会太提前影响启动顺序——这是一个"刚刚好"的位置。
一句话总结
postcore_initcall(spi_init)是把spi_init注册到内核启动的第 2 级初始化序列中,让它在其他更晚的子系统(如文件系统、设备驱动)启动之前先被执行,执行完后整个.init段内存被释放回收。
宏 (C 层面)vs 链接脚本标记——分工关系
宏(C 层面):把函数放到指定段
#define __init __section(.init.text)__init是一个编译器属性(__attribute__((__section__))),它告诉编译器:spi_init的机器码不要放在默认的.text段,放到.init.text段。
同理postcore_initcall(spi_init)展开后:
static initcall_t __initcall_spi_init2 __attribute__((__section__(".initcall2.init"))) = spi_init;它告诉编译器:这个存了spi_init函数地址的指针变量,不要放在默认的.data段,放到.initcall2.init段。
宏的工作到此为止——它们只负责在编译产物(.o文件)里贴上"我属于哪个段"的标签。至于这些段最终怎么排布、边界在哪里,宏不关心。
链接脚本(ld 层面):把所有同段名的内容收集起来
你的内核里include/asm-generic/vmlinux.lds.h定义了布局规则,关键片段:
// .initcall2.init 段布局规则 #define INIT_CALLS_LEVEL(2) __initcall2_start = .; // ← 定义起始符号 KEEP(*(.initcall2.init)) // ← 收集所有叫 .initcall2.init 的东西 KEEP(*(.initcall2s.init))展开到链接脚本中就是:
.init.data : { ... __initcall_start = .; KEEP(*(.initcallearly.init)) __initcall0_start = .; KEEP(*(.initcall0.init) *(.initcall0s.init)) __initcall1_start = .; KEEP(*(.initcall1.init) *(.initcall1s.init)) __initcall2_start = .; KEEP(*(.initcall2.init) *(.initcall2s.init)) ← spi_init 的指针在这里 __initcall3_start = .; KEEP(*(.initcall3.init) *(.initcall3s.init)) ... __initcall_end = .; }链接脚本的工作:把所有.o文件中标记为.initcall2.init的碎片收集起来,在最终的vmlinux里排成连续的一块,并在开头和结尾生成__initcall2_start/__initcall3_start这样的符号。
对比直观表
C源码宏(__init/postcore_initcall) | 链接脚本宏(INIT_CALLS/KEEP) | |
|---|---|---|
| 层面 | C 源码,预处理/编译阶段 | 链接脚本(.lds),链接阶段 |
| 做什么 | 把函数或变量放到指定的 ELF 段 | 把所有同名的 ELF 段碎片收集、拼接、定义边界符号 |
| 产出 | .o文件里带[.initcall2.init]标签的 4 字节指针 | vmlinux里连续排列的函数指针数组 + 可引用的起始/结束地址 |
| 缺少它会怎样 | 函数/指针放进默认段,不会被特殊回收 | 虽然放对了段,但链接器不知道要把它们挨在一起,也没有边界符号,内核找不到它们 |
两步合在一起就是完整的 initcall 机制
编译时(宏) 链接时(链接脚本) 运行时(init/main.c) spi_init() 的代码 → 集中到 .init.text → do_initcalls() 遍历 ↓ ↓ ↓ __attribute__((.init.text)) _sinittext ... _einittext free_initmem() 释放整块 __initcall_spi_init2 → 集中到 .initcall2.init → do_initcall_level(2) ↓ ↓ ↓ __attribute__((.initcall2)) __initcall2_start ... for (fn = level[2]; fn < level[3]; fn++) __initcall3_start do_one_initcall(*fn)一句话总结
宏是源头标签——编译时给代码贴上"我属于哪个段";链接脚本是收集器——链接时把贴了同标签的所有碎片拼成连续数组,并生成边界符号让内核能在运行时遍历它。缺了前者东西放不对缺,少了后者东西放对了但找不到。
spi_init本身 vsspi_init的指针
这是两样东西,放在两个不同的段里。
第一样:spi_init的函数体(代码)
static int __init spi_init(void) ← __init 修饰的是这个 { ... }__init展开为__section(.init.text)- 所以
spi_init的机器码(几十条 CPU 指令)被放进了.init.text段 - 这是函数本体,不是指针
第二样:指向spi_init的指针变量
postcore_initcall(spi_init);展开后变成:
static initcall_t __initcall_spi_init2 __used __attribute__((__section__(".initcall2.init"))) = spi_init;initcall_t的类型是int (*)(void)——一个函数指针。所以这里定义了一个变量__initcall_spi_init2,它的值就是spi_init函数的入口地址。
这个指针变量本身被放进了.initcall2.init段,跟spi_init函数体所在的.init.text段完全不同。
直观对比
内存布局示意: .init.text 段: .initcall2.init 段: ┌─────────────────────┐ ┌──────────────────────────┐ │ spi_init 函数体 │ │ __initcall_spi_init2 │ │ push %rbp │ │ .quad 0xffffffff81001234 │ ← 存的是左边 spi_init 的地址 │ mov %rsp, %rbp │ │ │ │ ... │ │ __initcall_xxx2 │ │ call bus_register │ │ .quad 0xffffffff8100abcd │ │ ... │ │ __initcall_yyy2 │ │ ret │ │ .quad 0xffffffff8100ef01 │ └─────────────────────┘ └──────────────────────────┘运行时do_initcall_level(2)做的事情大致是:
// ptr 从 __initcall2_start 开始,遍历 .initcall2.init 段里的每一个指针 initcall_t fn = __initcall_spi_init2; // fn = 0xffffffff81001234 // 然后通过这个指针跳过去执行真正的函数 fn(); // → 实际执行的是 0xffffffff81001234 处的 spi_init回答"spi_init为啥就是指针了"
因为postcore_initcall需要把spi_init放进一个函数指针的数组里,这样内核才能统一地、按顺序地遍历并调用所有 init 函数。如果把函数体直接复制到数组里,体积巨大且没法调用。所以它只存地址(4 或 8 字节),函数体还留在.init.text的专属位置。
两者各自独立,但运行时通过指针跳转才连起来。
函数名在表达式中的行为
在绝大多数上下文中,函数名会被隐式转换为指向它自己的指针。
看postcore_initcall(spi_init)展开后的结果:
// initcall_t 是一个函数指针类型 typedef int (*initcall_t)(void); // 展开后的定义 static initcall_t __initcall_spi_init2 __used __attribute__((__section__(".initcall2.init"))) = spi_init; // ← 这里 spi_init 是函数名,被当做函数指针用这里spi_init出现在赋值号右边,类型会自动从int (void)(函数类型)转为int (*)(void)(函数指针类型)。所以下面两种写法等价:
initcall_t fp = spi_init; // 隐式转换——函数名 → 函数指针 initcall_t fp = &spi_init; // 显式取地址——结果也是函数指针,值相同函数调用本质上也是通过指针
spi_init(); // 编译器看到函数名 + (),通过函数指针跳转 (&spi_init)(); // 同样效果理解宏定义#define __define_initcall(fn, id) \ static initcall_t __initcall_##fn##id __used \ __attribute__((__section__(".initcall" #id ".init"))) = fn;
这个宏是整套 initcall 机制的枢纽。一行行拆开看。
先看整体宏定义
#define __define_initcall(fn, id) \ static initcall_t __initcall_##fn##id __used \ __attribute__((__section__(".initcall" #id ".init"))) = fn;两行,四个部分。
第一部分:static initcall_t
static initcall_tinitcall_t是typedef int (*initcall_t)(void)——函数指针类型static表示这个变量只在本文档内可见,不会与其他编译单元冲突
第二部分:__initcall_##fn##id
##是 C 预处理的标记粘贴(token concatenation)操作符。
假如调用__define_initcall(spi_init, 2):
__initcall_ ## spi_init ## 2 ↓ __initcall_spi_init2用##把三段拼成一个合法的 C 标识符作为变量名。如果同一个 fn 用不同 id 调用,就会生成不同的变量名,不会冲突。这里的 fn 就是函数名,id 是数字——两者都是编译器输入的常量,不会重合。
第三部分:__attribute__((__section__(".initcall" #id ".init")))
#id是字符串化(stringizing)操作符。
假如 id = 2:
".initcall" #2 ".init" ↓ ".initcall" "2" ".init" ↓ (相邻字符串字面量自动拼接) ".initcall2.init"__attribute__是 GCC 编译器提供的一种给编译器下指令的语法。它不生成任何代码,而是告诉编译器"对这个东西做特殊处理"。
基本用法
__attribute__(( 某种属性 ))夹在声明中间,修饰函数、变量或类型。
更多常见的__attribute__
| 属性 | 含义 |
|---|---|
__attribute__((packed)) | 结构体不要填充字节对齐,紧凑排列 |
__attribute__((aligned(64))) | 变量或结构体按 64 字节对齐 |
__attribute__((unused)) | 即使没用到也不 warning |
__attribute__((section("xxx"))) | 放到指定段 |
__attribute__((cold)) | 告诉编译器这个函数很少执行,优化时不用关心它的速度 |
__attribute__((noreturn)) | 告诉编译器这个函数不会返回(如panic) |
第四部分:= fn;
初始值,把函数指针变量指向fn。
所以整句展开结果:
// 定义一个函数指针变量,名字叫 __initcall_spi_init2, // 把它放在 .initcall2.init 段中,初值指向 spi_init 的地址 static initcall_t __initcall_spi_init2 __used __attribute__((__section__(".initcall2.init"))) = spi_init;把这些碎片连起来
当你写: postcore_initcall(spi_init) ↓ 展开为 __define_initcall(spi_init, 2) ↓ 展开为 static initcall_t __initcall_spi_init2 __used __attribute__((__section__(".initcall2.init"))) = spi_init; ↓ 编译后 .o 文件里多了一个 4/8 字节的变量, 被标记为 "属于 .initcall2.init 段", 值为 0xffffffff81001234(spi_init 的地址) ↓ 链接时 链接脚本把所有 .o 的 .initcall2.init 段碎片 收集成一个连续数组,首尾生成 __initcall2_start / __initcall3_start ↓ 运行时 for (fn = __initcall2_start; fn < __initcall3_start; fn++) do_one_initcall(*fn); // 读到 0xffffffff81001234 → 跳转到 spi_init##解决的是变量名不冲突的问题(每个函数有自己的__initcall_xxx2),#解决的是段名字符串的拼装问题。两个预处理器操作符加上__attribute__((__section__)),就把 C 源码中分散在各处的 init 函数指针,在最终的二进制里排列成了一个有序数组——然后在运行时遍历它。
其实是从 `spi_init` 这一行代码开始,沿着一条完整的逻辑链,把编译→链接→运行时调用的全路径走通了。按顺序理一下: --- **1. `__init` 是什么?** 修饰函数,告诉编译器把函数体放进 `.init.text` 段,而不是默认的 `.text`。内核启动完成后整段内存会被释放回收。 **2. `postcore_initcall(spi_init)` 是什么?** 宏展开后生成一个函数指针变量 `__initcall_spi_init2`,放在 `.initcall2.init` 段中,初值指向 `spi_init`。内核启动时按等级遍历 initcall 数组,level 2 轮到时就会通过这个指针调用 `spi_init`。 **3. 链接脚本标记和宏的区别?** 实际上两者都是 `#define` 宏。C 源码宏(如 `__init`)展开成编译器属性,决定单个 `.o` 里段的归属;链接脚本宏(如 `INIT_CALLS`)展开成链接器指令,决定所有 `.o` 合并后段的排布。前者作用在编译阶段,后者作用在链接阶段。 **4. `spi_init` 为什么是指针?** `postcore_initcall(spi_init)` 生成的是一个**函数指针变量**,存的是 `spi_init` 的入口地址;`spi_init` 函数体在 `.init.text` 段。两者是分开的,运行时通过指针跳转。 **5. 函数名可以用作指针吗?** 可以。C 语言规则:函数名在大多数上下文中自动隐式转换为指向它自己的指针,`spi_init` 和 `&spi_init` 值相同。 **6. 怎么理解 `__define_initcall` 这个宏?** 逐层拆开:`##` 拼接变量名,`#` 字符串化 id 拼段名,`__attribute__((__section__))` 指定段。整句就是定义一个函数指针变量,放到指定段,初值指向目标函数。 **7. 为什么要跳两层宏而不是一步到位?** 14 个 initcall 等级宏的展开逻辑完全相同,唯一区别是 id 数字。抽成 `__define_initcall` 避免写 14 份重复代码,改一处即可。 **8. `.initcall2.init` 段里有多少?** 从你的 System.map 数到 22 个函数指针(包括 `spi_init2`),共 88 字节,`spi_init2` 排在第 18 位。 **9. ELF 段是什么?** `.o` / `vmlinux` 等 ELF 二进制文件内部按功能划分的区域——类似图书馆的不同楼层,同类东西放一起。你看到的各种 `.text`、`.data`、`.initcall2.init` 都是段。 **10. `__attribute__` 是什么?** GCC 给编译器下指令的语法,不产生代码,只影响编译器行为。内核用它最多的就是 `__attribute__((__section__))`——把函数或变量强制放到指定的段中,这也是整个 initcall 机制能工作的基础。 --- 如果把这些问题的答案串成一句话,就是:**`static int __init spi_init(void) { ... }` 这条定义 + `postcore_initcall(spi_init)` 这条注册,通过 `__attribute__((__section__))` 和链接脚本的配合,把 `spi_init` 的函数体打入 `.init.text`、函数指针打入 `.initcall2.init`,两个段最终在链接时被收集成有序布局,内核启动时按等级遍历到 level 2 时取出这个指针跳转执行,执行完后整块内存被回收。**类型转换
const struct spi_device *spi = to_spi_device(dev); const struct spi_driver *sdrv = to_spi_driver(drv);这两行做的是类型转换——把 driver core 的通用类型转成 SPI 子系统的具体类型。
看原型就清楚了。spi_match_device是总线匹配函数,它的签名是由 driver core 定义的:
static int spi_match_device(struct device *dev, struct device_driver *drv)参数是通用的struct device *和struct device_driver *。但 SPI 子系统内部要操作的是 SPI 特有的结构体——spi_device和spi_driver。所以需要转换:
const struct spi_device *spi = to_spi_device(dev);to_spi_device是一个内联函数或宏,利用container_of原理:struct spi_device的第一个成员是struct device dev,所以拿到dev的指针就能反推出外围spi_device的地址。
const struct spi_driver *sdrv = to_spi_driver(drv);同理,struct spi_driver的第一个成员是struct device_driver driver,to_spi_driver反推出外层结构体。
转换完成后,下面的代码就能用 SPI 特有的字段了,比如spi->modalias、sdrv->id_table:
if (sdrv->id_table) return !!spi_match_id(sdrv->id_table, spi); // 用 sdrv 查表 return strcmp(spi->modalias, drv->name) == 0; // 用 spi 取名字在 Linux 驱动模型里到处都是这个模式——driver core 层只认struct device和struct device_driver,每个子系统都用to_xxx这套手法把通用指针转换成自己需要的具体类型。
static inline struct spi_device *to_spi_device(struct device *dev) { return dev ? container_of(dev, struct spi_device, dev) : NULL; }这一行是container_of宏的典型用法。让我从里到外拆开。
先看整体
return dev ? container_of(dev, struct spi_device, dev) : NULL;三目运算符:
- 如果
dev不为 NULL,调container_of把通用dev指针转成struct spi_device * - 如果
dev为 NULL,直接返回 NULL
条件 ? 条件为真时取的值 : 条件为假时取的值核心在container_of。
container_of的原理
定义在include/linux/kernel.h中:
#define container_of(ptr, type, member) \ ({ const typeof(((type *)0)->member) *__mptr = (ptr); (type *)((char *)__mptr - offsetof(type, member)); })拆开来看每一步。
已知什么?
- 已知
struct spi_device内部有一个成员叫dev(类型是struct device) - 已知
dev这个成员在内存中的地址(即函数参数传进来的指针) - 想反推出整个
struct spi_device的起始地址
举例说明
假设一个struct spi_device分配在地址0x100处:
地址 内容 0x100 struct spi_device ← 想得到这个地址 0x100 { .dev ← 已知这个成员的地址 0x100 ... (dev 是第一个成员,所以偏移为 0) 0x200 .max_speed_hz 0x204 .chip_select }container_of做的事就是:
已知:dev 成员的地址 = 0x100 已知:dev 在 spi_device 中的偏移 = 0(因为它是第一个成员) 计算:spi_device 地址 = 0x100 - 0 = 0x100如果dev不是第一个成员,比如:
struct spi_device { u32 max_speed_hz; // 偏移 0 u8 chip_select; // 偏移 4 struct device dev; // 偏移 8(假设对齐后) ... };那么:
已知:dev 成员的地址 = 0x108 已知:dev 在 spi_device 中的偏移 = 8 计算:spi_device 地址 = 0x108 - 8 = 0x100宏展开
container_of(dev, struct spi_device, dev)展开后变成:
// 第一步:用一个临时指针 __mptr 保存 dev 的地址 const typeof(((struct spi_device *)0)->dev) *__mptr = (dev); // typeof(...) 得到类型是 struct device // 所以等价于: const struct device *__mptr = dev; // 第二步:用 __mptr 的地址减去 dev 字段在 spi_device 中的偏移 (struct spi_device *)((char *)__mptr - offsetof(struct spi_device, dev)); // offsetof(struct spi_device, dev) 计算 dev 字段的偏移字节数为什么用(char *)强转?因为指针减法是按单位长度计算的,char *确保按字节算偏移量,不受指针类型影响。
为什么dev是第一个成员就不用减?
看struct spi_device的定义:
struct spi_device { struct device dev; // ← 第一个成员 struct spi_master *master; u32 max_speed_hz; u8 chip_select; u8 bits_per_word; u16 mode; ... };dev是第一个成员,所以offsetof(struct spi_device, dev) = 0。这时候container_of退化为:
(struct spi_device *)((char *)__mptr - 0) // 就是 (struct spi_device *)dev直接把传入的dev指针强转成spi_device *。但内核代码不会这样简写,而是统一用container_of——因为万一以后有人把dev挪到后面去了,用container_of的代码不用改,简写的就崩了。
modalias 属性获取
第一层:bus_type.dev_groups(数组)
struct bus_type spi_bus_type = { .dev_groups = spi_dev_groups, // ← 所有 spi_device 共享这组属性 };这一层说:"所有挂在这个总线上的设备,都应该有这些属性文件。"如果没有这一层,每个 SPI 设备驱动都要自己手动创建 modalias 文件——那 100 个 SPI 设备驱动就要写 100 次重复代码。
第二层:attribute_group
static const struct attribute_group *spi_dev_groups[] = { &spi_dev_group, // ← 组 1:通用属性(modalias) &spi_device_statistics_group, // ← 组 2:统计属性(messages, errors...) NULL, };这一层说:"把属性按用途分组。"统计属性代码量大(十几个计数器),单独放一个组,以后想加权限控制、条件显示(比如某些属性只在调试内核可见),直接在组上加is_visible回调就行,不用拆散其他属性。
第三层:.attrs[](数组)
static struct attribute *spi_dev_attrs[] = { &dev_attr_modalias.attr, NULL, };这一层说:"这个组里有多个属性文件。"数组让代码可以任意增删属性,加一个逗号一行就行,不改动结构体定义。
第四层:dev_attr_modalias.attr
static DEVICE_ATTR_RO(modalias); // 等价于 { .attr.name = "modalias", .attr.mode = 0444, .show = modalias_show }这一层说:"单个属性文件的名字、权限、读写函数。"
如果去掉所有分层,硬编码到直接让 sysfs 创建 modalias 文件,那会是什么样?大概需要给bus_type加一个字段:
struct bus_type { const char *name; // 所有设备共有的属性直链 struct device_attribute *single_dev_attr; // 但统计属性怎么办?再加一个? struct device_attribute *stat_dev_attr; // 如果以后想加第三个呢?每加一个改一次结构体? };这就不灵活了。现在用groups → group → attrs → attr四层指针,每一层只负责一种粒度的组合问题,各层之间不用互相知道内部细节:
bus_type.dev_groups → 总线级别:所有设备共有的属性集合 attribute_group[0] → 逻辑分组1:核心属性 .attrs[] → 文件列表 .attr → 单个文件 "modalias" attribute_group[1] → 逻辑分组2:统计属性 .attrs[] → 文件列表 .attr → "messages" .attr → "errors" .attr → "bytes"想给所有 SPI 设备加一个新属性?在对应组加一个数组元素就行。想给 i.MX 的 SPI 设备额外加一个属性?只给那个控制器驱动的dev_groups加一行就行,不影响全局。想禁用统计属性?删掉spi_device_statistics_group那行就行。
每一层跳转不是浪费,是留了一个解耦的接头。
sysyfs
sysfs 是一个内存里的虚拟文件系统,挂载在/sys下,把内核的对象(设备、驱动、总线、类等)以目录和文件的形态暴露给用户态。
/sys/
├── bus/ ← 所有总线(spi、i2c、pci、usb...)
│ └── spi/
│ ├── devices/ ← 该总线上的所有设备
│ └── drivers/ ← 该总线上的所有驱动
├── class/ ← 按功能分类的设备(input、net、spi_master...)
├── devices/ ← 所有设备的真实层级树
├── block/ ← 块设备
├── power/ ← 电源管理状态
└── kernel/ ← 内核参数
三个核心用途:
1. 替代 /proc 的混乱
以前内核信息散落在/proc下的各种文件中,没有统一规范。sysfs 把每个设备、总线、驱动都变成目录,属性变成文件,结构清晰可预测。
2. 给用户态程序读/写内核信息
cat /sys/bus/spi/devices/spi0.0/modalias # 读设备别名 echo on > /sys/devices/.../power/control # 控制设备电源状态3. 给 udev/mdev 提供事件源
当内核里注册了一个新设备,sysfs 创建对应的目录和文件,同时发一个 uevent。用户态的 udev 收到事件后读取 sysfs 里的信息,决定创建设备节点、加载固件或其他动作。