如何阅读汇编语言

2021-03-02 13:19:26

为什么在2021年,任何人都需要学习汇编语言?首先,阅读汇编语言是确切了解您的程序在做什么的方式。确切地说,为什么C ++程序是1个MiB(而不是100 KiB)?是否有可能从一直调用的函数中挤出更多性能?

特别是对于C ++,很容易忘记或根本没有注意到源代码和语言语义所隐含但未明确说明的某些操作(例如,隐式转换或对复制构造函数或析构函数的调用)。查看由编译器生成的程序集,可以使所有内容一目了然。

其次,更实际的原因:到目前为止,尽管与Compiler Explorer的链接不断,但此博客上的帖子并不需要理解汇编语言。但是,根据需求,我们的下一个主题将是参数传递,为此,我们需要对汇编语言有基本的了解。我们将只专注于阅读汇编语言,而不是编写汇编语言。

汇编语言的基本单位是指令。每条机器指令都是一个小操作,例如加两个数字,从内存中加载一些数据,跳转到另一个内存位置(如可怕的gotostatement)或从函数调用或返回。 (x86体系结构也有很多不太小的指令。其中一些是在该体系结构存在40多年的基础上建立起来的,而另一些则是新的形式。)

我们的第一个玩具示例将使我们熟悉简单的说明。它只是计算2D向量范数的平方:

#include< cstdint> struct Vec2 {int64_t x; int64_t y; int64_t z;}; int64_t normSquared(Vec2 v){return v.x * v.x + v.y * v.y;}

我们来谈谈第一条指令:imulq%rdi,%rdi。该指令执行有符号整数乘法。 qsuffix告诉我们它以64位数量运行。 (相反,l,w和b分别表示32位,16位和8位。)它将第一个给定寄存器(rdi;寄存器名称前缀为%符号)中的值乘以将值存储在第二个寄存器中,并将结果存储在该第二个寄存器中。这是在我们的示例C ++代码中对v.x进行平方运算。

接下来,我们有一个奇怪的指令:leaq(%rsi,%rdi),%r​​ax。最低和“加载有效地址”,它将第一个操作数的地址存储到第二个操作数中。 (%rsi,%rdi)的意思是“%rsi +%rdi指向的内存位置”,因此这只是将%rsi和%rdi相加并将结果存储在%rax中。 lea是一个特定于quirkyx86的指令;在像ARM64这样的更RISC体系结构上,我们期望看到一个普通的旧addinstruction。 2个

让我们绕道走一下,以解释我们在示例中看到的寄存器是什么。寄存器是汇编语言的“变量”。与您喜欢的编程语言不同(可能),它们的数量是有限的,它们具有标准化的名称,我们将要讨论的名称最多为64位。其中一些具有特定用途,我们将在以后看到。我无法从内存中摆脱出来,但是perWikipedia,x86_64上16个寄存器的完整列表3是rax,rcx,rdx,rbx,rsp,rbp,rsi,rdi,r8,r9,r10,r11,r12 ,r13,r14和r15。

#include< cstdint> struct Vec2 {int64_t x; int64_t y; void debugPrint()const;}; int64_t normSquared(Vec2 v){v.debugPrint();返回v.x * v.x + v.y * v.y;}

subq $ 24,%rsp movq%rdi,8(%rsp)movq%rsi,16(%rsp)leaq 8(%rsp),%rdi callq Vec2 :: debugPrint()const movq 8(%rsp),%rcx movq 16(%rsp),%rax imulq%rcx,%rcx imulq%rax,%rax addq%rcx,%rax addq $ 24,%rsp retq

除了对Vec2 :: debugPrint()const的明显调用之外,我们还有一些其他新的指令和寄存器! %rsp是特殊的:它是“堆栈指针”,用于维护函数调用堆栈。它指向堆栈的底部,堆栈在x86上“向下”生长(朝着较低的地址)。因此,我们的subq $ 24,%rsp指令为堆栈上的three64位整数腾出空间。 (通常,在函数的开头设置堆栈并进行注册称为函数序言。)然后,以下两个mov指令将normSquared的第一个和第二个参数存储为vx和vy(有关如何在下一个博客中传递参数的更多信息) post!)到堆栈,在内存中有效地在地址%rsp + 8上创建v的副本。接下来,使用leaq 8(%rsp),%rdi将v的副本地址加载到%rdi中,然后调用Vec2: :debugPrint()常量

在debugPrint返回之后,我们将v.x和v.y加载回%rcx和%rax。我们之前有相同的imulq和addq指令。最后,我们添加$ 24,%rsp来清理我们在函数开始时分配的24字节4的堆栈空间(称为functionepilogue),然后使用retq返回到调用方。

现在,让我们看一个不同的例子。假设我们要打印一个大写的C字符串,并且我们希望避免为较小的字符串分配堆。 5我们可能会写类似以下的内容:

#include< cstdio> #include< cstring> #include< memory> void copyUppercase(char * dest,const char * src); constexpr size_t MAX_STACK_ARRAY_SIZE = 1024; void printUpperCase(const char * s){auto sSize = strlen(s);如果(sSize< = MAX_STACK_ARRAY_SIZE){char temp [sSize + 1]; copyUppercase(temp,s);放置(温度); } else {// std :: make_unique_for_overwrite在Godbolt上丢失。 std :: unique_ptr<字符[]> temp(new char [sSize + 1]); copyUppercase(temp.get(),s); puts(temp.get()); }}

printUpperCase(char const *):#@printUpperCase(char const *)pushq%rbp movq%rsp,%rbp pushq%r15 pushq%r14 pushq%rbx pushq%rax movq%rdi,%r14 callq strlen leaq 1(%rax) ,%rdi cmpq $ 1024,%rax#imm = 0x400 ja .LBB0_2 movq%rsp,%r15 movq%rsp,%rbx addq $ 15,%rdi andq $ -16,%rdi subq%rdi,%rbx movq%rbx,% rsp movq%rbx,%rdi movq%r14,%rsi callq copyUppercase(char *,char const *)movq%rbx,%rdi callq放置movq%r15,%rsp leaq-24(%rbp),%rsp popq%rbx popq%r14 popq%r15 popq%rbp retq.LBB0_2:callq运算符new [](unsigned long)movq%rax,%rbx movq%rax,%rdi movq%r14,%rsi callq copyUppercase(char *,char const *) movq%rbx,%rdi callq放入movq%rbx,%rdi leaq-24(%rbp),%rsp popq%rbx popq%r14 popq%r15 popq%rbp jmp运算符delete [](void *)#TAILCALL

我们的功能序言已经更长了,并且我们还有一些新的控制流程指令。让我们仔细看一下序幕:

pushq%rbp movq%rsp,%rbp pushq%r15 pushq%r14 pushq%rbx pushq%rax movq%rdi,%r14

pushq%rbp; movq%rsp,%rbp序列很常见:它将%rbp中存储的帧指针推入堆栈,并将旧的堆栈指针(即新的帧指针)保存在%rbp中。以下四个pushq指令存储我们在使用前需要保存的寄存器。 7最后,我们将第一个参数(%rdi)保存在%r14中。

转到功能主体。我们用callq strlen调用strlen,并在lea 1(%rax),%rdi中将sSize + 1存储在%rdi中。

接下来,我们终于看到了我们的第一个if语句! cmpq $ 1024,%rax根据%rax-$ 1024的结果设置标志寄存器,然后ja .LBB0_2(“ jump if above”)将控制转移到标记为LBB0_2的位置。 1024.通常,较高级别的控制流原语(如if / else语句和循环)是使用条件跳转指令在汇编中实现的。

让我们首先看一下%rax< = 1024的路径,因此未采用分支到.LBB0_2的路径。我们有一堆指令来在堆栈上创建char temp [sSize + 1]:

movq%rsp,%r15 movq%rsp,%rbx addq $ 15,%rdi andq $ -16,%rdi subq%rdi,%rbx movq%rbx,%rsp

我们将%rsp保存到%r15和%rbx供以后使用。 8然后,将15加到%rdi(记住,它包含数组的大小),用andq $ -16,%rdi掩盖较低的4位,并从%rbx中减去结果,并将其加回到%rbx中rsp。简而言之,这会将数组大小四舍五入到16个字节的下一个倍数,并在堆栈上为其留出空间。

movq%rbx,%rdi movq%r14,%rsi callq copyUppercase(char *,char const **)movq%rbx,%rdi callq puts

movq%r15,%rsp leaq-24(%rbp),%rsp popq%rbx popq%r14 popq%r15 popq%rbp retq

我们使用leaq恢复堆栈指针以释放可变长度数组。然后,我们弹出在函数序幕中保存的寄存器,然后将控制权返回给调用者,我们就完成了。

接下来,让我们看一下%rax> 1024,然后跳转到.LBB0_2。这条路更简单。我们将运算符称为new [],将结果(以%rax返回)保存为%rbx,然后调用copyUppercase和puts像以前一样。在这种情况下,我们有一个单独的functionepilogue,它看起来有点不同:

movq%rbx,%rdi leaq-24(%rbp),%rsp popq%rbx popq%r14 popq%r15 popq%rbp jmp运算符delete [](void *)#TAILCALL

第一个mov设置%rdi带有一个指向我们之前保存的heap-allocatedarray的指针。与其他函数结尾一样,wehen恢复堆栈指针并弹出我们保存的寄存器。最后,我们有了一条新指令:jmp运算符delete [](void *)。 jmp就像goto一样:将控制权转移到给定的标签或函数。与callq不同,它不会将返回地址压入堆栈。因此,当操作员delete []返回时,它将控制权转移给printUpperCase的调用方。本质上,我们已经将自己的retq与callq结合起来用于运算符删除。这称为尾调用优化,因此,编译器会有用地发出#TAILCALL注释。

我在引言中说过,读取程序集使隐式复制和销毁操作非常清楚。我们在前面的示例中看到了其中的一些内容,但是我想通过讨论一个常见的C ++ movesemantics辩论来结束。为了避免左值引用重载而右值引用重载,按值获取参数是否可以?有一种流派说:“是的,因为在左值情况下您还是会复制,而在右值情况下只要您的类型便宜即可,就可以了”。如果我们看一下右值情况的示例,我们会发现“便宜的移动”并不意味着“自由移动”,正如我们可能更希望的那样。如果我们想要最大的性能,我们可以证明过载解决方案将帮助我们实现这一目标,而按价值解决方案则不会。 (当然,如果我们不愿意编写额外的代码来提高性能,那么“便宜”就足够便宜了。)

#include< string>类MyString {std :: string str; public:显式MyString(const std :: string& s);显式MyString(std :: string& s);};类MyOtherString {std :: string str; public:显式MyOtherString(std :: string s);}; void createRvalue1(std :: string& s){MyString s2(std :: move(s));}; void createRvalue2(std :: string& s){MyOtherString s2(std :: move(s));};

如果我们看一下generatedassembly 9(即使我故意概述了10个有问题的构造函数,生成的汇编9也太长了),我们可以看到createRvalue1进行了1次移动操作(在MyString :: MyString(std :: string& &))和1个std :: string :: ~~ string()调用(运算符在返回之前删除)。相比之下,createRvalue2更长:总共执行了2个移动操作(向MyOtherString :: MyOtherString(std :: string s)的s参数内联1个内联,在同一构造函数的主体中1个)和2个std: :string ::〜string调用(上述参数1,MyOtherString :: strmember 1)。公平地讲,移动std :: string便宜,因此要从std :: string移走,但是就CPU时间或代码大小而言,它并不是免费的。

汇编语言的历史可以追溯到1940年代后期,因此有大量的学习资源。就个人而言,我对汇编语言的第一个介绍是在我的母校密歇根大学的EECS 370:计算机组织入门初级课程中。不幸的是,该网站上链接的大多数课程资料都是不公开的。在伯克利(CS61C),卡内基·梅隆(15-213),斯坦福(CS107)和麻省理工(6.004)的以下课程中,似乎有相应的“计算机如何真正工作”课程。 (请告诉我,如果我对任何这些学校的课程建议都不正确!)“与非”到“俄罗斯方块”似乎也涵盖了类似的材料,并且免费提供项目和书籍章节。

尤其是我第一次实际接触x86程序集是在安全漏洞的背景下,或者像孩子们所说的那样学会了成为“ l33t h4x0r”。如果这是使您更有趣地学习组装的理由,那太好了!太空中的经典作品是为乐趣和利润砸碎堆栈。不幸的是,现代的安全缓解措施使您自己运行该文章中的示例变得很复杂,因此,我建议您找到一个更现代的实践环境。微腐败是一个由行业创造的示例,或者您可以尝试从大学安全课程中找到一个应用安全项目,以作为跟进对象(例如,伯克利CS 161的项目1,目前似乎可以公开获得)。

最后,总是有Google和Hacker News。 Pat Shaughnessy的“ 2016年的学习阅读x86 AssemblyLanguage”从Ruby和Crystal的角度介绍了该主题,最近(2020年)也有关于如何学习x86_64assembly的讨论。

我使用AT& T语法,因为这是Linux工具中的默认语法。如果您喜欢Intel语法,请在“输出”下的CompilerExplorer上进行切换。本文中的Compiler Explorer链接将同时显示,AT& T左侧,Intel右侧。区别的指南简短易懂;简而言之,Intel语法在内存引用方面更为明确,将b / w / l / q后缀删除,并将destinationoperand放在最后而不是最后。 ↩︎

如果您实际在此示例中查看ARM64组件,则会看到使用了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进行构建,我们可以看到其他8个字节用于pushq在函数开始处为%rbp,随后在结尾处弹出%qbpr。编译器并不完美;如果您大量阅读汇编,您会时不时地看到这种小的错失优化。有时确实错过了优化机会,但由于使用不同编译器(甚至同一编译器的不同版本)构建的代码段之间存在兼容性,因此还有很多不幸的ABI约束会导致生成次优代码的情况。更新:额外的8个字节的堆栈空间这是因为System V x86_64ABI的3.2.2节要求调用函数时,堆栈帧必须与16字节边界对齐。换句话说,每个编译器都犯了这个“错误”,因为它是必需的! ↩︎

还要假设我们没有absl :: FixedArrayavailable之类的东西。我不想让这个例子进一步复杂化。 ↩︎

我使用-fno-exceptions构建,以通过删除异常清除路径来简化示例。它似乎在尾叫之后出现,我认为这可能会造成混淆。 ↩︎

另一个可能错过的优化:我看不到这里需要推送%rax;它没有被保存者,我们也不在乎printUpperCase的输入值。如果您知道这是错过的优化还是确实有这样做的理由,请与我们取得联系!更新:这很可能是因为推送寄存器比发出sub 8,%rsp指令要小和/或快。 ↩︎

再一次,我认为不需要movq%rsp,%r15。在我们移动%r15,%rsp之前,不会再次使用%r15,但是该指令后紧跟leaq -24(%rbp),%rsp,该指令立即覆盖%rsp。我认为我们可以通过删除两条movq%rsp,%r15和movq%r15,%rsp指令来改进代码。另一方面,Intel的icc编译器似乎也很愚蠢,无法还原%rsp给定的代码,因此,要么有充分的理由这样做,要么在存在可变长度数组的情况下清理堆栈指针操作只是一个困难的或被忽略的问题。不编译器。同样,如果您知道是哪一个,请随时与我们联系! ↩︎

如果我们内联MyString和MyOtherString的构造函数,则可以在createRvalue2上节省一些费用:我们最多只能调用一次运算符delete。但是,我们仍然执行2次移动操作,并且需要32个额外的堆栈空间字节。 ↩︎