前言此方法已经过气，请参阅 WSL2 2.0 更新。网上为 WSL2 配置静态 IP 的方案有非常多，其中比较方便的一是其自带的桥接，二是这里的提前创建网络法。然而，桥接实在是不太适合外部网络经常变化的环境（比如笔记本），而提前创建网络的方法则显得有些太过于 hacky 了：ICS 仿佛是 Windows 不愿暴露在外的“隐藏功能”，而且我也完全不知道他那网络的 GUID 是哪里来的，会不会存在向后兼容的问题。所以，有没有什么相对“正常”一点的方法，能够为 WSL2 创建一个类似 VMware NAT 模式的网络，既做到固定网段，又做到不受外部网络环境影响呢？这篇文章研究的就是这个问题。步骤先说步骤，最后再慢慢解释。首先，打开设备管理器，选中你的电脑名，点击上方的操作，选择“添加过时硬件”：选择手动安装：选择网络适配器：厂商选择 Microsoft ，找到其下的“Microsoft KM-TEST 环回适配器”：完成后，你应该就可以在“网络连接”中看到它了，可以随便改个名字啥的（无所谓）：接下来，打开 Hyper-V 的虚拟交换机管理器，创建一个外部网络，将其桥接到刚刚创建的环回适配器上：完成上一步后，“网络连接”中应该会出现一个新的名为“vEthernet(<你的外部网络名>)”的网络适配器，点开它的属性，为它设置一个 IPv4 地址，这个地址就是以后的宿主机 IP 了：接下来，需要下载 DHCP Server ，这是一个不需要安装（甚至不需要管理员权限）的 portable 软件。这个软件分为两个部分，一个是 dhcpwiz.exe 用于创建配置文件，另一个是 dhcpsrv.exe 用于提供 DHCP 服务。将下载的压缩包解压到某个位置后双击 dhcpwiz.exe 启动“配置器”。启动后，选择刚刚创建的外部网络，点击下一页：这一页无需配置，可以直接跳过：在这一页中，你可以配置 IP 池、租期等信息，别忘了点开 Advanced ，并在其中配置 DNS 和网关地址，网关地址需要被配置为宿主机 IP，同样的， IP 池也得和宿主机 IP 在同一网段 ：↑ 对于上面这一步，如果你只是想要固定 IP 段，像我这样配置即可。但是如果你想要固定 WSL2 的 IP 地址，那么需要将 IP 池进行收缩，收缩到只有一个地址时，WSL2 的 IP 也就固定下来了 xD。在这一页中，将对刚刚设置的内容进行保存。如果你之前没有配置过，直接点击 Write INI file 即可，如果之前配置过，则需要勾选 Overwrite existing file 来确保配置能够成功覆盖：接下来这一页，因为我不想给管理员权限，于是什么都不干，直接点击完成：↑ 至于开机启动，接下来自有办法。那么配置过程就到此结束了，接下来点开 dhcpsrv.exe 来在我们的网络上启动 DHCP：↑ 勾选 Don't show this window next time ，然后点击 Continue as tray app 让其最小化到托盘，此时 DHCP 服务已经在后台默默运行了。接下来我们在不给管理员权限、不安装服务的前提下，为它配置一个超干净的开机启动。打开“运行”，输入 shell:startup ，此时资源管理器会弹出 “启动” 窗口，我们只需要在其中为 dhcpsrv.exe 创建一个快捷方式即可。此时，网络本身算是配置好了，那就打开 .wslconfig 把 WSL2 接入进去吧：C:\Users\<用户名>\.wslconfig[wsl2] networkingMode=bridged vmSwitch=vNAT #这个要对应到上面在 Hyper-V 中创建的外部网络名称好了，现在 wsl --shutdown 然后重新打开 WSL2 ，看一看自己的 IP 地址，诶，是不是在目标网段了？甚至还就是右下角弹出的 DHCP Server 分配的那个：现在，宿主机和 WSL2 之间已经可以正常通信了，但是，WSL2 还不能访问外网，这是因为现在的网关是宿主机，而宿主机却并没有转发这些数据包的能力。接下来，我们要为宿主机配置这种能力，上 NAT ！这一步，很遗憾的，我没有找到可以用的 GUI ，只能上 Powershell 了，记得给管理员权限：New-NetNat -Name NAT -InternalIPInterfaceAddressPrefix 192.168.114.0/24这里指定的内部网段要与上面分配的网段相对应。好了，现在在 WSL2 中 ping 一下，网已经通了：如果它没有通，那么你就得检查一下防火墙了，比如我用的卡巴斯基就得把这个虚拟网络设置为“本地网络”或者“受信任网络”才能正常使用 NAT 。完成了，我们得到了一个类似 VMware NAT 模式的 WSL2 网络。解释接下来，解释一下这个过程，以及我为什么这么做。首先的首先，最根本的原因，WSL2 是基于 Hyper-V 的，而 Hyper-V 压根就不支持创建 NAT 模式的网络。嗯，它自带的 Default Switch 是一个 NAT 模式的，甚至自带 DHCP 的网络，为 WSL2 自动创建的名为 WSL 的网络也是如此。但，它们都是基于 ICS 服务的，在开机时/开机后首次打开 WSL2 时会被重新创建，因而地址全都在乱跳。ICS 几乎没有什么比较正常的手动配置方法，或者说，微软似乎很不情愿让你感知到它们的存在，它们不仅不在任务管理器中显示，也不在“网络连接”中显示，但它们确确实实是网络😂。文章“前言”中提到的那种直接调用 api 的方式算是一种非常 hacky 的 ICS 配置方法，由于没什么文档，我也没法在此处深入探究了。嗯，Windows 既有 NAT 功能又有 DHCP 功能，但就是不让你用，气不气？按照上面我们的搭建方式，其得到网络的拓扑结构是这样的：↑ 图中的命名就按上文中的来。我们最开始创建的回环网卡，被 Hyper-V 的“外部网络”交换机一分为二，分别连接到了宿主机和 WSL2 中，这是通信的物理基础。你可能会问，为什么我不直接创建一个“内部网络”，这样不也可以得到一对相互连接的虚拟网卡吗？是的，对于一般的 Hyper-V 虚拟机，这样确实是可以的。但是，WSL2 的 networkingMode=bridged 只支持类型为“外部网络”的网络，“内部网络”压根就接不进去🤣。所以，这算是一种迫不得已的 workaround 。不得不说，微软在优雅的地方真的能做到非常优雅，但是这种屑的地方又能做到非常蛋疼。这样建立的网络仅仅只是在数据链路层完成了配置，网络层的配置并没有完成。此时其实在 WSL2 中手动配置一个同网段的 IP 地址是能够通信的，但是对于 WSL2 这种东西来说，从外部配置远比从内部配置来的简单和稳定，这便是为什么需要搭建一个 DHCP 服务让 WSL2 能够自动获取 IP 地址，或者说，我只是在复现 ICS 自带的功能罢了。Windows 完全是自带了 DHCP 服务能力的，比如 ICS 自身具备的那个，又或者 Windows Server 系统中提供的那个，但，对于我们这些普通用户，微软就是不让你用，气不气？又是一次迫不得已，选择了 DHCP Server 。接下来说一说 NAT ，这个东西的文档是在这里找到的，我取了其中创建 NAT 的一行命令，因为这个东西是真的找不到 GUI 。另外，这个 NAT 是不支持 IPv6 的（微软：怎么样就问你服不服？）。问题首先是 IPv6 ，上面也说到了，New-NetNat 创建的 NAT 不支持 IPv6 ，我们使用的 DHCP Server 也不支持 DHCPv6 。其次，假如宿主机采用的是拨号上网，那么我们这样创建的网络是无法正常连接外网的，表现为 ping 都正常，但是 https 全部 insecure ，tls 握手直接爆炸。这并不是我们的配置哪里有问题，而是 Hyper-V 的 bug ，不信你试试 Hyper-V 自带的 Default Switch ，在拨号上网环境下也是这个鸟样。真有你的啊微软。↑ 但是 WSL2 自动创建的那个名为 WSL 的网络并没有这个问题，可能是修复了但又没有完全修复（翻了翻 issue 发现以前好像也有这个问题，但好像被修复了，那为什么普通的 Hyper-V 就没修？？？）。后记由于拨号上网是刚需，这篇文章中的经验对我来说已经是答辩了。如果它能对你有所帮助，那我的努力也算没有白费🤣。
借助 uprobe 实现对应用帧生成时间的监听
English前言前段时间有个概念很火，它被称为 FAS (Frame time Aware Scheduling) ，即根据帧生成时间而非处理器负载来调控处理器频率。这种操作能够在不影响帧率的前提下尽可能的降低处理器频率，从而改善游戏的能效。不少手机厂商也采取了类似的思路来改善游戏功耗，如小米的 FEAS 。接下来，我想要明确一下接下来会用到的概念。首先是“帧时间”和“帧生成时间”，这两者看着类似但实际上是完全不同的。“帧时间”是两次显示内容改变之间的间隔，“帧生成时间”则是生成这一帧花费的时间。以上面手搓的这张图为例，这张图描述了简化的帧渲染——显示流程(1)。上图中，横向为时间轴，绿色竖线代表在该时刻发生了 vsync 信号，红色部分代表新的帧正在生成。红色标号代表第 n 帧正在生成，蓝色标号代表第 n 帧正显示在屏幕上。在 vsync 信号发生时，之前已经生成完毕的帧会被显示在屏幕上，而新一帧的生成也会开始。我们来详细解释一下上面这张图：首先，第 1 帧开始生成，这一帧的生成很快就完成了，但是必须等到 vsync 信号到来时才会被显示在屏幕上，于是它凭空等待了一会儿。vsync 信号到来，第 1 帧被显示在了屏幕上，此时第 2 帧的生成开始。第 2 帧的生成也很快就完成了，但必须继续等待下一个 vsync 信号。vsync 信号到来，第 2 帧被显示在了屏幕上，此时第 3 帧的生成开始。第 3 帧的生成看起来有点慢。vsync 信号到来，但是此时第 3 帧的生成还没有完成，于是屏幕显示的内容没有改变，第 2 帧继续显示在屏幕上。此时，我们便可以认为发生了丢帧。vsync 信号到来，这下第 3 帧已经生成好了，于是它被显示在了屏幕上，同时第 4 帧的生成开始了。通过上面的这些解释，我想你应该能够更好的区分“帧时间”和“帧生成时间”了。在上面的图中，被蓝色括号包住的部分即为第 n 帧的“帧时间”，被红色括号包住的部分即为第 n 帧的“帧生成时间”。可以看到，“帧生成时间”能够直接反应当前性能，假如某一帧的“帧生成时间”超过了 vsync 间隔（即屏幕刷新间隔），丢帧势必发生。在丢帧已经开始发生时，“帧时间”也在一定程度上能够反应性能。对于上面所说的 FAS ，最佳的实践方式是：使“帧生成时间”尽量从左侧逼近（小于等于）“屏幕刷新间隔”。但是，实际上，这并不是一件容易做到的事情，原因在于“帧生成时间”的数据实在是有些难以获取。一般而言，想要获取“帧生成时间”，是要对应用进程下手的，毕竟帧的渲染流程主要发生在对应的应用进程中(2)。“帧时间”则可以直接从 surfaceflinger 中获取，相对来说就会方便许多。于是，现在常见的 FAS 实现，如 Scene FAS ，采用的都是使用“帧时间”来替代“帧生成时间”的方案，比如控制处理器频率使得“帧时间”尽量稳定。对于游戏类应用，这种方式其实就已经能够起到不错的效果了，因为游戏画面的帧率往往是稳定且连续的，但是对于日常应用，这可是要翻车的。日常应用中，我们可能会遇到许多画面完全静止的情况，比如在阅读时。在这种情况下，是不存在帧的刷新的(3)，那么此时的“帧时间”将会变得非常大，但是，这能够说明此时因为性能不足而丢帧吗？并不能，因为此时是压根没有新的帧，而不是因为性能不足而丢帧。“帧时间”存在的这种缺陷使得 FAS 思想迟迟难以被使用到日常应用中。这篇文章所展示的内容将会帮助解决这一困境，但也只能解决基于 libhwui 的普通 Android 应用的“帧生成时间”获取问题，对于采用自己渲染引擎的游戏类应用，到目前为止，使用“帧时间”代替“帧生成时间”仍然是最好的妥协方案。注释：(1) 没错，这真的简化了很多东西，但拿来帮助理解我觉得完全没有问题。在真实情况下，由于 GPU 的异步性，还存在三重缓冲的问题，而且 vsync-sf 和 vsync-app 信号发生的时间也不一定是同步的，这些信号的发生时间甚至可能不是固定的，详见 (3) 。(2) 我曾经想过在 surfaceflinger 中把分发 vsync 信号到应用 queuebuffer() 之间的间隔作为“帧生成时间”，但是没有成功，因为这对于在内核中操作来说太不友好了，需要大量的逆向和面向寄存器编程工作。但是，对于第三方 ROM ，这也许是一个可行的思路。不过鉴于应用在收到 vsync 后不一定会更新帧，那就还得弄清楚这个 queuebuffer() 属于哪一次 vsync 信号，还是有些难度👀。(3) surfaceflinger 自身使用的（vsync-sf）和向应用程序分发的（vsync-app） vsync 信号并不直接来自于 hwcomposer 。surfaceflinger 自身维护了一个软件 vsync 发生器，hwcomposer 发来的硬件 vsync 信号会被送去校准这一“软件发生器”。不必要时，硬件 vsync 信号甚至会被直接关闭掉。这也就意味着，软件发生的 vsync 信号不一定是规律且连续的，vsync-sf 和 vsync-app 信号之间也可以存在相位差。以 vsync-sf 信号为例，我的理解是这样的（不一定准确）：只有在应用提交了 Graphic Buffer 后，surfaceflinger 才会 schedule 下一个 vsync-sf 来刷新屏幕；假如压根就没有应用提交 Graphic Buffer ，就不存在下一个 vsync-sf 信号，屏幕也就不会刷新。从 uprobe 开始Linux 内核提供了一种机制，通过替换应用内存页面中指定地址上的指令为“断点异常指令”，从而使应用在指定的时机陷入内核态并执行回调代码，这一机制被称为 uprobe 。总的来说，这个过程是这样的：替换指定地址上的指令为“断点异常指令”。应用执行到该指令，陷入内核态。在内核态查询注册的 uprobes 及其 handler ，并执行指定的回调。单步执行之前被替换的指令。恢复应用程序，使其在断点指令之后继续正常执行。在陷入内核态时，刚刚应用处于用户态时的寄存器信息会被保存起来。在 uprobe 回调函数中，我们可以访问这些信息，甚至可以修改这些信息。uprobe 的典型使用方法是基于 tracefs 的，就比如 官方使用指南 。但很遗憾，这种东西对于内核侧开发并没有什么帮助。在内核侧使用 uprobe api 相关东西的资料在网上也比较少，那接下来就简单介绍一下，投喂一下 new bing 。以下内容基于 4.19 内核。#include <linux/fs.h> #include <linux/uprobes.h> #define BINARY_PATH "/bin/my_binary" #define OFFSET 0x114514 static int my_handler( struct uprobe_consumer *self, struct pt_regs *regs) { pr_info("Here we are in uprobe callback"); return 0; } static struct uprobe_consumer my_consumer = { .handler = &my_handler }; static void my_uprobe_init(void) { struct path p; struct inode *i; int ret; ret = kern_path(BINARY_PATH, 0, &p); if (ret) goto error; i = d_inode(p.dentry); ret = uprobe_register(i, OFFSET, &my_consumer); if (ret) goto clean; return; clean: path_put(&p); error: pr_err("Unable to register uprobe, ret = %d", ret); }可以看到，注册 uprobe 的关键函数是 uprobe_register(struct inode *inode, loff_t offset, struct uprobe_consumer *uc) 。这个函数需要传入三个参数，首先是那个需要被替换指令的文件的 inode ，其次是需要被替换的指令在文件中的偏移量，最后则是用以指定处理方式的 uprobe_consumer 。inode 我们可以使用上面代码中所示的方式简单的拿到；偏移量则非常的关键，我们留到下面专门说；uprobe_consumer 中其实除了用来指定回调的 handler 外，还有用于指定 uretprobe 回调的 ret_handler ，以及用于过滤的 filter 。这些内容由于这里用不到就不再展开了，有兴趣可以自己去阅读相关代码。接下来讲一讲非常关键的偏移量，这个点我感觉官方文档并没有讲清楚。这里的偏移指的是需要被替换的指令在文件中的偏移量，既不是该指令在虚拟地址空间中的地址，也不是该指令相对于 .text 段的偏移量。不知道为什么，官方文档 把在 .text 段中的偏移量当成在文件中的偏移量了。接下来用一个小例子来看看这个偏移量该如何计算。#include <stdio.h> void hello() { printf("Hello\n"); } int main(int argc, char *argv[]) { int i = 10; while (i--) { hello(); } return 0; }这是一段非常简单的代码，它会调用 hello() 十次。编译完成后(1)，我们可以使用 readelf -s 命令打印 hello 函数在虚拟地址空间中的地址：$ readelf -s obj/local/arm64-v8a/up-test | grep hello 46: 0000000000001744 28 FUNC GLOBAL DEFAULT 14 hello可以看到，其地址为 0x1744 。接下来，我们使用 readelf -S 寻找 hello 所在的 .text 段在虚拟内存空间中的地址：$ readelf -S obj/local/arm64-v8a/up-test | grep .text [14] .text PROGBITS 00000000000016ac 000006ac在这里，0x16ac 即为 .text 段在虚拟内存空间中的地址。那么，0x6ac 又是什么呢？它是 .text 段在该文件中的偏移量。此时，我们想要创建一个监控 hello 调用情况的 uprobe ，我们不妨就把断点放在 hello 的入口处吧，我们需要计算 hello 的入口处指令在文件中的偏移量。我们已经掌握的是：hello 函数的虚拟地址、hello 所在的 .text 段的虚拟地址、.text 段在文件中的偏移量。那这就是一个小学数学题了。hello 的文件中偏移 = hello 的虚拟地址 - .text 段的虚拟地址 + .text 段在文件中的偏移 0x1744 - 0x16ac + 0x6ac = 0x7440x744 就是那个我们该用来注册 uprobe 的文件中偏移量。当然，更简单的方法也不是没有，像 ida 之类的逆向软件，就会直接把文件内偏移量显示在底下：噢对了，回调函数还有个返回值，如果不想进行什么骚操作（比如触发了这个 uprobe 就移除它之类的），返回 0 即可。注释：(1) 对于这种代码，编译器很容易把这个函数 inline 优化掉，所以记得加上 -O0 。此外，如果编译采用的是 Android NDK ，那么它会自动 strip 掉符号表，导致 readelf -s 一片空白。解决方案是使用 obj/ 下生成的中间产物，而非 libs/ 下的最终产物。帧生成时间数据源这是一个相当头疼的点，上面我有写到 surfaceflinger 也许可以作为一个入手点，但是要从中区分出不同应用、区分出哪次 queuebuffer() 对应哪个 vsync 的难度极大。而且更关键的是编译完的 surfaceflinger 不会携带调试符号表，再加上它是 c++ 写的，逆向起来简直就是依托答辩：即使这次定位到了目标方法，下一次、换一个人、换台设备可能就只能以失败告终。我突然想起了以前有个玄学叫做 “GPU 呈现模式分析” ：这玩意儿的数据源是什么？图表又是由谁画出来的？如果数据源在系统服务，那它是怎么搜集数据的？如果数据源在应用本身，那它是怎么把这些数据发给系统服务来画图的？简单看了看代码，我发现我一个都没猜对，真实的情况是：应用自己搜集数据，应用自己画图。也就是说，这个图并不是系统叠加上去了什么东西，而是应用在绘制完界面后又主动绘制了这幅图，从搜集数据到画图的过程中不存在任何的 IPC 。那，为什么我一个开关就能控制一堆应用开始自己画图，这不是很奇怪吗？在这背后，“GPU 呈现模式分析”的开关控制的是 debug.hwui.profile 这个 prop ，当这个 prop 的值为 visual_bars 时，各个应用就会开始主动画图了。那，为什么应用们会千篇一律的监听这个 prop ，会自主搜集数据，还会画样式统一的图表呢？难道应用开发者们都提前约好了吗？并没有约好，只是因为一般的 Android 应用都是基于平台 api 所提供的组件（比如 View ）开发，而这些组件的硬件加速渲染过程会调用到系统自带的 libhwui 库，我们所熟知的 skiavk、skiagl 就是 libhwui 中渲染管线的一部分。事实上，上面监听 prop 和画图的过程本身就被做在 libhwui 当中，而数据搜集的过程是由预埋在平台 api 中的“时间采样点”完成的。所以简单回答上面的问题：虽然大家的应用会一起主动的搜集数据和画图，但这不是因为大家都写了搜集数据和画图的代码，而是因为大家使用的平台 api 本身就包含了这些东西。并不是所有的应用都有这种默契，比如浏览器类和游戏类应用，它们往往有自己的渲染引擎，不会使用 libhwui 所提供的渲染管线。对于这些应用，我们就没法使用这种方法来获取“帧生成时间”了。至此，我们明确了一般的应用其实都会默默搜集渲染相关性能数据的事实，这些数据原本是为 “GPU 呈现模式分析” 服务的，但是，现在轮到它们来服务我们了。数据的搜集过程接下来展开讲一讲平台 api 自带的东西是如何进行数据搜集的。在这之前，得先了解一下应用是如何对外界做出响应的。响应过程主要牵扯到两个线程，一个是大名鼎鼎的 UI 线程，另一个则是对应用开发者相对透明的渲染线程（RenderThread）。surfaceflinger 会向应用程序发出 vsync-app 信号，应用在收到这一信号后即要进行下列的工作：(UI 线程) 处理输入事件（如屏幕滑动）（外加使得下一步中牵扯到的组件发生改变，于是就响应起来了）。(UI 线程) 遍历 View 树，调用相关组件的 draw() ，并记录产生的绘制调用（比如“在(0,0)画个圆”）。(UI 线程) 为正在进行的动画记录所需的绘制调用。(UI 线程) ......(UI 线程) 递交所记录的绘制调用给渲染线程。(渲染线程) 整理、优化绘制调用。(渲染线程) 递交绘制调用给 GPU 。(渲染线程) 递交 Graphic Buffer 给 surfaceflinger(1)。需要注意的是，上面的这一整套过程都已经被包含在了平台 api 中，需要应用开发者开发的，往往只是这个过程中牵扯到的一些回调，比如 onTouchEvent() 和 onDraw() 。总的来说，UI 线程负责产生绘制调用，渲染线程负责递交绘制调用给 GPU 。上面这一整套过程需要在一帧（甚至更短）的时间内完成(2)，否则就会造成跳帧和卡顿。我们常说不要在 UI 线程上进行耗时操作，从上面的过程中可以看到，在 UI 线程上进行耗时操作会耽误绘制调用的产生，从而使上面的一整套流程难以在一帧的时间内跑完。众所周知，普通的 Android 应用可以由 java 和 native 部分组成，平台 api 也是如此，我们可以将上面的这套流程分为 java 和 native 部分：(java 层) 处理输入事件（如屏幕滑动）（外加使得下一步中牵扯到的组件发生改变，于是就响应起来了）。(java 层) 遍历 View 树，调用相关组件的 draw() ，并记录产生的绘制调用（比如“在(0,0)画个圆”）。(java 层) 为正在进行的动画记录所需的绘制调用。(java 层) ......(native 层) 递交所记录的绘制调用给渲染线程。(native 层) 整理、优化绘制调用。(native 层) 递交绘制调用给 GPU 。(native 层) 递交 Graphic Buffer 给 surfaceflinger(1)。总的来说，java 层的部分用来为应用开发者提供方便的开发接口，native 层则是渲染引擎的主场，这里的 native 部分主要就由 libhwui 负责。接下来开始正题，现在我们已经了解了应用从收到 vsync 信号到产生帧的大致流程，为了追踪这一整套流程的性能，Android 在平台 api 中埋入了许多“时间采样点”。以 java 层为例：frameworks/base/core/java/android/view/Choreographer.javavoid doFrame(long frameTimeNanos, int frame, DisplayEventReceiver.VsyncEventData vsyncEventData) { ...... ...... ...... mFrameInfo.setVsync(intendedFrameTimeNanos, frameTimeNanos, vsyncEventData.preferredFrameTimeline().vsyncId, vsyncEventData.preferredFrameTimeline().deadline, startNanos, vsyncEventData.frameInterval); ...... ...... mFrameInfo.markInputHandlingStart(); ...... mFrameInfo.markAnimationsStart(); ...... mFrameInfo.markPerformTraversalsStart(); ...... ...... }这些“时间采样点”会记录 vsync 信号开始的时间以及那些关键且耗时的流程的发生时间，从而为性能追踪提供依据。同样的，native 层的 libhwui 渲染库中也有类似的采样点，这里我就只展示头文件中的采样方法了：frameworks/base/libs/hwui/FrameInfo.hvoid markSyncStart() { set(FrameInfoIndex::SyncStart) = systemTime(SYSTEM_TIME_MONOTONIC); } void markIssueDrawCommandsStart() { set(FrameInfoIndex::IssueDrawCommandsStart) = systemTime(SYSTEM_TIME_MONOTONIC); } void markSwapBuffers() { set(FrameInfoIndex::SwapBuffers) = systemTime(SYSTEM_TIME_MONOTONIC); } void markSwapBuffersCompleted() { set(FrameInfoIndex::SwapBuffersCompleted) = systemTime(SYSTEM_TIME_MONOTONIC); } void markFrameCompleted() { set(FrameInfoIndex::FrameCompleted) = systemTime(SYSTEM_TIME_MONOTONIC); }这些方法会在渲染管线的关键位置被调用，记录下对应的时间，并为后续的性能追踪提供依据。注释：(1) 此时 GPU 渲染不一定完成了，这里牵扯到 fence ，内容非常复杂，故不再展开了。(2) 完成并不一定意味着要产生帧，也不一定需要递交 surfaceflinger ，比如你的应用就是一个静态界面时。这里强调的是没有及时完成一定会跳帧。数据利用接下来，就该研究一下该如何利用这些本属于“GPU 呈现模式分析”的数据了。我们已经知道了，画那幅玄学分析图的逻辑是被做在 native 层 libhwui 的渲染管线中的，而数据采集过程则又同时发生在 java 层和 native 层中。这个过程中有什么弱点吗？或者说，有什么地方比较适合 uprobe 插入断点指令吗？在 java 层，显然是没有的，这东西牵扯到虚拟机和 JIT ，依托答辩难以下手。纯的 native 层，好像也没有，它压根就没有保留符号表，找一个函数都费劲。但是，在 java 层搜集的数据总需要被传递到 native 层吧？毕竟用这些数据画图可发生在 native 层。诶，这个传递过程总是固定的吧？毕竟 jni call 可是需要固定的符号的。这就是一个比较简单的下手点。先来看看 java 层是怎么把数据传递到 native 层的：frameworks/base/graphics/java/android/graphics/HardwareRenderer.java/** * Syncs the RenderNode tree to the render thread and requests a frame to be drawn. * * @hide */ @SyncAndDrawResult public int syncAndDrawFrame(@NonNull FrameInfo frameInfo) { return nSyncAndDrawFrame(mNativeProxy, frameInfo.frameInfo, frameInfo.frameInfo.length); }这个方法，便是把刚刚采集的数据塞进 native 层的通道。其中，frameInfo.frameInfo 是一个数组，数组的不同下标代表了不同的数据（比如各个阶段的开始时间），具体定义可参见 FrameInfo.java 。它调用的 nSyncAndDrawFrame() 是一个 native 方法，不过这个方法并没有作为符号导出，而采用的是动态注册：frameworks/base/libs/hwui/jni/android_graphics_HardwareRenderer.cppstatic const JNINativeMethod gMethods[] = { ...... {"nSyncAndDrawFrame", "(J[JI)I", (void*)android_view_ThreadedRenderer_syncAndDrawFrame}, ...... }; ...... int register_android_view_ThreadedRenderer(JNIEnv* env) { ...... return RegisterMethodsOrDie(env, kClassPathName, gMethods, NELEM(gMethods)); }它实际被绑定到的 native 方法是 android_view_ThreadedRenderer_syncAndDrawFrame 。我们来看看它：frameworks/base/libs/hwui/jni/android_graphics_HardwareRenderer.cppstatic int android_view_ThreadedRenderer_syncAndDrawFrame(JNIEnv* env, jobject clazz, jlong proxyPtr, jlongArray frameInfo, jint frameInfoSize) { LOG_ALWAYS_FATAL_IF(frameInfoSize != UI_THREAD_FRAME_INFO_SIZE, "Mismatched size expectations, given %d expected %zu", frameInfoSize, UI_THREAD_FRAME_INFO_SIZE); RenderProxy* proxy = reinterpret_cast<RenderProxy*>(proxyPtr); env->GetLongArrayRegion(frameInfo, 0, frameInfoSize, proxy->frameInfo()); return proxy->syncAndDrawFrame(); }java 层传进来的数组到了 native 层变成了一个 handle ，需要通过调用虚拟机的对应方法来取得数组元素，比如 env->GetLongArrayRegion() 就把数组元素全部 copy 到了 proxy->frameInfo() 。到了这里，应该能够意识到我们所在的 android_view_ThreadedRenderer_syncAndDrawFrame 是一个非常适合插入 uprobe 断点的方法。这个方法本身虽然没被作为符号导出，但是注册它时使用的 nSyncAndDrawFrame 是一个常量，那么顺藤摸瓜的找出这个方法的地址应该不成问题。再看看这个方法的内容，在调用完 env->GetLongArrayRegion() 后，proxy->frameInfo() 不就存储着我们想要的帧数据吗？那可太棒了，这个方法又好找，又短，又能够方便的获取目标地址，我想应该很难找到一个更完美的 uprobe 对象了。断点选择接下来，拿起逆向工具，打开 /system/lib64/libhwui.so ，来确定用于 uprobe 的 offset ！这里以 ida 为例，我们可以直接搜索关键的 nSyncAndDrawFrame ，并找到使用它的地方（注意，不是定义它的地方）：这里，便是我们在上面看到的 gMethods 注册表，而标黄的地方便是目标 android_view_ThreadedRenderer_syncAndDrawFrame 的指针（ida 给它起了个额外的名字）。双击它，我们可以跳转到 android_view_ThreadedRenderer_syncAndDrawFrame 开始的地方：（如果你的图是流程图形式，可以按空格切换到这种代码形式）接下来，我们把汇编对应到上面的原始代码，整理一下这个函数的过程。首先是红色括号，它所对应的原始代码是 LOG_ALWAYS_FATAL_IF() ，做的是比较和跳转执行，我们不需要管它。接下来的蓝色括号则是关键部分，它负责为 GetLongArrayRegion() 准备参数。绿色箭头所指的指令，便是跳转执行 GetLongArrayRegion() 的地方。橙色部分下面再说。接下来简单介绍一下 fastcall 函数调用约定：它约定了调用函数时，采用寄存器而不是栈来传递参数（除非参数太多）。这里的函数调用采用的就是 fastcall 。在 arm64 上，整型参数会被塞进 X 寄存器，比如 this 指针会被塞进 X0 ，第一个参数会被塞进 X1 ，第二个参数被塞进 X2 ，依此类推（前提是参数不要太多）。可以看到，用来存储帧数据的目标地址 proxy->frameInfo() 是第四个参数，于是在准备参数时，我们最最想要的目标地址将会被放在 X4 寄存器中。粉色箭头所指向的指令，便把目标地址塞进了 X4 寄存器。那么，是不是在绿色箭头所指向的指令执行完成后，我们对 X4 寄存器中存储的地址取值，就能够获得帧数据的第一个元素呢？不对，因为执行绿色箭头所指向的指令，会跳转执行另一个函数，在那个函数中，X4 寄存器的值可能早就被覆盖掉了。这就意味着，我们需要两个 uprobe 断点才能读取到帧数据。第一个断点，应该被放在 X4 寄存器赋值之后，即标号为 1 的橙色箭头位置。我们将在此处借助 uprobe 保存 X4 寄存器的值，从而取得存储帧数据的地址。第二个断点，应该被放在 “跳转执行” 指令之后，即标号为 2 的橙色箭头位置。此时帧数据刚刚被保存到对应地址中，我们需要借助 uprobe 把帧数据取出来。于是，帧生成时间有了，我们就可以进行任何想要的操作了。你可能会问，为什么是“之后”，而不是就放在那条指令上？那，可以回上面阅读一下 uprobe 的时序。造好的轮子https://github.com/libxzr/kernel-playground/commits/playground/hwui_mon我已经把从操作 uprobe 到计算帧时间的相关逻辑造成了一个轮子，基于 4.19 内核。你所需要做的，仅仅只是把所需的断点位置以及目标寄存器号填进下面这张表里：static struct { ...... } hwui_info[] = { // Here, record info to support different versions of libhwui.so. #define INFO_COUNT 1 { .checksum = "dea5f6def7787d5c89fe66e7f888455953fd8880", .inject1_offset = 0x26868C, .reg = 4, .inject2_offset = 0x2686A0 } };checksum 代表这个 libhwui.so 的 sha1 值，不同机型、不同系统版本的断点位置是很有可能不同的。inject1_offset 代表上面“标号为 1 的橙色箭头”所指指令的文件内偏移。reg 代表那个用来存储目标地址的寄存器号。inject2_offset 代表上面“标号为 2 的橙色箭头”所指指令的文件内偏移。你大可为不同的系统版本增加不同的信息，记得把上面的 INFO_COUNT 一起改了就行。这些信息会根据当前 libhwui.so 的 sha1 值进行自动匹配。接下来讲一讲使用。上面的链接里已经提供了一个 demo：#include <linux/hwui_mon.h> #include <linux/module.h> static void handler(unsigned int ui_frame_time) { pr_info("Detect jank in %s with frametime = %d", current->comm, ui_frame_time); } static struct hwui_mon_receiver receiver = { .jank_frame_time = 4000, .jank_callback = handler }; static int __init demo_init(void) { return register_hwui_mon(&receiver); } module_init(demo_init);注册时需要指定“卡顿时间” jank_frame_time ，只有在“帧生成时间”大于等于“卡顿时间”时，jank_callback 才会被调用。需要注意的是 jank_callback 运行在应用的 UI 线程上，因此一定不要在其中进行耗时操作，如果有耗时操作请 offload 到 kthreads or workqueues 。另外，由于它运行在应用进程中，所以你可以使用 current->comm 打印出当前应用的（部分）包名，这对于分应用处理也许会有一些帮助。帧生成时间？聪明的你也许已经发现了，我们“帧生成时间”的数据源来自应用进程中 java 层向 native 层传递的数据，这意味着我们得到的所谓“帧生成时间”只是应用在 java 层的耗时，并不完整。没错，在我们的“帧生成时间”之上，还得加上 native 层渲染引擎的耗时和 GPU 的渲染耗时，才算得上是真正意义上的“帧生成时间”。但是，不同的应用之间采用的都是相同的 libhwui 库啊，大家跑的 native 渲染引擎代码逻辑是几乎相同的。只要给的绘制调用不是太阴间，native 渲染引擎耗时和 GPU 耗时大家都大差不差。确实，卡顿主要发生在 java 层的垃圾代码中，这种部分的“帧生成时间”是能够确切反应应用的性能需求的。但不管怎么样，这都是在找理由，要是完整的“帧生成时间”能轻松拿到，谁还用这个啊（笑虽然是个妥协，但效果也还不错吧。因此，你得记住，选择 jank_frame_time 时得为渲染引擎和 GPU 留足余量。比如对 120hz 来说，选择如 4000 us 这样更小的值，而不要卡在 8333 us ，后者即使是掉帧了不一定能够检测出来。后记算是比较系统的讲述了一下在内核侧提取帧生成时间的原理以及设计思路，期待你能够在自己的设备上复现上述操作(●'◡'●)另外，如此获取“帧生成时间”的思路应该还可以通过 Zygisk 实现，只是需要解决一下应用进程与帧时间消费者之间的通信问题（直接用 logcat 也不是不行 xD ）↑ 要是哪天有人做出来了记得给我个 credit 👀@shadow3 的一个 zygisk 实现参考资料https://wizzie.top/Blog/2021/04/14/2021/210414_android_VsyncStudy/https://www.cnblogs.com/roger-yu/p/16167404.htmlhttps://www.androidperformance.com/2019/12/01/Android-Systrace-Vsynchttps://medium.com/@workingkills/understanding-the-renderthread-4dc17bcaf979https://docs.kernel.org/trace/uprobetracer.htmlhttps://jayce.github.io/public/posts/trace/user-space-probes/https://gist.github.com/fntlnz/96a6d7bdd9881420c28e6454b8f7b91e
前言我手里有两台一加 8250 平台的机子，一台小 8 ，一台 8T 。照理说这两台机子的硬件上的区别应该不大，但是这台 8T 从出厂系统开始，我就没有成功获得过一次 pstore ，warm reset 之后开机看 pmic 的日志还是 cold boot，非常的诡异。相反，这台小 8 的 pstore 则一直工作正常。我甚至非常怀疑这台 8T 的 pmic 固件是不是有什么猫病。无奈，小 8 是主力机，没法天天拿来调试。于是糊了个在 panic 时转储日志到本地文件 / 指定分区的方案，也算是对 pstore 的一个小替代吧。源码https://github.com/libxzr/kernel-playground/commits/playground/logstore是基于高通 4.19 平台的，别的平台可能需要移植一下。细节单纯转储日志还是很简单的，利用 kmsg_dump 相关的 api 把日志从 buffer 里拿出来，再转储到文件里即可。这里的关键点是在于，该在何时转储日志。panic 的流程大概可以概括为以下几个阶段：进入 panic() 函数。在本 CPU 上禁用中断与抢占。打印 panic 信息与（本 CPU 的）调用栈。关闭其它 CPU。调用各个模块注册的 panic notifier。调用各个模块注册的 kmsg dumper。准备重启 / halt 住。在越是后面的阶段转储日志，得到的日志越是详细。比如在回调各个模块的 panic notifier 和 kmsg dumper 时，它们就很有可能往日志中增加额外的内容。我们所熟悉的 pstore 就是注册在 kmsg dumper 中的。但是，这一套流程中有一个对转储影响巨大的因素，那就是“关闭其它 CPU”（smp_send_stop()）。这里的关闭可不是温柔的 cpu_down()，而是直接发送 IPI 强行下线其它的处理器（ARM64）。要知道，对于正常的热插拔流程，在关闭 CPU 之前至少要把运行在它上面的任务迁移走，还要通知中断控制器不再给这个 CPU 分发中断。但是，panic 时真的是什么都没做就把 CPU 强行下线了，所有上面没有来得及迁移的东西都直接被毁灭了。这一操作其实是导致 panic 时设备直接 freeze 住的直接原因——别的 CPU 啥也不能执行，当前 CPU 被独占只能执行 panic 代码，自然而然设备看起来就像死机了一样。对于 pstore ，这一操作影响不大，反正它只需要写入指定的内存区域罢了。但是，对于我们这种正儿八经的日志转储，这种操作可能就是毁灭性的。就不说 vfs 了，光光是最底层的存储器驱动就可能依赖于一堆中断和 kthread / queue_work ，这些原本可能跑在别的 CPU 上的东西都被摧毁了，它该怎么转储日志？所以呐，想要和 pstore 在同一阶段转储日志已经变得非常不靠谱。对于我们这种非常复杂的转储方式来说，最好的选择就是在 down 掉别的 CPU 之前。此时我们虽然不能得到完整的 panic 日志（比如别的 CPU 的调用栈），但是对于得到 panic 的原因以及本 CPU 的调用栈已经非常足够了。此时我们得到的一个典型日志是长这样的：[ 19.122168] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000 [ 19.122175] Mem abort info: [ 19.122177] ESR = 0x96000046 [ 19.122179] Exception class = DABT (current EL), IL = 32 bits [ 19.122181] SET = 0, FnV = 0 [ 19.122182] EA = 0, S1PTW = 0 [ 19.122183] Data abort info: [ 19.122185] ISV = 0, ISS = 0x00000046 [ 19.122187] CM = 0, WnR = 1 [ 19.122189] user pgtable: 4k pages, 39-bit VAs, pgdp = 000000003746e1fd [ 19.122191] [0000000000000000] pgd=000000015722b003, pud=000000015722b003, pmd=0000000000000000 [ 19.122195] Internal error: Oops: 96000046 [#1] PREEMPT SMP [ 19.122198] Process ScreenDecoratio (pid: 4483, stack limit = 0x00000000b107786d) [ 19.122202] CPU: 4 PID: 4483 Comm: ScreenDecoratio Tainted: G S W 4.19.157-Horizon-03261204 #3 [ 19.122204] Hardware name: Qualcomm Technologies, Inc. kona MTP 19805 (DT) [ 19.122206] pstate: 40400005 (nZcv daif +PAN -UAO) [ 19.122214] pc : hwui_inject1_handler+0x10/0x24 [ 19.122219] lr : uprobe_notify_resume+0x530/0xca4 [ 19.122220] pc : ffffffa33a4b42b8 [ 19.122221] lr : ffffffa339212e3c [ 19.122223] sp : ffffff801e6dbc50 [ 19.122224] x29: ffffff801e6dbcb0 x28: 0000000000000080 [ 19.122227] x27: 00000000004000c0 x26: ffffffa33c2b52a8 [ 19.122229] x25: 0000000000000001 x24: 0000000000000000 [ 19.122231] x23: ffffffd3d5f00d18 x22: ffffffd3d5f00d00 [ 19.122233] x21: ffffffd3d5f00dc0 x20: ffffffd40d5b0000 [ 19.122234] x19: ffffff801e6dbec0 x18: 0000000000000000 [ 19.122236] x17: 0000000000000000 x16: 0000000000000000 [ 19.122238] x15: 0000000000000000 x14: 0000003103bac000 [ 19.122240] x13: ffffffa33bf6b9a0 x12: 16157c9af96779a3 [ 19.122242] x11: e9895543204eefdc x10: e9ea834ef4b11023 [ 19.122244] x9 : ffffffa33a4b42a8 x8 : 0000000000000000 [ 19.122246] x7 : 0000000000000000 x6 : 000000000000003f [ 19.122247] x5 : 0000000000000040 x4 : 0000000000000000 [ 19.122249] x3 : 0000000000000004 x2 : 0000000000000000 [ 19.122251] x1 : ffffff801e6dbec0 x0 : 0000000000000000 [ 19.122253] Call trace: [ 19.122256] hwui_inject1_handler+0x10/0x24 [ 19.122260] do_notify_resume+0xe0/0x23cc [ 19.122262] work_pending+0x8/0x10 [ 19.122264] Code: aa1f03e8 90000009 910aa129 2a1f03e0 (f9000109) [ 19.122267] ---[ end trace cb552490e52b4757 ]--- [ 19.126648] binder: context.name[binder] tr.size:152 service:qti.radio.extphone [ 19.126649] bt_configure_gpios: BTON:Turn Bt Off bt-reset-gpio(1121) value(0) [ 19.126710] Kernel panic - not syncing: Fatal exception [ 19.126735] save_dump_reason_to_smem: info : Fatal exception [ 19.126739] save_dump_reason_to_smem: dump_reason : Fatal exception reason_len=15 function caused panic :die name_len=3 [ 19.126770] bt_configure_gpios: BTON:Turn Bt Off [ 19.126777] bt_configure_gpios: bt-sw-ctrl-gpio(1224) value(1) [ 19.126780] bt_enable_bt_reset_gpios_safely: bt_enable_bt_reset_gpios_safely: BTON: Asserting BT_EN [ 19.138935] oplus_binder_stats binder_stats_driver_ioctl: unknown ioctl cmd:120 [ 19.139622] oplus_binder_stats binder_stats_driver_ioctl: unknown ioctl cmd:120其实还不错。这种转储方式有一个非常重要的前提假设是：panic 既不是 vfs 相关东西导致的，也不是存储驱动导致的。不然真就直接升天了。而且这种转储方式并不适用于启动早期造成的 panic ，此时相关的文件系统可能根本就还没挂载好，想要转储日志也是无稽之谈。还有一个点，也许我们真的需要区分一下什么才是 panic 。我所认为的 panic 必然是需要调用到 panic() 函数的，就比如上面的空指针异常，是由缺页中断的相关处理函数经过一系列判断后主动调用的 panic() ，这整个调用过程都是稳定可控的，全程代码并没有“跑飞”，也没有发生死锁。但是还有一种情况，那就是代码“跑飞”或死锁了，系统整个死掉了，panic() 函数并没有被调用。此时展现出的状况也是死机重启，但是这时的重启往往是由 watchdog 触发的，而非在 panic() 函数中调用的重启方法。这种情况下，很显然日志是不会转储的，甚至 pstore 也大概率不会生效。有一种测试代码是否“跑飞”的方法是把 CONFIG_PANIC_TIMEOUT 设为 0 。此时系统如果 panic ，那么将会一直 freeze 住不会重启（因为 panic 函数会主动喂狗），而如果是代码“跑飞”了，那么 watchdog 照样会按时触发系统重启。为了方便测试，有一种触发典型 panic 的方法是 echo c > /proc/sysrq-trigger ，它依赖于 CONFIG_MAGIC_SYSRQ 。就到这里吧 88。
前言vbmeta 分区是 Android AVB 机制的重要组成部分，其中包含了设备上部分分区的校验信息。有时候出于调试需要，我们需要修改设备上一些分区的内容，此时为了能使设备顺利启动，我们还需要额外禁用 vbmeta 分区中的校验。众所周知，我们可以通过执行 fastboot --disable-verity --disable-verification flash vbmeta vbmeta.img 来刷入一个 vbmeta 镜像，并同时禁用这个镜像中的校验。但是，fastboot 并不会修改原镜像，也没有提供导出这个禁用了校验的镜像的能力。那么有没有什么办法能够从原镜像获得禁用了校验的镜像呢？调查首先，可以看看 fastboot 是怎么做的：system/core/fastboot/fastboot.cppstatic void rewrite_vbmeta_buffer(struct fastboot_buffer* buf, bool vbmeta_in_boot) { // Buffer needs to be at least the size of the VBMeta struct which // is 256 bytes. if (buf->sz < 256) { return; } std::string data; if (!android::base::ReadFdToString(buf->fd, &data)) { die("Failed reading from vbmeta"); } uint64_t vbmeta_offset = 0; if (vbmeta_in_boot) { // Tries to locate top-level vbmeta from boot.img footer. uint64_t footer_offset = buf->sz - AVB_FOOTER_SIZE; if (0 != data.compare(footer_offset, AVB_FOOTER_MAGIC_LEN, AVB_FOOTER_MAGIC)) { die("Failed to find AVB_FOOTER at offset: %" PRId64, footer_offset); } const AvbFooter* footer = reinterpret_cast<const AvbFooter*>(data.c_str() + footer_offset); vbmeta_offset = be64toh(footer->vbmeta_offset); } // Ensures there is AVB_MAGIC at vbmeta_offset. if (0 != data.compare(vbmeta_offset, AVB_MAGIC_LEN, AVB_MAGIC)) { die("Failed to find AVB_MAGIC at offset: %" PRId64, vbmeta_offset); } fprintf(stderr, "Rewriting vbmeta struct at offset: %" PRId64 "\n", vbmeta_offset); // There's a 32-bit big endian |flags| field at offset 120 where // bit 0 corresponds to disable-verity and bit 1 corresponds to // disable-verification. // // See external/avb/libavb/avb_vbmeta_image.h for the layout of // the VBMeta struct. uint64_t flags_offset = 123 + vbmeta_offset; if (g_disable_verity) { data[flags_offset] |= 0x01; } if (g_disable_verification) { data[flags_offset] |= 0x02; } unique_fd fd(make_temporary_fd("vbmeta rewriting")); if (!android::base::WriteStringToFd(data, fd)) { die("Failed writing to modified vbmeta"); } buf->fd = std::move(fd); lseek(buf->fd.get(), 0, SEEK_SET); }可以很简单的发现，fastboot 在刷入镜像前在缓冲区中对镜像的第 123 字节的最后两位进行了置 1 操作，其中 --disable-verity 对应第 123 字节的最后一位， --disable-verification 则对应第 123 字节的倒数第二位。不妨进一步看看：external/avb/libavb/avb_vbmeta_image.htypedef struct AvbVBMetaImageHeader { ...... /* 120: Flags from the AvbVBMetaImageFlags enumeration. This must be * set to zero if the vbmeta image is not a top-level image. */ uint32_t flags; ...... } AVB_ATTR_PACKED AvbVBMetaImageHeader;可以看到，vbmeta 镜像从第 120 字节开始存储的是一个名为 flags 的大端 32 位无符号类型整数，也就是说这个整数的覆盖范围是第 120 到 123 字节。上面的修改实际上正好修改了这个整数的最低两位。那么，除了上面提到的两个“禁用校验”位，这个 flags 中其它的位有什么含义吗？external/avb/libavb/avb_vbmeta_image.h/* Flags for the vbmeta image. * * AVB_VBMETA_IMAGE_FLAGS_HASHTREE_DISABLED: If this flag is set, * hashtree image verification will be disabled. * * AVB_VBMETA_IMAGE_FLAGS_VERIFICATION_DISABLED: If this flag is set, * verification will be disabled and descriptors will not be parsed. */ typedef enum { AVB_VBMETA_IMAGE_FLAGS_HASHTREE_DISABLED = (1 << 0), AVB_VBMETA_IMAGE_FLAGS_VERIFICATION_DISABLED = (1 << 1) } AvbVBMetaImageFlags;好吧，看起来并没有，目前这个 flags 仅仅只服务于“禁用校验”。可以看到，--disable-verity 对应的是 FLAGS_HASHTREE_DISABLED ，--disable-verification 对应的是 FLAGS_VERIFICATION_DISABLED。实践于是按照上面的逻辑，写了个 binary ，直接作用于本地的 vbmeta 镜像，生成禁用校验的镜像。这个 binary 传入的参数除了可以是 vbmeta 镜像路径，还可以是 vbmeta 分区对应的 block device ，比如 /dev/block/bootdevice/by-name/vbmeta ，这将直接修改存储器上的 vbmeta 分区（当然运行时需要 root ）。binary 是静态编译的，应该适用于采用 Linux 内核的所有平台（前提是包含了你的架构），Windows 的话，自己写一个或者移植一下吧 :) 。
前言差不多是一个月前摸了摸 AOSP 自动亮度相关的东西，这篇文章主要是对当时得到的一些结论进行整理。省流助手：自动亮度的代码框架 / 自动亮度策略 / 如何学习用户偏好 / AOSP “自适应亮度”机器学习是骗局AOSP 自动亮度的代码是一个大屎山，特别是 DisplayPowerController 中的 updatePowerState() 部分，看着就让人犯恶心，因此本文主要注重于描述“某一部分代码大概干了什么事情”，而不会深入屎山去讨论“这个 temporary 变量是干嘛用的？这个 pending 又是怎么更新的？”本文基于 Android 12L 讨论。本文仅针对 AOSP，被任何 OEM 修改过的 ROM 甚至是谷歌 Pixel 的自带系统，都有可能具有与文章描述不符合的行为。把握代码框架面对屎山，我认为找到其出入口是最为重要的，顺着出入口能弄清楚不少东西，至于中间那些糊不清楚的屎就算了，只需要知道它们能跌跌撞撞的正常运行就够了。对于自动亮度，它的入口有好几个：光线传感器的读数、配置文件的读取 以及 用户的输入。而它的出口就只有一个，那就是改变屏幕亮度。传感器读数从何输入？services/core/java/com/android/server/display/AutomaticBrightnessController.java private final SensorEventListener mLightSensorListener = new SensorEventListener() { @Override public void onSensorChanged(SensorEvent event) { if (mLightSensorEnabled) { final long time = SystemClock.uptimeMillis(); final float lux = event.values[0]; handleLightSensorEvent(time, lux); } } @Override public void onAccuracyChanged(Sensor sensor, int accuracy) { // Not used. } };顺藤摸瓜顺着摸，我们能够了解到 AutomaticBrightnessController 大概干了些什么事情。 private void handleLightSensorEvent(long time, float lux) { ...... if (mAmbientLightRingBuffer.size() == 0) { ...... } ...... updateAmbientLux(time); }看起来它维护了一个环形缓冲区，这很好理解，毕竟采集到的数据肯定不能直接使用，至少也得消抖啥的吧。接着往下摸。 private void updateAmbientLux(long time) { // If the light sensor was just turned on then immediately update our initial // estimate of the current ambient light level. if (!mAmbientLuxValid) { ...... updateAutoBrightness(true /* sendUpdate */, false /* isManuallySet */); } ...... // Essentially, we calculate both a slow ambient lux, to ensure there's a true long-term // change in lighting conditions, and a fast ambient lux to determine what the new // brightness situation is since the slow lux can be quite slow to converge. // // Note that both values need to be checked for sufficient change before updating the // proposed ambient light value since the slow value might be sufficiently far enough away // from the fast value to cause a recalculation while its actually just converging on // the fast value still. ...... if ((slowAmbientLux >= mAmbientBrighteningThreshold && fastAmbientLux >= mAmbientBrighteningThreshold && nextBrightenTransition <= time) || (slowAmbientLux <= mAmbientDarkeningThreshold && fastAmbientLux <= mAmbientDarkeningThreshold && nextDarkenTransition <= time)) { ...... updateAutoBrightness(true /* sendUpdate */, false /* isManuallySet */); ...... } ...... // If one of the transitions is ready to occur, but the total weighted ambient lux doesn't // exceed the necessary threshold, then it's possible we'll get a transition time prior to // now. Rather than continually checking to see whether the weighted lux exceeds the // threshold, schedule an update for when we'd normally expect another light sample, which // should be enough time to decide whether we should actually transition to the new // weighted ambient lux or not. ...... }看来我上面所言极是，这个方法压根不用细看，看注释就知道是在实现某些算法对输入的亮度数据进行处理。现在我们只关心整体框架，那么它又是如何往下走的呢？ private void updateAutoBrightness(boolean sendUpdate, boolean isManuallySet) { ...... float value = mBrightnessMapper.getBrightness(mAmbientLux, mForegroundAppPackageName, mForegroundAppCategory); float newScreenAutoBrightness = clampScreenBrightness(value); ...... if (!BrightnessSynchronizer.floatEquals(mScreenAutoBrightness, newScreenAutoBrightness)) { ...... if (sendUpdate) { mCallbacks.updateBrightness(); } } }它似乎把环境亮度数据交给了 mBrightnessMapper，然后从它那里拿到了适合的屏幕亮度值，最后调用 mCallbacks.updateBrightness() 执行了亮度更新。这里的 mCallbacks 中的其中一个成员是 DisplayPowerController，于是到这里 AutomaticBrightnessController 的工作就结束了。可以概括一下它干了什么：从传感器处获得数据，对数据进行预处理，将处理得到的环境亮度交给 mBrightnessMapper 得到合适的屏幕亮度，最后调用 DisplayPowerController 更新屏幕亮度结束这一周期的工作。（当然，这里的调用 DisplayPowerController并不是很干净的单向调用，它只是通知其该更新屏幕亮度了，然后 DisplayPowerController 会在适宜的时间反向调用回 AutomaticBrightnessController 读取之前得到的那些数据）逆藤摸瓜逆着摸，我们还能够找到传感器的由来。services/core/java/com/android/server/display/DisplayPowerController.java DisplayDeviceConfig.SensorData lightSensor = mDisplayDeviceConfig.getAmbientLightSensor(); ...... mLightSensor = SensorUtils.findSensor(mSensorManager, lightSensor.type, lightSensor.name, fallbackType);core/res/res/values/config.xml <string name="config_displayLightSensorType" translatable="false" />可以看到，我们可以通过 overlay 来配置目标传感器的 type ，但是也仅限 type ，这个配置似乎是毫无意义的（毕竟光线传感器也就只能是这个类型吧）。默认情况下，SensorUtils.findSensor() 会返回首个符合 type 要求的传感器。这也就意味着，到目前为止 AOSP 并不支持某些手机所具有的前后多光感，它只会选择一个光线传感器的读数作为参考。(不过，这里的 type 是 string type 而不是 int type ，因此如果想要在多个光感中指定一个，可以尝试在 sensor hal 中将一个光感改名？)（手里的万普拉斯虽然传说有后置光感，但是各种检测软件根本看不到，怀疑是拿摄像头充数的）配置被谁读取？众所周知，device tree 中往往有一大堆 overlay 来配置自动亮度的相关参数。core/res/res/values/config.xml ...... <bool name="config_allowAutoBrightnessWhileDozing">false</bool> <integer name="config_autoBrightnessBrighteningLightDebounce">4000</integer> <integer name="config_autoBrightnessDarkeningLightDebounce">8000</integer> <integer name="config_autoBrightnessInitialLightSensorRate">-1</integer> <integer name="config_autoBrightnessLightSensorRate">250</integer> <fraction name="config_autoBrightnessAdjustmentMaxGamma">300%</fraction> <fraction name="config_screenAutoBrightnessDozeScaleFactor">100%</fraction> <!-- When the screen is turned on, the previous estimate of the ambient light level at the time the screen was turned off is restored and is used to determine the initial screen brightness. If this flag is true, then the ambient light level estimate will be promptly recomputed after the warm-up interface and the screen brightness will be adjusted immediately. If this flag is false, then the ambient light level estimate will be adjusted more gradually in the same manner that normally happens when the screen is on according to the brightening or dimming debounce thresholds. As a result, it may take somewhat longer to adapt to the environment. This mode may be better suited for watches. --> <bool name="config_autoBrightnessResetAmbientLuxAfterWarmUp">true</bool> <integer name="config_screenBrightnessDim">10</integer> <item name="config_screenBrightnessDimFloat" format="float" type="dimen">0.05</item> <item name="config_screenBrightnessMinimumDimAmountFloat" format="float" type="dimen">0.04</item> <!-- Minimum allowable screen brightness to use in a very dark room. This value sets the floor for the darkest possible auto-brightness adjustment. It is expected to be somewhat less than the first entry in config_autoBrightnessLcdBacklightValues so as to allow the user to have some range of adjustment to dim the screen further than usual in very dark rooms. The contents of the screen must still be clearly visible in darkness (although they may not be visible in a bright room). --> <integer name="config_screenBrightnessDark">1</integer> <!-- Array of lux values to define the minimum brightness curve, which guarantees that any brightness curve that dips below it is rejected by the system. This prevents auto-brightness from setting the screen so dark as to prevent the user from resetting or disabling it. The values must be non-negative and strictly increasing, and correspond to the values in the config_minimumBrightnessCurveNits array. --> <array name="config_minimumBrightnessCurveLux"> <item>0.0</item> <item>2000.0</item> <item>4000.0</item> </array> <array name="config_minimumBrightnessCurveNits"> <item>0.0</item> <item>50.0</item> <item>90.0</item> </array> <!-- Array of light sensor lux values to define our levels for auto backlight brightness support. The N entries of this array define N + 1 control points as follows: (1-based arrays) Point 1: (0, value[1]): lux <= 0 Point 2: (level[1], value[2]): 0 < lux <= level[1] Point 3: (level[2], value[3]): level[2] < lux <= level[3] ... Point N+1: (level[N], value[N+1]): level[N] < lux The control points must be strictly increasing. Each control point corresponds to an entry in the brightness backlight values arrays. For example, if lux == level[1] (first element of the levels array) then the brightness will be determined by value[2] (second element of the brightness values array). Spline interpolation is used to determine the auto-brightness backlight values for lux levels between these control points. Must be overridden in platform specific overlays --> <integer-array name="config_autoBrightnessLevels"> </integer-array> <integer name="config_autoBrightnessShortTermModelTimeout">300000</integer> <integer-array name="config_autoBrightnessLcdBacklightValues"> </integer-array> <!-- Array of desired screen brightness in nits corresponding to the lux values in the config_autoBrightnessLevels array. As with config_screenBrightnessMinimumNits and config_screenBrightnessMaximumNits, the display brightness is defined as the measured brightness of an all-white image. If this is defined then: - config_autoBrightnessLcdBacklightValues should not be defined - config_screenBrightnessNits must be defined - config_screenBrightnessBacklight must be defined This array should have size one greater than the size of the config_autoBrightnessLevels array. The brightness values must be non-negative and non-decreasing. This must be overridden in platform specific overlays --> <array name="config_autoBrightnessDisplayValuesNits"> </array> <integer-array name="config_autoBrightnessButtonBacklightValues"> </integer-array> <integer-array name="config_autoBrightnessKeyboardBacklightValues"> </integer-array> <integer-array name="config_screenBrightnessBacklight"> </integer-array> <!-- An array of floats describing the screen brightness in nits corresponding to the backlight values in the config_screenBrightnessBacklight array. On OLED displays these values should be measured with an all white image while the display is in the fully on state. Note that this value should *not* reflect the maximum brightness value for any high brightness modes but only the maximum brightness value obtainable in a sustainable manner. This array should be equal in size to config_screenBrightnessBacklight --> <array name="config_screenBrightnessNits"> </array> <!-- Array of ambient lux threshold values. This is used for determining hysteresis constraint values by calculating the index to use for lookup and then setting the constraint value to the corresponding value of the array. The new brightening hysteresis constraint value is the n-th element of config_ambientBrighteningThresholds, and the new darkening hysteresis constraint value is the n-th element of config_ambientDarkeningThresholds. The (zero-based) index is calculated as follows: (MAX is the largest index of the array) condition calculated index value < level[0] 0 level[n] <= value < level[n+1] n+1 level[MAX] <= value MAX+1 --> <integer-array name="config_ambientThresholdLevels"> </integer-array> <!-- Array of hysteresis constraint values for brightening, represented as tenths of a percent. The length of this array is assumed to be one greater than config_ambientThresholdLevels. The brightening threshold is calculated as lux * (1.0f + CONSTRAINT_VALUE). When the current lux is higher than this threshold, the screen brightness is recalculated. See the config_ambientThresholdLevels description for how the constraint value is chosen. --> <integer-array name="config_ambientBrighteningThresholds"> <item>100</item> </integer-array> <!-- Array of hysteresis constraint values for darkening, represented as tenths of a percent. The length of this array is assumed to be one greater than config_ambientThresholdLevels. The darkening threshold is calculated as lux * (1.0f - CONSTRAINT_VALUE). When the current lux is lower than this threshold, the screen brightness is recalculated. See the config_ambientThresholdLevels description for how the constraint value is chosen. --> <integer-array name="config_ambientDarkeningThresholds"> <item>200</item> </integer-array> <!-- Array of screen brightness threshold values. This is used for determining hysteresis constraint values by calculating the index to use for lookup and then setting the constraint value to the corresponding value of the array. The new brightening hysteresis constraint value is the n-th element of config_screenBrighteningThresholds, and the new darkening hysteresis constraint value is the n-th element of config_screenDarkeningThresholds. The (zero-based) index is calculated as follows: (MAX is the largest index of the array) condition calculated index value < level[0] 0 level[n] <= value < level[n+1] n+1 level[MAX] <= value MAX+1 --> <integer-array name="config_screenThresholdLevels"> </integer-array> <!-- Array of hysteresis constraint values for brightening, represented as tenths of a percent. The length of this array is assumed to be one greater than config_screenThresholdLevels. The brightening threshold is calculated as screenBrightness * (1.0f + CONSTRAINT_VALUE). When the new screen brightness is higher than this threshold, it is applied. See the config_screenThresholdLevels description for how the constraint value is chosen. --> <integer-array name="config_screenBrighteningThresholds"> <item>100</item> </integer-array> <!-- Array of hysteresis constraint values for darkening, represented as tenths of a percent. The length of this array is assumed to be one greater than config_screenThresholdLevels. The darkening threshold is calculated as screenBrightness * (1.0f - CONSTRAINT_VALUE). When the new screen brightness is lower than this threshold, it is applied. See the config_screenThresholdLevels description for how the constraint value is chosen. --> <integer-array name="config_screenDarkeningThresholds"> <item>200</item> </integer-array> <integer name="config_lightSensorWarmupTime">0</integer> ......那么这些资源究竟在何处被取走了呢？大概搜索了一下，主要是 DisplayPowerController、BrightnessMappingStrategy 和 DisplayDeviceConfig。前两个都在上面出现过了。services/core/java/com/android/server/display/DisplayDeviceConfig.java private void loadBrightnessMapFromConfigXml() { ...... final float[] sysNits = BrightnessMappingStrategy.getFloatArray(res.obtainTypedArray( com.android.internal.R.array.config_screenBrightnessNits)); final int[] sysBrightness = res.getIntArray( com.android.internal.R.array.config_screenBrightnessBacklight); final float[] sysBrightnessFloat = new float[sysBrightness.length]; ...... }services/core/java/com/android/server/display/BrightnessMappingStrategy.java public static BrightnessMappingStrategy create(Resources resources, DisplayDeviceConfig displayDeviceConfig, float adjustment) { // Display independent values float[] luxLevels = getLuxLevels(resources.getIntArray( com.android.internal.R.array.config_autoBrightnessLevels)); int[] brightnessLevelsBacklight = resources.getIntArray( com.android.internal.R.array.config_autoBrightnessLcdBacklightValues); float[] brightnessLevelsNits = getFloatArray(resources.obtainTypedArray( com.android.internal.R.array.config_autoBrightnessDisplayValuesNits)); float autoBrightnessAdjustmentMaxGamma = resources.getFraction( com.android.internal.R.fraction.config_autoBrightnessAdjustmentMaxGamma, 1, 1); long shortTermModelTimeout = resources.getInteger( com.android.internal.R.integer.config_autoBrightnessShortTermModelTimeout); // Display dependent values - used for physical mapping strategy nits -> brightness final float[] nitsRange = displayDeviceConfig.getNits(); final float[] brightnessRange = displayDeviceConfig.getBrightness(); ...... }services/core/java/com/android/server/display/DisplayPowerController.java private void setUpAutoBrightness(Resources resources, Handler handler) { ...... mBrightnessMapper = BrightnessMappingStrategy.create( resources, mDisplayDeviceConfig, getAutoBrightnessAdjustmentSetting()); if (mBrightnessMapper != null) { final float dozeScaleFactor = resources.getFraction( com.android.internal.R.fraction.config_screenAutoBrightnessDozeScaleFactor, 1, 1); int[] ambientBrighteningThresholds = resources.getIntArray( com.android.internal.R.array.config_ambientBrighteningThresholds); int[] ambientDarkeningThresholds = resources.getIntArray( com.android.internal.R.array.config_ambientDarkeningThresholds); int[] ambientThresholdLevels = resources.getIntArray( com.android.internal.R.array.config_ambientThresholdLevels); float ambientDarkeningMinThreshold = mDisplayDeviceConfig.getAmbientLuxDarkeningMinThreshold(); float ambientBrighteningMinThreshold = mDisplayDeviceConfig.getAmbientLuxBrighteningMinThreshold(); HysteresisLevels ambientBrightnessThresholds = new HysteresisLevels( ambientBrighteningThresholds, ambientDarkeningThresholds, ambientThresholdLevels, ambientDarkeningMinThreshold, ambientBrighteningMinThreshold); int[] screenBrighteningThresholds = resources.getIntArray( com.android.internal.R.array.config_screenBrighteningThresholds); int[] screenDarkeningThresholds = resources.getIntArray( com.android.internal.R.array.config_screenDarkeningThresholds); int[] screenThresholdLevels = resources.getIntArray( com.android.internal.R.array.config_screenThresholdLevels); float screenDarkeningMinThreshold = mDisplayDeviceConfig.getScreenDarkeningMinThreshold(); float screenBrighteningMinThreshold = mDisplayDeviceConfig.getScreenBrighteningMinThreshold(); HysteresisLevels screenBrightnessThresholds = new HysteresisLevels( screenBrighteningThresholds, screenDarkeningThresholds, screenThresholdLevels, screenDarkeningMinThreshold, screenBrighteningMinThreshold); long brighteningLightDebounce = resources.getInteger( com.android.internal.R.integer.config_autoBrightnessBrighteningLightDebounce); long darkeningLightDebounce = resources.getInteger( com.android.internal.R.integer.config_autoBrightnessDarkeningLightDebounce); boolean autoBrightnessResetAmbientLuxAfterWarmUp = resources.getBoolean( com.android.internal.R.bool.config_autoBrightnessResetAmbientLuxAfterWarmUp); int lightSensorWarmUpTimeConfig = resources.getInteger( com.android.internal.R.integer.config_lightSensorWarmupTime); int lightSensorRate = resources.getInteger( com.android.internal.R.integer.config_autoBrightnessLightSensorRate); int initialLightSensorRate = resources.getInteger( com.android.internal.R.integer.config_autoBrightnessInitialLightSensorRate); if (initialLightSensorRate == -1) { initialLightSensorRate = lightSensorRate; } else if (initialLightSensorRate > lightSensorRate) { Slog.w(TAG, "Expected config_autoBrightnessInitialLightSensorRate (" + initialLightSensorRate + ") to be less than or equal to " + "config_autoBrightnessLightSensorRate (" + lightSensorRate + ")."); } loadAmbientLightSensor(); if (mBrightnessTracker != null) { mBrightnessTracker.setLightSensor(mLightSensor); } if (mAutomaticBrightnessController != null) { mAutomaticBrightnessController.stop(); } mAutomaticBrightnessController = new AutomaticBrightnessController(this, handler.getLooper(), mSensorManager, mLightSensor, mBrightnessMapper, lightSensorWarmUpTimeConfig, PowerManager.BRIGHTNESS_MIN, PowerManager.BRIGHTNESS_MAX, dozeScaleFactor, lightSensorRate, initialLightSensorRate, brighteningLightDebounce, darkeningLightDebounce, autoBrightnessResetAmbientLuxAfterWarmUp, ambientBrightnessThresholds, screenBrightnessThresholds, mLogicalDisplay, mContext, mHbmController); } else { mUseSoftwareAutoBrightnessConfig = false; } }读了些什么？BrightnessMappingStrategy 所读取的配置是很有特点的，它读取的表正是自动亮度调节的关键——这几个表决定了从环境光到屏幕亮度等级的映射关系 （后面会详细解释）。这与我们上方对 AutomaticBrightnessController 的分析非常符合，在其中 BrightnessMappingStrategy 被用于完成从传感器读数到屏幕亮度的转换。DisplayDeviceConfig 所读取的东西也是很有特点的——它们描述了屏幕的物理特性。在自动亮度中，它所读取的配置会被直接提供给 BrightnessMappingStrategy，因此接下来不会再围绕它展开讨论（后面会详细解释这些物理特性在亮度映射中的用途）。DisplayPowerController 读取的东西不是很好总结（阈值、消抖、传感器参数等），但相对于上面的表只能算是“边角料”了罢。“三大组件”之间的关系我们在此定义 DisplayPowerController、BrightnessMappingStrategy、AutomaticBrightnessController 为自动亮度中的三大组件，这三大组件在上方的传感器摸瓜中都已经出现过了。（“三大组件”没有包含上面只出现了一瞬间的 DisplayDeviceConfig ，不过怎么叫无所谓，仅仅只是因为它们在自动亮度中十分关键罢了）上面的初始化代码中除了有被读取的资源，其实也在暗暗告诉我们三大组件之间的关系。可以看到 BrightnessMappingStrategy 是由 DisplayPowerController 创建的，并且后者一直持有前者的对象。AutomaticBrightnessController 是由 DisplayPowerController 创建的，创建过程中传入了刚刚创建并持有的 BrightnessMappingStrategy 对象。并且，传入了几乎所有读到的“边角料”参数（那为什么不让 AutomaticBrightnessController 直接读这些参数呢？）。毕竟AutomaticBrightnessController所作的事情就是从传感器读取数据与数据预处理，自然非常需要这些参数，这与我们上方从传感器读数开始的分析非常符合。屏幕亮度如何改变？这里我觉得没必要挖的太深（比如挖到内核里是如何改变屏幕亮度的），仅仅只需要找到一个“能直接改变屏幕亮度，过程没啥东西掺和”的方法就可以了。大概看了一圈，这个方法位于services/core/java/com/android/server/display/DisplayPowerState.java /** * Sets the display brightness. * * @param brightness The brightness, ranges from 0.0f (minimum) to 1.0f (brightest), or is -1f * (off). */ public void setScreenBrightness(float brightness) { if (mScreenBrightness != brightness) { if (DEBUG) { Slog.d(TAG, "setScreenBrightness: brightness=" + brightness); } mScreenBrightness = brightness; if (mScreenState != Display.STATE_OFF) { mScreenReady = false; scheduleScreenUpdate(); } } }这个方法并没有那么底层，但大概也符合要求了，就从这里回溯看看自动亮度调节是怎么调用到这里的。向上回溯：services/core/java/com/android/server/display/DisplayPowerState.java public static final FloatProperty<DisplayPowerState> SCREEN_BRIGHTNESS_FLOAT = new FloatProperty<DisplayPowerState>("screenBrightnessFloat") { @Override public void setValue(DisplayPowerState object, float value) { object.setScreenBrightness(value); } @Override public Float get(DisplayPowerState object) { return object.getScreenBrightness(); } };它的直接调用者是一个 FloatProperty。你可以这样理解 FloatProperty ：它将一对 setter 和 getter 绑定在了一个浮点数上，对本案例来说，屏幕亮度就是这个浮点数，当对浮点数执行读取时，会自动调用获取屏幕亮度的方法并返回其值，当对浮点数进行赋值时，会自动调用设置屏幕亮度的方法（当然，这个浮点数是特殊的，不能直接读写，得调用对应的 setValue() 和 get() 方法）。或者，干脆把它理解为对屏幕亮度读写方法的打包，谁拿到了它，谁就掌握了任意改变屏幕亮度的控制权。xxxProperty 一般在动画中比较常用，比如你想要改变 View 的某个属性，那就把这个属性的 get 和 set 方法打包成一个 xxxProperty，传到动画里，于是动画就能连续的改变该 View 的属性而无需在动画里写死 view.xxx() 的方法，这解耦了动画与 View，大大增加了动画代码的可复用性（于是大量针对不同属性的动画可以使用一套动画代码）。于是...动画？这里有用到动画吗？接着回溯：services/core/java/com/android/server/display/DisplayPowerController.java mScreenBrightnessRampAnimator = new DualRampAnimator<>(mPowerState, DisplayPowerState.SCREEN_BRIGHTNESS_FLOAT, DisplayPowerState.SCREEN_SDR_BRIGHTNESS_FLOAT);诶，这个 FloatProperty 还真的被交给了一个动画诶，但最重要的是我们回到了熟悉的“三大组件”之一的 DisplayPowerController。这个动画的代码就不想看了，我毫无兴趣，看看顶上的注释吧。services/core/java/com/android/server/display/RampAnimator.java/** * A custom animator that progressively updates a property value at * a given variable rate until it reaches a particular target value. * The ramping at the given rate is done in the perceptual space using * the HLG transfer functions. */一下子就能够明白它是干什么的：设置亮度值的时候，让屏幕亮度能够平滑的从“当前亮度”过度到“目标亮度”，而避免闪一下的那种跳变，让人看着更舒服些。so，这个动画何时触发呢？services/core/java/com/android/server/display/DisplayPowerController.java private void animateScreenBrightness(float target, float sdrTarget, float rate) { ...... if (mScreenBrightnessRampAnimator.animateTo(target, sdrTarget, rate)) { ...... } }盯住它，接着网上找。 private void updatePowerState() { ...... // Apply auto-brightness. ...... if (Float.isNaN(brightnessState)) { ...... if (autoBrightnessEnabled) { brightnessState = mAutomaticBrightnessController.getAutomaticScreenBrightness(); newAutoBrightnessAdjustment = mAutomaticBrightnessController.getAutomaticScreenBrightnessAdjustment(); } ...... } ...... // Apply manual brightness. if (Float.isNaN(brightnessState)) { brightnessState = clampScreenBrightness(mCurrentScreenBrightnessSetting); ...... } ...... float animateValue = clampScreenBrightness(brightnessState); ...... if (......) { if (......) { animateScreenBrightness(animateValue, sdrAnimateValue, SCREEN_ANIMATION_RATE_MINIMUM); } else { ...... animateScreenBrightness(animateValue, sdrAnimateValue, rampSpeed); } } ...... }进大屎山哩，这个 updatePowerState() 足足有 512 行，经过一大堆删减后，现在应该是一眼就能看出来，这个动画的目标亮度便是自动亮度或手动亮度的值（中间别的因素先忽略了）。我们主要关注自动亮度的部分，可以看到目标亮度最终来自于 AutomaticBrightnessController。诶，还记得我们上面在对传感器读数的顺藤摸瓜中了解到了 AutomaticBrightnessController 在得到新的屏幕亮度后会通知 DisplayPowerController 进行更新吗？那么这个“通知更新”的过程是怎样的呢？会不会调用到 updatePowerState() 呢？如果会的话一切就都通了。那么我们继续从上次结束的 AutomaticBrightnessController 调用 DisplayPowerController 更新屏幕亮度开始摸瓜，看看能不能和这次倒着摸到的 updatePowerState() 汇合。从 DisplayPowerController 继承于 AutomaticBrightnessController.Callbacks 的接口开始：services/core/java/com/android/server/display/DisplayPowerController.java @Override public void updateBrightness() { sendUpdatePowerState(); } private void sendUpdatePowerState() { synchronized (mLock) { sendUpdatePowerStateLocked(); } } private void sendUpdatePowerStateLocked() { if (!mStopped && !mPendingUpdatePowerStateLocked) { mPendingUpdatePowerStateLocked = true; Message msg = mHandler.obtainMessage(MSG_UPDATE_POWER_STATE); mHandler.sendMessage(msg); } }走进了 handler 里，合理的，对于这种复杂的服务，保障单线程的按序处理还是很有必要的，能够避免很多复杂的 race。看看 handler 里是怎么处理的吧： case MSG_UPDATE_POWER_STATE: updatePowerState(); break;欸嘿，汇合了。于是，对于屏幕亮度是如何改变的，我们可以下结论了：AutomaticBrightnessController 在得到新的目标屏幕亮度后，会通知 DisplayPowerController schedule 一次 updatePowerState()。而在 updatePowerState() 中，DisplayPowerController 会从 AutomaticBrightnessController 处读取新的目标屏幕亮度，经过处理后通过优雅的屏幕亮度渐变动画进行应用。用户如何输入？这一块内容会牵扯到很多的屎山，于是乎放在这个板块的最后。从源头看起罢（恼）亮度条拖动事件packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessController.javapublic class BrightnessController implements ToggleSlider.Listener ...... { ...... @Override public void onChanged(boolean tracking, int value, boolean stopTracking) { ...... setBrightness(valFloat); if (!tracking) { AsyncTask.execute(new Runnable() { public void run() { mDisplayManager.setBrightness(mDisplayId, valFloat); } }); } } ...... private void setBrightness(float brightness) { mDisplayManager.setTemporaryBrightness(mDisplayId, brightness); } ...... }于是进入了系统 api：BrightnessController -> DisplayManager -> DisplayManagerGlobal这两层都只是简单包装，代码直接省略。最后从 DisplayManagerGlobal 离开系统 api ，通过 binder call 进入 system server。这里对应的服务是 DisplayManagerService：services/core/java/com/android/server/display/DisplayManagerService.java @Override // Binder call public void setTemporaryBrightness(int displayId, float brightness) { ...... mDisplayPowerControllers.get(displayId) .setTemporaryBrightness(brightness); ...... } @Override // Binder call public void setBrightness(int displayId, float brightness) { ...... DisplayPowerController dpc = mDisplayPowerControllers.get(displayId); if (dpc != null) { dpc.putScreenBrightnessSetting(brightness); } ...... }于是兜了一圈我们回来了，熟悉的 DisplayPowerController。现在我们知道了拖动亮度条时发生了什么：在亮度条拖动时，会时刻调用 DisplayPowerController 的 setTemporaryBrightness() 方法设置临时亮度，在亮度条结束拖动时调用其 putScreenBrightnessSetting() 设置真正的亮度。于是当你拖动亮度条看着屏幕亮度跟随手指变化时，其实这个亮度值并没有被应用进去，只是个临时量，松手的那一刻才真正的写入系统（这很合理）。所以接下来我们有两条路径可以研究，一条是临时亮度的设置，另一条则是真正亮度的应用，那就先从简单的开始吧。临时亮度的设置services/core/java/com/android/server/display/DisplayPowerController.java public void setTemporaryBrightness(float brightness) { Message msg = mHandler.obtainMessage(MSG_SET_TEMPORARY_BRIGHTNESS, Float.floatToIntBits(brightness), 0 /*unused*/); msg.sendToTarget(); }这个东西很简单捏，schedule 了一条 handler message 然后发了出去，去看看这条消息是怎么被处理的： case MSG_SET_TEMPORARY_BRIGHTNESS: // TODO: Should we have a a timeout for the temporary brightness? mTemporaryScreenBrightness = Float.intBitsToFloat(msg.arg1); updatePowerState(); break;仅仅只是更新了一个变量，然后就去调用我们熟悉的 updatePowerState() 了，还记得在这个方法里面，会调用一个动画来更新屏幕亮度吧？不妨结合应用自动亮度和手动亮度的代码，看看临时亮度和它们是什么关系： private void updatePowerState() { ...... if (isValidBrightnessValue(mTemporaryScreenBrightness)) { brightnessState = mTemporaryScreenBrightness; mAppliedTemporaryBrightness = true; mBrightnessReasonTemp.setReason(BrightnessReason.REASON_TEMPORARY); } ...... // Apply auto-brightness. ...... if (Float.isNaN(brightnessState)) { ...... if (autoBrightnessEnabled) { brightnessState = mAutomaticBrightnessController.getAutomaticScreenBrightness(); newAutoBrightnessAdjustment = mAutomaticBrightnessController.getAutomaticScreenBrightnessAdjustment(); } ...... } ...... // Apply manual brightness. if (Float.isNaN(brightnessState)) { brightnessState = clampScreenBrightness(mCurrentScreenBrightnessSetting); ...... } ...... float animateValue = clampScreenBrightness(brightnessState); ...... if (......) { if (......) { animateScreenBrightness(animateValue, sdrAnimateValue, SCREEN_ANIMATION_RATE_MINIMUM); } else { ...... animateScreenBrightness(animateValue, sdrAnimateValue, rampSpeed); } } ...... }可以看到，如果临时亮度存在，则 brightnessState 会被最先赋值，于是自动亮度和手动亮度的值都会被忽略，最终，临时亮度得到了应用。也就是说，临时亮度的优先级是高于自动和手动亮度的，合理的。真正亮度的设置这个过程就复杂了，于是放在了这个板块最后的最后，得在屎山里搅一会儿。从 DisplayPowerController 被调用的函数开始吧：services/core/java/com/android/server/display/DisplayPowerController.java void putScreenBrightnessSetting(float brightnessValue) { putScreenBrightnessSetting(brightnessValue, false); } private void putScreenBrightnessSetting(float brightnessValue, boolean updateCurrent) { if (!isValidBrightnessValue(brightnessValue)) { return; } if (updateCurrent) { setCurrentScreenBrightness(brightnessValue); } mBrightnessSetting.setBrightness(brightnessValue); }看样子，它钻进了另一个东西里面—— BrightnessSetting：services/core/java/com/android/server/display/BrightnessSetting.java/** * Saves brightness to a persistent data store, enabling each logical display to have its own * brightness. */ public class BrightnessSetting { ...... public float getBrightness() { ...... } ...... public void registerListener(BrightnessSettingListener l) { ...... } ...... public void unregisterListener(BrightnessSettingListener l) { ...... } void setBrightness(float brightness) { ...... } /** * Listener for changes to system brightness. */ public interface BrightnessSettingListener { /** * Notify that the brightness has changed. */ void onBrightnessChanged(float brightness); } }幸运的是这个类的公开方法还挺少的，而且甚至不用看代码只要看看注释和方法名称就能把它的功能猜出来：它负责两件事，一是亮度数据的永久存储，二是在新的亮度数据被存储时通知注册的所有 BrightnessSettingListener。于是，我们可以在 DisplayPowerController 中找到这个注册的 Listener：services/core/java/com/android/server/display/DisplayPowerController.java mBrightnessSettingListener = brightnessValue -> { Message msg = mHandler.obtainMessage(MSG_UPDATE_BRIGHTNESS, brightnessValue); mHandler.sendMessage(msg); };functional interface 结合 lambda 表达式，优雅！在这里，它把接下来的工作交给了 handler ，跟过去看看： case MSG_UPDATE_BRIGHTNESS: if (mStopped) { return; } handleSettingsChange(false /*userSwitch*/); break;最终起作用的是 handleSettingsChange()： private void handleSettingsChange(boolean userSwitch) { mPendingScreenBrightnessSetting = getScreenBrightnessSetting(); if (userSwitch) { ...... } ...... sendUpdatePowerState(); }先来看看 getScreenBrightnessSetting()： float getScreenBrightnessSetting() { float brightness = mBrightnessSetting.getBrightness(); if (Float.isNaN(brightness)) { brightness = mScreenBrightnessDefault; } return clampAbsoluteBrightness(brightness); }它做了一件事：把刚刚存进 BrightnessSetting 的亮度数据给读出来（诶，你都把亮度数据给塞进 handler message 里了，直接在那里取出来就好了，重新读可真是多此一举👀）。再看看 sendUpdatePowerState()。不对，这个根本就不用看了，上面 AutomaticBrightnessController 通知 DisplayPowerController 更新屏幕亮度时不就是走的这个函数么？这个函数最终会在 handler 线程上 schedule 一次屎山的 updatePowerState()。所以，在此，可以对用户输入进行一个小结：手指松开时，亮度值先被塞给 BrightnessSetting 存储，然后经过一系列的调用被 updatePowerState() 给应用了。接下来看看应用的过程，这也是最为屎的一部分：我们已经知道了，亮度值被保存在全局变量 mPendingScreenBrightnessSetting 中。 mPendingScreenBrightnessSetting = getScreenBrightnessSetting();回到屎山 updatePowerState() 看看： private void updatePowerState() { ...... final boolean userSetBrightnessChanged = updateUserSetScreenBrightness(); ...... // Configure auto-brightness. if (mAutomaticBrightnessController != null) { ...... mAutomaticBrightnessController.configure(...... mLastUserSetScreenBrightness, ......); } ...... // Apply auto-brightness. ...... if (Float.isNaN(brightnessState)) { ...... if (autoBrightnessEnabled) { brightnessState = mAutomaticBrightnessController.getAutomaticScreenBrightness(); newAutoBrightnessAdjustment = mAutomaticBrightnessController.getAutomaticScreenBrightnessAdjustment(); } ...... } ...... // Apply manual brightness. if (Float.isNaN(brightnessState)) { brightnessState = clampScreenBrightness(mCurrentScreenBrightnessSetting); ...... } }这坨屎还是很有分量的，可以看到在这里压根没有用到 mPendingScreenBrightnessSetting，那新的亮度是怎么被传进去的？答案是：在上面调用的 updateUserSetScreenBrightness() 把亮度放进了另外两个全局变量里： private void setCurrentScreenBrightness(float brightnessValue) { if (brightnessValue != mCurrentScreenBrightnessSetting) { mCurrentScreenBrightnessSetting = brightnessValue; ...... } } // We want to return true if the user has set the screen brightness. // If they have just turned RBC on (and therefore added that interaction to the curve), // or changed the brightness another way, then we should return true. private boolean updateUserSetScreenBrightness() { ...... setCurrentScreenBrightness(mPendingScreenBrightnessSetting); mLastUserSetScreenBrightness = mPendingScreenBrightnessSetting; ...... }于是乎，由结束拖动亮度条得到的亮度数值，被放进了全局变量 mLastUserSetScreenBrightness 和 mCurrentScreenBrightnessSetting 里，分别被用来“配置”自动亮度和作为手动亮度值。那到了这里，我们已经彻底明白了手指松开亮度条时得到的“真正亮度”是如何被应用的：手指松开时，亮度值先被塞给 BrightnessSetting 存储，然后经过一系列的传递和转储，最终在 updatePowerState() 中被使用；对于手动亮度，该值会被直接使用作为手动亮度值；对于自动亮度，该值会被传递给 AutomaticBrightnessController 用于“配置”自动亮度，然后应用的是从 AutomaticBrightnessController 中读取到的新自动亮度值，而非直接使用传入的值。接下来开始骂屎山：这他妈的写的是什么玩意儿，整个 DisplayPowerController 的有效业务逻辑基本全部堆在一个方法里，一个方法写好几百行，参数还全 tmd 用全局变量瞎 jb 传递，谁知道这坨全局变量是干嘛的啊，代码可读性为零。看了一下，这套东西的整体框架还是十年前的，安卓 4.2 时代的东西了，一直没有大改，一直靠往上堆东西来支持新的特性。小结到了这里，我们已经完成了整体框架上的分析，了解了与自动亮度相关的 “三大组件” 和它们的职责与配合方式。简单概括一下：DisplayPowerController 直接持有另外两大组件，并对它们进行配置；BrightnessMappingStrategy 负责将环境亮度转换为屏幕亮度，是自动亮度的核心；AutomaticBrightnessController 负责传感器采样，数据预处理，消抖等等，是自动亮度的中间桥梁，连接着 BrightnessMappingStrategy 与 DisplayPowerController；DisplayPowerController 则负责综合考虑各种设置与功能，给出最终的屏幕亮度值。自动亮度策略这一块的内容主要聚焦于：究竟是怎么把环境亮度映射到屏幕亮度的、overlay 中配置的那几张长长的关键表是如何起作用的、拖动亮度条是怎么影响自动亮度的“配置”的。聚焦的组件主要是：BrightnessMappingStrategy 与 AutomaticBrightnessController。基础铺垫一些光学概念在正式开始之前，我想铺垫一些光学知识还是有必要的，这可以避免问出：“如何把勒克斯转为尼特？”这样的无意义问题。以下内容不科学不严谨，仅仅只用于帮助理解。首先，我觉得最先要定义的是“光通量”，其单位是“流明”。这个概念看起来很复杂但实际上非常简单：你可以将它类比到电磁发射器的功率，嗯说白了就是每秒发射或接收了多少光（多少能量的电磁波）。当然，这里没法把光量和电磁波能量进行直接转换，毕竟光量是根据人类眼睛的感受能力定义的，仅仅只是一个类比。光源可以向四面八方发射光线，假如我们把光源周围遮住一部分，它每秒发射的总光量就下降了，这是显然的。但是，光源的强度减弱了吗？我想是没有的。于是，我们需要另一个概念来定义光源的强度，那就是“光强”，其单位是“坎德拉”。“光强”是光源每“立体角”发出的“光通量” （光强 = 光通量 / 立体角），这里的“立体角”可以简单理解为“平面角”的推广（相当于把弧度上升到了三维空间），可以简单理解为三维空间中的一度（并不严谨）。“光强”描述的是光源在单位角度区间内的“光通量”，但是在现实中我们往往无法接收这样一个区间内的所有光，因为当离得很远的时候这个角度张成的空间会变得超级大。于是，我们还需要定义一个对现实更有参考意义的概念，来让我们评估接收时光的强度。这个东西便是“照度”，单位是“勒克斯”，是不是很熟悉，这就是光线传感器读到的数值。“照度”的定义是：单位面积的光通量（照度 = 光通量 / 接收面积）。如何理解这个东西呢？上面也说了，光通量可以理解为发射功率，那么这个“照度”实际上就是和“太阳常数”差不多的东西，描述单位面积上接收到的功率（光量）。最后一个，好像也是最难弄明白的是“亮度”，单位是“尼特”，没错就是厂家经常吹的什么屏幕峰值亮度高达 xxx nit。亮度的定义是：单位面积的光强。按照 维基百科 上的定义，这里的单位面积应该是指光源的面积，也就是说写成公式应该是：亮度 = 光强 / 发光面积。乍一看这好像很难理解，但是仔细想想确实没啥毛病：光源可以有大有小，一个超级大的光源和一个很小的光源如果具有相同的光强和发射角度，也就是发射着相同功率的光，那么谁会看着更亮呢？形象一点，假如太阳和一个弹珠发射着相同功率的光...谁更亮不用说了吧。需要注意的是，亮度是针对光源的，属于光源（或者反射面）的固有属性，它是不随观察距离变化的。中文版的维基上有这样一句话，我认为是存在大问题的：简而言之，当任两个物体表面在照相时被拍摄出的最终结果是一样亮、或被眼睛看起来两个表面一样亮，它们就是亮度相同。它忽略了观察者的距离这一重要因素。人眼所感受到的亮度归根结底是照度，而照度是会随着距离呈平方关系衰减的。还有一种理解方式：在上方的公式中，亮度是使用二阶偏导进行定义的，微元化意味着每一根光线的亮度都是恒定的，不会随距离衰减。但是距离远了，意味着人眼所接收到的光线数量变少了，自然会觉得变暗了。所以，为什么光线传感器不能用尼特作为单位呢？因为它根本就不是在监测一个光源或者一个反射面啊，传感器得到的数据是千千万万个反射面发出的光线汇聚在一起的结果，而亮度的定义是针对光源或者单一反射面的，因此在这里使用尼特就显得不合实际。有了这些铺垫，相信对接下来的照度——亮度映射表会有更好的理解。Gamma 校正之所以这里会牵扯到 gamma 校正，是因为自动亮度中的一个小算法牵扯到了它。这个小算法不能说和 gamma 校正作用相同，但是思想是可以相互借鉴的。关于它，网上有很多的江湖传说，我这里就只讲一个自己理解的超简化版本。众所周知，相机在记录图像的时候，是在收集传感器的电信号罢。这些电信号是线性的，即拍摄的画面亮度越大，记录的电信号也越大，两者之间相差一个系数。把这些照片传到电脑上，如果想要把拍到的照片显示出来，那么我们需要把刚刚记录的电信号发送给电脑屏幕。但是，电脑屏幕的物理特性决定了它的显示不是线性的，即所加的电信号和显示的亮度之间并不是只差一个系数，它们往往呈幂函数关系：显示器亮度 = 电信号 ^ k。于是，想要让我们拍摄的照片，在被人眼看到时能将亮度线性的还原，我们需要为我们的照相机也配一个幂次 电信号 = 拍到的亮度 ^ (1 / k)。嵌套这两个等式，我们能够得到从 拍到的亮度 到 显示器亮度 的转换关系，我们配上的幂次抵消了显示器的物理特性，从而使得这一关系重新变得线性，这个过程便被称为“ gamma 校正”。简单来说， gamma 校正就是进行了如下变换：新曲线 = 老曲线 ^ gammagamma 校正的核心在于通过配一个幂次来改变原有曲线，从而让它能够去符合某些想要的特性。比如在这里，我们通过配一个幂次来使它变得线性。但是，想要的特性也可以是：我想要让这个曲线通过某一特定的点，并不一定要是线性的，只要过那个点就行。这种要求靠 gamma 校正也是可以实现的，效果还挺优雅的，而这正是它在“自动亮度拟合用户偏好”中的应用，接下来会细说。我们只考虑 gamma 大于 0 的情况。归一化其实不止是在这里，归一化在别的一些地方比如机器学习，也有着广泛的应用。之所以要在这里介绍归一化，得从 gamma 校正的特点说起：看到了吗，对原曲线 (Original) 应用不同 gamma (γ) 值的校正，其在原曲线值域小于 1 的区间和大于 1 的区间上呈现出了完全不同的特性：在值域小于 1 的区间上应用小于 1 的 gamma 值，曲线会被往上拽。在值域小于 1 的区间上应用大于 1 的 gamma 值，曲线会被往下拽。在值域大于 1 的区间上应用小于 1 的 gamma 值，曲线会被往下拽。在值域大于 1 的区间上应用大于 1 的 gamma 值，曲线会被往上拽。对值为 1 的点，无论怎么应用校正都无济于事！这种特性好吗？一点也不好！以值域 1 为界呈现完全相反的特性，实在是太离谱了，无法接受！于是，在使用 gamma 校正之前，我们会对值域进行一个归一化，把它从原来的任意区间映射到 0 到 1 之间 （也就是取图中交点左边的部分），这样 gamma 校正带来的曲线移动方向就定下来了。那么，它是如何进行的？其实非常简单。我们假设原来的数字分布在 0 到 255 之间，想要进行这个归一化，只需要把原来的数字除以 255 即可 （新值 = 老值 / 老区间上限）。基本工作机制先从我们写在 overlay 中的那几张长长的表看起。这块东西甚至不需要看代码，看看注释足以。照度的输入 <!-- Array of light sensor lux values to define our levels for auto backlight brightness support. The N entries of this array define N + 1 control points as follows: (1-based arrays) Point 1: (0, value[1]): lux <= 0 Point 2: (level[1], value[2]): 0 < lux <= level[1] Point 3: (level[2], value[3]): level[2] < lux <= level[3] ... Point N+1: (level[N], value[N+1]): level[N] < lux The control points must be strictly increasing. Each control point corresponds to an entry in the brightness backlight values arrays. For example, if lux == level[1] (first element of the levels array) then the brightness will be determined by value[2] (second element of the brightness values array). Spline interpolation is used to determine the auto-brightness backlight values for lux levels between these control points. Must be overridden in platform specific overlays --> <integer-array name="config_autoBrightnessLevels"> </integer-array>首先，有一张表是用于描述传感器输入的照度值的。若这张表中有 N 组数据，则代表了 N + 1 个照度等级。为什么会多出来 1 呢？因为这张表自带了一个照度为 0 的等级，你可以理解为它会在你定义的第一个等级之前自动加上一个照度为 0 的等级。这张表中的每一个照度等级，都将被映射到一个输出等级（包括自动补上去的 0 照度），这种映射将会是一一对应的，即：自动加的 0 照度会被映射到输出表的第一个等级你定义的第一个照度会被映射到输出表的第二个等级依此类推...那么，假如输入的照度值在两个等级之间咋办呢？该映射到哪个输出呢？答案是会使用“ 样条插值 ”。因此，想要让插值的结果保持合理，输入和输出表具有单调递增性是十分重要的。事实上，自动亮度映射牵扯到的几张表，都应该是单调递增的。接下来，来看看输出等级是如何定义的。方式一：直接背光输出 <integer-array name="config_autoBrightnessLcdBacklightValues"> </integer-array>这非常好理解，每一个输入直接被映射为一个背光等级参数，而这个参数可以直接被作为亮度参数进行应用。典型的参数范围是 0 到 255，0 代表屏幕完全熄灭，255 则代表亮度拉满。这种映射方式非常方便，易于理解但不易于移植，因为不同的屏幕具有不同的发光特性，相同的背光等级参数在不同的屏幕上效果可能是千差万别的。假如想要在一台全新的设备上调出一条完美的自动亮度曲线，使用这种方式可能需要大量的调试，以找出那个合适的亮度等级。那么，有没有一种方式能够提高亮度映射的可移植性，减少调参工作量呢？方式二：间接背光输出 <!-- Array of desired screen brightness in nits corresponding to the lux values in the config_autoBrightnessLevels array. As with config_screenBrightnessMinimumNits and config_screenBrightnessMaximumNits, the display brightness is defined as the measured brightness of an all-white image. If this is defined then: - config_autoBrightnessLcdBacklightValues should not be defined - config_screenBrightnessNits must be defined - config_screenBrightnessBacklight must be defined This array should have size one greater than the size of the config_autoBrightnessLevels array. The brightness values must be non-negative and non-decreasing. This must be overridden in platform specific overlays --> <array name="config_autoBrightnessDisplayValuesNits"> </array>在这种方式下，输出表变为了屏幕亮度，是以尼特为单位的亮度。诶，不对啊，给一个亮度值我该怎么知道这个亮度对应的屏幕背光等级？我不知道背光等级我该怎么告诉硬件该调多亮？别急，还有两个表： <integer-array name="config_screenBrightnessBacklight"> </integer-array> <!-- An array of floats describing the screen brightness in nits corresponding to the backlight values in the config_screenBrightnessBacklight array. On OLED displays these values should be measured with an all white image while the display is in the fully on state. Note that this value should *not* reflect the maximum brightness value for any high brightness modes but only the maximum brightness value obtainable in a sustainable manner. This array should be equal in size to config_screenBrightnessBacklight --> <array name="config_screenBrightnessNits"> </array>这两个表描述了屏幕的物理特性——在某个背光等级下屏幕有多亮。config_screenBrightnessBacklight 与 config_screenBrightnessNits 一一对应，前者描述屏幕背光等级，后者描述该等级下的亮度。于是，在从输出表拿到所需屏幕亮度后，只需将该亮度值放进 config_screenBrightnessNits 表进行匹配，即可从 config_screenBrightnessBacklight 表获得对应的背光等级，于是便可以进行调整了。当然，处于两个等级之间的值，也会通过插值进行计算。这种方式大大提高了亮度曲线的可移植性，不同的机型完全可以使用相同的 config_autoBrightnessDisplayValuesNits 表，而只需要对屏幕硬件进行测量得到描述屏幕物理特性的两张表即可。而测量物理特性是机器可以干的事情，人只需要为每一个环境光照度选择一个屏幕亮度即可，非常方便。自动亮度曲线由于这个东西在接下来的分析中会出现，因此在这里先铺垫一下。以方式二为例。我们可以以照度为横轴，以屏幕亮度 (nit) 为纵轴，绘制一条曲线，这条曲线描述了屏幕亮度随环境光的变化情况：我们还可以利用描述屏幕物理特性的两张表，将归一化背光等级作为横轴，屏幕亮度作为纵轴，绘制屏幕特性曲线：结合上面两条曲线，我们也能够得到以环境光为输入，以归一化背光等级为输出的曲线，正是这条曲线描绘了自动亮度策略的总输入和总输出：对于方式一来说，我们能够直接得到第三条曲线，而无需通过计算进行融合。无论如何，时刻记住：曲线仅仅只是一组映射表的可视化罢了。曲线可以很优雅的反应输入与输出之间的关系，以及凹凸性、增速等复杂的关系，可以为我们的理解和调整带来很大的方便。上图的曲线，是利用 LineageOS 从一加官方 overlay 反编译得到的数据绘制而成的。代码上的实现这块内容就没必要细看了，上面的注释已经写的很清楚了，我们只要看个框架，把东西对应上就够了。“亮度映射策略” 这整个东西，对应于三大组件中的 BrightnessMappingStrategy ，这是一个抽象类，它包含了两个静态内部类负责对抽象的东西进行实现。第一个实现为 SimpleMappingStrategy ，它负责实现上面提及的方式一，从照度对背光等级进行直接映射。第二个实现为 PhysicalMappingStrategy ，它负责实现方式二，先进行照度到亮度的映射，再从亮度映射到背光等级。那么，是如何选择要使用哪一种方式的呢？ @Nullable public static BrightnessMappingStrategy create(Resources resources, DisplayDeviceConfig displayDeviceConfig) { // Display independent values float[] luxLevels = getLuxLevels(resources.getIntArray( com.android.internal.R.array.config_autoBrightnessLevels)); int[] brightnessLevelsBacklight = resources.getIntArray( com.android.internal.R.array.config_autoBrightnessLcdBacklightValues); float[] brightnessLevelsNits = getFloatArray(resources.obtainTypedArray( com.android.internal.R.array.config_autoBrightnessDisplayValuesNits)); float autoBrightnessAdjustmentMaxGamma = resources.getFraction( com.android.internal.R.fraction.config_autoBrightnessAdjustmentMaxGamma, 1, 1); long shortTermModelTimeout = resources.getInteger( com.android.internal.R.integer.config_autoBrightnessShortTermModelTimeout); // Display dependent values - used for physical mapping strategy nits -> brightness final float[] nitsRange = displayDeviceConfig.getNits(); final float[] brightnessRange = displayDeviceConfig.getBrightness(); if (isValidMapping(nitsRange, brightnessRange) && isValidMapping(luxLevels, brightnessLevelsNits)) { BrightnessConfiguration.Builder builder = new BrightnessConfiguration.Builder( luxLevels, brightnessLevelsNits); builder.setShortTermModelTimeoutMillis(shortTermModelTimeout); builder.setShortTermModelLowerLuxMultiplier(SHORT_TERM_MODEL_THRESHOLD_RATIO); builder.setShortTermModelUpperLuxMultiplier(SHORT_TERM_MODEL_THRESHOLD_RATIO); return new PhysicalMappingStrategy(builder.build(), nitsRange, brightnessRange, autoBrightnessAdjustmentMaxGamma); } else if (isValidMapping(luxLevels, brightnessLevelsBacklight)) { return new SimpleMappingStrategy(luxLevels, brightnessLevelsBacklight, autoBrightnessAdjustmentMaxGamma, shortTermModelTimeout); } else { return null; } }很简单，如果方式二有效（ overlay 都正确定义了），则优先使用方式二，否则尝试方式一。屏幕的物理属性是从 DisplayDeviceConfig 直接读取的，这在上面的 “配置被谁读取？” 板块中有所介绍。那么 BrightnessMappingStrategy 都有哪些重要的公开方法呢？这有助于我们对这个类产生一个更全面的认识。public abstract class BrightnessMappingStrategy { ...... public static BrightnessMappingStrategy create(Resources resources, DisplayDeviceConfig displayDeviceConfig) { ...... } ...... public abstract boolean setBrightnessConfiguration(@Nullable BrightnessConfiguration config); ...... public abstract BrightnessConfiguration getBrightnessConfiguration(); ...... public abstract float getBrightness(float lux, String packageName, @ApplicationInfo.Category int category); ...... public abstract float getAutoBrightnessAdjustment(); ...... public abstract boolean setAutoBrightnessAdjustment(float adjustment); ...... public abstract void addUserDataPoint(float lux, float brightness); ...... public abstract void clearUserDataPoints(); ...... }我所认为的重要公开方法总共分为五类：实现类的创建方法。亮度配置的读写方法。从照度获取背光值的方法。（这个在上面的框架分析中已经牵扯到了，接下来就不再介绍）“亮度调整值”的读写方法。“用户数据点”的创建与清除方法。没介绍到的方法都会在接下来有所阐述，此处仅供热身。小结通过这一块内容，我们了解了自动亮度的基本工作机制——即从照度到屏幕背光值的映射是如何进行的，这也是自动亮度工作原理中最重要的内容。所以，现在，假如你觉得自动亮度不舒服，你一定会动手调一调 overlay 中的几张映射表了吧？“学习”用户偏好在上面的框架分析中，我们已经了解到了自动亮度的正向传播方式，即环境光是如何被映射为屏幕背光值的。但是，我们并不知道用户行为是如何反向传播给自动亮度策略的，也就是，拉动亮度条究竟对自动亮度策略产生了什么影响。代码跟踪在上面，我们已知，用户停止拖动亮度条那一瞬间的指定亮度，会被交给 AutomaticBrightnessController 的 configure() 方法，那么接下来又发生了什么呢？继续追着看看。services/core/java/com/android/server/display/AutomaticBrightnessController.java public void configure(...... float brightness, boolean userChangedBrightness, ......) { ...... if (userChangedBrightness && ......) { ...... changed |= setScreenBrightnessByUser(brightness); } ...... }继续： private boolean setScreenBrightnessByUser(float brightness) { if (!mAmbientLuxValid) { // If we don't have a valid ambient lux then we don't have a valid brightness anyway, // and we can't use this data to add a new control point to the short-term model. return false; } mBrightnessMapper.addUserDataPoint(mAmbientLux, brightness); mShortTermModelValid = true; mShortTermModelAnchor = mAmbientLux; if (mLoggingEnabled) { Slog.d(TAG, "ShortTermModel: anchor=" + mShortTermModelAnchor); } return true; }可以看到，这个家伙最终调用了 BrightnessMappingStrategy 的 addUserDataPoint() 方法，把当前“环境亮度”和用户拖动亮度条设置的“用户亮度”一起传了进去。继续：services/core/java/com/android/server/display/BrightnessMappingStrategy.java @Override public void addUserDataPoint(float lux, float brightness) { float unadjustedBrightness = getUnadjustedBrightness(lux); if (mLoggingEnabled) { Slog.d(TAG, "addUserDataPoint: (" + lux + "," + brightness + ")"); PLOG.start("add user data point") .logPoint("user data point", lux, brightness) .logPoint("current brightness", lux, unadjustedBrightness); } float adjustment = inferAutoBrightnessAdjustment(mMaxGamma, brightness /* desiredBrightness */, unadjustedBrightness /* currentBrightness */); if (mLoggingEnabled) { Slog.d(TAG, "addUserDataPoint: " + mAutoBrightnessAdjustment + " => " + adjustment); } mAutoBrightnessAdjustment = adjustment; mUserLux = lux; mUserBrightness = brightness; computeSpline(); }看起来，用户指定的新亮度被先拿去算了一个 adjustment ，也就是“亮度调整值”，然后“用户亮度”和“环境亮度”被记录了下来，最后调用了重新计算插值曲线的方法，我们接着去看看：接下来会牵扯到不同的实现（上面说到了亮度映射的两种方式），我们这里选择简单一点的方式一，方式二的整体原理也是大同小异的，只不过会多一层屏幕物理特性的插值罢了： private void computeSpline() { Pair<float[], float[]> curve = getAdjustedCurve(mLux, mBrightness, mUserLux, mUserBrightness, mAutoBrightnessAdjustment, mMaxGamma); mSpline = Spline.createSpline(curve.first, curve.second); }可以看到，之前算出来的“亮度调整值”，还有之前临时保存的“用户亮度”和“环境亮度”，被一起塞进了 getAdjustedCurve() ，来创建新插值曲线的数据点，我们进去看看： protected Pair<float[], float[]> getAdjustedCurve(float[] lux, float[] brightness, float userLux, float userBrightness, float adjustment, float maxGamma) { float[] newLux = lux; float[] newBrightness = Arrays.copyOf(brightness, brightness.length); if (mLoggingEnabled) { PLOG.logCurve("unadjusted curve", newLux, newBrightness); } adjustment = MathUtils.constrain(adjustment, -1, 1); float gamma = MathUtils.pow(maxGamma, -adjustment); if (mLoggingEnabled) { Slog.d(TAG, "getAdjustedCurve: " + maxGamma + "^" + -adjustment + "=" + MathUtils.pow(maxGamma, -adjustment) + " == " + gamma); } if (gamma != 1) { for (int i = 0; i < newBrightness.length; i++) { newBrightness[i] = MathUtils.pow(newBrightness[i], gamma); } } if (mLoggingEnabled) { PLOG.logCurve("gamma adjusted curve", newLux, newBrightness); } if (userLux != -1) { Pair<float[], float[]> curve = insertControlPoint(newLux, newBrightness, userLux, userBrightness); newLux = curve.first; newBrightness = curve.second; if (mLoggingEnabled) { PLOG.logCurve("gamma and user adjusted curve", newLux, newBrightness); // This is done for comparison. curve = insertControlPoint(lux, brightness, userLux, userBrightness); PLOG.logCurve("user adjusted curve", curve.first ,curve.second); } } return Pair.create(newLux, newBrightness); }这是啥啊？莫慌，我们可以看到它做了两件事情，首先，是把 adjustment 也就是 “亮度调整值” 经过一系列数学运算进行了应用。然后对应用后的曲线，调用了 insertControlPoint() 并把“用户亮度”和“环境亮度”传了进去。看看 insertControlPoint()： private Pair<float[], float[]> insertControlPoint( float[] luxLevels, float[] brightnessLevels, float lux, float brightness) { final int idx = findInsertionPoint(luxLevels, lux); final float[] newLuxLevels; final float[] newBrightnessLevels; if (idx == luxLevels.length) { newLuxLevels = Arrays.copyOf(luxLevels, luxLevels.length + 1); newBrightnessLevels = Arrays.copyOf(brightnessLevels, brightnessLevels.length + 1); newLuxLevels[idx] = lux; newBrightnessLevels[idx] = brightness; } else if (luxLevels[idx] == lux) { newLuxLevels = Arrays.copyOf(luxLevels, luxLevels.length); newBrightnessLevels = Arrays.copyOf(brightnessLevels, brightnessLevels.length); newBrightnessLevels[idx] = brightness; } else { newLuxLevels = Arrays.copyOf(luxLevels, luxLevels.length + 1); System.arraycopy(newLuxLevels, idx, newLuxLevels, idx+1, luxLevels.length - idx); newLuxLevels[idx] = lux; newBrightnessLevels = Arrays.copyOf(brightnessLevels, brightnessLevels.length + 1); System.arraycopy(newBrightnessLevels, idx, newBrightnessLevels, idx+1, brightnessLevels.length - idx); newBrightnessLevels[idx] = brightness; } smoothCurve(newLuxLevels, newBrightnessLevels, idx); return Pair.create(newLuxLevels, newBrightnessLevels); }逻辑很复杂但是并不难，它根据传进来的“环境亮度”，在原来的映射表中找到合适的位置，然后把“环境亮度”和“用户亮度”作为一组新的数据点插了进去，最后调用 smoothCurve() 对曲线进行了光滑。所以，看了这么多，松开亮度条的瞬间，究竟对自动亮度策略干了些什么？这一瞬间的“环境亮度”和“用户亮度”（即用户指定的亮度）被记录了下来。“用户亮度”被用来计算“亮度调整值”。“亮度调整值”被应用于整条自动亮度曲线（映射表）。“环境亮度”和“用户亮度”被插入了原来的映射表（曲线）（就是我们在 overlay 中指定的那些）。对映射表（曲线）进行光滑化，得到新的映射表（曲线）。接下来，我们去看一看这个过程中的一些细节。“亮度调整值”的意义接下来，我们将会进入“亮度调整值”的计算过程 ，来从有些数学的角度看看它是怎么出来的。下面的这段代码，是“亮度调整值”的计算函数，是刚刚略过的东西： protected float inferAutoBrightnessAdjustment(float maxGamma, float desiredBrightness, float currentBrightness) { float adjustment = 0; float gamma = Float.NaN; // Extreme edge cases: use a simpler heuristic, as proper gamma correction around the edges // affects the curve rather drastically. if (currentBrightness <= 0.1f || currentBrightness >= 0.9f) { adjustment = (desiredBrightness - currentBrightness); // Edge case: darkest adjustment possible. } else if (desiredBrightness == 0) { adjustment = -1; // Edge case: brightest adjustment possible. } else if (desiredBrightness == 1) { adjustment = +1; } else { // current^gamma = desired => gamma = log[current](desired) gamma = MathUtils.log(desiredBrightness) / MathUtils.log(currentBrightness); // max^-adjustment = gamma => adjustment = -log[max](gamma) adjustment = -MathUtils.log(gamma) / MathUtils.log(maxGamma); } adjustment = MathUtils.constrain(adjustment, -1, +1); if (mLoggingEnabled) { Slog.d(TAG, "inferAutoBrightnessAdjustment: " + maxGamma + "^" + -adjustment + "=" + MathUtils.pow(maxGamma, -adjustment) + " == " + gamma); Slog.d(TAG, "inferAutoBrightnessAdjustment: " + currentBrightness + "^" + gamma + "=" + MathUtils.pow(currentBrightness, gamma) + " == " + desiredBrightness); } return adjustment; }这里处理了不少的 edge case，我们先看最一般的版本，也就是最后一个 else 分支。本质上，这块代码就是实现了以下两条公式：在这里，C 代表 current brightness ，即把“环境亮度”代入默认亮度曲线得到的“默认亮度”。D 代表 desired brightness，即“用户亮度”。M 是一个常数，从 overlay 中读取，默认取值为 3 。γ (gamma) 和 adj (adjustment “亮度调整值”) 都是待求量。第一条式子只有 gamma 一个变量，我们可以通过两边取对数直接求得结果 gamma = ln(D) / ln(C)。将结果代入第二条式子，我们可以再通过两边取对数，求得 adj 的值 adj = -ln(gamma) / ln(M)。所以，它的意义是什么？从上图中的式子我们已经可以知道，以 gamma 为幂次的变换，可以把“默认亮度曲线”上的一点，变换到理想位置。因此假如我们对整条曲线应用该变换，就可以拉高或拉低该曲线，使之通过我们理想的数据点：当然，为了保证变换的效果符合期待，这里的纵轴，也就是屏幕背光值，都是要进行归一化的。事实上，自动亮度策略在创建时，会在第一时间对背光值进行归一化。既然 gamma 代表了这个能够拟合新数据点的变换，那“亮度调整值”又是什么呢？嗯，其实是另一种奇怪的归一化。它将 gamma 映射到了一个与 M 有关的空间：从上面的代码可以看到adjustment = MathUtils.constrain(adjustment, -1, +1);“亮度调整值”的范围被限制在了 -1 到 1 之间，这反向导致了 gamma 的范围被限制在了 1/M 到 M 之间。（如上图所示，上图的 M 使用的是默认的 3 ）（你可能会问这 adj 不是从 gamma 算过来的么，限制 adj 怎么会反向限制 gamma 的范围呢？别急接着往下看）那为什么计算 adj 时要取负号呢？因为观感。我们的归一化范围是 0 到 1，在这个区间上，大于 1 的 gamma 值会导致曲线被下拉，小于 1 的 gamma 值才会导致曲线被上抬。在经过 adj 的计算后，本来大于 1 的 gamma 值被映射到了小于 0 的区间，而小于 1 的 gamma 值被映射到了大于 0 的区间 （不信再看看图），于是，我们就得到了非常符合直觉的 adj 值：adj 越大，曲线上抬越多。adj 越小，曲线下拉越多。adj 达到边界 1 时，曲线上抬达到极限，此时 gamma = 1/M。adj 到达边界 -1 时，曲线下拉达到极限，此时 gamma = M。adj 为 0 时，对应 gamma = 1，曲线无变化。上面的函数还只是 adj 的计算过程，这东西还没被应用到曲线呢！其实在上面的“代码跟踪”中，应用代码已经可以看到了，非常简单： float gamma = MathUtils.pow(maxGamma, -adjustment); if (mLoggingEnabled) { Slog.d(TAG, "getAdjustedCurve: " + maxGamma + "^" + -adjustment + "=" + MathUtils.pow(maxGamma, -adjustment) + " == " + gamma); } if (gamma != 1) { for (int i = 0; i < newBrightness.length; i++) { newBrightness[i] = MathUtils.pow(newBrightness[i], gamma); } }“亮度调整值”被按原路还原回了 gamma，然后以幂的形式进行了应用。虽然我们保存的是“亮度调整值”而不是 gamma，但在应用时亮度调整值会被还原成 gamma，然后针对整条曲线进行应用。所以我们上面针对 adj 的范围限制，在应用时就会变成 gamma 的范围限制，这就是为什么 gamma 的范围会被 adj 限制在 1/M 到 M 之间。当“用户亮度”过于变态时，adj 撞到边界，导致曲线移动不足，此时使用 adj 还原出的 gamma 是无法完美拟合的：以上图为例，此时的 adj 撞到了下界 -1 ，gamma 值为 M ^ 1 = 3。最后，M 是什么呢？我们已经知道了它的含义：限制最大和最小 gamma 的范围。于是，它在 overlay 中也有一个非常合理的名字： <fraction name="config_autoBrightnessAdjustmentMaxGamma">300%</fraction>小结一下：对于一般情况，计算“亮度调整值”时，先利用原亮度值与目标值之间的关系得到幂变换 gamma，再利用 gamma 得到符合直觉的“亮度调整值” adj。adj 被存储了起来，并在应用时被重新转换回了 gamma。最终应用的，就是应用一个以 gamma 为幂次的幂变换，在未触及边界的理想情况下，这个幂变换将拖动原曲线使之经过目标数据点。上面看了常规的情况，接下来看看 edge case。这次说它是 edge case，是因为它真的在边缘而不是因为很罕见。在上面的计算代码中，我们可以看到： // Extreme edge cases: use a simpler heuristic, as proper gamma correction around the edges // affects the curve rather drastically. if (currentBrightness <= 0.1f || currentBrightness >= 0.9f) { adjustment = (desiredBrightness - currentBrightness); ......在“默认亮度”接近值域边缘时，不再先计算 gamma，而是简单的使用相减来得到 adj 的值。但是，adj 在被应用时仍然是走的上面的从 adj 反算 gamma 的路线。于是对于这一种情况，最终应用的是： gamma = M ^ -(D - C)。由于 adj 不是从 gamma 计算而来的，因此 adj 还原出的 gamma 也没法完美的拟合数据点，甚至只是使曲线向数据点方向略微移动了一点：之所以要设计这种边缘情况，大概是因为谷歌的工程师认为这种情况下如果仍然计算 gamma，会导致曲线的移动范围过大，从而严重破坏别的亮度下的体验。还有两种 edge case 由于过于简单就直接忽略了，一种是“用户亮度”拉满，adj 直接设为上界 1；另一种是“用户亮度”拉到 0 ，adj 直接设为下界 -1 ，之所以要独立处理这两种情况大概是因为对数在这种极限情况下很容易出 0 和 无穷，再做一个除法就是妥妥的 NaN 了。总结：我们现在已经彻底了解了“亮度调整值” adj 的含义，无论在何种情况下，它最终都是被用于还原幂变换系数 gamma 的，公式是 gamma = M ^ -adj。它的来源有两个，在一般且未越界的情况下，它由 gamma 计算而来，能够完美拟合用户数据点；对于值域边缘的亮度，它会被直接计算，此时还原出的 gamma 无法拟合用户数据点，只能使曲线向该方向略微移动。用户数据点插入与光滑在两种情况下，单靠上面的“亮度调整值”是无法完美进行“用户亮度”拟合的：在“亮度调整值”达到边界时，曲线的移动范围会受到 config_autoBrightnessAdjustmentMaxGamma 的限制。在“默认亮度”接近边界时，“亮度调整值”并非由 gamma 计算而来，曲线移动能力受限。既然无法拟合，那岂不是意味着只要用户一松手，屏幕亮度就会离开“用户亮度”？不至于不至于，看起来谷歌的工程师也考虑到了这一点，因此在应用“亮度调整值”后，还会将“用户亮度”和“环境亮度”直接插入到原来的映射表中，确保用户数据点一定位于曲线上。但是，单纯的插入一个数据点，造成的效果可能是极为糟糕的：这种操作只拉低了曲线的其中一段，甚至破坏了曲线的单调性，实在是很烂。于是，怎么让它变得正常一点呢？这便是上面代码中 smoothCurve() 所做的事： private void smoothCurve(float[] lux, float[] brightness, int idx) { if (mLoggingEnabled) { PLOG.logCurve("unsmoothed curve", lux, brightness); } float prevLux = lux[idx]; float prevBrightness = brightness[idx]; // Smooth curve for data points above the newly introduced point for (int i = idx+1; i < lux.length; i++) { float currLux = lux[i]; float currBrightness = brightness[i]; float maxBrightness = MathUtils.max( prevBrightness * permissibleRatio(currLux, prevLux), prevBrightness + MIN_PERMISSABLE_INCREASE); float newBrightness = MathUtils.constrain( currBrightness, prevBrightness, maxBrightness); if (newBrightness == currBrightness) { break; } prevLux = currLux; prevBrightness = newBrightness; brightness[i] = newBrightness; } // Smooth curve for data points below the newly introduced point prevLux = lux[idx]; prevBrightness = brightness[idx]; for (int i = idx-1; i >= 0; i--) { float currLux = lux[i]; float currBrightness = brightness[i]; float minBrightness = prevBrightness * permissibleRatio(currLux, prevLux); float newBrightness = MathUtils.constrain( currBrightness, minBrightness, prevBrightness); if (newBrightness == currBrightness) { break; } prevLux = currLux; prevBrightness = newBrightness; brightness[i] = newBrightness; } if (mLoggingEnabled) { PLOG.logCurve("smoothed curve", lux, brightness); } } private float permissibleRatio(float currLux, float prevLux) { return MathUtils.pow((currLux + LUX_GRAD_SMOOTHING) / (prevLux + LUX_GRAD_SMOOTHING), MAX_GRAD); }代码逻辑看起来复杂，其实还是挺简单的：它从被插值的点处切入，分别向左右逐个检验各个点的数值是否符合要求，若不符合要求则进行纠正，要求如下：首先是单调性，右边的点不能小于左边的点。其次是增长率（插值点往右）或下降率（插值点往左，因为它是从插值点往左逐个检验的，所以看成下降），不能超过一定的数值，而这个数值正是 permissibleRatio() 所计算出来的。之所以要限制曲线的变化速率，大概是为了能够把大的跳变转为多个点的连续变化，从而让曲线尽量变得“光滑”。可以看看把上面的曲线进行了光滑化之后的效果：呐，是不是很符合上面的分析？最终的“用户亮度”拟合方式从上面的“代码跟踪”中可以看到，AOSP 最终采用的策略是先应用“亮度调整值”进行一个幂变换，然后插入用户数据点并光滑曲线，确保在幂变换不足时“用户亮度”也能得到拟合。那就来看看效果吧。（第一张图是仅应用“亮度调整值”的效果，第二张图则是加上用户数据点与光滑后的最终效果）先来看一个案例，这个案例中，“亮度调整值”所带来的幂变换本身就已经足以完成拟合，因此插入数据点并没有带来很大的意义：再来看一个案例，这次，“亮度调整值”撞到边界了，但是插入数据点的操作力挽狂澜：最后一个案例，这次，处于“边缘情况”，“亮度调整值”本身是不足以拟合数据点的：好了，我相信现在，你对“亮度调整值”和“插入用户数据点再光滑”各自对于最终结果的意义，有了一个更深入的了解。对了，“用户数据点”是一次性的，插进了下一个点上一个点会自己失效，因此别想着手动画出一条完美曲线👀小结通过这一整块的内容，我们梳理了自动亮度的两个核心：环境照度是如何被映射为背光值的。用户拖动亮度条是如何影响亮度曲线的。一点自言自语骗子安卓 9 不是引入了所谓的“人工智能自适应亮度”么？那么，在上面的分析中，你看到它了吗？其实这个东西压根就没有进入 AOSP ，它只存在于谷歌的闭源组件当中。这正是在 Pixel 上你可以通过“快速关开自动亮度”来实现重设偏好，而在 AOSP 上压根没有反应的原因。你甚至可以找到为这个组件开的后门： /** * Sets the global display brightness configuration. * * @hide */ @SystemApi @RequiresPermission(Manifest.permission.CONFIGURE_DISPLAY_BRIGHTNESS) public void setBrightnessConfiguration(BrightnessConfiguration c) { setBrightnessConfigurationForUser(c, mContext.getUserId(), mContext.getPackageName()); }这个隐藏 api，可以做到轻松替换整条亮度曲线，甚至是为单个应用设置亮度偏好（比如降多少尼特）。（不过它只对上面的方式二有效）于是乎，假如你有兴趣，你完全可以写一个自定义亮度曲线的软件。继续骗你是否曾经有一种幻觉，认为 AOSP 可以“学习”你的亮度偏好，毕竟设置里写着 帮助“自适应亮度”功能了解您偏好的亮度。就算它没有人工智能，上面的“亮度调整值”和“用户数据点插入”也算是一种学习吧？大错特错，它只是临时学一下（演你），过一会儿就忘了。甚至你还可以配置一个“忘记时间”的 overlay： <integer name="config_autoBrightnessShortTermModelTimeout">300000</integer>时间到了，并且它描述的其它条件也满足时，它会主动清除用户数据点，主动还原“亮度调整值”，让一切都变回最原始的状态。啊，真不错，blame 了一下这个东西也是安卓 9 引入的。看来谷歌可能认为 Short Term Model 到期了，就该由刚刚学习到新鲜数据的人工智能模型接替工作了吧，可是 AOSP 根本就没有这个人工智能啊喂。于是，你拉了亮度条，过了一会儿，它忘了，于是你又拉亮度条，心中还满怀期待的认为多来几次，它总会学会。真是一个感人至深的故事。所以说，安卓 9 引入的这套东西实际上对于纯 AOSP 的自动亮度体验来说就是一个大倒车。在安卓 9 之前，拖动亮度条直接改变的是“亮度调整值”。虽然没有用户数据点，但也不至于主动忘记用户偏好啊。于是写了几个提交，至少让 adjustment 能被记住，对于我这种喜欢“暗一点”自动亮度的人有很大帮助：帮助“自适应亮度”功能了解您偏好的亮度 对于 AOSP 来说，真是一个莫大的讽刺。adjustment 的设置更新路径“亮度调整值”是存在于 System Settings 数据库中的。 /** * Adjustment to auto-brightness to make it generally more (>0.0 <1.0) * or less (<0.0 >-1.0) bright. * @hide */ @UnsupportedAppUsage @Readable public static final String SCREEN_AUTO_BRIGHTNESS_ADJ = "screen_auto_brightness_adj";对于早于安卓 9 的版本，这个值的更新路径大概是这样的：移动亮度条 -> 修改设置 -> system server 监听设置 -> 更新曲线 gamma。但是对于新于安卓 9 的版本，拖动亮度条不会直接改变 adj，而是将“用户亮度”告知相关组件，由它们计算适合的 adj。更新路径就变成了：移动亮度条 -> 传递用户亮度到 system server -> 计算 adj -> 保存 adj。保存 adj 的过程位于屎山 updatePowerState() 中： ...... if (autoBrightnessEnabled) { brightnessState = mAutomaticBrightnessController.getAutomaticScreenBrightness(); newAutoBrightnessAdjustment = mAutomaticBrightnessController.getAutomaticScreenBrightnessAdjustment(); } ...... if (autoBrightnessAdjustment != newAutoBrightnessAdjustment) { // If the autobrightness controller has decided to change the adjustment value // used, make sure that's reflected in settings. putAutoBrightnessAdjustmentSetting(newAutoBrightnessAdjustment); } ......但是，安卓 9 以上的版本仍然保留了直接写入 Settings 来改变 adj 的操作路径，大概是为了兼容性吧： mAutomaticBrightnessController.configure(autoBrightnessEnabled, mBrightnessConfiguration, mLastUserSetScreenBrightness, userSetBrightnessChanged, autoBrightnessAdjustment, autoBrightnessAdjustmentChanged, mPowerRequest.policy);在屎山 updatePowerState() 配置自动亮度的时候传入的 autoBrightnessAdjustment 便是从设置读取的 adj，而 autoBrightnessAdjustmentChanged 则标志着设置是否发生了改变。只有在设置改变时 autoBrightnessAdjustment 才会被采用。正常情况下 autoBrightnessAdjustmentChanged 基本永远是 false，除非你手动写入系统设置。这些屎在第一次看代码的时候非常具有迷惑性，非常让人误以为这是新算出来的 adj，甚至让人觉得自动亮度偏好就是从这条路径更新的，实际上它们只是为了兼容性而保留的旧路径。尾声这也算是写过的最长的 blog 了，主要是很多东西并不是三言两语就能说清楚的，特别是一些理论铺垫和验证还是蛮花时间的。这篇文章主要还是聚焦于自动亮度的算法，对于传感器数据预处理方面的算法并没有怎么涉及，主要是我不怎么感兴趣（笑仿真自动亮度曲线所使用的 MATLAB 代码已经开源在了 Github .Take a look if you are interested.感谢エキ在写这篇文章期间对我的支持。
把虚拟机变成一个网络磁盘以实现文件互通
前言曾经看到有人炫耀wsl的高度集成性——竟然能够像打开硬盘一样在Windows上打开虚拟机的文件？那可不爽死？但是wsl的性能实在是太屑了，于是，现在咱就拿Vmware也整一个本文基于Vmware + Ubuntu 20.04实现，别的组合能在原理上走通应该都一样打通网络这一步是可选的，你有千百种方法使宿主机能够连接上虚拟机，只要有一种能用就可以跳过这步而且，VMware提供的NAT本身应该是能够正确配置的，只有在遇到连接问题时才需要下方操作默认情况下，宿主机地址为192.168.*.1 （可在任务管理器查看VMnet8）（*是随机数）虚拟机地址为192.168.*.* （可通过ifconfig命令查看）只需将虚拟机网络指定为NAT模式似乎就行，甚至不需要特定虚拟网络下面介绍的是我自己使用的操作唠打通虚拟机和宿主机的网络还是挺有用的，可以实现如 虚拟机利用宿主机的网络代理 之类的骚操作要想实现不依赖外部（比如路由器）进行网络互通，可以使用Vmware提供的NAT机制配置NATVmware提供了一套虚拟网络适配器，只需要进行适当的配置，使得宿主机和虚拟机跑在同一个虚拟网的同一个子网上，那相互之间的连接肯定不是问题想要完成这配置，首先打开虚拟网络编辑器然后点击主窗口中的更改设置以使用管理员权限显示所有配置信息默认情况下，Vmware应该本身就分配了一个VMnet8作为NAT模式的虚拟网 （如果没有，就新建一个）你可以勾选使用本地DHCP服务将IP地址分配给虚拟机，如果你懒得为虚拟机配置静态IP的话（对于我这种VMnet8下只有一台虚拟机的来说，DHCP约等于静态ip，反正我就没看它变过。。）你必须勾选将主机虚拟适配器连接到此网络然后，点开NAT设置记一下网关的IP，因为配置宿主机的时候需要用到（宿主机如果不指定静态地址，经常和网关重IP，或者飘到别的子网去就离谱）网关似乎默认给的是.2，但是无所谓，如果看着不爽可以改成.1配置宿主机静态IP宿主机的静态IP，不配置似乎也行，配置起来最好，能够避免很多奇奇怪怪的问题我遇到的问题不限于，宿主机漂移到别的子网宿主机把网关IP占了导致虚拟机直接没网打开控制面板\网络和 Internet\网络连接对着VMNet8右键，属性，Internet协议版本4（TCP/IPv4）为宿主机指定一个ip并把刚刚记录的网关地址填进去，DNS自己看吧，我这里用的是阿里的一路确认退出，然后可以去任务管理器里看看宿主机在VMnet8中的地址，如果定住了，那就ok配置虚拟机网络右键虚拟机，设置，网络适配器，改成上面的那个虚拟网（VMnet8）确认即可，接下来就可以启动虚拟机，使用ifconfig命令查看ip地址，也可以自己配置一下静态ip可能的坑Windows快速启动只要Windows是快速启动的，我的VMnet必炸，两端都通不了，非常离谱。可以在控制面板\硬件和声音\电源选项\系统设置关闭配置SambaSamba是一个SMB协议的实现，允许 Windows 客户访问 Linux 系统上的目录、打印机和文件安装sudo apt install samba基本配置配置文件位于/etc/samba/smb.conf找到配置文件的Share Definitions部分，这一行下面的内容就是共享配置啦先说一下共享的结构，[]包含的内容是共享的名称，比如题图的Ubuntu它默认提供了homes的共享，可以取消注释直接用，但是，我不建议，因为，似乎存在一些问题连接后显示的homes无法打开，不显示鉴权有了凭据之后才会显示用户目录，但是凭据在哪里输入呢？也许需要手动在地址里追加一个用户名称？因此，我建议自己定义一个共享[Ubuntu] comment = libxzr-virtual-machine path = /home/libxzr browseable = yes read only = no valid users = libxzr可以直接放在conf的末尾然后，新建一个Samba用户sudo smbpasswd -a 用户名 这个用户名必须是linux下存在的用户，比如libxzr嗯，用户名也要和上面的valid users对应之后重复两次密码，账户就建立好了，可以尝试在Windows资源管理器里输入\\虚拟机ip进行连接规范化权限众所周知，在Ubuntu下新建文件夹的权限默认是775，而新建文件的默认权限是664，我们可以往配置中加入两行使得我们远程创建的东西也具有相同的权限（强迫症） directory mask = 0775 create mask = 0664规范化权限组假如你新增了一个用户，那么那个用户就可能在你的目录下创建不属于你的文件，这很令人不爽，可以通过强制覆盖用户/用户组来进行规范 force user = libxzr force group = libxzr关闭垃圾ACL这破东西就是个屑我在使用Visual Studio打开虚拟机中代码的时候遇到了一个问题该文件夹不适用，原因如下：您没有此目录的写入权限好啊，我的用户，我的文件夹，我没有权限？搜遍全网，唯一有效的答案是chmod -R 777，smjbwy这里的问题出在：Visual Studio压根没有尝试写入，而是看了看ACL权限表，啊，因为权限表里都是远程用户，所以，“我觉得你没有权限”你寄吧谁啊😅chmod 777之所以能用，是因为它会把ACL中Everyone的权限给足，导致VS觉得，“我可以”只需要关闭ACL就可以解决这个问题nt acl support = no我的完整配置[Ubuntu] comment = libxzr-virtual-machine path = /home/libxzr browseable = yes read only = no valid users = libxzr directory mask = 0775 create mask = 0664 force user = libxzr force group = libxzr nt acl support = no映射网络驱动器我不建议为VMware的这种“有时候下线”的网络驱动器勾选登录时重新连接因为垃圾Windows有时候会因为连不上而被拖死，直接整一个explorer.exe未响应😀在桌面上建一个指向网络驱动器的快捷方式，按需映射是好的拓展配置有时，可能对树外软链接有需求（比如链接到别的磁盘的挂载点）此时需要在[global]节下增加 allow insecure wide links = yes wide links = yes需要注意的是，这可能导致未经授权的目录访问，因为用户可以随机创建指向某些位置的链接还要一种规避风险的方法 wide links = yes unix extensions = no但是，这会导致某些特性失效。。。结语Enjoy
前言曾经看到过有人测试了4800U的性能—功耗曲线，u1s1我确实有被震惊到，台积电7nm + zen2架构的加持下，其低频能效极为出色，身为低压处理器的4800U只需15w功耗就可以在性能上轻松秒杀牙膏厂的标压旗舰——45w的10875H，而在超过甜点频率后，4800U的能效骤降，在功耗大幅增加的同时，性能提升微乎其微。这不经让我好奇，我手里的这颗手动降压打磨版的过气9750H的性能—功耗曲线会长啥样，甜点频率又在何处？测试测试很简单呐，使用throttlestop锁定功耗，然后使用Cinebench R15跑分，记录频率与分数，三次取最高值。结果如图由于我这颗U只能做到全核4GHz，此时降压后功耗大约为60w，所以只能测到60w了。。。进行了一些简单的计算，包括能效，ipc和处理单位任务消耗的能量（使用1/分数 * 功耗进行估算）嗯，这颗过气处理器似乎并没有出现那种常常在手机处理器上出现的能效先增后降的情况，也就是说，可能无法找到一个所谓的最佳能效频率（因为频率越低能效越高...）不过没有关系，还可以拟合一下数据，看看这颗处理器的性能—功耗曲线大致是长什么样的拟合的时候为了让图像看起来更美观，我又额外测了一组空载，大约是(0, 2.5w) (真是辣鸡到爆了)数据拟合数据拟合使用Pytorch，标准的梯度下降和平方损失函数，当然，模型需要自己设计一下class Net(nn.Module): def __init__(self): super().__init__() self.k = Parameter(torch.tensor(0.0)) self.n = Parameter(torch.tensor(0.0)) self.b = Parameter(torch.tensor(0.0)) def forward(self, x): return self.k * (self.n ** x) + self.b大概长这样。思路很简单，众所周知，cpu的频率和功耗的关系通常成指数，而同频性能几乎是不变的，因此性能与功耗的关系显然也是一个指数啦~因此，我打算使用指数函数来进行拟合，再配上k和b进行放缩与平移然而，上面这样的模型是有大问题的，接下来就是踩坑时间坑一 底数拟合不动真的，指数函数很少拿来拟合，我从来没注意到这玩意儿在数学上的特殊性训练模型时遇到的第一个问题就是，底数n训练不动，训练全程似乎只有b在动，最终造成曲线极为奇怪而且损失爆表这个曲线就离谱经过一系列的探索，嗯，是这玩意儿在数学上具有一定的特殊性：底数的范围必须先被确定如果底数n初值被设为0，那么0的x次方始终为0，梯度根本无法反向传递，导致参数无法被更新 （现在的情况）如果底数的初值在0到1之间，那么参数的更新将永远无法越过1这条鸿沟，会造成下图的奇怪拟合因此，要解决这个问题，只需将底数n的初值设置在1以上即可（比如1.5），当然，取一个好点的值能够减少所需训练次数。。坑二 损失都显示不出来了又是一个很离谱的问题，在修改了底数初值之后，连b都没法训练了。。。通常，nan代表函数未能收敛，这种情况只需要通过调整到更小的学习率就可以解决，但是，似乎我怎么调都没有用。。嗯，这是一个由于没有进行特征缩放导致的问题。。我们的输入数据可都是上千的，底数是要训练到多接近于1才能拟合住啊，这都超出浮点的精度范围了吧，自然无法收敛。。解决办法很简单，在模型里面或者外面把输入数据砍一砍就是了比如把性能统一除以60，功耗统一除以10，这样两者的值都会在5左右，拟合起来自然也没啥压力了(self.k * (self.n ** (x / 300)) + self.b) * 10坑三 学习率的选择指数函数就是不一般呐，过大的学习率会导致损失来回震荡，甚至是收敛到一个大的离谱的值，也许指数函数的损失函数关于参数并非只有一个极小值？解决办法：多试试🤣最后选择使用5 * 1e-6的学习率训练200000次完成了！总算是能正常训练出来了。。#!python import torch from torch import nn, optim from torch.autograd import Variable import numpy as np import matplotlib.pyplot as plt from torch.nn.parameter import Parameter from matplotlib.font_manager import FontProperties x_train = torch.tensor([0.0, 365.0, 693.0, 801.0, 897.0, 1024.0, 1111.0, 1235.0]) y_train = torch.tensor([2.5, 7.0, 15.0, 19.5, 25.0, 35.0, 45.0, 59.0]) class Net(nn.Module): def __init__(self): super().__init__() self.k = Parameter(torch.tensor(0.0)) self.n = Parameter(torch.tensor(1.5)) self.b = Parameter(torch.tensor(0.0)) def forward(self, x): return (self.k * (self.n ** (x / 300)) + self.b) * 10 net = Net() criterion = nn.MSELoss() optimizer = optim.SGD(net.parameters(), lr=5 * 1e-6) num_epochs = 200000 inputs = Variable(x_train) target = Variable(y_train) for epoch in range(num_epochs): out = net(inputs) loss = criterion(out, target) optimizer.zero_grad() loss.backward() optimizer.step() if (epoch+1) % 20 == 0: print("Epoch[%d/%d], loss: {:%.6f}"%(epoch+1, num_epochs, loss.data)) net.eval() torch.save(net, "net.pkl") font = FontProperties(fname=r"../math/ITXEWriting.ttf", size=15) plt.xlabel("perf", fontproperties=font) plt.ylabel("power", fontproperties=font) plt.plot(x_train.numpy(), y_train.numpy(), 'ro') x2 = np.arange(0, 1900, 0.1) predict = net(torch.from_numpy(x2)) predict = predict.data.numpy() plt.plot(x2, predict) plt.show()loss也在可接受的范围内科技发展大赏对比一下这9代U的曲线和10代U + Zen2（当然，我这是6C12T的，不过换算过去依然是辣鸡）还有11代H45 + Zen3（注：9750h的R20成绩在2650左右这张图横轴为功耗，纵轴为性能，因此需要把图转一下才能变成这种类似指数的形式）但是转过来之后，what？zen3是指数没错，这h45都要变成对数函数了？？也就是说，目前的功耗，根本还没达到H45的甜点。。这10nm+还真是有点东西我突然发现10代U的牙膏似乎没有白挤呐，你看这功耗曲线，平滑的多（长得更不像指数）而且人家6C12T全核4Ghz只需要大约45w，而我则是需要60w也就是说，10代U虽然还是万年skylake，但是这14nm++++工艺确实还是有明显提升的至于和Zen2 Zen3对比。。。台积电yyds 低频无敌啊 （Zen3与H45的对比中更是如此，Zen3的45w性能约等于H45的60w性能。。）最后来整点好看的众所周知，5800U在15w的功耗下大概能做到1900的R15跑分那来看看我们的9750H做到这个分数大约需要多少功耗300w😅新的H45 11800H的R15跑分大约是2170那看看我们的9750H大概需要多少功耗才能达到这个分数600w草 这就是传说中的指数爆炸么。。最后，挑战5900HX的2205分接近700w 我爬了...单单是x86的前后对比都能达到如此离谱的差距，这么看，m1也不是什么天顶星科技了
前言本来一直在用这个频道的推送来追linux版本更新（以即时更新内核来爽），但是上个月这个频道不知怎么的似乎死了，到现在整整一个月过去了，4.19的longterm已经更新了5个版本，可它还是一点动静也没有。那就自己写一个吧😋开工首先，要用什么来写，第一个想到的便是python，虽说别的东西也能写，但是一个小脚本配合丰富的第三方库，实现起来应该是相当简单的不过python平时基本没摸过，那就来面向搜索引擎编程吧（不过u1s1，这东西真的面向搜索引擎就能轻松写起来..）库这种小脚本主要需要用到两类库吧，一类是用来发请求的，另一类是用来解析的（虽说其实自己手动解析字符串也不是不行。。）发请求的库选定的是著名的，有45k star的 requests解析库则是另一个著名的 Beautiful Soup 4安装这些库，pip install requests bs4pip是可以用代理的，比如pip install xxx --proxy="http://127.0.0.1:1080"里面的http在某个pip更新之后，就变成必须的了，否则直接报错当然，在bash/cygwin环境下可以通过bash profile中增加https_proxy和http_proxy变量来全局代理正式开工开发环境：Windows + Notepad++先导包import requests as req from bs4 import BeautifulSouppy有一种特别的from import语句from后面的东西是包名（文件夹+文件），而import后面的东西则是函数/类as则是取个昵称，取完昵称之后原来的名字就不认了有时候也可以用import xxx.*来把函数/类导进来，但是据说这是要靠对应包的内置配置指定？然后看看要爬的是啥目标：kernel.org真正的目标： 说人话就是release这个table下面的每一行的第二列程序要做的事情大概就是，先找到release这个table，然后在里面遍历每一行，把每一行的第二列放入一个数组（列表）然后返回总的来说，这个页面的结构还是相当鲜明的（好评）发送请求https://docs.python-requests.org/en/master/api/#requests.getres = req.get("https://kernel.org")（这里我在import的时候把requests库重命名为req了）嗯，一行就搞定了，返回的是一个requests.Response对象，我将其命名为res解析请求刚刚拿到了requests.Response的一个对象，这个对象可以有许多的操作https://docs.python-requests.org/en/master/api/#requests.Response此处只需要使用其text属性，拿出返回的正文信息然后用它来构造一个BeautifulSoup对象bs = BeautifulSoup(res.text)嗯，其实这样就可以了，但是会抛出一个warning,此时只需要按照提示再加上一个参数即可（这种是叫 关键字参数？ 不过去掉那个features=也是没有任何问题的）bs = BeautifulSoup(res.text,features="html.parser")之后，就可以跟着官方文档开干了https://www.crummy.com/software/BeautifulSoup/bs4/doc.zh/按照上面所说的思路，首先找到那个release的table 根据文档，我们可以使用find方法，并指定id该方法返回符合搜索条件的第一个东西，由于我们要找的东西只有一个，因此不需要使用能返回所有结果的find_all方法（find_all返回一个包含所有结果的list迭代器（list_iterator），当作数组用for循环遍历即可）table = bs.find("table", id="releases")之后，对这个table里面的东西进行遍历（遍历每一行）根据文档，可以使用table.children来获取其子元素列表（其实也是个list_iterator）在这之前，可以先了解一下遍历出来的东西可能有哪几种类型Tag对应html里的tag (比如div、a、h1都是)NavigableString可遍历的字符串，可以理解为字符内容，比如 aaa 里面装着的aaaBeautifulSoup整个文档，当成Tag就完事了Comment文档中的注释内容，可以视作特殊类型的NavigableString然后，我们开始遍历ret = [] table = bs.find("table", id="releases") for row in table.children: ret.append(row.find_all("td")[1].string)来解释一下这里的逻辑吧，上面说过了.children返回其所有的子节点的一个迭代器（可以当作列表），这里面的子节点应该就是行（ ）了，我们在for循环中对每一行进行遍历，寻找行中所有列（ ）节点，取出第二列的值（就是上面所说的NavigableString）运行一下，炸了。。AttributeError: 'NavigableString' object has no attribute 'find_all'wtf，这是怎么回事，我们遍历出来的行，居然是个NavigableString？然后对这个行进行一个find_all就炸了奇了怪了接着我在循环中打印了一下变量的类型for row in table.children: print(type(row))？？？？<class 'bs4.element.NavigableString'> <class 'bs4.element.Tag'> <class 'bs4.element.NavigableString'> <class 'bs4.element.Tag'> <class 'bs4.element.NavigableString'> <class 'bs4.element.Tag'> <class 'bs4.element.NavigableString'> <class 'bs4.element.Tag'> <class 'bs4.element.NavigableString'> <class 'bs4.element.Tag'> <class 'bs4.element.NavigableString'> <class 'bs4.element.Tag'> <class 'bs4.element.NavigableString'> <class 'bs4.element.Tag'> <class 'bs4.element.NavigableString'> <class 'bs4.element.Tag'> <class 'bs4.element.NavigableString'> <class 'bs4.element.Tag'> <class 'bs4.element.NavigableString'>嗯？居然是Tag和NavigableString的混合体？很明显，那些Tag是我们的目标 接着，来一下.contents，根据官方文档，这将会把子节点以列表形式输出，好让我看看那些NavigableString都是啥table = bs.find("table", id="releases") print(table.contents)结果：['\n', mainline: 懂了，虽然chrome里看源码，table里只有 ，但是实际上中间依然夹杂了一些空行，这些空行被作为字符对象也就是NavigableString输出了有两种解决方法，一种是进行一下类型判断from bs4.element import * for row in table.children: print(type(row)) if type(row) != Tag: continue ret.append(row.find_all("td")[1].string)需要import一下bs4.element，不然这玩意儿不认还有一种是对table进行find_all搜索，只搜索Tag节点for row in table.find_all("tr"): ret.append(row.find_all("td")[1].string)(最后我用了第一种，别问为什么)好啦，这样我们就能获得装着版本号的列表了['5.12-rc7', '5.11.15', '5.10.31', '5.4.113', '4.19.188', '4.14.231', '4.9.267', '4.4.267', 'next-20210416']比较版本别忘了要求，这是一个抓取linux版本更新的bot，也就是说，它需要知道哪些版本更新了思路很简单，一个新的版本列表，一个旧的版本列表，假如新列表里有东西在旧列表里没有，那些东西就是新的版本内容def list_cmp(list_old, list_new): ret = [] for element in list_new: if list_old.count(element) == 0: ret.append(element) return rettelegram 推送首先准备好一个bot （可以参阅 https://zhuanlan.zhihu.com/p/59228574）一个频道（推送消息的）然后，把你的bot拉进频道里，并且设置为管理员然后，在频道里发送一则消息，通过私聊转发给你的bot之后，浏览器打开 https://api.telegram.org/bot<你的token>/getUpdates（把<你的token>整体换成token，别留尖括号）然后，应该可以看到刚刚发给bot的消息的json格式内容，找到其中的forward_from_chat部分，后面紧跟着一个id，把id后面的数字记录下来（可能是负数，很正常），这在给telegram api发请求时会用到（人家要知道你要发给哪个频道啊喂）然后，诶嘿，使用requests库发post请求就是啦（requests库被我重命名为req了，将就着看）chat_id = "" bot_id = "" def send(what): status = 0 while status != 200: status = req.post("https://api.telegram.org/bot" + bot_id + "/sendMessage", {"parse_mode":"Markdown", "text":what, "chat_id":chat_id}).status_codepost方法的第二个参数为post的内容，其中的第一个为telegram的解析模式（为了能够显示粗体，链接之类的，可以指定为markdown或者html，看习惯啦），第二个为消息内容，第三个是要发送到哪个对话里（上面获取的chat_id）当然，telegram的markdown有些奇怪😢，似乎有点不正宗，比如粗体是*aaa*而不是**aaaa**（第二种直接不认）组装倒数第二步，组装上面的逻辑，形成完整代码😋#! /usr/bin/python3 import requests as req import time import logging from bs4.element import * from bs4 import BeautifulSoup chat_id = "-1001174556663" bot_id = "941245555:AAERjsDUPak_P5SJ2HeA4do6S8OVOyBpdi8" logging.basicConfig(level=logging.INFO, filename='/root/kernel_org/log', filemode='w', format= "%(asctime)s - %(pathname)s[line:%(lineno)d] - %(levelname)s: %(message)s" ) def fetch_once(): ret = [] try: res = req.get("https://kernel.org") bs = BeautifulSoup(res.text,features="html.parser") table = bs.find("table", id="releases") for row in table.children: if type(row) != Tag: continue ret.append(row.find_all("td")[1].string) logging.info(ret) except BaseException as e: logging.error(e) return None return ret def list_cmp(list_old, list_new): ret = [] for element in list_new: if list_old.count(element) == 0: ret.append(element) return ret def send(what): status = 0 while status != 200: logging.info("Try sending "+what) status = req.post("https://api.telegram.org/bot" + bot_id + "/sendMessage", {"parse_mode":"Markdown", "text":what, "chat_id":chat_id}).status_code last_result = fetch_once() while True: time.sleep(300) next_result = fetch_once() if next_result is None: continue for item in list_cmp(last_result, next_result): send("*New Linux Kernel Release*\n["+item+"](https://www.kernel.org/)") last_result = next_result 引入了logging方便记录日志，time用来设置多久爬取一次页面，解析页面的部分用try catch罩了起来以防止意外情况（比如这页面突然抽了导致脚本崩溃）部署写一个systemd启动😋[Unit] Description=Korg service After=network.target Wants=network.target [Service] Type=simple ExecStart=/usr/bin/python3 <脚本路径> [Install] WantedBy=multi-user.target 丢到/etc/systemd/system/kernel.org.service然后systemctl enable kernel.org.servicesystemctl start kernel.org.service大功告成可以通过systemctl status kernel.org.service或者日志文件内容来查看状态只要enable了，重启后服务也会自动启动的，无需担心成果展示https://t.me/s/linux_releases额外的踩坑CR LF的传统恶心问题，由于我是在Windows下面写的脚本，Linux下面部署，因此中间需要用dos2unix转换一下，否则直接./脚本名的时候会出现bash报错/usr/bin/python3^M这种第一行的可执行文件路径找不到的问题（当然，直接调用python是没事的，用bash跑才会这样）
前言几天前心血来潮，给内核关掉了WALT强行切换到了PELT负载追踪，但遗憾的是高通的魔改用力过猛，以至于直接切换回PELT（并修复编译）后并不能正常进行负载分配（负载迁移到大核之后难以摊开，EAS下负载全部堆在一个核心上，直到满频过载之后fall back to 传统的调度才会把负载摊开。。。）。当然，去谷歌Pixel的内核那边掏修复补丁是个办法（谷歌不走寻常路的把高通打磨了半天的WALT换回了PELT），但是，我觉得谷歌其实，emmmm也不是这么可信吧（其实是懒得解决移植的冲突，意外情况太多）。那就顺着逻辑，4.4内核上自带的是PELT，WALT是移植的，那就打磨PELT，现在WALT变成了原生支持，PELT反而需要一堆补丁来修复，那就打磨WALT吧。其实还有一个点让我突然想好好研究一下WALT的，那就是我发现PELT下小核的负载比默认WALT下的正常的多，WALT下酷安滑屏小核负载基本都在15%-20%（PELT下在40%以上），而三个中核却有较高的负载（30%以上），这是不科学的，因为能量模型应该已经告诉了内核小核最高频的能效高于大核最低频，而且此时小核性能反而更强，这种莫名的负载迁移确实让我很想调查一下。一些节点的结论懒得写过程了，摸鱼所得，毕竟这些调度算法也不是一时半会儿能看懂的，也都只知道了个大概schedtune节点sched_boost_no_override见下方sched_boostschedtune.colocate指定一个cgroup的任务是不是相关线程组，这在负载迁移和选择核心时会起作用，下面再说sysctl节点位于/proc/sys/kernelsched_boost这个其实很久以前就研究过了，不过还是记录下吧0 -> 小核优先负载 + 相关线程组负载如果超过sched_group_upmigrate则该线程组优先中核 （详细见下）1 -> 大核优先负载2 -> 指定了sched_boost_no_override的任务中核优先负载，其他任务小核优先（相关线程组会被忽略）3 -> 0的内容 + 负载缩放使频率调度更激进？sched_busy_hysteresis_enable_cpussched_busy_hyst_nssched_coloc_busy_hysteresis_enable_cpussched_coloc_busy_hyst_ns这几个是涉及cpuidle的控制，其中hysteresis_enable_cpus是指定CPU的bitmask，hyst_ns是时间，这里的时间大概是指任务执行后暂停cpuidle的时间？默认情况下powerhal会在触摸屏幕时对sched_busy_hysteresis_enable_cpus指定所有核心，对sched_busy_hyst_ns指定一个忘了是多少的时间，手指离开屏幕时复原，这个造成的效果是在中核/大核优先负载时产生明显的jitter下降，小核优先负载时效果不明显。有没有coloc的区别是在于是否针对相关线程组，指针对所有任务还是仅在相关线程组活跃时在指定的核心上进行。sched_dynamic_ravg_window_enablesched_ravg_window_nr_ticks这两个玩意儿提供了神奇的动态控制WALT window size的功能，当内核的HZ被设为250时会默认启用。注意：当HZ不为250时，就算你打开这个功能它也不会工作，所以这节点完全多余。。它默认为90Hz刷新率使用12ms的时间窗口为120Hz刷新率使用8ms时间窗口（display那边在刷新率改变时主动调用）你也可以通过sched_ravg_window_nr_ticks设置更小的时间窗口，窗口的大小算法是1000/HZ得到最小窗口大小，最小窗口大小 * sched_ravg_window_nr_ticks得到指定窗口大小注意：你只能设置更小的时间窗口，更大的时间窗口（比刷新率指定的要大）会被直接忽略（里面有个min的比较）（所以我觉得这两个节点完全没有意义）更小的时间窗口意味着更快的负载响应速度，但是也意味着轻负载更容易被判断为重负载导致不必要的任务迁移prefer_spread_on_idle决定了是否要主动把负载迁移到空闲的核心上，迁移到空闲核心意味着从（idle状态唤醒这些核心） | （阻止刚刚结束任务的核心进入idle）状态，这可能导致更高的耗电，在某些情况下能够提升性能0 -> 否1 -> 小核 是2 -> 全核 是3 -> 小核 仅在刚刚idle时是 （意味着不刻意唤醒深度idle的核心）4 -> 全核 仅在刚刚idle时是 （意味着不刻意唤醒深度idle的核心）sched_force_lb_enable这个东西本身不属于WALT，既然看过了就写一下决定是否要在小核出现需要向上迁移的重负载时，对非小核进行负载平衡sysctl_sched_user_hint临时性的对负载计算进行一点点干预（对负载进行放缩）？最大可写入1000执行强制负载向上转移？这个节点的值会在请求被处理后马上还原为0sched_window_stats_policy牵扯到window内计算负载的几种方式#define WINDOW_STATS_RECENT 0 #define WINDOW_STATS_MAX 1 #define WINDOW_STATS_MAX_RECENT_AVG 2 #define WINDOW_STATS_AVG 3默认是2 想知道到底有啥区别可以理解一下下面的代码，我觉得默认的2就已经挺合理的了 /* Push new 'runtime' value onto stack */ widx = sched_ravg_hist_size - 1; ridx = widx - samples; for (; ridx >= 0; --widx, --ridx) { hist[widx] = hist[ridx]; sum += hist[widx]; if (hist[widx] > max) max = hist[widx]; } for (widx = 0; widx < samples && widx < sched_ravg_hist_size; widx++) { hist[widx] = runtime; sum += hist[widx]; if (hist[widx] > max) max = hist[widx]; } p->ravg.sum = 0; if (sysctl_sched_window_stats_policy == WINDOW_STATS_RECENT) { demand = runtime;//0 } else if (sysctl_sched_window_stats_policy == WINDOW_STATS_MAX) { demand = max;//1 } else { avg = div64_u64(sum, sched_ravg_hist_size); if (sysctl_sched_window_stats_policy == WINDOW_STATS_AVG) demand = avg;//3 else demand = max(avg, runtime);//2 }sched_group_upmigratesched_group_downmigrate这两个玩意儿决定了相关线程组任务的集体向上、向下迁移阈值当负载大于sched_group_upmigrate时，这个组会被设为skip_min，而此时组内任务会中核优先负载（同一组的新任务也是中核优先负载）（不过还是有条件可以让某个组内任务负载在小核上，下面再说）当负载小于sched_group_upmigrate时，skip_min会消失，整个组的任务小核优先负载丢在哪个核心上决定了要通过哪个核心知道任务的实际性能需求（丢上去之前是不知道的），而这也决定了任务在因为性能不足/过剩而迁移前的性能sched_min_task_util_for_colocationsched_task_unfilter_period这就是上一条所说的“不过还是有条件可以让某个组内任务负载在小核上”如果相关线程组内的某个任务，负载小于sched_min_task_util_for_colocation的时间大于sched_task_unfilter_period，那么它就无法和线程组内的其他任务呆在一起，而被迫被丢回小核了sched_min_task_util_for_boost类似上一条，决定了在sched_boost=2时要把哪些boost在中/大核上的任务丢回小核剩下的节点基本就是字面意思了，另外有一两个节点暂且还没搞清楚作用，那就忽略吧~可Hack的点永远小核优先负载 kernel/sched/fair.c | 1 + 1 file changed, 1 insertion(+) diff --git a/kernel/sched/fair.c b/kernel/sched/fair.c index be82b1c345ce..4fbf0b027abf 100644 --- a/kernel/sched/fair.c +++ b/kernel/sched/fair.c @@ -6920,6 +6920,7 @@ static inline bool get_rtg_status(struct task_struct *p) static inline bool task_skip_min_cpu(struct task_struct *p) { return sched_boost() != CONSERVATIVE_BOOST && + sched_boost() != NO_BOOST && get_rtg_status(p) && p->unfilter; }作用：在sched_boost = 0时屏蔽schedtune.colocate（相关线程组）的作用，所有任务在小核上测试负载水平，然后决定是否迁移到中/大核，当然，可能有亿点卡，可以通过调window size 或 HZ 来缓解，直接解决了前言中所说的小核负载不高的问题禁用hysteresis特性一行干掉上面的四个相关节点( kernel/sched/sched_avg.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kernel/sched/sched_avg.c b/kernel/sched/sched_avg.c index 1e7bb64e3c2d..9981b2488bb2 100644 --- a/kernel/sched/sched_avg.c +++ b/kernel/sched/sched_avg.c @@ -221,6 +221,8 @@ u64 sched_lpm_disallowed_time(int cpu) u64 now = sched_clock(); u64 bias_end_time = atomic64_read(&per_cpu(busy_hyst_end_time, cpu)); + return 0; + if (now < bias_end_time) return bias_end_time - now;总结只是简单的摸了一下kernel/sched的相关代码，调速器的具体工作细节还没有摸清，也没有办法解释调度器中一些参数的作用（所以有个pl相关的节点还是没法解释），有生之年会再看看的（笑
解决一加sm8250平台数据流量下难以休眠
前言这个问题由来已久，一直搁着没看，它在官方系统+官方内核上同样发生，导致一加的865设备的数据流量下的待机续航普遍较差，idle drain高达4%-5%/h，这显然是无法接受的。今天决定了要把这个问题瞧一瞧，一瞧就是半个下午+一个晚上。问题描述数据流量下待机续航极差深度睡眠时间极少（截图来自官方内核）日志排查dmesg中存在大量类似这样的日志[ 750.094062] PM: Device alarmtimer failed to suspend: error -16 [ 750.094065] PM: Some devices failed to suspend, or early wake event detected [ 753.496178] active wakeup source: alarmtimer [ 756.421102] active wakeup source: alarmtimer [ 757.386560] active wakeup source: alarmtimer [ 760.735522] active wakeup source: alarmtimer [ 763.220499] active wakeup source: alarmtimer [ 764.955187] active wakeup source: alarmtimer [ 767.227574] active wakeup source: alarmtimer [ 769.694145] active wakeup source: alarmtimer [ 771.424980] active wakeup source: alarmtimer [ 773.686699] active wakeup source: alarmtimer [ 775.367871] pm_system_irq_wakeup: 351 triggered pm8xxx_rtc_alarm另外再附上一张来自官方内核的日志截图非常类似源码排查alarmtimer 来自 kernel/time/alarmtimer.c ，这是一个负责定时器的核心驱动，Android系统层的AlarmManager就是由它在内核层实现的这个驱动能发生问题就很神奇，首先，确定阻止唤醒的原因排查一 为什么它阻止了休眠？经过一次测试，马上锁定了问题的发生点static int alarmtimer_suspend(struct device *dev) { .............. if (ktime_to_ns(min) < 2 * NSEC_PER_SEC) { __pm_wakeup_event(ws, 2 * MSEC_PER_SEC); return -EBUSY; } .............. }错误代码-16就是在这里返回的，可以看到在这里跳出的条件是ktime_to_ns(min) < 2 * NSEC_PER_SEC，结合上下文可知此处是判断下一个定时器的发生时间如果在两秒以内，则阻止休眠（就休眠这两秒没什么用，而且可能导致问题（比如rtc时钟未即时响应导致下一次唤醒时间被设置在休眠时间之前了））我也尝试了移除这段代码，原来的难以休眠变为了诡异的休眠一点点时间（大概一秒）就唤醒，循环进行排查二 诡异的定时器哪里来？定时器创建函数 alarmtimer_enqueuestatic void alarmtimer_enqueue(struct alarm_base *base, struct alarm *alarm) { if (alarm->state & ALARMTIMER_STATE_ENQUEUED) timerqueue_del(&base->timerqueue, &alarm->node); timerqueue_add(&base->timerqueue, &alarm->node); alarm->state |= ALARMTIMER_STATE_ENQUEUED; } 我尝试在里面打印出current->comm从而显示执行这段代码的任务通过比较wifi下与数据流量下的定时器日志，我发现主要区别来自一大堆kworker/xx的进程（排除了来自用户空间的可能性，反而是松了一口气，因为一加的用户空间脏的不行 + 现在的动态分区 使得修改用户空间变得非常困难）而很明显，我无法直接追踪这些kworker到底是哪里的于是我尝试使用工具调试perf 工具我尝试使用perf record -g -a sleep 10记录了10秒内的系统活动情况然后通过perf report打印出来遗憾的是，没有什么有用的东西（主要是特征不是很明显，现在找到了问题再去回看日志，确实是能找到对应函数的痕迹的）定时器激活函数 alarmtimer_firedstatic enum hrtimer_restart alarmtimer_fired(struct hrtimer *timer) { struct alarm *alarm = container_of(timer, struct alarm, timer); struct alarm_base *base = &alarm_bases[alarm->type]; unsigned long flags; int ret = HRTIMER_NORESTART; int restart = ALARMTIMER_NORESTART; spin_lock_irqsave(&base->lock, flags); alarmtimer_dequeue(base, alarm); spin_unlock_irqrestore(&base->lock, flags); if (alarm->function) restart = alarm->function(alarm, base->gettime()); spin_lock_irqsave(&base->lock, flags); if (restart != ALARMTIMER_NORESTART) { hrtimer_set_expires(&alarm->timer, alarm->node.expires); alarmtimer_enqueue(base, alarm); ret = HRTIMER_RESTART; } spin_unlock_irqrestore(&base->lock, flags); trace_alarmtimer_fired(alarm, base->gettime()); return ret; }这个函数在定时器激活时被调用，其中有一个回调函数用于确定定时器是否应该被再次设定这里似乎是可以得到线索的我使用pr_err("%pF", 函数名)的方式从函数指针alarm->function还原出了函数名它指向了函数qmi_rmnet_work_alarm而在这里我也发现了定时器的调用频率超乎想象，每秒就有一次定时器激活（这直接解释了上面对于休眠的观察），这在打开wifi后会立即消失，而再启用流量后又会再次出现它又发生了什么？于是通过函数名我顺利的定位到了问题，来自drivers/soc/qcom/qmi_rmnet.c结合上下文我发现函数qmi_rmnet_check_stats在rmnet_work_quit==false的情况下就会不断通过开启延迟为1s的alarm执行自己，而这正是造成这个问题的原因于是我进行了一些临时的修复首先是Revert这个提交这个提交将原来的workqueue换成了alarm，按提交里的意思是这样唤醒更精确，更省电，但是不知道为什么rmnet_work_quit始终没有被设为true，导致alarm持续唤醒，反而造成了巨量的耗电其次是为工作队列加上unbound和freezable的flag来尽可能降低对功耗的影响一点额外调查我尝试弄清rmnet_work_quit的值在何时会发生改变很遗憾只有这唯一一条日志[ 311.346071] libxzr: rmnet_work_quit=false它就没有被设为true过结暂且不知道这bug应该甩锅给谁，一加还是高通？但是我的代码来自CAF rebase，问题却依然在发生。。嗯？又是帮厂商擦屁股的一天解决后的效果 正常了很多