Продолжаю повествование о том, как проходит мое изучение возможностей отладочной платы с 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! 

И тут в голове оформилась интересная задача:

  1. Нужно организовать внутри ПЛИС набор счетчиков, которые будут считать импульсы с частотой до 2МГц;

  2. Счетчики должны быть включаемыми\выключаемыми;

  3. Счетчики должны быть сбрасываемыми;

  4. Для включения, выключения и сброса счетчиков должны быть отдельные регистры управления;

  5. Счётчики должны работать независимо друг от друга и синхронно;

  6. Управление и считывание данных из счётчиков должно быть доступно из PS или Linux;

  7. Счётчики должны считать точное количество импульсов (допустимое отклонение 1-2 импульса).

Исходя из постановки задачи был сформирован четкий план действий:

  1. Создаем проект, настраиваем Processing System и Processor System Reset. 

  2. Настраиваем соответствующие пины ПЛИС, которые будут входами для импульсов;

  3. В ПЛИС делаем блок ограничения импульсов частотой более 2 МГц и проверяем генератором сигналов или другой ПЛИС;

  4. Добавляем счетчик импульсов в ПЛИС и проверяем корректность счёта сгенерировав конкретное количество импульсов другой ПЛИС;

  5. Организуем некую память в которую можно записывать и из которой можно читать данные как из ПЛИС, так и из PS-части. В этой же памяти внутри ПЛИС организуем две ячейки памяти которые будут выступать в качестве двух регистров управления (сброс, включение и выключение) и одну в качестве регистра хранения команд;

  6. Организуем блок управления счетчиками внутри ПЛИС, который сможет опрашивать память на предмет наличия команды из PS, и по команде “забирать” данные из счётчиков и сохранять их в память, сбрасывать значение и включаться\выключаться.

  7. Протестировать полученный результат с использованием 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 в размере . Нам этого будет достаточно для записи. Оставим значение по умолчанию.

Блок управления счетчиками

Для того, чтобы осуществить доступ к 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.  Итого нам нужно сделать целый список портов:

  1. Вход для сигнала тактирования;

  2. Вход для сброса;

  3. Входные 32-битные шины с текущим значением счётчиков для последующей записи в память;

  4. Выходные сигналы включения\выключения счётчиков;

  5. Выходные сигналы для сброса значения счётчиков;

  6. Выходной сигнал Write Enable для Block Memory Generator, сигнализирующий ему о том, что будет производиться запись в память;

  7. Выходная 32-битная шина Address будет сообщать в какую ячейку памяти нужно будет проводить запись или из которой нужно производить чтение;

  8. 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 штук

Настраиваем пробники на ширину данных и расставляем их на схеме. Полная схема того, что вышло после подключения лог. анализатора:

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

На будущее отмечу общую последовательность работы с лог.анализатором:

  1. Заливаем из Hardware manager в Vivado bitstream-файл в ПЛИС;

  2. Открываем SDK и делаем Clean Project;

  3. Запускаем приложение (в случае если мы отлаживаемся в baremetal-приложении);

  4. Нажимаем кнопку Refresh device и откроется логический анализатор;

  5. Расставляем нужные триггеры на нужных линиях и наблюдаем за результатом;

Попробуйте подебажить самостоятельно. Там ничего сложного нет.

Проверим, что получилось

Теперь можно подготовить проект к синтезу и имплементации с генерацией bitstream-файла. 

  1. На основном поле дизайна нажимаем Validate Design и проверяем, всё ли хорошо. 

  2. В левом меню Hierarchy правой кнопкой кликаем на файле zynq.bd и делаем команду Create HDL Wrapper, выбираем Let Vivado manage wrapper and auto-update и нажимаем ОК

  3. После нажимаем команду в левой части окна 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;
}

Исходный код можно взять тут.

Если описать то, что делает этот код то получается следующий алгоритм:

  1. Инициализируем платформу;

  2. Записываем новые значения в регистр ENA, чтобы включились первые три счётчика;

  3. Отправляем команду ENA в регистр команд;

  4. Записываем новые значения в регистр RST, чтобы был отключен сигнал сброса на трех счётчиках (0 - сброс активен, 1 - не активен);

  5. Отправляем команду RST в регистр команд;

  6. Теперь наши счетчики могут считать входящие импульсы и мы можем проводить чтение. Делаем это чтение с помощью команды WRT в цикле;

  7. Выводим значения ячеек памяти в цикле;

После загружаем 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-программы на Си.

Для этого нам нужно:

  1. Пересобрать образ BOOT.BIN собранный в прошлых занятиях с новым bitstream-файлом собранным в этом проекте и залить его на SD-карту заменив предыдущий.  Прочитать об этом можно в этой статье, а готовый BOOT.BIN и все остальные файлы можно тут;

  2. Установить кросс-компилятор и откомпилировать исходный файл нашей программы для Linux;

  3. Загрузить программу на плату и запустить её;

  4. Включив счетчики, отключив сигнал сброса, послать определенное количество импульсов и убедиться, что всё работает как задумано изначально;

Перейдем к реализации намеченного. Устанавливаем кросс-компилятор:

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

После этого можно загрузить файл на плату и проверить работает ли наша программа. Вариантов загрузки может быть несколько:

  1. Предварительно положить файлы на SD-карту и после загрузки Linux примонтировать раздел на котором она будет расположена;

  2. Стянуть файл по локальной сети подключив 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)


  1. Abwindzentrierer
    16.08.2021 09:22
    +3

    Спасибо за статью!

    Позвольте озвучить своё мнение по процессу разработки для ПЛИС и СНК. Если Вас интересует разработка логики, я бы рекомендовал следующим шагом уходить от работы с графическим интерфейсом, и писать весь код текстом. Это сложно только на первый взгляд, но в итоге оказывается более выигрышной стратегией. Проекты с малым числом компонент ещё более-менее возможно поддерживать/отлаживать/изучать в схематичном виде. Но уже даже в Вашем примере, на схеме видна весьма "запутанная" структура с большим количеством соединений. К примеру, если потребуется увеличить количество входов до 20-30, то подобная операция потребует очень много времени, а схема превратится в монструозную лапшу. Тогда как грамотно написанный HDL-код позволит сделать такое изменение исправлением одной константы. Так же с чистым HDL-кодом удобнее работать в системах контроля версий: схему невозможно просмотреть без установки тяжеловесного Vivado. А исходный код на verilog'e или VHDL воспринимается значительно проще. Ну и для того, чтобы воспроизвести этот проект на другом компьютере, придётся либо заново рисовать подобную схему, либо писать достаточно мудрёные скрипты для автоматизации создания проекта. Если проект создан в текстовом виде, то достаточно будет загрузить необходимые файлы и сконфигурировать систему процессора. И, конечно, чистый код легче сделать платформо- и плисинонезависимым.

    А ещё, следующим шагом я бы изменил этот проект так, чтобы модуль управления счётчиками подключался напрямую к AXI-шине. Это бы помогло сэкономить ресурсы ПЛИС и сильно упростило весь проект.


    1. megalloid Автор
      16.08.2021 15:16

      Вы совершенно правы! Но пока что у меня опыта не хватает, чтобы предпринимать оптимальные действия для решения задач. Большое спасибо за совет, попробую в эту сторону покопать, как можно было бы описать все подключения внутри HDL-кода. В Quartus я это делал, а вот с Xilinx Vivado есть вопросы...

      А в целом, как и предложил @old_bear можно в дополнение к картинкам с block design формировать TCL-скрипты и выкладывать их так же вместе с исходниками.


  1. old_bear
    16.08.2021 14:04
    +1

    Ну и для того, чтобы воспроизвести этот проект на другом компьютере, придётся либо заново рисовать подобную схему, либо писать достаточно мудрёные скрипты для автоматизации создания проекта. 

    Я тоже глубокий поклонник HDL в противовес графическому представлению, но справедливости ради хочу заметить, что в меню Vivado существует пункт сохранения проекта в виде TCL-скрипта. И этот скрипт потом разворачивается в исходный проект одной командой.

    P.S. Извините, немного промахнулся - это ответ на комментарий @Abwindzentrierer.


    1. Mirn
      16.08.2021 17:07

      1. а этот результирующий TCL-скрипт нагляден? глядя на него можно понять что к чему не заглядывая в схему?
      2. можно ли его изменить и эти изменения корректно будут отображены в обратной конвертации в схему? не придётся пересчитывать хеши и прочие MD5 суммы и прочие доп действия совершать по правке служебных или бинарных данных?
      спасибо.


      1. old_bear
        16.08.2021 17:43
        +2

        1. Ну это довольно нудная последовательность с перечислением внутренних объектов в проекте и их свойств. В принципе он понятен, если вы ориентируетесь в этих объектах и свойствах. Там даже какие-то комментарии добавляются автоматически.
          Но есть важный момент, который я кажется не совсем корректно описал. Этот скрипт именно для самого проекта и его свойств, включая ссылки на используемые исходники. А схематик (извините за англицизм) - это как раз один из вариантов исходников и он хранится в файле с расширением bd (aka block design) который тоже представляет из себя текстовик, но уже в каком-то кастомном формате. Этот текстовик ещё длиннее и нуднее поскольку описывает все используемые в схематике IP и их свойства и соединения, и пожалуй не предполагает что его будут читать и редактировать живые люди. Как я понимаю, основная идея этих файлов в возможности хранить их в системах версионирования и переносить состояние проекта, а не редактировать их напрямую.

        2. Насколько я знаю, никаких хешей и прочего в этих файлах нет. Скрипт проекта я даже в какие-то моменты редактировал для своих нужд. С файлом схемы я руками никогда не ковырялся, поскольку вообще стараюсь избегать как самого схематика, так и использования стороннего IP в своих проектах. По крайней мере в той части, которую я контролирую и храню в гите.
          Не исключено, что схематик тоже можно "нарисовать" TCL-командами и есть ненулевая вероятность, что эти TCL команды Vivado автоматически сохраняет в лог, когда схематик рисуется руками. Но это моё предположение основанное на косвенных наблюдениях, в явном виде я его не проверял.


        1. Mirn
          17.08.2021 11:31
          +1

          Большое спасибо за развёрнутый комментарий!