Привет! Я Константин Павлов, старший инженер по разработке СнК в компании YADRO. В этой статье я поделюсь опытом, полученным нами при прототипировании подсистемы PCI Express на ПЛИС.
Прототипирование здесь — это когда мы берем код на SystemVerilog, предназначенный для запуска исключительно на ASIC, и далее через минимальные воздействия адаптируем его, чтобы запустить на FPGA. Зачем это нужно? Дело в том, что производство ASIC занимает очень много времени, а ошибки крайне дороги. Поэтому, чтобы дать возможность программистам отлаживать код, писать драйверы, настраивать систему, причем гораздо раньше, чем реальный чип будет произведен, — для этого и делают ранние прототипы на ПЛИС.
Я перечислю стандартные этапы прототипирования, а затем подробно остановлюсь на более интересных приемах работы с кодом, которые нам пришлось применить.

Начну с постановки задачи. Нужно было сделать прототип подсистемы PCI Express, которая пришла к нам в виде исходников на SystemVerilog от стороннего производителя. Требуемая скорость — Gen3, количество лейнов — минимум x1. Наше устройство должно выполнять роль Root Complex, то есть позволять подключать к себе оконечные устройства (endpoint).
Для этого нам предстояло поработать над MAC- и PHY-контроллерами PCI Express. PHY-контроллер, к счастью, дает производитель ПЛИС: Xilinx предоставляет отдельный PHY для PCI Express в виде IP ядра. MAC-контроллер сторонний. Из всего этого требовалось создать работающий прототип.
В прототипе мы стремились достичь именно скоростей Gen3, не меньше и не больше, поскольку следующей нашей задачей по плану была работа с интерфейсом CXL. Это высокоскоростной протокол, который работает поверх PCI Express. С помощью CXL, например, в серверных системах расширяют системную память. По линиям PCI Express очень удобно делать доступ к дополнительной памяти в системе. Минимальные требования для CXL — это как раз Gen3.

У нас была отладочная плата с микросхемой Xilinx и плата расширения, которая работает через разъем FMC. На плате расширения установлено PCIe-устройство, в нашем случае NVMe SSD, у которого, как известно, четыре линии PCIe. Плата расширения ничего логически не меняет в тракте передачи данных. Через нее мы просто прокидываем линии PCI Express от основной платы до SSD-накопителя.

Перейдем к архитектуре проекта. Нативный PHY контроллер от Xilinx и сторонний MAC-контроллер должны быть связаны между собой интерфейсом PIPE — PHY Interface for PCI Express. Это стандартизованный интерфейс для связи MAC и PHY, в свое время он был разработан компанией Intel и постепенно дорабатывается, чтобы обеспечить потребности новых поколений PCI Express.

MAC контроллер подключается к системе по шине AXI4. В наших экспериментах, чтобы делать единичные транзакции, это мог быть стандартный JTAG-to-AXI-контроллер от Xilinx. В сложных тестах, когда требуется нагружать интерфейсы до предела пропускной способности, мастером AXI4 становится встраиваемый процессор или целый многоядерный CPU-кластер.

Теперь подробнее о режимах работы. Xilinx PHY описан в документе PG239, где приведена такая таблица. PHY начинает работать всегда на Gen1, при этом тактируется от частоты 125 МГц при ширине шины данных 16 бит. Gen2 мы пропускаем — по стандарту PCI Express есть возможность это сделать и сразу перепрыгнуть на Gen3. При этом полностью меняется режим работы и PHY, и MAC: частота интерфейса PIPE возрастает до 250 МГц, а ширина данных — до 32 бит.

Чтобы начать работать на Gen3 при полной скорости, необходим тренинг — выбор наилучших настроек передатчиков и приемников сигнала. Даже в самом простом случае, когда в системе PCI Express участвуют всего два устройства — Root Complex и Endpoint, — на пути распространения сигналов возникает много препятствий. Каждый разъем, каждое соединение, переходные отверстия и даже сами проводящие дорожки на плате — все это искажает высокоскоростные сигналы и приводит к ошибкам передачи данных. Распространяясь по тракту, сигнал неизбежно искажается и затухает — и чем выше скорость, тем больше. Если PCIe Gen1 нормально работает и без тренинга, без предварительной настройки, то для Gen3 она строго необходима и обязательна по стандарту.
Мы столкнулись с тем, что в Xilinx PHY процедура тренинга есть, но реализована не по стандарту. Процитирую документацию:
The Gen3/Gen4 TX and RX equalization defined here is different from the PIPE specification. The custom Gen3/Gen4 equalization scheme described here must be used. For more details, refer to Equalization Sequences.
Xilinx расписывает процедуру, приводит иллюстрации, указывает, какими сигналами управлять и как процедура должна выглядеть, чтобы пройти успешно. Но процедура все-таки нестандартная, а MAC-контроллер ожидает стандартную, потому что про Xilinx ничего не знает. От нас это потребовало некоторых усилий и написания кода-прокладки, который будет удовлетворять требованиям и со стороны MAC, и со стороны PHY.

Основное, что мы должны знать про MAC: в каждом устройстве PCI Express крутится машина состояний LTSSM (Link Training and Status State Machine). Цифровое значение, показывающее состояние машины, четко говорит нам, где мы сейчас на пути к достижению максимальной пропускной способности и в процессе тренинга. У каждого состояния есть стандартизированные условия, когда и как его можно достичь. Заранее определено, при каких условиях устройство переходит из со��тояния в состояние. Такая машина состояний есть и на Root Complex, и на endpoint-устройстве, причем они могут двигаться по своим состояниям несинхронно.
Наша цель — это увидеть состояние L0. На картинке выше оно обозначено как U0, но сути это не меняет. Это и есть полноскоростное состояние, в котором достигается максимальная пропускная способность в 8 гигатрансферов в секунду, в соответствии со стандартом.
Чтобы превратить сторонний код, предназначенный для ASIC, в работающий прототип на FPGA, обычно нужно проделать следующие действия:
добавить I/O-буферы ПЛИС,
написать XDC-констрейны для отладочной платы с ПЛИС,
переписать SDC-констрейны, поставляемые с ASIC-кодом, в XDC,
заменить низкоуровневые примитивы на нативные для ПЛИС, например, PLL, clock MUX, BRAM,
удалить clock gating,
иногда адаптировать синтаксис кода на SystemVerilog (где САПР для ASIC и для FPGA реализовали поддержку стандарта по-разному).
Это стандартные, понятные шаги, на которых я не буду подробно останавливаться. Я сосредоточусь на неочевидных приемах, которые нам пришлось применить. Почему? Потому что даже после переработки SDC-, XDC-констрейнов и замены всех примитивов дизайн у нас категорически не сводился по «времянкам». Так что пришлось применять более сложные приемы, более глубоко внедряться в поставочный код MAC-контроллера. Без этого даже на современных, самых производительных ПЛИС у нас не было шансов свести тайминги проекта.
Переписывание тактовой системы
Вот краткая выжимка того, что мы увидели в дизайне нашего проекта PCI Express:

Есть coreclk, который генерируется в PHY и тактирует две группы логики в MAC. Часть этой логики тактируется через примитив BUFGCTRL. Он нужен, чтобы переключать тактирование на более низкий клок auxclk. Другая часть логики тактируется coreclk без переключения, то есть высокоскоростной клок всегда активен.
BUFGCTRL хоть и является специализированным буфером для тактовой подсистемы, но все равно вносит дополнительную задержку, которая искажает тактовое дерево. Это значит, что логика в верхней ветке на картинке тактируется с задержкой, сигналы защелкиваются с задержкой и, соответственно, распространяются на следующие регистры тоже с задержкой. Но между верхней и нижней логикой нет никакой границы. И то и другое — это логика MAC, между ними множество взаимодействий, и сигналы должны на каждом такте переходить как сверху вниз, так и снизу вверх. Догадались уже, в чем тут может быть проблема?
Представим, что мы отправляем сигнал с верхней ветки на нижнюю. Задержка верхней ветки означает, что сигнал будет защелкнут с задержкой, но при переходе на нижнюю ветку он должен быть защелкнут без задержки. Сигнал будто бы опаздывает относительно защелкивающего клока. В этой ситуации вероятны нарушения таймингов по сетапу.
Обратная ситуация. Если мы представим, что сигнал из нижней ветки логики надо защелкнуть где-то в верхнем регистре, то, наоборот, незадержанный сигнал будто бы обгоняет защелкивающий его клок. Здесь уже вероятны временные нарушения по холду.
Обе эти ситуации происходят многократно и сильно усложняют работу САПР Vivado по имплементации дизайна. Несбалансированное тактовое дерево приводит к возникновению противоречивых требований при разводке. В результате мы получаем очень много временных нарушений.

Какое решение можно здесь предложить? Уравновесить тактовое дерево — просто повесить на нижнюю ветку тактирования элемент задержки. У нас в его роли будет тактовый буфер BUFGCE. Он уравновесит буфер в верхней ветке, что приведет к более синхронной работе верхней и нижней логики. Признаюсь, у нас ушло много времени, чтобы определить описанную проблему, но решение оказалось простым и очень помогло сведению проекта.
Переписывание модулей ASIC-кода
Следующий прием, к которому нам пришлось прибегнуть, — это переписывание некоторых модулей из поставки MAC-контроллера.
Здесь сразу возникает вопрос. Вот мы покупаем некий IP-блок, и его производитель гарантирует нам, что код блока является silicon-proven — то есть уже был имплементирован другими производителями в кремнии и поэтому точно заработает. Если мы будем беспечно менять код, то принимаем всю ответственность за его работоспособность на себя. Чуть позже я расскажу, что мы здесь предприняли.
module delay_n #( parameter N = 3, // Number of cycles to delay WD = 1, // Width of datapath RESETVAL = {WD{1'b0}} )( input clk, input rst_n, input clear, input [WD-1:0] din, output [WD-1:0] dout ); reg [WD-1:0] mem[0:N]; reg [WD-1:0] dout_r; wire [WD-1:0] dout0_mux; assign dout0_mux = din; assign dout = (N == 0) ? dout0_mux : mem[N]; integer i; always @(posedge clk or negedge rst_n) if (!rst_n) begin for (i=0; i<=N; i=i+1) mem[i] <= RESETVAL; dout_r <= RESETVAL; end else if (clear) begin for (i=0; i<=N; i=i+1) mem[i] <= RESETVAL; out_r <= RESETVAL; end else begin for (i=0; i<N; i=i+1) mem[i+1] <= (i==0) ? din : mem[i]; end endmodule
Итак, это модуль delay_n, библиотечный блок в составе PCIe MAC, он десятки раз встречается в различных частях контроллера. Проще говоря, это модуль задержки на N тактов. N — это параметр, мы можем задерживать любые сигналы на произвольное количество тактов. Еще в нем есть сигнал сброса rst_n, который обнуляет всю цепочку целиком. В целом это очень простой библиотечный код, сдвиговый регистр.
Сам по себе этот модуль неплох, работает хорошо и быстро. Вообще, сдвиговый регистр — это, наверно, самая простая структура, которую мы можем реализовать в ПЛИС. Но проблема в том, что в PCIe MAC таких модулей много, и они занимают много места. Одно их присутствие будто бы раздвигает другую логику на кристалле ПЛИС, что негативное влияет на сводимость проекта.
Что мы сделали здесь? Мы переписали модуль с учетом того, что в ПЛИС у нас есть примитив SRL32, на котором можно очень эффективно реализовать сдвиговые регистры:
module delay_n #( parameter N = 3, // Number of cycles to delay WD = 1, // Width of datapath RESETVAL = {WD{1'b0}} )( input clk, input rst_n, input clear, input [WD-1:0] din, output [WD-1:0] dout ); reg [WD-1:0] mem; reg [WD-1:0] dout_r; wire rst_n_local; assign rst_n_local = rst_n & !clear; localparam RST_WIDTH = $clog2(N)+1; reg [RST_WIDTH-1:0] rst_cnt; wire [WD-1:0] dout0_mux; assign dout0_mux = din; assign dout = (N == 0) ? dout0_mux : ((rst_cnt != 0) ? RESETVAL : mem); // counter-based reset always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rst_cnt[RST_WIDTH-1:0] <= RST_WIDTH'(N); end else if (clear) begin rst_cnt[RST_WIDTH-1:0] <= RST_WIDTH'(N); end else begin if (rst_cnt[RST_WIDTH-1:0] != 0) begin rst_cnt[RST_WIDTH-1:0] <= rst_cnt[RST_WIDTH-1:0] - 1'b1; end end end // SRL32 wrapper srldelay #( .LENGTH ( N ), .WIDTH ( WD ) ) data_srl_delay ( .clk ( clk ), .en ( 1'b1 ), .in ( din ), .out ( mem ) ); endmodule
Примитив SRL32 — это тоже сдвиговый регистр. У него есть 32 тапа, а самое ценное в нем то, что он очень компактен. Он заменяет собой 32 регистра, но реализуется всего лишь на одном аппаратном LUT. Наш доработанный код подразумевает, что если входной параметр N больше 32, то, соответственно, в цепочке будет инстанциировано несколько SRL32. Небольшая проблема состоит в том, что SRL32 не имеет входа rst, поэтому нам пришлось реализовать сброс самостоятельно на двоичном счетчике.

Давайте сравним исходную и доработанную версию кода. Слева на картинке я имплементировал исходный модуль delay_n. Длина цепочки составляет 128 тапа, ширина сигнала, который мы задерживаем, — 32 бита. Розовой рамкой обозначен P-блок, размер которого равен ровно одному clock region. Желтым отмечено расположение ресурсов, занятых экземпляром delay_n.
Справа на картинке — уже доработанный нами delay_n, который по функциональности, мы надеемся, полностью идентичен исходному. Параметры модуля и масштаб изображения одинаковые, и мы сразу видим, насколько наш модуль компактнее.
Здесь мы можем насладиться тем, как эффективно архитектура ПЛИС позволила нам доработать код. На картинках я перечислил, сколько ресурсов потрачено на каждую реализацию модуля. Мы видим, что вариант на SRL32 занимает гораздо меньше площади на кристалле. Больше нет проблемы в том, что эти модули мешают развестись другим блокам на ПЛИС.
Использование SEC
Вернусь к вопросу: почему мы думаем, что наш переписанный модуль ведет себя так же, как оригинальный? Как мы это гарантируем? Ответ на это есть: утилита SEС (Sequential Equivalence Checker). Даешь ей два набора исходников на SystemVerilog, и она с помощью математических методов проверяет, что выходы модулей полностью идентичны во всех ситуациях.

SEC можно запускать в командной строке — утилита просто напишет результат проверки, OK или FAIL. Есть и графический интерфейс, где утилита покажет на схематике или подсветит строчку, которая приводит к различиям в доработанном коде. Поскольку SEC дает возможность сравнить два набора кода по формальным математическим критериям, мы снимаем с себя ответственность за свои изменения в коде.
Доработанный сдвиговый регистр delay_n, о котором мы говорили выше, прошел проверку утилитой, значит, мы имеем право его использовать и код для ПЛИС не теряет статуса silicon-proven даже после доработки.
Использование отладочных средств ПЛИС
Еще один прием, что мы использовали, касается отладки: он очень помог нам при отладке PCI Express и наблюдением за машиной состояний LTSSM.
В САПР Quartus мы подсмотрели, как работает их SignalTap Logic Analyzer — встроенный логический анализатор. Один из доступных типов триггера в нем называется Transitional.

В САПР Quartus триггер Transitional означает, что логический анализатор будет делать очередную запись состояния всех сигналов только тогда, когда какой-либо из этих сигналов изменился. Это очень удобно для наблюдения длительных процессов. Мы не видим много семплов, не занимаем место на экране и в памяти логического анализатора одними и теми же данными, а смотрим только на изменения участвующих сигналов. Естественно, при анализе данных приходится всегда учитывать искажения временного масштаба: между первым и вторым семплом может пройти один такт, между вторым и третьим — тысяча. Но в целом это очень полезная штука, расширяющая границы применимости логического анализатора.
Мы задались вопросом, как это реализовать в среде Vivado. Оказалось, что автоматического, встроенного способа сделать transitional trigger в Vivado нет. Но мы можем логическими условиями, вручную, так назначить способ срабатывания, чтобы он был эквивалентен transitional trigger. Порядок действий таков:
Во время добавления ILA (Set Up Debug) установить галочку Capture control.
Собрать и загрузить битстрим в ПЛИС как обычно.
В окне ILA на вкладке Settings установить тип триггера BASIC.
На вкладке Capture Setup:
Добавить все сигналы, изменения которых должны запускать новый семпл.
Установить способ объединения Global NAND.
Установить Radix = "Binary", Value = "NNNNN" для всех сигналов.
Запустить ILA как обычно.

Получается довольно много ручных операций, но их можно автоматизировать через встроенный в Vivado язык TCL:
# setup transitional ILA triggers for BASIC captire mode in Vivado # be sure to turn on "Capture control" option to enable BASIC captire mode proc setup_transitional_triggers {ila_index} { set device [lindex [get_hw_devices] 0] set ila [lindex [get_hw_ilas -of_objects $device] $ila_index] set probes [get_hw_probes -of_objects $ila] set_property CONTROL.CAPTURE_MODE BASIC $ila set_property CONTROL.CAPTURE_CONDITION OR $ila foreach probe $probes { set probe_width [get_property WIDTH [get_hw_probes $probe -of_objects $ila]] set compare_value [join [list "neq" $probe_width "'" "b" [string repeat "N" $probe_width]] ""] set_property CAPTURE_COMPARE_VALUE $compare_value [get_hw_probes $probe -of_objects $ila] } }
Этот скрипт следует запускать в TCL консоли Vivado после добавления сигналов в логический анализатор. В качестве параметра следует указать порядковый номер ILA начиная с 0. В процессе выполнения скрипта будет установлен CAPTURE_MODE = BASIC, а в окно Capture setup будут добавлены все возможные сигналы с необходимыми настройками.
Перейдем к результатам наших стендовых испытаний.

Здесь показан финальный результат после всех описанных ранее доработок кода, в том числе применен transitional trigger. Слева подсвечено два сигнала. Основной сигнал, за которым мы следим, — это состояние LTSSM. На него нужно смотреть, чтобы понять, что мы достигли состояния L0. Второй сигнал — это phy_rate, один из сигналов интерфейса PIPE, который четко указывает, какой у нас режим работы. 0 — это Gen1, 2 — это Gen3, соответственно.
По сигналу phy_rate мы видим, что состояние LTSSM как-то менялось на Gen1, в итоге мы пришли в состояние L0 на Gen1. Это значение 0x11 в HEX.

Далее LTSSM пошла тренировать линк на Gen3. Во время тренинга устройства договариваются друг с другом, как предысказажать сигналы, чтобы качество на приеме у них было максимальное. В процессе phy_rate изменяется на Gen3, тактирование увеличивается со 125 до 250 МГц.

Далее, в определенный момент, мы получаем значение 0x11 состояния LTSSM, но уже на скоростях Gen3. С этого момента можно работать с подключенным Endpoint устройством, читать и писать в него данные.
Подведем итоги
Мы разработали FPGA прототип системы, состоящей из контроллера PCI Express, идентичного тому, что будет использоваться в ASIC, а также из нативного для ПЛИС PHY-контроллера. Мы применили все стандартные приемы прототипирования, а также ряд продвинутых методик, позволивших нам собрать проект для ПЛИС без временных нарушений. Для доработанных модулей соблюдена эквивалентность с исходной поставкой ASIC кода. На стенде подтверждено успешное соединение по интерфейсу PCI Express на скорости Gen3.
Если вам близки задачи, о которых мы пишем в этой и других статьях об аппаратной разработке, обратите внимание на наши вакансии: