Switch/button/LED demo for Arty-A7 using AXI

Digilent has on their website a small programming tutorial for the Arty-A7. It is a very simple demo in which the switches and push buttons are OR'ed to drive the LEDs. The implementation is just a few lines of Verilog:

        module sw_btn_led(
            input [3:0]sw,
            input [3:0]btn,
            output [3:0]led
            );

            assign led = sw | btn;

        endmodule
        

The goal here is to achieve the same with Vivado block designs from scratch.

New project setup

After downloading and installing the board support files for the Arty-A7, create a new project in Vivado with the Arty-A7 as target platform. Add a new block design to the project. Under the "Board" tab, you'll find a list of all the connections that the boards supports:

Board supported connections

Dragging for example the "4 Push Buttons" onto the block diagram, will result into this:

Buttons with GPIO

Vivado has automatically added (or prompts you to add, depending on your settings) an "AXI GPIO" block. This is because Digilent has specified in their board support file that this peripheral is preferably used with a "AXI GPIO" block. This is defined in the board.xml file in the board support package:

        <interface mode="master" name="push_buttons_4bits" type="xilinx.com:interface:gpio_rtl:1.0" of_component="push_buttons_4bits" preset_proc="push_buttons_4bits_preset">
          <description>4 Push Buttons</description>
          <preferred_ips>
            <preferred_ip vendor="xilinx.com" library="ip" name="axi_gpio" order="0"/>
          </preferred_ips>
          <port_maps>
            <port_map logical_port="TRI_I" physical_port="push_buttons_4bits_tri_i" dir="in" left="3" right="0">
              <pin_maps>
                <pin_map port_index="0" component_pin="push_buttons_4bits_tri_i_0"/>
                <pin_map port_index="1" component_pin="push_buttons_4bits_tri_i_1"/>
                <pin_map port_index="2" component_pin="push_buttons_4bits_tri_i_2"/>
                <pin_map port_index="3" component_pin="push_buttons_4bits_tri_i_3"/>
              </pin_maps>
            </port_map>
          </port_maps>
        </interface>
        

The AXI GPIO block is not very complicated, and the AXI protocol is also manageable for the simple read/write operations that we are going to use for AXI GPIO (streaming and burst data transfers with multiple AXI master and slave modules on the bus quickly gets really complicated, as this blog post explains very well).

A simple AXI master

First we create a simple AXI master in VHDL. This module will read the states of the buttons and switches and drive the LEDs accordingly. In the AXI specification you can find which inputs and ouputs are required for the "Lite" version of the AXI protocol, which supports only simple read and write operations. Here is the entity specification with all the required inputs and outputs specified:

        entity axi_led_ctrl is
            Port (
                    -- General
                    m_axi_aclk      : in std_logic;
                    m_axi_aresetn   : in std_logic;

                    -- Write address channel
                    m_axi_awready   : in std_logic;
                    m_axi_awvalid   : out std_logic;
                    m_axi_awaddr    : out std_logic_vector(31 downto 0);
                    m_axi_awprot    : out std_logic_vector(2 downto 0);

                    -- Write data channel
                    m_axi_wready    : in std_logic;
                    m_axi_wvalid    : out std_logic;
                    m_axi_wdata     : out std_logic_vector(31 downto 0);

                    -- Write response channel
                    m_axi_bvalid    : in std_logic;
                    m_axi_bready    : out std_logic;

                    -- Read address channel
                    m_axi_arready   : in std_logic;
                    m_axi_arvalid   : out std_logic;
                    m_axi_araddr    : out std_logic_vector(31 downto 0);
                    m_axi_arprot    : out std_logic_vector(2 downto 0);

                    -- Read data channel
                    m_axi_rready    : out std_logic;
                    m_axi_rvalid    : in std_logic;
                    m_axi_rdata     : in std_logic_vector(31 downto 0)
                 );
        end axi_led_ctrl;
        

Add a new VHDL source file to the project. You can specify the inputs and outputs in the wizard, or just copy the above into an empty source file. In this first version, I'm just going to have the module turn some LEDs on continuously. Later we'll add something more advanced. Add this below the interface specification in the same file:

        architecture Behavioral of axi_led_ctrl is
            signal clk  : std_logic;
            signal rstn : std_logic;
        begin

            -- Map general signals
            clk <= m_axi_aclk;
            rstn <= m_axi_aresetn;

            -- Default protection flags
            m_axi_awprot <= "000";
            m_axi_arprot <= "000";

            process(clk, rstn) is
            begin
                if rising_edge(clk) then
                    if rstn = '0' then
                        m_axi_awvalid <= '0';
                        m_axi_wvalid <= '0';
                        m_axi_arvalid <= '0';
                    else
                        -- Write to LEDs
                        m_axi_awvalid <= '1';
                        m_axi_awaddr <= x"40010000";  -- address of GPIO1
                        m_axi_wvalid <= '1';
                        m_axi_wdata <= x"0000000B";  -- LED3, 1 and 0 on

                        -- No reading
                        m_axi_arvalid <= '0';
                    end if;
                end if;
            end process;

        end Behavioral;
        

Note the address of GPIO1; we will come back to that later.

Block design

Create a new block diagram and drag the "4 Push Buttons", "4 Switches" and "4 LEDs" onto the canvas, as well as our master controller. Vivado will help with auto routing and connections, and will (should) ask to add the following components (your mileage may vary, since a lot seems to depend on the order in which you add things to the block design):

It should also connect to the reset button and the board's sys_clock. The reset button is active low, so Vivado will also add an inverter to connect the reset to the clocking wizard, which has an active high reset. In the end, the block design should look like this (click for larger):

Block design complete

Open the address editor to check which addresses Vivado assigned to which GPIO block. As you can see, I have both the push buttons and the switches on axi_gpio_0 and the LEDs on axi_gpio_1. Here is my address map:

Address map

Note that the LEDs are at 0x40010000, which corresponds to the address in the AXI master.

Synthesize the simple design

You cannot synthesize the block design directly (explanation). Create a HDL wrapper first and then follow the design flow: synthesis, implementation and bitstream generation. Finally, program the device and see LED3, LED1 and LED0 come on.

Reading the switches and buttons

In order to read the switches and buttons, we need to actually implement some logic to get the data over the AXI bus. The protocol is well documented (specification) and the principle is straightforward:

To write data somewhere, the sequence of events is roughly as follows:

  1. The AXI master first uses the write address channel to indicate where the data should go to.
  2. The data is put on the write data channel
  3. After the data transfer is complete, the AXI slave responds with the result (success, failure, etc.) on the write response channel.

For reading data from somewhere, it is more or less similar, except that there is no read response:

  1. The AXI master uses the read address channel to indicate where to read from.
  2. The slave puts the data on the bus and waits for the master to complete the transaction.

There is a good explanation written down here.

State machine

I use a state machine to correctly implement the AXI protocol and the transitions between the various phases. The state machine has seven states:

  1. Idle: do nothing
  2. ReadSwitchAddr: put the address of the switches on the read address channel
  3. ReadSwitchData: read the switch states via the read data channel
  4. ReadButtonAddr: put the address of the push buttons on the read address channel
  5. ReadButtonData: read the button states via the read data channel
  6. WriteLed: write the desired LED state on the write data channel
  7. NoOp: placeholder that I will explain later
        type state_t is (Idle, ReadSwitchAddr, ReadSwitchData, ReadButtonAddr, ReadButtonData, WriteLed, NoOp);
        

I didn't bother to make separate states for the write address channel and the write response, since I'm always writing to the same address. I didn't handle the write response in a separate state either, since if writing to the LEDs failed I had no other way to signal the error anyway.

Handshakes

Since all channels use the same handshake protocol, it is a good idea to make a procedure for it:

        procedure waitXferComplete(
            signal valid : out std_logic;
            signal ack : in std_logic;
            variable complete : inout boolean;
            signal state : inout state_t;
            constant next_state : in state_t
        ) is
        begin
            if complete = false then
                valid <= '1';
                state <= state;
                if ack = '1' then
                    valid <= '0';
                    complete := true;
                    if next_state /= NoOp then
                        state <= next_state;
                    end if;
                end if;
            else
                valid <= '0';
                state <= state;
            end if;
        end procedure;
        

The ack signal is defined as the logical and of VALID and READY. As soon as the transfer is complete, it deasserts the valid signal and transitions to the next state. I pass in the state of the state machine that should be next once the transfer is completed. I abuse the `NoOp` state so I can run multiple instances of this procedure in parallel with only one triggering a state transition (I do this in the WriteLed state). Here is a condensed version of how it is used:

        arack <= arvalid and M_AXI_ARREADY;
        -- ...
        process(clk, resetn, ..., arack, ...) is
            -- ...
                variable arcomplete : boolean := false;
            -- ...
            begin
                if rising_edge(clk) then
                    case state is
                        -- ...
                        when ReadSwitchAddr =>
                            M_AXI_ARADDR <= x"40010000";
                            waitXferComplete(arvalid, arack, arcomplete, state, ReadSwitchData);
        -- ...
        end process;
        

Note that in the read data state, the ready signal takes the place of the valid signal, as the roles of master and slave are reversed for the handshake.

Combinatorial logic

I choose to keep the logic of mapping switch and button states to LED states outside the process:

        M_AXI_WDATA <= switch_state or button_state;
        

The registers with the switch and button states are read in a process separate from the state machine:

        process(clk, state, rack, M_AXI_RDATA) is
        begin
            if rising_edge(clk) then
                if rack = '1' and state = ReadSwitchData then
                    switch_state <= M_AXI_RDATA;
                elsif rack = '1' and state = ReadButtonData then
                    button_state <= M_AXI_RDATA;
                end if;
            end if;
        end process;
        

Putting it all together

Here is the entire VHDL code for the updated AXI master. Adding the Xilinx Integrated Logic Analyzer to the block diagram allows to trace signals on the board. I put a trigger on a speciif value (0xd) of the write data channel so that the trace can be triggered by a button press (click for larger):

ILA trace

The trace shows an entire cycle of the state machine. On the left, we see two read cycles: one for the switches at 0x40010000 and one for the buttons at 0x40010008. Because the two registers with the switch and button states are wired directly onto the write data signal, this is where the ILA triggers. On the right of the trigger we see from top to bottom: the write address transaction, the write data transaction and the write response transaction (note how the signals AWVALID and WVALID and BREADY are kept high due to my aforementioned laziness for not implementing separate states). On the far right we see the Idle state and the beginning of the next read address state.


Home | Last update: 2021-12-12