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 图。你跟着箭头走一遍,应该就清楚了:
从这张图你能看到,参数解析是分阶段的。越早注册的参数,能用的基础设施越少。这也是为什么 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 或者直接看串口输出。