Управление памятью в ассемблере для Apple Silicon
Зачем нам нужно обладать базовыми знаниями ассемблера? Ведь они крайне редко применяются в современных реалиях. Несмотря на это, если вы хотите больше узнать про скрытые аспекты вашего кода, то понимание базовых концепций вам в этом поможет. Еще это знание может стать полезным для расследования непонятных крешей. Плюс ко всему, это добавляет интерес в нашу рутинную жизнь разработчика.
В этой статье мы познакомимся с командами LLDB для чтения и изменения содержимого памяти и регистров. Также узнаем несколько инструкций для управления памятью из языка ассемблера процессоров с архитектурой AArch64 (ARM64) для платформ Apple.
Регистры
Регистры это небольшие участки сверхбыстрой памяти, которая расположена внутри процессора. Это краеугольный камень в управлении памятью на самом нижнем уровне. У регистров общего назначения (наиболее часто используемые) есть короткие имена, Wn, Xn, Rn где n это числовой индекс регистра от 0 до 30.
Регистр, имя которого начинается на W, используется для хранения 32-x битов данных. Если в начале стоит имени стоит X — то это 64-х битный регистр. Также к регистрам можно обращаться по имени Rn.
В архитектуре ARM64 представлен также и набор специальных регистров, как например SP, FP и LR. Эти регистры задействованы во время вызова функций. Описание логики их использования выходит за рамки данной статьи.
Инструкции в действии
Давайте поместим следующий фрагмент кода в файл HelloWorld.s
.global _start
.align 4
_start:
adrp x1, number@PAGE
add x0, x1, number@PAGEOFF
ret
.data
number: .word 0xFF
Инструкции adrp x1 number@PAGE
используется для загрузки адреса, помеченного лэйблом number.Этот адрес сохраняется в регистр x1
. Инструкция add x1, x1, number@PAGEOFF
складывает содержимое регистра x1 и таинственный number@PAGEOFF и помещает результат в x0. А что такое number@PAGE и number@PAGEOFF? Для начала разберемся с ними.
number@PAGE — это начальный позиционно-независимый адрес 4K страницы памяти в которой располагается участок помеченный лейблом number.
number@PAGEOFF — это смещение адреса участка памяти number относительно начала страницы памяти.
На этапе линковки number@PAGE
и number@PAGEOFF
заменяются на соответствующие значения. И далее внутри LLDB мы увидим, во что превращаются эти ключевые слова. А пока запомним эту последовательность инструкций и не будет углубляться в тонкости адресации памяти в ARM64. В конечном итоге мы получим адрес в регистре x0.
Далее мы научимся применять LLDB для отладки и проверки содержимого наших регистров. Сначала убедимся в том, что LLDB у нас установлен при помощи этой команды в терминале:
lldb -v
Если эта команда выдала примерно следующие строки, значит LLDB у нас есть:
lldb-1600.0.36.3 Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)
Далее нам нужно скомпилировать ассемблерный код. Для удобства создадим Makefile и добавим в него следующие строки.
helloworld: HelloWorld.o
ld -o helloworld HelloWorld.o -lSystem -syslibroot `xcrun -sdk macosx --show-sdk-path` -e _start -arch arm64
HelloWorld.o: HelloWorld.s
as -arch arm64 -o HelloWorld.o HelloWorld.s
После этого в директории с этим файлов выполните команду в терминале:
make
Make соберет для нас исполняемый файл helloworld
, который мы будет исследовать с помощью LLDB. Начнем наконец наш сеанс отладки, запустив отладочную сессию в терминале:
lldb ./helloworld
Мы оказались внутри отладочной сессии. Для начала нужно поставить точку остановки (breakpoint) на функцию start. А поможет нам следующая команда:
breakpoint set --name start
И сразу после этого мы можем запустить нашу программу и приступить к исследованию:
run
Программа стартовала и сразу же остановилась в самом начале функции start.
helloworld`start:
-> 0x100003f90 <+0>: adrp x1, 1
0x100003f94 <+4>: add x0, x1, #0x0 ; number
0x100003f98 <+8>: ret
Рассмотрим строку под номером 2 в нашем листинге. Мы видим что number@PAGE
превратилось в 1
— за ним LLDB спрятал реальный адрес памяти, где располагается значение 0xFF
. Строка номер 3 теперь вместо number@PAGEOFF
содержит #0×0 — смешение относительно адреса. То есть количество байтов от начала адреса.
Давайте проследим, как меняется содержимое регистров x0
и x1
в процессе выполнения программы. Применим следующую команду:
reg read x0 x1
В начале выполнения программы она выводит значения, которые могут отличаться от ваших:
x0 = 0x0000000000000001
x1 = 0x000000016fdff340
Сейчас нас не особо интересует их содержимое. Но уже после следующей команды значение внутри x0 поменяется:
step
Эта команда выполняет следующую инструкцию программы и снова останавливает выполнение. Теперь LLDB выводит такой набор инструкций:
helloworld`start:
-> 0x100003f94 <+4>: add x0, x1, #0x0
0x100003f98 <+8>: ret
А это значит, что предыдущая инструкция adrp уже выполнилась и мы можем оценить ее влияние на регистры. Прочитаем их заново при помощи reg read x0 x1
и увидим, что адрес с меткой number сохранился в регистре x1:
x0 = 0x0000000000000001
x1 = 0x0000000100004000 number
Важное отметить, что у вас может быть другое значение адреса, но важно, что это именно адрес, где хранится наше значение 0xFF. Чтобы это проверить вызовем следующую команду для чтения содержимого первого байта адреса в памяти:
memory read -c 1 0x0000000100004000
Здесь опция -c регулирует кол-во байтов, которое будет прочитано по адресу. И сразу же видим нужное нам значение без префикса 0x в начале:
0x100004000: ff
Далее выполним еще одну инструкцию в нашей программе при помощи step
. Инструкция add x0, x1, #0x0
складывает содержимое регистра x1 с числом 0×0 (то есть с нулем) и сохраняет в регистр x0. Вызовем reg read x0 x1
и убедимся, что значения x0 и x1 теперь одинаковы:
x0 = 0x0000000100004000 number
x1 = 0x0000000100004000 number
Далее мы научимся читать и модифицировать значение, которое хранится по адресу сохраненному в регистре x0.
Чтение и изменение значений в памяти
Теперь, когда у нас есть адрес, мы можем писать в него или читать данные по этому адресу. В этом нам помогут инструкции LDR
и STR
.
LDR — читает значение из памяти и сохраняет его в регистр.
STR — сохраняет значение из регистра по адресу в памяти.
Здесь не будет полных листингов, а только примеры команд. И к каждой будет прилагаться схема, которая поможет запомнить принцип работы инструкции. Начнем знакомство с примера инструкции LDR, которая читает значение по адресу из регистра x0
и сохраняет его в регистр x1
.
ldr x1, [x0]
Сверху показано состояния регистров до выполнения инструкции, а внизу сразу после нее. Нагляднее видно, что поменялось и не нужно лезть в LLDB и воспроизводить. А что если нам нужно загрузить что-то не по точному адресу, а с каким-то смещением относительно него? В этом нам помогут разные режимы адресации. Например, для загрузки в регистр значение по адресу со смещением 0×8 можно выполнить следующую команду:
ldr x1, [x0, #0x8]
На картинке показано, что по адресу 0×10000010 хранится значение 0xFF, а в регистре x0 лежит адрес 0×10000008. Часть команды [x0, #0x8]
означает, что нужно прибавить 0×8 к значению в регистре x0 (0×10000008 + 0×8 = 0×10000010). А потом уже из конечного адреса ldr читает данные и записывает в x1. При этом содержимое x0 не меняется. А что если нужно отредактировать адрес в регистре x0? То есть конечный адрес 0×10000010 записать в регистр? В этом нам поможем другой режим адресации:
ldr x1, [x0, #0x8]!
Здесь одновременно поменялось значение в обоих регистрах. Это так называемый префиксный режим адресации. Но есть еще и постфиксный режим. В нем регистр, в котором хранится адрес также меняется, но сами данные будут взять из адреса без смещения. То есть сначала значение по адресу попадет в регистр, а уже потом новый адрес запишется в регистр:
ldr x1, [x0], 0x8
Данные по адресу 0×10000008 попадают в регистр x1, а уже потом в регистр x0 записывается новый адрес со смещением. Вот такую гибкость предоставляют нам разные режимы адресации.
Мы разобрались с инструкцией ldr, а теперь посмотрим на инструкцию str
, которая сохраняет значение из регистра в память. Но для начала нужно что-то положить в регистр x1. И в этом нам поможет инструкция mov
. Например, чтобы записать константу 0xFF воспользуемся следующей командой:
mov x1, #0xFF
А теперь с помощью базовой версии команды ldr запишем значение из x1 по адресу в памяти, который храниться в регистре x0:
str x1, [x0]
Инструкция str
сохраняет значение 0xFF из регистра x1 по адресу памяти, который хранится в регистре x0. У этой инструкции также есть разные режимы адресации. Например, чтобы записать значение из регистра по адресу с некоторым смещением, нужно использовать такой синтаксис:
str x1, [x0, #0x8]
В этом примере в регистре x0 лежит значение 0×10000008 и указано смещение #0×8. И значение из x1 будет записано по адресу 0×10000010 (0×10000008 + 0×8). При этом адрес в регистре x0 не меняется. Если нужно сделать так, чтобы новый адрес со смещением записался в регистр, нужно использовать такой вариант инструкции.
str x1, [x0, #0x8]!
Здесь по адресу 0×10000010 записалось новое значение и также этот адрес лежит в регистре x0 вместо старого 0×10000008. Также есть возможность поменять адрес в регистре x0 после того, как данные будут взяты и записаны в память по старому адресу в x0. В этом нам поможет следующий вариант команды:
str x1, [x0], #0x8
На схеме видим, как значение из x1 сначала записывается по адресу 0×10000008, а уже потом к адресу прибавляются смещение 0×8 и новое значение оказывается в регистре x0.
Что узнали в итоге?
Повторим инструкции, с которыми мы познакомились по ходу повествования. Самая первая и понятная это mov
. Она нужна для записи значения в регистр. Можно записывать константное значение либо значение другого регистра.
Далее научились получать адрес по некому лэйблу, которым мы обозначали значение. Познакомились с понятием адресация данных. Узнали, что такое базовый адрес и смещение относительно него и как применять это знание с инструкциями adrp
и add
.
И в самом конце затронули важные инструкции ldr
и str
. Команда ldr
загружает данные из памяти в регистр, а str
наоборот из регистра в память. Также в контексте этих команд узнали про разные режимы адресации — по сути варианты этих команд.
Внизу предлагаю ознакомиться с полезными ссылками по теме. Если обнаружили какие-то ошибки в повествовании напишите, пожалуйста, об этом в комментариях. Спасибо, за внимание!
Полезные ссылки:
https://developer.arm.com/documentation/102374/0102/Loads-and-stores---addressing
https://github.com/below/HelloSilicon/tree/daf43408bffdef9d6c6175d30d69337167385f60? tab=readme-ov-file
https://valsamaras.medium.com/arm-64-assembly-series-basic-definitions-and-registers-ec8cc1334e40