计算机系统

本文最后更新于:2023年5月23日 下午

计算机系统

浮点数

IEEE 浮点表示

IEEE 浮点标准用 V = (-1)^S X M X 2^E来表示一个数:

  • 符号位:s 决定这个数为负数( s = 1)或正数(s = 0);
  • 尾数: M 是一个二进制小数,其范围为 1 ~ 2 - 0,或 0 ~ 1 - 0;
  • 阶码:E 的作用是对浮点数加权,为 2 的 E 次幂。

浮点数的位表示
一个浮点数的位划分为三个字段,分别对这些值进行编码:

  • 第一个单独的符号位s直接编码s;
  • k 位的阶码字段 exp = … 编码 E;
  • n 位小数字段编码尾数 M 。

单精度字段分配
s、exp、frac 字段分别为 1,8,23位;
双精度字段分配
s、exp、frac 字段分别为 1, 11, 52位。

规格化的值

  • exp 的位模式不全为0 也不全为1时。
  • 阶码字段被解释为以偏置(biased)表示的有符号整数。即 E = e - Bias, e 是无符号数,Bias是 2^(k-1) - 1。(单精度 Bias 为127,双精度为 1023)
  • frac是小数值,取值是 [0 , 1)。
  • 尾数定义为 M = 1 + f

举个例子:

其过程:

  • 先化为二进制;
  • 然后数最高位的阶E;
  • 阶码 exp = E + Bias(127或1023);
  • 阶码 exp 化为二进制;
  • 将原二进制数中第一个1去除后,将剩下部分补入尾数,右侧补0补齐。

非规格化值

  • exp全为0;
  • 阶码 E = 1 - Bias ( 为了非规格化与规格化值之间平滑过渡)
  • 尾数 M = 0.xxxxxxx

如:

  • exp = 000000..0,frac = 000000..00,此时值为0,符号位决定 +,- 0;
  • exp = 000000..0,frac != 000000..00,此时为非常接近0的数。

特殊值
判断条件:exp = 全1;

  • exp = 全1,frac = 全0:表示无穷大,符号位决定正无穷或负无穷。
  • exp = 全1,frac 不是全0;表示这不是一个数。

总结如下:

舍入

向偶数舍入,即1.4会舍入为1,1.6舍入为2,1.5舍入为2,-1.5舍入为-2,使最低有效位为偶数。
二进制小数舍入:
若需要舍入至小数点后2位,先看第三位及以后,是否为中间值,即仅仅第三位为1,后面位全为0。
若是中间值,将第二位舍入为0,不是中间值,去向更靠近的一方。

浮点数运算

  • 符号位亦或^
  • 尾数相乘
  • 阶码E相加

  • M >= 2 , M 右移一位,E = E + 1
  • 若 E 超出范围,溢出
  • 将 M 舍入到 frac 的位数范围。

第三章 程序的机器级执行

  • mov系列指令:
  • movb/w/l:移动字节、字、双字
  • movsbw:符号拓展后,字节移动到字。
  • movzbw:零拓展后,字节移动到字。

imull/mull
要求其中一个操作数在eax中,调用如下:

1
imull 8(%ebp)

即最后一个参数与eax中的值相乘,乘积存放在寄存器 edx (高32位)和 eax(低32位)中。

cltd指令:将被除数拓展到 edx,eax 中

1
2
3
movl 8(%ebp),%eax // load x
cltd // extend into edx
idivl 12(%ebp) // divide by y

条件码寄存器:
CF : 进位标志。最高位产生进位,无符号数溢出;
ZF : 零标志。最近操作结果为0;
SF : 符号标志。最近操作结果为负;
OF : 溢出标志。最近操作补码溢出。

lea 指令不会改变任何条件码。

cmp 指令: 只改变条件码,除此之外,其与 SUB 指令完全一样。
test 指令:只改变条件码,其他操作与 AND 一样。

第五章 程序性能优化

性能的度量

CPE(Cycles Per Element)每元素的周期数,执行一个元素需要多少个时钟周期。

时钟周期:CPU完成一个最基本动作的时间(对应一个电平信号宽度)

高级语言层面的优化

将函数移出循环外

例如:

1
2
3
4
for ( i = 0 ; i < vec_length(v); i++ )
{
....;
}

可以将

1
i < vec_length(v)

单独列出,以 len 表示

消除不必要的内存引用

1
2
3
4
for(....)
{
*dest = *dest OP data[i]
}

上述代码一直在访问 dest指向的内存,可以考虑将这个改为一个循环内使用的局部变量(寄存器中)

1
acc = acc OP data[i]

机器层面的代码优化

指令的执行过程

  • 取指令(Instruction Fetch,IF)阶段:将一条指令从主存中取到指令寄存器的过程。程序计数器PC中的数值,用来指示下一条指令在主存中的位置。当一条指令被取出后,PC中的数值将根据指令字长度而自动递增;
  • 指令译码(Decode)阶段:取出指令后,计算机立即进入指令译码阶段,指 令译码器按照预定的指令格式,对取回的指令进行拆分和解释,识别区分出 不同的指令类别以及各种获取操作数的方法;
  • 取操作数;
  • 指令执行:此阶段的任务是完成指令所规定的各种操作,具体实现指令 的功能(分支、乘除、加法、加载和写存储器等)。为此,CPU的不同部分 被连接起来,以执行所需的操作;
  • 写回:将操作结果写入存储器或寄存器。

循环展开

将原本逐个递增的循环,改为每次加2,循环内语句也增加一倍。

两路并行

1
acc = (acc OP data[i]) OP data[i+1]

改为:

1
2
acc0 = acc0 + data[i];
acc1 = acc1 + data[i+1];

最后res = acc0 + acc1;

重新结合变换

1
2
3
acc = (acc OP data[i]) OP data[i+1]

acc = acc OPdata[i] OP data[i+1])

第六章 存储器层次结构

随机访问寄存器(RAM)

随机访问存储器(Random-Access Memory,RAM)

分为以下两类:

  • SRAM 即静态随机访问存储器;
  • DRAM 即动态随机访问存储器。

静态比动态更快,但也更贵

SRAM

SRAM 是将每个位存储在一个双稳态的(bistable)存储器中。每一个时刻,其状态只有可能是二者之一。

DRAM

DRAM 将每个位存储为对一个电容充电。每个单元由一个电容和一个访问晶体管组成。

第七章 链接

全局变量

extern 关键字

对于变量

1
2
3
4
extern int a; // 声明一个全局变量 a
int a; // 定义一个全局变量 a
extern int a =0 ; // 定义一个全局变量 a 并给初值,可以被外部引用。
int a =0; // 定义一个全局变量 a, 并给初值,可以被外部引用。

也就是说,extern int a是一个声明,其它的均为定义

  • 声明:不需要为变量分配内存空间;

  • 定义:要为变量分配内存空间;

那么,如果要引用一个全局变量的时候,就必须要声明extern int a or extern a,否则会认为是定义。

对于函数

函数,对于函数也一样,也是定义和声明,定义的时候用extern,说明这个函数是可以被外部引用的,声明的时候用extern说明这是一个声明。 但由于函数的定义和声明是有区别的,定义函数要有函数体,声明函数没有函数体(还有以分号结尾),所以函数定义和声明时都可以将extern省略掉,反正其他文件也是知道这个函数是在其他地方定义的,所以不加extern也行。两者如此不同,所以省略了extern也不会有问题。

总结

  • 对于一个文件中调用另一个文件的全局变量,因为全局变量一般定义在原文件.c中,我们不能用#include包含源文件而只能包含头文件,所以常用的方法是用extern int a来声明外部变量。 另外一种方法是可以是在a.c文件中定义了全局变量int global_num ,可以在对应的a.h头文件中写extern int global_num ,这样其他源文件可以通过include a.h来声明它是外部变量就可以了。
  • 还有变量和函数的不同举例int fun();extern int fun(); 都是声明(定义要有实现体)。 用 extern int fun() 只是更明确指明是声明而已。而 int a; 是定义 extern int a; 是声明。

static 关键字

当我们同时编译多个文件时,所有未加 static 前缀的全局变量和函数都具有全局可见性

隐藏

当变量、函数的定义加上 static 关键字后,其余的文件便看不见他们了;利用这一特性可以在不同的文件中定义同名函数和同名变量,而不必担心命名冲突。

对于函数来讲,static 的作用仅限于隐藏

保持变量内容持久

存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

int fun(void){
static int count = 10; // 事实上此赋值语句从来没有执行过
return count--;
}

int count = 1;

int main(void)
{
printf("global\t\tlocal static\n");
for(; count <= 10; ++count)
printf("%d\t\t%d\n", count, fun());

return 0;
}

程序的运行结果是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
global          local static

1 10

2 9

3 8

4 7

5 6

6 5

7 4

8 3

9 2

10 1

可以看到,加了 static 那一行的语句仅执行了一次,也就是第一次。

默认初始化为0

链接器

符号

程序中有定义引用的符号

1
2
3
void swap() {…} /* 定义符号swap */
swap(); /* 引用符号swap */
int *xp = &x; /* 定义符号 xp, 引用符号 x */

编译器将符号的定义储存在一个符号表(symbol table)中

  • 符号表是一个条目数组;
  • 每个条目包含符号的名称、长度和保存位置。

重定位

  • 合并:将多个代码节和数据节分别合并成一个代码节和数据节;
  • 定义位置修改:将多个.o文件中的符号定义,从相对位置重定位到单个可执行文件中最终的绝对存储器位置;
  • 引用指向修改:更新对这些符号的所有引用指向它们的新位置。

三种目标文件

  • 可重定位目标文件(xxx.o file):包含的代码和数据可以在链接时与其他可重定位目标文件合并起来。每个.o 文件由对应的.c文件生成,代码和数据地址都从0开始
  • **共享的目标文件 (.so file)**:特殊的可重定位目标文件,可以在加载或运行时被动态加载到存储器并链接,Windows 中叫做动态链接库(Dynamic Link Libraries, DLLs);
  • 可执行目标文件 (a.out file):代码和数据地址为虚拟地址空间中的地址

上图是可重定位目标文件与可执行目标文件的对比。

可重定位目标文件格式

  • ELF header:字长、字节序(大端/小端)、文件类型 (.o, exec, .so)、 机器类型(如 IA-32)等(readelf -h);

  • .text section:已编译的机器代码;

  • .rodata section:只读数据,比如 printf 字符串, switch 跳转表等;

  • .data section:已初始化的全局变量。注意局部变量运行时保存在中;

  • .bss section:未初始化全局变量,仅是占位符;不占用实际磁盘空间;区分初始化和非初始化是为了空间效率;

  • symtab section:符号表(readelf -s);函数和全局/静态变量名,不含局部变量条目;

  • .rel.text section:.text 节的重定位信息(readelf -r);用于重新修改代码段的指令中的地址信息;

  • .rel.data section:.data 节的重定位信息;用于对被模块引用或定义的全局变量进行重定位的信息;

  • .debug section (.line section):调试符号表;

  • .strtab section:包含.symtab节中符号及section节名;

  • Section header table(节头表):每个section的名称、偏移和大小(readelf -S);

可执行目标文件的格式

与.o文件的不同:

  • ELF头中字段e_entry给出执行程序时第一条指 令的地址,而在可重定位文件中,此字段为0;
  • 多一个程序头表(Program Headers table) 是一个结构体数组:偏移量,虚存地址,物理 地址,段大小,地址对齐等信息(readelf -l);
  • 多一个.init节,用于定义_init函数,该函数用来 进行可执行目标文件开始执行时的初始化工作;
  • 少两.rel节(无需重定位)。

符号和符号表

  • **Global symbols ** 模块内部定义的全局符号,不带static

    • 由模块m定义并能被其他模块引用的符号。
    • 例如,non-static C函数和non-static全局变量 ,main.c 中的全局变量名buf
  • External symbols 外部定义的全局符号

    • 由其他模块定义并被模块m引用的全局符号;
  • Local symbols(本模块的本地符号,带static)

    • 仅由模块m定义和引用的本地符号。
    • 例如,在模块m中定义的static C函数和static全局变量。

注意,要是符号!!局部变量不是符号。(函数名都是)

其他本地(Local)符号

  • non-static C局部变量与static C局部变量
    • non-static C局部变量:存储在堆栈中;
    • static C局部变量:存储在.bss或.data中;

深入理解 static 修饰符

  • 隐藏:把函数和变量隐藏在模块内部(C++/JAVA private);
  • 存储区:把变量存放在静态存储区,具备持久性,默认值0;
  • static函数
    • 函数的定义和声明默认是extern。当定义成静态函数后只在当前文件(模块)中可见,不能被其他文件所用;
    • 其他文件中可以定义同名函数,不会发生冲突;
  • static全局变量
    • 存储区:.data或.bss,只初始化一次;
    • 作用域:定义该变量的源文件内有效, 在同一源程序的其它源文件中不可见(不能使用它);
    • 其他文件中可以使用同名变量,不会发生冲突。
  • static局部变量
    • 存储区:.data或.bss,只初始化一次,具备持久性(下一次依据上一次结果值 );
    • 作用域:当前函数

符号解析

  • 编译时,编译器向汇编器输出的每个全局符号(强符号 或 弱符号)
    • Strong:函数名和已初始化的全局变量名是强符号定义;
    • Weak:未初始化的全局变量名是弱符号定义。

符号解析规则

  • Rule 1: 强符号不能多次定义;
  • Rule 2: 强符号覆盖同名的弱符号(若一个符号被定义为一次强符号和多 次弱符号,则按强符号定义为准);
  • Rule 3: 若有多个弱符号定义,则选择其中任意一个;

重定位条目

生成条目

  • 汇编器遇到对位置未知的目标引用时,生成一个重定位条目

    • 数据引用的重定位条目在.rel_data节中;
    • 指令中引用的重定位条目在.rel_text节中;
    • ELF中重定位条目的格式:

两种基本重定位类型

  • R_386_PC32: 使用32位PC相对地址的引用(PC为下条指令地址);
  • R_386_32: 使用32位绝对地址。

PC相对寻址

  • 跳转指令的机器码采用PC相关编码方式(PC-relative, Program Counter)

  • 目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的作为编码;

也就是dst_addr = next_addr + value

第八章 异常控制流

回收子进程

当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reap)。当父进程回收己终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃巳终止的进程,从此时开始,该进程就不存在了。

一个终止了但还未被回收的进程称为僵死进程 (zombie)

waitpid()

pid_t waitpid(pid_t pid, int *Status, int options);

  1. 判断等待集合的成员:
    • 如果pid>0,则等待的是一个单独的子进程。
    • 如果pid = -1 ,那么等待的是由父进程所有的子进程组成。
  2. 修改默认行为
    • 可以通过将 options 设置为常量 WNOHANG 和 WUNTRACED 的各种组合,修改默认行为。
    • WNOHANG:若等待集合中任何子进程都未终止,则立即返回。返回值为0。
    • WUNTRACED:挂起调用进程的执行,直到等待集合中一个进程为已终止或被停止。返回的pid是导致这个结果的子进程pid。原本默认的行为是只返回已终止的子进程,当想检查被停止及已终止进程时,这个选项会有用。
    • WNOHANG|WUNTRACED:立即返回,如果等待集合中,没有任何子进程被停止或已终止,则返回值为0。
  3. 检查已回收子进程的退出状态
    • WIFEXITED(status):如果子进程通过调用exit返回或return正常返回,返回值为true;
    • WEXITSTATUS(status):返回一个正常终止的子进程的退出状态。
    • WIFSIGNALED(status):如果一个子进程是因为一个未捕获的信号终止的,返回真。
    • 等等
  4. 错误条件
    • 调用程序无子进程:waitpid 返回值为-阿,error设置为ECHILD;
    • waitpid 被信号中断,返回-1,error为EINTR。

信号

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。

信号术语

  • 发送信号
    • 内核检查到一个系统事件,如除以0或者子进程终止;
    • 一个进程调用了 kill 函数,显示地要求内核发送一个信号给目的进程。
  • 接收信号
    • 当目的进程被内核强迫以某种方式对信号的发送做出反应,目的进程就接收了信号。进程可以忽略这个信号,终止,或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。

一个只发出而没有被接收的信号称为待处理信号(pending signal)。任何时刻,一个类型至多只会有一个待处理信号。先到的信号保留,后到的信号直接丢弃。

进程组

每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。

pid_t getpgrp(void)

默认的,子进程和父进程属于同一个进程组。可以通过setpgid(pid_t pid, pid_t pgid)来设置进程的进程组。如果 pid 设置为0,就是当前进程的 pid。如果 pgid 是0,就用 pid 指定的进程 pid 作为进程组 pid。(新进程组)

/bin/kill发送信号:/bin/kill -9 15213

将信号9(SIGKILL)发送到进程15213。如果pid为负,则将信号发送到进程组pid中每个进程

-9 -15213```就将信号发送到进程组15213中每个进程。
1
2
3
4
5
6
7
8
9

### 接收信号

进程可以使用```signal```函数修改和信号相关联的默认行为。除了 SIGSTOP & SIGKILL 这两个的默认行为是不能被修改的。

```c
typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
  • 若handler 是 SIG_IGN ,那么忽略前面的信号;
  • 若handler 是 SGI_DFL ,那么类型为 signum 的信号回到默认行为;
  • 否则,handler就是用户定义的函数的地址,这个函数称为信号处理程序(signal handler)。只要程序接收到对应的信号,就会调用这个程序。

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!