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 コード
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で実装した際に自動的にアドレスがアサインされる。
下図が実際の例で、hackcpuは0x40000000にvidoutgenは0x40010000に割り当てられている。

しかし、自分で定義したレジスタはこれらの先頭から始まるわけではない。
HLSでビルドした時のSynthesis Reportで確認することができる。以下ではhackcpuモジュールのレジスタ・オフセットが128(0x80)で、vidoutgenモジュールでは16(0x10)であることが確認できる。上記ipynbコードでMMIOでマッピングする際にはこのオフセットを足し込んでいる。


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