はじめに
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 から実行すると、
- プロジェクトを作る
- ソースファイルを登録する
- Top 関数を指定する
- ターゲットデバイスとクロックを決める
- C→RTL 合成(csynth)を回す
- 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 サンプル単位で行えるものです:
-
ミキシング
x_I = x * cos(2π f0 n) x_Q = x * -sin(2π f0 n) -
LPF(FIR, CICなど)
- N サンプルぶんの状態(過去値)を内部変数で保持するだけ
-
デシメーション
- 例えば R=8 なら、8 サンプル中 1 つだけ出力する
- AXIS の出力頻度を調整するだけ
-
(必要なら)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つあります。
-
LOの生成(NCO)
LUT方式/CORDIC方式/位相アキュムレータ方式…どれで作る? -
固定小数点のスケーリング
16bit ADC × 16bit sin/cos → 32bit になる。どこで丸める? -
符号・I/Qの定義
$Q=-x\sin$ なのか $Q=+x\sin$ なのかで、スペクトルの向きが反転します。 -
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が出た」こと自体が勝利で、ここまでが“配線と固定小数点と定義”の地雷原です。
関連記事