内核空间初始化概述
引导加载程序在加载完内核后把控制权交给系统,此时系统从main,.c文件开始执行。
下面是精简但准确的调用顺序(基于主线内核):
start_kernel()
{
setup_arch(&command_line); // ① 架构相关初始化
setup_command_line(command_line);
setup_nr_cpu_ids();
setup_per_cpu_areas();
parse_early_param(); // ② 解析 earlycon / earlyprintk
parse_args()
...
do_pre_smp_initcalls(); // SMP 之前
smp_init(); // 启动其他 CPU
do_initcalls(); // SMP 之后, 初始化子系统,例如console_init
...
start_init(); //
}
下面就从上面代码的顺序简单了解一下各个部分的作用。
架构初始化(setup_arch)先调用
setup_arch() 做的事情包括:
-
解析 DT / ACPI
-
初始化内存布局
-
初始化 MMU
-
决定 UART 地址、时钟等
内核命令行参数setup_command_line
输入内核命令参数字符串,然后对每个参数进行判断是early还是普通参数,最后通过注册函数进行处理。
parse_early_param() 的作用是:
在内核绝大多数子系统初始化之前,解析“必须尽早生效”的启动参数(early parameters)。这些参数 如果晚了就没意义,甚至会导致系统起不来。
| 类型 | 示例 | 解析时间 |
|---|---|---|
| 普通 | root= |
很晚 |
| early | earlycon= |
非常早 |
在内核代码中你会看到:
early_param("earlycon", earlycon_setup);
early_param("mem", early_mem);
这些参数 只能被 parse_early_param() 解析。
| 函数 | 作用 |
|---|---|
parse_early_param() |
解析 必须早执行 的参数 |
parse_args() |
解析普通启动参数 |
-
用
early_param()标记的启动参数
只会在parse_early_param()中被解析并调用对应回调 -
没有
early_param()标记的普通启动参数
会在后续的parse_args()中被解析处理
| 参数类型 | 注册宏 | 解析函数 | 例子 |
|---|---|---|---|
| early 参数 | early_param() |
parse_early_param() |
earlycon= |
| 普通参数 | __setup() |
parse_args() |
console= |
| 模块参数 | module_param() |
模块加载时 | snd_pcm= |
说明几个early_parameter
①earlycon / earlyprintk
earlycon=uart8250,mmio,0x01c28000
作用:
-
在 console 子系统尚未初始化前
-
直接往 UART 寄存器打字符
-
用来调试 内核起不来的问题
②mem= / memblock
mem=256M
作用:
-
限制内核可用内存
-
决定 memblock 布局
内存布局 一旦建立就不能随便改
③ early clock / cpu 参数
如:
maxcpus=
nr_cpus=
这些直接影响:
-
CPU bring-up
-
SMP 初始化
④Debug / tracing 的最早开关
比如:
initcall_debug
ignore_loglevel
以上,如果这些参数不被提早执行,会发生射门呢
| 问题 | 原因 |
|---|---|
| 串口完全没输出 | earlycon 没生效 |
| 内存识别异常 | mem= 没解析 |
| 多核异常 | CPU 参数没生效 |
| 无法 debug | loglevel 没作用 |
如果你发现某个启动参数“加了但不生效”,
第一件事就是看:
它是不是被注册成 early_param。
很多 debug 场景(尤其是串口没输出)就是这里卡住的。
Linux 内核是如何把 setup_command_line() 得到的那一整串字符串,
在 parse_args() 里拆开,并分别交给 early_param / __setup / 普通参数去处理的?
parse_args() 并不是“一次性处理整条命令行”,而是把 command line 按空格拆成一个个参数, 对每一个参数,依次尝试:
-
是否是
early_param(已经在parse_early_param()处理过的会被跳过) -
是否能匹配
__setup -
是否是普通
key=value/ flag 参数 -
否则:记录为未知参数
函数原型
int parse_args(const char *name,
char *args,
const struct kernel_param *params,
unsigned num,
int (*unknown)(char *param, char *val))
启动时看到的那次调用为
parse_args("Booting kernel",
static_command_line,
__start___param,
__stop___param - __start___param,
unknown_bootoption);
假设static_command_line的内容为
console=ttyS0,115200 root=/dev/mmcblk0p2 rw quiet
那么parse_args的第一步就是按空格把整条命令行拆成各个参数,这也是next_arg的作用
while (*args) {
char *param = next_arg(&args, &val);
...
}
例如
console=ttyS0,115200
拆成
param = "console"
val = "ttyS0,115200"
quiet
拆成
param = "quiet"
val = NULL
第二步是对每一个参数执行处理逻辑
①判断是否是 early_param
-
是否在parse_early_param()已经提前处理过 -
如果一个参数被标记为 is_early:会被记录,在
parse_args()中直接跳过
所以在 parse_args() 里你一般看不到 early_param 的处理代码
②是否匹配 __setup
if (setup_param(param, val))
continue;
其中setup_param的伪代码,这正是 __setup("console=", console_setup) 生效的地方
for (each setup_entry in __setup section) {
if (param matches setup_entry->str)
setup_entry->fn(val);
}
③如果不匹配set_up,则判断是否是普通 kernel parameter(module_param / __param)
if (parse_one(param, val, params, num))
continue;
这些参数来自:module_param()/__param section
例如如果是模块参数,则声明
int panic_timeout;
module_param(panic_timeout, int, 0644);
其中0644是文件权限主要用在sysyfs中,在启动阶段没用,这里也写出来是为了统一模块参数的“声明 + 可控 + 可观察”。
如果不是,那么 parse_args() 会调用unknown函数就是parse_args最后的函数指针指定的函数,
该函数的作用是兜底处理“既不是 early_param,也不是 __setup,也不是 module_param 的参数”。
unknown(param, val);
对应的处理函数为
unknown_bootoption()
会尝试解析成:
init 进程参数
或记录为 unused
| 项目 | module_param | unknown(param, val) |
|---|---|---|
| 本质 | 静态参数描述 | 函数回调 |
| 声明方式 | 宏 + section | 普通函数定义 |
| 解析方式 | parse_one 遍历 | 直接函数调用 |
| 是否要提前“登记” | 是(编译期) | 是(函数已存在) |
| 是否可扩展 | 多个参数 | 只能一个回调 |
module_param和parse_one的作用是什么?
module_param() 是“声明参数”;parse_one() 是“在启动时或运行时,把命令行里的值,写进你声明的变量里”
module_param() 并不“解析参数”,它只是: 告诉内核:有一个变量,可以用参数名来设置。
module_param(name, type, perm);
最终会展开为
static const struct kernel_param __param_name
__attribute__((section("__param"))) = {
.name = "name",
.ops = ¶m_ops_type,
.arg = &name,
};
-
它生成了一个
struct kernel_param并放进__paramELF section
其中“参数绑定”的核心结构
struct kernel_param {
const char *name; // 参数名
const struct kernel_param_ops *ops; // 解析/设置方法
void *arg; // 指向变量
};
例如
name = "panic_timeout"
ops = int 的 set/get 函数
arg = &panic_timeout
而parse_one() 负责:在所有 __param 里找名字匹配的、调用对应的 set() 函数然后把字符串值写进变量
int parse_one(char *param, char *val,
const struct kernel_param *params,
unsigned num)
{
for (i = 0; i < num; i++) {
if (strcmp(param, params[i].name) == 0) {
return params[i].ops->set(val, ¶ms[i]);
}
}
return -ENOENT;
}
| 机制 | 适合做什么 |
|---|---|
early_param |
极早期硬件行为 |
__setup |
自定义复杂解析 |
module_param |
简单“参数=变量”绑定 |
整体流程为
command_line
↓
split by space
↓
for each arg:
├── early_param? → 已处理 / 跳过
├── __setup match? → 调用 setup 回调
├── kernel_param match? → 设置变量
└── unknown? → 记录 / 忽略
伪代码
for each token in command_line:
if handled_by_early_param(token):
continue
if handled_by_setup(token):
continue
if handled_by_kernel_param(token):
continue
handle_unknown(token)
do_initcalls()
-
__setup:注册“启动参数处理函数”,即配置解析 -
__initcall:注册“初始化函数”
__setup 关联的回调 先被执行,__initcall 关联的初始化函数 后被执行。因为初始化函数(initcall)可能依赖参数,如果参数还没解析,初始化就可能用错配置。
__initcall 用来告诉内核:
“系统启动到某个阶段时,请执行这个初始化函数。”(等内核启动到合适阶段再调用)
__initcall是分级执行的、常见等级顺序如下
early_initcall
core_initcall
postcore_initcall
arch_initcall
subsys_initcall
fs_initcall
device_initcall
late_initcall
do_pre_smp_initcalls()
do_initcalls()函数与do_pre_smp_initcalls()函数有什么区别?
do_pre_smp_initcalls():
在 SMP 启动之前执行
只执行“必须在单核环境下完成”的 initcall
do_initcalls():
在 SMP 已就绪后执行
执行绝大多数普通 initcall(驱动/子系统)
前者是“单核安全初始化”,后者是“正常并发初始化”。
do_pre_smp_initcalls()执行那些“不能在多核并发环境下初始化”的 initcall。主要执行下面这一类initcall
early_initcall
core_initcall
postcore_initcall
| 特点 | 原因 |
|---|---|
| 必须单核 | 还没锁 / 并发模型 |
| 操作全局结构 | 尚未支持并发访问 |
| 影响 CPU bring-up | SMP 依赖它们 |
do_initcalls()执行“已经具备并发条件”的初始化函数。主要执行下面这一类initcall
arch_initcall
subsys_initcall
fs_initcall
device_initcall
late_initcall
| 特点 | 说明 |
|---|---|
| 允许多核 | SMP 已启动 |
| 有锁 | spinlock / mutex |
| 主要是驱动 | UART / 网卡 / GPIO |
| 可失败 | 不影响内核基本生存 |
为什么要拆成两个阶段呢?
SMP bring-up 本身依赖早期 initcall要启动其他 CPU,需要:中断系统、时钟、调度器、per-cpu 数据这些必须先初始化,SMP 启动之后,系统状态发生了根本变化,多个 CPU 同时跑,initcall 可能并发,调度器开始真正调度, 这时才能安全执行普通驱动初始化。
子系统初始化函数
early_initcall
core_initcall
postcore_initcall
arch_initcall
subsys_initcall
fs_initcall
device_initcall
late_initcall
上面其实也是子系统初始化的从最早到最晚的时间轴。内核从“能活” → “能用” → “能加载设备” 的过程。
下面逐一讲解一下
①early_initcall
系统“刚醒过来”,确保内核能继续跑下去。此时不能依赖设备、文件系统、用户空间,几乎不允许失败。一般用来初始化very early printk、时钟基础框架、调度器极早期结构、IRQ 框架雏形。
比如
early_initcall(init_sched_clock);
②core_initcall
内核核心子系统初始化,这一部分初始化的是系统“骨架”,所有 CPU / 内存 / 中断 都依赖它。
一般用来初始化 内存管理(buddy / slab 前期),中断子系统,调度器核心,VFS 核心结构。
比如:
core_initcall(init_irq);
③postcore_initcall
对 core 子系统的补充和修正。
为什么存在?有些初始化 依赖 core,但又必须尽早,不适合和 core 混在一起,一般用来初始化sysfs、kobject 框架、device model 基础。
postcore_initcall(kobject_init);
④arch_initcall
体系结构相关的初始化,和 CPU 架构 / SoC 强相关,不同 arch 内容完全不同
一般用来初始化平台总线(platform bus),pinctrl 框架,SoC 级别初始化,IOMMU。
arch_initcall(cpuinfo_init);
⑤subsys_initcall
“大子系统”的初始化(但还不是具体设备),就是驱动之前的“管理层”,不直接控制硬件,比如初始化总线(I2C / SPI / USB core),电源管理框架,DRM core,网络协议栈(net/core)。
subsys_initcall(i2c_init);
subsys_initcall(usb_init);
⑥fs_initcall
文件系统相关初始化。VFS 已就绪,但还没有真正 mount 根文件系统。一般用于ext4 / squashfs 注册和procfs / sysfs 注册
fs_initcall(ext4_init_fs);
⑦device_initcall
具体“设备驱动”的初始化,最常见,一般写的驱动 90% 在这里。例如UART、GPIO、网卡、I2C 设备、SPI 设备的初始化。
device_initcall(serial8250_init);
⑧late_initcall
“不影响系统运行”的收尾工作,非关键、可以失败、可以很晚。
比如说debugfs、tracing还有一些可选功能
late_initcall(debugfs_init);
注意:
“驱动都应该用 device_initcall”不完全对。
-
驱动核心 →
subsys_initcall -
具体设备 →
device_initcall
那每个initcall函数只对应一个子系统的初始化还是可以对应多个?
关键不是“数量”,而是 “职责边界和依赖关系”。
例如:
-
中断子系统:
-
kernel/irq/
-
drivers/irqchip/
-
-
时钟子系统:
-
kernel/time/
-
drivers/clocksource/
-
initcall 只是一个“在某个时间点被调用的函数指针”。它并不关心你在里面干什么:
static int __init foo_init(void)
{
init_a(); //初始化a设备
init_b(); //初始化b设备
init_c(); //初始化c设备
return 0;
}
device_initcall(foo_init);
内核社区的默认倾向是:
“一个 initcall = 一个清晰的初始化责任单元”
这样做的好处:依赖关系清晰、出问题容易定位。initcall 顺序更好推断。
什么时候一个initcall初始化多个东西是合理的?什么时候是不合理的?(可以学习这种设计思想)
情况一:强耦合、不可拆分的模块,逻辑上是 一个子系统,但代码上是多个部分。
情况二:初始化顺序必须严格一致。
不合理:
情况一:跨 initcall 等级的职责混在一起。
static int __init bad_init(void)
{
init_irq(); // core_initcall 级别
init_uart(); // device_initcall 级别
return 0;
}
情况二:一个 initcall 初始化多个“独立子系统”
例如:网络、USB、文件系统
放一个 initcall 里,会导致:无法独立裁剪、无法单独禁用、顺序不可控。
综上,
initcall 等级限制了“你能干什么”
-
早期 initcall:功能受限
-
晚期 initcall:可以用锁可以睡眠。
子系统往往是“多级 initcall”
一个真实的例子(I2C):
subsys_initcall(i2c_init); // I2C 核心
device_initcall(i2c_dev_init); // I2C 设备
“判断是否该拆 initcall”的工程法则
-
它们的依赖级别一样吗?
-
它们是否必须同时成功 / 失败?
-
将来是否可能被独立裁剪或禁用?
| 回答 | 建议 |
|---|---|
| 全是“是” | 合并 |
| 有一个“否” | 拆分 |
linux子系统从底层到上层如下所示
CPU / 架构层
├── 调度子系统
├── 中断子系统
├── 时钟子系统
├── 内存管理子系统
│
设备模型层
├── 设备模型(device / driver / bus)
├── 总线子系统(platform / PCI / USB / I2C / SPI)
│
核心功能子系统
├── 文件系统子系统(VFS / 各 fs)
├── 网络子系统
├── 电源管理子系统
│
硬件抽象与管理
├── GPIO / pinctrl
├── clock framework
├── regulator
│
系统接口子系统
├── sysfs / procfs
├── debugfs / trace
│
安全与控制
├── SELinux / LSM
initcall与子系统对照表
| initcall 级别 | 大概率子系统 |
|---|---|
| early / core | IRQ / MM / sched / time |
| postcore | kobject / device model |
| arch | SoC / pinctrl / clk |
| subsys | bus / net / pm |
| fs | 文件系统 |
| device | 具体设备 |
| late | debug / trace |
do_initcalls() 内部是“如何按阶段,一个一个把 initcall 执行起来的?”
就是个简单的概述,具体还是看源码
do_initcalls()本身并不“理解子系统”,
它只做三件事:
按 initcall 等级顺序
遍历 链接脚本生成的 section
顺序调用函数指针
initcall 的“阶段感”,完全来自 ELF section 的排列顺序
核心代码(去掉 debug / SMP 细节,保留本质)
void __init do_initcalls(void)
{
int level;
for (level = 0; level < ARRAY_SIZE(initcall_levels); level++)
do_initcall_level(level);
}
static const char *initcall_levels[] __initconst = {
"early",
"core",
"postcore",
"arch",
"subsys",
"fs",
"device",
"late",
};
static void __init do_initcall_level(int level)
{
initcall_t *fn;
for (fn = initcall_start[level];
fn < initcall_end[level];
fn++)
do_one_initcall(*fn);
}
initcall_start[level]是链接脚本(lds)生成的符号,每个 initcall 等级对应一个 section
__initcall_start = .;
__early_initcall_start = .;
*(.initcall0.init)
__early_initcall_end = .;
*(.initcall1.init) /* core */
*(.initcall2.init) /* postcore */
*(.initcall3.init) /* arch */
*(.initcall4.init) /* subsys */
*(.initcall5.init) /* fs */
*(.initcall6.init) /* device */
*(.initcall7.init) /* late */
__initcall_end = .;
那 initcall 函数是怎么进 section 的?
以 device_initcall() 为例:
#define device_initcall(fn) \
__define_initcall(fn, 6)
最终展开为,就是把函数指针放进 .initcall6.init
static initcall_t __initcall_fn \
__attribute__((__section__(".initcall6.init"))) = fn;
do_one_initcall() 核心代码如下
static int __init do_one_initcall(initcall_t fn)
{
int ret;
ret = fn();
return ret;
}
如果你用伪代码理解,Linux 的 initcall 机制等价于:
initcall_t early[] = { fn1, fn2 };
initcall_t core[] = { fn3, fn4 };
initcall_t device[] = { fn5, fn6 };
for (level = EARLY; level <= LATE; level++) {
for (fn in initcall[level]) {
fn();
}
}
Linux 只是把这个“数组”交给链接器来生成。
为什么linux的初始化子系统要这么设计?
完全解耦:子系统之注册函数就行,不用知道谁先执行谁后执行。
链接期决定顺序,运行期零成本:没有链表、没有排序。没有判断、启动极快,逻辑极稳。
裁剪天然成立:如果关掉一个 CONFIG,代码不会被编译、section 里没有这个指针、do_initcalls() 根本看不见它。
VMLINUX 链接阶段
↓
.initcallX.init sections 生成
↓
start_kernel()
↓
do_initcalls()
↓
for 每个 level:
for 每个 fn:
fn()
所以do_initcalls() 不“决定谁该初始化”,它只是“忠实地执行链接器已经排好的名单”。
其他
为什么说不能在内核vmlinux的汇编文件head.S中定制硬件,因为CPU有MMU单元,会导致单步调式出问题?
在 head.S 里定制复杂硬件是危险的,
不是因为“不能做”,
而是因为:
CPU 在这时正在切换 MMU / Cache / 地址空间,
会导致调试器看到的“地址、指令流、执行路径”发生断裂,
单步调试极易失效甚至跑飞。
head.S 执行时,CPU 正在经历这些变化:
| 项目 | 状态 |
|---|---|
| CPU 模式 | 特权模式 |
| C 运行环境 | ❌ 不存在 |
| 栈 | ❌ 或临时 |
| MMU | ❌ → ✅(切换中) |
| Cache | ❌ / 半初始化 |
| 虚拟地址 | ❌ → ✅(映射建立) |
这不是一个“稳定执行环境”
问题的核心是MMU会撕裂指令流。
MMU 打开前后,CPU“看到的世界”完全不同
MMU 关闭时:
PC = 0x40008000 → 物理地址
MMU 打开后:
PC = 0xC0008000 → 虚拟地址
但:这是“同一段代码”
这对 于CPU 来说没问题因为CPU可以处理MMU虚拟内存,但对调试器是灾难
CPU 的视角:
-
执行流连续
-
PC 正常跳转
-
没有“中断”
调试器(JTAG / GDB)的视角:
-
上一条指令:
0x4000801C -
下一条指令:
0xC000801C
调试器会认为:
“程序跳飞了”,因为调试器通过“PC 的变化是否符合预期的执行模型”来判断是否异常。PC发生了一个调试器无法解释的变化。
单步调试依赖什么?
-
精确的 PC
-
可预测的指令地址
-
稳定的地址映射
但在 head.S 中:
| 动作 | 后果 |
|---|---|
| 打开 MMU | PC 地址突变 |
| 开启 Cache | 指令不再来自内存 |
| 改页表 | 地址映射瞬间变化 |
“定制硬件”通常意味着:
-
访问外设寄存器
-
改 GPIO / Clock / Power
-
操作未映射的物理地址
在 MMU 切换期间:
-
地址可能:
-
尚未映射
-
已被重映射
-
正在被 cache shadow
-
轻则调试失效,重则直接 Data Abort。
硬件定制应该放在如下阶段,这个阶段时MMU和Cache都是稳定的。
| 阶段 | 文件 |
|---|---|
| 架构初始化 | setup_arch() |
| 板级硬件 | machine_desc / DT |
| 外设初始化 | 驱动 |
| GPIO / 时钟 | 设备树 |
好,又有问题了?那么为什么在head阶段调试器调试会崩,但是程序稳定之后调试器GDB又好用了?
因为内核进入了一个“地址语义稳定、调试器可预测”的阶段。调试器始终不知道 MMU 的存在,这一点从来没有变过。关键在于 PC 的变化方式是否“可预测”。在head.s中PC指令会发生不可预测的变化,而到了C代码阶段,PC 不再“跨地址空间突变”了,
从 start_kernel() 开始:
-
MMU 已经打开
-
虚拟地址空间已经固定
-
PC 始终在一个连续的虚拟区间内运行..
调试器的假设终于成立了:
调试器的默认假设是:
-
PC 连续变化
-
跳转通过 branch 指令
-
地址空间语义不变
-
ELF 符号是可信的
在 head.S 打开 MMU 之前,这些假设全被破坏
在 C 代码阶段,这些假设全部成立
更多推荐

所有评论(0)