Я хотел понять, проанализировать и тщательно рассмотреть машинный код, который выдают на моём 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 должен вывести этот символ вам в командную оболочку.Комментарии (19)
forthuse
08.08.2023 09:56+2C Си/C++ тоже нет особых проблем, если посмотреть навскидку сколько и каких Форт систем реализовано: kForth, pForth, gForth, Ficl, BigForth… (далеко не весь перечисленный список)
сложнее в них при реализации добится существенного ускорения Форт кода при использовании Си языка как основы (в gForth есть некоторая технология, но у меня, к примеру, в разных его реализациях для ускорения собирается полностью в 32/64 вариант gForth 7.3 версии, а версии 7.9 в репах не наблюдается ещё и со сборкой её под 32-бита есть заморочки)P.S. В том же gForth можно по слову SEE <имя слова> посмотреть ассемблерный код.
Это применимо и для gForth запускаемого под Андроид.dlinyj
08.08.2023 09:56-1Форт не интересен от слова совсем. И разговор тут идёт об особенностях библиотечных реализаций.
forthuse
08.08.2023 09:56+1В целом это так, т.к. нет аппаратных Форт процессоров, где программирование на нём заменило бы ассемблер (сейчас только аппаратно Форт (MISC )процессоры реализуют в FPGA за редкими исключениями и используют поверх существующего железа МК/Процессоров)
P.S. Труды ИСП РАН, том 33, вып. 5, 2021 г. // Trudy ISP RAN/Proc. ISP RAS, vol. 33, issue 5, 2021
Разработка компилятора для стековой процессорной архитектуры TF16 на основе LLVMВ первой версии компилятора архитектура TF16 рассматривалась как классическая регистровая архитектура, и сгенерированный код не использовал стековые возможности.
Эта версия была относительно проста в разработке и служила точкой сравнения для второй версии компилятора.
Во второй версии компилятора был разработан и реализован платформонезависимый алгоритм планирования команд c учётом особенностей стековых архитектур.
При сравнении двух версий версия компилятора с поддержкой стековых возможностей генерирует код, который в среднем на 35.7% быстрее по времени выполнения и на 50.8% меньше по размеру, чем код, генерируемый версией компилятора без поддержки стековых возможностей. Разработанный алгоритм позволяет реализовать в компиляторе LLVM поддержку других стековых процессорных архитектур.byman
08.08.2023 09:56+1В прошлом веке я принимал участие в разработке аналога NC1016, который в России называется ТF16. На данный момент это полная ерунда. Элементарный минимальный RISC-V будет на порядок эффективнее. Я читал приведенную статью. Опять же полная ерунда. Как можно писать статью не приведя ни одного примера. Какой обьем кода получился при компиляции хотя бы Коремарка на TF16? Почему бы не написать и сравнить с ARM или RISC-V? А вот нет. Если компилировать так, то получается Х, а если этак, то уже Х/2. И вот гадай чему у них равен этот Х :)
forthuse
08.08.2023 09:56А, где есть спецификация NC1016 и ПО под него, если сохранилось.
Тоже ПО для 4-ёх битного Форт контроллера Marc4 от Atmel было представлено только для DOS.P.S. В переведённой книге стековые компьютеры. Новая волна от 1989г. его ещё нет. (или это NC4016, но он точно не похож на TF16, но возможно послужил отправным дизайном)
Форт процессоры J1 и DCPU c Github тоже можно тогда приравнять к NC4016.
На данный момент это полная ерунда. Элементарный минимальный RISC-V будет на порядок эффективнее
А, насколько интересно/полезно использовать 0-адресную систему команд процессора для создания надёжного и предсказуемого ПО?
RTX-2010, вероятно, в проектах NASA и ESA себя окупил в полной мере.
NutsUnderline
08.08.2023 09:56+2По материалу видно что автор больше в линуксы ходил а не в BareMetal и многие вещи ему в диковинку. Что на atmega что на stm32 и более мощных arm - elf преобразуется в бинарник, в общем то стандартными средcтвами, а весь ввод-вывод надо писать самому "на регистрах", но обычно все регистры задокумнтированы, их не надо выискивать хитрыми путями, ну и вообще это уже кто то да сделал.
Ну и главное JTAG, отладка на реальном железе без всяких эмуляторов. первый одноплатник выбирал чтобы там JTAG был обязательно.. как же я ошибался :)
dlinyj
Когда читаешь такую статью, думаешь что там такого. Но если кто попробует написать что-то на голом железе на си, вот даже под x86, то осознает насколько глубока кроличья нора. И насколько это реально сложно.
forthuse
На голом железе в реализации с использованием ассемблера хорошо проработана
тематика создания Форт (Forth) систем и примеров этого подхода много и в проектах
на Github, но т.к. это мало представлено в каких то опубликованных статьях/книгах, то и
можно констатировать что этого в целом нет.
P.S. Один из проектов представленный в книге по программироанию на х86-64 ассемблере
Проект Forthress
dlinyj
С ассемблером как раз не очень сложно, а вы на си попробуйте.
emusic
За счет чего на C что-то может быть сложнее, чем на ассемблере? Ну, кроме экономии единиц байтов/тактов, само собой.
dlinyj
Главная проблема — это обеспечение совместимости. Можете посмотреть в моих последних статьях, например в этой. На асме всё просто, а вот си, нужно писать правильный линкер-файл, реализовывать стандартные библиотеки и прочее-прочее, но в результате получаем привычный инструментарий.
emusic
Правильный линкер-файл нужен в любом случае - ассемблер тоже не делает готового к исполнению кода.
И "на си" автоматически не подразумевает использования стандартных библиотек. Даже если их нет, писать на C всяко удобнее, чем на ассемблере.
dlinyj
Всё так, я об этом и говорил. Просто адаптировать си под голое железо чуть сложнее, но тоже реально. Мы говорим об одном и том же.
emusic
Я не понимаю, где там "сложнее". :) Любой вменяемый линкер изготовит корректный исполняемый бинарник из объектных файлов, не содержащих внешних ссылок. Разница лишь в том, что из ассемблерных файлов это получается автоматически, а для сишных придется явно указать линкеру точку входа, чтоб не тащил из библиотек CRT. То есть, вся "сложность" - в указании ключа в командной строке линкера. Считать это усложнением задачи несерьезно. :)
dlinyj
Ну начнём с того, что на си нужно будет использовать два языка. Плюс надо знать особенности компиляции и трансляции языка. Понимать формат elf и т.п. Значительно большее количество знаний. На ассемблере пишем на одном языке и потом линкуем линкером.
Но я полагаю, что у вас не было опыта такого, типа разработки загрузчика какого-нибудь для x86 платформы, поэтому вам кажется это простым.
Как человек реализовавший два проекта на ассемблере и си, скажу что си сложнее. Потому что надо в случае си знать и ассемблер, и особенности компилятора и прочее-прочее.
emusic
Это справедливо и для ассемблера, если он не совсем примитивный. Если просто писать текст на ассемблере по справочнику, не проверяя, во что это транслируется, нетрудно и нарваться.
Это не обязательно. Как уже правильно заметили, вполне достаточно иметь средства преобразования из ELF в голый бинарник.
Если это не любительское поделие на один вечер, то большее количество знаний с лихвой компенсируется увеличением скорости/надежности разработки.
Именно загрузчика - нет, а "голый код" для загрузки по абсолютному адресу, без привязки к внешним API - был. Чем это принципиально отличается?
dlinyj
Кто же с этим спорит.