オープンソースツールでTang Nano 9KのLチカからPWMブリージングLEDへ
Yosys、nextpnr-himbaechel、openFPGALoaderをインストールし、三角波とガンマ補正のルックアップ、自作のPWMモジュールで、Tang Nano 9Kの6個のLEDをゆっくり明滅させます。
| ボード | Sipeed Tang Nano 9K |
|---|---|
| 所要時間 | 約2時間 |
| 言語 | verilog |
| 追加部品 | Sipeed Tang Nano 9K、USB-Cケーブル、Linux、macOS、Windowsのいずれかが動くコンピュータ |
作るもの
Tang Nano 9Kは、完全なオープンソースFPGAフローが動く最も安価なボードです。このプロジェクトでは、空のディレクトリから始めて、6個のLEDが波のように順に明るくなったり暗くなったりするところまで進みます。Gowin EDAもライセンスも必要ありません。ツールをインストールし、回路図を見ながら制約ファイルを書き、Yosys、nextpnr、Gowinのパッカーを実行するMakefileを作り、三角波をガンマ風のルックアップに通して明るさを決めるPWMモジュールを書きます。一方のボタンを押している間は波が速くなり、もう一方のボタンでリセットします。
はじめてのHDLモジュールを終えていれば、Verilogの知識は十分です。新しく学ぶのはツールチェーンと、「LEDには半分点灯という状態がないので、明るさは時間でごまかすしかない」という考え方です。
必要なもの
- Sipeed Tang Nano 9K(Gowin GW1NR-LV9QN88PC6/I5、8,640 LUT)と、給電だけでなくデータ通信に対応したUSB-Cケーブル。
- Linux、macOS、Windowsのいずれかが動くコンピュータ。最も手間が少ないのはLinuxです。WindowsではUSBパススルーを使ったWSL2でも動きますが、ツールをネイティブにインストールするより手間がかかります。
- オープンソースフロー: 合成にYosys、配置配線にApiculaのGowinデータベースを使うnextpnr-himbaechel、ビットストリーム生成にApiculaの
gowin_pack、ボードへの書き込みにopenFPGALoader。 - 任意: 付属のPWMセルフテストを実行するIcarus Verilogと、lintのためのVerilator。
ツールのインストール
一番簡単なのはYosysHQのoss-cad-suiteです。上記のすべてのツールをLinux、macOS、Windows向けにまとめたナイトリービルドです。YosysHQのGitHubリリースページから自分のプラットフォーム用のアーカイブをダウンロードし、恒久的に置ける場所に展開して、そのbinディレクトリをパスに追加します。
tar -xzf oss-cad-suite-linux-x64-2026*.tgz -C ~/opt
source ~/opt/oss-cad-suite/environment
yosys --version
nextpnr-himbaechel --version
openFPGALoader --version
Linuxでは、openFPGALoaderに同梱されているudevルール(99-openfpgaloader.rules)を追加するとsudoなしで書き込めます。追加したらボードを抜き差ししてください。macOSでは追加の作業は不要です。ディストリビューションのパッケージを使いたい場合、DebianとUbuntuにはyosys、nextpnr-gowin、openfpgaloaderがありますが、ナイトリービルドより古く、himbaechelアーキテクチャが含まれていないこともあります。頼る前にnextpnr-himbaechelが存在するか確認してください。
設計
FPGAのピンから駆動されるLEDは、点灯か消灯かのどちらかです。半分の明るさに見せるには、短い周期ごとにその半分の時間だけ点灯させます。周期が十分に短ければ、目はパルスを平均して、安定した暗めの光として認識します。これがパルス幅変調(PWM)で、点灯している時間の割合をデューティと呼びます。
pwmモジュールは、フリーランニングの8ビットカウンタと1つの比較器です。カウンタがデューティ値より小さい間、出力はHighになります。デューティ0では一度も点灯せず、デューティ255では256クロックのうち255クロック点灯します。カウンタは256クロックごとに一周するので、27 MHzではPWM周波数は27,000,000 / 256 = 105 kHzになります。
PWM周波数とちらつき
周波数は、初心者が思う以上に重要です。およそ60 Hzを下回ると、ほとんどの人はちらつきを直接感じます。約60 Hzから数百Hzの間では、じっと見ている分には安定して見えますが、目やボードを動かすと点の軌跡が見え、スマートフォンのカメラには縞模様が映ります。数kHzを超えれば、どれも見えなくなります。PWM周波数を高くする代償は分解能です。クロックが固定なら、デューティのビット数を増やすほど周期は長くなります。27 MHzなら、105 kHzで8ビット、または6.6 kHzで12ビットを確保でき、どちらも目で検知できる範囲をはるかに上回ります。モジュールのtick入力を使えば、プリスケーラでカウンタを遅くして、ちらつきを自分の目で確かめることもできます。
三角波とガンマルックアップ
三角波は0から255まで数えてから折り返します。105,468クロックごとのステップパルスで1つ進むので、1回の明滅には512ステップ、約2 sかかります。このレベルをそのままデューティに入れると、LEDは早い段階で最大の明るさに達してそのまま止まっているように見えます。目の応答が線形ではなく対数に近いためです。レベルを2乗して上位バイトを残す(通常の2.2ではなく、2.0という粗いガンマ)と、知覚される明るさの変化がずっと滑らかになります。この乗算は実質的にルックアップです。入力256通り、出力256通りで、合成ツールはDSPブロックか数個のLUTを選びます。
各LEDにはレベルに位相オフセットを加えた値を与えるので、6つの出力は同じ波を時間方向にずらしたものになり、光がボードの上を流れていくように見えます。
コード
Makefileはtop.vだけを読むので、すべてを1つのファイルにまとめています。pwmモジュールとtriangleモジュールは同じファイルに置けるほど小さく、あとで別ファイルに移すこともできます。
// Breathing LEDs on the Tang Nano 9K: a triangle wave feeds a gamma lookup, which feeds six PWM channels.
// One PWM channel. The output is high while the free-running counter is below the duty value.
module pwm #(
parameter integer BITS = 8
) (
input wire clk,
input wire rst,
input wire tick, // advance the counter on this (1 = every clock)
input wire [BITS-1:0] duty, // 0 = off, 255 = almost fully on
output reg out
);
reg [BITS-1:0] count;
always @(posedge clk) begin
if (rst) begin
count <= {BITS{1'b0}};
out <= 1'b0;
end else begin
if (tick) count <= count + {{(BITS-1){1'b0}}, 1'b1};
out <= (count < duty);
end
end
endmodule
// Triangle wave: counts 0..255 then 255..0, one step per 'step' pulse.
module triangle (
input wire clk,
input wire rst,
input wire step,
output reg [7:0] level
);
reg rising;
always @(posedge clk) begin
if (rst) begin
level <= 8'd0;
rising <= 1'b1;
end else if (step) begin
if (rising) begin
if (level == 8'd255) rising <= 1'b0;
else level <= level + 8'd1;
end else begin
if (level == 8'd0) rising <= 1'b1;
else level <= level - 8'd1;
end
end
end
endmodule
module top (
input wire clk, // 27 MHz crystal on pin 52
input wire s1_n, // button S1, active low
input wire s2_n, // button S2, active low
output wire [5:0] led_n // six LEDs, active low
);
localparam integer CLK_HZ = 27_000_000;
// Synchronise the buttons and derive an active-high reset from S1.
reg [1:0] s1_sync, s2_sync;
always @(posedge clk) begin
s1_sync <= {s1_sync[0], ~s1_n};
s2_sync <= {s2_sync[0], ~s2_n};
end
wire rst = s1_sync[1];
wire fast = s2_sync[1]; // hold S2 to breathe four times faster
// Step pulse for the triangle wave. 512 steps per breath, about 2 s per breath.
localparam integer STEP_SLOW = CLK_HZ / 256; // 105,468 clocks per step
localparam integer STEP_FAST = STEP_SLOW / 4;
reg [16:0] step_cnt;
reg step;
wire [16:0] step_limit = fast ? STEP_FAST[16:0] : STEP_SLOW[16:0];
always @(posedge clk) begin
step <= 1'b0;
if (rst || step_cnt >= step_limit - 17'd1) begin
step_cnt <= 17'd0;
step <= ~rst;
end else begin
step_cnt <= step_cnt + 17'd1;
end
end
wire [7:0] level;
triangle u_tri (.clk(clk), .rst(rst), .step(step), .level(level));
// Gamma-ish correction: perceived brightness is roughly the square root of duty,
// so square the level to make the ramp look linear. 255 * 255 >> 8 = 254.
function [7:0] gamma(input [7:0] x);
/* verilator lint_off UNUSEDSIGNAL */
reg [15:0] sq; // the low byte is deliberately dropped
begin
sq = x * x;
gamma = sq[15:8];
end
/* verilator lint_on UNUSEDSIGNAL */
endfunction
// Each LED gets the wave with a phase offset so the glow appears to travel.
wire [7:0] lvl [0:5];
assign lvl[0] = level;
assign lvl[1] = level + 8'd40;
assign lvl[2] = level + 8'd80;
assign lvl[3] = level + 8'd120;
assign lvl[4] = level + 8'd160;
assign lvl[5] = level + 8'd200;
genvar i;
generate
for (i = 0; i < 6; i = i + 1) begin : g_led
wire pwm_out;
reg [7:0] duty;
always @(posedge clk) duty <= gamma(lvl[i]);
pwm #(.BITS(8)) u_pwm (
.clk(clk), .rst(rst), .tick(1'b1), .duty(duty), .out(pwm_out)
);
assign led_n[i] = ~pwm_out; // LEDs light when the pin is low
end
endgenerate
endmodule
PWMセルフテスト
短いテストベンチで、いくつかのデューティ値について、256クロックの1周期のうち出力がHighになるクロック数を数えます。ビルドには不要ですが、実行してもコストはかかりません。
`timescale 1ns / 1ps
// Checks that a pwm channel is high for exactly 'duty' clocks out of every 256.
module tb_pwm;
reg clk = 1'b0;
reg rst = 1'b1;
reg [7:0] duty = 8'd0;
wire out;
always #18.5 clk = ~clk; // roughly 27 MHz
pwm #(.BITS(8)) dut (.clk(clk), .rst(rst), .tick(1'b1), .duty(duty), .out(out));
integer highs, k, errors = 0;
task measure(input [7:0] d);
begin
duty = d;
@(posedge clk); @(posedge clk);
highs = 0;
for (k = 0; k < 256; k = k + 1) begin
@(posedge clk);
if (out) highs = highs + 1;
end
if (highs !== d) begin
errors = errors + 1;
$display("FAIL: duty %0d gave %0d high clocks", d, highs);
end else begin
$display("ok: duty %0d -> %0d/256 high", d, highs);
end
end
endtask
initial begin
repeat (3) @(posedge clk);
rst = 1'b0;
measure(8'd0);
measure(8'd1);
measure(8'd64);
measure(8'd128);
measure(8'd255);
if (errors == 0) $display("PASS");
else $display("FAIL: %0d errors", errors);
$finish;
end
endmodule
制約
Gowinフローの物理制約はCSTファイルに書きます。お使いのボードリビジョンのSipeedの回路図と照らして、すべてのピンを確認してください。LEDは3.3 Vバンクにあります。9Kでは2つのボタンが1.8 Vバンクにあるため、I/Oタイプが異なります。ボタンも6個のLEDもすべて負論理です。
// Tang Nano 9K physical constraints. Check every pin against the Sipeed schematic.
IO_LOC "clk" 52;
IO_PORT "clk" IO_TYPE=LVCMOS33 PULL_MODE=NONE;
IO_LOC "s1_n" 4;
IO_PORT "s1_n" IO_TYPE=LVCMOS18 PULL_MODE=UP;
IO_LOC "s2_n" 3;
IO_PORT "s2_n" IO_TYPE=LVCMOS18 PULL_MODE=UP;
IO_LOC "led_n[0]" 10;
IO_LOC "led_n[1]" 11;
IO_LOC "led_n[2]" 13;
IO_LOC "led_n[3]" 14;
IO_LOC "led_n[4]" 15;
IO_LOC "led_n[5]" 16;
IO_PORT "led_n[0]" IO_TYPE=LVCMOS33 DRIVE=8;
IO_PORT "led_n[1]" IO_TYPE=LVCMOS33 DRIVE=8;
IO_PORT "led_n[2]" IO_TYPE=LVCMOS33 DRIVE=8;
IO_PORT "led_n[3]" IO_TYPE=LVCMOS33 DRIVE=8;
IO_PORT "led_n[4]" IO_TYPE=LVCMOS33 DRIVE=8;
IO_PORT "led_n[5]" IO_TYPE=LVCMOS33 DRIVE=8;
Makefile
# Open-source flow for the Tang Nano 9K
TOP = top
DEVICE = GW1NR-LV9QN88PC6/I5
FAMILY = GW1N-9C
BOARD = tangnano9k
all: $(TOP).fs
$(TOP).json: $(TOP).v
yosys -p "read_verilog $(TOP).v; synth_gowin -top $(TOP) -json $(TOP).json"
pnr.json: $(TOP).json $(TOP).cst
nextpnr-himbaechel --json $(TOP).json --write pnr.json \
--device $(DEVICE) --vopt family=$(FAMILY) --vopt cst=$(TOP).cst
$(TOP).fs: pnr.json
gowin_pack -d $(FAMILY) -o $(TOP).fs pnr.json
load: $(TOP).fs
openFPGALoader -b $(BOARD) $(TOP).fs
flash: $(TOP).fs
openFPGALoader -b $(BOARD) -f $(TOP).fs
sim:
iverilog -g2012 -o sim.vvp tb_pwm.v top.v && vvp sim.vvp
lint:
verilator --lint-only -Wall -Wno-DECLFILENAME --top-module top top.v
clean:
rm -f $(TOP).json pnr.json $(TOP).fs sim.vvp
.PHONY: all load flash sim lint clean
ビルドと書き込み
3つのファイルを1つのディレクトリに置いた状態で、次を実行します。
make # synthesise, place, route, pack: produces top.fs
make load # write the bitstream into SRAM (lost at power-off)
make flash # or write it to the on-board flash so it survives a power cycle
各ステップを手で実行して、何をしているか確かめることもできます。
yosys -p "read_verilog top.v; synth_gowin -top top -json top.json"
nextpnr-himbaechel --json top.json --write pnr.json \
--device GW1NR-LV9QN88PC6/I5 --vopt family=GW1N-9C --vopt cst=top.cst
gowin_pack -d GW1N-9C -o top.fs pnr.json
openFPGALoader -b tangnano9k top.fs
Yosysは最後にリソースの集計を表示します。LUTは200個未満、フリップフロップは数個のはずです。nextpnrは達成した最大クロック周波数を報告し、27 MHzを大きく上回るはずです。openFPGALoaderがデバイスを見つけられないと言う場合は、ケーブルがデータ通信に対応しているか、Linuxではudevルールがインストールされているかを確認してください。
セルフテストとlintを実行するには次のようにします。
make sim
make lint
シミュレーションはok:の行を5つとPASSを表示します。
テスト
書き込み直後から、6個のLEDが列の端から端へ流れる波のように明滅し、2 s前後で繰り返します。S2を押している間は波が4倍速くなります。S1を押している間はすべてのLEDが消え、離すと波が最初から再開します。
スマートフォンのカメラをLEDに向けてみてください。105 kHzはどんなカメラの露光レートよりはるかに高いので、縞模様は見えないはずです。次に、pwmインスタンスのtick入力を1'b1からstepに変えて再ビルドしてください。PWMカウンタは三角波の1ステップごとに1つ進むようになるので、PWM周期は256ステップ、約1 sになり、「ゆっくりした明滅」は明らかな遅い点滅に変わります。PWM周波数が低すぎるとこう見えます。
試してみること
tickがNクロックごとに立つようにプリスケーラレジスタを追加し、ボードを動かしてもちらつきが見えなくなる最低のPWM周波数を実験で見つけてください。- 2乗のルックアップを、短いPythonスクリプトで生成して
case文に格納した本来の2.2のガンマテーブルに置き換えてください。変化の滑らかさを目で比べてみましょう。 - 分解能を12ビットに上げて、8ビットPWMでは届かない最も暗い段階を観察してください。
- 同じモジュールから、ボード上のRGB LCDのバックライトや、MOSFET経由の外部LEDストリップを駆動してください。
- 同じ設計をGowin EDAでビルドして、LUT数と報告される最大周波数をオープンソースの結果と比べてください。
まとめ
- オープンソースのGowinフローは、Yosys、Apiculaを使うnextpnr-himbaechel、
gowin_pack、openFPGALoaderで構成され、oss-cad-suiteにすべてが入っています。 - CSTファイルにはピン位置とI/O規格を書きます。必ず回路図と照らして確認してください。
- PWMは時間から明るさを作ります。目に見えるちらつきやカメラの縞模様を避けるため、周波数はkHz台に保ちます。
- 知覚される明るさはデューティに対して線形ではないので、比較器に渡す前にレベルを2乗します。
- 小さなモジュール1つを異なる入力で6回インスタンス化するのが、FPGA流の設計のスケールのさせ方です。