Стек

Последнее обновление: 18.07.2023

Стек представляет некоторую область в памяти, которая хранит различную информация о программе, включая локальные переменные, информацию о подпрограммах и временные данные.

Когда операционная система запускает программу, то этой программе выделяется стек в виде области в оперативной памяти. Например, Linux выделяет для программы область размером 8 мегабайт. Для работы со стеком в программе в ARM64 применяется регистр указателя стека (SP). Этот регистр имеет специальное назначение: он может выступать в качестве нулевого регистра XZR, и если инструкция не работает со стеком, то она рассматривает этот регистр как нулевой регистр XZR.

Стек работает по принципу LIFO (last in first out - последний вошел, первый вышел). Это значит, что первым извлекается из стека тот элемент, который был добавлен последним. Стек подобен стопке тарелок - каждую новую тарелку кладут поверх предыдущей и, наоборот, сначала берут самую верхнюю тарелку.

Ассемблер ARM предоставляет ряд инструкций для управления стеком:

  • STR и STP сохраняют данные регистров в стек
  • LDR и LDP загружают данные из стека в регистры

ARM-процессор требует, чтобы значение регистра SP всегда имело выравнивание в 16 байт, то есть чтобы самый младший его бит всегда имел значение 0. Собственно поэтому данный регистр также применяется в качестве нулевого регистра XZR. А это значит, что мы можем изменить значение регистра SP на число, кратное 16, то есть на 0, 16, 32, 64 и т.д. В ином случае мы столкнемся с ошибкой выравнивания стека.

Сохранение данных из регистров в стек

Для копирования данных из регистра Xn в стек может применяться преиндексная адресация:

STR Xn, [SP, #-N]!

Выражение [SP, #-N]! указывает, что данные Xn копируются в память по адресу SP – N, N представляет смещение относительно верхушки стека. После чего в регистр SP помещается этот новый адрес SP – N И поскольку в этом случае изменяется значение SP, то смещение N должно быть кратным 16 для обеспечения выравнивания стека.

Стек растет от наибольших адресов к меньшим. То есть при добавлении данных в стек, адрес в указателе стека уменьшается.

Рассмотрим пример:

.global _start 
_start: 
    mov x0, #259            // данные для сохранения в стек
    str x0, [sp, #-16]!     // сохраняем значение из X0 по адресу (SP - 16) и обновляем значение SP

    mov x8, #93       // устанавливаем функцию Linux для выхода из программы
    svc 0             // Вызываем функцию Linux

Здесь данные из X0 (число 259) сохраняем в стек по адресу SP - 16, при этом в регистр SP сохраняется новый адрес. Визуально это могло бы выглядеть так (адреса условны):

Сохранение в стек в ассемблере ARM 64

По умолчанию к запуску программы стек уже выровнен по 16 байтам. Допустим, изначально указатель стека указывает на адрес 0x00FFFFF0. После выполнения инструкции

str x0, [sp, #-16]!

По адресу SP - 16 сохраняется число из X0 (то есть число 259), которое занимает 8 байт. Затем обновляется значение указателя стека. Теперь он хранит адрес 0x00FFFFE0, по которому сохранено число из регистра X0.

Также стоит отметить, что поскольку мы должны соблюдать выравнивание по 16 байтам, указатель стека перемещается на 16 байт, однако значение из регистра X0 занимает всего 8 байт. То есть фактически число из Х0 займет первые 8 байт, то есть область по адресам 0x00FFFFE0 - 0x00FFFFE7, а остальная область 0x00FFFFE8 - 0x00FFFFEF фактически будет свободна и будет простаивать. Но дальше мы посмотрим, как можно сократить лишние выделения памяти.

В самом стеке сначала будут располагаться младшие байты (по адресу SP) и далее по более старшим адресам - старшие байты. Так, число 259 в двоичной системе равно 0b000000001_00000011:

Расположение данных в стеке в ассемблере ARM 64

Загрузка данных из стека в регистры

Для загрузки данных из стека в регистр можно применять постадресную индексацию, которая одновременно обновит значение регистра SP:

LDR Xn, [SP], #N

Эта инструкция помещает данные по адресу, который хранится в регистре SP, из стека в регистр Xn и затем добавляет к адресу в SP N байт - тот размер, который занимали извлеченные из стека данные. Опять же поскольку здесь идет изменение значения SP, то смещение N должно быть кратным 16.

Например, получим из стека ранее сохраненные в него данные:

.global _start 
_start:
    mov x1, 22             // данные для сохранения в стек
    str x1, [sp, #-16]!     // сохраняем значение из X1 по адресу (SP - 16) и обновляем значение SP

    ldr x0, [sp], #16       // получаем данные в Х0 по адресу в SP и обновляем значение SP (SP = SP + 16)

    mov x8, #93       // устанавливаем функцию Linux для выхода из программы
    svc 0             // Вызываем функцию Linux

Здесь загружаем из стека данные, адрес которых хранится в SP, в регистр X0. Затем к адресу в SP прибавляется 16.

LDR X0, [SP], #16

Визуально это могло бы выглядеть так (адреса условны):

Загрузка данных из стека в регистр в ассемблере ARM 64

Загрузка и сохранение в два регистра

Выше отмечалось, что при сохранении в стек регистра при использовании смещения/выравнивания в 16 байт в выделенной области 8 байт по сути никак не используются. Если нам надо сохранить четное количество регистров, то мы можем решить эту проблему. Инструкции STP/LDP позволяют сохранить/загрузить данные сразу в два регистра:

STP X0, X1, [SP, #-16]!
LDP X0, X1, [SP], #16

Применение:

..global _start 
_start: 
    mov x2, 22              // данные для сохранения в стек
    mov x3, 33

    stp x2, x3, [sp, #-16]!     // сохраняем значение из X2, X3 по адресу (SP - 16) и обновляем значение SP

    ldp x0, x1, [sp], #16       // получаем данные в Х0, X1 по адресу в SP и обновляем значение SP (SP = SP + 16)

    mov x8, #93       // устанавливаем функцию Linux для выхода из программы
    svc 0             // Вызываем функцию Linux

Здесь загружаем в регистры X2 и X3 первое и второе число из набора bytes. Затем эти значения загружаем в стек:

STP X2, X3, [SP, #-16]!
Сохранение пары регистров в ассемблере ARM64

Сначала в стеке размещается Х3, а затем на верхушке стека - Х2.

Затем сохраненные данные загружаем обратно из стека в регистры X0 и X1:

LDP X0, X1, [SP], #16

Обращение к стеку без изменения адреса

В примерах выше применялись преиндексная и постиндексная адресации, которые при сохранении/получении данных также обновляют значение в SP. Из-за этого мы вынуждены передавать смещение, кратное 16 байт, чтобы соблюсти выравниваение адреса верхушки стека по 16-байтной границе. Однако мы можем использовать и другие подходы для работы со стеком. Прежде всего для выделения и очистки памяти в стеке можно применять стандартные операции вычитания и сложения.

Так, для выделения памяти в стеке достаточно вычесть из адреса в SP нужное количество байт (кратное 16):

sub sp, sp, #32     // выделяем в стеке 32 байта

Чтобы очистить ранее выделенную память в стеке достаточно прибавить к адресу в SP выделенное количество байт (кратное 16):

add sp, sp, #32     // очищаем ранее выделенные в стеке 32 байта

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

.global _start 
_start: 
    mov x1, 11
    mov x2, 12
    mov x3, 13
    
    sub sp, sp, #32     // выделяем в стеке 32 байта
    str x1, [sp]        // сохраняем значение из X1 по адресу SP
    str x2, [sp, #8]     // сохраняем значение из X2 по адресу SP + 8
    str x3, [sp, #16]     // сохраняем значение из X3 по адресу SP + 16
    
    ldr x0, [sp, #8]     // получаем в X0 число по адресу SP + 8
    add sp, sp, #32     // очищаем ранее выделенные в стеке 32 байта
    
    mov x8, #93       // устанавливаем функцию Linux для выхода из программы
    svc 0             // Вызываем функцию Linux

Здесь в начале программы выделяем в стеке 32 байта. Далее последовательно сохраняем значения регистров Х1, Х2, Х3 по адресам SP, SP + 8, SP + 16 соответственно.

str x1, [sp]        // сохраняем значение из X1 по адресу SP
str x2, [sp, #8]     // сохраняем значение из X2 по адресу SP + 8
str x3, [sp, #16]     // сохраняем значение из X3 по адресу SP + 16

Если регистр SP выступает в качестве базового регистра, как в случае выше, то в качестве смещения для 64-разрядных регистров применяется смещение, кратное 8 (как в примере выше - 8 байт), а для 32-разрядных регистров W0-W30 - смещение, кратное 4. Благодаря этому мы можем сохранить данные в определенной части стека. Таким образом, расположение данных в стеке условно будет выглядеть следующим образом:

Сохранение в стеке по смещению в ассемблере ARM64

Подобным образом мы можем получить нужные данные по смещению:

ldr x0, [sp, #8]     // получаем в X0 число по адресу SP + 8

Здесь получаем в регистр Х0 данные с адреса SP+8, то есть это адрес числа 12.

Подобным образом, можно применять разновидности инструкций LDR/STR. Например, инструкция LDRB загружает один байт, а инструкция STRB сохраняет один байт. Поскольку эти инструкции манипулируют одним байтом, то в качестве смещения применяется 1 байт:

.global _start 
_start: 
    mov w1, 'A'
    mov w2, 'R'
    mov w3, 'M'
    
    sub sp, sp, #16     // выделяем в стеке 16 байт
    strb w1, [sp]        // сохраняем значение из W1 по адресу SP
    strb w2, [sp, #1]     // сохраняем значение из W2 по адресу SP + 1
    strb w3, [sp, #2]     // сохраняем значение из W3 по адресу SP + 2
    
    ldrb w0, [sp, #1]     // получаем в W0 число по адресу SP + 1
    add sp, sp, #16     // очищаем ранее выделенные в стеке 32 байта
    
    mov x8, #93       // устанавливаем функцию Linux для выхода из программы
    svc 0             // Вызываем функцию Linux

Здесь в стеке выделяется 16 байт, из которых первые три займут символы "A", "R", "M". Последующие 13 байт будут содержать произвольные значения.

Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850