做RK3588加Android 12这套方案的这些日子,我被问到最多的问题反而不是屏幕怎么点亮、摄像头怎么出图,而是这句——“我的App为什么打不开串口?” 问题的根源基本都指向设备权限。RK3588作为Rockchip的旗舰级SoC,跑在它上面的Android 12默认把硬件设备节点的权限收得很紧:串口默认只给root和shell用,GPIO、I2C、SPI这类总线节点更是普通应用连看一眼都会被SELinux拦下来。做AIoT盒子、工控面板、边缘计算网关的朋友,几乎都会遇到要把节点权限放给自家App的场景。这篇文章就系统讲清楚这件事:设备权限到底由谁管、在哪里改、改完怎么验证,以及我在真机上踩过的那些坑。
1. 项目概述:RK3588上“设备权限”到底指什么
1.1 RK3588与Android 12组合的典型应用场景
先讲清楚这套平台本身。RK3588是Rockchip当前的旗舰级SoC,8nm工艺,四颗Cortex-A76大核加四颗Cortex-A55小核,最高主频2.4GHz,自带6 TOPS算力的NPU。它跟Android 12的组合,在市面上几乎成了高性能AIoT设备的“标准答案”:边缘AI盒子、人脸识别闸机、工业HMI一体机、商显广告屏、智能座舱娱乐主机,用的全是这套方案。
这类设备有个共同特征——应用层不只是做界面,它要直接跟硬件打交道。典型需求包括:
- 通过UART串口跟下位机(PLC、控制器、传感器板)通信;
- 通过GPIO控制继电器、指示灯、门锁;
- 通过I2C读取温湿度传感器、触摸芯片寄存器;
- 通过SPI连接RC522读卡器、LCD屏甚至FPGA;
- 插上USB转串口工具、USB摄像头、USB打印机。
这些设备在Linux内核里都对应一个或多个/dev节点。Android应用要访问这些节点,表面上只需要拿到文件读写权限,但实际机制远比想象中复杂,权限链条至少有三层。
1.2 设备权限的三个层次:节点权限、SELinux策略、应用权限
我在日常沟通中发现,很多开发兄弟把“设备权限”理解成Android里运行时权限(就是跳对话框请求的那种),这个是两回事。在RK3588的BSP层,设备权限由三个互不相同的机制共同决定:
| 层次 | 机制 | 负责内容 | 典型配置文件 |
|---|---|---|---|
| 第一层 | 内核VFS文件权限 | /dev下节点的owner/group和mode | ueventd.rc、init.rc |
| 第二层 | SELinux强制访问控制 | 即使rw权限对了,SELinux不允许照样拒绝 | sepolicy目录下的.te、file_contexts |
| 第三层 | Android框架权限 | App清单里声明的权限,以及系统服务层的校验 | AndroidManifest.xml、权限配置文件 |
三层是串联关系,任何一层没通过,最终open()都会失败。大部分“权限改了没用”的情况,都是因为只改了第一层,第二层SELinux还在拦截;或者反过来只改了SELinux,但节点owner和mode本身就不对。
1.3 权限修改失败的典型表现
我在协助定位问题时,通常会先问一句:你看到的具体报错是什么?这能快速缩小问题范围。
- open()返回Permission denied,且dmesg里没有avc记录——大概率是第一层节点权限问题,当前用户对节点没有读写权限;
- open()返回Permission denied,同时dmesg里有“avc: denied”日志——这是第二层SELinux拦截;
- open()返回No such file or directory——节点压根没创建出来,这不是权限问题,是驱动没加载或者ueventd没处理;
- App运行时直接抛SecurityException——这是第三层Android框架权限,多发生在使用系统API而非直接open/dev节点时。
记住这个分类,后面所有排查都会围绕它展开。
2. 核心原理:节点、ueventd、SELinux如何共同决定设备访问权
2.1 设备节点如何在Android启动时生成
设备节点不是人手动创建的,整个流程是自动化的。Linux内核加载驱动程序时,驱动探测到硬件,调用device_create之类的接口向内核注册设备,同时发出一条uevent内核事件。Android的init进程里的ueventd组件接收到这个事件,根据ueventd.rc配置文件里预先写好的规则,在/dev目录下创建节点,并设置owner、group和mode。
整个过程可以类比成这样:内核说“我造了一台设备,编号ttyS3”,ueventd这个“物业管家”根据手上的花名册去办理登记,花名册上写着这个设备的门牌号、房主和门禁级别。如果花名册上没有这个设备,ueventd就按默认规则给它一个相对保守的权限——通常就是root:root 0600,普通应用根本进不去。
所以第一层权限的修改落点非常明确:改”花名册“,也就是ueventd配置文件。在Rockchip的Android 12 SDK里,这个文件就是device/rockchip/common/ueventd.rockchip.rc。系统还有一个通用的ueventd.rc,位于system/core/rootdir目录,Rockchip的方案会把平台特有规则追加到rockchip后缀文件里,两个文件都会被加载。
2.2 SELinux在权限链中的位置
如果说VFS文件权限是“门禁”,那SELinux就是“楼道里的第二道安检”。门禁放你进了楼,安检不认你,你还是进不了那扇门。SELinux工作在内核的LSM(Linux安全模块)层,所有open、read、write、ioctl、mmap等系统调用在真正执行前,都要经过安全策略的允许判断。
Android从5.0开始全面强制SELinux,RK3588的Android 12自然是enforcing模式。这意味着即使你把/dev/ttyS3设成了0666,普通App直接open还是会失败,因为SELinux的avc规则里,应用进程域(如untrusted_app域)默认对串口设备没有访问权限,打不开就是打不开。
SELinux的判定逻辑用一句话概括:主体(进程域)对客体(设备类型,即设备节点上的安全标签)执行某个操作类,是否被allow规则允许。缺一条allow,就会有一条avc denied日志。这也是嵌入式Android开发里SELinux最让人头疼的地方——规则不透明,报错全靠翻dmesg。
2.3 Android应用权限如何落到设备访问
第三层相对独立,它管的是App通过Android框架层API去访问硬件的情形。比如App调用UsbManager去拿USB设备权限,或者调用SerialPort相关系统服务。这种情况下,Android框架会先检查App的包名和权限声明,通过后才把文件描述符交给你。
但对大多数BSP层面的场景,App其实是直接用JNI或反射去open /dev节点,绕过框架层,这时候第三层基本不参与,真正卡人的就是前两层。这也是为什么排查时先跑adb shell,用shell用户直接去访问节点,能快速区分是系统层问题还是App层问题。
这三层的关系理清楚后,才能理解后面的每种修改路径为什么这么写。
3. 实操路径一:通过ueventd配置修改设备节点权限
3.1 定位SDK中的ueventd配置文件
拿到一套RK3588的Android 12 SDK,先别着急动手,确认好要改的文件路径。以常见SDK结构为例:
# 平台通用配置 device/rockchip/common/ueventd.rockchip.rc # 板级配置(部分方案会以具体板子命名) device/rockchip/rk3588/ueventd.rockchip.rc有些定制率高的方案会单独建目录,但ueventd.rockchip.rc这个文件名基本不变。还有个细节值得注意:设备的on boot阶段,init可能还会执行init.rc里的chmod/chown命令,这会覆盖ueventd设置的权限。所以排查时如果发现ueventd改了没生效,记得去init.rc和init.rockchip.rc里查一下有没有后续的chmod。
3.2 配置语法与关键参数
ueventd.rc的语法非常简单,每行一条规则:
设备路径 权限模式 owner group举个例子,放开UART串口:
# 把ttyS3给root/system组,权限0660 /dev/ttyS3 0660 root system # 开发期图省事直接全开放 /dev/ttyS3 0666 root root这里有几个参数选择上的门道:
权限mode建议先用0660配合group控制,不要一上来就0666。量产设备如果让任意App都能读写串口,等于把你的控制总线暴露给所有应用,安全性很成问题。0666只能在开发调试阶段临时用,交付前务必要收紧。
owner和group怎么选,取决于你的App是什么身份。Android的普通App是以自己的uid运行,不在system、root这些系统级gid里,所以即使节点是0660 root:system,普通App也无权访问。常见的处理思路有两种:
- 把节点的group设为App所属的android组(比如AID_INET、AID_SDCARD_RW等),再把App进程加入对应gid,操作繁琐且脆弱;
- 更实际的做法是让App以system权限运行——通过签名或sharedUserId设置,让进程uid归入system组,再把节点设为0660 root:system;
- 开发期想快速验证,直接把mode设为0666,一了百了,后面再收紧。
我一般会这样建议:先用0666验证功能逻辑,功能通之后改回0660,配合SELinux规则精准放行,两不耽误。
3.3 验证修改是否生效
配置文件改好之后,要编译并烧写对应的分区镜像。在Rockchip Android 12 SDK里:
source build/envsetup.sh lunch rk3588-userdebug # ueventd.rockchip.rc被打进boot分区的ramdisk或odm/vendor分区,具体看方案 make bootimage -j32 # 或者直接整包 make -j32烧录后验证命令很直接:
adb root adb shell ls -l /dev/ttyS3预期输出类似:
crw-rw-rw- 1 root root 4 67 2025-01-01 10:00 /dev/ttyS3当你在控制台看到crw开头,后面权限位已经是目标值,第一层就算改通了。这时候App再去open还是可能失败,那就该排查SELinux了。
4. 实操路径二:SELinux策略编写与编译
4.1 新建设备类型还是沿用已有类型
SELinux策略不是随便写几行allow就完事,第一步要决定设备节点挂什么标签。RK3588的Android 12 SDK里,sepolicy相关目录主要是:
device/rockchip/common/sepolicy/这个目录下通常有plat、non_plat、vendor等子目录,分别对应不同分区。改动原则是:能不碰平台公共文件就不碰,优先在vendor目录下新增自己模块的.te和file_contexts条目。
对于设备节点的SELinux类型,最常见的做法是新建一个类型,不要直接给节点打上系统已有类型的标签。原因很简单:系统类型背后已经关联了大量allow规则,你给设备乱打标签,可能误开放一堆权限,也可能因为某些进程对该类型有特殊限制而踩到隐藏的坑。新建类型,规则完全自己控制,干净可控。
4.2 编写te文件和file_contexts
假设要给App放行UART3串口,节点是/dev/ttyS3。我先在vendor sepolicy目录下新建一个te文件,内容如下:
# device/rockchip/common/sepolicy/vendor/my_serial.te # 声明一个新的设备类型 type my_serial_dev, dev_type;然后把这个设备节点关联到新类型,编辑file_contexts:
# device/rockchip/common/sepolicy/vendor/file_contexts /dev/ttyS3 u:object_r:my_serial_dev:s0之后要给访问方进程域加allow规则。通常我们放行的是所有App域,最稳妥的写法是用appdomain这个属性,它在Android里概括了所有应用进程:
# 放行所有应用对my_serial_dev节点的打开、读写、IOCTL allow appdomain my_serial_dev:chr_file rw_file_perms; allow appdomain my_serial_dev:chr_file ioctl;rw_file_perms是Android sepolicy里预定义的宏,包含open、read、write、getattr、setattr等。如果串口需要配置波特率、流控,ioctl是必不可少的,不加会踩到奇奇怪怪的失败。
还有一类情况:你的App本身就是系统应用(预置App,声明了sharedUserId为android.uid.system),那进程域可能是system_app,需要额外加:
allow system_app my_serial_dev:chr_file rw_file_perms; allow system_app my_serial_dev:chr_file ioctl;我经常碰到只加了appdomain没加system_app的情况,预置App稳如老狗地报权限错误,翻dmesg才发现没给system_app放行。教训就是:把所有可能访问该节点的进程域都列一遍。
4.3 编译打包与烧录
SELinux规则修改后,编译目标根据Android版本的SEPolicy分区方式而定。Android 12通常会有独立的vendor sepolicy的cil文件编译阶段,实际编译命令:
make vendorbootimage -j32 # 或 make bootimage -j32如果是老一点的SDK,需要make -j32整包编译,因为sepolicy相关的打包分散在system/vendor/odm等多个分区。烧录后先做两个基础确认:
adb root # 确认SELinux处于enforcing adb shell getenforce # 确认设备节点标签已更新 adb shell ls -Z /dev/ttyS3ls -Z输出里的安全上下文应该是u:object_r:my_serial_dev:s0,而不是默认的u:object_r:device:s0。标签没变,就要回头检查file_contexts是否真的打进了镜像,或者节点创建路径是否和配置一致。
5. 实操路径三:init脚本与补充手段
5.1 使用init.rc做chown/chmod
除了ueventd,init.rc是另一个常见的权限修改入口。它适合做两类事:一是ueventd不便处理的动态权限需求,二是对已有节点做二次权限调整。在init.rc或板级init.rockchip.rc里,可以在on boot或者on property触发的section里加:
on boot chown system system /dev/ttyS3 chmod 0660 /dev/ttyS3需要注意,init.rc的chown/chmod执行时机是在对应事件触发时,如果设备节点当时还没创建,命令会静默失败。为避免这种竞态,可以改成监听节点创建事件:
on device-added /dev/ttyS3 chown system system /dev/ttyS3 chmod 0660 /dev/ttyS3device-added事件在Android的init语法里是支持的,当udev类似事件发生时触发。这个写法在实际项目中比死板的on boot靠谱很多,尤其是USB设备热插拔场景。
5.2 临时关闭SELinux的“开发期方案”
还有一个几乎所有做BSP的开发兄弟都用过的招:setenforce 0。把SELinux切成permissive模式,只告警不拦截,立刻能验证到底是节点权限问题还是SELinux问题。
adb root adb shell setenforce 0 adb shell getenforce特别提醒:setenforce在重启后失效,而且它只能算定位手段,不是交付方案。量产设备如果直接permissive,等于把你整个设备的安全防线拆了,发生问题连审计日志都不可靠。我见过一个项目,开发阶段项目组图省事写了一整套permissive配置交付,客户做安全测试时一顿爆出漏洞,最后项目组连续加班好几周补规则,教训太深刻。
正确的开发流程应该是:遇到avc denial,记录日志,再写规则,再验证,用setenforce 0只作为一个快捷验证开关。
5.3 三种路径的选择策略
结合经验,我把三条路径的适用场景整理成一张表:
| 路径 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|
| ueventd.rc | 固定设备节点、系统固有硬件 | 稳定性高,开机即生效 | 只解决VFS权限,管不到SELinux |
| sepolicy | 任何需要放行进程访问节点的情况 | 精细控制,安全可控 | 规则编写和调试成本高 |
| init.rc chmod/chown | 动态节点、已有驱动、临时调整 | 灵活,可基于事件触发 | 依赖加载顺序,易踩竞态 |
实际项目里有超过一半的情况是“ueventd+sepolicy”组合拳:ueventd先把节点权限放大到0660/0666,sepolicy再把具体进程放行。init.rc作为补充手段,处理那些ueventd规则覆盖不到的场景。
6. 完整案例:让第三方App访问RK3588的UART3串口
6.1 需求与前置检查
拿一个真实需求走一遍完整流程。假设要做一台工控主机,内置了一个第三方开发的App,需要访问RK3588的UART3串口跟PLC通信。第一步先确认硬件和系统现状:
# 确认节点存在 adb shell ls -l /dev/ttyS* # 看串口设备标签和当前权限 adb shell ls -Z /dev/ttyS3 # 查看内核是否识别到UART3 adb shell dmesg | grep -i ttyS3正常情况下能看见:
/dev/ttyS3 c 4 67 root root u:object_r:device:s0权限是0600,标签是device,App直接open肯定失败。定方案:VFS层用ueventd把权限放开给system组,SELinux层用自定义类型放行App域。
6.2 修改ueventd配置
编辑device/rockchip/common/ueventd.rockchip.rc,追加:
/dev/ttyS3 0660 root system如果第三方App拿不到system gid,开发期可以改成:
/dev/ttyS3 0666 root root我在项目里通常是两种情况分别出两版配置,开发版0666、发布版0660,方便切换。
6.3 添加SELinux规则
在sepolicy的vendor目录下新建文件,我这里先明确一点:RK3588 Android 12的SDK在sepolicy组织上可能有细微差异,但.te语法和file_contexts语法是AOSP通用的。
新建设备类型和规则:
# vendor/my_serial.te type my_serial_dev, dev_type; allow appdomain my_serial_dev:chr_file rw_file_perms; allow appdomain my_serial_dev:chr_file ioctl;file_contexts追加:
/dev/ttyS3 u:object_r:my_serial_dev:s0;这里我故意没加system_app的allow,因为第三方App是普通签名应用,进程域就是appdomain。
6.4 编译并验证
source build/envsetup.sh lunch rk3588-userdebug make bootimage -j32烧录后分四步验证:
# 1. 确认权限位 adb shell ls -l /dev/ttyS3 # 期望: crw-rw-r-- 1 root system ... # 2. 确认SELinux标签 adb shell ls -Z /dev/ttyS3 # 期望: u:object_r:my_serial_dev:s0 # 3. 确认没有新的avc拒绝 adb shell dmesg | grep "avc: denied" | tail -20 # 4. shell下直接读写测试 adb shell su echo "hello" > /dev/ttyS3 cat /dev/ttyS3 &四步全过,再把接好串口线的宿主机端打开,两边收发数据正常,第三方App就能顺利跑通了。开发期如果第3步刷出一堆avc,别急着改规则,先看看拒绝的是不是新类型节点,是的话按日志里的tcontext和tclass补allow规则就对了。
7. 常见问题与排查技巧实录
7.1 节点存在但权限不对
现象:ls -l看到的权限跟你改的不一致,或者一直停留在root:root 0600。
这种我第一反应是检查配置有没有打进去。排查顺序:
- 确认修改的文件是不是当前生效的那份ueventd配置。有的方案在设备树里重定向了ueventd,实际加载的是odm分区里的文件;
- 确认编译烧录的镜像没有被旧缓存覆盖。make bootimage前先make clean或删除out目录下对应中间文件,必要时lunch后再重新source环境;
- 检查init.rc后续是否又chmod覆盖了。全局搜一下chmod /dev/ttyS3或chown相关命令。
还有一个容易被忽略的:设备节点对应的驱动如果支持动态创建和销毁(比如USB转串口),ueventd会在设备插拔时重新按规则设置权限。这时你手动chmod改过,一旦重新插拔就恢复原样。量产时如果被这个问题坑了,说明驱动或ueventd没走到你预期的规则分支。
7.2 SELinux avc denied刷个不停
现象:权限位完美,SELinux也是enforcing,App就是打不开,dmesg里全是avc。
先看一条典型日志:
avc: denied { open } for pid=1234 comm="myapp" name="ttyS3" dev="tmpfs" ino=123 scontext=u:r:untrusted_app:s0 tcontext=u:object_r:my_serial_dev:s0 tclass=chr_file permissive=0解读三个关键段:scontext是被拒绝的进程域,tcontext是设备节点标签,tclass是操作类型。对着你的allow规则核对是不是落了pair。比如你写了allow appdomain,但日志里scontext是untrusted_app,那是同一回事,appdomain是个attribute,包含untrusted_app。如果scontext是system_app,说明进程身份是系统应用,得补system_app的allow。
avc日志定位法是我做SELinux调试的主力,几乎80%的问题都能靠dmesg里的avc日志直接定位原因。还有一个技巧是开启neverallow调试模式,但一般用不上。
7.3 设备节点完全没有创建
现象:/dev下连节点都找不到,App报No such file or directory。
这是常见的误判“权限问题”的情况。优先查内核日志:
adb shell dmesg | grep -i "ttyS3\|serial"如果驱动probe失败,比如设备树里uart3的status没配成okay,或者引脚mux冲突,节点根本不会注册。这种情况下改多少ueventd和sepolicy都白搭。
如果是USB设备,检查有没有挂上对应的USB驱动。RK3588上常见的CH340、CP210x、FT232这类USB转串口芯片,有些方案默认没编对应驱动,需要在内核配置里打开。
7.4 应用层拿到的权限还是不够
现象:shell下测试一切正常,App一跑就失败。
这种通常是上面提到的进程身份差异。shell下你用root执行命令,SELinux的scontext是su或shell域,权限规则自然不一样。App的scontext是untrusted_app或system_app,规则没覆盖到就失败。
还有一个容易被忽视的点是App的安装位置。预置在system分区和安装在data分区的App,在Android 12里进程域可能不同(platform app和普通app)。解决方案很明确:先明确App最终是以什么签名、什么安装位置、什么uid运行,再针对性地补对应进程域的allow规则。
我整理了一张速查表,方便现场排查:
| 现象 | 首选排查项 | 常用命令 |
|---|---|---|
| open返回Permission denied | dmesg查avc | dmesg | grep "avc: denied" |
| open返回No such file | 驱动是否加载 | dmesg | grep -i 设备名 |
| shell能访问但App不行 | 进程域差异 | ls -Z /proc/进程pid/exe |
| 改完配置重启还原 | 是否烧了正确分区 | 检查boot/vendor_boot镜像 |
| 节点被热插拔后权限还原 | ueventd规则是否覆盖该设备 | 插拔后ls -l复查 |
8. 经验总结:设备权限修改的通用方法论
这套方法不只在RK3588上有效,换成其他芯片平台、其他Android版本,思路完全可以迁移。我在实际项目里最大的体会有三点。
第一,按层排查能让问题缩小十倍。遇到访问失败,先分清楚是VFS层、SELinux层还是框架层,不要上来就一顿乱改。我用过的最高效路径永远是那三板斧:ls -l看权限位、ls -Z看安全标签、dmesg看avc日志。三步走完,问题几乎都能定位。
第二,开发期宽松、量产期收紧,是唯一的正路。我见过太多项目因为开发期图省事,把所有权限一刀切全放开,交付后客户做合规测试时翻车,回头补规则比一开始就规范设计痛苦得多。技术债在这里是真实存在的。
第三,动态节点永远比固定节点麻烦。USB热插拔设备、动态创建的内核设备,它们的权限管理和烧录一次就固定的内置UART完全是两个难度。做方案设计时能选固定节点就不要引入动态节点,实在避不开,就一定要利用好device-added这类事件机制。
最后分享一个小技巧:调试设备权限时,我习惯先在板子上放一个测试用的native小程序,直接调open/read/write,把返回值和errno打出来。这比反复用App测试要快得多,因为App层还隔着一层Java异常和可能吞掉的系统错误,根本看不出真实原因。内核返回的errno才是真相——EACCES是权限不够,ENOENT是节点没有,ENOTTY多半是ioctl参数或驱动不匹配。有了这个工具,你能在半小时内解决别人折腾一天的问题。
RK3588和Android 12的组合在嵌入式领域还会存在相当长一段时间,把设备权限这套机制吃透,基本上就能应对从串口、GPIO到I2C传感器、USB外设的绝大多数接入需求。开发之路多踩坑,但只要把每一层的机理理清楚,每个坑都能变成可复用的经验。