Разработка и тестирование целочисленного сумматора с AXI-Stream интерфейсами. Часть 5

Автор: https://github.com/VSHEV92

Исходные коды: https://github.com/pcbproj/AXI-Stream-Adder/tree/main

Оглавление

  • Введение

  • Настройка окружения с помощью +args

  • Verification IP для AXI-Stream интерфейса

  • Модифицированное тестовое окружение

  • Примеры использования окружения

  • Заключение

Введение

Этой частью завершается серия статей, рассказывающих о разработке и тестировании сумматора с AXI-Stream интерфейсами. Мы покажем, как можно улучшить тестовое окружение за счет добавления возможности его настройки без повторной перекомпиляции исходников. Также мы модифицируем драйверы и мониторы AXI-Stream интерфейса, чтобы их можно было повторно использовать в других окружениях и проектах.

Настройка окружения с помощью +args

В текущей реализации тестового окружения все настройки вынесены в отдельный файл tb_defines.vh, и их значения задаются с помощью директивы ``define`. Это может вызывать некоторые неудобства, так как конкретные значения параметров окружения должны быть известны уже на этапе компиляции. Если мы, например, захотим изменить настройки сторожевого таймер, то нам придется заново пересобирать всё окружение. Для больших проектов эта процедура может занимать много времени, поэтому на нужен другой подход.

Разделим настройки тестового окружения на две группы. В первую группу отнесем параметры, значения которых задаются при компиляции исходников. К таким настройкам, например, относятся разрядности сигналов и размеры массивов. Эти параметры по-прежнему будут задаваться с помощью директивы ``define` и находиться в файле tb_defines.vh. Его содержимое представлено ниже:

`ifndef TB_DEFINES_VH
`define TB_DEFINES_VH

// разрядность шины входных слагаемых
`ifndef WIDTH
  `define WIDTH 4
`endif

// ширина входных и выходных шин AXI-Stream интерфейса
`define AXIS_IN_WIDTH $ceil($itor(`WIDTH)/8)*8
`define AXIS_OUT_WIDTH $ceil($itor(`WIDTH+1)/8)*8

// максимальное число транзакций в тесте
`ifndef MAX_TRANS_NUMBER
  `define MAX_TRANS_NUMBER 1000
`endif

`endif

Как и ранее, параметр WIDTH определяет разрядность входных слагаемых, а константы AXIS_IN_WIDTH и AXIS_OUT_WIDTH — разрядности шины tdata для входных слагаемых и результата суммирования соответственно. Мы также добавили один новый параметр MAX_TRANS_NUMBER. Напомним, что в нашем окружении есть такие компоненты, как коллекторы, которые принимают входные слагаемые от мониторов и сохраняют их в массивы. Размеры массивов должны быть известны на этапе компиляции и задаются с помощью параметра MAX_TRANS_NUMBER.

Значения для другой группы настроек можно будет задавать непосредственно в команде запуска теста. В языке Verilog для этих целей применяются +args (plusargs). Например, если в качестве симулятора используется Icarus Verilog, то добавив в команду запуска конструкцию +A=3, мы определим +arg с именем A, значение которого равно трем. Для получения +arg из тестового окружения в Verilog присутствует системная функция $value$plusargs(). Ее работа похожа на функцию scanf() из языка С. В качестве аргументов мы должны передать форматированную строку, которая содержит имя параметра, а также переменную, в которую будет записано значение параметра. Например, чтобы получить значение +arg с именем А и сохранить его в переменную b, нам необходимо выполнить следующий вызов: $value$plusargs("A=%d", b). Функция $value$plusargs() возвращает бинарное значение. Если +args с заданным именем был определен при запуске теста, то функция вернет единичное значение, иначе — нулевое.

Внесем все настройки окружения, которые можно будет задавать с помощью +args, в отдельный файл с именем tb_plusargs.vh. Также добавим вспомогательную процедуру для их получения. Содержимое файла tb_plusargs.vh представлено ниже:

`ifndef TB_PLUSARGS_VH
`define TB_PLUSARGS_VH

`include "tb_defines.vh"

integer seed;            // начальное состояние генератора случайных чисел
integer trans_number;    // число транзакций в тесте
integer min_axis_delay;  // минимальная задержка в тактах на AXI-Stream интерфейсе
integer max_axis_delay;  // максимальная задержка в тактах на AXI-Stream интерфейсе
integer max_axis_value;  // максимальное значение, которое может появиться на шине tdata
integer max_clk_in_test; // максимальная длительность теста в тактах

// получение +args
task get_plusargs();
begin
    // число транзакций в тесте
    if (!$value$plusargs("trans_number=%d", trans_number))
      trans_number = 5;

    // начальное состояние генератора случайных чисел
    if (!$value$plusargs("seed=%d", seed))
      seed = 0;

    // минимальная задержка в тактах на AXI-Stream интерфейсе
    if (!$value$plusargs("min_axis_delay=%d", min_axis_delay))
      min_axis_delay = 0;

    // максимальная задержка в тактах на AXI-Stream интерфейсе
    if (!$value$plusargs("max_axis_delay=%d", max_axis_delay))
      max_axis_delay = 10;
    
    // максимальное значение, которое может появиться на шине tdata
    if (!$value$plusargs("max_axis_value=%d", max_axis_value))
      max_axis_value = 2**`WIDTH - 1;

    // максимальная длительность теста в тактах
    if (!$value$plusargs("max_clk_in_test=%d", max_clk_in_test))
      max_clk_in_test = 300;
end
endtask

`endif

Мы определяем следующие параметры окружения:

  • trans_number — число суммирований, которое будет выполнено в тесте. Используется для завершения теста. Значение этого параметра не может превышать MAX_TRANS_NUMBER.

  • seed — переменная, задающая начальное состояние генераторов случайных чисел. Изменяя это значение, можно формировать разные последовательности случайных воздействий;

  • min_axis_delay — минимальная задержка в тактах перед установкой сигналов tvalid или tready в AXI-Stream интерфейсе;

  • max_axis_delay — максимальная задержка в тактах перед установкой сигналов tvalid или tready в AXI-Stream интерфейсе;

  • max_axis_value — максимальное значение, которое может появиться на шине tdata. Используется для ограничения значений входных слагаемых. По умолчанию определяется исходя из величины параметра WIDTH;

  • max_clk_in_test — максимальная длительность теста в тактах. Используется для настройки watchdog.

Процедура с именем get_plusargs() отвечает за инициализацию описанных выше переменных. С помощью функции $value$plusargs() мы пытаемся получить значение каждого параметра. Если +arg с заданным именем определен, то его значение сохраняется в соответствующую переменную, функция $value$plusargs() возвращает единичное значение, и код внутри блока if не выполняется. Иначе функция $value$plusargs() возвращает нулевое значение, отрицание которого запускает код внутри блока if. Это приводит к инициализации незаданного параметра значением по умолчанию.

Verification IP для AXI-Stream интерфейса

Теперь модифицируем описание драйверов и мониторов, чтобы решить проблему с дублированием кода в тестовом окружении. Обычно повторяющиеся части кода выделяют в виде отдельной процедуры или функции. Мы можем попробовать поступить подобным образом и, например, реализовать драйвер для входного интерфейса в виде отдельной процедуры. В качестве входных аргументов процедура должна принимать сигналы AXI-Stream интерфейса. Однако, это приведет к проблеме. В языке Verilog все аргументы передаются по значению, то есть внутри процедуры создаются их локальные копии. Поэтому если, например, в процедуру передается тактовый сигнал aclk, и нам необходимо детектировать появление его фронта, то мы не можем это сделать с помощью @(posedge aclk). В локальную копию внутри процедуры будет скопировано текущее значение сигнала aclk, и оно уже не будет изменяться, при изменении самого сигнала.

В SystemVerilog эта проблема решена за счет добавления возможности передавать аргументы по ссылке. Чтобы остаться в рамках языка Verilog, нам необходимо воспользоваться макросами, которые представляют из себя части кода, объявленные с помощью ключевого слова ``define`. Макросы, как и процедуры, могут иметь аргументы. Перед компиляцией исходников препроцессор заменяет места, где вызываются макросы, непосредственно на их код с учетом переданных аргументов.

Например, ниже представлен код драйвера для входного AXI-Stream интерфейса:

// драйвер для входного AXI-Stream интерфейса
`define AXIS_SLAVE_DRIVER(aclk, tready, tdata, tvalid, min_delay, max_delay, max_value, seed)   \
    while(1) begin                                                                              \
        tvalid <= 1'b0;                                                                         \
        repeat($urandom(seed) % (max_delay + 1) + min_delay)                                    \
            @(posedge aclk);                                                                    \
        tdata <= $urandom(seed) % (max_value + 1);                                              \
        tvalid <= 1'b1;                                                                         \
        @(posedge aclk);                                                                        \
        while (!tready)                                                                         \
            @(posedge aclk);                                                                    \
    end

С помощью ключевого слова ``defineмы объявляем макрос с именемAXIS_SLAVE_DRIVER`, который принимает следующие аргументы:

  • aclk — тактовый сигнал AXI-Stream интерфейса;

  • tdata — шина данных;

  • tvalid — сигнал валидности данных,

  • tready — сигнал готовности принять данные;

  • min_delay — минимальная задержка в тактах перед отправкой транзакции;

  • max_delay — максимальная задержка в тактах перед отправкой транзакции;

  • max_value — максимальное значение на шине данных;

  • seed — переменная, для настройки начального состояния генератора случайных чисел.

Логика работы драйвера совпадает с той, что была представлена в предыдущей статье. В бесконечном цикле (while (1)) драйвер начинает формировать случайные слагаемые и отправлять их сумматору. В начале драйвер не имеет транзакций для сумматора, поэтому сигнал валидности tvalid устанавливается в нулевое значение. Далее с помощью функции $urandom() мы получаем целое число в диапазоне от min_delay до max_delay и используем его для формирования случайной задержки перед отправкой транзакции. Для ее реализации используется цикл repeat, который ожидает заданное число фронтов тактового сигнала @(posedge aclk).

После этого мы генерируем случайные данные tdata, устанавливаем сигнал валидности tvalid в единичное значение и ждем появления фронта тактового сигнала aclk. Если сигнал готовности tready был равен единице, то это означает, что на шине произошел handshake, сумматор получил транзакцию, и мы можем повторить весь цикл заново. Иначе с помощью цикла while мы каждый такт проверяем, произошел ли handshake, ожидая, когда значение сигнала tready перестанет быть равным нулю. Макрос должен быть объявлен в виде одной строки кода, поэтому для лучшей читаемости мы выделяем отдельные строки драйвера с помощью переноса \.

Также с помощью макроса опишем драйвер для выходного AXI-Stream интерфейса:

// драйвер для выходного AXI-Stream интерфейса
`define AXIS_MASTER_DRIVER(aclk, tready, min_delay, max_delay, seed) \
    while (1) begin                                                  \
        tready <= 1'b0;                                              \
        repeat($urandom(seed) % (max_delay + 1) + min_delay)         \
            @(posedge aclk);                                         \
        tready <= 1'b1;                                              \
        @(posedge aclk);                                             \
        repeat($urandom(seed) % (max_delay + 1) + min_delay)         \
            @(posedge aclk);                                         \
    end

Драйвер должен управлять только сигналом tready, поэтому в его аргументах отсутствуют сигналы tdata и tvalid. В бесконечном цикле (while (1)) формируется значение сигнала tready. Cначала мы задаем ему нулевое значение и с помощью функции $urandom() и цикла repeat удерживаем его в этом состоянии в течение случайного числа тактов. Далее мы присваиваем сигналу tready единичное значение и опять ждем случайное число тактов. После этого цикл повторяется.

Наконец, рассмотрим макрос для монитора:

// монитор для AXI-Stream интерфейса
`define AXIS_MONITOR(aclk, tvalid, tready, handshake_event)     \
    while (1) begin                                             \
        @(posedge aclk);                                        \
        if (tready && tvalid)                                   \
            -> handshake_event;                                 \
    end

Монитор должен наблюдать за сигналами интерфейса и определять моменты времени, когда происходит передача транзакции. Для этого на каждом такте отслеживаются значения сигналов tvalid и tready. Когда оба сигнала будут равны единице (tready && tvalid), мы запускаем событие handshake_event с помощью оператора -> . Само событие также передается в макрос в виде аргумента.

Внесем все рассмотренные выше макросы в отдельный файл с именем axis_vip.vh. Таким образом, мы сможем их повторно использовать в любом другом окружении, подключая файл axis_vip.vh с помощью директивы ``include`. Компоненты окружения, которые можно многократно переиспользовать называют Universal Verification Component (UVC) или Verification IP (VIP). Многие компании выпускают свои VIP для различных цифровых интерфейсов. Как правило, коммерческие VIP содержать множество настроек, часто реализуются на основе ООП и по сравнению с нашими макросами устроены намного более сложным образом.

Применение VIP для работы со стандартными интерфейсами позволяет разработчику окружения выполнять проверку устройства, находясь на более высоком уровне абстракции. Достаточно сформировать транзакции и передать их в VIP, который уже самостоятельно правильным образом выставит значения для всех сигналов интерфейса. Это существенно упрощает жизнь разработчику окружения, так как он может сосредоточиться на внутренней логике работы тестируемого устройства, а не на интерфейсах взаимодействия с ним.

Модифицированное тестовое окружение

Применим рассмотренные ранее модификации и реализуем окончательный вариант тестового окружения. Для начала включим в окружение вспомогательные файлы:

module adder_axis_tb ();
   
  `include "axis_vip.vh"
  `include "tb_defines.vh"
  `include "tb_plusargs.vh"
  `include "tb_tasks.vh"
    

Далее объявим сигналы и переменные:

  // тактовый сигнал и сигнал сброса
  reg aclk = 1'b0;
  reg aresetn = 1'b0;

  // сигналы для AXI-Stream интерфейсов
  reg  data1_i_tvalid, data2_i_tvalid, data_o_tready;
  wire data1_i_tready, data2_i_tready, data_o_tvalid;

  // слагаемые и результат суммы
  reg  [ `AXIS_IN_WIDTH-1:0]  data1_i_tdata;
  reg  [ `AXIS_IN_WIDTH-1:0]  data2_i_tdata;
  wire [ `AXIS_OUT_WIDTH-1:0] data_o_tdata;

  // массивы для сохранения входных слагаемых
  reg [`WIDTH-1:0] axis_data1 [0:`MAX_TRANS_NUMBER];
  reg [`WIDTH-1:0] axis_data2 [0:`MAX_TRANS_NUMBER];

  // счетчики числа слагаемых и результатов суммы
  integer unsigned axis_data1_cnt = 0;
  integer unsigned axis_data2_cnt = 0;
  integer unsigned trans_cnt = 0;

  // события handshake на AXI-Stream интерфейсах
  event data1_i_e, data2_i_e, data_o_e;

  // флаг наличия ошибок в тесте
  reg error_flag = 1'b0;

Добавим в окружение наш сумматор и подключим к его портам все необходимые сигналы:

// проверяемый модуль
  adder_axis_pipe #(
      .ADDER_WIDTH(`WIDTH)
  ) dut (
      .aclk          (aclk),
      .aresetn       (aresetn),
      .data1_i_tdata (data1_i_tdata),
      .data1_i_tvalid(data1_i_tvalid),
      .data1_i_tready(data1_i_tready),
      .data2_i_tdata (data2_i_tdata),
      .data2_i_tvalid(data2_i_tvalid),
      .data2_i_tready(data2_i_tready),
      .data_o_tdata  (data_o_tdata),
      .data_o_tvalid (data_o_tvalid),
      .data_o_tready (data_o_tready)
  );

Запуск всех компоненов окружения выполним в одном блоке initial. Это удобно, так как все требуемые действия будут собраны в одном месте. В начале вызовем процедуру для получения +args:

// запуск тестового окружения
initial begin
  // получаем +args
  get_plus_args();

Далее ожидаем 10 тактов и переводим сигнал сброса в неактивное единичное состояние:

// ждем 10 тактов и снимаем сигнал сброса
repeat(10) @(posedge aclk);
  aresetn <= 1'b1;

Все компоненты окружения должны работать параллельно, поэтому их запуск выполняется внутри блок fork-join. Для старта трех драйверов, добавим три макроса и передадим им нужные аргументы:

fork
// --------- drivers --------
  `AXIS_SLAVE_DRIVER(aclk, data1_i_tready, data1_i_tdata, data1_i_tvalid, min_axis_delay, max_axis_delay, max_axis_value, seed)
  `AXIS_SLAVE_DRIVER(aclk, data2_i_tready, data2_i_tdata, data2_i_tvalid, min_axis_delay, max_axis_delay, max_axis_value, seed)
  `AXIS_MASTER_DRIVER(aclk, data_o_tready, min_axis_delay, max_axis_delay, seed) 

Аналогичным образом запускаем все мониторы:

// -------- monitors --------
  `AXIS_MONITOR(aclk, data1_i_tvalid, data1_i_tready, data1_i_e)
  `AXIS_MONITOR(aclk, data2_i_tvalid, data2_i_tready, data2_i_e)
  `AXIS_MONITOR(aclk, data_o_tvalid, data_o_tready, data_o_e)

Далее в виде отдельных циклов while(1) запускаем коллекторы и scoreboard. В конце закрываем блоки fork-join и initial.

// -------- scoreboard ---------
  while (1) begin // запись данных на data1_i интерфейсе
    @(data1_i_e);
    axis_data1[axis_data1_cnt] = data1_i_tdata;
    axis_data1_cnt = axis_data1_cnt + 1;
  end
  while (1) begin // запись данных на data2_i интерфейсе
    @(data2_i_e);
    axis_data2[axis_data2_cnt] = data2_i_tdata;
    axis_data2_cnt = axis_data2_cnt + 1;
  end
  while (1) begin // сравнение результатов с моделью
    @(data_o_e);
    compare(axis_data1[trans_cnt], axis_data2[trans_cnt], data_o_tdata, error_flag);
    trans_cnt = trans_cnt + 1;
    check_finish(trans_cnt, trans_number, error_flag);
  end
join
end

Чтобы закончить реализацию тестового окружения, нам необходимо добавить генератор тактового сигнала, сторожевой таймер и блок initial для дампа временных диаграмм в формате VCD.

  // создание тактового сигнала
  always #5 aclk = ~aclk;

  // сторожевой таймер для отслеживания зависания теста
  initial begin
    repeat(max_clk_in_test) @(posedge aclk);
    $display("ERROR! Watchdog error!");
    $display("----------------------");
    $display("---- TEST FAILED! ----");
    $display("----------------------");
    $finish;
  end

  // дамп waveforms в VCD файл
  initial begin
    $dumpfile("wave_dump.vcd");
    $dumpvars(0);
  end 

Обратите внимение, насколько уменьшилось число строк в описании нашего окружения. При желании можно сделать его еще более лаконичным, записав коллекторы и scoreboard также в виде макросов. Однако, это не принесет ощутимой пользы. Такие компоненты, как scoreboard, обычно тесно связаны с проверяемым устройством, поэтому их сложнее реализовать в виде VIP и переиспользовать в других проектах. Как правило, в тестовых окружениях VIP используются для взаимодейстия со стандартными интерфейсами устройства.

Примеры использования окружения

Мы завершили описание тестового окружения и готовы приступить к его использованию. Для демонстрации работы окружения в качестве симулятора выберем Icarus Verilog. Для начала с помощью команды iverilog скомпилируем исходники в snapshot с именем adder_snap. Далее воспользуемся командой vvp для запуска моделирования. С помощью +arg зададим число транзакций, равным 5 (+trans_number=5). Сообщения от симулятора показывают, что тест пройден успешно.

nn9j9wm6-uzrugvs5hmmuq29byw.png

На временных диаграммах можем увидеть, что сумматор получил пять пар слагаемых и корректно выполнил суммирование:

0g-jbriy-i-1hv-wt2vxabthc-u.png

Допустим, что теперь мы хотим рассмотреть более длинный тест, который будет состоять уже из 15 транзакций. Благодаря использованию +args нам не нужно заново компилировать окружение. Достаточно просто запустить тест с помощью vvp, добавив +trans_number=15.

f5kpre8rflfbjg1exp3jrm2jlxs.png

Можно увидеть, что тест завершается после выполнения 15 суммирований.

gjybhhy1iky3jugvszwb4_yijia.png

Также заметим, что несмотря на то, что слагаемые формируются генератором случайных чисел, в обоих тестах их значения совпадают. Это связано с тем, что начальное состояние генератора задается с помощью переменной seed, которая по умолчанию равна нулю. Мы можем изменить это значение, используя +arg:

r20kivqq5azy1regdsoivkzxzvm.png

Теперь мы получили новую случайную последовательность слагаемых:

lurnpeljojyqeumpzz01zfztkfu.png

Если в операционной системе Linux воспользоваться переменной $RANDOM, то можно автоматически генерировать случайные значения для переменной seed. Таким образом, каждый запуск теста будет формировать новую последовательность воздействий и исследовать новые области пространства состояний устройства.

3gvoa0krjkvoi5lxrus9cr1_b5u.png

Наконец покажем, как можно проверить пропускную способность нашего сумматора. Для этого необходимо настроить окружение таким образом, чтобы передатчики на каждом такте готовы были предоставить новые слагаемые, а приемник всегда мог принять результат суммы. Мы можем регулировать скорость работы драйверов, задавая максимальное (max_axis_delay) и минимальное (min_axis_delay) значения задержки перед отправкой транзакции. Присвоив этим параметрам нулевые значения, мы заставим драйверы работать без задержек.

Ниже представлены временные диаграммы работы сумматора в режиме максимальной нагрузки. Можно увидеть, что сумматор каждый такт готов принимать новые слагаемые. Результат сложения появляется на выходе спустя два такта.

u-sb7tacsj1kyrjnqcji2g1zeog.png

Ради интереса сравним это результат с первой реализацией сумматора, при которой блок управления состоял из одного конечного автомата с последовательно изменяющимися состояниями.

nhq4db0znxjsjzulxdnrsodcymk.png

Видно, что данная версия сумматора готова принимать новые слагаемые только каждый третий такт. Таким образом, после модернизации блока управления максимальная пропускная способность сумматора увеличилась в три раза.

Заключение

На этом мы завершаем данный цикл статей. Мы узнали некоторые приемы, которые часто используются при проектировании цифровых устройств. Например, было показано, как можно разбивать разрабатываемый модуль на тракт данных и блок управления. Также мы применили декомпозицию для создания блока управления из нескольких более простых конечных автоматов.

Мы познакомились с тем, как строятся тестовые окружения и рассмотрели преимущества constraint random testing. Созданное нами окружение по-прежнему имеет ряд недостатков. Например, функция $urandom() возвращает случайное 32-битное число, поэтому с ее помощью проблемно проверять сумматор, ширина слагаемых которого будет больше, чем 32 бита. Применение макросов также не является хорошей практикой, так как они имеют глобальную область видимости, что может приводить к конфликтам имен в случае больших проектов. Интересующимся читателям рекомендуем обратить внимание на язык SystemVerilog, имеющий множество удобных языковых конструкций, которые облегчают реализацию тестовых окружений для сложных цифровых устройств.

© Habrahabr.ru