之前排查一个奇怪问题:用setprop persist.sys.demo_switch 1设置一个持久属性,命令返回成功,getprop也能立刻读到,但重启之后数据直接消失。当时第一反应是权限或 SELinux 拦截了,后来把Property Service相关的源码从头翻了一遍,才发现问题出在 /data 挂载时序上。这篇就把那几天读过的代码串起来,聊聊 Android 里的 Property Service 到底是怎么工作的。
这里说的 Property Service,是 Android 系统里那套全局 key-value 属性服务。终端里敲的getprop/setprop,应用里读的android.os.SystemProperties,底层最终都会落到它身上。文章基于 AOSP 主线代码,主要涉及system/core/init/property_service.cpp和bionic/libc/system_properties这一块。无论你是做系统开发、ROM 定制,还是单纯想弄明白 init 之外还有哪些轻量 IPC 设计,这篇都值得看。
我不打算只贴代码,而是按照我实际跟踪调用的顺序,把读写路径、数据结构和隐藏的坑一层层拆开。中间会夹杂不少我在调试时踩过的教训,尽量还原“拿到一份源码后到底怎么看”的思路。
1. 别小看这些key:Property Service在系统里到底管什么
1.1 "属性"到底是什么:几个常见属性前缀的含义
Android 里的 property,本质上就是一个全局可见的字符串键值对,格式是name=value。系统从开机到运行会不断产生和读取这些键值:ro.product.model是设备型号,sys.usb.config控制 USB 模式,persist.sys.timezone保存用户时区,ctl.start还能用来拉起系统服务。
这些属性看起来不起眼,但整个系统都在依赖它们。Zygote 启动时要读属性决定运行时行为,SurfaceFlinger 要读属性判断合成方式,HAL 层也要通过属性拿到各种开关。可以这么说:属性系统是 Android 启动链路上最早建立的基础设施之一,它挂了,后面几乎所有用户态进程都会出问题。
属性名的前缀是硬约定,不是一个注释,而是直接影响系统行为的分类规则:
ro.开头的属性是只读属性,一旦设置过,后面任何人都改不了。persist.开头的属性会被持久化到 /data 分区,重启后还能保留。ctl.开头的属性不是普通配置,而是让 init 去启动或停止一个服务。sys./vendor./hw.这类前缀,主要用来区分“运行期系统属性”和“厂商定制属性”。
这些命名规则在源码里不是靠约定俗成,而是有一层层逻辑判断在管。
1.2 property和build.prop的关系
不少初学者会误以为getprop输出的是 build.prop 文件的内容。其实 build.prop 只是属性系统的一个静态来源。init 在启动过程中会解析/system/build.prop、/vendor/build.prop、/odm/etc/build.prop等文件,把里面的键值一次性导入共享内存属性区。导入之后,运行时的属性和文件本身就没有直接关系了。
你开机后运行setprop ro.xxx 1,改的是内存里的属性区,不是 build.prop 文件。同理,你直接编辑 build.prop,也不会立刻影响已经运行的系统,必须重启让 init 重新加载。这个认知非常重要,否则在调 ROM 时会浪费大量时间。
那persist.属性呢?它虽然也是setprop写进去的,但 property service 会额外把它落盘到/data/property/persistent_properties文件里。所以下次开机时,init 会先从持久化文件恢复这些属性,再通过getprop读到。
1.3 读写分离的设计:读走共享内存,写走socket
属性系统最核心的设计是读写分离。读操作不走任何服务端进程,每个用户态进程在 libc 初始化阶段就会 mmap 同一块属性共享内存。之后任何一次__system_property_get,本质上是直接在当前进程地址空间里查表,不需要 socket,不需要 Binder,不需要向 init 发请求。
写操作则完全不同。普通应用进程不能直接改共享内存,否则权限控制就形同虚设了。进程需要先连接到/dev/socket/property_service,把属性名和值发给 init 进程,由 init 完成权限校验后,再由 init 去更新共享内存。
这个设计带来的效果就是:读属性非常快,几乎是内存访问级别;写属性相对慢,而且会经过协议解析、权限检查、共享内存更新、触发器匹配等一串逻辑。理解了这一点,后面看源码就知道该从哪里切入了。
2. 源码地图:从getprop命令出发找代码
2.1 客户端读路径:__system_property_get
如果你从一条getprop命令开始追,第一站是 toolbox/toybox 里的 getprop 工具,它最终会调用到 bionic 提供的函数__system_property_get(name, value)。
这个函数在bionic/libc/system_properties/system_property_api.cpp里实现。它内部先通过__system_property_find(name)去共享内存属性区里找属性条目,找到后把 value 拷贝出来。如果找不到,直接返回 0。
比较关键的是,__system_property_find的实现依赖一块全局共享内存。这块内存是 init 在启动早期创建好的,之后每个进程通过mmap映射到自己的地址空间。bionic 在进程启动阶段通过构造函数完成映射,所以从应用角度看,读属性就是一个普通函数调用。
这里有个容易被忽略的细节:读路径上没有任何锁,也没有 socket 交互。后面第 4 节我会专门讲它是怎么保证并发安全的。
2.2 客户端写路径:__system_property_set
setprop 的写入路径在bionic/libc/system_properties/system_property_set.cpp里。核心函数是__system_property_set(name, value)。
它的逻辑不复杂:
- 检查 name 和 value 是否合法,主要是长度和基本字符规则。
- 构造一个
prop_msg消息,命令字填PROP_MSG_SETPROP。 - 建立到
/dev/socket/property_service的本地 socket 连接。 - 发送消息,阻塞等待 init 返回结果。
- 根据返回结果决定写入是否成功。
注意这里的“写入成功”指的是 init 处理完请求并返回,并不是客户端直接改共享内存。客户端只负责提交请求,真正的写入动作在 init 进程里完成。
2.3 服务端入口:init里的property_service.cpp
服务端核心代码在system/core/init/property_service.cpp。init 启动过程中会调用property_init()初始化属性区,再调用start_property_service()创建属性服务 socket,并把这个 socket 的 fd 注册到 init 的 epoll 事件循环里。
一旦有进程来连接,init 的事件回调会触发handle_property_set_fd()。这个函数会accept()新连接,读取完整消息,然后根据消息里的命令字进入不同处理分支。对于PROP_MSG_SETPROP,最终会调用property_set(name, value)。
property_set是整个服务端的关键函数。它做了三件重要的事:
- 检查属性名是否已经被设置为只读。
- 检查调用者是否有权限修改该属性。
- 通过
__system_property_set更新共享内存,并触发属性变化回调。
源码文件不多,但链路特别清晰。我第一次看的时候只用了不到一小时,就把整个调用树走完了。
2.4 相关源码文件一览
给出一张我实际跟着看的文件表,方便你按图索骥:
| 层次 | 主要代码位置 | 作用 |
|---|---|---|
| 客户端读 | bionic/libc/system_properties/system_property_api.cpp | 提供__system_property_get/__system_property_find |
| 客户端写 | bionic/libc/system_properties/system_property_set.cpp | 提供__system_property_set,连接属性服务 socket |
| 数据区域 | bionic/libc/system_properties/prop_area.cpp | 管理属性共享内存布局、查找和更新 |
| 服务端 | system/core/init/property_service.cpp | init 侧监听 socket、权限校验、写共享内存 |
| 持久化 | system/core/init/persistent_properties.cpp | 管理persist.*属性的落盘与恢复 |
| 触发器 | system/core/init/action.cpp | 属性变化后匹配on property:条件并执行 action |
从这张表能看出来,属性系统其实横跨了 bionic 和 init 两层。读懂读路径主要看 bionic,读懂写路径主要看 init。
3. setprop请求的完整链路:权限、协议、共享内存更新
3.1 网络协议:prop_msg与硬编码的128字节
属性服务的通信协议特别简单,简单到用一个 C 结构体就能描述。在bionic/libc/include/sys/system_properties.h里可以看到类似这样的定义:
#define PROP_NAME_MAX 32 #define PROP_VALUE_MAX 92 #define PROP_MSG_SETPROP 1 struct prop_msg { unsigned cmd; char name[PROP_NAME_MAX]; char value[PROP_VALUE_MAX]; };一个完整的 setprop 请求,cmd 占 4 字节,name 最大 32 字节,value 最大 92 字节,加起来正好 128 字节。所以 init 侧读取消息时,通常会分配一个足够大的 buffer,然后循环recv直到收满固定长度。
这里有两个硬限制值得注意:
- 属性名最长 31 个有效字符,因为
PROP_NAME_MAX是 32,还要留一个给结尾的\0。 - 属性值最长 91 个有效字符,
PROP_VALUE_MAX也是同理。
一旦超过这个长度,客户端在__system_property_set里就会被拦下。有的版本会直接返回错误,有的版本会截断。如果你在定制系统时发现某个属性值写不完整,先检查是不是撞了这个长度上限。
3.2 权限校验:SELinux context和uid/gid双重检查
服务端收到prop_msg后,不会立刻写共享内存,而是先做权限校验。我在源码里看到的大致逻辑是:init 会通过 socket 的SO_PEERCRED拿到调用者的 uid 和 gid,再结合进程的 SELinux 安全上下文,去和属性名对应的权限规则做匹配。
权限匹配规则通常来自property_contexts文件和 SELinux policy。比如一个persist.前缀的属性,可能要求调用者属于 system 组,或者具备特定的 SELinuxset_prop权限。规则越严格,越能避免普通应用随意篡改系统配置。
这里有一个很实际的排错经验:如果setprop返回成功,但getprop读到的值没变,大部分情况不是共享内存坏了,而是权限校验失败时客户端没有把错误明确反馈出来。你需要去内核日志里找avc: denied关键字,那才是真正原因。
3.3 更新共享内存并触发回调
权限校验通过后,init 进程会调用__system_property_set(name, value)。注意这时候执行者已经是 init 自己了,所以写共享内存不会再经过 socket,而是直接操作 mmap 的属性区。
prop_area负责在共享内存里定位或创建属性条目。如果属性名不存在,会分配新的条目;如果已经存在,就把新值拷贝进去,并更新 serial 序号。这些操作都在共享内存上完成,其他进程下一次读时就能立刻看到新值。
写完共享内存之后,property service 还会做两件收尾工作:
- 如果属性名以
persist.开头,会触发持久化保存逻辑。 - 调用
property_changed(name, value),让 init 去匹配on property:触发器。
触发器不是立即执行的,而是把匹配到的 action 入队,等 init 主循环统一处理。也就是说,setprop 返回时,属性值一定已经更新了,但依赖该属性的 action 不一定会立刻跑完。
3.4 ro. 前缀为什么改不了
ro.前缀的只读限制,在property_set里面有专门的判断。逻辑大概是这样:如果你尝试设置的属性名以ro.开头,而且该属性在共享内存里已经存在,那么直接拒绝这次修改。
这个判断就意味着ro.属性必须在“第一次写入”时就把值定下来。很多厂商喜欢在 init.rc 里通过setprop ro.xxx设置只读属性,但一旦系统启动到一定阶段,属性被别的代码读过甚至只是存在过,再想改就不可能了。
所以定制 ROM 时,如果要增加一个只读属性,最稳妥的办法是编译期写进build.prop,或者放在 init 非常早期的加载脚本里。不要想着运行时通过普通setprop去覆盖。
4. getprop为什么那么快:无锁共享内存与seqlock
4.1 属性区在共享内存中的形态
属性区本质上是一块固定大小、由 init 创建、之后被所有进程共享的内存区域。AOSP 里通常用ashmem创建这块匿名共享内存,大小我记得是 128KB 量级。prop_area会把这 128KB 划分成若干属性条目,每个条目存放serial、name、value和一些必要的索引信息。
很多文章喜欢把属性区称为“哈希表”,但它的实现随版本变化挺大。老版本里确实有类似链表的查找结构,新版本有做序列化索引。关键不在于具体的数据结构,而在于整个属性区的字节流被直接映射到了每个进程的地址空间。所以读属性时,不需要把数据从内核拷贝到用户态再走一遍解析,直接在本地内存里就能完成查找。
这带来一个非常明显的好处:属性读操作没有进程切换,没有上下文切换,甚至没有锁。进程多、读得再频繁,也不会给 init 造成压力。
4.2 seqlock如何保证读不脏
虽然读端没有锁,但写端可能正在更新某个属性值。如果不做保护,读者可能读到半个新值、半个旧值的脏数据。
源码里用的是一种非常轻量的顺序锁思路,通常叫 seqlock。每个属性条目有一个serial字段,写者更新属性值时按以下流程操作:
- 将 serial 加 1,此时 serial 变成奇数。
- 写入新的 value。
- 再将 serial 加 1,此时 serial 变成偶数。
读者读取时,不断重复这样的步骤:
do { old_serial = pi->serial; memcpy(tmp, pi->value, sizeof(tmp)); new_serial = pi->serial; } while (old_serial != new_serial || (old_serial & 1));如果读者发现old_serial != new_serial,说明读取过程中存在写者介入,需要重试;如果 serial 是奇数,说明写者还没写完,也要重试。只有两次读到的 serial 都是同一个偶数,value 才被认为是完整一致的。
这个机制特别适合“一写多读”的场景。它不会阻塞读者,也不会让写者等待锁,代价只是极端并发下读者可能多重试几次。对属性这种读远远多于写的系统,几乎是完美匹配。
4.3 共享内存设计的代价:固定大小和重启清零
共享内存方案不是没有代价。首先,属性区大小是固定的,一旦属性条目占满了,新的属性就加不进去。源码里会有property_area容量不足的错误日志,但普通setprop可能只会静默失败,排查起来很费劲。
其次,这块共享内存是内存态数据,重启后全部清零。普通属性本来就不保证持久化,ro.属性靠启动阶段重新加载文件来恢复,persist.属性靠持久化模块从 /data 分区恢复。如果你把非 persist 属性当成持久配置写入,那重启丢失就是预期行为。
理解了这套设计,你就能解释很多现象:为什么getprop快、为什么setprop慢、为什么普通属性重启会丢、为什么属性区满了之后新属性加不进去。
5. 源码里的经验教训:persist不生效、SELinux拒绝、属性区满了
5.1 persist.* 属性和落盘时序
这是我在开头提到的那个问题的正解。persist.*属性并不是只要前缀对就能永久保存,它还依赖/data分区的可用性。源码里对 persist 属性的处理,会走到持久化模块,把数据写到/data/property/persistent_properties文件里。
如果setprop persist.xxx执行时/data还没挂载好,属性可能只会更新共享内存,持久化落盘这步会被跳过或延后。等下次重启时,init 从持久化文件里恢复属性,发现根本没有这条记录,于是属性就消失了。
我在实际调试中踩到的就是这个问题:在 init 启动早期阶段调用setprop persist.xxx,当时 /data 还没 ready,命令返回成功但落盘没发生。解决思路也很简单:不要在启动早期依赖 persist 属性;如果必须在早期设置,改用 init 的on property触发器,等某个条件满足后再设置。
5.2 属性名和值长度限制是硬约束
PROP_NAME_MAX和PROP_VALUE_MAX这两个宏不是写来好看的,它们直接决定了prop_msg的布局。属性名一旦超过 31 个字符,要么被截断,要么直接失败。属性值超过 91 个字符,也会被拦截。
开发自定义系统属性时,我建议从一开始就定一个规矩:属性名尽量短,控制在 20 字符以内;属性值只放简短的状态枚举,不要把一长串 JSON 或者复合配置塞进去。如果确实需要大配置块,应该走文件或数据分区,而不是属性系统。
5.3 avc denied排错步骤
一旦setprop静默失败,第一步永远不是看应用代码,而是先确认有没有 SELinux 拦截。在设备上执行:
adb shell dmesg | grep -i avc adb logcat -b events | grep -i avc如果看到类似avc: denied { set } for property=persist.sys.demo_switch的日志,说明是 SELinux 权限不足。这时候需要检查property_contexts里这条属性前缀对应的 context,再在 sepolicy 里给调用进程增加对应的set_prop权限。
还有一种情况是 uid/gid 不匹配。属性服务对部分前缀有 uid 要求,比如某些ctl.属性只允许 system 进程设置。普通 shell 或者应用进程去调用,自然会被拒。
5.4 属性区满的表现和处理思路
属性区扩容不是自动的,默认 128KB 左右的空间对一般设备够用,但如果一个 ROM 里塞了太多厂商自定义属性,或者某个服务在不停创建新的唯一属性名,就可能把属性区塞满。
塞满后的表现通常是:旧的属性还能读,新的setprop操作失败,日志里能看到属性区空间不足之类的提示。这时候需要回到源码,确认当前PROP_AREA_SIZE或property_area初始化大小,评估后适当增大。同时也要检查代码里是不是存在“每次用不同属性名记录临时状态”的坏习惯,这种写法最容易把属性区写爆。
6. 顺着源码继续挖:属性触发器和定制建议
6.1 setprop ctl.restart 是怎么控制服务的
很多人不知道,setprop ctl.restart xxx其实并不只是更新一个属性值。ctl.前缀在 property service 里有专门的处理分支:init 收到属性名以ctl.开头的请求后,会解析出后面的服务名,然后调用服务管理逻辑去启动、停止或重启对应的服务。
这个机制的价值在于,它提供了一条绕过 Binder 的进程管理命令通道。系统框架里如果需要重启某个 native 服务,不需要拿到服务 manager 的引用,只要往属性系统里写一个ctl.属性即可。init 作为属性服务的宿主,天然就能把这些请求转成对 service 的管理动作。
读代码时如果能顺着这个分支往下走,你会发现 init 作为“服务总管”的形象变得更加清晰。属性服务不只是键值仓库,它还是进程间控制命令的入口。
6.2 property trigger与init action队列的配合
init.rc 里常见的on property:sys.usb.config=adb语法,底层就是靠属性变化事件驱动的。property_set写完共享内存后,会触发property_changed,init 会拿着新的属性名和值去扫描所有注册过的 property trigger,把匹配到的 action 放进执行队列。
这里有个容易被误解的点:action 并不会在 setprop 的调用线程里同步执行。它只是入队,等到 init 主循环下一次 dispatch 时才会真正运行。所以你在on property块里写得再重,也不会卡住调用者。
如果想验证这个机制,可以自己加一条on property:test.demo=1的 rc 规则,然后用setprop test.demo 1触发。你会发现日志输出的顺序会带着明显的主循环调度痕迹。
6.3 从Property Service设计里能带走的东西
读完整套属性服务代码,我最强烈的感受是:一个好的系统服务,不一定非得绑在 Binder 上。Property Service 给了另一套很实用的设计范式:
- 读多写少的场景,用共享内存加无锁读取。
- 写操作统一走服务端,方便做权限审计和控制。
- 用固定大小内存池管理数据,简单且性能可预期。
- 通过前缀规则做分层管理,而不是把所有属性一视同仁。
如果你在自研一个轻量级配置中心,或者要给嵌入式系统设计一套运行时配置服务,这套架构非常值得参考。不用一上来就上数据库、上 Binder,先把读路径和写路径拆开,往往能获得更简单稳定的方案。
读代码时我最推荐的做法,是把property_service.cpp从头到尾完整看一遍,重点盯住handle_property_set_fd和property_set的调用链,然后再回到 bionic 里跟着__system_property_set走一遍。两边一对照,整个设计就清楚了。源码这东西,看别人分析只能算地图,真正自己踩一次坑,印象才会深。