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

Android属性服务完全解析:从init启动到权限校验与调试实战

Android属性服务完全解析:从init启动到权限校验与调试实战 ★ FEATURED ARTICLE
在我接触Android系统底层的那几年里如果要挑一个“无处不在却又经常被人忽略”的系统组件那我一定选property service。上层应用开发几乎感觉不到它的存在但系统里每一个进程、每一段行为逻辑从开机动画的启停、网络栈的启动时机到log开关的切换、虚拟机的参数配置背后统统离不开这一套全局键值对服务。这篇文章我打算把这套机制从设计思路到代码实现完整拆开讲清楚它为什么长成现在这个样子、一次属性请求在系统内部到底走了哪些路、以及我们调试和排障时该如何利用它。先说个大概。属性服务property service由Android的init进程在启动早期拉起并常驻运行它维护了一个全局统一的线程安全字典所有系统进程都可通过这套接口读取、设置和监听键值对属性的变化。它的存在让系统各组件之间不需要显式建立连接就能共享配置和运行状态。换句话说它承担的是“系统全局状态中枢”的角色。这篇文章适合系统开发、BSP工程师、框架层开发以及对Android底层机制感兴趣的读者参考。我会带着你从源码视角看这套机制的设计逻辑也会穿插我实际调试中踩过的坑和处理思路。1. 属性服务在Android系统里的真实地位一个开机就要运行的全局字典1.1 从init进程拉起的第一个“系统能力”Android系统上电之后内核起来紧接着就是init进程。init进程的main函数里几乎在早期阶段就调用了property_init随后启动property service相关的socket监听。这一步做完系统才有能力对外提供属性读写。也就是说属性服务是整个Android用户空间的“元服务”其他服务还没开始启动它已经把基础设施铺好了。我最初读这段代码的时候有个疑问为什么系统不干脆用一个普通的配置文件或者环境变量来干这事后来我总结出几个关键原因。第一环境变量是进程级的没法做到系统级的全局统一。每个进程启动时继承的环境变量来自父进程子进程改了不影响兄弟进程这不满足Android系统组件间共享状态的需求。第二普通配置文件缺少统一的、带权限控制的动态修改能力。文件能随便改但没法做到“改一个值立刻通知一堆进程”也没法在写入时按SELinux规则做精细管控。第三Android系统启动过程高度动态很多决策依赖运行时的即时状态。比如系统启动到哪个阶段了、当前是不是安全模式、某个硬件是否就绪这些信息无法在编译期写死必须在运行期由一个可信的中心节点来维护。于是property service就承担起了这个“系统级全局字段”的职责。它以init进程为宿主通过socket和共享内存对外提供服务。任何进程都能读取但只有具备权限的调用方才能写入。1.2 属性名命名规律你从属性名就能反推谁是写入者Android的属性名采用了点分命名空间的设计类似DNS域名结构从前往后逐级细化。最常见的几个前缀是ro.只读属性一旦设置便不可更改只能由init在启动阶段写入persist.持久化属性修改后会同步写入数据分区重启仍能保留sys.系统运行时状态多由各个系统服务写入反映当前运行情况ctl.控制属性比较特殊写入后会触发init执行对应的动作service.与init管理的服务状态相关vendor.厂商分区自定义属性用来隔离厂商与系统框架的命名空间这套命名规则是Android在长期演进里形成的约定。我看到过不少新人工程师写属性时随意命名结果到了CTS兼容性测试阶段就出问题因为部分测试项会检查属性的命名空间是否符合规范。1.3 属性服务要同时回答的三个基本问题属性服务看起来只是“存和取”但实际对外要提供的能力可以拆成三类。第一是查询能力也就是property_get。任何进程拿到一个属性名能得到对应的字符串值。这是最常用的接口系统服务拿它读取运行参数上层应用也可以通过反射拿到部分系统属性。第二是设置能力也就是property_set。写入方把属性名和值交给initinit经过权限校验后写入共享内存。这条链路的关键在于权限校验不是谁想写就能写的。第三是订阅能力也就是属性变化通知。进程可以对某个属性注册监听一旦它发生变化就会收到回调。比如网络栈会监听sys.boot_completed知道自己该什么时候执行后续初始化逻辑。这三种能力叠加起来就形成了一套“读写 通知”的完整状态管理模型。很多系统服务之间不需要直接通信而是通过属性来解耦这也是Android组件化设计的核心思想之一。1.4 它和普通全局变量的本质区别有人可能会说这不就是全局变量吗从语义上看确实像但从工程实现上它是一个跨进程的、带权限控制的、可以持久化的、具备变更通知能力的全局变量。普通全局变量只活在单个进程内属性服务的存储却位于共享内存中所有进程都能在自己的地址空间里映射同一块物理内存。普通全局变量改了就改了没人知道属性服务却能通过socket通知机制把变更事件广播出去。普通全局变量没有权限概念但属性服务每次写操作都要经过uid校验和SELinux安全检查。所以它更像一个“操作系统级别的内核变量”只不过在用户空间实现了。2. 一次property_set调用的完整旅程socket链路里的每个中转站2.1 为什么设计基于socket而不是Binder历史原因和现实考量Android的进程间通信头牌是Binder那属性服务为什么早期一直用的socket原因很简单属性服务必须在Binder还没准备好的时候就能工作。Binder驱动虽然在内核里但servicemanager进程的启动、上下文管理器的注册都是在init走到比较后面的阶段才完成的。而属性服务在init一启动就要可用甚至要为启动阶段的很多基础模块提供配置查询所以它选择了一条更底层的路径Unix domain socket。这也是经典的分层思想——基础服务不依赖上层服务只依赖内核提供的原始IPC能力。不过这套socket机制也不是原封不动用到了今天。Android 9开始属性服务引入了Binder化的调用方式上层进程通过Binder去访问属性服务而不是直接连socket。但底层核心逻辑依然由init内的property_service模块完成只是入口路径增加了Binder这一条。目前同时保留并存的还有sock传染的机制。2.2 调用入口property_set的调用栈拆解从Native层代码来看一次属性设置的调用链路大致是libc库的property_set函数上抛到libcutils的property_set接口libcutils通过property_service的客户端模块构造一个property_set请求客户端与init建立socket连接或者在新版本走Binder把属性名和值发过去init进程内的property_service模块收到请求进入权限校验逻辑校验通过后将新值写入共享内存的属性区如果属性名带有persist前缀同时触发持久化写文件操作init进程通知监听者通过socket广播或者状态机回调属性发生了变化每一步都有独立的职责边界一个看似简单的写操作实际在系统内部经历了一次完整的“请求-校验-存储-广播”生命周期。2.3 属性设置的阻塞与非阻塞语义在实际开发中我们经常遇到一个疑惑property_set返回之后值立即可见吗答案是不一定的。property_set本身只是把请求发出去了真正写入共享内存的操作发生在init进程里。init在处理属性请求时是串行的——每次处理一个如果前一个请求还在执行后面就要排队等待。但在同一个进程里我们调用property_set之后再去property_get同一个属性通常能拿到新值这是因为libc层的接口本身也维护了本进程的缓存副本一次更新就能让同进程立刻可见。跨进程立即见则需要满足条件写入请求已经到达init并完成内存更新。Binder调用是同步的Binder调用返回后基本可以认为写入完成socket路径则需要等到请求被服务端处理。我在早期调试一个系统服务时就遇到过“这边set完那边get到的还是旧值”的怪异问题后来发现是写入走的socket通路存在异步性处理线程还没竞争到CPU。如果你在自研代码里依赖这种即时性建议用Binder接口或者显式加同步确认而不是赌时序。2.4 开机阶段的特殊通道第一条属性是谁写入的开机阶段init首先要设置的属性包括ro.serialno、ro.bootmode、ro.bootloader等这些值来自内核cmdline和bootloader传递的参数。init在启动早期会解析这些信息并写入属性区。随后init开始解析并执行init.rc中的各种action。rc文件中常见的setprop命令就是在告诉init把某某属性设成某某值。这个阶段属性服务已经处于“可写”状态但很多系统服务还没有启动所以它服务的对象基本是init自身以及早期执行的模块。这个阶段的代码路径清晰、可控但也是问题高发区——很多厂商定制开机动画、加密逻辑、网络策略都喜欢在这一阶段插入setprop很容易造成属性冲突或时序冒险。3. 藏在共享内存里的数据结构property_area的一次完整解析3.1 把“键值表”直接映射到内存零拷备读取方案早期Android系统里属性服务的存储是一个内存文件在init启动时通过mmap映射到共享内存中。所有进程在init的早期代码里也会把这个文件映射到自己进程的地址空间。这样设计最直接的好处是读取属性不需要跨进程通信。一个进程要想查一个属性值直接在自己进程内访问共享内存映射区找到对应的属性节点读取字符串就行。避免了每次读属性都触发一次IPC的延迟。你能感觉到这个设计有多么激进——一个系统里被调用次数最多的查询操作做到了几乎零开销。3.2 存储节点的字段布局在早期AOSP源码中每个属性节点是一条记录主要包含属性名长度信息属性名内容属性值内容关联的上下文标签信息用于SELinux判断后续版本引入一种序列化存储格式把所有属性节点一个接一个缩排在连续的内存区域里。这样整个属性区就是一块规整的二进制块新属性通过追加方式写入查找通过线性遍历属性名来实现。为了加快查找系统采用了一种类似哈希索引的手段根据属性名字符串计算一个索引位置把同哈希值的节点串成链表查询时只需要比较少量节点即可。这种“连续存储 哈希索引”的组合在内存占用和查询效率之间取得了不错的平衡。3.3 并发读写单写多读模型的工程意义属性区的并发模型非常值得一说。它的核心是“单写多读”——只有init进程在写入其他进程都在阅读。读进程不需要加锁共享内存里的字符串更新是原子性可控的因为写入者会把值作为一个整体写入。这个整体操作在同一个写入线程中串行执行不会出现“读到半个字符串”的情况。写进程是唯一的所以不需要竞争锁。整个属性区的写操作都被一个互斥锁串行化但读操作连锁都不用碰。这种简洁的设计极大降低了锁竞争开销也让整个系统的属性读取性能变得非常高。3.4 空间不足属性区满了会发生什么属性区大小在系统启动时固定Android早期版本默认约几百KB。遇到属性写满的情况时init会打日志并拒绝新的写入。高版本Android引入了属性空间扩展机制允许在需要时追加新的内存分页。实际开发中厂商如果大量往系统里塞自定义属性很容易耗尽空间。一些系统服务在启动时发现写不进属性就直接崩溃或者行为异常排查起来非常隐蔽。我处理过类似问题最后是通过统计property区已用量和节点数才定位到根因。所以写厂商代码时一定要有属性数量意识不是什么东西都往属性服务里塞。临时状态可以放文件或数据库只有需要跨进程共享、且对性能和实时性有要求的场景才值得放进属性区。4. 权限校验链路不是谁想改就能改4.1 传统uid校验白名单的粗粒度控制在SELinux还没全面加持的早期Android版本里属性服务的写权限控制主要靠一份属性权限映射表。表中定义了哪些uid可以写哪些前缀的属性。这份表是编译期写死的逻辑就是“发起请求的进程uid是否落在允许范围内”。这套机制简单粗暴但粒度较粗。一个带root权限的进程基本可以写所有属性一个普通应用uid则没有写权限。它无法做到“同一个进程对不同属性拥有不同权限”的灵活控制。4.2 SELinux接管后权限细化到了“进程域 属性名”级别Android从5.0开始全面铺开SELinux属性服务的权限校验也逐步切到SELinux模式。每次写属性时init会向内核查询“当前调用进程是否具备对目标属性域的写权限”。SELinux针对属性服务定义了一套专用的属性和策略可以看到类似的规则语句typeproperty用途是给特定属性名打上安全标签再用允许规则约束allow source_domain target_property:property set;通过这种规则系统可以做到只允许某个服务域写某几个特定属性其他一律拒绝。4.3 权限控制相关的属性前缀及其语义细节在我调过的项目里权限规则最密集、踩坑最多的就是ro、persist、ctl这几个前缀。ro开头的属性理论上开机后就不能再改动。如果某个进程试图写入ro属性通常会被直接拒绝。但有个细节init在启动阶段拥有某种“默认写入权”它可以在早期把ro属性初始化好。persist开头的属性值会同步落盘。但落盘路径有专门的控制persist属性只能由具备特定权限的进程写入否则就算你set成功重启后也可能丢失。ctl开头的属性是控制指令不是普通状态值。它通常由系统服务用来让init启动/停止某个服务例如常见的ctl.start、ctl.stop。这类属性的权限控制极其严格写错可能导致服务崩溃或死循环重启。4.4 一次权限拒绝的调试记录SELinux audit日志里的救命线索我在某款商用量产设备上遇到过一个诡异问题系统服务A在启动时尝试设置某个sys属性总是被权限拒绝而其他同前缀的属性又能正常写。第一步是抓取SELinux的avc日志。可以在内核日志中看到类似“avc: denied { set } for propertyxxx scontextu:r:system_server:s0 tcontextu:object_r:vendor_prop:s0”的记录。这说明问题出在system_server域对vendor_prop属性域的set权限缺失。解决方式是检查vendor的property_contexts文件是否正确注册了这条属性以及SELinux策略里是否漏了allow规则。这类问题在自研和移植项目里非常常见。每次遇到权限被拒第一反应不要去看业务逻辑而是先把对应时段的avc denied抓出来基本都能一击命中。5. 属性变化的监听机制从socket广播到回调风暴5.1 传统property_changed广播链路属性服务在写入新值后需要让关心这个属性的进程知道变化发生。早期机制基于socketinit在属性更新完成后把变化事件广播到所有监听的客户端。但这个机制的粒度比较粗它是“广播”而不是“订阅式”的点对点通知。也就是说所有正在监听的进程都会收到消息再由每个进程自己判断变化的是不是自己关注的那个属性。当系统里属性变更非常频繁时这种广播很容易造成无谓的CPU唤醒。5.2 Native层监听代码的常用写法在Native层监听属性变化通常用这样的形式客户端通过property_service注册一组需要订阅的属性名init在属性变更时比对订阅列表把匹配的事件推送给客户端客户端在自己的looper里收到回调再处理具体逻辑在Android框架层Java代码也可以注册PropertyChangeListener来监听系统属性。这部分通过系统服务转发。实际工程里我建议尽量缩小监听范围。曾经有个模块监听了一个高频率变化的属性导致系统频繁唤醒、功耗异常。从日志里看到大量属性变更回调和WakeLock竞争后我们才意识到监听面太宽了。5.3 高频写属性是性能杀手属性写操作本身不是重操作但每个写操作都会触发校验、锁、共享内存更新、持久化判断、监听通知等一连串动作。在高频场景下比如每秒几十次这些开销会被放大。最典型的反面例子是在循环里设置属性来传递状态。正确做法是把状态聚合到内存变量里只有在变化真正有意义时再更新属性或者用其他轻量级IPC方案替代。5.4 属性监听之外的替代何时不要用属性如果你的模块需要频繁交换大段数据属性的字符串容量和更新模式就不适合了。这时候可以考虑用socket、Binder或者共享文件的方式。属性更适合的场景是“低频、小体积、状态类”的传递比如“启动完成”“网络可用”“进入休眠”这样的标志位。6. 实战getprop、setprop与系统调试的搭配玩法6.1 getprop的输出解读信息量全在这张表里Android系统里getprop命令可以不带参数输出全部属性。高版本Android输出顺序基本按名称排序字段间用方括号括起来。这里是一张我整理的分类速查表属性前缀典型例子含义与用途常见问题ro.ro.product.model、ro.build.version.sdk编译期确定的只读信息不可修改改系统分区才会变persist.persist.sys.usb.config持久化用户配置改名/重写需谨慎涉及数据分区sys.sys.boot_completed系统运行阶段标志系统服务依赖它做启动后续动作vendor.vendor.audio.tunnel厂商硬件相关配置注意权限规则和命名空间限制ctl.ctl.start、ctl.stop控制init拉起/停止服务权限严格违规可能触发重启循环这张表我建议收藏调试时对照着用能省很多时间。6.2 开机关键节点属性串读判断系统进度在调试开机问题时一条经典的做法是抓取一组关键属性和它们的变化时间点init.svc.zygotezygote服务当前状态sys.boot_completed系统是否完成启动service.bootanim.exit开机动画是否已退出dev.bootcomplete系统盘是否挂载完成通过观察这些属性的变化时间可以快速定位开机卡顿发生在哪一阶段。比如sys.boot_completed一直不出现说明SystemServer还没走完关键流程bootanim.exit不为空说明开机动画管理有异常。我在一次项目里就靠这组属性把开机时间从40秒优化到了25秒前后对比非常直观。6.3 调试属性时的常用工具打法设备连上adb后调试属性主要靠几条命令getprop [key]查询单个属性值setprop key value设置属性值需要root或者权限watchprops监听属性变化并实时打印适合排殉watchprops这个工具在调试服务联动时非常有用。我曾经在一个显示异常问题里通过watchprops发现某个display相关属性被异常改写顺藤摸瓜找到了违规写入的进程。6.4 属性持久化的隐藏细节persist属性的落盘与恢复persist属性的持久化路径在数据分区的某个专用文件里每次set成功后写入请求会同时触发文件落盘。不过落盘策略是异步的断电或者异常重启可能导致最后一次修改丢失这是个容易被忽略的坑。另外persist属性在系统启动早期会被读回来填充到属性区。假如数据分区的文件损坏可能会导致部分属性缺失引发各种难以排查的异常。我见过一个案例persist.sys.usb.config损坏导致设备无法识别调试端口最后只能通过删除持久化文件恢复默认值来解决。7. 从property service看Android系统设计的核心思路7.1 用全局状态中枢解耦组件间的直接依赖回看整个属性服务它最大的价值并不是“存数据”而是作为系统的状态解耦中枢。各个系统组件不再需要两两建立通信链路只需要跟属性服务打交道。这种“星型拓扑”简化了系统里的协作关系也让系统状态对开发者透明可见。调试时打开getprop等于在看系统当前的“整体健康状况表”。7.2 设计上的取舍与无奈任何设计都有取舍。属性服务为了启动阶段的可用性选择了socket作为基础通信方式为了读取性能选择了共享内存结构为了保证安全选择了多级权限校验。这些选择在当年都是合理的但也留下了扩展性受限、写路径串行等后续问题。后来的版本里Android逐步把属性访问纳入Binder体系也是为了在保持兼容的同时让属性服务的访问方式更统一、更可扩展。7.3 给底层开发者的三点建议把属性服务彻底理解清楚之后我有几个建议想分享给从事底层的开发者。第一写属性前一定先确认权限。不要拿root去绕过权限然后抱怨开机行为异常。正确做法是按SELinux域的规范补策略让权限明确化。第二控制属性数量。系统属性空间是共享资源每个属性都有生命周期最好在设计阶段就确认是否真的需要跨进程、跨重启保存。第三多用现有属性少造同类轮子。很多状态其实已经有人表达过了比如网络、电源、存储都有现成属性先用搜索工具查一遍再决定是否新增。8. 写在最后的排障心得属性服务这东西平时看不出存在感一出现问题往往都是大麻烦。我这几年的实际体会是遇到系统行为诡异、状态不同步、权限被拒、服务起不来这类问题先别急着改业务代码打开getprop看一圈状态顺着属性变化链路找通常能节省半天时间。另外如果你在刷机包或OTA差分升级里动过属性相关的东西务必关注规格升级时的属性映射和兼容策略。属性名的变化可能引发老进程读到空值、新服务写不进系统等连锁反应。拿一次真实的升级兼容问题来说某个系统版本把wifi.interface属性从wlan0改成了wlan1结果大量服务启动时读不到预期值Wi-Fi整个起不来。这种问题排查起来不可谓不痛苦但从属性服务的视角看本质上就是“全局状态分布表”变了而消费方没有同步适配。所以我的习惯是每次改属性相关逻辑都顺带把所有getprop对这些属性的引用点搜一遍确认没有遗漏。这算不上什么高深技巧但确实帮我躲过了很多次线上问题。
阅读完成 · 觉得有帮助?
咨询建站