人是有经历的,软件也如此。
简历记录着一个人的经历,而调用栈(call stack)则记录着软件的经历。看一个人的简历可以快速了解一个人。观察调用栈,则可以快速理解软件。
因为此,我非常喜欢看软件的调用栈。每当看到一个漂亮的调用栈,我常常如获至宝,端详许久。
因为对调试技术的热爱,这些年,我花了很多时间在调试器上。特别是开发了NDB调试器(Nano Code Debugger)。
对于NDB调试器,我很看重的一个功能当然也是调用栈,因为这个命令太重要了。上图的WIFI网络调用栈就是使用NDB显示出的。图中的lk是Linux Kernel的缩写,bcmdhd是博通公司(Broadcom)的WIFI驱动名字。
对于上面的调用栈,已经包含了丰富的信息,可以从中学到很多东西,但是,它还不完美,因为它只有内核空间的部分,缺少用户空间的部分。
软件世界的两大空间一阳一阴,与古老的道家哲学有着惊人的相似。
系统调用是沟通两大空间的一种重要机制。现代CPU一般都有专门的指令来执行系统调用。SVC指令是ARM CPU的系统调用指令。
比如下面的这段shmget函数的反汇编指令中,+16的位置便是SVC指令,它的前一条指令将0xC2赋值给x8,0xC2是shmget这个系统调用的唯一编号。
=> 0x0000007ff7f20f58 <+0>: sxtw x0, w0
0x0000007ff7f20f5c <+4>: sxtw x2, w2
0x0000007ff7f20f60 <+8>: mov x3, #0x0 // #0
0x0000007ff7f20f64 <+12>: mov x8, #0xc2 // #194
0x0000007ff7f20f68 <+16>: svc #0x0
0x0000007ff7f20f6c <+20>: cmn x0, #0x1, lsl #12
0x0000007ff7f20f70 <+24>: b.hi 0x7ff7f20f78 // b.pmore
0x0000007ff7f20f74 <+28>: ret
0x0000007ff7f20f78 <+32>: adrp x1, 0x7ff7fa1000 <__libio_codecvt+136>
0x0000007ff7f20f7c <+36>: ldr x1, [x1, #3608]
0x0000007ff7f20f80 <+40>: mrs x2, tpidr_el0
0x0000007ff7f20f84 <+44>: neg w3, w0
0x0000007ff7f20f88 <+48>: mov w0, #0xffffffff // #-1
0x0000007ff7f20f8c <+52>: str w3, [x2, x1]
0x0000007ff7f20f90 <+56>: ret
CPU执行到svc指令后,便会像蹦极一样“自由下坠”,坠落到内核空间的陷阱处理函数,即图1中的el0_svc。
9月上旬去了趟庐山,在太乙峰下和参加纳秒级优化的小伙伴度过了几天忙碌而又快乐的时光。回上海后,又忙着LINUX研习班上海站。两个研习班结束后,距离国庆假期就只有一周多了,没有什么大的安排。思考一番,我决定用这个时间来实现NDB的“跨界调用栈”。
我喜欢用PPT来记录工作过程,既方便记录截图,也方便记录文字,还可以做各种标注。精彩的转为讲义,就可以分享给小伙伴们。
调用栈的原理不难,就是从栈寻找软件的历史状态。但因为每个函数用的栈空间大小不一,所以实际实现是有各种困难的。
对于跨界调用栈,困难有以下几个:
- 用户空间的栈和内核栈不连续,是两个栈,要找到用户空间栈的位置
- 恢复用户空间寄存器的状态
- 加载用户空间模块
要解决上面的问题,需要有一个得心应手的环境。好在挥码枪和GDK8就在手边,基础的内核调试功能已经工作的很稳定。
但仍有一个棘手的问题就是要选个合适的系统调用做突破口。刚开始我用vfs_read,但是设断点后,频繁命中。改为vfs_write后,还是如此。
思考一番,改为使用shmget,庐山研习班上演示跨进程通信时刚好使用了这个系统调用,示例代码叫uking(幽王之意,源于烽火戏诸侯的故事),是现成的。
将本来在x86上的幽王复制到GDK8,编译成功后,使用gdb开始调试,在执行svc指令前停下来,观察它的寄存器。
(gdb) info r
x0 0x73616d69 1935764841
x1 0x400 1024
x2 0x3a4 932
x3 0x0 0
x4 0x0 0
x5 0x0 0
x6 0x7ff7fa3b00 549621218048
x7 0x4001001010000 1125968643162112
x8 0xc2 194
x9 0xffffffffffffffff -1
x10 0x8000000000000000 -9223372036854775808
x11 0x4001001010000 1125968643162112
x12 0x0 0
x13 0x7ff7ffe048 549621588040
x14 0x7ff7ffc218 549621580312
x15 0x7ff7ffc158 549621580120
x16 0x5555566fa8 366503948200
x17 0x7ff7f20f58 549620682584
x18 0x3 3
x19 0x5555555f00 366503878400
x20 0x0 0
x21 0x5555555b50 366503877456
x22 0x0 0
x23 0x0 0
x24 0x0 0
x25 0x0 0
x26 0x0 0
x27 0x0 0
x28 0x0 0
x29 0x7ffffff330 549755810608
x30 0x5555555d60 366503877984
sp 0x7ffffff330 0x7ffffff330
pc 0x7ff7f20f68 0x7ff7f20f68
cpsr 0x80200000 [ EL=0 SS N ]
fpsr 0x0 0
fpcr 0x0 0
在执行svc指令前,在内核空间对ksys_shmget设置断点。
内核空间准备好断点后,在用户空间单步执行svc指令,内核空间的断点顺利命中。
接下来,使用k命令激发调用栈功能,一边调试NDB的代码(debug the debugger),一边分析栈上的数据。
上图右侧x0开始一段内存区是所谓的陷阱帧(Trap Frame),它记录着CPU从用户空间切到内核空间后的寄存器状态,这个状态里面就有我要找的用户空间栈位置。
人工分析清楚后,要落实到代码上。NDB的符号模块叫NDW,其中包含着繁琐的DWARF符号格式解析逻辑。
经过2天多的攻坚战,昨晚7点时,跨界栈回溯开始工作了,可以跨越“鸿沟”找libc模块了。
libc是用户空间的核心模块,是发起系统调用的地方。
今天上午解决了几个小问题后,第一个完整的扩展栈回溯显示出来了。
上图中,栈帧3-4是两个空间的分界。
有了跨界栈回溯能力后,我又设置了vfs_write断点,这一次,就可以看清楚是哪些应用在写文件了。比如下面这一次是gnome的gmain工作线程因为检查版本在写文件。
kn 100
# Child-SP RetAddr Call Site
00 ffffff80`0b89be20 ffffff80`0828dca4 lk!vfs_write [fs/read_write.c @ 535]
01 ffffff80`0b89be70 ffffff80`0828dd34 lk!ksys_write+0x64 [fs/read_write.c @ 601]
02 ffffff80`0b89be80 ffffff80`08098f6c lk!__arm64_sys_write+0x14 [fs/read_write.c @ 610]
03 ffffff80`0b89beb0 ffffff80`080990a8 lk!el0_svc_common.constprop.0+0x64 [./arch/arm64/include/asm/current.h @ 19]
04 ffffff80`0b89beb8 ffffff80`08083d08 lk!el0_svc_handler+0x28 [arch/arm64/kernel/syscall.c @ 164]
05 0000007f`93d0c640 0000007f`94376a90 lk!el0_svc+0x8 [arch/arm64/kernel/entry.S @ 941]
06 0000007f`93d0c648 0000007f`944a3590 libc!__GI___libc_write+0x70 [../sysdeps/unix/sysv/linux/write.c @ 27]
07 0000007f`93d0c678 0000007f`94457f28 libglib_2_0_so_0_5600_4!glib_check_version+0x2a0
08 0000007f`93d0c6b8 0000007f`9445bc90 libglib_2_0_so_0_5600_4!g_list_sort_with_data+0x340
09 0000007f`93d0c6d8 0000007f`9445bfdc libglib_2_0_so_0_5600_4!g_main_context_dispatch+0x1d0
0a 0000007f`93d0c768 0000007f`9445c07c libglib_2_0_so_0_5600_4!g_main_context_dispatch+0x51c
0b 0000007f`93d0c7c8 0000007f`9445c0cc libglib_2_0_so_0_5600_4!g_main_context_iteration+0x34
0c 0000007f`93d0c7e8 0000007f`94484a64 libglib_2_0_so_0_5600_4!g_main_context_iteration+0x84
0d 0000007f`93d0c808 0000007f`9420a088 libglib_2_0_so_0_5600_4!g_test_get_filename+0x1b4
0e 0000007f`93d0c828 0000007f`943840cc libpthread_2_27_so!__pthread_get_minstack+0x13e0
0f 00000000`00000000 00000008`00000008 libc!thread_start+0xc [../sysdeps/unix/sysv/linux/aarch64/clone.S @ 81]
调试器是软件生产的基础工具,也是解决复杂软件问题的关键工具。用了2天多时间为NDB增加了期待已久的功能,让用户在调试时可以观察到贯通两大空间的调用栈,化天堑变通途,我觉得还是非常值得的。
这个功能成功后,我立刻将截图发到了兰舍群。软件技术没有止境,欢迎更多的伙伴加入兰舍群一起成长。
对于NDB的用户,可以通过如下步骤体验这个功能:
x lk!vfs_read
ba e4 lk!vfs_read
.reload /user
k
如果ndb提示缺少so文件,那么可以最好提前从目标机复制到主机。
(写文章很辛苦,恳请各位读者点击“在看”,也欢迎转发)
*************************************************
正心诚意,格物致知,以人文情怀审视软件,以软件技术改变人生
扫描下方二维码或者在微信中搜索“盛格塾”小程序,可以阅读更多文章和有声读物
也欢迎关注格友公众号