深入理解计算机系统笔记

1 信息就是位+上下文,系统中所有的信息都是用一串位来表示,包括磁盘文件、存储器中的程序、存储器中的用户数据以及网络上传递的数据。区分不同数据对象的唯一方法是,我们在读取这些数据对象的上下文,在不同的上下文当中,一个同样的字节序列可以表示为一个整数、浮点数、字符串或者机器指令。

 

2 有符号的整数表示一般采用补码的方式,字的最高位被解释为负权,置为一表示负数,否则为正数。见下图。在位值不变的情况下,要把有符号的正数强制转换为无符号的正数,如果是正数的话,转换后数值不变,如果是负数的话,那么会变成一个更大的正数,比如下图-8变为8,值增加16。对于多字节的数据,需要考虑大小端编码问题。




 

由于这种转变会有很多细微的变化,所以编码尽量不要做这类事情,避免出错。

数学运算需要注意溢出的情况,比如两个int类型的数相加,最终可能会溢出int最大值。

另外还要注意效率的问题,乘法和除法的执行都比较耗时,最好是转换为移位运算和加减法。

浮点数加法不满足结合律,如果编译器对这类运算通过结合率进行运算可能会出问题。同时浮点数乘法也不满足分配率。还有舍入问题也需要考虑。

浮点数的编码方式使用了IEEE标准754,参考http://zhan.renren.com/programming4idiots?gid=3602888498026486936&checked=true

 3 汇编代码是机器代码的可读形式,一般来说一条汇编指令对应一条机器指令,汇编代码最终还会通过汇编编译器汇编成机器指令,然后让主机执行。

 4 过程调用包括把数据(以过程参数和返回值的形式)和控制从代码的一部分传送到另一部分。另外他还包括在过程进入时为局部变量分配存储空间,在过程退出时释放空间。大部分的机器都只提供转移控制到过程和从过程转移出控制这些简单的命令。数据传递,局部变量分配和释放,都是通过操作程序栈来实现。



 为单个过程分配的那部分栈成为栈帧,栈帧的最顶端由两个指针界定,帧指针和栈指针,分别保存在ebp和esp寄存器里。程序执行时,栈指针移动。

 

5 数组T A[N],在存储器中分配了一个L*N大小的连续内存区域,L为T类型所占的字节数。

多维数组是按照行优先的方式来存储的,因此处于性能的考虑,在遍历多维数组的时候,应该行扫描优先,提高cpu cache的命中率。cpu cache是按照字对齐的,内存位置相邻的数据会缓存在一起。

 

6 很多计算机系统会对基础数据类型的地址合法性做出限制,一般会要求地址必须是K(2,4,8或其他)的整数倍。这种限制简化了处理器和存储器之间的接口设计。比如如果处理器一次只能从存储器取出8个字节,那么就按8个字节对齐,保证取一个8个字节的数据,只需要访问一次存储器,提高性能。

 

7 随着硬件的发展,大于4G的内存已经很常见了,原来32位的系统,由于字长32位的限制,导致程序所能使用的虚拟内存空间最大为4G。不能最大限度利用内存,会导致在处理大数据集的时候数据落地,使用核心外算法,即数据要在内存和磁盘切换,影响程序性能。现在支持64位的cpu也很常见,所以把操作系统升级为64位对提高程序性能有重大意义。

IA32指代在基于因特尔处理器上运行32位linux操作系统时,硬件和GCC代码的组合。x86-64指代在64位的intel或者amd上面运行的硬件和代码的组合。

x86-64的优势:

  • 指针和长整数用64位来表示。整数算术运算支持8,16,32,64位的数据类型。
  • 通用目的寄存器组从8个增加到16个。
  • 许多程序状态保存在寄存器上面,而不是栈。整数和指针通过寄存器来传递。有些过程无需访问栈。这样就可以减少访问主存的次数,提高性能。即在过程调用中减少对栈帧的依赖。
  • 如果可能,条件操作用条件传送指令实现,会比传统的条件分支指令性能更佳。
  • 浮点操作用面向寄存器的指令集,而不是面向栈的指令集。

汇编代码对比。



 

 

 可以看出,64位的指令数量少,对主存的访问次数也少,性能会有明显提升。

 

7 存储器的层次结构,编程要善于利用局部性原理来改善程序性能。



 

8 链接技术用于将多个代码和数据块收集组合成一个完整的可以加载到内存中运行的程序。链接技术让程序可以单独编译,让程序的模块化得以实现。链接可以发生在编译时刻、装载时刻、运行时刻。

main程序调用是swap程序的方法,程序跨越多个文件通过链接技术来实现。main(swap)首先被编译成中间文件main.i,接着被编译成汇编文件,然后被编译成可重定向的目标文件,最后通过链接技术,组合成一个可执行目标文件。


静态链接需要完成两项任务,一个是符号解析,目标文件定义和引用符号,符号解析需要把符号引用关联到他实际的定义处;另外一个是重定向,编译器和汇编器生成的代码和数据区块地址都从0开始,链接器通过把每个符号定义关联到内存位置进行重定向,然后更新符号引用指向真实的内存地址。

现代的unix系统大多采用Executable and Linkable Format (ELF)作为目标文件的格式。每一个section维护不同的信息。


symtab作为符号表区域,负责解析符号引用为具体的符号定义。

例如main.o里面的符号表。全局符号buf位于Ndx第三个区域.data区域,在该区域的偏移量为0,size为8个byte。


 9 链接器在链接过程中把来自每个可定位向目标文件中符号表的符号都关联到一个确切的符号定义。符号表里的符号可能是局部的,可以在单个文件里面解析得到。还有一些是全局的符号,全局符号不可避免的会出现符号名称重复,连接器按照一定的规则来处理这些重名冲突。

在编程实践中,程序通常会使用到一些库函数,需要引用一些库文件。每个库文件里面通常都包含了多个目标文件。使用静态链接库的链接技术能够实现按需加载目标文件,只加载程序里面用到的函数所在的文件,减少内存的开销。

 

10 符号解析完成之后,链接器要做的就是进行重定位,即把目标文件代码和数据的静态地址重定位为运行时地址。重定位分为两步,第一步是把所有的目标文件的section区按类别合并成一个文件的section区,并赋予运行时地址,这样一来每一条指令和变量都有一个唯一确定的地址;第二步,是把代码和数据section内部的符号引用指向正确的运行时地址。

 

11 可执行文件已经包含了所有运行时需要被加载到内存里的信息,他和可重定位文件最大的一个不同点是,他已经被重定位,符号引用所指的地址都是运行时地址。


在linux里面用shell 命令行运行程序,./p  (p为可执行文件)。这个命令的作用是触发unix操作系统保留的loader的execuve方法。把文件的数据和代码加载到内存,并跳转到可执行文件的第一条指令执行。



 
 

 

12 尽管静态链接技术解决了一些问题,但是依然有很多缺点。比如当引用的函数库更新时,应用程序必须知道,并且重新链接,以此来更新函数库。对于一些常见的函数,比如printf,几乎每个程序都要用到,这会导致系统中的多个进程同时包含了printf所在目标文件的副本,浪费操作系统的内存资源。

于是就有了动态链接技术,在运行时刻才去重定位函数库,并且可以让多个进程共用一个共享库,减少函数库副本浪费内存。

首先函数库要先编译为共享库so,gcc -shared -fPIC -o libvector.so addvec.c multvec.c,创建vector库。

然后把用户程序和so进行静态链接gcc -o p2 main2.c ./libvector.so,这一步主要是拷贝一些重定位和符号表的信息,以便能够在运行时刻解析对libvector.so的数据和代码引用。

常规的动态链接技术是在装载程序的时候进行动态链接,在运行程序之前,一旦链接好之后在运行时刻就不再变化。除此之外,运行中的应用还可以向连接器发起装载和链接新的共享库,这种方式无需在编译时刻对欲链接的库进行任何预链接。

总的来说链接技术包含了编译时刻链接,即静态链接,还有装载时刻和运行时刻的动态链接。链接过程中最核心的步骤就是符号解析和重定位。

 

13 从处理器启动直至关闭,他都不停的在执行指令,一般来说程序计数器PC的地址都是自增的,每条指令所在地址相邻。但是实际上经常会出现下一条指令不是和当前指令相邻,比如调用jump,过程调用,函数返回。这种指令的跳转控制成为处理器的控制流。

cpu的控制流必须能够处理突变,比如硬件故障、网卡数据就绪、读磁盘阻塞、上下文切换、抛异常等等,这些都称为异常控制流。

下图是一个异常的剖析图,这个异常可能是和当前程序相关的,比如算术运算溢出、除0、页错误等等,也有可能是和当前程序无关的,比如io完成、定时器触发了。


当检测到异常事件时,处理器会进行间接过程调用,跳到异常表,执行操作系统异常处理例程(中断是其中一种)。异常处理结束后,cpu控制权可能会继续执行被打断的程序,也有可能会执行另一个不会发生异常的程序,或者丢弃被中断的程序。

异常处理需要硬件和软件的协作,系统的每一种异常都对应一个非负数的异常编号。有一些异常编号是内置在cpu中的,例如除0、页错误、算术溢出、非法内存访问、断点。有一些异常编号则是操作系统内核赋予的,比如系统调用,外部io设备的信号。



 

系统在启动时会分配和初始化异常表,cpu在检测到异常事件后,会跳转到异常表,根据异常编号寻找异常处理程序。一个异常处理的过程可以分为 硬件事件触发,然后定位到异常处理程序后交由软件处理。




下图是异常的类别



 首先是中断异常,中断异常是由外部设备触发的,是异步的。对于由外部设备引起的,需要进行的异常处理,称为中断处理器。



 cpu每执行完一条指令就会检测一下中断位是否有中断事件,如果有,那么通过异常编号找到对应的中断处理器,执行中断例程。当中断例程执行完之后,控制权还给中断之前的程序,程序继续运行,仿佛中断没有发生过。

 

14 还有一些异常是和当前指令同步执行的结果,即指令发生了错误,称为错误指令。

陷入异常trap,发生在指令执行内部,比如进行了系统调用读文件、创建线程fork、执行线程、退出线程exit,这些都需要使用内核提供的服务。进行系统调用后,会发生陷入异常,陷入处理结束后,会继续执行下一条指令。陷入异常处理器会解码参数,找到对应的内核例程执行系统调用。系统调用和普通过程调用是有区别的,普通过程调用是在用户态执行,访问的是当前的栈帧;而系统调用陷入内核态执行,访问的是内核的栈帧。
  

 

错误fault异常,是执行指令在某些条件下才会发生的,并且错误处理器有可能修复成功。如果修复成功,那么重新执行指令,如果修复不成功,那么丢弃指令。

一种常见的错误异常是页错误,指令访问的虚拟内存地址对应的物理页(一般为4kb的连续内存区域)没有保留在内存中。这个时候页错误处理器会从磁盘加载物理页到内存里,然后重新执行内存访问指令,这个时候就可以在内存中读到目标数据。



 abort异常,当发生不可恢复的异常的时候,就会触发abort例程。一般来说是硬件发生的错误,当触发abort例程时,操作系统会终止当前的程序,不会归还控制权给发生错误的程序。

 

15 进程是计算机执行体的一个抽象,他让用户觉得自己的程序独占处理器。每个进程都有一个上下文,维护进程的状态,每个进程的上下文包括了程序执行所需要的代码、数据、栈帧、寄存器、PC等信息。不同进程之间互不干扰,轮流占用cpu时间片。每个进程都有一个私有的内存空间,通过操作系统的虚拟内存子系统来管理,让用户感觉独占内存。


 

16 为了给操作系统提供无懈可击的进程抽象,处理器提供一个寄存器用于标识用户态和内核态。处于用户态的进程只能执行某些受限制的指令,只能访问受限制的内存区域。而处于内核态的进程则拥有特权可以访问所有的内存区域,执行所有的指令。用户进程要从用户态切换到内核态,只能通过异常控制流的方式,比如发生中断、陷入、页错误等异常,进程陷入内核态转入执行异常处理例程。执行完毕后,控制权返回到原来的程序,切换为用户态。

linux系统提供一种聪明的方式,可以通过访问/proc文件,来访问内核信息,无需陷入内核态。

 

17 多任务操作系统会在某些情况下发生上下文切换,比如进行一个系统调用read file。如下面的例子。

进程A进行read磁盘请求,陷入内核。陷入异常处理器发起一个DMA,并安排磁盘控制器在复制数据到内存结束后中断cpu。然后进行上下文切换cpu执行进程B。当磁盘控制器复制数据完毕后,发起中断,cpu将控制器交还给进程A。


 

18 fork,父进程通过fork创建一个子进程。子进程的内存映像、数据是父进程的一个副本,但是处于不同的地址空间。子进程继承父进程的打开文件,和父进程共享文件。父子进程的不同点只有pid。fork命令调用一次返回两次pid,一次返回给子进程,pid为0,一次返回给父进程子进程的真实pid。如下:



 这段代码会在父子进程同时运行。当pid为0说明是子进程,会进入printf("child : x=%d\n", ++x);的逻辑。当pid不为0,进入printf("parent: x=%d\n", --x);的逻辑。fork完之后会往同一个标准输出打印两行,因为他们共享同一个打开文件。

当一个子进程因为某些原因被终止了,内核并不会马上回收资源,而是将子进程置为终止态。等待父进程回收,只有父进程回收完之后,内核才会释放这个子进程占用的资源。当一个子进程被终止但是没有成功被回收的话,那么就成了僵尸进程。僵尸进程浪费内存空间。

 

execve函数用于读取一个可执行文件,传入参数list和环境变量,并执行。函数原型为int main(int argc, char **argv, char **envp);



 shell和web server大量使用了fork和execve函数。

19 unix信号是异常控制流的一种用户级进程的控制方式,他允许一个进程通过发送信号终止掉另外一个进程。比如我们用在shell按ctrl+c,那么就会终止掉前台运行的进程。我们调用kill pid就会杀掉进程pid。

一个进程发送信号给另一个进程包含两步:

发送信号,内核通过更新目标进程的上下文状态来发送信号。一般内核发送信号会有这么几种原因,一种是内核检测到系统事件比如除0或子进程终止;一种是进程A显式调用signal,要求内核给目标进程发送信号,比如kill pid。进程还可以发送信号给自己

接受信号,内核会强制目标进程对发送过来的信号进程处理。它可以忽略信号、终止自己、或者用户可以自定义信号处理器,通过事件驱动方式响应信号。

一个目标进程对于同一种类型的信号在某个时刻只能接受一个,比如正在处理信号A,如果同时再发送一个信号A给目标进程,那么这个信号A就会被丢弃。

20 度量程序执行时间

度量程序执行时间是非常复杂的事情,没有统一的万金油。因为同一个程序的执行时间,受到很多因素的影响,比如硬件的性能、系统的负载、并发的用户数量、网络负载、磁盘操作、上下文切换频率、cpu缓存命中率等等。

程序的执行时间可以分为硬件的微观层面和软件的宏观层面,从纳秒级别--》微秒-》毫秒。

当发生外部事件,比如键盘事件、io事件,cpu会发生中断异常,转而执行另一个进程,进行上下文切换;就算没有外部事件发生,进程调度会按照时间片轮转法,会有timer触发中断,每隔10ms就会强制切换到另一个进程,发生上下文切换。所以一个程序在执行过程中,不可能占满cpu时间片,会交替出现活动和不活动状态。


每个进程的执行时间可以分为两类,一个是用户态时间、一个是内核态时间。下图是只有A\B两个进程同时执行的时间情况。



 
 通过unix>time prog -n 17可以得到执行prog的时间,执行完的结果是

2.230u 0.260s 0:06.52 38.1% 0+0k 0+0io 80pf+0w,表示2.23s的用户态时间、0.26s的系统态时间、经过的总时间6.52s大于u+s的时间,说明中间cpu还去处理了其他事情,比如页错误或者io。

21 虚拟内存

现代操作系统都支持多任务并行处理,当多个进程占用内存超过物理内存的时候,那么就挂了。于是就有了虚拟内存技术。虚拟内存机制为每个进程提供了私有的地址空间,进程的地址空间不会招到其他恶意进程的破坏,致使崩溃;虚拟内存地址空间存储在磁盘,扩大了内存的空间,同时把物理内存当做虚拟内存的缓存,由于局部性原理,程序的性能就如同直接使用物理内存一样,性能不会因为数据存储磁盘而受到影响。虚拟内存会把活跃的数据驻留在物理内存,并通过swap in ,swap out在物理内存和磁盘之间交换数据。

物理内存相当于是一个可以随机寻址的字节数组,如果直接使用物理内存的话,那么结构如下图


如果使用虚拟内存则需要先经过MMU进行地址转换。地址转换工作需要硬件和软件的协作。


虚拟内存和物理内存以page为最小寻址单元。由于存储的层次结构特点,高一级的访问性能往往是低一级的访问性能的好几倍,比如cpu缓存的访问性能是主存的10倍,主存的访问性能是磁盘的10万倍,访问磁盘某扇区的第一个字节的性能比访问后续字节的性能慢10万倍,这就意味着缓存不命中代价相当大。所以为了提高命中率,提高性能,page的大小设定较大,一般是4-8kb。

维护page索引的页表,每个进程都持有一个page table,pagetable驻留在物理内存里。



 
 



 

 

 

 使用虚拟内存简化了内存的管理:简化了链接、装载、贡献代码和数据、分配内存。


使用虚拟内存作为内存保护工具。在访问内存的时候引入权限判断机制,同时内个进程都拥有私有的地址空间。




 

地址转换过程


页命中和页错误的步骤图
 

inux虚拟内存



 
 22 内存映射

Linux将虚拟内存的某个区域的内容关联到一个磁盘对象的进程称为内存映射。

磁盘对象可以是常规的linux文件,比如可执行文件。虚拟内存区域可以映射到一个磁盘文件的连续段。文件段被按页(4 or 8kb)分成一片一片的,每页都包含了虚拟内存页的初始化内容。虚拟内存页并不是一开始就驻留内存,而是等到cpu第一次touch之后,触发页错误才会驻留到内存中的。如果虚拟内存区域比文件段大,那么会用0填充补齐。

磁盘对象也可以是匿名文件,这个是内核创建的,填充了二进制0的内存区域。

无论如何,一旦虚拟内存被初始化,他都会通过内核维护的一个特殊文件swap file,进行swap。swap file的大小限制住了当前进程所能分配的虚拟内存页个数。

内存映射产生了初衷是为和文件系统结合,使得程序和数据的加载变得更加简单高效。比如对于函数库的使用,所有进程都引用函数库的同一个副本,指向同一个物理内存空间,这样比每个进程都拥有自己的函数库副本更加节约空间。这个也称为内存共享,虚拟内存的机制很容易就可以实现这样的功能。

除此之外,进程也有可能会对自己私有的内存空间进行写操作,这部分私有的内存空间初始化状态是和其他进程一样的,这个时候就可以采用copy on write的方式来提高内存的使用效率。


fork函数也是采用这类的机制来实现,刚开始子进程是父进程的一个副本,复制父进程的页表、area 结构体等等,但是指向的物理内存区域是同一片,直到发生写操作,通过copy on write创建新副本。

通过mmap函数还可以提供用户级的内存映射,void *mmap(void *start, size t length, int prot, int flags, int fd, offt offset);可以创建一片虚拟内存区域,然后把对象映射到这片区域。


23 由于在编译时刻无法确定运行时刻的进程占用内存,所以操作系统支持动态内存分配技术。

动态内存分配器在进程的虚拟内存空间维护一个区域称为heap,用于进行动态内存分配。



 动态内存分配器把heap当做一个可变尺寸的block的集合,每个block可能处于已分配或者未分配的状态。动态内存分配器提供显式分配和隐式分配的方式,显式分配要求应用自身要显式释放已分配内存,比如c的malloc就是一种显式分配的方法,需要显式调用free来释放已分配block。

隐式分配则要求分配器能够自动检测没有使用的内存,然后自行释放,通过垃圾回收的机制,比如java。

动态内存分配会产生碎片问题,包括内部碎片和外部碎片。内部碎片的出现是由于真实的payload小于分配的block大小(由于对齐的原因,不能完全保证分配的block刚好等于payload,一定会大于等于payload)。

外部碎片的原因是因为存在多个非连续的未分配的内存块,当应用要请求一个大内存块的时候,无法找到一个符合条件的连续内存区域,外部碎片的发生和内存分配、释放的模式有关,难以量化和预测。为了解决外部碎片的问题,一般分配器会使用启发式算法,宁可维护少量大的连续内存区域,而不是大量的小连续内存区域。

内存分配的实现采用了隐式空闲块链表,他的缺点是进行空闲块查找的需要遍历整个堆,性能较差;优点是简单。


每个方格表示4个byte,header有一个位用来标识是否free。这样就形成了一条隐式free块链表
 

分配内存的时候,就沿着这条free block链表搜索,按照首次适配、最佳适配等原则,寻找合适的连续内存区域分配给应用程序。不同的原则各有优缺点。

当实在找不到足够大小的free连续内存区域的时候,首先会尝试合并所有的free块,如果合并之后依然无法满足要求,那么就会请求内核分配额外的堆空间,比如调用mmap方法。

隐式free块链表的查询时间和堆的总块数成线性增长,时间复杂度O(堆总块数)性能较差,一般不被使用。取而代之的是显式free块链表,每个free块的body维护一个前驱和后继指针,双向链表。这样查询一个可用的free块的时间复杂度就是O(free总块数)。这种方式的缺点是空间开销大,也会潜在的增大内部碎片的可能性。


24 线程模型

多线程技术的流行在于可以提高并行性,同时创建线程、线程上下文切换的成本远小于进程。对于一个进程中的线程,每个线程的地位都是平等的,没有所谓的父子进程关系。main线程和其他后续创建线程的唯一区别是main线程是第一个运行的。进程中的线程可以共享全局的数据。一个线程可以被另外一个线程终止



 

 

 

25 一个unix文件是一个字节序列,所有的IO设备,包括网络、磁盘、终端,都被抽象为文件。unix通过对文件进行读写来实现io设备的输入输出。这种优雅的文件映射使得unix系统只需暴露一组简洁、低级的应用接口,就可以按照统一的方式来对IO设备进行输入输出操作。

应用程序要访问io设备,必须先通过内核打开对应的文件描述符,以此为句柄进行后续的输入输出操作。

内核负责维护所有打开的文件,应用只需要跟踪文件描述符即可。

一般来说,通过shell启动的进程都会默认打开三个文件,标准输入、标准输出、标准出错。

c函数库还提供了标准io库,这是对unix IO的封装,使用更加便利。但是需要注意几点,比如read是有buffer的,采用预读策略。

如果使用全双工的stream,在同一个流上进行输入输出操作,那么必须注意,在输入操作和输出操作交替调用之间,需要调用fflush , fseek, fsetpos,or rewind 。

seek命令不能用于套接字文件。




 由于服务器一般要能够同时处理成千上万的请求,所以为了实现并发性,会有基于多进程的并发架构和基于多线程的并发架构,各有优劣。

多进程架构:当和客户A建立好连接之后,父进程会fork出一个一模一样的子进程,子进程复制父进程的文件描述符列表,然后子进程独立处理客户A的请求。在fork结束之后,父子进程都要关闭对应的无效的文件描述符,避免内存泄露,比如fork出child1之后,child1要关闭掉listenfd3,server要关闭掉connfd4。当子进程处理完毕退出后,父进程需要回收僵尸子进程。多进程架构比较简单,容易实现,多个进程只共享文件描述符,但是有各自的私有地址空间,基本上是share nothing,不易出错。只要我们记得回收僵尸子进程和及时关闭无用的文件描述符即可。多进程的缺点就是性能,进程运行的开销比较大,进程间通信的开销也比较大,共享数据只能通过一些IPC机制,比如共享内存、信号量、FIFO等等。


多线程架构,当和客户A建立好连接之后,父进程会Pthread_create创建一个线程,单独处理客户A的请求。多线程架构的优势是有更高的吞吐量,因为线程控制、通信、切换的开销都远小于进程。同时当需要共享数据的时候,多线程有天然的优势,他们都共享所有的数据。比如pagecache,所有的线程都可以读取同一个cache数据。多线程的唯一问题就是线程同步、共享数据的保护,不做好线程、数据同步的话,很容易出现一些难以排查的问题。
 
 
 


 


 



 


 
 


 


 

 


 


 
 
 
 
 


 
 

 

 

 

猜你喜欢

转载自hill007299.iteye.com/blog/1750620