How to create a IP-core driver

This tutorial creates a software driver for the AXI Test IP. Only a part of the functionality of AXI Test IP is implemented here, i.e., the multiplication of two integer values on the PL. The driver takes two integer values as input, writes them to the PL, and reads the result. The implementation is done in a way that implements the driver and the Unit tests (Ceedling) in parallel.

Note

This example is purely for understanding how to write the driver. Using the PL for simple integer multiplication is much slower than just doing it on the PS.

Tip

Sometimes, Ceedling fails to build, and unclear errors are present. Try to use ceedling clean to delete temporary files of Ceedling or commenting out the last test, running ceedling clean`, running all tests again, and comment in the last test again. If this does not work, run ceedling clobber to delete more temporary files and run the tests again.

Video

Setup

  1. Open VS Code with VS Code Remote Container

  2. Open a new terminal (Terminal -> New Terminal)

  3. Change the directory of the terminal by typing:

cd vitis/software/Baremetal
  1. Create a new software module uz_myIP_hw and specify the path IP_Cores/uz_myIP/:

ceedling module:create[IP_Cores/uz_myIP/uz_myIP_hw]
  1. Ceedling creates the files:

    • uz_myIP_hw.c in vitis/software/Baremetal/src/IP_Cores/uz_myIP/

    • uz_myIP_hw.h in vitis/software/Baremetal/src/IP_Cores/uz_myIP/

    • test_uz_myIP_hw.c in vitis/software/Baremetal/test/IP_Cores/uz_myIP/

  2. Create the file uz_myIP_hwAddresses.h in vitis/software/Baremetal/src/IP_Cores/uz_myIP/ and add an include guard to it (#pragma once in first line)

  3. Open uz_axi_testIP_addr.h in ultrazohm_sw/ip_cores/AXI_testIP/uz_axi_testIP/ipcore/uz_axi_testIP_v1_0/include

  4. Copy all defines in this file

  5. Paste the defines of uz_axi_testIP_addr.h into uz_myIP_hwAddresses.h

  6. Open test_uz_myIP_hw.c and add:

#include "test_assert_with_exception.h"
#include "mock_uz_AXI.h" // Tells Ceedling to create mock versions of the functions in uz_AXI (e.g., _Expect)
#include "uz_myIP_hwAddresses.h"
#define TEST_BASE_ADDRESS 0x00000000F // random hex value that represents a fictional base address
  1. Rename the existing test test_uz_myIP_hw_NeedToImplement to test_uz_myIP_hw_write_to_A

  2. Run the tests by typing ceedling test:all in the terminal

  3. The tests pass, but a message a ignore message is printed

--------------------
IGNORED TEST SUMMARY
--------------------
[test_uz_myIP_hw.c]
  Test: test_uz_myIP_hw_write_to_A
  At line (21): "Need to Implement uz_myIP_hw"
  1. This means that the test builds, but no test is implemented for the new IP-Core.

IP-Core driver: Hardware registers

To multiply two variables \(C=A \cdot B\) of type int32_t, the driver has to write \(A\) and \(B\) from the PS to the PL by AXI in the correct registers and read back the result \(C\) from the PL to the PS. In this section, we write a software module (uz_myIP_hw) that only deals with writing and reading the hardware registers of the IP-Core such that software modules can use the module without having to know the hardware addresses. See AXI Test IP for the available hardware registers of the IP-Core. The addresses of the registers are listed in uz_myIP_hwAddresses.h. Read/write operations on AXI are done by using the Hardware Abstraction Layer. Therefore, we expect that the driver first has to call the function uz_axi_write_int32 with the register address of \(A\) and an integer as arguments. Write the test for this behavior:

  1. Delete the line TEST_IGNORE_MESSAGE("Need to Implement uz_myIP_hw"); and add a first test in test_uz_myIP_hw.c.

1void test_uz_myIP_hw_write_to_A(void)
2{
3    int a=-10;
4    // Test passes if uz_axi_write_int32 is called once with these arguments
5    uz_axi_write_int32_Expect(TEST_BASE_ADDRESS+A_int32_Data_uz_axi_testIP,a);
6    uz_myIP_hw_write_A(TEST_BASE_ADDRESS,a);
7}
  1. Run the tests (type ceedling test:all in terminal)

  2. Tests fail with a warning that uz_myIP_hw_write_to_A has an implicit declaration

  3. Declare the required functions to read and write from the IP-core in uz_myIP_hw.h

Listing 20 uz_myIP_hw.h
1#ifndef UZ_MYIP_HW_H
2#define UZ_MYIP_HW_H
3#include <stdint.h>
4void uz_myIP_hw_write_A(uint32_t base_address,int32_t A);
5void uz_myIP_hw_write_B(uint32_t base_address,int32_t B);
6int32_t uz_myIP_hw_read_C(uint32_t base_address);
7#endif // UZ_MYIP_HW_H
  1. Run the tests. They will fail due to undefined references to uz_myIP_hw_write_A

  2. Implement the write function in uz_myIP_hw.c

Listing 21 uz_myIP_hw.c
1#include "uz_myIP_hw.h"
2#include "uz_myIP_hwAddresses.h"
3#include "../../uz/uz_AXI.h"
4
5void uz_myIP_hw_write_A(uint32_t base_address,int32_t A){
6    uz_axi_write_int32(base_address+A_int32_Data_uz_axi_testIP,A);
7}
  1. Run the tests. They will pass

  2. Currently, we only test the good case in which everything works as expected. However, we need to protect against some basic errors.

  3. Add a test that protects against calling the write function without a valid base address:

Listing 22 Testing asserts
1void test_uz_myIP_hw_write_to_A_with_zero_base_address(void)
2{
3    int a=-10;
4    // Tell the test that we do not care how often this function is called
5    uz_axi_write_int32_Ignore();
6    // Test passes if an assert fails in the function under test
7    TEST_ASSERT_FAIL_ASSERT(uz_myIP_hw_write_A(0,a))
8}
  1. Run the tests, they fail with the following message because we expected that an Assertions fires in uz_myIP_hw_write_A to prevent calling the function with base address 0:

FAILED TEST SUMMARY
-------------------
[test_uz_myIP_hw.c]
  Test: test_uz_myIP_hw_write_to_A_with_zero_base_address
  At line (31): "Code under test did not assert"
  1. Add the following to uz_myIP_hw.c

Listing 23 uz_myIP_hw.c with assert to prevent call with base_address == 0
1#include "uz_myIP_hw.h"
2#include "uz_myIP_hwAddresses.h"
3#include "../../uz/uz_AXI.h"
4#include "../../uz/uz_HAL.h"
5
6void uz_myIP_hw_write_A(uint32_t base_address,int32_t A){
7    uz_assert_not_zero(base_address);
8    uz_axi_write_int32(base_address+A_int32_Data_uz_axi_testIP,A);
9}
  1. Run the tests. They pass. Note that this assertion only prevents calling the function with base_address == 0, e.g., if a struct initializer automatically initialized the variable. The function still can be called with a wrong base address!

  2. We can now write \(A\) to the IP-Core and have a test that ensures that we write to the correct addresses. Next step: do the same for \(B\):

Warning

It is tempting to copy & paste everything here - be careful to get all addresses, functions, and variable names right!

  1. Write a test that checks that uz_myIP_hw_write_B writes to the correct address and a test that prevents calls with base_address == 0:

Listing 24 Test for writing to register B
1void test_uz_myIP_hw_write_to_B(void)
2{
3    int b=100;
4    uz_axi_write_int32_Expect(TEST_BASE_ADDRESS+B_int32_Data_uz_axi_testIP,b);
5    uz_myIP_hw_write_B(TEST_BASE_ADDRESS,b);
6}
  1. Run the test. It does not compile since uz_myIP_hw_write_B is not implemented. Add the implementation in uz_myIP_hw.c:

Listing 25 Function to write to register B_int32_Data_uz_axi_testIP
1void uz_myIP_hw_write_B(uint32_t base_address,int32_t B){
2uz_assert_not_zero(base_address);
3uz_axi_write_int32(base_address+B_int32_Data_uz_axi_testIP,B);
4}
  1. Run the test. It passes. We already implemented the assert for the base address in this case. Make sure to add the test for this:

Listing 26 Test that assert fires in write to b
1void test_uz_myIP_hw_write_to_B_with_zero_base_address(void)
2{
3    int b=2;
4    uz_axi_write_int32_Ignore();
5    TEST_ASSERT_FAIL_ASSERT(uz_myIP_hw_write_B(0,b))
6}
  1. Run the test, it passes.

  2. To get the result of the multiplication, read the register C. Create a test for this. uz_axi_read_int32_ExpectAndReturn creates a mock for the function uz_axi_read_int32 that returns c if it is called. Furthermore, we test that the right value is returned form uz_myIP_hw_read_C:

Listing 27 Test that uz_myIP_hw_read_C returns the correct value
1void test_uz_myIP_hw_read_from_C(void)
2{
3    int c=101230;
4    uz_axi_read_int32_ExpectAndReturn(TEST_BASE_ADDRESS+C_int32_Data_uz_axi_testIP,c);
5    int c_readback=uz_myIP_hw_read_C(TEST_BASE_ADDRESS);
6    TEST_ASSERT_EQUAL_INT(c,c_readback);
7}
  1. Run the test, this does not compile since there is no implementation of uz_myIP_hw_read_C. Add it to uz_myIP_hw.c:

Listing 28 Implementation of uz_myIP_hw_read_C
1int32_t uz_myIP_hw_read_C(uint32_t base_address){
2
3}
  1. Run the test. The test fails since uz_myIP_hw_read_C did not return the right value.

  2. Implement a real version of uz_myIP_hw_read_C:

Listing 29 Implementation of uz_myIP_hw_read_C with right return value
1int32_t uz_myIP_hw_read_C(uint32_t base_address){
2return (uz_axi_read_int32(base_address+C_int32_Data_uz_axi_testIP));
3}
  1. Run the tests. They will pass now.

  2. Add a test for the missing assert:

Listing 30 Assert test for read C function
1void test_uz_myIP_hw_read_C_with_zero_base_address(void)
2{
3    int c=123;
4    // Ignores how often the read function is called and returns (c)
5    uz_axi_read_int32_IgnoreAndReturn(c);
6    TEST_ASSERT_FAIL_ASSERT(uz_myIP_hw_read_C(0));
7}
  1. Run the test, the test fails with Code under test did not assert

  2. Add

Listing 31 Add assert to read C function
1int32_t uz_myIP_hw_read_C(uint32_t base_address){
2uz_assert_not_zero(base_address);
3return (uz_axi_read_int32(base_address+C_int32_Data_uz_axi_testIP));
4}
  1. Run the tests. All tests will pass!

IP-Core driver: User software

Recall that we use the AXI Test IP to calculate \(C=A \cdot B\). Until now, we created an abstraction layer for the hardware registers. We implement the actual function of the driver in the following.

  1. Type in the terminal:

ceedling module:create[IP_Cores/uz_myIP/uz_myIP]
  1. Create the interface of the IP-Core driver in uz_myIP.h. Notice how the interface is focused on the user: We only have to initialize the module and use the hardware calculation \(C=A \cdot B\) without knowledge about hardware registers and addresses. We use Doxygen to document the interface. Type /** above a function, struct or typedef you want to comment and press enter, VSCode will auto-generate the doxygen boiler plate. We only use doxygen comments for the interface (in the .h file) and later include these in the sphinx documentation.

Listing 32 Software interface of IP-Core
 1#ifndef UZ_MYIP_H
 2#define UZ_MYIP_H
 3#include <stdint.h>
 4
 5/**
 6 * @brief Data type for object myIP
 7 *
 8 */
 9typedef struct uz_myIP_t uz_myIP_t;
10
11/**
12 * @brief Configuration struct for myIP
13 *
14 */
15struct uz_myIP_config_t{
16    uint32_t base_address; /**< Base address of the IP-Core */
17    uint32_t ip_clk_frequency_Hz; /**< Clock frequency of the IP-Core */
18};
19
20/**
21 * @brief Initializes an instance of the myIP driver
22 *
23 * @param config Configuration values for the IP-Core
24 * @return Pointer to initialized instance
25 */
26uz_myIP_t* uz_myIP_init(struct uz_myIP_config_t config);
27
28/**
29 * @brief Calculates C=A*B
30 *
31 * @param self Pointer to IP-Core instance that was initialized with init function
32 * @param A First factor
33 * @param B Second factor
34 * @return Product of A times B
35 */
36int32_t uz_myIP_multiply(uz_myIP_t* self, int32_t A, int32_t B);
37
38#endif // UZ_MYIP_H
  1. Run Ceedling, the tests will pass but the test for uz_myIP is ignored.

  2. Open the file uz_myIP.c in src/IP_Cores/uz_myIP/.

  3. Use the allocation VSCode snippet for the static memory allocation boiler plate code (see Static memory allocation for details). If you use VS Code Remote Container, you can use the snippet by typing allocator in the file. Alternatively copy the following code.

https://images2.imgbox.com/6d/39/mL1WUwjP_o.gif
Listing 33 Boilerplate code and static allocation for the module
 1#include "../../uz/uz_global_configuration.h"
 2#if UZ_MYIP_MAX_INSTANCES > 0U
 3#include <stdbool.h>
 4#include "../../uz/uz_HAL.h"
 5#include "uz_myIP.h"
 6
 7struct uz_myIP_t {
 8    bool is_ready;
 9};
10
11static uint32_t instance_counter = 0U;
12static uz_myIP_t instances[UZ_MYIP_MAX_INSTANCES] = { 0 };
13
14static uz_myIP_t* uz_myIP_allocation(void);
15
16static uz_myIP_t* uz_myIP_allocation(void){
17    uz_assert(instance_counter < UZ_MYIP_MAX_INSTANCES);
18    uz_myIP_t* self = &instances[instance_counter];
19    uz_assert_false(self->is_ready);
20    instance_counter++;
21    self->is_ready = true;
22    return (self);
23}
24
25uz_myIP_t* uz_myIP_init() {
26    uz_myIP_t* self = uz_myIP_allocation();
27    return (self);
28}
29#endif
  1. Open uz_global_configuration.h if you already renamed the sample configuration. If not, see Global configuration.

  2. Add #define UZ_MYIP_MAX_INSTANCES 5U to uz_global_configuration.h inside the test ifdef (at the bottom of the file). We can now use up to 5 instances of the IP-core driver for five different instances of the IP-Core in the tests.

  3. Add the following code to test_uz_myIP.c. We isolate the testing by using a mock version of our already implemented uz_myIP_hw.

Listing 34 test_uz_myIP.c test setup
1#include "test_assert_with_exception.h"
2#include "uz_myIP.h"
3#include "mock_uz_myIP_hw.h" // Mock the _hw functions to isolate testing
4#include <stdint.h>
5
6#define TEST_BASE_ADDRESS 0x0000000A
7#define TEST_IP_CORE_FRQ 100000000U
  1. Change the implementation of uz_myIP_init in uz_myIP.c to match the interface in uz_myIP.h

1uz_myIP_t* uz_myIP_init(struct uz_myIP_config_t config){
2 uz_myIP_t* self = uz_myIP_allocation();
3 return (self);
4}
  1. Run the tests, all tests pass, but uz_myIP_test is ignored.

  2. Start writing a test for the multiplication \(C=A \cdot B\) by initializing an instance of the IP-Core driver:

1void test_uz_myIP_test_A_times_B_equals_C(void)
2{
3    struct uz_myIP_config_t config={
4        .base_address= TEST_BASE_ADDRESS,
5        .ip_clk_frequency_Hz=TEST_IP_CORE_FRQ
6    };
7    uz_myIP_t *instance = uz_myIP_init(config);
8}
  1. Run the tests, they will pass but a warning about unused variables config and instance is shown.

  2. Add to the test:

 1 void test_uz_myIP_test_A_times_B_equals_C(void)
 2 {
 3     struct uz_myIP_config_t config={
 4         .base_address= TEST_BASE_ADDRESS,
 5         .ip_clk_frequency_Hz=TEST_IP_CORE_FRQ
 6     };
 7     uz_myIP_t *instance = uz_myIP_init(config);
 8     int32_t a = -10;
 9     int32_t b = 200;
10     uz_myIP_hw_write_A_Expect(TEST_BASE_ADDRESS, a);
11     uz_myIP_hw_write_B_Expect(TEST_BASE_ADDRESS, b);
12     uz_myIP_hw_read_C_ExpectAndReturn(TEST_BASE_ADDRESS, a * b);
13     int32_t c = uz_myIP_multiply(instance, a, b);
14     TEST_ASSERT_EQUAL_INT32(a * b, c);
15 }
  1. Run the tests, we have a linker error since uz_myIP_multiply is not implemented yet.

  2. Add #include "uz_myIP_hw.h" to uz_myIP.c and implement the calls to the hardware registers.

1int32_t uz_myIP_multiply(uz_myIP_t* self, int32_t A, int32_t B){
2uz_assert(self->is_ready);
3uz_myIP_hw_write_A(self->config.base_address,A);
4uz_myIP_hw_write_B(self->config.base_address,B);
5return (uz_myIP_hw_read_C(self->config.base_address));
6}
  1. Run the tests, we have several errors since we have no struct member config. Add config to the struct uz_myIP_t:

1struct uz_myIP_t {
2 bool is_ready;
3 struct uz_myIP_config_t config;
4};
  1. Run the tests, they fail since uz_myIP_hw_write_A is not called with the correct base address.

  2. Assign the passed config value to the instance in uz_myIP_init():

1uz_myIP_t* uz_myIP_init(struct uz_myIP_config_t config){
2 uz_myIP_t* self = uz_myIP_allocation();
3 self->config=config;
4 return (self);
5}
  1. Run the tests, they pass!

  2. Add a test to prevent calling init without initialization of the base address:

1void test_uz_myIP_fail_assert_if_base_address_is_zero(void)
2{
3    struct uz_myIP_config_t config={
4        .ip_clk_frequency_Hz=TEST_IP_CORE_FRQ
5    };
6    TEST_ASSERT_FAIL_ASSERT(uz_myIP_init(config) );
7}
  1. Test fails, add uz_assert_not_zero(config.base_address); to uz_myIP_init before the allocation is done.

  2. Run the test again, it passes now.

  3. Repeat for ip_clk_frequency_Hz. Add uz_assert_not_zero(config.ip_clk_frequency_Hz); to uz_myIP_init and the following test:

1void test_uz_myIP_fail_assert_if_ip_frequency_is_zero(void)
2{
3    struct uz_myIP_config_t config={
4        .base_address=TEST_BASE_ADDRESS
5    };
6    TEST_ASSERT_FAIL_ASSERT(uz_myIP_init(config) );
7}
  1. Add a test to prevent calling uz_myIP_multiply() with a NULL pointer

void test_uz_myIP_fail_assert_if_multiply_is_called_with_NULL_pointer(void)
{
    TEST_ASSERT_FAIL_ASSERT(uz_myIP_multiply(NULL, 5, 1));
}
  1. Add an assertion to prevent calls with NULL pointer:

int32_t uz_myIP_multiply(uz_myIP_t* self, int32_t A, int32_t B){
    uz_assert_not_NULL(self);
    uz_assert(self->is_ready);
    uz_myIP_hw_write_A(self->config.base_address,A);
    uz_myIP_hw_write_B(self->config.base_address,B);
    return (uz_myIP_hw_read_C(self->config.base_address));
}
  1. We now have a working and fully tested driver for our IP-Core!

Warning

While we tested our functions with different error cases and made sure they behave as expected, we omitted the fact that the multiplication can overflow. This is especially tricky in this case since the multiplication is implemented in hardware. Thus the rules for C do not apply to it. There are two ways to handle this: implement the hardware multiplication to saturate on overflow or check if the multiplication will overflow before writing to the PL. The way AXI Test IP is implemented will wrap on overflow, i.e., 2147483647*2 will be a negative value. Keep this concept in mind for real IP-Cores that you implement. Additionally, prevent the software driver from writing values that are out of range to the IP-Core, e.g., if the register only uses 10 bit. Note that the AXI data width is always 32-bit.

Integration in Vitis

  1. Open Vitis

  2. (if not already done) Run the tcl script to generate the workspace (Tcl Scripts)

  3. Navigate to the Baremetal code

  4. Create the file uz_myIP_testebench.h in the include folder

Listing 35 uz_myIP_testbench.h
1#pragma once
2
3void uz_myIP_testbench(void);
  1. Create the file uz_myIP_testebench.c in the sw folder. Note how the code is basically the same as the test test_uz_myIP2_test_A_times_B_equals_C without the assertions and ceedling function calls.

Listing 36 uz_myIP_testbench.c
 1#include "../include/uz_myIP_testbench.h"
 2#include "../uz/uz_HAL.h"
 3#include "../IP_Cores/uz_myIP/uz_myIP.h"
 4#include "xparameters.h"
 5
 6void uz_myIP_testbench(void){
 7    struct uz_myIP_config_t config={
 8         .base_address= XPAR_UZ_AXI_TESTIP_0_BASEADDR,
 9         .ip_clk_frequency_Hz=100000000U
10    };
11    uz_myIP_t *instance = uz_myIP_init(config);
12    int32_t a = -10;
13    int32_t b = 200;
14    int32_t c = uz_myIP_multiply(instance, a, b);
15    uz_printf("Hardware multiply: %i, Software multiply: %i\n", c, a*b);
16    if (c==a*b){
17      uz_printf("Success: hardware and software multiply are equal! \n");
18    }else{
19      uz_printf("Fail: hardware and software multiply are NOT equal! \n");
20    }
21
22    while(1){
23      // do nothing and loop forever
24    }
25}
  1. Add #define UZ_MYIP_MAX_INSTANCES 1U between ifndef TEST and the first #endif to use one instance of the module in the software.

  2. Build the software.

  3. Include #include "include/uz_myIP_testbench.h" in main.c (Baremetal R5) and call uz_myIP_testbench(); before the ISR is initialized!

  4. Connected the serial port to the Vitis Serial Terminal

  5. Run the UltraZohm. The success message should be printed to the Vitis Serial Terminal.