DMA(Direct Memory Access)直接内存访问
DMADirect Memory Access直接内存访问DMADirect Memory Access是一种允许硬件设备如网卡、磁盘控制器、GPU绕过 CPU直接与系统内存进行数据传输的技术。为什么需要 DMA如果没有 DMA当网卡收到一个数据包时流程是网卡告诉 CPU“我有数据来了”中断。CPU 暂停当前工作亲自执行一段程序从网卡寄存器里一个字节一个字节地把数据读到 CPU 内部再一个字节一个字节地写回内存。这个过程叫做PIOProgrammed I/O程序控制输入输出。缺点CPU 被完全占用来搬运数据无法处理其他任务。对于 1Gbps 的网络CPU 可能要花 80% 的时间来做这种“搬运工”非常低效。DMA 做了什么有了 DMA流程变为CPU 事先告诉 DMA 控制器或者支持 DMA 的设备自身“当数据来了请直接把它放到内存的地址 X 开始的地方”。网卡收到数据后通过 DMA 引擎直接把数据写入内存整个过程不需要 CPU 参与。写完后网卡才发一个中断告诉 CPU“数据已经放在内存里了你可以来拿了”。CPU 只在开始配置 DMA 和结束后处理数据时介入中间的搬运工作完全由硬件完成。工作流程分两种主流实现方式传统的专用 DMA 控制器如老式 PC 的 8237和现代总线主控 DMA如 PCIe 设备自带的 DMA 引擎一、传统 DMA 控制器方式以 8237 为例这种架构有一个独立的 DMA 控制器CPU 编程它它负责在设备和内存之间搬运数据。1.CPU 初始化 DMA 控制器CPU 将源地址设备的数据寄存器 地址例如网卡的接收 FIFO 端口、目标地址内存缓冲区地址、传输长度字节数写入 DMA 控制器的寄存器。设置传输方向从设备 → 内存和模式单次/块/循环。向 DMA 控制器发送“启动”命令。**CPU 初始化 DMA 控制器这一步发生在什么时候发生在 DMA 传输开始之前具体可以细分为两种常见时机“CPU 初始化 DMA 控制器”这句话默认指第二次每次传输前的参数设置因为第一次通常叫“DMA 初始化”或“系统配置”。但明确区分有助于深入理解 DMA 的工作机制。1.1. 系统启动/驱动加载时的“一次性初始化”发生时机操作系统启动、驱动加载时一次主要操作分配 DMA 缓冲区、建立描述符环、设置控制器基本模式如总线宽度、中断使能配置内容长期有效的控制参数基地址、环形队列大小、中断掩码等目的让 DMA 控制器准备好知道去哪里找缓冲区和描述符执行频率极低一次1.2. 每次具体传输前的“传输参数配置”发生时机每次 DMA 传输开始之前多次主要操作设置源地址、目标地址、数据长度、传输方向写启动位配置内容本次传输的临时参数地址、长度、方向目的告诉 DMA 控制器“现在要传什么数据”执行频率高每次数据块传输一次2.DMA 控制器请求总线DMA 控制器在 CPU 完成参数配置后不会立刻请求总线它必须等待 CPU 明确写入“启动”位或者检测到外设的 DREQ 信号才会向 CPU 的总线仲裁 单元发出 HOLD总线保持请求 信号要求占用系统总线。内存到内存软件触发CPU 写入启动位后DMA 控制器立即发出 HOLD。外设到内存硬件触发CPU 配置好 DMA 控制器并使能外设的 DREQ 后DMA 控制器等待外设发出 DREQ收到后才发出 HOLD。DMA 控制器发出 HOLD 信号后处在什么状态DMA 控制器进入“等待总线应答”状态。它持续将HOLD 信号保持为有效电平通常是高电平直到收到 CPU 返回的HLDA信号。在此期间DMA 控制器不会开始数据传输也不会去驱动地址/数据总线因为总线还在 CPU 手里。它只是“耐心等待”同时可能做内部准备比如预置地址计数器但核心是阻塞在等待 HLDA 的状态。比喻你打电话给房东CPU说“我要搬东西”HOLD然后你拿着手机等着对方回答“好你搬吧”HLDA。在等回答的这段时间里你不会动手搬东西。3.CPU 释放总线CPU 在当前指令周期结束后不会打断正在执行的一半指令检测到 HOLD 信号有效则立即发出 HLDA总线保持应答。发出 HLDA 的同时CPU 将其地址总线、数据总线以及相关的控制总线如 RD、WR、IOR、IOW 等引脚设置为高阻态三态输出物理上放弃对总线的驱动权将总线控制权交给请求方如 DMA 控制器。在释放总线后CPU 无法再访问内存或 I/O 端口因为地址/数据总线已断开但仍可继续执行内部不需要访问总线的操作例如寄存器间的算术运算、逻辑运算、循环等待等前提是指令已预先取入内部缓存或指令队列。一旦 DMA 控制器完成传输并撤销 HOLD 信号CPU 收回总线控制权恢复正常的内存访问。HLDA 的发出时机DMA 控制器发出HOLD信号。CPU 在每个总线周期结束的时刻采样 HOLD 信号。若为有效则立刻将地址总线、数据总线、控制总线的引脚置为高阻态三态输出物理上放弃总线驱动。向 DMA 控制器发出HLDA应答信号。不会打断正在执行的一半指令如果一条指令需要 5 个时钟周期CPU 会等这 5 个周期结束后才在下一个周期的开始处响应 HOLD。采样 HOLD 信号采样的是 HOLD 信号的当前电平瞬时值HOLD 是电平敏感信号不是边沿触发的事件DMA 控制器想要占用总线时会把 HOLD 信号线拉高或低并一直保持直到获得 HLDA 后才撤销。也就是说只要 DMA 控制器还在等待HOLD 信号始终是有效电平。CPU 在特定采样点“看一眼”这个电平CPU 在每个指令周期结束的时刻或者更精确地说在每个总线周期结束的某个时钟沿会去读取 HOLD 引脚上的电平。如果读到高电平有效说明有 DMA 请求正在等待CPU 就在那个时刻响应发出 HLDA。如果读到低电平说明没有请求CPU 继续执行下一条指令。多个 HOLD 信号由总线仲裁器进行优先级排队每次只向 CPU 提供一个有效的 HOLD。CPU 响应后该 DMA 设备完成传输并释放总线仲裁器再提供下一个 HOLD直到所有请求都处理完毕。CPU 为什么还能“继续执行内部运算”CPU 内部有很多运算、控制逻辑ALU、寄存器、缓存等。只要不访问内存包括取指令、读写数据它就可以继续执行指令。但因为 CPU 已经释放了总线它无法从内存中取指令也无法读写内存数据。那它怎么继续运算如果 CPU 内部有指令缓存Cache或指令预取队列如 8086 的 6 字节预取队列CPU 可以先执行已经提前取进来的指令。这些内部指令可以是寄存器到寄存器的运算如ADD AX, BX、逻辑运算、循环等待等它们不需要访问内存。一旦缓存的指令用完或者遇到需要读取内存的指令CPU 就会暂停等待总线重新归自己。所以“可以继续执行内部运算”是有条件的只能执行那些不涉及内存访问的指令。实际中DMA 传输期间 CPU 通常会执行一段短循环或直接进入空闲状态等待中断。4.DMA 控制器传输数据此时 DMA 控制器已经获得了总线控制权CPU 已发出 HLDA。DMA 控制器内部已经配置好了源地址寄存器存放数据要从哪里读例如内存地址或外设端口地址。目标地址寄存器存放数据要写到哪里。计数器还需要传输多少字节或字。控制信息传输方向内存→外设外设→内存或内存→内存。DMA 控制器驱动总线从源地址读取一个字节或一个字然后写入目标地址。地址寄存器和计数器自动减 1或增 1(8237 支持地址递增或递减计数器递减)。重复直到计数器归零。单次传输的微观操作简化模型DMA 控制器一个字节或一个字一个字节地搬运数据。每搬运一个字节它执行以下子步骤驱动总线放置地址DMA 控制器将源地址的值送到地址总线上。同时发出读控制信号如MEMR或IOR取决于源是内存还是外设。数据从源端出现在数据总线上内存或外设响应读信号将数据放到数据总线 上。DMA 控制器接管数据它内部暂时保存这个数据一个字节或一个字。驱动总线放置目标地址DMA 控制器将目标地址的值送到地址总线。发出写控制信号MEMW或IOW。数据写入目标端DMA 控制器将刚才保存的数据放到数据总线上目标设备内存或外设将其写入。更新内部寄存器源地址寄存器增加或减少通常增加方向由设定决定。目标地址寄存器增加或减少。计数器减 1。5.传输完成释放总线DMA 控制器撤销 HOLD 信号CPU 收回总线控制权。DMA 控制器向 CPU 发送一个中断 **或等待 CPU 轮询状态寄存器通知传输结束。DMA 撤销 HOLD 后CPU 收回总线控制权这仅仅解决了“总线谁用”的问题。但 CPU 并不知道“这次传输已经完成”这个事件。中断的作用是告诉 CPU “数据已经准备好了你可以来处理了”。流程顺序DMA 计数器归零 → 传输完成。DMA 控制器立即撤销 HOLD释放总线让 CPU 恢复运行。DMA 控制器同时或随后发送中断或设置状态标志。CPU 在下一个指令周期会检查到中断或在轮询中读到标志然后调用处理函数。如何释放DMA 控制器内部计数器归零后会主动撤销 HOLD 信号将 HOLD 引脚从高电平拉低。CPU 检测到 HOLD 信号无效后停止输出 HLDA并重新驱动地址/数据/控制总线从高阻态恢复为正常输出收回总线控制权。此后 CPU 可以继续正常访问内存和 I/O。为什么需要通知CPU 之前只是启动了 DMA然后就去干别的事了。它不知道 DMA 什么时候完成。传输结束后CPU 需要知道“数据已经在内存里了”以便去处理这些数据例如从磁盘读入的数据需要交给应用程序6.CPU 处理CPU 响应中断检查传输是否成功然后开始使用内存中的数据。CPU 响应 DMA 中断后先检查传输状态是否成功然后才能安全地访问内存中已经由 DMA 填好的数据进行后续处理。1. CPU 响应中断当 DMA 控制器完成数据传输后会向 CPU 发送一个硬件中断信号。CPU 在执行完当前指令后检测到有中断请求会保存当前程序的上下文寄存器、程序计数器等。**根据中断向量表 跳转到对应的ISR中断服务程序 这个 ISR 是设备驱动程序事先注册好的。2. 检查传输是否成功在 ISR 中CPU 会读取 DMA 控制器的状态寄存器。状态寄存器中通常包含完成标志传输是否正常结束。错误标志例如总线错误、地址越界、数据校验失败等。如果发现错误驱动程序可能进行错误处理如重试、记录日志如果成功则继续下一步。3. 开始使用内存中的数据确认传输成功后CPU 就可以访问 DMA 缓冲区中的数据了。具体使用方式取决于场景磁盘读将数据从 DMA 缓冲区拷贝到用户进程的地址空间如read()系统调用完成。网卡收包将数据封装成内核的sk_buff交给协议栈处理。音频播放将数据送到音频设备缓冲区开始播放。处理完数据后ISR 通常会清除中断标志并通知之前可能等待该数据的进程如唤醒read()调用。关键点中断处理必须快ISR 中一般只做必要的检查、数据指针传递和唤醒动作费时的数据拷贝往往推迟到下半部tasklet、工作队列或用户态。“开始使用”不等于立即完成这句话概括的是整体逻辑实际实现中 ISR 可能只是标记缓冲区就绪真正使用数据的是后续的软中断或用户进程。缺点需要专用 DMA 控制器芯片且每次传输前 CPU 要编程适合大块数据但不够灵活。二、现代总线主控 DMAPCIe 设备常用设备自身集成了 DMA 引擎可以直接发起内存读写不需要独立的 DMA 控制器。网卡、NVMe SSD、GPU 都采用这种方式。详细步骤以网卡接收数据为例阶段 1驱动初始化配置 DMA1.分配内存驱动程序在主机物理内存中分配 一个或多个DMA缓冲区 一个描述符数组环形描述符数组和缓冲区都在主机物理内存中由驱动分配。2.将描述符基地址 写入网卡寄存器驱动把描述符数组的总线物理基地址 和大小写入网卡内部寄存器。这样网卡就知道去哪里找描述符。3.设置 DMA 控制寄存器驱动启用网卡的 DMA引擎 配置传输参数例如最大数据突发长度、中断合并条件。网卡内部有一个DMA 引擎负责发起 PCIe 传输。驱动需要“启动”它并告诉它一些工作参数最大数据突发长度一次 DMA 最多可以搬多少字节例如 512 字节避免长时间占用总线。中断合并条件网卡收多少个包才触发一次中断例如每 32 个包或者经过一定时间如 100 微秒。这可以减少中断数量提高性能。配置好后DMA 引擎就进入待命状态只要收到数据包就会按照前面设定的描述符环自动工作。阶段 2网卡自动 DMA 写入无 CPU 参与4.网卡收到一帧数据网卡内部 MAC 层完成 CRC 校验、地址过滤得到一个完整的帧已剥离前导码/SFD。5.网卡从描述符环中获取下一个空闲缓冲区网卡内部 DMA 控制器读取当前指针指向的描述符获得缓冲区的总线物理地址和长度。同时检查所有权位应为 1硬件拥有否则表示没有可用缓冲区丢弃该帧。6.DMA 写入内存网卡通过PCIe总线发起 MemoryWrite内存写事务将帧数据直接写入该总线物理地址。该过程完全由网卡硬件控制CPU 不参与。7.更新描述符写完后网卡修改描述符中的所有权位为 0表示缓冲区已被硬件填充等待软件处理。可选设置其他状态位如帧长度、错误标志。网卡移动描述符指针到下一个位置循环。阶段 3通知 CPU8.触发中断或使用轮询根据中断合并 配置每 N 个帧或经过一定时间发一次中断网卡通过 MSI-X 向 CPU 发送中断信号。对于高吞吐场景驱动可能暂时禁用中断改用 NAPI 轮询。阶段 4CPU 处理数据并回收缓冲区9.CPU 响应中断 / 进入轮询驱动在中断处理函数或 NAPI poll方法 中检查描述符环中哪些槽位的所有权位 已变为 0。驱动程序通过检查环形描述符队列中每个描述符的“所有权位”来判断网卡是否已经将数据 DMA 写入对应的缓冲区。驱动通过检查描述符的“所有权位”是否变为 0来确定哪些 DMA 缓冲区已经被网卡填充了新数据从而高效地完成收包处理。10.提取数据驱动通过描述符索引从初始化时维护的虚拟地址数组中取出对应的内核虚拟地址从而访问 DMA缓冲区 中的数据构建 sk_buff 并交给协议栈。“交给协议栈” 驱动调用netif_receive_skb(skb)将sk_buff作为参数传递给内核网络协议栈的入口由协议栈继续完成 IP、TCP 等层层处理。怎么获取的内核虚拟地址- 驱动初始化- 调用 dma_alloc_coherent() 或 dma_map_single() 分配 DMA 缓冲区。- 该函数返回内核虚拟地址供 CPU 访问和总线物理地址供硬件 DMA 使用。- 驱动将总线物理地址填入描述符供网卡使用。- 同时驱动把内核虚拟地址保存在自己的私有数据结构中例如一个 void * 数组索引与描述符索引对应。- 收包时- 网卡根据描述符中的物理地址完成 DMA 写入。- 驱动在 NAPI 轮询中通过描述符索引或描述符中可能附带的其他信息找到事先保存的内核虚拟地址。- 然后驱动使用该虚拟地址访问 DMA缓冲区中的数据构建 sk_buff。为什么需要“构建 sk_buff”- sk_buff 是 Linux 内核网络协议栈的标准数据包容器。- 协议栈的所有函数IP 层、TCP 层、socket 层都只认识 sk_buff不认识原始的 DMA 缓冲区。- 驱动必须把 DMA 缓冲区的信息数据指针、长度、设备、协议类型等封装成 sk_buff才能递给 netif_receive_skb() 等函数。- 构建 sk_buff 的过程非常轻量只分配一个结构体几百字节并把 data 指针指向已有的 DMA 缓冲区数据没有拷贝数据。所以开销很小。11.回收缓冲区驱动清空描述符的状态位如错误标志并将所有权位重新设置为 1表示缓冲区已空闲、网卡可再次使用。如果使用了固定的 DMA 缓冲区物理地址不变无需重新填写。如果使用动态分配则更新描述符中的物理地址。12.重新启用中断若之前禁用只有所有待处理帧处理完毕work_done budget时才重新使能中断达到轮询预算时不会开中断。这是 NAPI 的标准行为由napi_complete()完成在 NAPI 的poll函数中当实际处理的帧数小于预算即所有待处理帧已处理完毕时驱动会调用 napi_complete() 重新使能网卡中断恢复正常中断模式如果实际处理的帧数等于预算可能还有未处理帧则保持中断禁用等待下一次软中断继续轮询。