在x64架构下混合C++和ASM编程,堆栈调试器信息错误的问题
本文讨论一个调试C++和ASM混合代码的技术案例。其中涉及到PE文件结构中UNWIND_INFO和UNWIND_CODE等概念。
背景说明:
我在一个大型软件的开发调试过程中,遇到堆栈信息错误的问题。这个大型软件基于C++语言,支持二次开发,提供了数量若干的DLL库,并且内置了Origin C语言(简称OC)。通过OC语言可以访问内部或第三方的DLL库。具体问题就出现在某个OC代码调用内部DLL库时候,如果在调试器中查看堆栈,则无法显示正确。编译和调试工具为MSVC。
有问题的堆栈显示为:
正常的堆栈显示为:
由于这个问题在x64架构下才出现,而在x86架构下并没有发现此类问题。据猜测,与栈帧信息从x86下的ebp改换成x64下的rdi有关。
我们先观察一下普通的DLL函数的x86指令和x64指令的区别。这里将okutil_get_book_sheet_names这个函数的x86指令和x64指令反汇编出来。
x86指令
OC_API bool okutil_get_book_sheet_names(LPOCSTR lpcszBookSheet, string *pstrBook, string *pstrSheet, DWORD dwCntrl)
{
0326CB60 push ebp
0326CB61 mov ebp,esp
0326CB63 sub esp,30h
0326CB66 push esi
0326CB67 push edi
0326CB68 lea edi,[ebp-30h]
0326CB6B mov ecx,0Ch
0326CB70 mov eax,0CCCCCCCCh
0326CB75 rep stos dword ptr es:[edi]
… first code line
x64指令
OC_API bool okutil_get_book_sheet_names(LPOCSTR lpcszBookSheet, string *pstrBook, string *pstrSheet, DWORD dwCntrl)
{
00000000014405E0 mov dword ptr [rsp+20h],r9d
00000000014405E5 mov qword ptr [rsp+18h],r8
00000000014405EA mov qword ptr [rsp+10h],rdx
00000000014405EF mov qword ptr [rsp+8],rcx
00000000014405F4 push rdi
00000000014405F5 sub rsp,80h
00000000014405FC mov rdi,rsp
00000000014405FF mov ecx,20h
0000000001440604 mov eax,0CCCCCCCCh
0000000001440609 rep stos dword ptr [rdi]
000000000144060B mov rcx,qword ptr [lpcszBookSheet]
… first code line
通过对比分析发现,在x86架构下的ebp(对应用于x64下的rbp),在x64架构下已经不使用了,转而使用rdi作为栈帧信息。
在x86架构下,ebp用来保存栈帧信息,可以获取参数值,还有函数返回地址。其中返回地址存放于[ebp + 4]位置,而第i个参数值存放于[ebp+4(i+1)]的位置。esp则用来保存栈头信息。在函数过程中,每当需要新的栈空间,就会改变esp的栈头信息。
在x64架构下,rbp被rdi替换了。其中返回地址存放于[rdi + 8]位置,而第i个参数值存放于[rdi+8(i+1)]的位置。并且,栈空间只在函数入口处增加一次,中途不再另外改变。也就是说在函数过程中,栈头信息不再改变。因为栈头信息不再改变,所以原来需要用rbp保存的栈帧位置,相对栈头rsp位置就是一个固定的偏移量。这也意味着,返回地址和参数可以认为是存放于[rsp + xx]的位置。
再详细看看x86的函数入口和出口处的反汇编代码
入口
0326CB60 push ebp
0326CB61 mov ebp,esp
0326CB63 sub esp,30h
0326CB66 push esi
0326CB67 push edi
出口
0326CDF9 mov esp,ebp
0326CDFB pop ebp
0326CDFC ret
其中ebp被用来记录函数入口处的esp栈头信息,并且之后用于获取参数值等信息。在函数出口处,ebp会用于恢复esp栈头,并且还原上一栈帧的ebp,然后根据esp栈头保存的返回地址,返回上一函数调用处。
再详细看看x64的函数入口和出口处的反汇编代码
入口
00000000014405E0 mov dword ptr [rsp+20h],r9d
00000000014405E5 mov qword ptr [rsp+18h],r8
00000000014405EA mov qword ptr [rsp+10h],rdx
00000000014405EF mov qword ptr [rsp+8],rcx
00000000014405F4 push rdi
00000000014405F5 sub rsp,80h
出口
0000000001E90861 add rsp,80h
0000000001E90868 pop rdi
0000000001E90869 ret
其中(除了开头和结尾一次push,pop以外),在函数入口处,栈头esp被减了0x80,栈空间增加了0x80字节的空间。在函数出口处,栈头rsp被加了0x80,栈空间减少了0x80字节的空间。此外,在函数体内没有任何push,pop的指令出现。这意味着,只要获取[esp+88h]的位置(包括push rdi),就能得到返回地址。另外,也可以将rdi视为ebp栈帧信息,用[rdi+8]也可以得到返回地址。
从上述分析中,可以得出:
在x86下,堆栈调试器从ebp寄存器中,递归读取堆栈内存,恢复完整的堆栈表。
在x64下,堆栈调试器从rdi,rsp寄存器中,递归读取堆栈内存,恢复完整的堆栈表。
要特别说明的是,调试器一般依赖于编译器的实现,如果双方没有正确的约定,那么堆栈调试器就可能显示错误的堆栈表。而二者之间的关键桥梁,就是UNWIND_INFO和UNWIND_CODE。
接下开始分析本案例中那个失败的堆栈表是如何产生的
堆栈表失败的位置,始于CallFN()。这个函数的代码是一个纯汇编的ASM文件。
ASM代码结构大概如下
CallFN proc pfn:QWORD, pReturnValue:QWORD, rettype:DWORD, pArgs:QWORD, nCountArgs:DWORD, pnArgTypes:QWORD
sub rsp, 18h
push r12
push r13
push r14
…
call QWORD PTR [rbp - 8h]
…
pop r14
pop r13
pop r12
ret 0
CallFN endp
编译后的指令为
CallFN proc pfn:QWORD, pReturnValue:QWORD, rettype:DWORD, pArgs:QWORD, nCountArgs:DWORD, pnArgTypes:QWORD
000000002363102D push rbp
000000002363102E mov rbp,rsp
0000000023631031 sub rsp,18h
0000000023631035 push r12
0000000023631037 push r13
0000000023631039 push r14
…
0000000023631192 call qword ptr [rbp-8]
…
00000000236311C2 pop r14
00000000236311C4 pop r13
00000000236311C6 pop r12
00000000236311C8 leave
00000000236311C9 ret
在这个例子中,编译器为函数自动生成了入口和出口的指令
入口处的
push rbp
mov rbp, rsp
出口处的
leave
(相当于)
mov rsp, rbp
pop rbp
似乎在x64下,编译器将ASM文件仍然按照x86的风格补充了函数出入口处的指令,并没有按照我们期待的x64风格。其中rbp仍然被当成了栈帧信息寄存器,即便如此,调试器也无法按照约定识别出来。
所以,从CallFN往前的堆栈表,都无法正确识别出来。
由于这个ASM代码是从本项目中原来的x86代码中移植过来的,其中大量的使用ebp(rbp)寄存器去访问参数表(以及本地堆栈中的变量)。所以编译器可能将其识别成以rbp为栈帧信息的风格,进而自动补充一些rbp相关的指令。
另外,这个ASM代码中,rdi被当成普通的寄存器使用,特别是在rep movsq指令中作为参数使用。
可能有人会问,为什么要在C++项目里,嵌入这个ASM代码呢?
这是因为这个大型软件有二次开发的需求。特别是在OC语言中,需要调用各种DLL库。其中,这个技术的具体实现,需要根据参数数量,动态压栈来完成。而在C++中,用动态参数压栈的方法,去调用外部DLL库,是非常困难的。但是,在ASM代码中,通过对rsp直接修改的方法,就可以很简单的实现。
综合各种分析,我们得出结论,即仍然需要将ASM代码按照x86风格编译,避免过度修改原有代码。但是,由此引发的问题是,如何让调试器正确的识别这个x86风格的编译结果呢?
在对x64架构下ASM技术的各种文档的研究中,我们找到了一个解决方案。
我们需要在PE结构文件中,加入UNWIND_INFO和UNWIND_CODE。从而使这个ASM函数被认为成Unwindable ASM function。
其中关键的代码为
.pushreg rbp ; encode unwind info
.setframe rbp, 0 ; encode frame pointer
完成后编译出来的DLL文件,包含了UNWIND_INFO,通过这个命令可以查看。 link -dump -unwindinfo OCallFN64d.dll
00000000 0000102D 000011CA 0007CD40 CallFN
Unwind version: 1
Unwind flags: None
Size of prologue: 0x04
Count of codes: 2
Frame register: rbp
Frame offset: 0x0
Unwind codes:
04: SET_FPREG, register=rbp, offset=0x00
01: PUSH_NONVOL, register=rbp
这个包含了UNWIND信息的DLL函数,能被调试器完美的识别,并正确的恢复完整堆栈表。
可以看到,这个信息表中,rbp被指定为栈帧寄存器(Frame register),这是个非常重要的信息,也是解决问题的关键。
接下来,我把修改前后的ASM代码列出
修改前的ASM代码
CallFN proc pfn:QWORD, pReturnValue:QWORD, rettype:DWORD, pArgs:QWORD, nCountArgs:DWORD, pnArgTypes:QWORD
;function body
CallFN endp
修改后的ASM代码
NewPrologue64 MACRO procname, flags, parambytes, localbytes, reglist, userparms
push rbp
.pushreg rbp
mov rbp, rsp
.setframe rbp, 0
.endprolog
EXITM %localbytes
ENDM
NewEpilogue64 MACRO procname, flags, parambytes, localbytes, reglist, userparms
leave
ret 0
ENDM
OPTION PROLOGUE:NewPrologue64
OPTION EPILOGUE:NewEpilogue64
CallFN proc frame pfn:QWORD, pReturnValue:QWORD, rettype:DWORD, pArgs:QWORD, nCountArgs:DWORD, pnArgTypes:QWORD
;function body
CallFN endp
OPTION PROLOGUE:PrologueDef
OPTION EPILOGUE:EpilogueDef
另外,要稍微说明一下,在我的环境里,OPTION EPILOGUE有问题,所以我就只能关闭OPTION EPILOGUE,并且手工添加结束代码,如果读者没有遇到这个问题,可以忽略。
OPTION EPILOGUE:None
CallFN proc frame pfn:QWORD, pReturnValue:QWORD, rettype:DWORD, pArgs:QWORD, nCountArgs:DWORD, pnArgTypes:QWORD
;...
leave
ret 0
CallFN endp
OPTION PROLOGUE:PrologueDef
OPTION EPILOGUE:EpilogueDef
至此,我在本案例中遇到的问题,已经得到解决。
其中各种技术细节的研究,花费了大量的时间和精力。而解决办法往往是非常简略的。
关于编译器各种技术规范,我仍然不是十分的了解,因为现代编译器基本已经隐藏了汇编层面的各种细节了。我们很少需要深入研究底层的技术问题。一般都是遇到问题,才去寻找解决问题的方法。而且,往往是已经解决了问题,仍然有很多弄不懂的东西。
关于x64下的asm结构,以及上述我用来解决本案例的技术细节,可以参考
http://www.codemachine.com/article_x64deepdive.html
http://lallouslab.net/2016/01/11/introduction-to-writing-x64-assembly-in-visual-studio/
上一篇: vue动态修改div、table...元素高度宽度
下一篇: element table高度自适应