Python и FPGA. Тестирование
В продолжение к первой статье, хочу на примере показать вариант работы с FPGA (ПЛИС) на python. В данной статье затрону подробнее аспект тестирования. Если фреймворк MyHDL позволяет людям, работающим на python, используя знакомый синтаксис и экосистему, заглянуть в мир FPGA, то опытным разработчикам ПЛИС смысл использования python не ясен. Парадигмы описания аппаратуры для MyHDL и Verilog похожи, а выбор в пользу определенного языка вопрос привычки и вкуса. За Verilog/VHDL выступает то, что на этих языках давно пишут прошивки, и по факту они являются стандартными для описания цифровой аппаратуры. Python, как новичок в этой сфере, может конкурировать в области написания тестового окружения. Значительную часть времени у FPGA разработчика занимает тестирование своих дизайнов. Далее я хочу на примере продемонстрировать как это делается в python с MyHDL.
Допустим, есть задача описать на ПЛИС некое устройство, работающее с памятью. Для простоты возьму память, общающуюся с другими устройствами через параллельный интерфейс (а не через последовательный, например I2C). Такие микросхемы не всегда бывают практичны в виду того, что для работы с ними требуется много пинов, с другой стороны обеспечивается более быстрый и упрощенный обмен информации. Например отечественная 1645РУ1У и ее аналоги.
Запись выглядит так: ПЛИС даёт 16-ти разрядный адрес ячейки, 8-бит данных, формирует сигнал на запись WE (write enable). Поскольку OE (output enable) и CE (chip enable) всегда разрешены, чтение происходит по смене адреса ячейки. Запись и чтение может производиться, как последовательно по несколько ячеек подряд, начиная с определённого адреса adr_start, записываемого по переднему фронту сигнала adr_write, так и по одной ячейке по произвольному адресу (random access).
На MyHDL код выглядит следующим образом (сигналы на запись и чтение приходят в обратной логике):
from myhdl import *
@block
def ram_driver(data_in, data_out, adr, adr_start, adr_write, data_memory, read, write, we): # объявляются входы и выходы
mem_z = data_memory.driver() # драйвер портов с третьим состоянием
@always(adr_write.posedge, write.posedge, read.negedge)
def write_start_adr():
if adr_write: # начальный адрес памяти
adr.next = adr_start
else: # увеличивается адрес при чтении/записи
adr.next = adr + 1
@always(write)
def write_data():
if not write:
mem_z.next = data_in
we.next = 0 # если есть сигнал записи, то записываем данные
else:
mem_z.next = None # в противном случае читаем данные из памяти
data_out.next = data_memory
we.next = 1
return write_data, write_start_adr
Если сконвертировать в Verilog при помощи функции:
def convert(hdl):
data_memory = TristateSignal(intbv(0)[8:])
data_in = Signal(intbv(0)[8:])
data_out = Signal(intbv(0)[8:])
adr = Signal(intbv(0)[16:])
adr_start = Signal(intbv(0)[16:])
adr_write = Signal(bool(0))
read, write, we = [Signal(bool(1)) for i in range(3)]
inst = ram_driver(data_in, data_out, adr, adr_start, adr_write, data_memory, read, write, we)
inst.convert(hdl=hdl)
convert(hdl='Verilog')
то получится следующее:
`timescale 1ns/10ps
module ram_driver (
data_in,
data_out,
adr,
adr_start,
adr_write,
data_memory,
read,
write,
we
);
input [7:0] data_in;
output [7:0] data_out;
reg [7:0] data_out;
output [15:0] adr;
reg [15:0] adr;
input [15:0] adr_start;
input adr_write;
inout [7:0] data_memory;
wire [7:0] data_memory;
input read;
input write;
output we;
reg we;
reg [7:0] mem_z;
assign data_memory = mem_z;
always @(write) begin: RAM_DRIVER_WRITE_DATA
if ((!write)) begin
mem_z <= data_in;
we <= 0;
end
else begin
mem_z <= 'bz;
data_out <= data_memory;
we <= 1;
end
end
always @(posedge adr_write, posedge write, negedge read) begin: RAM_DRIVER_WRITE_START_ADR
if (adr_write) begin
adr <= adr_start;
end
else begin
adr <= (adr + 1);
end
end
endmodule
Для моделирования конвертировать проект в Verilog не обязательно, этот шаг потребуется для прошивания ПЛИС.
После описания логики, следует провести верификацию проекта. Можно ограничиться, например тем, чтобы смоделировать входные воздействия и на временной диаграмме увидеть ответ модуля. Но при таком варианте сложней предсказать взаимодействие Вашего модуля с микросхемой памяти. Поэтому для полноценной проверки работы созданного устройства нужно создать модель памяти и протестировать взаимодействие между этими двумя устройствами.
Поскольку работа происходит в python, за модель памяти сам собой напрашивается тип данный dictionary (словарь). Данные в котором хранятся как {ключ: значение}, а для этого случая {адрес: данные}.
memory = {
0: 123,
1: 456,
2: 789
}
memory[0]
>> 123
memory[1]
>> 456
Для этих же целей подходит тип данных list (список), где у каждого элемента есть свои координаты, обозначающие расположение элемента в списке:
memory = [123, 456, 789]
memory[0]
>> 123
memory[1]
>> 456
Использование словарей для имитации памяти выглядит более предпочтительным в виду большей наглядности.
Описание тестовой оболочки (в файле test_seq_access.py) начинается с объявления сигналов, инициализации начальных состояний и прокидывания их в вышеописанную функцию драйвера памяти:
@block
def testbench():
data_memory = TristateSignal(intbv(0)[8:])
data_in = Signal(intbv(0)[8:])
data_out = Signal(intbv(0)[8:])
adr = Signal(intbv(0)[16:])
adr_start = Signal(intbv(20)[16:])
adr_write = Signal(bool(0))
read, write, we = [Signal(bool(1)) for i in range(3)]
ram = ram_driver(data_in, data_out, adr, adr_start, adr_write, data_memory, read, write, we)
Далее описывается модель памяти. Инициализируются начальные состояния, по умолчанию память заполняется нулевыми значении. Ограничим модель памяти 128 ячейками:
memory = {i: intbv(0) for i in range(128)}
и опишем поведение памяти: когда WE в низком состоянии записываем значение в линии в соответствующий адрес памяти, в противном случае модель выдает значение по заданному адресу:
mem_z = data_memory.driver()
@always_comb
def access():
if not we:
memory[int(adr.val)] = data_memory.val
if we:
data_out.next = memory[int(adr.val)]
mem_z.next = None
После, в этой же функции можно описать поведение входных сигналов (для случая последовательной записи/чтения): записывается начальный адрес → записываются 8 ячейки информации → записывается начальный адрес → читаются 8 записанных ячеек информации.
@instance
def stimul():
init_adr = random.randint(0, 50) #генерация начального адреса
yield delay(100)
write.next = 1
adr_write.next = 1
adr_start.next = init_adr #запись начального адреса
yield delay(100)
adr_write.next = 0
yield delay(100)
for i in range(8): #запись 8 ячеек случайной информацией
write.next = 0
data_in.next = random.randint(0, 100)
yield delay(100)
write.next = 1
yield delay(100)
adr_start.next = init_adr #запись начального адреса
adr_write.next = 1
yield delay(100)
adr_write.next = 0
yield delay(100)
for i in range(8): #чтение записанной информации
read.next = 0
yield delay(100)
read.next = 1
yield delay(100)
raise StopSimulation
return stimul, ram, access
Запуск моделирования:
tb = testbench()
tb.config_sim(trace=True)
tb.run_sim()
После запуска программы в рабочей папке сгенирируется файл testbench_seq_access.vcd, открываем его в gtkwave:
gtkwave testbench_seq_access.vcd
И видим картинку:
Записанная информация успешно прочиталась.
Увидеть содержимое памяти можно добавив в testbench следующий код:
for key, value in memory.items():
print('adr:{}'.format(key), 'data:{}'.format(value))
В консоли появиться следующее:
После этого можно провести несколько автоматизированных тестов с увеличенным количеством записываемых/читаемых ячеек. Для этого в testbench добавляются несколько циклов проверки и фиктивные словари, куда складывается записываемая и читаемая информация и конструкция assert, которая вызывает ошибку в случае неравенства двух словарей:
@instance
def stimul():
for time in range(100):
temp_mem_write = {}
temp_mem_read = {}
init_adr = random.randint(0, 50)
yield delay(100)
write.next = 1
adr_write.next = 1
adr_start.next = init_adr
yield delay(100)
adr_write.next = 0
yield delay(100)
for i in range(64):
write.next = 0
data_in.next = random.randint(0, 100)
temp_mem_write[i] = int(data_in.next)
yield delay(100)
write.next = 1
yield delay(100)
adr_start.next = init_adr
adr_write.next = 1
yield delay(100)
adr_write.next = 0
yield delay(100)
for i in range(64):
read.next = 0
temp_mem_read[i] = int(data_out.val)
yield delay(100)
read.next = 1
yield delay(100)
assert temp_mem_write == temp_mem_read, "ошибка при последовательной записи"
for key, value in memory.items():
print('adr:{}'.format(key), 'data:{}'.format(value))
raise StopSimulation
return stimul, ram, access
Далее можно создать второй testbench для проверки работы в режиме случайного доступа к памяти: test_random_access.py.
Идея у второго теста схожая: записываем случайную информацию по случайному адресу и добавляем пару {адрес: данные} в словарь temp_mem_write. После чего обходим адреса в этом словаре и считываем информацию из памяти, занося ее в словарь temp_mem_read. И в конце конструкцией assert проверяем содержимое двух словарей.
import random
from myhdl import *
from ram_driver import ram_driver
@block
def testbench_random_access():
data_memory = TristateSignal(intbv(0)[8:])
data_in = Signal(intbv(0)[8:])
data_out = Signal(intbv(0)[8:])
adr = Signal(intbv(0)[16:])
adr_start = Signal(intbv(20)[16:])
adr_write = Signal(bool(0))
read, write, we = [Signal(bool(1)) for i in range(3)]
ram = ram_driver(data_in, data_out, adr, adr_start, adr_write, data_memory, read, write, we)
memory ={i:intbv(0) for i in range(128)}
mem_z = data_memory.driver()
@always_comb
def access():
if not we:
memory[int(adr.val)] = data_memory.val
if we:
data_out.next = memory[int(adr.val)]
mem_z.next = None
@instance
def stimul():
for time in range(10):
temp_mem_write = {}
temp_mem_read = {}
yield delay(100)
for i in range(64):
write.next = 1
adr_write.next = 1
adr_start.next = random.randint(0, 126)
yield delay(100)
adr_write.next = 0
yield delay(100)
write.next = 0
data_in.next = random.randint(0, 100)
temp_mem_write[int(adr_start.val)] = int(data_in.next)
yield delay(100)
write.next = 1
yield delay(100)
for key in temp_mem_write.keys():
adr_start.next = key
adr_write.next = 1
yield delay(100)
adr_write.next = 0
yield delay(100)
read.next = 0
temp_mem_read[key] = int(data_out.val)
yield delay(100)
read.next = 1
yield delay(100)
assert temp_mem_write == temp_mem_read, 'ошибка при random access'
raise StopSimulation
return stimul, ram, access
tb = testbench_random_access()
tb.config_sim(trace=True)
tb.run_sim()
Для автоматизации выполнения тестов у python есть несколько фреймоворков. Я возьму для простоты pytest, его надо ставить из pip:
pip3 install pytest
При запуске из консоли команды «pysest», фреймворк найдёт и исполнит все файлы в рабочей папке, в названиях которых присутствует «test_*».
Тесты выполнены успешно. Нарочно сделаю ошибку в описании устройства:
@block
def ram_driver(data_in, data_out, adr, adr_start, adr_write, data_memory, read, write, we):
mem_z = data_memory.driver()
@always(adr_write.posedge, write.posedge, read.negedge)
def write_start_adr():
if adr_write:
adr.next = adr_start
else:
adr.next = adr + 1
@always(write)
def write_data():
if not write:
mem_z.next = data_in
we.next = 1 # здесь ошибка, отсутствует возможность записи
else:
mem_z.next = None
data_out.next = data_memory
we.next = 1
Запускаю тесты:
Как и предполагалось, в обоих тестах считалась начальная информация (нули), то есть новая информация не записалась.
Использование python вместе с myHDL позволяет автоматизировать тестирование разработанных прошивок для ПЛИС и создавать практически любое тестовое окружение используя богатые возможности языка программирования python.
В статье рассмотрено:
- создание модуля, работающего с памятью;
- создание модели памяти;
- создание тестового сценария;
- автоматизация тестирования с фреймворком pytest.