笔记 January 01, 2020

CASPP 深入理解计算机系统

Words count 27k Reading time 24 mins. Read count 0

1 引言

  • 编辑 (输出hello.c)
  • 预处理(输出hello.i)
  • 编译(输出hello.s)
  • 链接(链接可以完成源程序里面的库函数解析,目标文件里面包括机器代码和链接信息。)

SLP课程的目的

  • 从程序员的角度看待计算机系统
  • 写出高水平的计算机代码

2 数据的存储与表示

6种位运算操作符号

  1. 取反 ~
  2. 与 &
  3. 或 |
  4. 异或 ^
  5. 左移右移 << and >>

位运算用法

  1. 位与
    • 用来从字或字节中选择bits: 对应位为1
    • 用来清除bit:对应位为0
  2. 位或
    • 用来设置bit:对应位为1
  3. 异或
    • 与0异或,保持不变
    • 与自身异或,所有位清零
  4. 左移和右移
    • 逻辑移动:全用0填补
    • 算术移动:右移时用左边第一位补齐(在C语言中,对于移位操作执行的是逻辑左移和算术右移,不过对于无符号类型,所有的移位操作都是逻辑的。所以要相对一个有符号数执行逻辑右移,那么可以先将它强制类型转换为无符号类型。)
    • C语言中左移一位相当于数值*2, 右移一位相当于数值/2
    • 注意:<< 高于 &

示例

  1. 取字节(字节编号从LSB(0) 到 MSB(3), 例如getByte(0x12345678, 1) == 0x56)
    (x >> n*8) & 255
  2. 取相反数
    ~x + 1
  3. 返回有符号整数中最大的数(注意是补码形式)
    ~(1 << 31)
  4. 用与和非表示异或:
    ~(~(x & ~y) & ~(~x & y))

整数

  • 在计算机中,正数的真实表示形式为原码本身,而负数为原码取反+1(即补码)

  • 补码:原码取反再加1(符号位始终不变)

  • 溢出:当整数达到无法fit into一个字的时候,发生溢出,而且这种错误不会再C中报告,程序员应当注意

    可以采取的措施:

    unsigned long x, y, sum; 
    sum = x + y; 
    if (sum < x) 
        handle_overflow();
    

定点数和浮点数

  1. 定点数:由程序设计者约定,该程序中所有数的小数点固定在同一位置不变。eg. 10.5 == 1010.1
  2. BCD: 十进制编码的二进制:127.5 == 0001 0010 0111 . 0101
  3. 浮点数(IEEE754):

    • ​$(-1)^sM2^E$
    • S为符号位,M为系数(通常为$[1.0, 2.0)$范围内的一个分数),E为指数
    • 编码格式:

      • 单精度浮点数:1 + 8(exp)+ 23(frac)
      • 双精度浮点数:1 + 11(exp)+ 52(frac)
  • 176.0625的浮点数[https://blog.csdn.net/crjmail/article/details/79723051]:
    1. 整数部分转化为二进制:10110000, 小数部分转化为(小数点后:小数部分乘以2,取整数部分,直至乘积小数部分为0 ):0001
    2. 10110000.0001=1.01100000001 * 2^7(小数点向左偏移7位)
    3. 指数偏移量为127 + 7 = 134(10) = 10000110(2)
    4. 尾数长度为23, 所以表示为01100000001000000000000
    5. 正数,符号位为0
    6. 176.0625使用IEEE754规格化后的表示为:0 10000110 01100000001000000000000

3

汇编

寄存器

  • RSP:栈指针,存运行时栈结束的位置,即调用一个函数时,会记录本函数内下一条指令的位置
  • RIP:程序计数器指针,下一条指令的地址
  • 32位寄存器:
    • EAX:用于加法

操作数寻址模式

  • 立即数 mov ax,10H

  • 寄存器 mov ax, bx

  • 内存 mov ax, [10H] mov ax, [bx]

汇编语句1

  • 可执行指令
  • 宏指令
  • 伪指令

汇编语句2

  1. PUSH & POP

    SP:指向当前栈顶最后压入的一个字节

    PUSH EAX (32bits)

    • 假设压栈前SP = 0x0012ff40,则PUSH执行后:
      • 0x0012ff3c~3f将用于存放EAX的值,
      • SP=SP-4,即SP修改为 0x0012ff3c

    POP AX (16bits)

    • 假设压栈前SP = 0x0012ff40,则POP执行后:

      • 0x0012ff40~41的值拷贝入AX

      • SP=SP+2,即SP修改为 0x0012ff42

  1. 编译器如何实现作用域?

    • 相同的变量名会被编译器映射到不同的地址
    • 硬件不了解作用域
  2. 全局变量可以被静态分配,也就是说编译器可以在程序执行之前为全局变量指定地址。但是编译器必须动态地分配局部变量,因为编译器不知道有多少本地变量需要动态分配空间,所以它保留了大量的空间。所以分配给局部变量的地址往往跟保存全局变量的地址相距很远

  3. 结论:局部变量和实参需要动态分配,会降低运行速度

  4. 编译器和硬件内部维护两个重要的值来界定和操作激活记录:

    • stack pointer R — esp 栈顶(低地址)

    • frame pointer R —ebp 栈底(高地址)

  1. 图例:

  1. 只有栈顶的激活记录可以被访问,也就是只有当该函数调用的所有子函数返回,该函数才可能返回,同时我们只能访问当前正在执行的函数的局部变量(和全局变量不在stack上)

函数调用的机器级表示

  • caller将实参保存到callee能访问到的地方
  • caller保存返回的地址并将控制转移到callee(call指令)
  • callee保存caller的现场,并为自己的非静态局部变量分配空间
  • 执行callee的过程(函数体)
  • callee恢复caller的现场,释放局部变量空间
  • callee取出返回地址,将控制转移到caller(ret指令)

IA-32约定:

  • caller保存寄存器:eax、edx、ecx
  • callee保存寄存器:ebx、esi、edi
  • ebp和esp分别是帧指针寄存器和栈指针寄存器

内存层次和分配

内存管理

变量的生命周期对应三种内存分配机制:

  • static: 程序执行期间保留的绝对地址
  • stack: 按后进先出的顺序分配和释放,带有函数调用和返回
  • Heap: 根据用户的要求在任意时刻分配和释放

stack和heap用于动态分配。

静态分配(static allocation)

static意味着发生在编译和链接时,无法在运行时改变。

静态变量包括:

  • 所有的全局变量

  • 被显式地声明为static的局部变量

  • 显式的常量

静态分配的变量会在main开始运行之前对其存储进行分配和初始化,并且直到main终止后才会释放。

静态分配的局部变量仅在声明它们的函数中可见,但不会在每次调用该函数时重新初始化。

局限性:

  • 命名成为问题
  • 不适合运行时内存分配
  • 无法实现递归

动态分配(dynamic memory allocation)

动态内存分配是程序运行期间的内存存储分配

stack allocation(栈分配)

动态分配的最常见形式,按后进先出的顺序分配和释放,并带有函数调用和返回,是现代编程语言的标准功能,因为堆栈支持递归

  • IA32 stack

    • 通过堆栈管理内存区域
    • 向低位发展
    • ebp指示最高堆栈地址(base pointer)
    • esp指示最低堆栈地址

    eg.

    push:

    1. 获取操作数
    2. esp-4
    3. 在esp提供的地址中写操作数

    pop:

    1. 根据ESP指示的地址读操作数
    2. esp+4
    3. 写到目的地址
  • 绝不能返回局部变量的地址,因为它在栈上,随时会改变。例如:

    int* ptr_to_zero() {
        int i = 0;
        return &i;
    }
    

heap allocation (堆分配)

  • 使用void* malloc (size_t size)函数在堆上分配空间
  • 使用void free(void *ptr)来释放内存

    • 必须是已经被malloc()分配的内存空间
    • 完成后应该将指针置为NULL
  • 堆和栈的区别

    • 栈是自动的(隐式的):作用域内创建,作用域外销毁
    • 堆是手动的(显式的):注意指针有自己的作用域(虽然动态分配的内存可以在程序退出或释放前一直存活,但其生存期和作用域受指向它的指针限制。比如指针为全局的,那么该分配的内存一直可以使用,如果指针为局部的,且在函数退出后该指针不会将地址传递给其它可以继续生存的变量,那么就必须在该函数退出时释放全局变量,否则指针的死亡将宣告内存泄露,因为无人知晓该内存区域的地址。)
  • 不要

    1. 在栈上使用free()
    2. 丢失对malloc()分配的内存的跟踪
    3. 对同一内存区域使用free()两次
    4. 不要用return语句返回指向栈内存的指针
    5. 未初始化指针
  • 内存分配算法

    • first fit (第一个满足要求的块)
    • best fit (刚好满足要求的块)
    • worst fit (最大的块)

内存bugs

  • 警惕指针

    • 指针变量没有被初始化(Wild pointer)
    • 指针被free或delete之后没有置为NULL,让人误以为是合法指针(Dangle pointer)
    • 指针操作超越了变量作用域(Dangle pointer)
  • 越界错误

    • 注意string必须以0x00结尾,所以在

      char *heapify_string(char *s)  { 
          int len = strlen(s);
          char *new_s = (char *) malloc(len);
          if(new_s) strcpy(new_s, s);
           return new_s;
      }
      

      中,应该写作:

      char *new_s = (char *) malloc(len + 1);
      
    • 运算符优先级

      *a--(*a)--

  • 两次free

  • 内存泄漏

内存分配机制

  • 动态内存分配

    • 显式的:程序分配和释放空间,例如C的malloc和free
    • 隐式的:程序分配,但不释放,例如JAVA
  • 内存分配的数据表示

    • Bitmap:一个比特数组,每个比特代表一个内存块
    • Linked-list:存储连续的未分配或已分配的内存区域
  • 跟踪空闲块

    • Implicit free list: 使用长度来链接所有的块
    • Explicit free list:使用空闲块内部指针,通常是双向指针
    • Segregated free list:分离空闲链表(为不同大小的内存块种类创建不同的空闲链表)
    • Blocks sorted by size:使用平衡树和指针,长度作为key
  • 放置策略

    • Fisrt Fit 首次适应法(会造成碎片)
    • Next Fit 循环首次适应法(碎片程度更严重)
    • Best Fit 最佳适应法(保持碎片较小,但比首次适应慢)
    • Simple segregated method 简单分离存储
    • segregated fit 分离适配(first fit,若找到则插入,若未找到,则着更大的类)
  • 不存在空闲块满足要求时

    • 向内核申请更多堆内存
    • 合并相邻的空闲块以产生更大的空闲块
  • 当释放时合并(coalescing)

    • 立即合并
    • 推迟合并(Deferred coalescing):可以提高分配速度(faster)
    • 合并实现
      • 带边界标记的合并技术

垃圾回收

  • Explicit Memory Management:EMM

    • c(malloc, free)
    • c++(new, delete)
  • Automatic Memory Management:AMM

    • JAVA(Garbage collection)
  • 垃圾回收的特点

    • 自动回收堆内存
    • 不用程序去释放
    • 延迟处理(块仅在需要时被重组)

GC算法

我们将内存看作如下的有向图:

  • 每个块都是图中的节点
  • 每个指针都是图中的边
  • 根结点和堆节点
  • 可达节点和不可达节点

Mark and Sweep collection, 标记清除法

可以在malloc/free包的上层构建

void mark(ptr p){ //起始p为根结点
  if ((b = isPtr(p)) == NULL) return;
  if (blockMarked(b)) return;
  markBlock(b);
  len = length(b); 
  for(i=0; i < len; i++)
    mark(b[i]); //深度优先遍历
  return;
}

void sweep(ptr b, ptr end){
  while(b < end){
    if (blockMarked(b)) unmarkBlock(b);
    else if (blockAllocated(b)) free(b);
    b = nextBlock(b);
    return;
  }
}
/* 
ptr isPtr(ptr p): 如果p指向一个已分配块中的字,则返回一个指向这个块起始位置的指针,否则返回NULL
ptr nextBlock(ptr b): 返回堆中块b的后继
int length(ptr b): 返回块b的字长
*/

在每个块的头部使用标记位

  • mark:从roots开始,并在所有的可达内存块上设置标记位
  • sweep:扫描所有的未标记的块并清除,也就是清除所有不可达的块

copying collection, 复制法

两个堆, 一个由程序使用,另一个由垃圾回收使用

  • 优点:比MS算法快,只需要一次遍历
  • 缺点:只能使用一半的堆空间等,不可预测的周期性中断

Reference counting, 引用计数法

跟踪指向每个对象的指针数量,当引用计数变为0时,该对象是不可访问的垃圾。

缺点:

  • 时间上:频繁的引用增减
  • 空间上:保持计数的额外开销
  • 无法处理对象之间相互循环引用

优点:

  • 可立即回收垃圾,也就是当程序每次生成垃圾时都会被回收,无需挂起应用程序,消减了最大暂停时间。

Generational garbage collection, 分代式垃圾回收法

事实:

  • 如果一个对象很长时间内都可以被访问,那么它很有可能保持原状

  • 在大部分语言中,大多数对象很快失效

结论:

  • 频繁扫描新生对象,较少扫描老对象

通常分配对象到两代, G0&G1

  • G0包含新生对象,更容易成为垃圾

  • G0比G1扫描更频繁

优点:

  • 无需遍历整个引用树
  • 新生一代包含最高比例的垃圾,将工作重点放在此处,系统更加高效

总结

  1. 引用计数

    是解决显示内存分配问题的常用解决方案,但实现赋值时的递增和递减操作的代码通常是程序缓慢的原因之一,无论如何,引用计数不是全面的解决方案,因为无法解决循环引用。

  2. 何时执行垃圾回收?

    只在内存变得紧张时执行,当内存尚且宽裕时,不会再释放内存上花费任何时间

  3. 垃圾回收的优势

    1. 不会因为内存泄漏的累积而崩溃
    2. 拥有长期稳定性
    3. 更少的难以发现的指针错漏
    4. 开发和调试更快
  4. 垃圾回收的劣势

    1. 回收时间不可预测,程序可能意外暂停
    2. 回收时间没有上限
    3. 所有线程在回收时停止运行
  5. 参考文章

    1. https://blog.csdn.net/u012960981/article/details/23672969
    2. https://www.cnblogs.com/andy-zcx/p/5522836.html
    3. https://blog.csdn.net/zdy0_2004/article/details/51485198
    4. https://blog.csdn.net/yangxuefeng09/article/details/10066403
    5. https://blog.csdn.net/guoping16/article/details/6579434
    6. https://blog.csdn.net/c602273091/article/details/53576494#segregated-list

5

性能

  • Hotspots : 代码中需要花很长时间执行的部分

  • 80/20 rule:80%的cpu时间被20%的程序占用

  • Amdahl’s Law:系统优化某部件所获得的系统性能的改善程度,取决于该部件被使用的频率,或所占总执行时间的比例

    • $Speedup(E) = \frac{1}{(1-P) + \frac{P}{S}}$

    • 其中P为被优化部分占整个程序的比例, S为被优化部分在优化前后的时间比值

  • 三种时间

    • real time:是从进行开始执行到完成所经历的墙上时钟时间(wall clock)时间,包括其他进程使用的时间片(time slice)和本进程耗费在阻塞(如等待I/O操作完成)上的时间。
    • user time:是进程执行用户态代码(内核外)耗费的CPU时间,仅统计该进程执行时实际使用的CPU时间,而不计入其他进程使用的时间片和本进程阻塞的时间
    • sys time :是该进程在内核态运行所耗费的CPU时间,即内核执行系统调用所使用的CPU时间
  • 通常情况下,速度并不是程序设计最重要的考虑因素,而是可读性、可维护性

  • 最好和最重要的优化程序的方法是使用好的算法

  • 时间复杂度并非总是合适的效率指标,隐藏的常数可能会误导

优化编译器

妨碍编译器优化的因素:

  • 存储器别名使用

    编译器必须假设不同的指针可能会指向存储器的同一位置

    void func1(int* xp, int* yp){
      *xp += *yp;
      *xp += *yp;
    }
    // what if xp and yp point to the same object?
    void func2(int* xp, int* yp){
      *xp += 2**yp;
    }
    
  • 函数副作用

    int func1(x){
      return f(x) + f(x)
    }
    int func2(x){
      return 2 * f(x)
    }
    //what if f(x) has side effect?
    counter = 0;
    int f(x){
      return counter++;
    }
    

通用优化技巧

  1. 更快的数学运算

    • 简化表达式(合并同类项等)
    • 更快的计算技巧(乘除法替换为位移动)
  2. 更快的循环

    • 展开循环:
    for(i=0; i<3; i++) {     
        something(i); 
    }
    // unfold loop
    something(0); 
    something(1); 
    something(2);
    
    • 减少循环(可以用一次循环实现的,避免使用两次循环)
  3. 避免重复表达式

    通过变量保存表达式结果,不要重复计算

  4. 使用寄存器

    在for循环中,最适合在最内层循环中使用寄存器循环变量

    例如:

    • register float val;
    • register double dval;
    • register int ival;
    for ( int i = 0; i < 10; i++) {
      for ( int j = 0; j < 10; j++) {
        for ( register int k; k<10; k++) {
          // Function body
        }
      }
    }
    
  5. 整数

    • 如果可以,使用无符号整数而不是整数

    • 整数运算比浮点运算快

  6. 减少指针解引用

    for( int i = 0; i < BigNum; i++ )  {
      Inventory -> StuffToSell -> LowProfitStuff -> Count[i] = Value;
    }
    
    //optimization as follows
    
    unsigned int *InventoryCount = 
        Inventory -> StyffToSell -> LowProfitStuff -> Count;
    for( int i = 0; i < BigNum; i++ )  {
      InventoryCount[i] = Value;
    }
    
  7. 减少临时变量(但会损失一定的可读性)

    a = a + a1 + a2 + a3 => a+=a1; a+=a2; a+=a3

  8. 自增自减

    优先使用前置自增自减运算符

  9. 值传递和引用传递

  10. new或malloc

    昂贵的操作。尽量使用静态或局部变量,避免在堆上分配内存,最好不要使用malloc

  11. printf()

    最好不要使用printf(),它通常使用malloc()可能造成额外开销(推荐puts())

  12. reallocate

    内存分配要花费大量时间,尽量重复使用已分配的内存块,另外如果要为较小的对象重复分配内存,最好先分配一个大内存块并管理其中的较小对象。

  13. switch/case 比 if/else 更快

  14. 其他建议:

    • 不要使用递归,会带来很大开销

    • 避免在循环中使用sqrt函数,计算它会占用大量CPU

    • 一维数组比多维数组快

    • 单精度运算比双精度快

    • 浮点乘法通常比除法快(除以2用乘以0.5代替)

    • puts()比printf()快

    • 使用#define宏而不是小函数,消除调用函数开销并使编译器在优化时更激进

    • 二进制/未格式化的文件访问比格式化的访问快

6

链接

介绍

程序耗时 = 编译耗时 + 链接耗时 + 加载耗时 + 运行耗时

链接的作用:将可重定位目标文件转化为可执行文件

链接器有两种工作

  • 静态链接:在编译之后
  • 动态链接:在加载过程中或运行过程中

链接过程

  • 符号解析(确定符号引用关系)
    • 程序中有定义和引用的符号(包括变量和函数)
    • 编译器将定义的符号存放在一个符号表(symbol table)中
      • 符号表是一个结构数组,在.symtab节中,每个表项包含符号名、长度、位置等信息
    • 编译器将符号的引用存放在重定位节中(.rel.text、.rel.data中)
  • 重定位(确定每个符号的地址,在指令中填入新地址)
    • 将多个代码段和数据段分别合并为一个单独的代码段和数据段
    • 计算每个定义的符号在虚拟地址空间中的绝对地址
    • 将可执行文件中的符号引用处的地址修改为重定位后的地址信息
foreach section s{
  foreach relocation entry r{
    refptr = s + r.offset; //重定位ptr的指向
    if (r.type == R_386_32)
      *refptr = (unsigned)(ADDR(r.symbol) + *refptr)
  }
}

链接的好处

  • 模块化
    • 一个程序可以分成很多源文件
    • 可构建共享函数库
  • 效率高
    • 时间上可以分开编译
    • 空间上无需包含共享库所有代码
    • 可执行文件和内存中只需要包含所调用函数的代码

链接过程的本质

合并相同的section(不同目标文件中的代码.text 和 数据.data、.bss节合并到同一区域)

示例

swap()中的temp不是符号定义,因为它是定义在栈中的,不会在过程外被引用(局部变量不属于符号定义,不出现在符号表)

  • 符号类型
    • Global symbols(全局符号,模块内部定义的, 其他模块可以使用)
    • External symbols(外部符号,外部定义的)
    • Local symbols(局部符号,本模块定义并引用的,其他模块不可使用,注意不是程序中的局部变量)

全局符号的强弱

  • 强符号:函数名和已经初始化的全局变量名
  • 弱符号:未初始化的全局变量名
  • 解析规则(Rules)

    • 强符号不能多次定义,否则链接错误
    • 若一个符号被定义为一次强符号和多次弱符号,则以强定义为准
    • 若有多个弱符号定义,任选其中一个
  • 建议:

    • 尽量避免使用全局变量以避免出错,尽量使用本地变量(static)
    • 全局变量要赋初值,使之成为强符号,容易查出错误
    • 外部全局变量使用extern,以示其引用的定义在其他模块

目标文件

种类

  • 可重定位目标文件(通过链接成为可执行目标文件)
  • 可执行目标文件
  • 共享目标文件

目标文件包含

  • 头部信息和可选头部(描述文件的基本属性,比如文件版本、大小端、程序入口地址等)

  • 许多节(Sections)

    • Text, Data, BSS
    • Strtab, shrtrtab
    • Line, Debug
    • Symtab, Relocation(Rel.text & Rel.data) 符号表和重定位节分别包含定义和引用
  • 节头部表(Section Header Table)

    每个节的属性包括:

    • Section Name
    • Physical Address
    • Virtual Address
    • Size of Raw Data
    • File Pointer to Raw Data (指示节在文件中的位置)
    • File Pointer to Relocation Table (该节在重定位表中的位置)
    • File Pointer to Line Numbers (该节的行号表在文件中的位置)
    • Characteristics (标志位)

符号表目

image-20191229171448132

Num:字符串表中的字节偏移

Value: 符号地址

Size:目标大小(字节)

Type:数据or函数

Bind:全局or局部

Ndx:1(.text)3(.data) UND(未定义的符号) COM(未初始化的数据目标) ABS(不该重定位的符号)

重定位表目(ELF)

image-20191229172214826

  • 两种最基本的重定位类型
    • R_386_PC32
    • R_386_32

加载

将可执行程序拷贝到内存并将控制转移到程序的开始的过程。

  • 对于windows,加载过程的用户是Explorer 或者 Cmd,程序是CreateProcess()函数
  • 对于LInux, 加载过程的用户是shell, 程序是execve()函数

加载过程

  1. 创建进程,Create process

  2. 虚拟内存映射, VM memory mapping

    image-20191229174052340

  3. 跳转到_start(不是_main)

  4. 请求页,Demand paging)

7

Cache

image-20191230145710793

Cache的实现

  • 分块方法

  • 主存与cache的映射方法

  • 写数据一致性

  • cache牺牲块选择方法

映射方法

直接映射

每个主存块通过取模映射到固定的cache行

标记段指明了cache中存储的是主存中的哪个块群

  • 有效位

    • 为1 表示信息有效, 为0 表示信息无效
    • 某行被替换或装入新块后设置有效位为1
    • 通过有效位置0来冲刷Cache
  • 优点:

    • 命中时间短
    • 无需考虑牺牲块问题
    • 命中率低,存储空间不能重复利用

全相联映射

  • 优点:
    • 没有冲突缺失,充分利用空间
    • 命中率高
  • 缺点:
    • 比较时间长

组相联映射

组内直接映射,组外全相联映射

cache友好的编程

  • 在每个循环内部使cache尽可能多的命中
  • 注意二维数组的空间局部性
    • C语言中的二维数组是行优先存储的
  • 按照数据对象存储在存储器中的顺序来读取数据
  • 一旦从存储器中读取了某个对象就尽可能多地使用它
  • 存储器访问次数和不命中率往往需要折中考虑

8

异常、进程与线程

控制流

  • 按顺序取下一条指令执行
  • 通过CALL/RET/Jcc/JMP等指令跳转到转移目标地址处执行

正常控制流

CPU所执行的指令的地址序列成为CPU的控制流,通过上述两种方式的控制流称为正常控制流。

异常控制流

CPU会因为遇到内部异常或外部中断等原因打断程序的正常控制流,转去执行操作系统提供的针对这些特殊事件的处理程序,这样形成的意外控制流称为异常控制流(Exception Control of Flow, ECF)

  • 形成原因

    • 内部异常

      • 缺页
      • 越权
      • 越级(用户越级访问内核信息)
      • 整除0
      • 溢出
    • 外部中断

      • Ctrl-C
      • 打印缺纸
      • DMA结束
    • 进程上下文切换(os层面)

    • 一个进程发送信号给另一个进程(软件层面)

异常与中断

当系统启动时,操作系统分配和初始化一张称为异常表的跳转表,表目k包含异常k的处理程序的地址

异常号是异常表中的索引,异常表的起始地址保存在异常表基寄存器(ETR)中

异常的类别有

  • 中断(来自i/o设备的信号)
  • 陷阱(trap, 有意的异常,缘于执行的指令)
    • 在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用
    • 在IA32系统上,系统调用是通过INT n指令实现的,n可能是异常表256个表目中的任意一项
  • 故障(fault,潜在的可恢复错误)
  • 终止(abort,不可恢复的错误)

image-20191229152256637

异常与过程调用的区别:

  • 过程调用时,在跳转到处理程序之前,CPU将返回地址压到栈中,然而根据异常的类型,返回地址要么是当前指令要么是当前指令的下一条指令
  • 处理器会把一些额外的状态入栈,处理程序返回时,重新开始被中断的程序会用到这些状态
  • 如果控制从用户程序转到内核,所有这些项目都被压到内核栈中,而不是用户栈
  • 异常处理程序运行在内核模式下,意味着它可以访问所有的系统资源

简易版

  • 返回地址可能不是下一条指令
  • 把更多内容压入栈
  • 使用内核栈
  • 处于内核模式

进程

异常提供了基本的构造块,以允许os提供进程的概念

进程提供给应用程序的关键抽象:

  1. 一个独立的逻辑控制流(提供一个独占处理器的假象)
  2. 一个私有的地址空间(提供一个独占使用存储器的假象)

用户态和内核态

某个控制寄存器中的方式位设置与否决定了进程处于内核态还是用户态

上下文切换

操作系统利用上下文切换这种一场控制流来实现多任务

上下文:内核重新启动一个被抢占的进程所需要的状态信息,包括寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等

当内核调度一个进程时,上下文切换可以:

  1. 保存当前进程的上下文
  2. 恢复某个先前被抢占的进程的上下文
  3. 将控制传递给这个新恢复的进程

多任务的种类

  1. 协同式多任务(Cooperative multitasking) 进程自愿放弃控制
  2. 抢占式多任务(Preemptive multitasking ) 进程被强制挂起

线程

线程同步和互斥

未完待续。。。

0%