Property Service是init进程里最容易被忽视却又处处离不开的模块。日常用getprop/setprop操作系统属性时底层就是它在工作一块共享内存、一组socket接口、一套权限规则三者拼起来支撑起了整个Android的属性世界。如果你正在啃init源码或者想搞懂一个属性从setprop到真正生效究竟经历了什么这篇文章就是给你准备的。我以Android 8之前的经典实现为主线来拆源码后文也会顺带提一下新版的变化。整套代码集中在system/core/init/property_service.cpp、system/core/libcutils/properties.cpp以及bionic底层的system_properties相关文件里。别看它只是一个“服务”牵扯到的知识点横跨IPC、共享内存、SELinux、init机制读起来非常划算。1. 先搞清楚Property Service是个什么角色1.1 系统属性到底是什么Android系统属性是一组全局键值对类似Linux环境变量但作用域是整个系统。所有进程都可以读取但写入受严格限制。属性名通常是点号分隔的命名空间比如ro.build.version.release、persist.sys.timezone、dalvik.vm.heapsize前面那段前缀就是“命名空间”决定了这个属性的行为。常用的前缀有这么几类ro.只读属性一旦设置就不能再修改通常存硬件信息、编译版本persist.持久化属性会写入/data分区重启后仍然保留ctl.控制属性用于让init启动、停止、重启服务不带前缀的普通属性运行时的临时配置进程重启后消失。这个命名规则看起来简单实际在源码里到处都是“按前缀分流”的逻辑。读源码的时候如果发现某个key的行为和预期不一样第一反应应该是检查前缀是不是用错了。1.2 Property Service在init中的位置init是Android用户空间第一个进程所有系统服务都是它拉起来的。Property Service并不是一个独立进程而是init里面的一份子。init启动后会做三件和属性有关的事初始化一块共享内存用来存放所有属性从build.prop、default.prop等文件把初始化属性灌进去创建一个socket服务监听所有进程发来的“设置属性”请求。这三步对应代码里的property_init、property_load_boot_defaults和start_property_service。读完这三个函数基本上就对整个框架有了整体印象。它表面上是“读写一个键值表”实际上承担了系统配置中心、服务控制入口、开机参数分区隔离等职责。1.3 为什么不能用普通文件直接存很多人第一反应是这不就是个配置文件吗每个人直接打开写不就行了不行原因有三。第一是并发和原子性。几十个进程同时写一个文件加锁、崩溃恢复都是大麻烦。共享内存配合序列号可以做到无锁读、原子更新性能好得多。第二是权限控制。属性不只是给普通应用读的很多涉及系统安全、功能开关。如果直接开放文件写权限等于每个人都能篡改系统配置。所以必须有一个服务端统一收口按调用者的身份决定“你能不能写这个key”。第三是控制类动作。有的属性只是改数据比如通知时间有的属性则要触发动作比如ctl.start一个服务。这种“写入即调用”的语义只有中心化服务才做得优雅。一句话总结属性服务是“读多写少、权限严格、带控制语义”的全局配置中心。这种场景用共享内存做读用socket做写是非常合适的设计。2. 源码框架与核心数据结构2.1 源码入口和阅读顺序想要高效读这部分源码不建议直接打开property_service.cpp从头看到尾最好按“头文件 → 客户端 → 服务端 → 存储层”的顺序来。system/core/init/property_service.h先看这里面的接口定义和权限表结构system/core/init/property_service.cppinit服务端的核心逻辑包括socket监听、权限检查、写入system/core/libcutils/properties.cpp传统版本里客户端property_set实现bionic/libc/bionic/system_properties.cpp属性区的内存模型和__system_property_update等底层接口。如果用的是较新版本源码路径可能调整到了system/core/property_service/下但核心模块名没变。建议找一个固定的Android版本tag比如android-8.0.0_r1对着看最舒服。2.2 客户端与init之间的通信协议属性设置的请求包在头文件里定义得很明白一个prop_msg结构体就搞定了早期版本大概是这样的#define PROP_NAME_MAX 32 #define PROP_VALUE_MAX 92 struct prop_msg { unsigned cmd; char name[PROP_NAME_MAX]; char value[PROP_VALUE_MAX]; };这里cmd表示命令类型最常用的是PROP_MSG_SETPROP。后面跟着属性名和属性值。注意属性名最多32字节属性值最多92字节。很多人第一次看到这个92会觉得很奇怪它不是64也不是128恰恰是历史原因。所以写属性时值别太长否则底层直接拒绝。客户端与init通信用的是本地socket路径叫/dev/socket/property_service类型是SOCK_STREAM。为什么不用UDP因为属性设置需要可靠执行和返回值。TCP式的流式socket可以保证请求顺序也能让服务端把成功、失败结果写回来。每次设置属性的过程其实就是“连接socket → 发送请求结构体 → 等待一个int返回码”的过程。2.3 共享内存里的属性区长什么样属性读取性能要求很高因为系统服务启动时可能连续读几百个属性。如果每次读都走进程间通信效率太低。所以init把属性数据放到一块共享内存里所有进程通过mmap映射同一份数据读属性时直接内存读取完全不需要进入内核。这块共享内存在早期版本里就是/dev/__properties__文件init创建并初始化好结构后后续进程打开映射即可。内存里的结构是一个大prop_area内部包含一个属性信息的列表/树结构每个属性项对应一个prop_infostruct prop_info { char name[PROP_NAME_MAX]; // 属性名 unsigned serial; // 序列号用于并发和可见性 char value[PROP_VALUE_MAX]; // 属性值 };实际实现会比这个复杂很多特别是Android 8之后引入分区属性prop_area被拆成多个属性名也换成了Trie树索引。但万变不离其宗每个属性都有一个名字、一个值、一个序列号。序列号特别重要读端看到序列号变化就知道值更新了写端更新时先改值再递增序列号保证读端不会读到写了一半的脏数据。这一层是整个Property Service的地基理解了共享内存模型后面看写入链路会轻松许多。3. 从property_set到属性生效的完整链路3.1 客户端发起property_set干了什么很多人用setprop好几年却没想过property_set这个C接口到底做了什么。在传统版本里它是这样工作的int property_set(const char* key, const char* value) { struct prop_msg msg {.cmd PROP_MSG_SETPROP}; if (key nullptr || value nullptr) return -1; if (strlen(key) PROP_NAME_MAX) return -1; if (strlen(value) PROP_VALUE_MAX) return -1; strlcpy(msg.name, key, sizeof(msg.name)); strlcpy(msg.value, value, sizeof(msg.value)); int s socket(AF_LOCAL, SOCK_STREAM | SOCK_CLOEXEC, 0); if (s 0) return -1; struct sockaddr_un addr {.sun_family AF_LOCAL}; strlcpy(addr.sun_path, PROPERTY_SERVICE_SOCKET, sizeof(addr.sun_path)); if (connect(s, (struct sockaddr*)addr, sizeof(addr)) 0) { close(s); return -1; } if (write(s, msg, sizeof(msg)) ! sizeof(msg)) { close(s); return -1; } int result -1; read(s, result, sizeof(result)); close(s); return result; }代码逻辑不算复杂但有几个细节值得注意第一属性名和属性值在客户端就做了长度校验超过限制直接返回-1根本不会去请求init。第二socket是即时连接、即时关闭的没有保持一个长连接。每次setprop都重新创建socket这种设计牺牲了一点点性能换来了实现简单和连接清理的方便。第三客户端会阻塞等待服务端返回结果所以property_set是同步调用不是发完就完事。3.2 init收到请求handle_property_set的入口逻辑init启动时会调用start_property_service创建好socket并注册到自己的epoll循环里。之后每次有客户端连上来init都会调用handle_property_set_fdstatic void handle_property_set_fd(int fd) { int s accept(fd, NULL, NULL); if (s 0) return; struct ucred cred; socklen_t ucredsz sizeof(cred); getsockopt(s, SOL_SOCKET, SO_PEERCRED, cred, ucredsz); struct prop_msg msg; if (read(s, msg, sizeof(msg)) ! sizeof(msg)) { close(s); return; } int result -1; switch (msg.cmd) { case PROP_MSG_SETPROP: if (check_perms(msg.name, cred)) { result property_set(msg.name, msg.value); } break; default: break; } write(s, result, sizeof(result)); close(s); }这段代码藏着两个关键动作。第一个是用getsockopt取SO_PEERCRED。这是Linux提供的能力可以通过socket拿到对端的uid、gid和pid。注意这里拿到的uid是“进程真实身份”不是应用层可以伪造的所以把它作为权限判断依据非常可靠。第二个是check_perms失败后result保持-1但代码没有输出额外日志。所以实际使用中如果你发现property_set返回-1但不知道原因往往要去logcat -b all | grep -i avc查SELinux拦截记录而不是指望init给你打印一行“Permission denied”。3.3 权限校验为什么有些属性你写不了权限校验是Property Service最核心的地方。早期系统用一张property_perms表硬编码谁能写哪些前缀大概长这样struct property_perms { const char* prefix; uid_t uid; gid_t gid; }; static const struct property_perms property_perms[] { {ro., AID_SYSTEM}, {persist.sys., AID_SYSTEM}, {persist., AID_SYSTEM}, {ctl., AID_SYSTEM}, {service., AID_SYSTEM}, {NULL, 0}, };意思是只有system用户uid1000能写ro.、persist.这些关键前缀普通应用只能去写剩下的、没有在表里被限制的前缀。这个模型简单粗暴但很快暴露问题一张静态表无法覆盖越来越细粒度的权限需求不同应用对不同属性的访问控制开始变得复杂。所以从Android 6开始权限校验逐渐转交给SELinux完成。check_perms内部会先根据属性名找到对应的SELinux上下文再结合客户端的安全上下文做一次avc_has_perm检查。简单理解就是传统表是“名单制”SELinux是“规则制”。规则制可以把授权写得非常细比如“只有A应用能写debug.xxx这个属性”同时还能顺便做系统级的强制访问控制。3.4 真正写入property_set内部的那些隐规则权限检查通过后init还要调用自己内部的property_set函数完成真正的写入。这个函数有几个“隐规则”特别容易踩坑第一ro.属性只能写一次。属性已经存在时再写入直接返回-1。这个逻辑保证了“只读属性”的语义也意味着你没法通过setprop去改一个已经存在的ro.字段。第二值如果是空字符串在一些版本里会被当作“删除属性”处理。但并不是所有前缀都支持删除普通属性可以ro.不行。第三写入内存时不是简单memcpy。先要在共享内存里找到对应的prop_info更新value然后更新serial。serial递增的规则很讲究写端先更新值再递增serial这样读端看到serial变了再去读value就能拿到完整的新值。第四property_set内部会先判断是否以ctl.开头如果是就不会走普通属性写入流程而是进入控制逻辑。这一点单独拆开讲。3.5 ctl.控制属性是怎么“不写而治”的很多人用setprop ctl.start servicename来启动一个服务以为只是写了个属性其实完全不是。当key以ctl.开头时init的property_set会直接把后面的部分当作控制命令来解析。ctl.start对应启动服务ctl.stop对应停止ctl.restart对应重启。服务名不是属性值而是init里已经注册过的service name。这个流程实际上是一个“通过属性接口调用init服务管理功能”的机制。为什么要把启动服务包装成“设置属性”因为Android内部大量代码都是用属性API做通信的复用一套接口可以少引入一套IPC。代价是语义有点绕你以为在改配置实际是在发指令。这里还牵涉权限。ctl.*在早期权限表里只对system开放普通应用不能通过setprop ctl.start xxx拉起一个系统服务否则安全就崩了。所以你如果发现某个应用调用setprop ctl.start返回-1大概率是权限不匹配。4. 开机阶段的属性加载与持久化4.1 属性在开机时怎么“灌”进去共享内存一开始是空的必须有人把各种prop文件加载进来。这个工作由init按顺序完成主要经过这两步property_init()初始化属性区域创建共享内存文件property_load_boot_defaults()读取系统、vendor下的prop文件。传统系统里需要加载的文件主要有/system/build.prop、/vendor/build.prop、/system/default.prop等。每个文件中都是keyvalue的键值对init逐行解析后调用property_set写入共享内存。说到这里有个细节这些prop文件里的ro.属性为什么能被写入前面不是说ro.一旦存在就不能改吗因为加载这些文件时属性还不存在第一次写入自然允许。开机完成后你再想用setprop改一个ro.属性就不行了。这是设计上对“只读”的保证。4.2 persist.属性是靠什么保留的persist.开头属性的特点是重启不丢。它的实现方式其实和普通属性不同每次写入persist.属性时init不仅更新共享内存还会把这个键值对同步到持久化存储中通常是/data/property/persistent_properties这个文件。重启过程中init会先加载这个持久化文件把里面的属性重新灌进共享内存这就实现了“重启后还在”的效果。写入时如果/data分区尚未挂载或者空间不足property_set会失败。所以手机存储空间快满时经常会出现某个persist.属性设置失败的问题。这里也提醒一个常见误区ro.属性不会因为写了/data就持久化persist.属性也不会因为改了/system/build.prop就永久生效。要持久化自定义属性要么用persist.前缀要么修改prop文件后重启。4.3 Android 8之后的分区属性与结构变化Android 8开始属性系统做了一次不小的重构。为了支持system和vendor分区的独立升级属性被拆成了多个属性区域不再只有一个/dev/__properties__。system、vendor、product等分区各自有独立的prop文件和属性区init在加载时会把它们分别映射。同时底层数据结构也变了从简单的数组改成了更高效的Trie树索引。属性名仍然有限制但属性值的上限在某些场景下被扩展了。这一重构对上层应用有一定兼容性影响不过核心的property_set调用链和权限模型跟旧版一脉相承。如果你是初学者建议不要一上来就啃新版的重构代码先看经典版本把链路摸透再对比新版反而更容易理解改动动机。5. 源码阅读与实际开发中的经验5.1 property_set返回-1的排查思路碰到属性写不进去别慌按这个顺序排查现象可能原因怎么确认返回-1日志无异常属性名或值超长检查长度ro.属性已存在后再次写入返回-1AVC deniedSELinux权限不够logcat里搜AVC看SELinux上下文persist属性重启丢失/data分区异常或空间满查看/data/property/persistent_properties普通应用写入失败属性名属于受限前缀查看权限表或property_contexts最常见的就是SELinux拦截。真机上跑一下logcat -d | grep -i avc如果看到类似avc: denied { set } for propertyxxx基本就是权限问题。不要图省事直接setenforce 0正确做法是补SELinux规则用audit2allow生成对应te规则既安全又能解决问题。5.2 正确新增一个系统属性的操作流程很多初级开发者喜欢直接在某段代码里调property_set(my.custom.prop, 1)结果死活不生效。正确步骤应该是确认命名空间。ro.、persist.、ctl.、普通前缀语义和权限都不同在prop文件中定义默认值。比如system分区属性放/system/build.propvendor属性放/vendor/build.prop如果是给第三方应用用还要在property_contexts里增加对应的SELinux上下文并补好set_prop规则重新编译烧录先用getprop确认默认值存在再用setprop测试。大量“属性没法用”的问题都是跳过第3步导致的。记住在新版本Android里SELinux规则永远比代码先决定一个属性能不能被写。5.3 用getprop和setprop验证时的细节调试时getprop和setprop是两大法宝但有几个细节要注意。getprop不带参数会把所有属性打出来方便审计getprop key只查指定keysetprop默认会打印返回值返回失败时shell里不一定有直观报错需要配合上面提到的方法排查。还有一个细节在真机上设置控制类属性比如setprop ctl.restart servicename很多版本要求你必须是root或者有system权限。如果是在普通shell下执行可能直接被权限校验拒了。这时候可以用adb root后再试这属于日常调试和防护体系无关但要记得生产环境别这么做。5.4 读源码时的几个实用经验最后分享几个我啃这份源码时的经验。先搜PROP_MSG_SETPROP这个关键字能帮你快速定位整个请求处理主链路比从init入口一层层追效率高得多。再读property_area相关代码时一定要配合system_properties.cpp里的底层实现一起看。共享内存和序列号如果没有对照着看很难理解为什么读端能一边读一边看到最新值。另外源码版本尽量选稳定tag。不同版本之间函数名、权限表结构差异很大看网上的分析文章时先确认版本避免拿着新代码看旧文章、拿着旧代码看新文章越看越乱。6. 读源码的一点个人体会读Property Service源码这件事我前前后后做过两遍。第一遍囫囵吞枣只记住了“setprop会走socket”很多细节都是一带而过。第二遍带着问题去读比如“为什么这里用SO_PEERCRED而不是自己传uid”“为什么serial要先增后读”才慢慢品出这套设计的味道。建议你也带着具体问题去读比如“普通应用设置属性到底哪一步会被拦”沿着问题走一遍property_set再顺着SELinux日志回到代码往往比从头到尾读文件更有效。读完之后你不仅会懂一个属性怎么写入更会对多进程共享数据、中心化权限校验有更深的体感。这套思路放到任何需要“配置中心”的系统里都值得再琢磨一遍。
