assembly_log


汇编语言

一、操作数格式

  1. 操作数被分为三种类型

    • 立即数(immediate): 用来表示常数值,在ATT格式的汇编代码中,立即数的书写方式是‘$’后面跟一个C标准整数,如$-577或$0x1F
    • 寄存器(register): 标志某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节、8字节中的一个作为操作数
    • 内存引用:根据计算出来的地址访问某个内存位置
  2. 下表为操作数格式

    对于下表中出现的符号,有以下定义:

    • ra : 表示任意寄存器a
    • R[ra] : 引用ra的值
    • Mb[Addr]: 表示对储存在内存中从地址Addr开始的b个字节的引用
    • Imm(rb, ri, s): 内存引用,有效地址被计算为 Imm + R[rb] + R[ri] * s
类型 格式 操作数值 名称
立即数 $Imm Imm 立即数寻址
寄存器 ra R[ra] 寄存器寻址
储存器 Imm M[Imm] 绝对寻址
储存器 (ra) M[R[ra]] 间接寻址
储存器 Imm(rb) M[Imm + R[ri]] (基址+偏移量)寻址
储存器 (rb, ri) M[R[rb] + R[ri]] 变址寻址
储存器 Imm(rb, ri) M[Imm + R[rb] + R[ri]] 变址寻址
储存器 (,ri, s) M[R[ri] * s] 比例变址寻址
储存器 Imm(,ri, s) M[Imm + R[ri] * s] 比例变址寻址
储存器 (rb, ri, s) M[R[rb] + R[ri] * s] 比例变址寻址
储存器 Imm(rb, ri, s) M[Imm + R[rb] + R[ri] * s] 比例变址寻址
表 1-1 操作数格式。

二、数据传送指令

1. MOV 类

指令 效果 描述
MOV S, D DS 传送
movb 传送字节
movw 传送字
movl 传送双字
movq 传送四字
movabsq I, R RI 传送绝对四字
表 2-1 简单的数据传送指令
指令 效果 描述
MOVZ S, R RS(零扩展) 以零扩展进行传输
movzbw 将做了零扩展的字节传送到字
movzbl 将做了零扩展的字节传送到双字
movzwl 将做了零扩展的字传送到双字
movzbq 将做了零扩展的字节传送到四字
movzwq 将做了零扩展的字传送到四字
表 2-2 零扩展数据传送指令。这些指令以寄存器或内存地址作为源,以寄存器作为目的
指令 效果 描述
MOVS S, R RS(符号扩展) 传送符号扩展的字节
movsbw 将做了符号扩展的字节传送到字
movsbl 将做了符号扩展的字节传送到双字
movswl 将做了符号扩展的字传送到双字
movsbq 将做了符号扩展的字节传送到四字
movslq 将做了符号扩展的双字传送到四字
ctlq %rax ← %eax(符号扩展) 把%eax符号扩展到%rax
表 2-3 符号扩展数据传送指令。movs指令以寄存器或内存地址作为源,以寄存器作为目的。ctlq指令只作用于寄存器%eax和%rax

2. 压入与弹出栈数据

指令 效果 描述
pushq S R[%rsp] ← R[%rsp] - 8;
M[R[%rsp]] ← S
将四字压入栈
popq D D ← M[R[%rsp]];
R[%rsp] ← R[%rsp] + 8
将四字弹出栈
表 2-4 入栈和出栈指令

三、x86_64的寄存器

​ 一个x86-64 的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。下图显示了这16个寄存器。它们的名字都以%r开头,不过后面还跟着一些不同命名规则的名字,这是由于指令集历史演化造成的。最初的8086中有8个16位的寄存器,即%ax到%sp。每个寄存器都有特殊用途。扩展到IA32架构时,这些寄存器也扩展成32位寄存器,标号从%eax到%ebp。扩展到x86-64后,原来的8个寄存器扩展成64位,标号从%rax到%rsp。除此之外,还增加了8个新的寄存器,它们的标号是按照新的命名规则制定的:从%r8到%r15。

图3-1 x86_64寄存器

四、算数与逻辑操作

1. 整数算数操作

​ 图4-1列出了x86-64的一些整数和逻辑操作。大多数操作都分成了指令类,这些指令类有各种带不同大小操作的变种(只有leaq没有其他大小的变种)。例如,指令类ADD由四条加法指令组成:addbaddwaddladdq

​ 事实上,给出的每个指令类都有对这四种不同大小数据的指令。这些指令操作被分为四种:加载有效地址、一元操作、二元操作和移位

图4-1 整数算术操作

2. 特殊的算术操作

​ 两个64位有符号或无符号整数相乘得到的乘积需要128位来表示。x86-64指令集对128位(16字节)数的操作提供有限的支持。图 4-2 描述的是支持产生两个64位数字的全128位乘积以及整数除法的指令。

图 4-2 特殊的算术操作

​ imulq指令有两种不同的形式,一种是“双操作数”乘法指令,它从两个64位操作数产生一个64位乘积。此外,x86-64指令集还提供了两条不同的“单操作数”乘法指令,以计算两个64位值的全128位乘积——一个是无符号数乘法(mulq),另一个是补码乘法(imulq)。这两条指令都要求一个参数必须在寄存器%rax中,而另一个作为指令的源操作数给出。然后乘积存放在寄存器%rdx(高64位)和%rax(低64位)中。汇编器能够通过计算操作数的数目,分辨出想用哪条指令。

  • 除法或取模操作:

    ​ 有符号除法指令 idivl 将寄存器%rdx1(高64位)和%rax(低64位)中的128位数作为被除数,而除数作为指令的操作数给出。指令将商储存在寄存器%rax中,将余数储存在%rdx中。

    ​ 对于大多数64位除法应用来说,除数也常常是一个64位的值。这个值应该放在%rax中,%rdx的位应该设置为全0(无符号运算)或者%rax的符号位(有符号算)。后面这个操作可以用指令cqto来完成。这条指令不需要操作数——它隐含读出%rax的符号位,并将它复制到rdx的所有位。 注: ATT格式中指令cqto,在Intel文档中叫cqo

3. 逻辑操作

1、控制

  • jump指令可以改变一组机器代码指令的执行顺序,jump指令指定控制应该被传递到程序的某个其他部分,可能是依赖于某个测试的结果。
  • 条件码

    ​ 除了整数寄存器,CPU还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:

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

    leaq指令不会改变任何条件码,因为它是用来进行地址计算的。除此之外 图4-1 中列出的所有指令都会设置条件码。

    除了 图4-1 中的指令会设置条件码,还有两类指令,它们只设置条件码,不更新目的寄存器,如图4-2:

    CMP指令与SUB指令的行为是一样的,如果两个操作数相等,这些指令会将零标志设置为1;

    TEST指令与AND指令的行为一样,除了只设置条件码而不改变寄存器的值。

    图4-3

  • 访问条件码

    ​ 条件码通常不会直接读取,常用的使用方法有三种:

    • 可以根据条件码的某种组合,将一个字节设置为0或者1,如图4-4

      图4-4

      一条SET指令的目的操作数是低位单字节寄存器元素之一,或是一个字节的内存地址,指令会将这个字节设置成0或者1。为了得到一个32或64位结果,我们必须对高位清零。例如:一个计算C语言表达式a < b的典型指令如下所示,这里a和b都是long类型:

      图4-5

      注意cmpq指令的比较顺序,比较的是a和b:a - b

      movzbl指令不仅会将%eax的高3个字节清零,还会将整个寄存器%rax的高4个字节都清零

      对于“同义名”,编译器和反汇编器会随意决定使用哪个名字

    • 可以跳转到程序的某个其他部分

    • 可以有条件地传送数据

  • 跳转指令

    ​ 正常情况下,指令按照顺序一条条执行,跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(lable)指明。

    ​ 在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码为跳转指令的一部分。

    • 图 4-6 列举了不同的跳转指令。jump指令是无条件跳转。有两种跳转方式:

      1. 直接跳转:如 jmp .L1,即跳转目标作为指令的一部分编码。
      2. 间接跳转:如jmp *%raxjmp *(%rax),即跳转目标是从寄存器或内存位置中读出的。

      图 4-6

      表中所示的其他跳转指令都是有条件的——它们根据条件码的某种组合,或者跳转,或者继续执行代码序列中下一条指令。条件跳转只能是直接跳转。

  • 跳转指令的编码

    • 在汇编代码中,跳转目标用符号标号书写。汇编器,以及后来的链接器,会产生跳转目标的适当编码。跳转指令有几种不同的编码,但最常用的都是PC相对的(PC-relative)。

      1. 将目标指令的地址与紧跟在跳转指令后面的那条指令的地址之间的差作为编码,这些地址偏移量可以编码为1、2、4字节。
      2. 给出“绝对”地址,用4字节直接指定目标。

      汇编器和链接器会选择适当的跳转目的编码。

      • 下面是一个PC相对寻址的例子:

      图 4-6

      反汇编器产生的注释中,第二行跳转指令的跳转目标指明为0x8,第五行为0x5(反汇编器以十六进制格式给出所有的数字)。

      不过,观察指令字节编码,会看到第一条跳转指令的目标编码(在第二个字节中)为0x03。把它加上0x5,也就是下一条指令的地址,就得到跳转目标地址0x8,也就是第4行指令的地址。

      类似的,第二个跳转指令的目标用单字节、补码表示为0xf8(十进制-8)。将这个数加上0xd(十进制13),即第6行指令的地址,我们得到0x5,即第3行指令的地址。

      这些例子说明,当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。

      • 下面是链接后的程序反汇编版本:

      图 4-7

      指令reprepz是同义名,它通常用来实现重复的字符串操作。在AMD给编译器编写者的指导意见书中,建议rep后面跟ret的组合来避免ret指令成为条件跳转指令的目标。

      ret指令通过跳转指令到达时,处理器不能正确预测ret指令的目的。

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

    • 将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。例如,图 4-8a 给出了一个计算两数之差绝对值的函数的C代码(实际上,如果一个减法溢出,这个函数就会返回一个负值。)这个函数有一个副作用,会增加两个计数器,编码为全局变量lt_cnt和ge_cnt之一。GCC产生的汇编代码如图 4-8c 所示。将机器代码再转换为C语言,如图 4-8b。

      图 4-8

      汇编代码首先比较了两个操作数,设置条件码,然后通过条件跳转指令,跳转到对应的代码行,同时增加了全局变量。

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

    • 实现条件操作的传统方法是通过使用控制的条件转移。当条件满足时,程序沿着一条执行路径执行,而当条件不满足时,就走另一条路径。这种机制简单而通用,但是在现代处理器上,它可能会非常低效。
    • 一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。只有在一些受限制的情况中,这种策略才可行,但是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性。
    • 图 4-9a 给出了一个可以用条件传送编译的实例代码:

      图 4-9

      我们可以发现,它既计算了y - x,也计算了x - y,然后再测试x是否大于等于y。

      处理器在使用流水线(pipeline)中,每个阶段执行每条指令所需操作的一小部分,通过重叠连续指令的步骤来获得高性能。因此能够事先确定要执行的指令序列,才能保持流水线中充满待执行的指令。

    • 图 4-10 列举了x86-64上一些可用的条件传送指令。每条指令都有两个操作数:源寄存器或者内存地址S,和目的寄存器R。

      图 4-10

      源和目的的值可以是16位、32位或64位长。不支持单字节的条件传送。无条件传送指令的操作数的长度显式地编码在指令名中(例如movwmovl),汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个指令的名字。

      当表达式可能产生错误条件或副作用时,就会导致非法行为,此时不能使用条件传送,只能使用条件转移。

      使用条件传送也不总是会提高代码效率,比如两个结果需要大量的计算。编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能。但编译器并不具备足够的信息来做出可靠的决定,只有两个表达式都很容易计算时,才会使用条件传送,大部分情况下都使用条件转移。

  • 循环

    • do-while循环

      看一个示例,图 4-11a给出了一个函数的实现,用do-while循环来计算函数参数的阶乘,并且只计算n > 0时的阶乘值。

      图 4-11

    • while 循环

      有很多种方法将whlie循环翻译成机器代码,GCC在代码中使用其中的两种方法。

      ​ 1、第一种翻译方法:可以称为跳转到中间(jump to middle),它先跳转到循环结束处的测试,然后进行跳转。

      goto test;
      loop:
          t = test-expr;
          if (t)
          goto loop;
      

      图 4-12a 给出了使用while循环的阶乘函数的实现,这个函数能够正确的计算0! = 1,它旁边的图b是GCC带优化命令-Og时产生的汇编代码的C语言翻译。

      图 4-12

      2、第二种翻译方法:首先使用条件分支,条件不成立就跳过循环,把代码变换成do-while循环。当使用较高优化等级编译时,如-o1,GCC会采用这种策略。

      t = test-expr;
      if (!t)
        goto done;
      loop:
        body-statement;
          t = test-expr;
          if (t)
              goto loop;
      done;
      

      利用这种策略,编译器常常可以优化初始的测试,例如认为测试条件总是满足。

      图 4-13 给出了-o1优化下的while阶乘代码:

      图 4-13

      一个有趣的特征是,循环测试从原始C代码中的n > 1变成了n != 1。二者是等价的。

  • for循环

    for循环的通用形式:

    for (init-expr; test-expr; update-expr)
        body-statement
    

for循环会变为while的形式,取决于优化程度的不同,会优化为:”jump to middle“形式、guarded-do形式。

:当for循环中使用的continue,改成while循环时需要将continue替换成goto的形式。

  • switch语句

    • switch语句可以根据一个整数索引值进行多重分支,当开关情况比较多,并且值的范围跨度比较小时,编译器会使用跳转表;其余情况还有比较跳转链、二分查找。

    • 下面几张图为跳转表的例子:

      图 4-14

      图 4-15

      图 4-16

      该图为跳转表的声明。

2、过程

​ 过程是一种很重要的抽象,在不同的编程语言中有不同的形式:函数、方法、子例程、处理函数等,但他们都有一些共有的特性,如传递控制、传递数据、内存管理。

  • 运行时栈

    图 4-17 给出了运行时栈的通用结构,包括把它划分为栈帧。

    图 4-17

    当过程P调用过程Q时,会把返回地址当作P的栈帧的一部分,为了提高空间和时间效率,对于x86-64而言,只要参数不大于6个,都可以通过寄存器进行传递,超过的参数,P在调用Q之前就存在自己的栈帧里。

  • 转移控制

    ​ 将控制函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码起始位置。不过当Q返回时,处理器必须记录好继续执行的代码位置。x86-64中,这个信息是用指令call Q调用来记录的。该指令会把地址A(返回地址)压入栈中,并将PC设置为Q的起始地址。对应的指令ret会从栈中弹出地址A,并将PC设置为A。

    ​ 下表给出call和ret指令的一般形式:

    表 4-1

    (这些指令在程序objdump产生的反汇编输出中被称为callq和retq。后缀是为了强调这些是x86-64版本的调用和返回,而不是IA32的。在x86-64汇编代码中,这两种版本可以互换。)

    call指令有一个目标,即指明被调用过程起始的指令地址,可以是直接的,也可以是间接的。

    ​ 下面是函数multstore和main的反汇编代码节选,以及call与ret函数说明:

    图 4-18

    注意:0x840 - 0x838 = 8

    ​ 再看一个更详细的例子:

    图 4-19

    main调用top再调用leaf,过程中数据使用%rax传递,下一条指令地址在过程调用时直接压栈。

  • 数据传送

    ​ 当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。x86-64中,大部分过程间的数据传送是通过寄存器实现的。

    ​ x86-64中,可以通过寄存器最多传递6个整形参数。寄存器使用的名字取决于要传递的数据类型大小,如图 4-20。

    图 4-20

    ​ 如果一个函数有大于6个整形参数,超出部分通过栈来传递。如图 4-21所展示的参数传递示例:

    图 4-21

    通过栈传递参数时,所有数据大小都向8的倍数对齐,参数到位后程序就可以执行call指令进行控制转移。

    该代码的栈帧结构如下图 4-22:

    图 4-22

  • 栈上的局部存储

    ​ 有些时候,局部数据必须存放在内存中,常见的情况包括:

    1. 寄存器不足够存放所有的本地数据。

    2. 对一个局部变量使用地址运算符&,因此必须能够为它产生一个地址。

    3. 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。

      下图 4-23是一个处理地址运算符的例子。

      图 4-23

      图中因为要传递局部变量的地址,因而将局部变量压入栈中,为局部变量分配了一个地址。

      再看一个复杂点的例子,如图 4-24:

      图 4-24

      图中可以看到,代码中一大部分是为了调用proc做准备,其中包括为局部变量和函数参数建立栈帧,将函数参数加载至寄存器。

      该函数的栈帧如图 4-25:

      图 4-25

      栈帧的内存申请的对齐方式根据不同平台有不同的要求,如arm架构为8字节对齐,Windows-x64为16字节。该栈帧为call调用前的栈帧,call调用后,还会插入返回地址。

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

    ​ 寄存器是唯一被所有过程共享的资源,必须确保当一个过程调用另一个过程时,被调用者不会覆盖调用者之后会使用的寄存器值。x86-64采用了一组统一的寄存器使用惯例:%rbx、%rbp、%r12 ~ %r15都被划分为被调用者保存寄存器。被调用者可以通过两种方式保存这些寄存器:

     1. 不使用这些寄存器。
     2. 将寄存器的值压入栈中,返回前弹出这些值。压入寄存器的值会在栈帧中创建标号为“保存的寄存器”的一部分。
    

    ​ 所有的其他寄存器(除了%rsp)都被分为调用者报错寄存器,如P调用Q时,P需要先保存好所需要的来自这类寄存器的数据。

    ​ 下图 4-26是一个过程调用,保存寄存器的例子:

    图 4-26

  • 递归过程

    ​ 每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会相互影响。图 4-27 给出了递归的阶乘函数的C代码和生成的汇编代码:

    图 4-27

    可以看到汇编代码使用寄存器%rbx来保存参数n,由于%rbx为被调用者保存寄存器,因此调用者储存在里面的值在过程调用后并不会改变,被调用者会先把已有的值保存在栈上,随后在返回前恢复该值。

3、数组分配和访问

​ C语言中的数组是一种将标量数据聚集成更大数据类型的方式。由于实现方式简单,因此很容易翻译成机器代码。C语言可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。

​ 优化编译器非常善于简化数组索引所使用的地址计算,不过会有些难以理解。

  • 基本原则

    ​ 对于数据类型T和整形常数N,有如下声明:

    T A[N];

    ​ 起始位置表示为$$x_A$$。它在内存中分配一个$$LN$$字节的连续区域,L是数据类型T的大小(单位为字节)。可以使用$$x_{A}+Li$$来访问数组元素i

    ​ x86-64的内存引用指令可以用来简化数组访问。假设E是一个int型的数组,而我们想计算E[i],E的地址存放在寄存器%rdx中,而i存放在寄存器%rcx中。然后执行:

    movl (%rdx, %rcs, 4), %rax

    允许的伸缩因子1、2、4、8覆盖了所有基本简单数据类型的大小。

  • 指针运算

    ​ C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型大小进行伸缩。也就是说,如果p是一个指向类型为T的数据的指针,p的值为$$x_p$$,那么表达式$$p+i$$的值为$$x_p + L * i$$,这里$$L$$是数据类型$$T$$的大小。

    ​ 单操作数操作符‘&’和‘*’可以产生指针和间接引用指针,假设整形数组E的起始地址和整数索引$$i$$分别存放在寄存器%rdx和%rcx中,下图 4-28展示了一些E有关的表达式:

    图 4-28

    图中可以看到返回数组值的操作类型为int,返回指针的操作数类型为int*,最后一个例子是同一数据结构的两个指针之差,结果的数据类型为long,结果为两元素之间的距离,等于两个地址之差除以该数据类型的大小。(指针减法规则)

  • 嵌套的数组

    ​ 当我们创造数组的数组时,数组分配和引用的一般原则也是成立的。如,声明 int A[5][3];,等价于声明:

    typedef int row3_t[3];
    row3_t A[5];
    

    ​ 数组元素在内存中按照“行优先”的顺序排列,使用A[i],可以访问第i行的所有元素。

    图 4-29

    ​ 要访问多维数组的元素,编译器会以数组起始为基地址,偏移量为索引,计算期望元素的偏移量,再使用某种MOV指令。通常来说,对于一个声明如下的数组:T D[R][C];它的数组元素D[i][j]的内存地址为:

    ​ &D[i][j] = $$x_D$$ + $$L(C*i+j)$$

    假设$$x_A$$、$$i$$、$$j$$分别在寄存器%rdi、%rsi、%rdx中。考虑读取A[i][j]:

    leaq (%rsi, %rsi, 2), %rax    Compute 3i
    leaq (%rdi, %rax, 4), %rax    Compute x_A + 12i
    movl (%rax, %rdx, 4), %rax    Read from M[x_A + 12i + 4j]
    
  • 定长数组

    ​ C语言编译器能够优化定长多维数组上的操作代码。下图 4-30 展示优化等级为-O1时GCC采用的一些优化。假设我们声明$$16 * 16$$的整形数组:

    #define N 16
    typedef int fix_matrix[N][N];
    

    图 4-30

    代码计算A的行i与B的列k的内积。这段代码有很多优化:它去掉了整数索引j,并把所有的数组引用都转换成了指针间接引用。

  • 变长数组

    ​ 历史上C语言只支持大小在编译时就能确定的多维数组,ISOC99开始允许数组的维度是表达式,在数组分配的时候才计算出来。在变长数组的C版本中,我们可以将一个数组声明如下:

    int A[expr1][expr2]

    ​ 它可以作为局部变量,也可以作为函数参数,在遇到该声明时,通过对表达式expr1expr2求值来确定数组的维度。例如要访问$n*n$数组的元素$i,j$,我们能写如下函数:

    int var_ele(long n, int A[n][n], long i, long j) {
        return A[i][j];
    }
    

    ​ 参数n必须在参数A[n][n]之前,GCC产生的汇编代码如下所示:

    图 4-31

    相对于定长版本,动态版本必须使用乘法指令对$i$伸缩$n$倍,而不能使用一系列的位移和加法(因为长度是运行时确定的)。

    ​ 下面是一个动态数组的例子,同样是A与B的某个乘积元素:

    图 4-32

    下面是循环部分的汇编代码:

    图 4-33

    相对于定长数组,动态数组在代码优化后,保留了循环变量j,汇编代码中也使用了n来检查循环边界。

4、异质的数据结构

​ C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:结构(struct)、联合(union)

  • 结构

    ​ 用struct声明创建一个结构体,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有部分组成部分都存放在内存中一段连续的区域内,而指向结构体的指针就是结构体第一字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。

    ​ 如下面的结构声明:

    struct rec {
        int i;
        int j;
        int a[2];
        int *p;
    };
    

    这个结构体包括4个字段:两个4字节int、一个8字节数组、一个8字节指针,总共24字节:

    图 4-34

    最后举一个例子,实现 r->p = &r->a[r->i + r->j];其中r为结构:

    图 4-35

    结构的各个字段的选取完全是在编译时处理的。机器代码不包含关于字段声明或字段名字的信息。

  • 联合

    ​ 联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象。如:

    union U3 {
      char c;
      int i[2];
      double v;
    };
    struct S3 {
        char c;
        int i[2];
        double v;
    };
    

    在一台x86-84 Linux系统上编译时,字段的偏移量如下:

    图 4-35

    对于union U3 *的指针pp->c、p->i[0]、p->v引用的都是数据结构的起始位置。一个联合的总大小等于它最大字段的大小。

  • 数据对齐

    ​ 许多计算机系统对基本数据类型的合法地址做了一些限制,要求某种类型对象的地址必须是某个值(通常是2,4,8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计,只用一次内存操作就能操作读或写值了。

    ​ 结构体按最大字段对齐。

    ​ 注:针对x86-64处理器的编译器和运行时系统都必须保证分配用来保存可能会被SSE寄存器读写的数据结构的内存,都必须满足16字节对齐。较近版本的x86-64处理器实现了AVX多媒体指令,是SSE指令的超集,并且没有强制性的对齐要求。

注: linux的x86环境下,使用g++ -Og -S test.cpp可以获得.s结尾的汇编代码,也可以使用objdump -d text.o来获取反汇编代码(反汇编代码的移动指令会省略后面的长度,如movq变为mov


文章作者: cfrost
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 cfrost !
  目录