引导加载程序在加载完内核后把控制权交给系统,此时系统从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 按空格拆成一个个参数, 对每一个参数,依次尝试:

  1. 是否是 early_param(已经在 parse_early_param() 处理过的会被跳过)

  2. 是否能匹配 __setup

  3. 是否是普通 key=value / flag 参数

  4. 否则:记录为未知参数

函数原型

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  = &param_ops_type,
    .arg  = &name,
};
  • 它生成了一个 struct kernel_param并放进 __param ELF 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, &params[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() 本身并不“理解子系统”,
它只做三件事:

  1. initcall 等级顺序

  2. 遍历 链接脚本生成的 section

  3. 顺序调用函数指针

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 始终在一个连续的虚拟区间内运行..

调试器的假设终于成立了:

调试器的默认假设是:

  1. PC 连续变化

  2. 跳转通过 branch 指令

  3. 地址空间语义不变

  4. ELF 符号是可信的

在 head.S 打开 MMU 之前,这些假设全被破坏
在 C 代码阶段,这些假设全部成立

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐