Зачем нам нужно обладать базовыми знаниями ассемблера? Ведь они крайне редко применяются в современных реалиях. Несмотря на это, если вы хотите больше узнать про скрытые аспекты вашего кода, то понимание базовых концепций вам в этом поможет. Еще это знание может стать полезным для расследования непонятных крешей. Плюс ко всему, это добавляет интерес в нашу рутинную жизнь разработчика.

В этой статье мы познакомимся с командами 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? Для начала разберемся с ними.

  1. number@PAGE - это начальный позиционно-независимый адрес 4K страницы памяти в которой располагается участок помеченный лейблом number.

  2. 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 содержит #0x0 - смешение относительно адреса. То есть количество байтов от начала адреса.

Давайте проследим, как меняется содержимое регистров 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 с числом 0x0 (то есть с нулем) и сохраняет в регистр 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 и воспроизводить. А что если нам нужно загрузить что-то не по точному адресу, а с каким-то смещением относительно него? В этом нам помогут разные режимы адресации. Например, для загрузки в регистр значение по адресу со смещением 0x8 можно выполнить следующую команду:

ldr x1, [x0, #0x8]

На картинке показано, что по адресу 0x10000010 хранится значение 0xFF, а в регистре x0 лежит адрес 0x10000008. Часть команды [x0, #0x8] означает, что нужно прибавить 0x8 к значению в регистре x0 (0x10000008 + 0x8 = 0x10000010). А потом уже из конечного адреса ldr читает данные и записывает в x1. При этом содержимое x0 не меняется. А что если нужно отредактировать адрес в регистре x0? То есть конечный адрес 0x10000010 записать в регистр? В этом нам поможем другой режим адресации:

ldr x1, [x0, #0x8]!

Здесь одновременно поменялось значение в обоих регистрах. Это так называемый префиксный режим адресации. Но есть еще и постфиксный режим. В нем регистр, в котором хранится адрес также меняется, но сами данные будут взять из адреса без смещения. То есть сначала значение по адресу попадет в регистр, а уже потом новый адрес запишется в регистр:

ldr x1, [x0], 0x8

Данные по адресу 0x10000008 попадают в регистр 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 лежит значение 0x10000008 и указано смещение #0x8. И значение из x1 будет записано по адресу 0x10000010 (0x10000008 + 0x8). При этом адрес в регистре x0 не меняется. Если нужно сделать так, чтобы новый адрес со смещением записался в регистр, нужно использовать такой вариант инструкции.

str x1, [x0, #0x8]!

Здесь по адресу 0x10000010 записалось новое значение и также этот адрес лежит в регистре x0 вместо старого 0x10000008. Также есть возможность поменять адрес в регистре x0 после того, как данные будут взяты и записаны в память по старому адресу в x0. В этом нам поможет следующий вариант команды:

str x1, [x0], #0x8

На схеме видим, как значение из x1 сначала записывается по адресу 0x10000008, а уже потом к адресу прибавляются смещение 0x8 и новое значение оказывается в регистре 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

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