第二章:嵌入式GPU内存架构深度解析:从硬件到驱动
各位同学,欢迎来到第二讲。
上一章我们聊了显存泄漏的危害,以及为什么嵌入式设备上这个问题尤其致命。今天,咱们得把底层的「战场」看清楚——也就是嵌入式GPU的内存架构。
你想想看,如果连内存是怎么划分的、谁在管、怎么流动的都不清楚,那排查泄漏基本就是瞎猫碰死耗子。我个人习惯,遇到内存问题,第一件事不是看代码,而是先搞清楚硬件的内存拓扑。
2.1 物理内存(VRAM):GPU的专属仓库
先说最传统的概念:VRAM,也就是显存。
在PC或者独立显卡上,VRAM是焊在显卡板子上的专用内存颗粒。GPU访问它,走的是专用总线,带宽高、延迟低。但在嵌入式世界里,情况有点不一样。
核心区别:嵌入式GPU的VRAM,很多时候并不是独立的物理芯片,而是从系统DDR中划出来的一块「保留区域」。
我在做一款车机芯片驱动时遇到过这么个事:硬件手册上写着「GPU专用2GB VRAM」,我一开始以为真有2GB独立颗粒。结果一查地址映射,发现这2GB就是系统内存的高地址区域,只是通过MMU(内存管理单元)做了隔离。
说白了,嵌入式GPU的VRAM,更多是一种「逻辑上的专用内存」。硬件上它和CPU共用同一片DDR颗粒,但驱动层必须把它当成独立资源来管理。
VRAM的关键特征:
- 物理连续:GPU的DMA引擎通常要求物理地址连续,否则会触发页错误。
- 不可被CPU缓存:GPU和CPU共享数据时,必须使用非缓存(Non-cacheable)或写合并(Write-combine)属性。
- 生命周期长:纹理、帧缓冲、着色器代码等,通常在整个渲染会话期间常驻。
避坑指南:我曾经在调试一个视频播放器花屏问题时,发现是驱动把VRAM区域标记成了Cacheable。CPU写入了数据,但GPU读到的却是缓存中的旧数据。嗯,这种问题排查起来非常痛苦,因为它是间歇性出现的。
2.2 统一内存架构(UMA):CPU和GPU的共享客厅
接下来是UMA。这个概念在嵌入式领域越来越常见,尤其是手机SoC和部分低功耗GPU。
UMA,说白了就是CPU和GPU共用同一片物理内存。没有独立的VRAM,大家挤在一个客厅里干活。
UMA的优势很明显:
- 硬件成本低,少焊几颗内存颗粒。
- 数据不用来回拷贝,CPU写个缓冲区,GPU直接就能读。
- 内存利用率高,不会出现「GPU显存爆了,CPU那边还剩一大半」的尴尬。
但代价呢?
- 带宽竞争:GPU和CPU同时访问内存时,会抢总线。我见过一个项目,GPU跑满时,CPU的memcpy速度直接掉到原来的三分之一。
- 内存碎片化:UMA下,GPU分配大块连续内存时,很容易因为CPU那边的碎片而失败。
我的经验:在UMA架构下调试显存泄漏,比独立VRAM要难得多。因为泄漏的内存可能被CPU的malloc/free系统污染,你很难分清这块内存到底是GPU在用,还是CPU的某个进程忘了释放。
2.3 DMA缓冲区:数据搬运的传送带
DMA缓冲区,是GPU和外部世界交换数据的核心通道。
GPU要渲染一个模型,CPU得先把顶点数据、纹理数据准备好,然后通过DMA搬运到GPU能访问的地方。这个搬运过程,就是DMA缓冲区在起作用。
DMA缓冲区的典型生命周期:
- 分配:驱动从系统内存或VRAM中申请一块物理连续的内存。
- 映射:将这块内存映射到CPU的虚拟地址空间,方便CPU写入数据。
- 提交:CPU填好数据后,驱动把DMA描述符提交给GPU的DMA引擎。
- 同步:GPU处理完后,驱动通过Fence或中断通知CPU「数据用完了,可以回收了」。
- 释放:驱动回收这块缓冲区,归还给内存池。
你发现没有?这个流程里,任何一个环节出问题,都会导致内存泄漏。比如第4步的同步信号丢了,驱动以为GPU还在用,就一直不释放。我遇到过最离谱的一次,是DMA描述符写错了地址,GPU根本没读到数据,但驱动傻傻地等了一个超时周期才回收——那段时间里,缓冲区就这么白白占着。
// 一个简化的DMA缓冲区分配示例(伪代码)
struct dma_buf *gpu_alloc_dma(size_t size) {
struct dma_buf *buf = kmalloc(sizeof(*buf), GFP_KERNEL);
buf->pages = alloc_pages(GFP_DMA32, get_order(size));
// 注意:这里必须保证物理连续
buf->dma_addr = dma_map_page(dev, buf->pages, 0, size, DMA_TO_DEVICE);
return buf;
}
注意:上面的代码里,如果dma_map_page失败,但alloc_pages已经成功了,那就泄漏了。我曾经在代码审查时发现过这种低级错误——分配了内存,映射失败后直接返回NULL,但没人去释放那几页内存。
2.4 帧缓冲区:最后的输出窗口
帧缓冲区,就是GPU渲染完成后,存放最终图像的地方。它最终会被显示控制器扫描出去,变成你看到的画面。
帧缓冲区的特殊之处:
- 必须物理连续:显示控制器通常没有MMU,它要求帧缓冲区的物理地址是连续的。
- 大小固定:分辨率一旦设定,帧缓冲区的大小就固定了。比如1080p@32bpp,就是1920x1080x4 = 约8MB。
- 双缓冲/三缓冲:为了避免画面撕裂,驱动通常会维护2~3个帧缓冲区,轮流使用。
帧缓冲区的泄漏,通常不是「忘记释放」,而是「忘记归还」。什么意思呢?
比如你切换分辨率时,驱动会分配新的帧缓冲区,但旧的缓冲区可能因为引用计数没清干净,一直挂在链表上。我调试过一个机顶盒项目,每次切换分辨率,系统可用内存就少8MB。切了十几次,系统直接OOM(内存耗尽)了。
排查技巧:如果你怀疑帧缓冲区泄漏,可以查看/proc/iomem或者驱动暴露的debugfs接口。看看「framebuffer」区域是不是越积越多。
2.5 驱动层如何管理这些内存?
好了,硬件概念讲完了。接下来是重头戏——驱动层怎么管这些内存?
嵌入式GPU驱动,说白了就是一个「内存管家」。它要干这几件事:
- 内存池化:预分配一大块内存,然后切成小块,按需分配。避免频繁调用内核的
alloc_pages,那玩意儿慢,还容易碎片化。 - 地址映射管理:维护GPU虚拟地址空间(如果有的话),把物理内存映射到GPU能访问的地址。
- 引用计数:每块内存被谁在用?用完了没有?驱动得记清楚。这是防泄漏的第一道防线。
- 内存回收:当系统内存紧张时,驱动得能释放掉一些不常用的GPU资源(比如缓存着的纹理)。
我见过的一个典型驱动内存管理结构:
struct gpu_mem_manager {
struct list_head allocated_bufs; // 已分配的缓冲区链表
struct list_head free_bufs; // 空闲缓冲区链表
spinlock_t lock; // 保护链表的自旋锁
atomic_t total_allocated; // 当前已分配的总字节数
size_t pool_size; // 内存池总大小
};
这个结构看着简单,但实际用起来坑很多。比如free_bufs链表,如果驱动在中断上下文释放缓冲区,而中断上下文不能睡眠,那你就不能用mutex保护链表,只能用自旋锁。自旋锁用不好,死锁就来了。
我的建议:写驱动内存管理时,一定要把「分配路径」和「释放路径」画成流程图。尤其是释放路径,可能从多个地方触发(用户态close、GPU完成中断、进程退出)。画清楚了,再动手写代码。
2.6 本章小结
这一章我们走了一遍嵌入式GPU的内存架构:
- VRAM是GPU的专属仓库,但嵌入式里往往是逻辑隔离。
- UMA是共享客厅,省成本但带宽竞争激烈。
- DMA缓冲区是数据传送带,生命周期管理是防泄漏的关键。
- 帧缓冲区是输出窗口,切换分辨率时最容易出问题。
- 驱动层就是管家,池化、映射、引用计数、回收,一个都不能少。
下一章,我们会深入驱动源码,看看实际的内存分配和释放函数是怎么写的。到时候我会拿一个真实的开源驱动代码来拆解,保证让你看完就能上手。
好了,今天就到这儿。有问题欢迎在评论区留言,或者直接找我私聊。
公众号:蓝海资料掘金营,微信deep3321