目录
1、概述
2、从汇编的角度去理解问题的若干实例说明
2.1、使用空指针去访问类的数据成员或调用类的虚函数为什么会引发崩溃?
2.2、从汇编代码的角度去理解多线程的执行细节,去理解多线程在访问共享资源时为什么要加锁
2.3、使用Windbg静态分析dump时先从崩溃的那条汇编指令中得到初步的线索
3、了解汇编有哪些具体的好处?
3.1、在代码中插入汇编代码块,提升代码的执行效率
3.2、在分析C++软件异常时可能需要查看汇编代码
3.3、从汇编代码的角度可以理解很多高级语言没法理解的代码执行细节
3.3.1、函数调用时主调函数是如何将参数传递给被调用函数的
3.3.2、 从汇编代码的角度可以很好地理解多线程编程中的代码执行细节
3.3.3、Switch...case语句中case分支过多引发Stack Overflow线程栈溢出问题
4、C/C++程序员如何学习汇编?
4.1、常用汇编指令
4.2、常用寄存器的用途
4.2.1、EAX寄存器
4.2.2、ECX寄存器
4.2.3、ESI和EDI寄存器
4.2.4、ESP和EBP寄存器
4.2.5、EIP寄存器
4.2.6、段寄存器
4.2.7、标志寄存器EFlags
4.3、函数调用时栈分布情况
4.3.1、函数调用的栈分布
4.3.2、关于call指令和ret指令的说明
4.3.3、查看函数调用时的汇编代码,看函数调用堆栈分布实例
4.4、熟悉虚函数调用的汇编实现
4.4.1、虚函数调用过程中的两次寻址
4.4.2、虚函数调用的汇编代码走读
4.5、可以在VS中调试代码时查看C++代码对应的汇编代码
5、使用IDA查看二进制文件的汇编代码
6、如何看懂汇编代码上下文?
7、最后
C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931C/C++实战专栏(专栏文章已更新400多篇,持续更新中...)https://blog.csdn.net/chenlycly/article/details/140824370C++ 软件开发从入门到精通(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_12695902.htmlVC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585C++软件分析工具从入门到精通案例集锦(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/article/details/131405795开源组件及数据库技术(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_12458859.html网络编程与网络问题分享(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_2276111.html 最近在技术群中有人问,C++程序员要不要了解汇编,要不要系统地学习一下汇编,当时大家进行了深入的讨论与交流,我在群里也给出了我的理解与建议。今天就来详细讲一讲这个话题,讲一讲C++程序员有没有必要了解汇编,了解汇编都有哪些具体的好处,以及如何学习汇编。
1、概述
C/C++作为高级语言,在执行效率方面,是众多语言中最接近汇编语言的,这是很多开发语言难以匹敌的优势。C/C++代码经过编译链接后最终生成包含二进制机器码的二进制文件,C/C++程序最终执行的是二进制文件中的机器码。C/C++程序运行时,将二进制文件中的机器码逐一读入到CPU中执行。汇编代码是二进制机器码的助记符,相当于CPU中执行的是一条条汇编代码。
在不同架构的CPU平台中,汇编指令是有很大差异的,比如Intel的X86架构CPU与ARM架构的CPU,他们的汇编指令名称以及寄存器名称有着很大的不同。
一句C/C++代码对应着若干条汇编指令,C/C++程序在CPU中执行的是一条一条汇编指令,汇编代码最能反映程序的执行细节与步骤的。
通过线上讨论、线下对周边同事的观察以及技术培训时的反馈得知,大部分C/C++程序员都不了解汇编,或者不懂汇编,这与工作中几乎用不到汇编有很大的关系。大家平时使用C/C++高级语言编程,基本用不到汇编,但不用汇编,不代表我们不需要去了解汇编!
其实,C/C++程序员很有必要去了解汇编,了解汇编不仅可以很好地搞清楚高级语言代码难以理解的代码执行细节,还可以去辅助排查C/C++程序在运行过程中遇到的多种异常崩溃问题。我们在学习汇编时,要尽量将汇编与日常工作结合起来,从汇编的角度去理解工作中遇到的一些问题,必要时查看汇编代码上下文去辅助排查C/C++软件异常问题。通过日常实践,逐渐熟悉汇编,加深对汇编的认识与理解。
学习某些技术知识或技能,最终是为实践服务的,要主动的将所学东西应用到工作实践中去理解或者解决问题,去获取更进一步的理解与认知。
要将学到的东西应用到工作实践中,工作实践也会促进更进一步的学习与认知,二者相辅相成,相互促进!
2、从汇编的角度去理解问题的若干实例说明
下面我们来讲几个从汇编角度去理解问题的简单实例,以说明了解汇编的好处。
2.1、使用空指针去访问类的数据成员或调用类的虚函数为什么会引发崩溃?
使用空指针(假设指针的类型为某个C++类指针)去访问类对象中的成员变量(数据成员),或者使用该指针去调用类的虚函数,大家都知道这会导致程序发生异常崩溃。大家把这当成一条规则去记忆,而导致崩溃的具体原因,很多人其实是不太清楚的。
C/C++程序的异常,大多是与内存相关的,应该从内存的角度去看问题,而程序执行时最终执行的是程序二进制文件中的汇编代码,汇编代码中访问了内存,所以要搞清楚具体原因,要从汇编代码的角度去看。
对于使用空指针放为类对象的成员变量(访问变量,就是读取变量内存中的值,或者向变量内存中写入内容),因为类对象的首地址为0(NULL),那么该类对象中的成员变量的内存地址就是相对类对象首地址的偏移,因为类对象首地址为0,所以要访问的成员变量的内存地址就很小,所以就会出现访问很小内存地址的情况,就会触发内存访问违例,引发程序崩溃。至于小地址内存区,下面的内容会讲到,我就不在此赘述了。
对于使用空指针去调用虚函数为什么会引发异常,需要从汇编代码的角度去看虚函数调用的两次寻址:(虚函数的调用,要到虚函数表中找到虚函数的代码段地址,然后去call这个虚函数)
1)第一次寻址:根据当前类对象地址,得到虚函数表指针变量的首地址,从内存中读取该虚函数表指针的值,就是虚函数表的首地址,这样就找到虚函数表了。
2)第二次寻址:根据虚函数在类中排列的先后顺序,确定目标虚函数在虚函数表中的偏移,到虚函数表中找到存放目标虚函数的位置(该位置中存放目标虚函数的代码段的地址),从该内存中读出虚函数代码段的地址,然后去call这个代码段的地址就完成虚函数调用了。
此处需要搞清楚数据段地址与代码段地址的区别:
1)数据段地址:数据段内存主要用于存放变量数据的,即变量占用的内存地址都属于数据段地址。
2)代码段地址:代码段地址是二进制代码的地址。程序启动时,会将主程序及其依赖的底层模块的二进制文件加载到进程空间中,这些二进制文件中存放的是要执行的二进制机器码(程序运行时执行都是这些二进制机器码),每条二进制机器码都有对应的地址,即代码段地址。
这样,我们就能知道使用空指针调用虚函数发生崩溃的原因了,正是在第一次寻址时,虚函数指针变量的首地址就是类对象的首地址,因为是空指针,所以类对象首地址为0,所以虚函数表指针变量的首地址也是0,这样在读取虚函数表指针中的值(访问该指针变量的内存中的值)时就出现了访问小地址内存区的问题,产生了内存访问违例,产生崩溃。
关于虚函数调用的两次寻址的详细图文说明,可以查看我的文章:
几秒读懂C++虚函数调用的汇编代码实现https://blog.csdn.net/chenlycly/article/details/121046234
2.2、从汇编代码的角度去理解多线程的执行细节,去理解多线程在访问共享资源时为什么要加锁
此处我们举一个《Windows核心编程》书中典型实例(在该书的第8章第1节中),要完全理解这个实例,需要从汇编代码的角度去看。
这个例子中定义了一个long型的全局变量,然后创建了两个线程,线程函数分别是ThreadFunc1和ThreadFunc2,这两个线程函数中均对g_x变量进行自加操作(在访问共享变量g_x时未加锁同步),相关代码如下:
// define a global variablelong g_x = 0; DWORD WINAPI ThreadFunc1(PVOID pvParam){ g_x++; return 0;} DWORD WINAPI ThreadFunc2(PVOID pvParam){ g_x++; return 0;}
这里有个问题,当这两个线程函数执行完后,全局变量g_x的值会是多少呢?一定会是2吗?
实际上,在两个线程函数执行完后,g_x的值不一定为2。这个实例需要从汇编代码的角度去理解,从C++源码看则很难搞懂,这是一个从汇编代码角度去理解代码执行细节的典型实例。
熟悉汇编代码,不仅可以辅助排查C++软件异常,还可以理解很多高级语言无法理解的代码执行细节。
有些人可能觉得,代码中就是一个自加的操作,一下子就执行完了,中间应该不会被打断。会不会被打断,其实要看汇编代码的,因为CPU中最终执行的是一条一条汇编代码。这行实现自加的C++源码(g_x++)对应三行汇编代码,如下:
MOV EAX, [g_x] // 将g_x变量的值读到EAX寄存器中INC EAX // 将EAX中的值执行自加操作MOV [g_x], EAX // 然后将EAX中的值设置到g_x变量内存中
CPU在执行某条汇编指令时不会被打断(汇编指令是CPU执行的最小粒度),但3条汇编指令,指令与指令之间是可能被打断的。
为什么说两个线程执行完成后g_x变量的值是不确定的呢?比如可能存在下面的两种场景:
1)场景1(最终结果g_x=2)
假设线程1先快速执行了三行汇编指令,未被打断,g_x的值变成1。然后紧接着线程2执行,在g_x=1的基础上累加,最终两个线程执行完后,g_x等于2。
2)场景2(最终结果g_x=1)
假设线程1先执行,当执行完前两条汇编指令后,线程1失去时间片(线程上下文信息保存到CONTEXT结构体中):
即线程1前两条汇编指令执行完,第3条汇编指令还没来得及执行,就失去CPU时间片了!
线程2执行,一次执行完三条指令,当前g_x=1。然后线程1获得CPU时间片,因为上次执行两条汇编指令后EAX寄存器中的值为1,因为线程1获取了时间片,保存线程上下文信息的CONTEXT恢复到线程1中,EAX=1,继续执行第3条指令,执行完后g_x还是1。
所以,这个多线程问题,需要从汇编代码的角度去理解,从C++源码的角度则很难想明白。
从本例可以看出,即使是简单的变量自加操作,多线程操作时也要做同步,可以加锁,可以使用系统的原子锁Interlocked系列函数,比如原子自加函数InterlockedIncrement和原子自减函数InterlockedDecrement:
LONG InterlockedIncrement( LPLONG volatile lpAddend // variable to increment); LONG InterlockedDecrement( LPLONG volatile lpAddend // variable address);
这些原子函数能保证会被原子地被执行,中间不会被打断。 修改后的代码为:
// define a global variablelong g_x = 0; DWORD WINAPI ThreadFunc1(PVOID pvParam){ InterlockedIncrement(&g_x); // 调用原子锁函数InterlockedIncrement实现自加 return 0;} DWORD WINAPI ThreadFunc2(PVOID pvParam){ InterlockedIncrement(&g_x); // 调用原子锁函数InterlockedIncrement实现自加 return 0;}
如果单单从C/C++代码去看,很难理解为什么会有问题,因为看不到多线程的执行细节!但从汇编代码的角度就能看到完整的执行细节,就能真正地理解这个问题。该实例是从汇编代码的角度去理解很多高级语言没法理解的代码执行细节的典型实例,虽然问题很简单,但很能说明问题。关于上述问题的详细说明,可以查看我的文章:
从C++软件调试实战的角度去看多线程编程中的若干细节问题https://blog.csdn.net/chenlycly/article/details/134358655 之前我们在排查软件崩溃问题时,遇到过几次多线程同时访问一个STL列表导致的崩溃。究其原因,主要有两点,一是当时的开发人员水平与经验不足,二是这个崩溃问题不是必现的,只是偶现的(多线程访问共享资源的冲突偶尔才会出现)。对于多线程访问STL列表未加锁导致的软件崩溃的问题分析实例,可以查看我之前写的文章:
C++程序使用 STL 容器发生异常的常见原因分析与总结https://blog.csdn.net/chenlycly/article/details/136991353
在这里,给大家重点推荐一下我的几个热门畅销专栏,欢迎订阅:(博客主页还有其他专栏,可以去查看)
专栏1:(该精品技术专栏的订阅量已达到520多个,专栏中包含大量项目实战分析案例,有很强的实战参考价值,广受好评!专栏文章持续更新中,预计更新到200篇以上!欢迎订阅!)
C++软件调试与异常排查从入门到精通系列文章汇总https://blog.csdn.net/chenlycly/article/details/125529931
本专栏根据多年C++软件异常排查的项目实践,系统地总结了引发C++软件异常的常见原因以及排查C++软件异常的常用思路与方法,详细讲述了C++软件的调试方法与手段,以图文并茂的方式给出具体的项目问题实战分析实例(很有实战参考价值),带领大家逐步掌握C++软件调试与异常排查的相关技术,适合基础进阶和想做技术提升的相关C++开发人员!
考察一个开发人员的水平,一是看其编码及设计能力,二是要看其软件调试能力!所以软件调试能力(排查软件异常的能力)很重要,必须重视起来!能解决一般人解决不了的问题,既能提升个人能力及价值,也能体现对团队及公司的贡献!
专栏中的文章都是通过项目实战总结出来的,包含大量项目问题实战分析案例,有很强的实战参考价值!专栏文章还在持续更新中,预计文章篇数能更新到200篇以上!
专栏2:(本专栏涵盖了C++多方面的内容,是当前重点打造的专栏,订阅量已达160多个,专栏文章已经更新到400多篇,持续更新中...)
C/C++实战进阶(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_11931267.html
以多年的开发实战为基础,总结并讲解一些的C/C++基础与项目实战进阶内容,以图文并茂的方式对相关知识点进行详细地展开与阐述!专栏涉及了C/C++领域多个方面的内容,包括C++基础及编程要点(模版泛型编程、STL容器及算法函数的使用等)、数据结构与算法、C++11及以上新特性(不仅看开源代码会用到,日常编码中也会用到部分新特性,面试时也会涉及到)、常用C++开源库的介绍与使用、代码分享(调用系统API、使用开源库)、常用编程技术(动态库、多线程、多进程、数据库及网络编程等)、软件UI编程(Win32/duilib/QT/MFC)、C++软件调试技术(排查软件异常的手段与方法、分析C++软件异常的基础知识、常用软件分析工具使用、实战问题分析案例等)、设计模式、网络基础知识与网络问题分析进阶内容等。
专栏3:
C++常用软件分析工具从入门到精通案例集锦汇总(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/article/details/131405795
常用的C++软件辅助分析工具有SPY++、PE工具、Dependency Walker、GDIView、Process Explorer、Process Monitor、API Monitor、Clumsy、Windbg、IDA Pro等,本专栏详细介绍如何使用这些工具去巧妙地分析和解决日常工作中遇到的问题,很有实战参考价值!
专栏4:
VC++常用功能开发汇总(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/article/details/124272585
将10多年C++开发实践中常用的功能,以高质量的代码展现出来。这些常用的高质量规范代码,可以直接拿到项目中使用,能有效地解决软件开发过程中遇到的问题。
专栏5:
C++ 软件开发从入门到精通(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_12695902.html
根据多年C++软件开发实践,详细地总结了C/C++软件开发相关技术实现细节,分享了大量的实战案例,很有实战参考价值。
2.3、使用Windbg静态分析dump时先从崩溃的那条汇编指令中得到初步的线索
程序发生崩溃,基本是崩溃在某条具体的汇编指令上,查看这条发生崩溃的汇编指令或者这条汇编指令的上下文,就能搞清楚引发崩溃的最直接原因。
比如发生崩溃的汇编指令中访问了很小的内存地址:
在Windows中0 ~ 64KB地址范围的小地址内存区域是禁止访问的,称为空指针内存区,是专门用来辅助程序员定位空指针问题的。当访问了该小地址内存区,就会触发内存访问违例,程序就会产生异常崩溃。
再比如发生崩溃的那条汇编指令中访问了一个很大的内存地址:
假设当前程序是32位的,程序启动时系统会给程序分配4GB虚拟地址空间(32位程序按照32位寻址,所以分配了4GB的虚拟内存):
默认情况下用户态占用2GB(地址范围:0x00000000 - 0x7FFFFFFF),内核态占用2GB(地址范围:0x80000000 - 0xFFFFFFFF)。如果发生崩溃的汇编指令中访问了一个很大的内存地址,比如0xDCF88760,很明显这个内存地址属于内核态的,而我们的程序业务模块是运行在用户态内存中的,用户态的代码是禁止访问内核态内存的,否则会产生内存访问违例,产生异常崩溃的。
我们在用Windbg静态分析dump文件去排查软件异常崩溃问题时,首先就是查看发生异常崩溃的这条汇编指令,看看汇编指令是否访问了一个很小或很大的内存地址,从这点可能得到一些初步的线索:
1)如果访问了一个很小的内存地址,则可能是访问空指针导致的。我们在项目中多次遇到这样的问题。
2)如果访问了一个很大的内存地址,则可能是内存越界导致相关变量的内存被窜改导致的。
3)如果访问了一个正常的内存地址:
则可能是访问了野指针导致的。所谓野指针,就是指针的值指向的内存区域已经被释放了,但指针的值没有置为NULL,导致后续代码又访问到这块内存区域,进而产生了异常。
如果通过查看发生崩溃的汇编指令,得到一个初步的线索或者我们假定的可能原因,结合崩溃时的函数调用堆栈,到C/C++代码中去查看相关代码的上下文去定位问题。
我之前根据项目遇到的问题实例,写了一篇通过查看崩溃的汇编指令得到线索去快速定位问题的实战案例文章,感兴趣可以去查看:
根据发生异常的汇编指令,估计出问题的原因,确定排查方向,快速定位问题https://blog.csdn.net/chenlycly/article/details/141869579 关于使用Windbg静态分析dump文件的详细步骤,可以查看我的文章:
使用Windbg分析dump文件的一般步骤及要点详解https://blog.csdn.net/chenlycly/article/details/130873143 除了使用Windbg静态分析dump文件,有时还需要使用Windbg调试目标进程,关于使用Windbg动态调试目标程序的一般步骤,可以查看我的文章:
使用Windbg调试目标进程的一般步骤及要点详解https://blog.csdn.net/chenlycly/article/details/131029795
3、了解汇编有哪些具体的好处?
上面讲到了从汇编代码的角度去理解问题的几个具体实例,也充分说明了了解汇编的重要性。此处我们还是来系统地总结一下学习汇编都有哪些好处。
3.1、在代码中插入汇编代码块,提升代码的执行效率
一般我们会在一些对执行效率要求比较高的代码中嵌入汇编代码,提高代码的执行效率,汇编代码的执行效率是最高的。比如我们在处理音视频编解码的算法代码中,时常会嵌入一些汇编代码,以提高代码的运行速度,比如音视频编解码模块负责色彩空间转换的接口都是汇编代码实现的(汇编代码实现的函数是开源的),如下:
有人可能会问,经过IDE编译出来的二进制文件中也都是汇编指令,你人为的添加一段汇编代码,都是汇编代码,为啥会有执行速度上的差别呢?因为源代码经过编译器的处理生成的汇编代码在实现上可能不是最优的,这要依赖编译器,而编译器使用的都是一些通用的规则,很难保证在某些场景下是最优的。而我们人为地添加汇编,可以直接操控汇编代码,保证汇编代码是相对最优的。
3.2、在分析C++软件异常时可能需要查看汇编代码
一般程序是崩溃在某一条汇编指令上,汇编指令才能最直接反映为什么会发生崩溃。我们在分析异常崩溃时,一般先去查看发生异常的那条汇编指令,看看这条汇编指令是否有明显的异常。比如汇编指令中访问了一个很小的内存地址(64K地址范围内的地址是小地址内存区,禁止访问),如下:
或者一个很大的内核态地址(用户态的代码是不能访问内核态的内存地址的),如下:
上述情况都会出现内存访问违例,引发崩溃。
此外,有时通过崩溃时的函数调用堆栈及相关信息很难定位问题时,可以尝试去查看汇编代码上下文去辅助分析。
排查的C++软件异常与崩溃问题多了,就会知道汇编代码的好了,才会有深刻的理解和体会的!
3.3、从汇编代码的角度可以理解很多高级语言没法理解的代码执行细节
下面通过几个实例来说明如何从汇编代码的角度去看很多高级语言不好理解的代码执行细节。
3.3.1、函数调用时主调函数是如何将参数传递给被调用函数的
首先,我们来看函数调用时主调函数是如何将参数传递给被调用函数的。比如有如下的实现两个整型变量相加的函数AddSum,在调用该函数时传入的参数代码如下:
int AddNum( int a, int b){int nSum = a + b;return nSum;}void CTestDlgDlg::OnBnClickedBtnTest(){int a = 3;int b = 4;int nSum = AddNum( a, b );}
我们在调用AddSum接口是通过栈将要传递的参数传给被调用函数AddSum的,我们通过汇编代码可以清楚看到这一过程:(可以直接在Visual Studio中看到上述C++代码的汇编代码)
从上述汇编代码上我们清楚地看到,在call AddSum函数之前,把要传递的参数a和b都压到了栈上!
在Visual Studio中查看C++代码对应的汇编代码的方法:在要查看汇编代码的函数中添加断点,然后开启调试,在命中断点后,右键点击,在弹出的右键菜单中点击“查看反汇编”,即可查看汇编代码。
3.3.2、 从汇编代码的角度可以很好地理解多线程编程中的代码执行细节
再比如,我们可以从汇编代码的角度去很好地理解多线程编程中的细节问题,比如上面讲到的两个线程同时对一个long型变量进行自加操作的多线程问题,此处就不再赘述了。
3.3.3、Switch...case语句中case分支过多引发Stack Overflow线程栈溢出问题
还比如,之前讲到Switch...case语句中case分支过多引发Stack Overflow线程栈溢出问题时,虽然变量定义在case分支中,虽然生命周期在case分支中,但变量的栈内存在所在函数的入口处就已经分配了,可以通过汇编代码看出来。比如如下的case分支代码:
void TestCaseFunc(){int nType = 3;switch(nType){case 0:{//STARTUPINFO startInfo;//memset(&startInfo, 0, sizeof(startInfo));// ...break;}case 1:{SHELLEXECUTEINFO shellexecuteInfo;memset(&shellexecuteInfo, 0, sizeof(shellexecuteInfo));// ...break;}case 2:{STARTUPINFO startInfo2;memset(&startInfo2, 0, sizeof(startInfo2));// ...break;}case 3:{SHELLEXECUTEINFO shellexecuteInfo2;memset(&shellexecuteInfo2, 0, sizeof(shellexecuteInfo2));// ...break;}case 4:{STARTUPINFO startInfo4;memset(&startInfo4, 0, sizeof(startInfo4));// ...break;}}}
直接在Visual Studio中查看该函数的汇编代码:
可以新增或删减case分支,然后观察函数入口处分配的栈空间大小的变化!
在我们排查不出问题时,可以尝试着去查看汇编代码,甚至可以直接去调试汇编代码(可以直接在VS中跳转到汇编代码页面,去单步调试汇编代码)!
4、C/C++程序员如何学习汇编?
了解基本的汇编指令与常用寄存器的用途,了解函数调用时栈分布情况,熟悉常见代码块的汇编代码实现。
对于大部分C/C++程序员,我们主要使用汇编来理解问题,查看汇编代码去辅助排查软件异常,只要掌握一些基础的汇编知识即可。对于做安全领域的安全分析、做逆向分析研究的人,因为工作需要,则需要对汇编进行深入的学习。
另外,不能像上学时那样孤立地学习汇编,完全与工作实践割裂开来,是没有意义的,也是没有价值的。只有将所学技术知识应用到工作实践中,边学边与工作实践相结合,才更有价值,才能获取更进一步的理解和认知。
找一本汇编的书大概地学习一下常见的汇编指令,然后在工作中积极主动地去查看汇编熟悉汇编,多从汇编的角度去看问题、理解问题,通过实践去获取更进一步的理解和心得。以下以X86平台的内容进行展开。
4.1、常用汇编指令
X86汇编指令包括通用数据传送类指令、算术运算类指令、逻辑运算类指令、串指令、程序转移类指令和伪指令等几大类:
? 常见的数据传送类指令有mov、push、pop、lda等。
? 常用的算术运行类指令有add、sub、mul、div、inc、dec、cmp等。
? 常用的逻辑运算类指令有and、or、xor、not、test等。
? 常见的串指令则有movs、cmps、rep等。
? 常见的程序转移指令有jmp、jz、jnz、call、ret、loop等。
? 常见的伪指令有proc、assume、end等。
这些指令大概知道就行了,在阅读汇编代码时遇到不懂的或者不确定的,可以到网上查一下对应汇编指令的详细说明,比如这篇文章中就有对具体指令的详细说明:
汇编指令速查表(X86平台)https://blog.csdn.net/chenlycly/article/details/52235043 为了辅助记忆,也可以到下文中去查看一下常用汇编指令的英文全称:
汇编指令英文全称https://blog.csdn.net/chenlycly/article/details/52240792 另外,call指令和ret指令对理解一些关键汇编代码很重要,所以这个地方要单独拿出来讲一下。简单的说,call指令会跳转到指定的函数地址处执行,并将返回地址(主调函数中call指令的下一条指令)压入到栈上保存下来。
ret指令则是退出当前函数,并从栈中取出主调函数的返回地址(下一条指令)放到IP寄存器中,让代码返回到主调函数返回地址对应的位置继续向下执行。
CPU执行call指令和ret指令的具体过程如下:
?call指令:CPU 将call func指令的机器码读入,IP寄存器就指向了主调函数中的call func指令的下一条指令(主调函数调用被调函数时的返回地址,被调用函数返回后执行该返回地址处的汇编指令),然后CPU执行call func指令,将当前的IP寄存器的值压栈(返回地址压入栈中),并将IP寄存器值改变为标号func处的地址(即call指令中的函数地址);
? ret指令:CPU将ret指令的机器码读入,IP寄存器指向了ret 指令后的内存单元,然后CPU执行ret指令,从栈中弹出函数执行完后的返回地址(pop出栈操作会加esp),送入 IP寄存器中。然后再执行IP寄存器中的指令,即返回地址,即调用函数下面的下一条指令。
此处我们还要重点提下EIP寄存器。EIP寄存器中存放的是下一条即将被执行到的指令,当CPU将要执行的指令从IP寄存器中读到CPU中准备执行,IP寄存器中的地址会自动累加,自动累加的值就是CPU刚取走的那条指令的长度值,这样IP寄存器就指向下一条要执行的指令(IP寄存器中的值就是指令地址)。当CPU执行完当前指令后,就从EIP寄存器中读取下一条指令地址,执行下一条指令,这样代码就不断地执行下去。
注意,上面讲的地址均是代码段地址,和数据段的内存地址(变量的内存地址)是不同的。另外,上面讲的指令是二进制机器码,不是汇编代码,机器码和汇编代码有一一对应关系。比如我们在Visual Studio开发环境中可以看到如下的二进制机器码和汇编代码。
4.2、常用寄存器的用途
最初X86中主要使用32位寄存器,后来AMD公司率先搞出了64位的X86寄存器。X86 32位寄存器主要有以下几类:
? 4个数据寄存器:EAX、EBX、ECX和EDX;
? 2个变址和指针寄存器:ESI和EDI;
? 2个指针寄存器:ESP和EBP;
? 1个指令指针寄存器:EIP;
? 6个段寄存器:ES、CS、SS、DS、FS和GS;
? 1个标志寄存器:EFlags。
在X86-64寄存器中,所有寄存器都是64位,相对32位的x86寄存器来说,标识符发生了变化,比如从原来的ebp变成了rbp。为了保持兼容性,32位寄存器都可以继续使用,比如ebp寄存器,不过指向了rbp的低32位。X86-64的64位寄存器主要有以下几类
? 64位下有16个寄存器:rax、rbx、rcx、rdx、esi、edi、rbp、rsp、r8、r9、r10、r11、r12、r13、r14、r15。
? 6个传参寄存器:依次为:rdi、rsi、rdx、rcx、r8、r9
因为32寄存器比较多见,本文讲解的问题中使用的都是32位寄存器,下面我们主要介绍一下32寄存器在C++汇编代码中的一些用途,了解这些寄存器的常用用途之后,对于阅读汇编代码很重要。
有些人可能会有疑问,讲X86 32位寄存器是不是没什么意义了,现在用的应该都是64位寄存器了吧?在Windows系统中,C++编写的应用程序为了兼容32位操作系统,还是使用32位编译器编译的,所以用的还是32位寄存器,所以介绍32寄存器还是有用的!
4.2.1、EAX寄存器
在X86汇编指令中,EAX主要用于存放函数调用的返回值,比如返回一个C++类指针地址的函数GetContainerPtr:
IContainerUI* GetContainerPtr(){ // ......}
在call这个函数指令执行完成后,返回的类对象地址就保存到EAX寄存器中了。
4.2.2、ECX寄存器
在C++类的成员函数中都掩藏一个保存当前C++对象地址的this指针,成员函数中访问所属C++对象的成员变量时,都是通过this指针访问对应C++对象的成员变量的。
在C++汇编代码中,在调用C++成员函数时会使用ECX寄存器用来传递C++对象地址,即C++对象中的this指针。
4.2.3、ESI和EDI寄存器
这两个是变址寄存器,ESI是源地址寄存器,EDI是目的地址寄存器,主要用于内存拷贝的串操作指令中,比如memcpy的汇编实现中。它们也可以作为通用寄存器来使用。
4.2.4、ESP和EBP寄存器
ESP是栈顶地址寄存器,EBP是栈基址寄存器,主要用来存放当前运行到的函数的栈起始地址(栈基址)和栈顶地址。
下图是A函数调用B函数的栈分布图,可以看到B函数的栈基址和栈顶地址,当代码运行到B函数中时,B函数的栈基址就保存到EBP中,B函数的栈顶地址就保存在ESP寄存器中:
该图是函数调用时的大体栈分布图,这个图后面我们会详细说明,此处就不详细展开了。
EBP和ESP这两个寄存器很重要,函数中的局部变量都是在函数所占用的栈空间地址范围中分配的,在函数的汇编代码中,函数的局部变量要么通过EBP来寻址,要么是通过ESP来寻址。
此外,函数调用堆栈的栈回溯正是通过EBP寄存器来实现的。对于栈回溯的原理,感兴趣的可以看看这篇文章:
C++栈回溯原理(C++异常排查面试题)https://blog.csdn.net/chenlycly/article/details/121002139
4.2.5、EIP寄存器
EIP寄存器是用来存放即将要执行的汇编指令地址的。这里讲的汇编地址,是代码段的地址,和我们平时说的变量占用的内存(数据段地址)是两个概念,要注意区分一下,不要混淆。
当CPU从EIP寄存器中将汇编指令地址载入到CPU中时,EIP寄存器中的地址会自动累加,累加的值正好就是被取走的那条汇编指令的长度,这样EIP寄存器中的地址就是即将要执行的下一条汇编指令的地址了。
后面在讲到call指令和ret指令时,也会讲到EIP寄存器。
4.2.6、段寄存器
常用的段寄存器有CS、SS、DS、ES、FS和GS,这里我们简单讲述一下这些段寄存器的用途。
? 代码段寄存器CS(Code Segment)
存放当前正在运行的程序代码所在段的段基址,表示当前使用的指令代码可以从该段寄存器指定的存储器段中取得,相应的偏移量则由EIP寄存器提供。
? 数据段寄存器DS(Data Segment)
指出当前程序使用的数据所存放段的最低地址,即存放数据段的段基址。
? 堆栈段寄存器SS(Stack Segment)
指出当前堆栈的底部地址,即存放堆栈段的段基址。
? 附加段寄存器ES(Extra Segment)
指出当前程序使用附加数据段的段基址,该段是串操作指令中目的串所在的段。
? FS和GS辅助段寄存器
FS和GS是80386起增加的两个辅助段寄存器。FS段寄存器Windows用来存储一些进程信息,FS段的首地址是存储这些进程信息的首地址,在内核态FS指向GDT表的0x30地址,在用户态FS等于0x3B。也就是说,当切换到用户态时,操作系统会把进程下正在执行的线程的某些信息写入到0X3B为起始地址的空间里。内核态时候也一样,操作系统也会写入一些关于内核程序的相关信息到0x30里。GS通常用作指向线程本地存储(TLS)的指针。
这些段寄存器我们只需要了解一下即可,不需要去深入追溯,汇编代码中会自动去维护这些段寄存器。
4.2.7、标志寄存器EFlags
标志寄存器主要是用来存放条件码标志,条件码标志则用来记录程序中运行结果的状态信息,它们是根据有关指令的运行结果由(CPU)自动设置的。由于这些状态信息往往作为后续条件转移指令的转移控制条件,所以称为条件码。
常见的标志位有:
? 进位标志CF(Carry Flag),记录运算时最高有效位产生的进位值。
? 符号标志SF(Sign Flag),记录运算结果的符号。结果为负时置1,否则置0。
? 零标志ZF(Zero Flag),运算结果为0时ZF位置1,否则置0。
? 溢出标志OF(Overflow Flag),在运算过程中,如操作数超出了机器可表示数的范围称为溢出。溢出时OF位置1,否则置0。
? 辅助进位标志AF(Auxliliary Carry Flag),记录运算时第3位(半个字节)产生的进位值。
? 奇偶标志PF(Parity Flag),用来为机器中传送信息时可能产生的代码出错情况提供检验条件。当结果操作数中1的个数为偶数时置1,否则置0。
4.3、函数调用时栈分布情况
了解一下C++函数调用时的栈分布,对深入理解C++函数调用机制及汇编代码是很有好处的。在了解了函数调用的栈分布之后,才能搞懂函数调用堆栈回溯的原理。
4.3.1、函数调用的栈分布
假设A函数调用B函数,B函数只有一个参数,函数调用时涉及到的入栈操作、栈底指针ebp和栈顶指针esp的处理如下图所示:
上述函数调用的大致过程为:先将传给B函数的参数入栈,接着调用Call指令(Call指令涉及两步:将返回地址(下条指令的地址)压入栈,即返回地址是Call指令自动压入到栈中的,然后jump到被调用的函数地址),然后保存主调函数A的栈基址ebp,以及保护现场需要的其他寄存器,进入到B函数。B函数调用完成后,将栈顶指针esp及其他寄存器值都pop出来,然后调用ret指令(将返回地址pop出来,然后jump到A函数中返回地址),最后将调用函数的参数栈清掉。
ebp - 函数栈基址寄存器,esp - 函数栈顶地址寄存器。函数占用的栈空间(地址范围)就在esp中的栈顶地址到ebp中的栈基址之间,函数的栈空间在函数入口处就进行分配了。
4.3.2、关于call指令和ret指令的说明
简单地说,call指令会跳转到指定的地址处执行,并将下一条接下来要执行的指令(返回地址)压到栈上;ret指令会退出当前函数,并从栈中取出下一条要执行的指令(返回地址)放到IP寄存器中,继续执行。
CPU执行call指令和ret指令的具体过程如下:
1)call指令:CPU 将call s指令的机器码读入,IP寄存器指向了call s后的指令(函数调用的返回地址),然后CPU执行call s指令,将当前的IP寄存器的值压栈(push压栈操作会减esp),并将IP寄存器值改变为标号s处的偏移地址(即call指令中的函数地址);
2)ret指令:CPU将ret指令的机器码读入,IP寄存器指向了ret 指令后的内存单元,然后CPU执行ret指令,从栈中弹出函数执行完后的返回地址(pop出栈操作会加esp),送入 IP寄存器中。然后再执行IP寄存器中的指令,即返回地址,即调用函数下面的下一条指令。
此外,EIP寄存器是用来存放下一个CPU指令的地址(代码段地址),当CPU执行完当前指令后,从EIP寄存器中读取下一条指令的内存地址,然后继续执行。
4.3.3、查看函数调用时的汇编代码,看函数调用堆栈分布实例
编写简单的C++代码,查看函数调用时的汇编指令调用情况。下面再main函数中调用Add函数实现两数相加:
然后在代码中设置断点,启动调试,然后命中断点后,可以右键点击要查看的源码处,在弹出的右键菜单中点击“转到反汇编”菜单项:(想查看哪一块的汇编代码,就右键点击那一块)
就可以跳转到对应的汇编代码中:
对照着最上面的函数调用分布图,仔细看一下函数调用相关的汇编代码,就很容理解了。
了解函数调用的栈分布之后,在排查字符串格式化相关的异常问题时,才能搞清楚问题所在,相关的实战分析案例,可以查看我的这些文章:
将string类对象中的内容格式化到字符串buffer中时遇到的异常崩溃分析https://blog.csdn.net/chenlycly/article/details/126211718排查格式化符与待格式化参数不一致导致的程序崩溃问题https://blog.csdn.net/chenlycly/article/details/135296984UINT64整型数据在格式化时使用了不匹配的格式化符%d导致其他参数无法打印的问题排查https://blog.csdn.net/chenlycly/article/details/132549186
4.4、熟悉虚函数调用的汇编实现
虚函数是C++中一个很重要的概念,虚函数也是实现多态的基础,C++代码中到处都在使用虚函数。要搞懂虚函数调用的汇编代码实现,这对于读懂C++汇编代码上下文很重要。
多态可以描述为,将一个子类的对象赋给一个父类的指针,然后使用父类的指针去调用虚函数,实际调用的是子类实现的虚函数。
4.4.1、虚函数调用过程中的两次寻址
多态中虚函数的调用需要到虚函数表中找到虚函数的地址(函数代码段地址),然后再去call这个虚函数,整个过程会涉及到两次寻址。
整个过程中涉及到几个概念:虚函数表指针变量和虚函数表。只要类中有虚函数或者父类中有虚函数,那这个类中就会掩藏一个虚函数表指针,该指针中存放的就是虚函数表的首地址。虚函数表就是一段连续的内存,其中存放的就是虚函数地址,即每个虚函数的代码段地址。拿到虚函数的首地址,直接去call这个地址,就可以完成虚函数调用了。
在虚函数调用的具体过程中,子类对象的首地址,就是子类对象的内存起始地址,而子类的虚函数表指针变量在所在对象的内存排布上位于类对象内存的第一位,所以子类对象的首地址,就是类中虚函数表指针的首地址,取出该首地址内存中的内容,就是虚函数表指针变量中的内容,就是虚函数表的首地址(代码段地址),这是第一次寻址。
此处需要搞清楚数据段地址与代码段地址的区别:
1)数据段地址:数据段内存主要用于存放变量数据的,即变量占用的内存地址都属于数据段地址。
2)代码段地址:代码段地址是二进制代码的地址。程序启动时,会将主程序及其依赖的底层模块的二进制文件加载到进程空间中,这些二进制文件中存放的是要执行的二进制机器码(程序运行时执行都是这些二进制机器码),每条二进制机器码都有对应的地址,即代码段地址。
拿到虚函数表的首地址,再根据虚函数在虚函数表中的偏移,计算出目标虚函数在虚函数表中的位置,将该位置内存中的内容取出,就是虚函数的首地址,这样我们就可以直接去call这个虚函数的地址,完成虚函数调用了,这是第二次寻址。
4.4.2、虚函数调用的汇编代码走读
以如下的C++代码为例:
// 定义类class CContact : public IContactPtr{ CContact(); ~CContact(); // ...... // 类中的虚函数func1 virtual func1(); // ......};// 获取IContactPtr类指针IContactPtr* GetContactPtr(){ // 此处return一个子类CContact对象地址}// 通过类指针调用类的虚函数func1GetContactPtr()->func1();
我们定义了一个继承于接口类IContactPtr的子类CContact,然后调用GetContactPtr接口获取一个父类的指针,指针中存放的是子类对象的地址,然后去调用虚函数func1。调用虚函数的func1的汇编代码如下:
先call函数 GetContactPtr获取CContact业务类指针,call完成后,CContact业务类指针
保存到eax寄存器中,下面来详细解释接下来的几句汇编代码:
(1)mov edx, [eax]
eax寄存器中存放的C++类对象首地址,该类对象首地址就是类中成员变量虚函数表指针变量的地址(虚函数表指针变量在内存排列上位于C++对象的首位),所以对eax取址得到的就是虚函数表指针变量中存放的虚函数表的首地址,将虚函数表的首地址存到edx寄存器中(第一次寻址)。
(2)mov ecx, [ebp+var_8]
将[ebp+var_8]内存中保存的C++类对象首地址再给到ecx寄存器中,是为了调用虚函数(C++类的成员函数IsExistLocalArchFile)传递类的this指针的,C++的汇编代码中是通过ecx寄存器传递this指针的。
(3)mov eax, [edx+140h]
目标虚函数在虚函数表中的偏移是140h,所以edx+140h就是目标虚函数在虚函数表中的内存地址,对edx+140h取址(即[edx+140h])得到的就是虚函数表中存放的目标虚函数的首地址(虚函数代码段的地址),然后将虚函数的首地址放置到eax寄存器中,接下来直接去call eax,就是去调用虚函数了。(第二次寻址)
至此,就完成了虚函数的调用。
所以,在阅读到类似于上面的汇编代码时,就要下意识地想到,可能是虚函数调用对应的代码块。如果不了解虚函数调用的汇编代码实现,很难读懂这块汇编代码的。
4.5、可以在VS中调试代码时查看C++代码对应的汇编代码
我们在学习汇编代码时,要将C++源码与汇编代码结合起来看。如果我们想搞清楚某段C++代码的汇编代码是什么样子的,可以直接调试代码,在调试代码时查看对应的汇编代码。
比如如下的C++源码:
然后在代码中设置断点,启动调试,然后命中断点后,可以右键点击要查看的源码处,在弹出的右键菜单中点击“转到反汇编”菜单项:(想查看哪一块的汇编代码,就右键点击那一块)
就可以跳转到对应的汇编代码中:
在正在调试的VS中查看C++源码,也是学习汇编代码的一种重要途径。如果要查看某些C++代码的汇编代码实现,完全可以在VS中创建一个测试工程,然后将要查看汇编代码的C++代码拷贝过来,编译后打断点调试运行,就可以查看了!
5、使用IDA查看二进制文件的汇编代码
一般是在我们用Windbg静态分析dump文件,需要借助汇编代码上下文去辅助分析时去查看汇编代码。我们主要使用反汇编工具IDA去打开相关模块的二进制文件去查看模块中的汇编代码上下文。关于IDA工具的介绍以及如何使用IDA去查看汇编代码上下文,可以查看我的文章:
反汇编工具IDA使用详解(使用IDA查看二进制文件的汇编代码以及使用IDA分析崩溃问题实例分享)https://blog.csdn.net/chenlycly/article/details/120635120使用反汇编工具IDA查看发生异常的汇编代码的上下文去辅助分析C++软件异常https://blog.csdn.net/chenlycly/article/details/132158574Relocations for this machine are not implemented,IDA版本过低导致打开二进制文件时生成汇编代码失败https://blog.csdn.net/chenlycly/article/details/135076536
6、如何看懂汇编代码上下文?
要去阅读汇编代码的上下文,要掌握一定的汇编基础知识,比如我们上面讲到的,了解一些常用寄存器的用途、熟悉一些常用的汇编指令、了解函数调用时的栈分布、了解C++虚函数调用的汇编代码实现(虚函数调用时的二次寻址)等。
但我们很多需要用到汇编的人其实对汇编没有深入了解(大部分C/C++程序员甚至一点不了解汇编,或者很难将汇编用起来),没有很深的汇编功底,没法直接去看汇编代码上下文,我们更多的是将C++代码与汇编代码对照起来一起看。必要时可以将二进制文件对应的pdb符号文件取过来,放在二进制文件同级目录中,IDA打开二进制文件时会自动到同级目录中去搜索对应的pdb符号文件,找到后会自动将pdb文件加载起来。加载到pdb文件后,会读取出pdb文件中的变量及函数的符号,在展示的汇编代码中会自动添加上一些注释:
我们正好借助这些注释,找到和C++源码的对应关系,看懂汇编代码的上下文。
此外,有时Relase版本的二进制文件中的汇编代码可能会和C++代码有一定的出入,不能完全对应起来,因为在Release下编译时编译器会对代码进行优化,比如将某个变量给优化调了,将某个函数调用给优化了(将函数优化掉,减少函数调用的开销)。这一点在对照C++源码查看汇编时需要注意一下。
7、最后
本文详细讲解了C++程序员为什么要了解汇编,了解汇编都有哪些具体的好处,如何学习汇编,以及如何看懂汇编代码上下文等,希望能给大家提供一定的借鉴或参考。