庖丁解牛Linux内核分析

978-7-115-49186-2
作者: 孟宁娄嘉鹏刘宇栋
译者:
编辑: 张爽

图书目录:

详情

本书从理解计算机硬件的核心工作机制(存储程序计算机和函数调用堆栈)和用户态程序如何通过系统调用陷入内核(中断异常)入手,通过上下两个方向双向夹击的策略,并利用实际可运行程序的反汇编代码从实践的角度理解操作系统内核,然后开始分析Linux内核源代码,从系统调用陷入内核,进程调度与进程切换,最后返回到用户态进程。

我们针对图书内容开发了在线编程练习题,并提供了在线编程环境,您可以边读书,边练习,在线编程,双效合一! 购买【异步社区VIP会员】或直接购买以下图书的【e读版电子书】后,即可在线畅读全书,并可通过章节末尾入口进入在线编程练习!了解更多精品图书在线编程实验课:https://www.epubit.com/topicsDetails?id=877f112b-febb-49f4-b203-ec6432852759  

图书摘要

版权信息

书名:庖丁解牛Linux内核分析

ISBN:978-7-115-49186-2

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

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

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

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

编  著 孟 宁  娄嘉鹏  刘宇栋

责任编辑 张 爽

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

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

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

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书从理解计算机硬件的核心工作机制(存储程序计算机和函数调用堆栈)和用户态程序如何通过系统调用陷入内核(中断异常)入手,通过上下两个方向双向夹击的策略,并利用实际可运行程序的汇编代码从实践的角度理解操作系统内核,分析Linux内核源代码,从系统调用陷入内核、进程调度与进程切换开始,最后返回到用户态进程。

本书配有丰富的实验指导材料和练习,适合作为高等院校计算机相关专业的指导用书,也适合Linux操作系统开发人员自学。


陈莉君 西安邮电大学

李 曦 中国科学技术大学

黄敬群 台湾成功大学

代 栋 北卡罗来纳大学夏洛特分校

孙志岗 网易教育事业部

石 磊 实验楼在线教育


大大小小、可见与不可见的计算机已成为现代人日常工作、学习和生活中必不可少的工具。操作系统是计算机之魂,作为用户使用计算机的接口,它负责调度执行各个用户程序,使计算机完成特定的任务;作为计算机硬件资源的管理者,它负责协调计算机中各类设备高效地工作。操作系统的重要性不言而喻。

对于软件工程师,理解操作系统的工作原理和关键机制是设计高质量应用程序的前提,但要做到这一点是十分困难的。一方面,操作系统设计涉及计算机科学与工程学科的方方面面,包括数据结构与算法、计算机组成与系统结构、计算机网络,甚至程序设计语言与编译系统等核心知识,以及并发、同步和通信等核心概念。另一方面,作为一个复杂庞大的软件产品,理解操作系统更需要理论与实践深度结合。

操作系统的相关学习资料十分丰富。有阐述基本原理者,有剖析典型系统者,还有构造示例系统者;有面向专业理论者,亦有面向应用实践者。角度多种多样,内容简繁不一。

本书的最大特点在于作者结合其多年的Linux操作系统实际教学经验编撰而成。作为一位经验丰富的高级软件工程师和专业教师,本书作者基于自己学习和研究Linux的心得,创新性地以一个mykernel和MenuOS为基础实验平台进行教学和实验组织,实现了理论学习与工程实践的自然融合,达到了事半功倍的效果。同时,书中设计了丰富的单元测试题和实验,引导读者循序渐进地掌握所学知识,并有效地促进读者深入思考和实践所学内容。作者基于本书开设的操作系统课程,其教学形式涉及面对面的课堂教学和在线慕课教学,选课对象既包括软件工程硕士,又包括一般工程实践者,学习人数已数以万计。本书的出版体现了作者认真吸收大量的学员反馈,不断优化课程的教学内容和过程组织的成果。

易读性是本书的另一特色。作者采用二维码这一新媒体时代的代表性技术组织全书的内容,达到了兼顾完整性和简洁性的目标。

作为一名多年从事计算机系统结构研究和教学的教育工作者,我认为本书的出版对于提升国内操作系统教学和实践水平非常有益,相信它必将受到读者的喜爱!

李曦


作者于2000年左右开始接触计算机,一直对计算机系统的工作机制抱有浓厚的兴趣,阅读了很多相关书籍,包括关于分析Linux源代码的书籍,但一直不得要领,没能准确把握计算机系统工作的核心机制。2009年,我与中国科学技术大学软件学院结缘,从软件工程师转行成为教师。在学校里,我非常幸运地与陈香兰老师一起教授“Linux操作系统分析”课程,可是面对2000万行的Linux内核代码和厚厚的《深入理解Linux内核》这本教材,我发现自己依然无法从全局和本质上把握Linux系统。

直到2013年暑假,我替另一位老师代课,教授“操作系统原理”课程(见二维码1),凭借近10年使用Linux系统和学习Linux内核的经验,我为课程实验定下了一个“小目标”:学习“操作系统原理”就要动手编写一个小型操作系统。教学中的作业、实验和考试就像各种比赛一样,看似是在考学生,实际是在考验教师的水平和能力。当时学生的编程经验和动手能力普遍不足,很难独自完成编写一个哪怕非常微小的操作系统的任务,这时就需要教师给予启发和指导,帮助学生一步步完成预定的目标。正是在这次教学过程中,我在Linux内核繁杂的CPU初始化工作的基础上完成了一个简单、虚拟、可编程的计算机硬件模拟环境mykernel(见二维码2),在这个仅支持时钟中断的虚拟CPU中就可以建立属于自己的内核了。有了mykernel,稍有编程能力的学生就可以编写一个简单的时间片轮转调度的小型内核,并且能读懂代码,深刻理解如何在CPU的一个指令执行流上实现多个进程。

二维码1

二维码2

正是有了实现mykernel的经验,我在之后的“Linux操作系统分析”课程教学中有了清晰的思路。其中一位同学关于mykernel的总结也体现了我的感受:

mykernel这样一个短小精悍的模拟内核,时常会给我提供看问题的角度和思路。当被庞杂的Linux内核代码弄得一头雾水时,我就去看看mykernel,很多复杂的问题就可以用简单的机制解释了。

mykernel为Linux内核初学者提供了一个很好的平台,目前有很多的Linux内核学习者在使用。台湾成功大学的黄敬群创建的kernel-in-kernel项目(见二维码3)是一个mykernel的衍生项目,黄敬群还专门发邮件以取得我的授权。

二维码3

在我看来,mykernel是深入理解Linux的一个不错的工具,也是“Linux操作系统分析”慕课课程及本书的一个重要实验。除了mykernel这一个实验外,本书还有哪些内容?一位慕课课程学员的总结非常到位,远远超过了我自己来介绍这门慕课课程及本书的文字水平,这里也分享给读者:

这门课没讲什么?

在学习操作系统时,我们知道了操作系统将CPU抽象为进程,将内存抽象为虚拟内存,学习了进程的调度算法、内存页面的置换算法,这门课并没有关注这些算法。操作系统的主要功能就是为用户屏蔽硬件的操作细节,帮助用户管理计算机系统的各种资源。同步机制是我们处理并发任务和进行资源管理的重要手段。关于原子操作、信号量和自旋锁等内容,该课程中没有讲解。在操作系统原理课程中,没有着重讲解的各种设备驱动程序实际上占了Linux内核代码的大部分比例,这门课并没有这部分内容。没有讲解文件系统的结构与实现,以及VFS等。

这门课讲了什么?

对于要研究Linux内核的人来说,x86汇编语言是你必须要面对的第一关。因为操作系统需要大量对寄存器的操作,这是与体系结构相关的操作,所以必须用汇编语言来解决。这门课在一开始就讲解了x86汇编语言,并在后面的课程中不断巩固,这一点对于阅读内核源码非常有用。该课程用一个简单的演示内核mykernel来说明Linux是如何启动的,包括一个进程是怎样描述的(PCB信息)、0号进程(idle)的创建与演化、1号进程init的创建与加载、2号进程kthreadd的创建等。这可以使我们从顶层对Linux内核有一个大概的认识,并且课中手把手地进行源码教学,可以让人减少对结构复杂的内核代码的恐惧。我们日常使用内核,其实大部分功能都是使用它的系统调用,如从创建一个新的进程fork、装载程序execve,到输入/输出、时间查询等。因此,我们研究内核,很大一部分都是在研究如何实现这些系统调用。这门课花了两周时间来讲解系统调用在内核中是如何进行的。如果把进程创建和可执行程序的装载也当成系统调用的讲解,那么实际上占了课程的一半。因此,课程的设置正体现了这些系统调用在内核构成中的重要性。课中提供了一个试验环境MenuOS,该系统实现了一个命令行菜单系统,我们只需要添加我们希望执行的功能函数到菜单就好了。同时,利用Qemu和gdb,我们跟踪了各种系统调用的执行过程。虽然这门课没有讲具体的调度算法,如Linux内核中著名的完全公平队列CFS,但对于进程调度来说,除了调度算法,还有两个重要的问题,那就是进程的调度时机与切换过程,该课程花了一节课的时间来讲解schedule()函数的实现。我们不仅需要学习Linux内核的相关知识,更需要学习正确的人生观和世界观,这门课的精髓在于不仅教会你如何分析Linux内核,而且教你做事的方法论:“天下难事必做于易,天下大事必做于细”。对于代码规模庞大无从下手的内核,我们从小处入手,步步为营,最终掌控全局。

由于慕课课程的受众比较多元化,课程的容量和看视频做实验的时间都需要严格控制,因此8周的慕课课程及本书内容主要聚焦在Linux系统工作的核心机制上,算是基础核心篇,相对来讲比较短小精悍。我所讲授的中国科学技术大学软件学院的研究生课程“Linux操作系统分析”涉及的内容要比上述内容更多。不少学员提出问题:学完慕课课程之后想继续深入学习,需要学习哪些内容?我个人认为,深入理解Linux系统除了理解Linux系统工作的核心机制之外,文件的概念和实现也非常重要。类UNIX系统非常成功的抽象就是“一切都是文件”,深入理解文件的概念和内核实现对于理解Linux内核尤为重要。如果有机会继续做后续课程,我来选择的话首先要做的就是文件抽象篇。

无论是基础核心篇,还是上述提及的文件抽象篇,都要注重理解,而非应用。从应用的角度来学习和研究Linux内核,其实还可以分为API接口篇、网络协议篇和驱动程序篇,分别对应的阅读人群大致为底层应用软件或系统软件的开发人员、网络相关的工作人员和硬件驱动程序开发人员。可能有读者会疑惑为什么没有内存管理,内存管理的底层实现基本上固化到了CPU芯片内部,它对于理解Linux系统工作的核心机制和系统架构都相对单纯独立,已经通过进程的地址空间在逻辑上做了清晰的隔离。而从应用的角度来看,垃圾回收(Garbage Collection,GC)成为语言的标配已经是大势所趋。除非专业从事存储器产品研发、芯片内部存储管理模块或内核内存管理模块开发等细分领域,我个人认为操作系统原理中涉及的内存管理相关的知识已经足够了。

感谢中国科学技术大学软件学院曾一起合作教授“Linux操作系统分析”课程的陈香兰和李春杰两位老师,他们为“Linux操作系统分析”慕课课程及本书做出了前期基础性的贡献。

感谢网易云课堂的孙志岗,没有他的鼓励和支持,我多年获得的教学成果恐怕至今也不会以慕课课程的方式在互联网上与学习者见面,本书更是无从谈起。

感谢实验楼的石磊在开发和配置“Linux操作系统分析”慕课课程的实验环境过程中提供了很多支持和帮助。

感谢电子工业出版社章海涛老师提出了很好的意见和建议,以及为本书前期的筹备工作所做的贡献。

感谢本书的两位合作者,分别是北京电子科技学院的娄嘉鹏和刘宇栋,没有你们的鼓励和鼎力支持,本书出版恐怕遥遥无期。

感谢“Linux操作系统分析”慕课课程建设之前的中国科学技术大学软件学院的几届学生,感谢你们在学习过程中撰写了很多高质量的博客,为慕课课程和本书做出了贡献。

感谢人民邮电出版社陈冀康、张涛、张爽3位编辑为本书顺利出版所做的工作和努力。

由于写作时间仓促及作者的能力有限,本书难免会有不足之处,敬请各位读者批评指正,我的电子邮件地址为mengning@ustc.edu.cn。

孟宁 

2018年春


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/ selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


本章重点介绍计算机的工作原理,具体涉及存储程序计算机工作模型、基本的汇编语言,以及C语言程序汇编出来的汇编代码如何在存储程序计算机工作模型上一步步地执行。其中重点分析了函数调用堆栈相关汇编指令,如call/ret和pushl/popl。

存储程序计算机的概念虽然简单,但在计算机发展史上具有革命性的意义,至今为止仍是计算机发展史上非常有意义的发明。一台硬件有限的计算机或智能手机能安装各种各样的软件,执行各种各样的程序,这在人们看起来都理所当然,其实背后是存储程序计算机的功劳。

存储程序计算机的主要思想是将程序存放在计算机存储器中,然后按存储器中的存储程序的首地址执行程序的第一条指令,以后就按照该程序中编写好的指令执行,直至程序执行结束。

相信很多人特别是学习计算机专业的人都听说过图灵机和冯·诺依曼机。图灵机关注计算的哲学定义,是一种虚拟的抽象机器,是对现代计算机的首次描述。只要提供合适的程序,图灵机就可以做任何运算。基于图灵机建造的计算机都是在存储器中存储数据,程序的逻辑都是嵌入在硬件中的。

与图灵机不同,冯·诺依曼机是一个实际的体系结构,我们称作冯·诺依曼体系结构,它至今仍是几乎所有计算机平台的基础。我们都知道“庖丁解牛”这个成语,比喻经过反复实践,掌握了事物的客观规律,做事得心应手,运用自如。冯·诺依曼体系结构就是各种计算机体系结构需要遵从的一个“客观规律”,了解它对于理解计算机和操作系统非常重要。下面,我们就来看看什么是冯·诺依曼体系结构。

在1944~1945年期间,冯·诺依曼指出程序和数据在逻辑上是相同的,程序也可以存储在存储器中。冯·诺依曼体系结构的要点包括:

图1-1 冯·诺依曼体系结构

计算机硬件的基础是CPU,它与内存和输入/输出(I/O)设备进行交互,从输入设备接收数据,向输出设备发送数据。CPU由运算器(算术逻辑单元ALU)、控制器和一些寄存器组成。有一个非常重要的寄存器称为程序计数器(Program Counter,PC),在IA32(x86-32)中是EIP,指示将要执行的下一条指令在存储器中的地址。C/C++程序员可以将EIP看作一个指针,因为它总是指向某一条指令的地址。CPU就是从EIP指向的那个地址取过来一条指令执行,执行完后EIP会自动加一,执行下一条指令,然后再取下一条指令执行,CPU像“贪吃蛇”一样总是在内存里“吃”指令。

CPU、内存和I/O设备通过总线连接。内存中存放指令和数据。

“计算机内部采用二进制来表示指令和数据”表明,指令和数据的功能和处理是不同的,但都可以用二进制的方式存储在内存中。

上述第3个要点指出了冯·诺依曼体系结构的核心是存储程序计算机。

我们用程序员的思维来对存储程序计算机进行抽象,如图1-2所示。

图1-2 存储程序计算机工作原理示意图

我们可以把CPU抽象成一个for循环,因为它总是在执行next instruction(下一条指令),然后从内存里取下一条指令来执行。从这个角度来看,内存保存指令和数据,CPU负责解释和执行这些指令,它们通过总线连接起来。这里揭示了计算机可以自动化执行程序的原理。

这里存在一个问题,CPU能识别什么样的指令,我们这里需要有一个定义。学过编程的读者基本都知道API(Application Program Interface),也就是应用程序编程接口。而对于程序员来讲,还有一个称为ABI(Application Binary Interface)的接口,它主要是一些指令的编码。在指令编码方面,我们不会涉及那么具体的细节,而只会涉及和汇编相关的内容。至于这些指令是如何编码成二进制机器指令的,我们不必关心,有兴趣的读者可以查找指令编码的相关资料。此外,这些指令会涉及一些寄存器,这些寄存器有些约定,我们约定什么样的指令该用什么寄存器。同时,我们也需要了解寄存器的布局。还有,大多数指令可以直接访问内存,对于x86-32计算机指令集来讲,这也是一个重要的概念。对于x86-32计算机,有一个EIP寄存器指向内存的某一条指令,EIP是自动加一的(不是一个字节,也不是32位,而是加一条指令),虽然x86-32中每条指令占的存储空间不一样,但是它能智能地自动加到下一条指令,它还可以被其他指令修改,如call、ret、jmp等,这些指令对应C语言中的函数调用、return和if else语句。

现在绝大多数具有计算功能的设备,小到智能手机,大到超级计算机,基本的核心部分可以用冯·诺依曼体系结构(存储程序计算机)来描述。因此,存储程序计算机是一个非常基本的概念,是我们理解计算机系统工作原理的基础。

Intel处理器系列也称为x86,经过不断的发展,体系结构经历了16位(8086,1978)、32位(i386,1985)和64位(Pentium 4E,2004)几个关键阶段。32位的体系结构称为IA32(Intel Architecture 32bit),64位体系结构称为x86-64,但为了明确区分两者,本书中把32位体系结构称作x86-32。本书与Linux内核采用的汇编格式保持一致,采用AT&T汇编格式。

为了便于读者理解,下面先来介绍16位的8086 CPU的寄存器。8086 CPU中总共有14个16位的寄存器:AX、BX、CX、DX、SP、BP、SI、DI、IP、FLAG、CS、DS、SS和ES。这14个寄存器分为通用寄存器、控制寄存器和段寄存器3种类型。

通用寄存器又分为数据寄存器、指针寄存器和变址寄存器。

AX、BX、CX和DX统称为数据寄存器。

SP和BP 统称为指针寄存器。

SI和DI统称为变址寄存器。

控制寄存器主要分为指令指针寄存器和标志寄存器。

段寄存器主要有代码段寄存器、数据段寄存器、堆栈段寄存器和附加段寄存器。

以上数据寄存器AX、BX、CX和DX都可以当作两个单独的8位寄存器来使用,如图1-3所示,以AX寄存器为例。

图1-3 AX 寄存器示意图

除了上面4个数据寄存器以外,其他寄存器均不可以分为两个独立的8位寄存器。注意,每个分开的寄存器都有自己的名称,可以独立存取。程序员可以利用数据寄存器的这种“可分可合”的特性,灵活地处理字/字节的信息。

了解了16位的8086 CPU的寄存器之后,我们再来看32位的寄存器。IA32所含有的寄存器包括:

32位寄存器只是把对应的16位寄存器扩展到了32位,如图1-4所示为EAX寄存器示意图,它增加了一个E。所有开头为E的寄存器,一般是32位的。

图1-4 EAX寄存器示意图

EAX累加寄存器、EBX基址寄存器、ECX计数寄存器和EDX数据寄存器都是通用寄存器,程序员在写汇编码时可以自己定义如何使用它们。EBP是堆栈基址指针,比较重要;ESI、EDI是变址寄存器;ESP也比较重要,它是堆栈栈顶寄存器。这里可能会涉及堆栈的概念,学过数据结构课程的读者应该知道堆栈的概念,本书后面会具体讲到push指令压栈和pop指令出栈,它是向一个堆栈里面压一个数据和从堆栈里面弹出一个数据。这些都是32位的通用寄存器。

值得注意的是在16位CPU中,AX、BX、CX和DX不能作为基址和变址寄存器来存放存储单元的地址,但在32位CPU中,32位寄存器EAX、EBX、ECX和EDX不仅可以传送数据、暂存数据保存算术逻辑运算结果,还可以作为指针寄存器,因此这些32位寄存器更具通用性。

除了通用寄存器外,还有一些段寄存器。虽然段寄存器在本书中用得比较少,但还是要了解一下。除了CS、DS、ES和SS外,还有其他附加段寄存器FS和GS。常用的是CS寄存器和SS寄存器。我们的指令都存储在代码段,在定位一个指令时,使用CS:EIP来准确指明它的地址。也就是说,首先需要知道代码在哪一个代码段里,然后需要知道指令在代码段内的相对偏移地址EIP,一般用CS:EIP准确地标明一个指令的内存地址。还有堆栈段,每一个进程都有自己的堆栈段(在Linux系统里,每个进程都有一个内核态堆栈和一个用户态堆栈)。 标志寄存器的功能细节比较复杂烦琐,本书就不仔细介绍了,读者知道标志寄存器可以保存当前的一些状态就可以了。

现在主流的计算机大多都是采用64位的CPU,那么我们也需要简单了解一下x86-64的寄存器。实际上,64位和32位的寄存器差别也不大,它只是从32位扩展到了64位。前面带个“R”的都是指64位寄存器,如RAX、RBX、RCX、RDX、RBP、RSI、RSP,还有Flags改为了RFLAGS,EIP改为了RIP。另外,还增加了更多的通用寄存器,如R8、R9等,这些增加的通用寄存器和其他通用寄存器只是名称不一样,在使用中都是遵循调用者使用规则,简单说就是随便用。

在Intel的术语规范中,字(Word)表示16位数据类型;在IA32中,32位数称为双字(Double Words);在x86-64中,64位数称为四字(Quad Words)。图1-5所示为C语言中基本类型的IA32表示,其中列出的汇编代码后缀在汇编代码中会经常看到。

图1-5 C语言中基本类型的IA32表示

汇编指令包含操作码和操作数,其中操作数分为以下3种:

还有一些常见的汇编指令,我们来看它们是如何工作的。最常见的汇编指令是mov指令,movl中的l是指32位,movb中的b是指8位,movw中的w是指16位,movq中的q是指64位。我们以32位为主进行介绍。

首先介绍寄存器寻址(Register mode)。所谓寄存器寻址就是操作的是寄存器,不和内存打交道,如%eax,其中%开头后面跟一个寄存器名称。

movl %eax,%edx

上述代码把寄存器%eax的内容放到%edx中。如果把寄存器名当作C语言代码中的变量名,它就相当于:

edx = eax;

立即寻址(immediate)是用一个$开头后面跟一个数值。例如:

movl $0x123, %edx

就是把0x123这个十六进制的数值直接放到EDX寄存器中。如果把寄存器名当作C语言代码中的变量名,它就相当于:

edx = 0x123;

立即寻址也和内存没有关系。

直接寻址(direct)是直接用一个数值,开头没有$符号。开头有$符号的数值表示这是一个立即数;没有$符号表示这是一个地址。例如:

movl 0x123, %edx

就是把十六进制的0x123内存地址所指向的那块内存里存储的数据放到EDX寄存器里,这相当于C语言代码:

edx = *(int*)0x123;

把0x123这个数值强制转化为一个32位的int型变量的指针,再用一个*取它指向的值,然后放到EDX寄存器中,这就称为直接寻址。换句话说,就是用内存地址直接访问内存中的数据。

间接寻址(indirect)就是寄存器加个小括号。举例说明,%ebx这个寄存器中存的值是一个内存地址,加个小括号表示这个内存地址所存储的数据,我们把它放到EDX寄存器中:

move (%ebx), %edx

就相当于:

edx = *(int*)ebx;

把这个EBX寄存器中存储的数值强制转化为一个32位的int型变量的指针,再用一个*取它指向的值,然后放到EDX寄存器中,这称为间接寻址。

变址寻址(displaced)比间接寻址稍微复杂一点。例如:

movl 4(%ebx), %edx

读者会发现代码中“(%ebx)”前面出现了一个4,也就是在间接寻址的基础上,在原地址上加上一个立即数4,相当于:

edx = *(int*)(ebx+4)

把这个EBX寄存器存储的数值加4,然后强制转化为一个32位的int类型的指针,再用一个*取它指向的值,然后放到EDX寄存器中,这称为变址寻址。

如上所述的CPU对寄存器和内存的操作方法,都是比较基础的知识,需要牢固掌握。

x86-32中的大多数指令都能直接访问内存,但还有一些指令能直接对内存操作,如push/pop。它们根据ESP寄存器指向的内存位置进行压栈和出栈操作,注意这是指令执行过程中默认使用了特定的寄存器。

还需要特别说明的是,本书中使用的是AT&T汇编格式,这也是Linux内核使用的汇编格式,与Intel汇编格式略有不同。我们在搜索资料时可能会遇到Intel汇编代码,一般来说,全是大写字母的一般是Intel汇编,全是小写字母的一般是AT&T汇编。本书中的代码用到的寄存器名称都遵守AT&T汇编格式采用全小写的方式,而正文中需要使用寄存器名称一般使用大写,因为它们是首字母缩写。

还有几个重要的指令:pushl/popl和call/ret。pushl表示32位的push,如:

pushl %eax

就是把EAX寄存器的值压到堆栈栈顶。它实际上做了这样两个动作,其中第一个动作为:

subl $4, %esp

把堆栈的栈顶ESP寄存器的值减4。因为堆栈是向下增长的,所以用减指令subl,也就是在栈顶预留出一个存储单元。第二个动作为:

movl %eax, (%esp)

把ESP寄存器加一个小括号(间接寻址),就是把EAX寄存器的值放到ESP寄存器所指向的地方,这时ESP寄存器已经指向预留出的存储单元了。

接下来介绍popl指令,如:

popl %eax

就是从堆栈的栈顶取一个存储单元(32位数值),从堆栈栈顶的位置放到EAX寄存器里,这称为出栈。出栈同样对应两个操作:

movl (%esp), %eax
addl $4, %esp

第一步是把栈顶的数值放到EAX寄存器里,然后用指令addl把栈顶加4,相当于栈向上回退了一个存储单元的位置,也就是栈在收缩。每次执行指令pushl栈都在增长,执行指令popl栈都在收缩。

call指令是函数调用,调用一个地址。例如:

call 0x12345

上述代码实际上做了两个动作,如下两条伪指令,注意,这两个动作并不存在实际对应的指令,我们用“(*)”来特别标记一下,这两个动作是由硬件一次性完成的。出于安全方面的原因,EIP寄存器不能被直接使用和修改。

pushl %eip (*)
movl $0x12345, %eip (*)

上述伪指令先是把当前的EIP寄存器压栈,把0x12345这个立即数放到EIP寄存器里,该寄存器是用来告诉CPU下一条指令的存储地址的。把当前的EIP寄存器的值压栈就是把下一条指令的地址保存起来,然后给EIP寄存器又赋了一个新值0x12345,也就是CPU执行的下一条指令就是从0x12345位置取得的。

再看与call指令对应的指令ret,ret指令是函数返回,例如:

ret

上述代码实际上做了一个动作,如下一条伪指令,注意,这个动作并不存在实际对应的指令,我们用“(*)”来特别标记一下,这个动作是由硬件一次性完成的。出于安全方面的原因,EIP寄存器不能被直接使用和修改。

popl %eip(*)

也就是把当前堆栈栈顶的一个存储单元(一般是由call指令压栈的内容)放到EIP寄存器里。

上述pushl/popl和call/ret汇编指令对应执行的动作汇总如图1-6所示。

图1-6 pushl/popl和call/ret汇编指令

总结一下,call指令对应了C语言里我们调用一个函数,也就是call一个函数的起始地址。ret指令是把调用函数时压栈的EIP寄存器的值(即call指令的下一条指令的地址)还原到EIP寄存器里,ret指令之后的下一条指令也就回到函数调用位置的下一条指令。换句话说就是函数调用结束了,继续执行函数调用之后的下一条指令,这和C语言中的函数调用过程是严格对应的。但是需要注意的是,带个“(*)”的指令表示这些指令都是不能被程序员直接使用的,是伪指令。因为EIP寄存器不能被程序员直接修改,只能通过专用指令(如call、ret和jmp等)间接修改。若程序员可以直接修改EIP寄存器,那么会有严重的安全隐患。读者可以思考一下为什么?我们就不展开讨论了。

我们已经对指令和寄存器有了大致的了解,下面做一个练习来验证我们的理解。在堆栈为空栈的情况下,执行如下汇编代码片段之后,堆栈和寄存器都发生了哪些变化?

1    push   $8
2    movl    %esp, %ebp
3    subl    $4, %esp
4    movl    $8, (%esp)

我们分析这段汇编代码每一步都做了什么动作。首先在堆栈为空栈的情况下,EBP和ESP寄存器都指向栈底。

第1行语句是将立即数8压栈(即先把ESP寄存器的值减4,然后把立即数8放入当前堆栈栈顶位置)。

第2行语句是把ESP寄存器的值放到EBP寄存器里,就是把ESP寄存器存储的内容放到EBP寄存器中,把EBP寄存器也指向当前ESP寄存器所指向的位置。换句话说,在堆栈中又新建了一个逻辑上的空栈,这一点理解起来并不容易,读者暂时理解不了也没有关系。本书后面会将C语言程序汇编成汇编代码来分析函数调用是如何实现的,其中会涉及函数调用堆栈框架。

第3行语句中的指令是subl,是把ESP寄存器存储的数值减4,也就是说,栈顶指针ESP寄存器向下移了一个存储单元(4个字节)。

最后一行语句是把立即数8放到ESP寄存器所指向的内存地址,也就是把立即数8通过间接寻址放到堆栈栈顶。

本例是关于栈和寄存器的一些操作的,我们可以对照上述文字说明一步一步跟踪堆栈和寄存器的变化过程,以便更加准确地理解指令的作用。

再来看一段汇编代码,同样在堆栈为空栈的情况下,执行如下汇编代码片段之后,堆栈和寄存器都发生了哪些变化?

1  pushl  $8
2  movl   %esp, %ebp
3  pushl  $8

同样我们也分析一下这段汇编代码每一步都做了什么动作。首先在堆栈为空栈的情况下EBP和ESP寄存器都指向栈底。

第1行语句是将立即数8压栈,即堆栈多了一个存储单元并存了一个立即数8,同时也改变了ESP寄存器。

第2行语句把ESP寄存器的值放到EBP寄存器里,堆栈空间没有变化,但EBP寄存器发生了变化。

第3行语句将立即数8压栈,即堆栈多了一个存储单元并存了一个立即数8。

读者会发现,这个例子和上一个例子的实际效果是完全一样的。

小试牛刀之后,再看下面这段更加复杂一点的汇编代码:

1  pushl  $8
2  movl   %esp, %ebp
3  pushl  %esp
4  pushl  $8
5  addl   $4, %esp
6  popl   %esp

这段汇编代码同样首先在堆栈为空栈的情况下EBP和ESP寄存器都指向栈底。

第1行语句是将立即数8压栈,即堆栈多了一个存储单元并保存立即数8,同时也改变了ESP寄存器。

第2行语句是把ESP寄存器的值放到EBP寄存器里,堆栈空间没有变化,但EBP寄存器发生了变化。

第3行语句是把ESP寄存器的内容压栈到堆栈栈顶的存储单元里。需要注意的是,pushl指令本身会改变ESP寄存器。“pushl %esp”语句相当于如下两条指令:

subl $4, %esp
movl %esp, (%esp)

显然,在保存ESP寄存器的值到堆栈中之前改变了ESP寄存器,保存到栈顶的数据应该是当前ESP寄存器的值减4。ESP寄存器的值发生了变化,同时栈空间多了一个存储单元保存变化后的ESP寄存器的值。  

第4行语句是将立即数8压栈,即堆栈多了一个存储单元保存立即数8,同时也改变了ESP寄存器。

第5行语句是把ESP寄存器的值加4,这相当于堆栈空间收缩了一个存储单元。

最后一条语句相当于如下两条指令:

movl (%esp), %esp
addl $4, %esp

也就是把当前栈顶的数据放到ESP寄存器中,然后又将ESP寄存器加4。这一段代码比较复杂,因为ESP寄存器既作为操作数,又被pushl/popl指令在执行过程中使用和修改。读者需要仔细分析和思考这段汇编代码以理解整个执行过程,本书后续内容会结合C代码的函数调用和函数返回,来进一步理解这段汇编代码中涉及的建立一个函数调用堆栈和拆除一个函数调用堆栈。

有了前面的汇编基础之后,下面可以利用学到的知识在计算机上实际演练一下了。C语言程序在计算机上是怎样工作的呢?可以通过汇编C语言程序代码,并分析汇编代码来理解程序的执行过程。

本书涉及的Linux内核实验环境搭建比较复杂,为了简化读者自行搭建实验环境的工作,我们选用了实验楼shiyanlou.com(见二维码4)给我们提供的基于Web访问方式的64位虚拟机环境,并且我们已经在虚拟机中配置好了Linux内核相关的实验环境,这样可以大大减轻读者完成本书相关实验的负荷,以期达到轻松写一个操作系统内核的效果。当然由于网络环境的不稳定,个别读者可能在使用实验楼提供的实验环境时体验不佳,这时读者也可以用自己的Linux环境自行搭建Linux内核实验环境,本书及配套资料也提供了相应的配置说明。在此说明,不同的CPU和Linux发行版本的命令可能略微有些差异,所需的依赖环境可能需要自行安装,所以自行搭建Linux内核实验环境也许会遇到意想不到问题,如果读者还不具备自行解决相关问题的能力和信心,那么建议进入本书配套的Linux内核实验环境进行实验。Linux内核实验环境请访问链接(见二维码5),打开该链接即可看到如图1-7所示的Linux内核实验环境主页。

二维码4

二维码5

图1-7 Linux内核实验环境主页

进入“实验楼”的虚拟机后,可以看到左侧有实验指导。打开“Xfce终端”Shell控制台程序,输入ls命令即可看到目录“LinuxKernel”和“Code”,如图1-8所示为“实验楼”的虚拟机。目录“LinuxKernel”是本书配套实验环境相关的资料,目录“Code”按照“实验楼”的使用约定是保存用户编写代码的目录。但请读者特别注意,“实验楼”的虚拟机并不会永久保存用户的代码文件,请读者及时将“Code”目录下载到本地保存。“实验楼”提供了“下载代码”的功能,这样方便将完成的实验代码保存到读者自己的机器上。不过为了更好地进行版本控制,还是建议读者使用github.com(见二维码6)提供的git版本库及时将代码推送到GitHub的服务器上,这样更加方便一些。

二维码6

图1-8 “实验楼”的虚拟机

接下来就可以根据“实验楼”的虚拟机左侧的实验指导进行本章的实验了。如下所示,我们编写了一个由3个函数组成的C语言小程序,为了简便,这里的代码没有调用标准库函数。

int g(int x)
{
          return x + 3;
}

int f(int x)
{
          return g(x);
}

int main(void)
{
      return f(8) + 1;
}

读者可以将如上代码在“实验楼”的虚拟机手工输入一遍,不过为了提高效率,使用复制/粘贴比较方便。但是“实验楼”的虚拟机环境与本地操作系统环境是两个完全独立的系统,无法直接进行复制/粘贴操作。“实验楼”在这两个独立的系统之间提供了进行复制/粘贴数据传递的方法,我们可以学习一下。

如图1-9所示为复制左侧实验所需的代码。

图1-9 复制左侧实验所需的代码

单击图1-10右侧的“剪切板”。

图1-10 单击右侧的“剪切板”

把刚才复制的代码粘贴到剪切板上,并单击“保存”按钮,如图1-11所示。

图1-11 保存复制的代码到“实验楼”的虚拟机环境

这样我们就可以在“实验楼”的虚拟机环境下把代码粘贴过来,如图1-12所示。需要说明的是,在Shell命令行下我们常用的文本编辑器是VIM,读者只需要在命令行下输入“vi main.c”命令即可打开文本编辑器VIM编辑main.c文件,按“i”键进入输入状态,即可右击鼠标进行粘贴操作。

图1-12 在“实验楼”的虚拟机环境下粘贴代码

图1-13 将粘贴的代码保存到main.c文件

如图1-13所示,粘贴好代码后,在文本编辑器VIM中按“Shift”+“:”进行文本编辑器的命令模式,输入“wq”(即write和quit命令)即可将粘贴的代码保存到main.c文件。

这时我们就可以编译main.c这个代码文件了。直接编译可以使用如下命令:

gcc main.c

会生成一个目标文件a.out,它是可以执行的,但执行效果没有任何输出信息。可以通过如下命令查看一下这个程序的返回值,如图1-14所示。

echo $?

图1-14 编译、执行和查看程序返回值

如果想把main.c编译成一个汇编代码,那么可以使用如下命令:

gcc –S –o main.s main.c –m32

上述命令产生一个以“.s”作为扩展名的汇编代码文件main.s。需要注意的是,“实验楼”环境是64位的,32位和64位汇编程序会有些差异。本书以32位x86为例,上述gcc命令中的“-m32”选项即用来产生32位汇编代码。

这时打开main.s,会发现这个文件是main.c生成的,但main.s汇编文件还有一些“.cfi_”打头的字符串以及其他以“.”打头的字符串,这些都是编译器在链接阶段所需的辅助信息,如下完整的main.s汇编代码读起来会有点让人不知所措。

     .file    "main.c"
     .text
     .globl   g
     .type    g, @function
g:
.LFB0:
     .cfi_startproc
     pushl   %ebp
     .cfi_def_cfa_offset 8
     .cfi_offset 5, -8
     movl     %esp, %ebp
     .cfi_def_cfa_register 5
     movl    8(%ebp), %eax
     addl    $3, %eax
     popl    %ebp
     .cfi_restore 5
     .cfi_def_cfa 4, 4
     ret
     .cfi_endproc
.LFE0:
     .size    g, .-g
     .globl   f
     .type    f, @function
f:
.LFB1:
     .cfi_startproc
     pushl   %ebp
     .cfi_def_cfa_offset 8
     .cfi_offset 5, -8
     movl     %esp, %ebp
     .cfi_def_cfa_register 5
     subl    $4, %esp
     movl    8(%ebp), %eax
     movl    %eax, (%esp)
     call    g
     leave
     .cfi_restore 5
     .cfi_def_cfa 4, 4
     ret
     .cfi_endproc
.LFE1:
     .size    f, .-f
     .globl   main
     .type    main, @function
main:
.LFB2:
     .cfi_startproc
     pushl   %ebp
     .cfi_def_cfa_offset 8
     .cfi_offset 5, -8
     movl     %esp, %ebp
     .cfi_def_cfa_register 5
     subl    $4, %esp
     movl    $8, (%esp)
     call    f
     addl    $1, %eax
     leave
     .cfi_restore 5
     .cfi_def_cfa 4, 4
     ret
     .cfi_endproc
.LFE2:
     .size   main, .-main
     .ident  "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.3) 4.8.4"
     .section      .note.GNU-stack,"",@progbits

由于我们的任务是分析汇编代码,因此可以把main.s简化一下,所有以“.”打头的字符串(都是编译器在链接阶段所需辅助信息)不会实际执行,可以都删掉。在VIM中,通过“g/.s*/d”命令即可删除所有以“.”打头的字符串,就获得了“干净”的汇编代码,这样如下的代码看起来就比较亲切了。

g:
     pushl  %ebp
     movl   %esp, %ebp
     movl   8(%ebp), %eax
     addl   $3, %eax
     popl   %ebp
     ret
f:
     pushl  %ebp
     movl   %esp, %ebp
     subl   $4, %esp
     movl   8(%ebp), %eax
     movl   %eax, (%esp)
     call   g
     leave
     ret
main:
     pushl  %ebp
     movl   %esp, %ebp
     subl   $4, %esp
     movl   $8, (%esp)
     call   f
     addl   $1, %eax
     leave
     ret

接下来分析上述“干净”的汇编代码。可以看到,上述代码对应3个函数:main函数、f函数和g函数。很明显,将C语言代码和汇编代码对照起来,可以看到每个函数对应的汇编代码。阅读C语言代码时一般是从main函数开始读,其实阅读汇编代码也是一样的。C语言代码中的main函数只有一行代码:“return f(8) + 1;”。

int main(void)
{
  return f(8) + 1;
}

f函数也只有一条代码:“return g(x);”。其中的参数x是8,因此,f(8)返回的是g(8)。

int f(int x)
{
  return g(x);
}

g函数也只有一条代码:“return x+3;”。其中的参数x是8,g(8)返回的是8+3。那么最终main函数返回的是8+3+1=12。图1-14中的a.out执行结果的返回值12就是这么来的。

C语言代码比较容易读懂,因为其更接近自然语言,但汇编语言就比较难懂一些,因为其更接近机器语言。机器语言完全是二进制的,理解起来比较困难,汇编代码基本上是机器代码的简单翻译和对照。我们看这么简单的一个C语言程序main.c在机器上是如何执行的。本章前面大多都介绍过汇编文件main.s中的这些汇编指令,读者应该已经知道它们大概的功能和用途了。main.s中新出现的汇编指令是leave指令。enter和leave这对指令可以理解为宏指令了,其中leave指令用来撤销函数堆栈,等价于下面两条指令:

movl %ebp,%esp
popl %ebp

另外,enter指令用来建立函数堆栈,等价于下面两条指令:

pushl %ebp
movl %esp, %ebp

enter指令的作用就是再堆起一个空栈,后面介绍函数调用堆栈时会进行详细介绍。而leave指令就是撤销这个堆栈,和enter指令的作用正好相反。

讲解完这个陌生的leave指令,下面我们可以完整地分析一下main.s中的汇编代码了。EIP寄存器是指向代码段中的一条条指令,即main.s中的汇编指令,从“main:”开始,它会自加一,调用call指令时它会修改EIP寄存器。EBP寄存器和ESP寄存器也特别重要,这两个寄存器总是指向一个堆栈,EBP指向栈底,而ESP指向栈顶。注意,栈底是一个相对的栈底,每个函数都有自己的函数堆栈和基地址。另外,EAX寄存器用于暂存一些数值,函数的返回值默认使用EAX寄存器存储并返回给上一级调用函数。

下面来具体分析删除所有以“.”打头的字符串之后的main.s中的汇编代码。最初程序从main函数开始执行,即EIP寄存器指向“main:”下面的第一条汇编指令。为了简化,使用如下汇编代码的行号作为EIP寄存器的值,来表示EIP寄存器指向行号对应汇编指令。

1   g:
2       pushl   %ebp
3       movl    %esp, %ebp
4       movl    8(%ebp), %eax
5       addl    $3, %eax
6       popl    %ebp
7       ret
8   f:
9       pushl   %ebp
10      movl    %esp, %ebp
11      subl    $4, %esp
12      movl    8(%ebp), %eax
13      movl    %eax, (%esp)
14      call    g
15      leave
16      ret
17  main:
18      pushl   %ebp
19      movl    %esp, %ebp
20      subl    $4, %esp
21      movl    $8, (%esp)
22      call    f
23      addl    $1, %eax
24      leave
25      ret

代码在执行过程中,堆栈空间和相应的EBP/ESP寄存器会不断变化。首先假定堆栈为空栈的情况下EBP和ESP寄存器都指向栈底,为了简化起见,我们为栈空间的存储单元进行标号,压栈时标号加1,出栈时标号减1,这样更清晰一点。需要注意的是,x86体系结构栈地址是向下增长的(地址减小),但这里只是为了便于知道堆栈存储单元的个数大小,栈空间的存储单元标号是逐渐增大的。如图1-15所示,右侧的数字表示内存地址,EBP和ESP寄存器都指向栈底,即指向一个4字节存储单元的下边缘2000的位置,指2000~2003这4个字节,也就是标号为0的存储单元,依此类推,标号1的存储单元为1996~1999这4个字节。

图1-15 堆栈空间示意图

程序从main函数开始执行,即上述代码的第18行,也就是“main:”下面的第一条汇编指令“pushl %ebp”,这是开始执行的第一条指令,这条指令的作用实际上就是把EBP寄存器的值(可以理解为标号0,实际上是图1-15中的地址2000)压栈,pushl指令的功能是先把ESP寄存器指向标号1的位置,即标号加1或地址减4(向下移动4个字节),然后将EBP寄存器的值标号0(地址2000)放到堆栈标号1的位置。

开始执行上一条指令时,EIP寄存器已经自动加1指向了上述代码第19行语句“movl %esp,%ebp”,是将EBP寄存器也指向标号1的位置,这条语句只修改了EBP寄存器,栈空间的内容并没有变化。第18行和第19行语句是建立main函数自己的函数调用堆栈空间。

开始执行上一条指令时,EIP寄存器已经自动加1指向了上述代码的第20行“subl $4, %esp”,把ESP寄存器减4,实际上是ESP寄存器向下移动一个标号,指向标号2的位置。这条语句只修改了ESP寄存器,栈空间的内容并没有变化。

开始执行上一条指令时,EIP寄存器已经自动加1指向了上述代码的第21行“movl$8, (%esp)”,把立即数8放入ESP寄存器指向的标号2位置,也就是第20行代码预留出来的标号2的位置。这条语句的EBP和ESP寄存器没有变化,栈空间发生了变化。第20和21行语句是在为接下来调用f函数做准备,即压栈f函数所需的参数。

开始执行上一条指令时;EIP寄存器已经自动加1指向了上述代码的第22行指令“call f”,call指令我们仔细分析过,第22行指令相当于如下两条伪指令:

pushl %eip(*)
movl f %eip(*)

第22行语句“call f”开始执行时,EIP寄存器已经自加1指向了下一条指令,即上述代码的第23行语句,实际上把EIP寄存器的值(行号为23的指令地址,我们用行号23表示)放到了栈空间标号3的位置。因为压栈前ESP寄存器的值是标号2,压栈时ESP寄存器先减4个字节,即指向下一个位置标号3,然后将EIP寄存器的行号23入栈到栈空间标号3的位置。接着将f函数的第一条指令的行号9放入EIP寄存器,这样EIP寄存器指向了f函数。这条语句既改变了栈空间,又改变了ESP寄存器,更重要的是它改变了EIP寄存器。读者会发现原来EIP寄存器自加1指令是按顺序执行的,现在EIP寄存器跳转到了f函数的位置。

接着开始执行f函数。首先执行第9行语句“pushl %ebp”,把ESP寄存器的值向下移一位到标号4,然后把EBP寄存器的值标号1放到栈空间标号4的位置。

第10行语句“movl %esp, %ebp”是让EBP寄存器也和ESP寄存器一样指向栈空间标号4的位置。

读者可能会发现,第9行和第10行语句与第18行和第19行语句完全相同,而且g函数的开头两行也是这两条语句。总结一下:所有函数的头两条指令用于初始化函数自己的函数调用堆栈空间。

第11行语句要把ESP寄存器减4,即指向下一个位置栈空间的标号5,实际上就是为入栈留出一个存储单元的空间。

第12行语句通过EBP寄存器变址寻址:EBP寄存器的值加8,当前EBP寄存器指向标号4的位置,加8即再向上移动两个存储单元加两个标号的位置,实际所指向的位置就是堆栈空间中标号2的位置。如上所述,标号2的位置存储的是立即数8,那么这一条语句的作用就是把立即数8放到了EAX寄存器中。

第13行语句是把EAX寄存器中存储的立即数8放到ESP寄存器现在所指的位置,即第11行语句预留出来的栈空间标号5的位置。第11~13行语句等价于“pushl $8”或“pushl 8(%ebp)”,实际上是将函数f的参数取出来,主要目的是为调用函数g做好参数入栈的准备。

第14行语句是“call g”,与上文中调用函数f类似,将ESP寄存器指向堆栈空间标号6的位置,把EIP寄存器的内容行号15放到堆栈空间标号6的位置,然后把EIP寄存器指向函数g的第一条指令,即上述代码的第2行。

接下来执行函数g,与执行函数f或函数main的开头完全相同。第2行语句就是先把EBP寄存器存储的标号4压栈,存到堆栈空间标号7的位置,此时ESP寄存器为堆栈空间标号7。

接下来的第3行语句让EBP寄存器也和ESP寄存器一样指向当前堆栈栈顶,即堆栈空间标号7的位置,这样就为函数g建立了一个逻辑上独立的函数调用堆栈空间。

第4行语句“movl 8(%ebp), %eax”通过使用EBP寄存器变址寻址,EBP寄存器加8,也就是在当前EBP寄存器指向的栈空间标号7的位置基础上向上移动两个存储单元指向标号5,然后把标号5的内容(也就是立即数8)放到EAX寄存器中。实际上,这一步是将函数g的参数取出来。

第5行语句是把立即数3加到EAX寄存器里,就是8+3,EAX寄存器为11。

这时EBP和ESP寄存器都指向标号7,EAX寄存器为11,EIP寄存器为代码行号6,函数调用堆栈空间如图1-16所示。EBP或ESP+栈空间的标号表示存储的是某个时刻的EBP或ESP寄存器的值,EIP+代码行号表示存储的是某个时刻的EIP寄存器的值。

图1-16 执行到第5行代码时函数调用堆栈空间示意图

第6行和第7行语句的作用是拆除g函数调用堆栈,并返回到调用函数g的位置。第6行语句“popl %ebp”实际上就是把标号7的内容(也就是标号4)放回EBP寄存器,也就是恢复函数f的函数调用堆栈基址EBP寄存器,效果是EBP寄存器又指向原来标号4的位置,同时ESP寄存器也要加4个字节指向标号6的位置。

第7行语句“ret”实际上就是“popl %eip”,把ESP寄存器所指向的栈空间存储单元标号6的内容(行号15即代码第15行的地址)放到EIP寄存器中,同时ESP寄存器加4个字节指向标号5的位置,也就是现在EIP寄存器指向代码第15行的位置。

这时开始执行第15行语句“leave”,如上所述,leave指令用来撤销函数堆栈,等价于下面两条指令:

movl %ebp,%esp
popl %ebp

结果是把EBP寄存器的内容标号4放到了ESP寄存器中,也就是ESP寄存器也指向标号4。然后,“popl %ebp”语句把标号4的内容(也就是标号1)放回EBP寄存器,实际上是把EBP寄存器指向标号1的位置,同时ESP寄存器加4个字节指向标号3的位置。

第16行语句“ret”是把ESP寄存器所指向的标号3的位置的内容(行号23即代码第23行指令的地址)放到EIP寄存器中,同时ESP寄存器加4个字节指向标号2的位置,也就是现在EIP指向第23行的位置。

第23行语句“addl$1, %eax”是把EAX寄存器加立即数1,也就是11+1,此时EAX寄存器的值为12。EAX寄存器是默认存储函数返回值的寄存器。

第24行语句“leave”撤销函数main的堆栈,把EBP和ESP寄存器都指向栈空间标号1的位置,同时把栈空间标号1存储的内容标号0放到EBP寄存器,EBP寄存器就指向了标号0的位置,同时esp加4个字节,也指向标号0的位置。

这时堆栈空间回到了main函数开始执行之初的状态,EBP和ESP寄存器也都恢复到开始执行之初的状态指向标号0。这样通过函数调用堆栈框架暂存函数的上下文状态信息,整个程序的执行过程变成了一个指令流,从CPU中“流”了一遍,最终栈空间又恢复到空栈状态。

1.假定当前是32位x86机器,EBP寄存器的值为12(内存地址),ESP寄存器的值为8(内存地址),执行完如下代码后ESP寄存器的值是(  )。

pushl %ebp

2.假定当前是32位x86机器,EAX寄存器的值为1234,EBX寄存器的值为4321,执行完如下代码后EAX的值是(  )。

movl %eax, %ebx

3.寻址方式是直接寻址的指令是(  )。

A.movl %eax, %edx

B.movw $0x123, %ax

C.movb 0x12, %ah

D.movl (%ebx), %edx

4.在32位x86 CPU中,我们使用pushl和popl指令实现入栈和出栈,popl指令可以使得ESP寄存器的值增加(  )个字节。

5.在32位x86 CPU中,CS:EIP指向要执行的指令地址,所以想执行0x123处的代码,我们是否可以通过“movl $0x123, %eip”指令来跳转到0x123处的代码?请回答可以或不可以(  )。

6.冯·诺依曼体系结构的核心思想是存储程序计算机?请回答是或否(  )。

7.与下面两条指令等价的一条指令是(  )。

pushl %ebp 
movl %esp, %ebp

A.popl  

B.ret

C.leave  

D.enter

8.假定当前是32位x86机器,函数的返回值默认使用哪个寄存器来返回给上级函数?

参照第1.3节,将如下C语言代码汇编成“.s”文件,并分析“.s”文件中的汇编代码的执行过程,其中重点关注EBP/ESP寄存器、EAX寄存器、EIP寄存器和函数调用堆栈空间在汇编代码的执行过程是如何变化的。

int g(int x)
{
  return x + 3;
}

int f(int x)
{
  return g(x);
}

int main(void)
{
  return f(8) + 1;
}

使用如下命令汇编上述C语言代码(以下命令适用于实验楼64位Linux虚拟机环境):

gcc –S –o main.s main.c -m32

相关图书

Linux常用命令自学手册
Linux常用命令自学手册
庖丁解牛Linux操作系统分析
庖丁解牛Linux操作系统分析
Linux后端开发工程实践
Linux后端开发工程实践
轻松学Linux:从Manjaro到Arch Linux
轻松学Linux:从Manjaro到Arch Linux
Linux高性能网络详解:从DPDK、RDMA到XDP
Linux高性能网络详解:从DPDK、RDMA到XDP
跟老韩学Linux架构(基础篇)
跟老韩学Linux架构(基础篇)

相关文章

相关课程