嵌入式Linux驱动开发实战:从字符设备到中断的完整源码解析 简介这份资源是华清远见嵌入式培训的驱动源码合集面向具备一定C语言与Linux基础、希望深入理解嵌入式驱动开发的学习者可用于对照课程实验、复现板级驱动案例并梳理内核模块的编译与加载流程。压缩包共286个文件约4.97MB以98个C源文件为核心配合36个Makefile、30个cmd脚本与23个readme说明另有vsd图、ko模块、symvers与order等编译产物覆盖字符设备、DM9000网卡、按键及e2prom测试等典型驱动场景。已有580人学习下载。源码按实验模块组织读者可从中观察file_operations结构体、中断注册、设备树匹配与DMA传输的实际写法并借助Makefile与cmd脚本完成模块编译、加载和日志调试适合作为驱动开发入门到进阶的实践参考。1. 从一块点不亮的开发板说起这套驱动源码到底能帮你什么很多人第一次碰嵌入式 Linux板子上电、串口有输出觉得自己已经跨过门槛了结果一写字符设备驱动insmod之后dmesg一片安静/dev下啥也没有瞬间从“我会嵌入式”跌回“我连灯都点不亮”。这套华清远见培训体系的驱动源码解决的正是这个断层它把 Linux 内核模块、字符设备、平台总线、中断、I2C/SPI、输入子系统这些培训课上讲一遍就过的内容落成了一堆能编译、能加载、能对应到具体硬件行为的.c文件。适合两类人一是刚学完 C 和 Linux 基础、想照着把驱动跑一遍的初学者二是带学生做课设、需要一份结构完整、注释密度够的参考工程的开发者。它不追求产品级完备但胜在链路完整——从 Makefile 到file_operations到硬件寄存器操作一条线能走通。2. 工程结构与技术底座先看清这套源码是怎么组织的2.1 目录分层与模块划分拿到压缩包解压后第一件事不是急着make而是先tree -L 2看一眼。这类培训驱动源码通常按“外设类型 实验编号”两层组织常见结构大致如下不同批次会有出入以实际目录为准driver_src/ ├── Makefile # 顶层编译入口通常只做 SUBDIRS 递归 ├── include/ # 公共头文件寄存器宏、ioctl 命令码 ├── 01_hello/ # 最简模块验证编译链 ├── 02_chrdev/ # 字符设备open/read/write/ioctl ├── 03_platform/ # 平台总线device 与 driver 分离 ├── 04_irq/ # 中断申请与下半部 ├── 05_i2c/ # I2C 客户端驱动 ├── 06_spi/ # SPI 传输 └── 07_input/ # 输入子系统上报按键每个子目录里基本是“一个.c 一个Makefile”的最小组合公共逻辑抽到include/。这种划分的好处是每个实验自包含坏处是重复代码多——比如module_init/module_exit模板在每个文件里都抄了一遍。二次开发时我一般会把chrdev的注册/注销封装成一个misc风格的小工具函数后面所有实验复用能省掉大量复制粘贴。顶层 Makefile 通常长这样注意KERNELDIR必须指向你板子对应的内核源码树而不是主机/lib/modules下的头文件# 顶层 Makefile递归进入各实验目录 KERNELDIR ? /home/user/linux-4.9.88 # 改成你的内核源码路径 PWD : $(shell pwd) all: for dir in $(shell ls -d */ | grep -v include); do \ $(MAKE) -C $$dir KERNELDIR$(KERNELDIR) || exit 1; \ done clean: for dir in $(shell ls -d */ | grep -v include); do \ $(MAKE) -C $$dir clean; \ doneKERNELDIR是整套源码能不能编过的命门内核模块编译时要用目标内核的Module.symvers和配置主机内核版本和板子内核版本对不上编出来的.ko加载时会报version magic不匹配。-C $$dir表示切到子目录执行子目录 Makefile 里再走标准的obj-m流程。2.2 技术栈与运行环境这套代码的技术栈非常“纯”C 语言 Linux 内核模块 API 板级硬件手册没有用户态框架、没有第三方库。运行环境是交叉编译主机上装好arm-linux-gnueabihf-gcc工具链内核源码树配好.configmake ARCHarm CROSS_COMPILEarm-linux-gnueabihf- modules出.ko再通过 NFS 或scp传到板子上insmod。组成作用常见取值交叉工具链编译 ARM 目标代码arm-linux-gnueabihf-内核源码树提供Module.symvers、头文件与板子内核版本严格一致根文件系统提供insmod/rmmod/dmesgbusybox 即可传输方式把.ko送到板子NFS 挂载或scp新手最容易忽略的是“内核版本必须一致”这条。培训环境里通常给的是配套的 BSP 包内核源码和板子镜像是一起发的所以能直接编过一旦你换成自己下载的内核Module.symvers缺失insmod就会报Unknown symbol。这不是代码问题是环境问题排查时先uname -r对一下版本。2.3 编译与加载的最小闭环在动任何业务逻辑之前先用01_hello把“编译—传输—加载—卸载”这条闭环跑通这是后面所有实验的地基。子目录 Makefile 是标准写法# 01_hello/Makefile obj-m : hello.o KERNELDIR ? /home/user/linux-4.9.88 PWD : $(shell pwd) modules: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) cleanobj-m声明编成模块M$(PWD)告诉内核构建系统“只编当前目录的外部模块”不重编整个内核。编完得到hello.ko传到板子后insmod hello.ko # 加载触发 module_init lsmod | grep hello # 确认已加载 dmesg | tail -5 # 看 printk 输出 rmmod hello # 卸载触发 module_exitinsmod成功但dmesg没输出八成是printk的日志级别被过滤了用dmesg -n 8放开或者直接看printk(KERN_INFO ...)的级别。这一步跑通说明工具链、内核树、传输链路全对后面再出问题就只可能是驱动逻辑本身。3. 核心驱动模块拆解从字符设备到中断的完整链路3.1 字符设备file_operations 与设备号注册字符设备是这套源码里出现频率最高的模板02_chrdev基本就是后面所有外设驱动的骨架。核心是三步申请设备号、实现file_operations、把cdev挂到内核。下面这段是精简后的关键逻辑#include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #define DEV_NAME demo_chr static dev_t devno; // 主次设备号 static struct cdev demo_cdev; static char kbuf[64]; // 内核缓冲区 static int demo_open(struct inode *inode, struct file *filp) { printk(KERN_INFO demo open\n); return 0; } static ssize_t demo_read(struct file *filp, char __user *ubuf, size_t len, loff_t *off) { int ret; if (*off sizeof(kbuf)) // 已读到末尾 return 0; ret copy_to_user(ubuf, kbuf, sizeof(kbuf)); // 内核态拷到用户态 if (ret) return -EFAULT; *off sizeof(kbuf); return sizeof(kbuf); } static ssize_t demo_write(struct file *filp, const char __user *ubuf, size_t len, loff_t *off) { if (len sizeof(kbuf)) len sizeof(kbuf); if (copy_from_user(kbuf, ubuf, len)) // 用户态拷到内核态 return -EFAULT; return len; } static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .read demo_read, .write demo_write, }; static int __init demo_init(void) { int ret; ret alloc_chrdev_region(devno, 0, 1, DEV_NAME); // 动态申请设备号 if (ret) return ret; cdev_init(demo_cdev, demo_fops); demo_cdev.owner THIS_MODULE; ret cdev_add(demo_cdev, devno, 1); // 注册到内核 if (ret) { unregister_chrdev_region(devno, 1); return ret; } printk(KERN_INFO major%d minor%d\n, MAJOR(devno), MINOR(devno)); return 0; } static void __exit demo_exit(void) { cdev_del(demo_cdev); unregister_chrdev_region(devno, 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);几个参数必须说清alloc_chrdev_region是动态申请主设备号由内核分配好处是不会和已有设备冲突代价是每次加载主设备号可能变所以用户态要用mknod手动建节点或者靠udev/mdev自动建copy_to_user/copy_from_user是内核态和用户态之间唯一安全的拷贝方式直接解引用用户指针会触发 oops*off是文件偏移read里不更新它cat会无限循环读同一段数据。这套模板跑通后把demo_read/demo_write换成寄存器读写就是一个真实外设驱动了。3.2 平台总线device 与 driver 的分离写法03_platform是培训里最能体现“Linux 驱动分层思想”的一节。平台总线把“硬件资源”和“驱动逻辑”拆成两个结构体platform_device描述寄存器地址、中断号platform_driver描述probe/remove。匹配靠name字符串匹配成功才调probe/* 设备侧描述硬件资源 */ static struct resource demo_res[] { [0] { .start 0x12340000, // 寄存器物理基地址 .end 0x123400ff, .flags IORESOURCE_MEM, }, [1] { .start 45, // 中断号以板级手册为准 .flags IORESOURCE_IRQ, }, }; static struct platform_device demo_pdev { .name demo_plat, .id -1, .resource demo_res, .num_resources ARRAY_SIZE(demo_res), }; /* 驱动侧拿到资源后做映射和初始化 */ static int demo_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(pdev-dev, res); // 物理地址映射到虚拟地址 if (IS_ERR(base)) return PTR_ERR(base); /* 后续对 base 做 readl/writel 操作 */ return 0; } static struct platform_driver demo_pdrv { .probe demo_probe, .remove demo_remove, .driver { .name demo_plat, // 必须和设备侧 name 一致 }, }; module_platform_driver(demo_pdrv);devm_ioremap_resource是带资源管理的映射驱动卸载时自动iounmap比手动ioremap/iounmap少一类泄漏。platform_get_resource的第三个参数是同类资源的索引一个设备有多个内存段时靠它区分。这里最常见的翻车点是name拼写不一致——设备叫demo_plat、驱动写成demo-platprobe永远不触发dmesg里连报错都没有只能靠ls /sys/bus/platform/devices/和drivers/对照着看。3.3 中断处理申请、上半部与下半部04_irq演示的是按键中断。中断处理的核心约束是“上半部要快”耗时操作丢到下半部。培训代码通常用request_irqtasklet或工作队列static irqreturn_t demo_irq_handler(int irq, void *dev_id) { /* 上半部只做最轻量的确认和调度 */ tasklet_schedule(demo_tasklet); return IRQ_HANDLED; } static void demo_tasklet_fn(unsigned long data) { /* 下半部可以睡眠以外的耗时操作 */ printk(KERN_INFO key pressed, do deferred work\n); } static int demo_probe(struct platform_device *pdev) { int irq platform_get_irq(pdev, 0); int ret; tasklet_init(demo_tasklet, demo_tasklet_fn, 0); /* IRQF_TRIGGER_FALLING 要和硬件实际电平匹配 */ ret request_irq(irq, demo_irq_handler, IRQF_TRIGGER_FALLING, demo_key, pdev); if (ret) { printk(KERN_ERR request_irq failed: %d\n, ret); return ret; } return 0; }request_irq的第四个参数是中断名会出现在/proc/interrupts里调试时靠它定位第五个参数dev_id会原样传回中断处理函数共享中断时用来区分设备。IRQF_TRIGGER_FALLING配错是经典坑按键是低电平有效还是下降沿触发得看原理图配成上升沿就会出现“按下去没反应、松手才触发”的玄学现象。另外tasklet运行在软中断上下文里面不能睡眠要睡眠就用工作队列。4. 避坑与排查加载失败、无输出、设备节点缺失怎么定位4.1 insmod 报 Invalid module format现象是insmod: cant insert xxx.ko: Invalid module formatdmesg里跟着一行version magic 4.9.88 SMP mod_unload ARMv7 should be 4.9.88 ...。原因几乎都是编译用的内核源码树和板子运行的内核不是同一份vermagic对不上。解决办法是拿板子 BSP 配套的内核源码重新编或者确认KERNELDIR指向的是板子内核的构建目录而不是主机/lib/modules/$(uname -r)/build。别想着改vermagic糊弄过去结构体布局不一致照样崩。4.2 加载成功但 dmesg 没有任何输出lsmod能看到模块dmesg却干净得像没加载。先确认printk的日志级别默认控制台级别可能把KERN_INFO过滤了cat /proc/sys/kernel/printk看第一个数字小于 6 就看不到 info。临时用dmesg -n 8放开或者把printk改成KERN_ERR验证。还有一种情况是module_init函数根本没被调用——检查是不是把module_init写在了函数定义之前或者__init标记用错导致函数被丢弃。4.3 /dev 下没有设备节点驱动加载了、dmesg也打印了主设备号但/dev/demo_chr不存在。字符设备不会自动建节点要么手动mknod /dev/demo_chr c major 0要么依赖mdev/udev根据/sys/class/...自动创建。培训环境多用 busybox 的mdev需要在init脚本里mdev -s并确保/sys已挂载。手动mknod时主设备号填错open会报No such device对着dmesg打印的major核对一遍。4.4 probe 函数不执行平台设备注册了、驱动也加载了probe就是不进。按顺序查三处name是否完全一致包括大小写和连字符、设备是否真的注册成功ls /sys/bus/platform/devices/、驱动是否绑定成功ls /sys/bus/platform/drivers/name/。还有一种隐蔽情况是probe返回了非 0内核会解绑并打印probe failed但日志级别低时容易被忽略dmesg | grep -i probe专门捞一下。4.5 中断申请失败返回 -EBUSYrequest_irq返回-16说明这个中断号已经被别的驱动占了。cat /proc/interrupts看对应中断号那一行是谁在用。常见于板子上同一个 GPIO 中断被多个实验重复申请前一个模块没rmmod干净。另外共享中断要加IRQF_SHARED标志且dev_id必须唯一否则也会被拒。5. 二次开发把培训模板改成能复用的驱动骨架这套源码最大的价值不是“能跑”而是“能改”。培训代码为了教学清晰每个实验都是独立文件、重复模板直接拿去做项目会显得散。我的改造思路是抽一个公共的chrdev工具层把设备号申请、cdev注册、class_create/device_create自动建节点封装成demo_chrdev_register(name, fops)和demo_chrdev_unregister()两个函数所有外设驱动只写自己的file_operations和硬件操作注册注销一行调用。这样新增一个传感器驱动代码量能从两百行压到八十行。第二个改造点是资源管理全面转向devm_*系列。培训代码里大量手动ioremap/kfree/request_irq配goto err_xxx的清理链容易漏。改成devm_ioremap_resource、devm_request_irq、devm_kzalloc之后remove函数基本可以空着内核在解绑时按注册逆序自动释放少写一堆错误分支。代价是要求内核版本不太老3.5 以后都支持培训用的 4.x 内核完全够。第三个是验证方法。改完别急着上板子先在主机上做静态检查make C1走 sparse 检查指针地址空间标注__user、__iomem有没有漏make W1打开额外警告。这两个开关能提前抓出“用户指针直接解引用”“ioremap 返回值没标注”这类会在板子上变 oops 的问题。上板后按“加载看 dmesg → 建节点 → 读写验证 → 卸载看有无泄漏”四步走每步都留日志。改造项改造前改造后收益设备注册每个文件重复写公共chrdev工具层新增驱动少写百行资源释放手动 goto 清理devm_*自动释放消除泄漏分支静态检查无make C1 W1提前发现地址空间错误有个血泪教训我早期改这类模板时图省事把copy_to_user的返回值直接忽略主机上跑不出问题板子上偶发一次 oops查了两天才定位到是用户态传了个非法地址。从那以后我每次改read/write路径都强制走一遍“非法地址 超长长度 偏移越界”三个异常用例copy_*_user的返回值一个都不放过。这套源码拿来练手正好改坏了重新解压就是成本低但养成的习惯会跟着你很久。希望帮到你。本文还有配套的精品资源点击获取