在安卓系统里,property service(属性服务)可能是最“低调”却又最无处不在的基础设施。你平时不怎么直接碰它,但应用安装、开关机、网络切换、兼容性适配,几乎每个环节都在读写系统属性。很多开发者排查问题时会执行getprop或setprop,但真正讲清楚 property service 内部怎么工作、权限怎么管控、持久化怎么实现、为什么有时设置不生效的文章却不多。这篇内容就从源码设计和实战排查的角度,把安卓 property service 系统完整拆一遍,希望能帮你从“会用命令”进阶到“理解机制”。
1. 整体设计与核心思路
1.1 属性服务到底解决什么问题
要理解 property service,先想一个问题:安卓系统里有那么多进程,内核、init、系统服务、App,它们之间怎么共享一些轻量级的全局状态?比如当前设备是否已经开机完成、某个硬件驱动是否加载成功、当前语言环境是什么、系统版本是多少。这些信息的特点是:单值、短小、读多写少、需要跨进程访问,而且最好能随时读取、全局一致。
如果让每个进程各自维护一份,数据必然不一致;如果全部走 binder 或者文件读写,又太重了。安卓的解法非常直接——在 init 进程里启动一个属性服务,维护一张全局的属性表,同时把这块表映射到共享内存里,让所有进程都能直接读;写入则通过 socket 向 init 发起请求,由 init 统一处理权限和更新。这样读操作几乎零成本,写操作集中收敛,既快又可控。这个设计从早期安卓一直延续到今天,核心思路没变过。
1.2 为什么用“共享内存 + 本地 socket”这样的组合
很多第一次接触 property service 的人会问:为什么读取不直接走 binder,反而用共享内存?这里面有个历史原因,安卓早期还没有完整的 binder 体系,而且属性读操作实在太频繁,比如系统框架每执行几个函数就要读一次 property,如果每次读都走 binder,开销会非常大。共享内存的方案是:init 启动时就创建一个全局属性区域,通过mmap映射到匿名内存,然后把这个内存区域的 fd 通过 binder 或 socket 传递给其他进程,让它们直接mmap同一块物理内存。读取时只需要在内存里做一个字符串查找,不涉及进程间通信,所以property_get的性能接近本地函数调用。
写入则完全不同。属性写入涉及权限校验、规则检查、持久化,这些逻辑必须在单一权威进程里完成。init 作为属性服务的宿主,通过一个名为property_service的 socket 监听客户端的写入请求。客户端需要把属性名和值打包发送过去,init 解析后做 SELinux 权限检查、属性名合法性校验,再更新共享内存。也就是说,读是并发的、无锁的,写是串行的、有权限控制的。这个组合保证了整个属性系统的简单性和性能。
1.3 属性系统的三个核心角色
整个属性系统可以拆成三个角色来理解。
第一个是init进程。它既是属性区域的创建者,也是唯一的写权限决策者。所有property_set请求最终都会通过 socket 到达 init,由 init 决定是接受还是拒绝。init 还负责加载启动时的属性文件、触发属性变化后的回调动作(比如on property:xxx=1的触发条件)。
第二个是共享内存区域,也就是属性表的物理载体。它在 init 早期初始化,由一块连续内存组成,包含一个头部结构(记录总大小、版本、偏移等)和多个属性条目。每个属性条目存储属性名和值的字符串,以及序列号等信息。这个区域对所有进程只读,对 init 可写。
第三个是客户端 API 层,也就是 libc 提供的__system_property_get和__system_property_set函数。上层的property_get、property_set最终都会走到这组接口。__system_property_get直接遍历共享内存查找属性;__system_property_set会先尝试在本地更新,如果失败则通过 socket 发送给 init。
这三个角色配合起来,才构成了一套完整的“全局字典 + 授权写入”系统。
2. 核心机制与关键参数解析
2.1 属性名的命名规则与长度限制
属性名不是随便起的,它有严格格式。标准属性名由点号分隔的若干段组成,比如ro.build.version.release、persist.sys.timezone,每个段只能包含字母、数字和下划线,且必须以字母开头。类似ro.、persist.、sys.、vendor.、ctl.这些前缀都有自己的语义。比如ro.开头表示只读属性,一旦设置后不能再改,除非重启;persist.开头表示需要持久化的属性,修改后会写入/data/property目录;ctl.开头用来控制 init 启动和停止某些服务,普通 App 根本不能碰。
这里有个关键参数:属性名的最大长度是PROP_NAME_MAX,值为 32 字节(含结束符);属性值的最大长度是PROP_VALUE_MAX,值为 92 字节。也就是说,一条属性的名字不能超过 31 个有效字符,值不能超过 91 个有效字符。很多人在用setprop传一段较长字符串时发现被截断,多半就是撞上了这个限制。这个限制从早期安卓就存在,设计目标就是保持属性条目足够小,以便共享内存里能容纳数量可观的属性。
2.2 只读属性与可变属性的行为差异
属性系统里最容易被误解的就是只读属性。以ro.开头的属性,市面上叫“只读属性”,但其实它的完整语义是:一次写入后,本次开机周期内不能再修改。也就是说,ro.属性不是不能在 init 里设置,也不是不能由setprop命令设置,而是只要这次开机里有人成功写入过一次,之后再写就会被拒绝。比较典型的例子是厂商在 init 脚本里设置ro.product.model,App 层如果尝试再设置,就会收到failed to set property的报错。
为什么这么设计?因为ro.属性通常用于表示硬件型号、系统版本、编译类型等固定信息,如果运行期随便改,会出现系统行为不可预期的问题。比如依赖ro.debuggable判断是否允许调试的机制,如果可写,那任意进程都可以把自己变成可调试,安全就崩了。所以只读属性是保护系统稳定和安全的重要边界。
非只读属性则可以在运行期修改,但依然要过 SELinux 权限检查。普通应用能写的属性范围非常有限,通常只有sys.前缀下部分属性,以及带debug.前缀的少数调试项。每次setprop的权限判定结果都会被记录在 audit log 里,这也是后面排查问题的重要线索。
2.3 属性区域的存储结构与序列号
属性共享内存区域不是简单的 KV 散列表,它是一块精心设计的内存布局。头部是一个prop_area结构,包含magic、version、bytes_used、serial等字段。序列号serial非常关键,每次属性被修改时都会递增,客户端可以通过对比前后序列号来判断属性是否有变化。这也是property_foreach、wait_for_property这类机制的基础:通过读取序列号来判断是否需要重新扫描属性表。
属性条目存储时,名字和值是连续存放的。每个条目会按属性名字符串进行排序,方便二分查找。这样即使属性数量达到几百上千条,读取性能依然能保持稳定。早期的属性区域是固定大小的,默认在 32KB 到 256KB 之间,取决于系统配置。后续版本增加了动态扩展能力,但核心查找逻辑仍然是“有序数组 + 二分查找”。所以你会发现,属性表里的条目物理顺序是混乱的,只是逻辑上排好了序。
这里有个容易忽略的细节:属性区域本身是匿名共享内存,不同进程通过继承 fd 来访问。但为了防止恶意进程修改只读属性,init 在映射属性区域时,对其他进程通常使用只读映射。只有 init 自身持有可写映射。即使某个进程能拿到属性区域的 fd,也无法直接改写内容,这从内存映射层面就做了隔离。
2.4 SELinux 如何管控属性写入
安卓的属性权限控制分两层:第一层是属性名的前缀规则,第二层是 SELinux 的 type 检查。前缀规则是老机制,在 property service 内部有一张权限表,比如允许 shell 设置ctl.下某些属性、允许 system_app 设置sys.下某些属性。但真正精细的管控靠的是 SELinux policy 里的property_type和set_prop权限。
每个属性都有一个 SELinux 类型,这些类型定义在property_contexts文件里。比如ro.build.version.release对应build_prop类型,persist.sys.timezone对应persist_prop类型。客户端进程要写一个属性,必须满足两个条件:一是该进程的 domain 被允许对属性所属类型执行set操作,二是属性名前缀规则不拒绝。如果 SELinux policy 没有授权,会返回Operation not permitted,并在 dmesg 或 logcat 里出现avc: denied { set }的日志。
我用一个实际案例说明。某开发者在做系统定制时,需要让一个普通系统应用去修改vendor.demo.status这个属性。结果应用调用property_set后状态完全没有变化,也没有任何异常抛出。排查了半天,最后在dmesg里看到一条avc: denied { set } ... scontext=u:r:untrusted_app:s0 tcontext=u:object_r:vendor_demo_prop:s0 tclass=property_type。这个vendor_demo_prop就是属性vendor.demo.status的 SELinux 类型,untrusted_app是应用进程的 domain。解决方式就是在 sepolicy 里新增一条 allow 规则。这是典型的属性写入失败原因,排查看这里基本能定位。
3. 实操过程与核心环节实现
3.1 从 init 启动到属性区域初始化的全流程
要用好属性服务,最好把它的启动流程在脑内过一遍。系统启动时,内核会执行第一个用户态进程,也就是 init。init 在早期阶段会解析property_contexts和property_service相关的配置,然后调用属性系统初始化函数。这里会执行__system_property_area_init,创建属性区域并 mmap 好内存,接着加载默认属性文件,比如根分区下的default.prop、system/build.prop、vendor/default.prop等。这些文件里的内容会被逐一解析并写入属性区域,同时更新ro.只读属性的初始状态。
随后 init 开始循环监听属性 socket。这个 socket 的名字是property_service,属于本地通信 socket,路径在/dev/socket/property_service。客户端的property_set请求以特定协议格式发送过来,init 解析请求中的 name 和 value,按照前面说的权限检查流程决定是否接受。如果接受,init 会执行实际的属性更新函数,更新共享内存,同时,如果属性名以persist.开头,还会触发持久化逻辑,把值写入/data/property目录下以属性名命名的持久化文件中。整个过程是同步的,也就是说客户端setprop命令会一直等待 init 的响应,直到确认成功或失败。
3.2 核心数据结构的源码级解读
我们来看几个核心结构体的简化定义。属性区域头部可以理解成这样:
struct prop_area { uint32_t magic; // 固定魔数,校验合法性 uint32_t version; // 属性区域版本 uint32_t bytes_used; // 已使用字节数 uint32_t serial; // 全局序列号,每次修改递增 uint32_t magic2; uint32_t version2; uint32_t bytes_used2; uint32_t serial2; };属性条目则长这样:
struct prop_info { uint32_t serial; // 该条目的序列号 char name[PROP_NAME_MAX]; char value[PROP_VALUE_MAX]; };实际的源码实现会比这复杂,用了prop_bt树形结构来加速查找,但概念上就是一张表。读取属性时,libc 里的__system_property_find会根据属性名在这个区域里查找到对应的prop_info,然后读取 value 字段。由于 value 可能被并发写,读取时还会检查序列号是否发生变化,如果变了就可能需要重读。这里有一个细节:属性值更新不是直接覆盖原字符串,而是先把新值写入,然后更新序列号,保证读进程不会读到“半个新值半个旧值”的状态。这种朴素但有效的无锁读设计,在性能上比加锁方案更优,代价是读进程偶尔需要重试。
3.3 属性设置的完整调用链
我们以在 adb shell 里执行一条setprop persist.sys.location.timezone Asia/Shanghai为例,梳理完整调用链。shell 进程调用 libc 的property_set,然后进入__system_property_set函数。该函数首先会尝试在本地直接更新属性区域,这里会触发权限检查。但 shell 进程大概率没有权限直接改persist.属性,所以本地更新会返回错误。随后函数转入 socket 通信分支,连接property_servicesocket,把属性名和值打包发送给 init。init 收到请求后,解析命令字,进入权限检查函数。这里要检查属性是否以ro.开头且已经设置过、是否通过了 SELinux 权限校验、属性值长度是否合法等。都通过后,init 调用内部接口更新共享内存里的属性值,同时向/data/property目录写入持久化文件。最后 init 通过 socket 返回成功状态,shell 收到响应后结束整个过程。
这个过程看起来简单,但实际需要区分不同前缀的语义。比如ctl.start开头的属性,init 不会把它当作普通属性处理,而是会转化成启动指定服务的命令。也就是说,setprop ctl.start myservice的效果等同于告诉 init 去启动名为 myservice 的安卓服务,而不是真的创建一个属性。理解了这一点,你在看到那些控制蓝牙、Wi-Fi、热点的服务时就不会困惑了。
3.4 属性持久化机制的注意事项
属性持久化是persist.前缀独有的能力。每次成功写入一个persist.属性,init 都会把属性值写到/data/property目录下的某个文件里。文件名就是属性名,路径里的点号会被保留。比如persist.sys.timezone会生成/data/property/persist.sys.timezone文件,内容就是属性值字符串。
这里有一个坑:并非所有persist.属性都会立即落盘。init 内部会有属性更新的缓冲策略,有些版本会延迟同步,避免频繁的小文件写入拖慢系统。所以你在setprop成功之后立即强制断电,有一定概率发现属性没保存下来。如果这是硬件设备的关键配置,建议在写入后稍等片刻,或者通过sync命令确保落盘后再断电,否则数据容易丢。
另外,/data分区本身有加密和权限保护。如果设备处于未解锁或恢复模式,init 可能无法挂载/data,那么persist.属性写入就会失败。所以很多厂商会把一些高风险属性的默认值放在芯片级别或其他位置,尽量不依赖用户数据分区。对开发者来说,定制属性时如果发现persist.写入不生效,需要先确认/data是否可写、/data/property目录是否存在、SELinux 是否放行。
4. 常见问题与排查技巧实录
4.1 属性读取不到或值不对
我遇到的第一类问题是:某个属性明明在 shell 里getprop能看到,但 App 里调用SystemProperties.get却拿不到,或者拿到的是空值。这种情况通常有几个原因。第一个原因是属性名拼错了,尤其是大小写和下划线。属性名是大小写敏感的,ro.build.version和ro.Build.Version是两个完全不同的属性。第二个原因是属性只在某些进程里可见,例如ro.属性在 App 启动早期如果没被读取,可能缓存机制导致后续读不到最新值。但更常见的其实是 SELinux 对读取权限的限制。
安卓对属性读取也有 SELinux 控制,但比写入宽松很多。普通应用可以读取大部分非敏感属性,但像ro.build.selinux、ro.boot.*这类与安全启动相关的属性,可能会被限制。如果 App 里读到空值,可以先在 shell 里用getprop确认属性确实存在,再用dumpsys或logcat看有没有 avc denied 日志。对于ro.boot.*这类属性,它们是内核 cmdline 解析进来的,如果定义在property_contexts里没有给对应 domain 读取权限,就会出现“shell 能读,App 不能读”的怪现象。
还要提一个容易误导的点:getprop命令本身属于 shell domain,它的权限往往比普通 App 高。所以你在 adb shell 里测试一切正常,不代表 App 里也能正常。测试时最好使用与目标进程相同的 domain,或者直接在 App 里打日志,否则很容易得出错误结论。
4.2 设置属性报 “failed to set property” 的常见原因
setprop报failed to set property是最经典的问题。可以从下往上排查。首先确认属性名是否合法、值长度是否超出 92 字节。如果名字超过 31 字节或者包含非法字符,init 直接拒绝。其次是属性是否以ro.开头,如果是,那本次开机周期内已经存在初始值,再设置必然失败。然后看 SELinux。执行dmesg | grep avc或logcat -b all -d | grep avc,搜索与被拒绝属性相关的日志。如果能看到avc: denied { set },说明是权限不足。还有一种比较隐蔽的情况:persist.属性写入时/data分区已满或/data/property目录不可写,init 返回失败,但日志里不一定有明显报错。这时可以看df /data确认空间,再用ls -lZ /data/property检查目录的安全上下文。
我还遇到过一种情况:某个属性在设置成功后,立即读取却返回的是旧值。这里多半是并发问题。在不同进程中设置和读取属性,由于一个进程的本地缓存或读顺序问题,偶尔会读到旧值。实际上共享内存已经更新,但读取方的内存缓存没有失效。对于这种场景,可以在读取前做一次延迟,或者直接用__system_property_wait这类接口等待序列号变化。日常调试时最简单的方法是重启目标进程再读取,基本就能排除缓存干扰。
4.3 属性空间不足导致系统启动异常
属性空间不足是系统启动阶段非常头疼的问题。属性区域虽然支持动态扩展,但早期版本或某些定制 ROM 中,属性区域大小是编译期固定死的。如果系统里塞入了大量自定义属性,就可能导致属性区域满,新的属性写不进去。这会引发很多奇怪的问题:某些服务起不来、系统 UI 反复重启、getprop结果和配置不一致。
从 AOSP 后来的代码看,属性区域已经支持动态增长,但依然有最大上限。我在一个定制项目里就碰到过:某设备启动时 init 反复报Failed to set property,但手动在 shell 里设置却没任何问题。后来才发现是启动阶段 init 加载的属性太多,超过了属性区域的初始容量,而该版本没有启用扩展能力。解决方式是减少冗余属性,或者改用其他存储方式(例如直接写文件)来保存非关键配置。这里给个建议:编写 init 脚本时不要无脑塞setprop,要评估属性数量,尤其注意带persist.前缀的属性,每条都会产生额外的文件操作和内存占用。
4.4 使用 property 监听和回调的实战技巧
属性系统不仅支持读写,还支持“监听变化”。init 的脚本语言里有on property:foo=bar这种触发器,当属性foo的值变成bar时,init 会执行对应动作。应用层也有PropertyChangeListener之类的接口。这个机制非常适合做系统联动,比如某个硬件状态发生变化时,通过设置属性通知其他进程去执行动作。
但监听属性变化有个前提:属性变化必须发生在同一台设备的同一个 init 服务管辖范围内。普通的 App 直接修改属性,如果没有系统权限,根本改不了。所以通常的模式是:系统服务(system_server 或 vendor 进程)负责写入属性,App 监听属性的变化。比如充电状态、显示模式、网络状态切换等,都是通过属性传递状态变化。
从开发角度看,如果你想快速验证属性的变化,可以用watch -n 0.1 getprop persist.sys.test这样的命令持续观察,配合setprop修改值,就能实时看到变化。这个组合在调试 init 脚本时非常有用。另外,init 脚本里可以用property_get、setprop、wait_for_property等关键字,wait_for_property会一直阻塞直到指定的属性出现才继续执行,这在等待某个服务就绪时非常实用。
4.5 一个完整的属性问题排查示例
最后分享一个完整的排查过程。某开发者报告说,设备开机后某个功能不可用,怀疑是属性vendor.audio.spatial没有被正确设置。我建议他按下面的步骤排查。先执行getprop vendor.audio.spatial,结果为空。再查 init.rc 文件,发现里面有setprop vendor.audio.spatial true,确认脚本确实设置了。接着查dmesg -k,看到一行init: untrusted_app或者空进程试图写属性的拒绝日志吗?结果不是。继续排查发现,该 setprop 语句在on property:sys.boot_completed=1的条件里,而sys.boot_completed在那个版本上始终没有变为 1。也就是说,外层条件没满足,内层的属性设置根本没执行。
这就是属性依赖链条的问题。这种问题用字面排查很难发现,但如果你能理解 init 触发器的执行条件,把相关属性打印出来,问题就能很快定位。后来我把那条 setprop 移到了更早的阶段,功能就恢复了。这类经验说明,属性服务虽然简单,但它和 init 脚本执行流程耦合很深,排查时不能只看单条属性,要把上下文条件一并看掉。
最后的实操体会
这东西我实际用下来最大的感受是:property service 的设计已经足够成熟,大部分问题不是出在机制上,而是出在使用者对规则的理解上。如果你要自己定制属性,建议先把属性名规划好,避免和系统已有前缀冲突;能用文件存的大数据就别塞属性;需要跨进程同步的标志位用persist.或sys.都行,但要提前想清楚重启之后是保留还是归零。另外,调试时一定要善用getprop、dmesg、logcat -b all -d | grep avc三板斧,绝大多数写入失败案例都能靠这套组合快速定位。
还有一个小技巧:如果你想在代码里快速验证某个属性是否可写,可以用resetprop工具(在具备 root 权限或 Magisk 环境下)直接修改只读属性,但要注意这只是临时生效,重启后会被原始值覆盖。常规系统里没有这个命令,所以不要在正式方案里依赖它。理解了 property service 的底层机制之后,你会发现它在整个安卓体系里的角色很像一张“全局公告板”:谁都能看,但只有管理员能贴新通知。把这个模型记在心里,再看各种属性相关的坑,思路一下就清晰了。