深入 Android SystemProperties 属性系统全链路:从 init 进程属性服务到跨进程通知的系统级配置架构

做 Android 系统开发的人几乎每天都会跟 adb shell getprop 打交道,但 SystemProperties.get("ro.build.version.sdk") 为什么能在任意进程里调用、属性到底存在哪里、跨进程同步怎么实现、SELinux 在哪一层卡控——这些问题的答案,不翻源码很难串起来。

我在排查一个系统属性设不进去的 Bug 时,把整条链路走了一遍,发现它比想象中精巧得多。

属性文件的物理存储

Android 系统属性不是数据库,而是普通文本文件,分布在各个分区:

/system/build.prop          # 系统核心属性,ro. 前缀为主
/vendor/build.prop          # 硬件相关属性
/product/build.prop         # 产品定制属性
/system/etc/prop.default    # 默认属性(Android 10+)

文件格式极简,就是 key=value 的纯文本,一行一条:

ro.build.version.sdk=34
ro.product.brand=google
persist.sys.timezone=Asia/Shanghai

启动时,init 进程按顺序读取这些文件,后加载的覆盖先加载的同名属性。但 ro. 前缀例外——它在 init 的属性服务里被硬编码为只读,一旦设置就不可更改,这个约束不是文件系统权限决定的,所以即使后加载的文件里有同名 ro. 属性,也会被静默忽略。

persist.* 前缀的属性会持久化到 /data/property/ 目录下,每个属性一个文件,文件名就是属性名。重启后 init 重新加载,保证配置不丢失。如果在 /data/property/ 下看到一堆以属性名命名的文件,不用奇怪,这是正常行为。

init 进程的属性服务

属性系统的核心是一个跑在 init 进程里的属性服务(Property Service),监听一个 Unix Domain Socket:

// system/core/init/property_service.cpp
static constexpr const char kPropertyServiceSocket[] = "/dev/socket/property_service";

启动流程在 init 的 main() 中:

// start_property_service() 内部逻辑
void start_property_service() {
    // 1. 创建共享内存区域
    property_area = __system_property_area_init();
    
    // 2. 加载所有属性文件
    load_properties_from_file("/system/etc/prop.default", nullptr);
    load_properties_from_file("/system/build.prop", nullptr);
    load_properties_from_file("/vendor/build.prop", nullptr);
    // ...
    
    // 3. 监听 socket,接收来自其他进程的 set 请求
    listen(kPropertyServiceSocket, 8);
}

任何进程想设置系统属性,都要通过这个 socket 向 init 发请求。只有 init 进程有写入权限,其他进程只能读。这种单点写入、多点读取的架构,让属性系统天然避免了并发写入的竞争问题。

共享内存:跨进程读取的零拷贝机制

如果每次 getprop 都要走 socket 问 init,开销太大。Android 的解法是共享内存。

init 进程在启动属性服务时,通过 __system_property_area_init() 创建一块共享内存,映射到自己的地址空间。其他进程通过 __system_property_area__ 函数映射同一块物理内存,内核保证所有进程看到同一份数据。

共享内存的结构体简化后大致是:

struct prop_area {
    unsigned volatile count;     // 当前属性数量
    unsigned volatile serial;    // 魔法值,用于检测布局变化
    prop_info info[0];           // 变长数组,每个元素是一个属性
};

struct prop_info {
    unsigned serial;             // 属性被修改的次数
    char name[PROP_NAME_MAX];    // 属性名
    char value[PROP_VALUE_MAX];  // 属性值
};

读属性时,libc 提供 __system_property_find() 直接遍历这块共享内存,不需要任何 IPC 调用。adb shell getpropSystemProperties.get() 最终都走到同一个函数:

const prop_info* __system_property_find(const char* name) {
    // 直接遍历 prop_area 中的 info 数组
    // 无锁读取,利用 serial 字段做乐观锁检测
    for (unsigned i = 0; i < area->count; i++) {
        if (!strcmp(area->info[i].name, name)) {
            return &area->info[i];
        }
    }
    return nullptr;
}

写入路径则完全不同:调用方进程通过 socket 发送 PROP_MSG_SETPROP 消息给 init,init 校验后写入共享内存,然后更新 serial 字段。其他进程下次读取时通过 serial 变化感知到数据更新。

SELinux 的精准卡控

属性不是谁都能设的。Android 用 SELinux 对属性操作做细粒度控制,核心文件是 property_contexts

# system/sepolicy/private/property_contexts
ro.build.version.sdk      u:object_r:build_prop:s0
persist.sys.timezone       u:object_r:timezone_prop:s0
ctl.start                  u:object_r:ctl_start_prop:s0

每个属性被映射到一个 SELinux 安全上下文。当进程尝试设置属性时,init 会检查该进程的 SELinux domain 是否有对应 property_contextsset 权限:

// system/core/init/property_service.cpp
static bool check_mac_perms(const std::string& name, 
                            char* source_ctx) {
    // 调用 selinux_check_access() 查询 AVC (Access Vector Cache)
    bool has_access = (selinux_check_access(
        source_ctx,         // 请求进程的上下文
        property_context,   // 属性对应的上下文
        "property_service", 
        "set",              // 操作类型
        nullptr) == 0);
    return has_access;
}

不匹配的直接拒绝,内核日志里会留下 avc: denied 记录。我踩过一个坑:自己写的 native 服务想设 persist.vendor.xxx 属性,一直失败,查了半天发现是 property_contexts 里没声明这个属性的 context,而 fallback 规则默认禁止了非 system 进程的 set 操作。

注意ro. 前缀的属性在 SELinux 检查之前就已经被 init 拒绝——即使你有 SELinux 权限也改不了,这是两道独立的防线。

跨进程通知机制

属性改了之后,依赖方怎么知道?init 进程在写入属性后,会向所有注册了监听的进程发送 PROP_MSG_CHANGED 消息。

Java 层的监听入口是 SystemProperties.addChangeCallback(),底层通过 PropertyChangeListener 注册到 native 层。native 层启动一个监听线程,阻塞在 socket 上等待 init 推送变更通知。

这套通知机制有两个细节:

  1. 通知是批量的。短时间内多次修改同一属性,init 可能合并为一次通知,避免频繁唤醒监听进程。
  2. 通知不携带新值。收到 PROP_MSG_CHANGED 后,进程需要自己通过共享内存读取最新值。这样做有两个好处:通知消息体不会因为属性值过大而膨胀;即使你在处理通知时属性又被其他进程改了,读到的也一定是最新值。

实践建议

梳理完整条链路后,有几个点值得在日常开发中留意:

不要滥用 SystemProperties.set()。每次 set 都是一次 IPC 调用,经过 SELinux 校验、socket 传输、init 写入共享内存、序列号更新、通知广播,链路不短。高频读写场景用内存缓存,只在关键时机同步。

persist.* 属性谨慎使用。每次修改都会触发 /data/property/ 下的文件写入,频繁修改会带来磁盘 I/O 开销。如果只是运行时状态,用普通属性加开机重启即可。

排查属性问题时,三件套走一遍getprop | grep xxx 看当前值、dmesg | grep avc 看 SELinux 拦截、ls -la /data/property/ 看持久化文件。绝大多数问题在这三步里就能定位。

启动框架分阶段初始化:background/activity 两类 StartType 的设计与实践

App 启动阶段承载大量初始化逻辑,如果全部堆在 Application 中,冷启动耗时不可控。本文介绍一种分阶段初始化框架,将任务按 background 和 activity 两类 StartType 拆分,配合依赖声明、线程调度、异常降级和耗时监控,让初始化在正确时间完成必要工作。

图片加载统一门面:用 ImageUrlProcessor 动态裁剪与门面模式告别混乱的图片代码

图片加载是移动端体验的基础能力,但如果每个页面都直接调用底层库,URL 拼接规则、尺寸参数、预加载逻辑就会散落全项目。本文介绍一种图片加载统一门面设计,通过 ImageUrlProcessor 集中处理动态裁剪,配合门面接口、预加载调度和监控,让业务只关心展示意图。

深入 Android ContentProvider 跨进程数据共享:从 URI 路由到 ContentObserver 变更通知的全链路架构解析

深入解析 Android ContentProvider 跨进程数据共享机制,包括 URI 路由匹配、Cursor 跨进程透明代理与 ContentObserver 变更通知三条核心链路。

Android Perfetto 追踪全链路深度解析:从内核 ftrace 数据源到 SDK 自定义 TrackEvent 的生产级性能监控

从 traced + traced_probes 双守护进程架构讲起,覆盖 ftrace 内核数据源接入、Shared Ring Buffer 零拷贝传输、SDK 自定义 TrackEvent 埋点及 trace 文件格式优化,构建 Android 性能可观测性的统一底座。