|
|
||
Ядро XNU, драйверы устройств, виртуальная машина JavaScriptCore, криптографические функции Security framework, рантайм Swift, компилятор LLVM. Что общего у этих компонентов macOS? Все они работают на процессорах ARM64, которые Apple ставит в свои компьютеры с 2020 года. Ассемблер не умер. Он перестал быть массовым навыком, но остался фундаментом, на котором стоит любая программа, любой компилятор и любая операционная система.
Если вы знаете C и хотите понять, во что превращается ваш код после компиляции, этот курс для вас. C прячет от вас регистры процессора, конвенции вызова, выравнивание стека и машинные инструкции. Ассемблер раскрывает их. Прелесть ARM64 в том, что это архитектура с чистым дизайном: 31 универсальный регистр, фиксированная длина инструкции 4 байта, предсказуемое кодирование. Поняв ARM64, вы начинаете видеть, как процессор выполняет программу, как компилятор размещает переменные в регистрах и почему высокоуровневые абстракции стоят именно столько машинных тактов, сколько стоят.
C даёт вам управление памятью. Ассемблер даёт управление процессором. Каждая инструкция в ARM64 соответствует одной операции, которую выполняет железо. Нет скрытого вызова функций, нет неявного выделения памяти, нет магии компилятора. Вы видите, как значение перемещается из регистра в регистр, как условный переход меняет поток выполнения, как стек растёт вниз при вызове функции. Это уровень абстракции, ниже которого лежит только сам кремний.
Apple Silicon - это не просто ещё один ARM-чип. Это платформа со своими правилами: Mach-O вместо ELF, BSD-подобные системные вызовы вместо Linux, регистр X16 для номеров системных вызовов вместо X8, префикс подчёркивания для имён функций из C. Изучение ассемблера на macOS - это одновременно изучение ARM64 и изучение платформы Darwin. Этот курс охватывает обе стороны: архитектуру процессора и специфику операционной системы.
Курс разбит на 25 тем. Каждая тема - один принцип: краткая теория, практическое задание с рабочим кодом, домашнее задание для самостоятельной работы. Пройдя их, вы не просто выучите синтаксис ассемблера. Вы поймёте, как процессор работает с памятью, как операционная система принимает системные вызовы и почему компилятор генерирует именно такие инструкции, какие генерирует.
C транслируется в ассемблер. Каждая строка C превращается в несколько инструкций процессора. Компилятор решает, какие регистры использовать, как разместить локальные переменные, как вызывать функции. Вы пишете int x = 42;, а компилятор решает, положить ли 42 в регистр W0 или в стек. Вы пишете return a + b;, а компилятор генерирует ADD W0, W1, W2 и RET. Знание ассемблера превращает вас из человека, который доверяет компилятору, в человека, который понимает, что компилятор делает и почему.
Ассемблер остаётся языком выбора для задач, где важны три вещи: точный контроль над производительностью, понимание бинарного кода и работа с железом на самом низком уровне. В мире оптимизации горячих циклов, реверс-инжиниринга бинарных файлов, разработки эксплойтов и аудита безопасности альтернатив ассемблеру практически нет. Даже при работе на C часто приходится читать сгенерированный ассемблерный код, чтобы понять, почему программа тормозит или падает.
Есть и образовательный аргумент. ARM64 - это архитектура, в которой каждая инструкция делает ровно то, что написано. Нет микрокода, нет скрытых состояний, нет магии. Это делает ARM64 идеальным инструментом для изучения того, как процессор работает. Вы видите, где данные лежат, как они перемещаются между регистрами, почему конвейер процессора может дать просадку на ветвлении и как условные инструкции помогают этого избежать.
Apple Silicon - это семейство процессоров M1, M2, M3, M4, которые Apple ставит в свои компьютеры с 2020 года. Все они построены на архитектуре ARM64, она же AArch64. Это 64-битная версия архитектуры ARM, которая используется в большинстве смартфонов, планшетов и встраиваемых систем мира. Apple Silicon - не единственный ARM64-процессор на рынке, но он отличается высокой производительностью, эффективным энергопотреблением и тесной интеграцией с macOS.
Архитектура ARM64 характеризуется несколькими ключевыми свойствами. 31 универсальный регистр общего назначения (X0-X30), каждый 64 бита. Фиксированная длина инструкции - 4 байта. Load-store архитектура: арифметические операции работают только с регистрами, доступ к памяти - через отдельные инструкции LDR и STR. Конвенция вызова AAPCS64 определяет, какие регистры используются для аргументов, возвращаемых значений и сохранения контекста. Все это делает ARM64 более предсказуемым и читаемым, чем x86-64 с его десятилетиями обратной совместимости.
Apple добавила к стандартной архитектуре ARM64 несколько расширений. arm64e - версия с Pointer Authentication Codes (PAC), которая защищает от атак на управление потоком выполнения. NEON - SIMD-расширение для параллельной обработки данных. AMX - матричное расширение, доступное в macOS 14 и новее. Эти расширения не обязательны для изучения, но знание об их существовании помогает понимать, почему тот или иной код генерируется именно так.
Между Linux ARM64 и macOS ARM64 есть существенные различия. Linux использует формат ELF, macOS - Mach-O. Linux хранит номера системных вызовов в X8, macOS - в X16. Linux вызывает ядро через SVC #0, macOS - через SVC #0x80. Названия секций различаются: .text в ELF, __TEXT,__text в Mach-O. Эти отличия не меняют саму архитектуру процессора, но влияют на то, как пишется ассемблерный код для каждой платформы.
macOS поставляется со встроенным ассемблером, который является частью LLVM. Команда as в терминале вызывает Clang-интегрированный ассемблер, а не GNU assembler. Это важно: синтаксис LLVM-ассемблера немного отличается от GNU assembler, и код, написанный для Linux, может потребовать адаптации. Вам не нужно устанавливать отдельный ассемблер - всё уже есть в системе.
Для начала установите средства командной строки Xcode. Откройте терминал и выполните:
xcode-select --install
Появится диалоговое окно с предложением установить инструменты разработчика. Согласитесь и дождитесь завершения. После установки проверьте, что ассемблер доступен:
as --version
Вы увидите версию LLVM, обычно 15.x или 16.x на современных macOS. Для ARM64 не нужно указывать архитектуру вручную на Apple Silicon - система сама использует arm64 по умолчанию. На Intel Mac можно кросс-компилировать через флаг -arch arm64, но для полноценной работы нужен Apple Silicon Mac.
Для сборки программ также понадобится линкер ld, который входит в средства командной строки. Проверьте его наличие:
ld -v
Альтернативный путь - использовать clang напрямую. Clang может ассемблировать и линковать за один шаг, что упрощает сборку небольших программ. Мы будем использовать оба подхода: as + ld для понимания процесса и clang для быстрой сборки.
Visual Studio Code - бесплатный редактор, который можно настроить для работы с ассемблером. Установите VS Code с официального сайта, затем откройте панель расширений (Cmd+Shift+X) и найдите "ARM Assembly" от Xiao Cheng. Это расширение добавляет подсветку синтаксиса для ARM64. Также полезно расширение "Hex Editor" для просмотра бинарных файлов.
Создайте папку для проекта, откройте её в VS Code и создайте файл hello.s. Расширение .s (или .S для препроцессора) - стандартное расширение для ассемблерных файлов на macOS. Введите простой код:
.section __TEXT,__text
.global _main
_main:
MOV X0, #0
RET
Для компиляции и запуска в VS Code создайте задачу сборки. Нажмите Cmd+Shift+P, введите "Tasks: Configure Default Build Task" и выберите "Others". VS Code создаст файл .vscode/tasks.json. Отредактируйте его:
{
"version": "2.0.0",
"tasks": [{
"label": "ARM64: assemble and link",
"type": "shell",
"command": "clang",
"args": [
"-arch", "arm64",
"-o", "${fileDirname}/${fileBasenameNoExtension}",
"${file}"
],
"group": { "kind": "build", "isDefault": true }
}]
}
Теперь по Cmd+Shift+B файл будет ассемблироваться и линковаться в один шаг. Для запуска используйте терминал: ./hello. Для отладки создайте launch.json через меню Run - Add Configuration - LLDB.
На macOS стандартный отладчик - это LLDB, часть LLVM. Он заменяет GDB, который традиционно используется на Linux. LLDB интегрирован в VS Code через расширение CodeLLDB, но его можно использовать и из терминала. Основные команды LLDB для ARM64:
lldb ./hello # запустить отладчик
breakpoint set --name _main # поставить точку останова
run # запустить программу
stepi # выполнить одну инструкцию
register read # показать все регистры
register read x0 x1 x16 # показать конкретные регистры
memory read $x1 # прочитать память по адресу
disassemble --name _main # дизассемблировать функцию
continue # продолжить выполнение
LLDB на macOS поддерживает все возможности для работы с ARM64: просмотр регистров, дизассемблирование, точки останова на инструкциях, пошаговое выполнение. Для просмотра бинарных файлов используется otool - нативный инструмент macOS. Команда otool -tv program дизассемблирует исполняемый файл, otool -h program показывает заголовок Mach-O, otool -L program показывает динамические библиотеки.
Ещё один полезный инструмент - objdump из LLVM. Он работает с Mach-O через флаг --macho: objdump --macho -d program. Для просмотра символов используется nm program. Эти инструменты помогают понять, как устроен бинарный файл, какие секции в нём есть и какие функции экспортируются.
Apple следует стандарту AAPCS64 (ARM Architecture Procedure Call Standard for 64-bit Architecture), но добавляет несколько платформенных правил. Регистр X18 зарезервирован Apple - не используйте его в своём коде. Регистр FP (X29) должен всегда указывать на валидный frame record - это требование для работы отладчика и разворота стека. Регистр LR (X30) хранит адрес возврата и автоматически сохраняется при вызове функции через BL.
Конвенция вызова на macOS ARM64: первые 8 аргументов функции передаются в регистрах X0-X7 (или W0-W7 для 32-битных значений). Возвращаемое значение - в X0. Если аргументов больше восьми, остальные передаются через стек. Регистры X0-X7 не сохраняются при вызове - функция callee может их перезаписать. Регистры X19-X28 сохраняются - функция должна сохранить их перед использованием и восстановить перед возвратом.
Для variadic-функций (printf, scanf) Apple отходит от стандарта. На Linux ARM64 variadic-аргументы передаются в регистрах, на macOS - через стек. Это означает, что вызов printf из ассемблера на macOS требует укладки аргументов в стек, а не в регистры. Это важное отличие, которое легко пропустить при переносе кода с Linux.
Курс состоит из 25 тем. Каждая тема построена по схеме: краткая теория (синтаксис, механика, отличия от C), практика (рабочий код, который нужно набрать и запустить) и домашнее задание. Темы идут от простых к сложным. Первые пять - это базовый синтаксис и регистры. Дальше начинается специфика ARM64: адресация памяти, стек, вызов функций. Финальные темы посвящены оптимизации, SIMD и взаимодействию с C.
Рекомендуется проходить темы последовательно. Каждая последующая тема опирается на предыдущую. Код всех примеров собирается командой clang -arch arm64 file.s -o program. Если что-то не собирается - проверьте версию ассемблера: as --version. Для полной поддержки всех возможностей нужен LLVM 14 или новее.
| Аспект | C | ARM64 Assembly |
| Уровень | высокоуровневый (по сравнению с ассемблером) | машинный код один-к-одному |
| Регистры | скрыты, управляются компилятором | основной ресурс, ручное управление |
| Память | через указатели, malloc/free | LDR/STR, адреса в регистрах |
| Функции | имя, аргументы, return | метка, BL/RET, X0 для результата |
| Управление потоком | if/else/while/for | B/BL/B.cond, условные инструкции |
| Скорость | зависит от оптимизаций компилятора | максимальная, ручная оптимизация |
| Читаемость | высокая, абстракции | низкая, детальное управление |
| Портабельность | высокая (перекомпиляция) | зависит от архитектуры процессора |
ARM64 - это 64-битная архитектура процессора, разработанная компанией ARM Holdings. Она используется в большинстве мобильных устройств, встраиваемых системах и, с 2020 года, в компьютерах Apple. Архитектура ARM64 относится к типу load-store: арифметические операции выполняются только над регистрами, а доступ к памяти осуществляется через отдельные инструкции загрузки и сохранения. Это отличается от CISC-архитектур вроде x86-64, где одна инструкция может одновременно читать память, выполнять операцию и записывать результат обратно в память.
Apple Silicon - это реализация ARM64 от Apple. Процессоры M1, M2, M3, M4 построены на архитектуре ARMv8-A и ARMv9-A. Они поддерживают все стандартные инструкции ARM64 плюс дополнительные расширения: NEON для SIMD-операций, Pointer Authentication для безопасности, AMX для матричных вычислений. Для программиста на ассемблере Apple Silicon - это стандартный ARM64 процессор с некоторыми платформенными особенностями, которые диктует macOS.
Фиксированная длина инструкции - 4 байта - ключевая особенность ARM64. Каждая инструкция кодируется ровно 32 битами. Это упрощает декодирование и делает дизассемблирование однозначным. В x86-64 инструкции имеют переменную длину от 1 до 15 байтов, что усложняет разбор. В ARM64 вы всегда знаете, где начинается следующая инструкция - через 4 байта.
// Проверка архитектуры вашего Mac
// Выполните в терминале:
// uname -m
// Ожидаемый вывод на Apple Silicon:
// arm64
// Простая программа, которая возвращает 42
// Файл: arch.s
.section __TEXT,__text
.global _main
.align 2
_main:
MOV W0, #42 // W0 = 42 (32-битный регистр)
RET // вернуться в вызывающий код
Сборка: clang -arch arm64 arch.s -o arch. Запуск: ./arch; echo $?. Вывод: 42. Команда echo $? показывает код возврата последней программы, который помещается в W0.
Напишите программу, которая возвращает код 0. Затем измените её, чтобы возвращать 7. Проверьте через echo $?. Откройте бинарный файл в otool -tv ./arch и найдите вашу инструкцию MOV W0, #42.
ARM64 имеет 31 регистр общего назначения: X0-X30. Каждый из них 64 бита. Регистр X31 в инструкциях обозначает SP (стек-указатель) или ноль, в зависимости от контекста. Первые 8 регистров (X0-X7) используются для передачи аргументов в функции. X0 также хранит возвращаемое значение. X29 - это FP (frame pointer), X30 - LR (link register), X31 - SP.
Каждый 64-битный регистр X имеет 32-битный аналог W. Регистр W0 - это младшие 32 бита регистра X0. Использование W вместо X означает, что операция выполняется над 32 битами, а старшие 32 бита регистра обнуляются. Это важно при работе с int в C, который на ARM64 обычно 4 байта. Если вы используете X-регистр для 32-битного значения, старшие 32 бита могут содержать мусор.
Регистры делятся на две группы по конвенции вызова. X0-X18 - временные регистры (scratch), функция-калышей может их перезаписать без сохранения. X19-X28 - сохраняемые регистры (callee-saved), функция должна сохранить их перед использованием и восстановить перед возвратом. На macOS X18 зарезервирован Apple - не используйте его.
// Регистры X и W: разница между 64-битным и 32-битным доступом
// Файл: registers.s
.section __TEXT,__text
.global _main
.align 2
_main:
MOV X0, #0 // 64-битный: X0 = 0
MOVK X0, #0x1234, LSL #16 // сдвиг и установка: X0 = 0x12340000
MOVK X0, #0x5678, LSL #32 // X0 = 0x5678_1234_0000
MOV W1, #0xAB // 32-битный: W1 = 0xAB, X1 = 0x000000AB
MOV W2, #0xCD // W2 = 0xCD
ADD W3, W1, W2 // W3 = W1 + W2 = 0x178 (с переполнением)
MOV W0, #0 // код возврата 0
RET
Сборка и запуск аналогичны. Откройте в LLDB: lldb ./registers. Поставьте точку останова на _main, запустите, выполняйте по инструкции через stepi, после каждой инструкции проверяйте регистры через register read x0 x1 x2 x3. Вы увидите, как MOV W1, #0xAB обнуляет старшие 32 бита X1, а MOV X0, #0 работает с полными 64 битами.
Загрузите в X0 число 0xDEADBEEF через три инструкции MOV/MOVK. Затем загрузите его же в W0 и объясните, почему значение отличается. Используйте LLDB для проверки значений регистров.
Программа Hello World на ассемблере macOS ARM64 требует понимания системных вызовов. Системный вызов - это способ запросить ядро операционной системы выполнить действие: записать в файл, прочитать файл, выделить память, завершить процесс. На macOS системные вызовы основаны на BSD-конвенции, которая отличается от Linux.
На macOS ARM64 номер системного вызова помещается в регистр X16 (не X8, как на Linux). Аргументы - в X0-X5. Системный вызов инициируется инструкцией SVC #0x80 (не SVC #0, как на Linux). Номера системных вызовов macOS: write = 4, read = 3, exit = 1, open = 5, close = 6. Это BSD-номера, а не Linux generic.
Символы в Mach-O имеют свои особенности. Имя точки входа по умолчанию - _main (с подчёркиванием), если вы линкуете с libSystem. Если вы хотите использовать _start без стандартной библиотеки, нужно указать -e _start при линковке. Имена функций из C-библиотек имеют префикс подчёркивания: printf в C становится _printf в ассемблере.
// Hello World через системный вызов write
// Файл: hello.s
.section __TEXT,__text
.global _main
.align 2
_main:
// Сохраняем frame pointer и link register
STP X29, X30, [SP, #-16]!
MOV X29, SP
// write(1, msg, 14)
MOV X16, #4 // syscall: write
MOV X0, #1 // fd = stdout
ADR X1, msg // адрес строки
MOV X2, #14 // длина
SVC #0x80 // вызов ядра
// exit(0)
MOV X16, #1 // syscall: exit
MOV X0, #0 // статус 0
SVC #0x80
// Восстанавливаем регистры (для порядка, хотя exit не вернётся)
LDP X29, X30, [SP], #16
RET
.section __TEXT,__const
.align 2
msg:
.ascii "Hello, World!\n"
Сборка: clang -arch arm64 hello.s -o hello. Запуск: ./hello. Вывод: Hello, World!. Инструкция ADR X1, msg загружает адрес метки msg в регистр X1. STP и LDP сохраняют и восстанавливают пару регистров на стеке - это стандартный пролог и эпилог функции.
Измените программу, чтобы она выводила две строки: "Hello, " и "World!\n" - через два отдельных вызова write. Подсчитайте общее количество байтов, записанных в stdout, и выведите это число в код возврата.
Процесс сборки ассемблерной программы на macOS состоит из двух этапов: ассемблирование (превращение текста в объектный файл Mach-O) и линковка (объединение объектных файлов в исполняемый файл). Команда as ассемблирует, ld линкует. Команда clang может выполнить оба шага за один вызов.
Полный процесс выглядит так. Сначала as -arch arm64 -o hello.o hello.s создаёт объектный файл. Затем ld -arch arm64 -o hello hello.o -l System -syslibroot $(xcrun -sdk macosx --show-sdk-path) -e _main линкует с системной библиотекой. Флаг -l System подключает libSystem.dylib, который предоставляет стандартные функции. Флаг -syslibroot указывает путь к SDK macOS, где лежат системные библиотеки.
В повседневной работе проще использовать clang, который автоматически вызывает as и ld с правильными параметрами. Но понимание раздельных шагов важно для отладки и для работы с многофайловыми проектами. Если линковка не удаётся, часто помогает выполнить шаги раздельно и посмотреть, на каком этапе возникает проблема.
# Сборка через раздельные шаги # Шаг 1: ассемблирование as -arch arm64 -o hello.o hello.s # Шаг 2: линковка с libSystem ld -arch arm64 -o hello hello.o \ -l System \ -syslibroot $(xcrun -sdk macosx --show-sdk-path) \ -e _main # Шаг 3: запуск ./hello # Альтернатива: всё в один шаг через clang clang -arch arm64 hello.s -o hello # Проверка типа файла file hello # Вывод: Mach-O 64-bit executable arm64 # Просмотр заголовка Mach-O otool -h hello # Дизассемблирование otool -tv hello
Команда file подтверждает, что создан Mach-O исполняемый файл для arm64. otool -h показывает заголовок с magic-числом 0xFEEDFACF для 64-битного Mach-O. otool -tv дизассемблирует код и показывает ваши инструкции.
Соберите программу из темы 3 через раздельные шаги as и ld. Затем соберите через clang. Сравните размер получившихся бинарных файлов. Объясните, почему размер может отличаться.
Mach-O - это формат исполняемых файлов, используемый в macOS, iOS и других операционных системах Apple. Он заменяет ELF, который используется в Linux. Mach-O имеет другую структуру: заголовок Mach header, затем load commands (команды загрузки), затем сегменты с секциями. Каждый сегмент может содержать несколько секций.
Стандартные секции Mach-O для ARM64: __TEXT,__text - исполняемый код, __TEXT,__const - константы только для чтения, __DATA,__data - инициализированные данные, __DATA,__bss - неинициализированные данные. В ELF это .text, .rodata, .data, .bss. При написании ассемблерного кода для macOS используйте синтаксис Mach-O секций.
Apple Silicon использует размер страницы 16 КБ, а не 4 КБ, как в большинстве ARM64-систем. Это влияет на выравнивание сегментов в Mach-O и на работу с виртуальной памятью. Для ассемблерных программ это обычно не критично, но может повлиять на директиву .align. Используйте .align 2 для выравнивания инструкций на 4 байта (2 в степени 2 = 4).
// Структура Mach-O: секции и сегменты
// Файл: macho.s
.section __TEXT,__text // исполняемый код
.global _main
.align 2
_main:
ADRP X0, const_data@PAGE
ADD X0, X0, const_data@PAGEOFF
LDR W1, [X0] // загрузить 32-битное значение
MOV W0, W1 // вернуть его
RET
.section __TEXT,__const // константы
.align 2
const_data:
.word 0xCAFEBABE // 32-битная константа
.section __DATA,__data // изменяемые данные
.align 3
mutable_data:
.quad 0 // 64-битное значение, инициализировано нулём
После сборки изучите структуру: otool -l macho | head -80 покажет load commands и секции. Вы увидите сегменты __TEXT и __DATA с их секциями. Команда nm macho покажет символы _main, const_data, mutable_data и их адреса.
Создайте программу с тремя секциями: __TEXT,__text (код), __TEXT,__const (константа), __DATA,__data (изменяемая переменная). Код должен загрузить константу, прибавить к изменяемой переменной и вернуть результат. Проверьте через otool -l, что все три секции присутствуют в бинарном файле.
Системный вызов - это интерфейс между пользовательской программой и ядром операционной системы. На macOS ядро XNU (основанное на Mach и BSD) предоставляет системные вызовы в стиле BSD. Это означает, что номера и семантика вызовов отличаются от Linux, хотя многие имена совпадают. Для программиста на ассемблере это означает, что код, написанный для Linux ARM64, не будет работать на macOS без адаптации.
Конвенция системных вызовов на macOS ARM64: номер вызова в X16, аргументы в X0-X5, вызов через SVC #0x80. Результат возвращается в X0. При ошибке устанавливается флаг переноса (carry flag), и X0 содержит положительный код ошибки. Это отличается от Linux, где при ошибке X0 содержит отрицательный код.
Основные системные вызовы macOS: exit (1), read (3), write (4), open (5), close (6), getpid (20). Полный список находится в файле syscalls.master в исходном коде XNU, который доступен на GitHub. Номера считаются приватными и могут меняться, но на практике остаются стабильными между версиями macOS.
// Системные вызовы: write и exit
// Файл: syscalls.s
.section __TEXT,__text
.global _main
.align 2
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
// write(1, msg, 5)
MOV X16, #4 // syscall write
MOV X0, #1 // stdout
ADR X1, msg
MOV X2, #5 // длина
SVC #0x80
// exit(0)
MOV X16, #1 // syscall exit
MOV X0, #0
SVC #0x80
LDP X29, X30, [SP], #16
RET
.section __TEXT,__const
msg:
.ascii "Hello\n"
Программа вызывает write для вывода строки в stdout, затем exit для завершения. Обратите внимание: после SVC #0x80 можно проверить флаг переноса через B.CC для обработки ошибок. В данном примере обработка опущена для простоты.
Напишите программу, которая открывает файл (через open, syscall 5), читает из него 100 байтов (через read, syscall 3), выводит их в stdout (через write, syscall 4) и закрывает файл (через close, syscall 6). Обрабатывайте ошибки: если open вернул ошибку, выводите сообщение и завершайтесь с кодом 1.
На macOS ARM64 для загрузки адресов используется пара инструкций ADRP и ADD. ADRP загружает адрес страницы (4 КБ или 16 КБ на Apple Silicon), на которой находится символ. ADD добавляет смещение внутри страницы. Вместе они позволяют адресовать любой символ в диапазоне Ђ4 ГБ от текущего счётчика команд. Это отличается от ADR, который имеет диапазон всего Ђ1 МБ.
Макросы @PAGE и @PAGEOFF - это расширения LLVM-ассемблера для macOS. @PAGE возвращает старшие биты адреса (номер страницы), @PAGEOFF - младшие биты (смещение внутри страницы). На Linux GNU assembler использует другой синтаксис: :lo12: для смещения. При переносе кода между платформами нужно учитывать это различие.
Для адресации кода внутри одной функции часто достаточно ADR, так как диапазон Ђ1 МБ покрывает большинство функций. Для доступа к глобальным данным в секциях __TEXT,__const или __DATA,__data используется ADRP + ADD, потому что данные могут находиться далеко от кода в бинарном файле.
// ADRP + ADD: загрузка адреса глобальной переменной
// Файл: addressing.s
.section __DATA,__data
.align 3
counter:
.quad 41 // 64-битная переменная, инициализирована 41
.section __TEXT,__text
.global _main
.align 2
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
// Загружаем адрес counter
ADRP X0, counter@PAGE // X0 = страница counter
ADD X0, X0, counter@PAGEOFF // X0 = точный адрес counter
// Читаем значение
LDR X1, [X0] // X1 = *counter = 41
// Инкрементируем
ADD X1, X1, #1 // X1 = 42
// Записываем обратно
STR X1, [X0] // *counter = 42
// Возвращаем значение
MOV X0, X1
LDP X29, X30, [SP], #16
RET
Программа загружает адрес глобальной переменной counter, читает её значение, увеличивает на 1 и записывает обратно. LDR X1, [X0] загружает 64-битное значение из памяти по адресу в X0 в регистр X1. STR X1, [X0] сохраняет значение из X1 по тому же адресу.
Создайте массив из 5 целых чисел в секции __DATA,__data. Напишите программу, которая загружает адрес массива через ADRP + ADD, обходит все элементы, суммирует их и возвращает сумму. Используйте LDR для чтения каждого элемента и арифметику указателей для перехода к следующему.
ARM64 предоставляет полный набор арифметических инструкций: ADD (сложение), SUB (вычитание), MUL (умножение), SDIV (деление со знаком), UDIV (деление без знака). Все они работают с регистрами. Операнды могут быть регистр и константа (immediate), или два регистра. Деление на ноль не вызывает исключения - результат равен нулю.
Инструкция ADD может устанавливать флаги условий, если использовать суффикс S: ADDS. Флаги хранятся в регистре состояния процессора (CPSR) и используются условными ветвлениями. Основные флаги: N (negative), Z (zero), C (carry), V (overflow). После ADDS W0, W1, W2 флаг Z устанавливается, если результат равен нулю, флаг N - если результат отрицательный.
Инструкция MUL умножает два 64-битных регистра и возвращает младшие 64 бита результата. Для полного 128-битного произведения используются SMULH (со знаком) и UMULH (без знака). Инструкция MADD умножает и складывает: MADD X0, X1, X2, X3 выполняет X0 = X1 * X2 + X3, что полезно для вычисления адресов и полиномиальных вычислений.
// Арифметика: сложение, вычитание, умножение, деление
// Файл: arithmetic.s
.section __TEXT,__text
.global _main
.align 2
_main:
MOV W0, #10 // W0 = 10
MOV W1, #3 // W1 = 3
ADD W2, W0, W1 // W2 = 10 + 3 = 13
SUB W3, W0, W1 // W3 = 10 - 3 = 7
MUL W4, W0, W1 // W4 = 10 * 3 = 30
SDIV W5, W0, W1 // W5 = 10 / 3 = 3
// MADD: умножение со сложением
MOV W6, #5
MADD W7, W0, W1, W6 // W7 = 10 * 3 + 5 = 35
// ADDS с установкой флагов
MOV W8, #0
MOV W9, #0
ADDS W8, W8, #0 // устанавливает флаг Z (результат 0)
// Возвращаем 0
MOV W0, #0
RET
Соберите и откройте в LLDB. Выполняйте по инструкции через stepi, после каждой проверяйте регистры: register read w0 w1 w2 w3 w4 w5 w6 w7 w8. Вы увидите, как каждая инструкция меняет ровно один регистр. После ADDS W8, W8, #0 проверьте флаги: register read cpsr - бит Z должен быть установлен.
Вычислите факториал числа 5 в регистрах, используя только MUL и MOV. Результат должен оказаться в W0. Проверьте, что 5! = 120. Затем вычислите факториал числа 10 и объясните, почему результат не помещается в 32-битный регистр W0.
Управление потоком выполнения в ARM64 осуществляется через условные и безусловные ветвления. Безусловная ветвь B label переходит на метку без сохранения адреса возврата. Ветвь с сохранением BL label сохраняет адрес следующей инструкции в X30 (LR) и переходит - это вызов функции. Возврат из функции - RET, который переходит по адресу в X30.
Условные ветвления используют флаги условий. После инструкции CMP W0, W1 устанавливаются флаги, и условная ветвь B.EQ label переходит, если значения равны. Основные условия: EQ (равно), NE (не равно), GT (больше), LT (меньше), GE (больше или равно), LE (меньше или равно). Для беззнакового сравнения используются HS (выше или равно) и LO (ниже).
ARM64 также поддерживает условные инструкции с суффиксом: ADD W0, W1, W2, EQ выполнится, только если флаг EQ установлен. Это позволяет писать безветвлевой код, который часто быстрее, потому что избегает промахов предсказателя ветвлений. Но условные инструкции занимают ресурсы и могут быть медленнее, если условие почти всегда истинно или ложно.
// Ветвления: if/else через CMP и B.cond
// Файл: branches.s
.section __TEXT,__text
.global _main
.align 2
_main:
MOV W0, #7 // число для проверки
MOV W1, #5 // порог
CMP W0, W1 // сравниваем W0 и W1
B.GT greater // если W0 > W1, перейти
// W0 <= W1
MOV W2, #0 // результат 0
B done
greater:
MOV W2, #1 // результат 1
done:
// Возвращаем W2
MOV W0, W2
RET
Программа сравнивает два числа и возвращает 1, если первое больше, и 0 в противном случае. Инструкция CMP W0, W1 вычисляет W0 - W1 и устанавливает флаги, не сохраняя результат. Ветвь B.GT проверяет флаги и переходит, если W0 больше W1 (с учётом знака).
Напишите функцию, которая определяет, является ли число чётным или нечётным, используя TST W0, #1 (проверка младшего бита) и условную ветвь. Верните 0 для чётного и 1 для нечётного. Проверьте на числах 4 и 7.
Условные инструкции - отличительная черта ARM. Инструкция может выполняться или нет, в зависимости от флагов условий. Например, MOV W0, W1, EQ копирует W1 в W0 только если флаг EQ установлен. Это позволяет реализовывать простые условные операции без ветвлений, что улучшает производительность на процессорах с длинным конвейером.
Безветвлевой код особенно эффективен для коротких операций выбора. Например, нахождение максимума двух чисел через условное движение: CMP W0, W1; CSEL W2, W0, W1, GT. Инструкция CSEL (conditional select) выбирает один из двух регистров в зависимости от условия. Если W0 > W1, W2 = W0, иначе W2 = W1. Это одна инструкция вместо ветвления, и она не вызывает промах предсказателя.
Не все операции выгодно делать безветвлевыми. Если ветвление хорошо предсказывается (например, почти всегда выбирается один путь), обычная ветвь будет быстрее. Условные инструкции занимают исполнительные ресурсы процессора даже когда условие ложно. Баланс между ветвлением и безветвлевым кодом - одна из тонкостей оптимизации на ARM64.
// CSEL: выбор максимума без ветвления
// Файл: csel.s
.section __TEXT,__text
.global _main
.align 2
_main:
MOV W0, #42 // первое число
MOV W1, #17 // второе число
CMP W0, W1 // сравниваем
CSEL W2, W0, W1, GT // W2 = (W0 > W1) ? W0 : W1
// CSEL для минимума
CSEL W3, W0, W1, LT // W3 = (W0 < W1) ? W0 : W1
// Абсолютное значение через CSNEG
MOV W4, #-5
CMP W4, #0
CSNEG W4, W4, W4, LT // если W4 < 0, W4 = -W4
MOV W0, W2 // возвращаем максимум
RET
Инструкция CSNEG (conditional select negate) выбирает между регистром и его отрицанием в зависимости от условия. Это компактный способ вычисления абсолютного значения без ветвления. Все три операции - максимум, минимум, модуль - выполняются без единой ветви.
Напишите функцию clamp, которая ограничивает значение в диапазоне [min, max] с использованием только условных инструкций (CSEL, CSMIN, CSMAX если доступны, или комбинации CMP + CSEL). Проверьте на значениях: 5 с диапазоном [10, 20] должно вернуть 10; 25 с диапазоном [10, 20] должно вернуть 20; 15 с диапазоном [10, 20] должно вернуть 15.
Стек в ARM64 растёт вниз: от больших адресов к меньшим. Регистр SP (stack pointer) указывает на вершину стека. При вызове функции адрес возврата сохраняется в регистре LR (X30). Функция, которая вызывает другие функции, должна сохранить LR на стеке, потому что следующий BL перезапишет его. Это базовое правило, нарушение которого приводит к зависанию или падению программы.
Apple требует, чтобы стек был выровнен на 16 байт перед любым вызовом функции. Это значит, что SP должен быть кратен 16, когда выполняется BL. Несоблюдение этого правила вызывает исключение выравнивания. Для выравнивания используйте STP и LDP с шагом 16, которые автоматически работают с парами регистров.
Стандартный пролог функции на macOS ARM64: STP X29, X30, [SP, #-16]! (сохранить FP и LR, уменьшить SP на 16), MOV X29, SP (установить FP на новую вершину стека). Эпилог: LDP X29, X30, [SP], #16 (восстановить FP и LR, увеличить SP на 16), RET. Этот паттерн встречается в каждой нетривиальной функции.
// Стек и вызов функции
// Файл: stack.s
.section __TEXT,__text
.global _main
.align 2
// Функция add: W0 = W0 + W1
add_numbers:
ADD W0, W0, W1 // результат в W0
RET
_main:
// Пролог
STP X29, X30, [SP, #-16]!
MOV X29, SP
// Подготовка аргументов
MOV W0, #15
MOV W1, #27
// Вызов функции
BL add_numbers
// W0 теперь содержит 42
// Эпилог
LDP X29, X30, [SP], #16
RET
Инструкция BL add_numbers сохраняет адрес следующей инструкции в X30 и переходит на add_numbers. Функция add_numbers выполняет сложение и возвращает через RET, который переходит по адресу в X30. Главная функция сохраняет X30 на стеке, потому что без этого BL перезаписал бы адрес возврата в систему.
Напишите программу с двумя функциями: square (возводит число в квадрат) и sum_of_squares (сумма квадратов двух чисел, вызывает square дважды). _main должен вызывать sum_of_squares с аргументами 3 и 4 и возвращать результат (9 + 16 = 25). Все функции должны сохранять и восстанавливать FP и LR.
Конвенция вызова AAPCS64 (ARM Architecture Procedure Call Standard for 64-bit) определяет, как функции передают аргументы и возвращают значения. На macOS Apple следует AAPCS64 с небольшими платформенными дополнениями. Первые 8 аргументов передаются в регистрах X0-X7. Возвращаемое значение - в X0. Если аргументов больше 8, остальные передаются через стек.
Регистры X0-X7 - временные (caller-saved). Вызывающая функция должна сохранить их, если они нужны после вызова. Регистры X19-X28 - сохраняемые (callee-saved). Вызываемая функция должна сохранить их перед использованием и восстановить перед возвратом. На macOS X18 зарезервирован Apple, не используйте его.
Регистры X8-X15 - также временные, но имеют специальные роли в некоторых контекстах. X8 используется как indirect result location register (для больших возвращаемых значений). X9-X15 - полностью временные, могут использоваться как scratch. При написании функции, которая вызывает другие функции, помните, что любые из X0-X15 могут быть перезаписаны.
// Конвенция вызова: 4 аргумента в регистрах
// Файл: aapcs.s
.section __TEXT,__text
.global _main
.align 2
// Функция: W0 = W0 * W1 + W2 * W3
linear_combination:
// Сохраняем callee-saved, если нужны
// (здесь не нужны, мы не используем X19-X28)
MUL W4, W0, W1 // W4 = W0 * W1
MUL W5, W2, W3 // W5 = W2 * W3
ADD W0, W4, W5 // W0 = W4 + W5
RET // результат в W0
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
// Аргументы: 2 * 3 + 4 * 5 = 26
MOV W0, #2
MOV W1, #3
MOV W2, #4
MOV W3, #5
BL linear_combination
// W0 = 26
LDP X29, X30, [SP], #16
RET
Функция linear_combination принимает 4 аргумента в W0-W3 и возвращает результат в W0. Она использует временные регистры W4 и W5 для промежуточных вычислений - это безопасно, потому что они caller-saved и мы находимся в вызываемой функции. Если бы linear_combination вызывала другую функцию, W4 и W5 могли бы быть перезаписаны.
Напишите функцию avg4, которая принимает 4 числа в W0-W3 и возвращает их среднее арифметическое в W0. Используйте ADD для суммы и SDIV для деления. Вызовите из _main с аргументами 10, 20, 30, 40 и проверьте, что результат равен 25.
Инструкции LDR (load register) и STR (store register) - основные инструкции работы с памятью в ARM64. LDR X0, [X1] загружает 64-битное значение из памяти по адресу в X1 в регистр X0. STR X0, [X1] сохраняет 64-битное значение из X0 в память по адресу в X1. Размер данных определяется суффиксом: LDRB (1 байт), LDRH (2 байта), LDR W (4 байта), LDR X (8 байтов).
ARM64 поддерживает несколько режимов адресации. Базовый: LDR X0, [X1] - адрес в X1. Со смещением: LDR X0, [X1, #8] - адрес X1 + 8. Прединдексация: LDR X0, [X1, #8]! - адрес X1 + 8, и X1 обновляется до X1 + 8. Постиндексация: LDR X0, [X1], #8 - адрес X1, затем X1 обновляется до X1 + 8. Прединдексация и постиндексация удобны для обхода массивов.
Для работы со стеком есть парные инструкции STP (store pair) и LDP (load pair), которые сохраняют и загружают два регистра одновременно. Они эффективнее, чем две отдельные инструкции STR и LDR, и автоматически работают с 16-байтным выравниванием. Используйте их для пролога и эпилога функций.
// LDR/STR: чтение и запись памяти
// Файл: memory.s
.section __DATA,__data
.align 3
array:
.quad 10, 20, 30, 40, 50 // массив из 5 int64
.section __TEXT,__text
.global _main
.align 2
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
// Загружаем адрес массива
ADRP X1, array@PAGE
ADD X1, X1, array@PAGEOFF
// Читаем третий элемент (индекс 2, смещение 16)
LDR X2, [X1, #16] // X2 = 30
// Удваиваем его
LSL X2, X2, #1 // X2 = 60
// Записываем обратно
STR X2, [X1, #16] // array[2] = 60
// Обход массива через постиндексацию
MOV X3, #0 // индекс
MOV X4, #0 // сумма
MOV X5, #5 // количество элементов
loop:
LDR X6, [X1], #8 // X6 = *X1; X1 += 8
ADD X4, X4, X6 // сумма += X6
ADD X3, X3, #1 // индекс++
CMP X3, X5
B.LT loop
// X4 = сумма всех элементов
LDP X29, X30, [SP], #16
RET
Программа демонстрирует три режима адресации. Прямая адресация со смещением для чтения конкретного элемента. Запись обратно в память. Постиндексация LDR X6, [X1], #8 для обхода массива: после каждой загрузки X1 автоматически увеличивается на 8 (размер одного элемента).
Создайте массив из 10 байтов в секции __DATA,__data. Напишите программу, которая обходит массив с использованием LDRB и постиндексации, находит максимальный байт и возвращает его в W0. Инициализируйте массив значениями от 1 до 10 и проверьте, что результат равен 10.
Цикл в ассемблере реализуется через метку, тело цикла, изменение счётчика и условную ветвь обратно на метку. ARM64 не имеет специальной инструкции цикла вроде LOOP в x86. Цикл for из C превращается в последовательность: инициализация счётчика, метка начала, тело, инкремент, сравнение, условная ветвь.
Цикл while реализуется так: метка начала, проверка условия, условная ветвь на конец, тело, безусловная ветвь на начало. Цикл do-while: метка начала, тело, проверка условия, условная ветвь на начало. Разница между while и do-while - в месте проверки условия: до тела или после.
Оптимизация циклов на ARM64 часто сводится к уменьшению инструкций внутри цикла. Каждая инструкция добавляет такты. Использование прединдексации и постиндексации для обхода массивов экономит отдельную инструкцию инкремента. Условные инструкции вместо ветвлений внутри цикла могут убрать ветвь, которая вызывает промахи предсказателя.
// Цикл: сумма чисел от 1 до N
// Файл: loop.s
.section __TEXT,__text
.global _main
.align 2
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
MOV W0, #0 // сумма = 0
MOV W1, #1 // счётчик = 1
MOV W2, #100 // N = 100
loop_start:
ADD W0, W0, W1 // сумма += счётчик
ADD W1, W1, #1 // счётчик++
CMP W1, W2 // сравнить с N
B.LE loop_start // если счётчик <= N, продолжать
// W0 = 5050 (сумма 1..100)
LDP X29, X30, [SP], #16
RET
Цикл выполняет 100 итераций. Каждая итерация: ADD (1 такт), ADD (1 такт), CMP (1 такт, объединяется с ветвью), B.LE (1 такт при правильном предсказании). Всего 4 инструкции на итерацию, 400 инструкций на весь цикл. На современном Apple Silicon это менее 100 наносекунд.
Напишите цикл, который вычисляет сумму чётных чисел от 2 до 100. Используйте инкремент на 2: ADD W1, W1, #2. Сравните производительность с версией, которая проверяет каждый чётный бит через TST W1, #1. Какая быстрее и почему?
Массив в ассемблере - это последовательность элементов одинакового размера в памяти. Доступ к элементу выполняется через базовый адрес плюс смещение: LDR W0, [X1, X2, LSL #2] загружает элемент массива int по индексу в X2, где X1 - базовый адрес. Сдвиг LSL #2 умножает индекс на 4 (размер int в байтах). Для 64-битных элементов используйте LSL #3 (умножение на 8).
Строка в ассемблере - это массив байтов. ASCII-символы занимают по 1 байту. Для чтения символа используйте LDRB. Для сравнения строк нужно обойти оба массива и сравнить байты до нулевого терминатора. В C это делается через strcmp, в ассемблере вы пишете этот цикл вручную.
На macOS строки можно размещать в секции __TEXT,__const с помощью директивы .ascii или .asciz (с нулевым терминатором). Длина строки вычисляется как разность адресов: msg_len = . - msg. Директива . обозначает текущую позицию, поэтому . - msg даёт количество байтов от метки msg до текущей позиции.
// Строки: вычисление длины и преобразование в верхний регистр
// Файл: strings.s
.section __TEXT,__const
msg:
.asciz "Hello, ARM64!" // строка с нулевым терминатором
.section __TEXT,__text
.global _main
.align 2
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
ADRP X0, msg@PAGE
ADD X0, X0, msg@PAGEOFF
// Вычисление длины строки
MOV X1, X0 // X1 = указатель
strlen_loop:
LDRB W2, [X1] // W2 = текущий байт
CBZ W2, strlen_done // если 0, конец строки
ADD X1, X1, #1 // следующий байт
B strlen_loop
strlen_done:
SUB X3, X1, X0 // X3 = длина строки
// Преобразование в верхний регистр
MOV X1, X0 // сброс указателя
upper_loop:
LDRB W2, [X1] // читаем символ
CBZ W2, upper_done // конец строки
// Если 'a' <= W2 <= 'z', вычесть 32
CMP W2, #'a'
B.LT upper_skip
CMP W2, #'z'
B.GT upper_skip
SUB W2, W2, #32 // в верхний регистр
STRB W2, [X1] // записываем обратно
upper_skip:
ADD X1, X1, #1
B upper_loop
upper_done:
MOV W0, W3 // возвращаем длину
LDP X29, X30, [SP], #16
RET
Инструкция CBZ (compare and branch if zero) - компактная форма проверки на ноль. Она заменяет CMP W2, #0 + B.EQ. Сравнение с символьными константами #'a' и #'z' работает, потому что ASCII-коды букв идут подряд: 'a' = 97, 'z' = 122. Разница между строчной и прописной буквой - 32.
Напишите функцию strcmp_asm, которая сравнивает две строки и возвращает 0, если они равны, отрицательное число, если первая меньше, и положительное, если первая больше. Адресы строк передаются в X0 и X1. Результат - в W0. Протестируйте на строках "hello" и "world".
Функция в ассемблере - это метка, после которой идёт код, заканчивающийся инструкцией RET. Вызов функции - BL label, который сохраняет адрес возврата в X30 и переходит на метку. Возврат - RET, который переходит по адресу в X30. Если функция вызывает другие функции, она должна сохранить X30 на стеке, потому что BL перезапишет его.
Локальные переменные функции размещаются на стеке. Пролог функции выделяет место на стеке, вычитая из SP размер, нужный для локальных переменных. Эпилог освобождает это место, прибавляя обратно. Например, для двух локальных 64-битных переменных: SUB SP, SP, #16 в прологе, ADD SP, SP, #16 в эпилоге. Доступ к переменным - через STR и LDR с смещением от SP.
Функции с большим количеством локальных переменных или глубокой рекурсией могут переполнить стек. Стек на macOS по умолчанию ограничен 8 МБ для главного потока. Для рекурсивных функций с глубоким стеком это ограничение может стать проблемой. В C это решается через ulimit -s или программную итерацию вместо рекурсии.
// Функция с локальными переменными на стеке
// Файл: functions.s
.section __TEXT,__text
.global _main
.align 2
// Функция: W0 = W0 * W0 + local1 * local2
compute:
// Пролог с локальными переменными
STP X29, X30, [SP, #-32]! // 32 байт: 16 для FP/LR, 16 для локальных
MOV X29, SP
// Локальные переменные по смещениям от SP
STR W0, [SP, #16] // local1 = W0 (первый аргумент)
MOV W1, #3
STR W1, [SP, #20] // local2 = 3
// W0 * W0
LDR W2, [SP, #16] // W2 = local1
MUL W3, W2, W2 // W3 = W2 * W2
// local1 * local2
LDR W4, [SP, #16]
LDR W5, [SP, #20]
MUL W6, W4, W5 // W6 = local1 * local2
// Результат
ADD W0, W3, W6 // W0 = W3 + W6
// Эпилог
LDP X29, X30, [SP], #32
RET
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
MOV W0, #5 // аргумент
BL compute // W0 = 5*5 + 5*3 = 40
LDP X29, X30, [SP], #16
RET
Функция compute выделяет 32 байта на стеке: 16 для сохранения FP и LR, 16 для двух локальных 32-битных переменных. Локальные переменные хранятся по смещениям #16 и #20 от SP. Директива STP X29, X30, [SP, #-32]! одновременно уменьшает SP на 32 и сохраняет два регистра.
Напишите функцию swap, которая принимает адреса двух переменных (в X0 и X1) и обменивает их значения. Функция должна загрузить значения по адресам, обменять их и записать обратно. Вызовите из _main с двумя переменными на стеке.
Рекурсия в ассемблере - это функция, которая вызывает сама себя через BL. Каждый вызов создаёт новый фрейм на стеке с сохранённым LR и локальными переменными. При возврате из рекурсии фреймы освобождаются в обратном порядке. Рекурсия работает, потому что каждый вызов имеет свой набор сохранённых регистров и локальных переменных на стеке.
Классический пример рекурсии - факториал. Факториал n равен n, умноженному на факториал (n-1). Базовый случай: факториал 0 равен 1. В ассемблере функция получает n в W0, проверяет базовый случай, если n равно 0, возвращает 1. Иначе уменьшает n на 1, вызывает себя, умножает результат на исходное n.
Рекурсия требует сохранения LR перед рекурсивным вызовом, потому что BL перезапишет его. Каждый уровень рекурсии добавляет минимум 16 байт на стек (для FP и LR). Для факториала 10 это 160 байт - ничтожно мало. Для факториала 1000 это 16 КБ, что тоже приемлемо. Но для неконтролируемой рекурсии переполнение стека наступает быстро.
// Рекурсия: факториал
// Файл: recursion.s
.section __TEXT,__text
.global _main
.align 2
// Функция: W0 = factorial(W0)
factorial:
// Пролог
STP X29, X30, [SP, #-16]!
MOV X29, SP
// Базовый случай: если W0 <= 1, вернуть 1
CMP W0, #1
B.GT recurse
MOV W0, #1 // factorial(0) = factorial(1) = 1
B fact_done
recurse:
// Сохраняем n
MOV W19, W0 // W19 = n (callee-saved)
// Рекурсивный вызов: factorial(n-1)
SUB W0, W0, #1
BL factorial
// W0 = factorial(n-1)
// Умножаем: n * factorial(n-1)
MUL W0, W19, W0 // W0 = n * factorial(n-1)
fact_done:
// Эпилог
LDP X29, X30, [SP], #16
RET
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
MOV W0, #5 // 5!
BL factorial // W0 = 120
LDP X29, X30, [SP], #16
RET
Регистр W19 используется для сохранения n перед рекурсивным вызовом. W19 - callee-saved, поэтому factorial может его использовать, но должна сохранить перед использованием. В данном случае мы не сохраняем W19 явно, потому что полагаемся на то, что вызывающая функция (_main) не ожидает его сохранения. В более строгом коде нужно сохранить W19 в прологе и восстановить в эпилоге.
Напишите рекурсивную функцию Фибоначчи: fib(0) = 0, fib(1) = 1, fib(n) = fib(n-1) + fib(n-2). Вызовите fib(10) и проверьте, что результат равен 55. Сравните производительность с итеративной версией. Почему рекурсивная версия медленнее и как это исправить?
На macOS ARM64 ассемблерный код может вызывать функции из C-библиотек. libSystem.dylib предоставляет стандартные функции: printf, malloc, free, puts, exit. Для вызова C-функции из ассемблера используйте BL _function_name, где имя функции имеет префикс подчёркивания. Это специфика macOS: в C функция называется printf, в ассемблере - _printf.
При вызове C-функций нужно соблюдать конвенцию вызова AAPCS64. Первые 8 аргументов в X0-X7. Возвращаемое значение в X0. Стек выровнен на 16 байт. Для variadic-функций (printf, scanf) macOS требует укладки аргументов в стек, а не в регистры. Это важное отличие от Linux, где variadic-аргументы передаются в регистрах.
Для вызова printf на macOS нужно: загрузить форматную строку в X0 через ADRP+ADD, уложить остальные аргументы в стек, вызвать BL _printf. После вызова очистить стек. Форматная строка должна быть в секции __TEXT,__const и заканчиваться нулевым байтом.
// Вызов printf из ассемблера
// Файл: call_c.s
.section __TEXT,__const
.align 2
fmt:
.asciz "x = %d, y = %d, sum = %d\n"
.section __TEXT,__text
.global _main
.align 2
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
MOV W19, #15 // x (callee-saved)
MOV W20, #27 // y (callee-saved)
ADD W21, W19, W20 // sum = 42
// Подготовка аргументов для printf
// printf(fmt, x, y, sum)
// На macOS variadic-аргументы кладутся в стек
SUB SP, SP, #32 // место для 4 аргументов (16-байт выравнивание)
STR W21, [SP, #0] // sum (3-й аргумент после fmt)
STR W20, [SP, #8] // y
STR W19, [SP, #16] // x
ADRP X0, fmt@PAGE
ADD X0, X0, fmt@PAGEOFF
BL _printf
ADD SP, SP, #32 // очистка стека
MOV W0, #0 // код возврата
LDP X29, X30, [SP], #16
RET
Программа вызывает printf с форматной строкой и тремя аргументами. Аргументы укладываются в стек, потому что printf - variadic-функция. Выравнивание стека на 16 байт соблюдается: 32 байта - кратно 16. После вызова стек очищается прибавлением 32 к SP.
Напишите программу, которая вызывает printf для вывода строки "Hello from assembly!" без аргументов. Затем вызовите puts для вывода другой строки. Объясните разницу между printf и puts с точки зрения вызова из ассемблера.
Ассемблерные функции можно вызывать из C-кода. Для этого нужно: объявить функцию с внешней linkage в C, реализовать её в ассемблере с правильным именем (с префиксом подчёркивания на macOS), и собирать оба файла вместе. C-компилятор и ассемблер создают объектные файлы, которые линкер объединяет в один исполняемый файл.
Ассемблерная функция должна соблюдать конвенцию вызова AAPCS64. Аргументы приходят в X0-X7, результат возвращается в X0. Если функция использует callee-saved регистры (X19-X28), она должна сохранить их в прологе и восстановить в эпилоге. Регистры X0-X18 можно использовать свободно, но помните, что X18 зарезервирован Apple.
Для сборки многофайлового проекта используйте clang с обоими файлами: clang -arch arm64 main.c functions.s -o program. Clang автоматически вызовет ассемблер для .s файла и линкер для объединения. C-функции могут вызывать ассемблерные функции и наоборот без ограничений.
Файл main.c:
#include <stdio.h>
// Объявление внешней функции
extern int add_asm(int a, int b);
extern int factorial_asm(int n);
int main(void) {
printf("add_asm(10, 20) = %d\n", add_asm(10, 20));
printf("factorial_asm(5) = %d\n", factorial_asm(5));
return 0;
}
Файл functions.s:
.section __TEXT,__text
.global _add_asm
.global _factorial_asm
.align 2
// int add_asm(int a, int b)
_add_asm:
ADD W0, W0, W1
RET
// int factorial_asm(int n)
_factorial_asm:
STP X29, X30, [SP, #-16]!
MOV X29, SP
CMP W0, #1
B.GT fact_recurse
MOV W0, #1
B fact_end
fact_recurse:
MOV W19, W0
SUB W0, W0, #1
BL _factorial_asm
MUL W0, W19, W0
fact_end:
LDP X29, X30, [SP], #16
RET
Сборка: clang -arch arm64 main.c functions.s -o program. Запуск: ./program. Вывод:
add_asm(10, 20) = 30
factorial_asm(5) = 120
Напишите ассемблерную функцию strlen_asm(const char *s), которая вычисляет длину строки. Объявите её в C, вызовите из main и сравните результат с strlen из стандартной библиотеки. Сборка: clang main.c strlen.s -o test.
Указатель в ассемблере - это просто адрес в регистре. Нет специальных типов указателей, как в C. Регистр X0 может содержать адрес чего угодно: массива, структуры, функции. Тип данных определяется тем, как вы обращаетесь к памяти: LDRB для байта, LDR W для 32-битного int, LDR X для 64-битного long. Программист отвечает за правильный размер доступа.
Структура в C размещается в памяти последовательно, с возможным выравниванием полей. На ARM64 выравнивание по умолчанию: 1 байт для char, 2 для short, 4 для int, 8 для long и указателей. Поля идут в порядке объявления. Для доступа к полю структуры используйте базовый адрес плюс смещение поля.
Например, структура struct Point { int x; int y; } занимает 8 байтов. Поле x по смещению 0, поле y по смещению 4. Доступ: LDR W0, [X1] для x, LDR W0, [X1, #4] для y, где X1 содержит адрес структуры. Если структура передаётся по указателю, сначала загрузите указатель в регистр, затем обращайтесь к полям по смещению.
Файл struct_test.c:
#include <stdio.h>
struct Point {
int x;
int y;
};
extern int distance_squared(struct Point *p1, struct Point *p2);
int main(void) {
struct Point a = {3, 4};
struct Point b = {6, 8};
printf("distance^2 = %d\n", distance_squared(&a, &b));
return 0;
}
Файл struct_test.s:
// int distance_squared(struct Point *p1, struct Point *p2)
// X0 = p1, X1 = p2
// struct Point { int x (offset 0); int y (offset 4); }
.section __TEXT,__text
.global _distance_squared
.align 2
_distance_squared:
// Загружаем координаты первой точки
LDR W2, [X0] // p1->x
LDR W3, [X0, #4] // p1->y
// Загружаем координаты второй точки
LDR W4, [X1] // p2->x
LDR W5, [X1, #4] // p2->y
// Разности
SUB W2, W4, W2 // dx = p2->x - p1->x
SUB W3, W5, W3 // dy = p2->y - p1->y
// Квадраты
MUL W2, W2, W2 // dx * dx
MUL W3, W3, W3 // dy * dy
// Сумма
ADD W0, W2, W3 // dx^2 + dy^2
RET
Сборка: clang -arch arm64 struct_test.c struct_test.s -o struct_test. Запуск: ./struct_test. Вывод: distance^2 = 25. Функция получает указатели на две структуры Point и вычисляет квадрат расстояния между ними. Смещения полей (0 и 4) соответствуют структуре C.
Определите структуру struct Rect { int x, y, width, height; }. Напишите ассемблерную функцию int rect_area(struct Rect *r), которая возвращает площадь прямоугольника. Напишите функцию int rect_contains(struct Rect *r, int px, int py), которая возвращает 1, если точка (px, py) внутри прямоугольника, и 0 в противном случае.
NEON - это SIMD-расширение ARM (Single Instruction Multiple Data), которое позволяет выполнять одну операцию над несколькими данными одновременно. NEON-регистры (V0-V31) имеют ширину 128 бит и могут рассматриваться как массивы: 16 байтов, 8 halfwords, 4 words, 2 doublewords. Одна инструкция может сложить 4 пары 32-битных чисел за один такт.
NEON полезен для обработки медиа: графики, аудио, видео, научных вычислений. Например, конвертация RGBA-пикселя в grayscale: нужно умножить 4 компонента на коэффициенты и сложить. С NEON это одна инструкция вместо четырёх. Умножение матриц, свёртка, БПФ - все эти алгоритмы ускоряются в 2-4 раза при правильном использовании SIMD.
Apple Silicon полностью поддерживает NEON. В macOS 14 и новее также доступно расширение AMX (Apple Matrix Extension) для матричных вычислений. NEON-инструкции имеют суффиксы, обозначающие тип данных: .16B (16 байтов), .4S (4 слова), .2D (2 doubleword). Например, ADD V0.4S, V1.4S, V2.4S складывает 4 пары 32-битных чисел одновременно.
// NEON: сложение 4 пар чисел одновременно
// Файл: neon.s
.section __TEXT,__const
.align 4
vec_a:
.word 10, 20, 30, 40 // 4 x 32-битных числа
vec_b:
.word 1, 2, 3, 4
.section __TEXT,__text
.global _main
.align 2
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
// Загружаем векторы
ADRP X0, vec_a@PAGE
ADD X0, X0, vec_a@PAGEOFF
LDR Q0, [X0] // Q0 = 128-битный регистр
ADRP X1, vec_b@PAGE
ADD X1, X1, vec_b@PAGEOFF
LDR Q1, [X1]
// NEON-сложение: 4 пары 32-битных чисел
ADD V2.4S, V0.4S, V1.4S // V2 = {11, 22, 33, 44}
// Сохраняем результат
ADRP X2, vec_a@PAGE
ADD X2, X2, vec_a@PAGEOFF
STR Q2, [X2] // перезаписываем vec_a
// Возвращаем первый элемент
MOV W0, V2.S[0] // W0 = 11
LDP X29, X30, [SP], #16
RET
Инструкция LDR Q0, [X0] загружает 128 бит из памяти в NEON-регистр Q0. ADD V2.4S, V0.4S, V1.4S складывает четыре 32-битных элемента одновременно. MOV W0, V2.S[0] извлекает первый 32-битный элемент из вектора. Одна инструкция ADD выполнила работу четырёх обычных ADD.
Напишите программу, которая умножает два 4-элементных вектора через NEON: MUL V2.4S, V0.4S, V1.4S. Инициализируйте векторы значениями и проверьте результат. Затем напишите скалярную версию (через обычные MUL) и сравните количество инструкций.
Оптимизация ассемблерного кода начинается с измерений. Преждевременная оптимизация без профилирования часто ухудшает код. На macOS для измерения времени используется mach_absolute_time() из Mach API, или утилита time в терминале. Для точного профилирования - Instruments.app от Apple, которая показывает такты процессора на каждую функцию.
Основные приёмы оптимизации на ARM64: уменьшение количества инструкций в горячих циклах, использование условных инструкций вместо ветвлений, использование NEON для параллельной обработки, правильное расположение данных в памяти (cache-friendly), устранение зависимостей между инструкциями для параллельного выполнения. Современные процессоры ARM64 могут выполнять несколько инструкций за такт, если они независимы.
Компилятор C с оптимизацией -O2 или -O3 часто генерирует код, который трудно превзойти вручную. Но в специфических случаях ручной ассемблер может быть быстрее: криптографические функции, обработка медиа, эмуляторы, JIT-компиляторы. Перед написанием ручного ассемблера изучите сгенерированный компилятором код через clang -S -O3 file.c.
// Оптимизация: сумма массива через развёртывание цикла
// Файл: optimize.s
.section __DATA,__data
.align 4
array:
.word 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
.section __TEXT,__text
.global _main
.align 2
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
ADRP X0, array@PAGE
ADD X0, X0, array@PAGEOFF
MOV W1, #0 // сумма
MOV W2, #12 // количество элементов
MOV W3, #0 // индекс
// Обычный цикл (4 инструкции на итерацию)
sum_loop:
LDR W4, [X0, X3, LSL #2]
ADD W1, W1, W4
ADD W3, W3, #1
CMP W3, W2
B.LT sum_loop
// W1 = 78 (сумма 1..12)
LDP X29, X30, [SP], #16
RET
Базовая версия цикла - 5 инструкций на итерацию (LDR, ADD, ADD, CMP, B). Развёртывание цикла на 2 итерации уменьшает количество ветвлений вдвое: обрабатываем по 2 элемента за итерацию, ветвление только каждые 2 элемента. Это улучшает производительность на 10-20% за счёт лучшего использования конвейера процессора.
Напишите две версии суммирования массива: базовую и с развёртыванием цикла на 4. Измерьте время выполнения обеих версий на массиве из 100000 элементов. Используйте clock() из C или mach_absolute_time(). Сравните результаты.
Дизассемблирование - это превращение бинарного кода обратно в ассемблер. На macOS для этого используется otool -tv или objdump --macho -d. Дизассемблирование помогает понять, как работает скомпилированная программа, найти ошибки, проанализировать поведение библиотек и проверить, что компилятор сгенерировал ожидаемый код.
Для изучения кода C-функции используйте clang -S -O2 file.c -o file.s. Это сгенерирует ассемблерный код, который компилятор создаёт для вашего C-файла. Сравнение с вашим ручным ассемблером покажет, где компилятор делает лучше (обычно - почти везде) и где вы можете улучшить (специфические случаи).
Для обратной разработки бинарников без исходного кода используются специализированные инструменты: Hopper Disassembler, IDA Pro, Ghidra. Они предоставляют графический интерфейс, декомпиляцию в C-подобный псевдокод и анализ потока управления. Для базового анализа достаточно otool и nm в терминале.
# Дизассемблирование программы # Компиляция C-файла с оптимизацией clang -arch arm64 -O2 -S -o factorial.s factorial.c # Просмотр сгенерированного ассемблера cat factorial.s # Дизассемблирование бинарного файла otool -tv ./program # Использование objdump objdump --macho -d ./program # Просмотр символов nm ./program # Просмотр строк в бинарнике strings ./program | head -20 # Просмотр зависимостей от динамических библиотек otool -L ./program
Команда clang -S показывает, как компилятор превращает ваш C-код в ассемблер. Вы увидите, как компилятор размещает переменные, как он оптимизирует циклы, как он встраивает (inline) функции. Это лучший учебный материал для понимания связи между C и ассемблером.
Напишите простую C-функцию (например, вычисление факториала) и скомпилируйте её с -O0, -O1, -O2 и -O3. Сравните сгенерированный ассемблерный код. Какие оптимизации компилятор применяет на каждом уровне? Сколько инструкций в каждой версии?
arm64e - это расширение ARM64 с Pointer Authentication Codes (PAC), которое Apple использует начиная с чипа A12 (и всех Apple Silicon M-серии). PAC добавляет криптографическую подпись к указателям и адресам возврата. Это защищает от атак, основанных на подмене адресов: ROP (Return-Oriented Programming), JOP (Jump-Oriented Programming) и других.
При включённом PAC адрес возврата в LR подписывается специальной инструкцией PACIASP перед сохранением на стек. При возврате подпись проверяется инструкцией RETAA (или AUTIASP + RET). Если подпись не совпадает, процесс падает с SIGSEGV. Это делает атаки на управление потоком выполнения значительно сложнее.
Для большинства ассемблерного кода на macOS PAC прозрачен: система обрабатывает его автоматически. Но если вы пишете низкоуровневый код, который работает с указателями напрямую, или используете arm64e-цель, нужно учитывать PAC. На macOS по умолчанию системные библиотеки используют arm64e, а пользовательские приложения - arm64 (без PAC). Universal binaries могут содержать оба варианта.
// Проверка архитектуры бинарного файла // Выполните в терминале: # Проверка архитектуры Python3 lipo -info /usr/bin/python3 # Вывод: x86_64 arm64e # Извлечение arm64e-среза lipo -extract arm64e /usr/bin/python3 -o python3_arm64e # Проверка вашего бинарника file ./hello # Вывод: Mach-O 64-bit executable arm64 # Дизассемблирование arm64e-бинарника # Ищите инструкции PACIASP, AUTIASP, RETAA otool -tv /usr/bin/python3 | grep -A2 "PACIASP\|RETAA" | head -20
В arm64e-коде вы увидите инструкции PACIASP в прологах функций и RETAA или AUTIASP в эпилогах. Это признаки работы Pointer Authentication. В пользовательских arm64-бинарниках этих инструкций нет.
Скомпилируйте простую C-программу для arm64 и arm64e: clang -arch arm64e -o test_arm64e test.c и clang -arch arm64 -o test_arm64 test.c. Сравните размер бинарников и дизассемблируйте обе версии. Найдите инструкции PAC в arm64e-версии.
ARM64 предоставляет инструкции для многопоточного программирования: атомарные операции, барьеры памяти, ожидание события. Атомарные операции выполняются неделимо: LDXR (load exclusive) и STXR (store exclusive) реализуют compare-and-swap. Инструкция STXR возвращает статус: 0 если успешно, 1 если не удалось (другой поток изменил значение между LDXR и STXR).
Барьеры памяти гарантируют порядок доступа к памяти в многопоточной среде. DMB (data memory barrier) гарантирует, что все операции доступа к памяти до барьера завершены до операций после. DSB (data synchronization barrier) - более сильный: процессор ждёт, пока все операции до барьера не завершатся полностью. ISB (instruction synchronization barrier) сбрасывает конвейер и гарантирует, что последующие инструкции загружаются заново.
Инструкция WFE (wait for event) переводит процессор в режим ожидания до получения события от другого ядра. SEV (send event) отправляет событие всем ядрам. Это используется для эффективной синхронизации: вместо цикла ожидания (busy-wait) процессор засыпает и просыпается по сигналу. На Apple Silicon WFE и SEV поддерживаются, но обычно программисты используют высокоуровневые примитивы (мьютексы, очереди) вместо прямых инструкций.
// Атомарный инкремент через LDXR/STXR
// Файл: atomic.s
.section __DATA,__data
.align 3
counter:
.quad 0
.section __TEXT,__text
.global _atomic_increment
.align 2
// void atomic_increment(long *addr, long value)
// X0 = addr, X1 = value
_atomic_increment:
// Пролог
STP X29, X30, [SP, #-16]!
MOV X29, SP
atomic_loop:
LDXR X2, [X0] // эксклюзивная загрузка
ADD X2, X2, X1 // прибавляем значение
STXR W3, X2, [X0] // эксклюзивная запись
CBNZ W3, atomic_loop // если не удалось (W3 != 0), повторить
// Эпилог
LDP X29, X30, [SP], #16
RET
.global _main
_main:
STP X29, X30, [SP, #-16]!
MOV X29, SP
ADRP X0, counter@PAGE
ADD X0, X0, counter@PAGEOFF
MOV X1, #1
// Инкрементируем 5 раз
BL _atomic_increment
BL _atomic_increment
BL _atomic_increment
BL _atomic_increment
BL _atomic_increment
// Читаем результат
LDR X0, [X0] // X0 = 5
LDP X29, X30, [SP], #16
RET
Инструкция LDXR X2, [X0] загружает значение эксклюзивно: процессор запоминает, что этот адрес был прочитан. STXR W3, X2, [X0] пытается записать эксклюзивно: если адрес не был изменён другим ядром после LDXR, запись успешна и W3 = 0. Если изменён - W3 = 1, и нужно повторить цикл. CBNZ (compare and branch if non-zero) проверяет статус и зацикливается при неудаче.
Напишите функцию atomic_compare_and_swap(long *addr, long expected, long new_value), которая заменяет значение по адресу на new_value, только если текущее значение равно expected. Верните 1 при успехе и 0 при неудаче. Используйте LDXR и STXR. Протестируйте из C.
25 тем этого курса - не предел, а фундамент. Дальше идёт системное программирование: работа с Mach API, порты, сообщения, виртуальная память, нити. Затем - углубление в архитектуру: конвейер процессора, кэш-линейки, векторизация, предсказание ветвлений. Потом - безопасность: эксплойты, ROP-цепочки, защита PAC, reverse engineering.
Ассемблер - не конечная точка, а отправная. После ассемблера легче понять, как компилятор оптимизирует код, как JIT-компиляторы генерируют машинный код на лету, как работают эмуляторы и виртуальные машины. Знание ARM64 делает вас лучшим C-программистом: вы понимаете, что стоит за каждым int и pointer, и почему компилятор выбирает одни инструкции вместо других.
Главное, что даёт этот курс, - не синтаксис, а модель мышления. Вы начинаете видеть программу как поток инструкций в процессоре, управляемый регистрами и адресами памяти. Это уровень абстракции, на котором компьютер больше не чёрный ящик. И именно он отличает программиста, который понимает машину, от программиста, который умеет пользоваться инструментами.
Учите ассемблер. Не потому что он модный, а потому что он показывает, как работает процессор. А процессор - это то, что выполняет вашу программу каждую наносекунду.
|