【CSAPP笔记】第三章 程序的机器级表示(3)——数组、结构体、浮点表示

163 阅读9分钟

3.8 数组分配和访问

3.8.1 基本原则

  • C语言中,定义数组的方式:T A[N];T\ A[N];
    • TT表示数组的数据类型,NN表示数组的大小
    • L=sizeof(T)L = sizeof(T),则内存分配了LNL*N字节的连续区域
    • 令起始位置为xAx_A,则第ii个数组元素存放在xA+Lix_A+L*i

【例】64位操作系统中,定义了以下数组,则数组相关信息如下:

char    A[12];       //char占1字节
char   *B[8];        //指针占8字节
int    C[6];         //int占4字节
double *D[5];        //指针占8字节

image-20221204175427259.png

【例】数组的访问:将int数组C[i]C[i]的值赋值到%eax中。则C[i]=(C+i)=xC+4iC[i] = *(C + i) = x_C + 4i

//C in %rdx, i in rcx
movl (%rdx, %rcx, 4), %eax

3.8.2 指针运算

  • 指针的计算与数组数据类型大小相关。以3.8.1节数组的例子为例:
    • A+1=xA+1=&A[1]A[1]=(A+1)=(xA+1)A+1 = x_A+1 = \&A[1],A[1] = *(A+1) = *(x_A+1)
    • C+1=xC+4=&C[1]C[1]=(C+1)=(xC+4)C+1 = x_C+4 = \&C[1],C[1] = *(C+1) = *(x_C+4)

3.8.3 嵌套数组(多维数组)

  • 二维数组的定义:T D[R][C]T\ D[R][C]
    • 数组元素D[i][j]D[i][j]的内存地址为:&D[i][j]=xD+L(Ci+j)\&D[i][j] = x_D+L(C*i+j)
    • 在内存本质上都存储为一维数组

image-20221204183753344.png

【例】将5×35\times3的int数组A[i][j]A[i][j]的值赋值到%eax中。则A[i][j]=xA+4(3i+j)=xA+12i+4jA[i][j]=x_A+4*(3*i+j)=x_A+12i+4j

//A in %rdi, i in %rsi, j in %rdx
leaq (%rsi,%rsi,2), %rax        //Compute 3*i
leaq (%rdi,%rax,4), %rax        //Compute x_a+12i
movl (%rax,%rdx,4), %eax        //Compute x_a+12i+4j

3.8.4 定长数组 & 3.8.5 变长数组

书中这两节的意思,其实是想说编译器在解析多维数组时,会尽可能地优化它。比如:GCC可以直接识别出多维数组的元素步长,生成的汇编指令避免直接计算&D[i][j]=xD+L(Ci+j)\&D[i][j] = x_D+L(C*i+j),能显著提高程序性能。

【例】矩阵乘法例子,编译器有不少优化:

  • 在第2行:取左移计算*64;
  • 在第4行:使用leaq计算元素地址;
  • 在第11行:+4运算取A数组的下一个行元素;
  • 在第12行:+64运算取B数组的下一个列元素等等。

image-20221204184703063.png

3.9 结构体和联合

3.9.1 结构体struct

  • C语言中使用struct声明结构体:
struct rec {
    int i;        //4字节
    int j;        //4字节
    int a[2];     //共8字节
    int *p;       //8字节
};
struct rec* r;

image-20221204235612853.png

【例】结构体的访问,与数组访问类似,首地址加上对应偏移量r->j = r->i;

//r in %rdi
movl (%rdi), %eax        //eax = r->i
movl %eax, 4(%rdi)       //r->j = eax

【例】取&(r->a[i])的值

//r in %rdi, i in %rsi
leaq 8(%rdi, %rsi, 4), %rax    //rax = r->a + 4i = rdi + 8 + rsi*4

【例】r->p = &r->a[r->i + r->j];

//r in %rdi
movl 4(%rdi), %eax          //eax = r->j
addl (%rdi), %eax           //eax = r->i + r->j
cltq                        //eax extend to rax
leaq 8(%rdi,%rax,4), %rax   //rax = &r->a[r->i + r->j]
movq %rax, 16(%rdi)         //r->p = rax

3.9.3 结构体对齐

  • 结构体的数据有对齐限制,原则:任何K字节的基本对象的地址必须是K的倍数

image-20221205001432668.png

【例】结构体S1,对齐后char会插入一个3字节的空隙

struct S1 {
    int i;
    char c;
    int j;
};

image-20221205001711268.png

【例】结构体S2,对齐后char会插入一个3字节的空隙

struct S2 {
    int i;
    int j;
    char c;
};

image-20221205001823852.png

【例】结构体,计算总结构体一共有56字节。这里如果按大小顺序降序排列结构元素,可以节省结构体大小

struct {
    char *a;
    short b;
    double c;
    char d;
    float e;
    char f;
    long g;
    int h;
};

image-20221205002307251.png

【注】更多数据对齐的情况,还是要根据不同型号处理器和编译系统进行具体分析。

3.9.2 联合

  • 联合union:所有元素共享同一存储区域。也就是说,联合体的大小取决于最大元素的大小
  • 使用场景:已知两个不同字段是互斥的,可以声明为联合,减小分配空间的总量 (内核代码常用)

【例】联合体U3,大小是8字节。如果是结构体则占24字节。

union U3 {
    char c;        //1字节
    int i[2];      //共8字节
    double v;      //8字节
};

【例】书中例子:二叉树数据结构

//使用结构体,每个节点需要32字节
struct node_s {
    struct node_s *left;    //左节点,内部节点
    struct node_s *right;   //右节点,内部节点
    double data[2];         //叶子节点
};

//使用联合体,总计24字节(4字节type + 4字节padding + 16字节struct)
type enum { N_LEAF, N_INTERNAL } nodetype_t;
struct node_u {
    nodetype_t type;    //由于引入联合后无法区分是内部节点还是叶子节点,增加type类型,占4字节
    union {
        struct {        //内部节点
            struct node_u *left;
            struct node_u *right;
        } internal;
        double data[2]; //叶子节点
    } info;
};

【注意】联合体将不同数据类型结合在一起,有字节序问题(大端机器和小端机器表现不同)

include <stdio.h>

int main() {
    union {
        long l;
        int i[2];
    } temp;
    temp.i[0] = 0x01234567;
    temp.i[1] = 0x89abcdef;
    printf("0x%lx\n", temp.l);
    return 0;
}

【输出】在小端机器上输出:0x89abcdef01234567

3.10 数据和控制结合

3.10.3 内存越界引用和缓存区溢出

缓冲区溢出(buffer overflow):C对于数组引用不进行任何边界检查,所以局部的数组如果溢出会破坏栈,造成严重错误

#include <stdio.h>

int main() {
    char buf[8];
    gets(buf);
    puts(buf);
    return 0;
}

【测试】输入abcdefghijklmn,出现栈溢出

abcdefghijklmn
abcdefghijklmn
*** stack smashing detected ***: ./main terminated
Aborted (core dumped)

3.10.4 对抗缓冲区溢出方法

病毒可能利用缓冲区溢出的特性破坏计算机系统。有几种对抗方法:

  • 栈随机化:每次执行程序,栈的位置都不固定
#include <stdio.h>

int main()
{
    long local;
    printf("local at %p\n", &local);
    return 0;
}

【测试】

root@ubuntu:~/test# ./main
local at 0x7ffd5d767218
root@ubuntu:~/test# ./main
local at 0x7ffea89123a8
  • 栈破坏检测:在栈帧中任何局部缓冲区与栈状态之间,存储一个特殊的金丝雀值(canary)。当检测到值被改变,表示越界,程序异常终止(可以使用-fno-stack-protector阻止GCC产生这种代码)

image-20221205215038012.png

  • 限制可执行代码区域:栈标记为是否可读写、可执行,消除攻击者向系统中插入可执行代码的能力。

3.10.5 变长栈帧

  • 编译器通常能够提前确定一个函数要提前为栈帧分配多少空间。但有些函数的局部存储是变长的

【例】栈帧申请8n字节的变长数组,编译器无法确认n的值。返回时要释放栈帧

long vframe(long n, long idx, long *q) {
    long i;
    long *p[n];
    p[0] = &i;
    for (i = 1; i < n; i++) {
        p[i] = q;
    }
    return *p[idx];
}

【编译】

//long vframe(long n, long idx, long *q)
//n in %rdi, idx in %rsi, q in %rdx
vframe:
    pushq   %rbp
    movq    %rsp, %rbp            //栈指针记录在%rbp处
    subq    $16, %rsp             //分配16字节空间存放long i; 需要16字节对齐
    leaq    22(,%rdi,8), %rax     //rax = 8n+22
    andq    $-16, %rax            //rax = (8n+22) & 1111 0000,需要16字节对齐
    subq    %rax, %rsp            //分配long *p[n],总共分配了rax字节空间
......
    leave                         //恢复rsp和rbp, 等价于movq %rbp, %rsp; popq %rbp
    ret

【说明】栈帧情况:使用subq %rax, %rsp的方式申请8n的内存大小,使用leave恢复初始的%rsp

image-20221206002542637.png

3.11 浮点代码

3.11.1 浮点传送和转换操作

  • 为支持图形和图像处理,引入media寄存器集(MMX、SSE、AVX等),支持浮点的数学运算。这些指令允许多个操作以并行模式运行。本书基于AVX2版本
  • GCC编译浮点程序,给定命令行参数-mavx2时,会生成AVX2代码
  • 浮点处理时,允许数据存放在16个YMM寄存器中,名字是%ymm0~%ymm15。每个YMM寄存器是256位(32字节),低128位(16字节)对应XMM寄存器 %xmm0~%xmm15

image-20221206072654598.png

3.11.1 浮点传送和转换操作

  • VMOV:把数据从一个XMM寄存器复制到另一个XMM寄存器,或者从内存复制。类似于整数的MOV

image-20221206073718618.png

【例】

float float_mov(float v1, float *src, float *dst) {
    float v2 = *src;
    *dst = v1;
    return v2;
}

【编译】

//float float_mov(float v1, float *src, float *dst)
//v1 in %xmm0, src in %rdi, dst in %rsi
float_mov:
        vmovaps %xmm0, %xmm1     //%xmm1 = v1
        vmovss  (%rdi), %xmm0    //%xmm0 = *src
        vmovss  %xmm1, (%rsi)    //*dst = v1
        ret                      //return %xmm0
  • 浮点数和整数之间转换——VCVT
    • 浮点数\to整数:指令会执行截断,向零舍入
      • vcvttsd2siq (%rdx), %r8:从内存读取浮点数,转为长整型
    • 整数\to浮点数:三元操作,第二个源通常可以忽略
      • vcvtsi2sdq %rax, %xmm1, %xmm1:从%rax读取一个长整数,转为double,存进%xmm1的低字节中
    • 单精度浮点\to双精度浮点:把XMM寄存器的两个低位单精度值扩展为两个双精度值 (不必细扣)
      • vunpcklps %xmm0, %xmm0, %xmm0
      • vcvtps2pd %xmm0, %xmm0
    • 双精度浮点\to单精度浮点:把XMM寄存器的两个双精度值扩展为两个低位单精度值 (不必细扣)
      • vmovddup %xmm0, %xmm0
      • vcvtpd2psx %xmm0, %xmm0

image-20221206074757302.png

【例】书中例子

image-20221206081438709.png

【编译】

image-20221206081506962.png

3.11.2 函数中的浮点代码

  • 函数参数
    • 整数和指针使用通用寄存器传递
    • 浮点数使用XMM寄存器传递:%xmm0~%xmm7传递最多8个浮点参数
  • 函数返回:使用%xmm0返回浮点值

【注】所有的XMM寄存器都是调用者保存的,被调用者可以随意覆盖

3.11.3 浮点运算

  • AVX2浮点指令:区分单精度和双精度。
    • 指令含1个或2个源操作数,一个目的操作数。
    • 第一个源操作数可以是XMM寄存器或内存位置
    • 第二个源操作数和目的操作数都必须是XMM寄存器

image-20221206233301192.png

【例】

double funct(double a, float x, double b, int i) {
    return a*x - b/i;
}

【编译】

//double funct(double a, float x, double b, int i)
//a in %xmm0, x in %xmm1, b in %xmm2, i in %edi, return %xmm0
funct:
        vunpcklps       %xmm1, %xmm1, %xmm1    //The following tow instructions convert x to double
        vcvtps2pd       %xmm1, %xmm1
        vmulsd  %xmm0, %xmm1, %xmm0            //Multiply a by x
        vcvtsi2sd       %edi, %xmm1, %xmm1     //Convert i to double
        vdivsd  %xmm1, %xmm2, %xmm2            //Compute b/i
        vsubsd  %xmm2, %xmm0, %xmm0            //Substract from a*x
        ret

3.11.4 浮点常量定义

【例】书中例子

double cel2fahr(double temp) {
    return 1.8 * temp + 32.0;
}

【编译】

cel2fahr:
        vmulsd  .LC0(%rip), %xmm0, %xmm0
        vaddsd  .LC1(%rip), %xmm0, %xmm0
        ret
.LC0:
        .long   3435973837        //数字1.8的低4字节
        .long   1073532108        //数字1.8的高4字节
.LC1:
        .long   0                 //数字32.0的低4字节
        .long   1077936128        //数字32.0的高4字节

【说明】.LC0表示1.8的常量,含两个值,3435973837(0xcccccccd)和1073532108(0x3ffccccc)

  • 按小端法字节排序,常数编码为:0x3ffccccc cccccccd
  • 根据第2章分析,V=(1)s×M×2EV=(-1)^s \times M \times 2^E,阶码位k=10,偏移Bias=2^k-1=1023,小数位n=52
    • 符号s=0s=0,表示正数
    • 指数字段exp=0x3ff=1023exp=0x3ff=1023,减去偏移E=10231023=0E=1023-1023=0
    • 小数字段M=0xccccc cccccccdM=0xccccc\ cccccccd,二进制是0.8,规格化后值为1.8
0xccccc cccccccd252=0.80000000000000004440...\frac{0xccccc\ cccccccd}{2^{52} }=0.80000000000000004440...
  • 所以小数V=(1)01.820=1.8V=(-1)^0*1.8*2^0=1.8

3.11.5 浮点代码的位级操作

  • 位级异或(XOR)、位级与(AND)

image-20221206234539858.png

3.11.6 浮点比较

  • 比较单精度(ucomiss)、比较双精度(ucomisd),与正数的cmpq效果一样

image-20221206235020091.png

  • 浮点数比较后,会设置三个条件码:ZF(零标志位)、CF(进位标志位)、PF(奇偶标志位)
    • 注:这里比整数运算多一个PF位,目的是区分任一操作数为NaNNaN的情况

image-20221206235355028.png

【例】

image-20221206235929893.png

3.11.7 总结

  • 相似:AVX2指令的浮点数操作 类似于 整数的操作
  • 区别:AVX2指令的规则会更复杂,且支持并行操作,使计算执行得更快