十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux设备驱动开发从入门到实践:QEMU环境与关键机制解析

Linux设备驱动开发从入门到实践:QEMU环境与关键机制解析 如果你的工作方向是嵌入式、Linux内核或者底层系统开发那你最近大概率听说过《手把手教你学Linux设备驱动开发》这本书出版的消息。做Linux设备驱动开发这么多年我见过太多人拿着内核源码啃了几个月最后连一个led驱动都写不利索也见过科班出身的人一碰硬件寄存器就发怵被并发、中断、阻塞这几座大山反复碾压。这篇文章不只是聊这本书值不值得买我想借这个机会把驱动开发里那些真正要命的技术节点、我自己的实操经验以及一条从零到一能跑通的学习路径一次性说清楚。无论你是刚接触Linux设备驱动开发的在校学生还是已经做了几年应用开发想转内核方向的职场人这篇文章应该都能帮你少走一大段弯路。1. 驱动开发劝退无数人真正的卡点不在看不懂代码很多人一提到Linux设备驱动开发第一反应就是去啃内核源码。但以我这些年带人和被带的经验来看大部分人学不下去问题根本不在代码层面而是卡在了三个始终没人明说的节点上。1.1 应用态思维与内核态思维的本质差异做过应用开发的人写代码时脑子里默认是一个进程、一片虚拟地址空间、出了问题大不了崩掉重启。但驱动是跑在内核态的它服务的对象是硬件执行的上下文可能是进程、可能是中断、也可能是内核线程。同样一段C代码在用户态里写错了可能只是Segment Fault在内核里写错一个空指针整个系统直接oops甚至panic连抢救的机会都不给你。这种思维转变是驱动开发的第一道门槛。我见过不少朋友第一次写内核模块习惯性地在read回调里调用malloc、在中断里打印字符串结果要么编译不过要么系统直接挂掉。内核态没有libc、没有完整的标准库调用你面对的是kmalloc、kfree、spin_lock、schedule这些更底层的接口而且每个接口背后都有一套必须遵守的约定。这些约定不是靠读代码能读出来的而是需要有人告诉你这里为什么必须这么做。1.2 操作硬件寄存器第一步是看懂datasheet里的位域驱动开发的第二个卡点是它要求你具备软件加硬件的双重视角。很多纯软件背景的人看到datasheet里一段寄存器描述就懵了——什么叫做bit 7到bit 4是分频系数为什么要先置位再清位为什么读状态寄存器之前要写一个特定的值以GPIO为例你要点亮一颗LED硬件上需要做的无非是配置引脚为输出模式、设置输出电平。但在Linux驱动里你得先通过ioremap把寄存器物理地址映射到内核虚拟地址空间再按datasheet里的位域去修改寄存器的值。这个看datasheet、找寄存器地址、算位偏移、写掩码的过程纯粹靠看内核代码是学不会的必须实际拿一块芯片手册练手。我当年第一次操作三星的芯片寄存器时光一个GPIO配置就折腾了一晚上后来才发现是datasheet里一个保留位没照顾到。1.3 调试手段变了学习方法也必须跟着变应用开发有gdb、有IDE断点、有各种可视化工具但到了驱动开发绝大多数场景下你的调试手段退化成了printk、dmesg、/proc文件系统和一块示波器。不要说新手很多工作两三年的工程师遇到驱动崩溃还是只会搜dmesg的最后几行日志。这种调试方式的改变会直接挫败学习者的信心。你写了代码编译通过了insmod也成功了但设备节点就是不出现read数据就是不对你甚至不知道从哪一步开始排查。不是你看不懂代码而是驱动开发里写了代码-编译-运行-验证这个循环的反馈链路太长了长到你根本没法用试错法去学。这也是我在后面几个部分想重点讲的东西——怎么缩短这个反馈链路怎么建立起一套属于驱动开发的可调试感。2. 别急着买开发板先在PC上用QEMU跑通一整套驱动实验环境说完难在哪再讲怎么入手。很多初学者最容易犯的错误就是上来就买一块几百块的开发板想着真机操作才有感觉。但真机开发有个致命问题烧写一次镜像要几分钟改一行代码重新编译内核又要十几分钟而你的学习内容可能只是验证一个module_init函数有没有被调用。这种过长的反馈循环足以磨灭绝大多数人的学习热情。2.1 为什么用QEMU而不是直接下单一块开发板QEMU的优势在于它可以在你现有的PC上模拟出一台完整的ARM机器或者x86机器你不需要额外硬件不需要折腾串口线和TF卡烧录所有操作都在这一个终端里完成。对于入门阶段学习字符设备驱动、中断处理、并发控制这些纯软件层面的机制QEMU完全够用而且速度比真机还快。等你把基本框架都摸熟了再买一块正经开发板去捣鼓设备树、去对接真实传感器难度曲线会平滑得多。在驱动开发这条路上先软后硬是性价比最高的策略因为前期你要学的核心机制和硬件关系不大真正和硬件强相关的寄存器操作放到后期在开发板上用实际芯片手册去学反而效率更高。2.2 用几分钟搭好最小内核与根文件系统QEMU环境的核心是两部分一个可启动的内核镜像和一丢丢基础文件系统。我强烈建议新手直接用内核源码自己编译而不是下载现成的镜像因为编译内核这个过程本身就是在熟悉内核的构建体系。以ARM的vexpress-a9开发板模型为例你需要先下载一个Linux内核源码包然后执行配置和编译# 下载并解压内核源码这里以5.10 LTS版本为例 wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.10.224.tar.xz tar -xf linux-5.10.224.tar.xz cd linux-5.10.224 # 生成vexpress开发板的默认配置 make ARCHarm vexpress_defconfig make ARCHarm CROSS_COMPILEarm-linux-gnueabi- -j4编译完成后arch/arm/boot/zImage就是我们要的内核镜像。接下来还需要一个极简根文件系统一般推荐用busybox来制作# 编译busybox make ARCHarm CROSS_COMPILEarm-linux-gnueabi- defconfig make ARCHarm CROSS_COMPILEarm-linux-gnueabi- install # 在指定目录下创建必要的system目录 mkdir -p rootfs/{bin,sbin,etc,proc,sys,dev,lib/modules}等这两样都准备好你就可以用一条QEMU命令把整个开发板拉起来了qemu-system-arm -M vexpress-a9 -m 512M \ -kernel zImage \ -dtb vexpress-v2p-ca9.dtb \ -nographic \ -append consolettyAMA0 root/dev/mmcblk0 rw \ -sd rootfs.img提示如果你是第一次跑QEMU可以先不挂rootfs内核自己启动到最后报VFS: Cannot open root device也算成功了一半至少说明zImage是有效的。2.3 第一个内核模块从insmod到看到自己的打印信息环境起来了你的第一个驱动实验其实不需要任何硬件一个最简单内核模块就能让你把整套编译、加载、卸载流程跑通。模块代码是这样的#include linux/init.h #include linux/module.h static int __init my_driver_init(void) { printk(KERN_INFO my_driver: hello, driver world!\n); return 0; } static void __exit my_driver_exit(void) { printk(KERN_INFO my_driver: goodbye!\n); } module_init(my_driver_init); module_exit(my_driver_exit); MODULE_LICENSE(GPL);配套的Makefile也是固定套路obj-m : my_driver.o KERNELDIR : /path/to/linux-5.10.224 PWD : $(shell pwd) all: $(MAKE) ARCHarm CROSS_COMPILEarm-linux-gnueabi- -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) ARCHarm CROSS_COMPILEarm-linux-gnueabi- -C $(KERNELDIR) M$(PWD) clean把编译出的my_driver.ko拷进rootfs在QEMU里执行insmod my_driver.ko然后用dmesg | tail查看输出。当你能在自己的终端里看到hello, driver world这行日志时你和设备驱动开发之间那层窗户纸就捅破了——原来驱动并没有那么神秘它就是一个可以被内核加载和卸载的模块。2.4 模块化开发的核心收益省掉反复烧写镜像的时间为什么我坚持让你用编译模块的方式学驱动而不是把驱动直接编进内核因为模块化开发把你的反馈循环从改代码-重编内核-重启系统压缩成了改代码-重编模块-rmmod再insmod整个循环只需要几秒钟学习效率至少提升一个量级。内核加载模块这件事本质上就是在运行时把一段目标文件链接进内核地址空间然后调用其中的init函数。模块卸载则是调用exit函数再把占用的资源释放掉。你只要把这个核心流程理解透了后面的字符设备、平台驱动、设备树这些概念都可以在这个基础上层层叠加。我见过很多工程师把驱动编进内核之后每次调试都痛苦不堪其实他们只是没意识到模块化这个最基本的开发模式有多香。3. 第一个字符设备驱动从hello world直接升级到工业级骨架如果你已经能在QEMU里跑通模块加载和卸载下一步就是正儿八经写一个字符设备驱动。很多人写到这里会到处抄代码抄完了也不知道设备节点是怎么来的、read函数为什么必须用copy_to_user。这一节我把整条链路的逻辑理顺再给一个可以直接拿去改的骨架代码。3.1 设备号、设备节点与cdev的关系字符设备驱动要解决的问题是让用户态的应用程序能够通过文件操作的方式访问硬件。你想在用户态调open、read、write那你首先要有一个文件路径比如/dev/mydev。这个文件路径就是设备节点。设备节点背后的真正标识是一对设备号主设备号和次设备号。创建设备节点的流程在Linux里经历了几个阶段的变化但对于学习来说你只需要理解现代内核里的标准做法先申请设备号再注册cdev最后通过class和device在/dev下自动创建设备节点。下面这段代码就是完整的骨架#include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DEVICE_NAME mydev #define CLASS_NAME mychar static int major; static struct class *my_class; static struct cdev my_cdev; static char kernel_buffer[256] default data; static int my_open(struct inode *inode, struct file *filp) { printk(KERN_INFO mydev: open called\n); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t len strlen(kernel_buffer); if (*offset len) return 0; if (count len - *offset) count len - *offset; if (copy_to_user(buf, kernel_buffer *offset, count)) return -EFAULT; *offset count; return count; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { if (count sizeof(kernel_buffer)) count sizeof(kernel_buffer) - 1; if (copy_from_user(kernel_buffer, buf, count)) return -EFAULT; kernel_buffer[count] \0; return count; } static const struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, }; static int __init mydev_init(void) { dev_t dev_num; alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); major MAJOR(dev_num); cdev_init(my_cdev, my_fops); cdev_add(my_cdev, dev_num, 1); my_class class_create(CLASS_NAME); device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME); printk(KERN_INFO mydev: initialized, major%d\n, major); return 0; } static void __exit mydev_exit(void) { dev_t dev_num MKDEV(major, 0); device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO mydev: exited\n); } module_init(mydev_init); module_exit(mydev_exit); MODULE_LICENSE(GPL);3.2 file_operations每个回调的作用与边界很多新手看到file_operations结构体里一堆回调函数就发怵。其实你只需要抓住一个原则这个结构体就是你驱动和应用之间的系统调用翻译表。应用调用open()时内核会根据设备号找到对应的cdev然后调用你注册的open回调应用调用read()时内核调用你的read回调。回调函数的返回值不是给你自己看的而是最终返回给应用层系统调用的。初学阶段不需要把所有回调都实现一遍但open、release、read、write这四个足够你理解整个机制了。有一点必须注意驱动里read/write回调的使用场景是有明确边界的——file_operations里的回调可能运行在进程上下文也可能运行在中断上下文比如内核其他子系统直接回调所以你写回调时不能想当然地调用可能睡眠的函数。后面讲并发和中断时我会展开。3.3 设备号、设备节点与cdev的关系续回到代码本身。alloc_chrdev_region是让内核动态分配一个空闲设备号cdev_init和cdev_add把你的file_operations和这个设备号绑定起来class_create和device_create则是为了让内核自动在/dev目录下生成节点。模块加载完成后你可以ls -l /dev/mydev看到这个字符设备节点然后写一个小应用去open它、read它、write它。我强烈建议新手在这里补一步操作找到/sys/class/mychar/mydev/dev这个文件看一下里面的主次设备号再和cat /proc/devices里看到的设备号对应起来。把这个对应关系想明白你对Linux设备模型的理解就比那些只会抄代码的人强出一大截。3.4 为什么read/write必经copy_to_user与copy_from_user这是整个字符设备驱动里最容易被新手忽略、却最关键的细节。内核态和用户态拥有不同的地址空间内核不能直接访问用户态传进来的指针反之亦然。更危险的是用户态传入的指针可能是非法的、可能指向的地址还没被映射如果内核直接对这个指针做读写轻则oops重则被恶意程序利用实现内核态代码执行。所以内核提供了copy_to_user和copy_from_user这两个函数它们会先检查目标地址的合法性再逐页拷贝数据遇到非法地址会返回错误码。所有涉及用户态数据的操作都必须走这两个函数。我见过有人图省事直接memcpy结果系统崩溃后查了半天找不到原因——驱动开发里没有图省事这三个字。4. 并发、中断与阻塞这三道窄门我当年几乎是闭着眼趟过去的字符设备驱动你会写了接下来要面对的是驱动开发最核心、也最容易把劝退的三大机制并发控制、中断处理和阻塞式IO。这三者往往是纠缠在一起的我在实际项目中无数次被它们联合折磨。下面我把它们拆开讲清楚。4.1 并发来源比你想的要多得多应用开发里并发通常意味着多线程但在内核驱动里并发的来源至少有四种多核CPU同时执行、中断随时抢占当前执行流、底半部机制与进程上下文交错、以及用户态多个进程同时open同一个设备。这还没算上SMP下的CPU核间同步。如果没有保护机制两个进程同时写同一个缓冲区数据错乱是必然的甚至可能导致内核崩溃。保护的第一选择是原子操作比如atomic_t能解决简单的计数问题。但大部场景需要临界区这时你就要在自旋锁和互斥锁之间做选择。4.2 自旋锁与互斥锁的选择本质是能不能睡的问题很多内核新手分不清spinlock和mutex的使用场景其实判断标准只有一句话临界区里能不能睡眠。如果临界区很短只是修改几个字段、置几个标志位用自旋锁。自旋锁在等待时会原地打转不能睡眠但它开销小、可以用于中断上下文。如果临界区较长或者需要调用可能睡眠的函数比如copy_to_user、等待IO完成就必须用互斥锁。互斥锁等待时会睡眠调度但只能在进程上下文使用。用错锁的问题是灾难性的在中断上下文里拿互斥锁一旦锁被占用睡眠调用会直接触发内核的BUG在持锁时间长的临界区里用自旋锁多核系统上其他CPU会空转消耗大量CPU时间。我有个同事曾在一个IRQ处理函数里加了互斥锁保护结果一加载驱动整个系统就像死机一样其实就是锁导致的活锁问题。4.3 中断上下文里绝对不能做的三件事中断处理是驱动开发的高危区。中断回调运行时当前执行流是借来的不是任何一个进程的正常上下文所以很多操作在中断里都是禁忌不能睡眠中断上下文没有进程调度器依赖调用msleep、wait_event、mutex_lock这些都会导致内核异常。不能使用可能睡眠的内存分配标志kmalloc带GFP_KERNEL时会睡眠等待内存在中断里要用GFP_ATOMIC。不能访问用户空间内存中断上下文不隶属于任何一个进程copy_to_user/copy_from_user都无法正常工作。那么中断里要做的事情太多怎么办答案是中断上半部只做最紧急的事耗时的事推给下半部。下半部有tasklet、工作队列和线程化中断threaded IRQ几种方式。tasklet在软中断上下文执行不能睡眠工作队列运行在进程上下文可以睡眠threaded IRQ则直接把中断处理变成内核线程最简单直接。现代驱动里能用threaded_irq就用threaded_irq这是我在踩过无数坑之后最想告诉新手的一句话。4.4 等待队列让用户态read自然休眠的设计思路阻塞式IO是驱动与硬件交互的灵魂。用户态调用read时如果硬件还没有数据一个好的驱动不应该立即返回错误而应该让进程睡在那里等数据到达后再唤醒它。这个机制在内核里由等待队列wait queue实现。一个经典的按键驱动逻辑是这样的用户态read一个按键值如果按键没有按下进程进入睡眠一旦中断触发代表按键被按下中断处理函数里调用wake_up唤醒等待队列上的进程。这样用户态的程序只需要简单地read就能自动实现等按键的语义。wait_queue_head_t key_wait; static int key_value 0; static ssize_t key_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { wait_event_interruptible(key_wait, key_value ! 0); if (copy_to_user(buf, key_value, sizeof(key_value))) return -EFAULT; key_value 0; return sizeof(key_value); } // 在按键中断的处理函数中 static irqreturn_t key_irq_handler(int irq, void *dev_id) { key_value read_key_value(); // 读硬件寄存器 wake_up_interruptible(key_wait); return IRQ_HANDLED; }这里的核心逻辑是检查条件、睡眠、被唤醒后再检查条件循环往复直到数据真正可用。等待队列的好处是把硬件事件和进程调度解耦了驱动不需要关心进程什么时候执行只要有事件就唤醒没事件就让进程老实睡着。这套机制理解透了再看epoll、再去看内核里各种wait_event变体就一点都不怵了。5. dmesg、oops与日出而作这些调试手段让我少掉了半头头发驱动开发最大的痛点是没有好的调试工具。你没法像应用开发那样打断点单步跟踪驱动一旦出问题多半是系统直接崩溃。这几年的实战下来我的调试手段主要浓缩成几个简单但极其有效的套路。5.1 把printk当成正经日志工具来用很多初学者用printk就是printk(xxx)打印完再用dmesg慢慢翻。但实际上printk有自己的日志级别体系从KERN_EMERG到KERN_DEBUG一共8级。在日常驱动调试里我建议你养成三个习惯用KERN_INFO打印驱动加载卸载的关键节点用KERN_DEBUG打印数据流里的调试信息。熟悉dmesg -n 8和echo 8 /proc/sys/kernel/printk这些命令让所有级别的日志都能输出到console方便在串口或者QEMU终端上直接看到。学会使用动态调试dynamic_debug。编译内核时打开CONFIG_DYNAMIC_DEBUG后你可以通过echo file my_driver.c p /sys/kernel/debug/dynamic_debug/control在运行时灵活地打开或者关闭某个文件的debug打印再也不用为了加日志反复重新编译模块。不要觉得printk没有技术含量。在驱动调试场景里printk最大的优势是可以在任何上下文里安全调用只要别在真正的原子上下文里用太长的格式串而且它不依赖任何外部工具目标板上只要有一个console就能看到输出。我调过很多复杂硬件问题最后定位到根因的都是那几行看起来不起眼的printk。5.2 用debugfs导出驱动内部状态打印日志是主动输出的调试方法但很多问题时序相关日志打太多反而会影响时序。这时候我更倾向于用debugfs创建只读或者可写的调试节点把驱动的内部状态暴露出来需要时再用cat或者echo手工去查看和修改。#include linux/debugfs.h static struct dentry *debugfs_dir; static int my_state; static int state_show(struct seq_file *m, void *v) { seq_printf(m, state%d\n, my_state); return 0; } static int state_open(struct inode *inode, struct file *file) { return single_open(file, state_show, NULL); } static const struct file_operations state_fops { .owner THIS_MODULE, .open state_open, .read seq_read, .llseek seq_lseek, .release single_release, }; static int __init dbg_init(void) { debugfs_dir debugfs_create_dir(my_driver_dbg, NULL); debugfs_create_file(state, 0444, debugfs_dir, NULL, state_fops); return 0; }模块加载后在QEMU或者真机的/sys/kernel/debug/my_driver_dbg/state下就能读到驱动的实时状态。这一招在调试那些运行一段时间才出错的问题时特别有用因为你可以定期去采样状态而不是靠一堆日志去猜。5.3 解读oops信息并快速定位崩溃点内核崩溃时的oops信息看似天书其实包含的信息量极其丰富。你需要盯住几个关键字段首先是Unable to handle kernel NULL pointer dereference或者BUG: scheduling while atomic这类摘要它直接告诉你崩溃类型然后是PC is at xxx0x14/0x50这里给出了崩溃时CPU正在执行的函数地址再配合Call trace里的函数调用栈你基本能锁定出问题的函数。拿到PC地址后用addr2line或者objdump把它翻译成源码行号arm-linux-gnueabi-addr2line -e vmlinux ffffff8000123456注意这里用的vmlinux一定要和运行时的内核是同一个版本、同一份配置编译出来的否则地址对不上排查方向会完全跑偏。还有一种让你想砸电脑的情况oops信息里PC指针指向的地址完全没有符号基本都是中断或定时器回调在随机时刻触发导致的。这种问题通常和数据竞争有关这时我会回到前面讲的并发控制那一套检查锁是否用对、中断是否保护了共享数据。5.4 交叉编译环境里最容易被忽略的变量最后说一个看起来不太起眼、却能浪费你一周时间的坑——交叉编译环境。很多人在自己PC上编译模块好好的一放到开发板上就报invalid module format或者加载后内核直接拒绝。这通常不是代码问题而是你编译模块用的内核源码版本和你板子上跑的内核版本不一致。内核模块的二进制格式会绑定一系列版本信息包括内核版本号、编译器版本、CONFIG_*配置等。你在哪个内核上加载模块就必须用哪个内核源码来编译。搭建交叉编译环境时一定要把ARCH和CROSS_COMPILE这两个变量固化下来并且在Makefile里显式指定KERNELDIR指向板子对应的内核源码不能在开发板上直接裸学。这些看似琐碎的环境问题恰恰是工程师经验和效率的分水岭。6. 这本书拿到手该怎么读以及一条避开弯路的实操路线铺垫了这么多回到开头那本书。《手把手教你学Linux设备驱动开发》我通读过几章从内容结构上能明显感觉到作者应该是趟过不少坑的工程师因为它的组织逻辑和实际开发的学习曲线是贴合在一起的。但书终究只是工具怎么读、以什么顺序读效果可以差出好几倍。6.1 从书名里的手把手能看出什么一本驱动开发的书敢把手把手写进书名至少在定位上就和其他原理大全式的书拉开了距离。这类书的核心价值不是讲一大堆抽象概念而是帮你搭建一个照着做就能跑通的路径。买书这件事本身容易真正难的是你愿不愿意从第一个内核模块开始一行一行地把代码敲进编辑器、自己编译、自己验证。我的建议很简单别把这本书当成一本从第一页读到最后一页的小说。你看目录找到章节之间的依赖关系先挑一个能跑出可见结果的章节下手。比如先做LED或者按键驱动看到效果了再回头补前面的理论。6.2 三个层次的读法照着敲、改着玩、丢开书重写读书学习驱动开发可以用三个递进的层次来读。第一层照着敲。书里的代码不要直接复制粘贴一定要手动敲一遍。敲代码的过程会逼着你注意到那些容易被眼睛跳过的细节比如头文件、函数参数的类型、变量的初始化。这个阶段的目标只有一个让代码在你的环境里成功跑起来。第二层改着玩。跑通之后主动去改代码。比如把read函数里返回值改大改小、把等待队列的条件换个方向、把read改为只允许单个进程访问O_EXCL。改坏了再改回来这个过程就是你用试错法去理解每个函数真正作用的过程。我强烈建议这一层里多做故意犯错的练习因为只有踩过坑、见过oops才能对内核的安全约束产生肌肉记忆。第三层丢开书重写。当你对某个章节比较熟之后尝试合上书只根据功能需求重新实现一遍。比如先遮住代码自己写一个字符设备驱动要求有设备节点、支持read/write、支持多进程并发访问。写不出来的地方再回头翻书这时候的阅读效率是最高的因为你的大脑已经建立了问题框架书里的任何信息都能被有效吸收。6.3 没有开发板也能学驱动从模拟到真机的平滑过渡如果你现在手头没有开发板完全可以按照我在第2节说的方法先用QEMU把字符设备、并发控制、中断、等待队列这些都跑一遍。等你在模拟环境里已经具备把内核当成一个开放系统来操作的直觉时再考虑买板子。第一次上真机选一个最简单的外设比如一个GPIO口的LED灯把在QEMU里练过的框架搬到真机上。真机和模拟器最大的区别是真机有实际的中断触发时序、有实际寄存器读写开销、有真实的硬件设备树。你会遇到在模拟器里完全不会出现的问题比如中断频繁触发导致cpu占用率达到100%、IO地址映射错误导致内核oops、设备树节点匹配不上导致驱动无法probe。这些问题的排查思路和我前面讲的调试手段是一脉相承的只是对象从虚拟设备变成了物理硬件排查起来更刺激。特别提醒如果你用QEMU学习时绕过了设备树那么上真机前一定要花时间把设备树device tree的基础知识补上。现代ARM Linux已经全面设备树化不会dts和dtsi的编写和编译你在真机上会寸步难行。最后再分享一个我个人的习惯。无论是在模拟器还是真机上调试我都会在驱动代码的头部留一个#define DEV_DEBUG的开关把所有开发期间的调试日志都包在这个宏下面。上线前把开关关掉不需要把printk一行一行删除也不用担心被打日志拖慢时序。这个习惯帮我省了太多时间让我能专注在驱动逻辑本身而不是被日志管理分心。设备驱动开发这条路上没有捷径但你要是能把每一段代码、每一次oops、每一个莫名其妙的硬件行为都彻底搞明白你会发现自己进步的速度远超预期。
返回列表