在讨论计算机时,我们实际上在使用的是输入输出设备.对于初次接触计算机的人来说,他们首先会注意到的是显示器上的图像和图形,并通过鼠标和键盘向计算机发出指令.对于终端用户而言,这些输入输出设备构成了他们与计算机交互的直接接口.
然而,当我们深入学习计算机科学,例如计算组成原理或计算机系统基础时,我们会了解到CPU的核心作用, 它就是一个不断执行指令的机器, 取指令、译码和执行.这种认识揭示了一个本质的差异:我们日常使用的设备并不是计算机内部用于计算的核心部件.
计算机要为我们提供服务,就必须配备相应的输入输出设备,这样才能让人们真正地使用它.例如,我们需要打印机来将数据输出到纸张上,需要键盘来输入指令,以及需要显示器来查看信息.
对于任何一个CPU来说,要实现与外部设备的交互就需要精心设计的输入输出设备.这些设备本质上是类似的,它们使得CPU能够与外部世界进行交互.我们可以将输入输出设备视为计算机的"眼睛和耳朵",它们让计算机能够感知外部状态,并对外实施动作.
例如,显示器上每个像素的亮暗变化,都是对外部物理世界的一种影响.同样,键盘上的每一次按键和鼠标的每一次移动,都让计算机能够感知到物理世界中的变化.因此,CPU与外设之间的交互最核心的功能就是数据的交换.这种数据交换是实现计算机功能和用户交互的基础,是计算机系统不可或缺的一部分.
在讨论计算机系统中的CPU与输入输出(I/O)设备的关系时,我们可以从CPU的角度来理解这些设备的工作原理.对于CPU而言,所有的I/O设备在本质上可以被视为一根线,这根线上承载着数据,从设备端连接到CPU的引脚, 并且以一种与CPU预先约定好的方式进行数据交换.这种交换方式定义了输入(Input)和输出(Output)的概念:输入是将数据送入CPU进行处理,而输出则是将CPU处理后的数据发送到外部设备或控制器.
下图为 intel CPU 的引脚对照图
因此从 CPU 的视角来看, IO设备就是一个能和CPU交换数据的接口/控制器, 通过几组约定好功能的线和 CPU 相连, 通过握手信号从线上读出/写入数据. 每一组线有自己的地址, CPU可以直接使用指令和设备交换数据, 设备会被抽象为一组 (状态, 命令, 数据) 的接口, CPU 完全不管设备是如何实现的
状态寄存器(Status)、命令寄存器(Command)和数据寄存器(Data).状态寄存器用于反映设备的状态,例如磁盘是否正在写数据、设备是否忙碌等.命令寄存器允许我们对设备执行操作,比如发送指令让设备执行特定任务.数据寄存器则用于在CPU和设备之间传输数据.
所以计算机系统中的设备并没有神秘之处,它们的本质是一组寄存器的集合,这些寄存器代表了设备的状态、控制命令和数据传输等功能. CPU 可以向访问内存一样通过一个地址来访问外部设备的寄存器, 这些地址是由硬件厂商和操作系统通过协调统一的.
下图为 PS/2 键盘控制器的接口, 它被硬编码到两个 I/O port: 0x60 (data), 0x64 (status/command), 只要引出线与 CPU 对应的引脚相连, CPU 就可以通过这两个端口向键盘读写数据
但是, 在实际的计算机系统中,我们并不会为每一个I/O设备单独拉出一根线连接到CPU, 随着技术的发展和新设备的出现,如新型打印机、游戏控制器等各种形态的外设层出不穷. 如果为每个设备都单独连接一根线,CPU的结构将变得极其复杂. 需要一种更加灵活和可扩展的方式来管理I/O设备. 也就是总线(bus)
总线是一种特殊的I/O设备,它负责统一管理和协调所有连接到它的其他I/O设备.CPU不再需要直接与每个设备通信,而是通过总线来访问和控制这些设备, 包括设备的注册和地址到设备的转发
当多个设备需要与CPU通信时,总线控制器会根据中断优先级来协调,确保CPU能够及时响应最重要的事件.此外,总线控制器还能够识别发生中断的设备,进一步增强了系统的灵活性和响应能力.
今天 PCIe 总线肩负了这个任务. 关于总线的更多讨论见 bus, 这里不再赘述
CPU要想控制所链接的设备,不可避免需要通过IO(input/output)与外设打交道,CPU通过IO操纵设备上的寄存器等来实现对设备的控制. 那么 CPU 是如何与设备寄存器和设备数据缓冲区进行通信呢?
存在两个可选的方式:
例如 x86 的 in
out
指令, 读取控制寄存器 PORT 的内容并将结果放在 CPU 寄存器 REG 中或者将 REG 的内容写到控制寄存器中
IN REG,PORT
OUT PORT,REG
大多数早期计算机,包括几乎所有大型主机,如 IBM 360 及其所有后续机型,都是以这种方式工作的.
在这种方案中, IO的空间与CPU空间相互独立,互不干扰. 该IO端口有独立的空间. 例如下面的 IO 端口 4 是一个硬编码的端口号, 与地址无关.
IN R0,4
MOV R0,4
前文我们提到总线可以完成设备的注册和地址的转发. 在大多数系统中,分配给控制寄存器的地址位于或者靠近地址的顶部附近. 例如下图是笔者查看 /proc/iomem
的结果, 该文件用户记录物理地址映射范围.
可以看到在系统内存 System RAM 的 16GB 之外, 后面这几个 e010a6b0
711dad3a
559c9870
就是 IO 设备映射的物理地址范围.
当 CPU 想要读入一个字的时候,无论是从内存中读入还是从 I/O 端口读入,它都要将需要的地址放到总线地址线上,然后在总线的一条控制线上调用一个 READ
信号.还有第二条信号线来表明需要的是 I/O 空间还是内存空间.
早期的PC中, 所有的IO设备(除了存储设备之外的设备)的内部存储或者寄存器都只能通过IO地址空间进行访问(方案一).但是这种方式局限性很大,显而易见的问题就是 端口冲突 且 数量不足. 硬编码的端口号在硬件设备确定的情况还勉强可以接受, 但是今天显然限制了可以访问的设备数量和每个设备可以访问的端口数量. 随着系统复杂性的增加,传统的I/O地址空间可能难以扩展以支持更多的设备和更复杂的硬件配置
因此管理访问 IO 设备普遍采用的是方案二 MMIO,即Memory Mapped IO,也就是说把这些IO设备中的内部存储和寄存器都映射到统一的存储地址空间(Memory Address Space)中.
为了兼容一些之前开发的软件,PCIe仍然支持IO地址空间,只是建议在新开发的软件中采用MMIO. PCIe Spec中明确指出,IO地址空间只是为了兼容早期的PCI设备(Legacy Device),在新设计中都应当使用MMIO,因为IO地址空间可能会被新版本的PCI Spec所抛弃.
关于 MMIO 的更多内容见 mmio
关于 PCIe 的更多内容见 pcie
中断控制器是计算机系统中一项特殊而重要的设计,它为操作系统的稳定运行和高效管理提供了基础.在计算机系统中,应用程序不断执行指令,但并不会因此导致系统失去响应,这得益于中断机制的存在.
一个没有中断的计算机体系是决定论的: 得知某个时刻CPU和内存的全部数据状态,就可以推衍出未来的全部过程.这样的计算机无法交互,只是个执行指令的工具.
添加中断后,计算机指定了会兼容哪些外部命令,并设定服务程序,这种服务可能打断当前任务.这使得CPU"正在执行的程序"与"随时可能发生的服务",二者形成了异步关系,外界输入的引入使得计算机程序不再是决定论.由人实时控制的中断输入,是无法预测的.再将中断响应规则化,推广开,非计算机科学人群就能控制计算机,发挥创造力.电竞鼠标微操,数码板绘,音频输入合成,影像后期数值调整,键盘点评天下大势,这些都不是定势流程,是需要人实时创造参与其中的事件,就由中断作为载体,与计算机结合了起来.中断就是处理器的标准输入接口.
因此中断的本质是处理器对外开放的实时受控接口,例如IRQ线,这是一个边缘触发且低电平有效的信号线.当这条线被激活时,CPU必须响应这个特殊的事件,就像核弹发射按钮被按下时,系统必须立即采取行动一样.在这个过程中,中断控制器扮演了一个关键角色.
而负责处理中断的中断控制器内部包含一系列的控制寄存器, 连接到 CPU 的中断引脚. 中断控制器中保存 5 个寄存器 (cs, rip, rflags, ss, rsp), 当设备发送中断请求的时候由中断控制器向 CPU 发送中断信号, CPU 跳转到中断向量表对应项执行.
在我们的计算机系统中,许多外围设备,如键盘、网卡和鼠标等,都有能力产生中断.这些中断实际上是通过各自的信号线来实现的,通常这些线在接收到低电平信号时被激活.当这些设备中的任何一个需要CPU的注意时,它就会通过这根线发送中断请求.所有的这些中断请求线最终都会连接到一个中断控制器上.这个中断控制器是一个可编程的设备(PIC, programmable interrupt controller),它的主要任务是对这些中断请求进行裁决.例如,如果同时有两个或多个中断请求发生,中断控制器会根据每个中断的优先级来决定哪个请求应该首先被处理.这个优先级设置确保了更重要的任务能够优先得到处理.
在实时系统中,这种优先级处理尤为重要,因为系统需要确保关键任务能够及时得到响应.中断控制器还具备中断屏蔽的功能,允许CPU在处理某些紧急任务时暂时忽略那些优先级较低的中断,从而保持系统的稳定性和效率.
例如 Intel 8259 PIC 中断控制器就可以编程控制哪些中断可以屏蔽, 多个中断请求的优先级等等
现如今 8259 PIC 已经远远不能满足日益复杂的 CPU 了(比如说多处理器多核), 诞生了 APIC(Advanced PIC)
APIC是一种高级可编程中断控制器,它提供了比传统中断控制器更为强大和灵活的中断处理能力.APIC通常集成在现代芯片组或北桥中,它支持多处理器系统中的中断管理和协调.APIC的主要特点包括:
例如对于多个 CPU 核心, 每一个核心运行一个线程. 此时如果使用 mmap 分配了一块内存, 此时这块内存地址空间在两个 CPU 上都是可用的. 当某一个 CPU 核心上的线程执行了 munmap 之后, 此时操作系统会让该 CPU 核心采用核间中断(IPI, Inter-Processor Interrupt)的方式向其他执行线程的发送一个 tlb shootdown(详细讨论见 mm/tlb), 之后他们的页表会被同步, 其他CPU上的内存也会被消除映射
LAPIC(local APIC)是APIC的一种,它是集成在每个CPU内部的中断控制器.LAPIC为每个CPU提供了专用的中断处理能力,使得每个处理器都能够独立地处理中断,而不需要主控制器的介入.LAPIC的主要特点包括:
APIC和LAPIC在现代计算机系统中提供了高效、灵活的中断处理机制,它们使得多处理器系统能够更好地管理和响应各种中断请求,从而提高了系统的整体性能和稳定性.通过这些先进的中断控制器,操作系统能够实现更精细的中断管理,优化资源分配,提升用户体验.
关于中断的更多讨论见 interrupt, 这里不详细展开
在计算机系统中,当需要传输大量数据时,如写入1GB的数据到磁盘,CPU可能会花费大量时间来处理这个任务.这是因为,即使磁盘已经准备好接收数据,CPU仍需执行一个庞大的循环,逐字节地将数据从内存复制到磁盘上.
假设此时 CPU 想要从磁盘中读出 1GB 的数据, 整个流程如下所示
我们发现最大的问题在于从磁盘读出数据的需要经过 CPU 才能被写入内存, 这个过程不仅耗时,而且会占用CPU的大量资源,使其无法同时处理其他任务.
从内存写入数据的过程类似, 也需要经过 CPU 完成数据的写入指令
为了解决这个问题,我们可以设想一种专门的硬件设备,专门负责处理内存之间的数据传输,从而释放CPU的资源.这种设备被称为DMA(Direct Memory Access)控制器.DMA控制器可以被设计得相对简单,因为它只负责执行内存复制操作, 一共四种情况: 外设到内存;内存到外设;内存到内存;外设到外设. 它不需要像通用CPU那样具备复杂的指令集,而是可以通过硬编码的方式,将内存复制的逻辑直接嵌入到电路中.
DMA控制器的工作原理是使用一个计数器和两个地址指针.在每个时钟周期,它会从源地址读取数据,写入目标地址,然后更新计数器和地址指针.这样的固定逻辑使得DMA控制器非常高效,特别适合处理大量数据传输的任务.
大部分的设备都是 Slave 设备, 也就是只能接受来自其他处理器的信号做出反应. DMA 控制器是一个 Master 设备, 它可以主动发出指令执行操作
通过DMA,驱动程序可以事先(或在需要的时候)设定一个内存地址,设备就可以绕开CPU直接向内存中复制(或读取)数据.根据发起者不同,DMA可以被分为两种:
DMA的引入,优点是数据在内存和设备之间的搬运不需要CPU参与,这极大降低了CPU的负荷.但是也引入了新的问题,即cpu读取到的数据不一定是最新的,是因为中间cache的存在导致的一致性问题, 如下图所示
由于 DMA 只负责 device 和 memory 之间的数据迁移, 但是 cpu 读取内存数据时会先判断对应虚拟地址是否被cache命中,如果命中将直接读取cache中的数据内容不会经过内存,但是了cache中的数据并不是最新内存的数据,最新内存的数据已经被 DMA 修改了.
DMA 需要在内存中申请一段内存当做buffer,这段内存用作需要使用DMA读取I/O设备的缓存,或者写入I/O设备的数据.为了避免cache的影响,我们可以将这段内存映射 nocache
, 即不使用cache. 但是显而易见, 映射的这段内存将不会再享受到 cache 带来的好处, 如果数据量很小还可以接受, 但是如果是频繁访问的数据那么就会导致性能损失
nocache 对应 虚拟地址转换 中页表格式中的 PCD, 在 cache 中的 VIPT 解决方案中也有提到
Linux 的
dma_alloc_coherent()
接口的实现方法即采用的是此方案
为了充分使用cache带来的好处.我们映射依然采用cache的方式.但是我们需要格外小心, DMA通过总线获取数据时应该先检查cache是否命中,如果命中那么应当从 cache 而非内存中获取数据. 为了解决上述DMA与Cache不一致的问题,引入了两种dma机制来处理:一致性DMA(Consistent mapping) 和 流式DMA(Stream mapping)
显而易见的就是降低效率(相当于没有cache了),以至后来随着SOC的发展,SOC可以用硬件来做到CPU和外设的cache一致,简单来说就是DMA在处理设备和物理内存的搬运的过程中,硬件会同时把cache也更新了.一致性DMA通常在驱动初始化的时候进行mapping一块内存,在驱动卸载时再进行unmapping掉
这种方式适用于频繁的、小数据量传输.地址在整个生命周期内保持有效.
cache 的刷新需要根据方向来判断需要 clean cache 还是 invaild cache
注意,在DMA传输没有完成期间CPU不要访问DMA Buffer.例如以上的第一种情况中,如果DMA传输期间CPU访问DMA Buffer,当DMA传输完成时.CPU读取的DMA Buffer由于cache hit导致取法获取最终的数据.同样,第二情况下,在DMA传输期间,如果CPU试图修改DMA Buffer,如果cache采用的是写回机制,那么最终写到I/O设备的数据依然是之前的旧数据.所以,这种使用方法编程开发人员应该格外小心.
这也是Linux系统中流式DMA映射 dma_map_single()
接口的实现方法.
这种方式适用于一次性的大数据传输.使用后数据地址可以被释放
假设我们有2个全局变量 temp
和 buffer
, buffer用作DMA缓存. temp和buffer变量毫不相关.可能buffer是当前DMA操作进程使用的变量,temp是另外一个无关进程使用的全局变量.
int temp = 5;
char buffer[64] = { 0 };
假设,cacheline大小是64字节.那么temp变量和buffer位于同一个cacheline,buffer横跨两个cacheline.
假设现在想要启动DMA从外设读取数据到buffer中.我们进行如下操作:
在第4步中,就出现了问题.由于写回导致DMA传输的部分数据(buff[3]-buffer[49])被改写(改写成了没有DMA传输前的值).这不是我们想要的结果.因此,为了避免出现这种情况.我们应该保证DMA Buffer不会跟其他数据共享cacheline.所以我们要求DMA Buffer首地址必须cacheline对齐,并且buffer的大小也cacheline对齐.这样就不会跟其他数据共享cacheline.也就不会出现这样的问题.