计算机组成和操作系统

1 计算机系统漫游

  1. 只由ASCII字符构成的文件称为文本文件,除此以外都是二进制文件。
  2. .c文件\rightarrow预处理器(cpp)\rightarrow.i文件\rightarrow编译器(ccl)\rightarrow.s文件(汇编文件)\rightarrow汇编器(as)\rightarrow.o文件(可重定位目标程序)\rightarrow链接器(ld)\rightarrow可执行文件。
  3. hello程序的运行:首先shell读取用户的输入,将“./hello”读入寄存器,再保存在主存中。然后从磁盘上读取hello文件代码以及数据(DMA)。接着开始执行代码,这些指令将要输出的字符串加载到寄存器上,最后传输到显示设备。
  4. 操作系统提供的抽象表示:
    1. 文件是I/O设备的抽象,文件为应用程序提供了统一的视图
    2. 虚拟内存是I/O+主存,虚拟内存起到每个进程独占主存的假象
    3. 进程是I/O+主存+处理器,进程起到每个程序独占硬件资源的假象
    4. 虚拟机是操作系统+I/O+主存+处理器。
  5. 虚拟地址空间从低到高地址增大。最底层是程序代码和数据,然后是堆(运行时由malloc创建),共享库,用户栈(函数调用增长,函数返回收缩),内核虚拟内存(用户不可见)
  6. amdahl定律:对系统的一部分加速时,影响程度取决于该部分的重要性和加速程度。

2 信息的表示和处理

  1. 字节(byte)作为最小的可寻址的内存单位。字长(word size)决定了计算机的虚拟地址空间的最大大小,字长为w位的计算机,程序最大访问为2w2^w个字节。
  2. 最低有效字节在低地址是小端,反之是大端。字节顺序值得注意的地方:1是网络传输中的统一;2是阅读机器级代码时候的统一。
  3. C中对于有符号数的右移是算术右移,对于无符号数的右移必须是逻辑右移。
  4. 乘法的指令周期在10个左右,而加减,移位则是1个是时钟周期。因此编译器会通过移位和加减法结合来代替乘法,从而得到优化。除法更慢,需要30个甚至更多的时钟周期,对于除以2的幂的除法,可以通过右移操作来优化。
  5. 浮点数有符号位,尾数,和阶码组成。舍入的规则是,对于正好出于中间位置的数,我们向偶数舍入(由于这样能在计算平均数的时候不出现偏差),其他时候向靠着近的舍入。从F或D向int转的规则是向零舍入。

3 程序的机器级表示

  1. 查看二进制文件的linux命令:objdump -d filename.o。可以用–masm=intel来显示intel标准的汇编代码。
  2. gcc汇编代码指令都有一个字符的后缀,表示操作数的大小。b是字节,w是字,l是双字,q是四字。
  3. CPU中包含16个64位通用目的寄存器。他们分别由初代8086的8个寄存器ax—bp,以及后面新增的8—15构成。功能如下:16个寄存器功能
  4. 操作数的可能性分为三种:
    1. 是立即数,$符号后面加一个数字。
    2. 是寄存器,rar_a表示寄存器,R[rar_a]表示寄存器的值。
    3. 是内存寻址,通常是MbM_b[addr]。
      内存寻址中需要注意最特殊的比例变址寻址:Imm(r_b,r_l,s) = M[Imm+R[r_b]+R[r_c].s],没有r_b时前面逗号保留;s=1时不出现s,此时为变址寻址;只有一个寄存器时是间接寻址。
  5. mov操作在x86中禁止直接从内存赋值到另一片内存中,必须通过寄存器做中介。movz会将剩余位填补为0,movs会将剩余位填补为源数据的最高位。
  6. %rsp始终指向栈顶元素,且栈顶元素的地址是最低的。压入栈时,先移动栈顶指针,然后将数据压入栈。弹出时相反。(8086是这样,IA-32中则是直接将rsp的值压入栈中)
  7. leaq是算数逻辑运算中唯一只有q模式的指令。leaq S,D = D <- &S。这个指令可以被编译器用作简单的加法和乘法运算。
  8. 条件码寄存器:CF进位标志,ZF零标志,SF符号标志(最近得到的结果为负),OF溢出标志。
    符号寄存器的访问方式:
    1. 通过set访问
    2. 通过跳转指令访问,其中rep ret能够起到让跳转指令不会指向ret,避免无意义的操作的作用
    3. 基于条件判断的数据传输,数据更为常用是考虑到流水线会进行分支预测,一旦预测错误效率会大打折扣,因此用跳转指令效率会低。
  9. C中的switch语句在汇编中会有两步重要的处理:
    1. 将输入的范围进行缩小,缩小到0-n,这样判断起来更便捷。
    2. 构建跳转表,对于不存在的选项跳转至默认的loc_def标签,对于重复的选项跳转至同一个标签。
  10. 过程调用需要满足的三个要求:
    1. 过程调用开始时,PC需要设置为被调用程序的起始地址。
    2. 调用结束PC设置为调用程序的下条指令地址。
    3. 可以传递参数和返回值,前6个参数有寄存器传递,超过6个通过栈传递。被调用程序要为局部变量分配和释放内存。
  11. 浮点数会存放在媒体寄存器中YMM或XMM中。一共有16个寄存器。函数通过%xmm0 ~ %xmm7 8 个寄存器传递浮点参数,剩下的参数通过栈传递。返回的浮点数通过%xmm0传递。单双精度指令区别在于指令后面是ss还是sd

4 处理器体系结构

  1. 指令的字节级编码:每条指令的第一个字节表示指令类型。高4位是代码部分,第四位是功能部分。第二个字节表示使用到的寄存器的寄存器标识符,如果没有用到寄存器则用F表示。
  2. 程序员可以通过状态码来得到程序运行的总体状态。系统会调用异常处理函数处理碰到的异常。
  3. 数字系统三部分:
    1. 存储位的存储器
    2. 更新位的时钟
    3. 计算位的组合逻辑。
  4. 处理一条指令的流程:
    取指\rightarrow译码(读取最多两个操作数和寄存器)\rightarrow执行(对于跳转指令在这里决定分支)\rightarrow访问(内存交互)\rightarrow写回(寄存器交互)\rightarrow更新PC。
  5. 流水线尽管对特定指令会有延迟,但是增加了系统的吞吐量。
    流水线的问题:由于划分不一致,系统吞吐量受最慢的部分限制。流水线过深,收益会下降,这是因为受到流水线寄存器固定延迟导致的。
  6. 为了流水线的并行,需要把完成PC值的预测这一过程放在取指环节。
  7. 流水线冒险:数据冒险(一条指令用到这一条指令计算结果)和控制冒险(跳转的指令触发)。
    避免数据冒险的方式:
    1. 暂停。直到所有的源操作数指令完成了写回阶段,通过插入气泡让本来要执行的指令停下,类似于动态插入nop指令。
    2. 转发。检测到有未完成的读任务时,直接将计算结果转发到相应的指令阶段。转发在碰到前一条指令加载,后一条指令使用的情况时就会出问题,此时需要把暂停和加载结合起来,在使用阶段添加气泡,然后用转发处理。
  8. 异常处理的原则:流水线最深的异常指令优先级最高。出现异常指令后,禁止后面的指令更新寄存器

5 优化程序性能

  1. 编译器优化的原则:只执行安全的优化。举例:两个指针指向同一块内存的情况称为内存别名,编译器优化时必须考虑这个情况。
  2. 程序性能度量标准:每元素的周期数(CPE)。
  3. 消除循环的低效率。代码移动,将需要多级计算单计算结果不变的运算移到循环外,典型的例子是判断循环边界条件。
  4. 减少过程调用,每次函数调用都是一笔开销。在循环中调用函数就是一个例子。
  5. 避免不必要的内存开销。比如在循环中每次都要读写内存,可以使用局部变量代替。
  6. 程序性能的两个界限:一系列指令必须顺序执行时,会有延迟界限;处理器单元的原始计算能力是吞吐量界限。
  7. 循环展开:通过在循环中增加计算次数,来减少循环轮数。
  8. 通过增加并行性来提高效率。可以打破延迟界限。
  9. 通过重新结合运算也能显著提升效率。这个是考虑到指令发射时刻意并行完成加载和计算来实现的。
  10. 一些限制因素:
    1. 寄存器溢出。一旦超过可用寄存器数量,部分临时变量就会保存在栈上,这会拉低效率;
    2. 分支预测错误和预测惩罚。
      为了避免这个问题,需要:不要过分关心可预测的分支,写出适合条件传送的代码(依赖随机数据进行条件判断何容易出现分支预测错误,这时候功能性的代码是通过条件操作计算值来完成赋值)
  11. 程序剖析:通过在gcc中加入-pg的选项,就会在正常执行代码后得到一个gmon.out的文件,通过gprof就可以查看函数调用时间。
    报告有两部分
    1. 第一部分是各个函数调用花费的时间,调用次数。
    2. 第二部分是具体的函数调用与被调用情况。需要注意的是gprof对时间短于1秒的函数不够准确,并且默认不会统计库函数的调用。

6 存储器层次结构

  1. 局部性:具有局部性的计算机程序倾向于访问相同的数据项集合,或者邻近的数据项集合。
    时间局部性:引用过的内存,会在将来不久再次被引用。
    空间局部性:引用过的内存,会在将来不久被引用到它附近的内存。
  2. SRAM比DRAM要快,SRAM多用于CPU高速缓存存储器,一般为几兆。DRAM主要用于主存和图形系统帧缓存区,一般为几百兆。
    SRAM将位储存在双稳态电路中(倒挂的钟摆),抗干扰强,需要6个晶体管。DRAM将位储存在电容中,抗干扰差,只需一个晶体管。
    传统的DRAM由d个超单元组成,每个超单元由w个单元组成。数据通过pin传入传出。内存控制器通过行地址和列地址访问对应超单元。
    DRAM设计成二维阵列好处是减少地址pin,坏处是增加访问时间。
  3. 增强DRAM。快页模式:允许在行地址不变的情况下,直接通过列地址访问。扩展数据:列地址更加紧密。同步:前几种均为异步,
    同步速度更快。双倍数据速率同步:使用两个时钟沿作为控制信号,速度翻倍。
    SRAM和DRAM断电会有数据缺失,为易失性存储器。ROM是非易失性存储器。PROM,一次重编程,储存器单元是熔丝。EPROM,石英窗光控,
    1000次。EEPROM,电子控制,10^5次重编程(闪存)。储存在ROM中的程序称为固件。
  4. 磁盘容量受到记录密度(磁道上单位长有的位数)和磁道密度(从中心出发单位长上的磁道数)影响。
    CPU通过内存映射I/O的方式来控制I/O设备。地址空间中留一部分用于I/O通信,即I/O端口。
    SSD由闪存翻译块和闪存(块页)组成。数据以页为单位读写,读性能高于写性能,原因是写需要保证整个块是被擦除过的,否则要把原先块上
    的数据复制一个擦除过的块上。
  5. 缓存不命中分为:冷不命中(缓存为空),需要执行放置策略,通常为一个块号映射,但是会引起冲突不命中,此时需要设定一个工作集。当
    工作集超过缓存容量,为容量不命中。
    cache的参数:S组数,E组中行数,B块数,m整个物理内存的字节数。当且仅当有效位置位+找到符合的块地址才算命中。E=1的cache称为直接cache。
    CPU访问直接cache的三步骤:组选择,行匹配,字抽取。
    全相联高速缓存一般应用在快表上,是没有分组的。
  6. 高速缓存友好代码:让最常见的情况运行的更快。尽量减少循环中的不命中数。

7 链接

  1. 目标文件:可重定位目标文件(编译生成),可执行目标文件(静态链接生成),共享目标文件(动态链接生成)。
    ELF文件中的重要的分段:.text 已经编译的代码段 .rodata 只读的数据 .data 已初始化的全局和静态变量 .bss 未初始化的全局和静态变量 .symtab 符号表.rel.text .rel.data 重定位相关信息。
  2. 每一个可重定位模块m都有符号表。包含:由m定义的并被其他文件引用的全局符号(非静态函数和全局变量);由别的文件定义被m引用的外部符号;只被m定义和引用的局部符号(静态函数和带static的全局变量)
    符号表的参数:name是字符表中的offset;value是地址;size是大小;binding表示是本地还是全局的。每个符号被分配到一个section中,有三个例外:ABS(不该被重定位的符号)UNDEF(不在本模块定义的符号)COMMON(未初始化的全局变量)。
  3. linux处理多重定义的三原则:不准有重复强符号,有强弱符号选强符号,重复弱符号随机选。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
  4. 重定位:合并相同的节;重定位符号引用,使之指向正确的地址。
    重定位条目:offset需要被修改的节偏移。type重定位类型,其中两个常见的:R_X86_64_PC32,重定位PC的相对位置;R_X86_64_32,重定位绝对地址。
  5. 可执行文件相比可重定位文件,少了rel部分(已经不需要重定位),多了.init(内含_init函数初始化代码)。
  6. 动态链接库没有真正加载代码段和数据段,而是提供符号表和重定位信息。可以加载无需重定位的代码称为位置无关代码(PIC),PIC中数据引用是用了全局偏移量表(GOT)函数引用是用了延迟绑定。
  7. 通过readelf可以查看目标文件。几个重要的参数:-h 文件名,查看整体信息;-t 文件名 详细表;-s 符号表;-S 每一节的头部。-r 显示重定位;-d 动态表节。

8 异常控制流

  1. 处理器通过异常表处理异常。异常表起始地址放在异常表基址寄存器中。
    异常调用与过程调用的不同:异常调用返回当前指令或者下一指令的地址;会将额外的处理状态压入栈;压入的是内核栈;异常调用在内核中进行,有完全权限。
  2. 异常类型:
    中断(I/O设备的信号)
    陷阱(故意的错误)
    故障(可恢复)
    终止(不可恢复)
    除了中断全为同步。陷阱是用户和内核之间的一个过程调用。
  3. 进程提供了两种关系抽象:逻辑控制流(独占处理器),私有地址空间(独占内存)。
    进程的三状态:创建,挂起,终止。
    fork函数可以用来创建进程。默认情况下,父子进程并发执行,拥有相同但是独立的地址空间,享有copy-on-write机制,共享打开的文件。
    已经终止但是没有被回收的进程是僵尸进程。如果父进程终止,系统会安排PID为1的init进程来回收子进程。
    进程休眠:sleep(受制于时间),pause(受制于信号),sigsuspend(比前两者都好,既没有竞争,也没有时间浪费)。
    linux shell和web服务器用fork和execve来实现交互:读入命令行的输入,通过parseline判断前后台执行,通过builtin_command函数检查是否是内置命令,如果不是则调用execve函数来执行。
  4. 发送信号:内核检测到系统事件或者进程调用kill函数。
    接收信号:可以忽略或者调用signal handle来捕获信号。一个已发出但未接收的信号称为pending signal。每种类型至多只有一个待处理信号(由内核维护向量)
    信号处理原则:
    1. 处理程序尽可能小而简单
    2. 只调用异步信号安全函数(可重入,不可被信号处理程序中断)
    3. 保存errno
    4. 保护全局数据结构,使用volatile声明全局变量
    5. 使用sig_automatic_t声明flag,因为读写是原子操作。
  5. setjmp.h头文件提供了两个非本地跳转的函数:setjmp,longjmp。预先设置好setjmp的错误值,当之后再调用函数中遇到错误,直接调用longjmp返回setjmp处进行错误解码处理。

9 虚拟内存

  1. 虚拟内存被分割为虚拟页,在磁盘上;物理内存被分割为页帧,在主存上。
    三种虚拟页:
    1. 未分配页
    2. 已缓存在物理内存中的已分配页
    3. 未缓存的已分配页。
      将虚拟地址映射到物理地址的数据结构称为页表。有效位用来判断是否虚拟页缓存到物理内存中。地址为空则是尚未分配。
  2. 页表会存在页命中和缺页异常。因此当有不命中的时候,会进行页面调度。由于局部性是的程序会工作在一个工作集上,当程序需要频繁换页的时候,就是发生了抖动。
    虚拟内存应用在内存管理:由于地址空间独立,可以简化链接。简化加载。简化共享(操作系统内核代码,不同的虚拟页指向同一块物理帧)。简化内存分配(虚拟内存上连续,但物理内存不连续的情况)
    虚拟内存应用于内存保护:在PTE上增加关键字。
  3. 地址翻译流程图:
    地址翻译流程
    为了加速地址翻译,可会在高速缓存中储存一个TLB,储存页号映射。
    为节约内存,采用多级页表。intel corei7采用了四级页表,分成四个9位的片段
    常见PTE字段:P是否在内存中;R/W是否有读写权;U/S是否是有超级权限;WT直写还是写回;CD能否缓存页表;A是否被MMU访问过(页替换算法);D脏位,判断是否写过
    linux的虚拟内存结构:task_struct维护了进程的信息,其中的mm_struct维护了虚拟内存当前状态。其中pgd指向了第一级页表的基址,mmap指向了一个vm_area_structs的链表,该链表维护了虚拟内存的内容(起止地址,是否有读写权限,是否共享)
  4. linux将虚拟内存区域和磁盘上的对象关联起来的操作称为内存映射。映射对象是交换空间,它限制着虚拟页面的总数。
    mmap和munmap可以创建和删除内存区域。在运行时动态分配是在堆上完成的。动态分配器分为显示分配器(C中的malloc和free,C++中的new和delete)和隐式分配器(垃圾收集)
    显示分配器的要求:处理任意请求序列;立即响应;只用堆;对齐;不修改已分配的块。
  5. 堆分配会导致碎片产生:
    1. 内部碎片是分了有没有用到的
    2. 外部碎片是没分但是不连续,没法再次分出去。分配器需要通过显/隐式空闲链表解决。
  6. C中常见内存错误:
    1. 间接引用坏指针
    2. 读未初始化的内存
    3. 缓冲区溢出(典型的gets)
    4. 假设指向对象的指针和所指对象同大小
    5. 错位覆盖
    6. 误操作指针(由于运算符优先级问题,建议多用括号)
    7. 误解指针运算(指针操作是按照所指对象大小操作的,比如int指针++是直接跳过一个int数)
    8. 引用不存在的变量(典型返回指向局部变量的指针)
    9. 引用空闲堆的数据
    10. 内存泄漏(忘记释放指针,这个可以用智能指针解决)

10 系统级I/O

  1. unix 文件基操:打开文件有描述符fd标识,每个进程默认三个文件:标准输入(0),标准输出(1),标准错误(2)。
    此后fd会递增,关闭文件时收回;读写文件,正常返回字节数,出错返回-1,读越界返回0;定位seek;关闭文件。
  2. 通过stat(输入文件名)或者fstat(fd)可以获得文件元数据。其中st_size表示文件大小,st_mode表示文件类型和访问权限。
  3. 内核用三个数据结构表示打开的文件:描述符表:每个进程独立拥有,每个描述符指向文件表的一个表项。文件表:所有进程共享,
    每个表项有文件的起始位置,引用计数,指向v-node表的指针。v-node表:文件信息。
  4. 文件重定向函数:dup2(oldfd,newfd)。

11 网络编程

  1. 网络字节储存通常是大端法。因此需要转换。unix提供了hton,ntoh来转换。n代表网络,h代表主机。inet_pton inet_ntop提供了
    十进制点分IP地址和网络流之间的转换。
  2. 套接字:IP+端口。
    服务器端通过 socket\rightarrowbind\rightarrowlisten,收到请求后accept处理
    客户端通过socket\rightarrowconnect发送请求。
    可以通过 getaddrinfo函数,其中host参数是主机名,service是端口号。getnameinfo则是反过来获得host和service。getaddrinfo在设置hints时,ai_family设置IPV4,ai_socktype设置套接字类型。

12 并发编程

  1. 使用应用级并发的程序称为concurrent program。操作系统提供三种构造并发程序的方式:
    1. 进程。由内核维护和调度,进程间通过IPC通信。
      优点:独立的地址空间,不会出现覆盖。
      缺点:进程之间通信比较慢。
    2. I/O多路复用。由应用程序自己在一个进程的上下文显示调度。
      使用select函数,要求内核挂起进程,直到一个或多个I/O事件发生,再返回控制权给应用程序。select函数将read_set中发出请求的描述符产生一个ready_set,表明哪些请求来到。通过FD_ISSET区分需要怎么处理。
    3. 线程。一个进程内,由内核调度。
      线程上下文切换快,线程间平等无父子关系。
  2. 多线程程序内存模型:寄存器不共享,虚拟内存共享。全局变量和本地静态变量都可以被共享。
    对于临界区,可以通过semphore(PV操作)实现mutex。
    三类线程不安全的函数:
    1. 不保护共享变量的函数
    2. rand函数
    3. 返回值是指向静态变量的指针的函数
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章