欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

在x64架构下混合C++和ASM编程,堆栈调试器信息错误的问题

程序员文章站 2022-06-17 14:48:28
...

本文讨论一个调试C++和ASM混合代码的技术案例。其中涉及到PE文件结构中UNWIND_INFO和UNWIND_CODE等概念。


背景说明:
我在一个大型软件的开发调试过程中,遇到堆栈信息错误的问题。这个大型软件基于C++语言,支持二次开发,提供了数量若干的DLL库,并且内置了Origin C语言(简称OC)。通过OC语言可以访问内部或第三方的DLL库。具体问题就出现在某个OC代码调用内部DLL库时候,如果在调试器中查看堆栈,则无法显示正确。编译和调试工具为MSVC。

有问题的堆栈显示为:
在x64架构下混合C++和ASM编程,堆栈调试器信息错误的问题

正常的堆栈显示为:
在x64架构下混合C++和ASM编程,堆栈调试器信息错误的问题


由于这个问题在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/

相关标签: 调试器 ASM x64