Linux驱动开发实战:字符设备、设备树与中断并发全解析
2026/9/15 7:53:56 网站建设 项目流程

写Linux设备驱动这事,说难也难,说简单也简单。难在你得同时搞懂内核机制、硬件手册和C语言底层的那些坑;简单在一旦你把最基本的字符设备框架跑通,后面无非是往这个骨架上填中断、补并发、挂设备树。我做了十几年嵌入式Linux,带过不少人入门,最大的感受是:大部分人不是学不会,而是被一堆术语和碎片化的资料劝退了。

这篇文章就是给那些想入门Linux驱动开发,或者已经在写但总觉得根基不稳的朋友准备的。我会从最朴素的字符设备驱动讲起,一路聊到设备树匹配、中断与并发、调试技巧以及嵌入式系统裁剪部署,尽量把每一步背后的“为什么”拆开讲透。无论你是做嵌入式软件、物联网设备,还是想往内核方向深挖,这篇内容应该都能帮你把零散的知识串成一条线。

1. 设备驱动开发到底在做什么

1.1 驱动的本质与工作边界

驱动这个词被用得太滥了,Windows下装个打印机要驱动,游戏手柄要驱动,但在Linux世界里,驱动=内核模块+设备管理逻辑。它不是一个独立运行的程序,而是给内核提供“操作硬件”能力的一组函数集合。

我常跟新人打一个比方:内核是个大公司,硬件是外面的供应商,驱动就是公司里负责跟供应商对接的采购员。应用层根本不关心你对接的是哪家供应商、走什么协议,它只需要按标准流程提交申请(open/read/write),剩下的脏活累活都由采购员(驱动)搞定。供应商换了(换了芯片),采购员换一套,但公司内部流程不用变。

理解了这层,你就能明白驱动的核心职责就三件事:

  • 初始化硬件,让硬件处于可用状态(配置寄存器、申请中断、设置GPIO等);
  • 给内核提供统一的操作接口,主要是file_operations结构体里的那套回调函数;
  • 处理硬件事件,典型的就是中断:硬件完成了一次数据接收,驱动要第一时间感知并处理。

正因为驱动跑在内核态,权限极高,一个野指针就可能直接把整个系统搞崩,所以写驱动和写应用的心态是完全不一样的。应用写崩了,大不了崩溃退出;驱动写崩了,系统直接宕机,只能重启。

1.2 开发环境准备:内核源码与工具链

动手写驱动之前,环境必须搭好,这一步没做好后面全是坑。先说要准备哪些东西:

  • 一份内核源码。最稳妥的是跟目标系统完全同版本的内核源码,查uname -r确认版本号,去内核官网或厂商BSP里拿对应源码。做嵌入式的话,厂家提供的BSP(Board Support Package)往往比主线内核更实用,因为芯片平台相关的补丁都已经打好了。
  • 交叉编译工具链。如果是在开发板上跑,需要交叉编译链,比如arm-linux-gnueabihf-gccaarch64-linux-gnu-gcc。如果在PC虚拟机上调试,直接用宿主的gcc就行。
  • 根文件系统里有内核头文件。/lib/modules/$(uname -r)/build这个符号链接必须存在,编译外部模块时依赖它。

我当时图省事,直接在Ubuntu上用apt安装linux-source包,结果源码版本和运行内核差了两位版本号,编译出来的模块insmod时报"version magic"不匹配,折腾了半天才发现问题。所以第一条经验就是:内核源码版本必须和运行内核完全一致,别想当然。

另外,模块编译前建议先把内核配置好,重点确认这几项:

  • CONFIG_MODULES=y,允许加载模块;
  • CONFIG_MODULE_UNLOAD=y,允许卸载模块,调试时极其重要;
  • 自己设备相关的外设驱动如果编进内核了,可以先改成模块(=m),方便开发阶段反复加载测试。

配置用make menuconfig最直观,虽然是终端界面,但比直接改.config文件靠谱得多,因为它会自动处理依赖关系。很多新手直接改.config,改完编出来的内核缺这个缺那个,最后连启动都起不来。

2. 字符设备驱动:最小可用的驱动模型

2.1 驱动骨架与模块生命周期

万事开头难,但驱动开头的套路是固定的。任何一个可加载的内核模块,最少要有两个入口点:初始化和退出。对应到代码里就是module_init()module_exit()指定的两个函数。

#include <linux/init.h> #include <linux/module.h> static int __init my_drv_init(void) { printk(KERN_INFO "my_drv: module loaded\n"); return 0; } static void __exit my_drv_exit(void) { printk(KERN_INFO "my_drv: module unloaded\n"); } module_init(my_drv_init); module_exit(my_drv_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple demo driver");

__init__exit这两个宏值得解释一下。标记了__init的函数,在模块加载完成后,内核会把这部分代码占用的内存释放掉。因为初始化代码一次性用完就不需要了,省一点是一点。这在资源紧张的嵌入式设备上很有意义。__exit同理,如果驱动被编进内核而不是模块,这个标记会让编译器直接忽略该函数,反正也没法卸载。

从这里你能看出一个细节:内核态的内存管理思路和用户态截然不同,内核开发者对每字节都很抠。写驱动时要时刻保持这种意识,不要想当然地写一堆"反正内存够用"的代码。

2.2 file_operations与设备号的分配

上面那个模块只是个空壳,什么都干不了。要让应用层能操作你的设备,必须注册字符设备,核心是填充struct file_operations并告诉内核设备号。

设备号是设备的身份证,分主设备号和次设备号。主设备号标识设备类型对应的驱动,次设备号标识同一驱动管理的不同设备实例。分配设备号有两种方式:

// 方式一:静态指定 int major = 250; int ret = register_chrdev_region(MKDEV(major, 0), 1, "my_drv"); // 方式二:动态分配 dev_t devid; int ret = alloc_chrdev_region(&devid, 0, 1, "my_drv"); major = MAJOR(devid);

我的建议是能用动态分配就别用静态指定。静态指定得去查/proc/devices哪些主设备号还没被占,而且万一选到系统保留的号(比如1、2、5、7),注册直接失败或者跟现有驱动冲突。动态分配让内核给你一个空闲的号,省心。虽然设备号不固定会导致创建设备节点麻烦一点,但现在都有udev/mdev自动创建设备节点,这个劣势基本可以忽略。

接下来是填充file_operations。这个结构体是驱动和VFS(虚拟文件系统)之间的桥梁,应用层调用open()、read()、write(),VFS就会查表找到对应驱动的回调函数。

static struct file_operations my_drv_fops = { .owner = THIS_MODULE, .open = my_drv_open, .read = my_drv_read, .write = my_drv_write, .release = my_drv_close, };

用户空间write一次,内核最终会跳到你的my_drv_write。中间VFS层还做了一些合法性检查、锁处理、文件偏移更新等工作,这些你都不用操心。填好这个结构体,再用cdev_add()把它挂到内核里,设备就"活"了。

struct cdev my_cdev; cdev_init(&my_cdev, &my_drv_fops); my_cdev.owner = THIS_MODULE; cdev_add(&my_cdev, devid, 1);

这里还有一个容易被忽略的关键点:设备节点。你注册了字符设备,但/dev/my_drv这个文件不会自动出现。老派做法是用mknod /dev/my_drv c 250 0手动创建,但主设备号如果是动态分配的,你根本不知道是几。现代方案是在驱动里用class_create()device_create(),驱动加载时自动在/dev/下生成节点,卸载时自动删除。强烈建议用这套,省掉无数麻烦。

static struct class *my_class; my_class = class_create(THIS_MODULE, "my_drv_class"); device_create(my_class, NULL, devid, NULL, "my_drv_dev");

2.3 copy_to_user与内核态的用户态数据隔离

真正写read/write回调时,新手最容易踩的一个坑就是直接访问用户态指针。在内核里,你绝对不能像应用层那样直接*buf = xxx

原因有两层。第一层是安全:用户态传进来的指针可能是非法的,指向一个不存在的地址空间,内核直接访问会触发缺页异常,在原子上下文里直接oops。第二层是物理隔离:用户态和内核态的页表不同,内核不能假定用户态虚拟地址在自己空间内有效。所以必须用专用的拷贝函数:

static ssize_t my_drv_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { char kbuf[128]; size_t data_len; // 假设这里从硬件寄存器或内部缓冲区取到了数据,放在kbuf里 data_len = strlen(kbuf); if (copy_to_user(buf, kbuf, data_len)) { return -EFAULT; } return data_len; } static ssize_t my_drv_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { char kbuf[256]; if (len > sizeof(kbuf)) { return -EINVAL; } if (copy_from_user(kbuf, buf, len)) { return -EFAULT; } // 处理kbuf里的数据,可能写入硬件寄存器 return len; }

__user这个标记不会改变代码行为,它是给内核静态检查工具sparse用的,提醒开发者这个指针来自用户态,必须用copy_to_user/copy_from_user系列函数。

关于返回值,驱动里错误码是负的,表示出错原因,比如-EINVAL参数无效、-EFAULT地址非法、-ENOMEM内存不足、-EIO硬件IO错误。应用层拿到这些负数后,会让errno变成对应的正值,perror就能打印出原因。很多新手驱动read失败,dmesg里什么异常都没有,就是因为错误码被应用层悄悄吞了。所以调试应用和驱动的交互问题时,第一件事就是看perror打印的errno。

3. 设备树:驱动与硬件的“对暗号”

3.1 为什么必须有设备树

在老的内核版本里,板级硬件信息是用C代码硬编码的。换一块板子换一颗LED,都得改C代码重新编译内核。这样做的后果是Linux内核的arm目录下堆了几千个板级文件,谁都维护不动,所以社区搞出了设备树。

设备树的核心思想把“硬件是什么样”和“驱动怎么写”分离开。硬件用设备树来描述:这个芯片有几个串口,地址在哪里,中断挂在哪根线上;驱动只负责处理这些描述,不关心具体是谁家的板子。板子换了一个,改动只是设备树源文件(dts)而不是驱动代码。

我当时用一句话给同事解释设备树的作用:它就像一个接线说明书,内核拿到这份说明书才知道哪里有什么硬件、引脚是怎么连的。没有说明书,驱动就是瞎子。

3.2 设备树的基本结构与匹配机制

设备树的源文件扩展名是.dts,公共部分一般抽到.dtsi文件里include进来。编译工具dtc把dts编译成二进制的dtb,内核启动时解析dtb,构建设备树的内存模型。

一段设备树节点长这样:

/ { my_device: my-device@1c20000 { compatible = "vendor,my-device"; reg = <0x1c20000 0x1000>; interrupts = <0 14 4>; clock-frequency = <24000000>; }; };
  • compatible是重中之重,它是驱动和设备“对暗号”的字符串,格式一般推荐"厂商,设备型号";
  • reg表示设备的寄存器地址和范围,比如上面表示起始地址0x1c20000,长度0x1000;
  • interrupts描述中断号、触发类型;
  • 自定义属性随便加,驱动里可以用of_系列函数读取。

驱动的匹配部分长这样:

static const struct of_device_id my_drv_of_match[] = { { .compatible = "vendor,my-device", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_drv_of_match); static const struct of_device_id *of_id; static int my_drv_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct resource *res; void __iomem *base; int irq; u32 freq; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); base = devm_ioremap_resource(dev, res); irq = platform_get_irq(pdev, 0); of_property_read_u32(dev->of_node, "clock-frequency", &freq); // 用base操作寄存器,注册中断等 return 0; }

驱动用platform_driver_register()注册一个平台驱动,内核会遍历设备树上的节点,找到compatible字符串匹配的节点后,调用驱动的probe函数。probe函数拿到资源描述后,映射寄存器地址,注册中断,初始化硬件。

这套机制的好处是,硬件改动了只需要修改dts,驱动代码不用动。比如LED引脚从GPIO1_3挪到GPIO2_5,只要在dts里改GPIO号,驱动代码照样跑。平台化程度极高。

3.3 设备树配置的几个大坑

第一,改了dts没生效。嵌入式环境里dts编译成dtb后,可能是独立分区存放的,也可能被打包进内核镜像。很多人在dts里改了引脚复用,刷上去发现没反应,十有八九是dtb没更新或者flash里还是旧dtb。排查方法是在uboot里确认实际加载的dtb文件和时间戳。

第二,引脚复用(pinctrl)问题。芯片的每个引脚常有复用功能,设备树里配置了pinmux,驱动才能正常访问。dts里只写了reg但没配pinctrl,结果写寄存器没反应,这种情况很典型。要找芯片的数据手册,看清每个引脚在各个模式下的功能,再在dts里pin控制节点配置。

第三,中断号写在dts里但驱动读出来不对。这是因为很多新平台的GPIO中断是级联的,dts里的interrupts是硬件中断号,驱动里还要通过gpio_to_irq()irq_of_parse_and_map()做转换。直接拿节点里的数字当软件中断号用,很容易踩坑。

第四,compatible字符串必须完全一致,一个字符都不能差。我见过一个案例,dts里写的是"vendor,my-device",驱动of_match表里写的是"vendor,my_device"(中间是下划线),对不上,probe死活不执行,查了一个多小时才发现是这种低级错误。这种错误靠肉眼很难发现,建议把of_match表里的字符串和dts里的compatible都打出来比对。

4. 中断、并发与性能调优

4.1 中断上下文与延迟工作机制

写到这一步,驱动已经能响应应用层的open/read/write了,但现实世界是异步的——硬件随时可能产生事件。比如网卡收包了,串口收到一字节数据了,按键按下去了。这些事件需要立刻被感知,所以有了中断机制。

中断处理函数要求“快进快出”。因为中断来临时,处理器会暂停当前任务去执行中断服务程序,如果中断处理得太久,其他任务包括实时性任务都会受影响。所以Linux把中断处理分成了两部分:

  • 顶半部(top half):真正的中断处理函数,要求尽快结束,一般只做必要的硬件操作(读状态寄存器、清中断标志)和标记有事情要做;
  • 底半部(bottom half):将耗时工作延后执行,内核基于顶半部提取的信息做真正的数据处理。

底半部的实现方式有tasklet、工作队列workqueue和线程化中断request_threaded_irq。我的选择经验是:

  • 短小、不可睡眠、对延迟不敏感的处理:用tasklet,它跑在软中断上下文,不能睡眠,但开销小;
  • 处理耗时较长、可能需要睡眠(比如访问I2C设备、操作文件系统):用workqueue;
  • 只想简单地把中断处理跑在线程里:直接request_threaded_irq(irq, handler, thread_fn, flags, name, dev),中断来了内核帮你调度一个内核线程执行thread_fn。
static irqreturn_t my_drv_irq_handler(int irq, void *dev_id) { // 顶半部:读状态,清中断 u32 status = readl(reg_base + STATUS); writel(status, reg_base + STATUS); // 标记有事件发生,调度底半部 schedule_work(&my_work); return IRQ_HANDLED; } static void my_work_handler(struct work_struct *work) { // 底半部:真正耗时处理 // 注意:这里可以睡眠 }

请求中断时用devm_request_irq管理资源会自动释放,比裸request_irq少一个错误分支的处理,强烈推荐。而request_irq的最后一个参数dev_id不能传NULL,恢复中断时内核会根据dev_id区分不同设备,传NULL在共享中断场景会出问题。

4.2 自旋锁和互斥锁怎么选

驱动跑起来之后,并发问题随之而来。多个应用进程同时open同一个设备,中断处理函数和进程上下文同时访问同一份数据,如果不同步,数据就乱套了。

内核里最常用的两个锁:自旋锁(spinlock)和互斥锁(mutex)。很多人记不住区别,我给个很直白的判断标准:你的临界区代码能不能睡眠?能睡眠用mutex,不能睡眠用spinlock。

spinlock在获取不到锁的时候会原地自旋,不断检测锁是否释放,期间不释放CPU,所以临界区里不能有任何可能睡眠的操作——不能调用copy_to_user,不能kmalloc(可能触发内存回收),不能sleep。它的好处是开销小、响应快,适合保护很短很急的临界区。

mutex在获取不到锁的时候会把自己调度出去,让出CPU,代价是可能进程切换上下文,适合临界区比较长、需要睡眠的场景。

struct my_priv { spinlock_t lock; int data; }; static irqreturn_t my_drv_irq_handler(int irq, void *dev_id) { unsigned long flags; spin_lock_irqsave(&priv->lock, flags); priv->data++; spin_unlock_irqrestore(&priv->lock, flags); return IRQ_HANDLED; } static ssize_t my_drv_ioctl(...) { unsigned long flags; spin_lock_irqsave(&priv->lock, flags); // 操作共享数据 spin_unlock_irqrestore(&priv->lock, flags); }

为什么中断里用spin_lock_irqsave而不是spin_lock?因为中断里加锁时,如果这个锁正被进程上下文持有,而持有锁的进程刚好被这个中断打断,死锁就发生了——中断自旋等锁,进程被中断打断没法释放锁。irqsave会先把本地中断保存并关闭,等释放锁时再恢复,从根源上避免这种死锁。

还有一种情况是等待某件事完成,比如一个异步DMA传输要等它完结。内核里用completion机制:

struct completion xfer_done; init_completion(&xfer_done); // 在probe里初始化 // 进程上下文等待DMA完成 wait_for_completion_timeout(&xfer_done, msecs_to_jiffies(1000)); // 中断里DMA传输完成 complete(&xfer_done);

4.3 性能调优的实战经验

驱动层面的性能问题跟应用层完全不同。应用层慢,可能是算法不够优、数据库查询慢;驱动慢,往往是因为中断风暴、锁竞争、无谓的拷贝复制。

第一个经验是减少中断次数。高速设备比如网卡、ADC连续采样,每发一个中断CPU就切换一次上下文,开销巨大。大流量的场景应该启用NAPI或者环形缓冲区批量处理,每次中断把一批数据搬走,而不是一个两个地搬。对普通低速设备,也可以考虑设备侧是否有FIFO或DMA机制,尽量攒够一批再上报中断。

第二个经验是能用DMA就别用PIO。程序控制IO(PIO)就是CPU逐字节地从设备寄存器里读数据,搬到内存。DMA让设备直接往内存写,写完给个中断就完事,CPU全程不参与搬运,省下来的CPU周期可以做别的。代价是DMA对内存地址有对齐和连续性要求,所以驱动一般用dma_alloc_coherent分配DMA缓冲区。

第三个经验是能mmap就别反复copy。对于一些帧数据、显存这类大块数据,每次read都要从内核copy到用户态,开销不小。如果数据传输可以做成映射方式,用mmap把内核缓冲区直接映射到应用层,应用层读写就像操作普通内存,完全绕过copy代价。Linux的framebuffer驱动就是这个思路,所以GUI显示性能可以做得很好。

static int my_drv_mmap(struct file *filp, struct vm_area_struct *vma) { return remap_pfn_range(vma, vma->vm_start, virt_to_phys(buf) >> PAGE_SHIFT, size, vma->vm_page_prot); }

排查性能瓶颈时,先看/proc/interrupts里中断数和CPU分布,再用perf top看内核里哪个函数在耗CPU。我有一次性能调优,发现大量CPU时间耗在一个kmalloc上,意识到中断处理里频繁分配内存,改用预分配缓冲池后,吞吐直接翻倍。这种问题光看代码很难发现,必须上工具。

5. 调试方法与问题排查实录

5.1 printk的正确打开方式

内核调试没有用户态那么多好用的IDE和调试器,printk依然是最基本最可靠的调试手段。但printk也是门学问:

  • printk有8个日志级别,KERN_EMERGKERN_DEBUG,对应dmesg -n level可以控制打印阈值;
  • 开发阶段图省事全用printk(KERN_ERR)当然能出东西,但上线前一定要清理或降级,低级别printk刷屏会拖慢系统,特别是实时性要求高的场景;
  • 想要更精细的调试输出,可以用pr_debug()配合动态调试。pr_debug在默认配置下编译后会被优化掉,如果你开了CONFIG_DYNAMIC_DEBUG,它才会被保留,然后通过echo 'file my_drv.c +p' > /sys/kernel/debug/dynamic_debug/control在运行时动态打开,不用重新编译驱动就能看到调试日志。这个方法对排查线上问题极其有效。

另外就是查看内核日志要分清dmesg/var/log/kern.logdmesg显示的是ring buffer里的内容,实时性最强;/var/log/kern.log是syslog转存的,能保留更多历史。排查崩溃问题两个都要看。

5.2 /proc和/sys里藏着答案

驱动跑起来后,想知道设备和资源的状态,第一站就是/proc和/sys。

  • ls /proc/devices看到所有已注册的字符设备和块设备的主设备号,如果自己的设备不在列表里,说明cdev_add没成功;
  • cat /proc/interrupts看到中断号和中断次数,可以判断中断有没有触发、有没有触发太多导致CPU消耗过高;
  • ls /sys/class/查看驱动创建的设备类,ls -l /sys/class/my_drv_class/看设备节点关联的sysfs入口;
  • 驱动里用device_create创建一个设备后,真的会在/sys/devices/platform/下生成一个目录,里面是你在probe里设置的属性。

我自己排查问题的常规流程是:insmod成功与否看dmesg,设备节点有没有看/dev,中断触没触发看/proc/interrupts,寄存器读写对不对看/dev/mem或debugfs里导出的寄存器dump。这四个地方串一遍,大部分问题都能定位到具体层。

5.3 典型问题速查表

下面是我这些年做驱动开发遇到的高频问题,直接整理成速查表:

现象可能原因排查方向
insmod报Unknown symbol驱动引用了未导出的内核符号nm /lib/modules/$(uname -r)/build/Module.symvers检查符号
insmod报version magic不匹配内核版本或配置不一致确认源码版本与uname -r一致,检查CONFIG_LOCALVERSION
模块加载成功但没有设备节点class/device_create没执行或失败dmesg看有没有error,确认udev规则
open设备节点返回ENOENT或ENXIO设备节点的主次设备号不对或者cdev_add未成功ls -l /dev/xxx和/proc/devices对比设备号
read返回-1且errno=EFAULTcopy_to_user用了非法指针或地址空间不对检查buf是否有效,__user标记是否正确
中断不触发中断号配置错误、引脚复用没设置、设备树没匹配检查/proc/interrupts,对照数据手册查中断号
死机/内核panic空指针解引用、越界访问、自旋锁死锁、中断上下文睡眠console=ttyAMA0的串口看完整call trace,开CONFIG_DEBUG_KERNEL
内存访问异常但dmesg无输出printk优先级设置问题被过滤dmesg -n 8或者调/proc/sys/kernel/printk

内核panic时,串口输出的call trace信息极其关键。看到"Unable to handle kernel NULL pointer dereference",就去call trace里找哪个函数、哪一行触发的。用addr2line能把函数地址转成源码行号:

addr2line -e vmlinux ffffff8000123456

这个方法帮我解决过好几个崩溃问题,比盲猜效率高太多。

5.4 实时调试手段与工具清单

除了printk,还有几个工具我强烈建议驱动开发者掌握:

  • strace:跟踪应用层系统调用,可以看到open到底打开的是哪个设备、ioctl传了什么参数、read返回了什么错误。定位应用和驱动之间交互问题的一大利器。
  • ftrace:内核自带的跟踪工具,可以看到函数调用关系和时长。驱动里某个函数执行太慢,用ftrace就能看到耗时分布。
  • debugfs:内核里的一个小工具集,很多驱动会自己导出一组调试接口,比如寄存器dump、状态查询、手动触发中断。开发时建议做上,线上排障会舒服很多。
  • /dev/mem+ devmem2工具:在读寄存器时非常方便,devmem2 0x01c20000就能直接看这个地址的寄存器值。不过现在很多系统默认禁用了。CONFIG_STRICT_DEVMEM开启时会限制访问,嵌入式环境调试时可以临时关掉。

6. 从驱动到系统:嵌入式系统裁剪与部署

6.1 驱动模块与内核的编译集成方式

驱动写完,最终要部署到目标设备上。这里有两种路线:把驱动编进内核,或者编译成独立模块。两条路线的选择要分场景讲。

如果驱动是做产品的核心功能,硬件固定、不频繁改动,编进内核更稳妥。优点是启动即用,不依赖文件系统挂载后的模块加载逻辑,也不会出现模块加载顺序问题。缺点是每次改驱动都要重新编译整个内核镜像,调试周期长。

如果驱动还在开发阶段,或者功能需要支持多种设备(比如同一个固件跑在不同硬件上,一种外设可选),编译成模块更好。模块可以独立编译、独立加载,insmodrmmod循环播放,不用反复重启系统。

外部模块编译的makefile写法是固定的:

obj-m := my_drv.o KERNELDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

-C指定内核源码目录,M=$(PWD)告诉内核构建系统“去这个目录找模块源码”。

编译完的my_drv.ko拷到目标机,insmod加载,lsmod确认加载成功。加载时可以用modprobe来加载模块,它比insmod聪明的地方在于会自动解决模块依赖,比如你的模块依赖i2c-core,modprobe会根据modules.dep自动先把依赖模块加载进来。但modprobe依赖模块安装路径规范,所以常见做法是把驱动丢到/lib/modules/$(uname -r)/extra/下,跑一次depmod -a,之后就能用modprobe了。

6.2 系统裁剪的基本策略与操作

嵌入式设备的存储空间通常只有几十兆甚至几兆,而标准发行版动辄几个G,根本塞不进去。所以系统裁剪不是锦上添花,是必备技能。

裁剪的核心思路简单说就是“面向需求删东西”:

  • 内核层面:关闭用不到的驱动和子系统。比如没有USB需求就把CONFIG_USB关了,没有显卡需求就把DRM相关关掉。一次裁剪动作如果能把CONFIG_USB_SUPPORT这种大项关掉,能减少大量代码和模块。配置文件里看那些ym,反复问自己“这个功能我到底用不用”,用不到的果断关。
  • 文件系统层面:换用busybox实现常用工具,它一个二进制文件就包含了ls、cp、cat、ifconfig等几百个命令,比每个工具一个独立二进制省出几兆。然后所有工具链开静态编译,省动态库的依赖关系。
  • 启动流程层面:关掉无用的系统服务。嵌入式环境一般没有systemd那套复杂依赖,可以用简单的init脚本直接拉起业务应用。启动时间也能大幅缩短。

我做过一个项目,原厂给的镜像有500MB,客户要求固件低于64MB,最后通过裁剪内核模块、换瘦身文件系统、去掉无用的GUI组件,愣是把镜像压到了58MB,启动时间也从12秒减到了5秒。核心就一条:搞清哪些是刚需、哪些是伪需求。比如客户说“想要一个浏览器方便看文档”,但实际业务根本不需要,这种就是伪需求,砍掉能省一大坨。

6.3 驱动版本一致性与部署易错点

部署阶段有两个非常坑的问题,几乎每次团队里都有人踩:版本一致性和模块校验。

第一是vermagic。模块编译时,内核构建系统会把当前内核的版本信息、smp/抢占配置等打包成一个字符串,记录在.ko.modinfo段里。insmod加载模块时,如果这个字符串跟运行内核不一致,直接拒绝加载,报version magic不匹配。所以编译模块用的内核源码必须和运行内核一模一样,包括有没有开CONFIG_PREEMPTCONFIG_SMP等选项。

modinfo my_drv.ko | grep vermagic

如果版本不一致,常见解决办法是找到匹配的内核头文件重新编译,或者干脆把驱动改成out-of-tree时对当前内核版本设置KERNELRELEASE。但我的建议很简单:别在版本这点上抖机灵,老老实实保持源码版本一致,省下来的是自己的时间。

第二是模块卸载时的“Device or resource busy”。rmmod报这个错,说明有进程打开了设备节点没关,或者设备被引用着。排查方法:

fuser /dev/my_drv_dev lsof /dev/my_drv_dev

找到占用进程后让它退出,再rmmod。如果实在找不到是谁占的,可以查/sys/module/my_drv/refcnt看引用计数,不为0肯定有地方没释放干净。

第三是固件文件缺失。很多设备的驱动运行前需要从文件系统加载固件(比如WiFi网卡、GPU),如果你的根文件系统里没把固件文件放进去,驱动probe会卡在request_firmware超时,dmesg里能看到Direct firmware load for xxx failed。这个坑也经常出现,因为固件往往是独立于驱动代码仓库的。

写在最后的一点经验

我见过很多新手学驱动开发,拿着一本《Linux设备驱动开发详解》啃了大半本,一到动手还是懵。我的建议从来都是:先搭一个最小框架,点亮一颗LED或者读一个按键,把字符设备、设备树、中断这三座大山翻过去,后面就是熟能生巧的事。

驱动开发的本质不是背API,而是养成“内核态的思维”——时刻想着资源生命周期、并发安全、中断上下文约束,以及每一行代码在目标硬件上怎么执行。你踩过的每个坑、遇到的每一次panic,都是别人拿不走的经验。这也是为什么驱动开发这个方向虽然门槛高,但真正深入进去之后,周围能跟你聊到一块的人寥寥无几,正好说明这行的壁垒和含金量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询