Android12平台RK3588字符设备驱动开发实战:从GKI到SELinux
1. 先别急着写代码:Android12平台下驱动开发的关键差异
我最早在RK3588上写字符设备驱动时,基本延续了裸机Linux的路子——写好file_operations、注册设备、编译ko、insmod,结果一连串问题直接把我整懵了。纯粹的内核空间开发逻辑其实没变,但Android12这套用户态框架给驱动开发者加了不少"隐形条款",不先搞清楚这些,代码写得再漂亮也是白搭。
先说最核心的一件事:Android12默认不再随便给你insmod的机会。GKI(Generic Kernel Image)策略在Android 12上被全面强化,厂商内核必须与谷歌维护的通用内核二进制保持接口一致,这意味着你不能再指望像老版本那样把ko文件丢进根文件系统然后insmod完事。即便RK3588这类SoC对外发布的是非GKI的厂商内核,实际SDK里也默认关闭了模块加载的入口,甚至内核配置里直接把module支持关掉了。很多人在这里卡了一周:驱动源码编出来了,ko文件也放在vendor分区了,insmod一下直接报错,看起来是权限问题,实际是内核根本没开CONFIG_MODULES。
那绕开模块加载的正确姿势是什么?两条路:
- 把驱动直接编进内核。开发调试阶段最省心,改完代码编译内核、生成boot.img、刷机重启,probe流程在内核启动阶段就完成。缺点是迭代慢,一次编译加刷机动辄十几分钟。
- 编成ko模块再想办法加载。需要内核开启CONFIG_MODULES,同时Android侧还要处理SELinux标签、AVB校验、dm-verity等一系列权限和完整性检查。适合模块功能相对稳定、只改参数不改逻辑的场景。
我自己做主驱动开发时,长期维护的驱动走方案1,临时调试仪器走方案2。这样既有稳定的运行环境,又不会因为刷机太频繁打断开发节奏。
另外说个不少新手注意不到的细节:RK3588这颗SoC的Android12 SDK里,内核单独出包,路径一般是kernel-6.1这层(不同批次SDK版本内核主版本会不一样,但目录结构基本稳定)。设备树、板级配置都在这个目录下改,跟纯Linux主线开发流程是一样的,区别在于你对设备树节点和驱动代码的修改,需要重新打包整个boot.img而不是烧单独的kernel分区——Android12把内核和ramdisk合成一个boot镜像,这个机制在编译阶段会自动处理,但你要清楚改动最终落到了哪个镜像里。
总结一下:Android12下写驱动,最稳妥的开发路线是"设备树声明节点 + 驱动编入内核 + 刷boot.img"。单向流程走通之后,再考虑模块化的玩法。
2. 开发环境搭建:那些文档里不会写的编译前置条件
2.1 源码树准备不是简单解压
现在各大SoC厂商发布的Android SDK都是按repo方式组织的,一个完整开发环境包含u-boot、kernel、device/rockchip、hardware/rockchip这些仓库。拿到SDK压缩包解压之后,第一件事不是急着改代码,而是跑一遍确认编译环境能不能过。
我强烈建议在纯净的Ubuntu 18.04或20.04环境上编译RK3588 Android12工程,内存至少32GB,磁盘可用空间500GB以上。有人用16GB内存硬扛,结果每编一次内核就OOM一次,最后还是老实加内存了。如果你是在云服务器上编译,记得选带足够swap的实例,不然make -j$(nproc)跑到一半内存炸了特别浪费时间。另外repo sync之前一定要确认网络稳定,SDK整体同步下来接近200GB,断点续传的脚本也不是万能的。
2.2 内核交叉编译工具链的选择
Android12 SDK里自带prebuilts工具链,位于prebuilts/gcc/linux-x86/aarch64/aarch64-linux-android-4.9/bin目录下,默认编内核用的是这个gcc 4.9工具链。但RK3588官方SDK的实际脚本里,很多时候会调用Clang或更新的GCC版本做内核编译,这个需要看SDK里kernel-6.1/build.sh或者Makefile里实际指定的CC变量。
我自己试过直接用aarch64-linux-gnu-交叉工具链去编内核,完全没问题——内核的构建系统会自己适配。关键要注意的一点是不要混用工具链:如果你用SDK自带的路径编了一半,又换成你自己安装的交叉编译器继续编译,大概率会出现奇怪的链接错误。清掉out/目录重新来一次就好。
2.3 从零编译一个可刷机的boot镜像
在SDK根目录下依次执行:
source build/envsetup.sh lunch rk3588_android12-userdebug make bootimage -j$(nproc)编译完成后,产物在out/target/product/rk3588/boot.img。用RKDevTool或upgrade_tool烧录这个镜像之前,建议先备份原厂boot.img——不是所有同事都习惯你自己改过的内核对Android上层的影响。如果同时改了设备树分区参数,还得配套烧parameter和uboot,那就不只是boot.img的事了。
踩坑提醒:make bootimage只重编boot.img,不会把你不小心改坏的vendor或system分区重建。如果你改了HAL层代码、SELinux策略或者其他Android侧的东西,那必须用make -j$(nproc)全量编译,否则只会出现"驱动跑起来但应用层访问异常"的诡异现象。
2.4 编译时临时加打印信息的一个小技巧
内核调试时我习惯开这几个编译开关:
export ARCH=arm64 export CROSS_COMPILE=aarch64-linux-gnu- # 建议打开这些 CONFIG_DEBUG_KERNEL=y CONFIG_DEBUG_INFO=y CONFIG_KGDB=y CONFIG_FTRACE=y CONFIG_KPROBES=y开启KGDB之后,如果驱动在probe阶段panic,你可以用串口进入调试器直接看调用栈;FTRACE则能帮你在驱动运行阶段跟踪函数调用关系。用串口看内核日志比用adb shell dmesg方便得多,特别是驱动在设备树probe阶段出问题时,dmesg里的打印经常被Android日志系统过滤掉了。
3. 写一个"最小可用"的字符设备驱动并编入内核
3.1 从miscdevice还是完整字符设备说起
字符设备驱动最常见的注册方式有两种:register_chrdev_region+cdev_add,以及misc_register。在我实际写RK3588平台驱动时,大部分情况用miscdevice就够了,因为Android用户态访问外设节点时,主设备号是次要的,关键是设备节点的SELinux标签和应用层HAL怎么找到它。miscdevice自动分配次设备号,统一走misc主设备号10,省去手动管理主设备号的麻烦。
下面这个例子就是个典型的最小驱动框架。我拿它做过GPIO控制、寄存器读写、简单的数据通道收发,框架基本通用。
#include <linux/module.h> #include <linux/miscdevice.h> #include <linux/fs.h> #include <linux/uaccess.h> #include <linux/slab.h> #include <linux/platform_device.h> #define DRIVER_NAME "demo_chardev" struct demo_dev { void __iomem *base; struct miscdevice misc; }; static int demo_dev_open(struct inode *inode, struct file *filp) { return 0; } static int demo_dev_release(struct inode *inode, struct file *filp) { return 0; } static ssize_t demo_dev_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char kbuf[128]; /* 实际场景从寄存器/内存读取数据 */ if (copy_to_user(buf, kbuf, count < sizeof(kbuf) ? count : sizeof(kbuf))) return -EFAULT; return count; } static ssize_t demo_dev_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[128]; if (count > sizeof(kbuf)) return -EINVAL; if (copy_from_user(kbuf, buf, count)) return -EFAULT; /* 实际场景写入寄存器 */ return count; } static const struct file_operations demo_dev_fops = { .owner = THIS_MODULE, .open = demo_dev_open, .release = demo_dev_release, .read = demo_dev_read, .write = demo_dev_write, }; static int demo_dev_probe(struct platform_device *pdev) { struct demo_dev *dev; int ret; dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev->misc.minor = MISC_DYNAMIC_MINOR; dev->misc.name = DRIVER_NAME; dev->misc.fops = &demo_dev_fops; ret = misc_register(&dev->misc); if (ret) { dev_err(&pdev->dev, "failed to register misc device\n"); return ret; } platform_set_drvdata(pdev, dev); dev_info(&pdev->dev, "demo chardev probed\n"); return 0; } static int demo_dev_remove(struct platform_device *pdev) { struct demo_dev *dev = platform_get_drvdata(pdev); misc_deregister(&dev->misc); return 0; } static const struct of_device_id demo_dev_of_match[] = { { .compatible = "vendor,demo-chardev" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, demo_dev_of_match); static struct platform_driver demo_dev_driver = { .probe = demo_dev_probe, .remove = demo_dev_remove, .driver = { .name = DRIVER_NAME, .of_match_table = demo_dev_of_match, }, }; module_platform_driver(demo_dev_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Developer"); MODULE_DESCRIPTION("Demo chardev driver for RK3588 Android12");3.2 设备树和probe是怎么牵手的
上面代码里的compatible = "vendor,demo-chardev",必须和设备树节点的compatible完全一致,probe才会被触发。RK3588 SDK的设备树主要看kernel-6.1/arch/arm64/boot/dts/rockchip/rk3588-evb*.dtsi这系列文件,你新增外设节点时最稳妥的做法是放在自己板子的dtsi里,不要改动芯片原厂的基础dts。
/* 在板级dtsi里加 */ demo_chardev: demo-chardev { compatible = "vendor,demo-chardev"; status = "okay"; };status = "okay"这个属性,很多人第一次写的时候会漏掉。如果漏了或者写成disabled,驱动模块加载时不会报错,但probe就是不会进,内核日志一片安静。我排查过好几次这种问题,最后都是grep设备树才发现节点根本没使能。
配套的Makefile和Kconfig修改也很straightforward。如果在内核源码树里开发,放在drivers/misc/目录下建一个demo_chardev/文件夹,里面放Kconfig和Makefile:
config DEMO_CHARDEV tristate "Demo chardev driver for RK3588" default yobj-$(CONFIG_DEMO_CHARDEV) += demo_chardev.o然后在drivers/misc/Kconfig里source "drivers/misc/demo_chardev/Kconfig",drivers/misc/Makefile里obj-$(CONFIG_DEMO_CHARDEV) += demo_chardev/。编进内核后,启动日志里应该能看到demo chardev probed,/dev目录下出现对应设备节点——前提是用户态的ueventd给它创建了节点,不然SELinux那只手会挡住一切。
3.3 probe不触发?按这个顺序排查
我统计了一下自己项目里的报错,probe不触发的前三名是:
- 设备树节点没使能(
status缺失或okay拼错)。 compatible字符串不匹配,驱动和设备树各写各的。- 编译的“驱动”没有真正编进内核镜像——
Kconfig选的m但内核没开模块支持,导致ko文件根本没生成。
第三点最坑,因为很多人觉得自己Makefile写对了,menuconfig里也看到DEMO_CHARDEV了,但编译产物里找不到.ko文件。在Android12内核默认关掉CONFIG_MODULES的大前提下,你只能选y而不是m,除非你动手改了内核配置并确认模块加载路径全部打通。
4. Android用户态怎么访问驱动:HAL层、SELinux和节点权限
4.1 为什么不能直接让App open("/dev/demo_chardev")
Android应用默认跑在独立用户(system_server、app_xx这些uid)下,对/dev下设备的访问权限受限。如果你只是做个root下的自测工具,adb root之后用命令行直接读写节点完全OK;但落到产品里,App直接open设备节点会被SELinux拦截,即使你把节点权限设成0666也照样报Permission denied。
所以正常做法是加一层HAL封装:App通过JNI调用HAL接口,HAL进程以系统权限(比如system或vendor域)去访问设备节点。这样权限收口到一个系统组件,而不是把设备节点的访问权放给每个App。
在Android12的Treble架构下,HAL代码要打包进vendor分区,并用hidl_interface或AIDL定义对外接口。RK3588 SDK本身对硬件访问推荐走hardware/rockchip下的模块,你可以在里面加自己的HAL模块。
对于只想测试驱动的场景,我倒建议直接在/system/bin下放一个自写的可执行程序,比如C/C++写的demotool,用adb shell以root或shell用户跑它去访问节点。这个链路最短,适合先打通驱动读写再去封装HAL。
4.2 SELinux规则怎么写才不坑
Android12上,即使你u-boot阶段禁用了SELinux,系统启动后还是会强制变成enforcing。最省心的临时做法是adb shell setenforce 0,但重启失效,而且产品上绝对不能这样搞。
正确做法是给设备节点和访问进程配好SELinux域。假设你的HAL进程跑在vendor_demo域,设备节点标签设为u:object_r:vendor_demo_device:s0,则在device/rockchip/common/sepolicy下新增:
# file_contexts /dev/demo_chardev u:object_r:vendor_demo_device:s0# vendor_demo.te 里加访问规则 allow vendor_demo vendor_demo_device:chr_file { open read write ioctl }; allow vendor_demo self:capability { sys_admin };改完SELinux策略文件后,必须重新编译vendor.img或sepolicy相关分区。如果你偷懒直接改了根文件系统里的sepolicy再打包,很可能连系统都起不来。SDK里一般有make sepolicy或者全量make都会把这个处理掉。
4.3 排障时必看的一行log
SELinux拦截日志非常好认:
avc: denied { open } for pid=1234 comm="demotool" name="demo_chardev" dev="tmpfs" ino=456 scontext=u:r:shell:s0 tcontext=u:object_r:vendor_demo_device:s0 tclass=chr_file permissive=0看到这种log,别怀疑驱动,直接在.te文件里补规则。很多新人以为SELinux是"内核崩溃级别的错误",其实它就是静默拒绝,一不报错二不死机,只有log里能看到线索。我习惯在adb shell dmesg | grep avc和adb logcat -b events | grep avc两边同时搜,确保不遗漏。
5. RK3588平台上的性能与稳定性:不仅仅是"能跑就行"
5.1 dma_alloc_coherent与cache一致性
字符设备驱动如果只是读写几个寄存器,性能要求不高;但如果要传数据流——比如摄像头、网络包处理外设,不可避免要用DMA。Android12内核默认开启了CONFIG_ARM64_DMA_IOMMU,RK3588的IOMMU默认也工作正常,但你的驱动里申请DMA内存时要注意使用正确API。
下面是我常用的DMA缓冲区申请方式:
#include <linux/dma-mapping.h> struct demo_dev { dma_addr_t dma_handle; void *dma_buf; size_t dma_size; }; /* 驱动probe里 */ dev->dma_size = SZ_1M; dev->dma_buf = dma_alloc_coherent(&pdev->dev, dev->dma_size, &dev->dma_handle, GFP_KERNEL); if (!dev->dma_buf) return -ENOMEM;用dma_alloc_coherent申请的内存是已配置好cache一致性属性的,内核保证CPU视角和设备视角都能看到一致的数据。如果你用kmalloc分配内存再让外设做DMA,很可能出现设备看得到数据但CPU读不到的情况,这就是典型的cache一致性问题。RK3588是四核A76+四核A55的架构,多核并发访问外设共享内存时,这个问题更容易被放大。
5.2 中断底半部:Tasklet还是Workqueue
RK3588的外设中断有些是电平触发,有些是边沿触发。写中断处理时我一般遵循最基本原则:中断上半部只做最少的事,比如清中断标志、读状态寄存器;耗时操作全部交给底半部。
如果底半部需要访问可能睡眠的资源(比如i2c传输、msleep),用workqueue;如果没有睡眠需求、但要尽早执行,用tasklet。实际项目中我遇到过中断里直接调i2c_transfer导致系统watchdog复位的惨案,后来全部改成延迟work才解决。
static irqreturn_t demo_dev_irq_handler(int irq, void *data) { struct demo_dev *dev = data; /* 清中断标志 */ schedule_work(&dev->irq_work); return IRQ_HANDLED; } static void demo_dev_irq_work(struct work_struct *work) { struct demo_dev *dev = container_of(work, struct demo_dev, irq_work); /* 真正的中断后处理 */ }5.3 bind_lock竞争:RK3588多核场景容易踩的坑
多核平台上,如果驱动的probe、remove、open、release每次都去操作同一个全局设备链表,务必加锁。比如有一版驱动我图省事,用了一个全局变量记录设备状态,probe里写、某个sysfs接口里读,结果在A55核心密集访问时出现了状态不一致,调试了很久才发现是缺了mutex或spinlock保护。
建议在probe里初始化一把互斥锁,驱动里所有可变状态访问都在锁内完成:
mutex_init(&dev->lock); /* ... */ mutex_lock(&dev->lock); /* 共享状态操作 */ mutex_unlock(&dev->lock);锁粒度别太粗,不然中断回调里也拿这把锁,容易死锁。中断上下文只能用spinlock,千万别在中断里调用mutex——这是我被内核lockdep狠狠教训过的一课。
6. 总结我的工作流:一套可复制的RK3588 Android字符驱动开发流程
我写驱动时基本走固定套路,这里给出一份可直接抄作业的checklist:
- 确认需求:读外设数据、写寄存器、收发消息、中断通知?决定用miscdevice还是完整cdev。
- 写设备树节点:
compatible、reg、interrupts、pinctrl,清掉dts编译告警。 - 写驱动骨架:
file_operations、probe、remove,先用printk打印关键路径。 - 编入内核:Kconfig选
y(务必),编译boot.img,烧录,验证probe触发。 - 裸测用户态:用C程序或shell直接读写节点,确认数据通路正常。
- 处理SELinux:补te规则,确保App和HAL进程能访问设备节点。
- 加业务逻辑:中断、DMA、多核锁,同时做好异常路径处理。
- 在Android上层封装HAL接口,做压力测试和稳定性验证。
这套流程在RK3588 Android12的多个不同外设项目里都验证过,从最简单的GPIO按键到稍微复杂一点的PCIe数据通道,都能撑住。过程中踩坑最多的是设备树和SELinux两个环节,但只要按上面的顺序走,出了问题也能快速定位。
最后分享一个小技巧:保持设备节点的名称高度可猜测。比如我做USB转串口芯片的驱动,节点就叫/dev/ttyUSBX;做寄存器测试设备就叫/dev/regtool。这样HAL和应用层代码写起来不会因为设备名不一致而踩坑,也方便调试时一眼看懂当前操作的是哪个设备。测试阶段我还会在驱动里顺手加几个debugfs接口,用来读寄存器、查状态,调试效率比反复改代码重新编译高得多。