Я немного изучил 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 должен вывести этот символ вам в командную оболочку.

Комментарии (19)


  1. dlinyj
    08.08.2023 09:56
    +5

    Когда читаешь такую статью, думаешь что там такого. Но если кто попробует написать что-то на голом железе на си, вот даже под x86, то осознает насколько глубока кроличья нора. И насколько это реально сложно.


    1. forthuse
      08.08.2023 09:56

      На голом железе в реализации с использованием ассемблера хорошо проработана
      тематика создания Форт (Forth) систем и примеров этого подхода много и в проектах
      на Github, но т.к. это мало представлено в каких то опубликованных статьях/книгах, то и
      можно констатировать что этого в целом нет.


      P.S. Один из проектов представленный в книге по программироанию на х86-64 ассемблере
      Проект Forthress


      1. dlinyj
        08.08.2023 09:56
        -1

        С ассемблером как раз не очень сложно, а вы на си попробуйте.


        1. emusic
          08.08.2023 09:56
          +2

          За счет чего на C что-то может быть сложнее, чем на ассемблере? Ну, кроме экономии единиц байтов/тактов, само собой.


          1. dlinyj
            08.08.2023 09:56
            -1

            Главная проблема — это обеспечение совместимости. Можете посмотреть в моих последних статьях, например в этой. На асме всё просто, а вот си, нужно писать правильный линкер-файл, реализовывать стандартные библиотеки и прочее-прочее, но в результате получаем привычный инструментарий.


            1. emusic
              08.08.2023 09:56
              +1

              Правильный линкер-файл нужен в любом случае - ассемблер тоже не делает готового к исполнению кода.

              И "на си" автоматически не подразумевает использования стандартных библиотек. Даже если их нет, писать на C всяко удобнее, чем на ассемблере.


              1. dlinyj
                08.08.2023 09:56
                -1

                Всё так, я об этом и говорил. Просто адаптировать си под голое железо чуть сложнее, но тоже реально. Мы говорим об одном и том же.


                1. emusic
                  08.08.2023 09:56
                  +1

                  Я не понимаю, где там "сложнее". :) Любой вменяемый линкер изготовит корректный исполняемый бинарник из объектных файлов, не содержащих внешних ссылок. Разница лишь в том, что из ассемблерных файлов это получается автоматически, а для сишных придется явно указать линкеру точку входа, чтоб не тащил из библиотек CRT. То есть, вся "сложность" - в указании ключа в командной строке линкера. Считать это усложнением задачи несерьезно. :)


                  1. dlinyj
                    08.08.2023 09:56
                    -1

                    Ну начнём с того, что на си нужно будет использовать два языка. Плюс надо знать особенности компиляции и трансляции языка. Понимать формат elf и т.п. Значительно большее количество знаний. На ассемблере пишем на одном языке и потом линкуем линкером.
                    Но я полагаю, что у вас не было опыта такого, типа разработки загрузчика какого-нибудь для x86 платформы, поэтому вам кажется это простым.


                    Как человек реализовавший два проекта на ассемблере и си, скажу что си сложнее. Потому что надо в случае си знать и ассемблер, и особенности компилятора и прочее-прочее.


                    1. emusic
                      08.08.2023 09:56
                      +1

                      надо знать особенности компиляции и трансляции языка

                      Это справедливо и для ассемблера, если он не совсем примитивный. Если просто писать текст на ассемблере по справочнику, не проверяя, во что это транслируется, нетрудно и нарваться.

                      Понимать формат elf

                      Это не обязательно. Как уже правильно заметили, вполне достаточно иметь средства преобразования из ELF в голый бинарник.

                      Значительно большее количество знаний

                      Если это не любительское поделие на один вечер, то большее количество знаний с лихвой компенсируется увеличением скорости/надежности разработки.

                      полагаю, что у вас не было опыта такого

                      Именно загрузчика - нет, а "голый код" для загрузки по абсолютному адресу, без привязки к внешним API - был. Чем это принципиально отличается?


                      1. dlinyj
                        08.08.2023 09:56
                        -1

                        Если это не любительское поделие на один вечер, то большее количество знаний с лихвой компенсируется увеличением скорости/надежности разработки.

                        Кто же с этим спорит.


  1. forthuse
    08.08.2023 09:56
    +2

    C Си/C++ тоже нет особых проблем, если посмотреть навскидку сколько и каких Форт систем реализовано: kForth, pForth, gForth, Ficl, BigForth… (далеко не весь перечисленный список)
    сложнее в них при реализации добится существенного ускорения Форт кода при использовании Си языка как основы (в gForth есть некоторая технология, но у меня, к примеру, в разных его реализациях для ускорения собирается полностью в 32/64 вариант gForth 7.3 версии, а версии 7.9 в репах не наблюдается ещё и со сборкой её под 32-бита есть заморочки)


    P.S. В том же gForth можно по слову SEE <имя слова> посмотреть ассемблерный код.
    Это применимо и для gForth запускаемого под Андроид.


    1. dlinyj
      08.08.2023 09:56
      -1

      Форт не интересен от слова совсем. И разговор тут идёт об особенностях библиотечных реализаций.


      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 поддержку других стековых процессорных архитектур.


        1. dlinyj
          08.08.2023 09:56

          Я вам о тёплом, вы мне о мягком. Рад за форт (нет). Я говорю о си и gcc.


        1. byman
          08.08.2023 09:56
          +1

          В прошлом веке я принимал участие в разработке аналога NC1016, который в России называется ТF16. На данный момент это полная ерунда. Элементарный минимальный RISC-V будет на порядок эффективнее. Я читал приведенную статью. Опять же полная ерунда. Как можно писать статью не приведя ни одного примера. Какой обьем кода получился при компиляции хотя бы Коремарка на TF16? Почему бы не написать и сравнить с ARM или RISC-V? А вот нет. Если компилировать так, то получается Х, а если этак, то уже Х/2. И вот гадай чему у них равен этот Х :)


          1. 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 себя окупил в полной мере.


  1. NutsUnderline
    08.08.2023 09:56
    +2

    По материалу видно что автор больше в линуксы ходил а не в BareMetal и многие вещи ему в диковинку. Что на atmega что на stm32 и более мощных arm - elf преобразуется в бинарник, в общем то стандартными средcтвами, а весь ввод-вывод надо писать самому "на регистрах", но обычно все регистры задокумнтированы, их не надо выискивать хитрыми путями, ну и вообще это уже кто то да сделал.

    Ну и главное JTAG, отладка на реальном железе без всяких эмуляторов. первый одноплатник выбирал чтобы там JTAG был обязательно.. как же я ошибался :)


  1. viteo
    08.08.2023 09:56

    настолько роботизированный перевод, что оригинал легче читается.