[Перевод] Пишем и отлаживаем код для ARM64 на голом железе

Я немного изучил arm64 (aarch64) и решил: попробую написать для него код на голом железе.
Я хотел понять, проанализировать и тщательно рассмотреть машинный код, который выдают на моём MacBook Air M1 такие среды исполнения WebAssembly, как v8 или wasmtime. Для этого я (немного) изучил ассемблер arm64. Коллега Саул Кабрера порекомендовал мне почитать книгу Стивена Смита «Programming with 64-Bit ARM Assembly Language», и я могу только поддержать эту рекомендацию.

image


«Programming with 64-Bit ARM Assembly Language» by Stephen Smith, APress 2020

В книге отлично объясняется набор инструкций, приёмы оптимизации, а также действующие соглашения и интерфейсы ABI. Но с ней вы научитесь писать программы только под операционную систему. Я же люблю загружать с нуля мой собственный BBC Microbit или Rasperry Pi. В этом посте я набросал пару шагов, которые успел сделать в этом направлении.

Qemu


Реальное железо донельзя сложно поддаётся начальной загрузке и отладке, поэтому при решении задачи я ограничился полностью виртуальной платформой, а именно Qemu. Это эмулятор, в котором удобно воспроизводить самые разные архитектуры процессора для разнообразных плат. Для этого воспользуюсь системой virt, она совершенно виртуальная, но достаточно хорошо документирована. В качестве ЦП использую Cortex-A72, в основном потому, что именно этот процессор стоит на Rasperry Pi 400, которую я собирался программировать по итогам проделанных опытов.

Самое интересное в документации находится в самом низу страницы:

  • Флэш-память начинается по адресу 0x0000_0000
  • RAM начинается по адресу 0x4000_0000
  • Объект DTB (подробнее о нём ниже) находится в самом начале RAM, т. e., по адресу 0x4000_0000


Мы сконфигурируем экземпляр так, чтобы в нём было 128 МиБиБ, так что диапазон адресов в памяти, доступный для использования, проляжет от 0x4000_0000 до 0x4800_0000.

Тестовая программа


Без операционной системы вы не сможете воспользоваться старой доброй отладкой через printf. Просто отсутствует утилита, которая бы превратила за вас заданную строку в пиксели на экране, так что эту работу придётся делать самим. Работы много, и мы воспользуемся встроенными в qemu возможностями, позволяющими убедиться, что составленная нами конструкция работает.

Для сборки нашего ассемблерного кода в двоичный машинный воспользуемся as. Если вы, как и я, работаете на M1, то можете взять as, предоставляемую в системе. Правда, binutils, предоставляемые в MacOS, бывают только GNU-подобными, и их изменили, чтобы гарантировать соблюдение определённых ограничений и допущений, специфичных для процессоров Apple M1 и MacOS. Рекомендую установить aarch64-elf-binutils через homebrew или самостоятельно собрать binutils (хотя это и немного утомительно — я смог вскрыть все зависимости только шаг за шагом, натыкаясь на ошибки компиляции):

$ ./configure --prefix= --disable-gdb --target=aarch64-elf-linux
$ make
$ make install
# ... all tools will be in $PREFIX/bin


В качестве минимального теста работоспособности нашей конфигурации запишем специальное значение в один из регистров ЦП, а затем поставим его в вечный цикл.

# main.s
 
.global _reset
_reset:
  	# Set up stack pointer
  	LDR X2, =stack_top
  	MOV SP, X2
  	# Magic number
  	MOV X13, #0x1337
  	# Loop endlessly
  	B .


Этот код можно превратить в объектные файлы следующим образом:

$ as -o main.o main.s


На следующем шаге потребуется вплести наш объектный файл в готовый машинный код при помощи ld. По умолчанию ld конфигурируется для создания исполняемого файла именно так, как того требует выбранная в качестве цели операционная система. Эта конфигурация выполняется при помощи компоновщика (так называемый linker script). Если хотите просмотреть компоновщик, заданный по умолчанию, выполните ld --verbose. Но, поскольку операционной системы у нас нет, нам потребуется написать этот скрипт самостоятельно. На мой взгляд, такие компоновщики очень странные, и я до сих пор целиком в них не разобрался, несмотря на найденную по ним документацию.

Следующий компоновщик откорректирует наш машинный код, чтобы подготовить его к загрузке по адресу 0x4010_0000. По опыту работы с пресловутым деревом двоичных объектов (DTB) я выбрал размер 1 МиБиБ. Также здесь определяется символ stack_top, который будет указывать на адрес 4 КиБиБ после нашего кода. Это означает, что в стеке нужно предусмотреть 4 КиБиБ свободного пространства. Мы не будем использовать стек, но всегда полезно его предусмотреть, чтобы корректно работали такие элементарные вещи как вызовы функций.

/* linker.ld */
SECTIONS {
  	. = 0x40100000;
  	.text : { *(.text) }
  	. = ALIGN(8);
  	. = . + 0x1000;
  	stack_top = .;
}


Скомпонуем наш код:

$ ld -T linker.ld -o main.elf main.o


При помощи objdump можно проверить, в самом ли деле все адреса и инструкции связаны правильно:

$ objdump -d kernel.elf
 
main.elf:     file format elf64-littleaarch64
 
Disassembly of section .text:
 
0000000040100000 <_reset>:
    40100000:   d28266ed    	mov     x13, #0x1337                	// #4919
    40100004:   14000000    	b       40100004 <_reset+0x4>


У нас есть файл ELF, но, напомню: мы работаем на голом железе, где нет операционной системы, способной декодировать формат файла ELF и загрузить инструкцию в нужный участок памяти. Нам придётся заранее извлекать сырые двоичные инструкции, и с решением этой задачи нам очень поможет objcopy:

$ objcopy -O binary main.elf main.bin


Чтобы не иметь дела с BIOS-ами, загрузочными секторами или не прибегать к другим ухищрениям, воспользуемся предусмотренным в Qemu обобщённым загрузчиком, позволяющим поместить этот файл в память:

$ qemu-system-aarch64 \
  	-M virt -cpu cortex-a72 \
  	-m 128M -nographic \
  	-device loader,file=kernel.bin,addr=0x40100000 \
  	-device loader,addr=0x40100000,cpu-num=0


Первая директива -device загружает файл в память по указанному адресу. Вторая директива -device задаёт тот адрес, с которого ЦП начнёт работу.

Разумеется, никакого вывода мы не получим. Здесь мы сможем открыть консоль Qemu комбинацией клавиш Ctrl-a c и командой info registers вывести дамп той информации, что сейчас содержится в регистрах ЦП.

QEMU 7.2.0 monitor - type 'help' for more information
(qemu) info registers
 
CPU#0
 PC=0000000040100004 X00=0000000000000000 X01=0000000000000000
X02=0000000000000000 X03=0000000000000000 X04=0000000000000000
X05=0000000000000000 X06=0000000000000000 X07=0000000000000000
X08=0000000000000000 X09=0000000000000000 X10=0000000000000000
X11=0000000000000000 X12=0000000000000000 X13=0000000000001337
X14=0000000000000000 X15=0000000000000000 X16=0000000000000000
X17=0000000000000000 X18=0000000000000000 X19=0000000000000000
X20=0000000000000000 X21=0000000000000000 X22=0000000000000000
X23=0000000000000000 X24=0000000000000000 X25=0000000000000000
X26=0000000000000000 X27=0000000000000000 X28=0000000000000000
X29=0000000000000000 X30=0000000000000000  SP=0000000000000000
PSTATE=400003c5 -Z-- EL1h	FPU disabled
(qemu) q


X13 содержит 0x1337, то есть, наша программа действительно работает!

Прикосновение к 'elf


Я приврал. Действительно, в эмулированной нами среде нет операционной системы, которая могла бы декодировать файлы ELF, но Qemu может декодировать файлы ELF сама. На самом деле, обобщённый загрузчик поддерживает файлы ELF, автоматически распаковывает их и загружает их содержимое в память именно так, как это предписано в заголовках файлов ELF! Это сильно упрощает нам все вызовы, а также позволит в будущем пользоваться более сложными вариантами скриптов-компоновщиков.

$ qemu-system-aarch64 \
  	-M virt -cpu cortex-a72 \
  	-m 128M -nographic \
  	-device loader,file=kernel.bin,addr=0x40100000 \
  	-device loader,file=kernel.elf \
  	-device loader,addr=0x40100000,cpu-num=0


Отладка


Когда записываешь в регистры магические значения, к лёгкой отладке это не располагает. Гораздо лучше использовать шаг gdb, предусмотренный в эмулированной Qemu системе, но gdb не поддерживает платформу M1. К счастью, lldb понимает протокол удалённой отладки gdb. При запуске Qemu в нём можно активировать удалённую отладку gdb (-S), причём, задать ему состояние «пауза» в качестве исходного (-s).

$ qemu-system-aarch64 \
  	-M virt -cpu cortex-a72 \
  	-m 128M -nographic \
  	-device loader,file=kernel.elf \
  	-device loader,addr=0x40100000,cpu-num=0 \
  	-s -S


Чтобы подключить lldb к Qemu, выполните следующий код:

$ lldb kernel.elf
(lldb) gdb-remote localhost:1234                                                                                                                                      Process 1 stopped
* thread #1, stop reason = signal SIGTRAP
	frame #0: 0x0000000040100000 kernel.elf`_reset
kernel.elf`_reset:
->  0x40100000 <+0>: mov	x13, #0x1337
    0x40100004 <+4>: b      0x40100004            	; <+4>
    0x40100008:  	udf	#0x0
    0x4010000c:  	udf	#0x0
Target 0: (kernel.elf) stopped.


Теперь мы можем использовать отладчик на полную мощность. Мы можем выполнять программу пошагово, ставить точки остановка, проверять память и регистры… что угодно.

Последовательный ввод/вывод


Из документации по платформе virt следует, что на ней предусмотрен чип PL011 для обработки порта UART (также именуемого «последовательным портом»). Представляется, что это простейший вариант организовать ввод/вывод в той или иной форме.

Заглянув в мануал по PL011, видим, как заставить этот чип послать символ через UART: для этого нужно записать значение в регистр, именуемый UARTDR. Этот регистр отображается в память и расположен со смещением 0x000 от базового адреса PL011 –, но каков этот базовый адрес? Он меняется от системы к системе, и его требуется определять во время выполнения.

Дерево устройств


Дерево устройств — это открытая спецификация для двоичного формата (двоичный объект дерева устройств, сокращённо — DTB) и текстового формата (синтаксис дерева устройств, сокращённо DTS). Дерево устройств описывает, какие периферийные устройства есть в системе, и как получить к ним доступ. В документации по virt сказано, что DTB будет находиться по адресу 0x4000_0000.

В данном случае было бы правильно написать код для синтаксического разбора DTB, но мы удовлетворимся малым и просто сделаем дамп DTB на диск при помощи lldb, после чего извлечём интересующую нас информацию:

(lldb) memory read --force -o dump.dtb -b 0x40000000 0x40000000+1024*1024


Данные в дампе имеют двоичный формат. Установив dtc при помощи homebrew, можно преобразовывать данные из двоичного формата в текстовый и обратно:

$ dtc dump.dtb
/dts-v1/;
 
/ {
        interrupt-parent = <0x8002>;
        model = "linux,dummy-virt";
        #size-cells = <0x02>;
        #address-cells = <0x02>;
    	compatible = "linux,dummy-vi
...


Данных здесь будет очень много, но нас наиболее интересует этт раздел:

pl011@9000000 {
        clock-names = "uartclk\0apb_pclk";
        clocks = <0x8000 0x8000>;
        interrupts = <0x00 0x01 0x04>;
        reg = <0x00 0x9000000 0x00 0x1000>;
        compatible = "arm,pl011\0arm,primecell";
};


Базовый адрес PL011 — это 0x900_0000. Это значит, что наш регистр UARTDR также находится по адресу 0x900_0000. Давайте в него что-нибудь запишем:

.global _reset
_reset:
  	LDR X10, UARTDR
  	MOV W9, '!'
  	STRB W9, [X10]
  	B .
UARTDR:
  	.quad 0x9000000


Так мы записали код ASCII для символа '!' в UARTDR, а Qemu должен вывести этот символ вам в командную оболочку.

mxuanbovcusqgmqdgugvpnql8vq.jpeg

© Habrahabr.ru