Здравствуй! На связи вновь Павел Панкратов — ведущий инженер-программист дивизиона искусственного интеллекта YADRO. Мы добрались до финала моего повествования о параллельном запуске двух операционных систем на FPGA с процессорной подсистемой.
В этой статье мы запустим подготовленный проект и верифицируем его. А в качестве бонуса посмотрим на один из способов разработки ПО под Soft-CPU, минуя IDE Vitis. Плюс загрузим ОС Soft-CPU с помощью QEMU.
Напомню, что в первой части мы синтезировали проект программируемой логики и получили файл описания аппаратного обеспечения. Взяв этот файл за основу, во второй части мы разобрались с тонкостями сборки ОС под две архитектуры и подготовили загрузочный носитель. С последним мы вполне можем перейти к практическим испытаниям, однако перед этим я все же сделаю краткий экскурс в теоретические аспекты, связанные с загрузкой нашего программно-аппаратного решения.
Порядок загрузки платформы
Первым этапом, сразу после подачи питания или события “reset”, выполняется код, зашитый производителем в BootROM (Read-Only Memory). Его основная задача — базовая конфигурация системы, определение источника для загрузки First Stage Boot Loader (FSBL) и его загрузка в On Chip Memory (OCM). Будучи успешно загруженным, FSBL получает управление.
В процессе работы FSBL инициализируются все компоненты платформы, в том числе оперативная память. В программируемую логику прошивается битстрим, загружается ARM Trusted Firmware (ATF). В ОЗУ размещается Secondary Stage Bootloader (SSBL). В этом проекте в качестве вторичного загрузчика используем U-Boot.
К слову, загрузка SSBL не обязательна, его может замещать bare-metal приложение. Также процесс загрузки на данном этапе может варьироваться в зависимости от особенностей конфигурации — например, связанных с доверенной загрузкой, дешифровкой и аутентификацией загрузочных образов. Мы опустим эти нюансы, полагая, что итогом работы FSBL будет передача управления SSBL — в нашем случае так и происходит.
Основная задача SSBL (U-Boot) — определение источника и загрузка образа ОС в ОЗУ, с дальнейшей передачей управления на точку входа. Этот этап нам особо интересен, так как нам нужно загрузить два образа Embedded Linux. Поговорим об этом чуть позже.
Получив управление, ОС загружается в штатном режиме, начиная с определения местоположения DTB и initramfs, монтирования последней, запуска процесса init и заканчивая полной загрузкой средствами системы инициализации.
Напомню, что весь необходимый набор загрузчиков мы получили в процессе сборки ОС Hard-CPU средствами Petalinux. На SD-карте загрузчики, как и битстрим для программируемой логики, упакованы в файл BOOT.bin.
Рассмотрев весь загрузочный конвейер от начала до конца, давайте углубимся в наиболее интересные этапы. Первую остановку сделаем на вторичном загрузчике.
Загрузочный скрипт U-Boot
Выше я отметил, что основная задача вторичного загрузчика — определение источника и загрузка образа ОС в оперативную память. В зависимости от конфигурации, заданной при сборке, возможны различные варианты. Например, загрузка фиксированного образа ОС по фиксированному адресу в ОЗУ из заранее определенного источника.
Для нашей платформы (FZ5BOX) этот вариант основной при сборке ОС средствами Petalinux и конфигурации, примененной из поставляемого производителем .bsp-файла. Во второй части цикла я решил собрать ОС без последнего. В таком случае конфигурация по умолчанию предполагает загрузку ОС через исполнение скрипта вторичным загрузчиком. Этот скрипт также генерируется Petalinux и называется boot.scr.
Для целей проекта я выбрал второй из упомянутых вариантов загрузки — с использованием загрузочного скрипта. Обосновано это необходимостью динамически менять набор исполняемых команд для манипуляции образами ОС в целях отладки. В противном случае при неуспешной загрузке мне бы пришлось пересобирать вторичный загрузчик каждый раз для корректировки процесса. Спойлер: с выбором я не ошибся.
Изначально сгенерированный boot.scr несет в себе порядка 100 строк, содержащих заголовок, команды и директивы, понятные загрузчику U-Boot. Они включают в себя:
-
проверку и манипуляцию переменными окружения,
-
определение источника загрузки,
-
определение состава и формата загрузочных образов — в виде отдельных файлов или единого образа и т.д.
Мы, в свою очередь, точно знаем, что будем загружать образы обеих ОС в виде единого файла, содержащего весь набор компонентов (kernel, initramfs, dtb), с заранее определенного носителя. Поэтому большая часть команд нам попросту не нужна. Напишем свой скрипт, который будет состоять всего из четырех команд.
fatload mmc 1 0x10000000 image.ub
fatload mmc 1 0x60000000 simpleImage.system-top
cp.b 0x60000000 0x40000000 0x10000000
bootm 0x10000000
Давайте детально разберем каждую из них.
Первая команда. Команда fatload
отвечает за загрузку образа с определенным именем из носителя с файловой системой fat по заданному адресу. Вспомните, что во второй части мы говорили о загрузочном носителе и его первом разделе, который как раз отформатирован в fat. Конкретно первая команда загружает образ ОС Hard-CPU с именем image.ub
по адресу 0x1000_0000 c первого раздела SD-карты.
Расширение файла .ub специфично для Xilinx (AMD), оно говорит, что перед нами так называемое Flattened Image Tree (FIT). FIT — один из способов упаковки требуемых компонентов в единый образ. Этот формат понятен U-Boot и имеет широкие возможности. Детали о нем вы найдете по ссылке. Посмотреть состав FIT-образа можно одним из следующих способов.
dumpimage -l или mkimage -l
Давайте взглянем на состав нашего образа:
# mkimage -l image.ub
FIT description: Kernel fitImage for PetaLinux/6.6.10-xilinx-v2024.1+gitAUTOINC+3af4295e00/zynqmp-generic-xczu5ev
Created: Sat Apr 27 08:22:24 2024
Image 0 (kernel-1)
Description: Linux kernel
Created: Sat Apr 27 08:22:24 2024
Type: Kernel Image
Compression: gzip compressed
Data Size: 10443017 Bytes = 10198.26 KiB = 9.96 MiB
Architecture: AArch64
OS: Linux
Load Address: 0x00200000
Entry Point: 0x00200000
Hash algo: sha256
Hash value: 1c8c0529280db6ec9ed8be2bd26e6ae3aaca47429daf6c2dbbcd84975eb3a16d
Image 1 (fdt-system-top.dtb)
Description: Flattened Device Tree blob
Created: Sat Apr 27 08:22:24 2024
Type: Flat Device Tree
Compression: uncompressed
Data Size: 39995 Bytes = 39.06 KiB = 0.04 MiB
Architecture: AArch64
Hash algo: sha256
Hash value: 8f666bd2435dbc2a13b181c563d084b7179fc5a87d368e47bd2211891b071cfc
Image 2 (ramdisk-1)
Description: petalinux-image-minimal
Created: Sat Apr 27 08:22:24 2024
Type: RAMDisk Image
Compression: uncompressed
Data Size: 27717335 Bytes = 27067.71 KiB = 26.43 MiB
Architecture: AArch64
OS: Linux
Load Address: unavailable
Entry Point: unavailable
Hash algo: sha256
Hash value: d4940aa2518f00edf7e79d9d79b54e9e956bdcb14853f739c10aa36efb90b6b8
Default Configuration: 'conf-system-top.dtb'
Configuration 0 (conf-system-top.dtb)
Description: 1 Linux kernel, FDT blob, ramdisk
Kernel: kernel-1
Init Ramdisk: ramdisk-1
FDT: fdt-system-top.dtb
Hash algo: sha256
Hash value: unavailable
Как мы можем убедиться, в составе образа три компонента: kernel, dtb и initramfs. Для ядра заданы адрес загрузки и точка входа, которые будут использованы загрузчиком для запуска ОС.
Вторая команда. Образов ОС у нас два, а значит, команд загрузки тоже несколько. Вторая команда fatload
загружает уже образ ОС Soft-CPU по адресу 0x6000_0000. Загружаемый образ в этом случае имеет простое бинарное представление (не FIT). Адрес загрузки самого образа будет совпадать с адресом точки входа в ОС.
Читатели, которые помнят процесс резервирования памяти для нужд Soft-CPU, как и сам процесс сборки ОС, вероятно возразят: «Стоп! Но ведь мы резервировали регион памяти, начиная с адреса 0x4000_0000! Да и точку входа в ОС при конфигурации ядра мы указали аналогичной. Почему же тут адрес 0x6000_0000!?». Тут кроется первый подводный камень.
Все дело в том, что в процессе загрузки U-Boot и ядро ОС Hard-CPU используют единый devicetree-файл. В этом файле мы зарезервировали регион памяти с адреса 0x4000_0000 для исключения его совместного использования обоими процессорами. Попытка выполнить команду fatload
по данному адресу приведет к ошибке. Вторичный загрузчик сообщит нам о невозможности размещения образа по этому адресу из-за того, что последний зарезервирован. Существует несколько способов решения проблемы.
Первый, пожалуй, наиболее грамотный. Нужно разделить файлы devicetree для U-Boot и для ОС Hard-CPU. Сделать это можно, сконфигурировав U-Boot в процессе сборки. Метод понятен, но весьма нетривиален. Придется вручную готовить отдельный файл devicetree для U-Boot, в котором не нужно резервировать регион памяти для Soft-CPU. В целом, на этапе исполнения вторичного загрузчика Soft-CPU еще не функционирует, поэтому ничего страшного не произойдет. Однако это целый набор дополнительных манипуляций. Я покажу вам более простую альтернативу.
Второй способ — добавить в загрузочный скрипт команду блочного копирования cp.b
(третья команда из четырех в нашем скрипте). С ее помощью мы можем скопировать содержимое одного региона памяти в другой. По счастливому стечению обстоятельств реализация этой команды не подразумевает проверку регионов на резервирование. Таким образом третья команда копирует загруженный в ОЗУ образ ОС Soft-CPU с адреса 0x6000_0000 на адрес 0x4000_0000. Напомню, что размер зарезервированного региона у нас 512 МБ. Мы копируем 256 МБ (0x1000_0000, третий аргумент команды).
На самом деле можно копировать еще меньше — достаточно объема не меньше, чем размер образа ОС (десятки мегабайт). 256 МБ для этого точно достаточно. В то же время копирование 256 МБ занимает доли секунды, поэтому я решил не заморачиваться и оставить достоверно достаточный объем. Как вы уже, вероятно, поняли, в результате выполнения команды cp.b
наш образ будет расположен по требуемому адресу 0x4000_0000.
Четвертая команда. Команда bootm
осуществляет процесс запуска ОС Hard-CPU. Ее аргументом является адрес загруженного FIT-образа (0x1000_0000).
Чтобы этот набор команд был понятен U-Boot, нам необходимо добавить определенный заголовок. Последний представляется в бинарном формате и добавляется к текстовому файлу следующей командой:
# mkimage –A arm –T script –C none –d boot.txt boot.scr
Здесь я подразумевал, что мы написали скрипт самостоятельно и сохранили его как файл boot.txt. Если мы захотим модифицировать уже существующий файл boot.scr, то нам сперва необходимо будет обрезать бинарный заголовок, а потом вновь его добавить. Обрезать заголовок можно командой:
# dd if=boot.scr of=boot.txt bs=72 skip=1
На этом все необходимые действия выполнены, и мы можем двигаться дальше по процессу загрузки. Передав управление на точку входа ОС Hard-CPU, мы фактически перейдем к ее запуску. На нем я подробно останавливаться не буду, никакой магии и особых нюансов здесь нет. Следующую остановку предлагаю сделать на этапе запуска ОС Soft-CPU, но перед этим я пролью свет на ряд связанных с этим процессом вещей.
Пробуждение Soft-CPU и организация канала связи
Начнем с итогов первой и второй частей моего повествования. После подачи питания на аппаратную платформу, согласно конфигурации IP-блока MicroBlaze, последний переходит в режим ожидания до появления на его порту Wakeup логической единицы. Для организации этого процесса мы разместили блок AXI-GPIO и в процессе сборки ОС Hard-CPU назначили ему драйвер Linux Kernel's Userspace I/O system при конфигурации devicetree. Это означает, что мы сможем установить логическую единицу на порт Wakeup простой записью в соответствующий регистр, отображенный на физическую память.
В нашем случае блок AXI-GPIO отображен на адрес 0xA000_0000. Давайте посмотрим, в какой конкретный регистр и что именно нам нужно записать, согласно руководству.
Также вспомним, как мы конфигурировали этот блок в проекте программируемой логики.
У нас задействован только первый канал GPIO. В нем мы использовали 8 портов GPIO: два первых в качестве выходных и шесть оставшихся в качестве входных. Выходные мы соединили с портом Wakeup, а на входные подали статусные сигналы от MicroBlaze. Изначальное значение всех выходных портов установлено в логический 0.
Согласно карте регистров AXI-GPIO, адрес ячейки, отвечающей за сигналы GPIO первого канала, имеет смещение 0x00 от базового адреса (0xA000_0000). Значит, для подачи логической единицы на первый порт нам необходимо записать 0x1 по адресу 0xA000_0000. Также в тексте над картой регистров упомянуто, что последние имеют побайтовый доступ. Этого нам как раз достаточно, чтобы записать/считать сразу все состояния наших 8 портов GPIO.
Ранее я упоминал, что в состав образа Embedded Linux от Xilinx (AMD) входит утилита devmem, позволяющая читать и писать в произвольный адрес памяти.
# devmem
BusyBox v1.35.0 () multi-call binary.
Usage: devmem ADDRESS [WIDTH [VALUE]]
Read/write from physical address
ADDRESS Address to act upon
WIDTH Width (8/16/...)
VALUE Data to be written
Таким образом, чтобы вывести MicroBlaze из режима ожидания, нам необходимо выполнить следующую команду в консоли ОС Hard-CPU:
# devmem 0xA0000000 8 0x1
Пробудив MicroBlaze, нам необходимо организовать с ним канал связи. Для этого я разместил два блока AXI-Uartlite и соединил их линии Tx и Rx между собой. Отдав один блок в ведение Hard-CPU, а второй — в ведение Soft-CPU, мы получили возможность организовать искомый канал связи. Используя утилиту screen в консоли ОС Hard-CPU с указанием в качестве аргумента устройства /dev/ttyUL1, мы сможем открыть последнее как консоль, которая по внутреннему каналу будет связана с Soft-CPU.
Чуть более подробно про процесс и мотивацию
Вероятно, возникает вопрос, почему именно /dev/ttyUL1, как я это определил. Во-первых, устройство называется tty(Uart Lite) ака ttyUL. Его номер можно определить/сконфигурировать в devicetree. Рассмотрим на примере ноды axi_uartlite_1 в devicetree Hard-CPU (аналогичная нода в devicetree Soft-CPU называется axi_uartlite_0).
axi_uartlite_1: serial@a0010000 {
clock-names = "s_axi_aclk";
clocks = <&zynqmp_clk 71>;
compatible = "xlnx,axi-uartlite-2.0", "xlnx,xps-uartlite-1.00.a";
current-speed = <115200>;
device_type = "serial";
interrupt-names = "interrupt";
interrupt-parent = <&gic>;
interrupts = <0 89 1>;
port-number = <1>;
reg = <0x0 0xa0010000 0x0 0x10000>;
xlnx,baudrate = <0x1c200>;
xlnx,data-bits = <0x8>;
xlnx,odd-parity = <0x0>;
xlnx,s-axi-aclk-freq-hz-d = "99.999001";
xlnx,use-parity = <0x0>;
};
Здесь нас интересует свойство port-number. Число, присвоенное ему, и будет номером создаваемого при загрузке драйвером устройства. В проекте программируемой логики мы разместили два блока AXI-Uartlite. Тот, что с номером 0, отдали Soft-CPU, а с номером 1 — Hard-CPU. IDE автоматически перенесла номера IP-блоков в свойство port-number. Поэтому со стороны Soft-CPU у нас будет устройство /dev/ttyUL0, а со стороны Hard-CPU — /dev/ttyUL1. В нашем случае эти номера можно задать одинаковыми, так как устройства используются разными ОС, но я не стал ничего менять.
Устройство /dev/ttyUL0 будет использовано Soft-CPU как консоль по умолчанию. Устройство /dev/ttyUL1 мы откроем как консоль через утилиту screen. Так как оба этих UART связаны друг с другом (Tx-линия одного в Rx-линию другого), то, обращаясь из консоли к axi_uartlite_1, мы автоматом будем передавать данные на axi_uartlite_0 и наоборот. Записываемые Soft-CPU данные в axi_uartlite_0 будут отображаться через axi_uartlite_1 в нашей консоли открытой утилитой screen.
Теперь о мотивации. В комментариях к первой части справедливо отмечено, что по возможности лучше использовать уже реализованные в железе IP-блоки. Это как минимум экономит ресурсы программируемой логики. Я упоминал, что есть несколько вариантов реализации связи с Soft-CPU, в том числе средствами «железного» UARTа. Основная причина, почему я не выбрал такой путь, в том, что наша платформа (FZ5BOX) с двумя UART, выведенными на корпус, предоставляет встроенный USB-конвертер только для одного из них. Второй UART выведен пинами и подразумевает подключение внешнего USB-конвертера. Когда я работал над проектом, «подручные» конвертеры активно использовались для других целей. Поэтому я решил использовать, а потом и осветить альтернативный способ организации связи. На мой взгляд, он менее тривиален и не менее интересен.
Говоря о занятых ресурсах, напомню, что наша платформа базируется на Zynq UltraScale+. Ниже — скриншот со статистикой использованных ресурсов.
Полагаю, теперь вы понимаете, что экономия ресурсов была далеко не во главе угла :)
Резюмируя, для запуска и организации связи с Soft-CPU мы используем последовательно две команды в консоли ОС Hard-CPU.
# devmem 0xA0000000 8 0x1
# screen /dev/ttyUL1
Как вариант, можно написать небольшой скрипт, содержащий эти команды. Запускать его вручную или автоматически при загрузке ОС Hard-CPU.
Теперь можно смело выходить на финишную прямую — приступаем к запуску ОС Soft-CPU.
Запуск ОС Soft-CPU
С запуском ОС Hard-CPU все более или менее понятно. Мы загрузили образ Embedded Linux в ОЗУ средствами вторичного загрузчика и передали управление на точку входа.
А как быть с ОС Soft-CPU? Образ последней мы также загрузили в ОЗУ средствами U-Boot, но передать ей управление тем же загрузчиком мы уже не сможем. Это очевидно по двум причинам:
-
код загрузчика выполняется Hard-CPU, который архитектурно несовместим с инструкциями для Soft-CPU. Поэтому, передав управление на точку входа в ОС Soft-CPU напрямую из U-Boot, мы ничего не добьемся. Hard-CPU уйдет в исключение, так как наткнется на непонятный ему формат инструкций. Да и цель у нас другая. Мы все же хотим запустить исполнение на Soft-CPU.
-
согласно нашему загрузочному скрипту, U-Boot передает управление на точку входа ОС Hard-CPU и более ничего не может делать. Да и, к слову, MicroBlaze мы пробуждаем, уже загрузив ОС Hard-CPU. А значит, это тупиковый путь.
В предыдущих частях я уже ссылался на руководство к MicroBlaze, в котором отмечено: после подачи питания или пробуждения последний начинает исполнять инструкции с адреса, указанного как базовый для таблицы векторов прерываний. Этот адрес задается в расширенных настройках IP-блока.
Вспоминая, что мы загрузили образ ОС Soft-CPU по адресу 0x4000_0000, который по совместительству является и точкой входа, возникает логичное желание указать его как базовый адрес. Тогда после пробуждения MicroBlaze сразу начнет исполнение с адреса 0x4000_0000. Казалось бы, вот она — победа, но не тут-то было. Этот адрес — базовый в таблице векторов прерываний.
Эта таблица не только является точкой старта исполнения инструкций процессором, но еще и должна содержать корректные адреса обработчиков прерываний, которые генерируются разными событиями. Для нас это крайне важно, так как Embedded Linux завязан на обработку прерываний от таймера и прочей периферии.
Получается, прежде чем праздновать победу, необходимо убедиться, что образ ОС содержит эту таблицу с корректными адресами в самом его начале. Для решения задачи давайте обратимся к специфичному для нашей архитектуры коду инициализации. Его можно найти в репозитории ядра по пути arch/microblaze/kernel.
Для начала заглянем в файл head.S. В нем и содержится код, который находится в начале образа. На момент написания статьи со строки номер 63 располагаются самые первые инструкции, которые будут включены в состав образа ОС:
ENTRY(_start)
#if CONFIG_KERNEL_BASE_ADDR == 0
brai TOPHYS(real_start)
.org 0x100
real_start:
#endif
mts rmsr, r0
/* Disable stack protection from bootloader */
mts rslr, r0
addi r8, r0, 0xFFFFFFFF
mts rshr, r8
Взглянув на них, становится понятно, что это совсем не похоже на таблицу векторов прерываний. Но вдруг мы что-то перепутали. Как в этом убедиться? А убедиться в этом можно также путем дизассемблирования нашего образа Embedded Linux. Напомню, во второй части цикла мы собрали образ ОС средствами совместимого тулчейна, входящего в состав стандартной поставки Vivado/Vitis. Он находится по пути …/Vitis/2024.1/gnu/microblaze/linux_toolchain/lin64_le/bin/microblazeel-xilinx-linux-gnu-. Им-то мы и воспользуемся для дизассемблирования.
Теперь еще один важный момент: дизассемблировать мы хотим образ ядра, а не единый, совмещенный бинарный образ, состоящий из kernel, initramfs и dtb. Делать для этого дополнительно ничего не надо, образ ядра автоматически сгенерирован в процессе построения ОС Soft-CPU и находится в корне рабочей директории. Он называется vmlinux. Выполняем следующую команду:
# microblazeel-xilinx-linux-gnu-objdump -d vmlinux | more
vmlinux: file format elf32-microblazeel
Disassembly of section .text:
c0000000 <_start>:
c0000000: 9400c001 mts rmsr, r0
c0000004: 9400c800 mts rslr, r0
c0000008: 2100ffff addi r8, r0, -1
c000000c: 9408c802 mts rshr, r8
Как мы можем убедиться, все верно. Образ Embedded Linux действительно начинается не с таблицы векторов прерываний. Но она все же должна где-то быть. На этапе построения ядра ОС системе сборки известны адреса всех обработчиков прерываний, и она должна куда-то их размещать. Продолжаем наши поиски — теперь обратимся к файлу entry.S.
На момент написания статьи нас интересует содержимое, начиная со строки 1236.
ENTRY(_reset)
VM_OFF
brai 0; /* Jump to reset vector */
/* These are compiled and loaded into high memory, then
* copied into place in mach_early_setup */
.section .init.ivt, "ax"
#if CONFIG_MANUAL_RESET_VECTOR && !defined(CONFIG_MB_MANAGER)
.org 0x0
brai CONFIG_MANUAL_RESET_VECTOR
#elif defined(CONFIG_MB_MANAGER)
.org 0x0
brai TOPHYS(_xtmr_manager_reset);
#endif
.org 0x8
brai TOPHYS(_user_exception); /* syscall handler */
.org 0x10
brai TOPHYS(_interrupt); /* Interrupt handler */
#ifdef CONFIG_MB_MANAGER
.org 0x18
brai TOPHYS(_xmb_manager_break); /* microblaze manager break handler */
#else
.org 0x18
brai TOPHYS(_debug_exception); /* debug trap handler */
#endif
.org 0x20
brai TOPHYS(_hw_exception_handler); /* HW exception handler */
Бинго! Вот и она — таблица векторов прерываний. Если вы не очень разбираетесь в ассемблере, не отчаивайтесь, сам код нам не так интересен. Нужные выводы мы сделаем из комментария, расположенного в начале. Приведу его отдельно.
/* These are compiled and loaded into high memory, then
* copied into place in mach_early_setup */
Буквально здесь сказано, что этот код компилируется и загружается в раздел high memory, а затем копируется в нужное место в рутине mach_early_setup. Что ж, нам осталось разобраться, как определяется то самое нужное место? Заглянем в рутину mach_early_setup. Она находится в файле setup.c и, если быть более точным, называется machine_early_init.
Нас в первую очередь интересует строка 93. В ней объявляется некая переменная offset:
unsigned int offset = 0;
Далее нас будет интересовать код, начиная со строки 161.
/* Do not copy reset vectors. offset = 0x2 means skip the first
* two instructions. dst is pointer to MB vectors which are placed
* in block ram. If you want to copy reset vector setup offset to 0x0 */
#if !CONFIG_MANUAL_RESET_VECTOR
offset = 0x2;
#endif
dst = (unsigned long *) (offset * sizeof(u32));
for (src = __ivt_start + offset; src < __ivt_end; src++, dst++)
*dst = *src;
Проанализировав этот код, становится понятным, что копирование таблицы векторов прерываний осуществляется по адресу 0x0 и он жестко фиксирован. Конечно, есть одно условие, по которому копирование осуществляется по адресу 0x2 (но только в случае, если мы не копируем вектор события reset). О последнем как раз говорит комментарий сверху. На самом деле первый звоночек об этом мы могли наблюдать чуть раньше, в файле с кодом таблицы векторов:
ENTRY(_reset)
VM_OFF
brai 0; /* Jump to reset vector */
Этот код говорит о том, что в случае события reset мы отключаем подсистему виртуальной памяти (начинаем адресовать физическую память) и переходим на адрес 0x0. То есть этот код также жестко фиксирует адрес таблицы векторов прерываний, хоть и не участвует в ее копировании.
Что это означает для нас? Мы сталкиваемся с проблемой. Наш выделенный регион памяти ОЗУ для MicroBlaze начинается с адреса 0x4000_0000. Этот адрес четко фиксирован и обоснован в первой части цикла статей. Таким образом, к адресу 0x0 ОЗУ у нас попросту нет доступа.
Давайте искать выход из ситуации. Первое, что приходит в голову, — переписать инициализирующий код ОС таким образом, чтобы убрать жестко зафиксированный адрес 0x0 и вместо него указать необходимый нам (0x4000_0000). Вариант реализуемый, но, увы, нетривиальный и не самый элегантный. Мне бы все-таки хотелось оставить код ядра операционной системы нетронутым. Это позволит в дальнейшем проще переходить к новым версиям ядра без адаптации изменений. Да и желающим воспроизвести результаты этого проекта будет меньше работы.
Намек на второй вариант решения можно подсмотреть в комментариях к рутине копирования таблицы векторов прерываний:
/* Do not copy reset vectors. offset = 0x2 means skip the first
* two instructions. dst is pointer to MB vectors which are placed
* in block ram. If you want to copy reset vector setup offset to 0x0 */
В первой части я упоминал, что у MicroBlaze есть интерфейс Local Memory Bus (LMB). Он позволяет организовать доступ к блочной памяти (BRAM), расположенной в программируемой логике. Тогда я лишь упомянул, что мы не будем его исключать. Теперь можно понять почему. Комментарий выше сообщает нам, что таблица векторов прерываний размещается в блочной памяти — и на самом деле это частный случай. Полагаю, можно разместить ее и в оперативной памяти, просто в коде ядра ОС это не предусмотрено. Точнее наша архитектура решения этому не способствует. Если бы мы разместили регион памяти, выделенный под MicroBlaze, начиная с адреса 0x0 ОЗУ, блочную память можно было бы и не использовать. Но что сделано, то сделано, на то были свои причины.
Итак, согласно нашему решению, со стороны Soft-CPU у нас есть регион памяти размером в 128 КБ, расположенный по адресу 0x0. Еще раз взглянем на редактор адресов Vivado IDE, чтобы в этом убедиться.
Наша идея весьма проста: код ОС будет расположен в ОЗУ, начиная с адреса 0x4000_0000, а таблица векторов прерываний в блочной памяти, по адресу 0x0. Таким образом, в процессе инициализации ОС Soft-CPU таблица будет успешно туда скопирована.
Это уже похоже на вполне рабочее решение, но есть еще один нюанс. Раз таблица векторов прерываний будет располагаться в блочной памяти по адресу 0x0, то и базовый адрес, в конфигурации IP-блока MicroBlaze, нам нужно задать соответствующий — 0x0.
Очевидно, что таблица должна содержать корректные адреса векторов прерываний, и они там появятся во время исполнения инициализирующего кода ОС. Последний находится в ОЗУ, начиная с адреса 0x4000_0000. Вот тут и кроется тонкий момент. При подаче питания MicroBlaze начнет исполнение с базового адреса. По этому адресу должна быть корректная таблица прерываний. Но, чтобы она там появилась, должен исполниться инициализирующий код ОС. А чтобы он исполнился, нам нужно как-то туда попасть — для этого уже нужна корректная таблица прерываний. Получается замкнутый круг.
Решение здесь вполне очевидное — написать свою таблицу векторов прерываний. Чем мы и займемся в следующей главе.
Пишем таблицу векторов прерываний
У таблицы векторов прерываний MicroBlaze жестко фиксированный формат и размер. Я уже приводил ее описание выше. С точки зрения кода она представляет собой набор инструкций безусловного перехода с адресом в виде константы — brai
. В этом можно убедиться, взглянув на реализацию таблицы прерываний в файле entry.S.
Механизм работы крайне прост. После подачи питания или события Wakeup MicroBlaze начинает исполнение с базового адреса таблицы векторов прерываний, то есть по факту с самой первой инструкции, которая и определяет reset-вектор. Так как это инструкция brai
, то она незамедлительно передает управление по константному адресу, указанному во время компиляции. Перейдя по этому адресу, уже исполняется произвольный пользовательский код.
Механизм работы прочих прерываний абсолютно аналогичен. В зависимости от источника прерывания MicroBlaze выполняет инструкцию по одному из фиксированных адресов таблицы векторов. Как и в случае с вектором reset, там содержится инструкция brai
, которая направляет Soft-CPU в нужную сторону, по адресу конкретного обработчика.
Сказав ранее, что на момент пробуждения Soft-CPU в блочной памяти уже должна быть корректная таблица векторов прерываний, я немного слукавил. Если быть более точным, нас интересует только вектор reset. Он должен направить нас на точку входа в ОС. Прочие векторы могут и подождать, пока корректные инструкции brai
не будут прописаны в них инициализирующей рутиной. Таким образом, код нашей таблицы векторов прерываний будет выглядеть следующим образом:
# reset vector
brai 0x40000000
# user exception handler
brai 0x0
# interrupt handler
brai 0x0
# break handler
brai 0x0
# hw exception handler
brai 0x0
В качестве целевого адреса вектора reset мы указали адрес точки входа в ОС Soft-CPU — 0x4000_0000. Все прочие адреса мы заполнили 0x0, что при возникновении прерывания снова перебросит нас на вектор reset. Таким образом, поведение CPU определено. В крайнем случае он просто зациклится.
Приведенный выше код поместим в файл vectors.s. Скомпилировать его можно все тем же совместимым тулчейном. Выполняем следующую команду:
# microblazeel-xilinx-linux-gnu-as -o vectors.elf vectors.s
В результате получаем исполняемый файл vectors.elf. Теперь нужно разобраться с вопросом его размещения в блочной памяти. Делается это стандартными механизмами Vivado IDE. Да, нам придется вернуться на самый первый этап нашего проекта и пройти почти весь путь еще раз.
Открываем проект программируемой логики. Переходим в меню Tools → Associate ELF Files.
Ассоциируем полученный файл vectors.elf с блочной памятью MicroBlaze. После этого нажимаем OK. Теперь нам нужно вновь сгенерировать битстрим и упаковать его в образ BOOT.bin, чтобы тот включал в себя обновленную версию. Делается это средствами Vivado IDE и Petalinux. Подробности — в предыдущих частях повествования.
Вот теперь точно все, можно пожинать плоды нашего с вами труда. Убеждаемся, что мы не забыли скопировать новый файл BOOT.bin на SD-карту, и переходим к практическим испытаниям.
Верификация проекта
Перед тем как подать питание на аппаратную платформу, еще раз кратко о порядке загрузки:
-
После подачи питания исполнится код BootROM. Он передаст управление FSBL.
-
FSBL, в свою очередь, инициализирует платформу и передаст управление SSBL (U-Boot).
-
U-Boot, основываясь на boot.scr, разместит оба образа ОС по соответствующим адресам и передаст управление ОС Hard-CPU.
-
После загрузки либо вручную, либо автоматически необходимо выполнить команды для пробуждения Soft-CPU и организации с ним канала связи.
-
После пробуждения Soft-CPU начнет исполнение инструкций с базового адреса 0x0, расположенного в BRAM.
-
По адресу 0x0 находится подготовленная нами таблица векторов прерываний. Ее первая инструкция — безусловный переход на точку входа в ОС Soft-CPU.
-
Как результат загрузки ОС Soft-CPU мы должны получить две параллельно исполняемых на двух CPU операционных системы.
Подаем питание!
Полагаю, что два приведенных выше скриншота не нуждаются в комментариях. Из них видно, что у нас две различные архитектуры и два различных процессора. Оба исполняют Embedded Linux с ядром версии 6.6. FPGA-Arch говорит нам о том, что наша платформа — Zynq UltraScale+. Проект завершен!
Для самых стойких — бонус
Обещанный бонус для тех, кто дошел со мной до финала этого проекта. Начну, пожалуй, с запуска ОС Soft-CPU с помощью эмулятора QEMU.
Запуск ОС Soft-CPU на эмуляторе
В процессе работы над проектом, еще до запуска на целевой аппаратной платформе, я решил проверить работоспособность собранного совместимым тулчейном образа ОС на эмуляторе. Здесь не буду вдаваться в детали работы с эмулятором и задач, которые можно при его помощи решить. Ограничусь лишь тем, что это неплохой способ отлаживать ПО, минуя взаимодействие с реальным железом.
Для эмуляции MicroBlaze, по крайней мере на момент написания статьи, будет недостаточно стандартной поставки QEMU. Нам нужно собрать конкретную версию. Для этого обратимся к репозиторию поставщика. Сам процесс сборки описан в README.
После сборки мы можем запустить эмулятор следующей командой:
# qemu-system-microblazeel -M microblaze-fdt-plnx -m 256 -serial mon:stdio -display none -dtb workdir/arch/microblaze/boot/dts/system-top.dtb -kernel workdir/arch/microblaze/boot/simpleImage.system-top
Ключевым параметром здесь будет эмулируемая машина — microblaze-fdt-plnx. Она требует обязательного параметра -dtb
. Машина эмулирует аппаратные компоненты, основываясь на fdt (flattened device tree). Именно поэтому мы и должны передать в качестве параметра -dtb
(device tree blob) — бинарное представление файла devicetree. Оно было автоматически скомпилировано в процессе сборки ядра ОС Soft-CPU. Но его можно собрать и вручную, используя dtc (device tree compiler). Делается это следующей командой:
# dtc -I dts -O dtb -o file.dtb file.dts
Также в качестве параметра мы указываем сам образ Embedded Linux. Как вы помните, в нашем случае он называется simpleImage.system-top. Полагаю, нет необходимости делать акцент на том, что пути к файлам dtb и simpleImage.system-top должны соответствовать вашей рабочей директории. Остальные параметры задают размер памяти, настраивают консоль и сообщают QEMU, что у нашей системы нет дисплея.
Результатом выполнения команды будет успешная загрузка Embedded Linux:
Разработка bare-metal приложения в обход Vitis
Вторым бонусом я освещу способ разработки bare-metal приложений под MicroBlaze, из консоли, в обход IDE Vitis. В качестве примера предлагаю разработать мини-загрузчик ОС, который мы поместим в BRAM вместо нашей таблицы векторов прерываний.
Разработка ведется при помощи уже знакомой нам по предыдущим частям утилиты xsct, которую можно найти в стандартной поставке Vivado/Vitis. Все последующие команды выполняются из ее консоли. Для получения информации по любой из команд можно вызвать следующее:
xsct% help <command>
Для начала нам нужно установить наше рабочее пространство. На моей билд-машине я выбрал для этого директорию /home/dev/workspace/mb_loader. Делается это следующей командой:
xsct% setws /home/dev/workspace/mb_loader
Затем мы создаем проект приложения:
xsct% app create -name loader -hw /home/dev/workspace/mb_demo/mb_demo.xsa -proc microblaze_0 -os standalone -template “Hello World”
В качестве параметров мы передаем:
-
имя проекта,
-
путь к файлу описания аппаратного обеспечения, полученного в результате синтеза проекта программируемой логики,
-
конкретный процессор в проекте, для которого разрабатывается приложение,
-
тип операционной системы, в нашем случае standalone (без операционной системы, ака bare-metal).
-
шаблон создаваемого приложения. У данной команды много вариаций и масса шаблонов приложений. Посмотреть их можно следующим образом:
xsct% repo -apps
После создания мы можем конфигурировать проект. Например, зададим свойство, отвечающее за конфигурацию построения приложения. Установим опцию release. По умолчанию строится debug:
xsct% app config -name loader -set build-config release
Проверим получившийся результат.
xsct% app config -name loader -get build-config
release
Сконфигурировав приложение, перейдем к разработке. Исходные файлы находятся по пути /home/dev/workspace/mb_loader/loader/src. Конкретные файлы в этой папке зависят от выбранного шаблона. В случае “Hello World” основным является файл helloworld.c. Удалим его и создадим наш собственный с именем loader.c. Содержимое файла приведу ниже:
#include <stdio.h>
#include "platform.h"
#include "xil_printf.h"
#define KERNEL_ADDR 0x40000000
int main()
{
void (*entrypoint)();
// initialize platform
init_platform();
print("nrMicroBlaze Linux kernel loadernr");
/* Here you can place code to actually
load kernel from media to memory */
// start Linux
print("Starting kernel for Habr ...nr");
cleanup_platform();
entrypoint = (void (*)())KERNEL_ADDR;
(*entrypoint)();
return 0;
}
Код примитивен, но для примера этого достаточно. Так как образ ОС Soft-CPU у нас загружается U-Boot, здесь мы его повторно не загружаем. Однако в реальном проекте ничто не мешает вам использовать все возможности и библиотеки для работы с различными носителями, фактически загружать и размещать образ ОС в ОЗУ в соответствии с требованиями. В остальном здесь лишь несколько выводов текста в консоль и передача управления на KERNEL_ADDR — в нашем случае 0x4000_0000.
Построить приложение можно следующей командой:
xsct% app build -name loader
В результате построения создастся каталог с именем, соответствующим конфигурации построения. В нашем случае — Release. Находится он по пути /home/dev/workspace/mb_loader/loader/Release. В этом каталоге находится исполняемый файл loader.elf. Давайте ради интереса его дизассемблируем и посмотрим, что получилось:
# microblazeel-xilinx-linux-gnu-objdump -d loader.elf | more
loader.elf: file format elf32-microblazeel
Disassembly of section .vectors.reset:
00000000 <_start>:
0: b0004000 imm 16384
4: b8080000 brai 0 // 40000000 <_start1>
Disassembly of section .vectors.sw_exception:
00000008 <_vector_sw_exception>:
8: b0004000 imm 16384
c: b8080890 brai 2192 // 40000890 <_exception_handler>
Disassembly of section .vectors.interrupt:
00000010 <_vector_interrupt>:
10: b0004000 imm 16384
14: b80809c8 brai 2504 // 400009c8 <__interrupt_handler>
Disassembly of section .vectors.hw_exception:
00000020 <_vector_hw_exception>:
20: b0004000 imm 16384
24: b8080200 brai 512 // 40000200 <_hw_exception_handler>
Disassembly of section .text:
40000000 <_start1>:
40000000: b0004000 imm 16384
40000004: 31a00f28 addik r13, r0, 3880 // 40000f28 <completed.2>
40000008: b0004000 imm 16384
4000000c: 30400da8 addik r2, r0, 3496 // 40000da8 <_SDA2_BASE_>
40000010: b0004000 imm 16384
40000014: 30201b28 addik r1, r0, 6952
40000018: b0000000 imm 0
4000001c: b9f40128 brlid r15, 296 // 40000144 <_crtinit>
40000020: 80000000 or r0, r0, r0
40000024: b0000000 imm 0
40000028: b9f406b8 brlid r15, 1720 // 400006e0 <exit>
4000002c: 30a30000 addik r5, r3, 0
40000030 <_exit>:
40000030: b8000000 bri 0 // 40000030 <_exit>
В начале расположена таблица векторов прерываний. В отличие от образа ОС, она тут сразу на своем месте. Далее идет секция основной программы — .text
. Что примечательно, по умолчанию она располагается, начиная с адреса 0x4000_0000. То есть линковщик расположил таблицу векторов в BRAM, а саму программу — в ОЗУ. Нам это не подходит. ОЗУ у нас занята операционной системой.
Курьезный факт: ОС все равно успешно запустится нашим мини-загрузчиком в текущей версии. Случится это потому, что вектор reset указывает на все тот же адрес 0x4000_0000. Правда, согласно коду программы, там должна находиться секция
.text
нашего мини-загрузчика, а не точка входа в ОС.
Понять ход выполнения тут совсем не сложно. По сути, после подачи питания мы, как и в случае с самописной таблицей векторов, сразу перейдем на точку входа в ОС и не увидим строки, которые должен вывести наш мини-загрузчик. Потому что никакая его часть в ОЗУ в действительности не будет располагаться. Давайте это поправим: отредактируем файл /home/dev/workspace/mb_loader/loader/src/lscript.ld, который является скриптом линковщика.
Согласно этому файлу, секция .text
, как и целый ряд других, размещается следующим образом:
.text : {
*(.text)
*(.text.*)
*(.gnu.linkonce.t.*)
} > psu_ddr_0_HP0_AXI_BASENAME_MEM_0
Здесь мы видим, что по умолчанию размещение происходит в psu_ddr_0_HP0_AXI_BASENAME_MEM_0. В этом же файле найдем описание регионов памяти:
MEMORY
{
microblaze_0_local_memory_ilmb_bram_if_cntlr_Mem_microblaze_0_local_memory_dlmb_bram_if_cntlr_Mem : ORIGIN = 0x50, LENGTH = 0x1FFB0
psu_ddr_0_HP0_AXI_BASENAME_MEM_0 : ORIGIN = 0x40000000, LENGTH = 0x20000000
}
Все верно, у нас определены два региона:
-
Первый, с очень длинным названием, представляет нашу BRAM, начинается он с адреса 0x50 и имеет длину 0x1_FFB0. На первый взгляд, эти числа могут показаться странными, но тут все логично. Согласно документации (приводил скриншот выше), таблица векторов прерываний находится в диапазоне адресов 0x0–0x4F. Первый доступный адрес после нее как раз 0x50. Что касается размера, то наша BRAM вмещает 128 КБ (0x1_FFFF). Если вычесть из этого числа размер таблицы векторов прерываний, как раз получим 0x1_FFB0.
-
Второй регион представляет ОЗУ, начинается с адреса 0x4000_0000 и равен 512 МБ (0x2000_0000).
Чтобы линковщик расположил все секции в BRAM, а не в ОЗУ, нам всего лишь нужно поменять все случаи использования диапазона в ОЗУ на диапазон в BRAM. Сделаем это простым поиском с заменой. Заменим все psu_ddr_0_HP0_AXI_BASENAME_MEM_0 на то длинное название региона в BRAM.
Пересобираем приложение той же командой:
xsct% app build -name loader
Дизассемблируем и проверяем:
# microblazeel-xilinx-linux-gnu-objdump -d loader.elf | more
loader.elf: file format elf32-microblazeel
Disassembly of section .vectors.reset:
00000000 <_start>:
0: b0000000 imm 0
4: b8080050 brai 80 // 50 <_start1>
Disassembly of section .vectors.sw_exception:
00000008 <_vector_sw_exception>:
8: b0000000 imm 0
c: b80808e0 brai 2272 // 8e0 <_exception_handler>
Disassembly of section .vectors.interrupt:
00000010 <_vector_interrupt>:
10: b0000000 imm 0
14: b8080a00 brai 2560 // a00 <__interrupt_handler>
Disassembly of section .vectors.hw_exception:
00000020 <_vector_hw_exception>:
20: b0000000 imm 0
24: b8080250 brai 592 // 250 <_hw_exception_handler>
Disassembly of section .text:
00000050 <_start1>:
50: b0000000 imm 0
54: 31a00f60 addik r13, r0, 3936 // f60 <completed.2>
58: b0000000 imm 0
5c: 30400de0 addik r2, r0, 3552 // de0 <_SDA2_BASE_>
60: b0000000 imm 0
64: 30201b60 addik r1, r0, 7008
68: b0000000 imm 0
6c: b9f40128 brlid r15, 296 // 194 <_crtinit>
70: 80000000 or r0, r0, r0
74: b0000000 imm 0
78: b9f406b8 brlid r15, 1720 // 730 <exit>
7c: 30a30000 addik r5, r3, 0
00000080 <_exit>:
80: b8000000 bri 0 // 80 <_exit>
Вот теперь похоже на то, что нам нужно. Осталось ассоциировать этот исполняемый файл с блочной памятью в Vivado. Делаем это, как и прежде.
Вновь генерируем битстрим и упаковываем его в BOOT.bin. После чего копируем его в первый раздел SD-карты и загружаем платформу.
После успешного запуска ОС Hard-CPU нам нужно выполнить две уже знакомые команды для пробуждения Soft-CPU и организации с ним канала связи. Однако здесь есть один нюанс. Если мы сперва «разбудим» Soft-CPU, а потом откроем консоль, то просто не успеем увидеть вывод нашего мини-загрузчика. Поэтому сперва открываем консоль, потом ее сворачиваем, потом «будим» Soft-CPU, а затем снова открываем консоль. Последовательность команд такая:
# screen /dev/ttyUL1
# Ctrl+a – d (Зажать ‘Ctrl’ и ‘a’ одновременно, потом нажать ‘d’)
# devmem 0xA0000000 8 0x1
# screen -r
В результате ОС Soft-CPU успешно запустится нашим мини-загрузчиком.
В начале вывода видны наши сообщения из bare-metal приложения. После — частичка загрузочных логов Embedded Linux.
Вместо заключения
Что ж, это был весьма увлекательный проект! Он вылился в три статьи и выступление на конференции FPGA-systems 2024.2. Надеюсь, мой опыт будет полезен вам — читателям. Без вас эта работа не несла бы такого всестороннего смысла. Пожелаю вам успехов в ваших инженерных начинаниях! И до новых встреч.
Автор: PaPS_90