【第 4 周】深入理解计算机系统共读心得体会

所在小组

第2组

组内昵称

叶王

你的心得体会

  • 过程指的是将数据和控制从代码的一部分传递到另一部分。为单个过程分配的栈叫栈帧(stack frame)。寄存器是唯一被所有过程共享的资源。
  • 数组的分配和访问,本质上是通过指针,也就是地址计算。
  • 数据对齐与否不影响处理器的工作,但是对齐后可以提高数据存储器系统的性能。Linux对齐策略是2字节数据类型的地址必须是2的倍数(地址最低位是0),其他更大数据类型的地址是4的倍数(地址最低两位都是0)。
  • x86 是32位处理器(及其之前的版本),64位处理器最先是AMD实现(叫AMD64),后来Intel跟进,叫x86_64,或 x64, Intel64,本质上都是一样的指令集。
  • 浮点数的表示有很多种体系结构,目前在用的主要是两种,x87(标准实现)和SSE(较新,增加了多媒体支持等)。

所在小组
静默组

组内昵称
黄小黄

心得体会
对于数据类型T和整数常数N,声明如下:

T A[N];

起始位置表示为xA,其产生了2个效果:

  • 它在内存中分配了一个L * N字节的连续区域,L表示数据类型T的字节大小
  • 引入了标识符A,可以用A来作为指向数组开头的指针

当我们创建嵌套数组时,如

int A[5][3];

首先创建了3个数组,每个数组容纳3个整数,假定这个数据类型称为a,然后再创建了一个数组,这个数组能够容纳5个a这样的元素,每个a元素需要12个字节来存储3个整数,整个数组的大小就是4 * 5 * 3 = 60字节

异质的数据结构

C语言提供了两种将不同类型的对象组合到一起创建数据类型的方式:

  • 结构:类似数组的实现,结构的所有组成部分都存放在内存中一段连续的区域中,指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段的字节偏移,它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用
  • 联合:提供了一种方式能规避C语言的类型系统

所在小组

静默组

组内昵称

清风环佩

心得体会

  1. 现代处理器是基于流水线来进行指令的处理,当执行当前指令时,接下来要执行的几条指令已经进入流水线的处理流程了。但是对于条件分支来说,在跳转指令时可能会改变程序的走向,这个时候就只能清空流水线,然后重新进行载入。为了减少清空流水线所带来的性能损失,处理器内部会采用称为“分支预测”的技术。
  2. 过程调用包括:传递控制,包括如何开始执行过程代码,以及如何返回到开始的地方;传递数据,包括过程需要的参数以及过程的返回值;内存管理,如何在过程执行的时候分配内存,以及在返回之后释放内存。
  3. 内存对齐是c语言课中的基础,查资料显示,Windows对齐规则和Linux不同,Linux中的对齐策略是“2字节数据类型的地址必须为2的倍数,较大的数据类型(int,double,float)的地址必须是4的倍数”。
  4. 第四章出奇的难,囫囵吞枣,难以消化,需要过后再细看几遍。

所在小组
第一组

组内昵称
盆栽Charming

心得体会
这里

所在小组

第一组

组内昵称

SADAME

心得体会

虽然64位系统的虚拟地址是64位来表示,但实际实现中前16位为0,所以虚拟地址最大空间为256TB
CPU有16个64位的通用寄存器,一个返回值,一个栈指针,两个调用者保存,六个参数寄存器,六个被调用者保存寄存器
过程调用机制:运行时栈从高地址到低地址增长,先进后出
16个寄存器之一的栈指针%rsp指向运行时栈的栈顶元素,通过在%rsp上加减来实现栈帧的分配和释放

P调用Q ,首先是P的返回地址,然后是被调用保存寄存器中的值(如果要用到这种),再是P的局部变量,然后是P参数构造区(如果Q需要超过6个参数),然后是Q的返回地址,然后是Q把被保存的寄存器的值压到栈中(如果要用到),然后是Q的局部变量,再是参数构造区(递归下去。。)
所以如果局部变量都可以存在寄存器中,也不要用到被调用保存寄存器,并且不需要调用子过程,则该函数不需要栈帧。

理解过程中的递归运行十分关键

所在小组

第三组

组内昵称

kippa

心得体会

栈帧结构

  • 一个过程的调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。此外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放空间。大多数机器,包括 IA32,只提供转移控制到过程和从过程中转移出控制这种简单指令。数据传递和局部变量的分配释放都是通过操纵程序栈来实现。
  • IA32 程序用程序栈来支持过程调用。机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。而为单个过程分配的那部分栈称为帧栈(stack frame)。
  • 帧栈可以认为是程序栈的一段,它有两个端点,一个标识着起始地址,一个标识着结束地址,而这两个地址,则分别存储在固定的寄存器当中,即起始地址存在%ebp寄存器当中,结束地址存在%esp寄存器当中。也就是说寄存器 %ebp 为帧指针,寄存器 %esp 为栈指针。

  • 程序栈的构成,它由一系列栈帧构成,这些栈帧每一个都对应一个过程,而且每一个帧指针+4的位置都存储着函数的返回地址,每一个帧指针指向的存储器位置当中都备份着调用者的帧指针。各位需要知道的是,每一个栈帧都建立在调用者的下方(也就是地址递减的方向),当被调用者执行完毕时,这一段栈帧会被释放。还有一点很重要的是,%ebp和%esp的值指示着栈帧的两端,而栈指针会在运行时移动,所以大部分时候,在访问存储器的时候会基于帧指针访问,因为在一直移动的栈指针无法根据偏移量准确的定位一个存储器位置。

过程的实现

  • 过程的实现主要就是在于数据如何在调用者和被调用者之间传递,以及在被调用者当中局部变量内存的分配以及释放。而过程实现当中,参数传递以及局部变量内存的分配和释放都是通过以上介绍的栈帧来实现的。
  1. 备份原来的帧指针,调整当前的帧指针到栈指针的位置
  2. 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存
  3. 备份被调用者保存的寄存器当中的值,如果有值的话,备份的方式就是压入栈顶
  4. 使用建立好的栈帧,比如读取和写入,一般使用mov,push以及pop指令等等
  5. 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。因此在恢复时,大多数会使用pop指令
  6. 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针
  7. 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置
  8. 弹出返回地址,跳出当前过程,继续执行调用者的代码

理解指针

  • 指针的值表示地址,0表示空。

  • 对指针做强制类型转换,指针的值不变。

  • 函数指针的值是函数第一条指令的地址。

  • c语言没有防止溢出的保障,栈结构容易遭到破坏。针对这一点,有栈随机化(为栈帧添加随机长度的空白区域),金丝雀值(随机生成一些值用来检测栈是否被破坏)等方法。

所在小组

第六组

组内昵称

利健锋

你的心得体会

指针

  • 指针是有类型的
  • 指针值是一个内存地址,0 表示 NULL
  • & 创建指针
    • 可以取出内存地址对应的内存中的值
  • 可以运算指针
  • 可以强制转换指针类型
  • 指针允许指向函数

缓冲区

栈上的缓冲区

  • 溢出
    • 程序读写分配区域
  • 保护
    • 栈地址随机化
    • 栈破坏检测
    • 限制可执行代码区域

浮点数

  • 传送
  • 转换
  • 寄存器分配
  • 运算
  • 常数
  • 位级操作
  • 比较

所在小组

第四组

组内昵称
志林

心得体会

  • 条件码用于控制。最常用的四个是,CF: 进位标志,ZF:零标志,SF:符号标志,OF:溢出标志

  • 通过SET指令访问条件码,入sete/setz %al就是将ZF设置到%eax的最低byte上(0或1)

  • jmp表示跳转指令,一般汇编用一个标号来指定,如jmp .L1。也可以利用寄存器,如jmp *%rax表示跳转到%rax的值的地址,jmp *(%rax)表示跳转到%rax值对应的内存值的内存地址去。和SET一样,也有je,js,jle等指令

  • GCC当switch的情况较多,且跨度较小的时候,会使用地址跳转表来翻译switch

  • 当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就回在栈上分配空间,这个部分称为过程的栈帧

  • call指令用于函数调用,此时将会把将紧跟着当前地址后面的那条指令的地址压入栈,并吧要跳转的函数地址设置到PC。退出时用ret。

  • 缓冲区溢出

    通常,在栈中分配某个字节数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。C对于数组引用不进行任何边界检查,而且局部变量和状态信息,都存在栈中。这样,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令时,就会出现很严重的错误。

    缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码,另外还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret指令的效果就是跳转到攻击代码。

    通常,使用gets或其他任何能导致存储溢出的函数,都不是好的编程习惯。不幸的是,很多常用库函数,包括strcpy、strcat、sprintf,都有一个属性——不需要告诉它们目标缓冲区的大小,就产生一个字节序列。

  • 局部数据存到需要内存中的几种情况:

    1. 寄存器不够用
    2. 对局部变量使用了地址运算符&
    3. 某些局部变量是数组或结构体(如果优化了则不一定)
  • 数据对齐可提高软件性能,减少读的次数和额外的处理。无论数据是否对齐,x86-64都可以正确工作。汇编中在文件头部声明.align 8来保证后面的数据的起始地址都是8的倍数。

  • 对于结构体,字段之间也可能会存在间隙,以保证每个结构元素都满足它的对齐需求。

  • 对齐原则是任何K字节的基本对象的地址必须是K的倍数。

  • Linux上最新版的GCC已经会讲栈地址随机化,使得栈溢出攻击变得更加困难

所在小组

第四组

组内昵称

张旭辉

数组的访问

一维数组的访问第i个元素 Xa+L* i 其中 Xa 是数组开头的指针,L为数组类型的大小,

嵌套数组(二维)如 D[R][C] 访问某个地址d[i][j]的方式为Xd + L(c · i +j),

用内存引用指令可以用来简化数组访问。

例如上面的例子,访问数组的第i个元素、将数组的开头指针放在rdx中即Xa, 将i放进rcx中,通知下面这个指令将i个元素的内存地址放进eax中。

movl (%rdx, %rcx, 4),%eax, 4为伸缩因子,可选的有(1,2,4,8),同理可以运用到二维数组上

数据对齐

对齐数据可以通过减少对内存的操作来显著提高内存系统的性能。对齐原则是任何K字节的基本对象的地址必须是K的倍数。

越界引用 / 缓冲区溢出

对于C语言来说、对数组的边界不进行任何检查,局部变量和状态信息都保存在栈中,会导致严重的程序错误

通过 栈随机化、栈破坏检测、限制可执行代码区域 来对抗缓冲区溢出攻击

所在小组
第六组
组内昵称
钟荣荣
心得体会

数组:了解了C语言的数组实现方式,还可以去了解一下go的数组实现方式,研究两种实现方式生成的机器代码的差别;
结构:所有组成部分都放在连续区域内,指针指向第一个字节的地址,编译器维护指示每个字段的字节偏移;机器代码不包含字段声明和字段名字的信息;
指针:很多语言都有指针这个概念,关于指针的原理也很容易理解,但是正如书上说的,我们对指针的理解都非常浅显,但是看完书上关于理解指针的内容好像也没多深入。

第三章看起来真的挺吃力的,一遍真的不足以读懂。。

所在小组:第一组
组内昵称:Gerald
心得体会:
栈顶超界问题

  • 当栈空时,再使用pop出栈
  • 当栈满时,再使用push入栈
  • 有可能取到栈以外未知的值,若栈段的大小为64KB,会使栈指针重新指向最后进栈的元素的地址,重新又执行指令
  • 栈空间的大小我们要自己管理

我们要先了解运行时栈等相关的基础知识后,再去理解一个函数调用另一个函数的过程是怎么实现的。

例如函数P调用函数Q,函数Q执行完之后返回到函数P的过程:

  1. 传递控制 。简单的来说就是让CPU去不去执行P函数了,跳转的Q函数进行执行。其实汇编的处理就很简单,就是讲程序计数器设置成Q代码的起始位置就行了,接下来就会直接执行Q代码,这里要注意的是,离开P函数的时候,要记录之后P代码继续执行的位置,以便后续Q函数执行完,返回P函数的时候进行一些必要的恢复。
  2. 数据传递 。在函数调用的时候,我们还要进行必要的数据传递,包括P函数传递给Q函数的数据和Q函数执行完返回给P函数的数据。
  3. 栈上的局部存储 。我们看到的大多数过程都不需要超出寄存器大小的本地存储区域。不过有些时候需要将局部数据放在内存中。如寄存器大小不足、局部变量使用&取地址、局部变量是数组或结构的时候。除了寄存器大小不足,其他都是需要用地址的时候,会放在内存中。

递归:递归就是自己调用自己,每一个函数调用 在栈中都有它自己的私有空间,因此同一个函数的多个未完成的调用的局部变量不会相互影响,此外栈的原则很自然的提供了适当的策略:当过程调用时,分配局部存储,当返回时,释放存储。

第三章主要讲的就是C语言一些基本功能的实现在汇编层次上是怎么做的,这一章能够让我们对机器的运作有一个相对深度的认识。

所在小组: 第五组
组内昵称:孙恒
心得体会:

第四周

对抗缓冲区溢出攻击

  1. 栈随机化
  2. 栈破坏检测
  3. 限制可执行代码区域

tools

gcc

gcc
-Og 告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化级别
-O1
-O2 公司常用
-S 生成 汇编代码
-c 生成 目标代码  是二进制格式的

objdump

反汇编objdump -d 可执行文件

gdb

gdb 可执行文件

(gdb) disassemble sumstore // disassemble procedure

(gdb) x/14xb sumstore // Examine the 14 bytes starting at sumstore

(gdb) bt // print stack
(gdb) run // run
(gdb) break XX // XX可以是printf main等
(gdb) n // next 下一步
(gdb) stepi 
(gdb) p $rcs // 以整数值查看寄存器里面的值 或 i registers rcx
(gdb) tui enable / tui diable  // 开/关 windows
(gdb) layout regs //显示通用寄存器窗口
(gdb) layout split // Display the source, assembly, and command windows.
(gdb) tui reg float // 浮点寄存器
(gdb) b // 断点
(gdb) clear number // 清楚断点 number是文件中的行号 info b 可以获取断点信息 clear 删除断点是基于行的,不是把所有的断点都删除。
(gdb) delete [breakpoints num] [range] // delete可删除单个断点,也可删除一个断点的集合,这个集合用连续的断点号来描述。

 

https://github.com/hellogcc/100-gdb-tips/blob/master/src/index.md

总结

结合第三章的内容和读书会第一期所思: 与二进制直接打交道的情况比较少,与16进制打交道比较多,如:java的进程号、进程地址都是16进制,socket的source IP port和 dest IP port也是16进制,还有一种更加常见strace或pref所显示的调用栈更加是16进制+机器码。还有一个很典型的core文件

所以以我看来 汇编带给我的更多是 运维排查方面的提高,毕竟直接使用汇编语言的场景还是很少的

$ cat /proc/net/tcp # 用处: 监控进程级别的网络流量,出自nethogs
  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode                                                     
   0: 0100007F:177C 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 3679598230 1 ffff8b3602d27440 100 0 0 10 0                
   1: 0100007F:177E 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 3774949553 1 ffff8afec15787c0 100 0 0 10 0                
   2: 0100007F:AB05 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 124722 1 ffff8b7df5ab1f00 100 0 0 10 0                    
   3: 0A19FEA9:2426 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 487633556 1 ffff8b391aa67440 100 0 0 10 0 

所在小组
第一组

组内昵称
成都-郝立鹏

心得体会
这里

所在小组

第三组

组内昵称

uucloud

你的心得体会

  • 栈向低地址增长(x86-64),所以压栈相当于移动栈指针减小,出栈相当于增大栈指针释放空间。
  • 被调用方法需要的参数过多,可以存在调用方的栈帧中。
  • 转移控制:P→Q,call将地址A(返回地址)压入栈中,然后PC设置为Q的起始地址,ret会弹出地址A,设置为PC值。
  • 参数传递:寄存器最多传递6个整型,超出以后需要复制到栈,最后一个参数位于栈顶,栈传递的时候数据必须对齐8的倍数。
  • 嵌套的数组是将地址做一个偏移转换的计算得到的,相当于二维转一维。
  • 异质的结构,是由编译器做一个结构大小的计算,然后以这个作为偏移量实现的。

所在小组

静默组

组内昵称

李冲

心得体会

数组:数组的存储方式是通过分配连续内存实现的,数据内部的数据类型相同,存储的字节长度相同,访问数组的方式是通过数组首地址和偏移量及对应数据类型所占字节长度决定的。

嵌套数组:是将整个多维数组进行连续存储的,访问方式同一维数组访问方式,使用偏移量进行访问。

变长数组:C99引入功能,允许数组的维度是表达式,在数组被分配的时候才计算出来。

结构体:在内存中也是通过类似数组方式来完成的,分配一块连续的内存空间,通过首地址+偏移量来进行访问,在编译阶段完成。

数据对齐:数据对齐是为了方便系统更快地读取到对应地址的数据,提高程序读写效率,但并不会影响程序读取的行为。

指针:指针具有指针类型,并且通过&运算符创建和*运算符进行指针的引用。

所在小组

第四组

组内昵称

LuGH

体会

参见

所在小组

第六组

组内昵称

吴彬

你的心得体会

代码的局部性问题

int sum(int a[M][N]) {

    int i, j, sum = 0;

    for (i = 0;i < M;i++) {

        for (j = 0;j < N; j++) {

            sum += a[i][j];

        }

    }

    return sum;

}

局部性良好

int sum(int a[M][N]) {

    int i, j, sum = 0;

    for (j = 0;j < N; j++) {

        for (i = 0;i < M; i++) {

            sum += a[i][j];

        }

    }

    return sum;

}

局部性不好

高速缓存映射公式

在阅读第三版第12章12.5.4节的生产者-消费者问题时,图12-25所示的一部分代码如下所示
int sbuf_remove(sbuf_t *sp)
{
int item;
P(&sp->items); (1)
P(&sp->mutex); (2)
item = sp->buf[(++sp->front)%(sp->n)];
V(&sp->mutex);
V(&sp->items);
return item;
}
(1)与(2)的顺序不能颠倒,原因在于,当缓冲区为空时,cpu调度到消费者时,如果首先执行(2)会获取mutex,而进一步执行(1)时会被阻塞,我想问的是,线程被挂起之后,会释放mutex资源吗?