Разработка железа на System Verilog HDL/VHDL с использованием верилатора. Часть 4
В части 1 и части 2 мы обсудили основы использования Verilator и написания тестовых стендов на C++ для модулей Verilog/SystemVerilog, а также некоторые основные задачи проверки: обработка входных данных, наблюдение за выходными данными, генерация случайных стимулов и непрерывная проверка, подобная утверждениям. В части 3 мы построили и исследовали испытательный стенд в традиционном стиле, написанный на C++.
В этом руководстве мы рассмотрим более современные методы проверки, в частности, транзакционные тестовые стенды в стиле UVM. Если вы раньше не сталкивались с тестовыми стендами транзакционного стиля, мы надеемся, что это руководство даст вам четкое представление о том, как такие тестовые стенды работают и структурированы. С другой стороны, если вы использовали UVM, это руководство даст вам некоторые идеи о том, как применять концепции UVM при создании тестовых стендов на чистом C++.
Начало
Я предполагаю, что вы прочитали и завершили части 1, 2 и 3 серии руководств по Verilator, прежде чем читать это руководство. Эта часть будет немного отличаться — вместо того, чтобы шаг за шагом создавать наш код, мы будем исследовать готовый тестовый стенд в транзакционном стиле, построенный на коде из предыдущих частей.
реклама
Что такое транзакционные тестовые стенды?
Постоянно растущая сложность конструкций FPGA и ASIC влечет за собой ужесточение требований к проверке. Таким образом, повышенный спрос на передовые испытательные стенды подпитывает появление новых методологий и инструментов, таких как OVM, VVM, UVM, SystemC, а также различных фреймворков на основе Python. И все эти инструменты сосредоточены на одной цели — облегчить создание мощных самопроверяющихся испытательных стендов из многократно используемых компонентов, при этом увеличивая покрытие кода, сводя к минимуму дублирование кода и позволяя инженерам по проверке отвлечься от созерцания осциллограмм. Одним из основных способов достижения этой цели с помощью вышеупомянутых инструментов является поощрение разработчика к написанию тестовых стендов в транзакционном стиле, что, как назло, мы также можем сделать на чистом C++ для использования вместе с Verilator.
Тестовый стенд в транзакционном стиле — это тестовый стенд, который использует транзакции (это просто причудливый термин для обмена данными или управляющими пакетами), которые обеспечивают уровень абстракции над прямым присвоением значений входным контактам и непосредственной проверкой значений выходных контактов устройства. Испытание (ДУТ). Запутался? Позволь мне объяснить.
Тестовый стенд в традиционном стиле, написанный на VHDL, Verilog, SystemVerilog или C++ (как мы видели в части 1), обычно будет иметь экземпляр DUT вместе с некоторым длинным поведенческим кодом с циклами и условными операторами, которые последовательно управляют входными данными DUT для его обхода. через определенные состояния. Большую часть времени такие тестовые стенды проверяются на правильность путем визуального осмотра сигналов, и хотя вы можете добавить возможности самопроверки с помощью утверждений или некоторого кода мониторинга, это обычно активно не поощряется.
Напротив, транзакционный тестовый стенд поощряет строгое разделение между различными функциональными блоками, каждый из которых взаимодействует посредством транзакций. Базовый транзакционный тестовый стенд может состоять из:
1. блок, генерирующий данные стимула для DUT (генератор транзакций/последовательность)
2. блок, который передает вышеупомянутые данные на тестируемое устройство (драйвер)
3. блок, который наблюдает за выходами ИУ и генерирует пакеты данных результатов (монитор)
4. блок, который собирает различные пакеты данных, затем сравнивает их на правильность (табло)
5. блок, собирающий различные пакеты данных, и вычисляющий функциональное покрытие (coverage)
Сразу же существенным недостатком транзакционных тестбенчей является сложность — из-за количества отдельных функциональных блоков, которые необходимо построить, запуск базовых симуляций происходит гораздо медленнее. Однако за дополнительные усилия вы получаете непревзойденную гибкость и возможность повторного использования.
Например, если в вашем тестовом стенде есть драйвер, поддерживающий интерфейс типа A, вы можете повторно использовать тот же самый драйвер с любым другим тестируемым устройством, которое также имеет интерфейс типа A — все, что нужно изменить, — это данные, которые вы передаете. это. Если интерфейс типа A заменяется на интерфейс типа B, вы просто меняете блок драйвера указанного интерфейса с типа A на тип B и сохраняете остальную часть тестового стенда точно такой же.
Подходят ли мне транзакционные тестовые стенды?
Да, если вы можете уделить время обучению и решению дополнительных сложностей. Просто как тот. Единственным исключением из этого, на мой взгляд, является то, что рассматриваемое DUT очень маленькое — возможно, не стоит тратить дополнительное время на создание и понимание транзакционных тестовых стендов, если вы можете набросать окончательный модуль за пару дней.
Рандомизированные транзакционные (стиль UVM) тестовые стенды на необработанном C++ с Verilator
Тем, кто знаком с SystemVerilog UVM, будет приятно узнать, что тестовые стенды в стиле UVM можно довольно легко написать с использованием необработанного C++. Структуры, подобные драйверам, мониторам, подписчикам и табло, просты в реализации, и вы также можете реализовать покрытие, а также структуры, подобные последовательности, с некоторыми дополнительными усилиями.
Преимущество реализации современных транзакционных тестовых стендов с использованием необработанного C++ вместо SystemC и (или) с использованием UVM-SystemC заключается в том, что с ним намного проще начать работу, а в некоторых случаях обеспечивается гораздо более высокая производительность. Основным недостатком является то, что многие вещи, которые присутствуют в SystemC или UVM, должны быть реализованы заново. Теоретически можно также построить тестовую среду с помощью SystemVerilog UVM, преобразовать ее в C++ с помощью Verilator и использовать базовую тестовую среду C++ для запуска проверенной тестовой среды SV. Однако поддержки UVM в Verilator пока нет.
Учитывая все это, в этом руководстве будет рассмотрена реализация базового транзакционного тестового стенда на чистом C++ без каких-либо дополнительных библиотек. Мы сделаем это, написав структуры, которые в общих чертах представляют транзакции UVM, драйверы, мониторы и табло. Это даст вам необходимые знания, чтобы начать писать мощные тестовые стенды C++, а также предоставит понятную и простую в использовании основу для дальнейшего развития.
реклама
Базовая блок-схема транзакционного тестового стенда
Вот базовый пример того, как устроен современный транзакционный тестовый стенд:
Если вы новичок в этом стиле проверки, названия компонентов или термины могут быть вам незнакомы. Вот объяснение того, что представляет собой каждый из них:
Транзакция и элемент транзакции
Транзакция — это высокоуровневая операция обмена данными, а элемент транзакции — не что иное, как пакет данных, или, другими словами, набор данных или инструкций. Обычно они реализуются как класс или структура и содержат данные, которые отправляются или принимаются от DUT.
Генератор транзакций
Генератор транзакций (его также называют генератором случайных транзакций, генератором стимулов, генератором элементов транзакций или генератором случайных входных данных) — это класс или блок кода, который генерирует (создает) элементы транзакций, которые используются в качестве инструкций для управления входные интерфейсы тестируемого устройства.
Драйвер
реклама
Эти структуры являются компонентами, которые принимают элементы транзакций и на основе данных внутри указанных элементов манипулируют физическими контактами входного интерфейса с 1 и 0 для передачи данных в DUT. Драйверы также могут потребоваться для сложных выходных интерфейсов, где необходимы сложные подтверждения.
Интерфейс
Интерфейс — это просто набор или группа контактов, которые используются для определенной функции. Например, FIFO обычно имеет два интерфейса: вход и выход, а ОЗУ с двумя часами может иметь четыре: вход для порта A, выход для порта A, вход для порта B, выход для порта B.
Интерфейс может быть классом или структурой, объединяющей функционально связанные выводы, или, как в нашем случае, это может быть неявная группировка выводов без какой-либо явной группировки в коде.
реклама
Монитор
Это в значительной степени полная противоположность драйвера — вместо того, чтобы манипулировать физическими состояниями выводов на интерфейсах, они пассивно наблюдают (отслеживают) то, что происходит на интерфейсе. Мониторы обычно подключаются к выходному интерфейсу, где они используются для проверки выходных данных тестируемого устройства. Кроме того, вы можете видеть их подключенными к входным интерфейсам, где они могут убедиться, что соответствующий драйвер правильно управляет интерфейсом.
Что они делают с данными, полученными в результате наблюдаемых волновых моделей, зависит от разработчика испытательного стенда. Мониторы могут быть полностью автономными и самопроверяющимися, что означает, что они могут напрямую выполнять код, проверяющий правильность шаблонов волн по мере того, как события происходят на интерфейсе. Однако чаще мониторы предназначены для создания новых элементов транзакций на основе наблюдаемых изменений состояния выводов интерфейса. Эти результирующие элементы транзакций отправляются в порты анализа табло.
Табло
Структура табло обычно содержит самый большой фрагмент кода тестового стенда с самопроверкой. Одно табло имеет один или несколько портов анализа (прослушивания), которые используются для получения элементов транзакций от мониторов. Табло выполняет проверки полученных элементов транзакций, чтобы убедиться, что наблюдаемые входные стимулы приводят к правильным результатам, и определяет, проходит ли тестовый стенд или нет.
Как правило, в тестовом стенде у вас будет одна единственная табло, но ничто не мешает вам построить столько, сколько вы хотите.
Теперь, если вы новичок в тестовых стендах транзакционного стиля, вы, вероятно, держите голову и думаете: «Я не могу понять ничего из этого!». Не беспокойтесь — давайте рассмотрим, как транзакционные тестовые стенды работают с чем-то, вероятно, знакомым каждому, кто читает это, — с кофе!
Аналогии
Представьте, что вы только что купили новую кофемашину и хотите ее протестировать. Вы также приглашаете своего друга, который оказывается профессиональным бариста, для помощи. Будучи перфекционистом, он требует, чтобы вы дали ему точные инструкции для желаемого напитка в следующем формате:
brew_info {
brew_type;
water_quantity;
coffee_quantity;
coffee_grind_size;
cup;
}
Итак, вы берете лист бумаги и создаете инструкции для идеальной чашки:
brew_info my_cup_config;
my_cup_config.brew_type = espresso
my_cup_config.water_quantity = 50g
my_cup_config.coffee_quantity = 25g
my_cup_config.coffee_grind_size = 7
my_cup_config.cup = small blue cup
а затем передайте my_cup_config вашему другу, который затем проанализирует информацию, предоставленную в этом элементе транзакции, и начнет варить чашку точно так, как описано, управляя входными данными CUT (Coffeemaker-Under-Test). Кофемашина имеет входной интерфейс, состоящий из кнопок для запуска и остановки потока воды, циферблата для установки температуры и т. д., а также выходной интерфейс, также известный как носики.
Вы уверены в способностях своего друга, поэтому вряд ли он напортачит с вашими инструкциями. Однако вы просите сестру на всякий случай встать рядом с кофеваркой и следить за процессом приготовления кофе, а также записывать то, что она видит. Так она и делает:
brew_info observed_cup_config;
observed_cup_config.brew_type = espresso
observed_cup_config.water_quantity = 50g
observed_cup_config.coffee_quantity = 25g
observed_cup_config.coffee_grind size = 7
observed_cup_config.cup = small blue cup
Как видите, кофе был приготовлен именно так, как требовалось. Однако, если ваш друг случайно заварит кофе с холодной водой или забудет заранее смолоть зерна, вы узнаете об этом, потому что у вас есть предмет транзакции, который ваша сестра записала и дала вам.
Теперь, исходя из того факта, что мы наблюдаем за кофемашиной, с которой работаем определенным образом, возникают некоторые непосредственные ожидания. Во-первых, от выходного интерфейса кофемашины ожидается производство кофе, а не чая, горячего шоколада или колы. Во-вторых, ожидается, что кофейная жидкость будет обладать определенными качествами в результате особого процесса заваривания.
После того, как кофе подан, вы наблюдаете за чашкой, пробуете кофе, следите за своим опытом и делаете заметки на другом листе бумаги, как истинный знаток:
coffee_characteristics the_coffee_chars;
the_coffee_chars.crema = lots;
the_coffee_chars.sweetness = average;
the_coffee_chars.strength = like a kick from a mule;
the_coffee_chars.cup = small blue cup;
Поскольку вы дали своему другу конкретные инструкции о том, как приготовить эликсир черной энергии, вы точно знаете, каким он должен быть на вкус. Таким образом, вы можете использовать данные в visible_cup_config и the_coffee_chars, чтобы проверить, правильно ли был заварен кофе, используя табло:
if observed_cup_config.brew_type == espresso then the_coffee.crema should be 'lots'
if observed_cup_config.water_quantity/coffee_quantity >= 2 then the_coffee.strength should be 'high'
// and so on...
Если чашка соответствует вашим ожиданиям, мы можем сказать, что, насколько нам известно, кофемашина работает правильно. Надеюсь, эта аналогия оправдает мои ожидания и даст вам лучшее понимание.
Структура и компоненты транзакционного тестового стенда ALU
Подобно базовой блок-схеме на рис. 1, вот структура транзакционного тестового стенда для нашего ALU:
Если вы следовали приведенным выше инструкциям по началу работы, теперь мы можем изучить код, найденный в тестовом стенде tb_alu.cpp.
Элемент транзакции AluInTx
ALU имеет входной интерфейс, состоящий из выводов op_in, a_in, b_in и in_valid. Поэтому нам нужен класс элемента транзакции, который мог бы содержать данные для управления указанными контактами. Это можно сделать с помощью следующего
class AluInTx {
public:
uint32_t a;
uint32_t b;
enum Operation {
add = Valu___024unit::operation_t::add,
sub = Valu___024unit::operation_t::sub,
nop = Valu___024unit::operation_t::nop
} op;
};
Числовые значения для a_in и b_in могут легко содержаться в беззнаковом 32-битном целочисленном типе uint32_t.
Verilator позволяет использовать uint8_t для сигналов шириной до 8 бит, uint16_t — до 16, uint32_t — до 32 и vluint64_t — до 64 бит, и пока ширина сигнала меньше или равна размер целочисленной переменной, и компилятор выполнит преобразование с повышением частоты по мере необходимости. Подробнее здесь.
Для назначения операции операндам a и b мы создаем перечисляемую переменную op, где перечислениям присваиваются значения из проверенного кода SystemVerilog ALU. Это убережет нас от многократного написания болезненного префикса пространства имен Valu___024unit::operation_t::.
Наконец, нам не нужно хранить информацию о in_valid в AluInTx — драйвер позаботится об управлении in_valid на основе сгенерированного AluInTx, который он получает.
Генератор элементов транзакции rndAluInTx
Теперь, когда у нас есть план транзакции AluInTx, нам нужен некоторый код для создания объектов элемента транзакции и присвоения некоторых значений операндам. Это делается в генераторе транзакций:
AluInTx* rndAluInTx(){
//20% chance of generating a transaction
if(rand()%5 == 0){
AluInTx *tx = new AluInTx();
tx->op = AluInTx::Operation(rand() % 3); // Our ENUM only has entries with values 0, 1, 2
tx->a = rand() % 11 + 10; // generate a in range 10-20
tx->b = rand() % 6; // generate b in range 0-5
return tx;
} else {
return NULL;
}
}
Каждый раз, когда вызывается функция rndAluInTx(), она случайным образом либо
1.Выделить память для объекта AluInTx (строка 4), присвоить случайные значения op, a и b и вернуть указатель на вновь сгенерированный объект при выходе (строка 8).
2. Немедленно вернуть NULL в качестве значения указателя (строка 10), что означает, что транзакция не была сгенерирована.
Причина случайного пропуска генерации каждой второй транзакции состоит в том, чтобы допустить промежутки между командами, выдаваемыми ALU. Возврат NULL — не единственный способ добиться этого — вместо этого вы можете пойти совершенно противоположным путем и создать последовательность (см. здесь), которая зависит от некоторой переменной времени (например, тактов симуляции или счетчика положительных фронтов), и затем генерировать транзакции только в определенное время.
Драйвер интерфейса ввода AluInDrv
Как только мы сгенерируем элемент транзакции AluInTx с помощью генератора rndAluInTx, AluInTx передается в блок драйвера, который управляет входным интерфейсом ALU, используя информацию в элементе транзакции. Вот код драйвера из нашего тестового стенда:
class AluInDrv {
private:
Valu *dut;
public:
AluInDrv(Valu *dut){
this->dut = dut;
}
void drive(AluInTx *tx){
// we always start with in_valid set to 0, and set it to
// 1 later only if necessary
dut->in_valid = 0;
// Don't drive anything if a transaction item doesn't exist
if(tx != NULL){
if (tx->op != AluInTx::nop) {
// If the operation is not a NOP, we drive it onto the
// input interface pins
dut->in_valid = 1;
dut->op_in = tx->op;
dut->a_in = tx->a;
dut->b_in = tx->b;
}
// Release the memory by deleting the tx item
// after it has been consumed
delete tx;
}
}
};
Конструктору AluInDrv требуется дескриптор объекта dut. Нам нужно иметь дескриптор тестируемого устройства внутри драйверов/мониторов, чтобы иметь доступ к контактам DUT.
В строке 12 вы можете видеть, что ввод in_valid по умолчанию всегда равен 0. Это связано с тем, что если у нас нет действительного AluInTx, а AluInTx также не является NOP, ввод в ALU не будет действительным. Приведенный ниже код SV будет грубым эквивалентом:
always_comb begin
dut.in_valid = 1'b0;
if ( tx item exists && tx item operation != NOP ) begin
dut.in_valid = 1'b1;
end
end
Если элемент транзакции не был NULL, то в строке 26 удаляется (освобождается) соответствующая память, в которой находится объект элемента транзакции. Это очень важно сделать — после того, как элемент используется (загнан в DUT), его время жизни закончилось, и его необходимо удалить, иначе у нас будет утечка памяти.
Обратите внимание, что C++ поддерживает интеллектуальное управление памятью, которое я настоятельно рекомендую вам использовать. Однако в целях пояснения я считаю, что ручное управление памятью лучше иллюстрирует время жизни элементов транзакций.
Монитор входного интерфейса AluInMon
AluInMon отслеживает входной интерфейс ALU, что означает, что он делает прямо противоположное драйверу, который обсуждался в предыдущем разделе.
class AluInMon {
private:
Valu *dut;
AluScb *scb;
public:
AluInMon(Valu *dut, AluScb *scb){
this->dut = dut;
this->scb = scb;
}
void monitor(){
if (dut->in_valid == 1) {
// If there is valid data at the input interface,
// create a new AluInTx transaction item and populate
// it with data observed at the interface pins
AluInTx *tx = new AluInTx();
tx->op = AluInTx::Operation(dut->op_in);
tx->a = dut->a_in;
tx->b = dut->b_in;
// then pass the transaction item to the scoreboard
scb->writeIn(tx);
}
}
};
Здесь вы можете видеть, что в строке 12 мы наблюдаем in_valid ввод в ALU. Если ввод действителен, монитор создает новый элемент транзакции AluInTx, получает значения выводов op_in, a_in, b_in и вставляет их в переменные op, a и b AluInTx.
Этот вновь сгенерированный элемент транзакции сообщает нам, какую операцию мы отправили на входы АЛУ, что потребуется для проверки правильности выходов АЛУ. Таким образом, в строке 22 новый элемент транзакции записывается в таблицу результатов для последующего использования.
Элемент транзакции AluOutTx
Точно так же, как нам нужен элемент транзакции AluInTx для операций с входным интерфейсом, нам нужен элемент транзакции AluOutTx для хранения информации о результатах, выходящих из выходного интерфейса.
Однако, поскольку единственная информация, которую мы получаем от выходного интерфейса, — это одно значение результата, AluOutTx состоит только из одной переменной:
class AluOutTx {
public:
uint32_t out;
};
Монитор выходного интерфейса AluOutMon
Здесь в игру вступает элемент AluOutTx. Точно так же, как монитор входного интерфейса, AluOutMon точно так же наблюдает за выходным интерфейсом:
class AluOutMon {
private:
Valu *dut;
AluScb *scb;
public:
AluOutMon(Valu *dut, AluScb *scb){
this->dut = dut;
this->scb = scb;
}
void monitor(){
if (dut->out_valid == 1) {
// If there is valid data at the output interface,
// create a new AluOutTx transaction item and populate
// it with result observed at the interface pins
AluOutTx *tx = new AluOutTx();
tx->out = dut->out;
// then pass the transaction item to the scoreboard
scb->writeOut(tx);
}
}
};
AluOutMon терпеливо ждет, пока сигнал out_valid будет установлен в 1. Когда это происходит, генерируется новый элемент AluOutTx (строка 16), значение на выходных контактах DUT сохраняется в переменной out в элементе AluOutTx, а затем пункт передается в табло.
Табло AluScb
Табло — это, на мой взгляд, самый важный блок в тестбенче — это главный мозг всей этой операции. Табло — это место, где вся правда о жизни, вселенной и обо всем хранится, а также применяется:
// ALU scoreboard
class AluScb {
private:
std::deque<AluInTx*> in_q;
public:
// Input interface monitor port
void writeIn(AluInTx *tx){
// Push the received transaction item into a queue for later
in_q.push_back(tx);
}
// Output interface monitor port
void writeOut(AluOutTx* tx){
// We should never get any data from the output interface
// before an input gets driven to the input interface
if(in_q.empty()){
std::cout <<"Fatal Error in AluScb: empty AluInTx queue" << std::endl;
exit(1);
}
// Grab the transaction item from the front of the input item queue
AluInTx* in;
in = in_q.front();
in_q.pop_front();
switch(in->op){
// A valid signal should not be created at the output when there is no operation,
// so we should never get a transaction item where the operation is NOP
case AluInTx::nop :
std::cout << "Fatal error in AluScb, received NOP on input" << std::endl;
exit(1);
break;
// Received transaction is add
case AluInTx::add :
if (in->a + in->b != tx->out) {
std::cout << std::endl;
std::cout << "AluScb: add mismatch" << std::endl;
std::cout << " Expected: " << in->a + in->b
<< " Actual: " << tx->out << std::endl;
std::cout << " Simtime: " << sim_time << std::endl;
}
break;
// Received transaction is sub
case AluInTx::sub :
if (in->a - in->b != tx->out) {
std::cout << std::endl;
std::cout << "AluScb: sub mismatch" << std::endl;
std::cout << " Expected: " << in->a - in->b
<< " Actual: " << tx->out << std::endl;
std::cout << " Simtime: " << sim_time << std::endl;
}
break;
}
// As the transaction items were allocated on the heap, it's important
// to free the memory after they have been used
delete in;
delete tx;
}
};
Вау, это один большой кусок кода. Начиная сверху, на табло создается очередь для хранения элементов транзакций AluInTx:
std::deque<AluInTx*> in_q;
Каждый раз, когда в АЛУ отправляется допустимая операция, монитор интерфейса ввода записывает элемент AluInTx в табло с помощью метода writeIn (строка 8). Элемент AluInTx необходимо сравнить с соответствующим элементом AluOutTx, чтобы убедиться, что результаты ALU верны. Поскольку ALU является конвейерным, несколько команд могут быть отправлены на входной интерфейс, прежде чем будут получены какие-либо элементы результата AluOutTx. Следовательно, элементы AluInTx необходимо сохранить на потом, пока требуемые элементы AluOutTx не будут записаны в табло, и очередь является идеальным контейнером для этого приложения.
В методе writeOut (строка 14) происходит вся проверка результатов. Во-первых, в строке 17 мы проверяем, не генерирует ли АЛУ случайный мусор на выходе — мы знаем, что должны наблюдать результаты на выходном интерфейсе только в том случае, если некоторое время назад на входе была выполнена допустимая операция, поэтому никогда не должно быть момент времени, когда у нас есть результат, доступный на табло до того, как хотя бы один AluInTx будет доступен в очереди.
Далее, если очередь in_q не пуста, мы извлекаем первый (самый старый) элемент AluInTx из начала очереди, а затем переходим к оператору switch в строке 27. Первый случай в switch (строка 30) — это проверка для правильного поведения тестового стенда - допустимый сигнал не должен быть установлен на входном интерфейсе, если операция NOP, поэтому, если в табло получен anAluInTx с операцией NOP, то мы можем заподозрить, что AluInDrvis работает неправильно.
В остальных случаях, в зависимости от того, является ли AluInTx операцией сложения (строка 36) или операцией вычитания (строка 47), мы добавляем или вычитаем исходные операнды, хранящиеся в пакете AluInTx, который мы захватили из очереди, и проверяем, что результат, полученный в элементе транзакции AluOutTx, соответствует рассчитанному ожидаемому результату.
Наконец, в конце метода writeOut сравниваемые элементы транзакций больше не нужны, и поэтому они удаляются в строках 59 и 60.
Могучий основной цикл и то, как все взаимосвязано
Вот и все — последняя часть головоломки. В основном цикле происходит вся магия проверки. Основная функция содержит экземпляры блоков тестового стенда (мониторы, драйвер, табло), а затем основной цикл итеративно выполняет все функции блока тестового стенда, пока моделирование не будет завершено. Это, или испытательный стенд с треском провалится, и мы выйдем преждевременно. Вот код основной функции и основного цикла моделирования:
int main(int argc, char** argv, char** env) {
<...>
AluInTx *tx;
// Here we create the driver, scoreboard, input and output monitor blocks
AluInDrv *drv = new AluInDrv(dut);
AluScb *scb = new AluScb();
AluInMon *inMon = new AluInMon(dut, scb);
AluOutMon *outMon = new AluOutMon(dut, scb);
while (sim_time < MAX_SIM_TIME) {
dut_reset(dut, sim_time);
dut->clk ^= 1;
dut->eval();
// Do all the driving/monitoring on a positive edge
if (dut->clk == 1){
if (sim_time >= VERIF_START_TIME) {
// Generate a randomized transaction item of type AluInTx
tx = rndAluInTx();
// Pass the transaction item to the ALU input interface driver,
// which drives the input interface based on the info in the
// transaction item
drv->drive(tx);
// Monitor the input interface
inMon->monitor();
// Monitor the output interface
outMon->monitor();
}
}
// end of positive edge processing
m_trace->dump(sim_time);
sim_time++;
}
<...>
delete dut;
delete outMon;
delete inMon;
delete scb;
delete drv;
exit(EXIT_SUCCESS);
}
Давайте посмотрим, как все это работает. После создания экземпляров блоков тестового стенда (строки 14–17) симуляция переходит в основной цикл в строке 19, который выполняется до тех пор, пока не закончится время симуляции.
На каждой итерации основного цикла мы проверяем, находимся ли мы на положительном фронте тактовых импульсов (строка 25), и если да, тестовый стенд генерирует транзакцию AluInTx (строка 28).
Элемент транзакции AluInTx передается драйверу AluInDrv (строка 33), который затем управляет входными контактами ALU на основе информации внутри указанного элемента.
На том же фронте тактового сигнала монитор AluInMon наблюдает за входными контактами ALU (строка 36) и на основе наблюдения за входными контактами создает новый элемент транзакции AluInTx, который записывается в табло.
Все еще на том же фронте тактового сигнала монитор AluOutMon наблюдает за выходными контактами ALU (строка 39), но транзакции AluOutTx еще не создаются из-за задержки конвейера ALU. Требуется как минимум две итерации основного цикла, прежде чем AluOutMon начнет видеть результаты, выходящие из ALU.
Наконец, когда симуляция закончена и основной цикл завершен, все блоки тестового стенда удаляются для освобождения памяти (строки 46-50).
Моделирование
Теперь, когда вы знаете, как работает тестовый стенд, попробуйте смоделировать и изучить результаты. Если вы клонировали исходные коды проекта, запуск make в папке проекта приведет к созданию и запуску транзакционной тестовой среды. Однако по умолчанию он не выводит ничего интересного, потому что и АЛУ, и тестовый стенд ведут себя правильно, и тестовый стенд проходит без ошибок.
Это не очень интересно, поэтому давайте посмотрим, что происходит, когда что-то идет не так.
АЛУ плохие результаты
Эту проблему легко обнаружить — если ALU возвращает плохие результаты, табло немедленно сообщит об этом. Если мы изменим код вывода ALU следующим образом:
out <= 6'h5; //should be "<= result"
out_valid <= in_valid_r;
Затем табло быстро обнаружит неправильные результаты:
AluScb: add mismatch
Expected: 15 Actual: 5
Simtime: 204
AluScb: sub mismatch
Expected: 11 Actual: 5
Simtime: 234
AluScb: add mismatch
Expected: 17 Actual: 5
Simtime: 236
ALU неожиданно out_valid
Если мы изменим АЛУ так, чтобы вывод всегда был действительным, например:
out <= result;
out_valid <= 1'b1; //should be "<= in_valid_r"
Затем тестовый стенд немедленно завершится с фатальной ошибкой при первом обнаружении ошибочного действительного вывода:
./obj_dir/Valu +verilator+rand+reset+2
Fatal Error in AluScb: empty AluInTx queue
make: *** [Makefile:23: waveform.vcd] Error 1
Это связано с тем, что табло не ожидает увидеть выходные данные на выходном интерфейсе, если некоторое время назад на входном интерфейсе не было команды (см. здесь).
Волны!
Если в вашей системе настроен GTKWave, вы можете запустить make wave в папке проекта, чтобы увидеть, как выглядят случайно сгенерированные транзакции:
После огромного количества текста, представленного выше, я уверен, что вы сделали множество выводов о Verilator и транзакционных тестовых стендах самостоятельно, поэтому мне не нужно ничего писать. Лично я пришел к выводу, что буду продолжать использовать и продвигать Verilator и исходные тестовые стенды C++ в обозримом будущем везде, где это возможно, поскольку цена, скорость и гибкость слишком трудно упустить. А вы?
Что дальше?
В идеале у нас было бы несколько дополнительных проверок в функции генератора транзакций rndAluInTx(). Кроме того, в тестовом стенде отсутствует очень важная проверка, которую следует выполнить перед выходом. Могу подсказать: in_q. Если вы думаете, что знаете, что нужно добавить, отправьте мне сообщение в LinkedIn, чтобы убедиться, что вы правы! :)
Наконец, код можно сделать намного лучше, используя современные функции C++, например, заменив необработанные указатели AluInTx, AluOutTx интеллектуальными указателями. Небо это предел!
Есть также другие блоки для изучения, такие как последовательности, прослушиватели, блоки покрытия (см. Приложение A). Хорошим упражнением было бы попробовать реализовать их на этом тестовом стенде.
Приложение A: Другие термины/компоненты, с которыми вы можете столкнуться
Последовательность может быть блоком кода, который заменяет или дополняет генератор транзакций. Последовательность — это именно то, что названо — это, по сути, список шагов, описывающих, что тестовый стенд должен делать в определенное время. Последовательность может содержать, например, шаги, которые создают одну транзакцию или несколько транзакций с определенными значениями или со случайными значениями, или выполняют другие задачи, такие как настройка различных блоков тестового стенда или проверка результатов сложными последовательными или временными способами, которые могут это нелегко сделать на мониторах или табло. Для выполнения задач/проверок в определенное время можно создать последовательность для использования переменных хронометража (например, счетчик времени симуляции, счетчик выполнения, счетчик сброса).
Прослушиватель, также известный как подписчик, подобен уменьшенной однопортовой версии табло. Он может содержать некоторый код проверки, специфичный для интерфейса, который, возможно, не очень хорошо сочетается с другими элементами, содержащимися в основной таблице результатов.
Покрытие — это термин, используемый для описания тщательности проверки. Например, если вы проверяете модуль ОЗУ, единственный способ убедиться, что ваш модуль правильно работает со всеми адресами, — это имитировать операции чтения/записи для всего диапазона адресов. Это эквивалентно полному покрытию.
Следовательно, если вы только проверите, что операции чтения/записи работают по адресу 0, ваш тест может пройти успешно, но ваше покрытие будет практически нулевым.
Однако, если вы протестируете широкий диапазон случайных адресов, у вас будет приличное покрытие, и вы можете ожидать, что в определенной степени ваш модуль будет корректно работать во всем диапазоне адресов. Это экономит время моделирования, потому что вместо проверки каждого возможного адреса вы можете моделировать операции чтения/записи до предела (мин. и макс. адреса) и несколько случайных адресов в диапазоне.
Блок покрытия похож на табло в том, что он прослушивает один или несколько мониторов, однако вместо проверки правильности он регистрирует и сообщает о покрытии, что означает отслеживание того, какие входы, выходы и состояния ИУ наблюдались. . Возвращаясь к примеру с модулем ОЗУ, вы можете убедиться, что вы попали в каждый возможный адрес, или, может быть, только минимальный, максимальный, а также произвольное количество адресов в оставшемся диапазоне.
Лента материалов
Соблюдение Правил конференции строго обязательно!
Флуд, флейм и оффтоп преследуются по всей строгости закона!
Комментарии, содержащие оскорбления, нецензурные выражения (в т.ч. замаскированный мат), экстремистские высказывания, рекламу и спам, удаляются независимо от содержимого, а к их авторам могут применяться меры вплоть до запрета написания комментариев и, в случае написания комментария через социальные сети, жалобы в администрацию данной сети.
Комментарии Правила