Apple M2+QEMU/HVF平台上OpenHarmony OS无法正常启动的解决方案与调试心得

只需要解决方案的读者请直接跳转至第六节TLDR,本文正文部分将以调试者的视角,从只有一个地址开始,抽丝剥茧、条分理析,发掘出所有问题的根本原因。

最近在做OpenHarmony OS(下面简称ohos)相关的工作,需要将其运行在QEMU上。CPU为Apple M2(arm64架构),可使用macOS系统的Hypervisor.Framework,也就是HVF,进行加速。

在环境配置和编译流程完成之后,我们发现如果启用HVF,ohos就无法在QEMU上正常启动。QEMU只打出很少一点log,可以判断bug发生在内核之中,且与ohos无关。如果不启用HVF,ohos可以正常启动,但是性能很差,图形界面明显卡顿,影响后续的开发调试,这说明bug也可能与HVF有关

考虑到内核调试刚好是QEMU的重要用途之一,可以借此查明到底是内核还是HVF报错,如果是内核,问题出在哪里。一切问题总能得到解答,计算机系统的世界从来没有魔法。

1. QEMU+gdb环境配置

远程(remote)调试的原理,不外乎是一个client,一个server。当我们在命令行输入gdb,然后通过target remote 1234进行调试的时候,我们其实处在client的位置上,向监听于127.0.0.1:1234的server发起一个连接请求。而大家如果在操作系统实验课上用过QEMU,在实验讲义中读到过如何调试内核,就可以意识到,QEMU充当server的角色。server了解被调试程序的一切,从CPU状态(PC和各种通用寄存器),到内存布局(代码、数据段映射)。假如要远程调试一个不在QEMU上,而是直接运行于操作系统上的程序,gdbserver命令可以attach到需要调试的进程,通过砸瓦鲁多(ptrace系统调用),令被调试进程暂停,并暴露出一个端口,供client连接。这时,只需和刚才一样发起连接,就可以控制被调试程序的运行了。

逆天的是,苹果arm64上不支持安装gdb!但既然调内核本来就需要用remote+attach的方式,干脆remote到底,将苹果(被调试机)的端口暴露出来,在同一局域网的linux机器上启动gdb-multiarch client,连接这一端口即可。

QEMU启动内核时,如果指定-S参数,就可以将pc放在内核的第一条指令,等待调试client发起连接。

调试环境启动之后,还要提供正确的符号文件,才能将内存地址对应到源代码之中。图方便的话,直接用symbol-file命令后接符号文件路径即可,此处只需在ohos编译产物路径下找到vmlinux。只要在config中启用DEBUG_INFO,编译出的vmlinux就包含调试信息了。与ohos、aosp、lineageOS等一同编译的内核,其config名字由defconfig结尾,可以用find命令搜索一下。找到的defconfig可以用menuconfig的方式加以自定义配置。

符号表加载完毕,输入c命令启动内核,奇寄发生了——ohos运行起来了!

再试一次,还能运行!我感觉离大谱了,总不可能bug是被调试器吓得飞走了吧?看着运行无比流畅的ohos模拟器,我们高兴是高兴,但也不能以后开发的时候每次都接个gdb client然后读取符号表然后输入c才让它运行吧?

2. 正确显示内核符号和栈信息

尽管加载了符号表之后,ohos可以运行,但我觉得这事情跟符号表关系不大,很可能是ptrace之后的运行环境与正常的环境存在差异,导致ohos的bug消失了。所以,当下的第一要务仍然是让内核在gdb的环境下触发bug,然后再考虑加符号表。

这样的思路是可行的,至少能够在bug产生的同时读取符号表,离成功近了一步。但是,仍然无法看到错误发生在哪里,当前pc和bt命令显示的栈信息也无法对应到内核源代码,只能显示??字样。通过info line *addr这一命令,也不能对应到源码。

幸运的是,我发现程序是卡死在了一个特定的地址A上,那么这个地址上的指令想必暗藏玄机。但当使用layout asm查看时,却发现这是一个平平无奇的内存读取指令!读取的内存值是正常的,难道说在arm64里这是一个需要先获取锁才能访问某个地址的指令,访问内存导致了死锁?锁的问题可麻烦了,必须得知道是在哪发生的bug才行。

好在我们知道如何正常启动ohos了,不如看看正常情况下,这条内存读取指令做了什么吧!于是乎先在这个地址打了断点,然后加载符号表,最后启动内核,结果程序没有在断点停下。

这倒也合理,因为可能这是正常情况走不到的分支。那把断点打在start_kernel上吧,这下肯定没跑了。于是乎先加载符号表,然后在start_kernel打了断点,最后启动内核。断点还是没有停下……万一是arm64内核鬼畜地走了别的什么函数来启动内核呢?那再打个copy_process,这可是任何进程创建时必走的函数,这下绝对掉进如来佛的手掌心了。然后在ohos的桌面上启动计算器应用,断点还是没有停下……

断点看似打成功了,但是并不能正常停下,而且在使用^C(Ctrl+C)强制暂停时,bt指令只能显示两条栈帧信息,后面就是stack broken……导致这个问题的罪魁祸首,就是ASLR!ASLR(Address Space Layout Randomize)是一种加强安全性的技术,通过将代码、数据和堆栈加载到与链接后二进制文件所声明的地址不同的位置,攻击者就无法通过vmlinux文件获知关键数据和代码的位置,因此不能通过直接跳转到一个已知的地址实现攻击。而依赖于PIE(Position-Independent Executable)技术的数据和代码在地址随机化之后不受任何影响,因为它们是基于与程序加载的基地址(base address)之间的偏移起作用的。(不能再多说了,这篇文章是讲内核调试的,不是讲二进制安全的,有兴趣的读者可自行查阅资料)

禁止内核以ASLR的方式加载,可使用内核参数nokaslr。在QEMU命令行简单加上nokaslr,再次触发bug,一串完整的栈帧信息跃然出现于屏幕之上!

3. 明确自己身处的位置

栈帧信息的栈底,是一个叫做el1_sync的函数;栈中间出现了die的字样,所以再后面一直到栈顶是什么,我认为已经不重要了,从栈底开始考虑即可。了解arm64的同学恐怕已经一目了然,可以跳到第4节了;但对于平时只玩x64的同学(以及我自己),我还是把事情的来龙去脉讲清楚更好。

定位到出错位置所在,欣喜若狂地打开kernel源码,发现el1_sync位于entry.S文件中,遂怀疑是内核刚出门不久就遭遇了事故。arm64中,EL表示Exception Level,从这个名字听起来可能是发生了exception、trap等情况。EL0为用户级,EL1为内核级,EL2为VMM级,而EL3为固件级,掌控整个系统的一切权力。

el1_sync只做了一点简单的处理,就跳转到另一个.c文件的某个函数中。该函数由一个switch-case组成,通过栈帧信息中的参数,可以发现case的值为0x3c,而它有定义:

#define ESR_ELx_EC_BRK64	(0x3C)

搜索这个宏,发现了brk指令。这是一个特殊的指令,可用于实现断点;乍一听和我们正在玩的调试器有点关系,但实际上这个指令应该导致了一个陷入,一个特权级的转换。而在el1_sync所在的entry.S中,还发现了一个vector。种种迹象,暗示这是一个硬件跳转过来的处理例程!内核并不是刚启动即暴毙,而是在某个其他组件中,触发了非法指令。于是我查了el1_sync,发现这是EL1级下触发异常后的处理例程,可用于处理指令异常、缺页中断等[1]。看来,是brk指令导致了最后的崩溃!

4. bug定位、解决、可能原因分析

所谓解铃还须系铃人,总得知道是谁触发了brk才行。想想我们熟悉的系统调用,通过一个陷入指令,硬件自动将pc和各寄存器信息保存至栈中,异常和系统调用本质都是一样的,那我们在调试器中自然有办法把这些被保存的现场信息还原出来!

第一个要还原的肯定是pc,于是我再查el1_sync,发现pc信息保存在ELR_EL1寄存器中。来不及多想,我立即输入info line *$ELR_EL1,这下终于找到了根源——pc指向kernel/sched/walt.c:238,其函数为update_task_ravg!来看此处代码:

delta = wallclock - rq->window_start;
BUG_ON(delta < 0);

原来,正是这里的BUG_ON调用了brk指令,导致系统崩溃!这个walt到底是何方神圣呢?在linux source tree中并没有找到,原来它是Window Assisted Load Tracking,起源于Android Common Kernel[2],是一种CPU负载均衡算法,那么出现在ohos内核中也就很容易理解了。看ACK中的实现,是这样的[3]:

delta = wallclock - rq->window_start;
/* If the MPM global timer is cleared, set delta as 0 to avoid kernel BUG happening */
if (delta < 0) {
	delta = 0;
	WARN_ONCE(1, "WALT wallclock appears to have gone backwards or reset\n");
}

好吧!看来解决问题的一种良策,就是解决提出问题的人。用ACK里的这段代码替换ohos内核的代码,让它不要调用BUG_ON,就可以了。

重新启动编译,然后就是漫长的等待……现在就聊聊遗留的问题吧:

  • 其实在符号表正确加载、关闭ALSR之后,断点是仍然不能正常命中的。这就要回到虚拟化的作用——当QEMU使用HVF加速时,其实际上是调用了HVF提供的VCPU启动接口,VCPU启动后,QEMU看起来就阻塞在这一接口上,只有当VCPU执行sensitive instruction(例如PIO、MMIO)时,才会陷入到HVF中,若HVF无法处理其请求,则控制权交还给QEMU,并返回VCPU退出时的相关信息。显然,要是就这样顺其自然,肯定不能指望断点能够命中。因此,Francesco Cagnin在QEMU中实现了HVF虚拟化下的gdbstub支持[5],所以最新版本的QEMU是可以支持HVF debug的。
  • walt中的bug源于时钟作差,其中一个名字叫wallclock。它的字面义是“墙上时钟”,也就是标准的时钟,是由底层硬件提供支持的。不管这个时钟是来自HVF的抽象,还是来自物理意义上的CPU时钟,它在执行到walt这段代码之前有被清零的可能,因为只能WARN一下,绝不能使用BUG。至于gdb加了符号表后ohos就可以正常运行,考虑到ptrace和符号表查询对系统性能的影响,这很可能是个竞争问题,谁先谁后罢了——比如,先walt则活,先clear时钟则死。考虑到这只是CPU负载均衡算法的一个微小的边界条件,简单地将结果清零不会有什么影响,因此就这样解决就行了。其实这个bug是ACK的人写出来的,经过两次改动,最后决定这个地方不需要使用BUG_ON,可参考commit记录[5][6]。

从一条地址开始的抹黑探索,到最后真相大白,到此所有谜团已经全部解开。编译终于完成了,QEMU启动,ohos完美流畅运行!回宿舍睡觉咯!

5. TLDR

Apple M2(arm64)上OpenHarmony OS无法在QEMU/HVF上正常启动的解决方案:将所使用的内核根目录下kernel/sched/walt.c文件中update_window_start函数的内容按第4节的方式进行修改,去掉BUG_ON。之后重新编译,等待几分钟,重新运行QEMU即可。

调试技术总结:

  1. 调试内核代码,请通过内核参数nokaslr,关闭ASLR;
  2. 内核断点无法命中可能是因为QEMU没有为相应的VMM提供断点处理机制,如有刚需,请更新QEMU至最新版本;
  3. arm64的内核异常处理函数为el1_sync,遇到类似的情况,立即寻找触发异常时的pc和栈顶指针即可。

6. 参考材料

[1] armv8/arm64 中断/系统调用流程-腾讯云开发者社区-腾讯云 (tencent.com)

[2] sched: Introduce Window Assisted Load Tracking [LWN.net]

[3] kernel/sched/walt.c – kernel/msm – Git at Google (googlesource.com)

[4] hvf: add guest debugging handlers for Apple Silicon hosts (eb2edc42) · Commits · QEMU / QEMU · GitLab

[5] Diff – 00dfb3437ce03bc11be9e56ab6ed923a7daabb4d^! – kernel/msm – Git at Google (googlesource.com)

[6] Diff – 5ea9de8ee9ee44d4fa73e75c7b03af2a6b62f49b^! – kernel/msm – Git at Google (googlesource.com)

2人评论了“Apple M2+QEMU/HVF平台上OpenHarmony OS无法正常启动的解决方案与调试心得”

  1. 您好,想问一下您是用的macos arm64的环境来编译ohos的嘛?想请教一下您是怎么编译的?自己改的ohos的编译脚本嘛?

    1. 用docker就行,参见https://gitee.com/openharmony/docs/tree/master/docker#openharmony-docker%E9%95%9C%E5%83%8F。在自己的环境下编译,容易搞得乌烟瘴气的,这个编译脚本还要你root权限,一言难尽

发表评论

您的电子邮箱地址不会被公开。