模块参数与导出符号:让驱动更灵活

写驱动的时候,我经常遇到一个尴尬的情况:同一个驱动,在不同硬件上要配置不同的参数。比如中断号、缓冲区大小、采样率这些。难道每次都要重新编译内核模块?

当然不用。Linux 内核提供了两个很实用的机制——模块参数导出符号。说白了,一个让用户能在加载模块时传参,另一个让模块之间能共享函数和变量。

今天我们就来聊聊这两个东西。我会结合我实际踩过的坑,带你一次性搞明白。

模块参数:让驱动学会「听话」

先说说模块参数。它的作用很简单:在 insmod 或 modprobe 加载模块时,动态传入配置值

比如你写了一个字符设备驱动,缓冲区大小想留给用户决定。那你就可以定义一个模块参数:

#include <linux/module.h>
#include <linux/moduleparam.h>

static int buffer_size = 1024;
module_param(buffer_size, int, 0644);
MODULE_PARM_DESC(buffer_size, "缓冲区大小,默认1024字节");

加载的时候这样用:

sudo insmod my_driver.ko buffer_size=4096

嗯,这里要注意:module_param 的第三个参数是文件权限。0644 表示在 /sys/module/ 下能看到这个参数,并且 root 可以改。如果你不想暴露给用户空间,传 0 就行。

我的习惯:调试阶段权限给 0644,方便改参数。正式发布前改成 0,避免用户乱改导致驱动崩溃。

参数类型与数组参数

模块参数支持的类型不少:int、long、charp(字符串指针)、bool 等等。我列个表给你看:

类型宏 C 类型 说明
module_param(name, int, perm) int 整型参数
module_param(name, long, perm) long 长整型
module_param(name, charp, perm) char * 字符串指针
module_param(name, bool, perm) bool 布尔值
module_param_array(name, type, num, perm) 数组 数组参数,num 返回元素个数

数组参数稍微特殊一点。我记得有一次我需要传多个 GPIO 号,就用到了数组参数:

static int gpio_pins[4];
static int gpio_count;
module_param_array(gpio_pins, int, &gpio_count, 0644);
MODULE_PARM_DESC(gpio_pins, "GPIO引脚列表,最多4个");

加载时这样传:

sudo insmod my_driver.ko gpio_pins=17,22,27,5

内核会自动解析逗号分隔的值,gpio_count 会变成 4。

我曾经踩过的坑:数组参数如果传的值个数超过数组大小,内核不会报错,只会截断。所以一定要检查 gpio_count 是否合理,否则访问越界就 Oops 了。

导出符号:让模块之间「互通有无」

模块参数是让用户和驱动交互。那驱动和驱动之间怎么交互?

答案就是 EXPORT_SYMBOL。它可以把一个函数或变量导出,让其他模块也能调用。

举个例子。你写了一个底层硬件操作模块,封装了 SPI 读写函数。另一个模块想用这些函数,就可以通过导出符号来实现。

// spi_core.c
#include <linux/module.h>

int spi_read_reg(unsigned int addr)
{
    // 硬件操作...
    return 0;
}
EXPORT_SYMBOL(spi_read_reg);

int spi_write_reg(unsigned int addr, int val)
{
    // 硬件操作...
    return 0;
}
EXPORT_SYMBOL(spi_write_reg);

另一个模块使用时,只需要声明一下:

// sensor_driver.c
extern int spi_read_reg(unsigned int addr);
extern int spi_write_reg(unsigned int addr, int val);

void sensor_init(void)
{
    int val = spi_read_reg(0x10);
    // ...
}

编译时,两个模块各自编译成 .ko。加载时先加载 spi_core.ko,再加载 sensor_driver.ko。

核心要点:导出符号的模块必须先加载,否则依赖它的模块会加载失败。dmesg 会告诉你「Unknown symbol」。

EXPORT_SYMBOL_GPL 与 EXPORT_SYMBOL 的区别

你可能还会看到 EXPORT_SYMBOL_GPL。它和 EXPORT_SYMBOL 的区别只有一个:许可证限制

  • EXPORT_SYMBOL:任何模块都能用,不管它是什么许可证。
  • EXPORT_SYMBOL_GPL:只有 GPL 许可证的模块才能用。

我个人习惯:能加 GPL 就加 GPL。这不仅是法律层面的考虑,也是一种技术态度——你用了我的代码,你的模块也得开源。

当然,如果你写的是公司内部驱动,不想被外部模块依赖,用 EXPORT_SYMBOL_GPL 也能起到一定的隔离作用。

多文件模块的编译

实际项目中,驱动代码不可能全写在一个文件里。比如 spi_core.c、i2c_core.c、gpio_utils.c 各司其职,最后编译成一个 .ko。

怎么做?很简单,在 Makefile 里列出所有源文件:

obj-m := my_driver.o
my_driver-objs := main.o spi_core.o gpio_utils.o

这里 my_driver-objs 指定了组成最终模块的目标文件。内核构建系统会自动编译每个 .c 文件,然后链接成 my_driver.ko

完整的 Makefile 示例:

obj-m := my_driver.o
my_driver-objs := main.o spi_core.o gpio_utils.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean
我的建议:每个 .c 文件只做一件事。比如 spi_core.c 只放 SPI 操作,gpio_utils.c 只放 GPIO 操作。这样代码清晰,也方便复用。

多文件模块的符号可见性

多文件编译时,有一个细节容易忽略:文件之间的符号可见性

默认情况下,同一个模块内的 .c 文件之间,函数和全局变量是互相可见的。但如果你用 static 修饰了函数或变量,它就只在当前文件可见。

举个例子:

// spi_core.c
static int spi_lock;  // 只在 spi_core.c 可见
int spi_read_reg(unsigned int addr) { ... }  // 模块内可见
EXPORT_SYMBOL(spi_read_reg);  // 所有模块可见

所以,模块内部共享的函数,不要加 static,也不要加 EXPORT_SYMBOL。只有你想让其他模块调用的函数,才需要导出。

我曾经犯过的错:把所有函数都加上了 EXPORT_SYMBOL,结果内核符号表被污染了。后来排查问题时,发现一堆不该导出的符号。从那以后,我坚持「按需导出」原则。

本章知识体系

下面这张图总结了模块参数和导出符号的核心逻辑:

模块参数与导出符号知识体系 模块参数 (module_param) 用户空间 → 内核驱动传参 基本类型参数 数组参数 字符串参数 权限控制 (perm) 导出符号 (EXPORT_SYMBOL) 模块之间共享函数/变量 EXPORT_SYMBOL EXPORT_SYMBOL_GPL 加载顺序依赖 许可证限制 多文件模块编译 Makefile 配置 符号可见性 按需导出

这张图把三个核心知识点串在了一起。左边是模块参数,右边是导出符号,下面是多文件编译。你写驱动时,这三者经常要配合使用。

好了,这一章的内容就到这里。模块参数和导出符号是驱动开发中非常实用的工具。掌握了它们,你的驱动就不再是「死代码」,而是可以灵活配置、模块化组合的「活代码」。


公众号:蓝海资料掘金营,微信deep3321