Запускаем Embedded Linux на Hard- и Soft-CPU Xilinx Zynq: загружаем платформу и верифицируем проект

от автора

Здравствуй, Хабр! На связи вновь Павел Панкратов — ведущий инженер-программист дивизиона искусственного интеллекта 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 простой записью в соответствующий регистр, отображенный на физическую память.

Редактор адресов Vivado IDE

Редактор адресов Vivado IDE

В нашем случае блок AXI-GPIO отображен на адрес 0xA000_0000. Давайте посмотрим, в какой конкретный регистр и что именно нам нужно записать, согласно руководству. 

Карта регистров AXI-GPIO

Карта регистров AXI-GPIO

Также вспомним, как мы конфигурировали этот блок в проекте программируемой логики.

Конфигурация AXI-GPIO

Конфигурация AXI-GPIO

У нас задействован только первый канал 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-блока.

Расширенные настройки MicroBlaze

Расширенные настройки MicroBlaze

Вспоминая, что мы загрузили образ ОС Soft-CPU по адресу 0x4000_0000, который по совместительству является и точкой входа, возникает логичное желание указать его как базовый адрес. Тогда после пробуждения MicroBlaze сразу начнет исполнение с адреса 0x4000_0000. Казалось бы, вот она — победа, но не тут-то было. Этот адрес — базовый в таблице векторов прерываний.

Таблица векторов прерываний MicroBlaze

Таблица векторов прерываний MicroBlaze

Эта таблица не только является точкой старта исполнения инструкций процессором, но еще и должна содержать корректные адреса обработчиков прерываний, которые генерируются разными событиями. Для нас это крайне важно, так как Embedded Linux завязан на обработку прерываний от таймера и прочей периферии.

Получается, прежде чем праздновать победу, необходимо убедиться, что образ ОС содержит эту таблицу с корректными адресами в самом его начале. Для решения задачи давайте обратимся к специфичному для нашей архитектуры коду инициализации. Его можно найти в репозитории ядра по пути arch/microblaze/kernel.

Для начала заглянем в файл head.S. В нем и содержится код, который находится в начале образа. На момент написания статьи со строки номер 63 располагаются самые первые инструкции, которые будут включены в состав образа ОС:

ENTRY(_start) #if CONFIG_KERNEL_BASE_ADDR == 0 braiTOPHYS(real_start) .org0x100 real_start: #endif     mtsrmsr, r0 /* Disable stack protection from bootloader */     mtsrslr, r0     addi r8, r0, 0xFFFFFFFF     mtsrshr, 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 mtsrmsr, r0 c0000004:9400c800 mtsrslr, r0 c0000008:2100ffff addi r8, r0, -1 c000000c:9408c802 mtsrshr, r8

Как мы можем убедиться, все верно. Образ Embedded Linux действительно начинается не с таблицы векторов прерываний. Но она все же должна где-то быть. На этапе построения ядра ОС системе сборки известны адреса всех обработчиков прерываний, и она должна куда-то их размещать. Продолжаем наши поиски — теперь обратимся к файлу entry.S.

На момент написания статьи нас интересует содержимое, начиная со строки 1236.

ENTRY(_reset) VM_OFF brai0; /* 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) .org0x0 braiCONFIG_MANUAL_RESET_VECTOR #elif defined(CONFIG_MB_MANAGER) .org0x0 braiTOPHYS(_xtmr_manager_reset); #endif .org0x8 braiTOPHYS(_user_exception); /* syscall handler */ .org0x10 braiTOPHYS(_interrupt);/* Interrupt handler */ #ifdef CONFIG_MB_MANAGER .org0x18 braiTOPHYS(_xmb_manager_break); /* microblaze manager break handler */ #else .org0x18 braiTOPHYS(_debug_exception); /* debug trap handler */ #endif .org0x20 braiTOPHYS(_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     brai0; /* 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, чтобы в этом убедиться.

Редактор адресов Vivado IDE

Редактор адресов 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.

Окно ассоциирования elf-файлов Vivado IDE

Окно ассоциирования elf-файлов Vivado IDE

Ассоциируем полученный файл vectors.elf с блочной памятью MicroBlaze. После этого нажимаем OK. Теперь нам нужно вновь сгенерировать битстрим и упаковать его в образ BOOT.bin, чтобы тот включал в себя обновленную версию. Делается это средствами Vivado IDE и Petalinux. Подробности — в предыдущих частях повествования.

Вот теперь точно все, можно пожинать плоды нашего с вами труда. Убеждаемся, что мы не забыли скопировать новый файл BOOT.bin на SD-карту, и переходим к практическим испытаниям.

Верификация проекта

Перед тем как подать питание на аппаратную платформу, еще раз кратко о порядке загрузки: 

  1. После подачи питания исполнится код BootROM. Он передаст управление FSBL. 

  2. FSBL, в свою очередь, инициализирует платформу и передаст управление SSBL (U-Boot). 

  3. U-Boot, основываясь на boot.scr, разместит оба образа ОС по соответствующим адресам и передаст управление ОС Hard-CPU. 

  4. После загрузки либо вручную, либо автоматически необходимо выполнить команды для пробуждения Soft-CPU и организации с ним канала связи. 

  5. После пробуждения Soft-CPU начнет исполнение инструкций с базового адреса 0x0, расположенного в BRAM. 

  6. По адресу 0x0 находится подготовленная нами таблица векторов прерываний. Ее первая инструкция — безусловный переход на точку входа в ОС Soft-CPU. 

  7. Как результат загрузки ОС 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:

Запуск ОС Soft-CPU на QEMU

Запуск ОС Soft-CPU на QEMU

Разработка 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("\n\rMicroBlaze Linux kernel loader\n\r");      /* Here you can place code to actually          load kernel from media to memory */      // start Linux     print("Starting kernel for Habr ...\n\r");     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 imm16384    4:b8080000 brai0// 40000000 <_start1>  Disassembly of section .vectors.sw_exception:  00000008 <_vector_sw_exception>:    8:b0004000 imm16384    c:b8080890 brai2192// 40000890 <_exception_handler>  Disassembly of section .vectors.interrupt:  00000010 <_vector_interrupt>:   10:b0004000 imm16384   14:b80809c8 brai2504// 400009c8 <__interrupt_handler>  Disassembly of section .vectors.hw_exception:  00000020 <_vector_hw_exception>:   20:b0004000 imm16384   24:b8080200 brai512// 40000200 <_hw_exception_handler>  Disassembly of section .text:  40000000 <_start1>: 40000000:b0004000 imm16384 40000004:31a00f28 addikr13, r0, 3880// 40000f28 <completed.2> 40000008:b0004000 imm16384 4000000c:30400da8 addikr2, r0, 3496// 40000da8 <_SDA2_BASE_> 40000010:b0004000 imm16384 40000014:30201b28 addikr1, r0, 6952 40000018:b0000000 imm0 4000001c:b9f40128 brlidr15, 296// 40000144 <_crtinit> 40000020:80000000 orr0, r0, r0 40000024:b0000000 imm0 40000028:b9f406b8 brlidr15, 1720// 400006e0 <exit> 4000002c:30a30000 addikr5, r3, 0  40000030 <_exit>: 40000030:b8000000 bri0// 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 imm0       4:b8080050 brai80// 50 <_start1>  Disassembly of section .vectors.sw_exception:  00000008 <_vector_sw_exception>:       8:b0000000 imm0       c:b80808e0 brai2272// 8e0 <_exception_handler>  Disassembly of section .vectors.interrupt:  00000010 <_vector_interrupt>:      10:b0000000 imm0      14:b8080a00 brai2560// a00 <__interrupt_handler>  Disassembly of section .vectors.hw_exception:  00000020 <_vector_hw_exception>:      20:b0000000 imm0      24:b8080250 brai592// 250 <_hw_exception_handler>  Disassembly of section .text:  00000050 <_start1>:      50:b0000000 imm0      54:31a00f60 addikr13, r0, 3936// f60 <completed.2>      58:b0000000 imm0      5c:30400de0 addikr2, r0, 3552// de0 <_SDA2_BASE_>      60:b0000000 imm0      64:30201b60 addikr1, r0, 7008      68:b0000000 imm0      6c:b9f40128 brlidr15, 296// 194 <_crtinit>      70:80000000 orr0, r0, r0      74:b0000000 imm0      78:b9f406b8 brlidr15, 1720// 730 <exit>      7c:30a30000 addikr5, r3, 0  00000080 <_exit>:      80:b8000000 bri0// 80 <_exit>

Вот теперь похоже на то, что нам нужно. Осталось ассоциировать этот исполняемый файл с блочной памятью в Vivado. Делаем это, как и прежде.

Окно ассоциирования elf-файлов Vivado IDE

Окно ассоциирования elf-файлов Vivado IDE

Вновь генерируем битстрим и упаковываем его в 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 успешно запустится нашим мини-загрузчиком.

Запуск ОС Soft-CPU мини-загрузчиком

Запуск ОС Soft-CPU мини-загрузчиком

В начале вывода видны наши сообщения из bare-metal приложения. После — частичка загрузочных логов Embedded Linux.

Вместо заключения

Что ж, это был весьма увлекательный проект! Он вылился в три статьи и доклад на конференции FPGA-systems 2024.2. Надеюсь, мой опыт будет полезен вам — читателям. Без вас эта работа не несла бы такого всестороннего смысла. Пожелаю вам успехов в ваших инженерных начинаниях! И до новых встреч.


ссылка на оригинал статьи https://habr.com/ru/articles/860428/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *