Приветствую тебя, дорогой единомышленник! В данном репозитории содержатся исходные файлы статей канала Telegram-канала Verification For All.
Список статей:
- SystemVerilog и виртуальный интерфейс.
- Верификация на SystemVerilog. "Гонки" сигналов на симуляции.
- Кодовое покрытие в функциональной верификации.
- Обзор SystemVerilog IEEE 1800-2023.
- Demystifying UVM: Фабрика, часть 1.
- Demystifying UVM: Фабрика, часть 2.
- SystemVerilog Gotchas, Tips and Tricks, часть 1.
SystemVerilog и виртуальный интерфейс. Связь статического и динамического мира симуляции.
Вместе с большим количеством нововведений несинтезируемого подмножества (class
, fork-join
, randomize()
, queue
, ...) SystemVerilog "порадовал" инженеров новыми синтезируемыми конструкциями. Одной из них являлся интерфейс (interface
).
Задумка здесь очень простая. Интерфейс по своей сути является сгруппированным набором сигналов. Например, для простейшего AXI-Stream этот набор может быть объявлен следующим образом:
interface axi_s_intf (input logic clk);
// Simple AXI-Stream interface
// TSTRB, TKEEP, TID, TDEST, TUSER
// are not used for this impl.
logic tvalid;
logic tready;
logic [31:0] tdata;
logic tlast;
endinterface
Интерфейс обладает статической природой (как, например, и модуль). Его экземпляр создается в нулевой момент времени симуляции и не может быть удален до ее завершения.
module testbench;
// Тактовый сигнал и сигнал сброса
logic clk;
logic aresetn;
// Экземпляры AXI-Stream интерфейса
axi_s_intf intf_1 (clk);
axi_s_intf intf_2 (clk);
// Дизайн для верификации
my_design DUT (
.clk ( clk ),
.aresetn ( aresetn ),
// AXI-Stream 1
.tvalid_1 ( intf_1.tvalid ),
.tready_1 ( intf_1.tready ),
.tdata_1 ( intf_1.tdata ),
// AXI-Stream 2
.tvalid_2 ( intf_2.tvalid ),
.tready_2 ( intf_2.tready ),
.tdata_2 ( intf_2.tdata ),
.tlast_2 ( intf_2.tlast )
);
Как правило, экземпляры интерфейсов объявляются в главном модуле симуляции и подключаются к интерфейсам или портам тестируемого устройства.
Заметим, что не все провода интерфейса обязательно должны быть использованы при его подключении. Например, в примере выше провод tlast
для интерфейса intf_1
не подключается.
Структурно и в системной памяти имеем следующую картину:
Статические элементы в начале симуляции занимают место в памяти и существуют до завершения симуляции.
Так как в SystemVerilog появились динамические объекты (class
), то возникла также необходимость в получении доступа из динамических объектов к статическим элементам верификационного окружения. Для этих целей и существует виртуальный интерфейс.
Виртуальный интерфейс - указатель на статический экземпляр интерфейса. В ходе симуляции виртуальный интерфейс может указывать на различные экземпляры.
module testbench;
// Тактовый сигнал и сигнал сброса
logic clk;
logic aresetn;
// Экземпляры AXI-Stream интерфейса
axi_s_intf intf_1 (clk);
axi_s_intf intf_2 (clk);
...
// Виртуальный интерфейс AXI-Stream
virtual axi_s_intf vif;
initial begin
$display(vif);
vif = intf_1;
$display(vif);
end
endmodule
Результатом запуска симуляции в таком случае будет:
# null
# /testbench/intf_1
В начале виртуальный интерфейс не указывает ни на какой из статических интерфейсов, то есть проинициализирован null
(нулевым указателем). Строка vif = intf_1
определяет для виртуального интерфейса статический экземпляр интерфейса, на который он будет указывать.
При помощи виртуального интерфейса пользователь может взаимодействовать с сигналами статического интерфейса. В ходе симуляции виртуальный интерфейс может использоваться для изменения сигналов различных статических интерфейсов.
module testbench;
// Тактовый сигнал и сигнал сброса
logic clk;
logic aresetn;
// Экземпляры AXI-Stream интерфейса
axi_s_intf intf_1 (clk);
axi_s_intf intf_2 (clk);
initial begin
clk <= 0;
forever begin
#5 clk <= ~clk;
end
end
...
// Виртуальный интерфейс AXI-Stream
virtual axi_s_intf vif;
initial begin
vif = intf_1;
$display(vif);
@(posedge clk);
vif.tvalid <= 1;
vif.tdata <= 10;
@(posedge clk);
vif = null;
@(posedge clk);
vif = intf_2;
$display (vif);
@(posedge clk);
vif.tvalid <= 1;
vif.tdata <= 10;
vif.tlast <= 1;
@(posedge clk);
vif = null;
end
endmodule
Результаты симуляции:
# /testbench/intf_1
# /testbench/intf_2
Заметим, что в промежутке между присвоениями указателей на статические интерфейсы виртуальный "успел побывать" и в нулевом (null
) значении. В ходе симуляции указатель, содержащийся в виртуальном интерфейсе, может динамически создаваться и уничтожаться.
Изображение ниже демонстрирует процесс создания и удаления указателя на статические интерфейсы (пример кода выше). Изначально виртуальный интерфейс указывал на intf_1
(отмечено -->
), после чего был проинициализирован null
, а после стал указывать на intf_2
(отмечено ->
).
Виртуальные интерфейсы в большинстве случаев используются для передачи в объекты классов, в которых определен набор задач для манипулирования сигналами интерфейса.
module testbench;
// Тактовый сигнал и сигнал сброса
logic clk;
logic aresetn;
// Экземпляры AXI-Stream интерфейса
axi_s_intf intf_1 (clk);
axi_s_intf intf_2 (clk);
...
class my_design_driver;
virtual axi_s_intf vif;
function new(virtual axi_s_intf vif);
this.vif = vif;
endfunction
task drive();
@(posedge vif.clk);
vif.tvalid <= 1;
vif.tdata <= $random();
@(posedge vif.clk);
vif.tvalid <= 0;
endtask
endclass
my_design_driver driver;
initial begin
driver = new(intf_1);
repeat(10) driver.drive();
end
endmodule
Результат симуляции кода:
В данном примере в конструктор класса my_design_driver
передается статический интерфейс. Однако в аргументе конструктора тип аргумента объявлен как virtual axi_s_intf
, то есть происходит присвоение virtual axi_s_intf vif = axi_s_intf intf_1
, что является абсолютно легальным в SystemVerilog (было разобрано в примерах выше).
Как видите, ничего сложного! Больше заметок вы можете найти в Telegram-канале автора Verification For All.
Хорошего тебе дня, читатель, и до новых встреч!
Верификация на SystemVerilog. "Я же все правильно написал, почему не работает?" или "гонки" сигналов на симуляции.
Вступление
Приветствую тебя, читатель!
Проверить 8-битный последовательностный сумматор. Казалось бы, что может быть проще? Но есть нюансы.
Входные данные
Итак, имеем дизайн:
module sum (
input logic clk,
input logic [7:0] a,
input logic [7:0] b,
output logic [7:0] c
);
always_ff @( posedge clk) begin
c <= a + b;
end
endmodule
Напишем простейшее верификационное окружение.
Пишем верификационное окружение
Создадим нужные сигналы и подключим модуль.
logic clk;
logic [7:0] a;
logic [7:0] b;
logic [7:0] c;
sum DUT (
.clk(clk),
.a (a ),
.b (b ),
.c (c )
);
Сгенерируем тактовый сигнал. forever
- бесконечный цикл.
initial begin
clk <= 0;
forever #10 clk <= ~clk;
end
Подадим входные воздействия. 10 раз (repeat(10)
) значение в интервале от 0 до 5 ($urandom_range(0,5)
).
initial begin
repeat(10) begin
@(posedge clk);
a = $urandom_range(0, 5);
b = $urandom_range(0, 5);
end
$stop();
end
Реализуем логирование данных. Создаем mailbox
, куда каждый такт отправляем данные с входных и выходных портов в виде структуры packet
.
typedef struct {
logic [7:0] a;
logic [7:0] b;
logic [7:0] c;
} packet;
mailbox#(packet) mbx = new();
packet p;
initial begin
forever begin
@(posedge clk);
p.a = a;
p.b = b;
p.c = c;
mbx.put(p);
end
end
Осталось циклически проводить проверку каждый такт. Забираем их mailbox
пакеты и сравниваем, что результат текущего такта (c
) равен сумме операндов прошлого такта (a
и b
).
initial begin
mbx.get(p1);
forever begin
mbx.get(p2);
if( p2.c !== p1.a + p1.b ) begin
$error("%t Real: %h, Expected: %h",
$time(), p2.c, p1.a + p1.b);
end
p1 = p2;
end
end
Полный код окружения размещен в файле testbench.sv
.
Запускаем
Симулятор, используемый в примерах: QuestaSim.
Получается, все? Запускаем симуляцию!
cd src/
vlog *.sv
vsim -gui testbench -voptargs="+acc"
Неожиданно сталкиваемся с ошибками. Проблема в дизайне? Не думаю.
run -all
# ** Error: 50 Real: 07, Expected: 04
# Time: 50 ns Scope: testbench File: testbench.sv Line: 55
# ** Error: 90 Real: 01, Expected: 04
# Time: 90 ns Scope: testbench File: testbench.sv Line: 55
# ** Error: 170 Real: 07, Expected: 03
# Time: 170 ns Scope: testbench File: testbench.sv Line: 55
# ** Note: $stop : testbench.sv(26)
# Time: 190 ns Iteration: 1 Instance: /testbench
Смотрим временную диаграмму. Перемещаемся в момент времени 50ns, потому что согласно логу выше первая ошибка была обнаружена именно в этот момент времени.
Хм, кажется, все верно, 0x3
+ 0x4
= 0x7
. Ошибка не наблюдается. В чем же проблема? И ведь тестбенч показывает, что результат должен быть 0x4. Как будто это уже результат для следующего такта.
Проблема здесь кроется в блокирующих присваиваниях (=) вместо неблокируюищих (<=) в коде генерации входных воздействий. Почему это важно? Следите за руками.
SystemVerilog и регионы выполнения
Каждый уважающий себе верификатор знает, что выполнение событий симуляции распределено по так называемым "регионам выполнения" или "регионам событий" (Event Region). Попадая в конкретный момент времени, симулятор обрабатывает события в некоторой последовательности, определенной стандартом SystemVerilog.
Обратим внимание на два региона: Active и NBA.
Применительно к присваиваниям: все блокирующие (=) происходят в случайном порядке в регионе Active, все неблокирующие (<=) тоже в случайном порядке в NBA.
Когда я говорю "в случайном порядке", я имею в виду порядок относительно независимых процессов. То есть, если у вас есть два initial
-блока, которые выполняются совместно:
initial begin
a = 10;
a = 20;
end
initial begin
b = 5
b = 7;
end
То, присвоения внутри begin-end
происходят последовательно, то есть после выполнения a будет таки равно 20, а b равно 7. Однако симулятор может выполнять присвоения из этих двух блоков в любой последовательности.
Например:
a = 10;
b = 5;
b = 7;
a = 20;
Или:
b = 5;
a = 10;
a = 20;
b = 7;
Подробный разбор регионов выполнения можно найти тут и тут. Рекомендую посмотреть перед тем, как продолжим.
Также по этой теме рекомендую статью SystemVerilog Event Regions, Race Avoidance & Guidelines от Clifford Cummings.
Вооружившись знаниями, вернемся к симуляции.
Посыпаем голову пеплом
Мы имеем интересную ситуацию. Совместно у нас исполняются initial
-блоки генерации входных воздействий и мониторинга.
initial begin
repeat(10) begin
@(posedge clk);
a = $urandom_range(0, 5);
b = $urandom_range(0, 5);
end
$stop();
end
...
initial begin
forever begin
@(posedge clk);
p.a = a;
p.b = b;
p.c = c;
mbx.put(p);
end
end
Это означает, что в данном случае после каждого фронта (@(posedge clk)
) последовательность выполнения не определена.
Она может быть такой (что нас устраивает):
p.a = a;
p.b = b;
p.c = c;
mbx.put(p);
a = $urandom_range(0, 5);
b = $urandom_range(0, 5);
А может быть и такой (что нас не устраивает):
a = $urandom_range(0, 5);
b = $urandom_range(0, 5);
p.a = a;
p.b = b;
p.c = c;
mbx.put(p);
Вернемся к моменту времени в 50ns.
А теперь переместимся в момент предыдущего такта (`30ns``) и переключимся в режим Events Mode (правой кнопкой мыши по временной диаграмме -> Expanded Time -> Events Mode, затем снова правой кнопкой мыли по временной диаграмме -> Expanded Time -> Expand All).
Приблизим момент времени 30ns
.
Видим значения 30ns + 2 + 4 + ....
Это как раз таки очередность изменения сигналов в ходе одного региона выполнения времени 30ns
. А теперь внимательно посмотрите, какие данные сохраняются в пакет p. Верно, 0x0
и 0x4
. Почему? Потому что сначала выполнилось a = $urandom_range(0,5)
(вернуло 0), а затем p.a = a
. Получается, что вместо 0x3
и 0x4
в пакет попали 0x0
и 0x4
.
a = $urandom_range(0, 5);
p.a = a;
p.b = b;
p.c = c;
mbx.put(p);
Для b = $urandom_range(0, 5)
точно сказать нельзя, потому что случайное число этого такта совпало с числом на предыдущем такте и наблюдать изменения мы не можем. Но в данном контексте это не важно.
А что происходит дальше? Передвигаемся в следующий такт и попадаем в момент 50ns
.
Здесь нам важно только сохранение результата. Обратите внимание, что сохранение результата 0x7
происходит до его обновления по фронту, т.к. значение c обновляется в коде сумматора через <=
.
После получения пакета с результатом в этом же такте происходит происходит сравнение: 0x0
+ 0x4
!== 0x7
(данные из пакета p
"попадают" пакет p2
сравниваются с данными из p1
). Откуда 0x0
и 0x4
в p1
? Так с предыдущего такта, где вместо 0x3
в пакет попало значение 0x0
.
Явление, описанное выше, когда неопределенность последовательности выполнения событий симулятором приводит к неопределенному поведению симуляции, и называется "гонками сигналов" (англ. race condition).
Исправляем ошибки
Так, в чем проблема - разобрались. Остался вопрос: как её решить? На самом деле очень просто. Нужно помнить одно важное правило: при взаимодействии с портами тестируемого последовательностного устройства используется неблокирующее присваивание (<=
).
initial begin
repeat(10) begin
@(posedge clk);
a <= $urandom_range(0, 5);
b <= $urandom_range(0, 5);
end
$stop();
end
Как это поможет? Так ведь значения, присвоение которым происходит через неблокирующее присваивание (<=
), в обязательном порядке выполняются после выполнения всех блокирующих (вспомните раздел SystemVerilog и регионы выполнения). Для нас это значит, что a <= $urandom_range(0 ,5)
и b <= $urandom_range(0, 5)
выполнятся после сохранения информации о входных значениях, выставленных на предыдущем такте.
Сохраняем изменения и запускаем симуляцию. Ошибки пропали.
run -all
# ** Note: $stop : testbench.sv(26)
# Time: 190 ns Iteration: 1 Instance: /testbench
Код исправленного окружения размещен в файле testbench_fixed.sv
.
Давайте сравним время 30ns
для ошибочного и справленного модулей тестирования. Сверху ошибочный модуль, снизу - исправленный.
Видим, что в исправленном окружении значение 0x0
подается на вход a
после сохранения информации о значениях на входах в текущий момент времени. Происходит это потому, что выставление значения на вход делается через неблокирующее присваивание (<=
), а считывание значений через блокирующее.
Здесь мы сами для себя дополнили озвученное выше правило, звучать оно теперь будет так:
При тестировании последовательностного устройства входные воздействия следует подавать через неблокирующие (<=
) присваивания, а считывать выходные и проверять их - через блокирующие (=
).
Таким образом, в моменте времени 30ns
получаем пакет с верными данными 0x3
и 0x4
. Далее, в моменте времени 50ns
(на следующем такте) получаем данные с выхода.
После получения пакета с результатом в этом же такте происходит происходит сравнение: 0x3
+ 0x4
!== 0x7
(данные из пакета p
"попадают" пакет p2
сравниваются с данными из p1
). Откуда 0x3
и 0x4
в p1
? Так с предыдущего такта, где они были сохранены в процессе мониторинга.
Заключение
Что ж, читатель, мы с тобой детально разобрали такое непростое, но одновременно интересное явление, как "гонки сигналов" на симуляции. В ходе разбора вывели правило, которое обезопасит меня, тебя и еще множество инженеров от потраченного на поиск ошибки времени и нервов.
А больше подобных заметок ты можешь найти в Telegram-канале автора Verification For All.
Хорошего тебе дня и до новых встреч!
Кодовое покрытие в функциональной верификации: все говорят, но никто не использует.
Вступление
Приветствую тебя, дорогой читатель! Данная заметка посвящена кодовому покрытию (англ. code coverage). Это одна из метрик, определяющая полноту проверки устройства. Кодовое покрытие определяет, сколько раз и какие именно участки кода были выполнены в ходе симуляции работы устройства.
Так уж сложилось, что начинающими инженерами кодовое покрытие собирается и анализируется реже, чем то же функциональное (исходя из личного опыта автора). Хотя имеет свои неоспоримые преимущества и причины, по которым этот тип покрытия должен учитываться верификатором.
Motivation или зачем это всё
В данной заметке я бы хотел познакомить тебя с кодовым покрытием, привести простые примеры его сбора и анализа. А еще я бы хотел показать, насколько полезным может быть включение сбора кодового покрытия в верификационный план.
Повествование будет построено по принципу "от простого к сложному", и, в основном, на примерах. Все рассуждения и пояснения касаются исключительно Verilog и SystemVerilog. VHDL не трогаем. Для симуляции работы цифровых устройств будем использовать QuestaSim 10.7c. Запускаться будем под Linux CentOS 7. Поехали!
P.S. Сопроводительные материалы (в основном код примеров и команды для запуска) теперь будут публиковаться в GitHub в репозитории. На момент публикации этой заметки, все сопроводительные материалы для нее уже загружены в репозиторий.
Начинаем с малого
Чтобы сразу быть к контексте, разберем простейший пример. Сначала рассмотрим дизайн, который будем проверять, потом тестовую среду для него, потом запустим симуляцию со сбором кодового покрытия и проанализируем результаты.
Подопытный
Конвертер двоичного кода (вход binary
) в one-hot (выход onehot
):
module binary_to_onehot (
input logic [1:0] binary,
output logic [3:0] onehot
);
always_comb begin
case(binary)
2'b00: onehot = 4'b0001;
2'b01: onehot = 4'b0010;
2'b10: onehot = 4'b0100;
2'b11: onehot = 4'b1000;
endcase
end
endmodule
Тестирование
Напишем простой тестовый сценарий. Просто будем подавать на входы значения.
module testbench;
logic [1:0] binary;
logic [3:0] onehot;
binary_to_onehot DUT (
.binary ( binary ),
.onehot ( onehot )
);
initial begin
binary = 2'b11;
#10;
binary = 2'b01;
#10;
$finish();
end
endmodule
Запуск
Компилируем исходники и запускаем симуляцию:
vlog *.sv
vsim testbench -coverage -voptargs="+cover=s+/testbench/DUT" -do "run -a;"
Передав необходимые опции в команду симуляции, мы активировали сбор одного из типов кодового покрытия - процедурного покрытия (англ. statement coverage).
Если далее в QuestaSim выбрать View -> Coverage -> Code Coverage Analysis
, то откроется окно, в котором можно проанализировать кодовое покрытие.
Если выберем DUT в иерархии, то во вкладке Code Coverage Analysis
увидим:
При сборе такого типа кодового покрытия, ПО в ходе симуляции анализирует выполнение кода, после чего формирует отчет о том, какие процедурные выражения были выполнены. На изображении выше видим пример такого отчета. Из всех возможных выражений выполнилось 3. Почему? Давай разбираться.
Анализ результатов
Так, ну список возможных выражений вполне понятен. Модуль состоит из одного always_comb
блока. Внутри него case
, в зависимости от входного значения, формирует выходное.
// ...
always_comb begin
case(binary)
2'b00: onehot = 4'b0001;
2'b01: onehot = 4'b0010;
2'b10: onehot = 4'b0100;
2'b11: onehot = 4'b1000;
endcase
end
// ...
Блок always_comb
выполняется при каждом изменении переменной внутри этого блока. Очевидно, что если вход binary
изменился хоть раз в ходе симуляции, то и блок должен выполниться. Видим это в отчете (см. выше) в виде зеленой галки напротив соответствующего выражения.
Присвоение определенного значения выхода onehot
выполняется в зависимости от значения на входе binary
. 2'b00
соответствует 4'b0001
, 2'b01
соответствует 4'b0010
и так далее. У нас выполнилось: 2'b01: onehot = 4'b0010;
, 2'b11: onehot = 4'b1000;
. Ну так понятно, почему! Мы ведь в нашем тестбенче (см. выше) подали значения : 2'b01
, 2'b11
:
// ...
initial begin
binary = 2'b11;
#10;
binary = 2'b01;
#10;
$finish();
end
// ...
которые и привели к выполнению соответствующих участков кода.
А если посложнее
Так, ну с базовым примером разобрались, теперь сделаем модуль для тестирования чуть интереснее. Алгоритм тот же, разбираем дизайн, тесты, запускаемся и анализируем результаты.
Подопытный
Можем считать, что это некий декодер, который в зависимости от входа s определяет некоторые значения выходов a
, b
и c
.
module decoder (
input logic [1:0] s,
output logic a,
output logic b,
output logic c
);
always_comb begin
if( s[0] ) begin
a = 0;
b = 0;
c = 0;
end
else begin
case( s[1] )
1'b0: begin
a = 0;
b = 1;
c = 1;
end
1'b1: begin
a = 1;
b = 1;
c = 1;
end
endcase
end
end
endmodule
Тестирование
Тут все тоже тривиально. Подаем на входы значения.
module testbench;
logic [1:0] s;
logic a;
logic b;
logic c;
decoder DUT (
.s ( s ),
.a ( a ),
.b ( b ),
.c ( c )
);
initial begin
s = 2'b00;
#10;
s = 2'b10;
#10;
$finish();
end
endmodule
Запуск
Компилируем исходники и запускаем симуляцию:
vlog *.sv
vsim testbench -coverage -voptargs="+cover=sb+/testbench/DUT" -do "run -a;"
Передав необходимые опции, мы активировали сбор уже двух типов кодового покрытия - процедурного покрытия (англ. statement coverage) и покрытия ветвления (англ. branch_coverage).
Во вкладке Code Coverage Analysis
увидим:
Хм, в целом картина похожая. Часть выражений выполнена, а часть нет. Добавилось ли что-то новое? Да! Я хочу, чтобы ты обратил внимание на верхний правый угол. Там, где написано Statement
. Это как раз кнопка выбора типа кодового покрытия. Если нажмем на нее, то сможем выбрать Branch
, то есть покрытие ветвления.
Анализ результатов
Вообще, если посмотреть на оба отчета, то в нашем случае они говорят об одном и том же, просто разным "языком".
Давайте вернемся к коду тестируемого модуля.
// ...
if( s[0] ) begin
a = 0;
b = 0;
c = 0;
end
else begin
case( s[1] )
1'b0: begin
a = 0;
b = 1;
c = 1;
end
1'b1: begin
a = 1;
b = 1;
c = 1;
end
endcase
end
// ...
Что нам говорит отчет о покрытии ветвлений? Что переход else-begin
, а также все переходы внутри case
были выполнены. Если посмотреть код тестового сценария, то очевидно, почему. А если посмотреть на процедурный отчет, то он как раз говорит нам о том, что выполнились выражения, которые и должны были выполниться при обозначенных переходах (ну и always_comb
в придачу).
А не выполнился только лишь переход if-begin
, потому что в тестовом сценарии s[0]
никогда не принимало значение 1
. А что нам говорит процедурное покрытие? Верно, не выполнились выражения, которые должны были бы выполниться при переходе if-begin
.
Немного теории
Типы кодового покрытия
Так, разбирая примеры, мы с вами попутно познакомились с двумя часто используемыми типами кодового покрытия: процедурным покрытием (англ. statement coverage) и покрытием ветвлений (англ. branch coverage).
Процедурное покрытие предназначено для сбора информации о том, какие независимые участки кода выполнились в ходе симуляции. Сюда входит выполнение блоков always_ff
, always_comb
, процедуры assign
, просто единичных присвоений =
и так далее.
Покрытие ветвлений предназначено для сбора информации о том, какие условные переходы были произведены в ходе симуляции. Сюда входит анализ конструкций if-else
, case
, тернарных операторов.
Существуют и другие типы, но об этом позже.
Замечу, что для примеров я выбрал два обозначенных выше типа кодового покрытия, так как они являются "минимальной базой", без которой практически пропадает смысл всего затеянного. А теперь, читатель, нам нужно разобраться в особенностях кодового покрытия и его отличиях от функционального.
Сбор функционального покрытия
Для сбора функционального покрытия используются специальные конструкции SystemVerilog (covergroup
, coverpoint
, bin
и т.д.). То есть, для сбора функционального покрытия, верификатор сам пишет необходимый код, который и определяет ту область, которую он хочет отслеживать.
Например, если тестируется модуль конвертера:
module testbench;
logic [1:0] binary;
logic [3:0] onehot;
binary_to_onehot DUT (
.binary ( binary ),
.onehot ( onehot )
);
initial begin
binary = 2'b11;
#10;
binary = 2'b01;
#10;
$finish();
end
endmodule
То для покрытия значений входа binary
нужно внутри модуля testbench
написать:
// ...
covergroup dut_cg @(binary);
coverpoint binary;
endgroup
dut_cg cg = new();
// ...
После запуска тестового сценария можно будет наблюдать:
То есть в ходе симуляции вход binary
принимал 2 из 4 возможных значений. При этом мы сами описали, что хотим отслеживать именно его. Здесь еще раз замечу, что для сбора функционального покрытия, верификатор должен обладать знаниями соответствующего синтаксиса SystemVerilog.
Сбор кодового покрытия
Для получения кодового покрытия инженеру не нужно ничего описывать при помощи синтаксиса SystemVerilog. Все необходимое уже имеется "под капотом" у симулятора. Верификатор просто активирует сбор при помощи определенных опций (флагов, передаваемых в команды запуска) и анализирует полученный результат.
Рассмотрим команду запуска симуляции из примера выше:
vsim testbench -coverage -voptargs="+cover=sb+/testbench/DUT" -do "run -a;"
Флаг -coverage
отвечает за включение сбора кодового покрытия. Флаг -voptargs="+cover=sb+/testbench/DUT"
отвечает за активацию процедурного покрытия (+vcover=s
) и покрытия условий (+vcover=b
) для экземпляра модуля с именем DUT
(+/testbench/DUT
).
То есть, для настройки функционального покрытия используются конструкции SystemVerilog, а для настройки кодового покрытия используются флаги и опции симулятора.
Заметим, что флаги кодового покрытия специфичны для конкретного ПО. А это наталкивает нас на мысль о том, что на самом деле кодовое покрытие зависит от используемого ПО (англ. tool specific), потому что аргументы для его сбора определяются симулятором, который используется.
Пример аргументов кодового покрытия для QuestaSim:
-voptargs="+cover=bcesxf"
Пример аргументов кодового покрытия для VCS:
-cm line+cond+fsm+tgl+branch+assert
На самом деле, отличаются не только аргументы, но и формат представления. Также могут варьироваться и типы кодового покрытия. Об этом в разделах ниже.
Специфичность кодового покрытия
Сбор кодового покрытия не ограничен никаким стандартом и специфичен для симулятора, а это значит, что здесь открывается простор для реализации различных типов, а также дополнительных опций.
Например, в QuestaSim, кроме процедурного покрытия и покрытия ветвлений, присутствуют следующие типы:
- покрытие условий (англ. condition coverage) - для отслеживания состояний логических выражений, определяющих условные переходы;
- покрытие выражений (англ. expression coverage) - для отслеживания состояний логических выражений;
- покрытие переключений (англ. toggle coverage) - для отслеживания изменения значения каждого бита переменных;
- покрытие состояний конечных автоматов (англ. FSM coverage) - для отслеживания состояний конечных автоматов;
- покрытие классов (англ. class coverage) - для отслеживания типов созданных объектов классов.
В качестве дополнительных опций можно выделить гибкую настройку элементов симулируемой системы, на которые будет распространяться сбор кодового покрытия (например +/testbench/DUT
), а также сбор покрытия переключений только для портов (-toggleportsonly
). Очевидно, что это лишь примеры опций, в реальности их гораздо больше.
Взаимозаменяемость кодового покрытия
Набор типов кодового покрытия может варьироваться от симулятора к симулятору, однако для "большой тройки" это характерно в меньшей степени. Например, в VCS после QuestaSim я не нашел только покрытие классов. Для остальных типов же есть соответствие:
- statement coverage (Questa) - line coverage (VCS)
- branch coverage (Questa) - branch coverage (VCS)
- condition coverage, expression coverage (Questa)- conditional coverage (VCS)
- toggle coverage (Questa) - toggle coverage (VCS)
- FSM coverage (Questa) - FSM coverage (VCS)
Стоит заметить, что названия типов, конечно, не повторяются "слово в слово", а также одиночный тип одного симулятора может инкапсулировать в себе несколько типов другого. Так что, в любом случае, придется потратить некоторое время для сопоставления типов.
А вот для опций это менее характерно, здесь, "есть, где разгуляться", и у каждого симулятора они свои, хотя, конечно, есть и повторяющиеся.
Переносимость кодового покрытия
Что если нужно "мигрировать" с одного симулятора на другой? Как обстоят дела с переносом кодового покрытия?
Для функционального покрытия в этом плане все просто: написал код на SystemVerilog, а значит он будет работать в любом симуляторе, который поддерживает необходимое подмножество языка. Сбор будет осуществляться посредством выполнения команд этого подмножества.
Для кодового покрытия есть нюансы. Так как флаги сбора, опции и типы варьируются от симулятора к симулятору, то пользователю в любом случае придется открыть соответствующую документацию (чаще всего это User Guide и Command Reference Manual) для воспроизведения конфигурации, которая использовалась в другом симуляторе.
Сохранение кодового покрытия
В режиме GUI в ходе симуляции и после ее завершения, пользователь может просмотреть отчет о кодовом покрытии. Вырезки из таких отчетов и были представлены при разборе примеров. Но что если инженер хочет сохранить результаты "до лучших времен". Конечно, такая возможность присутствует. Причем формат может быть разным даже в рамках одного симулятора.
Вернемся к тестированию декодера:
module decoder (
input logic [1:0] s,
output logic a,
output logic b,
output logic c
);
always_comb begin
if( s[0] ) begin
a = 0;
b = 0;
c = 0;
end
else begin
case( s[1] )
1'b0: begin
a = 0;
b = 1;
c = 1;
end
1'b1: begin
a = 1;
b = 1;
c = 1;
end
endcase
end
end
endmodule
После завершения симуляции в режиме GUI можем наблюдать отчет:
Однако, если закрыть GUI, то результаты будут утеряны.
Для сохранения результатов в файл базы данных специального формата .ucdb
следует добавить к команде запуска coverage save <имя-файла>.ucdb
.
Полная команда запуска:
vsim testbench -coverage -voptargs="+cover=sb+/testbench/DUT" -do "run -a; coverage save cov.ucdb;"
Сохраненную базу данных сможем в любое время открыть командой:
vsim -viewcov cov.ucdb
Но и это еще не все. Также мы можем сформировать детализированный текстовый отчет или HTML отчет, для просмотра которого не нужен будет сам симулятор. Для этого следует добавить к команде запуска coverage report -details -file <имя-файла>.txt
для текстового формата и coverage report -html -details -htmldir <имя-директории>
для HTML формата.
Пример соответствующих команд:
vsim testbench -coverage -voptargs="+cover=sb+/testbench/DUT" -do "run -a; coverage report -details -file cov.txt;
vsim testbench -coverage -voptargs="+cover=sb+/testbench/DUT" -do "run -a; coverage report -html -details -htmldir htmlcov;"
Открыть можем текстовым редактором и браузером:
nano cov.txt
firefox htmlcov/index.html
Пример HTML-отчета (но лично я предпочитаю текстовый):
Влияние сбора кодового покрытия на скорость симуляции
Сами разработчики симуляторов заявляют, что сбор кодового покрытия может существенно замедлять скорость работы. Не удивительно, ведь, параллельно с симуляцией каждого элемента системы, ПО высчитывает количество выполнения определенных участков кода.
В качестве примера, тест декодера на 100 миллионов случайных значений без кодового покрытия занимает 6 секунд:
time vsim -batch testbench -do "run -a;"
real 0m5.942s
user 0m5.045s
sys 0m0.079s
А с кодовым покрытием двух типов 14.5 секунд:
time vsim -batch testbench -coverage -voptargs="+cover=bs+/testbench/DUT" -do "run -a;"
real 0m14.514s
user 0m13.284s
sys 0m0.086s
Машина одна и та же.
Так что при сборе кодового покрытия внимательно настраивайте области, для которых хотите его осуществлять, стараясь минимизировать работу симулятора.
Причины использовать кодовое покрытие
Так уж сложилось, что начинающими инженерами кодовое покрытие собирается и анализируется реже, чем функциональное. Однако, у него есть свои преимущества и цели использования.
Основные преимущества:
- практически все за вас делает симулятор, нет необходимости писать дополнительный код на SystemVerilog;
- если есть заготовки команд для конкретного ПО, то интегрировать простейший сбор кодового покрытия - дело нескольких минут.
Основные цели:
- проверка задействования всего кода в ходе тестирования;
- поиск "недостающих" тестовых сценариев;
- поиск dead-code.
На целях остановимся чуть подробнее. Очевидно, когда описывается RTL устройства, то подразумевается, что каждое выражение в коде несет смысловую нагрузку и будет выполнено. Кодовое покрытие помогает в этом убедиться.
Также, кодовое покрытие может помочь в поиске недостающих тестовых воздействий. Если часть условных переходов, выражений и т.п. не была выполнена, или, если конечный автомат был не во всех состояниях, то это наталкивает на мысли о неполноте тестовых сценариев.
Ко всему прочему, чаще всего при рефакторинге, остается код, который не выполнится ни при каком сочетании внутреннего состояния устройства и входных воздействий на него (англ. dead code). Казалось бы ничего страшного, но теоретически этот код может повлиять на результаты синтеза, а также, по прошествии времени, заставит мучаться инженеров в попытках понять, зачем он нужен.
Кодовое покрытие и обнаружение бага
Разберем простой пример, в котором кодовое покрытие поможет нам выявить неполноту тестовых воздействий и обнаружить баг. А заодно познакомимся с третьим типом кодового покрытия - покрытием состояний конечного автомата (англ. FSM coverage).
Подопытный
Конечный автомат со схемой переходов:
По спецификации выходы idle
, read
, write
принимают значения логической единицы в соответствии с текущим состоянием автомата. То есть, если автомат в состоянии IDLE, то idle = 1
, если в состоянии READ, то read = 1
и так далее.
Однако же, внесем баг. Приравняем выходу write
значение логического нуля на постоянной основе.
Исходный код:
module fsm (
input logic clk,
input logic resetn,
input logic req,
input logic we,
input logic ack,
output logic idle,
output logic read,
output logic write
);
typedef enum logic [1:0] {
IDLE = 2'b00,
READ = 2'b01,
WRITE = 2'b10
} state_e;
state_e nextstate, state_ff;
assign idle = state_ff == IDLE;
assign read = state_ff == READ;
assign write = 0;
always_ff @(posedge clk) begin
if(!resetn) state_ff < = IDLE;
else state_ff < = nextstate;
end
always_comb begin
case(state_ff)
IDLE: begin
if( req ) nextstate = we ? WRITE : READ;
else nextstate = IDLE;
end
READ: begin
nextstate = ack ? IDLE : READ;
end
WRITE: begin
nextstate = ack ? IDLE : WRITE;
end
default: nextstate = IDLE;
endcase
end
endmodule
Тесты
Здесь осознанно не привожу полный код, чтобы не перегружать повествование. Обозначу только, что set_inputs()
отвечает за выставление значений на входы, а check_ouputs()
за проверку значений на выходах, а wait_clocks()
за ожидание заданного количества положительных фронтов тактового сигнала.
module testbench;
// ...
// Reset
initial begin
resetn < = 0;
@(posedge clk);
resetn < = 1;
end
// ...
task check_outputs(logic [2:0] outputs);
if( { idle, read, write } !== outputs )
$error("Real: %3b, Expected: %3b",
{ idle, read, write }, outputs);
endtask
// ...
// Generate
initial begin
// Set initial values
req < = 0;
we < = 0;
ack < = 0;
// Wait for unreset
do wait_clocks(1); while(!resetn);
// Check for idle state
check_outputs(3'b100); // idle: 1, read: 0, write: 0
// Check some transitions
// 1
set_inputs(3'b100); wait_clocks(2); // req: 1, we: 0, ack: 0
check_outputs(3'b010); // idle: 0, read: 1, write: 0
// 2
set_inputs(3'b001); wait_clocks(2); // req: 0, we: 0, ack: 1
check_outputs(3'b100); // idle: 1, read: 0, write: 0
//
$finish();
end
endmodule
Запуск
Компилируем исходники и запускаем симуляцию:
vlog *.sv
vsim testbench -coverage -voptargs="+acc +cover=f+/testbench/DUT" -do "run -a;"
Передав необходимые опции, мы активировали сбор кодового покрытия состояний конечного автомата (англ. FSM coverage).
Симуляция завершается без ошибок:
# Loading sv_std.std
# Loading work.testbench(fast)
# Loading work.fsm(fast)
# run -a
# ** Note: $finish : testbench.sv(73)
# Time: 115 ns Iteration: 1 Instance: /testbench
Анализ результатов
Во вкладке Code Coverage Analysis переключимся на FSM на увидим:
Очевидно, что не были совершены переходы IDLE -> WRITE и WRITE -> IDLE.
Если два раза кликнуть на state_ff
, то откроется диаграмма переходов, на ней тоже явно можем увидеть количество переходов между состояниями. Примечательно, что симулятор, проанализировав код, сам сделал выводы о том, какие переходы возможны, а какие нет. Красота!
Исправляемся
Допишем тестовый сценарий для инициации нужных переходов:
// ...
// Generate
initial begin
// ...
// 3
set_inputs(3'b110); wait_clocks(2); // req: 1, we: 1, ack: 0
check_outputs(3'b001); // idle: 0, read: 0, write: 1
// 4
set_inputs(3'b001); wait_clocks(2); // req: 0, we: 0, ack: 1
check_outputs(3'b100); // idle: 1, read: 0, write: 0
//
$finish();
end
// ...
Обнаружили баг:
# Loading sv_std.std
# Loading work.testbench(fast)
# Loading work.fsm(fast)
# run -a
# ** Error: Real: 000, Expected: 001
# Time: 75 ns Scope: testbench.check_outputs File: testbench.sv Line: 46
# ** Note: $finish : testbench.sv(78)
# Time: 95 ns Iteration: 1 Instance: /testbench
Смотрим теперь на покрытие состояний:
Все переходы были совершены.
Исправляем RTL:
assign write = state_ff == WRITE;
Запускаемся снова. Результат - без ошибок:
# Loading sv_std.std
# Loading work.testbench(fast)
# Loading work.fsm(fast)
# run -a
# ** Note: $finish : testbench.sv(78)
# Time: 115 ns Iteration: 1 Instance: /testbench
Мы прекрасны!
Заключение
Что ж, дорогой читатель, вот мы и добрались до конца. Было ли интересно? Лично мне - да. Потому что, "стыдно признаться, грех утаить", а я и сам достаточно давно не собирал кодовое покрытие:) Исправлюсь, обещаю.
Надеюсь, прочитав текст выше, ты открыл для себя что-то новое.
Спасибо тебе за уделённое время. Всего наилучшего и до новых встреч!
А больше подобных заметок ты можешь найти в Telegram-канале автора Verification For All.
SystemVerilog IEEE 1800-2023. Обзор нововведений.
Вступление
Доброго времени суток, дорогие читатели! Не могу пройти мимо значительного события в сфере микроэлектроники, а уж в сфере её верификации и подавно.
28 февраля 2024 года была опубликована новая версия стандарта языка SystemVerilog 2023. Итак, что же стандарт 2023 года принесёт нового в существующий "уклад" инженеров? Давайте разбираться!
Данный пост представляет собой краткий авторский обзор нововведений. Обзор каждого нововведения будет содержать примеры, а также ссылки на соответствующие разделы нового стандарта. Приступаем!
1. Наследование функционального покрытия
В SystemVerilog существует такое понятие, как embedded covergroup
. Если переводить дословно, то "встроенная" covergroup
. Тип такой covergroup
объявляется внутри конкретного типа класса и становится "неразрывно" с ним связанным.
Например:
class base;
enum {red, green, blue} color;
covergroup g1;
option.weight = 1;
color_cp: coverpoint color;
endgroup
function new();
g1 = new;
endfunction
endclass
Такая covergroup
по умолчанию имеет доступ ко всем полям класса и должна создаваться в функции new()
этого класса.
В новой версии стандарта появилась возможность наследовать embedded covergroup
. Наследованная covergroup "получает" доступ ко всем элементам (coverpoint
, cross
и т.д.) родительской, а также всем опциям.
Наследованная covergroup
может переопределять элементы и опции родительской, а также добавлять свои.
Например:
class derived extends base;
bit d;
covergroup extends g1;
option.weight = 1;
color_cp: coverpoint color
{
ignore_bins ignore = {blue};
}
d_cp: coverpoint d;
endgroup
function new();
super.new();
endfunction
endclass
В данном примере наследованная covegroup
(от covergroup cg1
из примера выше) переопределяет опцию weight
и точку покрытия color_cp
базовой, а также добавляет новую точку покрытия d_cp
.
Соответствующие разделы стандарта: 19.4.1.
2. Функция map() распакованного массива
В новой версии стандарта появилась новая функция map()
поэлеметной работы с распакованным массивом.
Основная суть функции заключается в итерировании по массиву и применении к каждому элементу выражения, заключенного в сопутствующую конструкцию with()
.
Например:
int A [] = {1,2,3}, B [] = {2,3,5}, C [$];
// Add one to each element of an array
A = A.map() with (item + 1'b1); // {2,3,4}
// Add the elements of 2 arrays
C = A.map(a) with (a + B[a.index]); // {4,6,9}
В данном примере при помощи функции map()
к каждому элементу массива A
сначала прибавляется 1, а после в очередь C
записывается результат поэлеметного сложения массивов A
и B
. Заметим, что функция map()
предоставляет пользователю ключевое слово item
для доступа к элементу массива и метод index
для доступа к его положению в массиве.
Также обратим внимание на важную особенность добавленной функции. Она может возвращать значение типа, отличающегося от типа обрабатываемого массива.
Например:
int A [] = {2,3,4}, B [] = {2,3,5};
// Element by element comparison
bit C [];
C = A.map(a) with (a == B[a.index]); // {1,1,0}
В данном примере происходит поэлементое сравнение массивов A
и B
. Обрабатываемый массив имеет тип int []
. Возвращаемый map()
результат имеет тип bit []
.
Соответствующие разделы стандарта: 7.12.5.
3. Множественные идентификаторы в ifdef
В новой версии стандарта был дополнен синтаксис директивы компилятора ifdef
. Ранее директива могла обрабатывать лишь одиночные идентификаторы, теперь могут обрабатываться логические выражения из множественных идентификаторов.
Пример-сравнение старой и новой версий стандарта.
В старой версии стандарта:
`ifdef A
`ifdef B
// code for AND condition
`endif
`endif
В новой версии стандарта:
`ifdef (A && B)
// code for AND condition
`endif
Обратите внимание, что в новой версии стандарта также актуальна и первая запись. А вообще - удобно! Доступные логические операторы: &&
, ||
, ->
, <->
.
Кстати, первые два нововведения (а на самом деле и все последующие после этого) относятся к несинтезируемому подмножеству. Чего не скажешь про ifdef
, который может использоваться и в синтезируемом коде. Как думаете, когда "завезут" в ПО для синтеза? Делаем ставки, господа.
Соответствующие разделы стандарта: 22.6.
4. Multiline strings
В новой версии стандарта была добавлена возможность размещать текст типа string
на нескольких строках без использования специальных символов. Для этого используются тройные кавычки """
.
Например:
$display("Humpty Dumpty sat on a wall.\n\
Humpty Dumpty had a great fall.");
Эквивалентно:
$display("""Humpty Dumpty sat on a wall.
Humpty Dumpty had a great fall. """);
То есть элемент типа string
в тройных кавычках как бы автоматически "генерирует" необходимые символы для переноса на другую строку. Интересно также, что внутри тройных кавычек можно использовать одиночные "
без специальных символов.
Например:
$display("Humpty Dumpty sat on a \"wall\".\n\
Humpty Dumpty had a great fall.");
Эквивалентно:
$display("""Humpty Dumpty sat on a "wall".
Humpty Dumpty had a great fall. """);
Вообще мне введение такого форматирования очень напомнило Python, где оно также присутствует. И кстати, оно часто используется для документирования кода. И тут я задумался...
А что, если:
function int add(int a, int b);
"""
This function adds two integers.
Arguments:
int a - first integer to add;
int b - second integer to add.
"""
return a + b;
endfunction
Соответствующие разделы стандарта: 5.9.
5. Использование чисел с плавающей точкой в функциональном покрытии
В новой версии стандарта теперь возможно определение функционального покрытия для чисел с плавающей точкой (real
, shortreal
).
Рассмотрим одну из проблем определения функционального покрытия для чисел с плавающей точкой. Разделы покрытия представляют собой конечные наборы значений. Для целых чисел такой набор может быть однозначно определен.
int a;
covergroup a_cg;
a_cp: coverpoint a {
bins b1 [] = {[0:9]}; // {0}, ..., {9}
}
endgroup
Числа же с плавающей точкой хранятся в памяти с некоторой точностью, что приводит к тому, что два разных числа могут быть представлены одним и тем же набором бит. Такая особенность может являться причиной дублирования значений в разделах покрытия, а также пересечения разделов покрытия.
real a;
covergroup a_cg;
a_cp: coverpoint a {
bins b1 [] = {[0:9]}; // ???
}
endgroup
В новой версии стандарта для поддержки использования чисел с плавающей точкой в функциональном покрытии была добавлена новая опция real_interval
, а также новый синтаксис вида +/-
и +%-
, которые призваны решить проблемы с точностью при помощи явного указания минимального шага между "соседними" числами. Давайте разбираться, как это работает.
Пример:
real a;
parameter real VALUE = 50.0;
covergroup a_cg;
option.real_interval = 0.01;
a_cp: coverpoint a {
// [49.9:50.1]
bins b1 = {[VALUE+/-0.1]};
// [49.5:50.5]
bins b2 = {[VALUE+%-1.0]};
// [0.75:0.76), [0.76:0.77), ... [0.84:0.85]
bins a1 [] = {[0.75:0.85]};
}
endgroup
В данном примере раздел b1
покрывает значения от 49.9 до 50.1. Синтаксис +/-
определяет отклонение от 50.0 на 0.1 (абсолютное значение) в обе стороны. Раздел b2
в свою очередь покрывает значения от 49.5 до 50.5. Синтаксис +%-
определяет отклонение от 50.0 на 1% (относительное значение) в обе стороны, то есть на 0.5 в абсолютном выражении.
Для массива разделов a1
количество элементов в нем определяется опцией real_interval
, которая задает шаг разбиения диапазона чисел с плавающей точкой. В данном примере шаг равен 0.01, так что интервал от 0.75 до 0.85 будет равномерно разбит на 10 интервалов: [0.85:0.86), [0.86:0.87), ..., [0.84:0.85]. Обратите внимание на включение границ в интервалы.
Соответствующие разделы стандарта: 19.5.1, 19.7.1.
6. Использование метода в качестве промежуточного результата
В большинстве симуляторов SystemVerilog в настоящее время поддерживается выполнение следующего примера:
module test;
class my_class;
function void print();
$display("Inside my_class!");
endfunction
endclass
function my_class get_my_class();
my_class cl;
cl = new();
endfunction
initial begin
get_my_class().print();
end
endmodule
Результат функции get_my_class()
используется в качестве промежуточного результата, который содержит указатель на объект типа my_class
, у которого вызывается метод print()
. В англоязычной литературе это называют "chaining of method calls".
Не трудно догадаться, что результатом выполнения будет:
Inside my_class!
Хоть данный пример и поддерживается многими симуляторами, в стандарте явно не обозначались требования к соответствующему функционалу. В новой версии наконец-то появилось соответствующее описание. Из интересного: появилось правило разграничения иерархического обращения и промежуточного результата.
Например:
class A;
int member=123;
endclass
module top;
A a;
function A F(int arg=0);
int member;
a = new();
return a;
endfunction
initial begin
$display(F.member);
$display(F().member);
end
endmodule
Результатом выполнения будет:
0
123
Новая версия стандарта явно указывает на то, что при использовании функции в качестве промежуточного результата всегда должны использоваться ()
, даже если у функции нет аргументов. В данном же примере выражение F.member
обращается к статической переменной member
функции F
, а F().member
обращается к полю member
объекта класса, который возвращается вызовом функции F
.
Соответствующие разделы стандарта: 13.4.1.
7. ref static
Для начала нам необходимо вспомнить, что переменная в SystemVerilog может быть автоматической. Если говорить простым языком, то это значит, что её присутствие в памяти на протяжении всего времени симуляции не гарантируется.
Для автоматических переменных стандартом накладываются определенные ограничения на их использование. Например, переменная не может стоять слева от <=
(быть целью Non-blocking assignment). Или, что более интересно, автоматическая переменная, объявленная вне блоков fork-join_any
и fork-join_none
не может быть использована внутри этих блоков.
Например:
initial begin
for (int j = 1; j <= 3; ++j) begin
fork
begin
automatic int k = j;
#k $write("%0t %0d\n", $time(), k);
end
join_none
end
end
Такая конструкция абсолютно легальна. Выводом будет:
1 1
2 2
3 3
А вот другой пример:
module test;
function void print(ref logic arg);
fork
forever @(arg) $display("arg changed %0t", $time());
join_none
endfunction
logic C;
initial begin
print(C);
#10; C = 1;
#10; C = 2;
end
endmodule
Который запрещен стандартом, так как для компилятора при анализе функции print()
переменная arg
потенциально может быть автоматической, а её использование внутри fork-join_none
запрещено. Обратите внимание, что, даже если переменная, передаваемая в задачу будет статической, компилятор все равно сгенерирует ошибку.
Результатом запуска примера выше в QuestaSim 2021.2 будет:
vlog test.sv
** Error (suppressible): test.sv(4): (vlog-13300)
The task or function 'print' with ref argument 'arg' must be automatic.
А вот в новой версии стандарта существует возможность передавать статическую переменную в подобную функцию по ссылке. Теперь аргумент можно будет объявить как ref static
.
Пример:
module test;
function void print(ref static logic arg);
fork
forever @(arg) $display("arg changed %0t", $time());
join_none
endfunction
logic C;
initial begin
print(C);
#10; C = 1;
#10; C = 2;
end
endmodule
Результат симуляции ожидается таким:
arg changed 10
arg changed 20
И знаете что. Симулятора, официально поддерживающего все нововведения SystemVerilog 2023 нет, а ожидаемый результат выполнения (ну почти) мне получить удалось. Спросите как?
Компиляцию первого примера в QuestaSim 2021.2 можно запустить с особыми флагами:
vlog test.sv -suppress 13300 -suppress 13219
-- Compiling module test
Errors: 0, Warnings: 0, Suppressed Errors: 1
Что это за флаги - уже другая история. Но результат симуляции был следующим:
arg changed 10
arg changed 20
Кто-то заранее "подложил соломку"? Вопрос, скорее, риторический.
Соответствующие разделы стандарта: 9.3.2, 13.5.2.
Заключение
Вот и все, дорогие читатели. Только что мы с вами погрузились в новую версию стандарта SystemVerilog. Как думаете, хороши нововведения? Спасибо вам за уделенное время! Всего наилучшего и до новых встреч!
А больше подобных обзоров вы можете найти в Telegram-канале автора Verification For All.
Post Scriptum
Если вам известны иные нововведения версии стандарта SystemVerilog 2023 года, то не стесняйтесь написать об этом в комментариях или в личные сообщения автору в Telegram. Также автору было бы интересно узнать мнение читателей по поводу того, какого еще функционала, по их мнению, не хватает в теперь уже самом актуальном стандарте языка SystemVerilog IEEE 1800-2023.
Demystifying UVM: Фабрика, часть 1
- Demystifying UVM: Фабрика, часть 1
Вступление
Цикл статей Demystifying UVM
Доброго времени суток, дорогие читатели! Данная статья является первой (нулевой) в целом цикле, который называется Demystifying UVM. Цикл будет посвящен глубокому погружению в основные концепции и механизмы работы библиотеки универсальной методологии верификации (Universal Verification Methodology, UVM).
Motivation или зачем это всё
Как часто начинающий верификатор начинает использовать UVM, совершенно не понимая, что вообще происходит. Что такое uvm_component_utils
и type_id::create()
? Почему у конструктора объекта один аргумент, а у конструктора компонента два? Откуда вдруг взялась функция get_full_name()
? Как создаются иерерхические имена по типу uvm_test_top.env.ag.mon
? И что это вообще за uvm_test_top
?! Очень много вопросов и очень мало общедоступных ответов.
Автор поставил перед собой задачу рассеять туман над исходным кодом UVM и основными концепциями, используемыми в данной библиотеке.
Необходимые знания
Стоит заметить, что цикл Demystifying UVM не рассчитан на инженера с околонулевыми знаниями.
Для освоения материала читателю необходимо знать:
- принципы ООП в SystemVerilog;
- очереди (
queue
,[$]
); - ключевые слова
local
,protected
,virtual
; - upcasting классов в SystemVerilog;
- downcasting классов в SystemVerilog;
- статические методы классов в SystemVerilog;
- параметризацию классов в SystemVerilog.
Для получения перечисленных выше знаний рекомендуется ознакомиться с лекцией автора в Школе синтеза цифровых схем: ООП в функциональной верификации. Транзакционная модель тестирования.
От автора про UVM
Версионирование
Библиотека UVM впервые была официально представлена в феврале 2011 года и со временем претерпевала изменения в различных версиях. Полный список версий и их исходный код представлены в соответствующем разделе на сайте компании Accellera.
В цикле статей Demystifying UVM для анализа будет использован исходный код UVM 1.2. Основной причиной является то, что, хоть версия 1.2 и была выпущена в далеком 2014 году, она по сей день является самой часто используемой и поддерживается всеми коммерческими симуляторами (в данном контексте "поддерживается" — предоставляется в уже скомпилированном виде)1. Стоит также заметить, что основные концепции, которые будут рассматриваться в циклей статей Demystifying UVM, были представлены уже в самой первой версии (1.0) и не претерпели практически никаких изменений вплоть до актуальной на момент написания статьи версии 2020-3.1. Исходные файлы библиотеки UVM версии 1.2 можно скачать на сайте компании Accellera по ссылке.
Сложность изучения
В ходе изучения UVM основными сложностями для инженера являются:
- высокий порог входа;
- внушительная и сложновоспринимаемая кодовая база;
- сравнительно небольшое количество профильной литературы.
Перед освоением UVM инженеру необходимо изучить множество конструкций SystemVerilog, а также принципы ООП и их реализацию. И, даже освоив вышеперечисленное, инженер столкнется с кодовой базовой из более 30000 строк кода, которая к тому же не всегда задокументирована.
Также стоит заметить, что хоть библиотека UVM и предоставляет действительно широкий спектр возможностей для верификации, в реальности используется лишь их небольшая часть со специфичными для конкретного проекта "вставками" функционала библиотеки.
Отдельным камнем преткновения является небольшое количество профильной литературы по UVM, в особенности той, в которой бы детально описывались основные концепции и механизмы работы. Известная UVM Cookbook по своей сути является сборником советов и шаблонов, что, тем не менее, нисколько не умаляет иных достоинств данной книги.
Подход автора к изучению
Сочинять не так уж трудно; зачеркивать лишние ноты – вот что труднее всего.
Иоганнес Брамс
Автор искренне убежден, что для разбора не самых простых концепций, прежде всего, необходимо отбросить все лишнее. И в исходном коде UVM "лишнего" действительно достаточно. Важно заметить, что под "лишним" в данном контексте подразумевается код, который никак не влияет на разбираемый функционал и который можно исключить в угоду удобству изучения.
Обращу ваше внимание, что под "исключить" имеется в виду буквальное исключение. В цикле Demystifying UVM будет анализироваться авторская версия библиотеки UVM, в которой сохранен лишь код, непосредственно отвечающий за разбираемый функционал. Это позволит начинающим инженерам сфокусироваться на конкретных концепциях, а после, при желании, изучить оригинальные исходные файлы UVM, которые можно скачать на сайте компании Accellera по ссылке.
Насколько хороша описанная выше задумка, дорогие читатели, — покажет время ваша обратная связь. А теперь — к делу!
SystemVerilog и параметризация классов типами
Перед погружением в исходный код UVM необходимо вспомнить одну из особенностей SystemVerilog. А именно — возможность параметризации класса типом. Пример параметризованного таким образом класса представлен в файле src/test/test_pkg.sv
.
class my_wrapper #(type T);
static function T create_some_class();
T cl = new();
return cl;
endfunction
endclass
Класс my_wrapper
параметризован типом T
и содержит статический метод create_some_class()
, функционалом которого является создание объекта типа T
при помощи T cl = new()
и возвращение указателя на него при помощи return cl
.
Также в файле объявлены два класса для демонстрации параметризации типом:
class my_awesome_class;
virtual function void print();
$display("Hello from 'my_awesome_class'!");
endfunction
endclass
class my_new_awesome_class extends my_awesome_class;
virtual function void print();
$display("Hello from 'my_new_awesome_class'!");
endfunction
endclass
Обратите внимание, что так же, как и с "классическими" параметрами, my_wrapper#(my_awesome_class)
и my_wrapper#(my_new_awesome_class)
являются разными типами. Пример использования параметризованного класса представлен в файле src/test/tb_simple.sv
.
initial begin
my_awesome_class cl;
cl = my_wrapper#(my_awesome_class)::create_some_class();
cl.print();
cl = my_wrapper#(my_new_awesome_class)::create_some_class();
cl.print();
end
В данном примере при помощи статического метода create_some_class()
создается сначала объект типа my_awesome_class
, а затем типа my_new_awesome_class
. Handle cl
будет указывать на объект типа my_awesome_class
, а далее на объект типа my_new_awesome_class
. При вызове метода print()
сначала будет вызвана реализация класса my_awesome_class
, а затем класса my_new_awesome_class
, т.к. метод является виртуальным. Результат выполнения представлен ниже.
# Hello from 'my_awesome_class'!
# Hello from 'my_new_awesome_class'!
Для запуска примера при помощи QuestaSim или Verilator в директории src
необходимо выполнить скрипты run_questa.sh
и run_verilator.sh
соответственно (с аргументом tb_simple
):
run_questa.sh tb_simple
run_verilator.sh tb_simple
Лог запуска будет выведен в консоль, а артефакты симуляции сохранены в директорию src/out
.
Создание компонентов в UVM
Case study
Типовое создание компонента в UVM выглядит следующим образом:
monitor = apb_monitor::type_id::create("mon", this);
У каждого начинающего (и не только) верификатора строка выше вызовет вопрос: что такое type_id::create()
? В абсолютном большинстве литературы инженер увидит лишь упоминание о том, что вызов type_id::create()
является альтерантивой вызову new()
, и он должен использоваться для создания компонентов верификационного окружения.
Почему для создания компонентов необходимо использовать type_id::create()
и какие возможности предоставляет данный подход — рассмотрим далее!
Макросы регистрации
Предварительно изучив необходимую теорию и таким образом размявшись, начинаем наше погружение. При объявлении компонента UVM необходимо использовать макрос uvm_component_utils
, который, как указано в большинстве источников, отвечает за регистрацию типа. Пример использования представлен ниже.
class base_test extends uvm_component;
`uvm_component_utils(base_test)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
...
endclass
Что такое регистрация типа и что же кроется за представленным макросом? Рассмотрим исходный код файла, в котором определен uvm_component_utils
. Для этого откроем файл src/uvm/uvm_object_defines.svh
:
`define uvm_component_utils(T) \
`m_uvm_component_registry_internal(T,T) \
`m_uvm_get_type_name_func(T) \
Видим, что макрос uvm_component_utils(T)
параметризован переменной T
и раскрывается в два других макроса: m_uvm_component_registry_internal(T,T)
и m_uvm_get_type_name_func(T)
. Их исходный код приведен ниже.
`define m_uvm_component_registry_internal(T,S) \
typedef uvm_component_registry #(T,`"S`") type_id; \
static function type_id get_type(); \
return type_id::get(); \
endfunction
`define m_uvm_get_type_name_func(T) \
const static string type_name = `"T`"; \
virtual function string get_type_name (); \
return type_name; \
endfunction
Давайте раскроем макрос в примере выше:
class base_test extends uvm_component;
typedef uvm_component_registry #(base_test, "base_test") type_id;
static function type_id get_type();
return type_id::get();
endfunction
const static string type_name = "base_test";
virtual function string get_type_name();
return type_name;
endfunction
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
...
endclass
Если анализировать код снизу вверх, то макрос uvm_component_utils
отвечает за создание в теле класса статического поля type_name
, которое содержит название типа класса в виде строки, а также метод get_type_name()
, возвращающий это название типа.
Также макрос определяет внутри класса тип type_id
, который является алиасом для типа uvm_component_registry #(base_test, "base_test")
и статический метод get_type()
, который вызывает статический метод get()
типа type_id
.
Так что же значит type_id::create()
? На самом деле это ничего более, чем вызов статического метода типа type_id
, который является алиасом для класса регистрации uvm_component_registry
, параметризованного типом класса T
, который мы хотим зарегистрировать (в примере выше T = base_test
).
Внимательно прочитайте предыдущее предложение и постарайтесь его осознать. Постарайтесь объяснить, почему для класса типа base_test
вызов type_id::create()
эквивалентен вызову uvm_component_registry#(base_test, "base_test")::create()
.
Визуализация вызова type_id::create()
представлена на изображении ниже.
Но что же все-таки значит "зарегистрировать" тип, и что такое класс регистрации? Давайте обсудим!
Proxy-класс регистрации
Рассмотрим исходный код файла, в котором определен тип uvm_component_registry
. Для этого откроем файл src/uvm/uvm_registry.svh
:
class uvm_component_registry #(
type T = uvm_component,
string Tname = "<unknown>"
) extends uvm_object_wrapper;
...
local static this_type me = get();
static function this_type get();
if (me == null) begin
me = new();
end
return me;
endfunction
...
endclass
Класс типа uvm_component_registry
является proxy-классом для типа, которым он параметризован (T
). По своей сути proxy-класс является своеобразным хранилищем информации о типе. Также proxy-класс предоставляет API для создания объектов этого типа.
Заметим, что proxy-класс наследуется от класса uvm_object_wrapper
. Данный класс не представляет особого интереса и является непараметризованным классом, через handle которого можно передавать указатель на параметризованные uvm_component_registry
.
Вернемся к объявлению класса uvm_component_registry
. Важно заметить, что класс является singleton-классом2. Особенность singleton'а заключается в том, что объект такого типа в ходе симуляции существует в единственном экземпляре и создается при первом обращении к нему при помощи специализированного статического метода. Название такого метода может быть любым, но часто можно встретить такие наименования, как get()
или get_inst()
.
Давайте разберем чуть подробнее тело класса выше. Статическая переменная me
является защищенным указателем на единственный экземпляр singleton-класса. Метод get()
является методом доступа к этому защищенному указателю. Обратите внимание, что указатель инициализируется единственный раз в том случае, если он равен null
(не указывает ни на какой объект в памяти).
Концептуально использование singleton-класса представлено на изображении ниже.
Каждый компонент (в примере это component_a
, component_b
и component_e
, они выделены зеленым цветом) при обращении к singleton-классу uvm_component_registry
,будет получать указатель единственный экземпляр этого класса. Указатель будет храниться в защищенном поле me
.
Проанализируем другую часть тела класса регистрации:
class uvm_component_registry #(
type T = uvm_component,
string Tname = "<unknown>"
) extends uvm_object_wrapper;
...
virtual function uvm_component create_component(
string name,
uvm_component parent
);
T obj;
obj = new(name, parent);
return obj;
endfunction
...
endclass
Метод create_component()
возвращает объект типа T
(или объект типа, наследуемого от T
). Именно в этом методе мы можем увидеть непосредстевенно вызов конструктора new()
. То есть именно в данном методе создается объект запрашиваемого типа T
. Это значит, что вызов type_id::create()
приводит к вызову create_component()
proxy-класса типа type_id
, ведь type_id
— это просто алиас для proxy-класса, параметризованного типом T
. Но где вызывается этот метод? Скоро узнаем!
Проанализируем другую часть тела класса регистрации:
class uvm_component_registry #(
type T = uvm_component,
string Tname = "<unknown>"
) extends uvm_object_wrapper;
...
static function T create(
string name,
uvm_component parent
);
uvm_component obj;
uvm_coreservice_t cs = uvm_coreservice_t::get();
uvm_factory factory = cs.get_factory();
obj = factory.create_component_by_type(get(), name, parent);
$cast(create, obj);
endfunction
...
endclass
Вот мы и добрались до метода create()
! Именно этот статический метод вызывается при помощи type_id::create()
. Погрузимся еще глубже. В данном методе нас интересуют строки:
uvm_coreservice_t cs = uvm_coreservice_t::get();
uvm_factory factory = cs.get_factory();
obj = factory.create_component_by_type(get(), name, parent);
В первой строке реализуется доступ к singleton-классу типа uvm_coreservice_t
. Данный класс предоставляет API для создания объектов, настроек логирования и т.д., его реализация будет рассмотрена чуть позже. Во второй строке при помощи указателя на класс сервисов производится получение доступа к указателю на класс фабрики UVM (uvm_factory
) при помощи cs.get_factory()
. И, наконец, в 3 строке производится создание объекта при помощи create_component_by_type()
.
Можно сделать вывод, что вызов type_id::create()
приводит к вызову метода create_component_by_type()
класса фабрики UVM (uvm_factory
). Доступ же к классу фабрики производится через класс сервисов UVM (uvm_coreservice_t
). Также теперь становится очевидно, что каждый вызов type_id::create()
приводит к получению указателя на единственный экземпляр proxy-класса при помощи get()
.
Дополненная визуализация вызова type_id::create()
представлена на изображении ниже.
Какова реализация класса фабрики и класса сервисов в UVM? Давайте обсудим это в разделах ниже!
Класс сервисов UVM
Рассмотрим исходный код файла, в котором определен тип uvm_coreservice_t
. Для этого откроем файл src/uvm/uvm_coreservice.svh
:
class uvm_coreservice_t;
local static uvm_coreservice_t inst;
static function uvm_coreservice_t get();
if(inst == null) begin
inst = new();
end
return inst;
endfunction
local uvm_factory factory;
virtual function uvm_factory get_factory();
if(factory == null) begin
factory = new();
end
return factory;
endfunction
endclass
Уже вооружившись знаниями о singleton-классах, делаем вывод о том, что данный класс также является singleton. Доступ к единственному экземпляру осуществляется при помощи статического метода get()
. Обратим внимание, что класс содержит указатель на класс фабрики UVM в защищенном поле factory
. Доступ к этому указателю осуществляется при помощи метода get_factory()
. Стоит заметить, что в оригинальной версии (не авторской) класс сервисов содержит больше функционала, однако в рамках данной статьи дополнительный код осложнил бы восприятие.
Дополненная визуализация вызова type_id::create()
представлена на изображении ниже.
To be continued...
Вот и подошла к своему логическому завершению первая статья цикла Demystifying UVM. В ней мы узнали, что классы в SystemVerilog можно параметризовывать типами, и запустили простейший пример. Узнали, что кроется за макросом uvm_component_utils
и что такое proxy-класс регистрации. Освоили концепцию singleton-класса и проанализировали минимальную авторскую реализацию класса сервисов UVM.
В конце данной статьи мы вплотную приблизились к реализации класса фабрики UVM. Она будет подробно разобрана в следующей статье, ссылка на которую в скором будет опубликована в Telegram-канале автора Verification For All (VFA). Вас ждет подробный разбор механизмов создания компонентов UVM и переопределения их типов, а также множество примеров.
Всего вам наилучшего, дорогие читатели! Мира и процветания!☀️
Siemens QuestaSim, Cadence Xcelium, Synopsys VCS. Что интересно: UVM 1.2 единственная версия UVM, которая поддерживается встроенным симулятором Xilinx Vivado.
Ссылка на отличную статью о singleton-классах в SystemVerilog.
Demystifying UVM: Фабрика, часть 2
Вступление
Цикл статей Demystifying UVM
Доброго времени суток, дорогие читатели! Данная статья является второй в целом цикле, который называется Demystifying UVM. Цикл будет посвящен глубокому погружению в основные концепции и механизмы работы библиотеки универсальной методологии верификации (Universal Verification Methodology, UVM).
Motivation или зачем это всё
Как часто начинающий верификатор начинает использовать UVM, совершенно не понимая, что вообще происходит. Что такое uvm_component_utils
и type_id::create()
? Почему у конструктора объекта один аргумент, а у конструктора компонента два? Откуда вдруг "взялась" функция get_full_name()
? Как создаются иерерхические имена по типу uvm_test_top.env.ag.mon
? И что это вообще за uvm_test_top
?! Очень много вопросов и очень мало общедоступных ответов.
Автор поставил перед собой задачу рассеять туман над исходным кодом UVM и основными концепциями, используемыми в данной библиотеке.
Необходимые знания
Стоит заметить, что цикл Demystifying UVM не рассчитан на инженера с околонулевыми знаниями.
Для освоения материала читателю необходимо знать:
- принципы ООП в SystemVerilog;
- очереди (
queue
,[$]
); - ключевые слова
local
,protected
,virtual
; - upcasting классов в SystemVerilog;
- downcasting классов в SystemVerilog;
- статические методы классов в SystemVerilog;
- параметризацию классов в SystemVerilog.
Для получения перечисленных выше знаний рекомендуется ознакомиться с лекцией автора в Школе синтеза цифровых схем: ООП в функциональной верификации. Транзакционная модель тестирования.
Подсвечивание
Автор убежден, что прежде чем приступить к изучению нового материала, необходимо повторить ранее изученный, который связан с новым. Автор называет этот подход подсвечиванием. По образу и подобию того, как при грозе в темном небе вспыхивают молнии, в разуме человека при повторении уже изученного материала возникают "вспышки", подсвечивающие ключевые аспекты. Озаренная вспышками часть материала далее успешно служит фундаментом для освоения нового.
Первая часть статьи
Автор настоятельно рекомендует ознакомиться с первой частью статьи: Demystifying UVM: Фабрика, часть 1. В ней была рассмотрена теория о параметризации классов типами в SystemVerilog. Был проанализирован макрос uvm_component_utils
и освоены важнейшие концепции singleton-класса и proxy-класса регистрации типа. Также была проанализирована минимальная авторская реализация класса сервисов UVM.
Маршрут создания компонента UVM
Вспомним, что в первой части статьи был частично определен маршрут создания компонента UVM (то есть любого класса, который наследуется от uvm_component
или наследника uvm_component
любого уровня) при помощи type_id::create()
.
Визуализация вызова type_id::create()
для некоторого класса драйвера интерфейса AMBA APB1 apb_driver
представлена на изображении ниже.
Создание компонента начинается с вызова type_id::create()
, где type_id
— алиас для класса регистрации uvm_component_registry
, параметризованного типом класса T
.
Еще раз рассмотрим реализацию метода type_id::create()
. Для этого откроем файл src/uvm/uvm_registry.svh
:
class uvm_component_registry #(
type T = uvm_component,
string Tname = "<unknown>"
) extends uvm_object_wrapper;
...
static function T create(
string name,
uvm_component parent
);
uvm_component obj;
uvm_coreservice_t cs = uvm_coreservice_t::get();
uvm_factory factory = cs.get_factory();
obj = factory.create_component_by_type(get(), name, parent);
$cast(create, obj);
endfunction
...
endclass
Вызов type_id::create()
инициирует получение указателя на объект класса сервисов UVM, который, в свою очередь, содержит указатель на объект фабрики UVM. Через указатель вызывается метод фабрики UVM create_component_by_type()
, который возвращает указатель на объект запрашиваемого типа. Запрашиваемый тип — это тип, которым параметризован proxy-класс type_id
для которого вызывается метод create()
. Но только ли указатель на объект запрашиваемого типа может быть возвращен фабрикой? Узнаем уже совсем скоро!
Класс фабрики UVM
Первое касание
Рассмотрим исходный код файла, в котором определен тип uvm_factory
. Для этого откроем файл src/uvm/uvm_factory.svh
:
class uvm_factory;
...
virtual function uvm_component create_component_by_type(
uvm_object_wrapper requested_type,
string name,
uvm_component parent
);
requested_type = find_override_by_type(requested_type);
return requested_type.create_component(name, parent);
endfunction
...
endclass
Рассмотрим реализацию метода create_component_by_type()
. При вызове type_id::create()
вызывается именно этот метод, а в него в качестве одного из аргументов передается указатель на proxy-класс запрашиваемого типа. Заметим, что create_component_by_type()
возвращает указатель на объект через handle типа uvm_component
. Это значит, что данный метод фабрики UVM может вернуть указатель на любого наследника uvm_component
.
Реализация метода create_component_by_type()
представляет собой:
- Поиск возможных переопределений запрашиваемого типа через
find_override_by_type()
; - Создание объекта результирующего типа через
create_component()
и возвращение указателя на этот объект.
Дополненная визуализация вызова type_id::create()
для класса apb_driver
представлена на изображении ниже.
Важно более подробно остановиться на термине "результирующий тип". Дело в том, что в ходе создания компонентов верификационного окружения часто используется переопределение типов. Метод find_override_by_type()
используется для поиска возможных переопределений. Результатом вызова (то есть результирующим типом) этого метода может быть как указатель на proxy-класс запрашиваемого типа, так и proxy-класс типа, на который запрашиваемый тип был переопределен. Звучит достаточно расплывчато, не так ли? Прежде чем разбирать реализацию find_override_by_type()
, следует более подробно ознакомиться с механизмами переопределения типов в UVM. Сделаем это!
Переопределение типов
Типовое переопределение компонента в UVM выглядит следующим образом:
apb_driver::type_id::set_type_override(extended_apb_driver::get_type());
Строка кода выше при помощи метода set_type_override()
переопределяет тип apb_driver
на тип extended_apb_driver
. Под "переопредением" в данном контексте подразумеваются действия, которые приводят к тому, что любой запрос на создание компонента типа apb_driver
через type_id::create()
приведет к созданию компонента типа extended_apb_driver
. Это значит, что все компоненты, созданные при помощи apb_driver::type_id::create()
, будут иметь тип extended_apb_driver
.
Рассмотрим простейший пример. Разберем верификационное UVM-окружение, структурная схема которого представлена на изображении ниже.
Окружение состоит из класса теста типа apb_test
, в котором содержится два агента типа apb_agent
, в каждом из которых содержится драйвер типа apb_driver
и монитор типа apb_monitor
.
Возможный вариант объявления класса apb_test
представлен ниже:
class apb_test extends uvm_test;
`uvm_component_utils(apb_test)
apb_agent ag_master;
apb_agent ag_slave;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
virtual function void build_phase(uvm_phase phase);
ag_master = apb_agent::type_id::create("ag_master", this);
ag_slave = apb_agent::type_id::create("ag_slave", this);
endfunction
endclass
А теперь предположим, что уже в процессе верификации спецификация на дизайн изменилась, и был добавлен дополнительный функционал. Для взаимодействия с новой версией был разработан новый класс драйвера extended_apb_driver
. И теперь необходимо создать такой тест, где все компоненты типа apb_driver
должны быть заменены на компоненты типа extended_apb_driver
. Код для создания такого тестового сценария представлен ниже.
class extended_apb_apb_test extends apb_test;
`uvm_component_utils(extended_apb_apb_test)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
virtual function void build_phase(uvm_phase phase);
apb_driver::type_id::set_type_override(extended_apb_driver::get_type());
super.build_phase(phase);
endfunction
endclass
Буквально при помощи одной строки кода драйверы были "заменены" во всем верификационном окружении. Просто прекрасно! Визуализация влияния вызова apb_driver::type_id::set_type_override(extended_apb_driver::get_type())
на базовую структуру верификационного окружения представлена на изображении ниже.
Почему пример выше нельзя запустить
Потому что в авторской реализации библиотеки UVM в настоящий момент нет поддержки фаз UVM (UVM phases), которые используются в примере. Автор решил не изобретать временных "костылей" в виде пользовательского метода build()
и не делать запускаемого примера.
Spoiler: Разбор фаз UVM будет в следующих статьях цикла Demystifying UVM.
Чтобы понять, почему пример выше функционирует именно таким образом, погрузимся в код UVM. Начнем разбор механизмов переопределения с метода set_type_override()
. Для этого откроем файл src/uvm/uvm_registry.svh
:
class uvm_component_registry #(
type T = uvm_component,
string Tname = "<unknown>"
) extends uvm_object_wrapper;
...
static function void set_type_override(
uvm_object_wrapper override_type
);
uvm_coreservice_t cs = uvm_coreservice_t::get();
uvm_factory factory = cs.get_factory();
factory.set_type_override_by_type(get(), override_type);
endfunction
endclass
Прежде всего заметим, что в качестве аргумента в метод передается указатель на proxy-класс типа, который для любого типа можно получить при помощи статического метода get_type()
, который определяется при вызове макроса uvm_component_utils
и вызывает метод get()
proxy-класса для получения указателя на него (пример вызова get_type()
представлен в начале раздела).
По аналогии с разобранным ранее методом create()
в методе set_type_override()
реализуется доступ к классу сервисов UVM (uvm_coreservice_t::get()
). Далее при помощи указателя на класс сервисов производится получение доступа к указателю на класс фабрики UVM (uvm_factory
) при помощи cs.get_factory()
. И, наконец, в 3 строке тела метода производится переопределение типа при помощи set_type_override_by_type()
.
В метод set_type_override_by_type()
передается два указателя на proxy-классы: переопределяемого типа (вызов get()
возвращает указатель на proxy-класс типа, для которого был вызван set_type_override()
) и типа, на который будет происходить переопределение (передается через аргумент override_type
).
Можно сделать вывод, что вызов type_id::set_type_override()
приводит к вызову метода set_type_override_by_type()
класса фабрики UVM (uvm_factory
). Доступ же к классу фабрики производится через класс сервисов UVM (uvm_coreservice_t
).
Визуализация вызова type_id::set_type_override()
для класса apb_driver
представлена на изображении ниже.
Рассмотрим подробно реализацию метода set_type_override_by_type()
фабрики UVM. Для этого вновь откроем файл src/uvm/uvm_factory.svh
:
class uvm_factory;
...
protected uvm_factory_override m_type_overrides[$];
...
virtual function void set_type_override_by_type(
uvm_object_wrapper original_type,
uvm_object_wrapper override_type
);
bit replaced;
foreach (m_type_overrides[index]) begin
if (m_type_overrides[index].orig_type == original_type) begin
replaced = 1;
m_type_overrides[index].orig_type = original_type;
m_type_overrides[index].ovrd_type = override_type;
end
end
if (!replaced) begin
uvm_factory_override override;
override = new(.orig_type(original_type),
.ovrd_type(override_type));
m_type_overrides.push_back(override);
end
endfunction
...
endclass
У метода set_type_override_by_type()
есть два аргумента: original_type
и
override_type
, которые являются указателями на proxy-класс переопределяемого типа и на proxy-класс типа, на который производится переопределение.
Обратим внимание на объявленное над методом защищенное поле m_type_overrides
, которое является очередью, в которой хранится вся информация о переопределении типов. Далее в тексте эта очередь может упоминаться как "очередь переопределений". Она содержит в себе указатели на объекты типа uvm_factory_override
. Рассмотрим этот тип более подробно (описан в этом же файле):
class uvm_factory_override;
uvm_object_wrapper orig_type;
uvm_object_wrapper ovrd_type;
function new (
uvm_object_wrapper orig_type,
uvm_object_wrapper ovrd_type
);
this.orig_type = orig_type;
this.ovrd_type = ovrd_type;
endfunction
endclass
Все, что содержит данный тип в своем определении, — это поля-указатели на proxy-классы переопределяемого типа и типа, на который производится переопределение: orig_type
и ovrd_type
. Эти указатели должны быть переданы в конструктор при создании объекта данного типа. Делаем вывод, что тип uvm_factory_override
является своего рода записью, хранящей информацию о переопределении типа.
Продолжим рассматривать реализацию метода set_type_override_by_type()
, в котором производится взаимодействие с очередью переопределений и добавление в нее записей. Обратим внимание на цикл foreach
. Он реализует итерирование по всей очереди переопределений. Если в очереди переопределений содержится запись, где поле orig_type
указывает на proxy-класс запрашиваемого типа (original_type
), то делается вывод о том, что данный тип уже был переопределен ранее, и полю ovrd_type
присваивается указатель на proxy-класс override_type
. Локальная переменная replaced
служит информационным флагом, который принимает значение 1, если в очереди переопределений была найдена запись для типа original_type
.
После цикла foreach
производится анализ флага replaced
. Если он не равен 1, то создается запись о переопределении типа и отправляется в очередь при помощи m_type_overrides.push_back(override)
.
Дополненная визуализация вызова type_id::set_type_override()
для класса apb_driver
представлена на изображении ниже.
Закрепим анализ кода примером:
class extended_apb_apb_test extends apb_test;
`uvm_component_utils(extended_apb_apb_test)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
virtual function void build_phase(uvm_phase phase);
apb_driver::type_id::set_type_override(extended_1_apb_driver::get_type());
apb_driver::type_id::set_type_override(extended_2_apb_driver::get_type());
super.build_phase(phase);
endfunction
endclass
Как думаете, сколько записей будет содержаться в очереди переопределений m_type_overrides
после выполнения кода в функции build_phase()
, если изначально очередь была пуста?
Раскройте, чтобы узнать ответ
В очереди будет содержаться одна запись. Первый вызов set_type_override()
создаст и отправит в m_type_overrides
запись о переопределении типа apb_driver
на extended_1_apb_driver
. Второй вызов set_type_override()
поменяет указатель ovrd_type
в уже созданной записи с proxy-класса для типа extended_1_apb_driver
на proxy-класс типа extended_2_apb_driver
.
Визуализация представлена на изображении ниже.
Красным отмечена запись переопределения на тип extended_1_apb_driver
, которая была перезаписана переопределением на тип extended_2_apb_driver
.
Создание компонентов
Вернемся к методу создания компонентов в классе фабрики UVM create_component_by_type()
. Для этого откроем файл src/uvm/uvm_factory.svh
:
class uvm_factory;
...
virtual function uvm_component create_component_by_type(
uvm_object_wrapper requested_type,
string name,
uvm_component parent
);
requested_type = find_override_by_type(requested_type);
return requested_type.create_component(name, parent);
endfunction
...
endclass
Еще раз заметим, что реализация метода create_component_by_type()
представляет собой:
- Поиск возможных переопределений запрашиваемого типа через
find_override_by_type()
; - Создание объекта результирующего типа через
create_component()
и возвращение указателя на этот объект.
Вооружившись знаниями о механизмах переопределения типов в UVM, разберем реализацию метода find_override_by_type()
, который определен также в классе фабрики UVM:
class uvm_factory;
...
virtual function uvm_object_wrapper find_override_by_type(
uvm_object_wrapper requested_type
);
foreach (m_type_overrides[index]) begin
if (m_type_overrides[index].orig_type == requested_type) begin
return find_override_by_type(m_type_overrides[index].ovrd_type);
end
end
return requested_type;
endfunction
...
endclass
Аргумент метода requested_type
является указателем на proxy-класс запрашиваемого типа. В методе производится итерирование через очередь переопределений m_type_overrides
(в эту очередь могут добавляться записи о переопределениях при вызовах set_type_override_by_type()
).
Если в ходе итерирования происходит совпадение запрашиваемого для создания типа и типа, который переопределяется в записи (m_type_overrides[index].orig_type == requested_type
), то происходит рекурсивный вызов find_override_by_type()
, но уже для типа, на который был переопределен целевой. Если же в ходе итерирования не было найдено переопределений, то возвращается указатель на запрашиваемый тип (return requested_type
). Этот возврат является конечной точкой, обрывающей рекурсию.
Итак, возвращаемся к реализации метода create_component_by_type()
:
class uvm_factory;
...
virtual function uvm_component create_component_by_type(
uvm_object_wrapper requested_type,
string name,
uvm_component parent
);
requested_type = find_override_by_type(requested_type);
return requested_type.create_component(name, parent);
endfunction
...
endclass
После вызова find_override_by_type()
возвращается либо указатель на proxy-класс запрашиваемого типа, либо указатель на proxy-класс типа, на который был переопределен запрашиваемый (через set_type_override_by_type()
). Далее через полученный указатель на proxy-класс выполняется создание компонента результирующего типа при помощи вызова метода proxy-класса create_component()
.
Заметим, что указатель на компонент результирующего типа возвращается методом create_component_by_type()
в метод create()
, а из метода create()
возвращается в контекст из которого был инициирован вызов создания через type_id::create()
.
Дополненная визуализация вызова type_id::set_type_override()
для класса apb_driver
представлена на изображении ниже.
Закрепим анализ кода примером:
class apb_test extends uvm_test;
`uvm_component_utils(apb_test)
apb_agent ag_master;
apb_agent ag_slave;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
virtual function void build_phase(uvm_phase phase);
ag_master = apb_agent::type_id::create("ag_master", this);
ag_slave = apb_agent::type_id::create("ag_slave", this);
endfunction
endclass
class extended_apb_apb_test extends apb_test;
`uvm_component_utils(extended_apb_apb_test)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
virtual function void build_phase(uvm_phase phase);
apb_driver::type_id::set_type_override(
extended_1_apb_driver::get_type());
extended_1_apb_driver::type_id::set_type_override(
extended_2_apb_driver::get_type());
super.build_phase(phase);
endfunction
endclass
Как думаете, на объект какого типа будет указывать поле drv
, а также сколько записей будет содержаться в очереди переопределений m_type_overrides
после выполнения кода в функции build_phase()
в классе extended_apb_apb_test
, если изначально очередь была пуста?
Раскройте, чтобы узнать ответ
Поле drv
будет указывать на объект типа extended_2_apb_driver
.
Первый вызов set_type_override()
создаст и отправит в m_type_overrides
запись о переопределении типа apb_driver
на extended_1_apb_driver
. Второй вызов set_type_override()
создаст и отправит в m_type_overrides
запись о переопределении типа extended_1_apb_driver
на extended_2_apb_driver
. Таким образом, в очереди переопределений m_type_overrides
будет содержаться две записи.
Вызов type_id::create()
приведет к вызову метода create_component_by_type()
, который в свою очередь приведет к вызову find_override_by_type()
, результатом которого будет указатель на proxy-класс для типа extended_2_apb_driver
. Почему это так? Потому что сначала в m_types_override
будет найдено переопределение типа apb_driver
на тип extended_1_apb_driver
, далее будет рекурсивно выполнен поиск для типа extended_1_apb_driver
и будет найдено переопределение этого типа на extended_2_apb_driver
.
В завершение будет вызыван метод create_component()
у proxy-класса для типа extended_2_apb_driver
, который вернет указатель на объект этого типа.
Визуализация примера представлена на изображении ниже.
Playground
Разобранный в статье исходный код авторской версии библиотеки UVM доступен в директории src/uvm
. Каждый исходный файл подробно задокументирован.
Читатель может самостоятельно попрактиковаться в создании компонентов и переопределении их типов. Минимальный задокументированный пример заботливо создан автором и располагается в файле src/test/tb_simple.sv
.
Для запуска примера при помощи QuestaSim или Verilator в директории src
необходимо выполнить скрипты run_questa.sh
и run_verilator.sh
соответственно (с аргументом tb_simple
):
run_questa.sh tb_simple
run_verilator.sh tb_simple
Лог запуска будет выведен в консоль, а артефакты симуляции сохранены в директорию src/out
.
Для добавления новых классов и работы с ними их необходимо объявить в пакете (package test_pkg
) в файле src/test/test_pkg.sv
. Объекты объявленных классов можно использовать в файле минимального примера src/test/tb_simple.sv
.
To be continued...
Вот и подошла к своему логическому завершению вторая статья цикла Demystifying UVM. В ней мы подробно разобрали механизмы создания компонентов UVM и переопределения их типов, развеяли наконец-то туман над исходным кодом фабрики UVM.
О чем же будет следующая статья? Иерархия, фазы, может быть, база ресурсов? Следите за обновлениями в Telegram-канале автора Verification For All (VFA)!
Всего вам наилучшего, дорогие читатели! Мира и процветания!☀️
Upcasting и downcasting в SystemVerilog был подробно разобран автором в лекции в Школе синтеза цифровых схем: ООП в функциональной верификации. Транзакционная модель тестирования (ссылка содержит таймкод на начало теории об upcasting).
SystemVerilog Gotchas, Tips and Tricks, часть 1
- SystemVerilog Gotchas, Tips and Tricks, часть 1
Вступление
Цикл статей SystemVerilog Gotchas, Tips and Tricks
Доброго времени суток, дорогие читатели! Данная статья является первой в целом цикле, который называется SystemVerilog Gotchas, Tips and Tricks. В нем я буду делиться интересными особенностями языка SystemVerilog и тем, как они могут проявляться в ходе функциональной верификации и как по возможности избежать негативных последствий этих проявлений.
Motivation или зачем это всё
Именно так: бед нет — есть лишь стечение более сложных и по-своему более критических обстоятельств. Я так и определяю для себя беду: стечение сложных обстоятельств.
Юрий Петрович Власов
Инженер в процессе обучения или разработки может сталкиваться с множеством препятствий. Одними из самых трудозатратных являются те, что связаны со скрытыми от поверхностного взгляда особенностями языка SystemVerilog. Для их преодоления могут потребоваться часы размышлений, отладки и чтения стандарта.
Автор имеет обширный опыт обучения начинающих и со временем пришел к выводу, что подавляющая часть ошибок практически один в один копируется от инженера к инженеру. Разумеется, не в прямом смысле. Тем не менее, каждый юный верификатор сталкивается с абсолютно типичным набором проблем.
Цель создания данного цикла статей — предупредить ошибки новичка и сохранить его ментальное здоровье. А если более серьезно и глобально — создать пособие, к которому можно будет обратиться в случае столкновения с новым и совершенно непонятным поведением верификационного окружения. Получится ли? Покажет время ваша обратная связь!
Рандомизация переменных ширины более 32 бит
Функции $urandom()
и $urandom_range()
в SystemVerilog возвращают случайный unsigned int
, разрядность которого 32 бита. Так что рандомизация переменных шириной более 32 бит за 1 вызов этих функций невозможна (будет срандомизирована лишь младшая 32-битная часть).
typedef bit [63:0] data_t;
initial begin
data_t data;
repeat(3) begin
data = $urandom(); $display("%h", data);
end
end
Результат запуска симуляции:
00000000ce46aa23
00000000a01b6e32
000000005cb53a0a
000000008b4e9f1d
00000000a80385b8
Для рандомизации переменных шириной более 32 бит можно использовать либо конкатенацию $urandom()
/$urandom_range()
, либо функцию std::randomize()
.
typedef bit [63:0] data_t;
initial begin
data_t data;
repeat(3) begin
data = {$urandom(), $urandom()}; $display("%h", data);
void'(std::randomize(data)); $display("%h", data);
end
end
Результат запуска симуляции:
ce46aa23a01b6e32
8b4e9f1d5cb53a0a
a80385b869968eda
e0a228ac13c11ac0
913a793217e9241c
12f806fda3ea30be
Пример с комментариями: src/test/rand_width.sv
.
Запуск примера в QuestaSim:
./run_questa.sh rand_width
На момент написания статьи Verilator (v5.035) не поддерживает std::randomize()
. Соответствующий issue.
Индексация данных при упаковке
При упаковке данных в SystemVerilog часто можно столкнуться с тем, что индексы, которые определяют интервал бит, должны быть compile-time или elaboration-time константами. То есть их значения должны быть известны симулятору на этапе компиляции или элаборации, которые предшествуют этапу симуляции. Иными словами, значения индексов не могут динамически изменяться в ходе симуляции.
То есть нет возможности написать, например, конструкцию:
byte data [];
longint word;
for(int i = 0; i < 8; i = i + 1) begin
word[i] = data[(i+1)*8-1:i*8];
end
Для упаковки данных в SystemVerilog можно использовать streaming operator:
byte data [];
longint word;
initial begin
data = new[8]('{8'hfa, 8'hde, 8'hca, 8'hfe,
8'hde, 8'had, 8'hbe, 8'hef});
word = {>>{data}};
$display("word: %h", word);
word = {<<8{data}};
$display("word: %h", word);
end
Результат запуска симуляции:
fadecafedeadbeef
efbeaddefecadefa
Подробнее о streaming operator можно узнать в статье How to Pack Data Using the SystemVerilog Streaming Operators от Amiq Consulting и в стандарте SystemVerilog IEEE Std 1800-2023 в разделе 11.4.14.
Для упаковки данных в SystemVerilog также можно использовать indexed part-select:
byte data [];
longint word;
initial begin
data = new[8]('{8'hfa, 8'hde, 8'hca, 8'hfe,
8'hde, 8'had, 8'hbe, 8'hef});
foreach(data[i]) word[64-8*(i+1)+:8] = data[i];
$display("word: %h", word);
foreach(data[i]) word[8*i+:8] = data[i];
$display("word: %h", word);
end
Результат запуска симуляции:
fadecafedeadbeef
efbeaddefecadefa
Подробнее об indexed part-select можно узнать в стандарте SystemVerilog IEEE Std 1800-2023 в разделе 11.5.1.
Пример с комментариями: src/test/data_packing.sv
.
Запуск примера в QuestaSim:
./run_questa.sh data_packing
На момент написания статьи Verilator (v5.035) некорректно определяет очередность данных для упаковки при помощи streaming operator. Автор создал issue.
Завершение потоков через disable fork
Пример ниже состоит из задачи run_processes()
и блока initial begin-end
, который запускает эту задачу. Выход из задачи происходит после завершения процесса 1. Процесс 2 при этом продолжит выполняться. Далее в блоке запускаются процессы 3 и 4. Выполнение кода продолжается после завершения процесса 3. Далее вызывается disable fork
, после чего запускается процесс 5.
task run_processes();
fork
repeat( 3) #1 $display("Process 1");
repeat(20) #1 $display("Process 2");
join_any
$display("-- Exit run_processes()");
endtask
initial begin
run_processes();
fork
repeat( 3) #1 $display("Process 3");
repeat(10) #1 $display("Process 4");
join_any
$display("-- Exit fork and disable it");
disable fork;
repeat(3) #1 $display("Process 5");
$finish();
end
Результат запуска симуляции:
Process 1
Process 2
Process 1
Process 2
Process 1
-- Exit run_processes()
Process 2
Process 3
Process 4
Process 2
Process 3
Process 4
Process 2
Process 3
-- Exit fork and disable it
Process 5
Process 5
Process 5
После disable fork
процесс 2 перестает выполняться, как и 4. Остается только 5. Получается, что disable fork
завершает не только процессы "ближайшего" fork
, как склонны полагать начинающие. Так на какие же процессы влияет disable fork
? Давайте обратимся к разделу 9.6.3. стандарта SystemVerilog IEEE Std 1800-2023.
The disable fork statement terminates all descendant subprocesses (not just immediate children) of the calling process, including descendants of subprocesses that have already terminated.
Оказывается, что disable fork
завершает все дочерние процессы вызывающего disable fork
процесса. То есть в нашем случае все процессы, созданные в initial
, в том числе процессы, которые были созданы при вызове задачи run_processes()
, так как она вызывалась в этом initial
.
Пример с комментариями: src/test/disable_fork.sv
.
Запуск примера в QuestaSim:
./run_questa.sh disable_fork
Запуск примера в Verilator:
./run_verilator.sh disable_fork
Для безопасного завершения процессов через disable fork
можно использовать особую обертку fork begin - end join
:
initial begin
run_processes();
fork begin
fork
repeat( 3) #1 $display("Process 3");
repeat(10) #1 $display("Process 4");
join_any
$display("-- Exit fork and disable it");
disable fork;
end join
repeat(3) #1 $display("Process 5");
$finish();
end
Дополнительный fork-join
создает новый процесс, в котором запускаются процессы 3 и 4, а также вызывается disable fork
, который принудительно завершит только процесс 4. Процесс 2 является дочерним процессом initial
, а не процесса, созданного в fork begin - end join
, так что он завершен не будет.
Результат запуска симуляции:
Process 1
Process 2
Process 1
Process 2
Process 1
-- Exit run_processes()
Process 2
Process 3
Process 4
Process 2
Process 3
Process 4
Process 2
Process 3
-- Exit fork and disable it
Process 2
Process 5
Process 2
Process 5
Process 2
Process 5
SystemVerilog concurrent assertions и event regions
Начнем с примера
Необходимо проверить заведомо сломанный модуль знакорасширения.
module signextend(
input logic clk_i,
input logic aresetn_i,
input logic [ 7:0] data_i,
output logic [31:0] data_o
);
always_ff @(posedge clk_i or negedge aresetn_i) begin
if(!aresetn_i) begin
data_o <= 'b0;
end
else begin
// NOTE: вычисление знакорасширения
// сломано в данном примере.
data_o <= {{23{data_i[7]}}, data_i};
end
end
endmodule
Напишем SystemVerilog property
и инициируем его проверку через assert property
:
property pData;
logic [7:0] data;
@(posedge clk_i) disable iff(!aresetn_i)
(1, data = data_i) ##1 data_o === {{24{data[7]}}, data};
endproperty
apData: assert property(pData) else begin
$error("data_i was: %h, data_o is: %h",
$past(data_i, 1), $sampled(data_o));
end
Каждый такт значение со входа data_i
сохраняется во временную переменную data
(да, в теле property
можно объявлять переменные!). На следующем такте значение data_o
сравнивается с знакорасширенным значением data
.
Если знакорасширенное значение data
на совпадает со значением data_o
(о чем автор любезно позаботился, сломав модуль), то генерируется пользовательская ошибка. У читателя может возникнуть вопрос: что за конструкции языка $past(data_i, 1)
и $sampled(data_o)
? Не забегайте вперед! Дайте автору начать чуть издалека...
Пример с комментариями: src/test/sva_event_regions.sv
.
Запуск примера в QuestaSim в режиме GUI:
./run_questa.sh sva_event_regions -gui
На момент написания статьи Verilator (v5.035) не поддерживает объявление переменной в теле property
.
Результат запуска симуляции:
# ** Error: data_i was: a4, data_o is: 7fffffa4
# Time: 75 ns Started: 65 ns Scope: sva_event_regions.apData
# ** Error: data_i was: c6, data_o is: 7fffffc6
# Time: 85 ns Started: 75 ns Scope: sva_event_regions.apData
# ** Error: data_i was: 9e, data_o is: 7fffff9e
# Time: 95 ns Started: 85 ns Scope: sva_event_regions.apData
Посмотрим на первое сообщение об ошибке. Оно гласит, что в момент 65ns
значение data_i
было равно a4
, а в момент 75ns
его знакорасширенное значение data_o
равно 7fffffa4
. Генерация ошибки ожидаема, так как модуль заведомо сломан автором.
Давайте внимательно взглянем на временную диаграмму:
В моменты времени 65ns
и 75ns
вход data_i
и выход data_o
меняются по положительному фронту clk_i
. Моменты изменения сигналов в ходе симуляции SystemVerilog уже принесли и продолжают приносить ощутимые неприятности инженерам по всему миру. И concurrent assertions — не исключение. Давайте подробнее!
О SystemVeriog event regions
На изображении ниже представлена авторская упрощенная версия схемы регионов выполнения, которые последовательно выполняются в рамках каждого момента времени симуляции, содержащего события. Подробнее про регионы выполнения можно узнать в стандарте SystemVerilog IEEE Std 1800-2023 в разделе 4. Также данная тема освещена в одной из лекций автора в школе синтеза цифровых схем, и еще более подробно уже в другой лекции автора там же.
Из предыдущего момента времени (time slot) симулятор переходит к preponed регион текущего момента времени. В данном регионе в том числе считываются значения переменных для проверки в concurrent assertions (см. SystemVerilog IEEE Std 1800-2023 раздел 16.5.1). Фиксируем: значения переменных для проверки в concurrent assertions считываются в самом начале момента времени.
Через несколько иных регионов выполняется Active регион, где в том числе рассчитываются значения в комбинационной логике. После еще нескольких регионов в NBA (Nonblocking Assignment) регионе значения с комбинационной логики защелкиваются в последовательностную.
После выполнения других регионов после NBA симулятор попадает в Reactive регион, где, при наличии ошибки в concurrent assertion, генерирует либо implementation-specific ошибку, либо выполняет пользовательский код в ветви else
в assert property
, если такая ветвь присутствует (см. SystemVerilog IEEE Std 1800-2023 раздел 16.4.1). Присутствие ветви else
— как раз случай из представленного ранее примера!
Итак, вооружившись теорией, продолжаем разбирать пример.
Системные функции $past()
и $sampled()
Вернемся к временной диаграмме. Продублирую ее ниже для удобства читателя.
Вспомним, что в моменты времени 65ns
и 75ns
вход data_i
и выход data_o
меняются по положительному фронту clk_i
.
Обратим внимание на момент времени 65ns
. Вход data_i
меняет свое значение с a4
на c6
. Для проверки в property
фиксируется значение a4
в Preponed регионе. Значение c6
защелкнется в data_i
в NBA регионе, который идет после Preponed.
Обратим внимание на момент времени 75ns
. Выход data_o
меняет свое значение с 7fffffa4
на 7fffffс6
. Для проверки в property
по аналогии с моментом времени 65ns
фиксируется значение 7fffffa4
.
Проверка не проходит (модуль сломан автором), ведь знакорасширенное значение должно быть ffffffa4
. Это отражено в результате запуска:
# ** Error: data_i was: a4, data_o is: 7fffffa4
# Time: 75 ns Started: 65 ns Scope: sva_event_regions.apData
Давайте попробуем убрать $sampled()
из кода ошибки:
apData: assert property(pData) else begin
$error("data_i was: %h, data_o is: %h",
$past(data_i, 1), data_o);
end
Результат запуска симуляции:
# ** Error: data_i was: a4, data_o is: 7fffffс6
# Time: 75 ns Started: 65 ns Scope: sva_event_regions.apData
Интересно! Значение data_o
в выводе изменилось на 7fffffc6
, которое соответствует результату для значения data_i
, равного c6
. Но, зная теорию из раздела выше, мы можем объяснить такое певедение симулятора. Ведь выполнение ветви else
происходит в Reactive регионе, после обновление выхода data_o
в NBA регионе. Соответственно значение data_o
для вывода берется уже обновленное!
Так какую же роль играет системная функция $sampled()
. Не трудно догадаться — функция возвращает значение переменной в Preponed регионе. То значение, которое было использовано при в проверке в concurrent assertion. Такие значения в контексте assertions в стандарте SystemVerilog называются сэмплированными (англ. sampled).
С системной функцией $past()
все еще проще. Вызов $past(val, n)
возвращает значение переменной val
, сэмплированное на n
тактов во времени ранее. Совет: при генерации пользовательских ошибок в concurrent assertions используйте $sampled()
и $past()
для вывода значений переменных.
Тонкости fork-join_none
Несовершенство обучающих материалов
В подавляющем большинстве обучающих материалов по SystemVerilog (в особенности открытых) про fork-join_none
можно увидеть формулировку, похожую на:
As in the case of
fork-join
andfork-join_any
fork block is blocking, but in case offork-join_none
fork block will be non-blocking. Processes inside thefork-join_none
block will be started at the same time, fork block will not wait for the completion of the process inside thefork-join_none
.
или:
A
fork
andjoin_none
will allow the main thread to resume execution of further statements that lie after the fork regardless of whether the forked threads finish. If five threads are launched, the main thread will resume execution immediately while all the five threads remain running in the background.
Цитаты взяты с популярных тематических сайтов ChipVerify и VerificationGuide соответственно.
Из формулировок выше можно сделать вывод, что процессы, объявленные в fork-join_none
, запускаются и выполняются совместно с выражениями в родительском процессе (в котором был объявлен fork-join_none
).
К сожалению, формулировки выше, а, следовательно, и вывод из них, неверны. И вот почему!
Продолжим примером
module fork_join_none;
initial begin
$display("Statement 1");
fork
$display("Process 1");
join_none
$display("Statement 2");
$display("Statement 3");
$display("Statement 4");
end
endmodule
Исходя из формулировок выше вывод симулятора будет не определен, так как процесс $display("Process 1")
запускается совместно с $display
после join_none
. Подробнее про недетерменированность можно узнать в стандарте SystemVerilog IEEE Std 1800-2023 в разделе 4.7.
Однако, любой симулятор большой тройки (Siemens QuestaSim, Synopsys VCS, Cadence Xcelium) выведет результат запуска симуляции:
Statement 1
Statement 2
Statement 3
Statement 4
Process 1
Да, разумеется, читатель может сказать, что симуляторы зачастую вносят дополнительную детерменированность там, где ее не должно быть. В этом случае, независимо от условий запуска (время, зерно рандомизации и т.д.), можно наблюдать одно и то же поведение, хотя согласно стандарту языка такое поведение не является обязательным. Однако это не наш случай, и сейчас я расскажу почему!
fork-join_none
и стандарт SystemVerilog
Давайте обратимся к разделу 9.3.2. стандарта SystemVerilog IEEE Std 1800-2023. Внимателно посмотрим на таблицу 9-1.
Пока противоречий с формулировками выше не наблюдаются. Процесс, вызвавший, fork-join_none
продолжает выполняться совместно с процессами, созданными в fork-join_none
. Однако обратим внимание на предложение после таблицы. Как на зло оно еще и на другой странице...
In all cases, processes spawned by a fork-join block shall not start executing until the parent process is blocked or terminates.
Это очень важное замечание, которое гласит, что процессы в любом блоке fork-join
не должны быть запущены до момента, пока процесс, вызвавший fork-join
, не заблокируется или не завершится. Если с завершением все понятно, то что значит заблокируется? В данном контексте заблокируется — выполнит блокирующее выражение. Но что значит блокирующее выражение?
Согласно Annex P стандарта SystemVerilog IEEE Std 1800-2023:
blocking statement: A construct having the potential to suspend a process. This potential is determined through lexical analysis of the source syntax alone, not by execution semantics. For example, the statement wait(1) is considered a blocking statement even though evaluation of the expression '1' will be true at execution.
То есть блокирующее выражение — это выражение, которое, исходя из синтаксиса, может привести к остановке процесса. Это может быть wait()
, @
, # =
. Также блокирующими выражениями являются join
и join_any
. Ведь они блокируют поток, вызвавший fork
до завершения всех или одного процесса в fork
соответственно.
Вернемся к уточнению в стандарте:
In all cases, processes spawned by a fork-join block shall not start executing until the parent process is blocked or terminates.
С fork-join
и fork-join_any
все понятно, они сами по себе блокируют родительский процесс, который их вызывал, а процессы в fork
начинают выполняться. А что с fork-join_none
?
И снова пример
module fork_join_none;
initial begin
$display("Statement 1");
fork
$display("Process 1");
join_none
$display("Statement 2");
$display("Statement 3");
$display("Statement 4");
end
endmodule
Однозначно определенный результат запуска симуляции:
Statement 1
Statement 2
Statement 3
Statement 4
Process 1
Такой результат обсусловлен тем, что процесс $display("Process 1")
по стандарту должен начать выполняться в момент когда родительский initial
начнет выполнять блокирующее выражение или завершится. В примере все $display()
после join_none
не являются блокирующими выражениями. Родительский initial
завершится, после чего процесс в fork_join_none
начнет свое выполнение. То есть вывод Process 1
будет сделан после всех остальных выводов в примере.
Пример с комментариями: src/test/fork_join_none.sv
.
Запуск примера в QuestaSim:
./run_questa.sh fork_join_none
На момент написания статьи Verilator (v5.035) некорректно выполняет код примера, выполняя вывод Process 1
до завершения родительского initial
. Автор создал issue.
Неужели это так важно?
Читатель может усомниться в необходимости знания таких тонкостей в поведении конструкций SystemVerilog и предположить, что в реальных проектах вероятность столкнуться с таким поведением крайне мала. К сожалению, это не так. Показательный пример представлен уже в следующем разделе.
Совместный запуск последовательностей на множестве агентов
Функциональная верификация межсоединений
В современных СнК для соединения множества функциональных блоков между собой используются межсоединения шин (англ. interconnect), которые маршрутизируют потоки данных между блоками. Типовая структурная схема СнК с межсоединением на основе интерфейса AXI4 представлена на изображении ниже.
Для верификации межсоединений в большинстве случаев проектируются верификационные окружения, имитирующие обмен данными между множеством функциональных блоков. Типовая структурная схема такого верификационного окружения представлена на изображении ниже.
На схеме компоненты типа Agent
(Агент) получают высокоуровневые транзакции записи/чтения от Sequence
(последовательностей) и отправляют их на входные порты тестируемого межсоединения DUT
. Абстракции в виде агентов и последовательностей активно используются в универсальной методологии верификации (Universal Verification Methodology, UVM), о которой вы можете подробнее узнать в цикле статей автора Demistifying UVM.
Продолжим примером
Пример UVM-класса теста для верификации межсоединения представлен ниже.
class icon_test extends uvm_test;
`uvm_component_utils(icon_test)
icon_agent ag []; int ag_num;
icon_seq seq [];
...
virtual function void build_phase(uvm_phase phase);
void'(uvm_resource_db#(int)::read_by_name(
get_full_name(), "ag_num", ag_num));
ag = new[ag_num];
foreach(ag[i]) begin
ag[i] = icon_agent::type_id::create(
$sformatf("ag_%0d", i), this);
end
endfunction
virtual task main_phase(uvm_phase phase);
phase.raise_objection(this);
seq = new[ag_num];
foreach(seq[i]) begin
seq[i] = icon_seq::type_id::create(
$sformatf("seq_%0d", i));
end
foreach(seq[i]) begin
fork
int j = i;
seq[j].start(ag[j].sqr);
join_none
end
wait fork;
phase.drop_objection(this);
endtask
endclass
Класс содержит динамический массив агентов и последовательностей для них. В методе build_phase()
создаются агенты в зависимости от настройки ag_num
, полученной извне. В методе main_phase()
создаются последовательности, и каждая из них запускается на своем агенте, после чего запускается ожидание завершения каждой из последовательностей при помощи wait fork
. Код запуска последовательностей рассмотрим подробнее.
foreach(seq[i]) begin
fork
int j = i;
seq[j].start(ag[j].sqr);
join_none
end
wait fork;
Особый интерес представляет строка int j = i;
, отвечающая за создание новой переменной, в которую копируется значение итератора. Но зачем это делать? Ведь итератор можно использовать напрямую, без промежуточной переменной. Но можно ли? Давайте обо всем по порядку!
Все последовательности запускаются совместно через fork-join_none
для полноценной нагрузки межсоединения со стороны различных функциональных блоков. i
-ая последовательность запускается на i
-ом агенте.
Пример с комментариями: src/test/fork_join_seqs.sv
.
Запуск примера в QuestaSim:
./run_questa.sh fork_join_seqs
На момент написания статьи Verilator (v5.035) не поддерживает UVM. Текущий статус поддержки можно посмотреть в соответствующем issue.
Результат запуска симуляции:
@ 0: uvm_test_top.ag_0.drv [drv] Got item: addr: f7, data: 13a33d07
@ 0: uvm_test_top.ag_1.drv [drv] Got item: addr: 50, data: ebf57900
@ 0: uvm_test_top.ag_2.drv [drv] Got item: addr: d1, data: 1864b414
@ 0: uvm_test_top.ag_3.drv [drv] Got item: addr: 10, data: 3c257344
@ 0: uvm_test_top.ag_4.drv [drv] Got item: addr: 73, data: 0c784afa
@ 0: uvm_test_top.ag_5.drv [drv] Got item: addr: 43, data: 8d248e59
@ 10: uvm_test_top.ag_5.drv [drv] Got item: addr: ba, data: 8aa33156
@ 12: uvm_test_top.ag_4.drv [drv] Got item: addr: 8a, data: 330f55a7
@ 16: uvm_test_top.ag_2.drv [drv] Got item: addr: 80, data: 02aa1ac7
@ 18: uvm_test_top.ag_3.drv [drv] Got item: addr: 1f, data: 26a9b0ca
@ 19: uvm_test_top.ag_0.drv [drv] Got item: addr: f9, data: 62c70ec5
@ 20: uvm_test_top.ag_1.drv [drv] Got item: addr: c0, data: 447628cb
Вложенный в класс агента класс драйвера информирует об обработке транзакций каждой из последовательностей. Обратите внимание, что транзакции обрабатываются разными агентами, это видно по их иерархическому имени. Также обратите внимание, что первые транзакции агенты получают одновременно в нулевой момент времени, это говорит об одновременной их отправке из последовательностей. Следующие транзакции агенты получают неодновременно из-за различных задержек взаимодействия с портами верифицируемого устройства.
Разбираемся с промежуточными переменными
Давайте уберем промежуточную переменную j
s:
foreach(seq[i]) begin
fork
seq[i].start(ag[i].sqr);
join_none
end
wait fork;
Результат запуска симуляции:
# ** Fatal: (SIGSEGV) Bad handle or reference.
# Time: 0 ns Iteration: 120 Process: /icon_dv_pkg::icon_test::main_phase
# Fatal error in Task icon_dv_pkg/icon_test::main_phase at ./test/fork_join_seqs.sv
Неожиданно получаем ошибку доступа к памяти. Но почему? Давайте раскроем цикл, приняв размерность массивов, равной 3.
int i = 0; // i = 0
fork
seq[i].start(ag[i].sqr);
join_none
i = i + 1; // i = 1
fork
seq[i].start(ag[i].sqr);
join_none
i = i + 1; // i = 2
fork
seq[i].start(ag[i].sqr);
join_none
i = i + 1; // i = 3
wait fork;
А теперь, руководствуясь знаниями об особенностях исполнения fork-join_none
из предыдущего раздела, обоснуем ошибку. Запуск всех seq[i].start(ag[i].sqr)
будет выполнен, как только родительский процесс заблокируется, то есть начнет выполнять блокирующее выражение. В нашем случае это выражение wait fork
, которое отвечает за ожидание завершения процессов в fork
. Каким же будет значение i
в этот момент? Верно: i
будет равно 3. Все вызовы seq[i].start(ag[i].sqr)
выродятся в seq[3].start(ag[3].sqr)
. Если количество агентов равно 3, то какой максимальный индекс в массиве агентов? Тоже верно: он равен 2. Отсюда и ошибка доступа к памяти. Это ошибка доступа к несуществующему элементу массива.
А теперь вернем промежуточную переменную и снова раскроем цикл.
int i = 0; // i = 0
fork
int j = i; // j = 0
seq[i].start(ag[i].sqr);
join_none
i = i + 1; // i = 1
fork
int j = i; // j = 1
seq[i].start(ag[i].sqr);
join_none
i = i + 1; // i = 2
fork
int j = i; // j = 2
seq[i].start(ag[i].sqr);
join_none
i = i + 1; // i = 3
wait fork;
Обратимся к разделу 9.3.2 стандарта SystemVerilog IEEE Std 1800-2023:
Variables declared in the block_item_declaration of a fork-join block shall be initialized to their initialization value expression whenever execution enters their scope and before any processes are spawned.
Согласно выдержке выше, переменные, объявленные в fork-join
блоке должны быть проинициализированы, как только симулятор в ходе выполнения "попадет" на выражение инициализации, то есть семантически его распознает. Таким образом, значения локальных переменных j
каждого fork-join_none
блока инициализируются еще до запуска процессов в них и сохраняют свое значение.
Во всех вызовах seq[j].start(ag[j].sqr)
переменная j
будет принимать соответственно значения 0
, 1
и 2
, что приведет к корректному запуску последовательностей на агентах.
To be continued...
Первая часть цикла SystemVerilog Gotchas, Tips and Tricks подошла к концу, дорогие читатели. Следите за обновлениями в Telegram-канале автора Verification For All (VFA)!
Всего вам наилучшего, дорогие читатели! Мира и процветания!☀️