Project

From blinky to PWM breathing LEDs on the Tang Nano 9K with open-source tools

Install Yosys, nextpnr-himbaechel and openFPGALoader, then make the six LEDs on the Tang Nano 9K breathe with a triangle wave, a gamma lookup and a PWM module you write yourself.

BeginnerGowinOpen sourceUpdated 13 Nov 2026
BoardSipeed Tang Nano 9K
Timeabout 2 hours
Languageverilog
Extra partsSipeed Tang Nano 9K, USB-C cable, Computer running Linux, macOS or Windows

What you will build

The Tang Nano 9K is the cheapest board that runs a complete open-source FPGA flow, and this project takes you from an empty directory to six LEDs that glow up and down in a travelling wave. Nothing here needs Gowin EDA or a licence. You will install the tools, write a constraints file from the schematic, build a Makefile that runs Yosys, nextpnr and the Gowin packer, and write a PWM module whose brightness follows a triangle wave through a gamma-style lookup. Holding one button speeds the wave up and the other resets it.

If you have finished Your first HDL module you know enough Verilog. The new material is the toolchain and the idea that an LED has no half-on state, so brightness has to be faked with time.

What you need

  • A Sipeed Tang Nano 9K (Gowin GW1NR-LV9QN88PC6/I5, 8,640 LUTs) and a USB-C cable that carries data, not only power.
  • A computer running Linux, macOS or Windows. Linux is the smoothest path. On Windows, WSL2 with USB passthrough works but is more fiddly than a native install of the tools.
  • The open-source flow: Yosys for synthesis, nextpnr-himbaechel with the Apicula Gowin database for place and route, gowin_pack from Apicula to write the bitstream, and openFPGALoader to send it to the board.
  • Optional: Icarus Verilog to run the included PWM self-test, and Verilator to lint.

Installing the tools

The easy route is the YosysHQ oss-cad-suite, a nightly bundle of every tool above for Linux, macOS and Windows. Download the archive for your platform from the YosysHQ GitHub releases page, extract it somewhere permanent, and put its bin directory on your path:

tar -xzf oss-cad-suite-linux-x64-2026*.tgz -C ~/opt
source ~/opt/oss-cad-suite/environment
yosys --version
nextpnr-himbaechel --version
openFPGALoader --version

On Linux, add the udev rule that ships with openFPGALoader (99-openfpgaloader.rules) so you can program without sudo, then unplug and replug the board. On macOS, nothing extra is needed. If you prefer distribution packages, Debian and Ubuntu carry yosys, nextpnr-gowin and openfpgaloader, but they lag the nightly and the himbaechel architecture may be missing, so check nextpnr-himbaechel exists before you rely on it.

Design

An LED driven from an FPGA pin is either on or off. To make it look half as bright, turn it on for half of each short period. If the period is short enough the eye averages the pulses and sees a steady, dimmer light. That is pulse-width modulation, and the fraction of time on is the duty.

step divider27 MHz / 105,468triangle0..255..0phase offset+ 40 per LEDgammax² / 256pwm8-bit, 105 kHzLEDactive lowstepleveldutyone phase, gamma and pwm block per LED (six in all)S2 held: step four times faster
Signal flow from the 27 MHz clock to the six LED pins.

The pwm module is a free-running 8-bit counter and one comparison: the output is high while the counter is below the duty value. A duty of 0 is never on and a duty of 255 is on for 255 clocks out of 256. The counter wraps every 256 clocks, so at 27 MHz the PWM frequency is 27,000,000 / 256 = 105 kHz.

PWM frequency and flicker

The frequency matters more than beginners expect. Below roughly 60 Hz most people see flicker directly. Between about 60 Hz and a few hundred hertz the LED looks steady when you stare at it, but moving your eyes or the board produces a trail of dots, and a phone camera shows banding. Above a few kilohertz none of that is visible. The cost of a high PWM frequency is resolution: with a fixed clock, more bits of duty means a longer period. At 27 MHz you can afford 8 bits at 105 kHz or 12 bits at 6.6 kHz, both well above anything the eye can detect. The tick input on the module lets you slow the counter down with a prescaler if you want to see flicker for yourself.

The triangle and the gamma lookup

A triangle wave counts 0 to 255 and back, advancing on a step pulse every 105,468 clocks, so a full breath takes 512 steps, about 2 s. If you feed that level straight into the duty, the LED appears to reach full brightness early and then sit there, because the eye’s response is closer to logarithmic than linear. Squaring the level and keeping the top byte (a crude gamma of 2.0 rather than the usual 2.2) makes the perceived ramp much more even. The multiply is a lookup in effect: 256 inputs, 256 outputs, and the synthesiser will pick a DSP block or a handful of LUTs.

Each LED gets the level plus a phase offset, so the six outputs are the same wave shifted in time and the glow appears to run along the board.

Code

Everything lives in one file because the Makefile reads only top.v. The pwm and triangle modules are small enough to live there, and you can move them out later.

// 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 self-test

A short testbench counts how many clocks the output is high across one 256-clock period for a few duty values. It is not needed for the build but it costs nothing to run.

`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

Constraints

Physical constraints for the Gowin flow go in a CST file. Check every pin against the Sipeed schematic for your board revision. The LEDs sit on a 3.3 V bank. The two buttons are on a 1.8 V bank on the 9K, which is why their I/O type differs. Both buttons and all six LEDs are active low.

// 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

Build and program

With the three files in one directory:

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

Each step can be run by hand to see what it does:

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 prints a resource summary at the end. Expect under 200 LUTs and a few flip-flops. nextpnr reports the maximum clock frequency it achieved, which will be well above 27 MHz. If openFPGALoader says it cannot find the device, check the cable carries data and, on Linux, that the udev rule is installed.

To run the self-test and the linter:

make sim
make lint

The simulation prints five ok: lines and PASS.

Test

Immediately after programming the six LEDs breathe in a wave that runs from one end of the row to the other, repeating every 2 s or so. Hold S2 and the wave runs four times faster. Hold S1 and all LEDs go dark, then the wave restarts from the beginning when you let go.

Point a phone camera at the LEDs. You should see no banding, because 105 kHz is far above any camera’s exposure rate. Now change the tick input of the pwm instances from 1'b1 to step and rebuild. The PWM counter now advances once per triangle step, so the PWM period becomes 256 steps, about 1 s, and the “breathing” turns into obvious slow blinking. This is what too low a PWM frequency looks like.

Things to try

  • Add a prescaler register so that tick fires every N clocks, and find by experiment the lowest PWM frequency at which you cannot see flicker when you move the board.
  • Replace the squared lookup with a proper 2.2 gamma table generated by a short Python script and stored in a case statement. Compare the ramp by eye.
  • Raise the resolution to 12 bits and watch the lowest brightness steps, which an 8-bit PWM cannot reach.
  • Drive the on-board RGB LCD backlight or an external LED strip through a MOSFET from the same module.
  • Build the same design with Gowin EDA and compare the LUT count and the reported maximum frequency with the open-source result.

Summary

  • The open-source Gowin flow is Yosys, nextpnr-himbaechel with Apicula, gowin_pack and openFPGALoader, and oss-cad-suite bundles all of them.
  • A CST file carries pin locations and I/O standards. Always check it against the schematic.
  • PWM makes brightness out of time. Keep the frequency in the kilohertz to avoid visible flicker and camera banding.
  • Perceived brightness is not linear in duty, so square the level before it reaches the comparator.
  • One small module instantiated six times with different inputs is the FPGA way to scale a design.