嵌入式驱动开发这个方向,在创客学院和嵌入式社区里一直是讨论热度最高的领域之一。不少人一开始以为驱动开发就是翻芯片手册、照着寄存器地址写代码,真正接触 Linux嵌入式驱动开发之后才发现,设备树配置、内核编译、系统裁剪优化、调试工具、性能调优、算法嵌入式部署,每一个环节都能让新手卡上一周。我这里做了一份比较完整的学习资料汇总,结合创客学院嵌入式驱动开发相关的课程资料、我自己的项目实战记录和踩坑笔记,整理成一条可以照着走的学习路径,适合两种人:一是刚接触驱动的初学者,二是已经能写字符驱动、但对设备树、系统裁剪、性能分析这些环节还不成体系的人。
这份资料汇总不只是给你列一堆书名和链接,而是帮你把“为什么学”“先学什么”“学到什么程度算合格”讲清楚。嵌入式驱动开发有一个明显特点:资料不缺,缺的是能落地的路径。网上教程满天飞,但多数讲完字符设备就没了,真正生产环境里需要的设备树配置、启动优化、内存排布、算法部署时的驱动配合,往往要靠自己踩坑。我希望这篇文章能把这些隐藏环节补上,让你在创客学院或者其他任何地方学习时,都能拿出一个可复用的框架。
1. 先搞清楚嵌入式驱动开发到底学什么
1.1 驱动开发不是“点灯”,而是理解软硬件的边界
新手最容易误解的一件事,就是把驱动开发等同于控制硬件。写个 GPIO 点灯、读个按键状态,这只是驱动开发的冰山一角,而且是刻意简化后的部分。真实项目里的 Linux 驱动,做的是“让操作系统能管理硬件”这件事,包括设备如何被内核发现、怎么注册进设备模型、中断来了怎么通知上层、数据怎么在用户空间和内核空间之间安全传递、休眠唤醒的流程怎么设计、多个进程同时访问时怎么保证不冲突。
我在整理资料时,给驱动开发下了一个很实在的定义:驱动是协议转换器,把硬件寄存器的时序和语义,翻译成 Linux 内核标准接口能理解的语言。应用层调open/read/write/ioctl,驱动层负责把这些调用变成寄存器操作、DMA 传输、中断处理。这个概念先立住,后面学东西才不会跑偏。比如你看到某个驱动里用request_irq注册中断,就不能只背函数名,而是要理解从这个函数被调用到硬件中断信号触发之间,内核经历了哪些机制。
1.2 学习路线的三个阶段
我把创客学院嵌入式驱动开发的课程资料和市面上的主流学习路径做了一次对比,发现比较靠谱的路线大致分三个阶段,每个阶段需要解决的问题是完全不一样的。
第一阶段是基本功:C 语言、ARM 体系结构、Linux 内核基础。这个阶段的目标不是写驱动,而是建立“程序在硬件上怎么跑”的画面。指针、内存布局、中断现场保存、栈的作用、MMU 和 cache 的概念,这些都会在驱动开发中被反复用到。C 语言不熟练的人,后面看内核链表、list_for_each_entry这种宏会非常痛苦;不理解 ARM 中断模式的人,也很难读懂中断处理函数里为什么要关中断、为什么要保存上下文。
第二阶段是核心机制:字符设备驱动、platform 总线、设备树匹配、中断子系统、内核并发与同步。到这个阶段,你已经要开始写代码,并且在开发板上反复烧写内核验证了。你需要理解一个模块从insmod到rmmod经历了哪些流程,file_operations里的函数指针是怎么被调用的,probe函数为什么是 platform 驱动的入口。
第三阶段才是真正的工程能力:系统裁剪优化、性能调优、算法嵌入式部署、稳定性测试。这三个阶段不是线性的,很多时候要来回跳。比如算法部署过程中发现内存不够,你得回到裁剪阶段看 rootfs 里哪些包能去;调试性能问题时发现中断延迟过大,可能又要回到中断驱动部分重新设计。资料汇总最重要的一点,就是把三阶段对应的内容分好类,免得你想学性能调优时还要去翻基础语法。
1.3 创客学院资料里值得优先看的几类
很多人在创客学院或者其他平台存了一堆资料,结果反而是“资料越多,动手越少”。我按优先级把资料分成几类:
- 内核源码和官方文档:这个是根,
Documentation/devicetree/bindings/、Documentation/driver-api/这些路径下的文档比绝大多数二手教程准确。遇到设备树节点不知道怎么写时,先查 bindings 文档,再看驱动源代码,最后才看网上的博客。 - 芯片厂商提供的板级支持包:当你拿到一块具体开发板时,厂商的 SDK 里包含了使用的内核版本、设备树源文件、配置文件和启动镜像。这些资料比通用教程更接近实际项目,因为每个板子的引脚、时钟、中断号都不一样。
- 可复制的实验代码:好的驱动学习资料,一定会配套一份能在开发板上直接编译运行的代码。光看代码和真正编译运行,体验完全不一样。抄代码也是学习,但抄完必须自己改引脚、改设备树、加功能。
我建议资料准备不用贪多,主线保持一到两套视频课程,辅以 Linux 内核官方文档,再配一个二手开发板或者 QEMU 模拟环境,基本就够了。
2. 从零开始的资料清单与选型建议
2.1 基础资料:C 语言、ARM 体系结构、Linux 内核
嵌入式驱动开发的基础资料,不需要像准备考研那样铺很厚。很多时候,你不需要把 Linux 内核全部读完,也不需要把 ARM 手册背下来,关键是抓大放小。
第一块是 C 语言。市面上讲 C 的书很多,但针对内核开发,重点是指针、结构体、函数指针、内存分配与释放、链表和红黑树的基本使用。建议边看边在 Linux 内核源码里找对应用法,比如container_of宏、list_head结构体,这些都是驱动开发中高频出现的写法。
第二块是 ARM 体系结构。不必从 ARMv7、ARMv8 的每一个指令集细节开始啃,优先理解这几个主题:内存映射、MMU 与页表、cache 一致性、中断控制器 GIC、异常向量表。这些概念和驱动息息相关,尤其是 DMA 和中断相关的驱动,如果没有 cache 一致性的概念,数据错乱的时候你会排查到怀疑人生。
第三块是 Linux 内核基础。建议先看进程、调度、内存管理的基本概念,没必要深挖每个子系统。驱动开发中你至少要能看懂kmalloc和kzalloc的区别、wait_queue是怎么工作的、spinlock和mutex什么时候能用。这部分参考资料我比较推荐直接看 Rusty Russell 写的《Linux Kernel in a Nutshell》,内容短小,适合作为工具书,不需要全读。
2.2 驱动框架资料:字符设备、platform 总线、设备树
驱动框架相关的资料,是这行最核心的“内功”。当年我在创客学院的资料库里看到两套内容,一套是传统字符设备驱动,另一套是现代 platform 驱动加设备树驱动,两套必须都学,因为老项目的维护还可能用到传统方式,而新项目基本上都是设备树加 platform 驱动。
字符设备驱动是入门第一课,它教的是设备号申请、cdev注册、file_operations实现、copy_to_user与copy_from_user、ioctl接口设计。platform 驱动则是真正生产环境的主流,它把“硬件信息”和“驱动代码”解耦,通过compatible字符串将设备树节点与驱动匹配起来。你需要理解platform_driver_register、platform_get_resource、devm_platform_ioremap_resource这些 API 背后的数据流。
设备树的资料近几年特别重要。早期的内核靠 board file 里硬编码硬件信息,现在全改成设备树描述硬件拓扑。所以设备树配置不是可选项,而是必备技能。学习资源方面,bootlin的设备树讲解、内核文档里的devicetree/usage-model.rst都值得读,创客学院里的设备树专题也可以看,但一定要配合实际编译设备树、在板子上修改节点来验证。只看不练,设备树永远学不会。
2.3 工具链与调试资料:交叉编译、gdb、ftrace、perf
资料清单里,工具链部分经常被忽略,但它是驱动开发效率的分水岭。
交叉编译工具链,建议用芯片厂商自带的 SDK 预编译版本,而不是自己从源码编。自己编译一套交叉工具链不是不行,而是性价比很低,前期学习阶段先把时间花在核心内容上。拿到工具链后,需要掌握arm-linux-gnueabihf-gcc这类交叉编译命令,以及 Makefile 中CROSS_COMPILE和ARCH变量的设置。
调试工具方面,按重要性排序:dmesg和 printk 是最基础的;GDB 配合 kgdb 可以调试内核,但环境配置比较麻烦,建议第二优先级;ftrace和perf是性能调优和函数跟踪的神器,等到做系统调优时再深入学习;strace和busybox里的工具,用于验证应用层行为。
这里给一个资料整理的优先级表,是我自己压箱底的经验:
| 资料类型 | 代表内容 | 优先级 | 学习时机 |
|---|---|---|---|
| C/数据结构 | 指针、链表、内核数据结构 | 高 | 入门期 |
| ARM 体系结构 | MMU、cache、中断 | 高 | 驱动第一课 |
| Linux 内核基础 | 进程、内存、同步机制 | 高 | 驱动第一课 |
| 字符设备驱动 | file_operations、cdev | 高 | 驱动入门 |
| 设备树配置 | DTS 语法、bindings 文档 | 非常高 | 现代驱动必需 |
| 内核配置与裁剪 | menuconfig、buildroot | 高 | 系统集成期 |
| 调试工具 | dmesg、gdb、ftrace、perf | 非常高 | 贯穿全程 |
| 算法部署 | 模型转换、内存布局、NPU 工具链 | 中高 | 项目落地期 |
3. 设备树配置:新手最容易卡住的地方
3.1 设备树的作用与基本语法
设备树是 Linux 用来描述硬件信息的“硬件说明书”。以前我们把硬件信息写在 C 源码的 board file 里,换一个板子就要改内核代码、重新编译内核。设备树出现之后,硬件描述和内核驱动代码分离了,同一个内核镜像可以适配多个板子,只要更换设备树文件就行。
设备树文件通常有三种后缀:dts是源文件,dtsi是被包含的头文件,dtb是编译后的二进制。编译命令一般是:
make dtbs # 或者单独编译某个设备树 make ARCH=arm dtbs看一个最简单的外部 gpio-led 设备树节点示例,很多开发板的 LED 点灯实验就是从这类节点开始的:
/ { leds { compatible = "gpio-leds"; status = "okay"; work_led { label = "work"; gpios = <&gpio1 5 GPIO_ACTIVE_LOW>; default-state = "off"; }; }; };这里的compatible = "gpio-leds"是驱动匹配的关键。内核里的leds-gpio驱动会通过这个字符串找到这个节点,然后读取gpios属性来初始化 GPIO。&gpio1是一个 phandle 引用,指向另一个描述 GPIO 控制器的节点;5是 GPIO 编号;GPIO_ACTIVE_LOW表示低电平时点亮。如果系统只租用了某颗 GPIO 而没配置 pinctrl,那驱动也能注册,但硬件引脚电平可能不对,这就是很多人遇到“设备树看起来没错,但灯不亮”的原因之一。
3.2 设备树配置的匹配逻辑
搞清楚设备树,必须理解内核的匹配顺序。内核启动到设备模型阶段,会遍历设备树中的节点,每个节点都会在总线上创建一个platform_device。驱动注册时,会调用platform_match,用compatible字段和驱动里的of_device_id做比较。这个匹配过程很多人把它简单理解成“字符串相等”,其实还有别名匹配、设备树节点的name属性匹配、ACPI匹配等,只是设备树开发中最常用的是compatible匹配。
以 platform 驱动为例:
static const struct of_device_id demo_of_match[] = { { .compatible = "vendor,my-device", }, { /* sentinel */ } }; static struct platform_driver demo_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = "demo_device", .of_match_table = demo_of_match, }, }; module_platform_driver(demo_driver);设备树里写compatible = "vendor,my-device";,驱动注册后probe就会被调用。无论你以前是否用过init和exit函数,现代驱动基本都推荐用module_platform_driver这个宏来简化模块入口。
这里要说一个我反复踩过的坑:不要把设备树里的compatible和驱动里driver.name混用。driver.name主要用于 sysfs 里显示的设备名,而compatible才是真正用于匹配的字段。如果只想快速测试,可以在模块参数里指定driver.name,但正式项目还是要保证compatible定义得规范,格式建议为 “厂商,型号”,比如"ti,beaglebone-black"或"fsl,imx6ull"。
3.3 配置后为什么不生效:常见排查思路
设备树配置不生效,是初学者问得最多的问题。我根据真实项目经验,整理了一套排查方法,按顺序执行基本能找到问题。
第一步,确认dtb真的被用上了。很多板卡有多个设备树,烧写时用的可能是默认的那份。启动 log 里能看到 kernel 截获的设备树信息,比如Machine model: xxx,如果和你改的不一致,说明烧错文件了。
第二步,确认节点状态是okay。很多开发板为了省电会默认把用不到的节点status = "disabled",或者 overlay 没加载。手动把节点改成okay再测试。
第三步,确认 pinctrl 配置正确。GPIO、UART、I2C 这类外设,除了设备树节点本身,还需要确认引脚被复用为对应的功能。如果 pinctrl 没配置,驱动读到的是虚拟寄存器,实际引脚波形完全不对,这种问题很难从软件报错里看到,只能拿着示波器或者逻辑分析仪去量。
第四步,确认驱动的compatible和设备树完全一致。这里注意大小写、下划线、短横线,比如my-device和my_device就是两个完全不同的字符串,内核不会给你任何容错。
第五步,看内核日志和 sysfs。dmesg里如果没有 probe 相关输出,多半没匹配上;ls /sys/bus/platform/devices/可以看设备是否注册成功;cat /proc/device-tree/leds/work_led/gpios可以确认设备树是否被正确解析成二进制属性。
| 现象 | 可能原因 | 排查命令 |
|---|---|---|
| probe 没执行 | compatible 不匹配、设备树没生效 | dmesg | grep driver名 |
| 设备树节点但无设备 | status disabled | cat /proc/device-tree/.../status |
| GPIO 电平不对 | pinctrl 缺失或配置错误 | cat /sys/kernel/debug/pinctrl/... |
| 注册成功但读写异常 | 寄存器地址错误、时钟未开 | cat /proc/iomem |
设备树配置不是背语法就会的,你把一个节点改了之后,驱动、pinctrl、时钟、电源、复位,每一步都可能是坑。经验多了以后,你会形成肌肉记忆:先查状态,再查 compatible,再查 pinctrl,最后查外设电源。
4. 系统裁剪优化:别只会去掉 menuconfig 里的选项
4.1 系统裁剪优化的目标
系统裁剪优化是把嵌入式 Linux 系统瘦身到“刚刚好能完成业务,又足够稳定”的程度。很多新人做裁剪,就只是在make menuconfig里逐个取消勾选模块,结果裁剪完系统起不来。压缩用户空间和内核,都是系统裁剪优化的组成部分,但它要求你理解底层依赖,而不是机械地取消选项。
裁剪的目标有三个维度:镜像体积更小、占用内存更低、启动时间更短。不同项目侧重点不一样,工业 HMI 可能更看重启动时间,电池供电的设备更看重内存占用,量产产品往往三者都要。所以做裁剪之前,先定指标。比如启动时间从 5 秒压到 3 秒,那你要先知道 5 秒花在哪儿;rootfs 从 200MB 减到 50MB,你要先知道每个模块占了多少空间。没有指标就裁剪,最后只会变成盲目删配置。
4.2 内核裁剪的正确做法
内核裁剪的第一步不是去 menuconfig 里乱取消选项,而是先看当前内核里加载了哪些模块:
cat /proc/modules这个文件会列出所有当前已加载的模块和引用计数。哪些模块是核心,哪些是冗余的,一目了然。然后再进入内核配置界面:
make ARCH=arm menuconfig取消勾选不需要的功能时,要特别注意依赖关系。比如取消了CONFIG_NET,那很多依赖网络的功能会在编译时报错。内核配置有一层依赖关系,Kconfig 中通过select和depends on表达,不是你想关就能关的。遇到编译错误时,回到 Kconfig 里查依赖关系,这才是正规做法。
还有一点,驱动模块和内核镜像之间的取舍。把驱动编译成模块M可以减小内核镜像体积,但是需要模块加载机制,而且如果编译进initramfs,实际裁减效果不一定好;编译成模块还会导致启动时挂在 rootfs 上的依赖。所以我的习惯是:如果这块硬件板子上固定存在,而且驱动不大,直接编译进内核;如果是可插拔设备或者需要单独升级的驱动,再编译成模块。
4.3 根文件系统裁剪与启动时间优化
用户空间的裁剪,我建议直接用 Buildroot,而不是纯手工拼 BusyBox。Buildroot 通过 menuconfig 选择包,自动处理依赖,还可以统一配置交叉编译工具链。当然,直接手工构建 rootfs 能让你理解/dev、/etc、/proc、/sys、/tmp这些目录的意义,但从产出效率来看,Buildroot 更适合大多数项目。
如果要代替 BusyBox,可以打开BR2_PACKAGE_BUSYBOX的配置,在里面取消不需要的 applet。注意 BusyBox 天然支持动态加载 applet 的依赖,但裁剪时不要删了mdev或udev,否则设备节点创建会出问题。
启动时间优化的方向也很有固定套路。第一阶段是 bootloader,优化主要是去掉不必要的延时、减少u-boot环境变量里的bootdelay,甚至用 Falcon mode 跳过 bootloader 直接启动内核。第二阶段是内核,通过CONFIG_CMDLINE设置直接用命令行而不是从 bootloader 传参,可以减少启动参数的解析时间;关掉不需要的驱动和initcalls,也能明显缩短启动时间。第三阶段是用户空间,重点在于让关键服务可以并行启动,使用systemd或者 BusyBox init 时,把init脚本里的串行启动改成并行,减少等待时间。
这里分享一个我在创客学院设备树和系统裁剪专题里学到、后来在项目中反复验证的点:镜像裁剪和启动优化都要在真机上测量,并且在每次改动后记录下来。我习惯每次改完配置,都跑一遍bootchart或者perf,把启动阶段的热点列出来,这样裁剪的效果是可量化的。不要在自己脑内估算启动时间,人的感知在 250ms 以内根本不靠谱,必须用日志时间戳或者波形分析。
5. 驱动开发实操:从简单字符驱动到平台驱动
5.1 一个完整的字符设备驱动
字符设备驱动是所有驱动的基础。下面这个代码结构是一个最小可用的字符设备驱动模板,它实现了设备号申请、cdev 注册、open/read 接口。
#include <linux/init.h> #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" static int major; static struct cdev demo_cdev; static char kernel_buf[64] = "hello from kernel\n"; static int demo_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { return simple_read_from_buffer(buf, count, pos, kernel_buf, sizeof(kernel_buf)); } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, }; static int __init demo_init(void) { dev_t devno; int ret; ret = alloc_chrdev_region(&devno, 0, 1, DEVICE_NAME); if (ret < 0) { return ret; } major = MAJOR(devno); cdev_init(&demo_cdev, &demo_fops); cdev_add(&demo_cdev, devno, 1); return 0; } static void __exit demo_exit(void) { cdev_del(&demo_cdev); unregister_chrdev_region(MKDEV(major, 0), 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");这个模板虽然能跑,但它不是现代工程风格。现代驱动代码会大量使用devm_系列 API,例如devm_kzalloc、devm_platform_ioremap_resource,好处是驱动在初始化失败或卸载时不用手动释放资源,由内核设备模型联调。新手可以把“字符设备 + resource managed API”作为标准写法,而不是只写早期的经典模板。
5.2 platform 驱动与设备树匹配
真正生产环境的驱动,几乎不会直接在__init里申请设备号,更多的是用一个platform_driver,由设备树来触发probe。这样驱动可以独立于硬件信息,同一个驱动代码兼容多个板子。
下面这段代码是一个典型的 platform 驱动框架:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_device.h> static int demo_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base); dev_info(&pdev->dev, "probe %s ok\n", pdev->name); return 0; } static int demo_remove(struct platform_device *pdev) { return 0; } static const struct of_device_id demo_of_match[] = { { .compatible = "vendor,my-device", }, { /* sentinel */ } }; static struct platform_driver demo_platform_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = "demo_device", .of_match_table = demo_of_match, }, }; module_platform_driver(demo_platform_driver); MODULE_LICENSE("GPL");devm_ioremap_resource会把设备树里的reg属性映射成内核虚拟地址,并且负责检查地址重叠等问题。很多驱动里的寄存器操作,都是先把寄存器基地址映射到虚拟地址,然后通过 readl/writel 访问。这里建议新手认真看一下reg属性的写法,它在设备树节点里通常长这样:
demo_dev: demo@42c00000 { compatible = "vendor,my-device"; reg = <0x42c00000 0x1000>; interrupts = <GIC_SPI 42 IRQ_TYPE_LEVEL_HIGH>; };reg的第一个值表示外设寄存器的物理起始地址,第二个值表示需要映射的地址范围。中断属性里的GIC_SPI是中断类型,42是中断号,IRQ_TYPE_LEVEL_HIGH是触发方式。这些都要和芯片手册对上,写错一个 Linux 启动时可能不掉链子,但是访问寄存器时会直接死机。
5.3 中断、并发与锁
写驱动不碰并发几乎不可能。内核里是多线程环境,你的驱动函数可能在多个 CPU 上同时执行,也会在使用者和中断上下文之间交错运行。如果不加锁,轻则是变量错乱,重则内核 panic。
处理并发的基础知识其实不复杂。如果是共享变量修改,用自旋锁;如果是需要睡眠的临界区,用 mutex;如果是在中断上下文,不能用 mutex,只能用 spin_lock;如果要等待某个硬件条件,用 wait_queue。这些都是驱动面试常问的点,也是实际调试中最容易出问题的地方。
除此之外,现在写驱动尽量用内核推荐的现代机制,比如completion和workqueue。DMA 传输完成时,用 completion 唤醒等待的 read 线程;耗时操作从中断下半部移到工作队列里处理,能减少中断关闭时间,提升系统实时性。真正上手以后,你会发现“中断底半部”的设计比“要不要加锁”更考验水平,因为它直接决定系统的响应延迟和吞吐量。
6. 性能调优与调试技巧:别让驱动拖垮整个系统
6.1 驱动性能瓶颈通常在哪
驱动性能出问题,表现形式往往不是“驱动自己慢”,而是整个系统变得卡顿、中断延迟增大、CPU 占用率高、网络吞吐下降。所以性能调优要会“从系统现象倒推驱动问题”。
最常见的瓶颈有这么几个:
- 中断处理时间过长。在中断上半部里做了太多耗时的寄存器读取、数据处理,导致中断被阻塞,其他中断响应变慢。
copy_to_user/copy_from_user频繁拷贝。每次读取都拷贝一次大块数据,严重拖慢吞吐。解决思路是 mmap、DMA、zero-copy。- 锁竞争严重。多个 CPU 同时访问同一个驱动时,自旋锁等待时间过长,表现为 CPU 利用率高但吞吐量上不去。
- cache 一致性问题。DMA 缓冲区如果没有正确维护 cache,数据可能读到旧值,这种问题不表现为性能下降,而表现为数据随机错误。
- 驱动阻塞读写的等待机制设计不合理。轮询等待和等待队列的调度延迟,会影响吞吐和数据实时性。
建议一开始就建立“驱动性能 = 延迟 + 吞吐 + 干扰”的视角。延迟针对单个请求,吞吐针对连续数据流,干扰是驱动占用了 CPU、总线和中断对系统其他部分的影响。这三个指标都能用工具量出来。
6.2 用 ftrace、perf 和 trace-cmd 定位问题
性能调优不能靠猜,要有数据支撑。最基础的是看 CPU 占用:
top perf top如果perf top里看到某个内核函数的占比很高,再用perf record抓栈:
perf record -g -a sleep 10 perf report当某个驱动函数出现在热点栈中,基本就知道问题大致范围了。如果问题出在中断延迟,可以看/proc/interrupts里的中断计数,再配合ftrace跟踪中断处理函数的执行时间。
echo function_graph > /sys/kernel/debug/tracing/current_tracer echo 'irq_handler_entry' > /sys/kernel/debug/tracing/set_event cat /sys/kernel/debug/tracing/trace函数图的输出会显示每个函数进入和退出的时间戳。如果中断处理函数的持续里超过预期,那就是驱动需要优化的位置。trace-cmd是 ftrace 的封装,提供了record、report等子命令,适合在板上抓更长时间的数据。
6.3 内核调试与内存排查
驱动开发中还有一类问题不涉及性能,而是稳定性。比如驱动崩溃后系统 panic、内存越界导致随机数据错乱。这类问题的排查,比性能调优更依赖工具。
dmesg是第一时间要看的信息,很多 panic 日志里已经把调用栈打出来了。要想知道 panic 发生的原因,必须学会看懂栈回溯。一般 panic 日志里的PC寄存器和LR寄存器能直接指示调用位置,配合addr2line或者 vmlinux 里的符号表,就能还原是哪一行代码出问题。
内存问题推荐用kmemleak检查内存泄漏:
echo scan > /sys/kernel/debug/kmemleak cat /sys/kernel/debug/kmemleak它能列出未被释放的内核对象。还有KASAN,这是目前检测内核内存越界最有效的工具。开启 KASAN 后,很多内存越界会在第一次访问时就报出来,而不是等到数据被破坏后才在某个诡异的地方崩溃。当然,KASAN 会显著增大内核镜像并影响性能,所以一般只在调试阶段打开,发布版本要关闭。
7. 算法嵌入式部署:与驱动开发强相关的那些事
7.1 算法嵌入式部署不是“交叉编译一下就完事”
很多做应用开发的同事第一次接触算法嵌入式部署,以为就是把 C/C++ 代码交叉编译一下塞到板子上。实际做下来会发现,算法部署的难点往往在驱动和系统的配合上。
嵌入式环境里跑算法,最常见的问题是内存。你的算法模型可能占用几十 MB 到几百 MB,而嵌入式设备的内存总共可能只有 256MB 到 1GB。这时候就需要在驱动层支持大块 DMA 缓冲区的分配,并允许用户态通过 mmap 直接映射,避免反复拷贝。比如视频处理的场景,摄像头驱动采集一帧图像到内核缓冲区,用户态算法要通过V4L2的mmap机制拿到帧,再把处理结果送到显示驱动。这一整条链路里,任何一次拷贝都可能成为性能瓶颈。
另一个问题是 cache 一致性。摄像头驱动通过 DMA 把数据写入内存后,CPU 读到的可能还是 cache 里的旧数据,所以 DMA 缓冲区一般需要标记为dma_alloc_coherent,或者用dma_map_single配合 cache maintenance。这块内容虽然不是算法本身,但不解决,算法就会拿脏数据算,结果全错。
7.2 从驱动层优化算子执行的稳定性
算法部署还有一个容易被忽略的维度:算子执行稳定性。嵌入式处理器不像 PC 那样有充足散热和稳定的供电,CPU 频率可能会因为温度升高而降低,导致算法运行时间抖动。这里不仅要做性能调优,还要在实际产品里考虑使用实时调度策略。
在 Linux 中,可以通过pthread_setschedparam将关键算法线程设置为SCHED_FIFO,并设置较高的优先级。同时,把中断和内核关键任务绑核到不同 CPU,避免互相抢占。驱动侧的 IRQ 也可以设置 CPU affinity:
echo 2 > /proc/irq/45/smp_affinity这个操作会把 45 号中断的亲和性设置到 CPU1。先查/proc/interrupts找到设备的 IRQ 号,再设置亲和性,这样中断不会频繁迁移到不同 CPU,能减少 cache 失效和上下文切换。
性能调优时还要考虑 CPU 频率和内存带宽的影响。你可以通过cpufreq设置固定频率来做基准测试,否则不同频率下的性能测试数据没有可比性。算法部署的最终指标,不能只看平均帧率,还要看最差情况下的耗时。驱动层一旦有很长的关中断临界区,最差耗时可能比平均耗时长一个数量级,所以对驱动代码做最坏情况分析比跑 benchmark 更重要。
7.3 工具链选择与集成注意事项
算法部署工具链取决于使用的平台和算法类型。如果是纯 C/C++ 算子,可能只需要交叉编译工具链和 OpenCV、Eigen 这类库;如果用了 NPU,还需要厂商提供的模型转换工具和 runtime driver。资料方面,我特别建议把平台厂商提供的examples目录完整跑一遍,因为 NPU 的模型转换工具版本和 runtime v版本绑定很严格,API 稍微对不上就会编译失败。
在创客学院的资料里,我看到过比较系统的算法部署实验流程:先做交叉编译验证跑通一个简单算子,再逐步引入模型推理,最后用 perf 看热点。这个流程很适合前置学习。实际项目里,我会额外注意浮点格式和内存对齐。ARM 平台上有硬浮点和软浮点两种 ABI,工具链必须保持一致,否则链接阶段会出现莫名其妙的 undefined reference。内存对齐则会影响 DMA 传输和 NEON 优化,不对齐的访问在某些平台上可能直接触发 bus error。
算法嵌入式部署和驱动开发是深度耦合的。如果你把算法部署当成纯粹的“软件移植”,通常会在性能、稳定性上栽跟头;只有理解了数据从硬件进来、经过 DMA、穿过 cache、最终到达算子的全过程,才能做出真正可交付的系统。
8. 我的资料整理方法与避坑经验
8.1 学习笔记不要记“API 用法”,要记“场景和判断”
驱动开发的 API 太多了,背是背不完的。真正有用的笔记,是记录“什么场景下该选什么方案”,而不是记录某个函数有几个参数。比如spin_lock和mutex,笔记里应该写“在中断上下文只能 spin_lock,在进程上下文且可能睡眠时用 mutex”,而不是抄函数签名。
我会把所有驱动相关的资料按“问题驱动”整理:一类是“设备树节点不生效排查”,一类是“DMA 数据错乱排查”,一类是“驱动并发崩溃排查”。这样每次遇到类似问题,直接翻笔记,效率极高。创客学院的课程资料适合作为第一次系统学习的索引,但你自己整理的排错笔记才是后续项目里最常用的“私藏资料”。
给新手一个很实用的建议:每次遇到一个 bug,不要只把解决方法记下来,还要把“排查路径”记下来。比如发现设备树没生效,你是先查了/proc/device-tree,再查 pinctrl,最后查到 pinctrl 配置错误。这样的记录,才是可以复用的经验,比单纯记住status = "okay"有价值得多。
8.2 动手压倒一切:先跑通,再优化
我见过太多人花几周时间啃资料,结果一块开发板都没碰过。嵌入式驱动开发最忌讳的就是只学不练。哪怕你手里只有一块几十块钱的开发板,也应该把字符设备、设备树、platform 驱动、中断、DMA 这几个基础实验亲手跑一遍。
跑实验的时候,也不要迷信“照着代码敲一遍就行”。必须做三件事:第一,自己改引脚和寄存器地址,哪怕驱动最终跑挂了,也比直接复制成功好;第二,主动制造一个 bug,比如把 GPIO 号写错,然后看现象,再通过dmesg和示波器把它定位出来;第三,把实验进度记录成文档,包括 kernel 版本、设备树源码、编译命令、实验结果和失败日志。这样你做的每个实验都可以复现,后续找工作或做正式项目时也能拿出证据。
8.3 一个容易被忽视的底层能力
最后再提一个容易被忽视的能力:阅读芯片手册。资料再多,最终还是要回到芯片原厂手册。很多驱动开发问题,比如外设寄存器怎么配、中断号怎么算、DMA 描述符怎么设置,网上教程不一定讲得细,但芯片手册里一定写得很清楚。所以我在资料汇总里永远会留一个位置给“芯片手册阅读方法”。
我个人的阅读习惯是,先看框图,搞清楚外设在整个系统中的位置;再看寄存器列表,了解每个寄存器的作用;最后看时序图,理解软件配置和硬件动作的时间关系。遇到配置不生效,先怀疑自己手册没读透,再怀疑代码问题,这个顺序能省很多时间。
嵌入式驱动开发这条路,资料不是不够,而是太散。真正能把一份资料吃透、把一套实验跑通、把一个项目做到底的人,少之又少。我的体会是,资料不需要多,两套主线资料、一块开发板、一个 QEMU 环境就足够了。把核心概念像讲故事一样讲明白,把每个实验跑完以后认真总结,遇到问题先动手排查,再反查资料,这套方法比收藏十个教程有用得多。