Продолжаю повествование о том, как проходит мое изучение возможностей отладочной платы с SoC Zynq 7000 на базе отладочной платы QMTech. В этой статье я опишу то, как я решал задачу примитивного обмена данными между PS и PL с использованием baremetal application и при использовании Linux. Всем интересующимся добро пожаловать под кат!
Дисклеймер
Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи - рассказать о своем опыте, с чего можно начать, при изучении отладочных плат на базе Zynq. Я не являюсь профессиональным разработчиком под ПЛИС и SoC Zynq, не являюсь системным программистом под Linux и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется.
Перед тем как приступить к выполнению каких-либо действий из этого урока - настоятельно рекомендую прочитать предыдущие уроки из серии уроков по Zynq. Инженер, который хочет повторить изложенное в этом уроке, уже должен уметь или хотя бы иметь представление о том, как:
Работать с Xilinx Vivado и иметь понимание того, как и в какой последовательность организуется разработка с использованием данной среды;
Что такое Xilinx SDK и как она взаимосвязана с Xilinx Vivado;
Собрать baremetal-приложение для Zynq, как его откомпилировать и запустить на отладке;
Собрать загрузочный образ Linux с FSBL, bitstream-файлом, U-Boot, Device Tree, Root FS и ядром;
Постановка задачи
Чтобы развивать дальнейшее повествование и изучать что-то новое нужна была реальная интересная задача. И я ее придумал! Ребята из моей команды рассказывали в этой статье историю того, как шла разработка Яндекс.Станции-Max и упомянули устройства для организации тестовых испытаний плат, так называемые “джиги” (англ. jigs).
Это устройство организовано таким образом, что есть специальная платформа, которая встаёт иглами на тестовые точки и делает через иглы определенные манипуляции для проведения тестирования. Не вдаваясь в подробности, в конечном итоге данные измерений преобразуются в специально сформированный набор импульсов и отправляются в процессор на обработку. Основная задача промежуточных вычислений до наступления момента интерпретации результатов тестирования - сосчитать количество импульсов с того или иного канала. Но с наращиванием количества каналов счёта таких импульсов есть ограничение в возможностях расширения. Плюсом к этому обработка импульсов идет через процессор с ядром Cortex-M4 и ограниченным набором по периферии, т.е. о реальной параллельности счёта большого числа каналов не может идти и речи. И тут я подумал о том, что можно же считать параллельно при помощи ПЛИС и забирать данные в Linux и отсылать уже данные по сети на сервер при помощи Zynq!
И тут в голове оформилась интересная задача:
Нужно организовать внутри ПЛИС набор счетчиков, которые будут считать импульсы с частотой до 2МГц;
Счетчики должны быть включаемыми\выключаемыми;
Счетчики должны быть сбрасываемыми;
Для включения, выключения и сброса счетчиков должны быть отдельные регистры управления;
Счётчики должны работать независимо друг от друга и синхронно;
Управление и считывание данных из счётчиков должно быть доступно из PS или Linux;
Счётчики должны считать точное количество импульсов (допустимое отклонение 1-2 импульса).
Исходя из постановки задачи был сформирован четкий план действий:
Создаем проект, настраиваем Processing System и Processor System Reset.
Настраиваем соответствующие пины ПЛИС, которые будут входами для импульсов;
В ПЛИС делаем блок ограничения импульсов частотой более 2 МГц и проверяем генератором сигналов или другой ПЛИС;
Добавляем счетчик импульсов в ПЛИС и проверяем корректность счёта сгенерировав конкретное количество импульсов другой ПЛИС;
Организуем некую память в которую можно записывать и из которой можно читать данные как из ПЛИС, так и из PS-части. В этой же памяти внутри ПЛИС организуем две ячейки памяти которые будут выступать в качестве двух регистров управления (сброс, включение и выключение) и одну в качестве регистра хранения команд;
Организуем блок управления счетчиками внутри ПЛИС, который сможет опрашивать память на предмет наличия команды из PS, и по команде “забирать” данные из счётчиков и сохранять их в память, сбрасывать значение и включаться\выключаться.
Протестировать полученный результат с использованием baremetal-приложения и из Linux.
Что ж, общее понимание, того что нужно сделать есть, можно приступить к реализации намеченного.
Подготовительный этап
Создаем новый проект к нашей плате по шаблону. Как это сделать - описано в предыдущих статьях. Подробно описывать этот шаг я не буду и сразу перейдем к формированию Block Design.
Нажимаем кнопку Create Block Design и в первую очередь добавляем IP-ядро с именем Zynq Processing System. После этого его можно быстро сконфигурировать с помощью tcl-скрипта. Я заготовил Preset для быстрой настройки для платы QMTech и поэтому в меню нажимаем Presets - Apply Configuration и выбираем файл который вы можете взять отсюда.
После этого нужно перейти в меню Clock Configuration - PL Fabric Clocks и включаем FCLK_CLK1 на той же частоте что и FCLK_CLK0. Этот тактовый сигнал нам понадобится для подключения логического анализатора (ILA - Integrated Logic Analyzer) и отладки полученного автомата.
После этого добавим в наш дизайн блок Processor System Reset. Выполняем предложенные автоматизации и должно получиться следующее:
Далее можно переходить к настройкам пинов через добавление Physical Constraints. Добавляем файл physical_constr и переходим в него из древа проектов для редактирования.
В файл physical_constr записываем следующее:
set_property -dict { PACKAGE_PIN P20 IOSTANDARD LVCMOS33 } [get_ports { pulse_1 } ];
set_property -dict { PACKAGE_PIN N20 IOSTANDARD LVCMOS33 } [get_ports { pulse_2 } ];
set_property -dict { PACKAGE_PIN T19 IOSTANDARD LVCMOS33 } [get_ports { pulse_3 } ];
Поясню немного по содержанию. В этом файле мы указываем имена портов которые будут использованы в дизайне и сопоставляем их реальным физическим выводам Zynq. В данном случае мы добавляем три порта для счётчиков.
Обработаем входные сигналы и сосчитаем их
Теперь можно начать с обработки входных импульсов. В первую очередь, нужно сделать модуль антизвона и ограничить частоты входных сигналов. Это сделать довольно легко, необходимо лишь перенести в проект модуль debouncer, который фигурировал в предыдущих статьях. Создаем новый source-файл, именуем его как debouncer и открываем на редактирование. Подробное объяснение по работе данного автомата я делал в этой статье и код обильно снабжен поясняющими комментариями. На разборе этого кода я останавливаться не буду.
Исходный код модуля debouncer. Ссылка.
`timescale 1ns / 1ps
//////////////////////////////////////////////////////////////////////////////////
// Company: -
// Engineer: megaloid
//
// Create Date: 08/14/2021 02:39:31 PM
// Design Name: Zynq Multichannel Counter
// Module Name: debouncer
// Project Name:
// Target Devices:
// Tool Versions:
// Description: Debouncer for input signals from pins
//
// Dependencies:
//
// Revision:
// Revision 0.01 - File Created
// Additional Comments:
//
//////////////////////////////////////////////////////////////////////////////////
module debouncer
// Параметры
#(
parameter CNT_WIDTH = 4 // Разрядность счётчика, выбрана подходящая для того,
// чтобы можно было пропустить высокую частоту счетчика но не больше 2МГц
)
// Порты
(
input clk_i, // Clock input
input rst_i, // Reset input
input sw_i, // Switch input
output reg sw_state_o, // Состояние нажатия клавиши
output reg sw_down_o, // Импульс "кнопка нажата"
output reg sw_up_o // Импульс "кнопка отпущена"
);
reg [1:0] sw_r; // Триггер для исключения метастабильных состояний
always @ (negedge rst_i or posedge clk_i)
if (~rst_i)
sw_r <= 2'b00;
else
sw_r <= {sw_r[0], ~sw_i};
reg [CNT_WIDTH-1:0] sw_count; // Счетчик для фиксации состояния
wire sw_change_f = (sw_state_o != sw_r[1]);
wire sw_cnt_max = &sw_count;
always @(negedge rst_i or posedge clk_i) // Каждый положительный фронт сигнала clk_i проверяем, состояние на входе sw_i
if (~rst_i)
begin
sw_count <= 0;
sw_state_o <= 0;
end
else if(sw_change_f) // И если оно по прежнему отличается от предыдущего
begin // стабильного, то счетчик инкрементируется.
sw_count <= sw_count + 'd1;
if(sw_cnt_max) // Счетчик достиг максимального значения.
sw_state_o <= ~sw_state_o; // Фиксируем смену состояний.
end
else // А вот если, состояние опять равно зафиксированному стабильному,
sw_count <= 0; // то обнуляем счет. Было ложное срабатывание
always @(posedge clk_i)
begin
sw_down_o <= sw_change_f & sw_cnt_max & ~sw_state_o; // Формируем импульс при нажатии кнопки
sw_up_o <= sw_change_f & sw_cnt_max & sw_state_o; // Формируем импульс при отпускании кнопки
end
endmodule
Следующим шагом нам необходимо сосчитать все импульсы которые прилетают из debouncer-модуля. Создаем еще один файл с исходным кодом и называем его counter. Записываем в него код достаточно простого автомата. Ссылка.
`timescale 1ns / 1ps
//////////////////////////////////////////////////////////////////////////////////
// Company: -
// Engineer: megalloid
//
// Create Date: 08/14/2021 02:51:56 PM
// Design Name: Zynq Multichannel Counter
// Module Name: counter
// Project Name: -
// Target Devices: -
// Tool Versions: -
// Description: Pulses counter
//
// Dependencies: -
//
// Revision: -
// Revision 0.01 - File Created
// Additional Comments: -
//
//////////////////////////////////////////////////////////////////////////////////
module counter(
input pulse_i, // Входной порт для сгенерированных импульсов
input rst_i, // Вход для сигнала сброса значения счетчика
input ena_i, // Вход для сигнала на разрешение считать импульсы
output [31:0] cnt_o // Выходной сигнал значения счётчика
);
reg[31:0] cnt_r = 0; // Регистр для хранения значения счётчика
assign cnt_o = cnt_r; // Присваиваем регистр к выходному порту
always @ (posedge pulse_i or negedge rst_i) begin // Каждый раз когда будет получен сигнал сброса или импульса
if (~rst_i) begin
cnt_r <= 32'b0; // Сбрасываем счетчик
end
else if(pulse_i && ena_i) begin
cnt_r <= cnt_r + 1'b1; // Или считаем если было разрешение на счёт
end
end
endmodule
После этого мы можем добавить в дизайн интересующее нас количество debouncer-ов и счётчиков. Я остановился на трёх каналах. Добавим эти блоки в наш дизайн, соединим порты проводниками, сделаем входные порты для debouncer-ов, переименуем их и получим следующее:
Теперь переходим к организации памяти для хранения данных счетчиков.
AXI-интерфейс, BRAM и вот это всё
Для того, чтобы осуществить обмен информации между PS и PL используется универсальный интерфейс AXI. Данный интерфейс предлагает широкий спектр функций, для организации информационного обмена внутри Zynq и по сути является универсальной шиной для самых разнообразных применений, с высокой пропускной способностью и минимальными задержками.
Глубокое рассмотрение принципов работы интерфейса AXI не планировалось в этой статье, и в Интернете очень много информации на этот счёт, и я решил не углубляться в описание возможностей AXI. Но за то расскажу о том, как применение этого интерфейса помогло мне решить поставленную задачу.
Для организации нужного нам обмена мы задействуем уже готовый, предложенный компанией Xilinx, блок AXI SmartConnect, который является преемником AXI Interconnect. В нашем случае будет достаточно использовать самый простой вариант - AXI4-Lite, который достаточно прост, и подходит для передачи небольших порций информации и позволяет только читать и записывать 32-битные слова за раз. Используется обычно для доступа к низкоскоростным периферийным устройствам. Самое то для получения и отправки команд счетчиками и выгрузки значений трёх счётчиков.
Не будем останавливаться на теории и перейдем к выполнению задачи. Добавим в наш дизайн IP-блок AXI SmartConnect и сразу зайдем в его настройки и выберем количество Slave-интерфейсов равным 1. Подключаем сигнал сброса и тактирования следующим образом:
Для хранения информации внутри ПЛИС мы можем задействовать AXI BRAM Controller и Block Memory Generator. Добавим их в дизайн и перейдем к первоначальной настройке.
Двойным кликом заходим в настройки AXI BRAM Controller-а и настраиваем настройки следующим образом:
Выбираем AXI Protocol AXI4LITE. И делаем один BRAM Interface. Остальные настройки оставляем по умолчанию. Нажимаем ОК.
Соединяем интерфейсы Master AXI с Slave AXI и подключаем тактовый сигнал и сигнал сброса к AXI BRAM Controller:
Переходим к настройке Block Memory Generator. Выбираем Memory Type как True Dual Port RAM и остальные настройки оставляем по умолчанию.
Подключаем соединения и получаем следующий вид:
Следующим шагом нужно определить какое количество памяти будет задействовано для хранения данных в BRAM. Для этого нужно зайти в верхнее меню Window - Address Editor и автоматически назначить адреса:
После этого произойдет автоматическая разметка по адресу смещения 0x4000_0000 в размере 8К. Нам этого будет достаточно для записи. Оставим значение по умолчанию.
Блок управления счетчиками
Для того, чтобы осуществить доступ к BRAM из нашей кастомной RTL-ки необходимо определить какие сигналы должны быть подключены. Итак, нам нужен BRAM_PORTB и именно по этому порту мы будем взаимодействовать с памятью. Развернем его и после этого можно увидеть набор сигналов:
Собственно все эти сигналы мы должны сформировать для того, чтобы работать с памятью. В первую очередь необходимо добавить два сигнала для Enable B Port (enb) и Reset B Port (rstb).
Для enb сделаем константу как сигнал разрешающий постоянную работу. Добавим IP-блок Contsant и откроем настройки. Выставляем ширину шины в 1 и сигнал по умолчанию - 1.
Для сигнала rstb - делаем логический элемент NOT через IP-блок Utility Vector Logic. Заходим в настройки выставляем тип NOT и ширину так же в 1.
Подключаем следующим образом:
Теперь можно перейти к созданию RTL-ки которая будет являться ключевым элементом всего проекта. Назовём его counter_mgmt. Итого нам нужно сделать целый список портов:
Вход для сигнала тактирования;
Вход для сброса;
Входные 32-битные шины с текущим значением счётчиков для последующей записи в память;
Выходные сигналы включения\выключения счётчиков;
Выходные сигналы для сброса значения счётчиков;
Выходной сигнал Write Enable для Block Memory Generator, сигнализирующий ему о том, что будет производиться запись в память;
Выходная 32-битная шина Address будет сообщать в какую ячейку памяти нужно будет проводить запись или из которой нужно производить чтение;
32-битная шина входных и выходных данных, которые будут использоваться при записи и чтении информации из BRAM;
Оформляем всё это и получается следующий набор входных портов:
//////////////////////////////////////////////////////////////////////////////////
// Company: -
// Engineer: Andrey Zaostrovnykh
//
// Create Date: 08/14/2021 02:39:31 PM
// Design Name: Zynq Multichannel Counter
// Module Name: counter_mgmt
// Project Name:
// Target Devices:
// Tool Versions:
// Description: Management module for counters
//
// Dependencies:
//
// Revision:
// Revision 0.01 - File Created
// Additional Comments:
//
//////////////////////////////////////////////////////////////////////////////////
module counter_mgmt(
input clk_i, // Вход для сигнала тактирования
input rst_i, // Вход для сброса автомата
input [31:0] cnt_0_data, // Значение 1-го счетчика
input [31:0] cnt_1_data, // Значение 2-го счетчика
input [31:0] cnt_2_data, // Значение 3-го счетчика
output cnt_0_en, // Включение 1-го счетчика
output cnt_0_rst, // Сброс 1-го счетчика
output cnt_1_en, // Включение 1-го счетчика
output cnt_1_rst, // Сброс 1-го счетчика
output cnt_2_en, // Включение 1-го счетчика
output cnt_2_rst, // Сброс 1-го счетчика
output we, // Сигнал Write Enable
output [31:0] addr, // Адрес чтения-записи
output [31:0] dout, // Выходные данные для записи в память
input [31:0] din // Входные данные при чтении из памяти
);
endmodule
Сохраняем код и добавляем его на общую диаграмму дизайна. Соединяем его с Block Memory Generator следующим образом:
После определения набора входных\выходных сигналов можно подумать об общей логике работы все автомата в целом.
Задумка получается следующая. Работа всего автомата в целом будет управляться через регистр, который будет являться ячейкой памяти в BRAM. То есть, наш автомат будет в постоянном режиме опрашивать эту ячейку, и если будут обнаружены какие-то команды, которые запишет PS в эту ячейку - мы будем выполнять ту или иную команду. Вторая и третья ячейка памяти будет отвечать за включение\выключение счетчиков, и сброс значения счетчика соответственно. Четвертая, пятая, шестая ячейка будут отвечать за хранение значения счётчиков для передачи из в PS.
Карта памяти получается следующая:
В регистрах управления состоянием счетчика и сброса каждый отдельный бит будет отвечать за соответствующий номеру бита счётчику:
Таким образом максимальное количество счётчиков которое можно сделать при таком планировании расходования памяти - 32 штуки.
Для хранения и промежуточной обработки данных объявим два регистра для хранения 32-битных данных о состоянии сброса и состояния счетчиков. Присвоим их соответствующим выходам нашего модуля. Запишем это в файл counter_mgmt.v:
reg [31:0] cnt_rst;
reg [31:0] cnt_en;
assign cnt_0_rst = cnt_rst[0];
assign cnt_1_rst = cnt_rst[1];
assign cnt_2_rst = cnt_rst[2];
assign cnt_0_en = cnt_en[0];
assign cnt_1_en = cnt_en[1];
assign cnt_2_en = cnt_en[2];
Следующим шагом нужно определиться с набором команд, которые мы будем посылать на исполнение. Тут всё достаточно очевидно:
После этого можно в целом ввести соответствующие режимы в которых будет находиться данный модуль:
localparam IDLE = 4'd1;
localparam EN = 4'd2;
localparam EN_W = 4'd3;
localparam EN_R = 4'd4;
localparam RST = 4'd5;
localparam RST_W = 4'd6;
localparam RST_R = 4'd7;
localparam WRT = 4'd8;
localparam WRT_W = 4'd9;
localparam WRT_R = 4'd10;
Так же добавим регистр для хранения текущего значения состояния автомата:
reg [3:0] state = IDLE;
Определим оставшиеся сигналы и регистры которые нам пригодятся при работе с памятью BRAM.
reg we_r = 1'd0; // Регистр для сигнализирования о том что будет производиться запись
assign we = we_r; // Присвоение значения регистра выходному сигналу
reg [31:0] addr_r = 32'd0; // Регистр для передачи адреса для записи\чтения
assign addr = addr_r; // Присвоение значения регистра выходному сигналу
reg [31:0] dout_r = 32'd0; // Регистр для хранения данных передаваемых в BRAM
assign dout = dout_r; // Присвоение значения регистра выходному сигналу
Добавим еще регистр, который будет использован для последовательной записи значений из каждого счетчика:
reg [3:0] reg_choose = 4'h0;
Опишем, что должно происходить c Write Enable и Address при попадании автомата в тот или иной state:
always @(posedge clk_i)
begin
case(state)
IDLE: begin // Пока состояние IDLE читаем адрес 0x0
we_r <= 1'b0;
addr_r <= 32'd0;
end
EN: begin // Когда получена команда EN читаем адрес 0x4
we_r <= 1'b0;
addr_r <= 32'd4;
end
EN_W: begin // Получаем значения вкл\выкл счетчиков
we_r <= 1'b0; // и прочитав переходим к записи в управляющие выходы
addr_r <= 32'd4;
end
EN_R: begin // Сбрасываем регистр команды
we_r <= 1'b1;
addr_r <= 32'd0;
end
RST: begin // Когда получена команда RST читаем адрес 0x8
we_r <= 1'b0;
addr_r <= 32'd8;
end
RST_W: begin // Получаем значения какие счетчики надо сбросить
we_r <= 1'b0; // и прочитав переходим к записи в управляющие выходы
addr_r <= 32'd8;
end
RST_R: begin // Сбрасываем регистр команды
we_r <= 1'b1;
addr_r <= 32'd0;
end
WRT: begin // Когда получена команда WRT включаем режим записи
we_r <= 1'b1;
addr_r <= 32'h0;
end
WRT_W: begin // Делаем пробег по всем адресам и записываем значения
we_r <= 1'b1;
case (reg_choose)
4'd0: begin
addr_r <= 32'hC;
reg_choose <= 4'h1;
end
4'd1: begin
addr_r <= 32'h10;
reg_choose <= 4'h2;
end
4'd2: begin
addr_r <= 32'h14;
reg_choose <= 4'h3;
end
default: begin
addr_r <= 32'h0;
end
endcase
end
WRT_R: begin // Сбрасываем значения команды
we_r <= 1'b1;
addr_r <= 32'd0;
reg_choose <= 4'h0;
end
default: begin
we_r <= 1'b0;
addr_r <= 32'd0;
end
endcase
end
Теперь нам нужно сделать механизм который будет в цикле опрашивать BRAM на предмет наличия той или иной команды на входе и определять текущий state автомата:
always @(posedge clk_i)
begin
if (rst_i == 1'b1) begin
state <= IDLE;
end
else begin
case(state)
IDLE: begin // Если мы находимся в режиме IDLE
case(din) // Смотрим на сигнал din подключенный к BRAM
32'd1: begin // Если получена команда 0x1
state <= EN; // Переходим в EN
end
32'd2: begin // Если получена команда 0x2
state <= RST; // Переходим в RST
end
32'd3: begin // Если получена команда 0x3
state <= WRT; // Переходим в WRT
end
default: begin // Если ни одно из значений не подошло
state <= state; // То остаёмся в том же состоянии
end
endcase
end
EN: begin
state <= EN_W;
end
EN_W: begin
state <= EN_R;
end
EN_R: begin
state <= IDLE;
end
RST: begin
state <= RST_W;
end
RST_W: begin
state <= RST_R;
end
RST_R: begin
state <= IDLE;
end
WRT: begin
state <= WRT_W;
end
WRT_W: begin
case (reg_choose)
4'd2: begin
state <= WRT_R;
end
default: begin
state <= IDLE;
end
endcase
end
WRT_R: begin
state <= IDLE;
end
default: begin
state <= IDLE;
end
endcase
end
end
И для передачи данных сделаем еще один behavioral-блок для того, чтобы передавать данные:
always @(posedge clk_i)
begin
case(state)
EN_R: begin
cnt_en[0] <= din[0];
cnt_en[1] <= din[1];
cnt_en[2] <= din[2];
dout_r <= 32'b0;
end
RST_R: begin
cnt_rst[0] <= din[0];
cnt_rst[1] <= din[1];
cnt_rst[2] <= din[2];
dout_r <= 32'b0;
end
WRT_W: begin
case (reg_choose)
4'd0: begin
dout_r <= cnt_0_data;
end
4'd1: begin
dout_r <= cnt_1_data;
end
4'd2: begin
dout_r <= cnt_2_data;
end
default: begin
dout_r <= 32'h0;
end
endcase
end
WRT_R: begin
dout_r <= 32'b0;
end
endcase
end
Полный исходный код модуля доступен тут.
После этого можно подключить к ключевым выходам обмена логический анализатор. Им мы сможем воспользоваться когда будет активна PS-система. Добавить логический анализатор можно путем добавление IP-блока Integrated Logic Analyzer.
Подключаем тактирование ILA к FCLK_CLK1. И открываем настройки лог. анализатора. Выбираем Monitor Type как Native. Записываем Number of Probes в значение 13 штук.
Настраиваем пробники на ширину данных и расставляем их на схеме. Полная схема того, что вышло после подключения лог. анализатора:
К слову. Наличие логического анализатора достаточно сильно увеличивает время синтеза всей схемы в целом. Поэтому если вы уверены в работоспособности своего автомата - логический анализатор можно не добавлять. Ну а поскольку я начинающий и не верю что всё заработает с первого раза - я соединю все линии связанные с управлением счётчиком с лог. анализаторам и пойду заваривать кофе, пока идет синтез.
На будущее отмечу общую последовательность работы с лог.анализатором:
Заливаем из Hardware manager в Vivado bitstream-файл в ПЛИС;
Открываем SDK и делаем Clean Project;
Запускаем приложение (в случае если мы отлаживаемся в baremetal-приложении);
Нажимаем кнопку Refresh device и откроется логический анализатор;
Расставляем нужные триггеры на нужных линиях и наблюдаем за результатом;
Попробуйте подебажить самостоятельно. Там ничего сложного нет.
Проверим, что получилось
Теперь можно подготовить проект к синтезу и имплементации с генерацией bitstream-файла.
На основном поле дизайна нажимаем Validate Design и проверяем, всё ли хорошо.
В левом меню Hierarchy правой кнопкой кликаем на файле zynq.bd и делаем команду Create HDL Wrapper, выбираем Let Vivado manage wrapper and auto-update и нажимаем ОК.
После нажимаем команду в левой части окна Generate Block Design, выбираем Synthesis Options - Global и нажимаем Generate. Дожидаемся конца генерации и запускаем синтез через команду Generate bitstream.
Процедура синтеза будет достаточно длительной (около 10 минут).
После окончания синтеза можно экспортировать результат в SDK и протестировать результат. Сначала мы попробуем проверить проект из baremetal-приложения и загрузим всё через JTAG. Отключаем питание платы, и вытащим microSD-карту.
Нажимаем меню File - Export - Export Hardware. Ставим галочку Include bitstream. И после окончания импорта запускаем SDK через меню File - Launch SDK.
После того как откроется Xilinx SDK создаем новый проект File - New - Application Project.
Пишем имя нового проекта, нажимаем Next и берем за основу шаблонный проект Hello World. После того как создан новый проект, переходим в его иерархию и находим файл helloworld.c:
И редактируем его следующим образом:
#include <stdio.h>
#include "platform.h"
#include "xil_printf.h"
#include "xil_io.h"
#include "xparameters.h"
#include "sleep.h"
int main()
{
int num;
int rev;
init_platform();
// Enable all counters
Xil_Out32(XPAR_BRAM_0_BASEADDR + 4, 0x7); // Регистр ENA и значение 0b111
Xil_Out32(XPAR_BRAM_0_BASEADDR + 0, 0x1); // Команда ENA
xil_printf("Enable all counters \n\r");
Xil_Out32(XPAR_BRAM_0_BASEADDR + 8, 0x7); // Регистр RST и значение 0b111
Xil_Out32(XPAR_BRAM_0_BASEADDR + 0, 0x2); // Команда RST
xil_printf("Counting...\n\r");
while(1)
{
Xil_Out32(XPAR_BRAM_0_BASEADDR + 0, 0x3); // Записываем в память текущие значения
for(num = 0; num < 6 ; num++)
{
rev = Xil_In32(XPAR_BRAM_0_BASEADDR + num * 4);
xil_printf("The data at 0x%x is 0x%x \n\r", XPAR_BRAM_0_BASEADDR + num * 4, rev);
}
xil_printf("\n\r");
usleep(500000);
}
cleanup_platform();
return 0;
}
Исходный код можно взять тут.
Если описать то, что делает этот код то получается следующий алгоритм:
Инициализируем платформу;
Записываем новые значения в регистр ENA, чтобы включились первые три счётчика;
Отправляем команду ENA в регистр команд;
Записываем новые значения в регистр RST, чтобы был отключен сигнал сброса на трех счётчиках (0 - сброс активен, 1 - не активен);
Отправляем команду RST в регистр команд;
Теперь наши счетчики могут считать входящие импульсы и мы можем проводить чтение. Делаем это чтение с помощью команды WRT в цикле;
Выводим значения ячеек памяти в цикле;
После загружаем bitstream в FPGA через меню Xilinx - Program FPGA и нажимаем Program.
После можно стартовать проект. Кликаем правой кнопкой по проекту, выбираем пункт Run as - Launch on hardware (System debugger).
Если подать импульсы на ножки, которые мы указали в constraints-файле - то счетчики будут нарастать. Проверить это можно если открыть консоль minicom и понаблюдать за выводом:
minicom -D /dev/ttyUSBx
Бесконечно будут сыпаться значения наших регистров:
The data at 0x40000000 is 0x0 - текущее значение регистра команд
The data at 0x40000004 is 0x7 - регистр вкл\выкл счётчиков
The data at 0x40000008 is 0x7 - регистр сброса счётчиков
The data at 0x4000000C is 0x14 - значение первого счётчика
The data at 0x40000010 is 0x1E - значение второго счётчика
The data at 0x40000014 is 0x18 - значение третьего счётчика
Отлично, в baremetal работает. Можно попробовать подергать импульсы из Linux.
Проверка из Linux
На этом этапе можем считать, что наша основная часть работы выполнена и можно проверить работает ли такое же взаимодействие как в baremetal из Linux. Тут всё достаточно просто, читать данные и управлять счетчиками мы будем с помощью простой userspace-программы на Си.
Для этого нам нужно:
Пересобрать образ BOOT.BIN собранный в прошлых занятиях с новым bitstream-файлом собранным в этом проекте и залить его на SD-карту заменив предыдущий. Прочитать об этом можно в этой статье, а готовый BOOT.BIN и все остальные файлы можно тут;
Установить кросс-компилятор и откомпилировать исходный файл нашей программы для Linux;
Загрузить программу на плату и запустить её;
Включив счетчики, отключив сигнал сброса, послать определенное количество импульсов и убедиться, что всё работает как задумано изначально;
Перейдем к реализации намеченного. Устанавливаем кросс-компилятор:
sudo apt install gcc-arm-linux-gnueabihf
Далее нам необходимо создать файл который мы будем компилировать и потом запускать на Zynq. В папке с проектом я создаю папку Application:
megalloid@megalloid-lenovo:~$ cd Zynq/Projects/10.LinuxMulticounter/
megalloid@megalloid-lenovo:~/Zynq/Projects/10.LinuxMulticounter$ mkdir Application
megalloid@megalloid-lenovo:~/Zynq/Projects/10.LinuxMulticounter$ cd Application/
В этой папке я создаю два файла, первый файл исходного кода counter_mgmt.c и Make-файл для удобной кросс-компиляции:
megalloid@megalloid-lenovo:~/Zynq/Projects/10.LinuxMulticounter/Application$ touch counter_mgmt.c Makefile
В файл counter_mgmt.c пишем следующее содержимое:
#include <stdio.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#define BRAM_CTRL_0 0x40000000
#define DATA_LEN 6
int fd;
unsigned int *map_base0;
int sigintHandler(int sig_num)
{
printf("\n Terminating using Ctrl+C \n");
fflush(stdout);
close(fd);
munmap(map_base0, DATA_LEN);
return 0;
}
int main(int argc, char **argv)
{
signal(SIGINT, sigintHandler);
fd = open("/dev/mem", O_RDWR | O_SYNC);
if (fd < 0)
{
printf("can not open /dev/mem \n");
return (-1);
}
printf("/dev/mem is open \n");
map_base0 = mmap(NULL, DATA_LEN * 4, PROT_READ | PROT_WRITE, MAP_SHARED, fd, BRAM_CTRL_0);
if (map_base0 == 0)
{
printf("NULL pointer\n");
}
else
{
printf("mmap successful\n");
}
unsigned long addr;
unsigned int content;
int i = 0;
addr = (unsigned long)(map_base0 + 1);
content = 0x7;
map_base0[1] = content;
printf("%2dth data, address: 0x%lx data_write: 0x%x\t\t\n", i, addr, content);
addr = (unsigned long)(map_base0 + 0);
content = 0x1;
map_base0[0] = content;
printf("%2dth data, address: 0x%lx data_write: 0x%x\t\t\n", i, addr, content);
addr = (unsigned long)(map_base0 + 2);
content = 0x7;
map_base0[2] = content;
printf("%2dth data, address: 0x%lx data_write: 0x%x\t\t\n", i, addr, content);
addr = (unsigned long)(map_base0 + 0);
content = 0x2;
map_base0[0] = content;
printf("%2dth data, address: 0x%lx data_write: 0x%x\t\t\n", i, addr, content);
while(1)
{
addr = (unsigned long)(map_base0 + 0);
content = 0x3;
map_base0[0] = content;
printf("%2dth data, address: 0x%lx data_write: 0x%x\t\t\n", i, addr, content);
sleep(1);
printf("\nread data from bram\n");
for (i = 0; i < DATA_LEN; i++)
{
addr = (unsigned long)(map_base0 + i);
content = map_base0[i];
printf("%2dth data, address: 0x%lx data_read: 0x%x\t\t\n", i, addr, content);
}
}
}
Ссылка на исходный код.
После редактирования сохраняем файл и открываем файл Makefile. В него записываем следующее:
CC=arm-linux-gnueabihf-gcc
CFLAGS ?= -O2 -static
objects = counter_mgmt.o
CHECKFLAGS = -Wall -Wuninitialized -Wundef
override CFLAGS := $(CHECKFLAGS) $(CFLAGS)
progs = counter_mgmt
counter_mgmt: $(objects)
$(CC) $(CFLAGS) -o $@ $(objects)
clean:
rm -f $(progs) $(objects)
$(MAKE) -C clean
.PHONY: clean
После этого можно запустить процесс компиляции и увидеть что у нас появился исполняемый файл:
# make
# file counter_mgmt
counter_mgmt: ELF 32-bit LSB executable, ARM, EABI5 version 1 (GNU/Linux), statically linked, BuildID[sha1]=adfec945dc44cf0b8a702405ca2a2ae9af1b06dd, for GNU/Linux 3.2.0, not stripped
После этого можно загрузить файл на плату и проверить работает ли наша программа. Вариантов загрузки может быть несколько:
Предварительно положить файлы на SD-карту и после загрузки Linux примонтировать раздел на котором она будет расположена;
Стянуть файл по локальной сети подключив Ethernet-кабель к плате;
Первый способ достаточно прост. Скидываем через картридер откомпилированный файл на SD-карту. Открываем UART-консоль нашей отладочной платы и пишем:
# mount /dev/mmcblk0p1 /media
# ls /media/
BOOT.bin counter_mgmt uImage uramdisk.image.gz
system.dtb uboot.env
Второй способ тоже очень простой. Подключаем кабель Ethernet от роутера к плате. Смотрим появился ли IP-адрес:
# ifconfig eth0
eth0 Link encap:Ethernet HWaddr 82:A4:28:8C:74:0A
inet addr:192.168.2.188 Bcast:192.168.2.255 Mask:255.255.255.0
inet6 addr: fe80::2a2f:f8c1:f353:e8b/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:6 errors:0 dropped:0 overruns:0 frame:0
TX packets:13 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000
RX bytes:1404 (1.3 KiB) TX bytes:1562 (1.5 KiB)
Interrupt:33 Base address:0xb000
В buildroot который мы собирали в предыдущем уроке уже настроен режим автоконфигурирования сетевого интерфейса и запущена служба DHCP для получения IP-адреса. Если у вас другой Linux - тоже присвоить IP-адрес вручную через команду:
# ifconfig eth0 192.168.2.188 netmask 255.255.255.0 up
После этого смотрим IP-адрес на компьютере, пингуем его из консоли Zynq, тем самым проверяя что связь есть и скачиваем файл по SSH (на PC должен быть включен SSH-сервер):
# ping 192.168.2.121
PING 192.168.2.121 (192.168.2.121): 56 data bytes
64 bytes from 192.168.2.121: seq=0 ttl=64 time=892.638 ms
64 bytes from 192.168.2.121: seq=1 ttl=64 time=1.057 ms
64 bytes from 192.168.2.121: seq=2 ttl=64 time=2.897 ms
64 bytes from 192.168.2.121: seq=3 ttl=64 time=2.661 ms
64 bytes from 192.168.2.121: seq=4 ttl=64 time=147.555 ms
^C
--- 192.168.2.121 ping statistics ---
5 packets transmitted, 5 packets received, 0% packet loss
round-trip min/avg/max = 1.057/209.361/892.638 ms
# scp megalloid@192.168.2.121:/home/megalloid/Zynq/Projects/10.LinuxMulticounter/Application/counter_mgmt .
Host '192.168.2.121' is not in the trusted hosts file.
(ssh-ed25519 fingerprint sha1!! 78:97:ca:b2:0d:fb:49:3a:3d:d3:6a:69:a1:10:b5:92:69:95:e1:46)
Do you want to continue connecting? (y/n) y
counter_mgmt 100% 3490KB 3.4MB/s 00:01
# chmod +x counter_mgmt
# ./counter_mgmt
Результат вывода программы должен выглядеть следующим образом:
# ./counter_mgmt
/dev/mem is open
mmap successful
0th data, address: 0xb6fd0004 data_write: 0x7
0th data, address: 0xb6fd0000 data_write: 0x1
0th data, address: 0xb6fd0008 data_write: 0x7
0th data, address: 0xb6fd0000 data_write: 0x2
0th data, address: 0xb6fd0000 data_write: 0x3
read data from bram
0th data, address: 0xb6fd0000 data_read: 0x0
1th data, address: 0xb6fd0004 data_read: 0x7
2th data, address: 0xb6fd0008 data_read: 0x7
3th data, address: 0xb6fd000c data_read: 0xb
4th data, address: 0xb6fd0010 data_read: 0x12
5th data, address: 0xb6fd0014 data_read: 0xa
6th data, address: 0xb6fd0000 data_write: 0x3
Видно, что у нас работают все счётчики и при подаче импульсов (в моем случае это кнопки из предыдущих уроков) - у нас наращивается значение счётчика.
Задачу можем считать решенной. Увидимся в следующих уроках! =)
P.S. Если у вас есть какие-либо интересные и не слишком сложные задачки, которые можно было бы попробовать решить с помощью Zynq - пишите мне в Telegram @megalloid. И мне будет интересно поразбираться, и возможно чем-то помогу вам. Если есть желание - можете сделать донат как скромную оплату за ту уйму времени что я потратил на подготовку этого материала...
Комментарии (6)
old_bear
16.08.2021 14:04+1Ну и для того, чтобы воспроизвести этот проект на другом компьютере, придётся либо заново рисовать подобную схему, либо писать достаточно мудрёные скрипты для автоматизации создания проекта.
Я тоже глубокий поклонник HDL в противовес графическому представлению, но справедливости ради хочу заметить, что в меню Vivado существует пункт сохранения проекта в виде TCL-скрипта. И этот скрипт потом разворачивается в исходный проект одной командой.
P.S. Извините, немного промахнулся - это ответ на комментарий @Abwindzentrierer.
Mirn
16.08.2021 17:071. а этот результирующий TCL-скрипт нагляден? глядя на него можно понять что к чему не заглядывая в схему?
2. можно ли его изменить и эти изменения корректно будут отображены в обратной конвертации в схему? не придётся пересчитывать хеши и прочие MD5 суммы и прочие доп действия совершать по правке служебных или бинарных данных?
спасибо.old_bear
16.08.2021 17:43+2Ну это довольно нудная последовательность с перечислением внутренних объектов в проекте и их свойств. В принципе он понятен, если вы ориентируетесь в этих объектах и свойствах. Там даже какие-то комментарии добавляются автоматически.
Но есть важный момент, который я кажется не совсем корректно описал. Этот скрипт именно для самого проекта и его свойств, включая ссылки на используемые исходники. А схематик (извините за англицизм) - это как раз один из вариантов исходников и он хранится в файле с расширением bd (aka block design) который тоже представляет из себя текстовик, но уже в каком-то кастомном формате. Этот текстовик ещё длиннее и нуднее поскольку описывает все используемые в схематике IP и их свойства и соединения, и пожалуй не предполагает что его будут читать и редактировать живые люди. Как я понимаю, основная идея этих файлов в возможности хранить их в системах версионирования и переносить состояние проекта, а не редактировать их напрямую.Насколько я знаю, никаких хешей и прочего в этих файлах нет. Скрипт проекта я даже в какие-то моменты редактировал для своих нужд. С файлом схемы я руками никогда не ковырялся, поскольку вообще стараюсь избегать как самого схематика, так и использования стороннего IP в своих проектах. По крайней мере в той части, которую я контролирую и храню в гите.
Не исключено, что схематик тоже можно "нарисовать" TCL-командами и есть ненулевая вероятность, что эти TCL команды Vivado автоматически сохраняет в лог, когда схематик рисуется руками. Но это моё предположение основанное на косвенных наблюдениях, в явном виде я его не проверял.
Abwindzentrierer
Спасибо за статью!
Позвольте озвучить своё мнение по процессу разработки для ПЛИС и СНК. Если Вас интересует разработка логики, я бы рекомендовал следующим шагом уходить от работы с графическим интерфейсом, и писать весь код текстом. Это сложно только на первый взгляд, но в итоге оказывается более выигрышной стратегией. Проекты с малым числом компонент ещё более-менее возможно поддерживать/отлаживать/изучать в схематичном виде. Но уже даже в Вашем примере, на схеме видна весьма "запутанная" структура с большим количеством соединений. К примеру, если потребуется увеличить количество входов до 20-30, то подобная операция потребует очень много времени, а схема превратится в монструозную лапшу. Тогда как грамотно написанный HDL-код позволит сделать такое изменение исправлением одной константы. Так же с чистым HDL-кодом удобнее работать в системах контроля версий: схему невозможно просмотреть без установки тяжеловесного Vivado. А исходный код на verilog'e или VHDL воспринимается значительно проще. Ну и для того, чтобы воспроизвести этот проект на другом компьютере, придётся либо заново рисовать подобную схему, либо писать достаточно мудрёные скрипты для автоматизации создания проекта. Если проект создан в текстовом виде, то достаточно будет загрузить необходимые файлы и сконфигурировать систему процессора. И, конечно, чистый код легче сделать платформо- и плисинонезависимым.
А ещё, следующим шагом я бы изменил этот проект так, чтобы модуль управления счётчиками подключался напрямую к AXI-шине. Это бы помогло сэкономить ресурсы ПЛИС и сильно упростило весь проект.
megalloid Автор
Вы совершенно правы! Но пока что у меня опыта не хватает, чтобы предпринимать оптимальные действия для решения задач. Большое спасибо за совет, попробую в эту сторону покопать, как можно было бы описать все подключения внутри HDL-кода. В Quartus я это делал, а вот с Xilinx Vivado есть вопросы...
А в целом, как и предложил @old_bear можно в дополнение к картинкам с block design формировать TCL-скрипты и выкладывать их так же вместе с исходниками.