CPU 和内存

CPU 周而复始地做同一件事:从内存取指令,然后解释执行它,接着再取下一条指令,再解释执行。

每个内存单元有一个地址(Address),内存地址是从 0 开始编号的整数,CPU 通过地址找到相应的内存单元,取其中的指令或者读写其中的数据。

  • 一个地址所对应的内存单元只能存储一个字节,多字节的数据类型保存在内存中要占用连续的多个内存单元,这种情况下数据的地址是它所占内存的起始单元的地址。

CPU 的核心功能单元:

  • 寄存器(Register):CPU 内部的高速存储器。
    • 特殊寄存器(Special-purpose Register)只能用于特殊用户,比如 eip 用作程序计数器。
    • 通用寄存器(General-purpose Register)可以用在各种运算和读写内存的指令中,比如 eax 寄存器。
  • 程序计数器(PC,Program Counter):一种特殊寄存器,保存着 CPU 取下一条指令的地址;CPU 按程序计数器保存的地址去内存中取指令然后解释执行,这时程序计数器保存的地址会自动加上该指令的长度,指向内存中的下一条指令。
  • 指令译码器(Instruction Decoder):CPU 取到的指令由若干个字节组成,其中有些位表示内存地址,有些位表示寄存器编号,有些位表示这种指令做什么操作(加减乘除、读写内存);指令译码器负责解释指令的含义,然后调动相应的执行单元去执行它。
  • 算术逻辑单元(ALU,Arithmetic and Logic Unit):译码器将一条指令解释为运算指令后,调动算术逻辑单元去做运算,比如加减乘除、位运算、逻辑运算;指令中会指示运算结果保存的位置,可能保存到寄存器中,也可能保存到内存中。
  • 地址和数据总线(Bus):CPU 和内存之间用地址总线数据总线控制线连接起来,每条线上有 1 和 0 两种状态。
    • 如果在执行指令过程中需要访问内存,比如从内存读一个数到寄存器。

CPU 访问内存读数据:

  1. CPU 内部将寄存器对接到数据总线上,使寄存器的每一位对接到一条数据线,等待接收数据;
  2. CPU 通过控制线发一个读请求,并且将要访问的内存地址通过地址总线发给内存
  3. 内存收到地址和读请求之后,将相应的内存单元对接到数据总线的另一端,如此一来,内存单元每一位的 1 或 0 状态通过一条数据总线到达 CPU 寄存器中相应的位,这样就完成了数据传送。
  • 图中画了 32 条地址线和 32 条数据线,CPU 寄存器也是 32 位,这是一种 32 位的体系结构。
  • 地址线、数据线和 CPU 寄存器的位数通常一致。
    • 有些寄存器(比如程序计数器)需要保存一个内存地址,因而地址线和 CPU 寄存器的位数也应该一致。
  • 处理器的位数也称为字长
  • 字(word)在有些上下文中指代字长。
    • 如果处理器是 32 位那么一个字就是 32 位,如果处理器是 64 位那么一个字就是 64 位。
    • 32 位计算机有 32 条地址线,地址空间(Address Space)从 0x000000000xffffffff,共 4GB;64 位计算机有更大的地址空间。

此处描述的地址线、数据线指 CPU 的内总线,直接和 CPU 的执行单元相连;内总线经过 MMU 和总线接口的转换之后引出到芯片引脚才是外总线,外地址线和外数据线的位数都有可能和内总线不同,例如 32 位处理器的外地址总线可寻址的空间可以大于 4GB。

CPU 取指执行的过程:

  1. eip 寄存器指向地址 0x80483a2,CPU 从这里开始取一条 5 字节的指令 a1 1c a0 04 08mov 0x804a01c, %eax),然后 eip 寄存器指向下一条指令的起始地址 0x80483a7
  2. CPU 对 5 字节指令进行译码,指令的内容是从地址 0x804a01c 开始取 4 个字节保存到 eax 寄存器;
  3. 执行指令,读内存,取到的数是 3,将 3 保存到 eax 寄存器;
    • 地址 0x804a01c~0x804a01f 要按地址从高到低的顺序看成 0x00000003(小端字节序, Little Endian Byte Order)。
  4. CPU 从此 eip 寄存器指向的地址 0x80483a7 开始取一条 3 字节的指令 83 c0 01add $0x1, %eax),然后 eip 寄存器指向下一条指令的起始地址 0x80483aa
  5. CPU 对此 3 字节指令进行译码,指令的内容是把 eax 寄存器的值加 1,结果仍保存到 eax 寄存器;
  6. 执行指令,eax 寄存器中的数更新为 4;
  7. CPU 从 eip 寄存器指向的地址 0x80483aa 开始取一条 5 字节的指令 a3 18 a0 04 08mov %eax, 0x804a018),然后 eip 寄存器指向下一条指令的起始地址 0x80483af
  8. CPU 对此 5 字节译码,指令的内容是把 eax 寄存器的值(4)保存到从地址 0x804a018 开始的 4 个字节;
  9. 执行指令,把 4 保存到从地址 0x804a018 开始的 4 个字节(按小端字节序保存)。

Endian

Endian and endianness (or “byte-order”) describe how computers organize the bytes that make up numbers.

  • Little-endian means storing bytes in order of least-to-most-significant (where the least significant byte takes the first or lowest address), comparable to a common European way of writing dates (e.g., 31 December 2050).
    • It’s used on all Intel processors.
  • Big-endian is the opposite order, comparable to an ISO date (2050-12-31).
    • Big-endian is also often called “network byte order” because Internet standards usually require data to be stored big-endian, starting at the standard UNIX socket level and going all the way up to standardized Web binary data structures.
    • Older Mac computers using 68000-series and PowerPC microprocessors formerly used big-endian.

number: 0x01234567
memory address      0x100   0x101   0x102   0x103
little endian       67      45      23      01
big endian          01      23      45      67

设备

CPU 执行指令时除了访问内存之外还要访问很多设备(Device),如键盘、鼠标、硬盘、显示器等。

  • 有些设备像内存芯片一样连接到处理器的地址总线和数据总线,不同的设备和内存芯片应该使用不同的地址范围。
    • 访问这种设备就像访问内存,按地址读写即可。
    • 和访问内存不同的是,往一个地址写数据只是给设备发一个命令,数据不一定要保存;从一个地址读数据也不一定是读先前保存在这个地址的数据,而是得到设备的当前状态。
    • 设备中可供读写访问的单元通常称为设备寄存器,操作设备的过程就是读写这些设备寄存器的过程。
      • 向串口发送寄存器里写数据,串口设备就会把数据发送出去,读串口接收寄存器的值,就可以读取串口设备接收到的数据。
    • 正因为地址线和数据线上可以挂多个设备和内存芯片所以才叫“总线”。
  • 还有一些设备集成在处理器芯片中。
    • 上图中 CPU 核引出的地址和数据总线有一端经总线接口引出到芯片引脚上了,还有一端没有引出而是接到芯片内部集成的设备上。
      • 无论是在 CPU 外部接总线的设备还是在 CPU 内部接总线的设备都有各自的地址范围,都可以像访问内存一样访问,很多体系结构(比如 ARM)采用这种方式操作设备,称为内存映射 I/O(Memory-mapped I/O)。
    • x86 比较特殊,x86 对于设备有独立的端口地址空间,CPU 核需要引出额外的地址线来连接片内设备(和访问内存所用的地址线不同),访问设备寄存器时用特殊的 in/out 指令,而不是和访问内存用同样的指令,这种方式称为端口 I/O(Port I/O)。
    • 从 CPU 的角度来看,访问设备只有内存映射 I/O 和端口 I/O 两种,要么像内存一样访问,要么用一种专用的指令访问。
  • 计算机设备种类繁多,有的要求带宽大,有的要求响应快,有的要求热插拔,于是出现了各种适应不同要求的设备总线,比如 PCI、AGP、USB、1394、SATA 等。
    • 这些设备总线并不直接和 CPU 相连,CPU 通过内存映射 I/O 或端口 I/O 访问相应的总线控制器,通过总线控制器再去访问挂在总线上的设备。
    • x86 平台上,硬盘是挂在 IDE、SATA 或 SCSI 总线上的设备,保存在硬盘上的程序不能被 CPU 直接取指令执行,操作系统在执行程序时会把它从硬盘拷贝到内存,这样 CPU 才能取指令执行,这个过程称为加载(Load)。程序加载到内存之后,成为操作系统调度执行的一个任务,就称为进程(Process)。

操作系统(Operating System)本身也是一段保存在磁盘上的程序,计算机在启动时首先执行一段固定的启动代码(称为 Bootloader)把操作系统从磁盘加载到内存,然后执行操作系统中的代码把用户需要的其它程序加载到内存。

  • 操作系统最核心的功能是管理进程调度、管理内存的分配使用和管理各种设备,做这些工作的程序称为内核(Kernel),它在计算机启动时加载到内存并常驻内存。
    • 操作系统和其它用户程序的不同之处在于操作系统是常驻内存的。

内存只是保存数据而不会产生新的数据,不会主动提供数据给 CPU,所以内存总是被动地等待被读或者被写。

设备往往会自己产生数据,并且需要主动通知 CPU 来读这些数据。这是由中断(Interrupt)机制实现的,每个设备都有一条中断线通过中断控制器连接到 CPU,当设备需要主动通知 CPU 时就触发一个中断信号,CPU 正在执行的指令将被打断,程序计数器会指向某个固定的地址(这个地址由体系结构定义),于是 CPU 从这个地址开始取指令(或者说跳转到这个地址),执行中断服务程序(ISR,Interrupt Service Routine),完成中断处理之后再返回先前被打断的地方执行后续指令。

  • 敲键盘产生一个输入字符,用户希望计算机马上响应自己的输入,这就要求键盘设备主动通知 CPU 来读这个字符并做相应处理。
  • 比如某种体系结构规定发生中断时跳转到地址 0x00000010 执行,那么就要事先把一段 ISR 程序加载到这个地址,ISR 程序是内核代码的一部分,在这段代码中首先判断哪个设备引发了中断,然后调用该设备的中断处理函数做进一步处理
  • 由于各种设备的操作方法各不相同,每种设备都需要专门的设备驱动程序(Device Driver)。设备驱动程序通常是内核里的一组函数,通过读写设备寄存器实现对设备的初始化、读、写等操作,有些设备还要提供一个中断处理函数供 ISR 调用。

MMU

现代操作系统普遍采用虚拟内存管理(Virtual Memory Management)机制,这需要处理器中的 MMU(Memory Management Unit,内存管理单元)提供支持。

如果处理器没有 MMU 或者 MMU 没有启用,CPU 执行单元发出的内存地址将直接传到芯片引脚上,被物理内存芯片接收,这样的地址称为物理地址(Physical Address,简称 PA)。

如果处理器启用了 MMU,CPU 执行单元发出的内存地址将被 MMU 截获,从 CPU 到 MMU 的地址称为虚拟地址(Virtual Address,简称 VA),而 MMU 将这个地址翻译成另一个地址发到 CPU 芯片的外部地址引脚上,也就是将 VA 映射成 PA。

  • 如果是 32 位处理器,则内地址总线也是 32 位,与 CPU 执行单元相连(图中只示意性画了 4 条地址线),经过 MMU 转换之后的外地址总线则不一定是 32 位,也就是说,虚拟地址空间和物理地址空间是独立的,32 位处理器的虚拟地址空间是 4GB,而物理地址空间既可以大于也可以小于 4GB。

MMU 以(Page)为单位将 VA 映射到 PA,32 位处理器的页尺寸通常是 4KB。

  • 物理内存中的页称为物理页面或者页帧(Page Frame)。
  • 虚拟内存的页和物理内存的页帧的映射关系通过页表(Page Table)来描述的,页表保存在物理内存中,MMU 会查找页表来确定一个 VA 应该映射到哪个 PA。

操作系统和 MMU 是这样配合的:

  1. 操作系统在初始化或分配、释放内存时会执行一些指令在物理内存中填写页表,然后用指令设置 MMU 并告诉 MMU 页表在物理内存中的位置;
  2. 设置好之后,CPU 每次执行访问内存的指令都会自动引发 MMU 做查表和地址转换操作。
    • 地址转换操作由硬件自动完成,不需要用指令控制 MMU 去做。

程序中使用的变量和函数都有各自的地址,程序被编译后,这些地址就成了指令中的地址,指令中的地址被 CPU 解释执行,就成了 CPU 执行单元发出的内存地址,所以在启用 MMU 的情况下,程序中使用的地址都是虚拟地址,都会引发 MMU 做查表和地址转换操作。

MMU 除了做地址转换之外,还提供内存保护机制

  • 各种体系结构都有用户模式(User Mode)和特权模式(Privileged Mode)之分,操作系统可以在页表中设置每个内存页面的访问权限,有些页面不允许访问,有些页面只有在 CPU 处于特权模式时才允许访问,有些页面在用户模式和特权模式都可以访问,访问权限又分为可读、可写和可执行三种。
  • 访问权限设定好之后,当 CPU 要访问一个 VA 时,MMU 会检查 CPU 当前处于用户模式还是特权模式,访问内存的目的是读数据、写数据还是取指令,如果和操作系统设定的页面权限相符,就允许访问,把它转换成 PA,否则不允许访问,产生一个异常(Exception)。
  • 异常的处理过程和中断类似,不同的是中断由外部设备产生而异常由 CPU 内部产生。
    • 中断产生的原因和 CPU 当前执行的指令无关,而异常的产生是由于 CPU 当前执行的指令出了问题,例如访问内存的指令被 MMU 检查出权限错误,除法指令的除数为 0 等都会产生异常。

通常操作系统把虚拟地址空间划分为用户空间内核空间

  • 用户程序加载到用户空间,在用户模式下执行,不能访问内核中的数据,也不能跳转到内核代码中执行。
    • 这样的目的是保护内核,即使一个用户程序访问了非法地址也不会影响到内核和整个系统的稳定性。
  • CPU 在产生中断或异常时不仅会跳转到中断或异常服务程序,还会自动切换模式,从用户模式切换到特权模式,因此从中断或异常服务程序可以跳转到内核代码中执行。
    • 事实上,整个内核就是由各种中断和异常处理程序组成的。
    • 正常情况下处理器在用户模式执行用户程序,在中断或异常情况下处理器切换到特权模式执行内核程序,处理完中断或异常之后再返回用户模式继续执行用户程序。
  • x86 平台的 Linux 系统虚拟地址空间是 0x00000000~0xffffffff,前 3GB(0x00000000~0xbfffffff)是用户空间,后 1GB(0xc0000000~0xffffffff)是内核空间。

段错误的产生:

  1. 用户程序要访问的一个 VA,经 MMU 检查无权访问;
  2. MMU 产生一个异常,CPU 从用户模式切换到特权模式,跳转到内核代码中执行异常服务程序;
  3. 内核把这个异常解释为段错误,把引发异常的进程终止掉。

虚拟内存管理

ps
#   PID TTY          TIME CMD
# 29977 pts/0    00:00:00 bash
# 30032 pts/0    00:00:00 ps
cat /proc/29977/maps 
# 08048000-080f4000 r-xp 00000000 08:15 688142     /bin/bash
# 080f4000-080f9000 rw-p 000ac000 08:15 688142     /bin/bash
# 080f9000-080fe000 rw-p 080f9000 00:00 0 
# 09283000-09497000 rw-p 09283000 00:00 0          [heap]
# b7ca8000-b7cb2000 r-xp 00000000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
# b7cb2000-b7cb3000 r--p 00009000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
# b7cb3000-b7cb4000 rw-p 0000a000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
# ...
# b7e15000-b7f6d000 r-xp 00000000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
# b7f6d000-b7f6f000 r--p 00158000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
# b7f6f000-b7f70000 rw-p 0015a000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
# ...
# b7fbd000-b7fd7000 r-xp 00000000 08:15 565466     /lib/ld-2.8.90.so
# b7fd7000-b7fd8000 r-xp b7fd7000 00:00 0          [vdso]
# b7fd8000-b7fd9000 r--p 0001a000 08:15 565466     /lib/ld-2.8.90.so
# b7fd9000-b7fda000 rw-p 0001b000 08:15 565466     /lib/ld-2.8.90.so
# bfac5000-bfada000 rw-p bffeb000 00:00 0          [stack]

# 另起一个终端
ps
#   PID TTY          TIME CMD
# 30697 pts/1    00:00:00 bash
# 30749 pts/1    00:00:00 ps
cat /proc/30697/maps
# 08048000-080f4000 r-xp 00000000 08:15 688142     /bin/bash
# 080f4000-080f9000 rw-p 000ac000 08:15 688142     /bin/bash
# 080f9000-080fe000 rw-p 080f9000 00:00 0 
# 082d7000-084f9000 rw-p 082d7000 00:00 0          [heap]
# b7cf1000-b7cfb000 r-xp 00000000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
# b7cfb000-b7cfc000 r--p 00009000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
# b7cfc000-b7cfd000 rw-p 0000a000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
# ...
# b7e5e000-b7fb6000 r-xp 00000000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
# b7fb6000-b7fb8000 r--p 00158000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
# b7fb8000-b7fb9000 rw-p 0015a000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
# ...
# b8006000-b8020000 r-xp 00000000 08:15 565466     /lib/ld-2.8.90.so
# b8020000-b8021000 r-xp b8020000 00:00 0          [vdso]
# b8021000-b8022000 r--p 0001a000 08:15 565466     /lib/ld-2.8.90.so
# b8022000-b8023000 rw-p 0001b000 08:15 565466     /lib/ld-2.8.90.so
# bff0e000-bff23000 rw-p bffeb000 00:00 0          [stack]
  • /proc 目录中的文件并不是真正的磁盘文件,而是由内核虚拟出来的文件系统。
  • 当前系统中运行的每个进程在 /proc 下都有一个子目录,目录名就是进程的 id,查看目录下的文件可以得到该进程的相关信息。
    • pmap 29977 也可以返回相似的信息。

  • 0x08048000-0x080f4000/bin/bash 加载到内存,访问权限为 r-x,表示 Text Segment,包含 .text 段、.rodata 段、.plt 段等。
  • 0x080f4000-0x080f9000/bin/bash 加载到内存,访问权限为 rw-,表示 Data Segment,包含 .data 段、.bss 段等。
  • 0x09283000-0x09497000 不是从磁盘文件加载到内存,这段空间称为堆(Heap),malloc 动态分配的内存从这里分配。
  • 0xb7ca8000 开始是共享库的映射空间,每个共享库也分为几个 Segment,每个 Segment 有不同的访问权限。
    • /lib/ld-2.8.90.so 就是动态链接器 /lib/ld-linux.so.2,后者是前者的符号链接。
    • 标有 [vdso] 的地址范围是 linux-gate.so.1 的映射空间,这个共享库是由内核虚拟出来的。
  • 堆空间的结束地址(0x09497000)到共享库映射空间的起始地址(0xb7ca8000)之间有大片地址空间;动态分配内存时堆空间可以向高地址增长
    • 堆空间的地址上限(0x09497000)称为 Break。
    • 堆空间要向高地址增长就要抬高 Break,映射新的虚拟内存页面到物理内存。
    • 抬高 Break 通过系统调用 brk 实现;malloc 也是调用 brk 向内核发起分配内存的请求。
  • 0xbfac5000-0xbfada000 是栈空间,其中高地址部分保存进程的环境变量和命令行参数,低地址部分保存函数栈帧。
    • 栈空间向低地址增长,但没有堆空间那么大的可供增长的余地。
      • 应用程序动态分配大量内存比较常见,但几十层深的函数调用并且每层调用都有很多局部变量的情况非常少见。
    • 栈空间可能用尽,而且比堆空间更容易用尽。
      • 无穷递归会用尽操作系统为程序预留的栈空间最终导致段错误。
    • 栈空间中,随着函数调用和返回而不断变化的这一端称为栈顶,每个函数调用的参数和局部变量的存储空间称为一个栈帧(Stack Frame);发生函数调用时在这个栈空间里分配栈帧,函数返回时释放栈帧。

虚拟内存管理的作用:

  1. 虚拟内存管理可以控制物理内存的访问权限。
    • 物理内存本身不限制访问,任何地址都可以读写,而操作系统要求不同的页面具有不同的访问权限,这是利用 CPU 模式和 MMU 的内存保护机制实现的。
      • Text Segment 被只读保护,防止被错误的指令意外改写。
      • 内核地址空间也被保护,防止在用户模式下执行错误的指令意外改写内核数据。
  2. 虚拟内存管理最主要的作用是让每个进程有独立的地址空间
    • 不同进程中的同一个 VA 被 MMU 映射到不同的 PA,在某一个进程中访问任何地址都不可能访问到另外一个进程的数据,这样依赖任何一个进程由于执行错误指令或恶意代码导致的非法内存访问都不会意外改写其它进程的数据,不会影响其它进程的运行,从而保证整个系统的稳定性。
    • 每个进程都认为自己独占整个虚拟地址空间,链接器和加载器比较容易实现,不必考虑各进程地址范围的冲突问题。
  3. VA 到 PA 的映射会给分配和释放内存带来方便,物理地址不连续的几块内存可以映射成虚拟地址连续的一块内存。
    • malloc 分配一块很大的内存空间、有足够多的空闲物理内存却没有足够大的连续空闲内存时,可以分配多个不连续的物理页面而映射到连续的虚拟地址范围。
  4. 一个系统如果同时运行着很多进程,为各进程分配的内存之和可能会大于实际可用的物理内存,虚拟内存管理使得这种情况下各进程仍然能够正常运行。
    • 各进程分配的只是虚拟内存的页面,这些页面的数据可以映射到物理页面,也可以临时保存到磁盘上而不占用物理页面。
    • 在磁盘上临时保存虚拟内存页面的可能是一个磁盘分区,也可能是一个磁盘文件,称为交换设备(Swap Device)。
      • 物理内存不够用时,将一些不常用的物理页面中的数据临时保存到交换设备,这些物理页面就可以重新分配给进程使用,这个过程称为换出(Page out);如果进程要用到被换出的页面,就从交换设备再加载回物理内存,这称为换入(Page in)。
      • 换出和换入操作统称为换页(Paging)。
      • 系统中可分配的内存总量 = 物理内存的大小 + 交换设备的大小

两个 /bin/bash 进程的 Text Segment 和 Data Segment 相同,这些地址在编译链接时被写进 /bin/bash 可执行文件。

  • Text Segment 是只读的,不会被改写,因此操作系统安排这两个进程的 Text Segment 共享相同的物理页面。
  • 这两个进程在同一个系统中同时运行,它们的 Data Segment 占用相同的 VA。
  • 每个进程都有自己的一套 VA 到 PA 的映射表,整个地址空间中的任何 VA 都在每个进程自己的映射表中查找相应的 PA,因此不可能访问到其它进程的地址。
  • 两个进程的共享库加载地址并不相同,共享库的加载地址是在运行时决定的,并不是写在 /bin/bash 可执行文件中。
    • 即便如此,也不影响两个进程共享相同物理页面中的共享库;只有只读的部分是共享的,可读可写的部分不共享。
    • 使用共享库可以大大节省内存。比如 libc,系统中几乎所有的进程都映射 libc 到自己的进程地址空间,libc 的只读部分在物理内存中只需要存在一份,就可以被所有进程共享,这就是“共享库”这个名称的由来。
    • 这也是共享库必须是位置无关代码的原因。比如 libc,不同的进程虽然共享 libc 所在的物理页面,但这些物理页面被映射到各进程的虚拟地址空间时却位于不同的地址,所以要求 libc 的代码不管加载到什么地址都能正确执行。

References