24 小时压测结束,导出 CPU 数据一看:
- 某进程 CPU% 是 -127%
- 两个进程 CPU% 加起来比整机还高
- 设备卡到 ANR,但整机 CPU% 只有 20%
采集工具跑了半年才发现,报出来的数据有一半不能信。以下是排查出的 8 个问题,按杀伤力排序。
坑 1:关核场景,两个 Bug 互相遮掩
现象:同一时间、同一台设备,A 工具报 CPU 23%,B 工具报 45%。
A 工具直接读 /proc/stat 做差值。B 工具用了一套"更精确"的自造公式:
分母 = 时间差(秒) × 总核数 × HZ
意图是算出"理论上产生了多少 tick"。听起来合理,但三个参数全在撒谎。
Bug 1:HZ 写死了 100
CONFIG_HZ 是内核每秒的 tick 中断次数,决定 /proc/stat 里时间片的粒度。写死 100 的后果:
硬编码 HZ=100:分母 = 10s × 4核 × 100 = 4000
设备实际 HZ=250:分母应该 = 10s × 4核 × 250 = 10000
分母少 2.5 倍 → CPU% 偏高 2.5 倍。20% 的真实值被报成 50%。
MTK 平台常用 HZ=250,部分高通平台 HZ=300。可通过
adb shell getconf CLK_TCK查看。
Bug 2:核数用的是"总核数"
4 核设备关了 2 核省电,公式分母还是 × 4。但 tick 只有 2 核在产生 → 分母偏大一倍 → CPU% 偏低 50%。
叠加效果:真实值 20% × 2.5(HZ 错)× 0.5(核数错)= 25%,和真实值只差 5 个百分点。
两个方向相反的 bug 互相抵消,制造了"差不多准"的假象——这就是它能存活半年没被发现的原因。
Bug 3:时间用的是宿主机的 time.time()
ADB 命令从发出到设备执行有 50~200ms 不等的延迟。用宿主机时间做除法,引入第三层随机误差。
根因
/proc/stat 汇总行本身就是所有在线核 tick 之和。核心关闭后不产生 tick,Δtotal 自然变小;上线后自动变大。内核已经帮你做好了在线核的汇总,根本不需要你手动重建分母。
正确做法
整机 CPU% = (Δtotal − Δidle − Δiowait) / Δtotal × 100
进程 CPU% = Δprocess / Δtotal × 100
一套公式,满核关核通用。三个"不需要":
- 无需 HZ——分子分母同单位,自动约掉
- 无需核数——汇总行只含在线核
- 无需宿主时间——纯设备内核数据
| 严重度 | 杀伤力 |
|---|---|
| 致命 | CPU% 偏差 2~3 倍,但因 bug 互相抵消可能永远发现不了 |
坑 2:iowait 到底算不算 CPU "使用"
现象:大文件拷贝时,CPU 显示 20%。以为 CPU 很空闲,又加了一个计算任务,设备立刻卡顿。
看 /proc/stat 增量:
user=50 system=30 idle=800 iowait=120 其余=0
Δtotal = 1000
不排除 iowait:(1000 − 800) / 1000 = 20%
排除 iowait:(1000 − 800 − 120) / 1000 = 8%
差 12 个百分点。那 20% 里有 12% 是 CPU 在等磁盘,没做任何计算。你以为"CPU 忙碌度 20%、还剩 80%",但真正在做计算的只有 8%。表面 20% 的"忙碌"里有一大半是在干等 IO。
两种口径
| 口径 | 公式 | 适用场景 |
|---|---|---|
| 计算密集度(推荐) | (total − idle − iowait) / total | 关注 CPU 算了多少东西 |
| 整体繁忙度 | (total − idle) / total | 关注 CPU 是否还有空 |
关键不在选哪种,在于团队必须统一口径。 同事 A 用"排除 iowait",同事 B 用"不排除",对着数据讨论永远对不上。
一个自查方法:跑一个纯磁盘读写(dd if=/dev/zero of=/sdcard/test bs=1M count=512),如果 CPU 显示 10%+,大概率是 iowait 被算进了"使用"。
| 严重度 | 杀伤力 |
|---|---|
| 中 | 12% 绝对偏差,IO 密集场景更严重 |
坑 3:整机和进程数据不是同一份快照
现象:监控 3 个进程,CPU% 加起来 85%,但整机 CPU% 只有 70%。"部分 > 整体"。
翻代码,结构是这样的:
for process in process_list:
dev_stat = adb_shell('cat /proc/stat') # ← 每个进程各读一次
pro_stat = adb_shell(f'cat /proc/{pid}/stat')
三个进程读了 3 次 /proc/stat,横跨 300~600ms。每次读到的 tick 不同,三个进程用的分母各不相同。更隐蔽的是,取核心数的函数里还额外读了一次(只为了数行数就丢掉)。一轮采集累计 7 次 ADB 调用,其中 4 次是 /proc/stat 的冗余读取。
正确做法
/proc/stat 只读一次,所有信息从同一份数据中提取:
raw = adb_shell('cat /proc/stat')
dev_use, dev_total = parse_cpu(raw)
alive_cores = count_cores(raw)
for process in process_list:
pro_stat = adb_shell(f'cat /proc/{pid}/stat')
# 所有进程共用同一份分母
ADB 调用从 2N+1 降到 N+1(N 为进程数)。数据一致性 + 性能,一箭双雕。
| 严重度 | 杀伤力 |
|---|---|
| 中 | 多进程 CPU% 加和超过整机值,对比逻辑矛盾 |
坑 4:进程悄悄重启了,你还在用旧基线
现象:24 小时压测,某进程 CPU 曲线在第 8 小时突然出现一个 -1788% 的尖刺,然后恢复正常。
/proc/{pid}/stat 的 utime/stime 是从进程启动时刻开始累积的。如果进程在两次采集之间 crash 并重启:
第 1 次采样:process_time = 85000(累积了 8 小时)
进程 crash → 自动拉起 → 新进程
第 2 次采样:process_time = 120(新进程刚跑了几秒)
Δprocess = 120 − 85000 = −84880
CPU% = −84880 / 4746 × 100 = −1788%
更隐蔽的变体:PID 复用
Linux 的 PID 是循环分配的(默认范围 1~32768)。进程 A(PID 12345)退出后,系统可能把 12345 分配给一个完全无关的进程 B。
你的采集工具还在读 /proc/12345/stat,数据格式正常、类型正常、数值也在合理范围内——不报错,但测的已经不是你的目标进程了。
在 24h+ 压测中,如果被测进程有偶发 crash + 自动拉起的逻辑,PID 复用几乎一定会出现。
正确做法——双重校验
delta_pro = cur_pro - old_pro
# 校验 1:负值 = 进程已重启
if delta_pro < 0:
old_pro = cur_pro # 重置基线
return None # 本轮不出数据,下轮重新开始
# 校验 2:进程身份核验(防 PID 复用)
cur_name = parse_comm(pro_stat) # /proc/{pid}/stat 里的进程名
if cur_name != expected_name:
new_pid = find_pid_by_name(expected_name)
if new_pid:
pid = new_pid
old_pro = 0 # 新 PID,重置基线
return None
| 严重度 | 杀伤力 |
|---|---|
| 致命 | CPU% 出现负值尖刺,或静默监控错误进程而不自知 |
坑 5:Δtotal = 0,除零直接崩
现象:压测脚本跑到凌晨 3 点挂了,日志:ZeroDivisionError: division by zero。
正常运行中 Δtotal 几乎不可能为 0,但两种真实场景会触发:
场景 1:跨越深度休眠
设备进入 suspend 后 CPU 完全停止,/proc/stat 不计时。唤醒后第一次采样和休眠前最后一次采样的 total 值可能完全相同。在自动化测试中,设备可能因为无操作超时自动休眠,被下一轮 ADB 命令唤醒。
场景 2:极短间隔 + 低负载
1 秒采集间隔,HZ=100,4 核关了 3 核,设备几乎全在 idle。理论 tick 增量 100/s,但如果两次采样恰好落在同一个 jiffies 更新周期 → Δtotal = 0。
正确做法
if delta_total <= 0:
return None # 丢弃本轮,下轮重算
dev_cpu = delta_use / delta_total * 100
app_cpu = delta_pro / delta_total * 100
# 上限校验(注意两个上限不同)
if dev_cpu < 0 or dev_cpu > 100:
return None
if app_cpu < 0 or app_cpu > alive_cores * 100:
return None
整机上限是 100%(Δuse ≤ Δtotal)。进程上限是在线核数 × 100%(多线程进程可以同时跑满多个核,4 核上限 400%)。
| 严重度 | 杀伤力 |
|---|---|
| 高 | 采集脚本崩溃,24h 压测数据中断 |
坑 6:采集工具是设备上最大的 CPU 消耗者
现象:测待机功耗,CPU 始终显示 3~5%。拔掉 USB、用纯电池计量,功耗低了 40%。
每次 adb shell cat /proc/stat,设备端发生:
- adbd 收到请求 → fork sh → fork cat
- cat 读 /proc/stat → 数据经 USB 回传
- sh 和 cat 进程退出,内核回收
2 次 fork + 2 次进程退出 ≈ 2~5ms CPU 时间。10 秒一轮、5 次 ADB 调用 → 采集自身占 10~25ms/10s,正常场景可忽略。
但两种场景下变成灾难:
场景 1:待机功耗测试
设备应该完全空闲。但 ADB 采集会唤醒 USB 控制器、唤醒 CPU 核心、阻止进入深度休眠。采集工具本身成了设备上唯一活跃的 CPU 使用者——你测到的是"设备被反复唤醒的功耗",不是"设备待机的功耗"。
场景 2:高频采集(1~2s 间隔)
每秒 35 次 ADB → fork 610 个进程,CPU 时间计入 system 字段 → 整机 CPU% 偏高 13 个百分点。低负载场景(整机 10%)下,**1030% 的相对误差**。
缓解方案
| 方案 | 效果 | 适用场景 |
|---|---|---|
| 合并 ADB(一次 shell 脚本读所有数据) | 调用次数减半 | 通用 |
| 设备端 Agent(push 脚本持续运行,stdout 流式输出) | 消除 fork 开销 | 高精度 / 低干扰需求 |
| 选对数据源 | 单次开销从 100ms 降到 3ms | 通用 |
三种方式的自身开销对比:
cat /proc/stat ≈ 2~3ms 最轻量
top -n 1 ≈ 50~200ms 需遍历所有进程
dumpsys cpuinfo ≈ 100~500ms 经 Binder + SystemServer
| 严重度 | 杀伤力 |
|---|---|
| 高(功耗场景) | 测到的是采集工具自己的性能 |
坑 7:/proc 文件解析的两个暗雷
两个问题单独看都不大,但合在一起说明一件事:/proc 文件是给人看的文本,不是结构化 API,格式比你想的脆弱得多。
暗雷 A:空格数量不一致
cpu 10132153 290696 3084719 ... ← "cpu" 后 2 个空格
cpu0 2503274 72633 771347 ... ← "cpu0" 后 1 个空格
用 split(' ') 按单空格拆分,汇总行多出一个空字符串,索引全部偏移。fields[5] 在汇总行上恰好是 idle,在 cpu0 行上变成了 iowait——同一套代码在不同行上指向不同字段。
最恶心的是:如果你只解析汇总行,这个 bug 永远不会暴露("恰好对了")。直到有一天你需要单核数据,才会莫名其妙地发现数值不对。
暗雷 B:进程名含空格
/proc/{pid}/stat 的格式:
12345 (com.example.app) S 1 12345 12345 0 ... utime stime ...
进程名被 () 包裹。按空格拆分后取 fields[13] 作为 utime。但如果进程名包含空格(某些系统进程如 (kworker/0:1 H)),所有字段索引错位。
不报错,只返回一个"看起来合理"的错误数字——因为错位后取到的可能是某个 ppid 或 pgrp 字段,也是整数。
统一修复
# /proc/stat:无参 split 合并所有连续空白
fields = line.split()
nums = [int(x) for x in fields[1:]]
idle = nums[3] # 永远是第 4 个数值字段
# /proc/{pid}/stat:用 ')' 做锚点
tail = raw.split(')')[1].strip().split()
utime = int(tail[11]) # ')' 之后固定偏移
stime = int(tail[12])
Linux 内核保证进程名被 () 包裹且名称内部不含 )(有则截断),所以 ) 锚点是内核级的安全保证。
| 严重度 | 杀伤力 |
|---|---|
| 中 | 静默返回错误值,可能长期隐藏 |
坑 8:cutime/cstime——子进程的 CPU 该不该算进来
现象:监控一个多进程 App,CPU 曲线每隔几分钟突然飙到 150~200%,然后立刻回落。看起来像性能毛刺,但复现不了。
第 5 篇给出的进程 CPU 公式是 process_time = utime + stime + cutime + cstime。这四个字段的含义:
| 字段 | 含义 | 增长方式 |
|---|---|---|
| utime | 进程自己在用户态的 tick | 持续累积 |
| stime | 进程自己在内核态的 tick | 持续累积 |
| cutime | 已退出子进程的用户态 tick | 子进程 exit 时一次性灌入 |
| cstime | 已退出子进程的内核态 tick | 子进程 exit 时一次性灌入 |
关键在"已退出"三个字。只有当子进程退出并被父进程 wait 回收后,子进程的 CPU 时间才会一次性累加到 cutime/cstime。
一个具体场景:
第 1 次采样:utime=5000 stime=1200 cutime=0 cstime=0 → total=6200
↓ 子进程干了 3 分钟的活后退出,cutime 一次性增加 2000
第 2 次采样:utime=5100 stime=1220 cutime=2000 cstime=300 → total=8620
Δprocess = 8620 − 6200 = 2420
但父进程自己只增长了 (5100-5000) + (1220-1200) = 120 tick,剩下 2300 tick 是子进程退出时灌入的。这一轮的 CPU% 会突然飙到正常值的 20 倍。
包含 vs 不包含
| 包含 cutime/cstime | 只用 utime + stime | |
|---|---|---|
| 含义 | 整个进程树的 CPU 消耗 | 目标进程本体的 CPU 消耗 |
| 曲线特征 | 子进程退出时有突发跳变 | 平稳,无跳变 |
| 适合场景 | 分析"这个 App 总共消耗了多少 CPU" | 实时监控曲线(推荐) |
大部分实时监控场景建议只用 utime + stime。如果需要分析进程树总消耗,建议单独上报一条 cpu_with_children 指标,不要和实时 CPU% 混在一起。
top命令默认只用 utime + stime,不包含子进程时间。如果你的公式包含了 cutime/cstime,就会和top的结果对不上——这也是"为什么我算的和 top 不一样"最常见的原因。
| 严重度 | 杀伤力 |
|---|---|
| 中 | CPU 曲线间歇性飙高,误判为性能毛刺;和 top 结果不一致引发困惑 |
修复清单
8 个坑归纳成 7 条改动:
| # | 改动 | 修了哪些坑 |
|---|---|---|
| 1 | 统一 Δuse / Δtotal 公式,删满核/关核分支 | 坑 1 |
| 2 | use = total − idle − iowait,团队明确口径 | 坑 2 |
| 3 | /proc/stat 只读一次,放循环外 | 坑 3 |
| 4 | 负值重置基线 + 进程名校验防 PID 复用 | 坑 4 |
| 5 | Δtotal ≤ 0 丢弃 + 结果上下限校验 | 坑 5 |
| 6 | 合并 ADB / 设备端 Agent / 选轻量数据源 | 坑 6 |
| 7 | 无参 split() + ) 锚点解析 | 坑 7 |
坑 8 的修复取决于业务需求:实时监控用 utime + stime,进程树分析用全量。
重构前后对比:
| 维度 | 重构前 | 重构后 |
|---|---|---|
| ADB 调用(3 进程) | 7 次 | 4 次(↓43%) |
/proc/stat 读取 | 4 次(数据不一致) | 1 次 |
| 关核处理 | 2 个分支 + 3 个假设 | 无分支,统一公式 |
| 进程身份校验 | 无 | 负值检测 + 进程名校验 |
| 边界保护 | 无 | 分母 / 符号 / 上限三道防线 |
| 子进程 CPU | 混在一起不可分 | 两个指标分开上报 |
小结
| 坑 | 严重度 | 症状 | 修复 |
|---|---|---|---|
| 1. 关核自造公式 | 致命 | CPU% 偏差 2~3 倍但不易察觉 | 统一 Δuse/Δtotal,删分支 |
| 2. iowait 口径 | 中 | IO 场景 CPU% 偏高 12%+ | 明确 use = total−idle−iowait |
| 3. 数据快照不一致 | 中 | 进程 CPU% 加和 > 整机 | /proc/stat 只读一次 |
| 4. 进程重启 / PID 复用 | 致命 | CPU% 负值或监控错进程 | 负值重置 + 进程名校验 |
| 5. Δtotal = 0 | 高 | 除零崩溃,数据中断 | 分母/符号/上限三道防线 |
| 6. 观察者效应 | 高 | 待机场景 CPU% 虚高 | 合并 ADB / 设备端 Agent |
| 7. /proc 解析 | 中 | 静默返回错误数值 | 无参 split + ) 锚点 |
| 8. cutime/cstime | 中 | 曲线间歇性飙高 | 实时监控只用 utime+stime |
这 8 个问题覆盖了 CPU 采集最常见的翻车场景。写采集逻辑时逐一对照,能避掉绝大部分坑。
修完这些坑,Raw CPU% 就够用了吗?
不够。 上面所有修复都是为了把 Raw CPU%(时间片占比)算准。但 Raw CPU% 有一个根本性的盲区——它不关心频率。
设备降频到 408MHz 后,50% 的时间片只能完成满频状态四分之一的计算量。Raw CPU% 会告诉你"50% 在忙",但不会告诉你"实际只干了 12% 的活"。
这就是下篇的主题:引入频率维度,从 Raw CPU% 进化到 Normalized CPU%。
下篇预告
CPU 降频了,你的采集数据还准吗?
同样 50% 的 CPU 占用,跑在 408MHz 和 1800MHz 有什么区别?为什么 CPU% 稳定在 45%,用户却反馈"越用越卡"? 下篇揭开频率维度对 CPU 数据的影响,引入 Normalized CPU% 概念。
系列目录
- 第 1 篇:内存泄漏自动检测(上)——采集层设计
- 第 2 篇:内存泄漏自动检测(中)——检测层设计
- 第 3 篇:内存泄漏自动检测(下)——响应层设计
- 第 4 篇:Android 内存采集避坑指南
- 第 5 篇:Android CPU 使用率采集入门
- 第 6 篇(本篇):CPU 采集的 8 个坑
- 第 7 篇(下一篇):CPU 降频了,你的采集数据还准吗?
- 第 8 篇:CPU 单核分析:均值是最大的谎言
- 第 9 篇:CPU 采集落地:从公式到平台的完整方案
我是测试工坊,专注 Android 系统级性能工程。 如果你也踩过 CPU 采集的坑,欢迎评论区交流 👇 关注我,后续更新不迷路。