1 ポイント 投稿者 GN⁺ 2024-07-29 | 1件のコメント | WhatsAppで共有
  • ハードコードした BAR0 アドレスを直接 peek/poke していた段階から脱し、Linux PCI サブシステムで BAR メモリを見つけ、カーネルドライバがデバイスを初期化する
  • ドライバは struct pci_driver の ID テーブルと probe 関数から始まり、BAR0 を カーネル仮想アドレスにマッピングしたうえでユーザー空間からのアクセスを準備する
  • /dev/gpu-io キャラクタデバイスを通じて read(2)write(2) をつなぎ、container_of でファイル操作からドライバの状態を取り戻す
  • DWORD 単位のコピーでは 1.2MiB の転送に約 800ms かかったが、MMIO レジスタベースの DMA 呼び出しに切り替えると約 300µs レベルまで短縮された
  • DMA 完了待ちは MSI-X 割り込みと wait queue で処理し、最終的に QEMU コンソールへ framebuffer の内容を表示する偽 GPU として動作する

BAR0 をカーネルドライバで見つけてマッピングする

  • 以前の実装では、lspci からコピーした BAR0 アドレス 0xfe000000 に対して 32 ビット単位で直接読み書きしていた
  • アドレスをハードコードしないために、Linux PCI サブシステムからデバイスのメモリマッピング情報を取得する
  • struct pci_driver には 2 つの主要フィールドが必要
    • 対応する device/vendor ID ペアのテーブル
    • ID が一致したときに呼び出される probe 関数
  • サンプルデバイスは PCI_DEVICE(0x1234, 0x1337) にマッチする
  • ドライバ状態 GpuState には struct pci_dev *pdev と BAR メモリ用の u8 __iomem * hwmem を保存する
  • probe 関数は次の順序でデバイスを準備する
    • pci_enable_device_mem(pdev) でデバイスのメモリアクセスを有効化
    • pci_select_bars(pdev, IORESOURCE_MEM) で利用可能なメモリ BAR のビットフィールドを取得
    • pci_request_region(pdev, bars, "gpu-pci") で BAR アドレス空間の所有権を要求
    • pci_resource_start(pdev, 0)pci_resource_len(pdev, 0) で BAR0 の開始アドレスと長さを取得
    • ioremap(mmio_start, mmio_len) で物理アドレスをカーネル仮想アドレスにマッピングする
  • pci_register_drivermodule_init で呼び出すと、ブートログに mmio starts at 0xfe000000 とカーネル仮想アドレスが出力される

ユーザー空間にキャラクタデバイスとして公開する

  • BAR0 アドレス空間をカーネルドライバにマッピングしたあと、ユーザー空間プログラムが read(2)write(2) で PCIe デバイスとやり取りできるように キャラクタデバイスを作る
  • このドライバに必要なファイル操作は openreadwrite の 3 つだけ
  • GpuStatestruct cdev cdev を追加し、setup_chardev で次の作業を行う
    • alloc_chrdev_region でデバイス番号を割り当て
    • cdev_initcdev_add でキャラクタデバイスを登録
    • device_create/dev/gpu-io を作成
  • init スクリプトに /busybox mdev -s を追加して /dev/ 疑似ファイルシステムを埋める
  • 以後 /dev/gpu-io はキャラクタデバイスとして見え、例では major 番号 241、minor 番号 0 として表示される

container_of でファイル操作からドライバ状態を探す

  • write 実装で struct file*private_dataopen が埋める必要があるが、open は別の private_datauser_data 引数を受け取らない
  • struct inode にはキャラクタデバイスを指す struct cdev *i_cdev ポインタがある
  • GpuStatestruct cdev埋め込んでいるため、container_of(inode->i_cdev, struct GpuState, cdev)GpuState ポインタを取り戻せる
  • gpu_open は取得した GpuStatefile->private_data に保存する
  • 以後 gpu_readgpu_writefile->private_data から GpuState を取り出して使う
  • 初期の read/write は一度に DWORD 1 つずつ処理する
    • gpu_readioread32(gpu->hwmem + *offset) で読み取り、copy_to_user でユーザーバッファにコピーする
    • gpu_write はユーザーバッファから 4 バイトをコピーし、offset を 4 増やす
  • 小さな転送では動作するが、CPU がパケットを 1 つずつ処理し続ける必要があるため、大きな転送では遅い
  • 640×480、32bpp に相当する 1.2MiB の転送には約 800ms かかる

MMIO レジスタで DMA 呼び出しを作る

  • CPU が DWORD 単位のコピーを繰り返す代わりに、デバイスが直接データをコピーするよう DMA を使う
  • 作業要求はメモリマップド I/O(memory-mapped IO)方式で送る
    • 一部のメモリアドレスは DMA 呼び出しの引数として機能するレジスタのように使う
    • 別のアドレスは関数呼び出しの実行を意味するコマンドのように使う
  • DMA インターフェースには、CPU がデバイスに知らせるべき値がある
    • コピーするデータの source アドレスと長さ
    • destination アドレス
    • データ方向:main memory 側へ、または main memory から
    • コピー開始の準備ができたという信号
  • デバイスは転送完了を CPU に知らせる必要がある
  • サンプルレジスタは次のように定義される
    • REG_DMA_DIR
    • REG_DMA_ADDR_SRC
    • REG_DMA_ADDR_DST
    • REG_DMA_LEN
  • CMD_DMA_START は、レジスタ値を埋める動作と実際の DMA 開始を区別するコマンドアドレスとして使われる
  • カーネルドライバの execute_dmaiowrite32 で方向、source、destination、長さを書き、最後に CMD_DMA_START1 を書く

QEMU デバイス側の DMA 処理

  • QEMU アダプタの MMIO gpu_write は以前の実装を置き換え、DMA レジスタとコマンドを処理する
  • レジスタ領域への書き込みは gpu->registers[reg] に値を保存する
  • コマンド領域で REG_DMA_START が来ると DMA 方向を確認する
  • DIR_HOST_TO_GPU 方向では pci_dma_read を呼び出す
    • host アドレスは REG_DMA_ADDR_SRC
    • device アドレスは gpu->framebuffer + REG_DMA_ADDR_DST
    • 長さは REG_DMA_LEN
  • それ以外の DMA 方向はサンプルコードで Unimplemented DMA direction として処理される
  • カーネルドライバの gpu_fb_write は次の手順でユーザーデータを DMA に渡す
    • kmalloc(count, GFP_KERNEL) でカーネルバッファを割り当て
    • copy_from_user でユーザーデータをカーネルバッファにコピー
    • dma_map_single(&gpu->pdev->dev, kbuf, count, DMA_TO_DEVICE) で DMA アドレスを生成
    • execute_dma(gpu, DIR_HOST_TO_GPU, dma_addr, *offset, count) を呼び出し
    • kfree(kbuf) でバッファを解放
  • この方式はサンプルシステムで約 300µs と測定されるほど高速化する

DMA 完了を MSI-X 割り込みで知らせる

  • DMA 実行は非同期なので、write が完了するまでブロックされるようにするとより便利
  • PCI-e カードは Message Signalled Interrupts で CPU に信号を送れる
  • MSI は専用の電気的接続を使う古典的な割り込みとは異なり、バス上の通常のメッセージパケットとして割り込みを届ける
  • MSI-X 設定のため、QEMU デバイスには 2 つの領域を置く
    • 各割り込み設定を保存する MSI-X table
    • pending interrupt bitmap である PBA
  • サンプル定数は次のとおり
    • IRQ_COUNT1
    • IRQ_DMA_DONE_NR0
    • MSIX_ADDR_BASE0x1000
    • PBA_ADDR_BASE0x3000
  • QEMU の pci_gpu_realizemsix_initmsix_vector_use を呼び出し、MSI-X を初期化する
  • lspci -vv では MSI-X が有効化され、vector table は BAR0 offset 00001000、PBA は BAR0 offset 00003000 と表示される
  • pci_dma_read が終わったあと、msix_notify(&gpu->pdev, IRQ_DMA_DONE_NR) を呼び出して割り込みを送る

カーネル IRQ ハンドラと bus mastering

  • カーネルドライバは pci_alloc_irq_vectors で MSI-X/MSI ベクタを割り当て、pci_irq_vector で IRQ 番号を取得する
  • request_threaded_irqGPU-Dma0 ハンドラを登録する
  • ブート後の /proc/interrupts には、例のように IRQ 24PCI-MSIX-0000:00:02.0GPU-Dma0 として表示される
  • 最初は動作しないが、それはカードが CPU へ独立してメッセージを送る権限を持っていないため
  • デバイスが CPU の介入なしにシステムメモリを直接操作できるようにする機能が bus mastering
  • カーネルの gpu_probepci_set_master(pdev) を呼び出すと、デバイスに bus master 権限が付与される
  • その後 write を 2 回呼び出すと、カーネルログに IRQ 24 received が 2 回出力される

wait queue で実際の blocking write を実装する

  • 割り込みベースの通知が準備できたら、Linux wait queuewrite をブロッキング呼び出しに変えられる
  • グローバル状態として wait_queue_head_t wqvolatile int irq_fired = 0 を置く
  • IRQ ハンドラは次の作業を行う
    • irq_fired = 1 で完了状態を設定
    • wake_up_interruptible(&wq) で待機中のスレッドを起こす
    • IRQ_HANDLED を返す
  • setup_msi には init_waitqueue_head(&wq) を追加する
  • gpu_fb_write は DMA 実行後に wait_event_interruptible(wq, irq_fired != 0) で割り込みを待つ
  • 待機中に割り込まれた場合は -ERESTARTSYS を返す

QEMU コンソールに framebuffer を表示する

  • ユーザー空間の write(2) を受け取り、DMA で PCI-e デバイスへ渡す framebuffer ができたので、QEMU コンソール出力につないで動作する GPU のように見せる
  • QEMU の GpuStateQemuConsole* con を追加する
  • pci_gpu_realizegraphic_console_init によりコンソールを作り、qemu_console_surface で display surface を取得する
  • 初期テストパターンは 640×480 範囲の surface データに値を埋めて表示する
  • vga_update_displaygpu->framebuffer の内容を QEMU display surface にコピーする
  • dpy_gfx_update(gpu->con, 0, 0, 640, 480) で 640×480 領域を更新する
  • 以後 underlying device にパターンを書き込むと表示が変わる
  • ソースコードは the Github repo にある

参考資料

1件のコメント

 
GN⁺ 2024-07-29
Hacker Newsのコメント
  • このシリーズの最終目標は、FPGAでディスプレイアダプタを作ることらしい。
    始めるために Tang Mega 138k [0] を買ったものの、ドキュメントがあまり多くなく時間がかかっているとのこと。
    PCI-e ハードIPを備えた安価な FPGA ボードのおすすめがあれば知りたいとのこと。
    [0]: https://wiki.sipeed.com/hardware/en/tang/tang-mega-138k/mega...
  • Linux PCIe デバイスドライバ入門としてとても良さそう。
    Linux のデバイスドライバを直接触ったことはないが、数年前に別の OS で複数の PCIe ドライバを扱ったことがあり、概念はとても見覚えがある。
    こういう種類のコンテンツがもっと増えてほしい。
  • 記事の流れが本当に気に入った。
    要点を示すのに十分な量だけコードを載せて、段階的に積み上げていくやり方が良い。
    一生のうちで新しいPCI デバイスを作りたいと思ったことはなかったのに、今は少し作ってみたくなったし、これこそ良い技術文章のリトマス試験のようなものではないかと思う。
  • こういう記事を書いてくれて本当にありがたいし、珍しい分野で非常に実用的かつ情報量が多い
    プロジェクト用の開発・プレイテスト環境を作りたかったのに、検索語すら分からなかったところ、まさに必要だった内容だった。
    ほかの 2 編も良かったし、ブートサービスドライバのコードを終了後に使う方法、バスマスタリング、MSI-X のような実践的な内容や、細かいが役立つディテールがたくさんあった。