世界是复杂的,但同时又符合一些简单的规律。朱子有一句名言:千丝万缕,归于一理。这意味着无论事情多么混乱,它们总是受一个简单真理的支配。比如在古代的《易经》中,对阴阳两个符号进行了定义,赋予了丰富而深刻的内涵。《易经》的西慈传中有一句名言:乙有太极,生于两乙。这句话有不同的解释。“天地本源说”就是其中之一。按照这个认识,宇宙本来就是混沌的,后来清的变成了天,混的变成了地。这种形成天地的过程,可以简称为“两礼两分”。我不敢说这句话在描述天地的形成上多少有些准确,但基于多年对软件的研究,我认为它非常适用于软件世界。最早的软件是一长串的卡片,连在一起,卡片上的指令都住在一个空间里,它们是平等的,没有权限差异,也没有空间划分。随着计算机的发展,计算机系统中的角色开始细分,每个角色的特点和位置也逐渐固定下来。所以有一个集中的内存和处理器,还有外部的输入输出设备。中央处理器和内存速度快,外部输入输出设备速度慢。让中央处理器直接与外围设备对话效率太低了。于是就有了中断的概念,中央处理器需要什么,就给外设下达命令,当外设准备好后,通过中断通知中央处理器:“报告老大,你命令的任务已经完成完全的。”外部设备不止一种,中断也有很多种。为了更好的处理中断,有专门处理中断的“控制程序”。这就是操作系统的前身。中断处理程序的逻辑是敏感的,一个故障可能导致整个系统无法运行。既然如此重要,就应该“优待”它,赋予它特权,赋予它特殊的“栖身之所”,加强对其栖身之所的防御,防止未经授权的入侵。所以就有了一个专门用于中断处理程序的空间,也就是所谓的内核空间。有了高权限的内核空间,也就有了低权限的用户空间。于是,原本杂乱无章的软件世界一分为二。在软件世界的两个空间中,“用户空间是可见的,上面住着很多程序,属于阳。内核空间是不可见的,但是它承载了上面的应用程序,为应用程序提供服务。它不”不发光,但能映照应用之光华,如月,属阴。(摘自《软件调试》Volume2)今天,软件世界的这种基本格局非常固定,无论是Windows还是Linux。用户空间和内核空间之间的关系与现实世界中公民和企业的关系非常相似。市民需要政府服务时,只能到政府窗口办理。软件世界中相应的机制称为“系统调用”,即调用系统的服务。【上半场科普,下半场换档,前半段冷场,非极客停】受两大空间划分的影响,我们在调试软件的时候,一般分为应用程序调试,用于调试用户空间代码,和调试内核空间代码的内核调试。相应地,调试器也可以分为应用程序调试器和内核调试器。很容易理解,当我们使用应用程序调试器调试应用程序时,无法调试内核代码。不一定相反。换句话说,是否可以使用内核调试器来调试用户空间代码?理论上是可以的,因为内核空间有高权限。既然内核空间是可以访问和控制的,那么理论上也可以访问和控制内核管理的用户。空间。理论上是可以的,但是实践中可能吗?答案不一定,要看调试人员的能力和调试人员的技术水平。例如,在使用WinDBG调试Windows内核时,也可以在用户空间设置断点。下断点后,可以读写变量,或者单步跟踪。不过虽然WinDBG有这个能力,但是真正能把WinDBG用到这个程度的人并不多。在这方面,WinDBG还有一个非常强大的能力,那就是可以跨越阴阳界显示系统调用的完整过程。例如:在上面完美的stacktraceback中,下面的部分是CPU在用户空间的执行过程。从线程起点,到AfxWinMain,通过消息循环,再到OLE的处理文件拖拽逻辑,收到一个文件后,不知所措,通过古老的DDE机制广播消息求助。用于广播消息的SendMessageW函数发起一个系统调用,进入内核空间,内核空间的Win32K模块处理服务请求,执行广播消息的任务,一个一个向顶层窗口发送消息,遇到一个“不回信息的坏人”,然后卡在那里。第一次看到这么完美的stackbacktrace,深深被微软调试工具的技术水平所折服。为什么?因为在两个空间中显示这个堆栈跟踪有很多困难。第一个难点是每个普通线程至少有两个栈,一个在用户空间,一个在内核空间。因此,要像上面那样显示一个完美的堆栈回溯,必须回溯两个堆栈,因为起点是在内核空间。因此,内核空间的栈更容易找到。但是用户空间的栈位置不是那么容易找到的。第二个难点是现在的主流操作系统都是多任务的,有很多用户空间。要显示上面的完美堆栈回溯,必须找到正确的用户空间,找到这个空间中的模块列表,加载用户空间。模块。自从我开始开发NDB调试器以来,我希望它也具有“生成完美的堆栈回溯”的能力。为了实现这个愿望,我花了很多时间思考,加上很多时间写代码,当然我也花了很多时间调试代码,让它按预期工作。长话短说,解决第一个难题,至少用了三十个小时。最终成功的解决方案就是走完这些步骤。首先通过内核空间的回溯,找到CPU进行系统调用时内核空间的起点。x64下,就是汇编写成的entry_SYSCALL_64。这个证据在内核源码的syscall_init中也能看到。entry_SYSCALL_64的源码位于:F:\bench\linux-5.0.7\arch\x86\entry\entry_64.S在这个值得仔细阅读的汇编源文件中,有一个很长的注释,帮助我很忙。更重要的是,在这个汇编函数中,保存了一个所谓的硬件框架,通过不断的压栈,在栈上形成了一个pt_regs结构体。这个pt_regs结构包含了关键的寄存器信息,尤其是我需要的用户空间栈指针rsp。以此类推,这个结构就相当于Windows下的陷阱框(KTRAP_FRAME),正应了朱子的话:线索万千,齐聚一堂。在这场战斗中,我的NDB发挥了非常重要的作用。比如我在entry_SYSCALL_64入口下了一个断点,成功命中。这让我在从用户空间飞到内核空间后,可以清楚地观察到CPU刚落地时的确切状态。每个寄存器的值。rax=00000000000000e4rbx=0000000000000001rcx=00007ffdc513099a//RIPrdx=00007f9b9dec9e10rsi=00007f9b9dec9e10rdi=0000000000000001rip=ffffffffa3e00010rsp=00007f9b9dec9db8rbp=00007f9b9dec9dc0r8=0000000000000000r9=00007f9b9dec9e10r10=00007f9b9dec9db0r11=0000000000000286r12=00007f9b9dec9e10r13=0000000000000000r14=00007f9b9dec9e10r15=00007f9b9dec9e20iopl=0nvupdingnznaponccs=0010ss=0018ds=0000es=0000fs=0000gs=0000efl=00010086lk!entry_SYSCALL_64:ffffffff`a3e000100f01f8invlpgeaxds:0010:00007ffd`c512d080=00126362rcx寄存器的值是用户空间的程序指针,因为根据SYSCALL指令的定义,CPU会将rip保存到rcx,也就是上面注释说的:64-bitSYSCALLsavesriptorcxUseNDBdisassemblethisvalue-2(减2是看已经执行的syscall指令)可以看到用户空间发起的指令系统调用:u007ffdc513099800007ffd`c51309980f05syscall00007ffd`c513099a415cpopr12ffd00007`c513099c5dpoprbp00007ffd`c513099dc3ret00007ffd`c513099ea860testal,0x6000007ffd`c51309a07438jz00007ffdc51309da00007ffd`c51309a24863c7movsxdrax,edi00007ffd`c51309a5488d0dd4c6fffflearcx,[00007ffdc512d080]顺便说一下,使用汇编语言编写的entry_SY准备SCALL_64函数的pt_regs结构体的目的是为C语言编写的do_syscall_64函数准备参数。后者的第二个参数是指向pt_regs结构体的指针,即:__visiblevoiddo_syscall_64(unsignedlongnr,structpt_regs*regs)下面准备调用C语言函数之前的Registercontext:和堆栈数据:对于这个堆栈数据,同事不常做底层调试的人,脸上可能会挂上问号。对我来说,几乎每一个字节都很亲切,就像老朋友一样,因为他们都有着鲜明的个性,而且16位的段选择器也住着64位的大房子(^-^)。对于第二个难点,解决难度较大,主要步骤如下。首先通过per-cpuarea找到当前任务指针,也就是当前指针,看我之前的文章。然后通过current指针找到当前任务的地址空间描述,也就是mm_struct。然后在mm_struct中找到VMA链表,然后遍历链表过滤掉so模块描述。找到这些so模块描述后,还需要将这些描述上报给调试引擎,以便调试引擎加载用户空间的模块。找到用户空间栈,加载用户空间模块,再观察栈回溯,就能看到希望的曙光。比如下面是我11月21日看到的场景:我看到了libc和大名鼎鼎的ld模块,给了我莫大的鼓舞。仔细观察上面的stackbacktrace,不难看出libc中的函数名非常不严谨,用调试器分析,发现使用的libc符号文件缺少对stackbacktrace至关重要的frame信息。追根究底,让人晕倒。与Windows下的符号文件不同,如果Linux的gcc(以Ubuntu为例)在编译时有-g选项,就会生成调试符号,并放在一个带有执行信息的文件中。包含符号信息的文件比较大,所以一般采用所谓的strip过程生成符号减少的产品文件和调试用的符号文件。例如使用下面的objcopy命令生成一个专门用于调试的符号文件:gedu@gedu-VirtualBox:~/labs/gemalloc$readelf--debug-dump=framesgemalloc.dbgsection'.eh_frame'hastheNOBITStype-itscontentsareunreliable。下面两条命令可以生成适合发布到生产环境的未符号化版本:prd观察文件大小,有一个明显的区别:调试时,我们一般使用符号文件。因为大部分符号信息都放在符号文件中。请注意,这是针对大多数,而不是全部。比如frame信息,因为发生异常时也需要栈扩展,所以frame信息放在product文件中。由于调试和正常执行都需要帧信息,所以说这两个文件都应该有一份,其实不然。至少上面截图中使用的libc符号文件不包含帧信息。section的header还在,但是在flag中有了NOBITS标志,说明这个section的数据不可靠,解析的时候会失败。例如使用readelf命令显示上面生成的符号文件的帧信息,会得到如下提示:gedu@gedu-VirtualBox:~/labs/gemalloc$readelf--debug-dump=framesgemalloc.dbgsection'.eh_frame'具有NOBITS类型-其内容不可靠。附上一张截图:下面是readelf程序的相关源码:if(section->sh_type==SHT_NOBITS){/*NOBITS类型的debuggingsection的内容-文件中的bits是没有意义的将是随机的。当包含.eh_frame部分的文件被--only-keep-debug命令行选项剥离时,可能会发生这种情况(“print_/section'%s'hastheNOBITStype-itscontentsareunreliable.\n"),print_name);return0;如何解决这个问题?就是让NDB为一个模块加载两个符号文件。细节省略。通过这个难度之后,你可以看到一个更好的堆栈回溯。libc的函数名称变得非常精确。仔细观察上面的结果,美中不足的是最后三行,两行重复clone函数,最后一行没有给出函数名,也没有显示root的返回地址为0。山,可以学习。用gdb做对比实验,看到的是:(gdb)bt#0thread_func(arg=0x9)atgemalloc.c:289#10x00007ffff68216bainstart_thread(arg=0x7fffc6ffd700)atpthread_create.c:333#20x00007ffff655741dinclone()at..//unix/sysv/linux/x86_64/clone.S:109看来clone函数确实是线程的起点。gdb在起点成功停止。但是ndb没有。给调试器一个调试器,调试NDB。Carefullytrackdownwhichndwmoduleisresponsibleforresolvingsymbols.跟踪发现,ndw可以顺利找到clone函数的帧信息,即:000146e8000000000000001400000000CIEVersion:1Augmentation:"zR"Codealignmentfactor:1Dataalignmentfactor:-8Returnaddresscolumn:16Augmentationdata:1bDW_CFA_def_cfa:r7(rsp)ofs8DW_CFA_offset:r16(rip)atcfa-8DW_CFA_undefined:r16(rip)上面的DW_XX是DWARF标准中定义的栈回溯指令,可以理解为脚本语言。NDW内部的解释器成功执行了前两个语句:DW_CFA_def_cfa:r7(rsp)ofs8DW_CFA_offset:r16(rip)atcfa-8但是当它执行第三个语句时,它变得混乱了。DW_CFA_undefined:r16(rip)当我追查这条语句对应的解释器代码时,以为遇到了未知指令,估计是版本不兼容导致的。经过长时间的反复跟踪和思考,我终于意识到这个undefined并不是我一开始想的那样。它的意思并不是说它是一个未定义的指令,而是将它的操作数,也就是后面的rip寄存器设置为“未定义”状态。从某种程度上来说,堆栈回溯就是回滚寄存器的状态,而这个DW_CFA_undefined:r16(rip)就是将rip寄存器的状态回滚到“未定义状态”。也就是让它进入一个不知道值是什么的状态。在NDW的旧代码中,对于这种情况,rip会回滚到当前值(即不回滚),所以上面clone函数的父函数还是clone函数。阅读libc中clone函数的源码,可以找到这个DW_CFA_undefined指令的来源,是同事手动添加的。代码中的注释很有意思:这个undefined是故意加的,用来标记最外层的栈帧,很明显!其实不加也挺好的。反正也是用心良苦。闭上眼睛想想,这里用的undefined也很有哲理。很多时候我们都在寻找源头,但是最终的源头在哪里呢?我们找到的来源可能不是真正的来源。真正的来源通常是未知的,即未定义。比如2020年人类能否找到新冠病毒的真正来源?至少直到今天,它仍然是未定义的。这么一想,这种在线程源头标记为undefined的方法还真是有意义。找到根源后,我直接把这个未定义的回滚处理为回滚到0,问题就解决了,梦寐以求的完美栈回溯就出现在了我的面前。本文转载自微信公众号“格优”,可通过以下二维码关注。转载本文请联系格友公众号。
