Learning FPGA development can feel overwhelming at first, especially when transitioning from software programming to hardware description languages like VHDL. Yet the best way to build confidence—and real skills—is through hands-on practice with simple, progressive projects. This post gathers 10 beginner-friendly FPGA projects designed to help you understand digital logic, timing circuits, finite state machines, communication protocols, display control, and more.

Each project focuses on a core concept that every FPGA developer must master, from basic logic gates and counters to UART communication and PWM signal generation. Whether you're a student, hobbyist, or aspiring embedded systems engineer, these projects offer a clear path to learning by doing—using real hardware, real circuits, and real VHDL code.

By following this collection, you’ll gradually build a strong foundation that prepares you for more advanced FPGA work such as DSP processing, soft-core CPUs, and high-speed communication systems. Let’s explore the world of programmable logic, one project at a time.

Nice, let’s turn this into a mini “project pack” you can actually drop into Vivado/Quartus.

🔧 Assumptions for all projects

At the top of every VHDL file, include:

library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.NUMERIC_STD.ALL;

I’ll use a generic CLK_FREQ so you can adapt to 50 MHz, 100 MHz, etc.

1️⃣ Project: Simple Logic Gate Playground

Goal: 4 switches → simple logic expressions → 4 LEDs.
Concepts: entity/architecture, combinational logic.

entity logic_playground is
    Port (
        sw  : in  STD_LOGIC_VECTOR(3 downto 0);
        led : out STD_LOGIC_VECTOR(3 downto 0)
    );
end logic_playground;

architecture Behavioral of logic_playground is
begin
    -- led(0) = AND of sw(0) and sw(1)
    led(0) <= sw(0) and sw(1);

    -- led(1) = OR of sw(0) and sw(1)
    led(1) <= sw(0) or sw(1);

    -- led(2) = XOR of sw(2) and sw(3)
    led(2) <= sw(2) xor sw(3);

    -- led(3) = (sw(0) AND NOT sw(2)) OR sw(3)
    led(3) <= (sw(0) and not sw(2)) or sw(3);
end Behavioral;

2️⃣ Project: Binary Counter on LEDs

Goal: Show a 4-bit counter on led(3 downto 0).
Concepts: clocked process, unsigned counter, generic clock divider.

entity led_counter is
    generic (
        CLK_FREQ : integer := 50_000_000;  -- Hz
        TICK_HZ  : integer := 2            -- count increments per second
    );
    Port (
        clk : in  STD_LOGIC;
        led : out STD_LOGIC_VECTOR(3 downto 0)
    );
end led_counter;

architecture Behavioral of led_counter is
    constant DIVIDER_MAX : integer := CLK_FREQ / TICK_HZ;
    signal div_cnt  : unsigned(31 downto 0) := (others => '0');
    signal tick     : STD_LOGIC := '0';
    signal count4   : unsigned(3 downto 0) := (others => '0');
begin

    -- clock divider
    process(clk)
    begin
        if rising_edge(clk) then
            if div_cnt = DIVIDER_MAX - 1 then
                div_cnt <= (others => '0');
                tick    <= '1';
            else
                div_cnt <= div_cnt + 1;
                tick    <= '0';
            end if;
        end if;
    end process;

    -- 4-bit counter
    process(clk)
    begin
        if rising_edge(clk) then
            if tick = '1' then
                count4 <= count4 + 1;
            end if;
        end if;
    end process;

    led <= std_logic_vector(count4);
end Behavioral;

3️⃣ Project: Button-Toggle LED (with simple debounce)

Goal: Press button → LED toggles state once per press.
Concepts: input sync, edge detect, simple debounce.

entity button_toggle is
    generic (
        CLK_FREQ : integer := 50_000_000;
        DEBOUNCE_MS : integer := 20
    );
    Port (
        clk : in  STD_LOGIC;
        btn : in  STD_LOGIC;   -- active-high push button
        led : out STD_LOGIC
    );
end button_toggle;

architecture Behavioral of button_toggle is
    constant DEBOUNCE_MAX : integer := CLK_FREQ / 1000 * DEBOUNCE_MS;

    signal btn_sync1, btn_sync2 : STD_LOGIC := '0';
    signal stable_cnt : unsigned(31 downto 0) := (others => '0');
    signal btn_clean  : STD_LOGIC := '0';
    signal btn_prev   : STD_LOGIC := '0';
    signal led_reg    : STD_LOGIC := '0';
begin
    -- synchronize button to clk
    process(clk)
    begin
        if rising_edge(clk) then
            btn_sync1 <= btn;
            btn_sync2 <= btn_sync1;
        end if;
    end process;

    -- debounce: only change when stable long enough
    process(clk)
    begin
        if rising_edge(clk) then
            if btn_sync2 = btn_clean then
                stable_cnt <= (others => '0');
            else
                if stable_cnt = DEBOUNCE_MAX - 1 then
                    btn_clean  <= btn_sync2;
                    stable_cnt <= (others => '0');
                else
                    stable_cnt <= stable_cnt + 1;
                end if;
            end if;
        end if;
    end process;

    -- detect rising edge of clean signal
    process(clk)
    begin
        if rising_edge(clk) then
            if btn_clean = '1' and btn_prev = '0' then
                led_reg <= not led_reg;
            end if;
            btn_prev <= btn_clean;
        end if;
    end process;

    led <= led_reg;
end Behavioral;

4️⃣ Project: Knight Rider / Running LED

Goal: Light moves left and right across 8 LEDs.
Concepts: shift registers, simple direction control.

entity knight_rider is
    generic (
        CLK_FREQ : integer := 50_000_000;
        STEP_HZ  : integer := 5
    );
    Port (
        clk : in  STD_LOGIC;
        led : out STD_LOGIC_VECTOR(7 downto 0)
    );
end knight_rider;

architecture Behavioral of knight_rider is
    constant DIV_MAX : integer := CLK_FREQ / STEP_HZ;
    signal div_cnt : unsigned(31 downto 0) := (others => '0');
    signal tick    : STD_LOGIC := '0';

    signal pattern : STD_LOGIC_VECTOR(7 downto 0) := "00000001";
    signal dir     : STD_LOGIC := '0'; -- 0 = right, 1 = left
begin
    -- clock divider
    process(clk)
    begin
        if rising_edge(clk) then
            if div_cnt = DIV_MAX - 1 then
                div_cnt <= (others => '0');
                tick    <= '1';
            else
                div_cnt <= div_cnt + 1;
                tick    <= '0';
            end if;
        end if;
    end process;

    -- move LED
    process(clk)
    begin
        if rising_edge(clk) then
            if tick = '1' then
                if dir = '0' then
                    pattern <= pattern(6 downto 0) & '0';
                    if pattern(6) = '1' then
                        dir <= '1';
                    end if;
                else
                    pattern <= '0' & pattern(7 downto 1);
                    if pattern(1) = '1' then
                        dir <= '0';
                    end if;
                end if;
            end if;
        end if;
    end process;

    led <= pattern;
end Behavioral;

5️⃣ Project: 7-Segment Hex Counter (0–F)

Goal: Show 0–F repeatedly on a single 7-seg display.
Concepts: combinational decode, counter, clock divider.

Assume common-anode display (segment low turns ON). Adapt as needed.

entity sevenseg_hex is
    generic (
        CLK_FREQ : integer := 50_000_000;
        STEP_HZ  : integer := 2
    );
    Port (
        clk    : in  STD_LOGIC;
        seg    : out STD_LOGIC_VECTOR(6 downto 0)  -- a b c d e f g
        -- add 'an' or 'dp' ports as needed
    );
end sevenseg_hex;

architecture Behavioral of sevenseg_hex is
    constant DIV_MAX : integer := CLK_FREQ / STEP_HZ;
    signal div_cnt : unsigned(31 downto 0) := (others => '0');
    signal digit   : unsigned(3 downto 0) := (others => '0');
begin
    -- divider and digit counter
    process(clk)
    begin
        if rising_edge(clk) then
            if div_cnt = DIV_MAX - 1 then
                div_cnt <= (others => '0');
                digit   <= digit + 1;
            else
                div_cnt <= div_cnt + 1;
            end if;
        end if;
    end process;

    -- hex to 7-segment (common-anode)
    with digit select
        seg <=
        "0000001" when "0000", -- 0
        "1001111" when "0001", -- 1
        "0010010" when "0010", -- 2
        "0000110" when "0011", -- 3
        "1001100" when "0100", -- 4
        "0100100" when "0101", -- 5
        "0100000" when "0110", -- 6
        "0001111" when "0111", -- 7
        "0000000" when "1000", -- 8
        "0000100" when "1001", -- 9
        "0001000" when "1010", -- A
        "1100000" when "1011", -- b
        "0110001" when "1100", -- C
        "1000010" when "1101", -- d
        "0110000" when "1110", -- E
        "0111000" when others; -- F
end Behavioral;

6️⃣ Project: PWM LED Dimmer (Button-Controlled Brightness)

Goal: Adjust LED brightness through duty-cycle steps using two buttons.
Concepts: PWM, comparing counter against duty value.

entity pwm_dimmer is
    generic (
        CLK_FREQ : integer := 50_000_000;
        PWM_HZ   : integer := 1000
    );
    Port (
        clk      : in  STD_LOGIC;
        btn_up   : in  STD_LOGIC;
        btn_down : in  STD_LOGIC;
        led      : out STD_LOGIC
    );
end pwm_dimmer;

architecture Behavioral of pwm_dimmer is
    constant PWM_MAX : integer := CLK_FREQ / PWM_HZ;
    signal pwm_cnt   : unsigned(15 downto 0) := (others => '0');
    signal duty      : unsigned(15 downto 0) := (others => '0');
    signal led_reg   : STD_LOGIC := '0';

    signal btnu_prev, btnd_prev : STD_LOGIC := '0';
begin
    -- simple PWM counter
    process(clk)
    begin
        if rising_edge(clk) then
            if pwm_cnt = PWM_MAX - 1 then
                pwm_cnt <= (others => '0');
            else
                pwm_cnt <= pwm_cnt + 1;
            end if;

            if pwm_cnt < duty then
                led_reg <= '1';
            else
                led_reg <= '0';
            end if;
        end if;
    end process;

    -- adjust duty on button rising edge (no fancy debounce here)
    process(clk)
    begin
        if rising_edge(clk) then
            if btn_up = '1' and btnu_prev = '0' then
                if duty < to_unsigned(PWM_MAX - PWM_MAX/16, duty'length) then
                    duty <= duty + to_unsigned(PWM_MAX/16, duty'length);
                end if;
            end if;

            if btn_down = '1' and btnd_prev = '0' then
                if duty > to_unsigned(PWM_MAX/16, duty'length) then
                    duty <= duty - to_unsigned(PWM_MAX/16, duty'length);
                end if;
            end if;

            btnu_prev <= btn_up;
            btnd_prev <= btn_down;
        end if;
    end process;

    led <= led_reg;
end Behavioral;

7️⃣ Project: Simple Traffic Light Controller

Goal: 3 LEDs (Red, Yellow, Green) change with fixed timing.
Concepts: FSM with timer.

entity traffic_light is
    generic (
        CLK_FREQ : integer := 50_000_000;
        GREEN_SEC  : integer := 5;
        YELLOW_SEC : integer := 2;
        RED_SEC    : integer := 5
    );
    Port (
        clk : in  STD_LOGIC;
        red, yellow, green : out STD_LOGIC
    );
end traffic_light;

architecture Behavioral of traffic_light is
    type state_type is (S_GREEN, S_YELLOW, S_RED);
    signal state : state_type := S_GREEN;

    constant T_GREEN  : integer := CLK_FREQ * GREEN_SEC;
    constant T_YELLOW : integer := CLK_FREQ * YELLOW_SEC;
    constant T_RED    : integer := CLK_FREQ * RED_SEC;

    signal timer : unsigned(31 downto 0) := (others => '0');
begin
    process(clk)
    begin
        if rising_edge(clk) then
            case state is
                when S_GREEN =>
                    if timer = T_GREEN - 1 then
                        state <= S_YELLOW;
                        timer <= (others => '0');
                    else
                        timer <= timer + 1;
                    end if;

                when S_YELLOW =>
                    if timer = T_YELLOW - 1 then
                        state <= S_RED;
                        timer <= (others => '0');
                    else
                        timer <= timer + 1;
                    end if;

                when S_RED =>
                    if timer = T_RED - 1 then
                        state <= S_GREEN;
                        timer <= (others => '0');
                    else
                        timer <= timer + 1;
                    end if;
            end case;
        end if;
    end process;

    -- outputs
    red    <= '1' when state = S_RED    else '0';
    yellow <= '1' when state = S_YELLOW else '0';
    green  <= '1' when state = S_GREEN  else '0';
end Behavioral;

8️⃣ Project: UART “Hello” Transmitter (8N1, Fixed Baud)

Goal: Send a single byte repeatedly over TX line.
Concepts: baud-rate generator, serial shift FSM.
(For real use you’d send a string; this is minimal.)

entity uart_tx_byte is
    generic (
        CLK_FREQ : integer := 50_000_000;
        BAUD     : integer := 115200;
        DATA     : std_logic_vector(7 downto 0) := x"48" -- 'H'
    );
    Port (
        clk : in  STD_LOGIC;
        tx  : out STD_LOGIC
    );
end uart_tx_byte;

architecture Behavioral of uart_tx_byte is
    constant BAUD_DIV : integer := CLK_FREQ / BAUD;

    type state_type is (IDLE, START, DATA_BITS, STOP);
    signal state : state_type := IDLE;

    signal baud_cnt : unsigned(15 downto 0) := (others => '0');
    signal bit_idx  : unsigned(2 downto 0) := (others => '0');
    signal tx_reg   : STD_LOGIC := '1'; -- idle high
begin
    process(clk)
    begin
        if rising_edge(clk) then
            case state is
                when IDLE =>
                    -- immediately start new frame
                    state    <= START;
                    baud_cnt <= (others => '0');
                    bit_idx  <= (others => '0');

                when START =>
                    tx_reg <= '0'; -- start bit
                    if baud_cnt = BAUD_DIV - 1 then
                        baud_cnt <= (others => '0');
                        state    <= DATA_BITS;
                    else
                        baud_cnt <= baud_cnt + 1;
                    end if;

                when DATA_BITS =>
                    tx_reg <= DATA(to_integer(bit_idx));
                    if baud_cnt = BAUD_DIV - 1 then
                        baud_cnt <= (others => '0');
                        if bit_idx = "111" then
                            state <= STOP;
                        else
                            bit_idx <= bit_idx + 1;
                        end if;
                    else
                        baud_cnt <= baud_cnt + 1;
                    end if;

                when STOP =>
                    tx_reg <= '1';
                    if baud_cnt = BAUD_DIV - 1 then
                        baud_cnt <= (others => '0');
                        state    <= IDLE;
                    else
                        baud_cnt <= baud_cnt + 1;
                    end if;
            end case;
        end if;
    end process;

    tx <= tx_reg;
end Behavioral;

9️⃣ Project: Simple Tone Generator (Buzzer / Speaker)

Goal: Generate square-wave audio at a given frequency; select tone with switches.
Concepts: frequency generation, lookup table.

entity tone_generator is
    generic (
        CLK_FREQ : integer := 50_000_000
    );
    Port (
        clk  : in  STD_LOGIC;
        sw   : in  STD_LOGIC_VECTOR(1 downto 0); -- select tone
        buz  : out STD_LOGIC
    );
end tone_generator;

architecture Behavioral of tone_generator is
    -- frequencies for notes (approx): 440, 880, 1.76k, 3.52k
    type int_array is array (0 to 3) of integer;
    constant NOTE_FREQ : int_array := (440, 880, 1760, 3520);

    signal half_period : integer := CLK_FREQ / (2 * NOTE_FREQ(0));
    signal cnt         : unsigned(31 downto 0) := (others => '0');
    signal buz_reg     : STD_LOGIC := '0';
begin
    -- update half_period based on switches
    process(sw)
    begin
        case sw is
            when "00" => half_period <= CLK_FREQ / (2 * NOTE_FREQ(0));
            when "01" => half_period <= CLK_FREQ / (2 * NOTE_FREQ(1));
            when "10" => half_period <= CLK_FREQ / (2 * NOTE_FREQ(2));
            when others => half_period <= CLK_FREQ / (2 * NOTE_FREQ(3));
        end case;
    end process;

    process(clk)
    begin
        if rising_edge(clk) then
            if cnt = to_unsigned(half_period - 1, cnt'length) then
                cnt     <= (others => '0');
                buz_reg <= not buz_reg;
            else
                cnt <= cnt + 1;
            end if;
        end if;
    end process;

    buz <= buz_reg;
end Behavioral;

🔟 Project: 2-Digit Decimal Counter on Multiplexed 7-Seg

Goal: Count 00–99 on 2 digits using multiplexing.
Concepts: time-multiplexing, BCD counters, resource reuse.

entity counter_2digit is
    generic (
        CLK_FREQ : integer := 50_000_000;
        STEP_HZ  : integer := 1;
        REFRESH_HZ : integer := 1000   -- multiplex freq per digit
    );
    Port (
        clk     : in  STD_LOGIC;
        seg     : out STD_LOGIC_VECTOR(6 downto 0); -- segments
        an      : out STD_LOGIC_VECTOR(1 downto 0)  -- digit enables
    );
end counter_2digit;

architecture Behavioral of counter_2digit is
    constant STEP_DIV    : integer := CLK_FREQ / STEP_HZ;
    constant REFRESH_DIV : integer := CLK_FREQ / (REFRESH_HZ * 2);

    signal step_cnt    : unsigned(31 downto 0) := (others => '0');
    signal refresh_cnt : unsigned(31 downto 0) := (others => '0');

    signal ones, tens  : unsigned(3 downto 0) := (others => '0');
    signal current_digit : STD_LOGIC := '0'; -- 0=ones,1=tens
    signal bcd         : unsigned(3 downto 0);
begin
    -- 0.5s or 1s decimal counter update
    process(clk)
    begin
        if rising_edge(clk) then
            if step_cnt = STEP_DIV - 1 then
                step_cnt <= (others => '0');

                if ones = 9 then
                    ones <= (others => '0');
                    if tens = 9 then
                        tens <= (others => '0');
                    else
                        tens <= tens + 1;
                    end if;
                else
                    ones <= ones + 1;
                end if;
            else
                step_cnt <= step_cnt + 1;
            end if;
        end if;
    end process;

    -- multiplex between ones and tens
    process(clk)
    begin
        if rising_edge(clk) then
            if refresh_cnt = REFRESH_DIV - 1 then
                refresh_cnt   <= (others => '0');
                current_digit <= not current_digit;
            else
                refresh_cnt <= refresh_cnt + 1;
            end if;
        end if;
    end process;

    bcd <= ones when current_digit = '0' else tens;

    -- select which digit is on (common-anode example: '0' = ON)
    an <= "10" when current_digit = '0' else "01";

    -- BCD to 7-seg (reuse mapping from project 5)
    with bcd select
        seg <=
        "0000001" when "0000",
        "1001111" when "0001",
        "0010010" when "0010",
        "0000110" when "0011",
        "1001100" when "0100",
        "0100100" when "0101",
        "0100000" when "0110",
        "0001111" when "0111",
        "0000000" when "1000",
        "0000100" when "1001",
        "1111110" when others; -- blank
end Behavioral;