Linux设备驱动开发实战:设备树、I2C与内核裁剪全解析
2026/9/13 19:47:03 网站建设 项目流程

一块新板子送到手上,厂家BSP能正常开机,但把自己外接的I2C传感器接上去,dmesg里什么动静都没有。这种场景我遇到太多次了,根子常常不在驱动代码的读写逻辑上,而在设备树节点、驱动匹配和内核配置这三件事的配合上。今天这篇内容,我就围绕Linux设备驱动开发里真正绕不开的那些环节——交叉编译环境、设备树配置、platform驱动框架、I2C驱动实例、内核裁剪和调试性能调优,把实际操作中积累的做法和踩过的坑一次性摊开讲。

这篇文章适合正在做嵌入式Linux驱动开发的工程师,也适合刚转行做Linux驱动、面对内核源码树一脸茫然的学习者。我不会只讲理论,每一部分都会给出可以照着抄的步骤和命令,同时解释清楚背后的设计逻辑。毕竟驱动开发的大部分问题,都出在“不知道这个机制为什么这样工作”上。

1. 写驱动前的三道关:交叉编译链、内核源码树与config裁剪

很多人写驱动,上来就打开编辑器敲file_operations,结果模块编译不过去,或者编译出来了加载不进去。我早期也这么干过,后来发现这些问题基本都出在准备工作上。准备工作不到位,后面写多少代码都是在给报错调试添素材。

1.1 内核源码树为什么是驱动编译的刚需

Linux内核模块不是一个完全独立的程序,它要引用内核内部的函数和数据结构,比如printkkmallocstruct file_operations。这些符号的定义都在内核源码树里。模块编译时,需要内核源码提供的头文件、生成的头文件(比如autoconf.hutsrelease.h)以及编译规则。

更关键的一点是:模块和内核版本必须严格匹配。内核模块加载时,内核会检查模块的vermagic字符串,里面记录了内核版本号、是否SMP、是否PREEMPT等信息。如果和你当前运行的内核不一致,insmod会直接报invalid module format。这个检查机制防止了模块和内核数据结构不匹配导致的内存破坏。

所以,第一步就是拿到和开发板运行版本一致的完整内核源码。你可以在开发板上执行:

uname -r

拿到版本号之后,去内核官网或者厂商BSP里找对应版本的源码。有些厂商会修改内核并打上自己的补丁,这时候用官方原版内核源码编译出来的模块,在厂商内核上一样可能加载失败。最稳妥的办法是直接用厂商提供的内核源码包。

1.2 交叉编译工具链的选型与版本匹配

开发板上的CPU架构和你的PC不同,所以需要用交叉编译工具链。ARM 32位平台一般用arm-linux-gnueabihf-,ARM 64位平台用aarch64-linux-gnu-

这里有一个容易忽略的坑:工具链版本和内核源码版本不能差太多。太新的工具链编译老内核,有时会因为编译器行为变化引入一些奇怪的编译错误;太老的工具链可能不支持新内核使用的某些语法特性。厂商SDK自带的工具链往往是最保险的,因为它就是针对这套BSP验证过的。

配置交叉编译环境的常用做法,是把工具链的bin目录加到PATH里,然后在内核编译时通过参数指定:

export PATH=$PATH:/opt/gcc-arm-9.2-aarch64/bin export ARCH=arm64 export CROSS_COMPILE=aarch64-linux-gnu-

这里我特别提醒一点:ARCHCROSS_COMPILE这两个环境变量,在编译内核和编译模块时都要保持一致。有些人编译内核时设置了,编译模块时在另一个终端里忘了设置,结果模块用宿主机的gcc编译,一加载就报格式错误。

1.3 模块编译的基本操作与y/m/n的选择

拿到源码树后,进到内核根目录,先执行配置:

make defconfig # 或者使用厂商提供的配置文件 cp vendor_defconfig .config make menuconfig

menuconfig之后必须执行一次:

make modules_prepare

这个步骤会生成模块编译需要的Module.symvers等文件。如果不执行,直接编译模块会报找不到Module.symvers或者各种头文件缺失。

编译单个模块的标准命令是这样的:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- M=/path/to/your/driver modules

这里的M=指向你驱动源码所在的目录。目录下需要有一个Makefile,里面至少写清楚:

obj-m := mydriver.o

如果你驱动由多个源文件组成,写法是:

obj-m := mydriver.o mydriver-objs := core.o interface.o

在menuconfig里,驱动有三种编译方式:y编译进内核、m编译成独立模块、n不编译。我的习惯是开发调试阶段用m,这样可以单独编译、单独加载,不用每次改一行代码就重新烧整个内核镜像。等驱动稳定了,再考虑是不是要改为y直接编进内核,这样启动时就不依赖模块加载顺序和根文件系统里的模块文件了。

2. 设备树配置:先让内核认识你的外设

设备树可能是我见过让新手最头疼的东西。它本质不复杂,但牵扯到的知识点特别碎。用一句话概括:设备树就是描述板级硬件拓扑的“履历表”。哪个外设挂在哪个总线上、寄存器基地址是多少、中断号是多少、速率多快,全部用一颗颗节点描述清楚。内核启动时解析设备树,建立起设备和驱动的对应关系。

2.1 设备树解决了什么问题

在早期的ARM Linux里,板级硬件信息都写在arch/arm/mach-xxx目录下的C文件里。换一个板子,要改文件、重新编译内核,非常麻烦。而且大量硬件配置和驱动代码混在一起,维护成本很高。设备树把硬件拓扑从内核代码里抽离出来,变成一份独立的数据文件。驱动侧只需要声明自己支持哪些设备,剩下的事情交给内核去匹配。

你可以把设备树理解成一份“招聘需求表”,驱动是“求职者”,compatible字段就是求职者简历上的技能标签。招聘需求表和简历对上了,双方才能见面(触发probe函数)。

2.2 从原理图到dts节点的完整过程

拿到一块板子,原理图上画着什么外设、接到哪个I2C控制器、从机地址多少,这些信息最终都要翻译成设备树节点。比如一个TI的温度传感器TMP117挂在I2C2控制器上,从机地址是0x48,设备树里就应该这样写:

&i2c2 { status = "okay"; clock-frequency = <100000>; tmp117@48 { compatible = "ti,tmp117"; reg = <0x48>; }; };

这段话的意思是:I2C2控制器使能,总线频率设为100kHz;总线上有一个地址为0x48的从设备,它兼容TI的tmp117驱动。

这里有几个新手高频翻车点:

节点名里的地址和reg必须对应。在设备树里,device@48表示这个节点在总线上的地址是0x48,reg = <0x48>也必须是同样的值。两者如果不一致,有些人会出现设备节点解析正常、但驱动probe拿到的reg地址不对的问题。

status = "okay"很关键。有些SoC的默认dtsi里,外设控制器默认是disabled的,你必须在板级dts里显式打开。少了这一行,控制器根本不工作,下面挂的设备节点也不会被扫描。

中断号的填写。中断号不是随便填的,它依赖SoC的中断控制器。比如ARM GIC中断号和Linux的软中断号之间往往存在一个偏移(通常是32)。到底是填硬件中断号还是软件中断号,要看SoC的dtsi里interrupt-cells的约定和现有节点的写法。我一般会先翻看同平台已经验证过的节点,照着写,踩坑概率最小。

2.3 compatible匹配机制:驱动被probe的前提

设备树写好了,驱动侧必须有一张“能力清单”来和它配对。这个清单在驱动代码里表现为of_match_table

static const struct of_device_id tmp117_of_match[] = { { .compatible = "ti,tmp117", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, tmp117_of_match);

内核在注册驱动时,会遍历总线上已有的设备,把设备节点的compatible属性和驱动的of_match_table一一比对。字符串完全一致,才调用驱动的probe函数。

很多人的驱动加载了、/sys/bus/i2c/devices/下也有设备节点,但probe就是不执行,最典型的原因就是compatible字符串没对齐。设备树里多写了一个空格、驱动里大小写不一致,都会导致匹配失败。排查时可以查看设备树解析结果:

cat /sys/firmware/devicetree/base/i2c@.../tmp117@48/compatible

看到实际字符串后,再去驱动源码里比对,问题通常一眼就能看出来。

2.4 设备树修改后如何生效

修改dts后,需要重新编译dtb文件并烧写。最简单的方式是在内核源码目录下:

make dtbs

生成的dtb在arch/arm64/boot/dts/对应厂商目录下。烧写dtb有两种常见方式:一是单独烧到dtb分区,二是打包进boot镜像。具体取决于平台的启动流程。调试阶段我个人更喜欢用U-Boot的tftp下载dtb,改一次下载一次,比反复烧sdcard快得多。

设备树是否正确生效,除了看/sys/firmware/devicetree/base/,还可以在U-Boot环境变量里确认fdtfile是否指向正确的dtb文件名。如果U-Boot加载的dtb和内核打包的dtb不是同一份,你在dts里改了半天也不会生效,这种情况我踩过不止一次。

3. platform驱动与字符设备:驱动代码的主骨架

设备树让内核认识外设了,接下来真正干活的就是驱动代码本身。Linux驱动框架很多,但嵌入式开发里占大头的是platform驱动模型加上字符设备接口。理解透这一块,其他总线类型(I2C、SPI、USB)都是在这个基础上的变体。

3.1 设备和驱动分离的设计逻辑

platform驱动模型的核心思想是设备与驱动分离。设备信息(用什么地址、占用哪个中断)由设备树或ACPI描述,设备驱动只关注“怎么操作硬件”。总线负责把两者匹配起来。

你可以这样理解:设备树是“插头”,驱动是“插头对应的电器功能”,platform总线就是“插座”。插头插到插座上,功能才通电工作。这样做的好处是,同一个驱动可以支持多个硬件实例,同一个硬件在不同板子上也可以通过改设备树灵活调整资源,不用改驱动代码。

3.2 一个最小字符设备驱动的骨架

字符设备是Linux里最基础、也最常用的设备类型。它给应用层提供一组类似文件的操作接口:open、read、write、ioctl、close。下面是最小骨架里init函数的核心动作:

static int __init mydrv_init(void) { dev_t dev_num; alloc_chrdev_region(&dev_num, 0, 1, "mydrv"); cdev_init(&my_cdev, &my_fops); cdev_add(&my_cdev, dev_num, 1); my_class = class_create("mydrv"); device_create(my_class, NULL, dev_num, NULL, "mydrv%d", 0); return 0; }

这段代码做的事情依次是:向内核申请一个设备号、初始化字符设备对象并绑定file_operations、把设备加到内核、创建设备类、在/dev下生成设备节点。应用层随后就能通过open("/dev/mydrv0", ...)来访问你的设备。

对应的file_operations长这样:

static const struct file_operations my_fops = { .owner = THIS_MODULE, .open = my_open, .read = my_read, .write = my_write, .unlocked_ioctl = my_ioctl, .release = my_close, };

注意unlocked_ioctl。旧内核里有个ioctl字段,现在推荐用unlocked_ioctl,因为内核默认不再为ioctl调大锁。这个细节虽然小,但面试和代码审查里经常被问到。

3.3 probe函数里到底该干什么

platform驱动的probe函数是驱动初始化的主战场,所有“准备让设备开始工作”的事情都在这里做。一个典型的probe函数通常包含这几件事:

  • 读取设备树里的资源:of_property_read_u32读参数,platform_get_resource拿地址和中断号;
  • 申请硬件资源:ioremapdevm_ioremap_resource映射寄存器地址,request_irq注册中断;
  • 初始化硬件:设置GPIO方向、配置外设寄存器、复位芯片;
  • 注册字符设备或其他子系统接口;
  • 初始化自旋锁、互斥锁、工作队列等内核同步机制。

这里我强烈建议优先使用devm_开头的资源管理API,比如devm_ioremap_resourcedevm_kzallocdevm_request_irq。这些API会在设备驱动卸载时自动释放资源,省去你在remove函数里逐一手动释放的麻烦。一旦资源泄漏,反复加载卸载驱动几次,系统可用内存越来越少,那种问题最难查。

probe函数的一个特殊返回值值得单独说:-EPROBE_DEFER。它表示“我依赖的资源现在还没准备好,请过一会儿再叫我”。最常见的情形是多个设备之间存在依赖关系,比如I2C设备依赖I2C控制器驱动先加载。内核会自动在依赖满足后再次调用你的probe。很多新人不知道这个机制,在probe里依赖外部资源时没有返回-EPROBE_DEFER,而是直接返回错误,导致设备永远初始化不了。

3.4 ioctl:应用和驱动之间的自定义协议

read和write适合传输连续的数据流,但驱动开发里更多时候应用层要的是“查状态”“设参数”这类操作。典型例子:读传感器的某个寄存器、设置采样频率、使能或关闭某个功能。这时候就该用ioctl。

ioctl命令的格式也有讲究,通常使用内核提供的宏:

#define MYDRV_SET_FREQ _IOW('M', 1, int) #define MYDRV_GET_TEMP _IOR('M', 2, int)

_IOW_IOR的意思是往内核写数据、从内核读数据。第一个参数是“魔数”,用于区分不同驱动;第二个是命令序号;第三个是数据类型。这个编码规则保证了应用层和内核层对命令的理解一致。

ioctl实现里有一个安全细节必须注意:从用户空间传入的指针不能直接访问。内核里要用copy_from_usercopy_to_user

if (copy_from_user(&val, arg, sizeof(val))) return -EFAULT;

直接解引用用户指针,后果轻则数据错误,重则内核崩溃。这个规矩没有例外,哪怕你确信用户程序是自己写的,也一定要走拷贝接口。

4. 一个I2C驱动从零到能跑的全过程

I2C大概是嵌入式外设里最常用的总线了。传感器、EEPROM、RTC、音频编解码器,几乎全走I2C。搞懂I2C驱动的完整链路,很多外设驱动都能触类旁通。

4.1 I2C子系统里的三个角色

Linux的I2C子系统分三层:adapter(控制器)、client(挂在总线上的从设备)、driver(从设备的驱动逻辑)。adapter是SoC上的I2C控制器,它负责在物理总线上产生时序;client代表挂在总线上的一颗具体芯片;driver就是我们要写的代码。

这三者的关系可以类比成一个商店:adapter是货架,client是放在货架上的商品,driver是商品的说明书。商品在货架上,说明书和商品匹配上之后,商品才能正常“工作”。

你可以在用户空间快速查看当前系统有几条I2C总线:

i2cdetect -l

每条总线都是kernel中的一个adapter,设备树里I2C控制器节点使能后,就会注册对应的adapter。

4.2 从设备树节点到i2c_client的注册

回到开头那个TMP117的例子。设备树里&i2c2下的子节点,内核在解析时会自动创建对应的i2c_client结构体,挂到i2c2这个adapter上。这个过程对驱动开发者是透明的,不需要你手动创建client。

但如果你在一个没有设备树的老平台上工作,就需要用i2c_new_client_device配合board_info来手动注册client:

static struct i2c_board_info tmp117_info = { I2C_BOARD_INFO("tmp117", 0x48), }; i2c_new_client_device(adapter, &tmp117_info);

这是设备树普及之前的旧方式,现在大部分平台已经不再使用。理解这个机制有助于你明白i2c_client到底是怎么来的,排查问题时知道在设备树和设备注册之间发生了什么。

4.3 i2c_driver的代码结构与寄存器读写

一个标准i2c_driver注册是这样的:

static const struct i2c_device_id tmp117_id[] = { { "tmp117", 0 }, { } }; MODULE_DEVICE_TABLE(i2c, tmp117_id); static struct i2c_driver tmp117_driver = { .driver = { .name = "tmp117", .of_match_table = tmp117_of_match, }, .probe = tmp117_probe, .remove = tmp117_remove, .id_table = tmp117_id, }; module_i2c_driver(tmp117_driver);

module_i2c_driver是个简写宏,它替你把module_init和module_exit都安排好,并且会自动处理依赖关系。只要这个驱动文件被编译,模块加载时就会向I2C子系统注册这个driver。

probe函数里读写寄存器,最常用的两个接口是:

s32 val = i2c_smbus_read_byte_data(client, reg_addr); s32 ret = i2c_smbus_write_byte_data(client, reg_addr, val);

i2c_smbus_*系列接口会在I2C总线上自动组装读写的时序,对大多数8位寄存器设备足够用。如果芯片有更复杂的随机访问需求,比如先写16位寄存器地址再读N字节数据,可以用i2c_transfer构造两个i2c_msg来完成。

这里有一个特别容易坑人的点:I2C设备返回的数据字节序。比如TMP117的温度寄存器是16位的,有的芯片高字节在前,有的低字节在前。读出来之后不处理字节序,计算得到的温度值看起来就是乱码、甚至是负的离谱数字。写驱动时一定要查清楚datasheet里的字节序描述。

还有ack失败的问题。I2C总线上如果从设备没上电、地址写错、或者上拉电阻没焊,读寄存器时dev_err会报acknowledge failed。这不是驱动代码的bug,而是硬件链路问题。我一般会用示波器或者逻辑分析仪看SCL/SDA波形,确认ACK位电平是否被拉低。软件上排查总线和地址是否正确,是调试I2C的第一步。

4.4 用户态i2c-tools在调试阶段的妙用

在写驱动之前,我强烈建议先用用户态工具确认芯片本身能通。这个习惯能帮你把“硬件问题”和“驱动代码问题”快速隔离开。常用命令组合是:

i2cdetect -y 2 i2cget -y 2 0x48 0x00 i2cset -y 2 0x48 0x01 0x23

i2cdetect扫描总线上所有从机地址,能看到0x48出现在列表里,说明设备和总线连接基本正常。i2cget读寄存器,确认寄存器值符合预期。如果这些能通,再回头查驱动代码;如果用户态都通不了,先查硬件。

4.5 在FPGA/SoC平台上的I2C差异

有些平台上的I2C控制器不是SoC原生的,而是在FPGA里用IP核实现的,比如Xilinx AXI IIC这种。设备树里的compatible会变成xlnx,xps-iic-2.00.a之类,内核加载对应的适配器驱动。这种场景下,你在设备树里需要特别注意控制器的寄存器地址范围、中断号是否与FPGA的地址分配一致。

还有更常见的做法是GPIO模拟I2C,设备树里用i2c-gpio节点描述:

i2c-gpio@0 { compatible = "i2c-gpio"; gpios = <&gpio0 3 0>, <&gpio0 4 0>; i2c-gpio,delay-us = <5>; };

这种方案适合低速、不计较CPU占用的场合。但它的时序全靠GPIO翻转模拟,遇到总线挂死需要恢复时,处理起来比原生I2C控制器麻烦不少。性能敏感的场景还是优先用控制器自带的I2C外设。

5. 内核裁剪与系统镜像瘦身:从冗余驱动到更快的启动

驱动开发做到后面,项目要交付生产了,内核裁剪就提上日程。厂商默认BSP一般都把什么驱动都编译进去,一个内核镜像动辄几十MB,启动时间十几秒。裁剪优化的目标,就是让镜像只包含当前产品真正需要的东西。

5.1 先盘库存,再动手术

裁剪内核最忌讳拍脑袋。我见过有人为了缩小镜像,把内核里所有不认识的功能全关掉,结果启动到一半崩溃,连串口日志都来不及看。正确的第一步,是梳理当前产品的硬件清单。

拿一份实际的嵌入式计算平台举例。板子上有CPU、DDR、eMMC、以太网PHY、USB Host、音频Codec、一个UART调试口,可能还有几个GPIO控制的LED和按键。裁剪前要做一张映射表:

外设内核配置项编译方式
eMMC存储CONFIG_MMCy
FAT文件系统CONFIG_FAT_FSy
以太网控制器CONFIG_STMMAC_ETHy
USB HostCONFIG_USB_XHCI_HCDy
UARTCONFIG_SERIAL_8250y
音频CodecCONFIG_SND_SOC_XXXm

这张表是裁剪的路线图。没用到的东西才允许关,用到的必须保住。

5.2 从内核config到启动路径的逐层裁剪

裁剪的层次一般从大功能开始。如果产品不需要无线功能,CONFIG_BTCONFIG_WIRELESSCONFIG_CFG80211这些直接关掉。不需要显示输出,CONFIG_DRMCONFIG_FB也可以关闭。不需要文件系统多样性,只保留ext4和fat16/32,剩下的CONFIG_EXT2_FSCONFIG_XFS_FSCONFIG_BTRFS_FS都可以关。

关注config的同时,还要看启动日志。用dmesg按时间戳分析:

dmesg -T

注意看哪些驱动在启动早期被加载,哪些在很晚才加载。有些模块其实根本不需要,但因为内核里没被裁剪掉,启动时还在做类似设备扫描的操作,白白浪费几百毫秒。

5.3 文件系统层面的瘦身思路

内核镜像瘦下来之后,还要看rootfs。很多BSP的rootfs是一个完整的发行版,里面装着一堆开发工具、文件系统工具、桌面组件。产品上线前,这些都要清掉。

我的做法是拿busybox重新拼一个最小rootfs。busybox把上百个常用命令合并到一个二进制文件里,通过软链接来区分调用哪个命令。配置busybox时只勾选产品需要的applet,比如常用的shlscatmountifconfigudhcpc,这些就够了。

大一点的可执行文件可以strip掉符号表:

aarch64-linux-gnu-strip --strip-unneeded /path/to/binary

一个没有strip过的二进制可能有好几MB,strip之后可能只有几百KB。不过要小心:如果产品后续需要远程抓取coredump调试,符号信息就很有价值,这时就不要急着strip,或者在交付镜像之外的调试版本里保留符号。

5.4 启动时间优化的实操方向

裁剪和瘦身做完了,接下来就是启动时间优化。这里有几个投产比很高的方向。

内核参数精简。U-Boot的bootargs里,console=保留、root=保留,其余能省的省掉。quiet参数可以屏蔽大部分内核输出,配合loglevel=3只打印错误级别的日志,减少串口输出对启动时间的拖累。串口在低波特率下打印大段日志非常耗时。

异步probe。内核启动时,platform设备是一个个串行匹配、串行probe的。如果某个设备的probe里有比较耗时的操作,比如等待外部芯片复位、读取EEPROM数据,会阻塞后续所有设备。内核提供了driver_async_probe参数,可以指定某些驱动异步执行probe:

driver_async_probe="tmp117,sdhci"

需要注意的是,异步probe后,设备节点创建的顺序不再可控,应用层如果依赖设备节点的创建顺序,就需要额外处理。

rootfs分区等待。rootwait会让内核无限等待root设备出现。如果rootfs在eMMC上,eMMC初始化一般很快,但有些平台的存储控制器要等固件加载,等待时间不稳定。用rootdelay=1设为固定1秒延迟,或者优化为检测设备节点出现后才继续,可以省掉rootwait带来的潜在长时间等待。

6. 调驱动必备:日志分级、崩溃现场与中断风暴

驱动跑不起来,或者跑着跑着系统崩了,这是驱动开发的家常便饭。和纯应用开发不一样,驱动崩溃往往直接让整个系统死掉,给你留下的线索只有一串内核日志。学会从日志里快速定位问题,是驱动工程师的核心技能。

6.1 printk的级别把控与动态调试开关

printk是驱动开发最常用的调试工具,但它也讲究用法。printk有8个级别,从KERN_EMERGKERN_DEBUG。级别越低越紧急,越容易被打印出来。平常开发用dev_infodev_err就够,这两个接口会自动带上设备名,日志里能直接看到是哪个设备报错。

调试细节时用dev_dbg,默认情况下它会被编译器优化掉,不产生日志。但内核提供了一套动态调试机制,可以在编译时保留dev_dbg,运行时按需打开:

# 挂载debugfs mount -t debugfs none /sys/kernel/debug # 打开某文件下所有动态调试日志 echo "file tmp117.c +p" > /sys/kernel/debug/dynamic_debug/control

这个机制的好处是:正式版本里不用删调试日志,保留它们但默认关闭,出问题时远程开一个开关就能拿到详细日志,非常有用。

6.2 oops信息里最关键的三行

驱动出错导致系统oops时,dmesg最后几行就是现场。很多人一看就慌,其实oops信息主要看三处。

第一处是PC is at ...,它告诉你崩溃时CPU正在执行哪个函数。第二处是LR is at ...,它告诉你这个函数是被谁调用的,也就是调用者。第三处是Call trace,它会列出完整的函数调用链。

比如拿到这样一条Call trace:

Call trace: my_irq_handler+0x28/0x48 __handle_irq_event_percpu+0x50/0x70 handle_irq_event_percpu+0x30/0x50

崩溃点在my_irq_handler里。这时候我一般先去源码里看这个函数偏移0x28附近在做什么,再用addr2line结合编译出来的vmlinux定位到具体源码行:

aarch64-linux-gnu-addr2line -e vmlinux -f my_irq_handler+0x28

如果符号被优化掉了,addr2line可能只给出??。这时候System.map文件里的符号表可以作为对照,找到函数起始地址,手工计算偏移对应的指令,再从objdump反汇编里看崩溃指令是什么。

6.3 ftrace跟踪函数调用过程

有些问题不是崩溃,而是驱动行为完全不符合预期。这时候光靠printk打点效率太低,我推荐用ftrace。

挂载tracefs后,设置跟踪器为函数图:

mount -t tracefs none /sys/kernel/tracing echo function_graph > /sys/kernel/tracing/current_tracer echo 'my_function*' > /sys/kernel/tracing/set_ftrace_filter echo 1 > /sys/kernel/tracing/tracing_on

之后/sys/kernel/tracing/trace文件里就会记录每次调用my_function的完整调用链和执行时长。用这个方式,你能看到驱动程序里哪些函数耗时异常、哪些函数被反复调用却什么都没干。

6.4 一个真实排查案例:中断风暴是怎么定位的

有一次项目里系统负载很低,但整体响应就是慢,top里能看到一个ksoftirqd线程占CPU接近100%。我当时第一反应是中断异常,直接看:

cat /proc/interrupts

找数字增长极快的那一行,发现是某个GPIO中断,每秒触发上万次。再对照设备树,这是按键的中断,按常理人不应该按那么快。用示波器量引脚波形,发现是一个浮动引脚在环境干扰下产生大量毛刺,每次都触发边沿中断。

这种情况如果只在软件里修,就是把中断改成双边沿触发加软件去抖,或者用request_threaded_irq把中断处理放到线程上下文,降低耗时。但真正的根因是硬件上漏了上拉电阻。先让硬件工程师补上拉,再在驱动里加去抖,双管齐下才算彻底解决。

这个案例给我的启发是:驱动里的“性能问题”,很多时候不是软件逻辑慢,而是硬件信号质量差、中断频繁触发导致的无效开销。碰到性能问题,先看一眼/proc/interrupts/proc/softirqs,能少走很多弯路。

7. 开发机环境的三个小坑:磁盘、乱码与DNS

驱动开发除了在目标板上调试,开发机上的环境问题一样能卡掉你半天时间。这三个问题是评论区里出现频率最高的,我在这里一并说清楚。

7.1 WSL里删除文件后Windows磁盘空间没释放

很多人用WSL做Linux开发环境,在WSL里删了大文件,Windows的磁盘占用一点没减少。这是因为WSL发行版的文件系统存放在一个动态扩展的vhdx虚拟磁盘文件里,删除文件不会自动回收已分配的空间,vhdx只会增长不缩水。

处理方法分两步。先在Windows命令行执行:

wsl --shutdown

然后以管理员身份打开diskpart,对WSL发行版的ext4.vhdx执行压缩:

select vhdx file=C:\Users\你的用户名\AppData\Local\Packages\...\ext4.vhdx attach vhdx compact vhdx detach vhdx exit

做完这一步,能节省大量磁盘空间。建议定时做一次,别等到磁盘满了才处理。

7.2 解压Windows压缩包出现乱码

Windows下用中文名压缩文件时,文件名编码大多是GBK;而Linux发行版默认用UTF-8,直接unzip解压就会出现文件名乱码。用支持指定编码的unzip版本可以解决:

unzip -O CP936 文件名.zip

如果系统自带的unzip不支持-O参数,可以用7z替代:

7z x 文件名.zip

7z对中文编码的处理比老版本unzip明显好很多。注意不要在乱码已经出现之后再用convmv批量改名,那会把正常文件也改乱,得不偿失。

7.3 配置DNS反复失效

开发机上配置DNS,很多人直接编辑/etc/resolv.conf,但重启网络或者重启系统后配置就没了。这是因为现代Linux发行版大多用systemd-resolved或NetworkManager管理DNS配置,/etc/resolv.conf其实是一个符号链接,真正的配置源在别处。

使用systemd-resolved的话,正确做法是编辑/etc/systemd/resolved.conf

[Resolve] DNS=223.5.5.5

然后重启服务:

systemctl restart systemd-resolved

resolvectl status可以查看当前实际生效的DNS服务器。如果你是在嵌入式板卡上开发,没有systemd-resolved,直接写/etc/resolv.conf就行,但要注意别同时启动多个网络管理服务,它们会互相覆盖配置。

这几个问题看起来和驱动本身没直接关系,但开发环境不通畅,真正写驱动的效率会大打折扣。我自己有个习惯,把这类开发机上遇到的问题单独记一份笔记,什么现象、什么原因、怎么处理,每次遇到都记一笔,后期基本都能在几分钟内绕开。

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

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

立即咨询