
第一次把编译好的 .ko 文件用 insmod 塞进 ARM 板子的内核然后在 /dev 目录下看到自己的设备节点再 echo 一个 1 进去开发板上的 LED 真的亮了。那一刻应该是我做嵌入式这么久最踏实的成就感之一。这篇就聊聊我的第一个 ARM 板硬件驱动怎么写、怎么调、踩了什么坑全过程记录从框架概念到寄存器操作都会拆开讲。如果你学过单片机、刚入手 ARM Linux 开发板或者正在准备嵌入式岗位面试这篇应该能帮你把 Linux 驱动开发这条路的骨架捋顺。先说明一下场景我用的是一块 Cortex-A7 核的开发板板子上跑的是 Linux要驱动的硬件是最简单的 GPIO LED。驱动形态是内核模块也就是 .ko 文件不是单片机那种裸机寄存器操作也不是 Keil ARM Compiler 5 环境下的 STM32 点灯。这两个路线差的非常远很多人一开始会把它们混在一起后面会找个地方专门说清楚。1. 动手前的整体思路为什么第一个驱动选了“点灯”1.1 先看一条用户命令在 Linux 里经历了什么写驱动之前我建议你先搞清楚一个问题你在终端里敲下echo 1 /dev/led这条命令数据是怎么从用户态一路到硬件的。/dev/led是一个设备文件它对应着一个设备号。你往这个文件里写数据内核虚拟文件系统VFS会先接管这次 write 操作然后根据设备号找到对应驱动程序里注册的 file_operations 结构体最终调用你在驱动里实现的led_write()函数。这个函数里写的代码决定了一字节数据会被解释成“点亮 LED”还是“熄灭 LED”。所以驱动在这个链条里干的事情很简单承上启下。对上它向内核注册一个“文件”接口对下它直接操作硬件寄存器往 GPIO 的数据寄存器里写值引脚电平就变了。你可以把驱动理解成一个翻译官用户程序说的是“open/write/close”这种通用语言硬件听的是“往这个内存地址写 1 或 0”这种寄存器指令驱动就是那个两头通的中间人。这就是我选点灯作为第一个驱动的根本原因链路完整、结果直观。一个 LED 从暗到亮意味着模块加载、设备注册、设备节点创建、file_operations 调用、寄存器操作这整条链路全部跑通了这比写一个只打印日志的 hello_world 模块有说服力得多。灯亮了你就知道你的驱动框架是活的。1.2 板子和工具链怎么选和单片机开发有什么不同板子选型方面我用的是一块以 IMX6ULL 为核心的学习板Cortex-A7 内核入门性价比很高。也可以用树莓派、瑞芯微 RK 系列或者全志的板子思路完全一样差别只在寄存器地址和复用配置。核心是你得拿到这个 SoC 的参考手册datasheet和对应的 Linux 内核源码树kernel source tree这两个是驱动开发的硬前提。工具链这里有个非常大的分水岭交叉编译。PC 上是 x86 架构板子上是 ARM 架构指令集不同编译出来的程序不能直接混用。交叉编译的意思就是在 x86 的 PC 上用一套针对 ARM 的编译器生成 ARM 上能跑的二进制。我的板子跑的是 32 位 ARM Linux工具链前缀是arm-linux-gnueabihf-如果你上的是 64 位系统比如树莓派 4b 的 64 位镜像那要换成aarch64-linux-gnu-。这两个前缀千万别搞混进内核编译的时候 ARCH 参数也要对应ARCHarm对应 32 位ARCHarm64对应 64 位。顺便说一下我从热词里看到好多人搜 “arm compiler 5.06”、“ARM Compiler 5”那是 Keil MDK 里编译 Cortex-M 裸机代码的编译器跟这里说的 ARM Linux 驱动交叉编译完全两码事。Cortex-M 系列跑的是裸机或者 RTOS没有独立的 Linux 内核驱动概念完全不同。你如果是从单片机转过来的先把这个区别刻在脑子里后面学习能少掉一层混乱。还有个关键东西叫内核源码树。写内核模块绝对不能直接引用你自己系统里的头文件必须用目标板内核源码里的头文件。因为模块要和内核的接口保持一致内核结构体变了、函数签名变了你的模块就会编不过或者加载失败。这也是后面 Makefile 里 KDIR 参数要指向板子对应内核源码路径的原因。2. 驱动框架拆解模块、字符设备、寄存器操作2.1 模块骨架init、exit、printk 和加载生命周期Linux 驱动最常见的形态就是可加载模块编译出来是 .ko 文件。它和普通程序最大的区别是没有 main 函数。模块的“入口”和“出口”是靠两个宏指定的static int __init led_init(void) { // 初始化代码 return 0; } static void __exit led_exit(void) { // 清理代码 } module_init(led_init); module_exit(led_exit); MODULE_LICENSE(GPL);__init和__exit这两个标记是有讲究的。__init表示这个函数只在加载阶段用一次初始化完了之后内核可以把这个函数占的内存直接释放掉腾给别的地方。这个机制省内存但你要是把运行期要用的逻辑也塞进__init函数里改 bug 的时候会调得怀疑人生。MODULE_LICENSE(GPL)不是随便写的。如果你声明了 GPL内核里那些 EXPORT_SYMBOL_GPL 导出的符号才能用比如很多内核 API。不声明或声明私有加载时经常会遇到 “Unknown symbol” 的错误这里面有知识产权因素的考虑也有内核社区保证开源的意图。实际开发里直接写 GPL 就完事了。模块里的打印不能用 printf得用printk比如printk(KERN_INFO led driver init ok\n);printk 带日志级别KERN_INFO、KERN_ERR、KERN_ALERT 这些。它输出到内核日志缓冲区靠 dmesg 命令查看而不是输出到终端。板子有串口的话printk 的内容也会从串口 console 打出来所以串口调试是嵌入式开发的底裤接上 115200 波特率的串口线什么内核日志都看得清清楚楚。调试的时候如果觉得日志级别不够可以echo 8 /proc/sys/kernel/printk把所有级别全放开不然低级别日志会被过滤掉这也是我最早踩过的坑之一。2.2 字符设备注册与设备节点的自动生成我写的第一个正式驱动是字符设备驱动也就是按字节流读写的那种终端、GPIO、串口基本都是字符设备。字符设备在内核里有三套件设备号、cdev 结构体、file_operations 操作表。设备号是设备在内核里的“门牌号”分主设备号和次设备号。主设备号区分驱动类型次设备号区分同类型下的第几个设备。传统做法是自己指定一个主设备号比如#define LED_MAJOR 240然后用register_chrdev_region注册。但主设备号是有限资源有冲突风险现在更推荐动态分配dev_t devid; int ret alloc_chrdev_region(devid, 0, 1, led);这个调用会让内核帮你挑一个空闲的主设备号第一个次设备号是 0数量是 1。然后是 cdevstruct cdev *led_cdev; led_cdev cdev_alloc(); cdev_init(led_cdev, led_fops); cdev_add(led_cdev, devid, 1);cdev_add就是把 cdev 对象挂到内核里。到这一步内核已经知道这个设备存在了但 /dev 下面还没有文件节点。在旧的、简单的驱动里你可以用device_create配合 class 让这个节点自动出现struct class *led_class; led_class class_create(THIS_MODULE, led_class); device_create(led_class, NULL, devid, NULL, led);class_create会在 /sys/class 下创建一个类目录而用户态的 udev 或者嵌入式系统常用的 mdev 会监控内核事件看到 device_create 之后就自动在 /dev 下创建对应节点名字就是最后那个 “led”。这是现代 Linux 驱动推荐的玩法开机后 /dev/led 自动出现不需要手动 mknod。注意一点class 名字和设备节点名字别取重了。我刚开始图省事class 叫 led节点也叫 led结果 /sys/class 下正常但 /dev 节点就是不出来后来才发现 class_create 的名字不能和同一子系统下其他设备重名换个不冲突的名字就好了。2.3 操作 ARM 寄存器ioremap、GPIO 配置和 volatileLinux 下的驱动不能像单片机裸机那样直接拿物理地址去访问。ARM 上开了 MMUCPU 访问的是虚拟地址物理地址要先映射成虚拟地址才能操作这就是ioremap的用途#define GPIO1_BASE 0x0209C000 #define GPIO_DR 0x0000 #define GPIO_GDIR 0x0004 static void __iomem *gpio_dr; static void __iomem *gpio_gdir; gpio_dr ioremap(GPIO1_BASE GPIO_DR, 4); gpio_gdir ioremap(GPIO1_BASE GPIO_GDIR, 4);以我用的 IMX6ULL 为例GPIO1 的寄存器基地址是 0x0209C000。数据寄存器 DR 偏移 0x00方向寄存器 GDIR 偏移 0x04。要输出得先在 GDIR 里把对应位写成 1要拉高电平就往 DR 的对应位写 1。这里必须强调一个问题驱动访问寄存器不要用普通的*(volatile unsigned int *)addr写法去猜更不要用没加 volatile 的指针。寄存器是硬件状态编译器不知道这块内存地址有“外部副作用”优化的时候可能把两次读合并成一次甚至把写操作优化掉。我记得第一次写的就是普通的*(unsigned int *)addr结果怎么编译怎么不对灯死活不亮后来在内核文档里看到驱动访问寄存器应该用 ioread/iowrite 系列接口u32 val ioread32(gpio_dr); val | (1 21); iowrite32(val, gpio_dr);ioread32/iowrite32不只是读写它们天然带内存屏障和 volatile 语义编译器不会乱优化CPU 也不会乱序执行到外部设备上这是内核开发的标准姿势。强烈建议你从一开始就养成用 ioread/iowrite 的习惯比手动加 volatile 更靠谱。3. 第一版驱动从编码到运行完整实操记录3.1 完整代码与逐段解释下面直接给出我第一版能跑的 LED 驱动代码本意是做个教材级的参考。平台相关部分我按 IMX6ULL 写了注释换成你自己的板子核心逻辑完全通用。头文件里有一堆linux/xxx.h这是内核模块必须包含的内核接口声明。#include linux/init.h #include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/io.h #define LED_GPIO_NUM 21 #define GPIO1_BASE 0x0209C000 #define GPIO_DR_OFFSET 0x0000 #define GPIO_GDIR_OFFSET 0x0004 static void __iomem *gpio_dr; static void __iomem *gpio_gdir; static dev_t led_devid; static struct cdev *led_cdev; static struct class *led_class; static int led_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { char kbuf; u32 val; if (copy_from_user(kbuf, buf, 1)) return -EFAULT; val ioread32(gpio_dr); if (kbuf 1) val | (1 LED_GPIO_NUM); else val ~(1 LED_GPIO_NUM); iowrite32(val, gpio_dr); return 1; } static struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .write led_write, }; static int __init led_init(void) { int ret; ret alloc_chrdev_region(led_devid, 0, 1, led); if (ret 0) return ret; led_cdev cdev_alloc(); if (!led_cdev) goto err_unregister; cdev_init(led_cdev, led_fops); ret cdev_add(led_cdev, led_devid, 1); if (ret 0) goto err_cdev_free; led_class class_create(THIS_MODULE, led_chardev); if (IS_ERR(led_class)) { ret PTR_ERR(led_class); goto err_cdev_del; } device_create(led_class, NULL, led_devid, NULL, led); gpio_dr ioremap(GPIO1_BASE GPIO_DR_OFFSET, 4); gpio_gdir ioremap(GPIO1_BASE GPIO_GDIR_OFFSET, 4); if (!gpio_dr || !gpio_gdir) { ret -ENOMEM; goto err_device_destroy; } iowrite32(ioread32(gpio_gdir) | (1 LED_GPIO_NUM), gpio_gdir); printk(KERN_INFO led driver init ok, major%d\n, MAJOR(led_devid)); return 0; err_device_destroy: device_destroy(led_class, led_devid); class_destroy(led_class); err_cdev_del: cdev_del(led_cdev); err_cdev_free: kfree(led_cdev); err_unregister: unregister_chrdev_region(led_devid, 1); return ret; } static void __exit led_exit(void) { iowrite32(ioread32(gpio_dr) ~(1 LED_GPIO_NUM), gpio_dr); iounmap(gpio_dr); iounmap(gpio_gdir); device_destroy(led_class, led_devid); class_destroy(led_class); cdev_del(led_cdev); kfree(led_cdev); unregister_chrdev_region(led_devid, 1); printk(KERN_INFO led driver removed\n); } module_init(led_init); module_exit(led_exit); MODULE_LICENSE(GPL);逐段说几个重点。led_write里先调用了copy_from_user。为什么不能直接buf指针解引用因为用户态传进来的指针指向的是用户空间的虚拟内存驱动运行在内核态不能假设那块内存已经映射到内核地址空间直接访问很大概率 page fault 导致内核崩溃。这就是用户态和内核态之间的“隔离墙”必须通过 copy_from_user/copy_to_user 拷贝。我听过有人为了偷懒用get_user替代标准做法还是 copy_xxx_user职责更明确。led_open我留空了只有返回值。说明驱动只需要支持 write不需要在 open 里准备什么。但别删因为 file_operations 里.open不写用户 open 会失败。你可以试试不写 open 会怎样会得到一个 ENODEV 的错误这就是驱动的“菜单项”缺菜的结果。led_write返回值我写了 1表示成功消费一个字节。如果不写返回值应用层 write 会认为写失败shell 里 echo 甚至不会报错但用 Python 或者 C 程序测试时返回值检查会失败。这种细节就是驱动和普通函数的差异严格按内核调用约定返回。3.2 Makefile 与交叉编译的实际细节内核模块不能像普通程序那样 gcc 直接编译需要一套专门的内核构建系统。Makefile 长这样obj-m : led_drv.o KDIR : /home/user/linux-imx PWD : $(shell pwd) all: make -C $(KDIR) M$(PWD) ARCHarm CROSS_COMPILEarm-linux-gnueabihf- modules clean: make -C $(KDIR) M$(PWD) ARCHarm CROSS_COMPILEarm-linux-gnueabihf- cleanobj-m : led_drv.o告诉内核构建系统把 led_drv.c 编译成外部模块。KDIR是板子对应的内核源码树路径这个源码树的版本和配置必须和板上运行的内核一致不然模块可能加载失败。ARCHarm CROSS_COMPILEarm-linux-gnueabihf-是给编译器指定的如果漏了 ARCHmake 会默认用本机 x86 内核头文件编出来的模块一 insmod 就报版本不匹配。编译好后在当前目录会生成 led_drv.ko。用file led_drv.ko看一下led_drv.ko: ELF 32-bit LSB relocatable, ARM, EABI5 version 1 (SYSV)看到 ARM 就对了。还可以用modinfo led_drv.ko查看模块信息vermagic 字符串里能看到内核版本号和编译器版本排查加载问题很有用。把 .ko 弄到板子上常见三种方式SD 卡拷贝、NFS 网络共享、局域网 scp 传文件。开发调试阶段我强烈建议配一个 NFS 根文件系统或者至少一个 NFS 目录改完代码重新编译板子上直接 mount 就能加载不用反复插拔 SD 卡效率差好几倍。3.3 加载、亮灯、清理残留板子起来之后insmod led_drv.ko然后看 /dev/led 是否出现。如果 udev/mdev 正常会自动生成。没有的话手动创建cat /proc/devices | grep led mknod /dev/led c 主设备号 0接下来试功能echo 1 /dev/led # 灯亮 echo 0 /dev/led # 灯灭如果灯能跟着 echo 的内容变化恭喜你的第一个 ARM Linux 驱动正式跑通了。但别高兴太早灯亮了只是一半还要验证卸载干不干净rmmod led_drv然后再次insmod led_drv.ko能正常加载不报错说明设备号、class、节点都释放干净了。这一步很关键很多新手用的资源一个没清第二次加载直接 cdev_add 失败现象就是 insmod 没报错但 /dev/led 不见了因为设备号冲突或者 cdev 重复注册。我建议你在 led_exit 里严格按 init 的逆序释放一个萝卜一个坑这个习惯后来帮我避过很多雷。4. 我在板上踩过的坑现象、原因、解决办法4.1 insmod 直接失败的典型报错第一类常见坑是加载时报 Invalid module format。这时候先怀疑版本不匹配你编译模块的内核源码树和运行时内核必须同版本同配置。用uname -r对比一下再modinfo led_drv.ko看 vermagic。如果源码树版本号不对换对应版本的分支重新编译即可。这种情况在拉取内核源码时很常见看到仓库里有一样版本号的分支但实际不上手编完就翻车。第二类常见坑是 Unknown symbol。如果模块里调用了某个内核函数而这个函数没有 EXPORT_SYMBOL 导出给模块用加载就会报这个。怎么查看内核源码里对应函数前有没有 EXPORT_SYMBOL 或 EXPORT_SYMBOL_GPL。有些平台相关的 API 导出白名单限制比如 gpiolib 有些函数只在特定配置下导出配置没开也调用不了。如果报错信息长了一串教一个看报错的技巧insmod报错后马上dmesg | tail -30内核日志里会写明在哪个文件哪一行、符号名是什么。比如Unknown symbol led_fops那多半是 file_operations 里某个函数指针的符号没配对或者模块间依赖没加载。这个排查思路对任何内核报错都通用。4.2 /dev/led 不出现的坑module 加载成功但 /dev/led 没出现原因一般集中在 device_create 和 udev/mdev 这两环。先确认内核里 device_create 有没有被调用。printk 打印一下或者在 led_init 里增加一个 “device_create called” 的日志。如果打印了但节点没出来去看 /sys/class 下你的 class 目录里到底有没有 call 文件。如果 /sys 里也没有那你可能压根没走到 device_create或者 class_create 失败被 PTR_ERR 拦住了。如果 /sys 里有设备但 /dev 没节点就是用户态设备管理器的问题。很多嵌入式系统没有完整 udev用的是 busybox mdev且需要启动时执行mdev -s扫描。如果板子没有配置 mdev 的 hotplug 规则即使内核 event 发出去了也没人处理。临时方案是手动 mknod长期方案是检查系统启动脚本里有没有mdev -s。另外还有一个权限的坑节点出现了但你 echo 的时候报 Permission denied。嵌入式板子默认 root 用户还好但如果你的板子跑的是普通用户需要 chmod 666 /dev/led或者写 udev 规则。设备节点的权限归属也是嵌入式产品的一个大话题涉及安全策略普通学习阶段 chmod 先顶一下。4.3 灯不亮地址、复用、方向这是最折磨人的一类问题代码逻辑全对但那个灯就是不给面子。拆开看原因基本不出以下几类。第一GPIO 复用没有配置。大多数 SoC 的引脚不是生下来就是 GPIO它默认可能接在 UART、I2C、PWM 或者其他外设功能上。你得在 IOMUX 寄存器里把该引脚设置为 GPIO 模式。以 IMX6ULL 为例有 IOMUXC_SW_MUX_CTL_PAD_XX 和 IOMUXC_SW_PAD_CTL_XX 两组寄存器前者选功能后者配置上下拉和驱动能力。很多学习板出厂默认就是 GPIO 模式所以你忽略这一块也能亮。但换一块板子、换一个引脚就不一定了这是平台相关的重灾区必须学会看数据手册里的引脚复用表。第二GPIO 外设时钟没使能。IMX6ULL 的 GPIO 控制器需要 CCM 时钟模块打开对应的 CCGR 位。它的时钟默认可能是关闭的你要去 CCM_CCGR1 等寄存器里把 GPIO 模块的时钟打开。时钟没开的表现非常神奇寄存器读出来全是 0 或者全是 f写进去也没反应。我当时查这个查了很久最后是拿着逻辑分析仪量引脚电平才发现的。Arm 的参考手册里 CCGR 位很多一定要按 GPIO 控制器的编号找对。第三方向寄存器没写对。你配的引脚是输入方向那么不管怎么往 DR 里写引脚永远是外部输入的状态。正确流程是先配置复用和时钟再设置 GDIR 方向为输出最后写 DR。顺序反了也会出现一时亮一时不亮的怪现象。第四位运算的边界问题。前面代码里我特意用了读改写方式先 ioread32 读整个寄存器改目标位再 iowrite32 写回去。有些人图省事直接iowrite32(1 21, gpio_dr)这会把其他 31 个 GPIO 引脚的数据全部清零。如果别的引脚在点灯前已经被 bootloader 或别的驱动设置了电平这条代码一跑整个 GPIO 组的输出全给你重置了。感觉像是“灯亮了但系统崩了”的诡异现象就是这样来的。4.4 调试与定位手段dmesg、dump_stack、调用栈回溯白板写代码能一次过的驱动我是没见过几个。遇到灯不亮先不要到处改代码先确认软件到硬件哪一环断了。第一招是 dmesg。printk 的日志都在里面dmesg | grep led立竿见影。但是 printk 也有个问题数据量大了会刷屏真正有用的信息被淹没掉。调试阶段可以把 printk 级别设为 KERN_ERR或者临时加一些特征明显的字符串。第二招是 dump_stack。在可疑函数里调用 dump_stack()内核会打印当前的调用栈。这是在“某个函数有没有被调用”这个问题上最直接的证据。esp当你在 file_operations 里写函数但用户程序死活调不进来时dump_stack 能告诉你调用链到底走到哪一层。第三招是看 Oops。内核出问题崩溃时那堆寄存器信息不是天书里面最关键的是 PC、LR 两个寄存器。PC 是当前指令地址LR 是函数返回地址。用arm-linux-gnueabihf-addr2line -f -e vmlinux 0x...把地址翻译成源码行号就能定位到出错位置。这个技巧对 ARM 调用栈回溯特别有用也是嵌入式面经里常考的 debug 能力。你可以打印出调用栈和寄存器快照用scripts/decode_stacktrace.sh脚本处理比人肉数行号快多了。第四招才是硬件工具。万用表量引脚电平逻辑分析仪看波形示波器看时序。软件层面所有可能性排除之后再上硬件工具。有个通用的排查顺序代码逻辑、printk 路径、寄存器值、引脚电平、物理连接一层层切断永远不要跳过前面的步骤直接怀疑硬件。5. 驱动这条路过了“点灯”之后往哪走5.1 让代码更“现代”设备树和 platform 驱动点灯驱动验证了框架但它有个硬伤硬件信息是写死在代码里的。你换一个 GPIO 号就要改代码重新编译。这在开发板学习阶段可以接受到了真实产品和内核社区的正统玩法这种做法基本就是异端。现代 ARM Linux 驱动的主流形态是 platform 驱动配合设备树。硬件信息通过设备树节点描述比如led { compatible mycompany,led; reg 0x0209c000 0x10; gpios gpio1 21 GPIO_ACTIVE_HIGH; };驱动里用of_property_read_u32等接口去匹配和读取这些属性。probe 函数在设备节点匹配时被自动调用。这样同一份驱动可以适配多个平台换硬件描述改设备树就行。这个演进方向建议你一定要走一遍它是后续几乎所有复杂驱动的基础。5.2 从输出到输入按键、中断、读写接口点灯只用了 write 方向。再加一个按键用中断方式检测你就能把 read 方向也打通了。核心是 request_irq 注册中断处理函数中断里用 workqueue 或者 tasklet 处理真正的工作然后通过 copy_to_user 把按键状态送出去。这样驱动就从一个“只会被写”的设备变成了“能读能写”的双向设备。加上 read 之后自然要处理阻塞、非阻塞和 poll 的问题。用户空间 read 没数据的时候是挂起等还是立刻返回驱动里怎么实现 wait_event_interruptible这些都是驱动工程师面试的常客也是从点灯到真实设备驱动的必然台阶。再往后就是 ioctl 接口、异步通知、DMA 传输、内核中的并发与锁每个方向挖下去都是深坑。我个人在写了几个字符设备之后的最大体会是驱动开发真正的难点不是那几十行 C 代码而是理解内核的模型。你写的每个接口函数并不是孤立存在的它要遵守 VFS、设备模型、中断子系统、并发控制等一整套规格。看懂内核文档里 Lifecycle、Data Types 这些章节比背一万行代码都管用。最后分享一个我自己的小习惯吧我把第一个驱动成功点亮那年记在代码开头注释里之后每次换新平台、新内核版本第一件事就是把点灯驱动重新移植一遍。它就像一个冒烟测试能最快告诉你工具链对没对、内核源码树匹配不匹配、设备模型改成了什么样。驱动这条路很长但点灯这第一个台阶值得你踩稳了再往上走。