首页 / 资讯中心 / 文章详情

嵌入式Linux与安卓驱动开发实战:从内核模块到HAL层完整链路

嵌入式Linux与安卓驱动开发实战:从内核模块到HAL层完整链路 ★ FEATURED ARTICLE
嵌入式Linux和安卓驱动开发这两个方向单拎出来任何一个都够啃上一阵子把两者揉在一起做实战项目基本上就是冲着系统底层岗位去的。我带过几届校招和社招的技术面试也帮不少朋友做过嵌入式方向的项目复盘发现一个很普遍的现象简历上写着“熟悉Linux驱动开发”的人一抓一大把但真正能讲清楚一个字符设备驱动从注册到用户态调用完整链路的十个里面挑不出两个。问题出在哪大多数人学驱动的方式是“看教程敲一遍代码编译通过加载成功截图完事”至于这个驱动在内核里到底经历了什么、为什么这么写、换个平台还能不能跑一概不知。面试官稍微往深处问一层比如“probe函数什么时候被调用”“设备树里的compatible是怎么匹配上的”“中断上半部和下半部你怎么划分的”立刻就露馅了。这个实战项目的核心价值就是帮你把“敲过代码”变成“真正理解并能讲明白”。我会围绕嵌入式Linux驱动开发和安卓驱动开发两条主线把从环境搭建、内核模块编写、设备树配置、字符设备与平台设备驱动、中断处理、并发控制一直到安卓HAL层对接的完整链路拆开揉碎讲清楚。适合正在准备嵌入式方向面试的应届生、想从应用层转底层的开发者以及工作一两年但驱动基础不扎实想系统补课的朋友。读完你至少能做到拿到一块新板子知道从哪里下手把驱动跑起来面试时能把驱动模型讲得有理有据。1. 为什么驱动开发是嵌入式面试的分水岭1.1 应用层和驱动层的能力差距到底在哪很多人觉得驱动开发就是“写C语言操作寄存器”这个理解太浅了。应用层开发和驱动开发最大的区别在于应用层跑在操作系统之上你面对的是封装好的API出了问题大不了重启进程驱动层跑在内核空间你面对的是硬件和内核子系统一个空指针就可能让整个系统panic。这种“出错代价”的差异直接决定了两者在知识结构上的不同要求。应用层开发者需要懂的是业务逻辑、框架使用、性能优化驱动开发者需要懂的是硬件手册、内核子系统框架、并发与同步、内存管理、中断处理机制。举个具体的例子应用层调用read()读一个传感器数据你可能只关心返回值对不对但驱动开发者要关心的是——这个read最终怎么走到你的驱动函数里、file_operations结构体是怎么注册的、内核在调用你的read之前做了哪些检查、如果你的read要休眠该怎么处理、如果多个进程同时read你的驱动该怎么保证数据一致性。这一层一层往下挖才是驱动开发真正的门槛。面试官区分候选人是“应用层水平”还是“驱动层水平”往往就问一个问题你写的驱动用户态是怎么调用到的能把这个链路讲清楚的人基本功都不会太差。1.2 安卓驱动开发和纯Linux驱动开发的差异点安卓底层本质上就是Linux内核所以驱动开发的核心机制是一样的。但安卓在上面加了自己的东西最典型的就是HAL层Hardware Abstraction Layer和Binder IPC机制。纯Linux驱动开发你的驱动可能直接通过/dev节点暴露给应用层用安卓体系里应用层不直接碰驱动节点而是通过HAL层封装的接口来访问硬件。这就带来一个实际项目中的关键差异安卓驱动开发不仅要写内核驱动还要写HAL层的适配代码让上层Framework能通过标准接口调用到你的硬件。比如你做一个安卓平台的传感器驱动内核层你要写IIO子系统驱动HAL层你要实现sensors HAL的接口上层通过SensorService最终调用到你的驱动。这条链路比纯Linux驱动长得多涉及的知识面也更广。从面试角度来说如果你能讲清楚“安卓应用调用一个硬件接口从App层一路往下经过Framework、HAL、内核驱动到硬件的完整链路”这基本上就是高级嵌入式岗位的敲门砖了。1.3 一个合格驱动工程师的知识地图我把驱动工程师需要掌握的知识分成四层你可以对照看看自己缺哪块层级核心内容掌握标准基础层C语言、数据结构、计算机组成原理能看懂内核源码中的链表、哈希表操作内核层内核模块机制、字符设备、平台设备、设备树能独立编写并调试一个完整驱动硬件层寄存器操作、中断控制器、时钟、GPIO子系统能对照芯片手册配置外设系统层并发控制、内存管理、电源管理、HAL对接能处理竞态、死锁、内存泄漏等复杂问题这个项目会沿着这四层逐步展开每一层都有对应的实战环节。你不需要一开始就全部掌握但至少要清楚自己现在在哪一层下一步该往哪走。2. 开发环境搭建别在第一步就卡住2.1 交叉编译工具链的选择与验证驱动开发的第一步不是写代码是把环境搭好。我见过太多人卡在交叉编译工具链上折腾一两天都搞不定。这里说几个关键点。交叉编译工具链的选择取决于你的目标平台。ARM 32位平台常用arm-linux-gnueabihf-ARM 64位平台用aarch64-linux-gnu-具体用哪个版本要和你目标板子上运行的系统匹配。最稳妥的方式是直接用芯片原厂或板子厂商提供的工具链因为内核编译对GCC版本很敏感版本不匹配会出现各种奇怪的编译错误。验证工具链是否可用不要只看--version输出要实际编译一个测试程序# 编写测试文件 cat test.c EOF #include stdio.h int main() { printf(cross compile test\n); return 0; } EOF # 交叉编译 arm-linux-gnueabihf-gcc test.c -o test # 查看文件架构 file test # 应输出: ELF 32-bit LSB executable, ARM, ...如果file命令输出的架构和你目标板子一致说明工具链基本可用。接下来还要确认工具链里的内核头文件版本和你目标内核版本是否匹配这个后面编译驱动模块时会用到。注意不要用系统自带的gcc去编译ARM平台的驱动即使编译通过了加载到板子上也会报“invalid module format”错误。这个错误的本质是模块的 vermagic 和内核不匹配。2.2 内核源码的获取与首次编译编译内核是驱动开发的基本功。你需要拿到和目标板子运行版本完全一致的内核源码否则编译出来的驱动模块加载不进去。获取内核源码有三个途径芯片原厂提供的BSP包、板子厂商的SDK、或者从内核官网下载对应版本。优先用原厂BSP因为里面包含了板子相关的配置文件和驱动补丁。首次编译内核的流程大致如下# 进入内核源码目录 cd linux-kernel # 配置内核使用厂商提供的默认配置 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- defconfig # 或者使用指定的配置文件 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- xxx_defconfig # 编译内核镜像和设备树 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- zImage dtbs -j$(nproc) # 编译内核模块 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- modules -j$(nproc)第一次编译可能要等十几分钟甚至更久取决于你的机器性能。编译完成后在arch/arm/boot/目录下会生成zImage在arch/arm/boot/dts/目录下会生成对应的.dtb文件。这里有个容易忽略的点编译内核模块之前必须先执行一次完整的内核编译因为模块编译依赖内核编译过程中生成的一些中间文件。如果你直接跳到make modules大概率会报错。2.3 根文件系统与NFS挂载调试环境调试驱动最方便的方式是通过NFS挂载根文件系统这样你在PC上编译好驱动模块板子上可以直接加载不用每次都烧写整个系统。搭建NFS环境分两步PC端配置NFS服务器板子端配置内核支持NFS挂载。PC端配置# 安装NFS服务 sudo apt install nfs-kernel-server # 编辑配置文件 sudo vim /etc/exports # 添加一行注意替换路径和网段 /home/yourname/nfs_root *(rw,sync,no_subtree_check,no_root_squash) # 重启NFS服务 sudo systemctl restart nfs-kernel-server板子端需要在内核配置中开启NFS客户端支持并在启动参数中指定NFS根文件系统路径。具体的内核配置项包括CONFIG_NFS_FS、CONFIG_NFS_V3、CONFIG_ROOT_NFS等。启动参数通过bootargs传递格式类似root/dev/nfs rw nfsroot192.168.1.100:/home/yourname/nfs_root ip192.168.1.200提示使用NFS v3而不是v4因为很多嵌入式内核的NFS v4支持不完整用v3兼容性更好。如果挂载时报“protocol not supported”先检查内核是否编译了对应的NFS版本支持。NFS挂载调通之后你的开发效率会提升一个档次。改驱动代码、编译、拷贝到NFS目录、板子上重新加载模块整个过程几十秒就能完成。3. 从零写一个字符设备驱动3.1 内核模块的基本骨架与加载卸载流程字符设备驱动是最基础的驱动类型也是理解Linux驱动模型的入口。一个内核模块的基本结构包括入口函数、出口函数、模块声明三部分#include linux/module.h #include linux/kernel.h #include linux/init.h static int __init mydrv_init(void) { printk(KERN_INFO mydrv: init\n); return 0; } static void __exit mydrv_exit(void) { printk(KERN_INFO mydrv: exit\n); } module_init(mydrv_init); module_exit(mydrv_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(yourname); MODULE_DESCRIPTION(A simple char device driver);对应的Makefileobj-m mydrv.o KDIR : /path/to/linux-kernel PWD : $(shell pwd) all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) clean编译完成后生成mydrv.ko通过insmod mydrv.ko加载rmmod mydrv卸载。加载后用dmesg可以看到printk输出的信息。这里解释几个“为什么”__init和__exit这两个宏的作用是告诉内核这两个函数只在初始化和卸载时使用内核会把__init标记的函数放在特殊的内存段初始化完成后释放这段内存。MODULE_LICENSE(GPL)必须写否则内核会认为你的模块是专有代码很多内核符号你都无法使用。3.2 file_operations结构体的注册与用户态调用链路字符设备驱动的核心是file_operations结构体它定义了用户态对设备文件进行操作时内核调用的函数集合。一个典型的实现static int mydrv_open(struct inode *inode, struct file *filp) { printk(KERN_INFO mydrv: open\n); return 0; } static ssize_t mydrv_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { char kbuf[] hello from kernel; if (copy_to_user(buf, kbuf, sizeof(kbuf))) return -EFAULT; return sizeof(kbuf); } static ssize_t mydrv_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { char kbuf[64]; if (count sizeof(kbuf)) count sizeof(kbuf); if (copy_from_user(kbuf, buf, count)) return -EFAULT; printk(KERN_INFO mydrv: received %zu bytes\n, count); return count; } static int mydrv_release(struct inode *inode, struct file *filp) { printk(KERN_INFO mydrv: release\n); return 0; } static const struct file_operations mydrv_fops { .owner THIS_MODULE, .open mydrv_open, .read mydrv_read, .write mydrv_write, .release mydrv_release, };用户态调用read(fd, buf, size)时内核的调用链路是系统调用sys_read→ VFS层vfs_read→ 根据文件类型找到对应的file_operations→ 调用你注册的mydrv_read。理解这条链路你就能回答面试中“用户态怎么调用到驱动”这类问题。copy_to_user和copy_from_user这两个函数不能替换成memcpy因为用户态和内核态的地址空间是隔离的直接访问用户态指针可能导致内核崩溃。这两个函数内部会做地址合法性检查并且在访问失败时返回非零值。3.3 设备号的申请与自动创建设备节点字符设备需要分配设备号设备号分主设备号和次设备号。主设备号标识驱动次设备号标识同一驱动下的不同设备。申请设备号有两种方式// 方式一静态指定主设备号 dev_t devno MKDEV(240, 0); register_chrdev_region(devno, 1, mydrv); // 方式二动态分配推荐 alloc_chrdev_region(devno, 0, 1, mydrv);动态分配的好处是不会和系统中已有的设备号冲突。拿到设备号之后需要初始化cdev结构体并添加到内核struct cdev mydrv_cdev; cdev_init(mydrv_cdev, mydrv_fops); mydrv_cdev.owner THIS_MODULE; cdev_add(mydrv_cdev, devno, 1);设备节点可以手动用mknod创建但更优雅的方式是让内核自动创建。这需要用到class_create和device_createstruct class *mydrv_class class_create(THIS_MODULE, mydrv); device_create(mydrv_class, NULL, devno, NULL, mydrv);这样加载模块后/dev/mydrv节点会自动出现用户态直接open(/dev/mydrv, O_RDWR)就能操作设备。注意class_create在较新的内核版本中参数有变化去掉了第一个THIS_MODULE参数。写代码时要根据你的内核版本调整否则编译会报参数过多的错误。4. 平台设备驱动与设备树实战4.1 为什么需要平台设备驱动模型字符设备驱动解决了“怎么让用户态访问驱动”的问题但没有解决“驱动怎么和硬件绑定”的问题。早期的Linux驱动把硬件资源寄存器地址、中断号等硬编码在驱动代码里导致同一份驱动换个板子就要改代码可移植性极差。平台设备驱动模型platform driver就是为了解决这个问题。它把驱动分成两部分platform_device描述硬件资源platform_driver描述驱动逻辑。两者通过名称匹配匹配成功后调用驱动的probe函数在probe函数里获取硬件资源并初始化。设备树的引入进一步解耦了硬件描述和驱动代码。硬件资源不再写在C代码里而是写在设备树文件.dts中内核启动时解析设备树自动生成对应的platform_device。驱动只需要通过compatible属性来匹配设备树节点即可。4.2 设备树节点的编写与compatible匹配机制假设我们要为一个I2C温度传感器写驱动设备树节点大概长这样i2c1 { status okay; temp_sensor: temp48 { compatible myvendor,tmp102; reg 0x48; interrupt-parent gpio1; interrupts 12 IRQ_TYPE_EDGE_FALLING; }; };驱动端定义匹配表static const struct of_device_id mydrv_of_match[] { { .compatible myvendor,tmp102 }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mydrv_of_match); static struct platform_driver mydrv_driver { .probe mydrv_probe, .remove mydrv_remove, .driver { .name mydrv, .of_match_table mydrv_of_match, }, }; module_platform_driver(mydrv_driver);匹配过程是这样的内核启动时解析设备树为每个节点创建platform_device其中包含compatible属性的值。当驱动注册时内核遍历所有platform_device比较设备树的compatible和驱动的of_match_table匹配成功就调用probe函数。compatible属性的命名规范是厂商前缀,设备型号厂商前缀必须是已在内核文档中注册的。自己写测试驱动时可以用自定义前缀但正式提交内核的驱动必须遵守规范。4.3 probe函数里该做哪些事probe函数是平台驱动的核心它在你驱动的设备被匹配到时调用。一个规范的probe函数应该按以下顺序做事static int mydrv_probe(struct platform_device *pdev) { struct mydrv_data *data; struct resource *res; int irq, ret; // 1. 分配私有数据结构 data devm_kzalloc(pdev-dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; // 2. 获取寄存器资源 res platform_get_resource(pdev, IORESOURCE_MEM, 0); >static irqreturn_t mydrv_isr(int irq, void *dev_id) { struct mydrv_data *data dev_id; u32 status; // 读取中断状态 status readl(data-base REG_STATUS); if (!(status IRQ_PENDING)) return IRQ_NONE; // 清除中断标志 writel(status,>// 自旋锁示例 spinlock_t lock; unsigned long flags; spin_lock_irqsave(lock, flags); // 临界区代码不能休眠 spin_unlock_irqrestore(lock, flags); // 互斥锁示例 struct mutex mtx; mutex_init(mtx); mutex_lock(mtx); // 临界区代码可以休眠 mutex_unlock(mtx);选择原则很简单中断上下文只能用自旋锁进程上下文且临界区可能休眠用互斥锁只是简单计数用原子操作。5.3 一个竞态问题的完整排查过程我之前调试过一个驱动现象是偶尔读取到的数据不对概率大概几百次出现一次。排查过程记录如下第一步确认不是硬件问题。用逻辑分析仪抓了I2C总线上的数据硬件层面数据是正确的排除硬件故障。第二步在驱动的read函数里加打印发现出错时读到的数据是上一次的旧数据。这说明数据更新和读取之间存在竞态。第三步检查代码发现中断处理函数更新数据缓冲区read函数读取缓冲区两者之间没有任何同步机制。在单核时代这可能没问题但在多核系统上read执行到一半被中断打断中断更新了缓冲区read继续用旧的部分数据就出现了不一致。第四步修复方案是加自旋锁保护缓冲区。中断处理函数和read函数在访问缓冲区前都先获取锁。修改后问题消失。这个案例说明一个道理驱动开发中只要涉及中断和进程上下文共享数据就必须考虑并发问题。不要心存侥幸觉得“概率很低不会出问题”产品量产之后低概率问题会变成高概率投诉。6. 安卓HAL层对接与驱动调试技巧6.1 从内核驱动到安卓HAL的完整调用链路安卓系统的硬件访问链路比纯Linux长得多。以传感器为例完整链路是App调用SensorManager API → Framework层的SensorService → HAL层的sensors HAL → 内核层的IIO驱动 → 硬件传感器。HAL层的实现方式经历过几次演变。早期是HAL stub方式HAL模块编译成.so文件由Framework动态加载。Android 8.0之后引入了HIDLHAL实现为独立的进程通过Binder IPC与Framework通信。Android 11之后又推出了AIDL HAL。你实际做项目时用哪种方式取决于你的安卓版本。以HIDL方式为例HAL层需要实现一个继承自HIDL接口的类在接口方法里通过open、ioctl、read等系统调用访问内核驱动节点。编译时需要编写对应的.hal接口定义文件和Android.bp编译脚本。6.2 printk、ftrace与动态调试的实战用法驱动调试最常用的工具是printk但printk用不好会拖慢系统。几个实用技巧用pr_debug代替printk(KERN_DEBUG)配合动态调试dynamic debug可以在运行时开关调试信息不用重新编译内核。启用方式# 挂载debugfs mount -t debugfs none /sys/kernel/debug # 启用某个文件的pr_debug输出 echo file mydrv.c p /sys/kernel/debug/dynamic_debug/controlftrace是分析内核执行流程的利器。比如你想知道你的驱动函数被调用了多少次、每次调用耗时多少# 启用函数追踪 echo function /sys/kernel/debug/tracing/current_tracer echo mydrv_read /sys/kernel/debug/tracing/set_ftrace_filter echo 1 /sys/kernel/debug/tracing/tracing_on # 查看追踪结果 cat /sys/kernel/debug/tracing/trace如果驱动崩溃导致内核panic可以用pstore或ramoops机制在重启后保留崩溃日志。配置好之后/sys/fs/pstore/目录下会保存上次崩溃的日志对定位问题非常有帮助。6.3 面试中驱动项目该怎么讲才有说服力最后说回面试。很多人简历上写了驱动项目但面试时讲得干巴巴的面试官听完没有任何追问的欲望。问题在于你只讲了“做了什么”没讲“为什么这么做”和“遇到了什么问题怎么解决的”。一个有说服力的项目讲述应该包含这几个要素项目背景为什么要做这个驱动、技术选型为什么用平台设备驱动而不是字符设备驱动、核心难点遇到了什么竞态或性能问题、解决方案怎么定位和修复的、最终效果性能提升了多少、稳定性如何。面试官最感兴趣的是你解决问题的过程而不是最终代码长什么样。举个例子你可以这样讲“我做的这个传感器驱动最初用字符设备实现后来发现设备树配置无法自动匹配改成了平台设备驱动模型。调试过程中遇到一个偶发的数据不一致问题排查后发现是中断和read之间的竞态加了自旋锁解决。最终驱动在压力测试下连续运行72小时无异常。”这段话里有技术选型、有问题定位、有解决方案、有量化结果面试官自然会顺着追问你就有机会展示更多细节。驱动开发这个方向入门确实比应用层陡一些但一旦跨过那道坎你会发现底层知识是相通的。今天写的是传感器驱动明天换成触摸屏驱动、摄像头驱动核心的框架和思路是一样的。把基础打扎实后面就是不断积累不同硬件和子系统的经验。我在实际带新人的过程中发现那些愿意花时间把原理搞透的人往往半年就能独立负责一个模块而只满足于“代码能跑”的人两年了还在原地打转。
阅读完成 · 觉得有帮助?
咨询建站