深入理解计算机系统

3. 程序的机器级表示

3.2 程序编码

gcc中的编译选项-Og<-O1,-O1优化地更好

实际上,gcc调用了一整套的程序来把源代码转化为可执行代码

3.21 机器级代码

  • 程序计数器PC(%rip)给出将要执行的下一条指令
  • 整数寄存器文件
  • 条件码寄存器,保存着状态信息,如正负,溢出,借位等等
  • 向量寄存器

3.3 数据格式

字节,字,双字,四字的含义

3.4 访问信息

64位的CPU包含一组16个存储64位值的通用目的寄存器

3.41操作数指示符

大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据的值,以及放置结果的目的位置。

其中,源数据的值可以以常数的形式给出,或是从寄存器或内存中读出。
结果可以存放在寄存器或者是内存中

因此各种不同的操作数可以分成三种类型

  • 立即数
    用来表示常数值,书写方式是$加上一个数字
  • 寄存器
    它表示某个寄存器的内容,当我们用ra来表示一个寄存器时,通常引用R[ra]来表示它的值,这是将寄存器集合看作是一个数组R,用寄存器的标识符作为索引
  • 内存引用
    它会根据计算出来的地址(通常称为有效地址)访问某个内存的位置。
    通常最常用的寻址形式是Imm(rb,ri,s),其中,Imm是立即数偏移,rb是基址寄存器,ri是变址寄存器,s为比例因子,因此,Imm(rb,ri,s)所表达的是M[Imm+R[rb]+R[ri]*s],需要留意的是,s必须为1,2,4,8

通俗地来说,内存引用一个寄存器,其实就是访问寄存器所存的地址,读取该地址上的那个数

3.42数据传送指令mov类

  • movb、movw、movl、movq分别传送的是1个字节2、4、8个字节的数据
  • 源操作数必须是一个立即数,或者是存储在寄存器或内存中,
  • 目的操作数要么是一个寄存器,要么是一个内存地址。

需要注意的是,源操作数和目的操作数不能同时为内存地址,如果要将一个指从一个内存地址复制到另一个内存地址,需要分两部,先把源值加载到寄存器中,再将该寄存器的值写入目的位置。

还有一个小问题,movq看起来像是把一个8个字节的立即数放到另一个地方,实际上,常规的movq只能够以32位补码数字的立即数作为源操作数,然后把这个数符号拓展为64位的值,再放到目的位置,而movabsq能解决这一问题

思考一个问题,我们把一个较小的值放到一个较大的目的地时,会发生什么?剩下多余的位会存些什么?基于这个问题,我们有了movs类(movsbw等)以及movz类(movzbl等),两种类分别进行符号位拓展以及零拓展,而如果我们不使用这两个类,数据是怎么传输的?我们通过一个例子来解释(*注意,所有的源数据都是立即数!)
。。。

cltq的作用是把%eax符号拓展到%rax

3.44压入和弹出栈数据

3.5 算术和逻辑操作

3.51 加载有效地址

加载有效地址指令leaq(load effective address)实际上是movq的变形。然而书上写了一堆它用来作算术运算的例子,形如%rax的值为x,%rdx为y

  • leaq (%rax,%rax,4),%rdx 的意思是y=5x

3.52 一元和二元操作

不管怎么样,最后一个操作数肯定是目的。

3.53 移位操作

3.55特殊的算术操作

两个64位有符号数或者是无符号整数相乘得到的乘积需要128位来表示。x86-64指令集对128位(16位)数的操作提供有限的支持。16字节称为八字。这里需要注意的是imul类以及mul类可以是一元操作,也可以是二元操作。一元操作的时候,需要把数存在%rax。同时clto提供转为为八字的方式

3.6 控制

3.61条形码

我们在前面提到,cpu除了维护着整数寄存器,还维护这一组单个位的条形码寄存器,他们描述了最近的算术或逻辑操作的属性。
常见的条形码有:

  • CF:进位标志。最近的操作使最高位产生了进位。可以用来检查无符号操作的溢出。
  • ZF:零标志。最近的操作得出了0。
  • SF:符号标志,最近的操作得出了负数。
  • OF:溢出标志:最近的操作导致了一个补码溢出——正溢出或者是负溢出

我认为,这里唯一需要注意的是除了leaq,基本上大部分指令都会设置条形码,但有些特别的如移位操作,进位标志设置为最后一个移除的位,溢出标志设置为0。

3.62访问条件码

cmp类指令要注意一个问题:

  • cmp %rsi %rdi,实际上比较的是%rdi和%rsi,之所以我们要强调顺序,是因为我们在设置条件码的时候大于小于是需要根据位置判断的。
    set类指令,把条件码赋值到目的的最后一位,

3.63 跳转指令

记住一些常见的跳转指令e=equal,s=signal,n=negative,g=giant,l=less,a=无符号大于,b=无符号小于

3.64 跳转指令的编码

3.65用条件控制来实现条件分支

将条件表达式和语句从C翻译成机器代码,最常用的方式是结合有条件和无条件跳转,汇编语言生成的时候,如果存在if,很有可能会转换成goto语句

3.66用条件传送来实现条件分支

这一部分涉及到分支预测的问题,不深入讨论,只要理解机器会试图去猜测进哪一个分支。猜对了就继续执行,没猜对的话将会浪费一堆时间的同时,返回已经猜对的地方。

3.67循环

我们学过do-while、while和for循环,汇编中没有对应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。

3.68 switch语句

switch 主要分为两个部分,程序以及表,表主要用来存储跳转表。

  • 执行switch语句的关键步骤是通过跳转表来访问代码位置。注意,跳转表是从0开始的。
  • 还有需要注意的是switch中default的情况
  • 在汇编代码中,跳转表用以下声明表示在这里插入图片描述
  • 这些声明表明,在叫做“.rodata”(Read-Only Data)的目标代码文件的段中,应该有一组7个四字,每个字的值都是与制定的汇编代码标号(如:L3)相关联的指令地址。标号.L4标记出这个分配地址的起始。与这个标号相对应的地址会作为间接跳转(第5行)的基地址

3.7过程

3.7.1运行时栈

栈帧的概念,以及x86-64的栈是向低地址增长的。
过程P调用过程Q时,具体的流程是怎么样的,比如返回地址怎么存放,传入Q的参数怎么存放等等。
接下来我们会具体介绍栈的内容。

3.7.2 转移控制

所谓的转移控制,就是过程P调用过程Q时,把执行完Q后应该返回的地址压入P的栈中,注意,这一过程中返回地址应该时call指令的下一个地址。

3.7.3 数据传送

当调用一个过程时,除了要把控制传递给它并且在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。

  • 第一种最普通的情况,是通过寄存器传递最多六个整型参数。而这些寄存器的使用是有顺序的传递的参数分别存在rdi,rsi,rdx,rcx,r8,r9,寄存器的名字取决于要传递的参数的大小
  • 而如果要传递超过6个参数,那么超出六个的部分将要通过栈来传递

3.7.4栈上的局部存储

3.7.5寄存器中的局部存储空间

寄存器组是唯一被所有过程共享的资源。虽然再给定时刻只有一个过程是活动的,但是我们仍然需要确保的是,当一个过程调用另一个过程时,被调用者不会覆盖调用者稍后需要使用的值。

  • 解决办法:采用统一的寄存器使用惯例,哪些是调用者保存,哪些是被调用者保存。
  1. 被调用者保存
  • 什么叫做被调用者保存:意思就是过程P调用过程Q时,Q必须保存这些寄存器的值,使得在Q返回P时这些寄存器的值与调用Q时的值是一样的。
  • 怎么做到被调用者保存:1. 要么就不去改变这些寄存器的值,2.要么就先把这些寄存器的值放进栈中,在返回前把值从栈中弹出。压入寄存器的值会被保存到栈帧的“保存的寄存器”这一栏中
  • 有哪些寄存器是被调用者保存的:rbx,rbp,r12-r15
    2.调用者保存

3.9异质的数据结构

结构,用关键字struct来声明,将多个对象集合到一个单位中;联合,用关键字union声明,允许用集中不同的类型来引用一个对象

3.9.1结构

  • 结构以内存的一块来表示
    • 足够大以至于能存放所有的域
  • 域的排序是完全根据声明的
    • 即便另一种排序的方式能使得结构更加紧凑
  • 编译器决定字段的总体大小以及位置

3.9.2联合

3.9.3数据对齐

对其原则:任何K字节的对象的地址必须是K字节的倍数

为什么要有数据对齐:简化了形成处理器和内存系统之间接口的硬件设计。举个例子,如果处理器每次从内存中读取8个字节,则地址必须为8的倍数。因为如果一个double类型的对象的地址不是8的倍数的话,我们需要两次的内存访问才能读取到这个对象。

虽然如此,x86-64无论在数据是否对齐的情况下都能正常工作,只不过如果你对齐了,那么将提高处理器的性能。

对于包含结构体的代码,编译器可能需要在字段分配中插入间隙,以保证每一个结构元素都满足它的对齐要求。而且结构体本身对它的起始地址也有一些对齐要求。

3.10 在机器级程序中将控制与数据结合起来

3.10.4 对抗缓冲区溢出攻击

第七章链接

什么是链接?
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。

  • 链接可以执行于编译时,也就是源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存执行时;甚至可以执行于运行时。

  • 执行链接操作的是链接器

我们为什么要有链接这一步而不是把所有代码写在一块?

  • 它使得分离编译成为可能,我们不用将一个大型应用程序组织成为一个巨大的源文件,而是把它分解成为更小的,可以独立地修改和编译的模块。

我们为什么要学习编译?

  • 理解链接器就可以减少在大作业时候的错误ahhh,理解链接器能帮助你构造大型程序。(缺少模块,缺少库,不兼容的库版本引起的链接器错误。)
    • 链接器是如何解析引用
    • 什么是库
    • 链接器是如何使用库来解析引用的。
  • 理解链接器能帮助你避免一些危险的变成错误
    • Linux链接器解析符号引用时所做的决定可以影响程序的正确性,错误地定义多个全局变量的程序将通过链接器而不产生警告信息。
  • 理解链接能够帮助理解语言的作用域规则是如何实现的。
    • 什么是全局变量
    • 什么是局部变量
    • 当你定义一个具有static属性的变量或者函数的时候,到底干了些什么
  • 理解链接能帮助理解其他重要的系统概念
    • 加载和运行程序
    • 虚拟内存
    • 分页
    • 内存映射
  • 理解共享库的概念

7.1 编译器驱动程序

编译器驱动程序包括

  • 语言预处理器cpp(把main.c->main.i)
  • 编译器ccl(把main.i->main.s汇编语言文件)
  • 汇编器as(把main.s->main.o可重定位文件relocatable object file)
  • 链接器ld (将main.o和sum.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件)

7.2 静态链接

为了构造可执行文件,链接器完成了两个主要任务:

符号引用-①-符号定义-②-内存位置

  • 符号解析:将每一个符号引用与一个符号定义关联起来

  • 重定位:编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每一个符号定义和一个内存地址关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。

7.3目标文件

目标文件有三种形式:

  • 可重定位目标文件(.o file):包含二进制代码和数据,其形式可以在编译时与其他可重定位合并起来,形成一个可执行目标文件。
  • 可执行目标文件(a.out file):包含二进制代码和数据,其形式可以直接复制到内存并执行。
  • 共享目标文件(.so file):一种特殊类型的可重定位文件,可以在加载和执行的时候被动态地加载进内存并链接。
    书上讨论的是目标文件的ELF格式(可执行可链接格式)

7.4可重定位目标文件

都是“节”

  • ELF头
    • 包括了字的大小(word size)以及字节顺序,文件类型(这个目标文件是可重定位的?还是可执行的目标文件?还是共享目标文件?),机器类型(x86-64)等信息
  • segment header table(段头表,只有可执行目标文件内有):包括了page size,等等
  • .text section:已编译的程序的机器代码
  • .rodata section:只读数据
  • .data section:已经初始化的全局变量
  • .bss section:没有初始化的全局变量,在目标文件中,这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已经初始化的数据和未初始化的数据是为了空间效率:在目标文件中,没初始化的数据不占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。
  • .symtab section:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
  • .rel.text section:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
  • .rel.data section:被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果他的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
  • .debug section:一个调试表。

7.5 符号和符号表(.symtab)

每一个可重定位目标模块m都有一个符号表。在链接器的上下文中,有三种符号:

  • Global symbols:被模块m定义并且能被其他模块引用的全局符号。

  • External symbols:Global symbols that

  • Local symbols:只被模块m定义和引用的局部变量。对用带static属性的C函数和全局变量。这些符号在模块m中的任何位置可见,但对于其他模块,它是不可见的。
    在这里插入图片描述
    本地变量的符号是被放在栈上管理的,链接器对这类符号不感兴趣。

符号表是汇编器构造的,
COMMON和.bss的区别:
COMMON:未初始化的全局变量
.bss:未初始化的静态变量,或者初始化为0的全局或者静态变量

7.6符号解析

链接器解析符号引用的方法是:将每个引用与它输入的可重定位文件的符号表中的一个确定的符号定义对应起来

7.6.1 链接器如何解析多重定义的全局符号

在这里插入图片描述
程序的符号要么是强的要么是弱的,而且汇编器把这个信息隐含的编码在可重定位目标文件的符号表里。
所谓的强符号:函数或者已经初始化的变量就是强符号
弱符号:未初始化的全局变量是弱符号。
链接器符号规则:

  • 不允许多个同名的强符号
  • 如果有一个强符号和多个弱符号重名,则选择强符号。
  • 如果有多个弱符号重名,则随便选择一个弱符号。
  • 一强一弱如果符号的类型不同,也可能选择弱的
    在这里插入图片描述
    在这里插入图片描述
    对于全局变量的建议:
  • 能不用就不要用
  • 如果可以,请加上static,使得只在自己的模块使用该全局变量
  • 如果你定义了一个全局变量,请将他初始化,来构成强符号
  • 如果要引用别的模块的全局变量,请加上extern
    • 需要注意的是,要把该引用的全局变量当作弱符号
    • 如果在别的模块没有定义,会产生链接错误

7.6.2 与静态库链接

packaging commonly used functions

  • How to package functions commonly used by programmers
    对于如今的编译器框架,这十分尴尬
  • 方法一:把所有的函数都放在一个单独的可重定位目标模块中,应用程序员可以把这个模块链接到它们的可执行目标文件中。
    缺点:浪费时间和空间
  • 方法二:为每一个函数单独创建一个独立的可重定位文件。这种方法要求程序员显式地链接合适的目标模块到他们的可执行文件中,容易出错而且耗时
    我们提出了与静态库链接的概念,所谓的静态库,就是把所有的目标模块打包成一个单独的文件,它就叫做静态库,它可以作为链接器的输入,当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的模块。
    相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中被定义的函数。

7.7 重定位Relocation

在这里插入图片描述

  • 重定位节和符号定义:简单地说,就是链接器把相同类型地节合并为统一类型地新的聚合节。然后,链接器将运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成后,程序中的一条指令和全局变量都有唯一的运行时的内存地址了。
  • 重定位节中的符号引用:

7.7.1 重定位条目

两种重定位类型:
R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。一个PC相对地址就是距离程序计数器PC的当前运行值得偏移
R_X86_64_32:重定位一个使用32位绝对地址的引用。

7.7.2 重定位符号引用

10系统级IO

在Linux系统里面,是通过由内核提供的系统级UnixI/O函数来实现这些较高级别的I/O函数的。(由底层->高层的映射)
而既然我们已经学习了高级的IO函数,为啥还要来学这些无味的系统级IO呢?
说白了:就是为了让你更深刻的了解高级语言以及高级语言是怎么来的

10.1Unix I/O

一个Linux文件就是一个m字节的序列,所有的I.O设备都被模型化为文件,而所有的输入输出操作都被当作对相应文件的读,写来执行。

  • 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个IO设备。内核返回一个非常小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关打开这个文件的所有信息,应用程序只需要记住这个描述符。

10.2文件

  • 普通文件:1.文本文件:只含有ASCII或者Unicode字符的普通文件 2.二进制文件:除了文本文件就是二进制文件。对于内核而言,两者没有区别。
  • 目录:是包含一组链接的文件,其中每个链接都将一个文件名映射到一个文件
  • 嵌套字

LINUX内核将所有文件都组成一个目录层次结构,由名为/的根目录确定。尾部有斜杠表示是目录

  • 绝对路径,以一个斜杠开始,表示从根节点开始的路径。
  • 相对路径,以文件名开始,表示从当前工作目录开始的路径

10.3打开和关闭文件

  • 最主要是要理解一个叫做描述符的东西,简单的说,他就是一个对文件的编号,但是需要注意的是因为Linux shell创建的每一个进程开始时都有三个打开的文件:标准输入,标准输出,标准错误,因此我们的描述符应该从3开始用。
  • 除了描述符以外,本小节还介绍了什么时访问权限位,因为一个open函数有三个参数,第一个是文件名,第二个是打开文件的方式(只读,只写,可读可写),以及第三位是mode参数,mode参数指明了新文件的访问权限位。比如S_IRUSR,S_IWUSR,S_IXUSR,这三个是使用者(程序拥有者的权限),S_IRGRP这个是拥有着所在组的成员。S_IROTH是指其他人(任何人)的权限。

10.4读和写文件

应用程序是通过read和write函数来执行输入和输出的
read函数从描述符位fd的当前文件读取最多N个字节到内存地址buf。它是具有返回值的,当返回值为-1时表示一个错误,而返回值为0时表示遇到EOF。否则返回的值就是实际传送的字节数量。返回值是一个ssize_t一个有符号数。
write函数差不多。

  • 在某些情况下,read和write可能会遇到传送的字节比应用程序要求的字节要少,但这些不足值不表示一个错误。出现这种情况的原因有:
    1. 读的时候遇到EOF
    2. 从终端读文本行。
    3. 读和写网络套接字

10.6读取文件元数据

所谓的文件元数据包含了文件的一些属性。我们主要了解的是文件的大小(字节数)以及文件的类型/文件访问许可位
Linux在sys/stat.h中定义了一些宏谓词来确定st_mode成员的文件类型,
S_ISREG(m)。这是一个普通文件吗
S_ISDIR(m)。这是一个目录文件吗
S_ISSOCK(m)。这是一个网络套接字吗

10.7读取目录内容

10.8共享文件

内核用三个相关的数据结构来表示打开的文件:

  • 描述符表:
  • 文件表:包括了文件位置,引用计数(即表示当前指向该表项的描述符表项数),以及一个指向v-node表的指针。
  • v-node表这个v-node表包含了stat结构里面的大多数信息,比如:文件大小,文件类型等等
  1. 对同一个filename调用两次的open函数会发生什么:会产生多个描述符,每一个描述符表项会指向不同的打开文件表,而这些文件表将指向同一个V-node表。也就是说多个描述符通过不同的文件表表项来引用同一个文件。
  2. 父子进程是如何共享文件的?
    fork函数是怎么使用的?
  • 需要注意的是子进程有一个父进程描述符表的副本,父子进程共享打开文件表,因此父子进程共享相同的文件位置。内核在删除对应的文件表表项之前,父子进程必须都关闭了它们的描述符。

10.9I/O重定向

Linux shell提供了I/O重定向操作符,允许用户将磁盘文件和标准输入输出联系起来

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值