Linux内核中的物理内存拼图:scatterlist API实战指南
引言
在驱动开发的世界里,我们常常需要处理一个看似简单却异常棘手的问题:如何让DMA控制器高效地访问那些分散在物理内存各处的数据块?想象一下,你正试图将一幅被打散成数百块的拼图重新组合——这就是scatterlist要解决的核心问题。不同于用户空间连续的内存视图,内核开发者经常需要面对物理内存的碎片化现实,而scatterlist正是将这些碎片重新"拼接"成DMA可识别视图的神奇工具。
本文将带你深入scatterlist API的实战应用,从内存申请到最终释放,完整呈现一个驱动开发者需要掌握的每个关键步骤。我们将避开枯燥的理论说教,直接聚焦于那些你在实际编码时会用到的核心API和常见陷阱。无论你是在开发存储驱动、网络设备还是任何需要高效DMA传输的模块,这些技巧都将成为你的得力助手。
1. 环境准备与基础概念
1.1 scatterlist的适用场景
scatterlist主要解决三类典型问题:
- 物理内存碎片化:系统长时间运行后,大块连续物理内存成为稀缺资源
- 高效DMA传输:减少设备与内存间的传输次数,提升I/O性能
- 零拷贝优化:避免数据在用户空间和内核空间之间的冗余拷贝
在以下场景中你会频繁接触scatterlist:
- 块设备驱动(如NVMe、SATA)
- 网络设备驱动(特别是支持SGIO的网卡)
- 视频采集/输出设备
- 加密加速设备
1.2 关键数据结构速览
理解几个核心结构体是使用API的前提:
struct scatterlist { unsigned long page_link; // 内存页指针+链标记 unsigned int offset; // 页内偏移 unsigned int length; // 数据长度 dma_addr_t dma_address; // DMA总线地址 }; struct sg_table { struct scatterlist *sgl; // 散列表头 unsigned int nents; // 有效条目数 unsigned int orig_nents; // 原始条目数 };注意:
page_link不仅存储页面指针,其低位还用作链标记(bit0为SG_CHAIN,bit1为SG_END),这种设计充分利用了指针对齐的特性。
2. scatterlist的申请与初始化
2.1 内存分配策略
内核为scatterlist提供了两种分配方式:
| 分配方式 | 适用条件 | 内存来源 | 最大数量 |
|---|---|---|---|
| 整页分配 | nents ≥ SG_MAX_SINGLE_ALLOC(128) | Buddy分配器 | 128个/页 |
| 对象分配 | nents < SG_MAX_SINGLE_ALLOC | kmalloc缓存 | 由slab决定 |
典型分配流程:
struct sg_table table; int ret = sg_alloc_table(&table, nents, GFP_KERNEL); if (ret) { // 错误处理 }2.2 分配时的常见陷阱
- GFP标志选择:在原子上下文使用GFP_ATOMIC,否则可能导致死锁
- 数量预估:orig_nents可能大于实际nents,预留足够空间
- 内存泄漏:务必检查返回值,失败时可能已分配部分内存
一个健壮的分配示例:
struct sg_table *alloc_sg_table(unsigned int nents) { struct sg_table *table; int ret; table = kzalloc(sizeof(*table), GFP_KERNEL); if (!table) return ERR_PTR(-ENOMEM); ret = sg_alloc_table(table, nents, GFP_KERNEL); if (ret) { kfree(table); return ERR_PTR(ret); } return table; }3. 组装内存拼图
3.1 关联物理页面的正确姿势
sg_set_page是关联物理页的核心API:
void sg_set_page(struct scatterlist *sg, struct page *page, unsigned int len, unsigned int offset)典型使用场景:
struct page *page = alloc_pages(GFP_KERNEL, order); if (!page) { // 错误处理 } sg_set_page(&table.sgl[i], page, PAGE_SIZE << order, 0);3.2 链式管理的艺术
当数据分散在多个不连续的缓冲区时,需要链式管理:
- 分配足够容纳所有片段的sg_table
- 为每个片段调用sg_set_page
- 使用sg_chain连接不同的sg数组
// 假设我们需要连接两个独立的sg数组 sg_init_table(sg1, SG_MAX_SINGLE_ALLOC); sg_init_table(sg2, SG_MAX_SINGLE_ALLOC); // ...填充sg1和sg2... // 将sg2链接到sg1的末尾 sg_chain(sg1, SG_MAX_SINGLE_ALLOC, sg2);警告:链式操作会修改原始sg的page_link字段,确保在DMA取消映射后再操作
4. DMA映射实战
4.1 建立DMA视图
完成sg_table组装后,需要为DMA控制器创建映射:
int dma_map_sg(struct device *dev, struct scatterlist *sg, int nents, enum dma_data_direction dir)关键参数说明:
dev:执行DMA操作的设备dir:数据传输方向(DMA_TO_DEVICE/DMA_FROM_DEVICE/DMA_BIDIRECTIONAL)
返回值是实际映射的sg数量,可能小于nents。
4.2 同步与一致性
根据设备能力选择适当的映射方式:
| 映射类型 | 适用设备 | 性能影响 | 代码提示 |
|---|---|---|---|
| 一致性映射 | 支持硬件缓存一致性 | 高开销 | DMA_ATTR_FORCE_CONTIGUOUS |
| 流式映射 | 普通设备 | 较低开销 | dma_map_sg_attrs |
流式映射的同步操作:
void dma_sync_sg_for_device(struct device *dev, struct scatterlist *sg, int nents, enum dma_data_direction dir)5. 资源释放与错误处理
5.1 安全释放模式
释放操作必须与分配配对:
// 取消DMA映射 dma_unmap_sg(dev, table.sgl, table.nents, dir); // 释放sg_table sg_free_table(table);5.2 常见内存问题排查
- 双释放:确保每个sg_table只释放一次
- 映射泄漏:每个dma_map_sg必须对应dma_unmap_sg
- 使用后释放:确保DMA操作完成后再释放资源
调试技巧:
// 检查sg是否有效 #define sg_dbg(sg) \ pr_debug("sg@%p: page=%p, offset=%u, length=%u\n", \ (sg), sg_page(sg), (sg)->offset, (sg)->length) // 遍历打印整个sg_table void dump_sg_table(struct sg_table *table) { struct scatterlist *sg; int i; for_each_sg(table->sgl, sg, table->nents, i) { sg_dbg(sg); } }6. 性能优化技巧
6.1 预分配策略
高频操作场景建议使用预分配池:
struct sg_pool { struct sg_table table; struct list_head list; }; // 初始化预分配池 int init_sg_pool(int pool_size) { // ...创建多个预分配的sg_table... } // 从池中获取 struct sg_table *get_sg_from_pool(void) { // ...实现获取逻辑... } // 归还到池中 void put_sg_to_pool(struct sg_table *table) { // ...实现归还逻辑... }6.2 合并相邻片段
利用sg_next和sg_is_last检测相邻片段:
struct scatterlist *sg = table->sgl; while (!sg_is_last(sg)) { struct scatterlist *next = sg_next(sg); if (sg_page(sg) + (sg->offset + sg->length) / PAGE_SIZE == sg_page(next) && (sg->offset + sg->length) % PAGE_SIZE == next->offset) { // 可以合并sg和next sg->length += next->length; sg->dma_length += next->dma_length; // 从链表中移除next... } sg = next; }7. 真实案例:块设备驱动中的scatterlist
在NVMe驱动中,一个完整的I/O请求处理流程:
- 从请求队列获取bio结构
- 将bio转换为sg_table
- 建立DMA映射
- 提交给硬件队列
- 完成中断中取消映射
关键代码片段:
struct nvme_queue *nvmeq = dev->queues[qid]; struct nvme_command cmnd; struct scatterlist *sg; int nseg; // 将bio转换为scatterlist nseg = blk_rq_map_sg(q, req, nvmeq->sg); if (nseg < 0) return BLK_STS_RESOURCE; // 建立DMA映射 dma_map_sg(dev->dev, nvmeq->sg, nseg, rq_data_dir(req) ? DMA_TO_DEVICE : DMA_FROM_DEVICE); // 准备命令 cmnd.rw.opcode = nvme_cmd_write; cmnd.rw.nsid = cpu_to_le32(ns->ns_id); cmnd.rw.slba = cpu_to_le64(sector >> shift); cmnd.rw.length = cpu_to_le16((sectors >> shift) - 1); // 设置PRP/SGL if (use_sgl) { cmnd.rw.flags |= NVME_RW_SGL_METABUF; nvme_setup_sgl(&cmnd.rw, nvmeq->sg, nseg); } else { nvme_setup_prps(&cmnd.rw, nvmeq->sg, nseg); } // 提交命令 nvme_submit_cmd(nvmeq, &cmnd);在最近的一个项目中,我们通过优化scatterlist的预分配策略,将NVMe驱动的高负载下的IOPS提升了约15%。关键在于找到适合你工作负载的sg_table大小——太小会导致频繁分配,太大则会浪费内存。