Автор: https://github.com/VSHEV92
Исходные коды:
https://github.com/pcbproj/AXI-Stream-Adder/tree/axis-adder-v1
https://github.com/pcbproj/AXI-Stream-Adder/tree/axis-adder-v2
Оглавление
Введение
Тестовое окружение для проверки сумматора
Настройки тестового окружения
Вспомогательные функции
Сигналы тестового окружения
Драйверы и мониторы AXI-Stream интерфейсов
Описание Scoreboard
Остальные компоненты окружения
Примеры использования окружения
Заключение
Введение
В предыдущей части был р��ссмотрен основной подход, применяемым для тестирования сложных цифровых устройств - constraint random testing. Мы узнали, как автоматизировать проверку корректности работы устройства с помощью сравнения его выходов с эталонной моделью. Тестовые окружения, работающие по такому принципу, называются self-test testbench. Мы увидели из каких компонентов строятся тестовые окружения и разработали структуру окружения для проверки сумматора с AXI-Stream интерфейсами. В этой статье мы перейдем от теории к практике и покажем, как реализовать это окружение на языке Verilog.
Тестовое окружение для проверки сумматора
Напомним структуру тестового окружения, которое мы хотим описать.

Окружение состоит из трех драйверов, два из которых выступают в роли передатчиков, а один - приемника. Задача передатчиков заключается в формировании случайных слагаемых и отправки их сумматору в соответствии с правилами AXI-Stream интерфейса. Эти два драйвера будут управлять сигналами tdata и tvalid входных интерфейсов сумматора. Драйвер-приемник должен управлять сигналом tready для выходного AXI-Stream интерфейса сумматора. Задержки на интерфейсах будут случайными в пределах настраиваемого диапазона.
Также окружение содержит три монитора, по одному на каждый AXI-Stream интерфейс. Мониторы будут непрерывно анализировать сигналы tvalid и tready и при обнаружении handshake отправлять данные на шине tdata в scoreboard.
В свою очередь scoreboard будет получать транзакции от мониторов и принимать решение о корректности выполнения суммирования. Входные слагаемые будут временно сохраняться в массивы с помощью коллекторов. После получения транзакции от выходного монитора scoreboard будет считывать слагаемые из массивов, передавать их в эталонную модель и далее отправлять эталонный результат в checker. Checker получает результаты от эталонной модели и тестируемого сумматора и выполняет их сравнение. В случае несовпадения выводится сообщение об ошибке.
Также мы будем использовать сторожевой таймер (watchdog), чтобы иметь возможность завершить тест при зависании проверяемого устройства или одного из компонентов окружения.
Перейдем, наконец, к практической части и начнем поэтапно реализовывать наше тестовое окружение на языке Verilog.
Настройки тестового окружения
Для удобства описания тестового окружения выделим некоторые его части в виде отдельных файлов. Окружение будет запускать один случайный тест, который можно будет конфигурировать с помощью различных настроек. Ниже представлено содержимое файла 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 TRANS_NUMBER `define TRANS_NUMBER 5 `endif // начальное состояние генератора случайных чисел `ifndef SEED `define SEED 0 `endif // минимальная задержка в тактах на AXI-Stream интерфейсе `ifndef MIN_AXIS_DELAY `define MIN_AXIS_DELAY 0 `endif // максимальная задержка в тактах на AXI-Stream интерфейсе `ifndef MAX_AXIS_DELAY `define MAX_AXIS_DELAY 10 `endif // максимальное значение, генерируемое драйвером AXI-Stream интерфейса `ifndef MAX_AXIS_VALUE `define MAX_AXIS_VALUE 2**`WIDTH - 1 `endif // максимальная длительность теста в тактах `ifndef MAX_CLK_IN_TEST `define MAX_CLK_IN_TEST 300 `endif `endif
Разберем назначение каждого параметра:
WIDTH- разрядность входных слагаемых;AXIS_IN_WIDTH- разрядность шиныtdataдля входных слагаемых;AXIS_OUT_WIDTH- разрядность шиныtdataдля результата суммирования;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.
Отметим, что почти все настройки объявлены внутри конструкции ifndef ... endif. Это сделано для того, чтобы при запуске теста их значения можно было переопределять. Например, если в качестве симулятора используется Icarus Verilog, то добавив в команду запуска конструкцию -D TRANS_NUMBER=15, мы получим тест, который завершится после выполнения 15 суммирований. Если при запуске теста значение для какого-либо параметра не задано, то будет использовано значение по умолчанию, определенное в файле tb_defines.vh. Например, для числа суммирований TRANS_NUMBER значение по умолчанию равно 5. Исключением являются параметры AXIS_IN_WIDTH и AXIS_OUT_WIDTH, которые напрямую зависят от значения WIDTH и не задаются вручную.
В начале файла также присутствуют следующие две строки:
`ifndef TB_DEFINES_VH `define TB_DEFINES_VH
Это известная в программировании конструкция, которая называется include guard. Она служит для защиты от ошибок множественного определения при многократном выполнении директивы include для одного и того же файла.
Вспомогательные функции
Создадим файл с именем tb_tasks.vh, в котором объявим некоторые вспомогательные процедуры. Файл будет начинаться с include guard и включать в себя настройки теста из tb_defines.vh.
`ifndef TB_TASKS_VH `define TB_TASKS_VH `include "tb_defines.vh"
С помощью отдельной процедуры gold_adder опишем эталонную модель сумматора. Модель будет принимать входные слагаемые data1_i и data2_i и вычислять результат суммирования data_o. Ширина шина tdata из-за требований AXI-Stream интерфейса (кратность восьми битам) может превышать значение WIDTH, поэтому перед вычислением суммы выделяем из слагаемых младшие WIDTH бит:
// эталонный сумматор task gold_adder(input integer data1_i, input integer data2_i, output integer data_o); integer in_1, in_2; begin in_1 = data1_i[`WIDTH-1:0]; in_2 = data2_i[`WIDTH-1:0]; data_o = in_1 + in_2; end endtask
Также в виде отде��ьной процедуры с именем compare реализуем checker. Процедура принимает входные слагаемые data1_i и data2_i от коллекторов, пропускает их через модель gold_adder и получает эталонный результат gold_out. Далее этот эталон сравнивается с выходом сумматора data_o. Если значения не совпадают, то выводится сообщение об ошибке. Также на вход процедуры поступает однобитный сигнал error_flag, который служит индикатором наличия ошибок в процессе работы теста. Если checker обнаруживает несовпадение данных, то сигнал error_flag устанавливается в единицу и остается в этом состоянии до конца теста.
// сравнение с эталонной моделью task compare(input integer data1_i, input integer data2_i, input integer data_o, inout reg error_flag); integer gold_out, dut_out; begin gold_adder(data1_i, data2_i, gold_out); dut_out = data_o[`WIDTH:0]; // вывод на экран и установка флага ошибки if (gold_out != dut_out) begin $display("ERROR! Data mismatch! input 1: %0d, input 2: %0d, output: %0d, gold: %0d, time: %0t", data1_i, data2_i, dut_out, gold_out, $time); error_flag = 1'b1; end end endtask
Последняя процедура check_finish отвечает за завершение теста и вывод отчета о его результатах. На вход поступает число выполненных суммирований trans_cnt, запланированное число суммирований trans_number и сигнал наличия ошибок error_flag. Если число выполненных суммирований совпадает с числом запланированных (trans_cnt == trans_number), то тест завершается с помощью функции $finish. Перед этим проверяется значение сигнала error_flag. Если оно равно единице, то это означает, что во время выполнения теста были обнаружены ошибки, поэтому выводится сообщение TEST FAILED!. Иначе выводится сообщение TEST PASSED!.
// проверка числа обработанных сложений и завершение теста task check_finish(input integer trans_cnt, input integer trans_number, input reg error_flag); begin if (trans_cnt == trans_number) begin if (error_flag) begin $display("----------------------"); $display("---- TEST FAILED! ----"); $display("----------------------"); end else begin $display("----------------------"); $display("---- TEST PASSED! ----"); $display("----------------------"); end $finish; end end endtask
Сигналы тестового окружения
Разобравшись со вспомогательными файлами, начнем описывать основные части тестового окружения. Для начала включим в окружение вспомогательные файлы:
module adder_axis_tb (); `include "tb_defines.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;
Объявим массивы, в которые коллекторы будут складывать входные слагаемые. Длина массивов определяется через запланированное число суммирований (TRANS_NUMBER):
// массивы для сохранения входных слагаемых reg [`WIDTH-1:0] axis_data1 [0:`TRANS_NUMBER]; reg [`WIDTH-1:0] axis_data2 [0:`TRANS_NUMBER];
Создадим несколько счетчиков. Сигналы axis_data1_cnt и axis_data2_cnt подсчитывают число входных слагаемых и используются для индексирования в массивах коллекторов. Счетчик числа суммирований trans_cnt передается в scoreboard для проверки условия завершения теста.
// счетчики числа слагаемых и результатов суммы integer unsigned axis_data1_cnt = 0; integer unsigned axis_data2_cnt = 0; integer unsigned trans_cnt = 0;
Отдельно объявим переменную seed для задания начального состояния генераторов случайных чисел. Функция $urandom() требует в качестве аргумента целочисленную переменную. Мы не можем в $urandom() напрямую передавать константу ``SEEDиз файла **tb_defines.vh**. Для этих целей будет использоваться переменнаяseed`.
// начальное состояние генератора случайных чисел integer seed = `SEED;
В Verilog все блоки initial и always, а также непрерывные присваивания assign, выполняются одновременно и параллельно друг относительно друга. Для обеспечения cинхронизации этих процессов в Verilog используются события (events). В нашем окружении с помощью events будет координироваться совместная работа мониторов и scoreboard:
// события 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) );
Драйверы и мониторы AXI-Stream интерфейсов
Для начала рассмотрим реализацию драйверов для входных интерфейсов сумматора. В нашем окружении должно быть д��а драйвера, которые выступают в роли передатчиков. Их задача заключается в создании входных слагаемых и отправки их сумматору. Каждый драйвер должен формировать данные для шины tdata и управлять сигналом валидности tvalid. Чтобы обнаружить момент возникновения handshake, драйвер должен следить за сигналом tready.
Ниже представлено описание драйвера для первого входного интерфейса сумматора:
// драйвер для data1_i AXI-Stream интерфейса initial begin // ожидаем выхода из состояния сброса @(posedge aresetn); while (1) begin data1_i_tvalid <= 1'b0; // выполняем задержку на случайное число тактов repeat ($urandom(seed) % (`MAX_AXIS_DELAY + 1) + `MIN_AXIS_DELAY) @(posedge aclk); // выставляем сигнал tvalid и данные data1_i_tdata <= $urandom(seed) % (`MAX_AXIS_VALUE + 1); data1_i_tvalid <= 1'b1; @(posedge aclk); // ожидаем сигнал tready для handshake while (!data1_i_tready) @(posedge aclk); end end
Драйвер реализован внутри блока initial. В начале теста сумматор будет находиться в состоянии сброса. Перед тем, как подавать на него транзакции, мы ожидаем установки сигнала сброса в неактивный единичный уровень (@(posedge aresetn)). Далее драйвер в бесконечном цикле (while (1)) начинает формировать случайные слагаемые и отправлять их сумматору.
В начале драйвер не имеет транзакций для сумматора, поэтому сигнал валидности data1_i_tvalid устанавливается в нулевое значение. С помощью функции $urandom() мы получаем целое число в диапазоне от MIN_AXIS_DELAY до MAX_AXIS_DELAY и используем его для формирования случайной задержки. Для этих целей мы используем цикл repeat, внутри которого ожидаем заданное число фронтов тактового сигнала @(posedge aclk).
После этого мы генерируем случайные данные data1_i_tdata, выставляем сигнал валидности data1_i_tvalid в единичное значение и ждем появления фронта сигнала aclk. Если сигнал готовности data1_i_tready равен единице, то это значит, что на шине произошел handshake. Сумматор получил транзакцию и мы можем повторить весь цикл заново. Иначе с помощью цикла while на каждом такте @(posedge aclk) мы проверяем, произошел ли handshake. Цикл while выполняется до тех пор, пока сигнал data1_i_tready не примет единичное значение.
Драйвер для второго входного интерфейса имеет тот же вид, за исключением того, что теперь мы используем сигналы data2_i_tdata, data2_i_tvalid и data2_i_tready. Его описание представлено ниже:
// драйвер для data2_i AXI-Stream интерфейса initial begin // ожидаем выхода из состояния сброса @(posedge aresetn); while (1) begin data2_i_tvalid <= 1'b0; // выполняем задержку на случайное число тактов repeat ($urandom(seed) % (`MAX_AXIS_DELAY + 1) + `MIN_AXIS_DELAY) @(posedge aclk); // выставляем сигнал tvalid и данные data2_i_tdata <= $urandom(seed) % (`MAX_AXIS_VALUE + 1); data2_i_tvalid <= 1'b1; @(posedge aclk); // ожидаем сигнал tready для handshake while (!data2_i_tready) @(posedge aclk); end end
Теперь рассмотрим, как устроен драйвер для выходного интерфейса сумматора. В этом случае драйвер выступает в качестве приемника данных, и его задача заключается в управлении сигналом tready. В соответствии с правилами AXI-Stream интерфейса сигнал tready не должен зависеть от сигнала tvalid и может изменять свое значение на каждом такте сигнала aclk.
Описание драйвера для выходного интерфейса сумматора представлено ниже:
// драйвер для data_o AXI-Stream интерфейса initial begin // ожидаем выхода из состояния сброса @(posedge aresetn); while (1) begin // сбрасываем сигнал tready data_o_tready <= 1'b0; // выполняем задержку на случайное число тактов repeat($urandom(seed) % (`MAX_AXIS_DELAY + 1) + `MIN_AXIS_DELAY) @(posedge aclk); // выставляем сигнал tready data_o_tready <= 1'b1; @(posedge aclk); // опять выполняем задержку на случайное число тактов repeat($urandom(seed) % (`MAX_AXIS_DELAY + 1) + `MIN_AXIS_DELAY) @(posedge aclk); end end
Мы ждем выхода из состояния сброса (@(posedge aresetn)), после чего в бесконечном цикле (while (1)) на каждом такте формируем значение сигнала data_o_tready. Сначала задаем нулевое значение и с помощью функции $urandom() и цикла repeat удерживаем сигнал data_o_tready в этом состоянии в течение случайного числа тактов. Далее присваиваем единичное значение и опять выполняем задержку на случайное число тактов. После этого цикл повторяется.
Разобравшись с драйверами, перейдем к описанию мониторов. Монитор должен наблюдать за сигналами интерфейса и определять моменты времени, когда происходит handshake. Для этого на каждом такте отслеживаются значения сигналов tvalid и tready. Handshake наступает, когда оба сигнала равны единице. Описание всех мониторов представлено ниже:
// монитор для data1_i AXI-Stream интерфейса always begin @(posedge aclk); if (data1_i_tready && data1_i_tvalid) -> data1_i_e; end // монитор для data2_i AXI-Stream интерфейса always begin @(posedge aclk); if (data2_i_tready && data2_i_tvalid) -> data2_i_e; end // монитор для data_o AXI-Stream интерфейса always begin @(posedge aclk); if (data_o_tready && data_o_tvalid) -> data_o_e; end
Для примера, рассмотрим, монитор для выходного интерфейса. Внутри блока always на каждом такте (@(posedge aclk)) проверяется условие, что сигналы data_o_tready и data_o_tvalid принимают единичное значение. При его выполнении с помощью оператора -> запускается событие data_o_e. Так монитор сообщает scoreboard о появлении транзакции на выходном AXI-Stream интерфейсе сумматора.
Описание Scoreboard
Теперь рассмотрим реализацию самого крупного компонента окружения - scoreboard. Он состоит из нескольких частей: коллекторы, эталонная модель и checker. Описание коллектора для слагаемых на первом входном интерфейсе сумматора показано ниже:
always begin @(data1_i_e); axis_data1[axis_data1_cnt] = data1_i_tdata; axis_data1_cnt = axis_data1_cnt + 1; end
В блоке always мы ожидаем срабатывания event (@(data1_i_e)). Его появление означает, что в текущий момент времени на интерфейсе происходит handshake. Поэтому мы берем значение на шине data1_i_tdata и сохраняем его в массив коллектора axis_data1. После этого мы увеличиваем счетчик полученных слагаемых (axis_data2_cnt) на единицу и ждем следующего срабатывания события data1_i_e.
Коллектор для второго входного интерфейса работает таким же образом:
always begin @(data2_i_e); axis_data2[axis_data2_cnt] = data2_i_tdata; axis_data2_cnt = axis_data2_cnt + 1; end
Оставшаяся часть scoreboard, состоящая из эталонной модели и checker, реализуется с помощью еще одного блока always:
always 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
Мы ожидаем событие data_o_e, срабатывание которого указывает на появление транзакции на выходном AXI-Stream интерфейсе. Это в свою очередь означает, что ранее уже были получены входные слагаемые, которые сейчас находятся в массивах коллекторов.
Далее мы вызываем процедуру compare, в которую передаем слагаемые из коллекторов axis_data1[trans_cnt] и axis_data2[trans_cnt], результат сложения на выходе сумматора (data_o_tdata) и сигнал наличия ошибок error_flag. Эта процедура вызывает эталонную модель и выполняет сравнение. В случае несовпадения результатов выводится сообщение об ошибке и значение сигнала error_flag устанавливается в единицу. После этого мы увеличиваем счетчик выполненных суммирований (trans_cnt) и проверяем, можно ли завершить тест. Когда число выполненных суммирований trans_cnt станет равным TRANS_NUMBER, тест завершается и выводится отчет о его выполнении. Если сигнал error_flag равен нулю, то тест пройден успешно, если единице - то нет.
Остальные компоненты окружения
Для завершения описания окружения нам необходимо добавить еще несколько компонентов. С помощью блоков always и initial сформируем тактовый сигнал и сигнал сброса:
// создание тактового сигнала always #5 aclk = ~aclk; // создание сигнала сброса initial begin repeat (10) @(posedge aclk); aresetn <= 1'b1; end
Включим в окружение сторожевой таймер. Для этого в блоке initial с помощью цикла repeat будем ожидать появления заданного числа фронтов тактового сигнала (@(posedge aclk)). Если моделирование выполняется уже на протяжении MAX_CLK_IN_TEST тактов, то мы считаем, что тест завис. Сторожевой таймер завершает тест, и выводится соответствующее сообщение об ошибке.
// сторожевой таймер для отслеживания зависания теста initial begin repeat(`MAX_CLK_IN_TEST) @(posedge aclk); $display("ERROR! Watchdog error!"); $display("----------------------"); $display("---- TEST FAILED! ----"); $display("----------------------"); $finish; end
Наконец, добавим последний блок inital, отвечающий за дамп временных диаграмм в формате VCD.
// дамп waveforms в VCD файл initial begin $dumpfile("wave_dump.vcd"); $dumpvars(0); end
Примеры использования окружения
Наше тестовое окружение готово к использованию. Давайте проверим его работу. Для начала рассмотрим, как оно детектирует различные ошибки. Для этого изменим описание сумматора таким образом, чтобы входной регистр для второго слагаемого работал некорректно и всегда содержал нулевое значение. Запускаем тест, который должен выполнить 5 суммирований, и видим следующие временные диаграммы:

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

Можно увидеть, что окружение обнаружило 5 несовпадений. В сообщениях указаны входные слагаемые, эталонный и фактический результаты сложения, а также момент времени, когда обнаружена ошибка. Также выводится сообщение о том, что тест не пройден.
Теперь внесем в сумматор другую ошибку. Пусть мы забыли подключить к выходному порту data_o_tvalid сумматора сигнал валидности данных от блока управления. После запуска теста получим следующую временную диаграмму:

Драйверы генерируют множество входных транзакций, но сигнал data_o_tvalid всегда находится в z-состоянии. Из-за этого монитор не видит ни одной транзакции на выходе сумматора и ничего не передает в scoreboard. Тот в свою очередь никогда не получит запланированное число транзакций (TRANS_NUMBER) и не завершит тест. Однако, тест не будет выполняться бесконечно долго, так как сторожевой таймер обнаружит зависание и прервет моделирование. В результате мы получим следующее сообщение от симулятора:

Наконец, уберем все ошибки из сумматора и запустим моделирование. Глядя на временные диаграммы, мы можем увидеть, что сложение выполняется правильно:

Однако, чтобы понять, что сумматор работает корректно, не обязательно просматривать все временные диаграммы. Достаточно просто прочитать сообщение симулятора о результатах теста:

Заключение
Мы реализовали на языке Verilog тестовое окружение, структура которого была разработана в предыдущей статье. Окружение построено по принципу self-test testbench и выполняет проверку сумматора с помощью consraint-random testing. Однако, у нас есть еще широкий простор для его улучшения.
Во-первых, при описании драйверов и мониторов мы получили большой объем дублированного кода. Это всегда приводит к сложностям при дальнейшей поддержке и модификации окружения. Во-вторых, для задания параметров теста мы использовали директивы define. Это не самый лучший подход, так как при изменении настроек теста, нам придется заново компилировать все окружение. В случае больших проектов перекомпиляция исходников может занимать достаточно много времени. Есть более удобный способ настройки окружения. Мы исправим эти недостатки и получим окончательный вариант тестового окружения в следующей статье.
