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