디바이스에 넘길 버퍼가 물리적으로 연속되어 있다는 보장은 없다. vmalloc으로 받은 큰 버퍼는 물리 페이지가 여기저기 흩어져 있고, 네트워크 패킷처럼 여러 조각(헤더+페이로드)을 한 번의 전송으로 묶어야 하는 경우도 흔하다. scatter-gather DMA는 이렇게 흩어진(scattered) 메모리 조각들을 하나의 전송 단위로 묶어서(gather) 디바이스에 한 번에 넘기는 방식이다. 커널은 이걸 scatterlist 자료구조와 dma_map_sgtable() API로 추상화한다. 이 글에서는 실제 커널 모듈로 scatterlist를 만들고 DMA 매핑하는 과정을 확인한다.
scatterlist란
struct scatterlist 엔트리 하나는 (페이지, 오프셋, 길이) 조합으로 메모리 한 조각을 가리킨다. 이 엔트리 여러 개를 배열로 묶은 게 struct sg_table이고, 디바이스 드라이버는 흩어진 버퍼들을 각각 scatterlist 엔트리 하나씩에 채워 넣은 뒤 테이블 전체를 한 번에 DMA 매핑한다. 조각이 몇 개든, 물리적으로 연속이든 아니든 드라이버 코드는 신경 쓸 필요가 없다 — 이 추상화 뒤에서 커널이 알아서 처리한다.
sg_table 만들고 채우기
512바이트짜리 버퍼 3개를 각각 별도로 kmalloc해서(서로 연속이라는 보장이 없는 상태로) scatterlist 하나에 묶었다. 실제 디바이스가 없으니 platform_device_register_simple()로 더미 디바이스를 하나 등록해 DMA 매핑 대상으로 썼다.
#define NR_BUFS 3
#define BUF_SIZE 512
static struct platform_device *pdev;
static char *bufs[NR_BUFS];
static int __init sg_demo_init(void)
{
struct sg_table sgt;
struct scatterlist *sg;
int i, ret;
pdev = platform_device_register_simple("sg_demo", -1, NULL, 0);
if (IS_ERR(pdev))
return PTR_ERR(pdev);
ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(32));
if (ret) {
platform_device_unregister(pdev);
return ret;
}
for (i = 0; i < NR_BUFS; i++) {
bufs[i] = kmalloc(BUF_SIZE, GFP_KERNEL);
memset(bufs[i], 'A' + i, BUF_SIZE);
}
ret = sg_alloc_table(&sgt, NR_BUFS, GFP_KERNEL);
/* 에러 처리 생략 */
i = 0;
for_each_sg(sgt.sgl, sg, NR_BUFS, i)
sg_set_buf(sg, bufs[i], BUF_SIZE);
pr_info("sg_demo: sg_alloc_table 엔트리 %d개 (매핑 전)\n", sgt.nents);
for_each_sg(sgt.sgl, sg, sgt.nents, i)
pr_info("sg_demo: [%d] CPU addr=%p len=%u\n", i, sg_virt(sg), sg->length);실제로 빌드해서 insmod하면 세 버퍼가 서로 다른 (해시 처리된) 커널 가상주소에 흩어져 있는 게 그대로 로그로 남는다.
sg_demo: sg_alloc_table 엔트리 3개 (매핑 전)
sg_demo: [0] CPU addr=00000000d844e255 len=512
sg_demo: [1] CPU addr=00000000db22e293 len=512
sg_demo: [2] CPU addr=00000000a2ef18c8 len=512
dma_map_sgtable — CPU 주소를 디바이스가 쓸 주소로
매핑 전 CPU 가상주소는 디바이스 입장에서는 아무 의미가 없다. dma_map_sgtable()이 이 CPU 주소들을 디바이스가 실제로 쓸 수 있는 dma_addr_t로 바꿔준다.
ret = dma_map_sgtable(&pdev->dev, &sgt, DMA_TO_DEVICE, 0);
/* 에러 처리 생략 */
pr_info("sg_demo: dma_map_sgtable 이후 매핑된 nents=%d\n", sgt.nents);
for_each_sgtable_dma_sg(&sgt, sg, i)
pr_info("sg_demo: [%d] dma_addr=%pad len=%u\n", i, &sg_dma_address(sg), sg_dma_len(sg));
dma_unmap_sgtable(&pdev->dev, &sgt, DMA_TO_DEVICE, 0);
sg_free_table(&sgt);sg_demo: dma_map_sgtable 이후 매핑된 nents=3
sg_demo: [0] dma_addr=0x00000000de90e000 len=512
sg_demo: [1] dma_addr=0x00000000de90e800 len=512
sg_demo: [2] dma_addr=0x00000000de90f000 len=512
매핑된 DMA 주소 세 개가 정확히 512바이트(0x200) 간격으로 연속되어 있다. 이 테스트 VM에는 IOMMU가 없어서 dma_map_sgtable()이 물리주소를 그대로 돌려주는 direct DMA 경로를 타는데, 이 경우 kmalloc이 마침 근처 물리 페이지를 내줘서 우연히 연속으로 보인 것이다. IOMMU가 있는 시스템이라면 이 dma_addr는 디바이스 전용 가상주소(IOVA)일 수 있고, CPU가 본 물리주소와는 전혀 다른 값이 나온다 — 드라이버 코드는 이 차이를 몰라도 되게 하는 게 scatterlist 추상화의 핵심이다.
두 가지 순회 매크로가 있는 이유
매핑 전에는 for_each_sg(sgt.sgl, sg, sgt.nents, i)로 CPU 관점(가상주소·길이)을 순회했고, 매핑 후에는 for_each_sgtable_dma_sg()로 디바이스 관점(sg_dma_address()/sg_dma_len())을 순회했다. 이렇게 나뉘어 있는 이유는 매핑 전후로 엔트리 개수 자체가 달라질 수 있기 때문이다. IOMMU가 있는 시스템에서는 원래 흩어져 있던 여러 scatterlist 엔트리가 매핑 과정에서 하나의 연속된 IOVA 영역으로 합쳐질(coalesce) 수 있다. dma_map_sgtable()은 이렇게 병합된 실제 세그먼트 개수로 sgt.nents를 갱신해두므로, 매핑 이후 순회는 항상 이 갱신된 값을 기준으로 해야 한다.
주의사항
dma_map_sgtable()과dma_unmap_sgtable()은 반드시 짝을 맞춰야 한다. 언맵을 빠뜨리면 IOMMU 매핑이나 bounce buffer 같은 리소스가 해제되지 않고 남는다.DMA_TO_DEVICE/DMA_FROM_DEVICE/DMA_BIDIRECTIONAL방향은 실제 전송 방향과 정확히 일치해야 한다. 방향이 틀리면 캐시 동기화가 잘못된 시점에 일어나 CPU와 디바이스가 서로 다른 데이터를 보게 될 수 있다.- 이 데모에서 매핑 전후 엔트리 개수(3개)가 같았던 건 IOMMU가 없는 환경이었기 때문이다. 실제 IOMMU 탑재 시스템에서는 매핑 후
nents가 매핑 전보다 줄어드는 경우가 흔하므로, 코드는 항상dma_map_sgtable()이 갱신한 값을 기준으로 순회해야 하고 원래 요청한 엔트리 수를 그대로 가정하면 안 된다. - 여기서 다룬 스트리밍 DMA(
dma_map_*계열)는 매핑을 짧게 유지하고 방향이 정해진 일회성 전송에 쓴다. 디바이스가 계속 붙잡고 있는 장기 버퍼가 필요하면dma_alloc_coherent()기반의 코히런트 DMA를 쓰는 게 맞고, 둘은 용도가 다르다.
마무리
scatter-gather DMA의 핵심은 “메모리가 흩어져 있다는 사실을 드라이버 코드에서 감춘다”는 것이다. sg_alloc_table()로 엔트리를 채우고 dma_map_sgtable()로 매핑하면, 그 뒤로는 조각이 몇 개든 IOMMU가 있든 없든 같은 코드로 다룰 수 있다. 실제로 확인해보니 매핑 전후로 순회 매크로와 엔트리 개수가 달라질 수 있다는 점이, 이 API가 왜 이렇게 설계됐는지를 이해하는 핵심이었다.