逆向工程权威指南

978-7-115-43445-6
作者: 【乌克兰】Dennis Yurichev(丹尼斯)
译者: Archer安天安全研究与应急处理中心
编辑: 陈冀康

图书目录:

详情

本书是针对初学者的一本逆向工程学习指南。简单介绍逆向工程的原理之后,向读者介绍和剖析了逆向工程的底层机制和技术,包括编码模式、C语言编程常用的函数等等。本书的作者是一位乌克兰的逆向工程技术专家和讲师,他通过自己的网站编写和发布本书英文版,获得了很大的关注和支持。

图书摘要

版权信息

书名:逆向工程权威指南

ISBN:978-7-115-43445-6

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [乌克兰] Dennis Yurichev

  译    Archer 安天安全研究与应急处理中心

  责任编辑 陈冀康

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


Simplified Chinese translation copyright ©2017 by Posts and Telecommunications Press

ALL RIGHTS RESERVED

Reverse Engineering for Beginners, by Dennis Yurichev

Copyright © 2016 by Dennis Yurichev

本书中文简体版由作者Dennis Yurichev授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。

版权所有,侵权必究。


逆向工程是一种分析目标系统的过程,旨在识别系统的各组件以及组件间关系,以便能够通过其他形式或在较高的抽象层次上,重建系统的表征。

本书专注于软件的逆向工程,是写给初学者的一本权威指南。全书共分为上、下两册,十二个部分,共102章,涉及X86/X64、ARM/ARM-64、MIPS、Java/JVM等重要内容,详细解析了Oracle RDBMS、Itanium、软件狗、LD_PRELOAD、栈溢出、ELF、Win32 PE文件格式、x86-64(critical sections、syscalls、线程本地存储TLS、地址无关代码(PIC)、以配置文件为导向的优化、C++ STL、OpenMP、SHE等众多技术问题,堪称是逆向工程技术百科全书。除了详细讲解,本书还给出了很多习题来帮助读者巩固所学的知识,附录部分给出了习题的解答。

本书适合对逆向工程技术、操作系统底层技术、程序分析技术感兴趣的读者阅读,也适合专业的程序开发人员参考。


相对于本书的中文名字《逆向工程权威指南》,我更喜欢它的英文原名《Reverse Engineering for Beginners》,可以直译为“逆向工程入门者必读”。在国外的程序员圈中,这本书被简称为《RE4B》,作者的Blog式连载已经引发了持续关注和好评。

本书的重要特色在于其清晰的章节结构,涵盖了函数、语句、结构体、数组等这些构成代码大厦的基本元素,如显微镜般逐一对比了这些元素在X86、ARM和MIPS体系架构下的“切片”影像。本书几乎每个章节都按照X86、ARM、MIPS三个体系架构分别展开,可以让读者深入对比理解在三种体系架构下,同样的指令、函数及数据结构呈现的异同和特点。无论对分析工程师,还是编码工程师来说,本书都是能更深入理解代码大厦原材料的工作指南。这是基础性、系统性的文献工作,也是令人耳目一新的结构安排。对于在中国传统高校计算机教育中,通过简单的X86 ASM来学习和了解体系结构的工程师来说,这也是尽快打通ARM、MIPS知识背景的“全栈”指南。对于那些从各种破解教程来切入逆向领域的安全爱好者来说,这更像是一份“正餐”。对于作者来说,我相信这个写作过程是充满激情的,但也充满了艰难和痛苦。对于Dennis这样的逆向工程专家来说,这本书的写作过程,更多的不是在探索未知的世界,也不是对高级技巧和经验的总结,而是要对看起来相对枯燥的单元式资料进行整理,将一些对其已经是常识的东西转化为系统而书面化(并易于读者理解)的语言。

逆向工程一直被宣传为一种充满乐趣和带有神秘主义的技能,而逆向分析者的工作看起来更有乐趣的原因,似乎就是在指令奔涌中逆流而上,绕开种种限制和保护,找到代码中的“宝藏”——错误、漏洞或者是被加密算法层层掩盖的数据结构。这个由媒体所打造的形象,也为部分逆向爱好者所自矜。而这也导致逆向领域的书籍和教程,更多地围绕着一些高阶的技巧展开,更多地讲解如何绕过加密和保护,却忘记了逆向工程的初衷是要洞察系统及软件的整体结构和机理。由于之前的逆向领域相关出版物中缺少“代码大全”式的基础读本,本书的完成填补了这一空白,然而,这不仅需要作者有高超的逆向研究能力,也要有正向系统的思维和视野。

本书的译者之一Archer提出想邀请安天的工程师们一同完成本书的翻译时,我曾经觉得他是不是“疯了”。本书当时并无定稿的英文版本,只是Dennis持续发布的Blog式的连载。尽管网络连载已经引发了好评如潮,但其精彩篇章彼时还如散落在海底的珍珠,尚未串成珠链。以Dennis天马行空的写作风格和追求完美主义的性格,他必然会在未完成全部章节的期间,不断地增加体系结构的新热点(如ARM64)和修补既有章节的内容,甚至会改动原有的样例代码,导致本书原版定稿遥遥无期。我觉得,与其说正在养病的Archer接手翻译这本书是一份艰难的任务,倒不如说,Dennis写完本书根本就是一份不可完成的任务。

因此当接近千页的样书摆在我面前时,我被深深震撼了,那种感觉和我之前依靠自己极为蹩脚的英语扫过的英文电子稿不同,我能想象Archer是如何一边咳嗽着、一边码字或者校对代码;以及安天CERT的同事们是如何在样本分析任务饱和的情况下挤出时间来一点点推动翻译进展的。当我在视频例会上向安天各地的部门负责人展示样书时,我能看到每个人的赞叹和兴奋,那种兴奋不啻于我们自己发布了一份最新的长篇分析报告。

二进制分析,是安全分析工程师,包括有志于投身安全领域的编码工程师的基本功底之一。硅谷一些安全人士都曾提及一个有趣的说法——华人的系统安全天赋。中国安全厂商和安全工作者,在系统安全领域和分析工作中,正在不断取得新的进步。在国际网络安全企业中,华人承担关键系统安全研究和分析工作的占比也很高,国际高校的一些华人研究者在新兴领域展开系统安全研究,同样取得了很大的国际影响。遗憾的是,Web和应用开发的兴起,一定程度上让更多年轻安全从业人员的目光转向Web和应用安全,当前新的安全从业者整体的二进制分析能力正在下降,存在着基本功缺失的问题,非常需要系统性的补课。

二进制分析能力,是对安全工程师的基础能力要求。但要对抗APT等新兴威胁,仅仅有基本功是不够的,还需要在更开阔的架构视野上,具备从攻击链、体系化等角度逆向分析研判攻击行为的全程、攻击对手的全貌的能力。

当年受到一些破解教程的误导,一些接触逆向工程的年轻人把破解序列号等当成逆向工程的目的,从而导致浅尝辄止,没有真正理解逆向工程的博大精深。即使是安天部分资深的分析工程师,也往往更乐于寻找解析攻击者的个性技巧和那些有“难度”的分析细节,并常以此感到自豪,而不能站到对攻击作业过程完整复盘和对攻击者画像的角度来看待问题,而后者才是一类高阶的逆向工程。

我们是从分析“震网”所遇到的系列挫折中,从对Flame蠕虫马拉松式的、收效甚微的模块分析中,认识到了传统分析工程师所面临的这种困境的。在“震网”分析中,由国际知名厂商的架构师和分析工程师所组成的混编分析团队,就能比安天完全由软硬件安全分析工程师组成的分析小组,取得更多的分析成果和进展。因为安全厂商和APT攻击之间,不是简单的查杀反制和单点的分析对抗,而是体系化对抗。从认识到这个问题开始,安天的分析团队一方面继续强化二进制分析的基本功底;另一方面自我批判那种“视野从入口点开始”的狭隘分析视角,避免我们自己从透析攻击者载荷的高级技巧中找到太多“快感”,而忘记那些更重要的全局性因素。

过去三年间,安天发布了针对APT-TOCS、白象、方程式等组织相关的多篇分析报告,也分析了类似乌克兰国家电网遭遇攻击停电等与关键基础设施有关的安全事件。在乌克兰停电事件的分析中,我们已经初步走出了入口点视野,而尝试在更大的时空视角上还原事件。

而即使从微观的分析来看,高阶恶意代码也已经从一个附着在宿主上的代码片段或单体文件的木马,进化为一个庞大的工程体系。对于高级恶意代码来说,不仅有前端的持久化载荷和大量按需投放的“原子化”模块,其后更有一个庞大的支撑工程体系。对于前端,我们还能够依靠代码逆向来分析,而对于后面这个大到无形的“魅影”,我们更多的只能依靠分析恶意代码的指令体系、有限的网络感知捕获、少得可怜的可公开检索信息,以及作为信息安全动物的逻辑和本能来进行猜测。我们所要完成的工作,不仅仅是要逐一进行载荷模块分析,而是要对整个攻击面和攻击链的深入复盘。

2017年1月25日,安天分析小组更新了针对方程式攻击组织的第四篇分析报告《方程式组织EQUATION DRUG平台解析》,基于对方程式组织大量恶意代码模块的分析,我们初步完成了一份有价值的工作,那就是方程式组织的主机作业模块积木拼图。

我的同事在《方程式组织EQUATION DRUG平台解析》中写道:

“这些庞杂的模块展开了一组拼图碎片,每一张图上都有有意义的图案,但如果逐一跟进,这些图案就会组成一个巨大的迷宫。迷宫之所以让人迷惑,不在于其处处是死路,而在于其看起来处处有出口,但所有的砖块都不能给进入者以足够的提示。此时,最大的期待,不只是有一只笔,可以在走过的地方做出标记,而是插上双翼凌空飞起,俯瞰迷宫的全貌,当然这是一种提升分析方法论的自我期待。我辈当下虽身无双翼,但或可练就一点灵犀。”

所有高阶的分析工作,都是以最基础的代码分析为起点的。无论是面对APT攻击的深度分析,还是对高级黑产行为的系统挖掘,仅有基本分析固然是远远不够的,但没有最基本的分析是万万不能的。从未来安全工程师所需要的能力来看,熟读本书并不能使你走出迷宫。但不掌握本书相关的基础能力,不能去透视代码迷宫的砖石,就连走入迷宫的资格都没有。也正因为此,本书是安天工程师人手一本的必备手册和必读之物。

肖新光

安天创始人,首席技术架构师,反病毒老兵


“逆向工程”一词用在软件工程领域的具体含义历来模糊不清。逆向工程是一种分析目标系统的过程,旨在识别系统的各组件以及组件之间的关系,以便通过其他形式或在较高的抽象层次上,重建系统的表征。按照目标系统的分类划分,人们常说的“逆向工程”大体可分为:

(1)软件逆向工程。研究编译后的可执行程序。

(2)建模逆向工程。扫描3D结构并进行后续数据处理,以便重现原物。

(3)重建DBMS结构。

本书仅涉及上述第一项,即软件逆向工程的知识范畴。

x86/x64、ARM/ARM64、MIPS、Java/JVM。

本书中涉及如下一些话题:

Oracle RDBMS(第81章)、Itanium(IA64,第93章)、加密狗(第78章)、LD_PRELOAD(67.2节)、栈溢出、ELF、Win32 PE文件格式(68.2节)、x86-64(26.1节)、critical sections(68.4节)、syscalls(第66章),线程本地存储TLS,地址无关代码PIC(67.1节)、以配置文件为导向的优化(95.1节)、C++ STL(51.4节)、OpenMP(第92章),SEH(68.3节)。

姓名:Dennis Yurichev

特长:逆向工程及计算机编程

联系方式:E-mail dennis(a)yurichev.com

     Skype dennis.yurichev

安天安全研究与应急处理中心(安天CERT),是承担安天安全威胁应急处理、恶意代码分析、APT攻击分析取证等方面工作的综合研究和服务的部门,由资深安全工程师团队组成。安天CERT以“第一时间启动,同时应对两线严重威胁”为自身能力建设目标,在红色代码Ⅱ、口令蠕虫、震荡波、冲击波等重大安全疫情的响应中,提供了先发预警、深度分析和解决方案;并在2010年后针对震网、毒曲、白象、方程式等APT攻击组织和行动,进行了深入的跟踪分析。分析成果有效推动了安天核心引擎和产品能力的成长,获得了主管部门和用户的好评。

感谢耐心回答我提问的Andrey “herm1t” Baranovich和Slava “Avid” Kazakov。

感谢帮助我勘误的Stanislav “Beaver” Bobrytskyy、Alexander Lysenko、Shell Rocket、Zhu Ruijin和Changmin Heo。

感谢Andrew Zubinski、Arnaud Patard (rtp on #debian-arm IRC)和Aliaksandr Autayeu的鼎力支持。

感谢本书的中文版翻译Archer和安天安全研究与应急处理中心。

感谢本书的韩语版翻译Byungho Min。

感谢校对人员Alexander “Lstar” Chernenkiy、Vladimir Botov、Andrei Brazhuk、Mark “Logxen” Cooper、 Yuan Jochen Kang、Mal Malakov、Lewis Porter和Jarle Thorsen。

特别感谢承担最多校对工作的,同时也是补救最多纰漏的朋友Vasil Kolev。

感谢封面设计Andy Nechaevsky。

感谢github.com的朋友,谢谢他们所发的各种资料以及勘误。

本书使用了LaTeX的多种工具。在此,我向它们的作者表示敬意。

希望鼓励作者继续创作的读者,通过下述网站进行捐赠:

Dennis Yurichevdonate.html

为了表示感谢,每位捐赠者都会获得题名。此外,捐赠者感兴趣的内容将会被优先更新或补充。

25 * anonymous, 2 * Oleg Vygovsky (50+100 UAH), Daniel Bilar ($50), James Truscott ($4.5), Luis Rocha ($63), Joris van de Vis ($127), Richard S Shultz ($20), Jang Minchang ($20), Shade Atlas (5 AUD), Yao Xiao ($10), PawelSzczur (40 CHF), Justin Simms ($20), Shawn the R0ck ($27), Ki Chan Ahn ($50), Triop AB (100 SEK), AngeAlbertini (10+50 EUR), Sergey Lukianov (300 RUR), LudvigGislason (200 SEK), Gérard Labadie (40 EUR), Sergey Volchkov (10 AUD), VankayalaVigneswararao ($50), Philippe Teuwen ($4), Martin Haeberli ($10), Victor Cazacov (5 EUR), Tobias Sturzenegger (10 CHF), Sonny Thai ($15), BaynaAlZaabi ($75), Redfive B.V. (25 EUR), JoonaOskariHeikkilä (5 EUR), Marshall Bishop ($50), Nicolas Werner (12 EUR), Jeremy Brown ($100), Alexandre Borges ($25), Vladimir Dikovski (50 EUR), Jiarui Hong (100.00 SEK), Jim_Di (500 RUR), Tan Vincent ($30), Sri HarshaKandrakota (10 AUD), Pillay Harish (10 SGD), TimurValiev (230 RUR), Carlos Garcia Prado (10 EUR), Salikov Alexander (500 RUR), Oliver Whitehouse (30 GBP), Katy Moe ($14), Maxim Dyakonov ($3), Sebastian Aguilera (20 EUR), Hans-Martin Münch (15 EUR), JarleThorsen (100 NOK), VitalyOsipov ($100)。

Q:学习汇编语言有何用武之地?

A:除去开发操作系统的研发人员之外,现在几乎没有什么人还要用汇编语言编写程序了。目前,编译程序优化汇编指令的水平已经超越编程人员的脑算水平[1],而且CPU越来越复杂——即使一个人具备丰富的汇编语言的知识,也不代表他有多么了解计算机硬件。但是不可否认的是,汇编语言的知识至少有两大用处:首先,它有助于安全人员进行安全研究、分析恶意软件;其次,它还有助于帮助编程人员调试程序。本书旨在帮助人们理解汇编语言,而不是要指导读者用汇编语言进行编程。所以,作者组织了大量的源代码和对应的汇编指令,供读者研究。

Q:这本书太厚了,有没有精简版?

A:精简版可从网上下载:http://beginners.re/#lite

Q:逆向工程方面的就业情况如何?

A:在reddit等著名网站里,很多著名公司一直在招聘熟悉汇编语言和逆向工程的IT专家,甚至专门招聘逆向工程领域的安全专家。有兴趣的读者可以访问以下网址:

http://www.reddit.com/r/ReverseEngineering/

http://www.reddit.com/r/netsec/comments/221xxu/rnetsecs_q2_2014_information_security_hiring

Q:我想要提些问题……

A:作者的邮件地址是:dennis(a)yurichev.com。

读者还可以在我们的网站forum.yurichev.com参与互动。

[1] 可查阅参考文献[Fog13b]。


在最初接触C/C++时,我就对程序编译后的汇编指令十分着迷。按照从易到难的顺序,我循序渐进地研究了C/C++语言编译器生成汇编指令的模式。经过日积月累的努力,现在我不仅可以直接阅读x86程序的汇编代码,而且能够在脑海里将其还原成原始的C/C++语句。我相信这是学习逆向工程的有效方法。为了能够帮助他人进行相关研究,我把个人经验整理成册,以待与读者分享。

本书包含大量x86/x64和ARM框架的范例。如果读者熟悉其中某一种框架,可以跳过相关的篇幅。


CPU是执行程序机器码的硬件单元。简要地说,其相关概念主要有以下几项。

指令码:CPU受理的底层命令。典型的底层命令有:将数据在寄存器间转移、操作内存、计算运算等指令。每类CPU都有自己的指令集架构(Instruction Set Architecture,ISA)。

机器码:发送给CPU的程序代码。一条指令通常被封装为若干字节。

汇编语言:为了让程序员少长白头发而创造出来的、易读易记的代码,它有很多类似宏的扩展功能。

CPU寄存器:每种CPU都有其固定的通用寄存器(GPR)。x86 CPU里一般有8个GPR,x64里往往有16个GPR,而ARM里则通常有16个GPR。您可以认为CPU寄存器是一种存储单元,它能够无差别地存储所有类型的临时变量。假如您使用一种高级的编程语言,且仅会使用到8个32位变量,那么光CPU自带的寄存器就能完成不少任务了!

那么,机器码和编程语言(PL)的区别在哪里?CPU可不像人类那样,能够理解C/C++、Java、Python这类较为贴近人类语言的高级编程语言。CPU更适合接近硬件底层的具体指令。在不久的将来,或许会出现直接执行高级编程语言的CPU,不过那种尚未问世的科幻CPU必定比现在的CPU复杂。人脑和计算机各有所长,如果人类直接使用贴近硬件底层的汇编语言编写程序,其难度也很高——因为那样很容易出现大量的人为失误。可见,我们需要用一种程序把高级的编程语言转换为CPU能受理的底层汇编语言,而这种程序就是人们常说的编译器/Compiler。

在x86的指令集架构(ISA)里,各opcode(汇编指令对应的机器码)的长度不尽相同。出于兼容性的考虑,后来问世的64位CPU指令集架构也没有大刀阔斧地摒弃原有指令集架构。很多面向早期16位8086 CPU的指令,不仅被x86的指令集继承,而且被当前最新的CPU指令集继续沿用。

ARM属于RISC[1] CPU,它的指令集在设计之初就力图保持各opcode的长度一致。在过去,这一特性的确表现出了自身的优越性。最初的时候,所有ARM指令的机器码都被封装在4个字节里[2]。人们把这种运行模式叫作“ARM模式”。

不久,他们就发现这种模式并不划算。在实际的应用程序中,绝大多数的CPU指令[3]很少用满那4个字节。所以他们又推出了一种把每条指令封装在2个字节的“Thumb”模式的指令集架构。人们把采用这种指令集编码的指令叫作“Thumb模式”指令。然而Thumb指令集并不能够封装所有的ARM指令,它本身存在指令上的局限。当然,在同一个程序里可以同时存在ARM模式和Thumb模式这两种指令。

之后,ARM的缔造者们决定扩充Thumb指令集。他们自ARM v7平台开始推出了Thumb-2指令集。Thumb-2指令基本都可封装在2个字节的机器码之中,2个字节封装不下的指令则由4字节封装。现在,多数人依然错误地认为“Thumb-2指令集是ARM指令集和Thumb指令集的复合体”。实际上,它是一种充分利用处理器性能、足以与ARM模式媲美的独立的运行模式。在扩展了Thumb模式的指令集之后,Thumb-2现在与ARM模式不相上下。由于Xcode编译器默认采用Thumb-2指令集编译,所以现在主流的iPod/iPhone/iPad应用程序都采用了Thumb-2指令集。

64位的ARM处理器接踵而至。这种CPU的指令集架构再次使用固定长度的4字节opcode,所以不再支持Thumb模式的指令。相应地,64位ARM工作于自己的指令集。受到指令集架构的影响,ARM指令集分为3类:ARM模式指令集、Thumb模式指令集(包括Thumb-2)和ARM64的指令集。虽然这些指令集之间有着千丝万缕的联系,需要强调的是:不同的指令集分别属于不同的指令集架构;一个指令集绝非另一个指令集的变种。相应地,本书会以3种指令集、重复演示同一程序的指令片段,充分介绍ARM应用程序的特点。

除了ARM 处理器之外,还有许多处理器都采用了精简指令集。这些处理器多数都使用了固定长度的32位opcode。例如MIPS、PowerPC和Alpha AXP处理器就是如此。

[1] Reduced instruction computing /精简指令集。

[2] 这种固定长度的指令集,特别便于计算前后指令的地址。有关特性将在13.2.2节进行介绍。

[3] 即MOV/PUSH/CALL/Jcc等指令。


返回预定常量的函数,已经算得上是最简单的函数了。

本章围绕下列函数进行演示:

指令清单2.1 C/C++ 代码

int f() 
{
         return 123;
}

在开启优化功能之后,GCC编译器产生的汇编指令,如下所示。

指令清单2.2 Optimizing GCC/MSVC(汇编输出)

f:
        mov   eax, 123
        ret

MSVC编译的程序和上述指令完全一致。

这个函数仅由两条指令构成:第一条指令把数值123存放在EAX寄存器里;根据函数调用约定[1],后面一条指令会把EAX的值当作返回值传递给调用者函数,而调用者函数(caller)会从EAX寄存器里取值,把它当作返回结果。

ARM模式是什么情况?

指令清单2.3 Optimizing Keil 6/2013 (ARM模式)

f PROC
         MOV    r0,#0x7b ; 123
         BX     lr 
         ENDP

ARM程序使用R0寄存器传递函数返回值,所以指令把数值123赋值给R0。

ARM程序使用LR寄存器(Link Register)存储函数结束之后的返回地址(RA/ Return Address)。x86程序使用“栈”结构存储上述返回地址。可见,BX LR指令的作用是跳转到返回地址,即返回到调用者函数,然后继续执行调用体caller的后续指令。

如您所见,x86和ARM指令集的MOV指令确实和对应单词“move”没有什么瓜葛。它的作用是复制(copy),而非移动(move)。

在MIPS指令里,寄存器有两种命名方式。一种是以数字命名($0~$31),另一种则是以伪名称(pseudoname)命名($V0~VA0,依此类推)。在GCC编译器生成的汇编指令中,寄存器都采用数字方式命名。

指令清单2.4 Optimizing GCC 4.4.5(汇编输出)

  j       $31
  li      $2,123          # 0x7b

然而IDA则会显示寄存器的伪名称。

指令清单2.5 Optimizing GCC 4.4.5(IDA)

        jr      $ra
        li      $v0, 0x7B

根据伪名称和寄存器数字编号的关系可知,存储函数返回值的寄存器都是$2(即$V0)。此处LI指令是英文词组“Load Immediate(加载立即数)”的缩写。

其中,J和JR指令都属于跳转指令,它们把执行流递交给调用者函数,跳转到$31即$RA寄存器中的地址。这个寄存器相当于的ARM平台的LR寄存器。

此外,为什么赋值指令LI和转移指令J/JR的位置反过来了?这属于RISC精简指令集的特性之一——分支(转移)指令延迟槽 (Branch delay slot)的现象。简单地说,不管分支(转移)发生与否,位于分支指令后面的一条指令(在延时槽里的指令),总是被先于分支指令提交。这是RISC精简指令集的一种特例,我们不必在此处深究。总之,转移指令后面的这条赋值指令实际上是在转移指令之前运行的。

习惯上,MIPS领域中的寄存器名称和指令名称都使用小写字母书写。但是为了在排版风格上与其他指令集架构的程序保持一致,本书采用大写字母进行排版。

[1] Calling Convention,又称为函数的调用协定、调用规范。


现在,我们开始演示《C语言编程》一书[1]中著名的程序:

#include <stdio.h>

int main() 
{
    printf("hello, world\n");
    return 0;
}

接下来我们将通过下述指令,使用MSVC 2010编译下面这个程序。

cl 1.cpp /Fa1.asm

其中/Fa选项将使编译器生成汇编指令清单文件(assembly listing file),并指定汇编列表文件的文件名称是1.asm。

上述命令生成的1.asm内容如下。

指令清单3.1 MSVC 2010

CONST   SEGMENT
$SG3830 DB       'hello, world', 0AH, 00H
CONST   ENDS
PUBLIC  _main
EXTRN   _printf:PROC
; Function compile flags: /Odtp
_TEXT   SEGMENT
_main   PROC
        push    ebp
        mov     ebp, esp
        push    OFFSET $SG3830
        call    _printf
        add     esp, 4
        xor     eax, eax
        pop     ebp
        ret     0
_main   ENDP
_TEXT   ENDS

MSVC生成的汇编清单文件都采用了Intel语体。汇编语言存在两种主流语体,即Intel语体和AT&T语体。本书将在3.1.3节中讨论它们之间的区别。

在生成1.asm之后,编译器会生成1.obj再将之链接为可执行文件1.exe。

在hello world这个例子中,文件分为两个代码段,即CONST和_TEXT段,它们分别代表数据段和代码段。在本例中,C/C++程序为字符串常量“hello,world”分配了一个指针(const char[]),只是在代码中这个指针的名称并不明显(参照下列Bjarne Stroustrup. The C++ Programming Language, 4th Edition. 2013的第176页,7.3.2节)。

接下来,编译器进行了自己的处理,并在内部把字符串常量命名为$SG3830。

因此,上述程序的源代码等效于:

#include <stdio.h>

const char *$SG3830[]="hello, world\n";

int main() 
{
    printf($SG3830);
    return 0; 
}

在回顾1.asm文件时,我们会发现编译器在字符串常量的尾部添加了十六进制的数字0,即00h。依据C/C++字符串的标准规范,编译器要为这个字符串常量添加结束标志(即数值为零的单个字节)。有关标准请参照本书的57.1.1节。

在代码段_TEXT只有1个函数,即主函数main()。在汇编指令清单里,主函数的函数体有标志性的函数序言(function prologue)和函数尾声(function epilogue)。实际上所有的函数都有这样的序言和尾声。在函数的序言标志之后,我们能够看到调用printf()函数的指令: CALL _printf。

通过PUSH指令,程序把字符串的指针推送入栈。这样,printf()函数就可以调用栈里的指针,即字符串“hello, world!”的地址。

在printf()函数结束以后,程序的控制流会返回到main()函数之中。此时,字符串地址(即指针)仍残留在数据栈之中。这个时候就需要调整栈指针(ESP寄存器里的值)来释放这个指针。

下一条语句是“add ESP,4”,把ESP寄存器(栈指针/Stack Pointer)里的数值加4。

为什么要加上“4”?这是因为x86平台的内存地址使用32位(即4字节)数据描述。同理,在x64系统上释放这个指针时,ESP就要加上8。

因此,这条指令可以理解为“POP某寄存器”。只是本例的指令直接舍弃了栈里的数据而POP指令还要把寄存器里的值存储到既定寄存器[2]

某些编译器(如Intel C++编辑器)不会使用ADD指令来释放数据栈,它们可能会用POP ECX指令。例如,Oracle RDBMS(由Intel C++编译器编译)就会用POP ECX指令,而不会用ADD指令。虽然POP ECX命令确实会修改ECX寄存器的值,但是它也同样释放了栈空间。

Intel C++编译器使用POP ECX指令的另外一个理由就是,POP ECX对应的OPCODE(1字节)比ADD ESP的OPCODE(3字节)要短。

指令清单3.2 Oracle RDBMS 10.2 Linux (摘自app.o)

.text:0800029A      push    ebx
.text:0800029B      call    qksfroChild
.text:080002A0      pop     ecx

本书将在讨论操作系统的部分详细介绍数据栈。

在上述C/C++程序里,printf()函数结束之后,main()函数会返回0(函数正常退出的返回码)。即main()函数的运算结果是0。

这个返回值是由指令“XOR EAX, EAX”计算出来的。

顾名思义,XOR就是“异或” [3]。编译器通常采用异或运算指令,而不会使用“MOV EAX,0”指令。主要是因为异或运算的opcode较短(2字节:5字节)。

也有一些编译器会使用“SUB EAX,EAX”指令把EAX寄存器置零,其中SUB代表减法运算。总之,main()函数的最后一项任务是使EAX的值为零。

汇编列表中最后的操作指令是RET,将控制权交给调用程序。通常它起到的作用就是将控制权交给操作系统,这部分功能由C/C++的CRT[4]实现。

接下来,我们使用GCC 4.4.1编译器编译这个hello world程序。

gcc 1.c -o 1

我们使用反汇编工具IDA(Interactive Disassembler)查看main()函数的具体情况。IDA所输出的汇编指令的格式,与MSVC生成的汇编指令的格式相同,它们都采用Intel语体显示汇编指令。

此外,如果要让GCC编译器生成Intel语体的汇编列表文件,可以使用GCC的选项“-S -masm=intel”。

指令清单3.3 在IDA中观察到的汇编指令

Main         proc near
var_10       = dword ptr -10h

             push    ebp
             mov     ebp, esp
             and     esp, 0FFFFFFF0h
             sub     esp, 10h
             mov     eax, offset aHelloWorld ; "hello, world\n"
             mov     [esp+10h+var_10], eax
             call    _printf
             mov     eax, 0
             leave
             retn
main         endp

GCC生成的汇编指令,与MSVC生成的结果基本相同。它首先把“hello, world”字符串在数据段的地址(指针)存储到EAX寄存器里,然后再把它存储在数据栈里。

其中值得注意的还有开场部分的“AND ESP, 0FFFFFFF0h”指令。它令栈地址(ESP的值)向16字节边界对齐(成为16的整数倍),属于初始化的指令。如果地址位没有对齐,那么CPU可能需要访问两次内存才能获得栈内数据。虽然在8字节边界处对齐就可以满足32位x86 CPU和64位x64 CPU的要求,但是主流编译器的编译规则规定“程序访问的地址必须向16字节对齐(被16整除)”。人们还是为了提高指令的执行效率而特意拟定了这条编译规范。[5]

“SUB ESP,10h”将在栈中分配0x10 bytes,即16字节。我们在后文看到,程序只会用到4字节空间。但是因为编译器对栈地址(ESP)进行了16字节对齐,所以每次都会分配16字节的空间。

而后,程序将字符串地址(指针的值)直接写入到数据栈。此处,GCC使用的是MOV指令;而MSVC生成的是PUSH指令。其中var_10是局部变量,用来向后面的printf()函数传递参数。

随即,程序调用printf()函数。

GCC和MSVC不同,除非人工指定优化选项,否则它会生成与源代码直接对应的“MOV EAX, 0”指令。但是,我们已经知道MOV指令的opcode肯定要比XOR指令的opcode长。

最后一条LEAVE指令,等效于“MOV ESP, EBP”和“POP EBP”两条指令。可见,这个指令调整了数据栈指针ESP,并将EBP的数值恢复到调用这个函数之前的初始状态。毕竟,程序段在开始部分就对EBP和EBP进行了操作(MOVEBP, ESP/AND ESP, ...),所以函数要在退出之前恢复这些寄存器的值。

AT&T语体同样是汇编语言的显示风格。这种语体在UNIX之中较为常见。

接下来,我们使用GCC4.7.3编译如下所示的源程序。

指令清单3.4 使用GCC 4.7.3 编译源程序

gcc –S 1_1.c

上述指令将会得到下述文件。

指令清单3.5 GCC 4.7.3生成的汇编指令

        .file   "1_1.c"
        .section       .rodata
.LC0:
        .string  "hello, world\n"
        .text
        .globl    main
        .type     main, @function
main: 
.LFB0:
        .cfi_startproc
        pushl    %ebp
        .cfi_def_cfa_offset 8
        .cfi_offset 5, -8
        movl     %esp, %ebp
        .cfi_def_cfa_register 5
        andl     $-16, %esp
        subl     $16, %esp
        movl     $.LC0, (%esp)
        call     printf
        movl     $0, %eax
        leave
        .cfi_restore 5
        .cfi_def_cfa 4, 4
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3"
        .section        .note.GNU-stack,"",@progbits

在上述代码里,由小数点开头的指令就是宏。这种形式的汇编语体大量使用汇编宏,可读性很差。为了便于演示,我们将其中字符串以外的宏忽略不计(也可以启用GCC的编译选项-fno-asynchronous-unwind-tables,直接预处理为没有cfi宏的汇编指令),将会得到如下指令。

指令清单3.6 GCC 4.7.3生成的指令

.LC0:
        .string "hello, world\n"
main:
        pushl   %ebp
        movl    %esp, %ebp
        andl    $-16, %esp
        subl    $16, %esp
        movl    $.LC0, (%esp)
        call    printf
        movl    $0, %eax
        leave
        ret

在继续解读这个代码之前,我们先介绍一下Intel语体和AT&T语体的区别。

现在再来阅读hello world的AT&T语体指令,就会发现它和IDA里看到的指令没有实质区别。有些人可能注意到,用于数据对齐的0FFFFFFF0h在这里变成了十进制的$-16——把它们按照32byte型数据进行书写后,就会发现两者完全一致。

此外,在退出main()时,处理EAX寄存器的指令是MOV指令而不是XOR指令。MOV的作用是给寄存器赋值(load)。某些硬件框架的指令集里有更为直观的“LOAD”“STORE”之类的指令。

若用64位MSVC编译上述程序,则会得到下述指令。

指令清单3.7 MSVC 2012 x64

$SG2989  DB      'hello, world', 0AH 00H

main     PROC
         sub     rsp, 40
         lea     rcx, OFFSET FLAT:$SG2989
         call    printf
         xor     eax, eax
         add     rsp, 40
         ret     0
main     ENDP

在x86-64框架的CPU里,所有的物理寄存器都被扩展为64位寄存器。程序可通过R-字头的名称直接调用整个64位寄存器。为了尽可能充分地利用寄存器、减少访问内存数据的次数,编译器会充分利用寄存器传递函数参数(请参见64.3节的fastcall约定)。也就是说,编译器会优先使用寄存器传递部分参数,再利用内存(数据栈)传递其余的参数。Win64的程序还会使用RCX、RDX、R8、R9这4个寄存器来存放函数参数。我们稍后就会看到这种情况:printf()使用RCX寄存器传递参数,而没有像32位程序那样使用栈传递数据。

在x86-64硬件平台上,寄存器和指针都是64位的,存储于R-字头的寄存器里。但是出于兼容性的考虑,64位寄存器的低32位,也要能够担当32位寄存器的角色,才能运行32位程序。

在64位x86兼容的CPU中,RAX/EAX/AX/AL的对应关系如下。

7th (字节号)
6th
5th
4th
3rd
2nd
1st
0th
RAX x64
EAX
AX
AH
AL

main()函数的返回值是整数类型的零,但是出于兼容性和可移植性的考虑,C语言的编译器仍将使用32位的零。换而言之,即使是64位的应用程序,在程序结束时EAX的值是零,而RAX的值不一定会是零。

此时,数据栈的对应空间里仍留有40字节的数据。这部分数据空间有个专用的名词,即阴影空间(shadow space)。本书将在8.2.1节里更详细地介绍它。

我们使用64位Linux的GCC编译器编译上述程序,可得到如下所示的指令。

指令清单3.8 GCC 4.4.6 x64

.string "hello, world\n"
main:
         sub      rsp, 8
         mov      edi, OFFSET FLAT:.LC0 ; "hello, world"
         xor      eax, eax  ; number of vector registers passed
         call     printf
         xor      eax, eax
         add      rsp, 8
         ret

Linux、BSD和Mac OS X系统中的应用程序,会优先使用RDI、RSI、RDX、RCX、R8、R9这6个寄存器传递函数所需的头6个参数,然后使用数据栈传递其余的参数。[6]

因此,64位的GCC编译器使用EDI寄存器(寄存器的32位)存储字符串指针。EDI不过是RDI寄存器中地址位较低的32位地址部分。为何GCC不直接使用整个RDI寄存器?

需要注意的是,64位汇编指令MOV在写入R-寄存器的低32位地址位的时候,即对E-寄存器进行写操作的时候,会同时清除R-寄存器中的高32位地址位[7]。所以, “MOV EAX, 011223344h”能够对RAX寄存器进行正确的赋值操作,因为该指令会清除(置零)高地址位的内容。

如果打开GCC生成的obj文件,我们就能看见全部的opcode。[8]

指令清单3.9 GCC 4.4.6 x64

.text:00000000004004D0                   main  proc near
.text:00000000004004D0 48 83 EC 08       sub     rsp, 8
.text:00000000004004D4 BF E8 05 40 00    mov     edi, offset format ; "hello, world\n"
.text:00000000004004D9 31 C0             xor     eax, eax
.text:00000000004004DB E8 D8 FE FF FF    call    _printf
.text:00000000004004E0 31 C0             xor     eax, eax
.text:00000000004004E2 48 83 C4 08       add     rsp, 8
.text:00000000004004E6 C3                retn
.text:00000000004004E6                   main  endp

在地址0x4004D4处,程序对EDI 进行了写操作,这部分代码的opcode占用了5个字节;相比之下,对RDI进行写操作的opcode则会占用7个字节。显然,出于空间方面的考虑,GCC进行了相应的优化处理。此外,因为32位地址(指针)能够描述的地址不超过4GB,我们可据此判断这个程序的数据段地址不会超过4GB。

在调用printf()之前,程序清空了EAX寄存器,这是x86-64框架的系统规范决定的。在系统与应用程序接口的规范中,EAX寄存器用来保存用过的向量寄存器(vector registers)。[9]

只要C语言代码里使用了字符串型常量(可参照3.1.1节的范例),编译器就会把这个字符串常量置于常量字段,以保证其内容不会发生变化。不过GCC有个有趣的特征:它可能会把字符串拆出来单独使用。

我们来看下面这段程序:

#include <stdio.h>
void f1() 
{
         printf ("world\n");
}

void f2() 
{
         printf ("hello world\n");
}

int main() 
{
         f1();
         f2(); 
}

多数的C/C++编译器(包括MSVC编译器)会分配出两个直接对应的字符串,不过GCC 4.8.1的编译结果则更为可圈可点。

指令清单3.10 在IDA中观察GCC 4.8.1 的汇编指令

f1                  proc near

s                   = dword ptr -1Ch

                    sub     esp, 1Ch
                    mov     [esp+1Ch+s], offset s ; "world\n"
                    call    _puts
                    add     esp, 1Ch
                    retn
f1                  endp

f2                  proc near

s                   = dword ptr -1Ch

                    sub     esp, 1Ch
                    mov     [esp+1Ch+s], offset aHello ; "hello "
                    call    _puts
                    add     esp, 1Ch
                    retn
f2                  endp

aHello              db  'hello'
s                   db  'world', 0xa, 0

在打印字符串“hello world”的时候,这两个词的指针地址实际上是前后相邻的。在调用puts()函数进行输出时,函数本身不知道它所输出的字符串分为两个部分。实际上我们在汇编指令清单中可以看到,这两个字符串没有被“切实”分开。

在f1()函数调用puts()函数时,它输出字符串“world”和外加结束符(数值为零的1个字节),因为puts()函数并不知道字符串可以和前面的字符串连起来形成新的字符串。

GCC编译器会充分这种技术来节省内存。

根据我个人的经验,本书将通过以下几个主流的ARM编译器进行演示。

除非特别标注,否则本书中的ARM程序都是32位ARM程序。在介绍64位的ARM程序时,本书会称其为ARM64程序。

请使用下述指令,用Keil编译器把hello world程序编译为ARM指令集架构的汇编程序:

armcc.exe --arm --c90 -O0 1.c

虽然armcc编译器生成的汇编指令清单同样采用了Intel语体,但是程序所使用的宏却极具ARM处理器的特色[11]。眼见为实,我们一起用IDA来看看它们的本来面目吧。

指令清单3.11 使用IDA观察Non-optimizing Keil 6/2013 (ARM模式)

.text:00000000               main
.text:00000000 10 40 2D E9    STMFD   SP!, {R4,LR}
.text:00000004 1E 0E 8F E2    ADR     R0, aHelloWorld ; "hello, world"
.text:00000008 15 19 00 EB    BL      __2printf
.text:0000000C 00 00 A0 E3    MOV     R0, #0
.text:00000010 10 80 BD E8    LDMFD   SP!, {R4,PC}

.text:000001EC 68 65 6C 6C+aHelloWorld  DCB "hello, world",0    ; DATA XREF: main+4

在本节的例子里,每条指令都占用4个字节。正如您所见到,我们确实要把源程序编译为ARM模式指令集的应用程序,而不是把它编译为以Thumb模式的应用程序。

现在回顾上面的代码,第一句“STMFD  SP!, {R4,LR}”[12]相当于x86的PUSH指令。它把R4寄存器和LR(Link Register)寄存器的数值放到数据栈中。此处,本文的措辞是“相当于”,而非“完全是”。这是因为ARM模式的指令集里没有PUSH指令,只有Thumb模式里的指令集里才有“PUSH/POP”指令。在IDA中可以清楚地看到这种差别,所以本书推荐使用IDA分析上述程序。

这条指令首先将SP[13]递减,在栈中分配一个新的空间以便存储R4和LR的值。

STMFD指令能够一次存储多个寄存器的值,Thumb模式的PUSH指令也可以这样使用。实际上x86指令集中并没有这样方便的指令。STMFD指令可看作是增强版本的PUSH指令,它不仅能够存储SP的值,也能够存储任何寄存器的值。换句话说,STMFD可用来在指定的内存空间存储多个寄存器的值。

接下来的指令是“ADR R0, aHelloWorld”。它首先对PC[14]进行取值操作,然后把“hello, world”字符串的偏移量(可能为负值)与PC的值相加,将其结果存储到R0之中。有些读者可能不明白此处PC寄存器的作用。严谨地说,编译器通常帮助PC把某些指令强制变为“位置无关代码/position-independent code”。在(多数)操作系统把程序加载在内存里的时候,OS分配给程序代码的内存地址是不固定的;但是程序内部既定指令和数据常量之间的偏移量是固定的(由二进制程序文件决定)。这种情况下,要在程序内部进行指令寻址(例如跳转等情况),就需要借助PC指针[15]。ADR将当前指令的地址与字符串指针地址的差值(偏移量)传递给R0。程序借助PC指针可找到字符串指针的偏移地址,从而使操作系统确定字符串常量在内存里的绝对地址。

“BL __2printf”[16]调用printf()函数。BL实施的具体操作实际上是:

当printf()完成工作之后,计算机必须知道返回地址,即它应当从哪里开始继续执行下一条指令。所以,每次使用BL指令调用其他函数之前,都要把BL指令的下一个指令的地址存储到LR寄存器。

这便是CISC(复杂指令集)处理器与RISC(精简指令集)处理器在工作模式上的区别。在拥有复杂指令集的x86体系里,操作系统可以利用栈存储返回地址。

顺便说一下,ARM模式跳转指令的寻址能力确实存在局限性。单条ARM模式的指令必须是32位/4字节,所以BL指令无法调用32位绝对地址或32位相对地址(容纳不下),它只能编入24位的偏移量。不过,既然每条指令的opcode必须是4字节,则指令地址必须在4n处,即偏移地址的最后两位必定为零,可在opcode里省略。在处理ARM模式的转移指令时,处理器将指令中的opcode的低24位左移2位,形成26位偏移量,再进行跳转。由此可知,转移指令B/BL的跳转指令的目标地址,大约在当前位置的±32MB区间之内[17]

下一条指令“MOV R0,#0”将R0寄存器置零。Hello World的C代码中,主函数返回零。该指令把返回值写在R0寄存器中。

最后到了“LDMFD SP!, {R4,PC}”这一条指令[18]。它与STMFD成对出现,做的工作相反。它将栈中的数值取出,依次赋值给R4和PC,并且会调整栈指针SP。可以说这条指令与POP指令很相似。main()函数的第一条指令就是STMFD指令,它将R4寄存器和LR寄存器存储在栈中。main()函数在结尾处使用LDMFD指令,其作用是把栈里存储的PC的值和R4寄存器的值恢复回来。

前面提到过,程序在调用其他函数之前,必须把返回地址保存在LR寄存器里。因为在调用printf()函数之后LR寄存器的值会发生改变,所以主函数的第一条指令就要负责保存LR寄存器的值。在被调用的函数结束后,LR寄存器中存储的值会被赋值给PC,以便程序返回调用者函数继续运行。当C/C++的主函数main()结束之后,程序的控制权将返回给OS loader,或者CRT中的某个点,或者作用相似的其他地址。

数据段中的DCB是汇编语言中定义ASCII字符数组/字节数组的指令,相当于x86汇编中的DB指令。

现在以Thumb模式编译前面的源代码:

armcc.exe --thumb --c90 -O0 1.c

我们会在IDA中看到如下指令。

指令清单3.12 使用IDA观察Non-optimizing Keil 6/2013 (Thumb模式)

.text:00000000              main
.text:00000000 10 B5          PUSH    {R4,LR}
.text:00000002 C0 A0          ADR     R0, aHelloWorld;"hello, world"
.text:00000004 06 F0 2E F9    BL      _2printf
.text:00000008 00 20          MOVS    R0, #0
.text:0000000A 10 BD          POP     {R4, PC}

.text:00000304 68 65 6C 6C  +aHelloWorld  DCB "hello, world",0    ; DATA XREF: main+2

Thumb 模式程序的每条指令,都对应着2个字节/16位的opcode,这是Thumb模式程序的特征。但是Thumb模式的跳转指令BL“看上去”占用了4个字节的 opcode,实际上它是由2条指令组成的。单条16位opcode传递的信息太有限,不足以向被调用函数传递PC和偏移量信息。所以,上面BL指令分为2条16位opcode。第一条16位指令可以传递偏移量的高10位,第二条指令可以传递偏移量的低11位。而Thumb模式的opcode都是固定的2 个字节长,目标地址位最后一个位必定是0(Thumb模式的opcode的启始地址位必须是2n),因而会被省略。在执行Thumb模式的转移指令时,处理器会将目标地址左移1位,形成22位的偏移量。即Thumb的BL跳转指令将无法跳到奇数地址,而且跳转指令仅仅能偏移到到当前地址 ±2MB(22位有符号整数的取值区间)附近的范围之内。

程序主函数的其他指令,PUSH和POP工作方式与STMFD/LDMFD相似。虽然表面上看不出来,但是实际上它们也会调整SP指针。ADR指令与前文的作用相同。而MOVS 指令负责把返回值(R0寄存器)置零。

如果不启用优化选项,Xcode 4.6.3 将会产生大量的冗余代码,所以不妨开启优化选项,让其生成最优的代码。请指定编译选项-O3,使用Xcode编译Hello world程序。这将会得到如下所示的汇编代码。

指令清单3.13 Optimizing Xcode 4.6.3 (LLVM) (ARM模式)

__text:000028C4             _helloworld
__text:000028C4 80 40 2D E9   STMFD         SP!, {R7, LR}
__text:000028C8 86 06 01 E3   MOV           R0, #0x1686
__text:000028CC 0D 70 A0 E1   MOV           R7, SP
__text:000028D0 00 00 40 E3   MOVT          R0, #0
__text:000028D4 00 00 8F E0   ADD           R0, PC, R0
__text:000028D8 C3 05 00 EB   BL            _puts
__text:000028DC 00 00 A0 E3   MOV           R0, #0
__text:000028E0 80 80 BD E8   LDMFD         SP!, {R7, PC}

__cstring:00003F62 48 65 6C 6C+aHelloWorld_0  DCB "Hello World!", 0

我们就不再重复介绍STMFD/LDMFD指令了。

第一个MOV指令将字符串“Hello World!”的偏移量,0x1686 赋值到R0寄存器。

根据Apple ABI 函数接口规范[19],R7寄存器担当帧指针(frame pointer)寄存器。

“MOVT R0, #0”将0写到R0寄存器的高16位地址。在ARM模式里,常规的MOV指令只能操作寄存器的低16位地址,而单条ARM指令最多是32位/4字节。当然,寄存器之间传递数据没有这种限制。所以,对寄存器的高位(第16位到第31位)进行赋值操作的MOVT指令应运而生。然而此处的这条MOVT指令可有可无,因为在执行上一条指令“MOV R0, #0x1686”时,R0寄存器的高16位本来就会被清零。这或许就是编译器智能方面的缺陷吧。

“ADD R0,PC,R0”将PC和R0进行求和,计算得出字符串的绝对地址。前文介绍过了“位置无关代码”,我们知道程序运行之后的启始地址并不固定。此处,程序对这个地址进行了必要的修正。

然后,程序通过BL指令调用puts()函数,而没有像前文那样调用printf()函数。这种差异来自于GCC编译器[20],编译器将第一个printf()函数替换为puts()函数(这两个函数的作用几乎相同)。

所谓“几乎”就意味着它们还存在差别事实上,如printf()函数支持“%”开头的控制符,而puts()函数则不支持这类格式化字符串。如果参数里有这类控制符,那么这两个函数的输出结果还会不同。

为什么GCC编译器会做这种替换?大概是由于这种情况下puts()的效率更高吧。由于puts()函数不处理控制符(%)、只是把各个字符输出到stdout设备上,所以puts()函数的运行速度更快[21]

后面的“MOV R0, #0”指令将R0寄存器置零。

默认情况下,Xcode 4.6.3 会启用优化模式,并以Thumb-2模式编译源程序。

指令清单3.14 Optimizing Xcode 4.6.3 (LLVM) (Thumb-2模式)

   __text:00002B6C                   
                                   _hello_world 
   __text:00002B6C 80 B5         PUSH                {R7,LR}
   __text:00002B6E 41 F2 D8 30   MOVW                R0, #0x13D8
   __text:00002B72 6F 46         MOV                 R7, SP
   __text:00002B74 C0 F2 00 00   MOVT.W              R0, #0
   __text:00002B78 78 44         ADD                 R0, PC
   __text:00002B7A 01 F0 38 EA   BLX                 _puts
   __text:00002B7E 00 20         MOVS                R0, #0
   __text:00002B80 80 BD         POP                 {R7, PC}
   __cstring:00003E70 48 65 6C   6F 20+aHelloWorld   DCB "Hello word!",0xA,0

上文提到过,thumb模式的BLX和BL指令以2个16位指令的形式成对出现的。在Thumb-2模式下,BL和BLX指令对应的伪opcode有明显的32位指令特征,其对应的opcode都以0xFx或者0xEx开头。

在显示Thumb和Thumb-2模式程序的opcode时,IDA会以两个字节为单位对调。在显示ARM 模式的指令时,IDA以字节为单位、依次逆序显示其opcode。这是字节序的排版差异。

简要地说,在IDA显示ARM平台的指令时,其显示顺序为:

在IDA中,我们可观察到上述MOVW 、MOVT.W、BLX指令都以0xFx开头。

之后的“MOVW R0,#0x13D8”将立即数写到R0寄存器的低16位地址,同时清除寄存器的高16位。

“MOVT.W R0, #0”的作用与前面一个例子中Thumb模式的MOVT的作用相同,只不过此处是Thumb-2的指令。

在这两个例子中,最显著的区别是Thumb-2模式“BLX”指令。此处的BLX与Thumb模式的BL指令有着根本的区别。它不仅将puts()函数的返回地址RA存入了LR寄存器,将控制权交给了puts()函数,而且还把处理器从Thumb/Thumb-2模式调整为ARM模式;它同时也负责在函数退出时把处理器的运行模式进行还原。总之,它同时实现了模式转换和控制权交接的功能,相当于执行了下面的ARM模式的指令:

__symbolstub1:00003FEC _puts            ; CODE XREF: _hello_world+E
__symbolstub1:00003FEC 44 F0 9F E5      LDR PC, =__imp__puts

聪明的读者可能会问,此处为什么不直接调用puts()函数?

直接调用的空间开销更大。

几乎所有的程序都会用到动态链接库,详细说来Windows的程序基本上都会用到DLL文件、Linux程序差不多都会用到.SO文件、MacOSX系统的程序多数也会用到.dylib文件。常用的库函数通常都放在动态链接库里。本例用到的标准C函数——puts()函数也不例外。

可执行的二进制文件(Windows的PE可执行文件,ELF或Mach-O)都有一个输入表段(import section)。输入表段声明了该程序需要通过外部模块加载的符号链接(函数名称和全局变量),并且含有外部模块名称等信息。

在操作系统执行二进制文件的时候,它的加载程序(OS loader)会依据这个表段加载程序所需要的模块。在它加载该程序主模块的时候,对导入的符号链接进行枚举,逐一分配符号链接的地址。

在本例中,_imp_puts是操作系统加载程序(OS loader)为hello world程序提供的外部函数地址,属于32位变量。程序只需要使用LDR指令取出这个变量,并且将它赋值给PC寄存器,就可以调用puts()函数。

可见,一次性地给每个符号链接分配独立的内存地址,可以大幅度地减少OS loader在加载方面的耗时。

前文已经指出,如果只能靠单条指令、而不借助内存的读取操作,CPU就无法把32位数值(指针或立即数)赋值给寄存器。所以,可以建立一个以ARM模式运行的独立函数,让它专门处理动态链接库的接口问题。此后Thumb模式的代码就可以跳转到这个处理接口功能的单指令专用函数。这种专用函数称为(运行模式的)形实转换函数(thunk function)。

前面有一个ARM模式的编译例子,它就使用BL指令实现相同功能的形实转换函数。但是那个程序使用的指令是BL而不是BLX,可见处理器并没有切换运行模式。

形实转换函数(thunk function)的由来

形实转换函数,是“形参与实参互相转换的函数”的缩写。它不仅是缩写词,而且是外来词。这一专用名词的出处可参见:http://www.catb.org/jargon/html/T/thunk.html

P. Z. Ingerman在1961年首次提出了thunk的概念,这个概念沿用至今:在编译过程中,为满足当时的过程(函数)调用约定,当形参为表达式时,编译器都会产生thunk,把返回值的地址传递给形参。

微软和IBM都对“thunk”一词有定义,将从16位到32位和从32位到16位的转变叫作“thunk”。

GCC

使用GCC 4.8.1将上述代码编译为ARM64程序,可得到如下所示的代码。

指令清单3.15 Non-optimizing GCC 4.8.1 + objdump

 1  0000000000400590 <main>:
 2    400590:        a9bf7bfd     stp     x29, x30, [sp,#-16]!
 3    400594:        910003fd     mov     x29, sp
 4    400598:        90000000     adrp    x0, 400000 <_init-0x3b8>
 5    40059c:        91192000     add     x0, x0, #0x648
 6    4005a0:        97ffffa0     bl      400420 <puts@plt>
 7    4005a4:        52800000     mov     w0, #0x0          //  #0
 8    4005a8:        a8c17bfd     ldp     x29, x30, [sp],#16
 9    4005ac:        d65f03c0     ret
10
11  ...
12  
13  Contents of section .rodata:
14   400640 01000200 00000000 48656c6c 6f210000  ........Hello!..

一方面,ARM64的CPU只可能运行于ARM模式、不可运行于Thumb 或 Thumb-2模式,所以它必须使用32位的指令。另一方面,64位平台的寄存器数量也翻了一翻,拥有了32个X-字头寄存器(请参见附录B.4.1)。当然,程序还可以通过W-字头的名称直接访问寄存器的低32位空间。

上述程序的STP(Store Pair)指令把两个寄存器(即X29,X30)的值存储到栈里。虽然这个指令实际上可以把这对数值存储到内存中的任意地址,但是由于该指令明确了SP寄存器,所以它就是通过栈来存储这对数值。ARM64平台的寄存器都是64位寄存器,每个寄存器可存储8字节数据。所以程序要分配16字节的空间来存储两个寄存器的值。

这条指令中的感叹号标志,意味着其标注的运算会被优先执行。即,该指令先把SP的值减去16,在此之后再把两个寄存器的值写在栈里。这属于“预索引/pre-index”指令。此外还有“延迟索引/post-index”指令与之对应。有关两者的区别,请参见本书28.2节。

以更为易懂的x86指令来解读的话,这条指令相当于PUSH X29和PUSH X30两条指令。在ARM64平台上,X29寄存器是帧指针FP,X30起着LR的作用,所以这两个寄存器在函数的序言和尾声处成对出现。

第二条指令把SP的值复制给X29,即FP。这用来设置函数的栈帧。

ADRP和ADD指令相互配合,把“Hello!”字符串的指针传递给X0寄存器,继而充当函数参数传递给被调用函数。受到指令方面的限制,ARM无法通过单条指令就把一个较大的立即数赋值给寄存器(可参见本书的28.3.1节)。所以,编译器要组合使用数条指令进行立即数赋值。第一条ADRP把4KB页面的地址传递给X0,而后第二条ADD进行加法运算并给出最终的指针地址。详细解释请参见本书28.4节。

0x400000 + 0x648 = 0x400648。这个数是位于.rodata数据段的C字符串“Hello!”的地址。

接下来,程序使用BL指令调用puts()函数。这部分内容的解读可参见3.4.3节。

MOV指令用来给W0寄存器置零。W0是X0寄存器的低32位,如下图所示。

高32位
低32位
X0
W0

main()函数通过X0寄存器来传递函数返回值0。程序后续的指令依次制备这个返回值。为什么这里把返回值存储到X0寄存器的低32位,即W0寄存器?这种情况和x86-64平台相似:出于兼容性和向下兼容的考虑,ARM64平台的int型数据仍然是32位数据。对于32位的int型数据来说,X0寄存器的低32位足够大了。

为了进行演示,我对源代码进行了小幅度的修改,使main()返回64位值。

指令清单3.16 main()返回uint64_t 型数据

#include <stdio.h>
#include <stdint.h>

uint64_t main()
{
         printf ("Hello!\n");
         return 0;
}

返回值虽然相同,但是对应的MOV指令发生了变化。

指令清单3.17 Non-optimizing GCC 4.8.1 + objdump

  4005a4:    d2800000    mov     x0, #0x0           // #0

在此之后,LDP (Load Pair)指令还原X29和X30寄存器的值。此处的这条指令没有感叹号标记,这意味着它将率先进行赋值操作,而后再把SP的值与16进行求和运算。这属于延时索引(post-index)指令。

RET指令是ARM64平台的特色指令。虽然它的作用与BX LR相同,但是它实际上是按照寄存器的名称进行跳转的(默认使用X30寄存器指向的地址),通过底层指令提示CPU此处为函数的返回指令、不属于普通转移指令的返回过程。RET指令经过了面向硬件的优化处理,它的执行效率较高。

开启优化功能之后,GCC生成的代码完全一样。本文不在对它进行介绍。

全局指针是MIPS软件系统的一个重要概念。我们已经知道,每条MIPS指令都是32位的指令,所以单条指令无法容纳32位地址(指针)。这种情况下MIPS就得传递一对指令才能使用一个完整的指针。在前文的例子中,GCC在生成文本字符串的地址时,就采用了类似的技术。

从另一方面来说,单条指令确实可以容纳一组由寄存器的符号、有符号的16位偏移量(有符号数)。因此任何一条指令都可以构成的表达式,访问某个取值范围为“寄存器−32768”~“寄存器+32767”之间的地址(总共69KB)。为了简化静态数据的访问操作,MIPS平台特地为此保留了一个专用的寄存器,并且把常用数据分配到了一个大小为64KB的内存数据空间里。这种专用的寄存器就叫作“全局指针”寄存器。它的值是一个指针,指向64KB(静态)数据空间的正中间。而这64KB空间通常用于存储全局变量,以及printf()这类由外部导入的的外部函数地址。GCC的开发团队认为:获取函数地址这类的操作,应当由单条指令完成;双指令取址的运行效率不可接受。

在ELF格式的文件中,这个64KB的静态数据位于.sbss 和.sdata之中。“.sbss”是small BSS(Block Started by Symbol)的缩写,用于存储非初始化的数据。“.sdata”是small data的缩写,用于存储有初始化数值的数据。

根据这种数据布局编程人员可以自行决定把需要快速访问的数据放在.sdata、还是.sbss数据段中。

有多年工作经验的人员可能会把全局指针和MS-DOS内存(参见本书第94章)、或者MS-DOS的XMS/EMS内存管理器联系起来。这些内存管理方式都把数据的内存存储空间划分为数个64KB区间。

全局指针并不是MIPS平台的专有概念。至少PowerPC平台也使用了这一概念。

下面这段代码显示了“全局指针”的特色。

指令清单3.18 Optimizing GCC 4.4.5 (汇编输出)

 1 $LC0:
 2 ; \000 is zero byte in octal base:
 3          .ascii "Hello, world!\012\000"
 4 main:
 5 ; function prologue.
 6 ; set the GP:
 7          lui    $28,%hi(__gnu_local_gp)
 8          addiu  $sp,$sp,-32
 9          addiu  $28,$28,%lo(__gnu_local_gp)
10 ; save the RA to the local stack:
11          sw     $31,28($sp)
12 ; load the address of the puts() function from the GP to $25:
13          lw     $25,%call16(puts)($28)
14 ; load the address of the text string to $4 ($a0):
15          lui    $4,%hi($LC0)
16 ; jump to puts(), saving the return address in the link register:
17          jalr   $25
18          addiu  $4,$4,%lo($LC0) ; branch delay slot
19 ; restore the RA:
20          lw     $31,28($sp)
21 ; copy 0 from $zero to $v0:
22          move   $2,$0
23 ; return by jumping to the RA:
24          j      $31
25 ; function epilogue:
26          addiu $sp,$sp,32 ; branch delay slot

主函数序言启动部分的指令初始化了全局指针寄存器GP寄存器的值,并且把它指向64KB数据段的正中央。同时,程序把RA寄存器的值存储于本地数据栈。它同样使用puts()函数替代了printf()函数。而puts()函数的地址,则通过LW(Load Word)指令加载到了$25寄存器。此后,字符串的高16位地址和低16位地址分别由LUI(Load Upper Immediate)和ADDIU(Add Immediate Unsigned Word)两条指令加载到$4寄存器。LUI中的Upper一词说明它将数据存储于寄存器的高16位。与此相对应,ADDIU则把操作符地址处的低16位进行了求和运算。ADDIU指令位于JALR指令之后,但是会先于后者运行[22]。$4寄存器其实就是$A0寄存器,在调用函数时传递第一个参数[23]

JALR (Jump and Link Register)指令跳转到$25寄存器中的地址,即puts()函数的启动地址,并且把下一条LW指令的地址存储于RA寄存器。可见,MIPS系统调用函数的方法与ARM系统相似。需要注意的是,由于分支延迟槽效应,存储于RA寄存器的值并非是已经运行过的、“下一条”指令的地址,而是更后面那条(延迟槽之后的)指令的地址。所以,在执行这条JALR指令的时候,写入RA寄存器的值是PC+8,即ADDIU后面的那条LW指令的地址。

第19行的LW (Load Word)指令,用于把本地栈中的RA值恢复回来。请注意,这条指令并不位于被调用函数的函数尾声。

第22行的MOVE指令把$0($ZERO)的值复制给$2($V0)。MIPS有一个常量寄存器,它里面的值是常量0。很明显,因为MIPS的研发人员认为0是计算机编程里用得最多的常量,所以他们开创了一种使用$0寄存器提供数值0的机制。这个例子演示了另外一个值得注意的现象:在MIPS系统之中,没有在寄存器之间复制数值的(硬件)指令。确切地说,MOVE DST, SRC是通过加法指令ADD DST,SRC, $ZERO变相实现的,即DST=SRC+0,这两种操作等效。由此可见,MIPS研发人员希望尽可能地复用opcode,从而精简opcode的总数。然而这并不代表每次运行MOVE指令时CPU都会进行实际意义上的加法运算。CPU能够对这类伪指令进行优化处理,在运行它们的时候并不会用到ALU(Arithmetic logic unit)。

第24行的J指令会跳转到RA所指向的地址,完成从被调用函数返回调用者函数的操作。还是由于分支延迟槽效应,其后的ADDIU指令会先于J指令运行,构成函数尾声。

我们再来看看IDA生成的指令清单,熟悉一下各寄存器的伪名称。

代码清单3.19 Opimizing GCC4.4.5(IDA)

 1 .text:00000000 main:
 2 .text:00000000
 3 .text:00000000 var_10       = -0x10
 4 .text:00000000 var_4        = -4
 5 .text:00000000
 6 ; function prologue.
 7 ; set the GP:
 8 .text:00000000             lui        $gp, (__gnu_local_gp >> 16)
 9 .text:00000004             addiu      $sp, -0x20
10 .text:00000008             la         $gp, (__gnu_local_gp & 0xFFFF)
11 ; save the RA to the local stack:
12 .text:0000000C             sw         $ra, 0x20+var_4($sp)
13 ; save the GP to the local stack:
14 ; for some reason, this instruction is missing in the GCC assembly output:
15 .text:00000010             sw         $gp, 0x20+var_10($sp)
16 ; load the address of the puts() function from the GP to $t9:
17 .text:00000014             lw         $t9, (puts & 0xFFFF)($gp)
18 ; form the address of the text string in $a0:
19 .text:00000018             lui        $a0, ($LC0 >> 16) # "Hello, world!"
20 ; jump to puts(), saving the return address in the link register:
21 .text:0000001C             jalr       $t9
22 .text:00000020             la         $a0, ($LC0 & 0xFFFF) # "Hello, world!"
23 ; restore the RA:
24 .text:00000024             lw         $ra, 0x20+var_4($sp)
25 ; copy 0 from $zero to $v0:
26 .text:00000028             move       $v0, $zero
27 ; return by jumping to the RA:
28 .text:0000002C             jr         $ra
29 ; function epilogue:
30 .text:00000030             addiu      $sp, 0x20

第15行的指令使用局部栈保存GP的值。令人感到匪夷所思的是,GCC的汇编输出里看不到这条指令,或许这是GCC自身的问题[24]。严格地说,此时有必要保存GP的值。毕竟每个函数都有着自己的64KB数据窗口。

程序中保存puts()函数地址的寄存器叫作$T9寄存器。这类T-开头的寄存器叫作“临时”寄存器,用于保存代码里的临时值。调用者函数负责保存这些寄存器的数值(caller-saved),因为它有可能会被被调用的函数重写。

代码清单3.20 Non-optimizing GCC 4.4.5 (汇编输出)

 1 $LC0:
 2          .ascii "Hello, world!\012\000"
 3 main:
 4 ; function prologue.
 5 ; save the RA ($31) and FP in the stack:
 6          addiu  $sp,$sp,-32
 7          sw     $31,28($sp)
 8          sw     $fp,24($sp)
 9 ; set the FP (stack frame pointer):
10          move   $fp,$sp
11 ; set the GP:
12          lui    $28,%hi(__gnu_local_gp)
13          addiu  $28,$28,%lo(__gnu_local_gp)
14 ; load the address of the text string:
15          lui    $2,%hi($LC0)
16          addiu  $4,$2,%lo($LC0)
17 ; load the address of puts() using the GP:
18          lw     $2,%call16(puts)($28)
19          nop
20 ; call puts():
21          move  $25,$2
22          jalr  $25
23          nop; branch delay slot
24
25 ; restore the GP from the local stack:
26          lw    $28,16($fp)
27 ; set register $2 ($V0) to zero:
28          move  $2,$0
29 ; function epilogue.
30 ; restore the SP:
31          move  $sp,$fp
32 ; restore the RA:
33          lw    $31,28($sp)
34 ; restore the FP:
35          lw    $fp,24($sp)
36          addiu $sp,$sp,32
37 ; jump to the RA:
38          j     $31
39          nop; branch delay slot

未经优化处理的GCC输出要详细得多。此处,我们可以观察到程序把FP当作栈帧的指针来用,而且它还有3个NOP(空操作)指令。在这3个空操作指令中,第二个、第三个指令都位于分支跳转指令之后。

笔者个人认为(虽然目前无法肯定),由于这些地方都存在分支延迟槽,所以GCC编译器会在分支语句之后都添加NOP指令。不过,在启用它的优化选项之后,GCC可能就会删除这些NOP指令。所以,此处仍然存在这些NOP指令。

使用IDA程序观察下面这段代码。

指令清单3.21 Non-optimizing GCC 4.4.5 (IDA)

 1 .text:00000000 main:
 2 .text:00000000
 3 .text:00000000 var_10      = -0x10
 4 .text:00000000 var_8       = -8
 5 .text:00000000 var_4       = -4
 6 .text:00000000
 7 ; function prologue.
 8 ; save the RA and FP in the stack:
 9 .text:00000000             addiu      $sp, -0x20
10 .text:00000004             sw         $ra, 0x20+var_4($sp)
11 .text:00000008             sw         $fp, 0x20+var_8($sp)
12 ; set the FP (stack frame pointer):
13 .text:0000000C             move       $fp, $sp
14 ; set the GP:
15 .text:00000010             la         $gp, __gnu_local_gp
16 .text:00000018             sw         $gp, 0x20+var_10($sp)
17 ; load the address of the text string:
18 .text:0000001C             lui        $v0, (aHelloWorld >> 16) # "Hello, world!"
19 .text:00000020             addiu      $a0, $v0, (aHelloWorld & 0xFFFF) # "Hello, world!"
20 ; load the address of puts() using the GP:
21 .text:00000024             lw         $v0, (puts & 0xFFFF)($gp)
22 .text:00000028             or         $at, $zero ; NOP
23 ; call puts():
24 .text:0000002C             move       $t9, $v0
25 .text:00000030             jalr       $t9
26 .text:00000034             or         $at, $zero ; NOP
27 ; restore the GP from local stack:
28 .text:00000038             lw         $gp, 0x20+var_10($fp)
29 ; set register $2 ($V0) to zero:
30 .text:0000003C             move       $v0, $zero
31 ; function epilogue.
32 ; restore the SP:
33 .text:00000040             move       $sp, $fp
34 ; restore the RA:
35 .text:00000044             lw         $ra, 0x20+var_4($sp)
36 ; restore the FP:
37 .text:00000048             lw         $fp, 0x20+var_8($sp)
38 .text:0000004C             addiu      $sp, 0x20
39 ; jump to the RA:
40 .text:00000050             jr         $ra
41 .text:00000054             or         $at, $zero ; NOP

在程序的第15行出现了一个比较有意思的现象——IDA识别出了LUI/ADDIU指令对,把它们显示为单条的伪指令LA(Load address)。那条伪指令占用了8个字节!这种伪指令(即“宏”)并非真正的MIPS指令。通过这种名称替换,IDA帮助我们对指令的作用望文思义。

NOP的显示方法也构成了它的另外一种特点。因为IDA并不会自动地把实际指令匹配为NOP指令,所以位于第22行、第26行、第41行的指令都是“OR $AT, $ZERO”。表面上看,它将保留寄存器$AT的值与0进行或运算。但是从本质上讲,这就是发送给CPU的NOP指令。MIPS和其他的一些硬件平台的指令集都没有单独的NOP指令。

本例使用寄存器来传递文本字符串的地址。但是它同时设置了局部栈,这是为什么呢?由于程序在调用printf()函数的时候由于程序必须保存RA寄存器的值和GP的值,故而此处出现了数据栈。如果此函数是叶函数,它有可能不会出现函数的序言和尾声,有关内容请参见本书的2.3节。

指令清单3.22 GDB的操作流程

root@debian-mips:~# gcc hw.c -O3 -o hw
root@debian-mips:~# gdb hw
GNU gdb (GDB) 7.0.1-debian
Copyright (C) 2009 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "mips-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/hw...(no debugging symbols found)...done.
(gdb) b main
Breakpoint 1 at 0x400654
(gdb) run
Starting program: /root/hw

Breakpoint 1, 0x00400654 in main ()
(gdb) set step-mode on
(gdb) disas
Dump of assembler code for function main:
0x00400640 <main+0>:     lui     gp,0x42
0x00400644 <main+4>:     addiu   sp,sp,-32
0x00400648 <main+8>:     addiu   gp,gp,-30624
0x0040064C <main+12>:    sw      ra,28(sp)
0x00400650 <main+16>:    sw      gp,16(sp)
0x00400654 <main+20>:    lw      t9,-32716(gp)
0x00400658 <main+24>:    lui     a0,0x40
0x0040065c <main+28>:    jalr    t9
0x00400660 <main+32>:    addiu   a0,a0,2080
0x00400664 <main+36>:    lw      ra,28(sp)
0x00400668 <main+40>:    move    v0,zero
0x0040066c <main+44>:    jr      ra
0x00400670 <main+48>:    addiu   sp,sp,32
End of assembler dump.
(gdb) s
0x00400658 in main ()
(gdb) s
0x0040065c in main ()
(gdb) s
0x2ab2de60 in printf () from /lib/libc.so.6
(gdb) x/s $a0
0x400820:          "hello, world"
(gdb)

x64和x86指令的主要区别体现在指针上,前者使用64位指针而后者使用32位指针。近年来,内存的价格在不断降低,而CPU的计算能力也在不断增强,当计算机的内存增加到一定程度时,32位指针就无法满足寻址的需要了,所以指针也随之演变为64位指针。

请描述下述32位函数的功能。

main:
     push 0xFFFFFFFF
     call MessageBeep
     xor  eax,eax
     retn

请描述Linux函数的功能,这里使用了AT&T汇编语言语法。

main:
     pushq   %rbp
     movq    %rsp, %rbp
     mov1    %2, %edi
     call    sleep
     popq    %rbp
     ret

[1] Brian W. Kernighan. The C Programming Language. Ed. by Dennis M. Ritchie. 2nd. Prentice Hall Professional Tech- nical Reference, 1988. ISBN: 0131103709。

[2] 但是CPU 标志位会发生变化。

[3] 参见http://en.wikipedia.org/wiki/Exclusive_or

[4] C runtime library:sec:CRT,参见本书68.1节。

[5] 参考Wikipedia:Data structure alignment http://en.wikipedia.org/wiki/Data_structure_ alignment

[6] 参考Mit13。

[7] 参考Int13。

[8] 可通过菜单“Options Number of opcode bytes”启用有关选项。

[9] 请参考Mit13。

[10] Apple公司的Xcode 4.6.3使用的前段编译器是开源的GCC程序,代码生成程序(code generator)使用的是LLVM。

[11] 例如,ARM模式的指令集里没有PUSH/POP指令。

[12] STMFD是Storage Multiple Full Descending 的缩写。

[13] stack pointer,栈指针。x86/x64框架中的SP是SP/ESP/RSP,而ARM框架的SP就是SP。

[14] Program Counter,中文叫做指令指针或程序计数器。x86/x64里的PC叫作IP/EIP/RIP,ARM里它就叫PC。

[15] 本书介绍操作系统的部分有更详细的说明。在不同框架的汇编语言中,PC很少会是当前指令的指针地址+1,这和CPU的流水/pipeline模式有关。如需完整的官方介绍,请参阅http://www.arm. com/pdfs/comparison-arm7-arm9-v1.pdf

[16] BL是Branch with Link的缩写,相当于x86的call指令。

[17] 这是二进制里26位有符号整型数据(26 bits signed int)的数值范围。

[18] LDMFD是Load Multiple Full Descending的缩写。

[19] 参照参考文献App10。

[20] Xcode 4.6.3是基于GCC的编译器。

[21] 请参考http://www.ciselant.de/projects/gcc_printf/gcc_printf.html

[22] 请参考前文介绍的分支延迟槽(Branch delay slot)效应。

[23] 有关MIPS各寄存器的用途,请参见附录C.1。

[24] 很明显,对于GCC的用户来说,查看汇编指令的功能不是那么重要。所以,GCC输出的汇编指令之中仍然可能存在一些(在生成汇编指令的阶段)未被修正的错误。


相关图书

黑客秘笈 渗透测试实用指南 第3版
黑客秘笈 渗透测试实用指南 第3版
软件定义安全及可编程对抗系统实战
软件定义安全及可编程对抗系统实战
可信计算远程证明与应用
可信计算远程证明与应用
C++ 黑客编程揭秘与防范(第2版)
C++ 黑客编程揭秘与防范(第2版)
C++ 黑客编程揭秘与防范
C++ 黑客编程揭秘与防范

相关文章

相关课程