三、进阶 | Linux 内核概念(2):initcall
initcall 机制
介绍
就像你从标题所理解的,这部分将涉及 Linux 内核中有趣且重要的概念,称之为 initcall
。在 Linux 内核中,我们可以看到类似这样的定义:
early_param("debug", debug_kernel);
或者
arch_initcall(init_pit_clocksource);
在我们分析这个机制在内核中是如何实现的之前,我们必须了解这个机制是什么,以及在 Linux 内核中是如何使用它的。像这样的定义表示一个 回调函数 ,它们会在 Linux 内核启动中或启动后调用。实际上 initcall
机制的要点是确定内置模块和子系统初始化的正确顺序。举个例子,我们来看看下面的函数:
static int __init nmi_warning_debugfs(void)
{
debugfs_create_u64("nmi_longest_ns", 0644,
arch_debugfs_dir, &nmi_longest_ns);
return 0;
}
这个函数出自源码文件 arch/x86/kernel/nmi.c。我们可以看到,这个函数只是在 arch_debugfs_dir
目录中创建 nmi_longest_ns
debugfs 文件。实际上,只有在 arch_debugfs_dir
创建后,才会创建这个 debugfs
文件。这个目录是在 Linux 内核特定架构的初始化期间创建的。实际上,该目录将在源码文件 arch/x86/kernel/kdebugfs.c 的 arch_kdebugfs_init
函数中创建。注意 arch_kdebugfs_init
函数也被标记为 initcall
。
arch_initcall(arch_kdebugfs_init);
Linux 内核在调用 fs
相关的 initcalls
之前调用所有特定架构的 initcalls
。因此,只有在 arch_kdebugfs_dir
目录创建以后才会创建我们的 nmi_longest_ns
。实际上,Linux 内核提供了八个级别的主 initcalls
:
early
;core
;postcore
;arch
;susys
;fs
;device
;late
.
它们的所有名称是由数组 initcall_level_names
来描述的,该数组定义在源码文件 init/main.c 中:
static char *initcall_level_names[] __initdata = {
"early",
"core",
"postcore",
"arch",
"subsys",
"fs",
"device",
"late",
};
所有用这些标识符标记为 initcall
的函数将会以相同的顺序被调用,或者说,early initcalls
会首先被调用,其次是 core initcalls
,以此类推。现在,我们对 initcall
机制了解点了,所以我们可以开始潜入 Linux 内核源码,来看看这个机制是如何实现的。
initcall 机制在 Linux 内核中的实现
Linux 内核提供了一组来自头文件 include/linux/init.h 的宏,来标记给定的函数为 initcall
。所有这些宏都相当简单:
#define early_initcall(fn) __define_initcall(fn, early)
#define core_initcall(fn) __define_initcall(fn, 1)
#define postcore_initcall(fn) __define_initcall(fn, 2)
#define arch_initcall(fn) __define_initcall(fn, 3)
#define subsys_initcall(fn) __define_initcall(fn, 4)
#define fs_initcall(fn) __define_initcall(fn, 5)
#define device_initcall(fn) __define_initcall(fn, 6)
#define late_initcall(fn) __define_initcall(fn, 7)
我们可以看到,这些宏只是从同一个头文件的 __define_initcall
宏的调用扩展而来。此外,__define_initcall
宏有两个参数:
fn
- 在调用某个级别initcalls
时调用的回调函数;id
- 识别initcall
的标识符,用来防止两个相同的initcalls
指向同一个处理函数时出现错误。
__define_initcall
宏的实现如下所示:
#define __define_initcall(fn, id) \
static initcall_t __initcall_##fn##id __used \
__attribute__((__section__(".initcall" #id ".init"))) = fn; \
LTO_REFERENCE_INITCALL(__initcall_##fn##id)
要了解 __define_initcall
宏,首先让我们来看下 initcall_t
类型。这个类型定义在同一个 头文件 中,它表示一个返回 整形指针的函数指针,这将是 initcall
的结果:
typedef int (*initcall_t)(void);
现在让我们回到 _-define_initcall
宏。## 提供了连接两个符号的能力。在我们的例子中,__define_initcall
宏的第一行产生了 .initcall id .init
ELF 部分 给定函数的定义,并标记以下 gcc 属性: __initcall_function_name_id
和 __used
。如果我们查看表示内核链接脚本数据的 include/asm-generic/vmlinux.lds.h 头文件,我们会看到所有的 initcalls
部分都将放在 .data
段:
#define INIT_CALLS \
VMLINUX_SYMBOL(__initcall_start) = .; \
*(.initcallearly.init) \
INIT_CALLS_LEVEL(0) \
INIT_CALLS_LEVEL(1) \
INIT_CALLS_LEVEL(2) \
INIT_CALLS_LEVEL(3) \
INIT_CALLS_LEVEL(4) \
INIT_CALLS_LEVEL(5) \
INIT_CALLS_LEVEL(rootfs) \
INIT_CALLS_LEVEL(6) \
INIT_CALLS_LEVEL(7) \
VMLINUX_SYMBOL(__initcall_end) = .;
#define INIT_DATA_SECTION(initsetup_align) \
.init.data : AT(ADDR(.init.data) - LOAD_OFFSET) { \
... \
INIT_CALLS \
... \
}
第二个属性 - __used
,定义在 include/linux/compiler-gcc.h 头文件中,它扩展了以下 gcc
定义:
#define __used __attribute__((__used__))
它防止 定义了变量但未使用
的告警。宏 __define_initcall
最后一行是:
LTO_REFERENCE_INITCALL(__initcall_##fn##id)
这取决于 CONFIG_LTO
内核配置选项,只为编译器提供链接时间优化存根:
#ifdef CONFIG_LTO
#define LTO_REFERENCE_INITCALL(x) \
static __used __exit void *reference_##x(void) \
{ \
return &x; \
}
#else
#define LTO_REFERENCE_INITCALL(x)
#endif
为了防止当模块中的变量没有引用时而产生的任何问题,它被移到了程序末尾。这就是关于 __define_initcall
宏的全部了。所以,所有的 *_initcall
宏将会在Linux内核编译时扩展,所有的 initcalls
会放置在它们的段内,并可以通过 .data
段来获取,Linux 内核在初始化过程中就知道在哪儿去找到 initcall
并调用它。
既然 Linux 内核可以调用 initcalls
,我们就来看下 Linux 内核是如何做的。这个过程从 init/main.c 头文件的 do_basic_setup
函数开始:
static void __init do_basic_setup(void)
{
...
...
...
do_initcalls();
...
...
...
}
该函数在 Linux 内核初始化过程中调用,调用时机是主要的初始化步骤,比如内存管理器相关的初始化、CPU
子系统等完成之后。do_initcalls
函数只是遍历 initcall
级别数组,并调用每个级别的 do_initcall_level
函数:
static void __init do_initcalls(void)
{
int level;
for (level = 0; level < ARRAY_SIZE(initcall_levels) - 1; level++)
do_initcall_level(level);
}
initcall_levels
数组在同一个源码文件中定义,包含了定义在 __define_initcall
宏中的那些段的指针:
static initcall_t *initcall_levels[] __initdata = {
__initcall0_start,
__initcall1_start,
__initcall2_start,
__initcall3_start,
__initcall4_start,
__initcall5_start,
__initcall6_start,
__initcall7_start,
__initcall_end,
};
如果你有兴趣,你可以在 Linux 内核编译后生成的链接器脚本 arch/x86/kernel/vmlinux.lds
中找到这些段:
.init.data : AT(ADDR(.init.data) - 0xffffffff80000000) {
...
...
...
...
__initcall_start = .;
*(.initcallearly.init)
__initcall0_start = .;
*(.initcall0.init)
*(.initcall0s.init)
__initcall1_start = .;
...
...
}
如果你对这些不熟,可以在本书的某些部分了解更多关于链接器的信息。
正如我们刚看到的,do_initcall_level
函数有一个参数 - initcall
的级别,做了以下两件事:首先这个函数拷贝了 initcall_command_line
,这是通常内核包含了各个模块参数的命令行的副本,并用 kernel/params.c源码文件的 parse_args
函数解析它,然后调用各个级别的 do_on_initcall
函数:
for (fn = initcall_levels[level]; fn < initcall_levels[level+1]; fn++)
do_one_initcall(*fn);
do_on_initcall
为我们做了主要的工作。我们可以看到,这个函数有一个参数表示 initcall
回调函数,并调用给定的回调函数:
int __init_or_module do_one_initcall(initcall_t fn)
{
int count = preempt_count();
int ret;
char msgbuf[64];
if (initcall_blacklisted(fn))
return -EPERM;
if (initcall_debug)
ret = do_one_initcall_debug(fn);
else
ret = fn();
msgbuf[0] = 0;
if (preempt_count() != count) {
sprintf(msgbuf, "preemption imbalance ");
preempt_count_set(count);
}
if (irqs_disabled()) {
strlcat(msgbuf, "disabled interrupts ", sizeof(msgbuf));
local_irq_enable();
}
WARN(msgbuf[0], "initcall %pF returned with %s\n", fn, msgbuf);
return ret;
}
让我们来试着理解 do_on_initcall
函数做了什么。首先我们增加 preemption 计数,以便我们稍后进行检查,确保它不是不平衡的。这步以后,我们可以看到 initcall_backlist
函数的调用,这个函数遍历包含了 initcalls
黑名单的 blacklisted_initcalls
链表,如果 initcall
在黑名单里就释放它:
list_for_each_entry(entry, &blacklisted_initcalls, next) {
if (!strcmp(fn_name, entry->buf)) {
pr_debug("initcall %s blacklisted\n", fn_name);
kfree(fn_name);
return true;
}
}
黑名单的 initcalls
保存在 blacklisted_initcalls
链表中,这个链表是在早期 Linux 内核初始化时由 Linux 内核命令行来填充的。
处理完进入黑名单的 initcalls
,接下来的代码直接调用 initcall
:
if (initcall_debug)
ret = do_one_initcall_debug(fn);
else
ret = fn();
取决于 initcall_debug
变量的值,do_one_initcall_debug
函数将调用 initcall
,或直接调用 fn()
。initcall_debug
变量定义在同一个源码文件:
bool initcall_debug;
该变量提供了向内核日志缓冲区打印一些信息的能力。可以通过 initcall_debug
参数从内核命令行中设置这个变量的值。从Linux内核命令行文档可以看到:
initcall_debug [KNL] Trace initcalls as they are executed. Useful
for working out where the kernel is dying during
startup.
确实如此。如果我们看下 do_one_initcall_debug
函数的实现,我们会看到它与 do_one_initcall
函数做了一样的事,也就是说,do_one_initcall_debug
函数调用了给定的 initcall
,并打印了一些和 initcall
相关的信息(比如当前任务的 pid、initcall
的持续时间等):
static int __init_or_module do_one_initcall_debug(initcall_t fn)
{
ktime_t calltime, delta, rettime;
unsigned long long duration;
int ret;
printk(KERN_DEBUG "calling %pF @ %i\n", fn, task_pid_nr(current));
calltime = ktime_get();
ret = fn();
rettime = ktime_get();
delta = ktime_sub(rettime, calltime);
duration = (unsigned long long) ktime_to_ns(delta) >> 10;
printk(KERN_DEBUG "initcall %pF returned %d after %lld usecs\n",
fn, ret, duration);
return ret;
}
由于 initcall
被 do_one_initcall
或 do_one_initcall_debug
调用,我们可以看到在 do_one_initcall
函数末尾做了两次检查。第一个检查在initcall执行内部 __preempt_count_add
和 __preempt_count_sub
可能的执行次数,如果这个值和之前的可抢占计数不相等,我们就把 preemption imbalance
字符串添加到消息缓冲区,并设置正确的可抢占计数:
if (preempt_count() != count) {
sprintf(msgbuf, "preemption imbalance ");
preempt_count_set(count);
}
稍后这个错误字符串就会被打印出来。最后检查本地 IRQs 的状态,如果它们被禁用了,我们就将 disabled interrupts
字符串添加到我们的消息缓冲区,并为当前处理器使能 IRQs
,以防出现 IRQs
被 initcall
禁用了但不再使能的情况出现:
if (irqs_disabled()) {
strlcat(msgbuf, "disabled interrupts ", sizeof(msgbuf));
local_irq_enable();
}
这就是全部了。通过这种方式,Linux 内核以正确的顺序完成了很多子系统的初始化。现在我们知道 Linux 内核的 initcall
机制是怎么回事了。在这部分中,我们介绍了 initcall
机制的主要部分,但遗留了一些重要的概念。让我们来简单看下这些概念。
首先,我们错过了一个级别的 initcalls
,就是 rootfs initcalls
。和我们在本部分看到的很多宏类似,你可以在 include/linux/init.h 头文件中找到 rootfs_initcall
的定义:
#define rootfs_initcall(fn) __define_initcall(fn, rootfs)
从这个宏的名字我们可以理解到,它的主要目的是保存和 rootfs 相关的回调。除此之外,只有在与设备相关的东西没被初始化时,在文件系统级别初始化以后再初始化一些其它东西时才有用。例如,发生在源码文件 init/initramfs.c 中 populate_rootfs
函数里的解压 initramfs:
rootfs_initcall(populate_rootfs);
在这里,我们可以看到熟悉的输出:
[ 0.199960] Unpacking initramfs...
除了 rootfs_initcall
级别,还有其它的 console_initcall
、 security_initcall
和其他辅助的 initcall
级别。我们遗漏的最后一件事,是 *_initcall_sync
级别的集合。在这部分我们看到的几乎每个 *_initcall
宏,都有 _sync
前缀的宏伴随:
#define core_initcall_sync(fn) __define_initcall(fn, 1s)
#define postcore_initcall_sync(fn) __define_initcall(fn, 2s)
#define arch_initcall_sync(fn) __define_initcall(fn, 3s)
#define subsys_initcall_sync(fn) __define_initcall(fn, 4s)
#define fs_initcall_sync(fn) __define_initcall(fn, 5s)
#define device_initcall_sync(fn) __define_initcall(fn, 6s)
#define late_initcall_sync(fn) __define_initcall(fn, 7s)
这些附加级别的主要目的是,等待所有某个级别的与模块相关的初始化例程完成。
这就是全部了。
结论
在这部分中,我们看到了 Linux 内核的一项重要机制,即在初始化期间允许调用依赖于 Linux 内核当前状态的函数。
如果你有问题或建议,可随时在 twitter 0xAX 上联系我,给我发 email,或者创建 issue。
请注意英语不是我的母语,对此带来的不便,我很抱歉。如果你发现了任何错误,都可以给我发 PR 到linux-insides。.
链接
"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。