В этой статье я хотел поделиться опытом тестирования своего контроллера динамической памяти на ПЛИС.

В первой части я рассказываю про отладочную плату, которую использовал, и с какими ограничениями при этом столкнулся. Показываю подход к построению схемы тестирования и принципы контроля и обнаружения ошибок. Привожу интересные на мой взгляд фрагменты кода на VHDL с пояснениями к ним. Вторую часть статьи я посвятил оценке реальной производительности контроллера при работе с памятью. В ней я привел результаты измерений быстродействия для потоковой записи и чтения при различных настройках и режимах работы контроллера.

И в самом конце для большей наглядности – примеры взаимодействия контроллера с памятью, снятые в отладчике во время работы. А также описание механизма адресации памяти.

1. Кратко про контроллер SDRAM

Разработанный мною контроллер SDRAM – это аппаратно-независимый IP-блок. Он предназначен для имплементирования внутрь ПЛИС. Код контроллера целиком написан на языке VHDL без использования каких-либо посторонних зависимостей. Тем самым я постарался обеспечить возможность использования данного IP-блока в ПЛИС любых производителей.

Структурная схема контроллера
Структурная схема контроллера

Структурно контроллер состоит из ядра (CORE), логики (LOGIC), арбитра потоков (ARBITER), набора буферов FIFO и блока чтения (READ). Более подробно про контроллер можно прочитать здесь: fpga-lab.ru/html/ip/ip_sdram.html.

Код контроллера я отлаживал в «Modelsim». Некоторые из перечисленных выше модулей сами по себе были законченными и протестированными IP-блоками (FIFO и Арбитр). На их моделирование можно было не тратить время. Для других модулей писались тестбенчи. Затем запускалось их моделирование. В конце – общий тестбенч для всего контроллера целиком, плюс отдельный файл упрощенной модели памяти.

Моделирование – это конечно же хорошо, но только одного его будет недостаточно. Полноценно работоспособность контроллера  необходимо подтверждать на «реальном железе». Нужно подключить его к микросхеме памяти.

2. Тестовый стенд

В качестве такого «реального железа» для проверки контроллера я использовал отладочную плату «DE10-Lite» производства «Terasic».

Отладочная плата (изображение с сайта "Terasic")
Отладочная плата (изображение с сайта "Terasic")

Центральный элемент  платы – это микросхема ПЛИС «MAX10» (10M50DAF484C7G). Она содержит около 50 000 логических элементов (LUT) и 1600 Kbit блочной памяти (BRAM). Внутрь этой ПЛИС загружается прошивка, содержащая контроллер памяти, блоки для его тестирования и модули вывода результатов.

На отладочной плате установлена микросхема динамической памяти «ISSI IS42S16320F-7TL». Ёмкость памяти 64 мегабайта, ширина шина данных – 2 байта. Управлением этой памятью как раз и занимается тестируемый контроллер.

К сожалению, на данной отладочной плате отсутствует Ethernet. Поэтому вывод результатов тестирования производился на монитор, подключенный по VGA.

3. Тестовая прошивка

Теперь подробно про прошивку ПЛИС, собранную для тестирования контроллера. Как я указал выше, на отладочной плате установлена ПЛИС «MAX10» производства «Intel» (а теперь это снова «Altera»). Поэтому для написания кода и сборки проекта использовалась среда «Quartus Prime», версии 20.1.

Структура прошивки, и ее взаимодействие с компонентами на отладочной плате приведена ниже:

Структура тестовой прошивки
Структура тестовой прошивки

Работа прошивки полностью автоматизирована. Если установлены соответствующие переключатели на плате, то блоки-генераторы «Gen1» и «Gen2» непрерывно и независимо друг от друга формируют тестовые последовательности (потоки). В любой момент можно отключить каждый из генераторов, а затем включить заново. Сами тестовые последовательности состоят из пакетов данных для записи в память и запросов чтения. Они подаются на проверяемый контроллер «SDRAM CTRL». Там они предварительно буферизируются, затем обрабатываются  и в результате контроллер формирует транзакции записи или чтения на шине памяти.

Кроме того, пакеты от генераторов тестовых потоков поступают еще и на блоки контроля «WR Check1» и «WR Check2». Эти блоки проверяют правильность сф��рмированных данных. Точно такие же блоки контроля я использую и для проверки прочитанных из памяти данных. Это блоки «RD Check1» и «RD Check2» на схеме. Они непрерывно отслеживают пакеты с выхода контроллера.

Информация о работе контроллера передается в текстовом виде на монитор по интерфейсу VGA. За формирования кадров отвечает видеоподсистема (VIDEO).

Все модули кроме видеоподсистемы работают на частоте шины памяти, которая задается внутренним PLL. Для видеоподсистемы выбрана фиксированная частота – 50 МГц.

3.1  Генерация тестовых последовательностей

Для формирования тестовых последовательностей (потоков) я создал отдельный модуль генерации (файл “packets4smc_gen.vhd”). Он подключается к контроллеру в необходимом мне количестве экземпляров (задано константой GEN_NUM). В данном случае это количество равно двум. Каждый генератор подключен к своему отдельному буферу FIFO.

pack_gen : for i in 0 to GEN_NUM-1 generate

	pack_gen_inst : entity work.packets4smc_gen
	Generic map (
		BASE_ADDR		=> ARR_BASE_ADDR(i),
		DEPTH			=> DEPTH,	
    	WR_DATA_PWR		=> WR_DATA_PWR
		)
	Port map (
		. . . 
	);

end generate;

Два генератора позволяют полноценно проверить взаимодействие всей цепочки модулей контроллера: FIFO–Арбитр–Логика. И при этом задействуют относительно небольшое количество блоков BRAM внутри ПЛИС. Блочную память приходится экономить, так как в MAX10 ее не так уж и много. Кроме того часть этой память в дальнейшем задействуется для отладчика. Да и видеоподсистема кое-что занимает.

Каждый модуль генерации формирует тестовые потоки в отдельном диапазоне адресов памяти. За это отвечают настроечные параметры BASE_ADDR и DEPTH. Адресное пространство равномерно поделено между двумя генераторами:

–     первый генератор (Gen1)  – 0x0000000…0x0FFFFFF;

–     второй генератор (Gen2) – 0x1000000…0x1FFFFFF.

За логику работы алгоритма генерации отвечает конечный автомат. Он начинает свою работу из состояния ожидания. Имеет циклы чтения и записи, а также состояние паузы.

Упрощенный граф конечного автомата
Упрощенный граф конечного автомата

В состоянии ожидания всегда проверяется готовность внутреннего FIFO контроллера памяти к приему данных (сигнал “smc_rdy”). И только при наличии свободного места в FIFO, автомат генерации пакетов может перейти к циклу записи или чтения. Включение самих циклов (сигналами “wr_ena” и “rd_ena”) осуществляется переключателями на отладочной плате.

---- Ожидание готовности FIFO и внешних разрешений
when st_idle =>
	if (smc_rdy) then
		if (wr_ena) then
			state <= st_wr_init;
		elsif (rd_ena) then
			state <= st_rd_bytes;
		end if;
	end if;

Цикл формирования пакетов для записи в память состоит из последовательности нескольких действий:

---- WRITE to SDRAM ----
when st_wr_init =>
	state <= st_wr_bytes;

when st_wr_bytes =>
	state <= st_wr_check;
 
when st_wr_check =>
	state <= st_wr_create;

when st_wr_create =>
	state <= st_wr_send;
	
when st_wr_send =>
	if (ok_cnt_word) then
		state <= st_wr_end;
	end if;
	
when st_wr_end =>
	if (ok_addr) then
		state <= st_wr_fin;
	elsif (smc_rdy) then
		state <= st_wr_addr;
	end if;
	
when st_wr_addr =>
	state <= st_wr_bytes;
	
when st_wr_fin =>
	if (not rd_ena) then
		state <= st_pause;
	elsif (smc_rdy) then
		state <= st_rd_bytes;
	end if;

Цикл записи начинается с состояния st_wr_init, в котором формируется случайное начальное значение первого байта данных. На его основе затем будет вычисляться последовательность байтов для всего цикла записи.

Далее определяется размер пакета в байтах (в состоянии st_wr_bytes). В общем случае он будет случайным. Но при этом ни один байт пакета не должен выходить за заданный диапазон адресов. Это проверяется в состоянии st_wr_check. В случае выхода за диапазон размер принудительно уменьшается. После вычисляется величина пакета в словах выходной шины данных генератора. Я реализовал генератор таким образом, чтобы выходные пакеты могли быть переданы словами по 1, 2, 4, 8, 16 либо 32 байта за такт. В данном случае я выбрал самый «нагруженный» вариант, и выдаю по 32 байта данных за такт. При этом размер последнего слова в байтах может быть любым (зависит от длины пакета).

После того как определены параметры пакета, в состоянии st_wr_send выполняется его передача. Вместе с байтами данных указывается и адрес. Если были записаны все адреса памяти, то цикл записи завершается (st_wr_fin). Далее следует либо цикл чтения, либо пауза. Иначе в состоянии st_wr_addr определяется адрес для следующего пакета и последовательность записи повторяется.

Далее – цикл чтения. Код для его состояний выглядит следующим образом:

---- READ from SDRAM ----
when st_rd_bytes =>
	state <= st_rd_check;
	
when st_rd_check =>
	state <= st_rd_send;
	
when st_rd_send =>
	state <= st_rd_end;

when st_rd_end =>
	if (ok_addr) then
		state <= st_rd_fin;
	elsif (smc_rdy) then
		state <= st_rd_addr;
	end if;
	
when st_rd_addr =>
	state <= st_rd_bytes;
	
when st_rd_fin =>
	state <= st_pause;

Цикл чтения во многом схож с записью. Таким же образом определяется размер пакета в байтах. Только теперь вместо потока данных на контроллер памяти передается однотактная команда чтения. Она содержит адрес и количество байт, которые необходимо прочитать, начиная с этого адреса. После цикла чтения всегда производится переход в состояние паузы. Это просто техническое состояние, введенное мною для пошаговой отладки.

И еще несколько слов про количество байт записи и чтения. Хотя оно и  формируется псевдослучайным образом (на сдвиговом регистре с обратной связью), но при этом всегда будет четным. Это связано с разрядностью шины памяти микросхемы SDRAM, установленной на отладочной плате. Она составляет 16 бит. При тестировании необходимо чтобы во время записи все данные в памяти всегда перезаписывались. Четное количество байт гарантирует это.

if (ce_bytes_num) then
	
	-- определение количества байт
	if (use_fixed_bytes_num) then
		bytes_num <= bytes_num_fixed;
	else
		bytes_num <= resize(unsigned(lfsr(10 downto 0)) & 1ux"1", bytes_num'length);
	end if;
	ok_addr <= '0';
	
elsif (upd_bytes_num) then

	-- проверка на выход пакета за разрешенный диапазон адресов
	if (bytes_num/MEM_BUS_WIDTH >= DEPTH-1 - offset_addr) then
		bytes_num <= to_unsigned((DEPTH-1 - offset_addr)*MEM_BUS_WIDTH+1, bytes_num'length);
		ok_addr <= '1';
	end if;
	
end if;

* размер пакета в байтах считается как «bytes_num+1»

Также я добавил возможность задать несколько фиксированных значений для количества байт (2, 4, 8, 16 … 4096), используя переключатели и кнопки на плате.

3.2  Контроль ошибок

Поиск и обнаружение ошибок – это и есть основная задача тестовой прошивки. Ошибкой я считаю такое событие, когда какой-либо байт данных, прочитанный из микросхемы SDRAM, отличается от записанного в нее. Если возникает хотя бы одна такая ситуация – значит контроллер работает неверно. И фактически, он становится бесполезным. В хорошем контроллере ошибок быть не должно!

Отслеживание ошибок я осуществлял следующим образом. Так как модули генерации («Gen1», «Gen2») создают непрерывные потоки байтов при записи в память, следовательно такие же непрерывные потоки должны быть и при чтении из нее. Вот именно они и проверяются модулями контроля «RD Check1» и «RD Check2».

---- Контроль правильности приема данных
process(clk, n_arst)
begin
	. . .

	-- проверяем в момент прихода данных и только для своего диапазона адресов
	if (ce_data and addr_valid) then
		
		--сравнение с последним значением (прошлый такт или пр��шлый пакет)
		if (ignore) then
			test_ok_vect(0) <= '1';
		elsif (unsigned(data(0)) = unsigned(test_last_val)+1ux"1") then
			test_ok_vect(0) <= '1';
		else
			test_ok_vect(0) <= '0';
		end if;
		
		--сравнение остальных значений
		if (data'length > 1) then
		
			for i in 1 to data'length-1 loop
				if (ce_lb = '1' and i-1 >= lb_ptr) then
					test_ok_vect(i) <= '1';
				elsif (unsigned(data(i)) = unsigned(data(i-1))+1ux"1") then
					test_ok_vect(i) <= '1';
				else
					test_ok_vect(i) <= '0';
				end if;
			end loop;
			
		end if;

	end if;

В правильном потоке данных каждый следующий байт должен быть больше предыдущего строго на единицу. То есть верная последовательность будет например: «…253, 254, 255, 0, 1, 2…» и так далее по кругу для всего заданного диапазона адресов. Это и проверяет приведенный выше код.

Есть только одно исключение – это байт для начального адреса диапазона. Ведь каждый раз он генерируется случайно. Поэтому он не сравнивается с предыдущим байтом (который расположен по последнему адресу). Для первого байта формируется сигнал «ignore».

addr_valid	<= '1' when (addr >= BASE_ADDR and addr < BASE_ADDR+DEPTH) else '0';
addr_first	<= '1' when (addr = BASE_ADDR) else '0';

process(clk, n_arst)
begin
. . .
	ignore <= rts and addr_first;

В двух вышеприведенных  фрагментах кода присутствуют такие сигналы как «rts», «ce_lb», «lb_ptr». Это сигналы выходной шины контроллера, по которой он выдает прочитанные из памяти данные. Тип для этой шины описан в отдельном пакете, и я использую его не только в контроллере памяти, но в прочих своих IP-модулях.

---- record для выходной шины (out) данных FIFO
type br_bus_o is record
	rts			: std_logic;				-- ready to start (за такт перед данными)
	data		: array_vector8(31 downto 0);	-- выходные данные (массив байт)	
  	ce_data		: std_logic;				-- 1 - признак данных
	ce_lb		: std_logic;				-- 1 - признак последнего байта
	lb_ptr		: unsigned(4 downto 0);		-- указатель для последнего байта в пакете
	pack_bytes	: unsigned(15 downto 0);	-- количество байт данных в пакете 
	extra		: array_vector8(23 downto 0);	-- дополнительная информация к пакету 
end record;

Еще один важный момент. А что если я пропускаю ошибки при контроле? Вдруг модуль контроля сам работает неверно? Чтобы исключить такую ситуацию, я добавил возможность внесения ошибок в исходный генерируемый поток по нажатию кнопки на плате. Таким образом, внося ошибки при записи, и видя при этом ошибки чтения, я могу убедиться в корректной работе логики контроля.

3.3  Видеоподсистема

Кратко про видеоподсистему. На отладочной плате отсутствуют интерфейсы для обмена данными с компьютером. Нет ни Ethernet, ни даже UART. Поэтому для вывода информации я использовал VGA-выход. Пришлось формировать и передавать видео поток на монитор.

Генерация видео состоит из нескольких этапов:

1. Создание горизонтальной и вертикальной развертки – 800x600, 30 Гц. Это разрешение было выбрано из-за удобства. Для его получения нужна тактовая частота 50 МГц, а генератор такой частоты как раз установлен на отладочной плате.

2. Формирование строк.  Изображения символов хранятся в отдельном блоке, организованном как ROM. Размер поля каждого символа – 8 бит по ширине и 12 бит по высоте. Кодовая таблица – ASCII Win-1251. Исходя из кодов символов строки, производится извлечение изображений из ROM. Затем они накладываются на основной кадр. В данном случае, основное изображение кадра – это просто светло-синий фон.

3. В завершении, каждый пиксель (RGB 8:8:8) видеокадра выдается на ЦАП, установленный на плате. Следует заметить, что ЦАП очень простой, выполнен на резисторах. Реально он использует только 4 бита для каждой цветовой компоненты. Но для вывода текста на монитор этого более чем достаточно.

Фото монитора, на который плата передает видеопоток
Фото монитора, на который плата передает видеопоток

На дисплей выводится отдельно для каждого потока следующая информация:

  • скорости записи и чтения (мегабайты в секунду);

  • количество выполненных циклов записи и чтения. Один цикл – это проход по всем адресам диапазона;

  • количество обнаруженных ошибок.

Также показывается суммарное быстродействие контроллера. Кроме того я добавил информацию об управлении платой. Без нее я постоянно путался и забывал какие переключатели за что отвечают 😀

4. Быстродействие контроллера

После того, как я реализовал логику отслеживания ошибок, стало интересно оценить производительность контроллера при работе с SDRAM. В прошивке я добавил набор простых 32-х битных счетчиков. Они считают количество байт данных, переданных или принятых за одну секунду.

Для начала – быстродействие в зависимости от тактовой частоты шины памяти. Для этого контроллер был сконфигурирован со следующими настройками:

  • ширина шины данных (DQ) – 2 байта;

  • CAS Latency = 3;

  • REF Latency = 10;

  • REF Period = 64 мс;

  • количество потоков – 2.

    Параметры генерации потоков были такими:

  • запись диапазона адресов, затем чтение этого диапазона;

  • размер пакетов записи случайный, от 2 до 4096 байт;

  • количество читаемых байтов за один запрос случайное, от 2 до 4096 байт.

Нужные тактовые частоты получал на внутреннем PLL ПЛИС. Правда, для этого каждый раз приходилось пересобирать прошивку. Значения были следующими:

  • 67 МГц (15 нс),

  • 100 МГц (10 нс),

  • 125 МГц (8 нс),

  • 143 МГц (7 нс).

Микросхема памяти (IS42S16320F-7TL), установленная на плате, не может работать на частоте выше чем 143 МГц. Поэтому быстродействие для 167 МГц (6 нс) я оценивал моделированием в Modelsim.

Как и ожидалось, зависимость скорости работы от частоты практически линейная. Более того, как видно из графика, уже при тактовой частоте 143 МГц быстродействие составляет 278 Мбайт/с. Это примерно 2,2 Гбит/с. При такой скорости работы становится возможным организовать полноценную буферизацию потокового видео FULL HD 30 fps. А с учетом того, что на одну шину память можно подключить сразу две (и даже более) микросхемы, то и 60 fps легко достижимы. И это на таком простом и дешевом типе памяти, которому уже 30 лет!

Далее – влияние размера пакетов на скорости записи и чтения. Я зафиксировал тактовую частоту равной 125 МГц. Изменял только количество байтов данных в пакетах (2, 4, 8, 16 … 4096 байт). Здесь не пришлось пересобирать прошивку каждый раз заново. Я добавил возможность менять количество байт по нажатию кнопки на плате.

Параметры контроллера были следующими:

  • ширина шины данных (DQ) – 2 байта;

  • CAS Latency = 2;

  • REF Latency = 10;

  • REF Period = 64 мс;

  • количество потоков – 1.

Сначала для записи в память:

Затем чтение из памяти:

Было очевидно, что чем меньше размер пакета, тем большее влияние будут оказывать накладные расходы при работе с памятью. Такие как выставление адресов на шину, команды закрытия строк и цикл реконфигурации.  Но с конкретными цифрами становится возможно наглядно оценить их влияние. Стало ясно, что начиная примерно с 256 байт, увеличение размера пакета не ��собо сказывается на быстродействии.

5. Пример работы отладчика

В данном пункте я решил привести несколько временных диаграмм работы контроллера при операциях записи и чтения. Они наглядно показывают взаимодействие контроллера с микросхемой SDRAM. Все диаграммы сняты с помощью отладчика «SignalTap» во время работы ПЛИС.

Запись пакета:

Диаграмма с записываемым пакетом (скриншот из «SignalTap»)
Диаграмма с записываемым пакетом (скриншот из «SignalTap»)

В верхней части диаграммы шина «MUX_DATA. Это выход внутреннего мультиплексора контроллера. На ней – записываемый пакет, размером 32 байта. Он должен быть записан в память, начиная с адреса 0x0AB6870.

В нижней части диаграммы – сигналы, идущие на шину памяти. Это не непосредственно сама шина, а сигналы с выхода контроллера. Они формируют последовательность команд. Сначала идет «ACTIVE» (nRAS=0, nCAS=1, nWE=1). Затем через два такта на третий – команда «WRITE» (nRAS=1, nCAS=0, nWE=0). Вместе с командой «WRITE» передается первое слово данных, а далее и все остальные. Запись завершается командой «BURST STOP» (nRAS=1, nCAS=1, nWE=0).

Также на диаграмме можно подробно рассмотреть принцип адресации памяти. В случае микросхемы SDRAM (32Mx16) диапазон адресов  – 0x0000000…0x1FFFFFF. Это 25 бит. Ниже – описание адресного пространства, взятое из документации на микросхему:

Таблица с принципом адресации микросхемы памяти (из д��кументации на микросхему)
Таблица с принципом адресации микросхемы памяти (из документации на микросхему)

Для пакета записи, приведенного на диаграмме, адрес начинается со значения 0x0AB6870.

  • Биты [9..0] передаются вместе с командой «WRITE». Это адрес столбца (Column Addres), он равен 0x070.

  • Биты [22..10] – вместе с командой «ACTIVE». Это адрес строки (Row Address), он равен 0x0ADA.

  • Два самых старших бита [24..24] указывают на один из четырех банков (Bank Address). В данном примере – ‘01’. Они передаются вместе с обеими командами «ACTIVE» и «WRITE».

Для операции чтения последовательность команд будет похожей. «ACTIVE» (nRAS=0, nCAS=1, nWE=1), затем «READ» (nRAS=1, nCAS=0, nWE=1), в конце – «BURST STOP» (nRAS=1, nCAS=1, nWE=0). Принцип адресации аналогичен записи.

Диаграмма с прочитанным пакетом (скриншот из «SignalTap»)
Диаграмма с прочитанным пакетом (скриншот из «SignalTap»)

В нижней части приведенной диаграммы можно увидеть последовательность сигналов «rts», «ce_lb», «lb_ptr». Это сигналы выходной шины контроллера для прочитанных из памяти данных. Про них я подробно писал в п.3.2. Видно, что данные идут пакетами, с каждым пакетом передается адрес, по которому он был прочитан. Здесь это адрес 0x0CCAF00.

И в конце пример как выглядит непрерывный поток записи пакетов произвольной длины:

Запись в память пакетов случайной длины
Запись в память пакетов случайной длины

Вместо заключения

Реализация законченных IP-ядер для ПЛИС требует достаточно большего объема работ. Кроме непосредственно написания исходных кодов модулей, нужно изучать стандарты и документацию, продумывать структуру и архитектуру. Необходимо составлять тестовые последовательности (тестбенчи) и скрипты для их выполнения. Моделировать работу средствами симуляции, а затем анализировать результат. А далее запускать IP-ядро на реальном железе. Подключать отладчик (а может даже и осциллограф), искать баги и сбои, анализировать результаты измерений. И затем вносить исправления в исходный код. Снова моделировать, запускать, отлаживать… Огромная трудоемкость. В итоге оказывается, что написание исходного кода  – это максимум процентов пять из затраченного на всю работу времени. Может даже и того меньше.

Но в последнее время некоторые из нейро-энтузиастов утверждают, что всю эту обширную инженерную работу сможет выполнить некая "волшебная" нейросеть. Для чего понадобится написать лишь пару-тройку промптов для неё. Они считают, что в обозримом будущем и вовсе исчезнет надобность в разработчиках и программистах. Ну что же, посмотрим, как ИИ будет работать в отладчике и брать в свои "виртуальные руки" щупы от реального осциллографа. А если нейросеть в состоянии только генерировать код, то ее использование теряет всякий смысл. Ведь написание кода – это лишь малая и далеко не самая сложная часть работы инженера.

Хотя, скорей всего я просто совершенно не умею писать правильные промпты…