Испытание абстракций: как чужая процессорная система RISC-V переехала на десятки отладочных плат

10 октября стартует новый сезон Школы синтеза цифровых схем. Её проведение рассредоточено по множеству независимых кластеров, в каждом из которых есть свой преподаватель, свои отладочные платы и САПР, и необходимо обеспечить выполнение всех примеров школы на этом зоопарке различных устройств таким образом, чтобы преподавателю не нужно было думать о проблеме вроде: “Этот код точно собирался у меня в BRAM, почему здесь он синтезируется в миллион регистров, сжирающих все ресурсы платы”.
Чтобы избежать подобных проблем был создан репозиторий basics-graphics-music (BGM), добавляющий примерам несколько слоёв абстракции, которые позволяют избежать подобных проблем.
Упор этого года будет сделан на архитектуру RISC-V. Для этого в репозиторий был добавлен пример многопоточной программы, а также несколько RISC-V ядер, включая ядро НИУ МИЭТ, проектируемое на курсе “Архитектуры процессорных систем”. Это стало отдельным испытанием существующих абстракций на прочность: данное ядро проектировалось независимо от репозитория BGM и его абстракций, поэтому его интеграция стала интересным опытом. Подробнее об этом под катом.
Часть I. Инфраструктура basics-graphics-music
Проблема, которую решает репозиторий: это сделать возможным запустить множество независимых проектов (примеров) на множестве независимых отладочных плат. У каждого примера свой набор входов и выходов, и у каждой платы есть свой набор периферии. Эти наборы могут входить друг в друга, либо пересекаться. Может конечно быть ситуация, когда эти множества не пересекаются, но это скорее вырожденный случай либо совсем голого кристалла, либо примера с узким набором специализированной периферии (вроде ps/2 и vga) которой может не быть на конкретной отладочной плате. Переписывать каждый из проектов под конкретную плату из набора — сизифов труд, ведь любую правку в исходном проекте придется позже разливать на все остальные подпроекты.
Репозиторий BGM решает эту проблему путем выделения трёх уровней иерархии, скреплённых одним контрактом.

Интеграция HDL-кода
На нижнем уровне абстракции находится файл board_specific_top.sv, располагающийся в директории boards/<board_name>. Это модуль верхнего уровня, написанный под конкретную плату. Он знает про эту плату всё: как называются пины, инвертированы ли светодиоды, какой частоты кварц, какова разрядность VGA — четыре бита на канал или пять, сколько доступно семисегментных индикаторов и есть ли они вообще.
Данный файл не зависит от примеров, которые будут на нём запускаться и всё что нужно сделать для добавления поддержки новой платы — это описать новый board_specific_top (в контексте HDL-кода).
Независимость данного файла от запускаемых примеров обеспечивается файлом центрального уровня абстракции: lab_top.sv. Этот файл и является тем контрактом, который приводит порты конкретного проекта и порты конкретной платы к единому интерфейсу. На ПЛИС может не быть конкретных пинов микрофонного входа, но могут быть GPIO, в этом случае в board_specific_top будет инстанциирован экземпляр i2s-модуля, который будет принимать данные с заданных GPIO-пинов и передавать их в виде микрофонного сигнала на внутренний провод mic, который подсоединён к одноимённому порту экземпляра модуля lab_top.
Прототип модуля lab_top:
module lab_top
# (
parameter clk_mhz = 50, w_key = 4, w_sw = 8, w_led = 8, w_digit = 8,
w_gpio = 100, screen_width = 640, screen_height = 480,
w_red = 4, w_green = 4, w_blue = 4,
w_x = $clog2 (screen_width), w_y = $clog2 (screen_height)
)
(
input clk, slow_clk, rst,
input [w_key - 1:0] key,
input [w_sw - 1:0] sw,
output logic [w_led - 1:0] led,
output logic [ 7:0] abcdefgh, // сегменты
output logic [w_digit - 1:0] digit, // разряды индикатора
input [w_x - 1:0] x, // координаты пикселя
input [w_y - 1:0] y,
output logic [w_red - 1:0] red,
output logic [w_green - 1:0] green,
output logic [w_blue - 1:0] blue,
input [ 23:0] mic, // микрофон
output [ 15:0] sound, // звук
input uart_rx,
output uart_tx,
inout [w_gpio - 1:0] gpio
);
Все значения в списке параметров — это значения по умолчанию. При создании экземпляра lab_top они переписываются модулем board_specific_top на те, что соответствуют конкретной отладочной плате.
board_specific_top создаёт экземпляр данного модуля и подключает к нему всю возможную периферию, которую может обеспечить выбранная отладочная плата. Модуль lab_top является параметризованным, поэтому если на плате есть только 8 светодиодов, в lab_top будет именно 8-разрядный выход led.
Более того, светодиодов на плате может не быть вовсе — зато к ней может быть подключён интерфейсный модуль TM1638 с последовательным интерфейсом на трёх проводах. В этом случае внутрь board_specific_top ставится экземпляр контроллера TM1638, в который входят 8 проводов со значениями светодиодов, а в параметры lab_top точно так же прописывается 8. Для самого примера разницы нет: он по-прежнему видит 8-разрядный выход led, не зная, что за ним стоит не восемь ножек ПЛИС, а сдвиговый протокол на трёх.
Важно отметить, что сам модуль lab_top не описан в единственном виде — он описывается для каждого конкретного примера, с одним и тем же заголовком. Именно внутри lab_top и происходит интеграция конкретного проекта под этот общий интерфейс: здесь добавляются адаптеры, подстраивающие порты проекта под порты lab_top.
Адаптеры справляются, когда ресурса просто больше или меньше. Но бывает, что его на плате физически нет. К примеру, лабораторная работа с ядром YRV использует пять управляющих сигналов: замедление тактовой частоты, переключение отображения адрес/данные, внешнее прерывание и два локальных. На Nexys A7 с её кнопками и переключателями это не проблема, в отличие от платы Omdazz с четырьмя кнопками. Решается это каскадом условий, который раскладывает сигналы по тому, что есть и зануляя то, чего нет:
generate
if (w_key >= 7) begin : keys_at_least_7
assign slow_clk_mode = key [w_key - 1];
assign external_interrupt_raw = key [w_key - 3];
// ...
end
else if (w_sw >= 7) begin : switches_at_least_7
// DE10-Lite: две кнопки, зато десять переключателей
assign slow_clk_mode = sw [w_sw - 1];
// ...
end
else if (w_key >= 4) begin : keys_at_least_4
// Omdazz: четыре кнопки, часть функций отключается
assign slow_clk_mode = key [w_key - 1];
assign local_interrupt_0_raw = 1'b0;
// ...
end
else begin : few_keys_and_sw_available
// ...
end
endgenerate
Плюс такого подхода заключается в том, что пример не падает на этапе эластификации с невнятной ошибкой про несуществующий сигнал — он собирается и работает, просто с урезанной функциональностью. Для преподавателя, который раздал студентам три разных типа плат, это разница между «работает у всех» и «работает у половины».
На верхнем уровне абстракции находится сам проект. Он не знает ничего ни про lab_top, ни про board_specific_top, все адаптеры находятся на более нижних уровнях абстракции.
Один BASH-скрипт, чтоб править всеми
Конкретный пример мало привести к формату пинов выбранной платы — проект нужно ещё и собрать. И здесь снова проявляется себя проблема множества плат: они могут использовать САПР разных вендоров и даже требовать разные версии САПР одного и того же вендора. Сборка может запускаться на разных системах: Linux/Mac/Windows.
Чтобы справиться с этой проблемой, в репозитории BGM представлена коллекция скриптов, задающих единый интерфейс сборки, абстрагирующийся от конкретного САПР или ОС. Сама логика сборки находится в папке scripts/steps в виде файлов *.source_bash, а в директории каждого примера лежит одинаковый пронумерованный набор скриптов-редиректов: 01_clean.bash, 02_simulate_rtl.bash, 03_synthesize_for_fpga.bash, 06_choose_another_fpga_board.bash и так далее.
Эти скрипты-редиректы не содержат никакой логики — все они являются побайтово одинаковыми копиями одного шаблона scripts/steps/local_redirect.template_bash. Отличает их друг от друга только собственное имя: из него подстановкой получается имя подключаемого файла, после чего скрипт поднимается вверх по дереву каталогов в поисках папки scripts/steps и подключает оттуда нужный .source_bash:
script=$(basename "$0")
source_script=${script/\.bash/.source_bash}
dir_source_script=../scripts/steps/$source_script
for i in {1..5}
do
[ -f $dir_source_script ] && break
dir_source_script=../$dir_source_script
done
# ... проверка на то, что файл найден ...
dir_source_script=$(readlink -f $dir_source_script)
. "$dir_source_script"
Польза от такой схемы двойная. Со стороны пользователя в каждом примере под рукой лежит один и тот же набор действий, не зависящий ни от платы, ни от САПР: почистить, промоделировать, синтезировать, прошить, сменить плату. Со стороны разработчика репозитория вся реальная логика живёт в одном месте, и её правка автоматически распространяется на все примеры сразу.
Часть II. Интеграция в репозиторий нового ядра
Испытанием выбранной архитектуры стала интеграция в репозиторий сторонней процессорной системы архитектуры RISC-V, которая даётся в Московском институте электронной техники на дисциплине “Архитектуры процессорных систем”. Данная процессорная система была спроектирована независимо от репозитория BGM, и нашей целью стала интеграция этой системы без каких-либо правок исходного кода процессорной системы, либо (если такой подход невозможен) с минимальными её правками.
Прежде чем рассказать о том, как велась интеграция, давайте коротко рассмотрим систему, о которой идет речь.
Саму процессорную систему можно представить в виде межсоединения:
процессорного ядра;
памяти инструкций;
памяти данных;
контроллеров периферии;
программатора.

При этом, поскольку архитектура RISC-V использует совмещённое адресное пространство, контроллеры периферии процессор считает точно такой же памятью данных, просто с другими адресами.
Ядро представляет собой однотактный процессор rv32i_zicsr. Поддерживает инструкции стандартного набора rv32i, а так же инструкции взаимодействия с регистрами контроля и статуса (CSR) и привилегированную инструкцию mret для возврата из обработчиков прерываний и исключений.

Из периферии процессорная система имеет:
контроллер 16 переключателей;
контроллер 16 светодиодов;
контроллер клавиатуры PS/2;
контроллер 8 семисегментных индикаторов;
контроллер приёмника UART;
контроллер передатчика UART;
символьный VGA-контроллер разрешением 80x30 символов;
контроллер системного таймера.
Адресное пространство периферии выглядит следующим образом:

Система проектировалась под работу на конкретной отладочной плате Nexys A7100T, и, как следствие, САПР Vivado. Система использует чисто гарвардскую архитектуру: память инструкций полностью отделена от памяти данных, и процессор работает с ними по раздельным интерфейсам.
Поскольку процессор однотактный, используется память инструкций с асинхронным чтением, чтобы чтение и исполнение инструкций выполнялось за один такт. Из-за этого, большая часть критического пути лежит в памяти инструкций на мультиплексировании всех имеющихся там регистров — и чем больше размер этой памяти, тем больше критический путь. Поскольку запуск некоторых программ (например coremark) требует памяти инструкций объёмом в 32 КБ, получающийся критический путь не позволял свести схему к 100 МГц тактовому сигналу платы — в связи с этим было принято решение жёстко ограничить тактирование процессорной системы до 10 МГц — в этом случае даже самый неоптимальный дизайн студента и самый большой размер памяти инструкций смогут уложиться в заданную частоту.
Прототип модуля процессорной системы:
module processor_system(
input logic clk10mhz_i, // Сигнал синхронизации системы
input logic clk25175khz_i, // Сигнал пиксельной развертки VGA
input logic rst_i,
// Входы и выходы периферии
input logic [15:0] sw_i, // Переключатели
output logic [15:0] led_o, // Светодиоды
input logic kclk_i, // Тактирующий сигнал клавиатуры
input logic kdata_i, // Сигнал данных клавиатуры
output logic [ 6:0] hex_led_o, // Вывод семисегментных индикаторов
output logic [ 7:0] hex_sel_o, // Селектор семисегментных индикаторов
input logic rx_i, // Линия приема по UART
output logic tx_o, // Линия передачи по UART
output logic [3:0] vga_r_o, // Красный канал vga
output logic [3:0] vga_g_o, // Зеленый канал vga
output logic [3:0] vga_b_o, // Синий канал vga
output logic vga_hs_o, // Линия горизонтальной синхронизации vga
output logic vga_vs_o // Линия вертикальной синхронизации vga
);
Что в итоге мы имеем в сравнении с целевым интерфейсом модуля lab_top:
кнопки, микрофон и динамик система не использует — их мы не подключаем;
линии UART — полностью совпадают, тут сложно в чём-либо разойтись;
сигналы переключателей, светодиодов, выбора семисегментных индикаторов и vga-каналов —
lab_topожидает их в виде параметризованной разрядности, в целевой системе их разрядность зафиксирована под конкретную плату;в
lab_topотсутствуют сигналы клавиатуры PS/2;в
lab_topиспользуется внешний контроллер вывода изображения (не обязательно VGA), поэтому вместо выходовvga_hs/vga_vsиспользуются входыx/yкоординат выводимого на текущий момент пикселя;целевая система рассчитывает получить два тактовых синхроимпульса фиксированной частоты: 10 и 25.175МГц, в
lab_topтактовая частота параметризована и не может гарантировать что придёт именно такая.
Проблема с несовпадением разрядностей решается довольно легко. Если разрядность порта в lab_top больше, чем в нашей системе, — подключаем все биты системы к младшей части порта, а старшие зануляем. Если меньше — берём только младшую часть сигнала системы, а старшие биты никуда не идут: на плате для них всё равно нет ни светодиодов, ни переключателей. Полярность сигналов для семисегментных индикаторов отличается, поэтому подключаемые биты сигнала hex_sel инвертируются. Для сигналов rgb-каналов подход несколько другой — лучше нормализовать значение под другую разрядность, нежели просто обрезать его. Т.е. если разрядность канала r в lab_top выше чем в АПС, мы сдвигаем значение влево. Если ниже — сдвигаем его вправо. Получается следующая форма адаптации совпадающих сигналов:
/*
=====================================================
LED adapter
=====================================================
*/
logic [15:0] aps_led;
generate
if(w_led > 16) begin
assign led[w_led-1:16] = '0;
assign led[15:0] = aps_led;
end
else begin
assign led = aps_led[0+:w_led];
end
endgenerate
//===================================================
/*
=====================================================
SW adapter
=====================================================
*/
logic [15:0] aps_sw;
generate
if(w_sw > 16) begin
assign aps_sw=sw[15:0];
end
else begin
assign aps_sw[0+:w_sw] = sw;
end
endgenerate
//===================================================
/*
=====================================================
7seg adapter
=====================================================
*/
logic [6:0] hex_led;
logic [7:0] hex_sel;
assign abcdefgh = ~{hex_led, 1'b0};
generate
if(w_digit > 8) begin
assign digit[w_digit-1:8] = '0;
assign digit[7:0] = ~hex_sel;
end else begin
assign digit = ~hex_sel[0+:w_digit];
end
endgenerate
//===================================================
/*
=====================================================
VGA adapter
=====================================================
*/
logic [3:0] vga_r;
logic [3:0] vga_g;
logic [3:0] vga_b;
logic vga_hs;
logic vga_vs;
generate
if(w_red > 4) begin
assign red = vga_r << (w_red - 4);
end else begin
assign red = vga_r >> (4 - w_red);
end
if(w_green > 4) begin
assign green = vga_g << (w_green - 4);
end else begin
assign green = vga_g >> (4 - w_green);
end
if(w_blue > 4) begin
assign blue = vga_b << (w_blue - 4);
end else begin
assign blue = vga_b >> (4 - w_blue);
end
endgenerate
//===================================================
Далее — подстройка частоты тактовых синхроимпульсов. Поскольку проект может запускаться на разных платах от разных вендоров, нельзя просто подставить какой-то экземпляр PLL, куда проще сделать параметризованный делитель частоты. Однако тут есть нюанс: делитель частоты заработает только если исходная частота кратна целевой. И даже более того, если вы хотите ровную скважность сигнала, исходная частота должна быть в четное число раз выше целевой. Для наших целей не обязательно, чтобы скважность сигнала была ровной, поэтому был реализован следующий делитель частоты:
module clock_divider #(
parameter int unsigned FAST_CLK_FREQ = 50_000_000,
parameter int unsigned SLOW_CLK_FREQ = 10_000_000
)(
input logic clk_i,
input logic aresetn_i,
output logic clk_o,
output logic rst_o
);
localparam int unsigned DIV = FAST_CLK_FREQ / SLOW_CLK_FREQ;
/*===============
Делитель клока
===============
*/
/*
Проверка параметров:
- FAST_CLK_FREQ должна быть кратна SLOW_CLK_FREQ
- FAST_CLK_FREQ должна быть выше SLOW_CLK_FREQ
*/
generate
if (FAST_CLK_FREQ % SLOW_CLK_FREQ != 0)
fast_clk_is_not_multiple_of_slow_clk err1();
if (FAST_CLK_FREQ <= SLOW_CLK_FREQ)
fast_clk_must_be_greater_than_slow_clk err2();
endgenerate
localparam int unsigned HIGH_CYCLES = (DIV + 1) / 2; // ceil(DIV/2)
localparam int unsigned LOW_CYCLES = DIV / 2; // floor(DIV/2)
localparam int unsigned DIV_WIDTH = $clog2(DIV);
logic [DIV_WIDTH-1:0] cnt;
always_ff @(posedge clk_i or negedge aresetn_i) begin
if (!aresetn_i) begin
cnt <= '0;
clk_o <= 1'b1;
end
else begin
if (clk_o) begin
if (cnt == HIGH_CYCLES-1) begin
cnt <= '0;
clk_o <= 1'b0;
end
else
cnt <= cnt + 1'b1;
end
else begin
if (cnt == LOW_CYCLES-1) begin
cnt <= '0;
clk_o <= 1'b1;
end
else
cnt <= cnt + 1'b1;
end
end
end
/*===================
Буферизация сброса
===================
*/
// Поскольку в системе используется синхронный сброс,
// он не будет виден, ведь пока удерживается сброс,
// генерируемый клок находится в нуле
// Получается что входной сброс никогда не будет
// зарегистрирован синхронной логикой.
// Решением является буферизовать сброс, чтобы
// он снялся уже после генерации первого такта.
// Кроме того, входной сброс необходимо синхронизировать.
logic [1:0] arstn_sync;
logic rstn_synced;
assign rstn_synced = arstn_sync[1];
always_ff @(posedge clk_i or negedge aresetn_i) begin
if(!aresetn_i) begin
arstn_sync <= 2'b0;
end
else begin
arstn_sync <= {arstn_sync[0], 1'b1};
end
end
logic [3:0] sys_rstn_buf;
assign rst_o = !sys_rstn_buf[3];
always_ff @(posedge clk_o or negedge rstn_synced) begin
if(!rstn_synced) begin
sys_rstn_buf <= 2'b0;
end
else begin
sys_rstn_buf <= {sys_rstn_buf[2:0], 1'b1};
end
end
endmodule
В нём мы проверяем исходную и целевую частоту, и если первая не кратна второй или не превышает её, то мы прерываем синтез посредством инстанциирования несуществующего блока с помощью generate.
Но не всю адаптацию получилось произвести в модуле lab_top. СнК АПС и lab_top принципиально по-разному представляют себе видеовыход. АПС исходит из предположения что мы работаем только с vga: система сама генерирует сигналы синхронизации развёртки по горизонтали и вертикали, а также значения пикселей по каналам rgb. Модуль lab_top в свою очередь исходит из того, что картинка может выводиться на разные дисплеи: spi-дисплей, двумерную матрицу светодиодов, vga-монитор и т.п. И там и там используются выходы rgb, но lab_top использует входы [x;y], для которых в ответ устройство генерирует rgb-значение пикселя по полученным координатам. Данное расхождение не получится разрешить каким-то внешним адаптером, поэтому при интеграции системы пришлось протащить данные входы до vga-контроллера (к счастью там уже была подходящая логика, которая могла бы их воспринять).
Помимо прочего, не весь RTL-код может быть спокойно перенесён между разными САПР. В частности, описание блочной памяти. САПР распознаёт определённые паттерны RTL-кода, которые заменяет на BRAM, и эти паттерны могут отличаться — например потому, что сами блоки имеют некоторые отличия в поведении, либо же просто потому, что САПР заточен под немного другой паттерн.
Ценой данного расхождения будет то, что память в итоге будет синтезирована за счёт обычных регистров и LUT. Для небольших памятей это приемлемо, но не для памяти, скажем, в 4 КБ — а именно столько занимает каждая из трёх памятей символьного VGA-контроллера: карта символов, карта цветов и знакогенератор.
Поэтому для блочной памяти пришлось добавить условные блоки:
always_ff @(posedge clka_i) begin
for (int i = 0; i < NUM_COLS; ++i) begin
if (wea_i[i]) mem[addra_i][i*COL_WIDTH+:COL_WIDTH] <= dina_i[i*COL_WIDTH+:COL_WIDTH];
`ifdef GOWIN_SYNTHESIS
else douta_o[i*COL_WIDTH+:COL_WIDTH] <= mem[addra_i][i*COL_WIDTH+:COL_WIDTH];
`endif
end
end
`ifndef GOWIN_SYNTHESIS
always_ff @(posedge clka_i) begin
douta_o <= mem[addra_i];
end
`endif
Важно уточнить, что эти две ветки — не одно и то же описание, записанное по-разному. Они отличаются поведением при одновременном чтении и записи:
в варианте без
GOWIN_SYNTHESISчтение стоит в отдельномalways_ffи выполняется безусловно каждый такт, поэтому на выходе оказывается содержимое памяти до записи — это режимREAD_FIRST;в варианте для Gowin чтение уехало в
elseот условия записи, причём побайтно: в такте записи соответствующий байтdouta_oвообще не обновляется и сохраняет предыдущее значение — это режимNO_CHANGE.
То есть выбор паттерна под вендора не бесплатен: вместе с паттерном меняется и режим разрешения коллизии чтения-записи. Такую замену можно делать только тогда, когда точно знаешь, что дизайн на эту разницу не завязан — в нашем случае значение douta_o в такте записи никем не используется. Показательно, что второй порт этой памяти (doutb_o <= mem[addrb_i]) никакими ifdef не обложен и одинаков для всех вендоров: он работает только на чтение, коллизии на нём не возникает, и подстраивать там нечего.
Отдельного упоминания заслуживает сам макрос GOWIN_SYNTHESIS. В отличие от XILINX_VIVADO или ALTERA_RESERVED_QIS, которые определяют сами САПР, этот макрос Gowin EDA не определяет — его определяет BGM. В репозитории есть файл define_gowin_or_nothing.svh, существующий в двух экземплярах: настоящий лежит в каталоге каждой Gowin-платы, а заглушка с единственной строчкой // Nothing — в общем каталоге peripherals. Общий для всех примеров config.svh подключает этот файл безусловно, а какая из копий выиграет, решает порядок путей поиска, в котором каталог выбранной платы идёт первым.
Получается третий механизм подстройки под плату, в дополнение к параметрам и адаптерам: каталог платы может протаскивать собственные макросы в общий код, не трогая ни один файл примеров.
Откуда это всё растёт
Репозиторий BGM используется в Школе синтеза цифровых схем — открытой образовательной программе по RTL-разработке, функциональной верификации и основам проектирования микросхем. Именно она и порождает требование «один пример, много плат, много САПР»: занятия идут параллельно в университетских кластерах, и у каждого кластера своё железо и свой набор установленных инструментов.
Оттуда же и задача с интеграцией ядра АПС: курс МИЭТ разрабатывался сам по себе, под свою плату и свой САПР, а собираться его процессорная система теперь должна одинаково у всех.
Ссылки на упомянутые ресурсы
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.