调试实战 | 永远不要忽略编译警告:记一次由重复switch语句导致的诡异崩溃

摘要

本文记录并剖析了一次由看似低级的代码错误引发的、令人意想不到的程序崩溃。问题的根源在于 GetErrorStr 函数中一个容易被忽略的重复 switch语句。这个错误导致函数返回的 std::wstring 对象未被正确初始化,最终在构造 CResult 对象时引发了空指针访问异常 。

本文通过深入分析崩溃调用栈和反汇编代码,清晰地总结了从函数异常返回(未初始化字符串)到调用端使用无效数据(触发崩溃)的完整过程。希望这个案例能提醒各位,编译警告是发现潜在风险的第一道防线,而基础的汇编知识则是深入调试的利器。文末有可复现问题的代码,欢迎动手实践。

缘起

好久没写文章了,一个是懒,一个是没有好的素材。最近在研发过程中遇到了几个崩溃问题,挺有意思的,值得总结。今天要总结的问题比较低级,而且编译的时候会报警告,但还是挺有教育意义的。一起看看吧。

示例代码

以下是我精简整理后的模拟代码,大家可以先锻炼一下眼力,看看是否可以一眼看出问题所在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <string>

class CResult
{
public:
CResult(const std::wstring& data_)
: data(data_)
{
}

protected:
std::wstring data;
};

int DoModify()
{
// a lot logic code, return error code
return 1;
}

std::wstring GetErrorStr(int error)
{
switch (error)
switch (error)
{
case 0:
return L"Ok";
case -1:
return L"Invalid Param";
case 1:
return L"Operation Failed";
// a lot more other cases
default:
return L"N/A";
}
}

int main()
{
auto modifyResult = DoModify();
CResult result(GetErrorStr(modifyResult));
}

初遇错误

优化完代码后,运行突然崩溃了,简单查看后,是非常典型的空指针异常。

nullptr-exception-callstacks

心想这不是小菜一碟吗?空指针异常?老朋友了!但是简单翻看调用栈,好像不是普通的空指针异常,在构造字符串对象的时候抛出了异常(上图红色高亮部分调用栈)。难道字符串出问题了?当时脑子里就一句话:字符串能出什么问题???

但是通过调试查看,好像还真是字符串出问题了,如下图:

string-error

既然是字符串出问题了,那就看下字符串的来源,来自函数 std::wstring GetErrorStr(int error) 的返回值。查看这个函数的实现。真是不看不知道,一看吓一跳!怎么有两行一样的 switch(error) ?这代码肯定是不对的,删掉一行,再次运行,果然没问题了。

为什么有重复的 swtich 就会崩溃呢?源码猜不出来,那就看看反汇编代码吧。

查看反汇编

反汇编代码如下,红色高亮部分是第一个 switch 对应的代码,什么有意义的事情都没做,直接跳转到函数结尾00007FF7800F3A34 了。

disassembly-code-of-geterrorstr

从图中反汇编代码可知,GetErrorStr() 没对字符串对象做任何初始化操作。那么调用端呢?是否初始化了字符串对象呢?我们接着看一下调用端的反汇编代码,从下图中可知,调用函数也没有初始化字符串对象。

disassembly-code-of-geterrorstr

综上,该字符串对象没有被初始化,其内容完全是随机的。

至此,此次崩溃问题已经很清楚了。但是,这种低级错误,按理说应该有警告才对啊?!

编译警告

再次编译,果然有警告,当时我应该忽略了,看来一定要注意编译警告!

compile-warning

汇编小知识

简单解释以上汇编代码,对于刚接触汇编的小伙伴会有帮助。先了解一些基本知识:

  1. x64 下的默认调用约定是 stdcall,前四个参数(非浮点型)通过 rcx rdx r8 r9 传递
  2. 对于 std::wstring GetErrorStr(int error) 这种返回复杂类型(非 POD 类型)的函数,生成的汇编代码相当于 std::wstring* GetErrorStr(std::wstring*, int error)第一个参数 指向了返回值的地址
  3. 调用结束后,返回值会保存在 rax

所以,上图中第二句汇编代码 00007FF7800F3924 mov qword ptr [rsp+8],rcx 把字符串对象地址保存到了 rsp+0x8 的位置。接下来的几句汇编指令把 rsp 向下移动了 0x8 + 0x8 + 0x108 = 0x118

1
2
3
4
00007FF7800F3924  mov         qword ptr [rsp+8],rcx  
00007FF7800F3929 push rbp
00007FF7800F392A push rdi
00007FF7800F392B sub rsp,108h

接下来的一条汇编指令 00007FF7800F3932 lea rbp,[rsp+20h] 使 rbp 指向了 rsp+0x20 的位置,那么 rbp+0x100 就指向了 第一个参数的地址。

亲自动手

相关工程代码已经上传到 github 了,感兴趣的小伙伴儿可以下载验证。

总结

  • 务必要关注编译器的警告
  • 掌握汇编知识非常重要,尤其对调试更是如此
  • 64 位程序默认的调用约定是 stdcall,前四个参数(非浮点型)通过 rcx rdx r8 r9 传递
BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%