我观察了这些粉丝过往的留言,发现他们大部分都是懂技术的,其专业程度远超普通用户;我对此表示理解,他们就是因为我的技术关注我的,现在我在这说别的,当然属于是在扯淡。的确,我做技术已经十年了,在某些领域做得还不错,这自然会给人一种固有的刻板印象;五年前,我也因为技术而从大厂出来选择自主创业;不过,我对技术的理解并非你们想象中的那样。
技术,只是解决问题的一种方式。它是你日常工具箱里面的一种工具,就像水管松了用扳手拧,灯泡不亮用万用表测,衣服破了用缝纫机补一样。
然而,很多技术人员总喜欢尝试用技术解释一切,误认为技术能够解决所有问题。我上面举的例子可能更偏工程技术一点,但即使是科学技术也只能解决特定领域的问题,甚至科学本身也是有范畴的。打个比方,你能用技术解决婆媳矛盾吗?你能用技术解决巴以冲突?你能用技术让 Google 进入中国?很显然不能,甚至你邻居家小孩天天在门口拉屎都用技术解决不了。这种“拿着锤子看什么都像是钉子”的问题,并非只有技术人员才有,但越优秀的技术人员,往往越容易陷入这个怪圈。
技术人员还有一个特点,因为他们懂技术,动手能力强,遇到问题往往喜欢自己折腾,除非实在搞不定,绝对不会交给别人。我在网上见到太多的人,它们自己亲自注册公司,自己去给自己软件申请著作权,自己去开通 ChatGPT 账号… 事实上呢,这些事情花钱请别人都能搞定,而且不需要花很多额外的钱。这种“花钱买别人的时间”就是典型的非技术思维,你花钱请别人做给你自己省下来的时间,如果你能创造更多的价值,那干嘛要自己做呢?有人说,我的时间不值钱,让别人做纯属亏本;那你能不能想想怎么把自己时间变得更值钱?
我知道我今天的这两个观点在技术人眼中大逆不道,但我还是要讲出来;我粉丝的绝大部分都是年轻人,很多都在读书还有的刚步入社会;我们在学校的时候,老师只会教我们“好好读书,学好知识(技术)”,但是技术只是一种解决问题的工具,当我们步入社会,会遇到很多技术无法解决的问题;所以越早意识到这一点,主动培养自己技术之外的能力,你的生活会变得越轻松。很遗憾我当年毕业的时候没有人告诉我这一点,因此我作为一个愣头青愣了很多年;我没有想要让你们认同我的观点,把它当作维叔的一个友情提醒就行了。
十年前我花了半个月的工资买了一个自定义域名 weishu.me
,并用它打造了自己的技术博客,博客中有一句座右铭:“不为繁华易匠心”,今天我想把它改成:“君子不器”。
我观察了这些粉丝过往的留言,发现他们大部分都是懂技术的,其专业程度远超普通用户;我对此表示理解,他们就是因为我的技术关注我的,现在我在这说别的,当然属于是在扯淡。的确,我做技术已经十年了,在某些领域做得还不错,这自然会给人一种固有的刻板印象;五年前,我也因为技术而从大厂出来选择自主创业;不过,我对技术的理解并非你们想象中的那样。
技术,只是解决问题的一种方式。它是你日常工具箱里面的一种工具,就像水管松了用扳手拧,灯泡不亮用万用表测,衣服破了用缝纫机补一样。
]]>然而当我真正要解决 Android 系统上应用程序的性能问题时,才发现理想很丰满现实很骨感——手头趁手的工具几乎没有。文章中提到的内核态追踪技术 SystemTap / DTrace 在 Android 系统上压根不存在,用户态的追踪技术开销大到可怕:TraceView 开启后程序性能直接下降十倍不止,Systrace 当时功能半残废,使用起来还需要自己插桩;simpleperf 能使,但就是有点 simple……到最后,真正要解决问题的时候,还是靠经验、二分法和 inline hook;为了定位 Android 虚拟机的性能问题,我甚至还自己造了个 ART HOOK 的轮子。
然而,时间来到 2022 年,世界已焕然一新:eBPF 这种革命性的技术改变了一切。
eBPF is a revolutionary technology with origins in the Linux kernel that can run sandboxed programs in a privileged context such as the operating system kernel. It is used to safely and efficiently extend the capabilities of the kernel without requiring to change kernel source code or load kernel modules.
简单来说,eBPF 是一个运行在 Linux 内核里面的虚拟机组件,它可以在无需改变内核代码或者加载内核模块的情况下,安全而又高效地拓展内核的功能。
eBPF 的前身是 BPF(Berkeley Packet Filter) 技术,它原始的含义是 extended BPF。BPF 是一种古老的技术,最早可以追溯到 1992 年,它是为捕捉和过滤网络数据包而设计的,鼎鼎大名的抓包软件 Wireshark 就是基于它实现的。然而,经过若干年的发展,eBPF 早已脱胎换骨,成为 Linux 内核可观测技术事实上的标准。
在 eBPF 之前,给内核拓展功能非常麻烦。由于内核是硬件资源的管理者,其对安全性和稳定性的要求非常高,所以它的功能迭代周期相比应用程序慢得多。如果想要在内核中添加新功能,要么就直接修改内核代码,要么通过内核模块(LKM)实现。修改源码的话,自己维护成本高,进主干发布周期长,改完替换内核还有风险;用内核模块的话,由于 Linux 内核不提供稳定的 API,其维护成本会很高,内核模块在面临不同的内核版本时会面临巨大的困难。
eBPF 通过在内核中实现一个轻量级虚拟机,可以动态地加载和运行自定义的程序,通过这个自定义的程序可以轻松地拓展内核的功能。虚拟机在运行程序之前会进行校检,可以确保其不会导致 panic(回想一下内核模块,一不小心系统分分钟重启给你看..)另外,在 BTF 的加持下,eBPF 程序可以在跨内核版本上做兼容(内核模块再次泪目…
扯了这么多,有人会说,这玩意不就是一个运行在内核里面的解释器嘛。。就像运行在浏览器里面的 Javascript 引擎一样,有什么好稀罕。这个类比好像没毛病,但由于它运行在内核里,Linux 上独此一家,因此得万千宠爱于一身。因为,Linux 内核它真的太需要一个虚拟机了…
eBPF 技术最早在 Linux 3.15 被引进,从时间上来看已经不算是一种新技术了,而且它在服务端的应用已经有很长的历史,为何到现在咱才想起它呢?
这玩意它首先得益于云原生技术的蓬勃发展,不过跟咱要讲的 Android 没啥关系,暂且不表。对 Android 系统来说,GKI(General Kernel Image) 的出现,让 eBPF 登上了历史舞台。
GKI——通用内核镜像,是 Google 为了解决 Android 碎片化而提出的一种技术。
在 GKI 之前,Android 内核的碎片化非常严重,不同的设备制造商、不同的设备型号,甚至不同的设备版本,其运行的内核代码都不一样;这就导致在内核里面添加功能维护成本巨高,进而使得内核升级几无可能(回想一下以前的 Android 设备,其内核版本出厂之后除了安全补丁,基本上被锁死了)
而 eBPF 作为内核的一个功能,它的启用需要依赖特定的内核编译配置,如果某个内核没有开启某编译选项,eBPF 可能就用不了;在 Android 内核碎片化的年代,想要搞到能完整支持 eBPF 的设备,那是挺难的;如果想用 eBPF 这个功能,只有自己去改内核代码然后自己编译,然而你会遇到各种设备驱动问题,比如触屏失灵,Wifi 不工作等等等等。。当你好不容易在自己设备上折腾好,想要在别的设备上运行时,哦豁,它不支持 BTF,你的 eBPF 程序跑不了。。
GKI 通过统一核心内核,把其他功能(如 SoC,ODM 等提供的)从内核剥离并提供稳定接口(KMI),一举解决了碎片化问题。并且,Google 强制要求,Android 12 以上版本的设备,出厂必须使用 GKI 内核。
更重要的是,GKI 内核的编译选项,完整支持 eBPF 的几乎所有功能!!也就是说,你拿到一个 GKI 的设备,无需自己编译内核代码,它必然支持 eBPF;你在一个 GKI 设备上编写 的 eBPF 程序,可以轻松地拓展到其他的 GKI 设备!
所以现在这个时间点,在 Android 12 已经发布,Android 13 已经 beta 的情况下,是时候开始学习和使用 eBPF 了。
长篇大论了这么多,把 eBPF 吹的那么神,这种所谓的革命性的技术能拿来干啥?官方文档这么说:
eBPF 在现代数据中心和云原生环境中提供高性能的网络和负载均衡,以低开销提取细粒度的安全可观察性数据,帮助应用程序开发人员追踪应用程序,为性能故障排除提供洞察力,预防应用程序和容器运行时的安全执行等等。eBPF 的可能性是无穷的,它所释放的创新才刚刚开始。
那么,在 Android 系统上,它能干什么?按照官方的说法,我们可以拿它来动态追踪应用程序、解决性能问题;可能还不够具体,我举几个例子。
我们知道,应用程序与内核打交道的方式就是系统调用,我们在分析应用程序的时候,监控系统调用是一种非常有效的方式。在 eBPF 之前,我们通常有如下方法:
然而,如果我们使用 eBPF,那事情简直不要太简单,写几行脚本就能实现,甚至有的还是现成的,比如 opensnoop
和 execsnoop
工具,以下是对某加固程序的 opensnoop
运行截图:
用 eBPF 可以很方便地编写各种 portable 的 hook 程序;它可以通过内核的 kprobe/uprobe/tracepoints/USDT 来动态监视甚至修改系统的状态。其中,kprobe 可以在内核的几乎任意地方注入代码, 不只包括函数的入口和出口, 也可以是函数内部的某个偏移地址;uprobe 是 kprobe 的用户空间版本,它可以对你的应用程序注入代码。比如说,我曾想观察 Android 应用对线程的创建,为此,我通过 plt hook 技术 hook 了 libc.so 中的 pthread_create 方法;而现在用 eBPF 的话,通过 bpftrace,我都不需要编写额外的代码,一行就搞定了:
1 | bpftrace -e 'BEGIN { printf("%-10s %-6s %-16s %s\n", "TIME(ms)", "PID", "COMM", "FUNC");} uprobe:/apex/com.android.runtime/lib64/bionic/libc.so:pthread_create{ printf("%-10u %-6d %-16s %s\n", elapsed /1000000, pid, comm, usym(arg2));}' |
由于 eBPF 可以对内核中几乎所有函数插桩,如果我们在内核的某些关键链路加入观测逻辑,那我们就可以监控特定组件的性能;比如说,我们发现应用程序启动过慢,怀疑是有大量的文件读写积累耗时。传统的方法比如微信的 Mars 组件,通过 hook libc 中的 io 函数做监测,而如果使用 eBPF,我们可以直接在内核中监控 vfs,开销更低并且更准确,而且还简单..因为别人已经写好了,一个命令完事;如果我们想监控系统的 io 和网络模块,同样一个命令就能告诉你个大概。。
抓包也是一个很常见的需求,通常情况下,我们有这么些方式:
以上无论何种方式,都可能被应用程序检测到,并且,通常情况下 https 的包抓起来没有那么容易。而如果我们用 eBPF,可以直接在内核中对网络模块插桩,无需绕过 https 证书直接抓包;事实上,有大佬已经写好了:eCapture(虽然目前还不支持 Android,但理论上是可行的。
当然,eBPF 的功能还远不止这么多,正如官方所述,它释放了无穷的可能性,我们可以拭目以待。
另外,虽然现在 eBPF 已经很成熟了,但是让它在 Android 系统上运行的资料并不多,接下来的文章我将介绍如何在 Android 系统中使用 eBPF 以及我们具体如何使用 eBPF,敬请关注~
]]>然而当我真正要解决 Android 系统上应用程序的性能问题时,才发现理想很丰满现实很骨感——手头趁手的工具几乎没有。文章中提到的内核态追踪技术 SystemTap / DTrace 在 Android 系统上压根不存在,用户态的追踪技术开销大到可怕:TraceView 开启后程序性能直接下降十倍不止,Systrace 当时功能半残废,使用起来还需要自己插桩;simpleperf 能使,但就是有点 simple……到最后,真正要解决问题的时候,还是靠经验、二分法和 inline hook;为了定位 Android 虚拟机的性能问题,我甚至还自己造了个 ART HOOK 的轮子。
然而,时间来到 2022 年,世界已焕然一新:eBPF 这种革命性的技术改变了一切。
]]>我听完之后一时间有点恍惚:长这么大,我还没有在外面过年过;每年春节,无论我身处何地,无论回家要转多少趟车,我都会回家陪爸妈过节。电话完之后我赶紧又给老家那边的疾控中心打电话确认了一遍,被告知疫情是隔壁县的,可以放心回家,心里一块石头落了地。终于,我在腊月廿七那一天踏上了回乡的旅途。
那一天我早早地起了床,在经过大半天的转车之后,终于在下午五点左右下了高速出口;结果出人意料的事情发生了:路上的一座大桥被人为堵住,回家的路被彻底堵死了;我辗转千里,却在离家不到两公里的地方被拒之门外。
这座桥大约有四层楼房那么高,其实按照障碍物的高度我是可以从侧边翻过去的,不过万一掉下去我可能就凉了;从上面翻的话,没有着力点,而且对面的就是社区的管理人员,看到你爬越就过来推你。常规方法这条路行不通。
我也尝试过跟工作人员沟通,说我们(很多人都被堵住)大老远回家过年,也不知道路会封,行行好让大家回家团个圆;它的话让我印象深刻:我不管你回来干啥的,这条路就是不让过。
后来实在没有办法,我们选择了直接从河里渡过去;就这样,在室外温度 3℃ 的条件下,我们徒步渡过了 30m 宽的河,美其名曰:大冬天偷渡长江(支流)。
当天晚上我非常气愤,打算直接去举报,并且还准备在公众号发文揭露一下;深思熟虑之后还是作罢了。社区工作人员都是村里人,我的亲人们不可能像我一样常年旅居在外,举报之后难免被穿小鞋;唉,成年人的生活就是这么无奈。
到家之后我惊奇地发现,老妈竟然换成了智能手机;我爸妈都是 50 后,之前有好多次劝说给他们换智能手机,我还放了几个手机在家里,我爸慢慢学会使用了,可是我妈却坚持使十几年前那种小灵通样式的手机;我实在没办法,专程去给她买了个老人机。
没想到短短的一年间,她竟然自己主动换成了智能机。我就问我妈怎么突然想通了要用智能机了,她说老人机用不了健康码——疫情已经持续两年多,它深刻地改变了我们的生活方式。
后来有一天在家我打准备开电脑摸摸鱼,四处寻找插线板;然后我爸给我找了一个出来,一脸得意地跟我说,这是我两块五在拼多多上买的,又便宜又好用。我顿时大为震惊。
我爸,一个年过花甲的老人,当了一辈子农民,手机上连打字都不顺畅,竟然学会了网购?
用着那个两块五的插线板,我越想越不安:这玩意会不会漏电,便宜的没好货啊。于是我就去给我爸说,拼多多上好多假货,他们那个砍一刀啥的都是骗人的,然后我还费了老大劲给他解释砍一刀小数点后面六位数的故事;我告诉他插线板这种东西还是要看质量,万一出现安全问题就完了,以后你要是想买东西就给你儿子说,我给你买;说完我就把它手机上的拼多多卸载了。我爸听完之后一脸失望地走开了。
又过了几天,我叔叔他们来我家拜年,无意之中聊到了拼多多。没想到拼多多获得了大家的一致好评,甚至是追捧。这一次,我没有说拼多多的坏话,安静地听他们讲拼多多的各种好:拼多多上的纸巾几块钱一大袋,质量比街上的那些破纸好多了;拼多多上的空调几百块钱很耐用,街上买的空调几千块用了几个星期就坏了;拼多多上买的水果又便宜又新鲜;拼多多…又便宜质量又好。乡亲们对拼多多的评价,跟我们在公众号、微博,知乎上的评价大相径庭,仿佛彼此生活不在一个世界。
再后来,我去街上逛了一圈,终于释怀了:拼多多上某些商品的质量可能的确比不上京东淘宝,但是秒杀农村街上的那些劣质商品绝对是绰绰有余;拼多多,让五六线城市的普通民众感受到了「性价比」和「质量」。这个结论可能会让人觉得非常诧异,但这的的确确是我亲身体味到的;拼多多改善了亿万人民的生活,它让我肃然起敬。
年轻人回家过年,总免不了被七大姑八大姨围观;问一些诸如“工资多少呀?”、“这么大了还不结婚?”,“什么时候生小孩?”的问题。很久以前我也非常反感这种问题,甚至会恶意抬杠:“今年挣的不多,也就四五百万的样子”。谈到结婚生娃的问题,我会反驳说:咱们又不是猪,到了年龄就要生崽。
不过后来,我再也不会这样了。乡亲们没有去过外面的世界,他们一辈子待在农村,对他们来说,结婚生娃,一辈子就圆满了。虽然在我们看来这种观念非常不对,但在他们的世界观里面,这就是所谓的好日子;他们本质上是希望你过得好或者说往过得好的路上走的,并没有什么恶意。也许你会说:“我不需要这种关心”,“你觉得好跟我觉得好不是一回事”,“以爱之名”;但遗憾的是,在他们有限的认知里面,你跟他们讲这些,他们脑子里面也许是一片空白。他们不懂这些道理,不理解我们,这是时代的局限性,不怪他们;而我们有幸懂得了这些道理,更应该去包容和理解他们,而不是觉得自己懂几个道理就很了不起。
大年初六,我又踏上了离乡的客车。看着窗外消逝的轮廓,我想起了无数次离乡时的场景;这么多年在外漂泊,我们以为自己经历了很多,但不过是翻开了现实的一点皮毛;曾经有一句话很火:“我懂得很多道理,却仍过不好这一生”;我更想说的是:“我们懂得了很多道理,更应该好好过这一生。”
]]>我听完之后一时间有点恍惚:长这么大,我还没有在外面过年过;每年春节,无论我身处何地,无论回家要转多少趟车,我都会回家陪爸妈过节。电话完之后我赶紧又给老家那边的疾控中心打电话确认了一遍,被告知疫情是隔壁县的,可以放心回家,心里一块石头落了地。终于,我在腊月廿七那一天踏上了回乡的旅途。
那一天我早早地起了床,在经过大半天的转车之后,终于在下午五点左右下了高速出口;结果出人意料的事情发生了:路上的一座大桥被人为堵住,回家的路被彻底堵死了;我辗转千里,却在离家不到两公里的地方被拒之门外。
]]>你如今的气质里,藏着你走过的路、读过的书、爱过的人,以及学习过的编程语言。
如果把编程类比作武侠或者修仙里面的打怪升级,那我在入行的头几年,一直在痴迷于各种各样的招式;那时候我学习和体验了各种各样的语言:C/C++/Java/Javascript/Kotlin/Python/Ruby/Scala/Clojure/Scheme/Erlang/Haskell,不过自从我学习了 Haskell 之后,这份列表就基本停止增长了;虽然后来也对 Elixer、Go 也有过简单的了解,但是也仅限于了解了。
一方面随着从业时间的增长,要解决的问题逐渐变成了系统问题或者业务问题;另一方面也是体会到,语言只是工具,招式再花里胡哨也是徒劳,修炼内功才是王道。
不过,在一次机缘巧合之下,我再一次接触到了 Rust。
那是在某大型 5V5 手游上个赛季更新的时候,我制作的游戏体验增强工具就用不了。当时我就想,与其用一些第三方工具找基址,不如直接自己整一个?如果再配合上我自制的内核 rootkit,那岂不是所向无敌?
一不做二不休,我就开始着手写一个内存修改器了。这种类型的工具,最出名的莫过于 CE,于是我就直接去看它的源码;这个项目是用古老的 Pascal 语言写的,我花了点时间了解语法就开始啃;啃着啃着就觉得浑身难受,这门语言语法其实很简单,但是它提供的抽象能力实在有限,语法又十分地啰嗦,写一行代码要吟唱半天;再加上其业务逻辑本身就比较复杂,文件动辄近万行,我估计除了作者本人,这玩意已经没人维护得动了,因此我果断放弃,开始寻找其他类似项目。在寻找的过程中,我发现了一个用 Rust 语言写的运行在 Windows 系统上的原型,其作者还详细地描述了其制作过程,于是我就又开始看起了它的 Rust 代码。
其实在几年前 Rust 发布 2018 Edition 的时候,Rust 有过一波小热潮,那时候我简单地了解过这门语言,不过没有继续深入下去。这个世界上每 30 天就会诞生一门新的编程语言,没什么好稀奇的;并且语言只是一门工具,是工具就要讲究其实用性,如果学会了一门手艺却无用武之地,那这项技能只会慢慢被遗忘。我曾花了很大的力气去学习 Haskell,但是现在我可能连一个 Hello World 也写不出来。
不过,在再一次接触 Rust 之后,我的看法有稍许改变。Rust 是一门充分吸收了现代语言优秀特性,同时提供了高阶的零开销抽象能力以及面向操作系统底层的接口,注重实用、性能,安全和编程体验的语言。它值得每一个已经拥有一定编程经验的工程师去深入学习。
对我来说,一门语言最重要的就是实用性。如果一门工具没有了实用性,那就失去了掌握它的意义。编程语言的使用者,绝大部分都是为了解决实际问题的。我们不是理论研究者,语言的完备性、设计美感甚至一致性都不是我们关注的重点。Haskell 的纯函数式,Ruby 的一切皆对象,在我看来都是非常不实用的特性;这些纯粹的东西,除了理论上的美感,带给我们的还有什么?与之相反,Java、PHP 和 Go 就是非常务实的语言,因此它们在工程师群体内大受欢迎并且广为流行。Rust 也是一门蕴含这种设计哲学的语言。Rust 语言的 Unsafe 经常被人吐槽,很多人嘲讽说「Rust 不是宣称安全性吗,那还提供 Unsafe 干什么?」实际上,现实世界本就是 Unsafe 的,一只蝴蝶扇动翅膀都可能引发一场飓风,一只臭虫都可能引发电路板故障;与 Unsafe 世界打交道使用 Unsafe 是很自然的事。有人会说,Haskell 就没有 Unsafe 呀?不,我不是 PLT 理论家,我不需要学习「自函子范畴上的幺半群」,给我一个 unsafe,我能干翻整个世界[滑稽]
在很多场景下,性能并不是至关重要的因素;“过早优化是万恶之源”。但在某些场合,性能问题是 0 和 1 的问题。我们经常听到人们对 Java 和 C++ 性能的比较,很多人有一种错觉,在 Java 强大的 JIT 加持下,不说超越 C++,接近应该是没问题的;但很多场景并非如此。Android 平台上也使用 Java 系语言,其运行在专为移动设备设计的虚拟机 ART 上,在这种性能受限的场合,原生语言和托管语言之间有着不可逾越的鸿沟。太极 App 的核心现在是用 C++ 构建的(它曾经是 Java),在我从 Java 切换到 C++ 之后,其核心路径的性能最高提升了 10 倍有余!另外,epic 的早期实现使用 Java 提供的 dexmaker,切换到 C++ 之后,dex生成速度提升了将近 10 倍。这是什么概念呢?原本拦截一个函数需要 80 ~ 100ms,大型模块拦截几百个函数,你打开 App 就要黑几秒,现在只需要 6 ~ 15ms,同样的模块就只需要几百 ms,肉眼根本感知不到;这也是太极速度飞快的秘密。
Rust 也是一门注重性能的语言,咱们使用的很多命令行工具就是用 Rust 写的,它们都有超越同类工具的卓越性能,这是它性能最好的佐证;比如 fd, ripgrep, starship 等等。说到 startship,我曾经使用了 spaceship,我去那个龟速简直跟树懒一样;后来切换成了 powerlevel10k 体验好了很多,直到我切换到 starship,我的妈呀那叫一个 balzing fast!用起来简直就是一种享受。虽然我没有探究过 starship 快的原因,也可能是与语言无关的,但是我们可以知道,Rust 在性能方面,有着极高的上限,它完全可以胜任你对性能敏感的场合。
右值和移动语义是 C++11 中最为重要的特性之一,可以说它深刻地改变了整个 C++。Rust 语言没有历史包袱,它默认就是移动语义,不需要你去考虑右值引用、引用折叠,完美转发这种问题,使用起来非常自然。
最近我接触到一个程序,它从一个 socket 读数据,然后解析处理,最后通过 websocket 异步发送到远端。程序是用 Java 写的,它遇到了频繁 GC 导致 stop the world 的性能问题。其原因实际上很简单,读取数据的时候要频繁分配数组导致 GC 繁忙;解决的办法也很简单,那就是建立一个对象池,这个数组在读到 socket 数据的时候分配,在 websocket 异步发送完毕之后就可以重复利用了。然而在没有这种默认语义的语言中,这个方案可能没有那么容易实施;比如说 websocket 你用的是第三方库,它异步处理的时候并没有给你回调,你无法知道数组何时应该 recycle。如果在 Rust 中,处理这种问题就非常容易。
大多数使用高级语言的程序员可能没有意识到,我们写代码本质上就是在与 CPU、内存和外设打交道。我们使用的很多语言并不直接提供对这些资源的访问方式,但是它并不意味着我们应该忽略这些资源。我们学习的任何一门技术,从上层到底层,自顶向下到最后,你一定会接触到 CPU、内存等底层资源。
很久以前我也是一个页面仔,觉得指针简直反人类,GC 就是 yyds,我曾无比期待 Kotlin 1.0 的发布,觉得它就是未来。然而,随着时间的推移,我发现解决很多问题不与底层打交道根本不够;我们使用的上层业务系统,各种框架和库,实际上是另一部份人对底层的封装;而我们对这些框架的使用,不过是遵循某些人制定的游戏规则;我们自己不能成为规则的制定者吗?你们所看到的太极,它需要通过汇编控制寄存器,需要精准地控制 GC 和 JIT,还需要小心翼翼地处理各种内存布局;而我们即将实现的内存修改器,它需要直接操作内核控制内存物理页面,这些东西哪一个不需要关心底层?
Rust 的所有权、生命周期对一些人可能比较新奇,但是,它本质上就是提供了一种对内存的控制方式,仅此而已。而这种访问方式,给了我们一种内存安全的可能,它需要我们编写程序的时候付出更多,但是这种 trade off 是有价值的。
其实 Rust 还有很多东西值得一提,但是我这并不打算做一个 Rust 的全面介绍,感兴趣的话可以 Google 查阅相关资料7,8,9,10,11,12。
如果你是一个有一定经验的程序员,已经能对若干门语言熟练使用,那么我强烈建议你去学习一下 Rust,它的很多优秀特性博采众长,再不济你也可以从这里了解到很多其他语言的精华。当然,如果你是一门新手,那么 Python 和 Java 可能更适合你。
传闻中 Rust 很难学,但我认为并非如此;我曾花费过很长的时间学习 Haskell,你甚至需要翻阅各种论文才能理解其设计,Rust 与其相比真的不算什么,顶多称得上是有门槛;然而,这个世界上没有门槛的事情大多没有核心竞争力。
另外,本文可能提及了一些其他的语言并且赞扬了一波 Rust,但是并没有 diss 和吹捧的意思;如果你是一个语言技术 fans,那么几乎毫无疑问你会成为一个糟糕的决策者;《失败的逻辑》忠告我们,不要为了工具而工具,忘记了工具的目的是什么。
最后,欢迎你成为 Rustaceans 的一员!
]]>你如今的气质里,藏着你走过的路、读过的书、爱过的人,以及学习过的编程语言。
如果把编程类比作武侠或者修仙里面的打怪升级,那我在入行的头几年,一直在痴迷于各种各样的招式;那时候我学习和体验了各种各样的语言:C/C++/Java/Javascript/Kotlin/Python/Ruby/Scala/Clojure/Scheme/Erlang/Haskell,不过自从我学习了 Haskell 之后,这份列表就基本停止增长了;虽然后来也对 Elixer、Go 也有过简单的了解,但是也仅限于了解了。
一方面随着从业时间的增长,要解决的问题逐渐变成了系统问题或者业务问题;另一方面也是体会到,语言只是工具,招式再花里胡哨也是徒劳,修炼内功才是王道。
不过,在一次机缘巧合之下,我再一次接触到了 Rust。
]]>In Unix-like computer OSes (such as Linux), root is the conventional name of the user who has all rights or permissions (to all files and programs) in all modes (single- or multi-user).
维基百科说,在类 Unix 系统中,root 是在所有模式(单用户或多用户)下对所有文件和程序拥有所有权利或许可的用户的名称。
现代操作系统(本文主要讨论 Android 系统,下同)一般都是多用户的,那个名为 root 的用户所拥有的权限就是 root 权限;而 root 权限中有三个「所有」,可以简单这么理解:root 意味着最高权限;不过,这么描述不够具象,接下来就带大家了解一下 root 的方方面面。
在类 Unix 系统中, 一切皆文件。而这些文件通常以一个分层的树形结构在文件系统中呈现,每一个文件都有一个父目录,而那个最顶层的父目录被称之为根目录(root directory);root 用户之所以被称之为 root,大抵是因为它是唯一能修改 root directory 的用户。
因为 root 用户拥有最高权限,因此它也通常被称之为超级用户(superuser);在类 Unix 系统中,每一个用户都有一个 ID,root 用户的 ID 是 0,从传统意义上讲,uid 为 0 就是 root 用户,也就拥有最高权限。
我们一直在说,「最高权限」,那么最高权限到底是哪些呢?
直白点来说,手机是由多个零部件组成的,比如说,CPU、内存、闪存、各种硬件设备(如相机,屏幕、传感器等),所谓最高权限,就是对所有这些设备的控制权。
对于 CPU 来说,现在的 CPU 芯片一般都有不同的特权等级,这些不同的等级可以执行不同的指令。就拿 AArch64 构架来说,CPU 有四个特权等级,EL0 ~ EL3:
在不考虑 Secure World 的情况下(感兴趣的可以 google TrustZone),EL0 就是通常所谓的用户空间,应用程序一般运行在 EL0 级别,操作系统(Linux 内核及驱动)运行在 EL1,EL2 上运行的则是虚拟化相关程序,EL3 则运行管理 Secure World 和 Normal Wrold 的 Secure Monitor。
从严格意义上来说,既然 root 权限意味着最高权限,这应该代表着它可以在 EL0 ~ EL3 中的任意 level 上执行任意的 CPU 指令。然而,当前的各种 root 实现并非如此,它们一般能在 EL0 上执行任意指令,在某些情况下可以在 EL1 上执行任意指令;更高的特权等级就不行了。因为 root 用户是操作系统的概念,因此 root 权限顶多是操作系统中的最高权限;在我们的类 Unix 系统中,它仅能触及 EL1 也就不算奇怪了。
对于内存、闪存以及各种外设来说,它们一般由操作系统内核和驱动来管理,而内核和驱动运行的指令通常位于 EL1,因此,root 权限意味着对所有的硬件(不包括 TEE 相关硬件)和外设拥有完全的控制权。
总结一下就是,root 权限所拥有的最高权限,是我们的用户操作系统能管理的所有权限,包括各种硬件和外设以及一部分 CPU 指令的执行权限。
在过去的很长一段时间里,root 用户拥有的 root 权限代表着最高权限,它能实施一切特权行为;然而,这种模式有着相当大的安全风险。比如说,如果一个 root 用户执行了 rm -rf /
命令(这种情况时有发生),那么整个系统就会瘫痪。
可以看出,直接把最高权限暴露给用户并不明智;本着把权力关进笼子里的原则,root 用户所拥有的 root 权限逐渐被加上了各种限制;从此以后,root 权限并不能为所欲为了。
SELinux 可能是最常见的一种权限控制手段。很多童鞋在刷机或者用框架的时候,会听到诸如「关闭 SELinux」,「开启宽容模式」,实际上就是对 SELinux 的控制。
传统的权限控制是通过自主访问控制实现的,所谓「自主」指的是,这个文件的所有者主动决定哪些其他的用户可以访问它所拥有的文件。一般通过文件权限码和访问控制表来实现。我们通常所说的,文件可读可写可执行,就是文件权限码,除了 rwx 以外,SUID/SGID 也是常见的权限码,它代表某个程序在被 execve 之后会自动切换其 EUID。而访问控制表(ACL)是一种补充手段,它可以实施更加精细的自主访问控制,感兴趣的可以参考Linux 中的访问控制列表。
由于自主访问控制是「自主的」,如果某个用户无意中实施了错误操作(如 rm -rf /
),那么整个系统就不受控了,因此强制访问控制(MAC)应运而生,而 SELinux 则是 MAC 的一种具体实现。
所谓强制访问控制,就是每当主体(通常是进程)尝试访问对象(文件)时,都会由操作系统内核强制施行授权规则,由这些特殊的规则来决定是否授予权限;这些规则是独立于文件权限码之外的,因此,即使某个用户(如 root)是某个文件的所有者,通过这些额外的规则,可以让这个用户无法对这个文件执行某些操作。
通常情况下,root 用户也有权限关闭 SELinux 的部分功能(如开启宽容模式),这种情况下 SELinux 的大部分机制会失效,使得 root 用户可以避开 SELinux 的限制。而我们所使用的一些 root 技术实现(如 Supersu, Magisk 等),会给 SELinux 系统添加一些特殊的规则,可以在不关闭 SELinux 的条件下,让我们的 root 用户绕过 SELinux 的限制,进而拥有传统意义上的超级权限。
在现代的 Android 系统中,如果 root 用户不幸运行在 SELinux Enforcing 状态下,而系统又没有为其开绿灯(定义特殊规则),那么它所能做的操作相当有限,某种程度上甚至连普通用户都不如,表现起来就像是个「残废」。
除了强制访问控制以外,实际上 Linux 系统中还有一些其他的机制来限制超级权限。
在传统的权限控制中,系统一刀切地把权限分为超级权限(privileged)和普通权限(unprivileged),特权用户可以绕过所有的系统限制,而普通用户则受限于其权限位;这种方式有着明显的缺陷:如果普通用户想要执行某一个特殊操作,那它只能切换到特权用户,然而一旦切换到特权用户,它就拥有了所有的特殊权限;那么恶意用户就可以挂羊头卖狗肉,表面上说我只是想读个文件,结果切换到特权用户之后直接把你其他重要东西删了。
因此,Linux 系统引入了 capabilities 机制,这种机制把超级权限划分为若干种权限,按照需要给普通用户分配,很好地解决了一刀切的问题。比如说 CAP_NET_ADMIN
定义了与各种网络操作相关的权限,CAP_SETUID
定义了用户是否可以任意改变自己的 uid;这些不同的 capabilities 可以组合起来形成某个权限范围,从而实现权限的细分。更多的 capabilities 可以参考 Linux man page和 linux-capabilities-in-a-nutshell。
我们在实现某些 root 机制的时候,如果没有处理好 capabilities 的问题,就会出现各种类似残废 root 的情况,比如 root 用户无法访问网络等。
严格来讲,seccomp 并不算是一种安全策略,不过这种机制也能对我们的 root 用户实施某些限制;这里也简单滴介绍一下。
seccomp 是 secure computing mode 的缩写,它是内核的一种安全计算机制,这种机制可以对进程能执行的系统调用(与内核打交道的机制)做出一些限制。
在传统的 seccomp 中,某个进程可以通过这种机制进入一种不可逆转的状态,在这种状态下,它只能执行 exit
, sigreturn
, read
, write
这几个系统调用;后来人们发现对进程的系统调用添加限制还挺有用,于是拓展了 seccomp,让它可以通过某种自定义的策略来限制特定的系统调用(而不是写死的 exit,read 那几个),也就是 seccomp-bpf。
与 capabilities 类似,如果某种 root 机制没有处理好 seccomp 的问题,同样会出现「残废 root 的情况。
我们前面提到,在 AArch64 中,操作系统内核运行在 CPU 的 EL1 特权模式中;然而,我们通常所使用的 root 机制并不能让我们任意地在 EL1 中执行指令,也就是说,我们现在用到的 root 机制,实际上是 EL0 root。
在 Linux 内核中,root 用户可以通过一种被称之为可加载内核模块(LMK)的机制来在内核空间(EL1 级别)执行代码。简单来说,就是写一个内核模块,然后通过 insmod
或者 modprob
将此模块动态注册到内核之中执行;这样我们的 root 用户实际上就拥有了 CPU 的 EL1 执行权限。
然而不幸的是,现代 Android 系统对内核模块加载通常有验证机制,它不允许任意加载模块。通常的验证方式有两种:
vermagic 验证是因为 Linux 不保证内核模块的跨版本兼容性,因此在某个内核版本编译出来的模块被禁止在另外一个版本上运行;它通过内核版本号和导出符号的布局来限制模块的加载,避免出现模块和内核间不同版本的兼容性问题。
签名验证就是一种纯粹的安全机制,在开启这个机制之后,内核拒绝加载未经验证过的签名的模块;与解锁 Bootloader 一样,这个签名通常在设备制造商手里,第三方签名的模块无法被加载。
简单来说,内核模块加载验证机制在某种程度上阻止了我们对 EL1 级别权限的获取,目前我们所使用的 root 实现被限制在 EL0,无法实现更高级别的特权操作。
前面我们描述了 root 权限所能执行的操作和为了限制超级权限做出的一些特殊机制,那么,在 Android 系统中,我们如何获取 root 权限呢?
实现 root 的方式整体上可以分为两种:
我们在前文 可以知道,解锁 Bootloader 实际上就允许我们刷入第三方或者被修改过的操作系统;如果我们可以解锁 Bootloader,那么理论上讲我们就可以任意修改其操作系统,而 root 权限只不过是操作系统的某种机制,因此获取 root 权限自然不在话下。而这种修改方式常见的如下:
init
实现;因为 init 进程是 Linux 系统的第一个进程,因此它实际上就以 root 身份执行的;通过修改 init 也可以实现 root 访问;Magisk 就是通过代理 init 实现的 root 权限。如果我们不能解锁 Bootloader,那么可以通过系统漏洞来提权,进而拥有 root 权限。在远古时代(特别是 Android 6.0 以前),各种一键 root 满天飞,比如 kingroot,360 一键 root,他们本质上都是通过系统漏洞来获取特殊权限;然而,随着 Android 系统的演进,这种通过漏洞提权获取 root 的方式已经很难出现在普通的大众视野里面了。我曾经提到太极有一种「少阳」模式,它就是通过这种方式来提权,进而实现不解锁就能对系统进程加载模块的;然而由于这种方式实际上就是利用漏洞,从机制上讲并不长久,因此被永久雪藏。
如果我们通过系统漏洞提权,那么必须妥善处理好我上面所介绍的各种限制,如绕过 SELinux,关闭 seccomp,获取所有 capabilities,否则提权之后的 root 基本就是个残废;然而,绕过这所有的机制并无一个稳定可用的方法,再加上 Android 系统的碎片化,导致通过漏洞获取通杀 root 权限的实现成本变得非常之高。
上面我们介绍了一些 root 权限的获取方式,那么具体来说,从 Android 诞生到现在,我们实际上所使用的 root 方案,具体是哪种机制呢?
在 Android 系统的远古时代,root 方案基本上是通过 SUID 实现的。在上文描述 capabilities 和自主访问控制的时候我们提到过这种机制。SUID 是一个特殊的权限位,它的特殊之处在于,如果某个可执行文件设置了这个权限位,某个用户在执行这个文件的之后,启动进程的 uid 会被自动切换为文件所有者的 uid。
打个比方,假设我有个文件名叫 su
,它的所有者是 root,其他进程有其可执行权限;如果没有设置 SUID,那么某个进程比如 uid 为 10000,在执行这文件之后,它启动的进程实际 uid 也是 10000,也就是一个普通进程;而如果这个文件有被设置 SUID 位,那么 uid 为 10000 的用户在执行这个文件之后,所启动的进程 uid 会被自动切换为文件所有者,也就是 root 用户,这个进程就是一个特权进程。
用这种方式实现 root 可谓是非常简单,只需要丢一个 SUID 的文件到系统里面就结束了。然而,Android 4.3 系统的安全性改进 让这种机制退出了历史舞台。这种机制其实非常简单,就是通过我们上文提到的 capabilities 机制,给 zygote 的子进程限制了 CAP_SETUID;而我们的 Android App 都是 zygote 子进程,因此 App 从此与 SUID root 告别了。然而,我们的 shell 用户还是可以 SUID 的;某些系统中自带的 su
还是这种 SUID 的 root,在这种系统中我们会发现,adb shell 可以获取 root 权限,但是 App 进程死活无法获取 root,这时候可以看一下 su
文件是否有 SUID 位,如果有的话就是这个原因。
在没有 SUID 之后,App 进程是无法 fork 一个 uid 为 0 的子进程的;从此以后,我们常见的 root 实际上是一种「远程 root 进程」。
因为我们的 App fork 出来的进程 uid 不能被改为 0,因此这个进程肯定无法变成 root 进程,怎么办呢?我们可以启动一个远程的特权进程,这个进程的 uid 是 0(比如从 init fork 出来),一旦我们的 App 需要使用 root,那就从 App fork 出一个子进程,让这个子进程与那个远程的特权进程关联起来,我们想要执行 root 命令的时候,还是与以前一样让这个子进程去做,不同的是,这个子进程实际上并不会执行这些命令,而是把命令发给那个远程的特权进程让它去执行,特权进程执行完毕之后把结果再返回给子进程,这样也实现了我们所需要的 root 访问。你可以简单地把我们 fork 出来的子进程当作那个远程特权进程的代理,所以我把这种方式称之为「远程 root 进程」。现在的Supersu 和 MagiskSU 都是通过这种方式实现的,如果你在 Magisk 下执行一个 root 命令,你会发现除了你自已的 su
进程之外,还会有一个 magiskd 的进程,这两个进程会通过 UDS(Unix Domain Socket) 通信,有很多 Magisk 检测手段就是通过检测 UDS 实现的。
从上文我们可以了解到,当前的各种 root 实现本质上都是 EL0 root。由于这种 root 的实现机制本质上运行在用户空间,它始终尤其局限性;
我们需要一个 EL1 root!
我认为,随着 GKI 的到来,内核的碎片化会逐渐消失,我们完全可以通过修改内核的方式去获取 EL1 root。无比期待这种新的 root 方式的到来。
【完】
本文酝酿了很久,现在终于写完了,如释重负!如果不当之处或者技术性错误,还请海涵!
但愿能帮到大家,晚安!
]]>In Unix-like computer OSes (such as Linux), root is the conventional name of the user who has all rights or permissions (to all files and programs) in all modes (single- or multi-user).
维基百科说,在类 Unix 系统中,root 是在所有模式(单用户或多用户)下对所有文件和程序拥有所有权利或许可的用户的名称。
现代操作系统(本文主要讨论 Android 系统,下同)一般都是多用户的,那个名为 root 的用户所拥有的权限就是 root 权限;而 root 权限中有三个「所有」,可以简单这么理解:root 意味着最高权限;不过,这么描述不够具象,接下来就带大家了解一下 root 的方方面面。
]]>那么,一定会有童鞋关心,解锁 BootLoader 到底意味着什么?为什么它会有限制?我们能绕过限制强制解锁吗?今天,我就尝试来回答一下这几个问题。
在搞清楚解锁 BootLoader 之前,我们必须先搞清楚什么是 BootLoader:
A bootloader is software that is responsible for booting a computer.
维基百科上的介绍言简意赅:Bootloader 是负责启动计算机的软件。计算机开机的时候,会执行一个相对较小的程序来初始化内存、外设等启动后续操作系统必备的资源,并最终启动用户所使用的操作系统(如 Windows, Android 等);这个程序就是 BootLoader。
我们知道,操作系统负责管理设备的硬件资源;而 BootLoader 是用来启动操作系统的,如果有人通过 BootLoader 来启动一个恶意的操作系统,那我们设备的安全性就无法得到保障了。因此,BootLoader 一个核心的功能就是,确保启动一个可信的操作系统。另外,当设备的操作系统出现问题时,BootLoader 还可以引导启动另外一个正常的可信系统来执行恢复;所以,BootLoader 另外一个功能重要功能就是恢复系统。
具体来说,BootLoader 是如何实现这两大功能的呢?以高通设备为例,我们看一下 BootLoader 的启动流程:
当我们按下电源键启动手机的时候,CPU 上电之后开始运行;它最开始运行的代码是 CPU 芯片厂商提供的,写死在某个只读存储上,这段代码一旦出厂便不可更改,我们通常称之为 BootROM 或者 PBL (Primary Boot Loader);PBL 的主要功能是上电自检并启动下一个组件 SBL(Secondary Boot Loader),现在被叫做 XBL (eXtended Boot Loader)。这个 SBL 主要是初始化一些硬件环境(如DDR, clocks 和 USB 等)和代码安全环境 (TrustZone),当然,最重要的还是验证并加载下一个组件——ABL(Android Boot Loader,也叫 aboot)。与 PBL 不同,SBL 程序一般存放在 eMMC 上,这个地方是可以被修改的,因此它可以被刷写和升级。正因如此,SBL 还承载着最底层的恢复设备的重任;我们常说的高通 9008 模式(全称 Emergency Download Mode)就运行在这里。9008 模式,本质上就是强制刷写设备的 eMMC 存储,因此不论你上层操作系统或者应用软件被破坏成什么样,除非硬件损坏,基本上都可以救回来。
有童鞋会问,为啥要整个 SBL,直接 PBL 一把干完不行吗?实际上,PBL 是芯片相关的,芯片无法预知到它用什么外设,因此这两个阶段被解耦了。
话说回来,SBL 执行完之后会验证并加载 ABL,ABL 的主要功能是从设备验证并加载 bootimg 然后启动 Linux Kernel 进而启动用户操作系统,根据选择的不同,可能会进入到 Android 系统或者 Recovery 模式。ABL 还有一个很重要的功能,它提供了一个 fastboot 协议,用户可以用 PC 通过 USB 连接设备,通过这个 fastboot 协议控制设备;我们通常所说的「线刷」,小米的兔子界面以及我们通过命令行工具 fastboot 刷机实际上就是运行在 ABL 下。正因 ABL 功能比较复杂,它内部其实运行着一个 mini 的操作系统,这个操作系统就是 lk(Little Kernel),顺带一提,Trusty TEE 可信执行环境下的操作系统以及 Google 新的 Fuchsia 的微内核 Zircon 也是基于 lk 的。
ABL 启动 Linux Kernel 之后,内核最终会进入用户态执行 init,init 进而启动 ueventd, watchdogd, surfaceflinger 以及 zygote 等;zygote 启动之后 fork system_server 并启动各种 binder 服务,系统核心 binder 服务启动之后会启动 Launcher 桌面,这样整个系统就启动完毕了。联发科的设备 BootLoader 启动过程类似:
从上述 BootLoader 启动过程,我们可以很清楚地知道,BootLoader 的恢复功能体现在 SBL 阶段的恢复模式以及 ABL 阶段的 fastboot 线刷模式。实际上,在我们手机底层软件出现问题之后,不论是自己救砖还是去售后,基本都是用的这两种模式。在搞清楚了 BootLoader 的恢复功能之后,那在安全性方面,它又是如何保障的呢?
细心的童鞋可能会注意到,上面我们多次提到了验证并加载。BootLoader 的各个启动过程串起来就是一个启动链,这个启动链的各个阶段在进行过渡和跳转的时候是需要进行验证的。也就是说,上一阶段在启动下一阶段的时候,会验证下一阶段的代码是否可信;只有在验证通过的情况下,整个启动过程才会继续进行。这就好比接力赛跑,在上一个选手把接力棒传递到下一个选手之前,他得先搞清楚是不是把接力棒交到了正确的伙伴手里;在现实世界中,这是通过五官和记忆来判断的;在计算里面,这个验证的过程实际上就是比对数字签名。不熟悉数字签名的童鞋,可以参阅一下我之前的文章。
如果 BootLoader 的每一个阶段都严格验证数字签名,在代码逻辑都正确的情况下,用户是无法通过 BootLoader 去加载一个修改过的第三方系统的(也就是无法刷机)。那么什么是解锁 BootLoader?
解锁 BootLoader 实际上就是让 BootLoader 启动链上某些阶段的签名验证不生效。如果让 BootROM 不验证 SBL,那我们就可以任意加载 XBL 从而接管接下来的操作流程如 TEE,lk, Linux Kernel;如果让 SBL 不验证 ABL,那我们就可以任意加载 ABL 从而接管 lk 和 Linux Kernel;如果让 ABL 不验证 boot.img,我们就可以控制 Linux Kernel。在现实场景中,人们最需要的就是刷入自定义 boot.img,毕竟这是用户能接触到的最上层系统 Android 的一部分。修改 TEE 和 LK 理论上不是不可以,但是修改这部分人们感知不强,并且通常这部分组件并不开源,也让很多人无从下手。因此实际上,我们通常所说的解锁 BootLoader 特指让 ABL(aboot) 在加载 bootimage 时不进行验证。
某些设备厂商提供了解锁功能,实际上就是通过某种方式关闭了 ABL 中对 bootimage 的加载验证。也许有童鞋会想,我们想办法篡改这个签名,那不是就可以解锁了?比如我们可以这样操作:把 bootimage 修改并用自己的签名,然后把 ABL 中存放签名的地方暴力改成我们的签名;这样 ABL 在校验签名的时候就会通过。但问题是,如果我们要修改 ABL 中存放签名的地方,势必要修改 ABL,那么 SBL 在加载 ABL 的时候就会验证签名不通过;这样的话,我们继续修改 SBL,然后把 PBL 中存签名的地方改掉?看起来或许可以。实际上,整个启动链之所以能保障安全,是因为它的信任传递机制——正因为第一个角色可信,第二个角色才可信,并一步步向下传递。如果有办法破坏掉启动链的第一个角色,那就破坏了整个信任链。所以,在 BootLoader 启动过程中,PBL(BootROM) 所持有的签名是所有安全的基石,也即信任根(Root of Trust)。在 BootLoader 中,有两个机制确保信任根受信:
因此,如果所有的组件都按照理想情况下工作,我们就无法更改 BootROM 所持有的信任根,进而无法破坏启动过程中的信任链,也就无法解锁设备。
当然,一切建立在所有的代码都正常工作的条件下。一旦某个阶段的代码有漏洞,那我们就有希望突破信任链,进而强制解锁设备。
事实上,这种漏洞并非天方夜谭。iOS 上著名的 checkmate 漏洞就是 BootROM 中的代码存在问题,通过这个漏洞人们可以越狱设备,甚至引导另外一个操作系统(比如在 iOS 上运行 Android)。联发科的芯片,也曾经出现过不少 BootROM 漏洞,比如 amonet 和 最近的 kamakiri。今年五月份的安全更新中,CVE-2021-0467 也是一个 BootROM 漏洞。
如果设备提供商偷懒,并没有完整地实现整个安全启动链,那也是可能强制解锁的。比如老的华为设备,其解锁码存放在 proinfo 分区下,这个分区是可以被刷写的;因此你可以拿别的设备的解锁码和 proinfo 分区刷入直接通过验证。还有,曾经的 vivo 对 bootimage 的验证并不严格,用户自定义一个 fastboot 就可以直接解锁。
总体来说,如果想绕过限制强制解锁,基本上只有非常规手段。如果非要搞机,还是去选择一个官方支持解锁的厂商比较靠谱。
不过,对于个人使用的主力机,我是不建议解锁的。上面提到过,解锁实际上是让设备启动过程中的某些安全机制失效;如果你解锁了 BL 的手机丢了或者由于某些原因被别人拿到,别人如果想要拿到你设备里面的数据,相比没有解锁的手机,要容易不止一个数量级。
但愿这篇文章能有所启发,大家周末愉快!
]]>那么,一定会有童鞋关心,解锁 BootLoader 到底意味着什么?为什么它会有限制?我们能绕过限制强制解锁吗?今天,我就尝试来回答一下这几个问题。
在搞清楚解锁 BootLoader 之前,我们必须先搞清楚什么是 BootLoader:
A bootloader is software that is responsible for booting a computer.
维基百科上的介绍言简意赅:Bootloader 是负责启动计算机的软件。计算机开机的时候,会执行一个相对较小的程序来初始化内存、外设等启动后续操作系统必备的资源,并最终启动用户所使用的操作系统(如 Windows, Android 等);这个程序就是 BootLoader。
我们知道,操作系统负责管理设备的硬件资源;而 BootLoader 是用来启动操作系统的,如果有人通过 BootLoader 来启动一个恶意的操作系统,那我们设备的安全性就无法得到保障了。因此,BootLoader 一个核心的功能就是,确保启动一个可信的操作系统。另外,当设备的操作系统出现问题时,BootLoader 还可以引导启动另外一个正常的可信系统来执行恢复;所以,BootLoader 另外一个功能重要功能就是恢复系统。
]]>Android 黑科技保活实现原理揭秘 中的进程永生术是第二种,它通过钻 Android 杀进程的空子实现了涅槃永生;不了解的同学可以参考一下 PoC。归根结底,所谓的黑科技就是利用系统漏洞。那么,既然我们可以利用漏洞逃过追杀,那何不更进一步,利用系统漏洞提权?
实际上,在 Android 系统中,这样的漏洞广泛地存在着。Google 会在每个月初公布其更新的安全漏洞,这些漏洞各种各样。通常情况下,更受人关注的是那些 RCE 或者 EoP 类型的漏洞,它们要么可以远程控制系统,要么可以直接获取操作系统最高权限(Root)。不过,这种类型的漏洞利用起来往往比较困难,要稳定地运行不是一件容易事,而且由于他们危害大,往往很快就会被修复。
太极的少阳模式实际上就是使用这种方法,通过利用 1 Day 漏洞(如水滴,CVE-2020-0423等)直接获取系统最高权限,然后进行注入和拦截,这种方式不需要解锁和刷机就能实现太极阳的完整功能。
但是,如果想要实现保活,可以大大降低这个要求:只需要提权到 system 就可以为所欲为了。当然,我们也不一定要提权,比如说想办法让系统帮忙启动一个服务,比如骗系统帮我们提升进程优先级都是可以的。
接下来,我们介绍一下最近公布的有关 Android 前台服务的漏洞。他们的编号分别是 CVE-2020-0108,CVE-2020-0313。
如果小伙伴们有印象的话,Android 上存在一个广为流传的灰色保活方法:创建两个 Service 来启动通知,最后可以创建一个没有通知栏的前台服务,从而提升进程的优先级。接下来要介绍的这个漏洞与此类似,实际上还有一个 CVE-2020-0313也是前台服务相关。。这块代码实在是写的稀烂,漏洞百出。好了回到正题,我们先介绍一下前台服务:
前台服务执行一些用户能注意到的操作。例如,音频应用会使用前台服务来播放音频曲目。前台服务必须显示通知。即使用户停止与应用的交互,前台服务仍会继续运行。
前台服务所在的进程优先级非常高,一般不会被系统轻易杀死;因此如果有条件创建一个前台服务,就可以实现保活。不过,Android 有一个很强的限制,那就是前台服务必须要显示一个通知;对那些既想要在后台偷偷地跑,又不想被人发现的 App 来说,这个限制实在是让人头大。有没有办法让系统既能启动一个前台服务,又不显示通知呢?
如果我们创建通知的时候,故意出错,系统会有什么反应?
以下是我们创建前台服务的样例代码:
1 | String CHANNEL_ID = "demo_channel"; |
可以看到,我们创建前台服务的时候需要创建一个 NotificationChannel,如果我随便搞一个channel 或者干脆传递一个错误的或者压根不存在的 channel 给系统会咋样?我们简单跟踪一下系统的前台服务启动流程,在真正要创建通知的时候,是在 ServiceRecord.postNotification
1 | try { |
看到这里其实就知道,我们传递了一个不存在的 channel,系统getNotificationChannel
会发现不对劲,然后直接抛出一个异常invalid channel for service notification
,捕获了异常之后,系统会调用 ams.crashApplication
,我们看一下这个 ams.crashApplicaiton
,一路跟踪,我们会发现代码调用到了这里:
1 | void scheduleCrash(String message) { |
哇,我们的系统真的是太温柔了!系统要让咱们进程去死的时候,不是直接提刀把咱砍了,而是赐了一杯毒酒就不管了:爱卿,你自己去死吧。不过,要是咱们进程不听话,把毒就扔了不就逍遥法外了吗!!
这个过程就是 CVE-2020-0108 的原理:创建一个前台服务,但是在他需要前台通知的时候给它一个子虚乌有的 channel,这样前台服务实际上创建好了,不过系统发现不对劲会让咱去死,咱厚着脸皮不死,最终就拥有了一个没有通知的前台服务。
你以为到这就完了?No!这个前台服务代码 Bug 一堆,咱还有个别的姿势同样能达到目的。
我们的总体思路是创建前台服务的时候,给它传递非法的参数让系统创建失败;上面我们给了它一个不合法的 channel,我们实际上还可以在别的地方动手脚:创建通知的时候是可以自定义布局的,如果我们给系统一个错误的布局会咋样?废话不多说我们直接跟踪代码,最终会到这里:
1 | @Override |
这里就更搞笑了,通知创建失败了,系统就是单纯把通知取消了;后面服务该咋运行还是咋运行,系统压根就不管!
好了写到这里,有关前台服务的漏洞我们已经介绍完了。Google 已经在 8 月份的安全更新中修复了这个漏洞;简单看一下修复办法:
1 | void scheduleAppCrashLocked(int uid, int initialPid, String packageName, int userId, |
很好,系统现在在赐死之后,过了五秒钟回来看一下是不是真的死了,如果没有死了再自己动手砍一刀;这才是正常的赐死逻辑嘛,哈哈。
如果你是一个普通用户,很可能会觉得奇怪,使用这么广泛的 Android 系统竟然存在着这么多低级漏洞?是的,任何软件系统都不可能没有 BUG,这是没法避免的客观事实。我们唯一能做到的是:如果手机有安全性更新,一定要及时更新!!千万不要觉得旧系统不是挺好的嘛,越升级越难用;否则,如果这些公开的漏洞被人利用,后果不敢设想。另外, 千万不要选择那些万年不更新安全补丁的辣鸡手机!!
]]>Android 黑科技保活实现原理揭秘 中的进程永生术是第二种,它通过钻 Android 杀进程的空子实现了涅槃永生;不了解的同学可以参考一下 PoC。归根结底,所谓的黑科技就是利用系统漏洞。那么,既然我们可以利用漏洞逃过追杀,那何不更进一步,利用系统漏洞提权?
]]>一开始我以为是我自己程序写的有问题,毕竟这个驱动是使用纯 C 语言实现的,并且用到了 epoll 的 ET 模式,这种非阻塞的编程模型的确有许多微妙的地方,一不小心就容易出错。我排查了很久都没有发现问题所在,更有趣的是,虽然看起来我的程序无法回收资源,但是在压力测试下他也能正常工作,完全没有资源泄漏的迹象;实在没办法,我就祭出了大杀器 strace。不看不知道,一看就好笑:strace 显示,我的程序逻辑是正常的,它正确地调用了相关的资源释放函数!但是,logcat 中没有相关的日志,在客户端退出之后 server 端的日志就戛然而止了。看起来,好像不是我程序的问题,而是系统的 logcat 丢失了日志?
出于好奇,我就去简单看了下 Android 上 logcat 的实现。原来,logcat 也用了 C/S 模式,有个 logd 的守护进程工作在 server 端,各个进程通过 Log.d 等方法输出日志的时候,实际上也是通过一个 socket 以异步的方式传递给了 logd,logd 再把日志输出到 logcat。当我看到客户端连接 logd 的代码的时候,就立马明白了。。我们看代码:
1 | int LogListener::GetLogSocket() { |
以上代码摘自 AOSP master 分支
原来我们的 logcat 的 socket 使用的是 UDP !
UDP 是User Datagram Protocol的简称, 中文名是用户数据报协议,是OSI(Open System Interconnection,开放式系统互联) 参考模型中一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务,IETF RFC 768 [1] 是UDP的正式规范。UDP在IP报文的协议号是17。(摘自百度百科)
与 TCP 不同,UDP 是不保证可靠传输的。我的程序用的 TCP,因此在 send/recv 完数据之后即使进程退出,内核也会保证数据能正确地发送到对端(在对端正常的情况下);而 logcat 使用的 UDP,一旦进程退出,数据包是有可能无法送达 logd 的。顺便一提,除了这种丢日志的情形之外,还有一种更常见的情况,就是 logcat 觉得你的日志太频繁把你阉割了,这种情况下我们会在日志中看到 “chatty” 等字样,只需要设置 logcat 的相关属性就可以解决了。
这不禁让我想起好几年前我在知乎上回答的一个问题: JAVA中:String的equals方法会不会因为恶劣的环境(海啸地震、外星人入侵等)导致运行出错?
还有,我之前在写太极的时候,发现有个 App 无论如何也注入不进去;后来发现是因为这个 App 把应用的日志全部重定向到了 /dev/null,使得我们无法看到任何日志,然后误以为是程序逻辑没有执行。
实际上,除了代码之外,我们经常会遇到类似的问题。归根结底,就是我们眼睛看到的东西看起来跟“事实”不一致。很多时候,我们会无意识地相信眼睛看到的,毕竟,「眼见为实」嘛!不过,如果“亲眼所见” 最终得出荒谬结论的时候,一定要想想是不是“看到的”有问题。
真实世界中没有鬼,如果有,也只能代表眼睛看到了鬼。
]]>一开始我以为是我自己程序写的有问题,毕竟这个驱动是使用纯 C 语言实现的,并且用到了 epoll 的 ET 模式,这种非阻塞的编程模型的确有许多微妙的地方,一不小心就容易出错。我排查了很久都没有发现问题所在,更有趣的是,虽然看起来我的程序无法回收资源,但是在压力测试下他也能正常工作,完全没有资源泄漏的迹象;实在没办法,我就祭出了大杀器 strace。不看不知道,一看就好笑:strace 显示,我的程序逻辑是正常的,它正确地调用了相关的资源释放函数!但是,logcat 中没有相关的日志,在客户端退出之后 server 端的日志就戛然而止了。看起来,好像不是我程序的问题,而是系统的 logcat 丢失了日志?
]]>早期的 Android 系统不完善,导致 App 侧有很多空子可以钻,因此它们有着有着各种各样的姿势进行保活。譬如说在 Android 5.0 以前,App 内部通过 native 方式 fork 出来的进程是不受系统管控的,系统在杀 App 进程的时候,只会去杀 App 启动的 Java 进程;因此诞生了一大批“毒瘤”,他们通过 fork native 进程,在 App 的 Java 进程被杀死的时候通过 am
命令拉起自己从而实现永生。那时候的 Android 可谓是魑魅横行,群魔乱舞;系统根本管不住应用,因此长期以来被人诟病耗电、卡顿。同时,系统的软弱导致了 Xposed 框架、阻止运行、绿色守护、黑域、冰箱等一系列管制系统后台进程的框架和 App 出现。
不过,随着 Android 系统的发展,这一切都在往好的方向演变。
uid
为标识,通过杀死整个进程组来杀进程,因此 native 进程也躲不过系统的法眼。然而,道高一尺,魔高一丈。系统在不断演进,保活方法也在不断发展。大约在 4 年前出现过一个 MarsDaemon,这个库通过双进程守护的方式实现保活,一时间风头无两。不过好景不长,进入 Android 8.0 时代之后,这个库就逐渐消亡。
一般来说,Android 进程保活分为两个方面:
随着 Android 系统变得越来越完善,单单通过自己拉活自己逐渐变得不可能了;因此后面的所谓「保活」基本上是两条路:1. 提升自己进程的优先级,让系统不要轻易弄死自己;2. App 之间互相结盟,一个兄弟死了其他兄弟把它拉起来。
当然,还有一种终极方法,那就是跟各大系统厂商建立 PY 关系,把自己加入系统内存清理的白名单;比如说国民应用微信。当然这条路一般人是没有资格走的。
大约一年以前,大神 gityuan 在其博客上公布了 TIM 使用的一种可以称之为「终极永生术」的保活方法;这种方法在当前 Android 内核的实现上可以大大提升进程的存活率。笔者研究了这种保活思路的实现原理,并且提供了一个参考实现 Leoric。接下来就给大家分享一下这个终极保活黑科技的实现原理。
知己知彼,百战不殆。既然我们想要保活,那么首先得知道我们是怎么死的。一般来说,系统杀进程有两种方法,这两个方法都通过 ActivityManagerService 提供:
在原生系统上,很多时候杀进程是通过第一种方式,除非用户主动在 App 的设置界面点击「强制停止」。不过国内各厂商以及一加三星等 ROM 现在一般使用第二种方法。第一种方法太过温柔,根本治不住想要搞事情的应用。第二种方法就比较强力了,一般来说被 force-stop 之后,App 就只能乖乖等死了。
因此,要实现保活,我们就得知道 force-stop 到底是如何运作的。既然如此,我们就跟踪一下系统的 forceStopPackage
这个方法的执行流程:
首先是 ActivityManagerService
里面的 forceStopPackage
这方法:
1 | public void forceStopPackage(final String packageName, int userId) { |
在这里我们可以知道,系统是通过 uid
为单位 force-stop 进程的,因此不论你是 native 进程还是 Java 进程,force-stop 都会将你统统杀死。我们继续跟踪forceStopPackageLocked
这个方法:
1 | final boolean forceStopPackageLocked(String packageName, int appId, |
这个方法实现很清晰:先杀死这个 App 内部的所有进程,然后清理残留在 system_server 内的四大组件信息;我们关心进程是如何被杀死的,因此继续跟踪killPackageProcessesLocked
,这个方法最终会调用到 ProcessList
内部的 removeProcessLocked
方法,removeProcessLocked
会调用 ProcessRecord
的 kill
方法,我们看看这个kill
:
1 | void kill(String reason, boolean noisy) { |
这里我们可以看到,首先杀掉了目标进程,然后会以uid
为单位杀掉目标进程组。如果只杀掉目标进程,那么我们可以通过双进程守护的方式实现保活;关键就在于这个killProcessGroup
,继续跟踪之后发现这是一个 native 方法,它的最终实现在 libprocessgroup中,代码如下:
1 | int killProcessGroup(uid_t uid, int initialPid, int signal) { |
注意这里有个奇怪的数字:40。我们继续跟踪:
1 | static int KillProcessGroup(uid_t uid, int initialPid, int signal, int retries) { |
瞧瞧我们的系统做了什么骚操作?循环 40 遍不停滴杀进程,每次杀完之后等 5ms,循环完毕之后就算过去了。
看到这段代码,我想任何人都会蹦出一个疑问:假设经历连续 40 次的杀进程之后,如果 App 还有进程存在,那不就侥幸逃脱了吗?
那么,如何实现这个目的呢?我们看这个关键的 5ms。假设,App 进程在被杀掉之后,能够以足够快的速度(5ms 内)启动一堆新的进程,那么系统在一次循环杀掉老的所有进程之后,sleep 5ms 之后又会遇到一堆新的进程;如此循环 40 次,只要我们每次都能够拉起新的进程,那我们的 App 就能逃过系统的追杀,实现永生。是的,炼狱般的 200ms,只要我们熬过 200ms 就能渡劫成功,得道飞升。不知道大家有没有玩过打地鼠这个游戏,整个过程非常类似,按下去一个又冒出一个,只要每次都能足够快地冒出来,我们就赢了。
现在问题的关键就在于:如何在 5ms 内启动一堆新的进程?
再回过头来看原来的保活方式,它们拉起进程最开始通过am
命令,这个命令实际上是一个 java 程序,它会经历启动一个进程然后启动一个 ART 虚拟机,接着获取 ams 的 binder 代理,然后与 ams 进行 binder 同步通信。这个过程实在是太慢了,在这与死神赛跑的 5ms 里,它的速度的确是不敢恭维。
后来,MarsDaemon 提出了一种新的方式,它用 binder 引用直接给 ams 发送 Parcel,这个过程相比 am
命令快了很多,从而大大提高了成功率。其实这里还有改进的空间,毕竟这里还是在 Java 层调用,Java 语言在这种实时性要求极高的场合有一个非常令人诟病的特性:垃圾回收(GC);虽然我们在这 5ms 内直接碰上 gc 引发停顿的可能性非常小,但是由于 GC 的存在,ART 中的 Java 代码存在非常多的 checkpoint;想象一下你现在是一个信使有重要军情要报告,但是在路上却碰到很多关隘,而且很可能被勒令暂时停止一下,这种情况是不可接受的。因此,最好的方法是通过 native code 给 ams 发送 binder 调用;当然,如果再底层一点,我们甚至可以通过 ioctl
直接给 binder 驱动发送数据进而完成调用,但是这种方法的兼容性比较差,没有用 native 方式省心。
通过在 native 层给 ams 发送 binder 消息拉起进程,我们算是解决了「快速拉起进程」这个问题。但是这个还是不够。还是回到打地鼠这个游戏,假设你摁下一个地鼠,会冒起一个新的地鼠,那么你每次都能摁下去最后获取胜利的概率还是比较高的;但如果你每次摁下一个地鼠,其他所有地鼠都能冒出来呢?这个难度系数可是要高多了。如果我们的进程能够在任意一个进程死亡之后,都能让把其他所有进程全部拉起,这样系统就很难杀死我们了。
新的黑科技保活中通过 2 个机制来保证进程之间的互相拉起:
具体来说,创建 2 个进程 p1, p2,这两个进程通过文件锁互相关联,一个被杀之后拉起另外一个;同时 p1 经过 2 次 fork 产生孤儿进程 c1,p2 经过 2 次 fork 产生孤儿进程 c2,c1 和 c2 之间建立文件锁关联。这样假设 p1 被杀,那么 p2 会立马感知到,然后 p1 和 c1 同属一个进程组,p1 被杀会触发 c1 被杀,c1 死后 c2 立马感受到从而拉起 p1,因此这四个进程三三之间形成了铁三角,从而保证了存活率。
分析到这里,这种方案的大致原理我们已经清晰了。基于以上原理,我写了一个简单的 PoC,代码在这里:https://github.com/tiann/Leoric 有兴趣的可以看一下。
本方案的原理还是比较简单直观的,但是要实现稳定的保活,还需要很多细节要补充;特别是那与死神赛跑的 5ms,需要不计一切代价去优化才能提升成功率。具体来说,就是当前的实现是在 Java 层用 binder 调用的,我们应该在 native 层完成。笔者曾经实现过这个方案,但是这个库本质上是有损用户利益的,因此并不打算公开代码,这里简单提一下实现思路供大家学习:
libbinder 是 NDK 公开库,拿到对应头文件,动态链接即可。
难点:依赖繁多,剥离头文件是个体力活。
如何组织 binder 通信的数据?
通信的数据其实就是二进制流;具体表现就是 (C++/Java) Parcel 对象。native 层没有对应的 Intent Parcel,兼容性差。
方案:
今天我把这个实现原理公开,并且提供 PoC 代码,并不是鼓励大家使用这种方式保活,而是希望各大系统厂商能感知到这种黑科技的存在,推动自己的系统彻底解决这个问题。
两年前我就知道了这个方案的存在,不过当时鲜为人知。最近一个月我发现很多 App 都使用了这种方案,把我的 Android 手机折腾的惨不忍睹;毕竟本人手机上安装了将近 800 个 App,假设每个 App 都用这个方案保活,那这系统就没法用了。
如果我们把系统杀进程比喻为斩首,那么这个保活方案的精髓在于能快速长出一个新的头;因此应对之法也很简单,只要我们在斩杀一个进程的时候,让别的进程老老实实呆着别搞事情就 OK 了。具体的实现方法多种多样,不赘述。
在厂商没有推出解决方案之前,用户可以有一些方案来缓解使用这个方案进行保活的流氓 App。这里推荐两个应用给大家:
通过冰箱的冻结和 Island 的深度休眠可以彻底阻止 App 的这种保活行为。当然,如果你喜欢别的这种“冻结”类型的应用,比如小黑屋或者太极的阴阳之门也是可以的。
其他不是通过“冻结”这种机制来压制后台的应用理论上对这种保活方案的作用非常有限。
早期的 Android 系统不完善,导致 App 侧有很多空子可以钻,因此它们有着有着各种各样的姿势进行保活。譬如说在 Android 5.0 以前,App 内部通过 native 方式 fork 出来的进程是不受系统管控的,系统在杀 App 进程的时候,只会去杀 App 启动的 Java 进程;因此诞生了一大批“毒瘤”,他们通过 fork native 进程,在 App 的 Java 进程被杀死的时候通过 am
命令拉起自己从而实现永生。那时候的 Android 可谓是魑魅横行,群魔乱舞;系统根本管不住应用,因此长期以来被人诟病耗电、卡顿。同时,系统的软弱导致了 Xposed 框架、阻止运行、绿色守护、黑域、冰箱等一系列管制系统后台进程的框架和 App 出现。
不过,随着 Android 系统的发展,这一切都在往好的方向演变。
]]>去年我给出了一种绕过Android P对非SDK接口限制的简单方法,经验证,这办法在 Android Q 的 Beta 版上依然能正常使用。虽然这个方法需要进行内存搜索,理论上有可能失败,但实际上它曾在 VirtualXposed 和 太极 中得到了较为广泛的验证,从未收到过由于反射失败而导致问题的反馈。而且据我所知,有若干用户量不少的 APP 在线上使用了我提供的 FreeReflection 库,想来应该也是没有问题的吧。
不过今天,我打算给出另外一种绕过限制的办法。这个办法目前来说是最优方案,我个人使用了一个多月,不存在任何问题。
上次分析系统是如何施加这个限制 的时候,我们提到了几种方式,最终给出了一种修改 runtime flag 的办法;其中我们提到,系统有一个 fn_caller_is_trusted
条件:如果调用者是系统类,那么就允许被调用。这是显而易见的,毕竟这些私有 API 就是给系统用的,如果系统自己都被拒绝了,这是在玩锤子呢?
也就是说,如果我们能以系统类的身份去反射,那么就能畅通无阻。问题是,我们如何以「系统的身份去反射」呢?一种最常见的办法是,我们自己写一个类,然后通过某种途径把这个类的 ClassLoader 设置为系统的 ClassLoader,再借助这个类去反射其他类。但是这里的「通过某种途径」依然要使用一些黑科技才能实现,与修改 flags / inline hook 无本质区别。
以系统类的身份去反射 有两个意思,1. 直接把我们自己变成系统类;2. 借助系统类去调用反射。我们一个个分析。
「直接把我们自己变成系统类」这个方式有童鞋可能觉得天方夜谭,APP 的类怎么可能成为系统类?但是,一定不要被自己的固有思维给局限,一切皆有可能!我们知道,对APP来说,所谓的系统类就是被 BootstrapClassLoader 加载的类,这个 ClassLoader 并非普通的 DexClassLoader,因此我们无法通过插入 dex path的方式注入类。但是,Android 的 ART 在 Android O 上引入了 JVMTI,JVMTI 提供了将某一个类转换为 BootstrapClassLoader 中的类的方法!具体来说,我们写一个类暴露反射相关的接口,然后通过 JVMTI 提供的 AddToBootstrapClassLoaderSearch
将此类加入 BootstrapClassLoader 就实现目的了。不过,JVMTI 要在 release 版本的 APP 上运行依然需要 Hack,所以这种途径与其他的黑科技无本质区别。
第二种方法,「借助系统的类去反射」也就是说,如果系统有一个方法systemMethod
,这个systemMethod
去调用反射相反的方法,那么systemMethod
毋庸置疑会反射成功。但是,我们从哪去找到这么一个方法给我们用?事实上,我们不仅能找到这样的方法,而且这个方法能帮助我们调用任意的函数,那就是反射本身!可能你已经绕晕了,我解释一下:
getDeclaredMethod
方法。getDeclaredMethod
是 public 的,不存在问题;这个通过反射拿到的方法我们称之为元反射方法。getDeclardMethod
。这里我们就实现了以系统身份去反射的目的——反射相关的 API 都是系统类,因此我们的元反射方法也是被系统类加载的方法;所以我们的元反射方法调用的 getDeclardMethod
会被认为是系统调用的,可以反射任意的方法。伪代码如下:
1 | Method metaGetDeclaredMethod = |
到这里,我们已经能通过「元反射」的方式去任意获取隐藏方法或者隐藏 Field 了。但是,如果我们所有使用的隐藏方法都要这么干,那还有点小麻烦。在 上文中,我们后来发现,隐藏 API 调用还有「豁免」条件,具体代码如下:
1 | if (shouldWarn || action == kDeny) { |
只要 IsExempted
方法返回 true,就算这个方法在黑名单中,依然会被放行然后允许被调用。我们再观察一下IsExempted
方法:
1 | bool MemberSignature::IsExempted(const std::vector<std::string>& exemptions) { |
继续跟踪传递进来的参数 runtime->GetHiddenApiExemptions()
发现这玩意儿也是 runtime 里面的一个参数,既然如此,我们可以一不做二不休,仿照修改 runtime flag 的方式直接修改 hidden_api_exemptions_
也能绕过去。但如果我们继续跟踪下去,会有个有趣的发现:这个API 竟然是暴露到 Java 层的,有一个对应的 VMRuntime.setHiddenApiExemptions Java方法;也就是说,只要我们通过 VMRuntime.setHiddenApiExemptions
设置下豁免条件,我们就能愉快滴使用反射了。
再结合上面这个方法,我们只需要通过 「元反射」来反射调用 VMRuntime.setHiddenApiExemptions
就能将我们自己要使用的隐藏 API 全部都豁免掉了。更进一步,如果我们再观察下上面的 IsExempted
方法里面调用的 DoesPrefixMatch
,发现这玩意儿在对方法签名进行前缀匹配;童鞋们,我们所有Java方法类的签名都是以 L开头啊!如果我们把直接传个 L
进去,所有的隐藏API全部被赦免了!
详细代码在这里:https://github.com/tiann/FreeReflection
理论上讲,这个方案不存在兼容性问题。即使 ROM 删掉了 setHiddenApiExemptions
方法,我们依然可以用「元反射」的方式去反射隐藏API,并且所有的代码加起来不超过30行!当然,如果 Google 继续改进验证隐藏API调用的方法,这个方式可能会失效;但是目前的机制没有问题。
文章的最后,我想说的是,本文的目的不是刻意去绕过限制。不给思维设限、不给人生设限,才会有更多可能。
]]>去年我给出了一种绕过Android P对非SDK接口限制的简单方法,经验证,这办法在 Android Q 的 Beta 版上依然能正常使用。虽然这个方法需要进行内存搜索,理论上有可能失败,但实际上它曾在 VirtualXposed 和 太极 中得到了较为广泛的验证,从未收到过由于反射失败而导致问题的反馈。而且据我所知,有若干用户量不少的 APP 在线上使用了我提供的 FreeReflection 库,想来应该也是没有问题的吧。
不过今天,我打算给出另外一种绕过限制的办法。这个办法目前来说是最优方案,我个人使用了一个多月,不存在任何问题。
]]>虽然插件化终将落幕,但是它背后的技术原理包罗万象,值得每一个希望深入Android的小伙伴们学习。
很遗憾曾经的系列文章没有写完,现在已经没机会甚至可以说不可能去把它完结了;不过幸运的是,我的良师益友包老师(我习惯称呼他为包哥)写了一本关于插件化的书——《Android插件化开发指南》,书中讲述了过去数年浩浩荡荡的插件化历程以及插件技术的方方面面;有兴趣的小伙伴可以买一本看看。
]]>虽然插件化终将落幕,但是它背后的技术原理包罗万象,值得每一个希望深入Android的小伙伴们学习。
很遗憾曾经的系列文章没有写完,现在已经没机会甚至可以说不可能去把它完结了;不过幸运的是,我的良师益友包老师(我习惯称呼他为包哥)写了一本关于插件化的书——《Android插件化开发指南》,书中讲述了过去数年浩浩荡荡的插件化历程以及插件技术的方方面面;有兴趣的小伙伴可以买一本看看。
]]>作为一个有追求的开发者,我们既要尊重并遵守规则,也要有能力在必要的时候突破规则的束缚,带着镣铐跳舞。恰好最近有人反馈 VirtualXposed 在 Android P上无法运行,那么今天就来探讨一下,如何突破Android P上针对非SDK接口调用的限制。
知己知彼,百战不殆。既然我们想要突破这个限制,自然先得弄清楚,系统是如何给我们施加这个限制的。
文档 中说,通过反射或者JNI访问非公开接口时会触发警告/异常等,那么不妨跟踪一下反射的流程,看看系统到底在哪一步做的限制(以下的源码分析大可以走马观花的看一下,需要的时候自己再仔细看)。我们从 java.lang.Class.getDeclaredMethod(String)
看起,这个方法在Java层最终调用到了 getDeclaredMethodInternal
这个native方法,看一下这个方法的源码:
1 | static jobject Class_getDeclaredMethodInternal(JNIEnv* env, jobject javaThis, |
注意那个 ShouldBlockAccessToMember 调用了吗?如果它返回false,那么直接返回nullptr
,上层就会抛 NoSuchMethodXXX
异常;也就触发系统的限制了。于是我们继续跟踪这个方法,这个方法的实现在 java_lang_Class.cc,源码如下:
1 | ALWAYS_INLINE static bool ShouldBlockAccessToMember(T* member, Thread* self) |
毫无疑问,我们应该继续看 hidden_api.cc 里面的 GetMemberAction
方法 :
1 | template<typename T> |
可以看到,关键来了。此方法有三个return语句,如果我们能干涉这几个语句的返回值,那么就能影响到系统对隐藏API的判断;进而欺骗系统,绕过限制。
在分析这三个条件之前,我们再思考一下,在调用一个方法/获取一个成员的时候,除了反射(JNI也算)就没有别的办法了吗?看起来系统只是把反射这条路堵死了,那如果我不走这条路呢?
首先,很显然,除了反射,我们还能直接调用。打个比方,我们要调用 ActivityThread.currentActivityThread()这个方法,除了使用反射;我们还可以把 Android 源码中的 ActivityThread 这个类copy到我们的项目中,然后使用 provided 依赖,这样就能像系统一样直接调用了。至此,我们得到了第一个信息:public类的public方法,可以通过直接调用的方式访问;当然,private的就都不行了。
其次,我们要访问一个类的成员,除了直接访问,反射调用/JNI就没有别的方法了吗?当然不是。如果你了解ART的实现原理,知道对象布局,那么这个问题就太简单了。所有的Java对象在内存中其实就是一个结构体,这份内存在 native 层和Java层是对应的,因此如果我们拿到这份内存的头指针,直接通过偏移量就能访问成员。你问我方法怎么访问?ART的对象模型采用的类似Java的 klass-oop方式,方法是存储在 java.lang.Class
对象中的,它们是Class对象的成员,因此访问方法最终就是访问成员。(后续我会专门介绍ART的对象模型,解释 ArtMethod/java.lang.Method/jmethodId之间的关系)。
思考完毕,我们会到反射调用的流程;仔细分析一下这三个条件。
先看第一个return语句,GetActionFromAccessFlags
,看方法名貌似是根据 Method/Field 的 access_flag
来判断,具体看下代码:
1 | inline Action GetActionFromAccessFlags(HiddenApiAccessFlags::ApiList api_list) { |
首先,如果 Method/Field 是白名单,那么直接允许访问。我们再往前看,发现这个 api_list
其实是存储在 Method/Field 的 access_flag
中的。
也就是说,所有的Method/Field的access_flag 中存储了hidden_api 的信息,如果有办法把这个flag直接设置为 kAllow,那么系统就认为它不是隐藏API了。但是,如果要修改 Method/Field 的 access_flag
这个成员变量,我们首先得拿到这个 Method/Field 的引用,然而 Android P上就是限制了我们拿这个引用的过程,似乎死循环了;前面我们提到可以通过偏移量的方式修改,但实际上这个场景还有别限制(比如压根拿不到Class对象);因此这个条件看似可以达到,实际上比较麻烦,于是我们暂且放下。
继续观察这个方法,接下来 调用了 GetHiddenApiEnforcementPolicy
方法获取限制策略,如果是 kNoChecks
直接允许;那 GetHiddenApiEnforcementPolicy 这个方法是啥样呢?在 runtime.h 中,如下:
1 | hiddenapi::EnforcementPolicy GetHiddenApiEnforcementPolicy() const { |
也就是说,返回的是 runtime 这个对象的一个成员。如果我们直接修改内存,把这个成员设置为 kNoChecks,那么不就达到目标了吗?
既然需要修改runtime对象的内存,那么首先得拿到runtime对象的指针。本来这个过程需要去分析 ART runtime的启动过程,但如果完全写出来那就又是几篇文章了;这里直接给出结论:
在JNI中,我们可以通过 JNIEnv指针拿到 JavaVM指针,这个JavaVM指针实际上是一个 JavaVMExt
对象,runtime是 JavaVMExt结构体的成员。说起来比较绕,实际上你看看代码就明白了:
1 | JavaVM *javaVM; |
感兴趣的可以自己去分析为什么可以这么做。
我们已经拿到了 runtime指针,也就是这个对象的起始位置;如果要修改对象的成员,必须要知道偏移量。如何知道这个偏移量呢?直接硬编码写死也是可行的,但是一旦厂商做一点修改,那就完蛋了;你程序的结果就没法预期。因此,我们采用一种动态搜索的办法。
runtime是一个很大的结构体,里面的成员不计其数;如果我们要精准定位里面的某一个成员,需要找一些参照物;然后通过这些参照物进一步定位。我们先来观察一下这个结构体:
1 | struct Runtime { |
这个结构体非常大,可以直接去看源码 runtime.h,上面我们挑出了一些我们能够使用的参照物,辅助进行内存定位:
因此结合这三个条件,我们对runtime指针执行线性搜索,首先找到 JavaVM指针,然后找到target_sdk_version,最后直达目标;顺便用 safe_mode, java_debuggable 等成员验证正确性。
找到目标 hidden_api_policy_
之后,直接修改内存,就能达到目的。用伪代码表示就是:
1 | nt unseal(JNIEnv *env, jint targetSdkVersion) { |
代码我已经放到 github 上了:FreeReflection,使用起来非常简单,添加依赖;一步调用即可。觉得好用别忘了 star 哦~
看起来我们已经达到目标了,但是不要慌;还有2个条件呢,我们继续,说不定有新发现。
然后看第二个return语句,fn_caller_is_trusted
,这里面的代码我就不分析了,直接给结论:这个方法通过回溯调用栈,通过调用者的Class来判断是否是系统代码的调用(所有系统的代码都通过BootClassLoader加载,判断ClassLoader即可),如果是系统代码,那么就允许调用(系统自己的API肯定得让它调)。这里我们又发现一个判断条件:caller.classloader == BootClassLoader
。因此,如果能把这个调用类的ClassLoader修改为 BootClassLoader,那么问题不就解决了吗?
那么问题来了,如何修改Class的classloader?我们看看Class 类的结构:
1 | public final class Class<T> implements java.io.Serializable, |
classloader实际上是Class类的第一个成员,而这个java.lang.Class
我们肯定是能拿到的,因此我们可以通过上面提到的修改偏移的方式直接修改ClassLoader,进而绕过限制。
但是需要注意一下这个偏移量。虽然 Class 声明没有继承任何东西,但实际上它继承自 Object。我们看下 java.lang.Object
:
1 | public class Object { |
因此,Class对象在内存中实际上是这样:
1 | struct Class { |
JVM规范中,一个int占4字节;在ART实现中,一个Java对象的引用占用4字节(不论是32位还是64位),因此 classloader的偏移量为8;我们拿到调用者的Class对象,在JNI层拿到对象的内存表示,直接把偏移量为8处置空(BootClassLoader在为null)即可。当然,如果你不想用JNI,Unsafe也能满足这个需求。
看起来我们已经有好几种办法达到目的了,别着急;我们继续看第三个条件。
当代码流程走到这里,那个action已经不可能是 kAllow了;不要放弃治疗,说不定还能复活。观察代码:
1 | if (shouldWarn || action == kDeny) { |
果然有“豁免”条件:GetHiddenApiExemptions()。跟踪这个方法之后,你会发现解决办法跟上面两种是一样的。要么去修改 runtime 的内存,要么修改signature;我就不赘述啦。
上面我们分析了系统的源代码,结合各种条件来实现绕过对非SDK API调用的检测;但实际上所有这些方式我们的目的都是一样的—— 通过某种方式修改函数的执行流程;而达到这个目标最直接的方法就是 inline hook!!由于inline hook太强大,你只需要找到一个关键的执行流程,hook其中的某个函数,修改他的返回值就OK了;这里我也没啥好分析的,只能给大家推荐一个 inline hook 库了,名字叫 HookZz,代码非常优秀,值得一看。
本来真的只是打算介绍那个简单方法的,结果一不小心全写完啦 :)
文章可能有疏漏,也可能有更优秀的办法;欢迎交流讨论~
]]>作为一个有追求的开发者,我们既要尊重并遵守规则,也要有能力在必要的时候突破规则的束缚,带着镣铐跳舞。恰好最近有人反馈 VirtualXposed 在 Android P上无法运行,那么今天就来探讨一下,如何突破Android P上针对非SDK接口调用的限制。
]]>我们想一下,Xposed为什么需要Root?从现在的实现来看,因为Xposed需要修改系统文件,而这些文件只有root权限才能修改;但是这只是当前实现的特性(修改系统分区文件),而非根本原因。Xposed要实现的最终目的是在任意App进程启动之前能任意加载 特定Xposed模块 的代码;这些特定的Xposed模块中能在App进程启动之前有机会执行特定代码,从而控制任意进程的行为。归根结底,Xposed需要控制别的进程,而没有高级权限(Root),越俎代庖是不行的。
有没有别的实现方式?
虽然没有办法控制别的进程,但是在本进程内,几乎是可以为所欲为的;如果换个方式,把别的App放在自己的进程里面运行,然后Hook自己 不就打到目的了嘛?「把别的App放在自己的进程里面运行」这种机制是容器,或者通俗点叫双开;「Hook自己」这是典型的Dexposed的思路,不过Dexposed不支持ART——但前不久 epic 的出现完成了这最后一块拼图。(关于epic在ART上实现AOP Hook可以参考 我为Dexposed续一秒——论ART上运行时 Method AOP实现
双开的典型实现是lody的 VirtualApp,那么我们来一看 VirtualApp
与 epic
结合会产生什么样奇妙的化学反应。
我们的思路很清晰:用 VirtualApp 去启动别的App,在启动过程中通过 epic Hook本进程,从而控制被启动的App。同时,由于Xposed模块已经比较成熟,而且有成千上万的插件生态,最好能够直接复用Xposed 的模块,使得在双开环境下,Xposed模块就跟运行在Root手机中的Xposed环境中一样。为此,我写了一个 双开环境下的Xposed兼容层:Exposed;同时,修改了 VirtualApp 的部分实现,使得它能够在进程的启动的时候加载 Exposed 这个兼容层,代码在这:VAExposed。这样,在双开环境中,可以直接加载已有的Xposed模块进而实现非Root模式下的Xposed的功能。更有趣的是,你还可以直接使用 XposedInstaller 安装和管理任意的Xposed模块,就跟你使用真正的Xposed一样!
具体的代码就不详细讲了,可以直接去看源码Exposed,VAExposed 我们以微信防撤回为例,看看具体的效果:
首先安装VAExposed这个修改版的双开APK,你可以clone源码直接build,也可以使用我编译好的版本 Github下载 百度网盘: https://pan.baidu.com/s/1qXB9qtY 密码: i45e
然后安装微信防撤回模块:微信巫师,发布的主页在这:WeChat Magician(微信巫师);直接下载 链接
接下来需要确保你手机上的微信是微信巫师所支持的,目前支持微信的版本为 6.5.8~6.5.16;如果不是的话需要去下载一个支持的版本,比如 微信_6.5.8.apk。
最后,你需要打开VAExposed这个双开软件,添加微信和微信巫师为双开模块,如下图:
这样,使用双开中的微信,就能享受Xposed模块的防撤回功能了!
另外,你还可以直接在双开中使用 XposedInstaller,然后就可以方便滴下载和管理Xposed模块了:
就这样,我们在非Root手机下,就能享用Xposed模块的功能,Have Fun :)
不过,在实现完这个功能之后,我不寒而栗:千万不要在Root环境或者双开环境下运行关键App,不然你的微信登录密码,支付宝支付密码,银行卡账号,很有可能被尽收眼底。
PS:目前 Exposed 层的实现处于初级阶段,个人精力非常有限(一般都是凌晨写代码);如果你对 实现非Root模式下的Xposed 感兴趣,非常欢迎跟我一起组队 :) 项目地址在这:https://github.com/android-hacker/exposed。
]]>我们想一下,Xposed为什么需要Root?从现在的实现来看,因为Xposed需要修改系统文件,而这些文件只有root权限才能修改;但是这只是当前实现的特性(修改系统分区文件),而非根本原因。Xposed要实现的最终目的是在任意App进程启动之前能任意加载 特定Xposed模块 的代码;这些特定的Xposed模块中能在App进程启动之前有机会执行特定代码,从而控制任意进程的行为。归根结底,Xposed需要控制别的进程,而没有高级权限(Root),越俎代庖是不行的。
有没有别的实现方式?
]]>今天,我在ART上重新实现了Dexposed,在它能支持的平台(Android 5.0 ~ 7.1 Thumb2/ARM64)上,有着与Dexposed完全相同的能力和API;项目地址在这里 epic,感兴趣的可以先试用下:) 然后我们聊一聊ART上运行时Method AOP的故事。
为什么Dexposed能够在Dalvik上为所欲为,到ART时代就不行了呢?排除其他非技术因素来讲,ART确实比Dalvik复杂太多;更要命的是,从Android L到Android O,每一个Android版本中的ART变化都是天翻地覆的,大致列举一下:
可以看出,ART不仅复杂,而且还爱折腾;一言不合就魔改,甚至重写。再加上Android的碎片化,这使得实现一个稳定的虚拟机层面上运行时Java Method AOP几无可能。
说到这里也许你会问,那substrate,frida等hook机制不是挺成熟了吗?跟这里说的ART Hook有什么联系与区别?事实上,substrate/frida 主要处理native层面的Hook,可以实现任意C/C++ 函数甚至地址处的调用拦截;而ART Java Method Hook/AOP 更多地是在虚拟机层面,用来Hook和拦截Java方法,虚拟机层面的Hook底层会使用于substrate等类似的Hook技术,但是还要处理虚拟机独有的特点,如GC/JNI/JIT等。
虽然ART上的运行时Java Method AOP实现较为困难,但还是有很多先驱者和探索者。最有名的莫过于AndFix(虽然它不能实现AOP);在学术界,还有两篇研究ART Hook的论文,一篇实现了Callee side dynamic rewrite,另一篇基于虚函数调用原理实现了vtable hook。另外,除了在讲epic之前,我们先看看这些已有的方案。
首先简单介绍下ART上的方法调用原理(本文不讨论解释模式,所有entrypoint均指compiled_code_entry_point)。在ART中,每一个Java方法在虚拟机(注:ART与虚拟机虽有细微差别,但本文不作区分,两者含义相同,下同)内部都由一个ArtMethod对象表示(native层,实际上是一个C++对象),这个native 的 ArtMethod对象包含了此Java方法的所有信息,比如名字,参数类型,方法本身代码的入口地址(entrypoint)等;暂时放下trampoline以及interpreter和jit不谈,一个Java方法的执行非常简单:
从上面讲述的ART方法调用原理可以得到一种很自然的Hook办法————直接替换entrypoint。通过把原方法对应的ArtMethod对象的entrypoint替换为目标方法的entrypoint,可以使得原方法被调用过程中取entrypoint的时候拿到的是目标方法的entry,进而直接跳转到目标方法的code段;从而达到Hook的目的。
AndFix就是基于这个原理来做热修复的,Sophix 对这个方案做了一些改进,也即整体替换,不过原理上都一样。二者在替换方法之后把原方法直接丢弃,因此无法实现AOP。AndroidMethodHook 基于Sophix的原理,用dexmaker动态生成类,将原方法保存下来,从而实现了AOP。
不过这种方案能够生效有一个前提:方法调用必须是先拿到ArtMethod,再去取entrypoint然后跳转实现调用。但是很多情况下,第一步是不必要的;系统知道你要调用的这个方法的entrypoint是什么,直接写死在汇编代码里,这样方法调用的时候就不会有取ArtMethod这个动作,从而不会去拿被替换的entrypoint,导致Hook失效。这种调用很典型的例子就是系统函数,我们看一下Android 5.0上 调用TextView.setText(Charsequence)
这个函数的汇编代码:
1 | private void callSetText(TextView textView) { |
OAT文件中的汇编代码:
1 | 0x00037e10: e92d40e0 push {r5, r6, r7, lr} 0x00037e14: b088 sub sp, sp, #32 0x00037e16: 1c07 mov r7, r0 0x00037e18: 9000 str r0, [sp, #0] 0x00037e1a: 910d str r1, [sp, #52] 0x00037e1c: 1c16 mov r6, r2 0x00037e1e: 6978 ldr r0, [r7, #20] 0x00037e20: f8d00ef0 ldr.w r0, [r0, #3824] 0x00037e24: b198 cbz r0, +38 (0x00037e4e) 0x00037e26: 1c05 mov r5, r0 0x00037e28: f24a6e29 movw lr, #42537 0x00037e2c: f2c73e87 movt lr, #29575 0x00037e30: f24560b0 movw r0, #22192 0x00037e34: f6c670b4 movt r0, #28596 0x00037e38: 1c31 mov r1, r6 0x00037e3a: 1c2a mov r2, r5 0x00037e3c: f8d1c000 ldr.w r12, [r1, #0] suspend point dex PC: 0x0002 GC map objects: v0 (r5), v1 ([sp + #52]), v2 (r6) 0x00037e40: 47f0 blx lr |
看这两句代码:
1 | 0x00037e28: f24a6e29 movw lr, #42537 0x00037e2c: f2c73e87 movt lr, #29575 |
什么意思呢?lr = 0x7387a629,然后接着就blx跳转过去了。事实上,这个地址 0x7387a629
就是TextView.setText(Charsequence)` 这个方法entrypoint的绝对地址;我们可以把系统编译好的oat代码弄出来看一看:
adb shell oatdump –oat-file=/data/dalvik-cache/arm/system@framework@boot.oat
1 | 364: void android.widget.TextView.setText(java.lang.CharSequence) (dex_method_idx=28117) |
其中这个方法的code_offset = 0x037d8629; boot.oat的EXECUTABLE OFFSET 为0x02776000, boot.oat 在proc/
1 | 700a1000-72818000 r--p 00000000 103:1f 32772 /data/dalvik-cache/arm/system@framework@boot.oat 72818000-74689000 r-xp 02777000 103:1f 32772 /data/dalvik-cache/arm/system@framework@boot.oat 74689000-7468a000 rw-p 045e8000 103:1f 32772 /data/dalvik-cache/arm/system@framework@boot.oat |
其中 可执行段的地址为 0x72818000,因此算出来的 TextView.setText(CharSequence) 这个方法的地址为 0x037d8629 - 0x02776000 + 0x72818000 = 0x7387a629
;丝毫不差。
为什么会这么干呢?因为boot.oat 这个文件在内存中的加载地址是固定的(如果发生变化,所有APP的oat文件会重新生成,于是又重新固定),因此里面的每一个函数的绝对地址也是固定的,如果你调用系统函数,ART编译器知道系统每一个函数入口的绝对地址,根本没有必要再去查找方法,因此生成的代码中没有任何查找过程。
所以,从原理上讲,如果要支持系统方法的Hook,这种方案在很多情况下是行不通的。当然如果你Hook自己App的代码,并且调用方和被调用方在不同的dex,在Android O之前是没什么问题的(在Android O之前跨dex一定会走方法查找)。
从上面的分析可以看出,就算不查找ArtMethod,这个ArtMethod的enntrypoint所指向代码是一定要用到的(废话,不然CPU执行什么,解释执行在暂不讨论)。既然替换入口的方式无法达到Hook所有类型方法的目的,那么如果不替换入口,而是直接修改入口里面指向的代码呢?(这种方式有个高大上的学名:callee side dynamic rewriting)
第一次学到这个词是在 Wißfeld, Marvin 的论文 ArtHook: Callee-side Method Hook Injection on the New Android Runtime ART上。这篇文章很精彩,讲述了各种Hook的原理,并且他还在ART上实现了 dynamic callee-side rewriting 的Hook技术,代码在github上:ArtHook
通俗地讲,dynamic callee-side rewriting其实就是修改entrypoint 所指向的代码。但是有个基本问题:Hook函数和原函数的代码长度基本上是不一样的,而且为了实现AOP,Hook函数通常比原函数长很多。如果直接把Hook函数的代码段copy到原函数entrypoint所指向的代码段,很可能没地儿放。因此,通常的做法是写一段trampoline。也就是把原函数entrypoint所指向代码的开始几个字节修改为一小段固定的代码,这段代码的唯一作用就是跳转到新的位置开始执行,如果这个「新的位置」就是Hook函数,那么基本上就实现了Hook;这种跳板代码我们一般称之为trampoline/stub,比如Android源码中的 art_quick_invoke_stub/art_quick_resolution_trampoline等。
这篇论文基本上指明了ART上Method Hook的方向,而且Wißfeld 本人的项目 ArtHook也差不多达到了这个目的。不过他的Hook实现中,被用来替换的方法必须写死在代码中,因此无法达到某种程度上的动态Hook。比如,我想知道所有线程的创建和销毁,因此选择拦截Thread.class 的run方法;但是Thread子类实现的run方法不一定会调用 Thread 的run,所以可能会漏掉一些线程。比如:
1 | class MyThread extends Thread { |
上述例子中,如果仅仅Hook Thread.class 的run方法,只有 Thread1能被发现,其他两个都是漏网之鱼。既然如此,我们可以Hook线程的构造函数(子类必定调用父类),从而知道有哪些自定义的线程类被创建,然后直接Hook这些在运行时才发现的类,就能知道所有Java线程的创建和销毁。
要解决「不写死Hook方法」这个问题有两种思路:其一,直接在运行时凭空创建出一个Method;其二,把Hook收拢到一个统一的方法,在这个方法中做分发处理。
第一种方式:凭空创建Method,并非new 一个Method对象就行了,这个方法必须要有你想执行的代码,以及必要的declaring_class, dex_method_index 等成员;要达到这个目的,可以使用运行时字节码生成技术,比如 dexmaker。另外,Java本身的动态代理机制也可以也会动态生成代理类,在代理类中有全新创建的方法,如果合适处理,也能达到目的;不过这种方案貌似还无人实现,反倒是entrypoint replcement中有人这么做 :(
第二种方式:用一个函数来处理hook的分发逻辑,这是典型的xposed/dexposed 实现方式。不过Xposed支持Android N过程中直接修改了 libart.so,这种方式对进程内Hook是行不通的。dexposed的 dev_art 分支有尝试过实现,但是几乎不可用。
有趣地是,还有另外一个项目 YAHFA 也提出了一种Hook方案;不过他这种方案看起来是entrypoint replacement和dynamic callee-side rewriting的结合体:把entrypoint替换为自己的trampoline函数的地址,然后在trampoline继续处理跳转逻辑。作者的博客值得一看。
除了传统的类inline hook 的 dynamic callee-side rewriting 的Hook方式,也有基于虚拟机特定实现的Hook技术,比如vtable hook。ART中的这种Hook方式首先是在论文 ARTDroid: A Virtual-Method Hooking Framework on Android ART Runtime 中提出的,作者的实现代码也在github上 art-hooking-vtable。
这种Hook方式是基于invoke-virtual调用原理的;简单来讲,ART中调用一个virtual method的时候,会查相应Class类里面的一张表,如果修改这张表对应项的指向,就能达到Hook的目的。更详细的实现原理,作者的论文以及他的博客讲的很详细,感兴趣的可以自行围观。
这种方式最大的缺点是只能Hook virtual方法,虽然根据作者提供的数据:
59.2% of these methods are declared as virtual
1.0% are non-virtual
39.8% methods not found
高达99%的方法都能被hook住,不管你信不信,反正我是不信。所以,这种Hook方式无法Hook所有的调用过程,只能作为一种补充手段使用。
了解到已有项目的一些实现原理以及当前的现状,我们可以知道,要实现一个较为通用的Hook技术,几乎只有一条路———基于dynamic dispatch的dynamic callee-side rewriting。epic正是使用这种方式实现的,它的基本原理如下图:
在讲解这张图之前,有必要说明一下ART中的函数的调用约定。以Thumb2为例,子函数调用的参数传递是通过寄存器r0~r3 以及sp寄存器完成的。r0 ~ r3 依次传递第一个至第4个参数,同时 sp, (sp + 4), (sp + 8), (sp + 12) 也存放着r0~r3上对应的值;多余的参数通过 sp传递,比如 *(sp + 16)放第四个参数,以此类推。同时,函数的返回值放在r0寄存器。如果一个参数不能在一个寄存器中放下,那么会占用2个或多个寄存器。
在ART中,r0寄存器固定存放被调用方法的ArtMethod指针,如果是non-static 方法,r1寄存器存放方法的this对象;另外,只有long/double 占用8bytes,其余所有基本类型和对象类型都占用4bytes。不过这只是基本情形,不同的ART版本对这个调用约定有不同的处理,甚至不完全遵循。
好了我们回到epic。如上图所述,如果我们要Hook android.util.Log.i
这个方法,那么首先需要找到这个方法的entrypoint,可以通过这个方法的ArtMethod对象得到;然后我们直接修改内存,把这个函数的前8个字节:
1 | e92d40e0 ; push {r5, r6, r7, lr} b088 ; sub sp, sp, #32 1c07 ; mov r7, r0 |
修改为一段跳转指令:
1 | dff800f0 ; ldr pc, [pc] |
这样,在执行Log.i
这个函数的时候,会通过这第一段跳板直接跳转到 0x7f132450 这个地址开始执行。这个地址是我们预先分配好的一段内存,也是一段跳转函数,我们姑且称之为二段跳板。在接下来的二段跳板中,我们开始准备分发逻辑:
1 | ldr ip, 3f ; ip = source_method_address |
这段代码是用来判断是否需要执行Hook的,如果不需要,跳转到原函数的控制流,进而达到调用原函数的目的。接下来就是一些参数准备:
1 | str sp, [ip, #0] |
在参数准备好之后,直接跳转到另外一个Java方法的入口开始执行,这个方法称之为bridge方法。bridge方法接管控制流之后我们就回到了Java世界,自此之后我们就可以开始处理AOP逻辑。
基本原理比较简单,但是在实现过程中会有很多问题,这里简单交代一下。
从上面的基本介绍我们可以知道,方法的AOP逻辑是交给一个Java的bridge函数统一处理的,那么这个统一的函数如何区分每一个被Hook的方法,进而调用对应的回调函数呢?
最直接的办法是把被Hook的方法通过额外参数直接传递给bridge函数,而传递参数可以通过寄存器和堆栈实现。用来传递参数的寄存器(如r0~r3)最好是不要直接改的,不然我们的处理函数可能就收到不到原函数对应的参数,进而无法完成调用原函数的逻辑。如果用堆栈传递参数的话,我们是直接在堆栈上分配内存吗?
事实证明这样做是不行的,如果我们在二段跳板代码里面开辟堆栈,进而修改了sp寄存器;那么在我们修改sp到调用bridge函数的这段时间里,堆栈结构与不Hook的时候是不一样的(虽然bridge函数执行完毕之后我们可以恢复正常);在这段时间里如果虚拟机需要进行栈回溯,sp被修改的那一帧会由于回溯不到对应的函数引发致命错误,导致Runtime 直接Abort。什么时候会回溯堆栈?发生异常或者GC的时候。最直观的感受就是,如果bridge函数里面有任何异常抛出(即使被try..catch住)就会使虚拟机直接崩溃。dexposed的 dev_art 分支中的AOP实现就有这个问题。
既然无法分配新的堆栈,那么能否找到空闲的空间使用呢?上面我们在介绍Thumb2调用约定的时候提到,r0~r3传递第一至第四个参数,sp ~ sp + 12 也传递第一至第四个参数,看起来好像是重复了;我们能否把 sp ~ sp + 12 这段空间利用起来呢?
但是实际实现的过程中又发现,此路不通。你以为就你会耍这点小聪明吗?虚拟机本身也是知道 sp + 12 这段空间相当于是浪费的,因此他直接把这段空间当做类似寄存器使用了;如果你把额外的参数丢在这里,那么根本就收不到参数,因为函数调用一旦发生,ART很可能直接把这段内存直接使用了。
既然如此,我们只能把要传递的一个或者多个额外参数打包在一起(比如放在结构体),通过指针一块传递了。再此观察我们上面的二段跳板代码:
1 | ldr ip, 4f |
其中,4f
处是我们预先分配好的一段16字节的内存(假设起始地址为base);我们把 sp 放到 (base)上,把r2寄存器(原第三个参数)放到 (base + 4),把r3(原第四个参数)放到 (base + 8),把 3f
(被Hook函数的地址)放到 (base + 12);然后把这个base 的地址放在r3寄存器里面,这样根据调用约定,我们的bridge函数就可以在第四个参数上收到四个打包好的数据,然后通过相同的访问方式就可以把原始数据取出来。这些数据中就包括了被Hook的原函数地址,通过这个地址,我们可以区分不同的被Hook函数,进而触发各自对应的处理逻辑。
在二段跳板函数的开始处,有这么一段代码:
1 | ldr ip, 3f ; ip = source_method_address |
也许你会问,这个比较逻辑是有必要的吗?除了达到调用原函数的目的之外,这个逻辑还有一个更重要的用途:区分入口相同,但是实际上Java方法完全不同的处理逻辑。
什么时候不同的Java函数的入口会一样呢?至少有下面几种情况:
static函数是lazy resolve的,在方法没有被调用之前,static函数的入口地址是一个跳板函数,名为 art_quick_resolution_trampoline,这个跳转函数做的事情就是去resvole原始函数,然后进行真正的调用逻辑;因此没有被调用的static函数的entrypoint都是一样的。
Android N以上,APK安装的时候,默认是不会触发AOT编译的;因此如果刚安装完你去看apk生成的OAT文件,会发现里面的code都是空。在这些方法被resolve的时候,如果ART发现code是空,会把entrypoint设置为解释执行的入口;接下来如果此方法被执行会直接进入到解释器。所以,Android N上未被编译的所有方法入口地址都相同。
如果代码逻辑完全一样,那么AOT编译器会发现这完全可以用一个函数来代替,于是这些函数都有了同一个入口地址;而JNI函数由于函数体都是空(也即所有代码相同),理所当然会共享同一个入口。
如果没有这段处理逻辑,你会发现你Hook一个函数的时候,很可能莫名其妙滴Hook了一堆你压根都不知道是什么的函数。
在基本的bridge函数调用(从汇编进入Java世界)的问题搞定之后,我们会碰到一个新问题:在bridge函数中接受到的参数都是一些地址,但是原函数的参数明明是一些对象,怎么把地址还原成原始的参数呢?
如果传递的是基本类型,那么接受到的地址其实就是基本类型值的表示;但是如果传递的是对象,那接受到的 int/long 是个什么东西?
这个问题一言难尽,它的背后是ART的对象模型;这里我简单说明一下。一个最直观的问题就是:JNI中的 jobject,Java中的Object,ART 中的 art::mirror::Object 到底是个什么关系?
实际上,art::mirror::Object 是 Java的Object在Runtime中的表示,java.lang.Object的地址就是art::mirror::Object的地址;但是jobject略有不同,它并非地址,而是一个句柄(或者说透明引用)。为何如此?
因为JNI对于ART来说是外部环境,如果直接把ART中的对象地址交给JNI层(也就是jobject直接就是Object的地址),其一不是很安全,其二直接暴露内部实现不妥。就拿GC来说,虚拟机在GC过程中很可能移动对象,这样对象的地址就会发生变化,如果JNI直接使用地址,那么对GC的实现提出了很高要求。因此,典型的Java虚拟机对JNI的支持中,jobject都是句柄(或者称之为透明引用);ART虚拟机内部可以在joject与 art::mirror::Object中自由转换,但是JNI层只能拿这个句柄去标志某个对象。
那么jobject与java.lang.Object如何转换呢?这个so easy,直接通过一次JNI调用,ART就自动完成了转换。
因此归根结底,我们需要找到一个函数,它能实现把 art::mirror::Object 转换为 jobject对象,这样我们可以通过JNI进而转化为Java对象。这样的函数确实有,那就是:
1 | art::JavaVMExt::AddWeakGlobalReference(art::Thread*, art::mirror::Object*) |
此函数在 libart.so中,我们可以通过 dlsym
拿到函数指针,然后直接调用。不过这个函数有一个art::Thread 的参数,如何拿到这个参数呢?查阅 art::Thread 的源码发现,这个 art::Thread 与 java.lang.Thread 也有某种对应关系,它们是通过peer结合在一起的(JNI文档中有讲)。也就是说,java.lang.Thread类中的 nativePeer 成员代表的就是当前线程的 art::Thread对象。这个问题迎刃而解。
上文提到,为了实现对象和指针的转换,我们需要 dlsym
一个 libart.so 中的导出函数;但不幸地是,在Android N中,Google禁止了这种行为,如果你用 dlsym
去取符号,返回的结果是nullptr。怎么办呢?
libart.so 不过是一个加载在内存中的elf文件而已。我们通过读取 /proc/self/maps
拿到这个文件的加载基地址,然后直接解析ELF文件格式,查出这个符号在ELF文件中的偏移,再加上内存基址,就能得到这个符号真正的地址。不过这过程已经有人实现了,而且放在了github上:Nougat_dlfunctions 可以直接使用 :)
Android N采用了混合编译的模式,既有解释执行,也有AOT和JIT;APK刚安装完毕是解释执行的,运行时JIT会收集方法调用信息,必要的时候直接编译此方法,甚至栈上替换;在设备空闲时,系统会根据收集到的信息执行AOT操作。
那么在APK刚装完然后使用的那么几次,方法都是解释执行的,我们要Hook掉解释执行的入口吗?这当然可以,但是如果解释执行到一半方法入口被替换为JIT编译好的机器码的入口,那么本次Hook就会失效;我们还需要把JIT编译的机器码入口也拦截住。但是问题是,我们何时知道JIT执行完成?
所以这种方式实行起来比较麻烦,还不如一开始就全部是机器码 这样我们只用Hook机器码的entrypoint就可以了。事实上,Android N可以手动触发AOT全量编译,如 官方文档 所述,可以通过如下命令手动执行AOT编译:
adb shell cmd package compile -m speed -f
这样一来,我们一般情况下就不用管解释器的事了。
虽然多这么一个步骤,勉强能解决问题,但还是有点小瑕疵;(毕竟要多这么一步嘛!何况如果这个投入线上使用,你指望用户给你主动编译?)在研究了一段时间的JIT代码之后,我发现可以主动调用JIT编译某个方法。这样,在Hook之前我们可以先请求JIT编译此方法,得到机器码的entrypoint,然后按照正常的流程Hook即可。具体如何调用JIT可以参阅epic的源码。
上文提到Android N上开启了JIT编译器,即使我们手动触发全量AOT编译,在运行时这种机制依然存在;JIT的一个潜在隐患就是,他有可能动态修改代码,这使得在Android N上的Hook可能随机出现crash。
记得我在刚实现完Android N上的Hook之后,发现我的测试case偶尔会崩溃,崩溃过程完全没有规律,而且崩溃的错误几乎都是SIG 11。当时追查了一段时间,觉得这种随机崩溃可能跟2个原因有关:GC或者JIT;不过一直没有找到证据。
某天半夜我发现一个有趣的现象,如果我把测试case中的Logcat日志输出关掉,崩溃的概率会小很多——如果输出Logcat可能测试八九次就闪退了,但如果关掉日志,要数十次或者几乎不会闪退。当时我就怀疑是不是碰上了薛定谔猫。
理性分析了一番之后我觉得这种尺度不可能触发量子效应,于是我只能把锅摔倒Log头上。我在想是不是Log有IO操作导致hook过程太慢了使得这段时间别的线程有机会修改代码?于是我在Hook过程中Sleep 5s发现一点问题没有。实在没辙,我就一条条删Log,结果发现一个神奇的现象:Log越多越容易崩。然后我就写个循环输出日志100次,结果几乎是毕现闪退。
事情到这里我就瞬间明白了:调用Log的过程中很有可能由于Log函数调用次数过多进而达到JIT编译的阈值从而触发了JIT,这时候JIT线程修改了被执行函数的代码,而Hook的过程也会修改代码,这导致内存中的值不可预期,从而引发随机crash。
按照这种情况推测的话,JIT的存在导致Android N上的Hook几乎是毕现闪退的。因为我的测试demo代码量很少,一个稍微有点规模的App很容易触发JIT编译,一旦在JIT过程中执行Hook,那么必崩无疑。
因此比较好的做法是,在Hook的过程中暂停所有其他线程,不让它们有机会修改代码;在Hook完毕之后在恢复执行。那么问题来了,如何暂停/恢复所有线程?Google了一番发现有人通过ptrace实现:开一个linux task然后挨个ptrace本进程内的所有子线程,这样就是实现了暂停。这种方式很重而且不是特别稳定,于是我就放弃了。ART虚拟机内部一定也有暂停线程的需求(比如GC),因此我可以选择直接调用ART的内部函数。
在源码里面捞了一番之后果然在thread_list.cc 中找到了这样的函数 resumeAll/suspendAll;不过遗憾的是这两个函数是ThreadList类的成员函数,要调用他们必须拿到ThreadList的指针;一般情况下是没有比较稳定的方式拿到这个对象的。不过好在Android 源码通过RAII机制对 suspendAll/resumeAll做了一个封装,名为 ScopedSuspendAll
这类的构造函数里面执行暂停操作,析构函数执行恢复操作,在栈上分配变量此类型的变量之后,在这个变量的作用域内可以自动实现暂停和恢复。因此我只需要用 dlsym
拿到构造函数和析构函数的符号之后,直接调用就能实现暂停恢复功能。详细实现见 epic 源码
写了这么多,实际上还有很多想写的没有写完;比如Android M Optimizing编译器上的寄存器分配问题,long/double参数的处理细节,不同ART版本的调用约定 与 ATPCS/AAPCS之间不同等;不过来日方长,这些问题以后在慢慢道来吧 :)
扯了这么久的实现原理,我们来看看这玩意儿具体怎么用吧。只需要在你的项目中加入epic的依赖即可(jcenter 仓库):
1 | dependencies { |
然后就可以在你的项目中做AOP Hook,比如说要拦截所有Java线程的创建,我们可以用如下代码:
1 | class ThreadMethodHook extends XC_MethodHook{ |
这里有2个AOP点,其一是 Thread.class 的run方法,拦截这个方法,我们可以知道所有通过Thread类本身创建的线程;其二是Thread的构造函数,这个Hook点我们可以知道运行时具体有哪些类继承了Thread.class类,在找到这样的子类之后,直接hook掉这个类的run方法,从而达到了拦截所有线程创建的目的。
当然,还有很多有趣的AOP点等待你去挖掘,这一切取决于您的想象力 :)
上文提到,「要在ART上实现一个完善而稳定的Hook机制,几无可能」,epic也不例外:它也有它自己的缺点,有些是先天的,有些是后天的,还有一些我没有发现的 ~_~;比如说:
我本人只在Android 5.0, 5.1, 6.0, 7.0, 7.1 的个别机型,以及这些机型的thumb2指令集,和6.0/7.1 的arm64指令集做过测试;其他的机型均未测试,因此这么长的文章还读到最后的你,不妨拿出你手头的手机帮我测试一下,在下感激不尽 :)
]]>今天,我在ART上重新实现了Dexposed,在它能支持的平台(Android 5.0 ~ 7.1 Thumb2/ARM64)上,有着与Dexposed完全相同的能力和API;项目地址在这里 epic,感兴趣的可以先试用下:) 然后我们聊一聊ART上运行时Method AOP的故事。
]]>为什么可以这么做呢?那得从 Android 虚拟机的方法调用过程说起。作为一个系列的开篇,本文不打算展开讲虚拟机原理等内容,首先给大家一道开胃菜;后续我们再深入探索ART。
众所周知,AndFix是一种 native 的hotfix方案,它的替换过程是用 c 在 native层完成的,但其实,我们也可以用纯Java实现它!而且,代码还非常精简,且看——
既然我们知道 AndFix 的原理是方法替换,那么为什么直接替换Java里面的 java.lang.reflect.Method
有什么问题吗?直接这样貌似很难下结论,那我们换个思路。我们实现方法替换的结果,就是调用原方法的时候最终是调用被替换的方法。因此,我们可以看看 java.lang.reflect.Method
类的 invoke
方法。(这里有个疑问,Foo.bar()这种直接调用与反射调用Foo.class.getDeclaredMethod(“bar”).invoke(null) 有什么区别吗?这个问题后续再谈)
1 | private native Object invoke(Object receiver, Object[] args, boolean accessible) |
这个invoke是一个native方法,它的native实现在 art/runtime/native/java_lang_reflect_Method.cc
里面,这个jni方法最终调用了 art/runtime/reflection.cc
的 InvokeMethod
方法:
1 | object InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod, |
上面函数 InvokeMethod 的第二个参数 javaMethod
就是Java层我们进行反射调用的那个Method对象,在jni层反映为一个jobject;InvokeMethod这个native方法首先通过 mirror::ArtMethod::FromReflectedMethod
获取了Java对象的在native层的 ArtMethod指针,我们跟进去看看是怎么实现的:
1 | ArtMethod* ArtMethod::FromReflectedMethod(const ScopedObjectAccessAlreadyRunnable& soa, |
我们在这里看到了一点端倪,获取到了Java层那个Method对象的一个叫做 artMethod
的字段,然后强转成了ArtMethod指针(这里的说法不是很准确,但是要搞明白这里面的细节一两篇文章讲不清楚 ~_~,我们暂且这么认为吧。)
AndFix的实现里面,也正是使用这个 FromReflectedMethod
方法拿到Java层Method对应native层的ArtMethod指针,然后执行替换的。
上面我们也看到了,我们在native层替换的那个 ArtMethod 不是在 Java 层也有对应的东西么?我们直接替换掉 Java 层的这个artMethod 字段不就OK了?但是我们要注意的是,在Java里面除了基本类型,其他东西都是引用。要实现类似C++里面那种替换引用所指向内容的机智,需要一些黑科技。
要在Java层操作内容,也不是没有办法做到;JDK给我们留了一个后门:sun.misc.Unsafe
类;在OpenJDK里面这个类灰常强大,从内存操作到CAS到锁机制,无所不能(可惜的是据说JDK8要去掉?)但是在Android 平台还有一点点不一样,在 Android N之前,Android的JDK实现是 Apache Harmony,这个实现里面的Unsafe就有点鸡肋了,没法写内存;好在Android 又开了一个后门:Memory
类。
有了这两个类,我们就能在Java层进行简单的内存操作了!!由于这两个类是隐藏类,我写了一个wrapper,如下:
1 | private static class Memory { |
接下来思路就很简单了呀,用伪代码表示就是:
1 | memcopy(originArtMethod, replaceArtMethod); |
但是还有一个问题,我们要整个把 originMethod 的 artMethod 所在的内存直接替换为 replaceMethod 的artMethod 所在的内存(上面我们已经知道,Java层Method类的artMethod实际上就是native层的指针表示,在Android N上更明显,这玩意儿直接就是一个long),现在我们已经知道这两个地址是什么,那么我们把 replaceArtMethod 代表的内存复制到 originArtMethod 的区域,应该还需要知道一个 artMethod 有多大。
但是事情没有一个 sizeof 那么简单。你看AndFix的实现是在每个Android版本把ArtMethod这个结构体复制一份的;要想用sizeof还得把这个类所有的引用复制过来,及其麻烦。更何况在Java里面 sizeof都没有。不过也不是没有办法,既然我们已经能在Java层拿到对象的地址,只需要创建一个数组,丢两个ArtMethod,把两个数组元素的起始地址相减不就得到一个 artMethod的大小了吗?(此方法来自Android热修复升级探索——追寻极致的代码热替换)
不过,既然我们实现了方法替换;还有最后一个问题,如果我们需要在替换后的方法里面调用原函数呢?这个也很简单,我们只需要把原函数copy一份保存起来,需要调用原函数的时候调用那个copy的函数不就行了?不过在具体实现的时候,会遇到一个问题,就是 Java的非static 非private的方法默认是虚方法,在调用这个方法的时候会有一个类似查找虚函数表的过程,这个在上面的代码 InvokeMethod
里面可以看到:
1 | mirror::Object* receiver = nullptr; |
在调用的时候,如果不是static的方法,会去查找这个方法的真正实现;我们直接把原方法做了备份之后,去调用备份的那个方法,如果此方法是public的,则会查找到原来的那个函数,于是就无限循环了;我们只需要阻止这个过程,查看 FindVirtualMethodForVirtualOrInterface 这个方法的实现就知道,只要方法是 invoke-direct 进行调用的,就会直接返回原方法,这些方法包括:构造函数,private的方法( 见 https://source.android.com/devices/tech/dalvik/dalvik-bytecode.html) 因此,我们手动把这个备份的方法属性修改为private即可解决这个问题。
详细代码见:github/epic
至此,我们就用纯Java实现了一个 AndFix,代码只有200行不到!!是不是很神奇?当然,这里面包含了很多黑科技,接下来我们将以这个为引子,深入探索Android ART的方方面面,揭开虚拟机底层的神秘面纱,敬请期待~~
]]>为什么可以这么做呢?那得从 Android 虚拟机的方法调用过程说起。作为一个系列的开篇,本文不打算展开讲虚拟机原理等内容,首先给大家一道开胃菜;后续我们再深入探索ART。
众所周知,AndFix是一种 native 的hotfix方案,它的替换过程是用 c 在 native层完成的,但其实,我们也可以用纯Java实现它!而且,代码还非常精简,且看——
]]>Android Studio 2.2版本带来了全新的对Android Native代码的开发以及调试支持,另外LLDB的Android调试插件也日渐成熟,我终于可以把这篇文章继续下去了!本文将带来Android Framework中native代码的调试方法。
在正式介绍如何调试之前,必须先说明一些基本的概念。调试器在调试一个可执行文件的时候,必须知道一些调试信息才能进行调试,这个调试信息可多可少(也可以没有)。最直观的比如行号信息,如果调试器知道行号信息,那么在进行调试的时候就能知道当前执行到了源代码的哪一行,如果调试器还知道对应代码的源文件在哪,那么现代IDE的调试器一般就能顺着源码带你飞了,这就是所谓的源码调试。相反,如果没有行号和源码信息,那么只能进行更低级别的调试了,调试器只能告诉你一些寄存器的值;而当前运行的代码也只是PC寄存器所指向的二进制数据,这些数据要么是虚拟机指令,要么是汇编指令;这就是所谓的无源码调试。显然无源码调试相比源码级别的调试要麻烦的多;接下来将围绕这两个方面分别介绍。
如上文所述,如果需要实现源码调试,必须知道足够的调试信息;在native调试中就是所谓的「调试符号」。但是release版本的动态链接库或者可执行文件一般并不会包含我们需要的调试信息,在Android系统中,/system/lib/*
目录下的那些系统so并没有足够的调试信息,因此如果要进行源码调试,必须自己编译Android源代码,才能获取调试信息,进而让调试器协助我们调试。
Android源码编译是个麻烦事儿,我写过一篇文章介绍 如何使用Docker调试 ;但是,Android版本众多,如果真的需要调试各个版本,在本地进行编译几乎是不可能的——一个版本约占60G空间,如果每个版本都编译,你的Mac还有空间可用吗?因此比较推荐使用云服务进行源码编译;比如使用阿里云的ECS,20M的网速15分钟就能下载完源码;编译速度还勉强,4核8G两个半小时。扯远了 :) 如果你没有精力编译Android源码,我这个 Demo工程 可以让你尝尝鲜,里面包含一些调试的必要文件,可以体会一下Native调试的感觉。
如果我们已经拥有了调试符号,那么还需要保证你的符号文件和设备上真正运行的动态链接库或者可执行文件是对应的,不然就是鸡同鸭讲了。最简单的办法就是使用模拟器。我们编译完源码之后,一个主要的编译产物就是 system.img
,这个 system.img
会在启动之后挂载到设备的 /system 分区,而system分区包含了Android系统运行时的绝大部分可执行文件和动态链接库,而这些文件就是我们的编译输出,正好可以与编译得到的调试符号进行配合调试。模拟器有一个 -system
选项用来指定模拟器使用的 system.img文件;于是这个问题也解决了。
最后一个问题就是,既然是源码调试,当然需要源码了;我们可以在 AOSP 上下载需要的源码即可;需要注意的是,在check分支的时候,必须保证你的分支和编译源码时候的分支是一致的。
需要说明的是,虽然我们使用Android Studio调试,但是其背后的支撑技术实际上是 LLDB。LLDB是一个相当强大的调试器,如果你现在还不知道它为何物,那真的是孤陋寡闻了!建议先简单学习一下 教程
万事俱备,Let’s go!
实际上任何Android Studio工程都可以进行native源码调试,但是为了方便还是新建一个工程;这个工程是一个空工程,没有任何实际用途;为了体验方便,你可以使用我的这个 Demo 工程,里面包含了调试符号以及模拟器需要使用的system.img。一定要注意Android Studio的版本必须是2.2以上(我的是2.2.3稳定版)。
如果你本地编译了Android源码,那么就不需要这一步了;但是更多的时候我们只是想调试某一个模块,那么只需要下载这个模块的源码就够了。我这里演示的是调试 ART 运行时,因此直接下载ART模块的源码即可,我编译的Android源码版本是 android-5.1.1_r9
,因此需要check这个分支的源码,地址在这里:ART-android-5.1.1_r9
由于我们的调试符号需要与运行时的动态链接库对应,因此我们需要借助模拟器;首先创建一个编译出来的调试符号对应的API版本的模拟器,我这里提供的是5.1.1也就是API 22;然后使用编译出来的 system.img 启动模拟器([Demo]工程的image目录有我编译出来的文件,可以直接使用。):
emulator -avd 22 -verbose -no-boot-anim -system /path/to/system.img
这个过程灰常灰常长!!我启动这个模拟器花了半个多小时,也是醉。现在是2017年,已经是Android创建的第十个年头,ARM模拟器还是烂的一塌糊涂,无力吐槽。一个能让它快一点的诀窍是创建一个小一点的SD card;我设置的是10M。
首先我们对调试的宿主工程设置一下,选择native调试功能。点击运行下面的按钮 Edit Configuration
:
然后在debugger栏选择Native:
然后我们点击旁边的 Debug
小按钮运行调试程序:
在运行程序之后,我们可以在Android Studio的状态栏看到,LLDB调试插件自动帮我们完成了so查找路径的过程,这一点比gdb方便多了!在Android Studio的Debug窗口会自动弹出来,如下:
我们点击那个 pause program
按钮,可以让程序暂停运行:
上图左边是正在运行的线程的堆栈信息,右边有两个tab,一个用来显示变量的值;一个是lldb交互式调试窗口!我们先切换到lldb窗口,输入如下命令设置一个断点:
(lldb) br s -n CollectGarbageInternal
Breakpoint 2: where = libart.so`art::gc::Heap::CollectGarbageInternal(art::gc::collector::GcType, art::gc::GcCause, bool), address = 0xb4648c20
可以看到,断点已经成功设置;这个断点在libart.so中,不过现在还没有调试符号信息以及源码信息,我们只知道它的地址。接下来我们设置调试符号以及关联源码。
接下来我们把编译得到的符号文件 libart.so 告诉调试器(符号文件和真正的动态链接库这两个文件名字相同,只不过一个在编译输出的symbols目录) ;在lldb窗口执行:
1 | (lldb) add-dsym /Users/weishu/dev/github/Android-native-debug/app/symbols/libart.so |
注意后面那个目录你的机器上与我的可能不同,需要修改一下。我们再看看有什么变化,看一下刚刚的断点:
(lldb) br list 2
2: name = ‘CollectGarbageInternal’, locations = 1, resolved = 1, hit count = 0
2.1: where = libart.so`art::gc::Heap::CollectGarbageInternal(art::gc::collector::GcType, art::gc::GcCause, bool) at heap.cc:2124, address = 0xb4648c20, resolved, hit count = 0
行号信息已经加载出来了!!在 heap.cc
这个文件的第2124行。不过如果这时候断点命中,依然无法关联到源码。我们看一下调试器所所知道的源码信息:
(lldb) source info
Lines found in module `libart.so
[0xb4648c20-0xb4648c28): /Volumes/Android/android-5.1.1_r9/art/runtime/gc/heap.cc:2124
纳尼??这个目录是个什么鬼,根本没有这个目录好伐?难道是调试器搞错了?
在继续介绍之前我们需要了解一些关于「调试符号」的知识;我们拿到的调试符号文件其实是一个DWARF文件,只不过这个文件被嵌入到了ELF文件格式之中,而其中的调试符号则在一些名为 .debug_*
的段之中,我们可以用 readelf -S libart.so
查看一下:
编译器在编译libart.so的时候,记录下了编译时候源代码与代码偏移之间的对应关系,因此调试器可以从调试符号文件中获取到源码行号信息;如下:
这下我们明白了上面那个莫名其妙的目录是什么了;原来是在编译libart.so
的那个机器上存在源码。那么问题来了,我们绝大多数情况下是使用另外一台机器上的源码进行调试的——比如我提供的那个 Demo工程 包含的带符号libart.so里面保存的源文件信息的目录实际上是我编译的电脑上的目录,而你调试的时候需要使用自己电脑上的目录。知道了问题所在,解决就很简单了,我们需要映射一下;在Android Studio的Debug 窗口的lldb 那个tab执行如下命令:
(lldb) settings set target.source-map /Volumes/Android/android-5.1.1_r9/ /Users/weishu/dev/github/Android-native-debug/app/source/
第一个参数的意思是编译时候的目录信息,第二个参数是你机器上的源码存放路径;设置成自己的即可。
这时候,我们再触发断点(点击demo项目的Debug按钮),看看发生了什么?!
至此,我们已经成功滴完成了在Android Studio中Native代码的源码调试。你可以像调试Java代码一样调试Native代码,step/in/out/over,条件断点,watch point任你飞。你可以借助这个工具去探究Android底层运行原理,比如垃圾回收机制,对象分配机制,Binder通信等等,完全不在话下!
接下来再介绍一下操作简单但是使用门槛高的「无源码调试」方式;本来打算继续使用Android Studio的,但是无奈现阶段还有BUG,给官方提了issue但是响应很慢:https://code.google.com/p/android/issues/detail?id=231116。因此我们直接使用 LLDB 调试;当然,用gdb也能进行无源码调试,但是使用lldb比gdb的步骤要简单得多;不信你可以看下文。
要使用lldb进行调试,首先需要在调试设备上运行一个lldb-server,这个lldb-server attach到我们需要调试的进程,然后我们的开发机与这个server进行通信,就可以进行调试了。熟悉gdb调试的同学应该很清楚这一点。我们可以用Android Studio直接下载这个工具,打开SDK Manager:
如上图,勾选这个即可;下载的内容会存放到你的 $ANDROID_SDK/lldb 目录下。
安装好必要的工具之后,就可以开始调试了;整体步骤比较简单:把lldb-server推送到调试设备并运行这个server,在开发机上连上这个server即可;以下是详细步骤。
如果你的调试设备是root的,那么相对来说比较简单;毕竟我们的调试进程lldb-server要attach到被调试的进程是需要一定权限的,如果是root权限那么没有限制;如果没有root,那么我们只能借助run-as
命令来调试自己的进程;另外,被调试的进程必须是debuggable,不赘述。以下以root的设备为例(比如模拟器)
首先把lldb-server push到调试设备。lldb-sever这个文件可以在 `$ANDROID_SDK/lldb/<版本号数字>/android/ 目录下找到,确认你被调试设备的CPU构架之后选择你需要的那个文件,比如大多数是arm构架,那么执行:
adb push lldb-server /data/local/tmp/
在调试设备上运行lldb-server。
adb shell /data/local/tmp/lldb-server platform \
–server –listen unix-abstract:///data/local/tmp/debug.sock
如果提示 /data/local/tmp/lldb-server: can’t execute: Permission denied,那么给这个文件加上可执行权限之后再执行上述命令:
adb shell chmod 777 /data/local/tmp/lldb-server
这样,调试server就在设备上运行起来了,注意要这么做需要设备拥有root权限,不然后面无法attach进程进行调试;没有root权限另有办法。另外,这个命令执行之后所在终端会进入阻塞状态,不要管它,如下进行的所有操作需要重新打开一个新的终端。
首先打开终端执行lldb(Mac开发者工具自带这个,Windows不支持),会进入一个交互式的环境,如下图:
选择使用Android调试插件。执行如下命令:
platform select remote-android
如果提示没有Android,那么你可能需要升级一下你的XCode;只有新版本的lldb才支持Android插件。
连接到lldb-server
这一步比较简单,但是没有任何官方文档有说明;使用办法是我查阅Android Studio的源码学习到的。如下:
platform connect unix-abstract-connect:///data/local/tmp/debug.sock
正常情况下你执行lldb-server的那个终端应该有了输出:
attach到调试进程。首先你需要查出你要调试的那个进程的pid,直接用ps即可;打开一个新的终端执行:
~ adb shell ps | grep lldbtest
u0_a53 2242 724 787496 33084 ffffffff b6e0c474 S com.example.weishu.lldbtest
我要调试的那个进程pid是 2242
,接下来回到lldb的那个交互式窗口执行:
process attach -p 2242
如果你的设备没有root,那么这一步就会失败——没有权限去调试一个别的进程;非root设备的调试方法见下文。
至此,调试环境就建立起来了。不需要像gdb那样设置端口转发,lldb的Android调试插件自动帮我们处理好了这些问题。虽然说了这么多,但是你熟练之后真正的步骤只有两步,灰常简单。
断点调试
调试环境建立之后自然就可以进行调试了,如果进行需要学习lldb的使用方法;我这里先演示一下,不关心的可以略过。
首先下一个断点:
(lldb) br s -n CollectGarbageInternal
Breakpoint 1: where = libart.so`art::gc::Heap::CollectGarbageInternal(art::gc::collector::GcType, art::gc::GcCause, bool), address = 0xb4648c20
触发断点之后,查看当前堆栈:
1 | (lldb) bt |
查看寄存器的值
1 | (lldb) reg read |
我们可以看到寄存器 r0
的值为 0xb4889600
,这个值就是 `CollectGarbageInternal
函数的第一个参数,this指针,也就是当前Heap对象的地址。在ARM下,r0~r4存放函数的参数,超过四个的参数放在栈上,具体如何利用这些寄存器的信息需要了解一些ARM汇编知识。
查看运行的汇编代码
1 | (lldb) di -p |
如果没有root权限,那么我可以借助run-as命令。run-as可以让我们以某一个app的身份执行命令——如果我们以被调试的那个app的身份进行attach,自然是可以成功的。
假设被调试的app包名为 com.example.lldb
,那么首先想办法把 lldb-server
这个文件推送到这个app自身的目录:
adb push
直接这么做不太方便(还需要知道userid),我们先push到 /data/local/tmp/
adb push lldb-server /data/local/tmp/
然后执行adb shell,连接到Android shell,执行
run-as com.example.lldb`
拷贝这个文件到本App的目录,并修改权限;(由于有的手机没有cp命令,改用cat)
cat /data/local/tmp/lldb-server > lldb-server
chmod 777 lldb-server
运行lldb-server
lldb-server platform –listen unix-abstract:///data/local/tmp/debug.sock
接下来的步骤就与上面root设备的调试过程完全一样了 :)
终于完成了Android调试这一系列的文章,时间跨度长达一年;从Java到C/C++再到汇编级别的调试,从有源码到无源码,从Application层到Framework层,任何代码都可以进行调试。借助强大的IDE以及调试器,我们不仅可以快速定位和解决问题,还可以深入学习任何一个复杂的模块。尤记得用探索用lldb进行native调试的过程,网上没有任何android方面的教程,唯一的学习资料就是Android Studio调试模块的源码以及LLDB Android插件的源码;这其中碰的壁和踩过的坑不计其数。好在最后终于一一解决,可以睡个安稳觉了 ~_~
]]>Android Studio 2.2版本带来了全新的对Android Native代码的开发以及调试支持,另外LLDB的Android调试插件也日渐成熟,我终于可以把这篇文章继续下去了!本文将带来Android Framework中native代码的调试方法。
在正式介绍如何调试之前,必须先说明一些基本的概念。调试器在调试一个可执行文件的时候,必须知道一些调试信息才能进行调试,这个调试信息可多可少(也可以没有)。最直观的比如行号信息,如果调试器知道行号信息,那么在进行调试的时候就能知道当前执行到了源代码的哪一行,如果调试器还知道对应代码的源文件在哪,那么现代IDE的调试器一般就能顺着源码带你飞了,这就是所谓的源码调试。相反,如果没有行号和源码信息,那么只能进行更低级别的调试了,调试器只能告诉你一些寄存器的值;而当前运行的代码也只是PC寄存器所指向的二进制数据,这些数据要么是虚拟机指令,要么是汇编指令;这就是所谓的无源码调试。显然无源码调试相比源码级别的调试要麻烦的多;接下来将围绕这两个方面分别介绍。
]]>今天,我给大家提供一个极其简单、稳定的方案,来解决Android源码的下载编译问题。
首先,下载问题可以通过镜像解决;清华镜像 和 科大镜像 都是非常不错的选择,正常情况下一到两个小时即可下载完一个Android源码分支。
然后就是编译环境问题。由于Android源码庞大,依赖复杂;一旦使用的编译工具链有细微的不同就可能引发编译失败。官方文档 推荐使用Ubuntu 14.04进行编译。如果我们用Windows或者Mac系统,传统方式是使用虚拟机;但是在今天,我们完全可以使用 Docker 替代!!借助Docker,我们可以不用担心编译环境问题;不论我们的开发机是什么系统,可以使用Docker创建Ubuntu Image,并且直接在这个Ubuntu系统环境中创建编译所需要的工具链(JDK,ubuntu系统的依赖库等等);而且,Docker运行的Ubuntu的系统开销比虚拟机低得多,这样下载以及编译速度就有了质的提升。更重要的是,这个环境可以作为一个Image打包发布!这样,你在不同的开发机,还有你与你的同事之间有了同一套编译环境,这会省去很多不必要的麻烦。关于Docker的更多内容,见 Docker官网
当然,这个伟大的创举并不是我完成的,而是 kylemanna/docker-aosp!我针对Docker以及天朝的网络环境做了一部分修改,fork了一份 tiann/docker-aosp。
废话不多说,我们看看具体如何使用,以及怎么个简单法。
Docker的下载地址见 Docker下载 ;下载完毕安装即可。
如果你不是Mac系统,可以直接略过这一步。
Mac的文件系统默认不区分大小写,这不满足Android源码编译系统的要求(编译的时候直接Error);因此需要单独创建一个大小写敏感的磁盘映像。步骤如下:
CMD + N,创建新的磁盘映像,参数设置如下图:
其中磁盘大小设置为 50~100G合适,格式一定要选择带区分大小写标志的。
真正的下载编译过程相当简单,脚本会自动完成;步骤如下:
设置Android源码下载存放的目录;如果是Mac系统,这一步必须设置为一个大小写敏感的目录;不然后面编译的时候会失败。如果不设置这一步,那么源码会下载到 ~/aosp-root
目录;设置过程如下:
export AOSP_VOL=/Volume/Android/
下载wrapper脚本;如果需要下载其他系统版本,直接修改下载完毕后的build-nougat.sh文件的 android-4.4.4_r2.0.1改成你需要的分支即可,分支的信息见 分支列表
curl -O https://raw.githubusercontent.com/kylemanna/docker-aosp/master/tests/build-nougat.sh
运行脚本,开始自动下载安装过程;Windows系统可以使用 Bash for Windows 或者cygwin。
bash ./build-nougat.sh
这样,所有的工作就都做完了。只需静静等待即可;时间视下载速度而定,清华的镜像速度还可以,笔者使用不到2小时就完成了下载编译过程。
三步完成,是不是灰常简单?赶紧下载编译安装属于你的Android系统吧 ^_^
]]>今天,我给大家提供一个极其简单、稳定的方案,来解决Android源码的下载编译问题。
首先,下载问题可以通过镜像解决;清华镜像 和 科大镜像 都是非常不错的选择,正常情况下一到两个小时即可下载完一个Android源码分支。
然后就是编译环境问题。由于Android源码庞大,依赖复杂;一旦使用的编译工具链有细微的不同就可能引发编译失败。官方文档 推荐使用Ubuntu 14.04进行编译。如果我们用Windows或者Mac系统,传统方式是使用虚拟机;但是在今天,我们完全可以使用 Docker 替代!!借助Docker,我们可以不用担心编译环境问题;不论我们的开发机是什么系统,可以使用Docker创建Ubuntu Image,并且直接在这个Ubuntu系统环境中创建编译所需要的工具链(JDK,ubuntu系统的依赖库等等);而且,Docker运行的Ubuntu的系统开销比虚拟机低得多,这样下载以及编译速度就有了质的提升。更重要的是,这个环境可以作为一个Image打包发布!这样,你在不同的开发机,还有你与你的同事之间有了同一套编译环境,这会省去很多不必要的麻烦。关于Docker的更多内容,见 Docker官网
]]>众所周知,我们的Android App运行在Java虚拟机之上,而Java是一门带GC的语言。在虚拟机进行垃圾回收的时候,要做一件很形象的事叫做STW(stop the world);也就是说,为了回收那些不再使用的对象,虚拟机必须要停止所有的线程来进行必要的工作。虽说这一点在ART运行时上得到了很大的改善,但是GC的存在对App运行时的性能始终有着微妙的影响。如果你观察过手机输入的日志,一定会看到类似如下的内容:
12-23 18:46:07.300 28643-28658/? I/art: Background sticky concurrent mark sweep GC freed 15442(1400KB) AllocSpace objects, 8(128KB) LOS objects, 4% free, 32MB/33MB, paused 10.356ms total 53.023ms at GCDaemon thread CareAboutPauseTimes 1
12-23 18:46:12.250 28643-28658/? I/art: Background partial concurrent mark sweep GC freed 28723(1856KB) AllocSpace objects, 6(92KB) LOS objects, 11% free, 31MB/35MB, paused 2.380ms total 108.502ms at GCDaemon thread CareAboutPauseTimes 1
上面的日志反映一个事实:GC是有代价的。有很多有关性能优化的文章提到GC,会花长篇大论讲述垃圾回收的过程以及原理,但所做的策略无非就是「不要创建不必要的对象」,「避免内存泄漏」最终就提到MAT,LeakCanary等工具的使用上去了;我只能说这很苍白无力——写出这样的代码、学会使用工具应该是基本要求。
虽说Android也支持NDK开发,但是我们不可能把所有代码全用C++重写吧?那么,我们有没有办法能影响GC的策略,使得GC尽量减少呢?答案是肯定的。原理在于Android的进程机制——每一个App都有一个单独的虚拟机实例,在App自己的进程空间,我们有相当大的主动权。
我举个简单的例子。(下面的内容基于Android 5.1系统,所有的原理以及代码不保证能在其他系统版本甚至ROM上工作)
Android上所有的App进程都从Zygote进程fork而来,App子进程采用copy on write机制共享了Zygote进程的进程空间;其中Android虚拟机以及运行时的创建在Android系统启动,创建Zygote进程的时候已经完成了。垃圾回收机制是虚拟机的一部分,因此,我们先从Zygote进程的启动过程谈起。
我们知道,Android系统是基于Linux内核的,而在Linux系统中,所有的进程都是init进程的子孙进程,Zygote进程也不例外,它是在系统启动的过程,由init进程创建的。在系统启动脚本system/core/rootdir/init.rc文件中,我们可以看到启动Zygote进程的脚本命令:
service zygote /system/bin/app_process -Xzygote /system/bin –zygote –start-system-server
也就是说init进程通过执行 /system/bin/app_process 这个可执行文件来创建zygote进程;app_process的源码可见 这里;在main函数的最后有这么一句话:
1 | if (zygote) { |
最终调用到了AndroidRuntime.cpp 的start
函数,而这个函数中最重要的一步就是启动虚拟机:
1 | JNIEnv* env; |
这个函数相当之长,不过都是解析虚拟机启动的参数,比如堆大小等等;探究largeHeap 这篇文章对一些重要的参数做了说明,这些参数对虚拟机非常重要,后面我们会见到。解析参数完毕之后,最终调用JNI_CreateJavaVM
来真正创建Java虚拟机。这个接口是Android虚拟机定义的三个接口这一,dalvik能切换到art很大程度上与这个有关。它的具体是现在 jni_internal.cc;JNI_CreateJavaVM 这个函数在拿到虚拟机的相关参数之后,就直接创建了Android运行时:
1 | if (!Runtime::Create(options, ignore_unrecognized)) { |
Runtime的创建非常复杂,其中,跟GC相关的是,App的堆空间被创建出来了;Heap的构造函数接受了一大堆参数,这些参数对于GC有着重大的影响,如果要调整GC的策略,从这里入手,是比较靠谱的。
1 | heap_ = new gc::Heap(options->heap_initial_size_, |
其中 heap_initialsize 是堆的初始大小,heap_growthlimit是堆增长的最大限制,heap_minfree以及heap_maxfree 是什么呢?详细的用途见 Android ART GC之GrowForUtilization的分析 简单来说就是,Android系统为了保证堆的利用效率,减少堆中的内存碎片;每次执行GC回收到一些内存之后,会对堆大小进行调整。比如说你进入了一个图片非常多的页面,这时候申请了100M内存,当你退出这个页面的时候,这100M自然就被回收了,成为了空闲内存;但是系统为了防止浪费,并不会把这100M的空闲内存全部留给你,而是做一个调整。而具体调整到多大,则与 heap_min_free_
, heap_max_free_
以及 heap_target_utilization_
相关。
说到这里,原理性的部分已经解释完了;除了流程稍微复杂,也没有什么难点。那么这个堆,跟我们的启动性能优化有什么关系呢?
在Android App的启动过程中,进程占用的内存在一段时间内是持续上涨的;假设堆的初始大小为8M,启动过程中的占用内存峰值30M;启动过程的进行中,伴随着大量临时对象的创建,它们朝生夕死,不久就被回收掉:
如上图,这是某次启动过程中某App的内存占用情况;我们看到了有很多小折线,专业术语叫做内存抖动;原因呢,也很明显——有大量的临时对象被创建。怎么解决?有人说,不要创建大量的临时对象。道理我都懂,可是做不到。对于很多大型App来说,启动的过程是相当复杂的,而很多操作也不能简单滴去掉。那么问题来了,30M并不是一个很大的数字,为什么系统如此恐慌,还需要不停滴回收内存呢?
有一种冷,叫做你妈妈觉得你冷。垃圾回收并不是说有垃圾了才去回收,而是只要系统觉得你需要回收垃圾就会进行。
那么,能不能在启动过程中让堆保持持续增长而不进行GC呢?毕竟,30M并不会造成什么OOM。是什么原因导致系统没有这么做?答案是空闲内存。比如说一开始堆有8M,随着启动过程的进行,堆增长到了24M;这时候执行了一次GC,回收掉了8M内存,也是堆回到了16M;我们还有8M的空闲内存。系统就会说,小伙子,你占这么多空闲内存干嘛呀?来妈妈帮你保管,于是你就只剩下2M的空闲内存了。但显然App使用的堆内存很快就会超过18M,于是又引发一系列GC以及堆大小调整,周而复始直至启动完成内存平稳。至此,我们的结论已经很明显:
如果我们能够调整 heap_minfree 以及 heap_maxfree,就能很大程度上影响GC的过程
如何调整这两个参数的大小呢?拿到Heap对象的指针,找到这两个参数的偏移量,直接修改内存即可 这里稍微需要一点C++内存布局的知识;至于如何拿到Heap对象的指针,只有去源码里面寻找答案了。这里我给出最终的实现代码:
1 | void modifyHeap(unsigned size) { |
修改之后启动过程中内存占用如下,可以看到我们的目的已经达到:
顺便说明一下,上面的代码没有考虑任何的可移植性和适配性,只起演示作用。真正投入使用是一个体力活:其一,我们依赖了某特定Android版本某个类的内存布局,其中的成员变量的偏移量可能不同版本不同;其二,这个 minfree 以及 maxfree 具体调整为多大,跟手机的物理内存,App使用的内存,手机配置的初始堆大小等等因素密切相关;调整一个合适的参数需要花费一些时间,Android机型如此之多,这里需要一些小技巧。
不知道上面这个例子有木有让你感受到深入系统底层,那种呼风唤雨无所不能的快感?可能很多人觉得我们都是写写if else而已,调节面改动画写业务已经够了;但我想说明的是,深入学习系统原理是非常有好处的,它可以赋予你在应用层永远无法拥有的能力。
另外留个作业,我们上面提到观察GC的次数,除了使用debug模式下用工具观察,能不能用代码监听到呢?本文主要说明了虚拟机运行时等native层的重要性,而这个答案可以在Java Framework中找到 ^_^
]]>众所周知,我们的Android App运行在Java虚拟机之上,而Java是一门带GC的语言。在虚拟机进行垃圾回收的时候,要做一件很形象的事叫做STW(stop the world);也就是说,为了回收那些不再使用的对象,虚拟机必须要停止所有的线程来进行必要的工作。虽说这一点在ART运行时上得到了很大的改善,但是GC的存在对App运行时的性能始终有着微妙的影响。如果你观察过手机输入的日志,一定会看到类似如下的内容:
12-23 18:46:07.300 28643-28658/? I/art: Background sticky concurrent mark sweep GC freed 15442(1400KB) AllocSpace objects, 8(128KB) LOS objects, 4% free, 32MB/33MB, paused 10.356ms total 53.023ms at GCDaemon thread CareAboutPauseTimes 1
12-23 18:46:12.250 28643-28658/? I/art: Background partial concurrent mark sweep GC freed 28723(1856KB) AllocSpace objects, 6(92KB) LOS objects, 11% free, 31MB/35MB, paused 2.380ms total 108.502ms at GCDaemon thread CareAboutPauseTimes 1
上面的日志反映一个事实:GC是有代价的。有很多有关性能优化的文章提到GC,会花长篇大论讲述垃圾回收的过程以及原理,但所做的策略无非就是「不要创建不必要的对象」,「避免内存泄漏」最终就提到MAT,LeakCanary等工具的使用上去了;我只能说这很苍白无力——写出这样的代码、学会使用工具应该是基本要求。
]]>第一次看到下面这个sp的时候,我的内心是崩溃的:
一个默认的sp有90K,当我打开它的时候,我都快哭了:除了零零星星的几个很小的key之外,存储了一个炒鸡大的key,这一个key至少占了其中的89K。知道这是什么概念吗?
在小米1S这种手机上,就算获取这个sp里面一个很小的key,会花费120+ms!!那个毫不相干的key拖慢了其他所有key的读取速度!当然,在性能稍好的手机上,这个问题不是特别严重。但是要知道,120ms这个是完全不能忍的!
之所以说SharedPreference(下文简称sp)是一种轻量级的存储方式,是它的设计所决定的:sp在创建的时候会把整个文件全部加载进内存,如果你的sp文件比较大,那么会带来两个严重问题:
也许有童鞋会说,sp的加载不是在子线程么,怎么会卡住主线程?子线程IO就一定不会阻塞主线程吗?
下面是默认的sp实现SharedPreferenceImpl这个类的getString函数:
1 | public String getString(String key, @Nullable String defValue) { |
继续看看这个awaitLoadedLocked:
1 | private void awaitLoadedLocked() { |
一把锁就是挂在那里!!这意味着,如果你直接调用getString,主线程会等待加载sp的那么线程加载完毕!这不就把主线程卡住了么?
另外,有一个叫诀窍可以节省一下等待的时间:既然getString之类的操作会等待sp加载完成,而加载是在另外一个线程执行的,我们可以让sp先去加载,做一堆事情,然后再getString!如下:
1 | // 先让sp去另外一个线程加载 |
更为严重的是,被加载进来的这些大对象,会永远存在于内存之中,不会被释放。我们看看ContextImpl这个类,在getSharedPreference的时候会把所有的sp放到一个静态变量里面缓存起来:
1 | private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() { |
注意这个static的sSharedPrefsCache
,它保存了你所有使用的sp,然后sp里面有一个成员mMap
保存了所有的键值对;这样,你程序中使用到的那些个sp永远就呆在内存中,是不是不寒而栗?!
所以,请不要在sp里面存储炒鸡大的key碰到这样的猪队友,请让他自行检讨!!赶紧把自家App检查一下!!
还有一些童鞋,他在sp里面存json或者HTML;这么做不是不可以,但是,如果这个json相对较大,那么也会引起sp读取速度的急剧下降。
JSON或者HTML格式存放在sp里面的时候,需要转义,这样会带来很多&
这种特殊符号,sp在解析碰到这个特殊符号的时候会进行特殊的处理,引发额外的字符串拼接以及函数调用开销。而JSON本来就是可以用来做配置文件的,你干嘛又把它放在sp里面呢?多此一举。下面我写个demo验证一下。
下面这个sp是某个app的换肤配置:
我们先用sp进行读取,然后用直接把它丢json文件,直接读取并且解析;json使用的代码如下:
1 | public int getValueByJson(Context context, String key) { |
然后我的测试结果是:直接解析JSON比在xml里面要快一倍!在小米1S上结果如下:
时间 | json | sp |
---|---|---|
Mi 1S | 80 | 38 |
Nexus5X | 3.5 | 6.5 |
这个JSON的读取还没有做任何的优化,提升潜力巨大!因此,如果你需要用JSON做配置,请不要把它存放在sp里面!!
我见过这样的使用代码:
1 | SharedPreferences sp = getSharedPreferences("test", MODE_PRIVATE); |
每次edit都会创建一个Editor对象,额外占用内存;当然多创建几个对象也影响不了多少;但是,多次apply也会卡界面你造吗?
有童鞋会说,apply不是在别的线程些磁盘的吗,怎么可能卡界面?我带你仔细看一下源码。
1 | public void apply() { |
注意两点,第一,把一个带有await的runnable添加进了QueueWork
类的一个队列;第二,把这个写入任务通过enqueueDiskWrite丢给了一个只有单个线程的线程池执行。
到这里一切都OK,在子线程里面写入不会卡UI。但是,你去ActivityThread类的handleStopActivity里看一看:
1 | private void handleStopActivity(IBinder token, boolean show, int configChanges, int seq) { |
waitToFinish?? 又要等?源码如下:
1 | public static void waitToFinish() { |
还记得这个toFinish
的Runnable是啥吗?就是上面那个awaitCommit
它里面就一句话,等待写入线程!!如果在Activity Stop的时候,已经写入完毕了,那么万事大吉,不会有任何等待,这个函数会立马返回。但是,如果你使用了太多次的apply,那么意味着写入队列会有很多写入任务,而那里就只有一个线程在写。当App规模很大的时候,这种情况简直就太常见了!
因此,虽然apply是在子线程执行的,但是请不要无节制地apply;commit我就不多说了吧?直接在当前线程写入,如果你在主线程干这个,小心挨揍。
还有童鞋发现sp有一个貌似可以提供「跨进程」功能的FLAG——MODE_MULTI_PROCESS
,我们看看这个FLAG的文档:
@deprecated MODE_MULTI_PROCESS does not work reliably in
some versions of Android, and furthermore does not provide any mechanism for reconciling concurrent modifications across processes. Applications should not attempt to use it. Instead, they should use an explicit cross-process data management approach such as {@link android.content.ContentProvider ContentProvider}.
文档也说了,这玩意在某些Android版本上不可靠,并且未来也不会提供任何支持,要是用跨进程数据传输需要使用类似ContentProvider的东西。而且,SharedPreference的文档也特别说明:
Note: This class does not support use across multiple processes.
那么我们姑且看一看,设置了这个Flag到底干了啥;在SharedPreferenceImpl里面,没有发现任何对这个Flag的使用;然后我们去ContextImpl类里面找找getSharedPreference的时候做了什么:
1 | @Override |
这个flag保证了啥?保证了在API 11以前的系统上,如果sp已经被读取进内存,再次获取这个sp的时候,如果有这个flag,会重新读一遍文件,仅此而已!所以,如果仰仗这个Flag做跨进程存取,简直就是丢人现眼。
总价一下,sp是一种轻量级的存储方式,使用方便,但是也有它适用的场景。要优雅滴使用sp,要注意以下几点:
第一次看到下面这个sp的时候,我的内心是崩溃的:
一个默认的sp有90K,当我打开它的时候,我都快哭了:除了零零星星的几个很小的key之外,存储了一个炒鸡大的key,这一个key至少占了其中的89K。知道这是什么概念吗?
在小米1S这种手机上,就算获取这个sp里面一个很小的key,会花费120+ms!!那个毫不相干的key拖慢了其他所有key的读取速度!当然,在性能稍好的手机上,这个问题不是特别严重。但是要知道,120ms这个是完全不能忍的!
之所以说SharedPreference(下文简称sp)是一种轻量级的存储方式,是它的设计所决定的:sp在创建的时候会把整个文件全部加载进内存,如果你的sp文件比较大,那么会带来两个严重问题:
与Activity,BroadcastReceiver等频繁被使用的组件不同,我们接触和使用ContentProvider的机会要少得多;但是,ContentProvider这个组件对于Android系统有着特别重要的作用——作为一种极其方便的数据共享的手段,ContentProvider使得广大第三方App能够在壁垒森严的系统中自由呼吸。
在Android系统中,每一个应用程序都有自己的用户ID,而每一个应用程序所创建的文件的读写权限都是只赋予给自己所属的用户,这就限制了应用程序之间相互读写数据的操作。应用程序之间如果希望能够进行交互,只能采取跨进程通信的方式;Binder机制能够满足一般的IPC需求,但是如果应用程序之间需要共享大量数据,单纯使用Binder是很难办到的——我相信大家对于Binder 1M缓冲区以及TransactionTooLargeException一定不陌生;ContentProvider使用了匿名共享内存(Ashmem)机制完成数据共享,因此它可以很方便地完成大量数据的传输。Android系统的短信,联系人,相册,媒体库等等一系列的基础功能都依赖与ContentProvider,它的重要性可见一斑。
既然ContentProvider的核心特性是数据共享,那么要实现它的插件化,必须能让插件能够把它的ContentProvider共享给系统——如果不能「provide content」那还叫什么ContentProvider?
但是,如果回想一下Activity等组件的插件化方式,在涉及到「共享」这个问题上,一直没有较好的解决方案:
虽然在插件系统中一派生机勃勃的景象,Activity,Service等插件组件百花齐放,插件与宿主、插件与插件争奇斗艳;但是一旦脱离了插件系统的温室,这一片和谐景象不复存在:插件组件不过是傀儡而已;活着的,只有宿主——整个插件系统就是一座死寂的鬼城,各个插件组件借尸还魂般地依附在宿主身上,了无生机。
既然希望把插件的ContentProvider共享给整个系统,让第三方的App都能获取到我们插件共享的数据,我们必须解决这个问题;下文将会围绕这个目标展开,完成ContentProvider的插件化,并且顺带给出上述问题的解决方案。阅读本文之前,可以先clone一份 understand-plugin-framework,参考此项目的 contentprovider-management 模块。另外,插件框架原理解析系列文章见 索引。
首先我们还是得分析一下ContentProvider的工作原理,很多插件化的思路,以及一些Hook点的发现都严重依赖于对于系统工作原理的理解;对于ContentProvider的插件化,这一点特别重要。
如同我们通过startActivity
来启动Activity一样,与ContentProvider打交道的过程也是从Context类的一个方法开始的,这个方法叫做getContentResolver
,使用ContentProvider的典型代码如下:
1 | ContentResolver resolver = content.getContentResolver(); |
直接去ContextImpl类里面查找的getContentResolver
实现,发现这个方法返回的类型是android.app.ContextImpl.ApplicationContentResolver,这个类是抽象类android.content.ContentResolver的子类,resolver.query
实际上是调用父类ContentResolver的query
实现:
1 | public final @Nullable Cursor query(final @NonNull Uri uri, @Nullable String[] projection, |
注意这里面的那个try..catch
语句,query
方法首先尝试调用抽象方法acquireUnstableProvider拿到一个IContentProvider对象,并尝试调用这个”unstable”对象的query
方法,万一调用失败(抛出DeadObjectExceptopn,熟悉Binder的应该了解这个异常)说明ContentProvider所在的进程已经死亡,这时候会尝试调用acquireProvider
这个抽象方法来获取一个可用的IContentProvider(代码里面那个萌萌的注释说明了一切^_^);由于这两个acquire*
都是抽象方法,我们可以直接看子类ApplicationContentResolver
的实现:
1 | @Override |
可以看到这两个抽象方法最终都通过调用ActivityThread
类的acquireProvider
获取到IContentProvider,接下来我们看看到底是如何获取到ContentProvider的。
ActivityThread类的acquireProvider
方法如下,我们需要知道的是,方法的最后一个参数stable
代表着ContentProvider所在的进程是否存活,如果进程已死,可能需要在必要的时候唤起这个进程;
1 | public final IContentProvider acquireProvider( |
这个方法首先通过acquireExistingProvider
尝试从本进程中获取ContentProvider,如果获取不到,那么再请求AMS
获取对应ContentProvider;想象一下,如果你查询的是自己App内部的ContentProvider组件,干嘛要劳烦AMS呢?不论是从哪里获取到的ContentProvider,获取完毕之后会调用installProvider
来安装ContentProvider。
OK打住,我们思考一下,如果要实现ContentProvider的插件化,我们需要完成一些什么工作?开篇的时候我提到了数据共享,那么具体来说,实现插件的数据共享,需要完成什么?ContentProvider是一个数据共享组件,也就是说它不过是一个携带数据的载体而已。为了支持跨进程共享,这个载体是Binder调用,为了共享大量数据,使用了匿名共享内存;这么说还是有点抽象,那么想一下,给出一个ContentProvider,你能对它做一些什么操作?如果能让插件支持这些操作,不就支持了插件化么?这就是典型的duck type思想——如果一个东西看起来像ContentProvider,用起来也像ContentProvider,那么它就是ContentProvider。
ContentProvider主要支持query, insert, update, delete
操作,由于这个组件一般工作在别的进程,因此这些调用都是Binder调用。从上面的代码可以看到,这些调用最终都是委托给一个IContentProvider的Binder对象完成的,如果我们Hook掉这个对象,那么对于ContentProvider的所有操作都会被我们拦截掉,这时候我们可以做进一步的操作来完成对于插件ContentProvider组件的支持。要拦截这个过程,我们可以假装插件的ContentProvider是自己App的ContentProvider,也就是说,让acquireExistingProvider
方法可以直接获取到插件的ContentProvider,这样我们就不需要欺骗AMS就能完成插件化了。当然,你也可以选择Hook掉AMS,让AMS的getContentProvider
方法返回被我们处理过的对象,这也是可行的;但是,为什么要舍近求远呢?
从上文的分析暂时得出结论:我们可以把插件的ContentProvider信息预先放在App进程内部,使得对于ContentProvider执行CURD操作的时候,可以获取到插件的组件,这样或许就可以实现插件化了。具体来说,我们要做的事情就是让ActivityThread
的acquireExistingProvider
方法能够返回插件的ContentProvider信息,我们看看这个方法的实现:
1 | public final IContentProvider acquireExistingProvider( |
可以看出,App内部自己的ContentProvider信息保存在ActivityThread类的mProviderMap
中,这个map的类型是ArrayMapput
方法的之后installProviderAuthoritiesLocked
,而这个方法最终被installProvider
方法调用。在分析ContentProvider的获取过程中我们已经知道,不论是通过本进程的acquireExistingProvider
还是借助AMS的getContentProvider
得到ContentProvider,最终都会对这个对象执行installProvider
操作,也就是「安装」在本进程内部。那么,我们接着看这个installProvider
做了什么,它是如何「安装」ContentProvider的。
首先,如果之前没有“安装”过,那么holder为null,下面的代码会被执行,
1 | final java.lang.ClassLoader cl = c.getClassLoader(); |
比较直观,直接load这个ContentProvider所在的类,然后用反射创建出这个ContentProvider对象;但是由于查询是需要进行跨进程通信的,在本进程创建出这个对象意义不大,所以我们需要取出ContentProvider承载跨进程通信的Binder对象IContentProvider;创建出对象之后,接下来就是构建合适的信息,保存在ActivityThread内部,也就是mProviderMap
:
1 | if (localProvider != null) { |
以上就是安装代码,不难理解。
那么,了解了「安装」过程再结合上文的分析,我们似乎可以完成ContentProvider的插件化了——直接把插件的ContentProvider安装在进程内部就行了。如果插件系统有多个进程,那么必须在每个进程都「安装」一遍,如果你熟悉Android进程的启动流程那么就会知道,这个安装ContentProvider的过程适合放在Application类中,因为每个Android进程启动的时候,App的Application类是会被启动的。
看起来实现ContentProvider的思路有了,但是这里实际上有一个严重的缺陷!
我们依然没有解决「共享」的问题。我们只是在插件系统启动的进程里面的ActivityThread的mProviderMap
给修改了,这使得只有通过插件系统启动的进程,才能感知到插件中的ContentProvider(因为我们手动把插件中的信息install到这个进程中去了);如果第三方的App想要使用插件的ContentProvider,那系统只会告诉它查无此人。
那么,我们应该如何解决共享这个问题呢?看来还是逃不过AMS的魔掌,我们继续跟踪源码,看看如果在本进程查询不到ContentProvider,AMS是如何完成这个过程的。在ActivityThread的acquireProvider
方法中我们提到,如果acquireExistingProvider
方法返回null,会调用ActivityManagerNative的getContentProvider
方法通过AMS查询整个系统中是否存在需要的这个ContentProvider。如果第三方App查询插件系统的ContentProvider必然走的是这个流程,我们仔细分析一下这个过程;
首先我们查阅ActivityManagerService的getContentProvider
方法,这个方法间接调用了getContentProviderImpl
方法;getContentProviderImpl
方法体相当的长,但是实际上只做了两件事件事(我这就不贴代码了,读者可以对着源码看一遍):
查询ContentProvider组件的过程看起来很简单,直接调用PackageManager的resolveContentProvider
就能从URI中获取到对应的ProviderInfo
信息:
1 | @Override |
但是实际上我们关心的是,这个mProvidersByAuthority
里面的信息是如何添加进PackageManagerService的,会在什么时候更新?在PackageManagerService这个类中搜索mProvidersByAuthority.put这个调用,会发现在scanPackageDirtyLI
会更新mProvidersByAuthority
这个map的信息,接着往前追踪会发现:这些信息是在Android系统启动的时候收集的。也就是说,Android系统在启动的时候会扫描一些App的安装目录,典型的比如/data/app/*,获取这个目录里面的apk文件,读取其AndroidManifest.xml中的信息,然后把这些信息保存在PackageManagerService中。合理猜测,在系统启动之后,安装新的App也会触发对新App中AndroidManifest.xml的操作,感兴趣的读者可以自行翻阅源码。
现在我们知道,查询ContentProvider的信息来源在Android系统启动的时候已经初始化好了,这个过程对于我们第三方app来说是鞭长莫及,想要使用类似在进程内部Hack ContentProvider的查找过程是不可能的。
获取到URI对应的ContentProvider的信息之后,接下来就是把它安装到系统上了,这样以后有别的查询操作就可以直接拿来使用;但是这个安装过程AMS是没有办法以一己之力完成的。想象一下App DemoA 查询App DemoB 的某个ContentProviderAppB,那么这个ContentProviderAppB必然存在于DemoB这个App中,AMS所在的进程(system_server)连这个ContentProviderAppB的类都没有,因此,AMS必须委托DemoB完成它的ContentProviderAppB的安装;这里就分两种情况:其一,DemoB这个App已经在运行了,那么AMS直接通知DemoB安装ContentProviderAppB(如果B已经安装了那就更好了);其二,DemoB这个app没在运行,那么必须把B进程唤醒,让它干活;这个过程也就是ActivityManagerService的getContentProviderImpl
方法所做的,如下代码:
1 | if (proc != null && proc.thread != null) { |
如果查询的ContentProvider所在进程处于运行状态,那么AMS会通过这个进程给AMS的ApplicationThread这个Binder对象完成scheduleInstallProvider调用,这个过程比较简单,最终会调用到目标进程的installProvider
方法,而这个方法我们在上文已经分析过了。我们看一下如果目标进程没有启动,会发生什么情况。
如果ContentProvider所在的进程已经死亡,那么会调用startProcessLocked来启动新的进程,startProcessLocked
有一系列重载函数,我们一路跟踪,发现最终启动进程的操作交给了Process
类的start
方法完成,这个方法通过socket与Zygote进程进行通信,通知Zygote进程fork出一个子进程,然后通过反射调用了之前传递过来的一个入口类的main函数,一般来说这个入口类就是ActivityThread,因此子进程fork出来之后会执行ActivityThread类的main函数。
在我们继续观察子进程ActivityThread的main函数执行之前,我们看看AMS进程这时候会干什么——startProcessLocked之后AMS进程和fork出来的DemoB进程分道扬镳;AMS会继续往下面执行。我们暂时回到AMS的getContentProviderImpl
方法:
1 | // Wait for the provider to be published... |
你没看错,一个死循环就是糊在上面:AMS进程会通过一个死循环等到进程B完成ContentProvider的安装,等待完成之后会把ContentProvider的信息返回给进程A。那么,我们现在的疑惑是,进程B在启动之后,在哪个时间点会完成ContentProvider的安装呢?
我们接着看ActivityThread的main函数,顺便寻找我们上面那个问题的答案;这个分析实际上就是Android App的启动过程,更详细的过程可以参阅老罗的文章 Android应用程序启动过程源代码分析,这里只给出简要调用流程:
最终,DemoB进程启动之后会执行ActivityThread类的handleBindApplication方法,这个方法相当之长,基本完成了App进程启动之后所有必要的操作;这里我们只关心ContentProvider相关的初始化操作,代码如下:
1 | // If the app is being launched for full backup or restore, bring it up in |
仔细观察以上代码,你会发现:ContentProvider的安装比Application的onCreate回调还要早!!因此,分析到这里我们已经明白了前面提出的那个问题,进程启动之后会在Applition类的onCreate 回调之前,在Application对象创建之后完成ContentProvider的安装。
然后不要忘了,我们的AMS进程还在那傻傻等待DemoB进程完成ContentProviderAppB的安装呢!在DemoB的Application的onCreate回调之前,DemoB的ContentProviderAppB已经安装好了,因此AMS停止等待,把DemoB安装的结果返回给请求这个ContentProvider的DemoA。我们必须对这个时序保持敏感,有时候就是失之毫厘,差之千里!!
到这里,有关ContentProvider的调用过程以及简要的工作原理我们已经分析完毕,关于它如何共享数据,如何使用匿名共享内存这部分不是插件化的重点,感兴趣的可以参考 Android应用程序组件Content Provider在应用程序之间共享数据的原理分析。
在实现ContentProvider的插件化之前,通过分析这个组件的工作原理,我们可以得出它的一些与众不同的特性:
在分析ContentProvider的工作原理的过程中我们提出了一种插件化方案:在进程启动之初,手动把ContentProvider安装到本进程,使得后续对于插件ContentProvider的请求能够顺利完成。我们也指出它的一个严重缺陷,那就是它只能在插件系统内部掩耳盗铃,在插件系统之外,第三方App依然无法感知到插件中的ContentProvider的存在。
如果插件的ContentProvider组件仅仅是为了共享给其他插件或者宿主程序使用,那么这种方案可以解决问题;不需要Hook AMS,非常简单。
但是,如果希望把插件ContenProvider共享给整个系统呢?在分析AMS中获取ContentProvider的过程中我们了解到,ContentProvider信息的注册是在Android系统启动或者新安装App的时候完成的,而AMS把ContentProvider返回给第三方App也是在system_server进程完成;我们无法对其暗箱操作。
在完成Activity,Service组件的插件化之后,这种限制对我们来说已经是小case了:我们在宿主程序里面注册一个货真价实、被系统认可的StubContentProvider组件,把这个组件共享给第三方App;然后通过代理分发技术把第三方App对于插件ContentProvider的请求通过这个StubContentProvider分发给对应的插件。
但是这还存在一个问题,由于第三方App查阅的其实是StubContentProvider,因此他们查阅的URI也必然是StubContentProvider的authority,要查询到插件的ContentProvider,必须把要查询的真正的插件ContentProvider信息传递进来。这个问题的解决方案也很容易,我们可以制定一个「插件查询协议」来实现。
举个例子,假设插件系统的宿主程序在AndroidManifest.xml中注册了一个StubContentProvider,它的Authority为com.test.host_authority
;由于这个组件被注册在AndroidManifest.xml中,是系统认可的ContentProvider组件,整个系统都是可以使用这个共享组件的,使用它的URI一般为content://com.test.host_authority
;那么,如果插件系统中存在一个插件,这个插件提供了一个PluginContentProvider,它的Authority为com.test.plugin_authorith
,因为这个插件的PluginContentProvider没有在宿主程序的AndroidMainifest.xml中注册(预先注册就失去插件的意义了),整个系统是无法感知到它的存在的;前面提到代理分发技术,也就是,我们让第三方App请求宿主程序的StubContentProvider,这个StubContentProvider把请求转发给合适的插件的ContentProvider就能完成了(插件内部通过预先installProvider可以查询所有的ContentProvider组件);这个协议可以有很多,比如说:如果第三方App需要请求插件的StubContentProvider,可以以content://com.test.host_authority/com.test.plugin_authorith
去查询系统;也就是说,我们假装请求StubContentProvider,把真正的需要请求的PluginContentProvider的Authority放在路径参数里面,StubContentProvider收到这个请求之后,拿到这个真正的Authority去请求插件的PluginContentProvider,拿到结果之后再返回给第三方App。
这样,我们通过「代理分发技术」以及「插件查询协议」可以完美解决「共享」的问题,开篇提到了我们之前对于Activity,Service组件插件化方案中对于「共享」功能的缺失,按照这个思路,基本可以解决这一系列问题。比如,对于第三方App无法绑定插件服务的问题,我们可以注册一个StubService,把真正需要bind的插件服务信息放在intent的某个字段中,然后在StubService的onBind中解析出这个插件服务信息,然后去拿到插件Service组件的Binder对象返回给第三方。
上文详细分析了如何实现ContentProvider的插件化,接下来我们就实现这个过程。
要实现预先installProvider,我们首先需要知道,所谓的「预先」到底是在什么时候?
前文我们提到过App进程安装ContentProvider的时机非常之早,在Application类的onCreate回调执行之前已经完成了;这意味着什么?
现在我们对于ContentProvider插件化的实现方式是通过「代理分发技术」,也就是说在请求插件ContentProvider的时候会先请求宿主程序的StubContentProvider;如果一个第三方App查询插件的ContentProvider,而宿主程序没有启动的话,AMS会启动宿主程序并等待宿主程序的StubContentProvider完成安装,一旦安装完成就会把得到的IContentProvider返回给这个第三方App;第三方App拿到IContentProvider这个Binder对象之后就可能发起CURD操作,如果这个时候插件ContentProvider还没有启动,那么肯定就会出异常;要记住,“这个时候”可能宿主程序的onCreate还没有执行完毕呢!!
所以,我们基本可以得出结论,预先安装这个所谓的「预先」必须早于Application的onCreate方法,在Android SDK给我们的回调里面,attachBaseContent这个方法是可以满足要求的,它在Application这个对象被创建之后就会立即调用。
解决了时机问题,那么我们接下来就可以安装ContentProvider了。
安装ContentProvider也就是要调用ActivityThread类的installProvider
方法,这个方法需要的参数有点多,而且它的第二个参数IActivityManager.ContentProviderHolder是一个隐藏类,我们不知道如何构造,就算通过反射构造由于SDK没有暴露稳定性不易保证,我们看看有什么方法调用了这个installProvider。
installContentProviders这个方法直接调用installProvder看起来可以使用,但是它是一个private的方法,还有public的方法吗?继续往上寻找调用链,发现了installSystemProviders这个方法:
1 | public final void installSystemProviders(List<ProviderInfo> providers) { |
但是,我们说过ContentProvider的安装必须相当早,必须在Application类的attachBaseContent方法内,而这个mInitialApplication
字段是在onCreate
方法调用之后初始化的,所以,如果直接使用这个installSystemProviders
势必抛出空指针异常;因此,我们只有退而求其次,选择通过installContentProviders这个方法完成ContentProvider的安装
要调用这个方法必须拿到ContentProvider对应的ProviderInfo,这个我们在之前也介绍过,可以通过PackageParser类完成,当然这个类有一些兼容性问题,我们需要手动处理:
1 | /** |
解析出ProviderInfo之后,就可以直接调用installContentProvider了:
1 | /** |
整个安装过程必须在Application类的attachBaseContent里面完成:
1 | /** |
把插件中的ContentProvider安装到插件系统中之后,在插件内部就可以自由使用这些ContentProvider了;要把这些插件共享给整个系统,我们还需要一个货真价实的ContentProvider组件来执行分发:
1 | <provider |
第三方App如果要查询到插件的ContentProvider,必须遵循一个「插件查询协议」,这样StubContentProvider才能把对于插件的请求分发到正确的插件组件:
1 | /** |
通过以上过程我们就实现了ContentProvider的插件化。需要说明的是,DroidPlugind的插件化与上述介绍的方案有一些不同之处:
本文我们通过「代理分发技术」以及「插件查询协议」完成了ContentProvider组件的插件化,并且给出了对「插件共享组件」的问题的一般解决方案。值得一提的是,系统的ContentProvider其实是lazy load的,也就是说只有在需要使用的时候才会启动对应的ContentProvider,而我们对于插件的实现则是预先加载,这里还有改进的空间,读者可以思考一下解决方案。
由于ContentProvider的使用频度非常低,而很多它使用的场景(比如系统)并不太需要「插件化」,因此在实际的插件方案中,提供ContentProvider插件化的方案非常之少;就算需要实现ContentProvider的插件化,也只是解决插件内部之间共享组件的问题,并没有把插件组件暴露给整个系统。我个人觉得,如果只是希望插件化,那么是否支持ContentProvider无伤大雅,但是,如果希望实现虚拟化或者说容器技术,所有组件是必须支持插件化的。
至此,对于Android系统的四大组件的插件化已经全部介绍完毕;由于是最后一个要介绍的组件,我并没有像之前一样先给出组件的运行原理,然后一通分析最后给出插件方案,而是一边分析代码一边给出自己的思路,把思考——推翻——改进的整个过程完全展现了出来,Android的插件化已经到达了百花齐放的阶段,插件化之路也不只有一条,但是万变不离其宗,希望我的分析和思考对各位读者理解甚至创造插件化方案带来帮助。接下来我会介绍「插件通信机制」,它与本文的ContentProvider以及我反复强调过的一些特性密切相关,敬请期待!
喜欢就点个赞吧,兜里有一块钱的童鞋可以点击下面的打赏然后扫一下二维码哦~持续更新,请关注github项目 understand-plugin-framework 和我的 博客! 另外很抱歉一个多月没有更新博客了,每天看到各位的来访记录深感惭愧,实在是业务繁忙,身不由已!不出意外接下来会以正常速度更新内容,谢谢支持 ^_^
]]>与Activity,BroadcastReceiver等频繁被使用的组件不同,我们接触和使用ContentProvider的机会要少得多;但是,ContentProvider这个组件对于Android系统有着特别重要的作用——作为一种极其方便的数据共享的手段,ContentProvider使得广大第三方App能够在壁垒森严的系统中自由呼吸。
在Android系统中,每一个应用程序都有自己的用户ID,而每一个应用程序所创建的文件的读写权限都是只赋予给自己所属的用户,这就限制了应用程序之间相互读写数据的操作。应用程序之间如果希望能够进行交互,只能采取跨进程通信的方式;Binder机制能够满足一般的IPC需求,但是如果应用程序之间需要共享大量数据,单纯使用Binder是很难办到的——我相信大家对于Binder 1M缓冲区以及TransactionTooLargeException一定不陌生;ContentProvider使用了匿名共享内存(Ashmem)机制完成数据共享,因此它可以很方便地完成大量数据的传输。Android系统的短信,联系人,相册,媒体库等等一系列的基础功能都依赖与ContentProvider,它的重要性可见一斑。
既然ContentProvider的核心特性是数据共享,那么要实现它的插件化,必须能让插件能够把它的ContentProvider共享给系统——如果不能「provide content」那还叫什么ContentProvider?
但是,如果回想一下Activity等组件的插件化方式,在涉及到「共享」这个问题上,一直没有较好的解决方案:
]]>