HyperOS 的 Greezer 与 AOSP Freezer
摘要
一次针对 HyperOS 后台冻结机制的完整排查。从开发者选项中的 Cached Apps Freezer 开关出发,追踪到 PowerMillet、Greezer、Aurogon、SmartPower,以及最终落在 Linux cgroup v2 cgroup.freeze 上的真实冻结链路。
为什么 HyperOS 的“暂停执行已缓存的应用”总会变回关闭:从 AOSP Freezer 到 Greezer 的一次完整追踪
本文记录一次针对 HyperOS 3.x / Android 16 的实际排障。目标不是证明所有 HyperOS 设备都采用完全相同的实现,而是把一个具体样本上的行为从设置层、Framework、厂商服务一路追到 Linux cgroup v2 的真实冻结状态。
为降低个人信息泄露风险,本文不公开设备序列号、终端用户名、精确设备型号、具体第三方应用包名、PID/UID 列表和细粒度使用时间线。保留属性名、类名、函数名、系统路径和必要命令,因为它们是复现技术结论所必需的。
事情起因很简单。
HyperOS 的开发者选项里有一个 Android 原生选项:“暂停执行已缓存的应用”。在 AOSP 语境下,它对应 Cached Apps Freezer,用来冻结满足条件的 cached process,减少后台 CPU 执行和部分资源消耗。
问题是:把它手动切到“已开启”,确认、重启,回来以后它又变成“已停用”。
一开始这很像一个普通的设置持久化问题。实际追下去,却发现它牵出了一整套 Xiaomi 自己的后台冻结体系:PowerMillet、Greezer、Millet monitor、Aurogon、SmartPower,以及最终落在 Linux cgroup v2 cgroup.freeze 上的真实冻结操作。
最后得到的结论可以先放在这里:
这台样本设备并不是“不支持进程冻结”。相反,它一直在冻结后台进程。只是 HyperOS 正常模式下选择由 Xiaomi 的 Greezer/PowerMillet 负责,而不是让 AOSP Cached Apps Freezer 成为主冻结器。
更关键的是,这不是偶然冲突,而是 Framework 里明确写出来的互斥逻辑。
表面现象其实包含两个问题
最开始看到的是:
重启后得到:
手工改成:
立即读取可以得到:
所以 SettingsProvider 并不是完全不让写。
与此同时,还可以把 AOSP 的 DeviceConfig 开关设置为 true:
但运行态仍然是:
而且这个现象甚至不需要等重启:在系统已经运行的情况下,把 cached_apps_freezer 切为 enabled,Framework observer 会立即响应,但结果依然是 Freezer disabled。
这实际上说明有两个不同的问题:
为什么运行中已经写成
enabled,AOSP freezer 仍然不启用?为什么重启以后,
cached_apps_freezer还会被主动写回disabled?
后来证明,这两个问题分别对应两段不同的 HyperOS 代码。
先回到 AOSP:正常情况下 freezer 是怎么决定是否启用的
现代 Android 的 Cached Apps Freezer 主要由 CachedAppOptimizer 管理。
简化以后,AOSP 的判断大致是:
也就是说:
Global Settings 显式
disabled:关闭;显式
enabled:尝试启用;没有显式覆盖时,DeviceConfig
use_freezer=true:尝试启用;真正启用前,还要通过
Freezer.isFreezerSupported()的能力检测。
Android 16 上的 Freezer.java 已经非常薄,Java 层主要把调用转给 native:
native 能力检测会检查诸如:
FreezerStatetask profile 是否能解析到正确属性路径;cgroup.freeze是否能打开和读取;Binder freezer info 是否能正常获取;
Frozen/Unfrozenprofile 是否有效。
如果这些步骤失败,native 侧通常会留下很有辨识度的日志,例如:
这成为后面判断“能力检测究竟有没有被执行”的重要依据。
第一个弯路:看到某个 cgroup.freeze 不存在,不代表内核没有 freezer
排查早期很容易犯一个错误:照着常见示例去找类似路径:
文件不存在。
如果在这里停下,很容易得出“这个 ROM 没启用 cgroup freezer”的结论。
但继续枚举 /sys/fs/cgroup 后发现,真正的布局根本不是这样。
系统进程使用类似:
应用进程使用类似:
进一步检查 ROM 自己的 cgroups.json,可以看到 cgroup v2 根目录就是:
并且显式声明了 freezer controller。
task_profiles.json 里也存在:
同时 Frozen、Unfrozen profile 都存在。
所以这一步真正得到的结论不是“freezer 不存在”,而是:
早期只是套用了错误的 cgroup 路径假设。HyperOS 使用的是自己的 system/apps 分层。
这是这次排查里很值得保留的一个经验:Android OEM 完全可以重新组织 cgroup 层级。判断某个进程的真实 cgroup,最可靠的方法不是猜路径,而是先读:
再根据 0::/... 拼接到 /sys/fs/cgroup。
Binder 和 native 也不像是缺失
继续检查目标系统的 Framework 和 native 二进制。
services.jar 中的 Freezer.java 是新的 native delegator 版本,而不是旧式 Java 自己解析 freezer 路径的实现。
libandroid_servers.so 中可以找到前面那些 AOSP isFreezerSupported() 失败分支字符串。
libbinder.so 的动态符号里也可以看到:
这当然还不能单独证明 kernel ioctl 运行时一定成功,但至少可以排除一个简单假设:不是用户态 libbinder 连 Binder freezer API 都没有。
到这里,最异常的地方反而变成了日志。
每次切换 cached_apps_freezer,ActivityManager 都会马上打印:
但即使把 Freezer 相关日志调高,也没有看到任何 native 能力检测失败信息。
如果 nativeIsFreezerSupported() 真被调用并返回 false,理论上应该更容易看到它究竟失败在哪一步。
于是怀疑开始转向另一个方向:
有没有可能 HyperOS 在进入 native 能力检测之前,就已经在 Java 层把
useFreezer判成 false?
后来反编译 services.jar,直接找到了答案。
真正的硬门控:persist.sys.powmillet.enable
目标 HyperOS 的 CachedAppOptimizer.updateUseFreezer() 并不是原封不动的 AOSP 逻辑。
关键部分是:
这一段已经足够解释运行时现象。
只要:
就会直接进入:
甚至不会调用:
设备实测正是:
输出:
普通 shell 尝试修改:
会被 property service 拒绝,属性保持 true。
这就把前面的所有现象串起来了:
所以之前看不到 native freezer 失败日志,不是“日志被吞了”,而是那段代码根本没运行。
到这一步,运行中强制打开 AOSP freezer 为什么无效,已经有了直接答案。
但还有第二个问题:为什么重启以后设置值本身还会被写回 disabled?
答案在 miui-services.jar。
重启后自动变回 disabled:AurogonImmobulusMode 在主动维护互斥状态
在 /system_ext/framework/miui-services.jar 里,可以找到 Greezer 的 AurogonImmobulusMode。
它的 updateCtsStatus() 几乎把 Xiaomi 的设计意图直接写在代码里:
正常用户模式下,mCtsModeOn=false。
此时只要:
cached_apps_freezer当前是enabled,或者DeviceConfig 的
use_freezer=true
它就会主动:
而 initCtsStatus() 又会在 PowerMillet 启用时调用这套状态同步逻辑。
至此,最开始的“重启后为什么又变成已停用”也完全解释了。
两段代码分工非常清楚:
CachedAppOptimizer的 PowerMillet 门控负责:即使你把设置改成 enabled,也不真正启用 AOSP freezer;AurogonImmobulusMode负责:在正常模式下,把设置值本身重新维护成 disabled。
这不是 UI bug,也不是某个 SettingsProvider 保存失败,而是 ROM 有意识地保持两套冻结方案互斥。
CTS 模式反而会切回 AOSP freezer
更有意思的是,AurogonImmobulusMode 还实现了反向逻辑。
当进入它定义的 CTS 状态:
系统会:
这说明 Xiaomi 并不是“删掉了 AOSP freezer”。
更准确地说,它保留了 AOSP 路径,但在正常用户模式下选择 PowerMillet/Greezer;进入兼容性测试状态时,再切向更接近 AOSP 的行为。
反编译中还可以看到,某些被识别为 CTS/GTS 的包会触发:
因此不建议把“伪装 CTS 包”当成日常切换方案。尤其 persist.sys.powmillet.enable 是持久属性,而普通 ADB shell 又没有权限自由写回,拿这种测试机制当用户开关没有必要。
既然目标是接受并使用 Greezer,最合理的做法反而是不要触发这条兼容路径。
那么 Greezer 到底是什么
继续追 miui-services.jar,可以看到一套相当完整的厂商后台冻结框架。
系统 init 中存在三个 Millet monitor service:
它们实际上都由同一个程序启动:
只是传入不同参数:
运行时它们处于专用 millet SELinux domain。
GreezeManagerService 会注册这些 monitor,并用位图记录状态。三类通道都注册完成后:
Greezer 才正式进入完整工作状态。
这里很重要的一点是:这些 monitor 并不是最终执行 freeze 的组件。
它们更像是事件入口,给 Greezer 提供各种“为什么需要解冻”“什么时候可以重新冻结”的反馈。
实际历史里能看到类似行为:
也就是说,Greezer 不是一个简单的:
而更像一个事件感知的后台状态机:
这也是为什么只盯着 Android 开发者选项里的 AOSP freezer 开关,会误判 HyperOS 实际的后台冻结状态。
Greezer 不是“只记账”:它真的写了 cgroup.freeze
这次调查最关键的内核证据来自 FreezeUtils。
代码初始化时默认:
如果发现:
存在,就会切成:
也就是:
Greezer 还支持 UID 维度或 PID 维度的冻结。
样本设备上的:
没有显式设置,代码默认值是 false。
因此当前实际走的是逐 PID 冻结,而不是整个 UID 一次冻结。
实际路径形态是:
冻结就是写:
解冻写:
为了排除“dumpsys greezer 只是 Java 层自己维护了 Frozen 列表”的可能性,又做了一次运行态交叉验证。
先从:
取得一个匿名化的 frozen PID,然后读取:
得到类似:
再看对应内核节点:
实际值是:
同时:
显示:
这一步非常关键,因为它把三层状态连起来了:
所以可以确定:
这台设备上的 Greezer 确实通过 Linux cgroup v2 freezer 停止目标进程执行。
它不是一个 UI 包装,也不是单纯记录“我认为这个应用被冻结了”。
Greezer 冻结成功后还维护 SmartPower 和自己的状态
freezeProcess() 并不是写完 cgroup.freeze 就结束。
成功冻结后,它还会维护一系列上层状态,大体包括:
解冻路径则基本对称:
因此从架构上看,至少有三层:
这也解释了为什么 dumpsys greezer 能输出非常丰富的冻结历史和原因,而不只是一个当前 PID 集合。
Binder freeze 路径存在,但这个样本当前没有启用
Android 原生 Cached Apps Freezer 不只是冻结 CPU 执行,还会协调 Binder freezer。
Greezer 也保留了一条类似路径。
它的逻辑大体是:
解冻时则反向执行。
但 freezeBinder() 前面有两个 gate:
样本设备的 dumpsys greezer 显示:
所以目前实际情况是:
最终 freezeBinder() 直接返回。
也就是说,这台设备当前的真实冻结链更接近:
这里要特别避免一个过度推断:
supportEnable=false不等于 “Android Binder driver 不支持 BINDER_FREEZE”。
它只能说明 Xiaomi 自己的 mKernelSupportNew 能力握手没有成功置位。
因为目标系统的 Framework 里,一旦 gate 打开,ProcessUtils.freezeBinder() 最终调用的还是:
也就是回到 Android 自己的 Freezer.freezeBinder() / native Binder freezer 路径。
Xiaomi 在这里增加的主要不是另一套 Framework Binder API,而是:
是否使用 Binder freeze 的策略;
自己的能力握手机制;
和 Millet monitor / Greezer 状态机的配合。
那个神秘的 MAGIC_FREEZE_BINDER = 111
mKernelSupportNew 默认是 false。
它会在 reportNet() 收到一个特殊 UID 时被置为 true:
而:
当三个 Millet monitor 都注册完成后,Framework 会调用:
其中:
从现有证据看,这很像一种 Xiaomi 自己的 userspace ↔ native/kernel 能力握手:
当前设备停在:
所以这条握手没有完成。
调查到这里就停止了,没有继续去定位 nativeCall() 最终落在哪个 .so、是否通过某个 netlink 通道与内核交互,也没有继续解释为什么这个样本没有得到 111 回报。
原因很简单:这些已经不影响原始问题的结论,也不影响 Greezer 当前正常通过 cgroup v2 工作。
为什么之前看到的 mcd / octvm SELinux 日志不是根因
早期日志里还出现过一条很诱人的线索:某个厂商进程尝试创建旧式:
并被 SELinux 拒绝。
如果只看这一条日志,很容易把它和“freezer 启用失败”联系起来。
但后来它被排除为主因,理由包括:
日志主要出现在启动阶段,与手工切换
cached_apps_freezer的 live re-check 时刻对不上;当前 ROM 的真实 cgroup v2
cgroup.freeze已被验证正常;Greezer 的实际后端已经在
/sys/fs/cgroup/apps/.../cgroup.freeze成功冻结进程;最关键的是,
CachedAppOptimizer在 PowerMillet=true 时根本不执行 native freezer support check。
也就是说,即使修掉那条旧路径的 AVC,也不会改变:
这一 Framework 决策。
这是另一个值得记住的排障原则:SELinux denial 很显眼,但显眼不等于因果。必须看时间线和真实调用路径是否对得上。
Greezer 的控制面:可以看,但普通 ADB shell 不能随便操作
系统注册了一个 Binder service:
所以普通 shell 可以:
看到类似:
反编译还发现 GreezeManagerService 有一套很强的 ShellCommand,包括类似:
但这里有个明确的权限限制。
checkPermission() 的逻辑大致是:
ADB shell 的 UID 正是 2000。
因此设计上就是:
这解释了一个看起来有点矛盾的现象:
dumpsys greezer可以读;但不能指望普通
adb shell cmd greezer ...获得完整 freeze/thaw 控制权。
对于日常排查,这其实是合理的安全边界。
最终架构可以这样理解
把所有已经验证过的部分放在一起,这台样本设备上的后台冻结大致可以画成:
与此同时,AOSP Cached Apps Freezer 仍然留在 Framework 里,但正常模式下被另一个更高优先级的条件压住:
而 Greezer 的 CTS 状态维护又进一步确保:
所以最准确的描述不是“小米删掉了 AOSP freezer”,而是:
HyperOS 保留了 AOSP freezer,但正常用户模式选择由 PowerMillet/Greezer 接管后台冻结,并显式阻止两套主冻结策略同时工作。
这次排查最终排除了哪些假设
为了避免后来重新掉进同样的坑,可以把主要假设整理成下面这样。
假设 | 最终判断 | 关键依据 |
|---|---|---|
Linux 内核没有 cgroup freezer | 排除 | cgroup v2 freezer controller、 |
只是找错了 cgroup 路径 | 确认 | ROM 使用 |
| 排除 |
|
用户态 libbinder 没有 freezer API | 排除 |
|
AOSP | 不是主因 | PowerMillet=true 时根本不会调用它 |
Settings.Global 根本写不进去 | 排除 | 运行中可以成功写成 |
只是重启时某处覆盖了设置 | 只解释一半 | 确实有写回代码,但运行中 PowerMillet 也会直接阻止启用 |
mcd/octvm 的 SELinux AVC 是根因 | 排除为主因 | 时序不匹配,真实 cgroup v2 后端工作正常 |
HyperOS 注入了一个自定义 | 排除 | AMS 默认仍返回标准 |
Greezer 只是厂商 UI/Java 账本 | 排除 | Frozen PID 对应 |
Greezer 当前启用了 Binder freeze | 排除 |
|
| 未证实 | Xiaomi 自己的握手失败不能等价于标准 Binder ioctl 不存在 |
如果只想确认自己的 HyperOS 是否也是类似情况
以后再遇到同类问题,不需要重走这么长的路。
可以先看三个状态:
如果看到类似:
就很值得直接检查目标 ROM 的:
搜索:
如果 CachedAppOptimizer.updateUseFreezer() 里存在和本文类似的门控,那么不必再优先怀疑 cgroup 或 Binder。
然后检查:
如果 Greezer 是活跃的,再从 Frozen 列表中选一个测试 PID,做内核交叉验证:
如果得到:
那就已经足以证明实际冻结发生在 kernel cgroup v2 层。
对日常使用的结论
如果接受 HyperOS 自己的 Greezer,那么最合理的状态其实就是当前默认状态:
这时开发者选项里的“暂停执行已缓存的应用”显示“已停用”,并不代表后台进程不会被冻结。
它只代表:
AOSP Cached Apps Freezer 不是当前 ROM 的主冻结策略。
没有必要继续反复把它强制切成 enabled,因为 PowerMillet 的 Framework 门控仍会让最终 mUseFreezer=false。
同样不建议为了“让 AOSP freezer 看起来开启”去停掉 millet_sig、millet_binder、millet_pkg。这些 monitor 是 Greezer 事件反馈体系的一部分,停掉它们只会破坏厂商自己的状态机,却不会自动移除 persist.sys.powmillet.enable 这层门控。
也不建议把 CTS 兼容模式当成普通用户开关。它会修改持久 PowerMillet 状态、执行 thawAll() 并调整 Greezer 的 Binder freeze 配置,其目标明显更偏向兼容性测试环境,而不是日常电源管理切换。
还有哪些问题没有继续追
到停止调查时,仍然有几个值得继续研究、但已经不影响主结论的问题:
nativeCall(MAGIC_FREEZE_BINDER=111, ...)最终落在哪个 native library;它是否通过 Millet 的 netlink 或其他厂商内核接口完成能力握手;
为什么当前样本没有得到
reportNet(uid=111)回报,从而一直supportEnable=false;GreezeBinderProxyManager在 Binder freeze gate 没打开时承担了多少事务代理职责;Millet 的 BINDER monitor 与标准 Binder freezer 之间精确如何分工;
不同 SoC / HyperOS 分支是否会让
mKernelSupportNew=true;Greezer 和 AOSP Cached Apps Freezer 在真实功耗、推送延迟、后台兼容性上的量化差异。
这些问题都很有意思,但已经属于下一层研究。
原始问题本身已经闭环:
而最值得这次排查时间的地方,是最后确认了另一件事:
在这个样本上,真正工作的冻结链路是:
开发者选项只是露出了 AOSP 那一层开关,而 HyperOS 真正运行的后台冻结系统,在它下面还有一整套厂商实现。
