从CMA到保留内存:Linux大块DMA内存分配的实战与抉择
1. 当CMA遇到极限:一个真实的嵌入式项目困境
最近在做一个嵌入式项目,需要驱动和FPGA进行高速数据交换。FPGA那边会产生海量的实时数据,直接往内存里写,我的驱动负责把这些数据读出来处理。听起来挺简单的,对吧?但坑马上就来了:FPGA通过DMA方式写数据,要求驱动提供一块物理地址连续且缓存一致性(Coherent)的大内存。有多大呢?初步估算需要超过600MB。
我当时的想法很直接:用内核标准的dma_alloc_coherent接口不就行了?这个API就是专门干这个的——分配一块设备可以直接访问的、物理连续的内存。于是,在基于ZC702(32位ARM)平台、1GB DDR、Linux内核3.15.0的环境下,我开始了踩坑之旅。
第一次尝试,申请200MB,直接失败。内核抱怨DMA区域空间不足。查了下,原来内核默认的CMA(Contiguous Memory Allocator,连续内存分配器)预留区域只有128MB。这好办,我心想,把CMA调大点就是了。于是修改内核配置CONFIG_CMA_SIZE_MBYTES=384,或者通过内核启动参数传递cma=384M,重启,问题解决。这让我觉得事情挺顺利。
然而,当我把需求提升到目标值——600MB时,真正的麻烦开始了。我把CONFIG_CMA_SIZE_MBYTES设为700,系统直接启动失败,报错cma: CMA: failed to reserve 700 MiB。内核连启动都完成不了,更别说跑应用了。那一刻我意识到,问题不再是简单地调整一个参数那么简单。我们遇到了一个典型的工程困境:当CMA机制无法满足超大连续内存需求时,我们该怎么办?
这不仅仅是分配失败,而是触及了Linux内存管理在特定硬件架构(尤其是32位系统)下的深层限制。项目卡在这里,FPGA的硬件设计已经定型,内存大小也无法增加,我必须在内核层面找到出路。经过一番折腾,我梳理出了两条主要的技术路径:一是深度优化CMA,挖掘出每一字节的连续空间;二是绕开CMA,采用保留内存(Reserved Memory)配合ioremap的“硬核”方案。这篇文章,我就来详细聊聊这两条路的实战过程、背后的原理、各自的优劣,以及最终我是如何做抉择的。如果你也在为海量DMA内存发愁,希望我的经验能帮你少走弯路。
2. 方案一:深度优化CMA,榨干最后一滴连续内存
当CMA分配失败时,我们的第一反应不应该是放弃它,而是先成为“侦探”,搞清楚为什么在1GB的物理内存中,连512MB甚至700MB的连续空间都挤不出来。这背后往往是内存布局的“隐形墙”在作祟。
2.1 排查元凶:是谁分割了我的连续内存?
首先,我通过内核启动的memmap信息,并结合dmesg中关于CMA初始化失败前后的日志,来观察物理内存的布局。一个非常经典的“刺客”就是设备树Blob(DTB)的加载位置。在很多Bootloader(如U-Boot)的默认配置中,DTB会被加载到内存的某个位置,比如0x20000000(512MB处)。想象一下,你希望CMA管理从0x10000000到0x50000000这一段1GB的连续空间,但中间512MB的位置突然插进了一个几MB的DTB,就像一条完整的公路上突然出现了一个路障,完美的连续区域就被一分为二了。
我的系统里就遇到了这个问题。解决方法很直接:修改U-Boot的环境变量,将DTB加载到更高且不影响目标CMA区域的地址。例如,设置fdt_high=0x30000000,告诉U-Boot将DTB放到768MB之后。同理,如果使用了initrd,也需要检查并可能修改initrd_high。清除这个路障后,原本因DTB阻挡而失败的512MB CMA预留,很可能就成功了。
2.2 挑战内核地址空间布局:3G/1G分割之殇
解决了DTB问题,我继续挑战700MB。这次失败,日志显示不再是中间有“异物”,而是更根本的限制。这引出了32位Linux内核内存管理的核心:虚拟地址空间布局。
默认情况下,32位Linux采用3G/1G分割,即用户空间占用3GB(0x00000000-0xBFFFFFFF),内核空间占用1GB(0xC0000000-0xFFFFFFFF)。我们通过CMA分配的物理内存,最终需要映射到这1GB的内核虚拟地址空间里,才能被内核代码访问。
这1GB内核空间可不是全部都能用来做线性映射的!它被划分成了几个部分:
- 直接映射区(lowmem):这是物理内存到内核虚拟地址的线性映射区,访问速度最快。其大小决定了内核能“直接看到”多少物理内存。
- vmalloc区:用于分配非连续物理内存的虚拟空间,内核模块、
ioremap等都放在这里。 - 高端内存映射区(如果开启HIGHMEM):用于访问超出直接映射区的物理内存。
- 固定映射区、向量表等:用于特殊用途。
关键点在于,CMA预留的物理内存,必须位于内核直接映射区(lowmem)对应的物理地址范围内。在3G/1G分割下,lowmem通常只有760MB左右(因为上方的空间要留给vmalloc等)。这意味着,即使物理上有1GB连续内存,内核也无法将其全部通过CMA管理并线性映射。这就是我设置CMA_SIZE_MBYTES=700失败的深层原因——它已经触及了lowmem的天花板。
2.3 实战调整:修改内核空间与关闭HIGHMEM
那么,如何扩大这个天花板呢?我尝试了组合拳:
- 关闭CONFIG_HIGHMEM:在我的场景中,系统总共就1GB物理内存,完全在32位地址空间的直接映射潜力范围内(理论上最高可映射896MB)。开启HIGHMEM机制反而会引入额外的映射开销和性能损耗。关闭它可以让内核尝试将更多物理内存纳入直接映射区。
- 修改内核启动参数,切换地址空间分割:这是更关键的一步。将默认的3G/1G分割改为1G/3G分割。通过给内核传递
vmalloc=384M(或类似参数,具体取决于内核版本和架构)并配合memmap参数调整,可以强制内核使用1GB用户空间和3GB内核空间。
实施第二步后,内核启动信息显示lowmem区域变成了1GB,vmalloc区域变得巨大。这相当于把内核的“视野”拓宽了。此时,再设置CMA_SIZE_MBYTES=700,配合调整fdt_high到更高地址(如0x36000000),CMA预留终于成功了!dma_alloc_coherent也能顺利分配出超过600MB的缓冲区。
这个方案的优点很明显:它仍然使用了内核原生的、为DMA优化过的CMA机制,内存由内核统一管理,缓存一致性有保障,API标准,驱动兼容性好。但缺点也同样突出:调整过程涉及内核编译选项和启动参数,需要对内存布局有深刻理解;并且,在极度紧张的内存系统中,过度扩大CMA可能会挤压系统其他部分的内存,影响整体稳定性。
3. 方案二:保留内存 + ioremap,一条“硬核”的旁路
当CMA的路因为内核布局限制实在走不通,或者你希望物理内存的分配完全确定、不受内核内存管理子系统动态行为影响时,就可以考虑方案二:保留内存(Reserved Memory)。
3.1 什么是保留内存?如何配置?
保留内存,顾名思义,就是在系统启动初期,从物理内存中划出一块“禁区”,标记为“保留”,Linux内核的内存管理系统不会去碰它,不会用它来分配页面、加载内核数据等。这块内存完全留给特定的驱动或设备使用。
配置保留内存主要有两种方式:
- 内核启动参数:最简单粗暴的方法。在U-Boot传递给内核的
bootargs中,添加mem=256M。这意味着你告诉内核:“系统只有256MB内存可用”,而实际的1GB内存中,剩下的768MB就从物理地址0x10000000(256MB)开始被“保留”了。这种方法简单,但不够灵活,且保留区域的大小和位置计算需要非常小心。 - 设备树(Device Tree):更现代、更推荐的方式。在设备树源文件(.dts)中定义保留内存节点。
这样,我们就明确保留了一块从768MB开始、大小为640MB的内存区域,并将其关联到了我们的FPGA驱动。驱动中可以通过/ { reserved-memory { #address-cells = <1>; #size-cells = <1>; ranges; fpga_reserved: buffer@30000000 { no-map; reg = <0x30000000 0x28000000>; // 起始地址0x30000000,大小640MB }; }; fpga_driver { compatible = "mycompany,fpga-axi"; memory-region = <&fpga_reserved>; // ... 其他属性 }; };of_reserved_mem_device_init或相关API获取这块内存的信息。
3.2 从物理保留到虚拟映射:ioremap的挑战
保留了物理内存,驱动怎么访问它呢?内核不能直接访问物理地址,必须通过页表映射到虚拟地址。这里就要用到ioremap系列函数。对于需要避免CPU缓存影响、确保设备直接访问一致性的场景(如FPGA DMA),我们使用ioremap_nocache或ioremap_wc(根据架构支持)。
我一开始天真的以为,保留出768MB内存,然后ioremap_nocache一下就完事了。结果内核崩溃,提示虚拟地址空间不足。这才恍然大悟:ioremap映射的空间位于内核的vmalloc区域。在默认3G/1G分割下,vmalloc区域可能只有240MB左右(从3.3内核开始默认值增大),根本映射不了768MB的巨幅空间!
这就回到了和CMA方案类似的核心矛盾:内核虚拟地址空间不够用。因此,方案二也必须进行内核地址空间的重构,即同样需要修改为1G/3G分割,来获得一个足够大的vmalloc区域(例如2GB以上),才能容纳对大块保留内存的ioremap映射。
3.3 驱动中的实现与注意事项
在驱动中,使用保留内存的典型流程如下:
static int fpga_probe(struct platform_device *pdev) { struct resource *res; void __iomem *fpga_buffer; phys_addr_t phys_addr; size_t size; // 1. 获取设备树中关联的保留内存区域 struct device_node *mem_node; mem_node = of_parse_phandle(pdev->dev.of_node, "memory-region", 0); if (!mem_node) { dev_err(&pdev->dev, "No reserved memory region specified\n"); return -ENODEV; } // 2. 获取该内存区域的物理地址和大小 if (of_address_to_resource(mem_node, 0, &res)) { dev_err(&pdev->dev, "Failed to get reserved memory resource\n"); return -EINVAL; } phys_addr = res->start; size = resource_size(res); // 3. 使用 ioremap_nocache 映射到内核虚拟地址空间 fpga_buffer = ioremap_nocache(phys_addr, size); if (!fpga_buffer) { dev_err(&pdev->dev, "Failed to ioremap reserved memory\n"); return -ENOMEM; } // 4. 将物理地址告知FPGA硬件(通过寄存器配置) // write_to_fpga_register(FPGA_DMA_ADDR_REG, phys_addr); // 5. 驱动可以使用 fpga_buffer 虚拟地址来访问这块内存 // ... // 在remove函数中记得 iounmap return 0; }这个方案的优缺点非常鲜明:
- 优点:内存分配100%确定,启动时即保留,绝对连续,不会因系统运行产生碎片。不受内核CMA管理器的动态行为影响,适合对内存布局有极端要求的场景。
- 缺点:实现更复杂,需要修改设备树或启动参数。失去了CMA的“按需分配、动态释放”的灵活性,这块内存即使不用,系统也无法回收。缓存一致性需要驱动开发者自己注意(虽然
ioremap_nocache有助于此,但复杂场景下仍需谨慎处理缓存操作)。此外,同样需要调整内核地址空间布局。
4. 终极对决:CMA优化 vs. 保留内存,我该如何选择?
两个方案都能解决超大连续DMA内存的问题,也都涉及对内核内存布局的“大手术”。在实际项目中如何抉择?我总结了一个决策流程图和对比表格,你可以根据自己的情况对号入座。
首先问自己几个关键问题:
- 内存需求是否绝对固定且永久?是否在整个系统生命周期内,这块内存都必须存在且大小不变?
- 系统内存压力大吗?除了这块DMA缓冲区,系统其他应用和服务是否也需要大量内存?
- 你对内核的修改和定制化接受度如何?项目是否允许修改内核启动参数、设备树甚至重新编译内核?
- 驱动的可移植性重要吗?这个驱动是否需要适配不同的内核版本或硬件平台?
基于这些问题,我们可以对比两种方案:
| 特性维度 | CMA深度优化方案 | 保留内存 + ioremap方案 |
|---|---|---|
| 内存管理 | 由内核CMA框架统一管理,可被迁移、回收(在设备未使用时)。 | 静态预留,内核完全不可见,无法被系统复用。 |
| 灵活性 | 高。大小可通过参数调整,可在系统运行时由多个驱动共享CMA池。 | 极低。大小和位置在启动时固定,独占使用。 |
| 实现复杂度 | 中等。主要工作是调整内核配置和启动参数,理解内存布局。驱动使用标准API。 | 较高。需配置设备树/启动参数,驱动中需手动处理映射,并确保缓存一致性。 |
| 性能 | 优。使用dma_alloc_coherent,缓存一致性由硬件和内核保障,访问效率高。 | 良。通过ioremap_nocache映射,通常为uncached访问,延迟可能略高,但更确定。 |
| 对系统影响 | 可能挤压系统可用内存,尤其是当CMA区域设置过大时。 | 直接永久剥夺一部分系统可用内存,即使不用也无法回收。 |
| 虚拟地址空间需求 | 高。需要大的内核低端直接映射区(lowmem)。 | 高。需要大的内核vmalloc区域来映射。 |
| 适用场景 | 内存需求较大但非绝对固定,系统其他部分内存需求也较紧张,希望保持一定灵活性和驱动兼容性。 | 内存需求极大且绝对固定,系统有充足物理内存,或对内存布局有确定性要求(如与硬件设计强绑定)。 |
以我自己的项目为例,我最终选择了CMA深度优化方案。原因如下:首先,600MB虽然大,但并非永久不可调整,未来可能有优化空间;其次,系统总内存1GB,如果永久保留768MB,留给Linux本身的内存就太少了,会影响系统服务和其他应用的运行;最后,使用标准DMA API的驱动可移植性更好,未来升级内核或移植到其他平台会更省心。当然,这要求我必须完成将内核空间调整为1G/3G分割等一系列操作。
如果你的FPGA板卡有2GB甚至更多内存,且DMA缓冲区大小是硬件设计固化的,那么保留内存方案可能更干净利落,一劳永逸。
5. 避坑指南与调试技巧
无论选择哪条路,调试过程都充满陷阱。这里分享几个我踩过的坑和有用的调试技巧,希望能帮你快速定位问题。
技巧一:读懂内核启动内存信息内核启动时Virtual kernel memory layout和Memory:这两行信息是宝藏。重点关注:
lowmem的大小:这决定了CMA的上限。vmalloc区域的大小:这决定了你能ioremap多大的空间。reserved的内存:看看是不是有意外的大块保留(如显卡显存预留)。
技巧二:利用/proc/iomem和/proc/vmallocinfo
cat /proc/iomem:查看物理内存的布局,哪些区域被系统占用,哪些被保留。你的CMA区域或保留内存应该在这里显示。cat /proc/vmallocinfo:查看vmalloc区域的分配情况,检查你的ioremap是否成功以及映射了多大空间。
技巧三:动态调试CMA如果CMA分配失败,可以开启内核动态调试:
echo -n 'file dma-contiguous.c +p' > /sys/kernel/debug/dynamic_debug/control然后查看dmesg,会输出CMA分配的详细过程,看它到底卡在哪一步。
一个常见的巨坑:Cache一致性问题使用ioremap_nocache映射的内存,CPU以uncached方式访问。但如果你在驱动中用了类似memcpy的标准库函数,这些函数可能会使用缓存优化的指令(如NEON),导致操作未按预期进行。对于需要软件初始化或处理这块内存的情况,建议使用memcpy_toio/memcpy_fromio这类专为I/O内存设计的函数。
另一个坑:32位系统的物理地址限制在有些32位SoC上,DMA控制器可能有物理地址访问限制(比如只能访问低4GB的某些范围)。确保你分配的连续物理内存(无论是CMA还是保留内存)落在DMA控制器支持的地址范围内。这通常需要在设备树中为设备正确设置dma-ranges属性。
调试的过程,其实就是和内核内存管理子系统对话的过程。耐心查看每一处日志,理解每一个参数的含义,最终你总能拼凑出内存版图的完整面貌,找到那条通往成功的路径。这次经历让我对Linux内存管理的理解深刻了许多,那种解决问题后的成就感,或许就是驱动开发的乐趣所在吧。
