本博客采用创作共用版权协议, 要求署名、非商业用途和保持一致. 转载本博客文章必须也遵循署名-非商业用途-保持一致的创作共用协议.

重点重读三, 五, 六, 十章节

#计算机系统漫游

编译系统:

  1. 预处理阶段:根据字符#开头的命令, 修改原始C程序
  2. 编译阶段:将文本文件翻译成汇编程序
  3. 汇编阶段:汇编器将编译程序翻译成机器语言指令, 并打包成可重定位目标程序
  4. 链接阶段:将调用函数目标文件合并到程序中, 形成可执行目标文件

执行执行过程:

当shell中输入结束后, shell执行命令将可执行文件的代码和数据从磁盘复制到内存, 处理器执行程序的机器语言指令(CPU将需要的数据从驻村复制到寄存器)

所有应用程序对硬件的操作尝试都必须通过操作系统

操作系统功能:

  • 防止硬件被应用程序滥用
  • 为上层应用程序提供简单的接口来使用底层不同的硬件设备

单处理四通进程并发执行, 通过处理器在进程间切换(上下文切换(系统调用))来实现, 在切换过程中操作系统保存原进程的上下文. 其中线程运行在进程的上下文中, 共享共同的代码和全局数据.

计算机系统的抽象

  • 文件是I/O的抽象(所有输入/输出都是I/O系统调用)
  • 虚拟存储器是对程序存储器的抽象
  • 进程是对一个正在运行的程序的抽象(每一个新的程序运行就会产生对应的进程, 进程中又包含多个线程执行单元)

#信息的表示和处理

  • 最低有效字节在最前面(低位地址)的方法称为小端法
  • 最低有效字节在最后面(高位地址)的方法称为大端法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <stdio.h>
#include <string.h>
/*
十进制数打印为十六进制
*/
typedef unsigned char *byte_pointer;
void show_bytes(byte_pointer start, int len) {
int i;
for (int i = 0; i < len; ++i)
{
printf("%.2x\n", start[i]);
}
printf("\n");
}
void show_int(int x) {
show_bytes((byte_pointer) &x, sizeof(int));
}
void show_float(float x) {
show_bytes((byte_pointer) &x, sizeof(float));
}
void show_pointer(void *x) {
show_bytes((byte_pointer) &x, sizeof(void *));
}
//打印字符串的ASCII
void show_str(const char *x) {
show_bytes((byte_pointer) x, strlen(x));
}
//使用位运算, 进行数据交换
void inplace_swap(int *x, int *y) {
*y = *x ^ *y;
printf("%d %d\n",*x, *y);
*x = *x ^ *y;
printf("%d %d\n",*x, *y);
*y = *x ^ *y;
printf("%d %d\n",*x, *y);
}

#程序的机器级表示

操作数指示符

  • 立即数, 常数值, 以$为前缀
  • 寄存器, 表示某个寄存器的内容, 以%为前缀
  • 存储器, 根据计算出的有效地址访问某个存储器的位置

指针就是地址, 间接引用指针(int x = *xp)就是将该指针放在寄存器中, 然后在寄存器引用中使用这个寄存器

程序中控制流(改变机器代码指令的执行顺序)的实现是通过CPU维护的条件码寄存器jump指令实现的.

一个方法调用必须将数据(参数和返回值)和控制从代码的一部分转移到另一部分, 还必须在进入上时, 为方法的局部变量分配空间, 并在推出时释放这些空间.

  • 程序用栈帧支持过程调用stack frame
  • 调用者P首先将当前返回地址(调用方法执行结束后的返回位置)压入栈, 形成P的栈帧的末尾
  • Q的栈帧包括被保存的帧指针, 被保存的寄存器,本地变量和临时变量, 参数构造区域, 栈指针指向栈顶

call指令将返回地址入栈, ret指令返回到call指令后的那条指令.

##数组和指针

数组和指针需要注意的问题:

  • C语言中声明数组的名称就是作为指向数组开头的指针, 数组A[i]等价与*(A + i)
  • 操作符&是给出对象地址(取地址), 操作符*是给地址的值(取值),point*&point是等价的
  • 二维数组&D[i][j] = D + L(C *j + j)(C为类型的大小)
  • C语言对数组引用不进行任何边界检查

##struct和union

要产生一个指向struct内部对象的指针, struct的地址加上该字段在struct内的偏移量

union用不同的字段引用相同的存储器快, union总的大小等于它最大字段的大小

数据对齐

  • 数据对齐简化了处理器和存储器之间接口的硬件设计
  • 分类存储器的库函数(如malloc)必须使返回的指针满足运行极其的最低对齐限制
  • struct需要在字段中填充字节, 保证对齐要求
1
2
3
4
5
6
//下面struct实际只需要9字节, 为满足4K对齐, 在c和j之间要插入3字节间隙, 实际要分配12字节
struct s1 {
int i;
char c;
int j;
}

#处理器体系

程序用虚拟地址来引用存储器的位置, 硬件和操作系统联合将虚拟地址转换为物理地址

处理一条指令的操作:

  1. 取指, 从存储器中取指令, 地址为PC(程序计数器)的值
  2. 译码, 从寄存器读入操作数
  3. 执行, ALU执行指令操作
  4. 访存, 写入数据到存储器或从存储器中读取数据
  5. 写回, 写回结果到寄存器
  6. 更新PC, 设置为下一跳指令的地址

指令流水线化增加了系统的吞吐量

  • 各流水线阶段不一致的时延, 导致流水线效率降低(运行时钟的速率由最慢阶段的延迟限制)
  • 流水线阶段过多, 导致通过各阶段是存期的延迟次数变多
  • 流水线中会出现数据相关和控制相关(指令控制)
    • 暂停来避免数据冒险(指令停顿在译码阶段, 直到冒险条件不再满足)
    • 数据转发来避免数据冒险

#优化程序性能

编写高质量程序:

  • 合适的算法和数据结构
  • 编译器能够有效优化, 转换成高效执行的源代码
  • 并行化和分布式
  1. 消除低效率循环(将公共代码移除循环, 称为代码移动)
  2. 减少过程调用
  3. 消除不必要的存储器引用(查看汇编代码)
  4. 除法相对加法和乘法运算是一种型对开销很大的运算(长延迟, 长发射时间)
  5. 循环展开, 增加每次循环中计算的元素数量, 减少迭代次数
  6. 提高程序的并行性(将程序拆分)

Amdahl定律: 主要思想是当优化系统一部分运行速度时, 对系统整体性能的影响依赖于这部分重要性和优化程度.

假设系统的某部分需要总程序运行时间的百分比为$\alpha$, 我们将这部分性能提高$k$倍, 则加速比$S$:

$$S=\frac{1}{(1-\alpha) + \alpha/k}$$

#存储器层次结构

  • 随机访问存储器
    • 静态RAM, 速度快于动态RAM, 常用与Cache Memory(不需要刷新)
    • 动态RAM, 常用作主存
  • 非易失性存储器(只读存储器)
    • 可编程ROM(PROM)
    • 可擦写可编程ROM(EPROM)
    • 闪存, 及基于闪存的固态硬盘(SSD)

程序局部性原理: 局部性分为时间局部性空间局部性, 在一个具有良好时间局部性的程序里, 被引用过一次的存储器很可能在不远的将来被多次被引用(例如for循环的执行). 在一个具有良好空间局部性的程序里, 如果一个存储器被引用了一次, 那么程序很可能在不远的将来引用附近一个存储器的位置.

高速缓存读工作原理, CPU请求一个内存地址, 这个地址被划分为三段(标记, 组索引, 块偏移), Cache被划分为多个组(组内可以有一行或者多行), cache收到地址后, 首先进行组选择, 然后通过块偏移得到组中对应块内的数据. 然后对比标记字段, 如果标记字段相匹配则命中, 否则缓存向驻村请求CPU需要的内存块的拷贝, 并返回给CPU.

高速缓存写策略: 写命中的情况下, write-through策略, 立即将高速缓存块写会到第一层(内层), 引起总线流量. write-back策略, 推迟存储器更新, 只有当替换算法要替换更新过的块(已写)时, 才将更新过的快写回存储器. 写不命中情况下, 写分配策略, 先加载对应的块到高速缓存, 再更新高速缓存. 非写分配策略, 直接写数据到存储器中(内存).

书中建议使用写回写分配策略.

#链接

链接是将所有代码和数据组合成一个可执行文件的过程.

  • C预编译器将.c源程序翻译成ASCII码中间文件.i
  • C编译器将.i翻译成ASCII汇编语言文件.s
  • 汇编器将.s翻译成可重定位目标文件.o
  • 连接器将.o文件及其他资源组合生成可执行目标文件

程序运行在一个程序上下文中, 有自己的虚拟地址空间, 当shell运行一个程序时, 父shell进程生成一个子进程, 它是父进程的复制品. 子进程通过execve系统调用启动加载器. 加载器删除子进程现有的虚拟存储器段, 并创建一组新的代码, 数据, 堆和栈. 使可执行文件初始化后, 加载器跳转到_start地址, 最终调用应用程序的main函数

动态库致力于解决静态库缺陷. 在运行时, 动态库可以加载到任意的存储器地址, 并和存储器中的程序链接起来.动态链接器执行

UNIX用.so表示共享库, Window上成为DDL

动态库的核心在共享, 不像静态库, 要对需要的数据和代码进行拷贝

#异常控制流(ECF)

  • 中断由来自I/O设备的信号引起, 异步
  • 陷阱, 故障, 终止是同步发生的. 陷阱是有意的异常, 陷阱最重要的作用是在用户程序和内核之间提供一个像过程一样的接口, 称为系统调用
  • 终止通常为硬件错误
  • 故障由错误情况引起, 可能被故障处理程序修正, 否则发生abort终止引起故障的程序.

保护故障被shell报告为Segmenttation Fault

使用模式位限制应用可执行的指令和可访问的地址空间范围

  • 设置了模式位, 进程就运行在内核模式(内核态)
  • 未设置, 则进程运行在用户模式(用户态)
  • 导致状态切换的唯一方法是异常(中断, 故障, 陷入系统调用)

通过fork()创建子进程, 并不与父进程完全相同, 他们有不同的进程ID(PID), 子进程有父进程上下文的拷贝, 有属于自己的私有独立地址空间. 在父进程中fork()返回子进程的PID, 在子进程中, fork()返回0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 错误输出的简单封装 */
void unix_error(char *msg) {
fprintf(stderr, "%s : %s\n", msg, strerror(errno));
}
/* fork的简单封装 */
pid_t Fork(void) {
pid_t pid;
if ((pid = fork()) < 0) {
unix_error("Fork error");
}
return pid;
}

子进程终止, 内核并不直接将它从系统中清除, 子进程保持终止状态, 直到被父进程回收, 终止但未被回收的子进程称为僵尸进程

若父进程没有回收子进程直接终止, 内核使用init进程回收

1
2
3
4
5
6
7
8
9
#include <sys/wait.h>
#include <sys/types.h>
/*
作用: 将已终止的子进程从系统去除
pid > 0, 则等待单独子进程; pid = -1, 则等待父进程所有子进程集合
返回: 成功返回子进程的PID, 如果WNOHANG, 返回0, 其他错误, 则返回-1
*/
pid_t waitpid(pid_t pid, int *status, int options);

程序和进程的主要区别: 程序是静态的, 进程是动态的

##信号

由内核发出到指定的进程, 通知进程中发生了某一类型的时间.

  • 发送信号: 内核向进程发出, 原因是内核检测到系统时间, 或者进程调用kill函数
  • 接收信号:进程接收到信号, 可以忽略信号, 终止或通过执行singal handler用户层函数捕获信号

  • 待处理信号会被阻塞

  • 待处理信号不会排队等待
  • 系统调用可以被中断.

编写信号处理程序, 要注意解决信号可以阻塞和不会排队等待的情况

1
2
3
4
5
6
7
8
9
10
11
12
/* 回收多个子进程 */
void handle(int sig) {
pid_t pid;
while((pid = waitpid(-1, NULL, 0)) > 0)
printf("Handler reaped child %d\n", (int)pid);
if (errno != ECHLD) {
unix_error("waitpid error");
}
sleep(2);
return;
}

快速阅读了后面网络编程的章节, 核心虚拟存储器过段时间需要重读, 网络编程部分看UNP

  • 汇编语言知识
  • 计算机组成原理知识