资讯动态

Linux设备驱动实战:从字符设备框架到I2C与系统裁剪

发布时间:2026/9/15 3:18:35 来源:尧图企业网站定制
搞了这么多年嵌入式我越来越觉得Linux设备驱动不是可以绕开的技术而是所有功底的地基。项目里一旦碰到内核崩溃、外设读写异常没有驱动层的底子排查起来就像在黑屋子里找东西全靠猜。今天把这个项目里从字符设备框架到设备树配置再到I2C驱动和系统裁剪优化的完整经历整理出来给准备啃驱动开发的同行一个参照。这篇文章不会端着教科书讲理论只讲我实际调试时怎么想、怎么写、怎么踩坑然后爬出来。1. 项目综述为什么驱动开发值得啃1.1 驱动开发到底在做什么很多人把驱动想得太高深其实内核驱动本质上就是给硬件写“使用方法”。CPU不会自带怎么操控某个传感器的知识I2C控制器也不知道你外挂的芯片是什么型号驱动就是把这层“人和硬件之间的翻译协议”落成代码。站在应用层看你对一个设备文件做open、read、write驱动负责把这些调用翻译成总线时序、寄存器操作再把硬件状态带回来。所以整个项目里字符设备驱动框架是入口设备树是描述硬件的纽带I2C驱动是实际读数据的抓手系统裁剪优化则是让整套东西能塞进有限存储和运行资源里的收尾工作。这四个点各自独立但又通过“一个可用的驱动模块”串起来。理解了整条链路你再看网上各种零散的驱动源码就不会觉得是一堆函数堆砌而能看到背后的数据流用户请求进内核驱动查设备树拿到资源通过总线访问寄存器再把结果往上抛。1.2 内核模块与用户程序有什么不同写驱动不能沿用写应用的那套思维。用户程序一启动就从main开始跑完就退出进程上下文很清晰。内核模块没有main它挂在内核里由内核事件触发比如设备文件被打开、中断到来、定时器超时。你写的module_init是初始化入口module_exit是卸载入口这俩看起来有点像钩子函数。另一个关键差异是内存管理。用户进程独占虚拟地址空间默认不会去碰物理地址内核模块运行在内核态所有代码共享内核地址空间代码里一旦写错指针直接oops甚至整机崩溃而且不像用户态段错误那样只干掉当前进程你只能重启。调试手段也很不一样用户态可以用gdb打断点内核驱动更多靠printk、dev_dbg、ftrace和perf。说白了写驱动门槛不在语法而在思维方式。1.3 开发环境准备与交叉编译环境选型时我建议优先用Ubuntu 22.04这类长期支持版本当宿主机内核源码用与目标板同版本的官方内核。不要随便换内核大版本一旦配置结构体变化旧驱动代码编译直接报错接口名都变了。交叉编译工具链我用aarch64-linux-gnu-那套因为项目里目标平台是ARM64如果你用老一点的ARM32板子就换成arm-linux-gnueabihf-。代码要编成模块必须保证内核源码配置过而且最好是make modules_prepare过。我踩过最典型的坑就是图省事用发行版自带的内核头文件编模块结果加载时报version magic不匹配因为内核本身开了CONFIG_MODVERSIONS而头文件里没有对应信息。所以正确做法是拿到目标板内核源码后先加载原厂config执行make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- olddefconfig再make modules_prepare之后编出的.ko才可能正常insert。整个过程别偷懒环境不对后面全白搭。2. 字符设备驱动框架最小可运行驱动的完整拆解2.1 file_operations驱动与用户的桥梁字符设备驱动最核心的就是file_operations结构体它定义了用户对设备文件操作时内核会回调哪些函数。常见字段有.owner、.open、.read、.write、.release、.unlocked_ioctl等。内核在write调用进入时会传进用户缓冲区指针、写入字节数、以及当前文件偏移。这里最容易犯的错误是直接copy_from_user失败不检查或者干脆用memcpy导致用户空间非法指针被打进内核。我习惯在编写时先把整个结构体定义好只保留需要的回调不需要的留空。比如最小驱动可以只有open和releaseread/write都返回-EINVAL或-EIO。但真实场景肯定要读写所以建议所有回调都加个dev_dbg打印调试时能看到调用链。多进程同时打开设备时你的open里能拿到struct file指针可以在file的private_data里挂自己的每进程状态这样一个驱动同时服务多个fd时无所谓数据共存关键就在这个private_data字段上。这个技巧不写进任何教科书但实际产品里几乎必须用。2.2 设备号的分配与注销字符设备必须具备设备号由主设备号和次设备号构成主设备号决定对应哪类驱动次设备号区分同类型的多个实例。分配方式有动态和静态两种。动态分配用alloc_chrdev_region内核会自动找未用主设备号起始次设备号从0开始推荐产品原型阶段用这个省得跟其他模块冲突。静态分配用register_chrdev_region适合已知设备号且需要固定编号的场景。设备号本质是个32位dev_t类型用MAJOR()和MINOR()宏可以拆出来。我在原型阶段经常先动态分配打印主设备号再用mknod手动建节点测试等驱动稳定后再改成固定的静态设备号方便系统里写udev规则。注销也要对称模块退出时先用unregister_chrdev_region释放设备号否则下次insmod会报“Device or resource busy”这种错误很多新手一脸懵。驱动里还要维护一个cdev结构体cdev_init关联file_operationscdev_add把设备加入内核设备驱动模型。cdev_add的第二个参数是设备号第三个参数是这个范围的次设备号数量。我见过有人把count写成1但后面申请了多个次设备号导致访问不到注意对应。2.3 设备节点的两种生成方式驱动注册cdev后还需要在/dev下面有设备节点用户才能操作。第一代做法是手动mknod简单粗暴缺点是不灵活开发阶段用没问题产品发布时不能让人家每次都自己输命令。第二种是自动创建设备节点利用内核的class和device机制class_create创建一个设备类然后device_create在指定类下创建设备。内核uevent通知用户空间的udevudev根据sysfs信息自动在/dev下创建设备节点。我用class_create device_create之后/sys/class/my_class/my_dev_0会出现在sysfs里这就证明设备模型挂上了。要注意device_create会创建/dev下的节点但权限默认是root所有一般用户打不开最好在udev规则里加GROUPdialout, MODE0666之类或者直接驱动里用devtmpfs自动好但实际项目里我习惯配合udev规则管理权限。2.4 一个带读写操作的字符驱动示例下面是我在项目原型阶段常用的最小结构去掉业务逻辑保留骨架。这段代码你可以直接拿来当模板改但别直接用因为有平台差异尤其是设备号的动态分配范围。#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DEVICE_NAME demo_dev #define CLASS_NAME demo static int major; static struct class *demo_class; static struct cdev demo_cdev; static char kernel_buf[256]; static int demo_open(struct inode *inode, struct file *filp) { pr_info(demo_open called\n); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { size_t len strlen(kernel_buf); size_t to_copy min(count, len); if (to_copy 0) return 0; if (copy_to_user(buf, kernel_buf, to_copy)) return -EFAULT; *f_pos to_copy; return to_copy; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { size_t to_copy min(count, sizeof(kernel_buf) - 1); if (copy_from_user(kernel_buf, buf, to_copy)) return -EFAULT; kernel_buf[to_copy] \0; *f_pos to_copy; return to_copy; } static const struct file_operations fops { .owner THIS_MODULE, .open demo_open, .read demo_read, .write demo_write, }; static int __init demo_init(void) { dev_t dev; int ret; ret alloc_chrdev_region(dev, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(alloc_chrdev_region failed\n); return ret; } major MAJOR(dev); cdev_init(demo_cdev, fops); ret cdev_add(demo_cdev, dev, 1); if (ret 0) { unregister_chrdev_region(dev, 1); pr_err(cdev_add failed\n); return ret; } demo_class class_create(CLASS_NAME); if (IS_ERR(demo_class)) { cdev_del(demo_cdev); unregister_chrdev_region(dev, 1); return PTR_ERR(demo_class); } device_create(demo_class, NULL, dev, NULL, DEVICE_NAME); pr_info(demo driver loaded, major%d\n, major); return 0; } static void __exit demo_exit(void) { dev_t dev MKDEV(major, 0); device_destroy(demo_class, dev); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(dev, 1); pr_info(demo driver unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);编译时Makefile按老套路写obj-m目标是当前内核版本。insmod后看dmesg应该在/var/log/kern.log里出现“demo driver loaded”。然后写个简单的用户程序open和write再用cat读出来验证双向通道通不通。注意这里的read用strlen写的是字符串如果写二进制数据会截断所以只能演示不能用在实际生产代码里。3. 设备树让驱动认识硬件的关键3.1 设备树是怎么描述硬件的设备树DT本质是描述硬件拓扑的文本文件经过编译成dtb传给内核。以前ARM平台是把设备信息写在c文件里代码里直接查板级宏后来改成了设备树驱动一旦匹配上节点就能拿到地址、中断号、时钟、GPIO等信息板级差异不再硬编码在驱动里。节点用{}定义可以有子节点label在节点前用:标识可以在别处引用。常见属性有compatible、reg、interrupts、clocks、pinctrl-0等。reg描述了设备占用的内存地址和长度interrupts描述中断号与触发类型pinctrl-0描述pinmux配置驱动加载时内核自动执行。整个设备树的逻辑非常像配置文件好处是改引脚、改地址都不用改驱动代码只要dtb里修正驱动通过API读取配置即可。3.2 compatible匹配机制与驱动绑定驱动与设备树节点匹配靠compatible字符串。比如设备树里写compatible vendor,my-device驱动的of_match_table里要有对应的字符串。内核会用of_match_table里的每个条目和设备树节点的compatible属性做比较匹配上后probe函数被调用。这里有个容易忽略的点platform_driver的id_table只在传统非设备树模式下用设备树模式主要看driver的of_match_table如果驱动里只设置了id_table却没用of_match_table设备树匹配会失败probe永远不被调用。我建议开发时先在驱动里加一条打印把你期望的compatible打印出来对比设备树里实际写的字符串。常见的坑是大小写不一致比如设备树里写了vendor,my_device驱动里写vendor,my-device漏看一个杠板子就是没反应。调试时在probe入口加pr_info(probe called\n)看到这行基本说明匹配没有问题后面才去查资源获取。3.3 从设备树节点获取资源的常用API驱动里拿到struct platform_device后常规操作就是用platform_get_resource获取内存和中断资源或者用device_property_read_*系列API读属性。platform_get_resource的一个容易出错点是参数类型区分IORESOURCE_MEM和IORESOURCE_IRQ写串了会得到一个空指针。建议先打印resource的start和end确认地址范围正确再ioremap映射。中断直接使用platform_get_irq返回的号然后request_irq注册处理函数。此外还有of_property_read_u32、of_get_named_gpio之类的老接口新代码里我更推荐通用的device_property_read_u32它同时兼容ACPI和DT模式代码可移植性更好。内核里对设备树属性值的单位也有约定比如clock-frequency的单位是Hzreg里的地址是32位还是64位要看父节点#address-cells所以读出来的值有时候不是你期望的十进制先hexdump确认或者用dtc把dtb反编译回dts检查原始字段。3.4 一个完整的设备树片元与驱动解析假设项目里有一颗I2C温度传感器挂在i2c1上地址是0x48。设备树里应该这么写i2c1 { status okay; temp_sensor: temp48 { compatible ti,tmp102; reg 0x48; interrupt-parent gpio1; interrupts 15 IRQ_TYPE_LEVEL_LOW; }; };驱动侧在probe里取属性static int my_probe(struct platform_device *pdev) { struct resource *res; int irq; u32 val; res platform_get_resource_byname(pdev, IORESOURCE_MEM, regs); if (!res) return -ENXIO; if (!device_property_read_u32(pdev-dev, threshold, val)) dev_info(pdev-dev, threshold%d\n, val); irq platform_get_irq(pdev, 0); if (irq 0) { devm_request_irq(pdev-dev, irq, my_isr, 0, temp_sensor, dev_id); } return 0; }注意你写的属性名如果设备树里不存在device_property_read_u32会返回错误码所以驱动里列的属性必须和设备树严格一致。我经常在看不懂为什么属性读不到时先用/sys/firmware/devicetree/base路径下对应节点看看有没有这个属性文件或者用/device树目录查实际内容比反复编译设备树快得多。4. I2C设备驱动从原理到代码4.1 I2C总线模型adapter、client与driverI2C子系统内核里分三层控制器驱动adapter、设备驱动client、总线核心。adapter代表硬件I2C控制器负责发时钟和读写时序client代表挂在总线上的具体从设备里面有地址和名称driver是设备驱动负责具体协议解析。匹配时总线核心会把driver和client按名称或ID表做匹配匹配成功调用probe。这类模型的好处是驱动只关心“怎么读这个芯片”不关心控制器细节。你在用户空间用i2c-tools直接读写时其实走的是adapter层接口。内核驱动里常用接口是i2c_transfer和i2c_smbus_read_byte_data、i2c_smbus_write_byte_data前者通用后者是SMBus封装很多传感器芯片支持SMBus或者基于SMBus的扩展不用自己拼时序。4.2 I2C驱动注册与probe流程写一个具体I2C从设备驱动首先要定义struct i2c_driver包含driver.name、id_table、probe和remove。id_table可以简单定义为I2C_BOARD_INFO(tmp102, 0x48)这种形式配合module_i2c_driver宏编译后insmod总线匹配到设备probe就被调用。注意probe里要完成两部分工作一是拿到struct i2c_client指针里面保存着地址和adapter二是初始化私有数据结构比如分配私有结构体、注册字符设备或者直接通过内核的输入子系统上报数据。传感器驱动通常还涉及触发方式比如轮询和中断。轮询简单用hrtimer定期读数据中断模式更省CPU但要保证irq号配置正确。4.3 读写函数实现中的关键参数i2c_transfer需要构造struct i2c_msg数组。读操作通常分两步先写寄存器地址再读数据。比如读tmp102温度寄存器static int tmp102_read_reg(struct i2c_client *client, u8 reg, u8 *buf, int len) { struct i2c_msg msgs[2] { { .addr client-addr, .flags 0, .len 1, .buf reg, }, { .addr client-addr, .flags I2C_M_RD, .len len, .buf buf, }, }; int ret i2c_transfer(client-adapter, msgs, 2); if (ret ! 2) { dev_err(client-dev, i2c transfer error, ret%d\n, ret); return ret 0 ? ret : -EIO; } return 0; }这类函数有个坑芯片有些寄存器是多字节读取顺序有先后比如16位温度值高字节在前你要按datasheet反过来拼。别直接读回字节就数值用转换系数单位也不同。还有地址对齐问题很多芯片支持auto-increment连续读一块数据方便但如果你寄存器不连续还是要分多次。4.4 实测I2C通信的常见问题我实测中遇到最多的一是挂死二是读到全0xFF三是错误返回-ETIMEDOUT。挂死大部分因为总线没上拉或者上拉电阻阻值选错示波器能看到SCL/SDA一直低电平。全0xFF往往是从设备没供电或者地址写错你用i2cdetect去扫如果0x48地址不明亮就查地址线是否被拉高或拉低。返回-ETIMEDOUT则是总线繁忙、多主竞争导致冲突或者中断清理不彻底发生在高频传输时尤其明显。排查手法先在用户空间用i2cdetect -y 1扫地址再用i2cget/i2cset单独读写寄存器确认没问题后再去排查驱动代码。驱动里如果读一次失败简单加三次重试可能暂时掩盖问题但根治还要查硬件时序和I2C controller的时钟频率。时序不达标时把时钟从400k降到100k往往能立刻解决很多问题。5. 系统裁剪优化让驱动跑在精简内核上5.1 内核裁剪的第一原则知道你要什么裁剪不是单纯把不该有的功能去掉而是带着目标取舍。先明确产品需要的驱动、文件系统、网络协议、电源管理模块然后逐个勾掉不需要的。我通常先在QEMU或者仿真环境里把基础驱动跑通再开始裁剪保证即使裁错也能快速回退。启动时先看启动日志里的硬件枚举再从/proc/modules看当前已加载模块最后确认需要保留的配置项。不建议上来就关CONFIG_UEVENT_HELPER或者删一堆驱动模块很可能导致你连控制台都没有。裁剪思路分大块关闭CPU频率调节相关、关闭不需要的I2C控制器、关闭不用的文件系统比如你只挂ext4就别编入vfat。每裁一块编译一次启动一次记录内核大小和启动时间变化。5.2 驱动与文件系统的裁剪实战在项目里我把芯片对应厂商的DTS里多余节点先注释掉然后内核配置里把对应的config关掉。这样可以省下不少内存占用和启动时间尤其对只有64MB内存的小板子每省512KB都很关键。文件系统盘裁剪也重要buildroot里可以把精简busybox之外的东西都去掉init脚本能不要则不装。有时候你的固件写着写着就超了实际上很多体积来自内核符号表和debugfs关掉CONFIG_DEBUG_INFO能让内核明显变小代价是出问题时难以定位栈回溯建议发布版关掉开发版留着。裁剪到后面最典型的启动异常是出现Kernel panic - not syncing: VFS: Unable to mount root fs这种八成是内核里没编译对应文件系统驱动或者root参数指定错误。我在一个项目里去掉所有块设备驱动后忘记保留mmcblk驱动结果SD卡起不来花半小时才想起来自己把关键驱动关了。5.3 裁剪后常见启动异常的应对裁剪后启动卡住先检查uboot传给内核的bootargs里的console参数是否和当前串口匹配。输出不完全时先调大内核log level或临时加earlycon。很多“起不来”不是驱动问题而是你在menuconfig里把串口console的驱动关掉了控制台看不到任何输出黑屏重启只能靠JTAG或者uboot阶段测试串口。另一个常见异常是启动过程中pinctrl警告pin request failed。因为你在设备树里引用了某个GPIO但对应的pinctrl驱动没有编入内核或者pin map冲突。解决办法是把CONFIG_PINCTRL相关的子项和芯片的pinctrl driver打开再检查设备树里的引脚是否重复占用。如果裁剪后网络不通先查phy驱动是否被裁掉以及mdio总线是否上电成功直接去/ sys/class/net看有没有eth0。6. 问题排查与调试技巧实录6.1 模块加载失败从dmesg找线索insmod一个.ko报错第一步别瞎猜直接dmesg | tail -50。常见错误是“Unknown symbol”说明模块引用了内核里没导出的符号或者依赖的模块没先加载。你编的模块依赖另一个模块加载顺序必须保证依赖在前或者统一modprobe会按depmod的依赖关系自动处理。出现“missing version magic”则是版本不匹配重新用同源码树编即可。还有一类错误是“Operation not permitted”常见于系统打开Secure Boot内核强制模块签名验证。这种需要在uboot禁用secure boot或者给模块签名不同平台方法差异大我在x86开发机上直接改grub关闭模块验证做嵌入式的板子一般uboot里关掉安全启动选项。6.2 设备节点不存在或无法打开的排查设备节点不存在先用ls /dev看看有没有没有的话查/sys/class下有没有对应类。类里也没有说明device_create根本没执行成功检查class指针有没有IS_ERR并且确认驱动probe是否被调用。如果你的驱动是用传统register_chrdev_region cdev_add但没有device_create那么/ dev下永远不会有自动节点必须mknod手动创建。node存在但打开报Permission denied基本就是权限问题node属于root普通用户打不开别慌chmod 666或者配置udev规则。打开报No such device or address通常是设备号没注册或者你在open里检查状态然后返回-ENXIO这时候用cat /proc/devices查主设备号是否存在再查驱动open是否执行过。6.3 用好dev_dbg与动态调试开关printk用得顺手但发布前要删掉不然内核日志刷屏拖慢性能。更好的方案是dev_dbg配合动态调试。menuconfig里开启CONFIG_DYNAMIC_DEBUG之后运行时可以通过/sys/kernel/debug/dynamic_debug/control精准控制某个文件、某个函数、某条打印是否开启。我在排查驱动时常用这个技巧保持代码里留好dev_dbg平时不打印出问题时在运行时开启对应驱动调试打印。具体用法比如echo file drivers/i2c/busses/i2c-xxx.c p /sys/kernel/debug/dynamic_debug/control这样就不用重新编译内核只在目标板上写一条命令就能看到相关调试信息。配合trace-cmd或perf可以追踪更复杂的内核函数调用栈但字符设备驱动排查中动态调试已经能覆盖大部分问题。6.4 一个I2C通信错误的完整排查过程遇到过一块板子i2cget能读到传感器但内核驱动里读出来全是0x00应用层乱跳。我先用i2cget验证寄存器可读证明硬件通路没问题再看驱动的client地址打印发现驱动里client-addr是0x49和设备树上写的0x48不一样原来是我在id_table里手滑写成了0x49。改对后继续读出来的值还是不对发现read函数里没有对寄存器地址做字节序转换因为芯片是16位温度拆成两个字节返回位置反了我把高字节和低字节拼写反了导致读出的温度对不上。之后干脆在驱动里把每一次read的原始字节用print_hex_dump打印出来跟i2ctransfer的结果对比一下就定位了。整个过程最耗时的不是代码而是反复编译刷机。后面我就学乖了把驱动编译成模块代码改动只要insmod新的.ko即可不用整机重刷调试效率提高了很多倍。最后再分享一个个人体会驱动开发最忌讳急于求成。代码写好后先静态过一遍结构体字段有没有对齐再想清楚probe流程和资源释放路径最后上板测试时不要急着跑功能先确认设备节点注册、属性读到的值、中断是否触发都符合预期再往下走业务逻辑。这样看似慢实际上比反复烧写快得多。希望这篇文章能帮你少走些弯路驱动调通后的那种成就感值得你为它熬几个夜。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价