🤖

PYNQ-Z2ボードでnand2tetrisのCPUを動かす 8

に公開

の続き。

今まではずっとベア・メタル環境を前提にVitis上でZynq PS部のARM CPUコードを開発していた。
しかし、今使用しているボードは名前の通りPYNQ環境に対応したボードであるので、せっかくだからPYNQ環境でも動作させてみようと思う。

PYNQとは?

Zynqでの開発スタイルは、大きく2つに分かれる。

  • ベア・メタル(OSなし)
  • Linuxベース(PYNQ)

ベア・メタル(OSなし)

ベア・メタルでは、OSを使わずにCPU上で直接プログラムを動かす。ずっと今まで使ってきた開発環境である。マイコンの組み込み開発に近いものである。

  • 開発環境
    • Vivado / Vitis
    • クロスコンパイル(ARM用)
    • ELFを書き込み実行
  • 特徴
    • ハードウェアに直接アクセス
    • メモリ・キャッシュ管理も自前
    • 基本的に1つのプログラムが全てを制御

PYNQ(Linuxベース)

PYNQはZynqのPS部で動くLinux(PetaLinux)上で動作するPython環境である。

  • 開発環境
    • Jupyter Notebook(ブラウザ)
    • Pythonスクリプト
    • SSHでリモート実行も可能
  • 特徴
    • PythonからFPGA制御
    • ファイルとして.bitをロード

PYNQ環境では、PL部のFPGAイメージのロードやそのモジュールの操作をLinux+Pythonベースで簡単にできる。
ただ、PL部単体で動作させるだけだとVitisのベアメタル環境で動かすのと手間などはそうそう変わらない。真価を発揮するのは、PythonでPS側の例えばUSBとかTCP/IPと組み合わせてアプリを作る場合や、Python自身で何か重い処理をして一部をPL側にオフロードするような時かなと思う。

PYNQ環境の立ち上げ

PYNQ-Z2ボードを使用していればビルド済みのイメージが用意されているので、それをマイクロSDカードにコピーしてSDカードからブートさせれば良い。
基本的には以下のページに従う。

SDカードのイメージのダウンロード

このページの中ほどにVivadoのVersionとSDカード・イメージの対応表があり、対応したイメージをダウンロードする。Vivado 2024.1を使用していたのでv3.1のイメージを使用した。
ダウンロードはここからできる。v3.1.1がダウンロードできるので実際にはそれを利用した。

SDカードのイメージの書き込み

SDカードへの書き込みは、ここに書いてあるが、基本的にはChat GPTに聞いて進めた。

SDカードをボードに挿して起動

起動オプションのジャンパを"SD"にセットしてボードの電源をON。他にはJupyer notebookをブラウザで使用するのでEthernetケーブルを接続してホストPCと通信できるようにする。また、最初はDHCP接続でIPが決まるので、USB-UARTケーブルでつないでコンソールで操作が必要となる。
jupyterを立ち上げるまでは検索すると色々出てくるのでここでは割愛。

Hardware定義ファイル

FPGAイメージを読み込ませるためVivadoプロジェクトから下記2つのファイルを使用する。

ファイル 場所
.bit [project_name]/[project_name].runs/impl_1/[block_design_name]_wrapper.bit
.hwh [project_name]/[project_name].gen/sources_1/bd/[block_design_name]/hw_handoff/[block_design_name].hwh

PYNQ環境にアップロードする際に
[hw_name].bit
[hw_name].hwh
と名前を統一する。([hw_name]は任意だが以下ではhackcpuとしている。)

jupyter notebook で実行させるコード

ほとんど元々のC++ファイルを読ませてChat GPTに変換してもらった。
このソースコードと同じディレクトリに.bit、.hwh と pong のROMコードをCのヘッダファイルにしたものを置いておく想定。実行するとPongのゲームが始まる。Game overになっても、Jupyter側で停止するまでループして実行される。

ipynb コード
hackcpu.ipynb
import numpy as np
import time
from pynq import Overlay, allocate, MMIO

# ----------------------------------------
# Overlayロード
# ----------------------------------------
ol = Overlay("hackcpu.bit") # .bit だけではなく同名の .hwh もロードされる

# ----------------------------------------
# IP情報取得(ドライバ回避)
# ----------------------------------------
# --- VDMA ---
vdma_info = ol.ip_dict['axi_vdma_0']
vdma = MMIO(vdma_info['phys_addr'], vdma_info['addr_range'])

# --- vidoutgen(+0x10注意)---
vid_info = ol.ip_dict['vidoutgen_0']
vid = MMIO(vid_info['phys_addr'] + 0x10, vid_info['addr_range'])

# --- HackCPU(+0x80注意)---
hack_info = ol.ip_dict['hackcpu_if_0']
hackcpu = MMIO(hack_info['phys_addr'] + 0x80, hack_info['addr_range'])

# ----------------------------------------
# フレームバッファ確保(Cの0x14000000の代替)
# ----------------------------------------
width = 1280
height = 720

frame = allocate(shape=(height, width), dtype=np.uint32)

# ランダム描画(Cコード相当)
frame[:] = np.random.randint(0, 0xFFFFFFFF, size=(height, width), dtype=np.uint32)

# キャッシュフラッシュ(重要)
frame.flush()

# ----------------------------------------
# VDMA設定(完全にCコード準拠)
# ----------------------------------------
# reset
vdma.write(0x00, 0x4)
# genlock
vdma.write(0x00, 0x8)
# start address
vdma.write(0x5C, frame.physical_address)
# hsize (bytes)
vdma.write(0x54, width * 4)
# stride
vdma.write(0x58, width * 4)
# enable
vdma.write(0x00, 0x83)
# vsize(これでDMA開始)
vdma.write(0x50, height)

# ----------------------------------------
# ヘッダからROM読み込み
# ----------------------------------------
def load_rom_from_header(path):
    rom = []
    with open(path) as f:
        for line in f:
            line = line.strip()
            if line.startswith("0b"):
                line = line.rstrip(",")
                rom.append(int(line, 2) & 0xFFFF)
    return rom

# ----------------------------------------
# 16bitアクセス
# ----------------------------------------
def write16(mmio, offset, value):
    base = offset & ~0x3        # 4byte境界に揃える
    shift = (offset & 0x2) * 8  # 0 or 16

    current = mmio.read(base)
    mask = 0xFFFF << shift

    newval = (current & ~mask) | ((value & 0xFFFF) << shift)
    mmio.write(base, newval)


def read16(mmio, offset):
    base = offset & ~0x3
    shift = (offset & 0x2) * 8

    val = mmio.read(base)
    return (val >> shift) & 0xFFFF

# ----------------------------------------
# HackCPUコマンド
# ----------------------------------------
def axireg_start_command(mmio, word, params):
    if read16(mmio, 0x04) & 0x1:
        return False

    write16(mmio, 0x04, 0)
    write16(mmio, 0x06, word)
    write16(mmio, 0x08, len(params))

    for i, p in enumerate(params):
        write16(mmio, 0x0A + i*2, p)

    write16(mmio, 0x02, 1)
    return True

def wait_done(mmio):
    while True:
        status = read16(mmio, 0x04)
        if status & (1 << 1):
            break

# ----------------------------------------
# test_bench_axireg 移植
# ----------------------------------------
def test_bench_axireg(mmio, rom):

    # UART disable
    write16(mmio, 0x00, 0)

    # コマンド定義(Cと一致させる)
    SET_RESET_CONFIG = 0x02
    LOAD_TO_IRAM     = 0x11
    NORMAL_OPERATION = 0x01

    RESET_BIT_RESET = 1 << 0
    RESET_BIT_HALT  = 1 << 1

    # Reset
    axireg_start_command(mmio, SET_RESET_CONFIG, [RESET_BIT_RESET | RESET_BIT_HALT])
    wait_done(mmio)

    axireg_start_command(mmio, SET_RESET_CONFIG, [RESET_BIT_HALT])
    wait_done(mmio)

    # ROMロード
    ptr = 0
    one_length = 16

    while ptr < len(rom):
        chunk = rom[ptr:ptr+one_length]
        params = [ptr, len(chunk)] + chunk
        ptr += len(chunk)

        axireg_start_command(mmio, LOAD_TO_IRAM, params)
        wait_done(mmio)

    # 実行
    axireg_start_command(mmio, NORMAL_OPERATION, [])
    wait_done(mmio)

# ----------------------------------------
# Vidoutgen設定(完全にCコード準拠)
# ----------------------------------------
# ----------------------------------------
# vidoutgen_quick_test 相当
# ----------------------------------------
def vidoutgen_quick_test(mmio, frame_addr):
    # BG
    mmio.write(0x04, 1280)  # bg_width
    mmio.write(0x08, 720)   # bg_height
    # bg_color (0x11,0x11,0x11)
    bg_color = (0x11) | (0x11 << 8) | (0x11 << 16)
    mmio.write(0x0C, bg_color)
    # FG
    mmio.write(0x10, 512)   # fg_width
    mmio.write(0x14, 256)   # fg_height
    mmio.write(0x18, (1280-512)//2)  # offset_x
    mmio.write(0x1C, (720-256)//2)   # offset_y
    # fg_color0 (RGBA)
    fg_color0 = (0xFF) | (0xFF << 8) | (0xFF << 16) | (0x00 << 24)
    mmio.write(0x20, fg_color0)
    # buffer address
    mmio.write(0x28, frame_addr & 0xFFFFFFFF)
    mmio.write(0x2C, (frame_addr >> 32) & 0xFFFFFFFF)
    # enable
    mmio.write(0x00, 1 << 0)
    # cls (セット→待ち→クリア)
    mmio.write(0x00, (1 << 0) | (1 << 1))  # enable + cls
    # 適当な待ち(Cのforループ相当)
    time.sleep(0.01)
    mmio.write(0x00, (1 << 0))  # cls clear

# ========================================
# メイン・ループ
# ========================================
while True:
    print("running...")
    # ----------------------------------------
    # vidoutgen設定
    # ----------------------------------------
    vidoutgen_quick_test(vid, frame.physical_address)

    # ----------------------------------------
    # HackCPU起動
    # ----------------------------------------
    rom = load_rom_from_header("rom_pong.h")
    test_bench_axireg(hackcpu, rom)
    
    time.sleep(2)

注意ポイント

PYNQの標準VDMAドライバは使えない

今回の実装がPYNQの想定とずれているため。

本設計で使用しているVDMAの実装

  • MM2S(メモリ→ストリーム)だけ使ってる
  • interrupt未使用
  • レジスタ直叩き前提

PYNQの想定

  • 両チャネルあり(MM2S / S2MM)
  • interrupt接続済み
  • フレーム管理もやる

解決方法: PYNQドライバを使わずにMMIO経由で直アクセス

from pynq import MMIO

vdma = MMIO(0xXXXXXXXX, 0x1000) # XXXXXXXXは実際のレジスタアドレス
vdma.write(0x00, 0x4)

MMIO経由でレジスタを叩くようにする。

ビデオ・バッファはAPIを使用して取得する

ベア・メタル環境では使っていなさそうな適当なアドレスを使ったが、Linuxが動く環境ではそうもいかない。allocate()で簡単に確保することができる。

Hack CPUモジュールのレジスタが16bitで扱いにくい

MMIO経由だと32bit単位のアクセスのため16bitレジスタはRead-Modify-Writeが必要になる。そのための関数がread16()write16()である。
ベア・メタル環境であればARM CPUからの16bitアクセスが可能だが、将来別モジュールを設計時には32bit単位で揃えた方が良さそう。

HLSで作成したモジュールのレジスタ・アドレス

これはPYNQ固有の話ではないが、HLSの自作モジュールを組み込む際に自分が定義したレジスタ・アドレスにはオフセットがつく。
モジュール自体のアドレスはVivadoで実装した際に自動的にアドレスがアサインされる。
下図が実際の例で、hackcpu0x40000000vidoutgen0x40010000に割り当てられている。
Vivadoのアドレス・エディタ
しかし、自分で定義したレジスタはこれらの先頭から始まるわけではない。
HLSでビルドした時のSynthesis Reportで確認することができる。以下ではhackcpuモジュールのレジスタ・オフセットが128(0x80)で、vidoutgenモジュールでは16(0x10)であることが確認できる。上記ipynbコードでMMIOでマッピングする際にはこのオフセットを足し込んでいる。
hackcpuモジュールのレジスタ・オフセット
vidoutgenモジュールのレジスタ・オフセット

まとめ

PYNQで簡単にLinux+PythonベースでFPGAを制御できることがわかった。Linuxのドライバを作るのは通常面倒に思えるが(というかほぼやったことがない)、バッファ確保やMMIO経由のアクセスが簡単にできるため、単純なアクセス制御であればドライバ開発を意識せずに済む。高度なアプリケーションを実装する折には活用してみたい。

「PYNQ-Z2ボードでnand2tetrisのCPUを動かす」シリーズは本稿で終わりとします。

Discussion