4. 内核参数解析流程:从 parse_args() 到模块参数

好,我们进入第四章。这一章我打算把内核参数解析的完整链路讲透。你可能会想,不就是启动时传几个参数吗?内核怎么接、怎么拆、怎么用,这里面的门道其实不少。我在做嵌入式启动优化时,就曾因为参数解析顺序踩过坑——嗯,后面会细说。

4.1 入口函数:parse_args() 干了什么

内核启动早期,setup_arch() 之后,会调用一个关键函数——parse_args()。它的原型长这样:

/* kernel/params.c */
int parse_args(const char *name,
               char *args,
               const struct kernel_param *params,
               unsigned num_params,
               s16 *min_level,
               s16 *max_level,
               void *arg,
               int (*unknown)(char *param, char *val,
                              const char *doing, void *arg));

参数看着多,核心逻辑其实就两件事:

  • 拆字符串:把 "foo=bar baz=123" 这种格式拆成一个个键值对
  • 匹配回调:根据参数名找到对应的处理函数,把值传进去

我个人习惯把 parse_args() 理解成一个「参数分发器」。它不关心参数具体含义,只负责拆包和投递。真正的处理逻辑,在注册阶段就已经挂好了。

核心流程:parse_args() 内部会循环调用 next_arg() 逐个提取参数,然后调用 parse_one() 去匹配。匹配成功就执行回调,失败则调用 unknown 回调(如果注册了的话)。

4.2 __setup 宏:最常用的参数注册方式

你写驱动时肯定见过这个:

/* include/linux/init.h */
#define __setup(str, fn) \
    __setup_param(str, fn, fn, 0)

展开后,它会把参数名和处理函数放到一个特殊的 ELF 段里——.init.setup。内核在启动时,会扫描这个段,构建一张参数表。

举个例子:

static int __init my_driver_setup(char *str)
{
    /* 解析参数值 */
    if (!strcmp(str, "enabled"))
        my_driver_enabled = 1;
    return 1;
}
__setup("my_driver=", my_driver_setup);

这里有个细节:__setup 注册的参数,只在内核初始化阶段有效。一旦 init 阶段结束,这些处理函数占用的内存会被释放。为什么?因为 .init.setup 段和 .init.text 一样,属于可丢弃的初始化数据。

我的经验:如果你需要参数在整个运行周期都可用,别用 __setup。用 module_param 或者 sysfs 接口更合适。我曾经在调试一个网络驱动时,把运行时参数也用 __setup 注册了,结果 init 完参数就丢了,查了半天才反应过来。

4.3 early_param 机制:比 __setup 更早

有些参数必须在非常早的阶段生效,比如 console=、mem=、init=。这些参数在 setup_arch() 之后、甚至内存管理还没完全初始化时就需要被解析。这时候,early_param 就派上用场了。

/* include/linux/init.h */
#define early_param(str, fn) \
    __setup_param(str, fn, fn, 1)

和 __setup 的唯一区别是第三个参数——early 标志位。内核在 parse_early_params() 中会优先处理这些参数:

/* init/main.c */
static void __init parse_early_params(char *cmdline)
{
    /* 只处理 early=1 的参数 */
    parse_args("early options", cmdline,
               NULL, 0, 0, 0, NULL,
               parse_early_param);
}

你看,它传了一个特殊的 unknown 回调——parse_early_param。这个回调会去 .init.setup 段里找 early 标志为 1 的条目。找到就执行,找不到就跳过。

机制 解析时机 段位置 典型用途
early_param setup_arch() 之后立即执行 .init.setup (early=1) console, mem, init
__setup start_kernel() 中后期 .init.setup (early=0) 驱动选项、文件系统参数
module_param 模块加载时 模块自身段 模块运行时参数

注意:early_param 的处理函数中,能调用的内核服务非常有限。堆、slab、中断都还没准备好。我曾经在 early_param 里尝试打印调试信息,结果 console 还没注册,printk 直接丢进了环形缓冲区——啥也看不见。

4.4 内核模块参数传递

模块参数的传递路径和内置参数不太一样。模块加载时,用户态通过 insmod 或 modprobe 传入参数,内核在 load_module() 中解析。

关键数据结构是这个:

/* include/linux/moduleparam.h */
struct kernel_param {
    const char *name;
    struct module *mod;
    const struct kernel_param_ops *ops;
    const u16 perm;
    s8 level;
    u8 flags;
};

每个 module_param 宏展开后,都会生成一个 kernel_param 实例,放在模块的 __param 段里。加载时,内核遍历这个段,调用对应的 ops->set() 方法。

举个例子:

static int debug_level = 0;
module_param(debug_level, int, 0644);
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");

这里 0644 是 sysfs 权限位。模块加载后,你可以在 /sys/module/<name>/parameters/debug_level 下看到这个参数,甚至可以在运行时修改它——前提是驱动实现了对应的 set 回调。

避坑指南:我曾经在调试一个存储驱动时,把模块参数权限设成了 0。结果参数虽然能传进去,但 sysfs 节点不显示,导致线上排查时没法动态调整。后来我统一用 0644,除非参数真的不允许运行时修改。

4.5 整体解析流程:一张图说清楚

我把整个参数解析的流程画成了 SVG 图。你跟着箭头走一遍,应该就清楚了:

内核参数解析完整流程 Bootloader 传递 cmdline parse_early_params() parse_args() → __setup 参数 模块加载 → module_param sysfs 运行时修改 说明: early_param 在 setup_arch() 后立即执行;__setup 在 start_kernel() 中后期执行; 模块参数在 insmod/modprobe 时解析,之后可通过 sysfs 动态调整。

从这张图你能看到,参数解析是分阶段的。越早注册的参数,能用的基础设施越少。这也是为什么 early_param 的处理函数里不能做复杂操作——你连 kmalloc 都调不了。

4.6 几个容易踩的坑

  • 参数名冲突:如果两个 __setup 注册了同名参数,后注册的会覆盖先注册的。内核不会报错,只会默默覆盖。我建议你在参数名前加模块前缀,比如 mydrv_debug=。
  • 返回值含义:__setup 回调返回 1 表示参数被消费了,返回 0 表示没消费。内核会根据返回值决定是否继续匹配其他处理函数。这个细节很多人忽略。
  • 模块参数权限:权限位 perm 如果为 0,sysfs 节点不会创建。如果你希望参数在运行时可见可改,记得设成 0644 或 S_IRUGO|S_IWUSR。

我曾经踩过的坑:在 early_param 里调用了 printk,但 console 还没初始化,printk 只能往环形缓冲区写。等 console 初始化后,这些信息可能已经被覆盖了。调试早期启动参数时,建议用 early_printk 或者直接看串口输出。