拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Linux设备驱动开发实战:从字符设备到设备树与I2C

Linux设备驱动开发从字符设备框架到设备树与I2C一位嵌入式老兵的实战笔记先说说为什么想写这篇东西。前阵子帮一个转行的朋友梳理驱动开发的学习路线发现网上的资料要么太散要么直接扔给你一堆源码注释看完还是一脸懵。我自己做嵌入式Linux驱动开发也有快十年了踩过的坑远比看过的文档多所以就想以“字符设备驱动框架”为主线把从环境搭建、内核编译到设备树配置、I2C设备驱动注册这套链路完整串一遍让你照着走也能把板子上的外设跑起来。这篇文章适合三类人看一是刚接触Linux驱动、想搞懂“驱动到底怎么写”的初学者二是做嵌入式应用层开发、想往下探一层理解内核运作机制的同学三是在做系统裁剪优化、需要自己适配外设的工程师。内容上我会避开纯教科书式的理论堆砌尽量用“为什么要这样设计”“实际跑起来会遇到什么问题”的角度来讲保证每一段都有能直接抄作业的干货。1. 动手前的准备开发环境、内核源码与第一个模块1.1 开发环境搭建的三种常见路径写驱动和写应用最大的区别在于驱动是跑在内核态的你不能像调试普通程序那样直接gdb也不能随便printf一切都要依赖内核日志和模块机制。所以第一步不是急着写代码而是把开发环境理清楚。针对不同的预算和场景我推荐三条路线虚拟机 x86开发板qemu模拟最快上手适合只想学框架、不关心硬件时序的读者。不需要买板子装个Ubuntu虚拟机自己编一遍内核然后用qemu启动模块加载、字符设备读写全都能验证。真实ARM开发板 NFS根文件系统这是工业界最常见的做法。PC上交叉编译内核和驱动开发板通过NFS挂载根文件系统每次编译完直接把.ko丢过去insmod迭代速度非常快。开发板独立根文件系统 U盘拷贝没有网络环境时的备选方案。每改一次驱动都要手动拷贝、重启效率低但胜在简单可靠。我个人强烈建议至少走一遍虚拟机方案因为很多驱动开发的初学者上来就买开发板结果连内核编译都过不了积极性直接被打没。先用虚拟机能让你把注意力集中在驱动本身的逻辑上。1.2 内核源码版本选择与编译配置注意点选择内核版本有个铁律不要用发行版自带的内核源码来编模块。Ubuntu自带的linux-source往往和当前运行内核版本不严格对应头文件路径、config都不一致编出来的.ko要么insmod报版本不匹配要么直接把内核搞崩。正确做法是到kernel.org下载一个稳定的LTS版本或者直接用你开发板厂商提供的BSP内核源码。我个人习惯用5.10或6.1这类维护周期长的内核资料多、坑少。内核源码就位后第一件事是清理并生成配置文件# 拷贝当前内核的config作为基础 cp /boot/config-$(uname -r) .config make olddefconfig make menuconfig # 可选有些选项需要手动开启 make -j$(nproc)这里解释一下为什么要先编一遍完整内核驱动模块的编译需要依赖内核源码树里生成的头文件和符号表文件Module.symvers如果没编过内核直接编模块你会发现链接阶段总是报Unknown symbol之类的错误。编一遍内核的过程虽然耗时但也是排查环境问题的最好时机。1.3 第一个内核模块hello world的完整落地有了编译好的内核我们来写第一个模块。这一步的目的不是炫技而是确认工具链、内核头文件、模块加载机制都畅通。#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO hello_drv: module loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello_drv: module unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple hello module);对应的Makefile这样写obj-m : hello_drv.o KERNEL_DIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean编译后执行sudo insmod hello_drv.ko dmesg | tail sudo rmmod hello_drv这里有个小技巧printk的日志级别默认可能不会显示在控制台上所以一定要用dmesg查看。当年我第一次调模块printk打印死活不出现差点以为模块没加载成功其实是日志级别的问题。2. 字符设备驱动框架拆解从register_chrdev到cdev2.1 为什么字符设备是学习驱动的起点Linux驱动大体分三类字符设备、块设备、网络设备。块设备比如硬盘、SD卡有page cache和IO调度层层包裹网络设备走的是sk_buff那一套逻辑复杂。而字符设备是最朴素的一类按字节流访问你write什么内核就看到什么没有复杂的缓存机制特别适合用来理解驱动模型的核心原理。我们平时说的GPIO控制、I2C读写、串口通信本质上都是字符设备。你可以在应用层用open/read/write/ioctl操作它们内核里的字符设备驱动负责把这些系统调用翻译成具体的硬件操作。所以学会了字符设备框架后面学什么都快。2.2 设备号管理主设备号与次设备号的分配逻辑每个字符设备在内核里都有对应的设备号一个设备号由主设备号major和次设备号minor组成。主设备号用来区分驱动类型次设备号用来区分同一驱动下的不同设备。分配设备号有两种方式静态申请自己指定一个主设备号用register_chrdev_region注册。适合你知道设备号不会冲突的情况比如一些老式驱动。动态分配用alloc_chrdev_region让内核帮你分配主设备号然后用cat /proc/devices查看。现代驱动几乎都用这种方式因为内核维护了一个主设备号分配表动态分配可以避开冲突。我们写示例代码时先用动态分配稳妥也规范dev_t dev_num; alloc_chrdev_region(dev_num, 0, 1, my_demo_dev); major MAJOR(dev_num); minor MINOR(dev_num);2.3 file_operations结构体驱动与应用层之间的桥梁字符设备驱动的核心是file_operations结构体。它定义了一组函数指针对应着应用层open、read、write、ioctl等系统调用。内核在VFS层接收到系统调用时会找到对应设备文件inode里的file_operations然后调用具体的实现函数。实际项目中我经常用到的成员就这几个.owner一般设为THIS_MODULE防止模块在使用中被卸载。.open打开设备时调用常做硬件初始化和资源分配。.read从设备读取数据注意把数据拷贝到用户空间要用copy_to_user。.write从用户空间接收数据拷贝进来用copy_from_user。.unlocked_ioctl设备控制命令的入口很多私有协议都靠它实现。.release关闭设备时调用释放资源。2.4 完整实现一个带读写功能的字符驱动demo下面给出一个可编译、可运行的字符设备驱动模板包含了设备号申请、cdev注册、设备文件创建、读写接口和人数据拷贝的实现#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/slab.h #define DEMO_BUFFER_SIZE 1024 static int demo_major 0; static struct cdev demo_cdev; static struct class *demo_class; static char *demo_buffer; 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 count, loff_t *offset) { int ret; if (count DEMO_BUFFER_SIZE) count DEMO_BUFFER_SIZE; ret copy_to_user(buf, demo_buffer, count); if (ret ! 0) return -EFAULT; return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { int ret; if (count DEMO_BUFFER_SIZE) count DEMO_BUFFER_SIZE; ret copy_from_user(demo_buffer, buf, count); if (ret ! 0) return -EFAULT; return count; } static int demo_release(struct inode *inode, struct file *filp) { printk(KERN_INFO demo: release called\n); return 0; } static struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .read demo_read, .write demo_write, .release demo_release, }; static int __init demo_init(void) { dev_t dev_num; int ret; // 动态分配设备号 ret alloc_chrdev_region(dev_num, 0, 1, demo_dev); if (ret 0) { printk(KERN_ERR demo: failed to alloc major number\n); return ret; } demo_major MAJOR(dev_num); // 初始化cdev cdev_init(demo_cdev, demo_fops); demo_cdev.owner THIS_MODULE; // 向内核注册字符设备 ret cdev_add(demo_cdev, dev_num, 1); if (ret 0) { unregister_chrdev_region(dev_num, 1); return ret; } // 自动创建/dev/demo0设备节点 demo_class class_create(THIS_MODULE, demo_class); if (IS_ERR(demo_class)) { cdev_del(demo_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(demo_class); } device_create(demo_class, NULL, dev_num, NULL, demo0); demo_buffer kzalloc(DEMO_BUFFER_SIZE, GFP_KERNEL); if (!demo_buffer) { device_destroy(demo_class, dev_num); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(dev_num, 1); return -ENOMEM; } printk(KERN_INFO demo: driver initialized with major %d\n, demo_major); return 0; } static void __exit demo_exit(void) { dev_t dev_num MKDEV(demo_major, 0); kfree(demo_buffer); device_destroy(demo_class, dev_num); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO demo: driver removed\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);编完后insmod然后执行echo hello driver /dev/demo0 cat /dev/demo0如果你看到终端回显了hello driver说明链路已经通了。这里遇到最多的坑是/dev/demo0没有生成原因往往是udev没有识别到class_create创建的设备类。可以手动执行mknod /dev/demo0 c 240 0来应急但生产环境建议还是用device_create自动创建设备节点。2.5 cdev_add与device_create之间的微妙关系很多初学者会问为什么有了cdev_add还要device_create这两个家伙的职责完全不同。cdev_add是让内核知道“有一个字符设备主次设备号是xxx操作函数是xxx”这时你在/proc/devices里能看到设备号但/dev目录下还没有对应的设备文件。device_create则是在用户空间创建设备节点让应用层能够用open/read/write去访问。在设备模型里cdev代表的是内核态的驱动实体device代表的是设备实体两者通过device_create里的dev_t参数关联起来。以前的老内核用mknod手动创建节点现在用device_create配合udev自动搞定了。3. 设备树与platform总线让驱动从“写死”走向“配置化”3.1 设备树到底解决什么问题在老内核时代比如2.6驱动里常常写死硬件寄存器地址、中断号。板子一改代码就要跟着改非常痛苦。设备树就是把硬件资源描述从驱动源码里抽离出来用一套树形结构描述板级硬件CPU型号、内存基址、外设挂在哪个总线、寄存器地址、中断号、引脚复用等。驱动不再关心“我挂在哪个板子上”而是关心“我的设备匹配到了没有”。匹配到了就去设备树里拿reg、interrupts、gpios这些属性来初始化硬件。这就是Linux设备模型推崇的“驱动与设备分离”思想。3.2 设备树基础语法与常见节点设备树源文件.dts经过dtc编译成.dtbbootloader启动时把.dtb传给内核。一个简单的设备树节点长这样/dts-v1/; / { compatible demo,board; model Demo Board; chosen { bootargs consolettyS0,115200; }; leds { compatible demo,leds; status okay; reg 0x01C20800 0x24; /* GPIO寄存器地址和长度 */ }; };每一个节点代表一个硬件设备或控制器节点的属性compatible、reg、interrupts、gpios等就是硬件资源的描述。compatible是驱动匹配的关键一般格式是“厂商,型号”。3.3 platform_driver注册流程probe函数何时被调用设备树准备好后驱动侧就可以用platform_driver框架来匹配设备了。platform总线是Linux内核里一种虚拟总线专门用来连接那些不挂在PCI、USB等物理总线上的设备。设备树里定义的 compatible 属性在启动时会通过 of_platform_bus_probe 挂到platform总线上驱动这边注册platform_driver时内核会遍历总线上的设备比对compatible字段匹配成功就调用probe函数。一个典型的platform驱动结构如下static const struct of_device_id demo_of_match[] { { .compatible demo,leds }, { } }; MODULE_DEVICE_TABLE(of, demo_of_match); 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); printk(KERN_INFO demo: probe success, reg base at %p\n, base); return 0; } static int demo_remove(struct platform_device *pdev) { printk(KERN_INFO demo: remove called\n); return 0; } static struct platform_driver demo_pdrv { .probe demo_probe, .remove demo_remove, .driver { .name demo_leds, .of_match_table demo_of_match, }, }; module_platform_driver(demo_pdrv);这里要特别注意module_platform_driver这个宏其实展开后就是做了module_init和module_exit两件事把platform_driver注册到内核总线。如果你用老式的platform_driver_register记得在exit里对应调用platform_driver_unregister。3.4 设备树裁剪与系统裁剪优化的实战经验设备树不光用来描述外设它也直接影响内核启动速度和内存占用。做系统裁剪优化时我习惯先保留最小可启动设备树跑起串口和根文件系统再按需一点点添加外设节点。很多人在裁剪时图省事设备树里塞了一堆用不到的节点内核虽然不会全部初始化但内存占用和启动时间都会受到影响。裁剪时还有个小技巧把没有焊接的芯片、没有使用的控制器的设备树节点state设为“disabled”而不是直接删掉。这样下次硬件改版启用时只需改回“okay”即可。留着的注释也能给硬件同事核对。4. I2C设备驱动详解从i2c_driver到设备树的完整链路4.1 I2C子系统整体架构I2C总线只有两根线SCL时钟和SDA数据协议简单但时序要求高。Linux内核把I2C子系统抽象成三层I2C控制器驱动adapter、I2C核心层、I2C设备驱动client。我们做应用级驱动开发时主要关注client这一层。内核里的I2C设备模型是典型的“分离”思想。控制器驱动负责处理不同SoC的I2C硬件差异比如寄存器配置、中断处理、DMA传输等设备驱动只管和挂在总线上的具体外设打交道比如读温湿度传感器、配置音频编解码芯片。编写I2C设备驱动时你不需要关心控制器的具体实现只需要调用i2c_transfer或者更上层的regmap接口就行。4.2 i2c_driver注册与probe匹配机制I2C设备在设备树里声明I2C控制器会把它们扫描并在总线上注册为i2c_client。i2c_driver通过id_table或者of_match_table来匹配设备。static const struct i2c_device_id demo_i2c_id[] { { demo-sensor, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, demo_i2c_id); static const struct of_device_id demo_i2c_of_match[] { { .compatible demo,sensor }, { } }; MODULE_DEVICE_TABLE(of, demo_i2c_of_match); static int demo_i2c_probe(struct i2c_client *client) { printk(KERN_INFO demo: found sensor at addr 0x%x\n, client-addr); return 0; } static int demo_i2c_remove(struct i2c_client *client) { return 0; } static struct i2c_driver demo_i2c_driver { .probe demo_i2c_probe, .remove demo_i2c_remove, .id_table demo_i2c_id, .driver { .name demo_sensor, .of_match_table demo_i2c_of_match, }, }; module_i2c_driver(demo_i2c_driver);对应的设备树描述如下i2c1 { status okay; clock-frequency 100000; demo_sensor1e { compatible demo,sensor; reg 0x1e; }; };4.3 regmap简化寄存器读写的利器如果你只是用i2c_transfer来做寄存器读写每写一个功能都要封装一层的“发地址发数据”代码量大且容易出错。Linux内核提供了regmap框架把I2C、SPI、MMIO等不同接口整合成统一的寄存器操作接口。修改内部逻辑不用动驱动主体非常适合传感器、Codec这类以寄存器控制为主的外设。static const struct regmap_config demo_regmap_cfg { .reg_bits 8, .val_bits 8, .max_register 0xff, }; static int demo_i2c_probe(struct i2c_client *client) { struct regmap *regmap; regmap devm_regmap_init_i2c(client, demo_regmap_cfg); if (IS_ERR(regmap)) { dev_err(client-dev, failed to init regmap\n); return PTR_ERR(regmap); } regmap_write(regmap, 0x10, 0x01); regmap_read(regmap, 0x10, val); return 0; }这里其实还有个使用细节regmap默认是带缓存和延迟写功能的在某些对实时性要求高的场合比如需要马上把数据刷到硬件记得调用regcache_sync或者考虑关闭缓存。4.4 用户态访问I2C设备i2c-dev工具与应用示例有时候你不想写内核驱动只是想快速验证一颗I2C芯片能不能工作。这种情况下可以直接用内核的i2c-dev框架在应用层打开/dev/i2c-1这样的设备文件用ioctl发起I2C读写。#include stdio.h #include fcntl.h #include linux/i2c-dev.h #include sys/ioctl.h int main(void) { int fd open(/dev/i2c-1, O_RDWR); if (fd 0) { perror(open); return -1; } // 设置从机地址 ioctl(fd, I2C_SLAVE_FORCE, 0x1e); unsigned char reg 0x10; unsigned char val 0x01; // 写寄存器 write(fd, reg, 1); write(fd, val, 1); // 读寄存器 read(fd, val, 1); printf(reg 0x10 0x%02x\n, val); close(fd); return 0; }命令行下如果用i2c-tools的话可以这样快速验证i2cdetect -y -r 1 # 扫描i2c-1上的设备地址 i2cget -y 1 0x1e 0x10 # 读寄存器 i2cset -y 1 0x1e 0x10 0x01 # 写寄存器这个方法在量产测试阶段特别管用。不需要为每颗芯片都写一遍内核驱动直接在产测脚本里用shell命令就能校验I2C通信。5. 调试技巧与常见问题排查驱动开发者的求生指南5.1 内核崩溃Oops与panic的定位思路驱动跑在内核态一个空指针就可能导致整个系统重启不像应用层那样崩了就崩了。遇到Oops时先别慌我们把dmesg里的关键信息找出来无非两种一是“Unable to handle kernel paging request at virtual address”二是“BUG: unable to handle kernel NULL pointer dereference”。定位的思路是看Oops信息开头的PC指针Program Counter这个地址指向了崩溃时的指令。用addr2line或gdb配合vmlinux符号表把PC地址翻译成函数名和行号。结合调用栈Call trace看是哪个函数调用了出错的函数。实战中我遇到过好几个新手把kmalloc返回的指针直接用了没检查NULL结果板子一跑就panic。养成习惯申请内核内存后一定要判断返回值。5.2 模块无法卸载的排查方向如果rmmod报“Module is in use”或者直接卡死常见原因是你在驱动的exit函数里忘了释放某些资源或者有一个进程还占着设备文件没关闭。可以用以下命令排查lsmod # 查看模块引用计数 cat /proc/modules fuser /dev/demo0 # 查哪个进程占用了设备文件还有一种隐蔽的情况你的设备节点还在但没有进程打开它引用计数却不降。这种往往是因为你在open里递增了module引用计数却没有在release里递减。自己写代码时最好不要手工修改THIS_MODULE的引用计数交给内核自动管理。5.3 调试利器printk级别、动态调试与ftrace驱动开发离不开日志。printk默认的日志级别是KERN_WARNING4低于控制台级别的消息不会直接显示。调试时我一般把控制台日志级别调低echo 8 /proc/sys/kernel/printk如果代码量大printk打太频繁会影响实时性。这时可以考虑动态调试dynamic debug在驱动里用dev_dbg然后在运行时开启指定文件、指定函数的日志开关echo file demo.c p /sys/kernel/debug/dynamic_debug/control再有就是ftrace用来跟踪内核函数调用关系。比如你想确认i2c_transfer到底走了哪条路径可以启用function_graph跟踪器。这个工具功能强大但需要内核开启CONFIG_FUNCTION_TRACER并且对新手来说信息量太大建议有一定基础后再用。5.4 驱动开发常见坑位速查表我把这几年带队时大家最常踩的坑整理成一个表方便你排查时对照现象可能原因解决办法insmod报“Invalid module format”内核版本或编译配置不匹配用uname -r确认版本重新编译模块insmod报“Unknown symbol”依赖其他模块未加载或符号未导出先加载依赖模块或检查EXPORT_SYMBOL设备节点不出现class_create或device_create失败查看dmesg确认dev_t参数正确打开设备文件失败文件权限或file_operations注册错误chmod 666检查cdev_add返回值read/write返回ENOMEM内存分配失败或拷贝失败检查kmalloc大小和copy_to_user返回值中断不触发没有正确申请IRQ或设备树中断号错误检查interrupts属性用cat /proc/interrupts确认系统重启驱动里有非法内存访问查看Oops信息检查指针是否初始化6. 常用Linux命令与开发效率工具6.1 驱动开发必知的内核与模块命令很多转行来的朋友被“linux常用命令大全”这类搜索词带偏了以为Linux开发就是记一堆文件操作命令。对于驱动开发来说真正高频的是这几条uname -r # 查看内核版本 lsmod # 查看已加载模块和依赖关系 modinfo xxx.ko # 查看模块信息 insmod xxx.ko # 加载模块 rmmod xxx # 卸载模块 dmesg | tail # 查看内核日志 cat /proc/devices # 查看已注册的设备号 cat /proc/iomem # 查看IO内存分配情况 cat /proc/interrupts # 查看中断分配情况 i2cdetect -y -r 1 # 扫描I2C总线设备6.2 提升开发效率的环境配置编内核和编驱动最烦的是重复等待。我建议在开发机上做两件事一是配置ccache把编译缓存开起来二次编译能快一半以上二是用screen或tmux一边跑编译一边保持串口终端监控开发板日志。串口工具我喜欢用minicom或者putty注意串口波特率要和bootloader一致通常115200。如果是在嵌入式板子上交叉编译务必确认交叉编译工具链里gcc版本和内核编译时用的gcc版本差异不能太大。版本跨度太大会出现隐晦的“internal compiler error”排查起来非常折腾。7. 从字符设备到实际产品一点个人经验分享写到这里驱动开发的主线已经基本串起来了从环境准备、字符设备框架、设备树配置到I2C设备驱动再到调试手段。很多朋友学到I2C就会问这就够了吗其实对大部分嵌入式产品来说字符设备能控GPIO、I2C能接传感器、设备树能适配不同板型确实已经能覆盖七八成的开发需求了。剩下那些进阶内容比如中断下半部、tasklet、工作队列、DMA、并发与锁竞争都是在具体项目里遇到问题后反推学习更高效。我个人带项目的经验是驱动开发最值钱的能力不是单纯会写代码而是会定位问题。调试工具的熟练掌握、内核日志的敏感度、对设备模型的理解深度这些是靠一个个项目慢慢磨出来的。现在很多网上教程给你一整套框架代码照着抄能跑但一旦换了芯片、换了内核版本问题马上暴露。所以我在整篇文章里刻意强调了“为什么要这样设计”、“匹配机制怎么工作”、“异常时怎么排查”就是希望你能建立起自己的排查链路。最后分享一个小技巧写完驱动后无论如何都要在rootfs里准备一份调试用的bash脚本自动加载模块、创建设备节点、跑一轮功能验证、收集dmesg。每次改动代码后一键执行能帮你快速判断是软件回归还是硬件不稳。这套做法在量产支持和现场排障中特别有用也是我从“能用”走向“好用”的重要一步。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门