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