ソフトウェアレベルでのDMAについて(NIC driver周り)

1回目は、最近NICのdriverを書いたときに使ったDMAに関するまとめです。

この記事はあくまで自分の学習の復習的な意味合いが強いので、自分に対して対話的に書いています。そのため、他の人からしたら不可解なことがあるかもしれませんが、ご容赦を(言葉遣いとか)

前提として、筆者は基本Linuxでしか開発しないため、使用しているOSはLinuxであり、解説するコードもLinux依存であることを明記します。

また、記事のまとめ方は、対象とする技術を概念的に言葉でまとめ、次に実際のソースや疑似コードで説明するという、自然言語->人工言語の流れで行います。

(余談ですが、個人的におすすめな学習方法は人工言語->自然言語です。つまり対象の技術に関するコードを書いた後に概念を調べたほうがコードレベルで腑に落ちるということです。)(しかし、こういったブログでいきなりコードを説明されて、その後に概念を説明されてもこのプラットフォームの名前どおり???となるので、やめておきます。)

さて、では本題に入ります。

今回扱うDMAというのは、Direct Memory Accessという技術です。
昨今はデーターセンターでのRDMAとか話題ですよね。(めっちゃ興味ある)

これは、ハードウェアサブシステムがCPUに頼ることなく許可された範囲内でメインメモリにアクセスすることができる技術です。

ちなみに、今回の内容で扱うのはDMA命令をソフトウェアでどのように扱うか(特にNICのドライバ周り)であり、DMAの技術それ自体やDMA engineについては詳しく述べません。(DMA mappingとDMA engine違いが判らない方は、DMAに関するハードのこととか後に紹介するソースコードを読んで、調べてたり考えてみたりしてください。)

 

カーネルでは、通常仮想アドレスを用いてメモリに関する操作を行います。例えば、ある構造体のメモリ領域を確保したいときに使用するkmalloc()やvmalloc()では、返り値として仮想アドレスが返ってきます。

もちろん仮想アドレスのままハードウェアにアクセスすることはできません。そのため、メモリアクセス時には、TLBやpage tableなどの仮想メモリシステムが仮想アドレスを物理アドレスに変換してくれます。
例えば、MMUがCPUから吐かれた仮想アドレスを物理アドレスに変換する流れは以下の図です。(バスが斜めってたり本数適当ですが許してください、だるかった...)

f:id:siiba_r:20191110020327j:plain

MMUは仮想アドレスのうち、上位アドレスだけページテーブルに基づいて変換し、下位のページサイズ分はそのまま使います。
コードはこんな感じ.

unsigned long int physical_address( unsigned long int virtual v ) {
        unsigned long int p, page, offset;
        p = v >> 12; // 32中、上位20ビット(32-12==20)の取り出し
        offset = v & 0xfff; // 下位 12 ビットの取り出し
        page = page_table[p]
        return( page + offset );
}

ちなみに、仮想アドレスは"void *"で受けられて、物理アドレスは"phys_addr_t"や"resource_size_t"で受けられます。

 

カーネルは/proc/iomem配下にレジスタのようなデバイスリソースの物理アドレス保有してますが、ドライバからは直接物理アドレスを使えません。よって、ドライバはioremap()を使って、そのスペースをマップし、仮想アドレスを取得します。

コード例としては以下です。

これはbar4(base address register)のメモリ空間の仮想アドレスを取得するために、bar4の最初のアドレスと長さを取得し、ioremap()することで仮想アドレスをbar4に取得しています。(barについて知らない人は別途調べることをおすすめします)
bar4_start = pci_resource_start(pdev, 4);
bar4_len = pci_resource_len(pdev, 4);
bar4 = ioremap(bar4_start, bar4_len);

 

また、いままでの仮想・物理アドレスとは異なり、I/Oデバイスはbusアドレスを使います。

基本的には、IOMMUとホストブリッジが物理アドレスとbusアドレスのマッピングを行ってくれます。

また、MMIOを使うことで物理アドレス空間上にメモリとI/Oデバイスを共存させ、メインメモリにアクセスする命令と同じアドレス空間でI/Oデバイスにアクセスできます。

これで、仮想アドレス-物理アドレス-busアドレスのマッピングに関してイメージがつきましたかね。

 

で、本題のDMAですが、前述のとおり筆者が最近書いたLinux kernelでのNICのdriverでの範囲で話します。

NICのdriverを書いていてDMAが必要になるときは、例えば、NICディスクリプターリングのデータ部分に存在するデータにDMAするための領域のmapやそのディスクリプター自体の確保、DMAしてほしいpageのmapなどです。


例えば、consistentなDMA領域を確保して、マップしたいときは、dma_alloc_coherentを使います。
https://elixir.bootlin.com/linux/v4.3/source/arch/arc/mm/dma.c#L52

具体的はコードは以下です。
tx_ring->desc = dma_alloc_coherent(dev,tx_ring->size,&tx_ring->dma,GFP_KERNEL);
ここでは、tx用のディスクリプターリングに対して、consistentなDMA領域をtx_ringのサイズ分割り当て、tx_ring->dmaに対してdmaのためのアドレスを格納しています。dmaのためのアドレスは、dma_addr_tという型で定義されています。(まあプロセッサの仮想アドレスのポインタが返ってくるんですが)
注意しておきたいのは、coherent memoryは割とexpensiveで、あんまりたくさん使わないほうがいいです。

お次は、dma_map_single()です。
https://elixir.bootlin.com/linux/v4.3/source/arch/arc/include/asm/dma-mapping.h#L76
これは、仮想アドレスが分かっているものをデバイスがDMAできるようにmapして、そのdma可能なアドレスを取得するために使います。
実際のコード例は以下です。
dma = dma_map_single(tx_ring->dev,skb->data,size,DMA_TO_DEVICE);
これは、skb->data領域をそのサイズ分device側へとDMAできるようmapして、そのdmaのためのアドレスを取得しています。
これは後に、
tx_desc->read.buffer_addr = cpu_to_le64(dma);

上記のように、ディスクリプターリングのバッファ用のアドレスとして登録されます。
よって、ここの領域をdeviceへとDMAすることで、skbのdataを送信することができます。
この関数のもう1つの注目ポイントは、DMA_TO_DEVICEです。
これはdevice側へのDMAということを指しており、device側がDMAしたい領域に対してはDMA_FROM_DEVICEを登録します。

ということで、DMA_FROM_DEVICEの説明に合わせて、dma_map_page()を説明します。
dma_map_page()は、pageのアドレスを物理アドレスに変換した後にdma_map_single()を呼びます。
なので、pageを使うときはdma_map_page()、データ領域の物理アドレスが分かっている場合はdma_map_singleを使います。
https://elixir.bootlin.com/linux/v4.3/source/arch/arc/include/asm/dma-mapping.h#L90
具体的な使用例としては以下です。
dma = dma_map_page(rx_ring->dev,page,0,PAGE_SIZE,DMA_FROM_DEVICE);
ここでは、rx用のディスクリプターリングで使うpageをPAGE_SIZE分マップして、dmaできるアドレスを取得しています。
んで、ここで取得したdma-ableなアドレスは
rx_desc->read.pkt_addr = cpu_to_le64(rb->dma + rb->page_offset);
こんな感じで、rx用のディスクリプターリングのパケット格納のために使われます。
つまり、DMA_FROM_DEVICEを先ほど登録した理由は、NICディスクリプターリングのパケット格納の場所へ直接DMAしてパケットを書き込むために使うからです。
このようにDMA_FROM_DEVICE・DMA_TO_DEVICEを使い分けていきます。

他にもdma_pool_create()(細切れの小さいdma-ableなメモリ領域を確保)やdma_set_mask_and_coherent()(dmaのbit maskの設定)などありますが、ここでは省きます。

もっと知りたい方はまずカーネルにおけるNICドライバのコードを解説した
https://bootlin.com/doc/legacy/pci-drivers/pci-drivers.pdf
ここらへんを眺めてみて、
https://www.kernel.org/doc/Documentation/DMA-API-HOWTO.txt

https://elinux.org/images/3/32/Pinchart--mastering_the_dma_and_iommu_apis.pdf

ここらへんを読むといいと思います。

筆者のコードを公開したいんですが、デバックがまだであるという点とそのドライバを使っている研究がまた未公開であるため、ここでは関数の紹介で断片的に載せました。
より詳しいことが聞きたい人は、直接聞いてください。

次はページキャッシュとその管理を行うradix_treeのデータ構造、buffer_head,address_spaceらへんの関係についてかNUMAにおけるbankを管理するためのデータ構造についてまとめようと思います。