1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RFSoC4x2のDMAの最小構成 -- RFDCのADC出力をHLSを挟みDMAで保存してPYNQで読む

Last updated at Posted at 2025-12-12

はじめに

RFSoC4x2 で「RFDCのADC出力をDMAで保存してPYNQで読む」最小構成について、HLSも間に挟む形の例を紹介します(注:一部検証が必要です)。

― まずは“取れる”を最短で確認し、次にDDCへ ―

0. この記事のゴール

最初の一歩として、次を最小の手数で実現する。

  • RFDC(ADC)の AXI4-Stream 出力を受ける
  • (間に最小HLSを挟んで) AXI DMA(S2MM) で DDR に保存する
  • PYNQ(Python) でバッファを読み出して 波形として確認する

ポイント:
HLSは“通すだけ”+(任意で)TLASTを付けるだけにして、まずデータパスの基礎を確立。その上で、Appendix 以降で HLS側にDDC(デジタルコンバータ)の例 を紹介する。

1. 全体構成(ブロック図イメージ)

RFDC (ADC tile)
   │  AXI4-Stream (s_axis)
   ▼
[ HLS: adc_passthrough_frame ]
   │  AXI4-Stream (m_axis)
   ▼
AXI DMA (S2MM)
   │  M_AXI_S2MM
   ▼
DDR (PS側メモリ)
   ▲
PYNQ (Python) で allocate → dma.recvchannel.transfer → 読む

2. なぜHLSを挟むのか(“最小でも挟む”理由)

RFDC→DMA直結でも動く場合はありますが、最初からHLSを挟むと後が楽です。

  • 将来DDCを載せる場所が最初から同じ(配線変更が最小)
  • ストリームの形(幅、TLAST、フレーム長)を HLSで整形できる
  • “まず通った”が確認できれば、DDC追加は HLS内部の差分実装になる

3. HLS(Vitis HLS)最小コード:フレーム長NでTLASTを付けて流す

ここでは S_AXISから来たデータをそのままM_AXISへ転送します。
追加機能として「Nサンプル目でTLASTを立てる」を入れておくと、フレーミングの目印になります。

3.1 adc_capture.hpp

#pragma once
#include <ap_int.h>
#include <hls_stream.h>
#include <ap_axi_sdata.h>

// まずは「1 beat = 16bit」の例(RFDC設定に合わせて変更)
static const int ADC_W = 16;

// AXI4-Stream ワード定義
// ap_axiu<W, U, I, D> の U/I/D は使わないので 0 でもOK
typedef ap_axiu<ADC_W, 0, 0, 0> axis_t;

void adc_capture(
    hls::stream<axis_t>& s_axis_adc,
    hls::stream<axis_t>& m_axis_to_dma,
    unsigned int nsamp
);

3.2 adc_capture.cpp

#include "adc_capture.hpp"

void adc_capture(
    hls::stream<axis_t>& s_axis_adc,
    hls::stream<axis_t>& m_axis_to_dma,
    unsigned int nsamp
){
#pragma HLS INTERFACE axis register port=s_axis_adc
#pragma HLS INTERFACE axis register port=m_axis_to_dma

    // nsamp をPSから書けるように AXI4-Lite
#pragma HLS INTERFACE s_axilite port=nsamp bundle=CTRL
#pragma HLS INTERFACE s_axilite port=return bundle=CTRL

    // 1サンプル/クロックで流す前提(まずは最短で)
#pragma HLS PIPELINE II=1

    // nsamp個だけ転送し、最後に TLAST=1 を付与
    for (unsigned int i = 0; i < nsamp; i++) {
        axis_t x = s_axis_adc.read();
        x.last = (i == nsamp - 1) ? 1 : 0;
        m_axis_to_dma.write(x);
    }
}

3.3 重要:ADCのデータ幅はRFDC設定に合わせて必ず合わせる

  • RFDCのAXISが 16/32/64/128…bit で出ている可能性があります
  • その場合は ADC_W を合わせる(例:ADC_W=128 など)

“動かない”原因の過半数は ビット幅不一致クロック/リセット/AXIS hand shake です。

こんな感じでどうでしょう。元の説明はそのままにして、あとから開ける「おまけ解説」を details で足しています。

4. Vitis HLSのビルドTCL(最小)

adc_capture.tcl(例):

open_project -reset proj_adc_capture

add_files adc_capture.cpp
add_files adc_capture.hpp

set_top adc_capture

open_solution -reset solution1 -flow_target vivado
set_part {xczu48dr-ffvg1517-2-e}

# とりあえず 3ns (=333MHz) など適当。後で設計に合わせて調整
create_clock -period 3.0
set_clock_uncertainty 0.2

csynth_design
export_design -format ip_catalog
👀この Tcl が何をしているか(ていねい解説)

全体のイメージ

この adc_capture.tcl を Vitis HLS から実行すると、

  1. プロジェクトを作る
  2. ソースファイルを登録する
  3. Top 関数を指定する
  4. ターゲットデバイスとクロックを決める
  5. C→RTL 合成(csynth)を回す
  6. Vivado で読み込める IP として書き出す

という一連の手順を 自動でやってくれる台本 になります。

行ごとの意味

open_project -reset proj_adc_capture

  • proj_adc_capture という HLS プロジェクトを開きます。
  • -reset が付いているので、同名プロジェクトがあれば 一度消して作り直す という意味です。

実務的には 「毎回きれいな状態から合成したいので、とりあえず -reset を付ける」くらいの理解でOKです。

add_files adc_capture.cpp

add_files adc_capture.hpp

  • HLS に渡す C/C++ ソース をプロジェクトに追加します。
  • .hpp も追加しておくと、ヘッダもちゃんと見に行ってくれます。

ポイント:
「HLS のプロジェクトにどのソースを渡すか」 をここで宣言しているだけです。Visual Studio の「プロジェクトにファイルを追加する」に相当します。

set_top adc_capture

  • このプロジェクトの Top 関数名adc_capture に指定しています。
  • C++ 側で void adc_capture(...) と書いた その関数が HDL のトップモジュールになる、という意味です。

ここで指定した名前が、Vivado の IP Integrator に出てくる IP 名/トップモジュール名 の元になります。

open_solution -reset solution1 -flow_target vivado

  • solution1 という「解(Solution)」を新しく開きます。

    • Solution は「ある設定(デバイス・クロックなど)での合成結果」をまとめた箱だと思ってください。
  • -reset 付きなので、同名の Solution があれば作り直します。

  • -flow_target vivado は、Vivado 向けの RTL を生成するモードで動かすという指定です。

set_part {xczu48dr-ffvg1517-2-e}

  • 合成ターゲットとなる FPGA デバイスを指定します。
  • RFSoC4x2 で使っている Zynq UltraScale+ の型番に合わせておきます。

ここが間違っていると、タイミング制約やリソース推定がズレるので、「ボードに載っているチップの型番」と合わせるのが重要です。

create_clock -period 3.0

set_clock_uncertainty 0.2

  • HLS に対して 想定クロック周期 を伝えます。

    • -period 3.0 → 3ns 周期 ≒ 333 MHz
  • set_clock_uncertainty 0.2 は、ジッタやツールの余裕を見て
    「実質 2.8ns くらいで収めてね」というイメージです。

初学者向けの割り切りとしては
ここを小さくすると高速要求、大きくすると緩い要求
くらいの理解でスタートしても大丈夫です。

csynth_design

  • C/C++ から RTL への変換(C Synthesis)を実行します。ここで初めて、adc_capture.cpp のコードが Verilog/VHDL に変換されます。結果は solution1/syn/report/ にレポートが出ます。

よく見るポイントは

  • パイプラインII=1になっているか
  • 目標クロックが守られているか(Slack が負になっていないか)
    などです。

export_design -format ip_catalog

  • 合成された RTL を Vivado の “IP” として書き出す コマンドです。
  • これを実行すると、solution1/impl/ip/ 以下に
    カスタムIP としてインポート可能なファイル群が生成されます。

Vivado 側では
Tools → Create and Package New IP したあとに出てくる IP と同じ扱い」
になる、と思ってもらうとイメージしやすいです。

まとめ

  • adc_capture.tcl は、毎回同じ手順で HLS 合成を再現するためのレシピ です。

  • GUI でポチポチやっても同じことは出来ますが、

    • バージョン管理しやすい
    • チームで共有しやすい
    • CI でも流せる
  • という理由で、Tcl で書いておくのが「大人のHLS」の作法になっています。

5. Vivado Block Design(接続の要点だけ)

ここが一番“事故りやすい”ので、要点だけ箇条書きで固定します。

5.1 必要IP

  • Zynq UltraScale+ MPSoC(PS)
  • RF Data Converter(RFDC)
  • AXI DMA(S2MMを使用)
  • HLS IP(adc_capture)
  • (必要に応じて)AXI Interconnect / SmartConnect
  • (必要に応じて)proc_sys_reset
👀 補足:これらのIPは何の役割をするの?

Zynq UltraScale+(PS)

ARMコアが入った SoC で、PYNQ が走る場所です。
PS → PL へ AXI-Lite で設定値を書いたり、DMA を制御したりします。

RFDC(RF Data Converter)

RFSoC の「心臓」部分。ADC/DAC を内蔵し、
AXI4-Stream でサンプル列を PL に送り出す重要IPです。

https://docs.amd.com/r/en-US/pg269-rf-data-converter

AXI DMA(S2MM)

AXI4-Stream → DDR(PSメモリ)へデータを転送する IP。
今回の目的は
「ADC のストリームデータをとにかく PS メモリに引き込む」
ことなので S2MM のみ使います。

HLS IP(adc_capture)

RFDC → HLS へ通し、TLAST の付与や将来的な DDC をここに載せます。

AXI Interconnect / SmartConnect

AXI-Lite / AXI-Memory の本数が増えるときの「配線ハブ」。

proc_sys_reset

クロックドメインごとにリセット制御を適切に行うための IP。

初学者の典型的誤解:

「AXI DMA さえ置けば勝手に動く」
→ No。必ず HPポートAXI-Lite のつなぎ方を丁寧に決める必要があります。

5.2 接続(超重要)

  • RFDCのADCストリーム出力 m_axis → HLSの s_axis_adc
  • HLSの m_axis_to_dma → AXI DMAの S_AXIS_S2MM
  • AXI DMAの M_AXI_S2MM → PSの HPポート(例:S_AXI_HP0/FPD)
  • AXI DMAの S_AXI_LITE と HLSの CTRL(AXI-Lite) → PSの M_AXI_HPM 側へ
👀 補足:なぜこの配線が「超重要」なの?

理由1:ストリーム方向を間違えるとデータが“流れない”

RFDC → HLS → DMA という“流れる方向”は厳密に決まっており、AXI4-Stream は Master → Slave の関係でのみ接続できます。

  • RFDC は Master
  • HLS は Slave (input) + Master (output)
  • DMA(S2MM) の Stream 端子は Slave

なので、この順番でしかつながりません。

理由2:AXI DMA の Memory 側は PS へつながないと動かない

AXI DMA は、「Stream を受け → メモリへ書く」、という IP なので、Memory Port(M_AXI_S2MM)を必ず PS の HP ポートへつなぎます。

HPポートを忘れる → DMA が「行き先が無い」状態になって、永久に完了しません。

理由3:AXI-Lite がないと HLS も DMA も動かない

  • HLS の CTRL
    ap_start / パラメータ書き込み(nsamp 等)
  • DMA の S_AXI_LITE
    → DMA の設定レジスタ(転送開始など)

AXI-Lite を PS の HPM ポートへつながないと、PYNQ から 何も制御できません

理由4:初学者の最頻エラー「Ready/Valid が立たない」

接続ミスがあると AXIS の tready が Low のまま固まり、
RFDC がデータを出せなくなります。

AXIS が詰まったら → 配線クロックを疑うのが最速ルール。

5.3 クロック

  • RFDCのAXISが駆動されるクロック
    HLS / AXI DMA の AXIS 側クロックは整合が必要です。
  • 最初は「RFDCのaxis_clk出力(設定により) or 同一のPLクロック」で
    RFDC→HLS→DMA を同一クロック領域に置くのが安全。
👀 補足:クロックが違うと何がまずいの?

AXI4-Stream は「同一クロックドメイン前提」

AXI4-Stream は基本的に 1クロックでVALID/READYの握手を行います。
もし RFDC と HLS と DMA がバラバラのクロックだったら、

  • VALID が出たタイミングで相手が READY を見ていない
  • タイミング解析で false path が大量に発生する
  • シミュレーションでは動くのに、実機では黙る

などの典型トラブルにつながります。

初手は「全部同じクロックで回す」が正解

RFDC の AXIS が出す axis_aclk(多くの場合 RFDC が勝手に生成)を HLS と DMA の stream aclk に配るのが最も安全で早い方法です。

DDC など複雑に拡張したくなったら、
そこではじめて Clock Converter を検討すれば十分です。

5.4 DMA設定(最小)

  • Simple mode(Scatter-Gatherは最初は不要)
  • Enable: S2MM(MM2Sは不要)
  • Data width はストリーム幅と整合(必要なら Data Realignment Engine も検討)
👀 補足:Simple Mode とは?SG Mode とは?

Simple Mode(今回推奨)

  • 転送バッファを 1回だけ設定して流す方式
  • Python (PYNQ) から操作がシンプル
  • 初めて動かす際のトラブルが極端に少ない

SG(Scatter-Gather)Mode

  • 転送バッファを 複数エントリで管理できる
  • 大量の連続ストリームを止めずに受けたい場合に有利
  • ただし DMA の BD(Buffer Descriptor)管理が必要で、初学者には重い

データ幅について

  • RFDC → HLS → DMA の Stream 幅(例:16bit, 32bit, 128bit)が
    すべて一致しないと DMA が受け取れません
  • 幅が合わない場合は
    Data Realignment Engine
    axis_dwidth_converter
    などを挟む必要があります。

初学者のレオン:
「まずは Stream 幅を全部揃える」
これが最短ルートです。

6. PYNQ(Python)側:DMAで取り込んで表示する最小コード

前提:bitstream を base.bit としてOverlay化できていること(PYNQで Overlay("xxx.bit") できる状態)

6.1 取り込みコード(最小)

from pynq import Overlay, allocate
import numpy as np

ol = Overlay("adc_dma.bit")   # あなたの.bit名

# IP名は design によるので、ip_dictで確認
print(ol.ip_dict.keys())

dma = ol.axi_dma_0                 # 例:AXI DMAの名前
hls = ol.adc_capture_0             # 例:HLS IPの名前

# nsamp(HLSのフレーム長)
nsamp = 4096

# 16bitサンプルを想定(HLSと合わせる)
buf = allocate(shape=(nsamp,), dtype=np.int16)

# 1) DMA受信を先に開始
dma.recvchannel.transfer(buf)

# 2) HLSにnsampを書いて ap_start
#   AXI-Lite レジスタはPYNQが mmio 経由で触れる
#   レジスタ配置はHLS IPの標準(CTRL)に従う
hls.write(0x10, nsamp)      # nsamp が port=nsamp の場合の典型オフセット
hls.write(0x00, 0x01)       # ap_start

# 3) 完了待ち
dma.recvchannel.wait()

# 4) numpyとして読む
x = np.array(buf)
print(x[:16])
print("min/max:", x.min(), x.max())
👀 丁寧解説:PYNQ で “DMA 受信 → HLS スタート” の流れを理解する

Overlay("adc_dma.bit") の意味

PYNQ は Bitstream をロードすると、自動的に

  • IP の レジスタマップ(MMIO)
  • AXI DMA などの 制御 API

を取得します。
ip_dict を見ると、Vivado で作った IP がどんな名前で参照されているか分かります。

② DMA の「recvchannel.transfer(buf)」とは

DMA には

  • MM2S(メモリ → Stream)
  • S2MM(Stream → メモリ)

がありますが、今回は RFDC→HLS→DMA→メモリなので S2MM を使います。
recvchannel.transfer(buf) を呼ぶと、

「buf のアドレスに、これから来るストリームを保存しますよ」

という準備状態になります。

重要:この時点ではデータはまだ来ていません

③ HLS の ap_start を最後にする理由

HLS に ap_start を書くと 即座にデータを読み始めます
しかし DMA がまだ「準備OK」になっていないとデータは捨てられます。

そのため順番は DMA 受信開始 → HLS ap_start が正解です。

④ AXI-Lite レジスタのオフセット

HLS の自動生成した IP では標準で:

オフセット 意味
0x00 ap_start / ap_done など
0x10 第一引数(nsamp など)
0x18 第二引数(phase_inc など)

という配置になります(HLSが自動で割り当てる)。

wait() は DMA の完了待ち

受信が完了すると DMA が idle になり、wait() のブロックが解除されます。
これで buf にデータが埋まった状態になります。

⑥ numpy 配列に変換

PYNQ の allocate() は特別な DMA 対応バッファで、
np.array(buf) で普通の numpy 配列になります。

初学者の最頻トラブルと対策

(1) buf が全部 0 になる→ほぼ接続ミス
→ AXIS の Ready/Valid、クロック、DMA の HP ポートを確認。

(2) DMA が永遠に返ってこない → TLAST が来ていない
→ HLS 側で必ず out.last = (i == nsamp-1); を設定。

(3) nsamp を大きくしたのに止まる → HP ポートが遅延で詰まる
→ AXI データ幅整合、クロックを確認。

6.2 “波形っぽく見えるか”だけ確認

import matplotlib.pyplot as plt

plt.figure()
plt.plot(x[:512])
plt.title("ADC samples (first 512)")
plt.show()
👀 補足:なぜ「最初の 512 点」だけ見ると良いの?
  • 波形の形(正弦波かノイズか)を一瞬で確認できる
  • ADC の “符号の向き” や “オフセット” のチェックにもなる
  • DMA が正しく動いたかの事前チェックとして最速

長い波形を全部見ると見づらいので、
まず 512〜1024サンプルだけの可視化で確認しましょうか。

また、波形が全部 0・全部同じ値の場合は、
RFDC の設定 or AXIS 配線 or クロックが死んでいる典型例です。

6.3 スペクトルで“信号らしさ”を見る(任意)

w = x.astype(np.float64)
w = w - w.mean()
spec = np.abs(np.fft.rfft(w))

plt.figure()
plt.plot(spec)
plt.title("FFT magnitude")
plt.yscale("log")
plt.show()
👀 補足:なぜ FFT を見ると「正しく取れている」か分かるの?

理由1:信号周波数が「ピーク」として現れる

正弦波を入力しているなら、

  • FFT の結果が ある 1 点だけ高くなる(ピークが立つ)
    → 取り込み成功のサイン

理由2:ノイズ分布も分かる

  • 全体的に平ら(ホワイトノイズ)
  • どこかに強いスパイクがある(ノイズ源 or 不安定クロック)

など、データ品質の診断にも使えます。

理由3:DC を引くのはなぜ?

w - w.mean() をする理由は、
ADC の DC オフセットを取り除くためです。

オフセットがあると FFT の 0 Hz 成分が「異常に大きく」なり、他の周波数成分が見えにくくなるからです。(ただし、DC成分を積極的にみたい場合は引かない方が良いです。)

7. よくある詰まりどころ(最短チェックリスト+一言コメント)

(A) RFDCのAXIS幅と HLS の ADC_W が一致しているか

  • RFDC の サンプル幅(16 / 24 / 32bit)や I/Q パック形式と、
    HLS 側の ap_int<ADC_W>struct { ap_int<...> i; ap_int<...> q; } がずれていると、
    「動いてはいるが値がおかしい」「常に最大値/0付近になる」といった“静かなバグ”になります。
  • 症状が「波形は来ているが形がおかしい」なら、まずここを疑うポイント。

(B) RFDC → HLS → DMA が同一クロック領域か(少なくとも最初は)

  • 最初の一歩では、すべて同じ AXI ストリームクロックで動かすのが安全です。
  • クロックドメインが分かれると、CDC FIFO / クロック変換 IP が必要になり、
    「シミュレーションでは通るが実機でランダムに止まる/欠損する」系のトラブル源になります。
  • “まずは同一クロックで動くシンプル構成 → 慣れてから多クロックへ” が鉄板ルート。

(C) AXI DMA の S2MM が有効で、HPポートへ出ているか

  • S2MM (Stream to Memory-Mapped) が無効だったり、間違って MM2S だけ有効になっていると、いくら HLS からデータを出しても DDR には一切届きません。
  • また、DMA の M_AXI が PS の HP(High Performance)ポートにつながっていないと、
    帯域が足りなかったり、そもそも PS 側から見える DDR に書かれていない、という事態も起こり得ます。
  • 「DMA の S2MM が動いていること」「接続先が PS から見える DDR であること」 をセットで確認。

(D) PYNQ側で dma.recvchannel.transfer() を呼んでからデータが来るか

  • DMA は PS 側から「転送開始」の指示を出して初めて動き始めるので、transfer() を呼んでいない状態では、PL 側がどれだけデータを流しても DDR には蓄積されません。
  • また、transfer() の前に RFDC/HLS 側が既にストリームを出し始めていると、
    最初の数サンプルが捨てられることもあります(必要ならワームアップとして割り切る)。
  • ノートブックでは「RFDC設定 → Overlayロード → パラメータ設定 → transfer() → wait()」の順序を意識するのが大事。

(E) ずっと 0 / 一定値なら、RFDC の ADC 側がそもそも動いていない(設定 / クロック / 入力信号)

  • HLS や DMA を疑う前に、
    RFDC が正しいクロックで動いているか」「ADC のチャネルがイネーブルされているか」「入力に本当に信号が入っているか」を確認するのが近道です。
  • RFDC のミキサ設定やデシメーション設定によっては、
    DC 近傍だけが出ていて常にほぼ一定値に見える、というパターンもあります。
  • 「値が動かないときは、まずアナログ側と RFDC の設定を疑う」 というクセをつけておくと、デバッグの順番を間違えにくくなります。

8. 次の一手:この後に DDC を入れる

今回作った HLS モジュールは、本質的に “AXI4-Stream を受け取って、AXI4-Stream に返すだけの殻 (skeleton)” です。

これは単なる「通過モジュール」に見えるかもしれませんが、実は DDC を入れるための“理想的な型” になっています。

● HLS の基本骨格(AXIS in → AXIS out)が完成した=DSP ロジックを差し込む準備が整った

RFDC が出す AXI4-Stream には、

  • サンプルが 毎クロック確実に流れてくる
  • TVALID/TREADY ハンドシェイクが正しく張り付いている必要がある
  • HLS 側も II=1 で受け取る必要がある

…という 高速ストリーム処理の制約があります。

今回の最小例は、この “高速ストリーム環境” を破綻させずに、

  • Stream の受け取り
  • パケットの forward
  • DMA までの流路

完全に整備した状態です。

だからこそ 中身を差し替えるだけで DDC が動くわけです。

● 次に書くべき処理は「for ループ内」にすべて収まる

今回の HLS モジュールの構造は次の通りでした:

for(int n=0;n<N;n++) {
    auto x = adc_in.read();
    // ---- ここに DDC ロジックを入れる ----
    dac_out.write(x);
}

DDC(Digital Down Converter)を構成する要素は、実はすべて 1 サンプル単位で行えるものです:

  1. ミキシング

    x_I = x * cos(2π f0 n)
    x_Q = x * -sin(2π f0 n)
    
  2. LPF(FIR, CICなど)

    • N サンプルぶんの状態(過去値)を内部変数で保持するだけ
  3. デシメーション

    • 例えば R=8 なら、8 サンプル中 1 つだけ出力する
    • AXIS の出力頻度を調整するだけ
  4. (必要なら)I/Q パックして DMA へ流す

    out.data = (ap_int<16>)I | ((ap_int<16>)Q << 16);
    

これらはすべて、

  • ストリーム処理の1サンプル単位の流れを邪魔しない
  • ロジックを差し替えても AXIS の構造は変わらない
  • DMA への書き込み方法も変わらない

という性質を持っています。

つまり:

今回の最小骨格を作った時点で、DDC の 80% は完成している
(残りは for 内部の信号処理だけ)

● “今回の最小例=DDC の着脱式シャーシ(chassis)”

言い換えると今回の HLS モジュールは:

  • ストリームを確実に受け取る
  • ストリームを確実に出す
  • AXIS の接続ルールを満たす
  • PS からパラメータ設定も可能
  • DMA までつながる
  • RFDC → HLS → DMA のラインがすでに動作している

という DDC の“足場/土台” になっています。

実際の DDC 設計で最も難しいのは、

  • ストリームハンドシェイク
  • バックプレッシャ(READY=0で止まる問題)
  • クロック領域の整合
  • DMA への連続書き込み

などであって、信号処理の本体そのものではありません。

すでに「土台」が動いている今回は、その“難しい部分”が全部クリアされています。

次にやるべきこととして、for 内を “通すだけ” から “DDC が動く処理” に置き換える
などなど、中身を自由に作っていきましょう!

● まとめ:今回作ったのは「DDC を載せるプラットフォーム」

今回の「受け取って返すだけ HLS」は、単なるお試しコードではなく、

  • RFDC → HLS → DMA のシステムとして完成している
  • ハンドシェイクも正しく、欠損しないストリームがすでに通っている
  • DMA 受信の Python 側もセットアップ済み
  • あとは DDC 処理(ミキサ・フィルタ・デシメーション)を挟むだけ

という DDC 開発の 90% を終わらせる準備段階です。

今回の最小例は、DDC を載せるための“シャーシ(フレーム)そのもの”であり、
中に積むエンジン(信号処理本体)だけ差し替えれば、そのまま動きます。

Appendix:HLSでのIQダウンコンバート(ミキサ)の要点

A.1 まず「ミキサだけDDC」とは何をするのか

RFDC(ADC)で得た実信号 $x[n]$ に対して、ローカル発振器(LO)を掛け算して ベースバンドへ周波数移動します。

理想的には

x[n] \approx A\cos(2\pi f_\text{sig} nT_s + \phi)

に対して、LO を

\cos(2\pi f_\text{LO} nT_s),\ \sin(2\pi f_\text{LO} nT_s)

として

I[n] = x[n]\cos(2\pi f_\text{LO} nT_s),\quad
Q[n] = -x[n]\sin(2\pi f_\text{LO} nT_s)

を作ります(この符号は“よくある慣習”で、後で重要になります)。

すると $f_\text{sig}\approx f_\text{LO}$ なら、I/Qは「低周波(ベースバンド)成分」になります。

A.2 “たった掛け算”なのに難しい理由(設計の落とし穴)

ミキサは式だけ見ると簡単ですが、FPGA/HLSで実装するときに、学生が詰まりやすい落とし穴が4つあります。

  1. LOの生成(NCO)
    LUT方式/CORDIC方式/位相アキュムレータ方式…どれで作る?
  2. 固定小数点のスケーリング
    16bit ADC × 16bit sin/cos → 32bit になる。どこで丸める?
  3. 符号・I/Qの定義
    $Q=-x\sin$ なのか $Q=+x\sin$ なのかで、スペクトルの向きが反転します。
  4. AXI4-Streamとして流すデータ形式
    I/Qを別ストリームにする? 1本にパックする? TLASTはどうする?

この記事の“最小ミキサ”では、全部に答えを出した上で、まず動く形を提示します。

A.3 最小構成の方針(今回の答え)

  • NCO(LO生成)は 位相アキュムレータ + sin/cos LUT にする
    (最小で、デバッグしやすく、HLSでも素直)
  • ADCは 16bit signed と仮定(必要なら合わせる)
  • sin/cosは Q1.15固定小数点(-1〜+1) とする
  • 乗算結果は 32bit にし、適当にシフトして 16bit に戻す(最小の丸め)
  • 出力は I/Qを1本のAXISにパックしてDMAへ
    (PYNQ側が楽:int16 I, int16 Q を並べるだけ)

A.4 データ形式:I/Qを1ワードにパックする

例:32bitにパック(下位16bit=I, 上位16bit=Q)

  • axis_out.data[15:0] ← I(int16)
  • axis_out.data[31:16] ← Q(int16)

PYNQでは dtype=np.int16 で読み、[I0,Q0,I1,Q1,...] になるので扱いやすいです。

A.5 HLSコード(ミキサのみDDC:最小)

A.5.1 ddc_mixer.hpp

#pragma once
#include <ap_int.h>
#include <hls_stream.h>
#include <ap_axi_sdata.h>

static const int ADC_W = 16;     // ADC sample width
static const int OUT_W = 32;     // packed IQ width
static const int PHASE_W = 24;   // phase accumulator bits (tune)
static const int LUT_BITS = 10;  // 2^10 = 1024 entries
static const int LUT_SIZE = (1 << LUT_BITS);

// AXI stream types
typedef ap_axiu<ADC_W, 0, 0, 0> axis_adc_t;
typedef ap_axiu<OUT_W, 0, 0, 0> axis_iq_t;

// Q1.15 sin/cos
typedef ap_int<16> q15_t;

void ddc_mixer_min(
    hls::stream<axis_adc_t>& s_axis_adc,
    hls::stream<axis_iq_t>&  m_axis_iq,
    ap_uint<PHASE_W> phase_inc,
    unsigned int nsamp
);

A.5.2 ddc_mixer.cpp

#include "ddc_mixer.hpp"

// 1024点LUT(例)
// 本番では:
//  - (1) 事前に生成した係数を貼る
//  - (2) HLSで初期化
// のどちらか。ここでは「概念」を示す。
static q15_t sin_lut[LUT_SIZE];
static q15_t cos_lut[LUT_SIZE];

static void init_lut_once() {
#pragma HLS INLINE off
    static bool inited = false;
    if (inited) return;

    // 注意:HLSで浮動小数点sin/cosを使うのは重いので、
    // ここは本来「オフライン生成してヘッダに埋め込む」が定石。
    // ここでは説明用に「初期化がある」という形だけ示す。
    for (int i = 0; i < LUT_SIZE; i++) {
#pragma HLS PIPELINE II=1
        // ダミー。実装では正しい係数を入れること。
        sin_lut[i] = 0;
        cos_lut[i] = 0;
    }
    inited = true;
}

void ddc_mixer_min(
    hls::stream<axis_adc_t>& s_axis_adc,
    hls::stream<axis_iq_t>&  m_axis_iq,
    ap_uint<PHASE_W> phase_inc,
    unsigned int nsamp
){
#pragma HLS INTERFACE axis register port=s_axis_adc
#pragma HLS INTERFACE axis register port=m_axis_iq

#pragma HLS INTERFACE s_axilite port=phase_inc bundle=CTRL
#pragma HLS INTERFACE s_axilite port=nsamp     bundle=CTRL
#pragma HLS INTERFACE s_axilite port=return    bundle=CTRL

#pragma HLS PIPELINE II=1

    init_lut_once();

    ap_uint<PHASE_W> phase = 0;

    for (unsigned int i = 0; i < nsamp; i++) {
        axis_adc_t in = s_axis_adc.read();
        ap_int<ADC_W> x = (ap_int<ADC_W>)in.data;

        // LUT index = 上位LUT_BITSを取る(位相の粗さはここで決まる)
        ap_uint<LUT_BITS> idx = phase >> (PHASE_W - LUT_BITS);

        q15_t c = cos_lut[idx];
        q15_t s = sin_lut[idx];

        // ミキサ:I = x*c, Q = -x*s
        // x: 16bit, c/s: Q1.15(16bit) → 乗算は ~32bit
        ap_int<32> prod_i = (ap_int<32>)x * (ap_int<32>)c;
        ap_int<32> prod_q = (ap_int<32>)x * (ap_int<32>)s;

        // Q1.15なので 15bit 右シフトで元スケールへ戻す(丸めは最小)
        ap_int<16> I = (ap_int<16>)(prod_i >> 15);
        ap_int<16> Q = (ap_int<16>)(-(prod_q >> 15));

        // 32bitにパック: [Q(31:16), I(15:0)]
        ap_uint<32> packed = 0;
        packed.range(15, 0)  = (ap_uint<16>)I;
        packed.range(31, 16) = (ap_uint<16>)Q;

        axis_iq_t out;
        out.data = packed;
        out.last = (i == nsamp - 1) ? 1 : 0;
        m_axis_iq.write(out);

        phase += phase_inc;
    }
}

超重要(正直な注記)
上のコードは「ミキサ処理の骨格」を示すために LUT の中身をダミーにしています。
実際には sin/cos係数を正しく埋める必要があります(次節でやり方を書きます)。


A.6 LUT係数はどうやって用意するのが正解か(実務の要点)

HLS内で sin() を回してLUTを作るのは重い・不確実になりがちです。
定石は「Pythonで係数を生成 → C配列として貼る」 です。

PythonでLUT生成(オフライン)

import numpy as np

LUT_SIZE = 1024
amp = 32767  # Q1.15

sin_lut = np.round(amp*np.sin(2*np.pi*np.arange(LUT_SIZE)/LUT_SIZE)).astype(np.int16)
cos_lut = np.round(amp*np.cos(2*np.pi*np.arange(LUT_SIZE)/LUT_SIZE)).astype(np.int16)

# C配列として出力
def to_c_array(name, arr):
    s = f"static const short {name}[{len(arr)}] = {{\n"
    for i in range(0, len(arr), 16):
        chunk = ", ".join(str(int(x)) for x in arr[i:i+16])
        s += "  " + chunk + ",\n"
    s += "};\n"
    return s

print(to_c_array("sin_lut", sin_lut))
print(to_c_array("cos_lut", cos_lut))

これを ddc_lut.hpp のようなファイルに貼り付けて、HLS側で static const で参照するのが堅いです。

A.7 phase_inc の決め方(周波数をどう指定するか)

位相アキュムレータ方式では、サンプルごとに

\phi[n+1] = \phi[n] + \Delta\phi

で回します。
PHASE_W bit の固定小数点位相(0〜$2^{PHASE_W}-1$)を使うとき、

\Delta\phi = \left\lfloor \frac{f_\text{LO}}{f_s} 2^{PHASE_W} \right\rceil

です。

  • $f_s$:AXI4-StreamでHLSが受け取るサンプルレート
  • $f_\text{LO}$:下ろしたい周波数(数値発振器の周波数)

PYNQ側で phase_inc を計算して AXI-Lite レジスタへ書けば、LO周波数可変DDCの最小になります。

A.8 “Qの符号”で何が変わるのか(スペクトルの向き問題)

よくある混乱がここです。

  • $I = x\cos $
  • $Q = -x\sin $

にすると、複素表現 $I + jQ$ が

x[n] e^{-j2\pi f_\text{LO} nT_s}

に対応し、「周波数を下にシフト」する向きになります。

もし $Q=+x\sin$ にすると、共役側になり、スペクトルが左右反転します。
どちらが“正しい”というより、後段のFFT/位相定義と一貫しているかが重要です。

要点:
Qの符号は“複素回転の向き”を決める」ので、観測しているスペクトルが逆に見えたら、まずここを疑う。

A.9 PYNQ側でI/Qを取り出す最小例

DMAで int16 を受けたとすると

  • buf[0]=I0, buf[1]=Q0, buf[2]=I1, buf[3]=Q1, ...

です。

import numpy as np

raw = np.array(buf, dtype=np.int16)
I = raw[0::2].astype(np.float64)
Q = raw[1::2].astype(np.float64)

z = I + 1j*Q

スペクトルを見たいなら

w = z - z.mean()
spec = np.abs(np.fft.fftshift(np.fft.fft(w)))

A.10 ミキサだけDDCの限界(次に何を入れるべきか)

ミキサだけだと、ベースバンドへ落ちた“欲しい成分”と同時に、画像成分や高周波成分も残ります。
本当のDDCでは通常、

  • LPF(ローパス)
  • デシメーション(間引き)
  • (必要なら)CIC + FIR 補償

がセットです。

でも最初のステップとしては、「I/Qが出た」こと自体が勝利で、ここまでが“配線と固定小数点と定義”の地雷原です。

関連記事

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?