简介:一份面向嵌入式入门与进阶读者的华清远见培训驱动源码集合,围绕Linux字符设备、块设备和网络设备展开,覆盖设备驱动模型、file_operations结构、设备树、中断处理、DMA传输等核心知识,适合正在系统学习驱动开发或准备嵌入式项目实践的读者对照源码理解内核运作。压缩包共286个文件,其中98个C源文件与23个readme构成主要学习主线,36个Makefile用于编译,9个ko内核模块可直接加载观察效果,30个cmd脚本辅助实验,24个vsd原理图补充硬件连接背景;整体仅4.97MB,便于快速下载并在测试环境使用。资料整理自华清远见培训实战项目,收录dm9000网卡驱动、sbull块设备驱动、fs210按键驱动等典型示例,代码中体现I/O读写、设备注册注销、中断服务、电源管理、DMA传输及GPIO/SPI/I2C等接口编程要点。已有576人学习下载,对希望借助真实驱动代码提升实践能力的读者具有较强参考价值。
1. 这套驱动源码值得静下心啃一遍:嵌入式Linux驱动的入门搭子
很多做嵌入式的人,C语言和单片机玩得挺溜,一碰到Linux驱动就蔫了。原因倒不复杂:驱动不是“写逻辑”,而是“跟内核对话”,缺了一份能跑、能拆、能对比的完整源码,光看书很容易卡在概念上。这套培训用的驱动源码,就是一份罕见的完整嵌入书——它不是东拼西揍的零散例程,而是把字符设备、平台设备、设备树、中断、并发控制串起来的一套资料。适合刚放下单片机、准备往Linux方向走的开发者,也适合写应用写了几年、想补驱动短板的工程师。它的价值在于:所有机制都落在可编译、可加载的代码里,跑一遍比看十遍书管用。
2. 源码目录与驱动框架:字符设备、平台设备、设备树三种套路怎么选
2.1 源码目录怎么读:先看入口 Makefile 还是先看 Kconfig
拿到这套驱动源码,第一反应肯定是打开文件夹挨个看.c文件,这个习惯得改。源码通常有几十个文件,没有讲解文档时,最靠谱的入口是根目录的Makefile。Makefile里藏着三个关键信息:编译目标、内核源码路径、交叉编译前缀。看懂这三行,整套源码的工程结构就清楚了一半。
一个典型的驱动模块Makefile长这样:
# 生成的目标模块名和组成源文件 obj-m := led_drv.o led_drv-objs := led_core.o led_platform.o # 内核源码树路径和架构 KERNELDIR := /home/user/linux-kernel ARCH := arm CROSS_COMPILE := arm-linux-gnueabihf- all: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) cleanobj-m告诉内核构建系统把哪些源文件编成模块,led_drv-objs把多个源文件合并成一个模块,这个在源码里很常见,因为培训例程往往把一个功能拆成多个文件,方便单独讲。KERNELDIR指向内核源码,不是指向开发板厂家的BSP包,而是你要在内核源码目录里完成模块编译。ARCH和CROSS_COMPILE缺一不可,前者指定ARM架构,后者指定编译器前缀,编译时make会用它拼出arm-linux-gnueabihf-gcc。很多新人直接把x86上的内核源码路径填进来,编出的模块加载就报错,这点后面避坑章节会细说。
2.2 字符设备驱动骨架:从 module_init 到 file_operations
这套资源里数量最大的就是字符设备驱动,涵盖LED、按键、ADC、PWM这类外设。字符设备的套路非常固定:注册设备号、初始化cdev、实现file_operations、把操作接口挂进内核。源码里的核心结构基本是这个样子:
#include <linux/module.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #define DEVICE_NAME "demo_dev" static int demo_major = 0; static struct cdev demo_cdev; static struct class *demo_class; static int demo_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "demo: open called\n"); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { /* 拷贝数据到用户空间的典型写法 */ if (copy_to_user(buf, "hello\n", 6)) return -EFAULT; return 6; } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, }; static int __init demo_init(void) { dev_t devid; if (demo_major) { devid = MKDEV(demo_major, 0); register_chrdev_region(devid, 1, DEVICE_NAME); } else { alloc_chrdev_region(&devid, 0, 1, DEVICE_NAME); demo_major = MAJOR(devid); } cdev_init(&demo_cdev, &demo_fops); cdev_add(&demo_cdev, devid, 1); demo_class = class_create(THIS_MODULE, "demo_class"); device_create(demo_class, NULL, devid, NULL, DEVICE_NAME); return 0; } static void __exit demo_exit(void) { dev_t devid = MKDEV(demo_major, 0); device_destroy(demo_class, devid); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(devid, 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");这段代码几乎覆盖了字符设备驱动的全部知识点。alloc_chrdev_region是动态分配主设备号,避免手动指定主设备号时和已有设备冲突;cdev_add之后,内核才真正承认这个设备存在,应用程序才能通过设备节点访问。class_create和device_create是配套动作,前者在/sys下创建类,后者负责在/dev下自动生成节点,没有这两行,insmod成功了也看不到设备文件。注意copy_to_user不能用memcpy替代,因为用户态指针可能还没映射到内核地址空间,这是驱动和普通C代码最大的区别之一。
源码里这类驱动很好认,只要看到file_operations结构体且里面的read/write直接对硬件寄存器操作,就是典型的字符设备驱动。学习时先找一个只有open/read的例程,把init和exit流程背下来,再去看带硬件操作的版本,会顺很多。
2.3 平台设备与设备树:驱动与硬件描述如何解耦
这套资料里另一大类是平台设备驱动,这类驱动出现在SoC内部外设场景,比如串口控制器、I2C控制器、GPIO控制器。它和字符驱动的最大区别是:驱动不自己找硬件地址,而是等待内核把硬件信息“喂”过来。硬件信息在设备树里描述,驱动通过compatible字段和设备树节点匹配。
设备树里通常会有一段这样的描述:
led: led@12000000 { compatible = "vendor,led"; reg = <0x12000000 0x1000>; interrupts = <GIC_SPI 42 IRQ_TYPE_LEVEL_HIGH>; };驱动侧对应的匹配表写法是:
static const struct of_device_id led_of_match[] = { { .compatible = "vendor,led" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_of_match); static int led_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); base = devm_ioremap(&pdev->dev, res->start, resource_size(res)); /* 之后用base操作寄存器 */ return 0; } static struct platform_driver led_driver = { .probe = led_probe, .driver = { .name = "led", .of_match_table = led_of_match, }, }; module_platform_driver(led_driver);这里platform_get_resource从设备树取寄存器地址和中断号,devm_ioremap把物理地址映射为内核虚拟地址,devm_前缀的好处是资源自动管理,probe失败或驱动卸载时内核帮忙释放,不用手动ioremap和iounmap对对齐。module_platform_driver是一个封装宏,展开后就是module_init加module_exit,减少样板代码。
源码里平台设备驱动和字符设备驱动往往混在一起,判断方法是看probe函数是否存在。有probe的说明硬件资源和驱动分离,属于现代推荐的写法;直接用固定地址ioremap的,多半是教学为了简化而保留的老式写法。学习时建议优先看probe版本,因为实际项目里设备树已经普及,老式写法除了维护老平台基本用不上。
3. 把驱动跑起来:交叉编译、内核树准备与开发板部署
3.1 交叉编译工具链:版本错了后面全白搭
源码在手,第一件事不是读代码,而是先把编译环境理清楚。这套驱动源码面向ARM开发板,必须在x86主机上用交叉编译器构建。我说的“版本错了全白搭”不是夸张——编译器版本和内核编译时用的版本差异过大,编出来的模块加载时会直接报version magic不匹配,连insmod都过不去。
交叉编译环境最少满足三个条件:编译器支持目标架构、内核源码树和目标板运行内核版本一致、编译模块时ARCH和CROSS_COMPILE变量正确。通常培训机构会随资料附带工具链路径,或者提示用arm-linux-gnueabihf-系列,但如果资料里没写,优先从开发板厂商的BSP里找工具链,因为厂商会拿同一套工具链去编内核和模块,兼容性最有保障。
配置环境变量时,我习惯把工具链路径写进~/.bashrc:
export PATH=$PATH:/opt/arm-toolchain/bin export ARCH=arm export CROSS_COMPILE=arm-linux-gnueabihf-ARCH=arm不能省,否则内核构建系统会按x86处理,头文件路径、配置项全错。CROSS_COMPILE是前缀,make在编译时会自动补全成arm-linux-gnueabihf-gcc、arm-linux-gnueabihf-ld这些命令。这两个变量放~/.bashrc里只对当前用户生效,不影响系统其他项目,比每次make时手动敲要稳。
3.2 内核树的两种使用方式:全量编译 vs 模块编译
交叉编译驱动模块前,需要先有一套和开发板运行内核对应的内核源码,并且已经完成了基本配置。所谓“对应”包含两层意思:版本号一致,比如都是4.19.x;配置项接近,至少内核源码里的Module version magic要和目标板一致。
实际操作中常见两种路径。第一种是整套内核源码,先在目标板运行的内核配置基础上跑一遍全量编译,之后再用同一套源码编模块;第二种是只把内核源码解压出来,用厂商提供的config文件生成Module.symvers,然后直接编模块,不做完整内核镜像。第一种最稳,缺点是全量编译一次要二三十分钟;第二种快,但自己改过内核配置后容易出问题。
编译模块的命令套路在Makefile里已经写好,手动执行时是这样的:
make -C /path/to/kernel M=/path/to/led_drv ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- modules-C指定内核源码目录,M=指定驱动源码目录,两者必须写绝对路径。编完会在驱动源码目录生成.ko文件。注意:这一步不会编译整个内核,只编译模块,所以速度很快,几秒到十几秒。如果报缺少Module.symvers或vmlinux等文件,说明内核源码没有先完成配置,需要先回到内核源码目录执行make modules_prepare。
3.3 insmod 之后的事:加载、卸载与 dmesg 日志定位
编译出.ko之后,把它复制到开发板(NFS、U盘、adb push都可以),接下来就是加载和验证。基本命令就三条:insmod、rmmod、dmesg。
insmod led_drv.ko lsmod | grep led rmmod led_drv dmesg | tail -20很多新手习惯加载后直接找/dev下的节点,找不到就以为驱动有问题。实际上字符设备驱动如果用了class_create和device_create,节点会自动出现;如果源码里只有register_chrdev_region和cdev_add,没有device_create,那就必须在加载后手动创建节点:
cat /proc/devices | grep led mknod /dev/led_drv c 240 0/proc/devices里能看到设备号和设备名,mknod的c表示字符设备,240和0就是主设备号和次设备号。如果每次重启都要手动mknod,说明驱动代码少了设备创建部分,这在老式教学源码里很常见,不是板子问题。insmod和modprobe的区别也值得说一句:insmod直接加载指定路径的模块,不处理依赖;modprobe会自动解析/lib/modules/$(uname -r)下的依赖关系。培训源码里模块少,用insmod够了。
4. 驱动调试避坑指南:版本魔法字、节点缺失与设备树不生效
4.1 现象:编译模块提示 version magic 不匹配
现象:insmod一个编好的.ko,终端返回version magic '4.19.66' should be '4.19.57'或类似的错误,模块加载失败。
原因:模块编译时使用的内核源码版本和开发板上运行的内核版本不一致。很多培训源码默认内核版本是固定的,但开发板厂商后续升级过内核,没同步更新资料里的内核树,就会撞上这个错。另外,内核配置里的CONFIG_MODVERSIONS和CONFIG_LOCALVERSION也会参与生成version magic字符串,只要有一项不一样,insmod就会拒绝。
解决:先用uname -r查看开发板内核版本,再确认编译用的内核源码版本是否完全一致。一致之后,确认内核源码的.config和开发板.config一致,或者直接进入内核源码目录重新执行make modules_prepare后再编模块。临时方案是给内核传modprobe.force_unsafe_modules=1强制加载,但这只是绕过机制,模块行为可能不可控,不建议在生产环境用。
4.2 现象:insmod 成功但 /dev 节点没出现
现象:insmod没有报错,lsmod也能看到模块,但/dev下找不到对应设备节点,应用程序打不开设备文件。
原因:驱动源码里没有class_create和device_create,内核虽然注册了设备号,但没有通过设备模型在用户空间建立节点。旧式教学代码图省事只做register_chrdev_region,或者用了register_chrdev这个老接口,不会自动生成节点。
解决:先cat /proc/devices看模块注册的设备号和名称,然后用mknod手动创建。想彻底解决,在驱动init函数里补上class_create和device_create两行,并确保卸载时调用device_destroy和class_destroy。补完之后重新编译加载,/dev节点就能自动出现,不用每次手动建。
4.3 现象:设备树改了没生效,设备根本没 probe
现象:修改了dts或dtsi文件,编译并通过fastboot或tftp烧录了dtb,重启后驱动probe函数没有被调用,dmesg里啥也没有。
原因:最常见的原因是dtb烧进了boot分区,但bootloader没加载新的dtb;其次是dts里节点的compatible字段和驱动的of_match_table里的字符串不完全一致,差一个字母都匹配不上。还有一种是节点状态没设为status = "okay",默认的disabled会让内核直接跳过这个节点。
解决:先用开发板上的/proc/device-tree确认实际生效的dts内容:
ls /proc/device-tree/ cat /proc/device-tree/led/compatible cat /proc/device-tree/led/status如果/proc/device-tree里看不到led节点,说明dtb根本没更新,需要检查烧录分区。如果能看到节点但status是disabled,改dts后重新编译。如果compatible值对不上,直接把驱动of_match_table里的字符串改成和dts一致,或者反过来,两者必须完全逐字符相等。还有一个常见的坑是小写和大写:compatible里.和-不能随意替换,设备树匹配是严格字符串比较,没有模糊匹配。
4.4 现象:模块加载就段错误,大概率是编译器不一致
现象:insmod时开发板控制台直接提示segfault,或者模块加载成功但访问设备时内核panic,栈回溯显示在内核模块代码里。
原因:驱动模块用的交叉编译器和内核镜像的编译器不一致,导致结构体对齐方式、内联函数展开、原子操作指令序列不兼容。最常见的情况是板子厂商用arm-linux-gnueabi-编译内核,资料里给的驱动却用arm-linux-gnueabihf-编译,hf和非hf的浮点ABI完全不同,硬浮点模块加载到软浮点内核里必挂。
解决:查一下开发板内核的编译信息:
zcat /proc/config.gz | grep CONFIG_ARM cat /proc/self/auxv | grep -a "HWCAP"更直接的做法是看内核源码根目录的.config里CONFIG_AEABI和CONFIG_VFP配置,然后选对应工具链。如果资料里同时给了多个编译器,优先用内核编译时用的那个。实在没法确定时,用buildroot重新生成一套工具链和内核,虽然费时间,但能保证ABI一致,也算是最稳的后悔药。
4.5 现象:驱动里 printk 的信息日志看不到
现象:驱动加载或者读写时,源码里写了printk,但终端上什么都没打印。新手经常以为是printk没生效。
原因:printk有日志级别,默认级别下的信息可能低于控制台的console_loglevel,直接被内核过滤掉。调试信息一般用KERN_INFO或KERN_DEBUG,而控制台默认只显示KERN_ERR以上级别。另外printk是进环形缓冲区的,终端没显示不代表没记录,可能被后续日志冲掉了。
解决:先dmesg | tail查内核日志缓冲区,肯定能看到。想直接在终端实时看,执行:
echo 8 > /proc/sys/kernel/printk把console loglevel提到8,所有级别都会显示。调试完记得调回默认值,否则生产环境日志会被刷爆。驱动代码里推荐用dev_info(&pdev->dev, "...")这类接口,它自带设备名,dmesg里一眼能看出是哪个设备在打日志,比裸printk好用。
5. 进阶阅读:从源码里抠出中断、并发与延时设计
5.1 中断上下半部:tasklet 与 workqueue 的取舍
这套源码里涉及按键和GPIO中断的驱动,都会实现中断处理函数。中断处理有个铁律:中断上下文里不能睡眠,不能调用可能导致阻塞的函数。但实际硬件处理往往需要做耗时操作,比如消抖、读取FIFO、通知用户态,于是Linux把中断拆成上下半部。
上半部是request_irq注册的处理函数,要求短小精悍,只做必须立即做的事。下半部延后处理,可选机制包括tasklet、workqueue、threaded irq。源码里最常见的两种是tasklet和workqueue,它们的区别很关键:tasklet运行在软中断上下文,不能睡眠;workqueue运行在进程上下文,可以睡眠。典型代码片段:
static void led_irq_work(struct work_struct *work) { /* 这里可以睡眠,适合做较重的处理 */ enable_irq(irq_num); } static irqreturn_t led_irq_handler(int irq, void *dev_id) { schedule_work(&led_work); disable_irq_nosync(irq_num); return IRQ_HANDLED; } static int led_probe(struct platform_device *pdev) { INIT_WORK(&led_work, led_irq_work); ret = request_irq(irq_num, led_irq_handler, IRQF_TRIGGER_FALLING, "led_irq", &led_data); return 0; }这个例程是“按键中断+延时上报”的套路:中断到来时上半部禁用中断并调度work,下半部做实际处理后重新使能中断。disable_irq_nosync和enable_irq配对使用,防止中断风暴。注意INIT_WORK必须在request_irq之前完成,否则work还没初始化就被调度,内核直接panic。实际项目里现在更推荐threaded irq,用request_threaded_irq的handler传NULL、thread_fn传处理函数,但老源码里tasklet和work这类教学写法更常见,看懂它们再迁移到threaded irq并不难。
5.2 并发控制:自旋锁、信号量与互斥体在驱动里的边界
驱动跑起来之后,最隐蔽的bug来自并发。一个字符设备可能被多个进程同时open和read,中断处理函数也可能和进程上下文同时访问同一块寄存器。这套源码里很多例程其实没做并发控制,直接操作全局变量,教学可以,产品不行。理清锁的边界,是进阶必须补的一课。
自旋锁适合临界区极短、不会睡眠的场景,比如中断处理函数里保护一个标志位;互斥体适合进程上下文里访问共享数据,临界区可能阻塞。错误用法是中断里拿mutex,因为mutex睡眠会导致系统卡死。标准API对比:
/* 自旋锁:用于原子上下文 */ spinlock_t lock; spin_lock_init(&lock); spin_lock(&lock); /* 临界区,不能睡眠 */ port->status |= BIT(1); spin_unlock(&lock); /* 互斥体:用于进程上下文 */ struct mutex mtx; mutex_init(&mtx); mutex_lock(&mtx); /* 临界区,可以睡眠,但不建议长时间持有 */ ret = copy_to_user(buf, data, len); mutex_unlock(&mtx);选锁的核心判断依据是“临界区里能不能睡眠”。如果临界区只有几行寄存器读写,用自旋锁;如果涉及copy_to_user、等待硬件完成、获取另一个锁,就用互斥体。很多新人把自旋锁当成万能锁,结果copy_to_user放进去直接死锁或报scheduling while atomic。源码里只要看到中断处理函数和普通read/write一起操作某个变量,就该想到加锁。
5.3 延时函数:忙等、睡眠与内核时间精度
驱动里做延时是高频操作,比如操作I2C设备时要等从设备准备好,操作LED时要产生闪烁波形。内核提供的延时函数分好几档,用错会导致延时不准甚至系统崩溃。
udelay是忙等,基于CPU循环,精度高但占用CPU,只能用在原子上下文或延时极短的场景;mdelay是udelay的多次调用,也不睡眠;msleep是睡眠式延时,让出CPU,只能进程上下文用;usleep_range允许给一个范围,内核可以更精确地唤醒。常见的坑是在中断处理函数里调用msleep,直接触发内核错误。选择逻辑可以简单记:短于几十微秒用udelay,几十到几百微秒用usleep_range,毫秒级以上且能睡就msleep。
实际看这套源码,消抖和时序控制大多用的udelay或mdelay,因为按键消抖逻辑写在中断里,不能睡眠。更合理的做法是把消抖放到workqueue里,用msleep延时,既准确又不占CPU。遇到这种代码,理解它为什么这么写,要比照抄更有价值。
6. 最后:按我的习惯,源码应该按这个顺序读
资料里的源码不会按照学习曲线排列,目录顺序往往是按外设分类排的,直接读很容易被复杂的平台设备驱动劝退。我自己拆过几套类似培训源码,固定按“编译→最小模块→字符设备→设备树→中断”这个顺序推进,效率高很多。
第一步,不管有多少个目录,先把整个源码树的Makefile捋一遍,找出哪几个模块能独立编译,然后挑一个最接近“hello world”的驱动模块编出来、加载进板子。这一步的目标不是理解代码,而是确认工具链和内核环境是通的。如果连最简单的模块都加载失败,先把第4章的坑排查完再继续。
第二步,读这个最小模块的init和exit函数,用dmesg观察加载和卸载日志。把module_init、module_exit、printk的加载顺序和日志输出对应起来,驱动就不再是黑匣子了。
第三步,接触带硬件操作的字符设备驱动,逐个看file_operations里的read/write/ioctl怎么操作寄存器。关键词是ioremap、writel、readl,看到这三个函数,说明驱动在直接访问物理地址。对照芯片手册的寄存器偏移,能算出代码里写的地址对应哪个外设,就算入门了。
第四步,读驱动里带probe函数的版本,理解设备树匹配、platform_get_resource取资源这个过程,然后对比老式固定地址驱动,你就知道为什么要设备树了。
第五步,才去看中断、并发、延时这些进阶内容。
我给你一个能验证学习效果的办法:别只读,动手删。把某个驱动里的device_create两行注释掉,重新编译加载,你会发现/dev下节点消失,然后自己用mknod建一次,恢复应用访问。这个实验做完,你对设备模型的理解比看十遍书都深。再往后,改改设备树里的interrupts属性,看中断申请还成不成立,这套驱动源码就算被你吃透了。
这套资料不是拿来“看”的,是拿来“拆”的。源码里每一个文件都可以被破坏性地修改,再通过编译和运行结果反向验证理解。我从那以后,每次拿到新的嵌入式源码都强制走一遍“先编译验证、再按依赖读、最后破坏性实验”的流程,收获比从头到尾读代码大得多。希望帮到你。
本文还有配套的精品资源,点击获取