如何阅读汇编语言
此文为加密收费内容添加我微信支付后可看:
更新:本文现在有一个ARM64端口。
为什么在2021年,有人需要学习汇编语言?首先,阅读汇编语言是准确了解程序正在做什么的方法。确切地说,为什么C++程序是1 MiB(比如)而不是100 KiB?有可能从那个一直被调用的函数中挤出更多性能吗?
特别是对于C++,很容易忘记或只是没有注意到源代码和语言语义所暗示但未显式拼写的某些操作(例如隐式转换或对副本构造函数或析构函数的调用)。看着编译器生成的程序集,一切都清晰可见。
其次,更实际的原因:到目前为止,尽管不断链接到编译器资源管理器,但此博客上的帖子不需要了解汇编语言。然而,根据大众需求,我们的下一个主题将是参数传递,为此,我们需要对汇编语言有一个基本的理解。我们只会专注于阅读汇编语言,而不是写作。
说明
汇编语言的基本单元是指令。每个机器指令都是一个小操作,例如添加两个数字,从内存加载一些数据,跳转到另一个内存位置(如可怕的goto语句),或从函数调用或返回。(x86架构也有很多不那么小的指令。其中一些是建筑存在40多年积累的遗留的十字架,还有一些是新奇的添加物。)
示例1:矢量规范
我们的第一个玩具示例将让我们熟悉简单的说明。它只是计算2D矢量的正方形:
|
|
这是通过编译器资源管理器:1从tangg 11生成的x86-64程序集
|
|
让我们谈谈第一条说明:imulq %rdi, %rdi
。此指令执行有符号整数乘法。q
后缀告诉我们,它正在64位数量上运行。(相比之下,l
、w
和b
分别表示32位、16位和8位。)它将第一个给定寄存器中的值(rdi
;寄存器名称前缀为%
符号)乘以第二个寄存器中的值,并将结果存储在第二个寄存器中。这是我们示例C++代码中的平方v.x
。
第二个指令与%rsi
中的值相同,该值平方为v.y
。
接下来,我们有一个奇怪的指令:leaq (%rsi,%rdi), %rax
.lea
代表“加载有效地址”,它将第一个操作数的地址存储到第二个操作数中。(%rsi, %rdi)
的意思是“%rsi + %rdi
指向的内存位置”,所以这只是添加%rsi
和%rdi
,并将结果存储在%rax
中。lea
是一个奇怪的x86特定的指令;在ARM64这样的更RISC-y架构上,我们希望看到一个普通的旧add
指令。2
最后,retq
从normSquared
函数返回。
注册
让我们绕一小节,解释我们在示例中看到的寄存器是什么。寄存器是程序集语言的“变量”。与您最喜欢的编程语言(可能)不同,它们的数量有限,它们有标准化的名称,我们将谈论的最多64位大小。其中一些有我们稍后会看到的具体用途。我无法从内存中删除这一点,但根据维基百科,x86_64上16个寄存器的完整列表3是rax
、rcx
、rdx
、rbx
、rsp
、rbp
、rsi
、rdi
、r8
、r9
、r10
、r11
、r12
、r13
、r14
和r15
。
示例2:堆栈
现在,让我们扩展我们的示例,在normSquared
中调试打印Vec2
:
|
|
再说一遍,让我们看看生成的程序集:
|
|
In addition to the obvious call to Vec2::debugPrint() const
, we have some other new instructions and registers! %rsp
is special: it is the “stack pointer”, used to maintain the function call stack. It points to the bottom of the stack, which grows “down” (toward lower addresses) on x86. So, our subq $24, %rsp
instruction is making space for three 64-bit integers on the stack. (In general, setting up the stack and registers at the start of your function is called the function prologue.) Then, the following two mov
instructions store the first and second arguments to normSquared
, which are v.x
and v.y
(more about how parameter passing words in the next blog post!) to the stack, effectively creating a copy of v
in memory at the address %rsp + 8
. Next, we load the address of our copy of v
into %rdi
with leaq 8(%rsp), %rdi
and then call Vec2::debugPrint() const
.
debugPrint
返回后,我们将v.x
和v.y
加载回%rcx
和%rax
。我们有和以前一样的imulq
和addq
说明。最后,我们addq $24, %rsp
来清理我们在函数开始时分配的24字节4个堆栈空间(称为函数结尾),然后用retq
返回给我们的调用方。
示例3:框架指针和控制流程
现在,让我们看看另一个例子。假设我们想打印一个大写C字符串,并希望避免对小字符串进行堆分配。5我们可能会写以下内容:
|
|
|
|
我们的函数序言要长得多,我们还有一些新的控制流程指令。让我们仔细看看序言:
|
|
pushq %rbp; movq %rsp, %rbp
序列非常常见:它将存储在%rbp
中的帧指针推送到堆栈,并将旧堆栈指针(即新的帧指针)保存在%rbp
中。以下四个pushq
说明存储我们在使用前需要保存的寄存器。7
进入功能主体。我们把第一个参数(%rdi
)保存在%r14
中,因为我们即将调用strlen
,它像任何其他函数一样,可能会覆盖%rdi
(我们说%rdi
是“调用者保存”),而不是%r14
(我们说%r14
是“调用者保存”)。我们使用callq strlen
调用strlen(s)
并将sSize + 1``lea 1(%rax), %rdi
一起存储在%rdi
中。
Next, we finally see our first if
statement! cmpq $1024, %rax
sets the flags register according to the result of %rax - $1024
, and then ja .LBB0_2
(“jump if above”) transfers control to the location labeled .LBB0_2
if the flags indicate that %rax > 1024
. In general, higher-level control-flow primitives like if
/else
statements and loops are implemented in assembly using conditional jump instructions.
让我们首先看看%rax <= 1024
的路径,然后分支到.LBB0_2
没有被拿走。我们有一个在堆栈上创建char temp[sSize + 1]
的说明:
|
|
我们将%rsp
保存到%r15
和%rbx
供以后使用。8然后,我们将15添加到%rdi
(记住,它包含我们数组的大小),用andq $-16, %rdi
掩码下4位,然后从%rbx
中减去结果,然后将其放回%rsp
。简而言之,这四舍五入数组大小,直到16字节的下一个倍数,并在堆栈上为其留出空间。
以下块只需调用copyUppercase
,并按代码中的书写形式puts
:
|
|
最后,我们有功能结语:
|
|
我们恢复堆栈指针,以使用leaq
分配我们的可变长度数组。然后,我们popq
我们在函数序言期间保存的寄存器,并将控件返回给我们的调用者,我们就完成了。
接下来,让我们看看%rax > 1024
的路径,然后我们分支到.LBB0_2
。这条路更简单。我们调用operator new[]
,将结果(以%rax
返回)保存到%rbx
,并调用copyUppercase
和puts
。我们为这个案例有一个单独的函数结尾,它看起来有点不同:
|
|
第一个mov
设置了%rdi
,并带有指向我们之前保存的堆分配数组的指针。与其他函数结尾一样,我们然后恢复堆栈指针并弹出保存的寄存器。最后,我们有一条新指令:jmp operator delete[](void *)``jmp
就像goto
一样:它将控件传输到给定的标签或函数。与callq
,它不会将返回地址推送到堆栈上。因此,当operator delete[]
返回时,它将把控制权传输给printUpperCase
的调用方。本质上,我们已经将operator delete[]
的callq
与我们自己的retq
相结合。这被称为尾部调用优化,因此编译器有益地发出了# TAILCALL
注释。
实际应用:捕捉令人惊讶的转换
我在导言中说,读取程序集使隐式复制和销毁操作非常清晰。我们在前面的例子中看到了其中一些,但我想通过查看一个常见的C++移动语义辩论来结束。为了避免lvalue引用有一个重载和rvalue引用重载,按值取参数可以吗?有一种思想流派说:“是的,因为无论如何,在lvalue情况下,你都会复制一份,在rvalue的情况下,只要你的类型移动起来便宜,就可以。”如果我们看看rvalue情况的一个例子,我们将看到“移动便宜”并不意味着“自由移动”,就像我们可能更喜欢的那样。如果我们想要最大的性能,我们可以证明重载解决方案将带我们到达那里,而副值解决方案不会。(当然,如果我们不愿意编写额外的代码来提高性能,那么“移动便宜”可能足够便宜。)
|
|
如果我们查看生成的程序集9(即使我故意勾勒了10个相关构造函数,它太长而无法包含),我们可以看到createRvalue1
执行1个移动操作(在MyString::MyString(std::string&&)
的正文中)和1 std::string::~string()
调用(返回前operator delete
)。相比之下,createRvalue2
要长得多:它总共执行2个移动操作(1个内联,进入MyOtherString::MyOtherString(std::string s)``s
参数,1个在同一构造函数的正文中),2个std::string::~string
调用(1个用于上述s
参数,MyOtherString::str
成员1个)。公平地说,移动std::string
很便宜,摧毁从std::string
移动也是便宜的,但它在CPU时间或代码大小方面都不是免费的。
进一步阅读
汇编语言可以追溯到1940年代末,因此有很多资源可以学习它。就我个人而言,我第一次介绍汇编语言是在母校密歇根大学举行的EECS 370:计算机组织入门初级课程。不幸的是,该网站上链接的大多数课程材料都不公开。以下是伯克利(CS 61C)、卡内基梅隆(15-213)、斯坦福大学(CS107)和麻省理工学院(6.004)的相应“计算机如何真正工作”课程。(如果我为这些学校推荐了错误的课程,请告诉我!)Nand to Tetris似乎也涵盖了类似的材料,项目和书籍章节是免费的。
我第一次实际接触x86程序集,特别是在安全漏洞的背景下,或者像孩子们过去常说的那样,学习成为“l33t h4x0r”。如果这让你觉得是学习组装的更有趣的理由,那就太好了!空间中的经典作品是《粉碎堆栈以获得乐趣和利润》。不幸的是,现代安全缓解使您自己运行该文章中的示例变得复杂,因此我建议您找到一个更现代的实践环境。微腐败是一个行业创建的例子,或者您可以尝试从大学安全课程中找到应用程序安全项目来跟进(例如,伯克利CS 161的项目1,该项目目前似乎公开提供)。
最后,总是有谷歌和黑客新闻。Pat Shaughnessy的《2016年开始学习阅读x86 AssemblyLanguage》从Ruby和Crystal的角度涵盖了该主题,最近(2020年)还讨论了如何学习x86_64程序集。
祝你好运,黑客攻击快乐!
- 我使用AT&T语法,因为它是Linux工具中的默认语法。如果您更喜欢英特尔语法,切换在编译器资源管理器的“输出”下。本文中的编译器资源管理器链接将同时显示两者,左侧是AT&T,右侧是英特尔。差异指南简短且随时可用;简而言之,英特尔语法对内存引用更明确,删除了
b
/w
/l
/q
后缀,并将目标操作数放在第一位而不是最后。↩︎ - 如果您实际查看此示例[的ARM64程序集](https://godbolt.org/#g:!((g:!((g:!((h:codeEditor,i:(fontScale:14,j:1,lang:c%2B%2B,selection:(endColumn:2,endLineNumber:10,positionColumn:2,positionLineNumber:10,selectionStartColumn:2,selectionStartLineNumber:10,startColumn:2,startLineNumber:10),source:'%23include+ struct+Vec2+{ ++++int64_t+x%3B ++++int64_t+y%3B }%3B int64_t+normSquared(Vec2+v)+{ ++++return+v.x++v.x+%2B+v.y++v.y%3B }'),l:‘5’,n:‘0’,o:‘C%2B%2B+source+%231’,t:‘0’)),k:50,l:‘4’,n:‘0’,o:'',s:0,t:‘0’),(g:!((h:compiler,i:(compiler:armv8-clang1101,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘1’,libraryCode:‘1’,trim:‘1’),fontScale:14,j:1,lang:c%2B%2B,libs:!((name:boost,ver:‘175’)),options:'-O3+-std%3Dc%2B%2B20',selection:(endColumn:1,endLineNumber:1,positionColumn:1,positionLineNumber:1,selectionStartColumn:1,selectionStartLineNumber:1,startColumn:1,startLineNumber:1),source:1),l:‘5’,n:‘0’,o:‘armv8-a+clang+11.0.1+(Editor+%231,+Compiler+%231)+C%2B%2B’,t:‘0’)),k:50,l:‘4’,n:‘0’,o:'',s:0,t:‘0’)),l:‘2’,n:‘0’,o:'',t:‘0’)),version:4),您将看到一个
madd
指令被使用,而不是:madd x0, x0, x0, x8
。这是一个乘法+添加在一条指令中:它正在做x0 = x0 * x0 + x8
。↩︎ - 这些只是大多数整数指令使用的64位寄存器。浮动点和指令集扩展附带的注册器实际上更多。↩︎
- ~~您可能已经注意到,尽管分配了24个字节,但我们只使用了16字节的堆栈空间。据我所知,代码中还剩下额外的8个字节,用于设置和恢复经过优化的帧指针。Clang、gcc和icc似乎都留下了额外的8个字节,msvc似乎浪费了16个字节,而不是8个字节。如果我们[使用-fno-omit-frame-pointer构建](https://godbolt.org/#g:!((g:!((g:!((h:codeEditor,i:(fontScale:14,j:1,lang:c%2B%2B,selection:(endColumn:1,endLineNumber:6,positionColumn:1,positionLineNumber:6,selectionStartColumn:1,selectionStartLineNumber:5,startColumn:1,startLineNumber:5),source:'%23include+ struct+Vec2+{ ++++int64_t+x%3B ++++int64_t+y%3B ++++void+debugPrint()+const%3B }%3B int64_t+normSquared(Vec2+v)+{ ++++v.debugPrint()%3B ++++return+v.x++v.x+%2B+v.y++v.y%3B }'),l:‘5’,n:‘0’,o:‘C%2B%2B+source+%231’,t:‘0’)),k:50,l:‘4’,n:‘0’,o:'',s:0,t:‘0’),(g:!((h:compiler,i:(compiler:clang1101,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘1’,libraryCode:‘1’,trim:‘1’),fontScale:14,j:1,lang:c%2B%2B,libs:!(),options:'-O3+-std%3Dc%2B%2B20+-fno-omit-frame-pointer',selection:(endColumn:1,endLineNumber:1,positionColumn:1,positionLineNumber:1,selectionStartColumn:1,selectionStartLineNumber:1,startColumn:1,startLineNumber:1),source:1),l:‘5’,n:‘0’,o:‘x86-64+clang+11.0.1+(Editor+%231,+Compiler+%231)+C%2B%2B’,t:‘0’)),k:50,l:‘4’,n:‘0’,o:'',s:0,t:‘0’)),l:‘2’,n:‘0’,o:'',t:‘0’)),version:4),我们可以看到其他8个字节用于在函数开始时
pushq %rbp
,然后在末尾用于推送popq %rbp
。编译器并不完美;如果您经常阅读程序集,您会不时看到这种小的错过优化。有时事情真的错过了优化机会,但也有很多不幸的ABI约束,由于使用不同编译器(甚至同一编译器的不同版本)构建的代码块之间的兼容性,迫使次优代码生成。~~更新:额外的8字节堆栈空间是因为System V x86_64 ABI的第3.2.2.2节要求在调用函数时,堆栈帧必须与16字节边界对齐。换句话说,每个编译器都犯了这个“错误”,因为它是必需的!↩︎ - 还假设我们没有像absl::FixedArray这样的东西可用。我不想让这个例子进一步复杂化。↩︎
- 我用
-fno-exceptions
构建,通过删除异常清理路径来简化示例。它出现在尾声之后,我想这可能会令人困惑。↩︎ Another possible missed optimization: I don’t see a need toUPDATE: this is likely because pushing a register is smaller and/or faster than issuing apushq %rax
here; it’s not callee-saved and we don’t care about the value on entry toprintUpperCase
. Get in touch if you know whether this is a missed optimization or there’s actually a reason to do it!sub 8, %rsp
instruction. ↩︎- 然而,我认为不需要这个
movq %rsp, %r15
。在我们movq %r15, %rsp
之前,%r15
不会再次使用,但该指令之后立即遵循leaq -24(%rbp), %rsp
,立即覆盖%rsp
。我认为我们可以通过删除两个movq %rsp, %r15
和movq %r15, %rsp
指令来改进代码。另一方面,鉴于此代码,英特尔的icc编译器[也做了看似愚蠢的事情来恢复%rsp
](https://godbolt.org/#g:!((g:!((g:!((h:codeEditor,i:(fontScale:14,j:1,lang:c%2B%2B,selection:(endColumn:74,endLineNumber:16,positionColumn:74,positionLineNumber:16,selectionStartColumn:74,selectionStartLineNumber:16,startColumn:74,startLineNumber:16),source:'%23include+ %23include+ %23include+ void+copyUppercase(char+*dest,+const+char+*src)%3B constexpr+size_t+MAX_STACK_ARRAY_SIZE+%3D+1024%3B void+printUpperCase(const+char+*s)+{ ++++auto+sSize+%3D+strlen(s)%3B ++++if+(sSize+<%3D+MAX_STACK_ARRAY_SIZE)+{ ++++++++char+temp[sSize+%2B+1]%3B ++++++++copyUppercase(temp,+s)%3B ++++++++puts(temp)%3B ++++}+else+{ ++++++++//+std::make_unique_for_overwrite+is+missing+on+Compiler+Explorer. ++++++++std::unique_ptr+temp(new+char[sSize+%2B+1])%3B ++++++++copyUppercase(temp.get(),+s)%3B ++++++++puts(temp.get())%3B ++++} }'),l:‘5’,n:‘0’,o:‘C%2B%2B+source+%231’,t:‘0’)),k:43.31039755351682,l:‘4’,n:‘0’,o:'',s:0,t:‘0’),(g:!((h:compiler,i:(compiler:clang1101,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘1’,libraryCode:‘1’,trim:‘1’),fontScale:14,j:1,lang:c%2B%2B,libs:!(),options:'-O3+-std%3Dc%2B%2B20+-fno-vectorize+-fno-unroll-loops+-fno-exceptions',selection:(endColumn:27,endLineNumber:18,positionColumn:1,positionLineNumber:13,selectionStartColumn:27,selectionStartLineNumber:18,startColumn:1,startLineNumber:13),source:1),l:‘5’,n:‘0’,o:‘x86-64+clang+11.0.1+(Editor+%231,+Compiler+%231)+C%2B%2B’,t:‘0’)),k:24.794543292249717,l:‘4’,n:‘0’,o:'',s:0,t:‘0’),(g:!((h:compiler,i:(compiler:icc202119,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘1’,libraryCode:‘0’,trim:‘1’),fontScale:14,j:2,lang:c%2B%2B,libs:!(),options:'-O3+-std%3Dc%2B%2B20+-fno-vectorize+-fno-unroll-loops+-fno-exceptions',selection:(endColumn:19,endLineNumber:28,positionColumn:19,positionLineNumber:28,selectionStartColumn:19,selectionStartLineNumber:28,startColumn:19,startLineNumber:28),source:1),l:‘5’,n:‘0’,o:‘x86-64+icc+21.1.9+(Editor+%231,+Compiler+%232)+C%2B%2B’,t:‘0’)),k:31.895059154233465,l:‘4’,n:‘0’,o:'',s:0,t:‘0’)),l:‘2’,n:‘0’,o:'',t:‘0’)),version:4),因此要么有充分的理由这样做,要么在存在可变长度数组的情况下清理堆栈指针操作,这只是编译器中一个困难或被忽视的问题。同样,如果您知道是哪个,请随时联系我们!↩︎ - 同样,我使用
-fno-exceptions
构建,以避免使事情复杂化,但清理路径除外。↩︎ - 如果我们[内联
MyString
和MyOtherString
的构造函数](https://godbolt.org/#g:!((g:!((g:!((h:codeEditor,i:(fontScale:14,j:1,lang:c%2B%2B,selection:(endColumn:40,endLineNumber:12,positionColumn:40,positionLineNumber:12,selectionStartColumn:40,selectionStartLineNumber:12,startColumn:40,startLineNumber:12),source:'%23include+ class+MyString+{ +std::string+str%3B +public: ++explicit+MyString(std::string%26%26+s)+:+str(std::move(s))+{} }%3B class+MyOtherString+{ ++std::string+str%3B +public: ++explicit+MyOtherString(std::string+s):+str(std::move(s))+{} }%3B void+createRvalue1(std::string%26%26+s)+{ ++++MyString+s2(std::move(s))%3B }%3B void+createRvalue2(std::string%26%26+s)+{ ++++MyOtherString+s2(std::move(s))%3B }%3B'),l:‘5’,n:‘0’,o:‘C%2B%2B+source+%231’,t:‘0’)),k:40.48706240487063,l:‘4’,n:‘0’,o:'',s:0,t:‘0’),(g:!((h:compiler,i:(compiler:clang1101,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘1’,libraryCode:‘1’,trim:‘1’),fontScale:14,j:1,lang:c%2B%2B,libs:!(),options:'-O3+-std%3Dc%2B%2B20+-fno-exceptions',selection:(endColumn:1,endLineNumber:1,positionColumn:1,positionLineNumber:1,selectionStartColumn:1,selectionStartLineNumber:1,startColumn:1,startLineNumber:1),source:1),l:‘5’,n:‘0’,o:‘x86-64+clang+11.0.1+(Editor+%231,+Compiler+%231)+C%2B%2B’,t:‘0’)),k:59.51293759512938,l:‘4’,n:‘0’,o:'',s:0,t:‘0’)),l:‘2’,n:‘0’,o:'',t:‘0’)),version:4),我们确实可以节省一些createRvalue2
:我们最多调用一次operator delete
。然而,我们仍然进行2个移动操作,我们需要32个额外的堆栈空间字节。↩︎