Python и FPGA. Тестирование

В продолжение к первой статье, хочу на примере показать вариант работы с FPGA (ПЛИС) на python. В данной статье затрону подробнее аспект тестирования. Если фреймворк MyHDL позволяет людям, работающим на python, используя знакомый синтаксис и экосистему, заглянуть в мир FPGA, то опытным разработчикам ПЛИС смысл использования python не ясен. Парадигмы описания аппаратуры для MyHDL и Verilog похожи, а выбор в пользу определенного языка вопрос привычки и вкуса. За Verilog/VHDL выступает то, что на этих языках давно пишут прошивки, и по факту они являются стандартными для описания цифровой аппаратуры. Python, как новичок в этой сфере, может конкурировать в области написания тестового окружения. Значительную часть времени у FPGA разработчика занимает тестирование своих дизайнов. Далее я хочу на примере продемонстрировать как это делается в python с MyHDL.

Допустим, есть задача описать на ПЛИС некое устройство, работающее с памятью. Для простоты возьму память, общающуюся с другими устройствами через параллельный интерфейс (а не через последовательный, например I2C). Такие микросхемы не всегда бывают практичны в виду того, что для работы с ними требуется много пинов, с другой стороны обеспечивается более быстрый и упрощенный обмен информации. Например отечественная 1645РУ1У и ее аналоги.
e6hhlsa3yvrcdjdzke3dc_jaxby.png


Запись выглядит так: ПЛИС даёт 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


И видим картинку:
ioakocq8btz-hdlk_-m7xzeshse.jpeg
Записанная информация успешно прочиталась.
Увидеть содержимое памяти можно добавив в testbench следующий код:

for key, value in memory.items():
    print('adr:{}'.format(key), 'data:{}'.format(value))


В консоли появиться следующее:
jssbly6h3kodhjqrw7ih9mtzamg.jpeg
После этого можно провести несколько автоматизированных тестов с увеличенным количеством записываемых/читаемых ячеек. Для этого в 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_*».
pvszzr_l6vdb6hnfn1pwkelu5vo.jpeg
Тесты выполнены успешно. Нарочно сделаю ошибку в описании устройства:

@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


Запускаю тесты:
q7f34bqhdrvwapsxawc_sdquxdu.jpeg
Как и предполагалось, в обоих тестах считалась начальная информация (нули), то есть новая информация не записалась.
Использование python вместе с myHDL позволяет автоматизировать тестирование разработанных прошивок для ПЛИС и создавать практически любое тестовое окружение используя богатые возможности языка программирования python.
В статье рассмотрено:

  • создание модуля, работающего с памятью;
  • создание модели памяти;
  • создание тестового сценария;
  • автоматизация тестирования с фреймворком pytest.

© Habrahabr.ru