• Добро пожаловать на сайт - Forumteam.bet !

    Что бы просматривать темы форума необходимо зарегестрироваться или войти в свой аккаунт.

    Группа в телеграме (подпишитесь, что бы не потерять нас) - ForumTeam Chat [Подписатся]
    Связь с администратором - @ftmadmin

Как код С выполняется на процессоре ARM: разбор ассемблера

Article Publisher

Публикатор
Команда форума
Регистрация
05.02.25
1740672104415.png


При вызовах функций на языке С активно используется стек, который также именуется «стек вызовов». По мере того, как мы вызываем функции, они формируют так называемый «стек кадров». При каждом вызове функции образуется кадр, и эти кадры укладываются в стеке, где под них выделяется место. Далее в кадре из стека выделяется память под переменные и промежуточные значения. В кадре стека также содержится указатель на предыдущий кадр и значение счётчика команд. Та команда, которой оно соответствует, должна быть выполнена, как только кадр будет вытолкнут из стека. Далее давайте дизассемблируем вызовы функций в C, чтобы понять, как устроен стек кадров в ассемблере для ARM.

❯ Анимация, демонстрирующая, как код C выполняется в процессоре ARM​

На следующей анимации показано выполнение функции add(1,2) на C. Перед тем, как будет вызвана функция add, вызывающая сторона сохраняет аргументы функции так: 1 идёт в регистр r0, а 2 в r1. Вернувшись, функция add будет иметь значение 3, которое будет сохранено в регистре r0 — соответственно, оно затрёт первый из тех аргументов, с которым была вызвана функция.

8c83642b8691206a456c69bedd5bd9fd.gif

Здесь в трёх столбцах показан код на C, соответствующий ассемблер для ARM и бок о бок — два представления стека. По мере выполнения кода на C мы также видим, как соответствующим образом меняется код ассемблера. Обратите внимание: в одной строке кода на C обычно содержится несколько инструкций. В самом правом столбце показан стек, и что именно в него закладывается. Посмотрите эту анимацию несколько раз.

❯ Что не показано в анимации​

Здесь есть ряд глубоких деталей. Если хотите, можете смело переходить к приведённым ниже примерам кода.

Как упоминалось выше, до вызова функции её аргументы находятся в регистрах r0 и r1.

Длина кадра составляет 3 слова. В этих трёх словах содержатся значения fp lr и локальная переменная int c. В зависимости от оптимизаций и операций, совершаемых в кадре, сам кадр может быть увеличен, и в нём также могут храниться аргументы функции int a и int b. Обратите внимание, что в этом кадре сохраняется и регистр r3, в котором записан результат сложения c = a + b.

Делаем ещё один вызов, на этот раз вызываем функцию some_func. Поскольку для этого вызова функции требуется bl, нам предварительно понадобится записать в стек lr, чтобы восстановить значение счётчика команд. Когда инструкция bl some_func находится по адресу 0x00010428, в регистре lr будет записано значение 0x0001042c. Дело в том, что вызов функции some_func окончится bx lr, поскольку регистр lr будет задвинут в стек. Если в add мы больше не вызываем никакую функцию, то нам придётся задвинуть lr в стек и, кстати, именно это и сделает gcc, если никакую другую функцию вызывать мы не собираемся.

Обрабатывая вызов функции some_func, мы сохоаняем значение r3 в кадр, чтобы перестраховаться на случай, если r3 будет разрушен. Внутри кадра мы можем защитить значения, являющиеся локальными для нашей функции на то время, пока будут вызываться другие функции. Дело в том, что значения можно хранить вне регистров. Затем восстанавливаем r3 из кадра и записываем в r0, где хранится возвращённое значение функции.

Далее при вызове функции some_func в стеке создаётся ещё один кадр. Затем этот кадр выталкивается из стека, и у нас остаётся кадр именно с теми тремя словами, которые функция add поместила в стек.

До начала анимации сторона, вызвавшая add, выполнила инструкцию bl add, которая, в свою очередь, сохраняет в регистре lr инструкцию, идущую сразу после написанного на C кода add(1,2)

❯ Из чего будем исходить​

Чтобы понять, как устроен стек кадров, необходимо усвоить следующее:

  • Стек вызовов: в стеке вызовов, который обычно называется просто стек, содержит информацию об активных вызовах функций, происходящих в программе. Стек начинается в верхней области памяти, а затем заполняется книзу.
  • Инструкции push/pop ARM: При помощи этих инструкций мы помещаем регистры в стек, а также выталкиваем из него регистры, когда он уже заполнен на всю глубину сверху вниз.
  • Регистр sp: Регистр sp — это указатель стека (stack pointer), в котором хранится значение, находящееся сейчас на вершине стека. Команда push двигает указатель стека вниз на 1 слово, равное 4 байтам на 32-разрядной машине ARM и сохраняет то значение, на которое указывает sp. В свою очередь, инструкция pop восстанавливает значения из стека, возвращая их в регистры, и двигает указатель стека вверх.
  • Регистр fp. Регистр fp — это указатель кадра (frame pointer), в котором хранится значение, находившееся на вершине стека непосредственно перед вызовом функции. Он указывает на вершину кадра. Именно от значения fp и далее вниз до значения sp находится «кадр», выделяемый для вызова функции. Для fp используется регистр r11.
  • Регистр lr. В lr хранится значение инструкции для pc, и именно отсюда эта инструкция должна выполняться после вызова функции. Поэтому, как только функция вернётся, pc может перейти к выполнению инструкции сразу после окончания работы над вызовом функции.
  • Функции bl/bx: Необходимо понимать инструкции bl и bx. Инструкция bl помещает адрес возврата в lr, а в качестве значения pc устанавливает адрес субпроцедуры. Инструкция bx устанавливает значение pc равным значению lr, и именно оттуда начинает выполнение.
  • Режимы адресации. Необходимо понимать, что такое смещение, а также режимы адресации pre-indexed (пре-индексирование) и post-indexed (пост-индексирование). Они принципиально важны, но всю связанную с ними математику я выполнил ниже, так что можете сами составить полную картину.
  • Соответствие регистров вызовам функций: аргументы для вызовов функции сообщаются через регистры r0-r3, а возвращаемое значение помещается в r0. В архитектуре ARM действуют соглашения о вызовах, которые мы не будем здесь обсуждать.
Объяснение ещё одного примера на C

Рассмотрим полную картину на следующем примере:

int one(int, int);
int two(int, int);
int three(int, int);

int
main(int argc, char *argv[])
{
int ia, ib, ic;

ia = 1;
ib = 2;
ic = one(ia, ib);

return ic;
}

int
one(int a, int b)
{
int c;
c = two(a,b);
return c;
}

int
two(int a, int b)
{
int c;
c = three(a,b);
return c;
}

int
three(int a, int b)
{
int c;
c = a+b;
return c;
}

❯ Описание стека кадров​

Опишем, как будет выглядеть стек кадров:

  • Четыре кадра будут выделены под функции main, one, two и three
  • При вызове main в ней будут содержаться значения argc и argv, хранимые в регистрах r0 и r1
  • Когда main завершит работу, у неё в регистре r0 будет содержаться значение c
  • В кадре main будет выделено пространство как минимум для fp, lr, int ia, int ib и int ic. Обычно выделяется больше пространства, как правило, слов на двадцать.
  • У функций one, two и three будут значения int a и int b, хранящиеся в регистрах r0 и r1
  • Возвращаемое значение функций one, two и three будет находиться в регистре r0
  • Функции three не потребуется сохранять lr, поскольку она не вызывает никаких других функций

❯ Дизассемблирование примера​

При помощи gdb можно дизассемблировать этот пример, чтобы посмотреть, какие инструкции в нём используются. Считаю, что функция disassemble из gdb для этого очень удобна, без неё пришлось бы смотреть файл .s из gcc. Она компилируется с опцией CFLAGS=-O0 -g. Вероятно, -O0 сразу покажет, что код можно оптимизировать. Это видно особенно явственно, когда аргументы для функций проталкиваются в кадр, а потом вытягиваются обратно в неизменённом виде.

Я подробно прокомментировал код, чтобы было понятнее, что делается в lr, fp и sp. Вероятно, вам понадобится калькулятор. Удобнее всего будет скомпилировать этот пример и запустить gdb. После этого можно будет проверить память, например, при помощи p/x *(0x0xbefff4d8) и x/20w 0xbefff4d8. Просмотреть регистры можно командой info registers.

Вот дизассемблированный код:

(gdb) disassemble main
Dump of assembler code for function main:
0x000103d0 <+0>: push {r11, lr} ; lr=0xbfe84718 r11 at lowest address
0x000103d4 <+4>: add r11, sp, #4 ; r11=fp=0x0
0x000103d8 <+8>: sub sp, sp, #24 ; sp=0xbefff4d8, frame is size 28=24+4
0x000103dc <+12>: str r0, [r11, #-24] ; 0xffffffe8
0x000103e0 <+16>: str r1, [r11, #-28] ; 0xffffffe4
0x000103e4 <+20>: mov r3, #1
0x000103e8 <+24>: str r3, [r11, #-8]
0x000103ec <+28>: mov r3, #2
0x000103f0 <+32>: str r3, [r11, #-12]
0x000103f4 <+36>: ldr r1, [r11, #-12]
0x000103f8 <+40>: ldr r0, [r11, #-8]
0x000103fc <+44>: bl 0x10414 <one> ; here the lr will be set to 0x00010400
0x00010400 <+48>: str r0, [r11, #-16] ; r0 has the return value from function one
0x00010404 <+52>: ldr r3, [r11, #-16]
0x00010408 <+56>: mov r0, r3 ; r0 will return with the value of int ic
0x0001040c <+60>: sub sp, r11, #4 ; point sp one word above fp
0x00010410 <+64>: pop {r11, pc} ; pc will be restored to 0xbfe84718
End of assembler dump.
(gdb) disassemble one
Dump of assembler code for function one:
0x00010414 <+0>: push {r11, lr} ; lr=0x00010400 r11=fp=0xbefff4d0
0x00010418 <+4>: add r11, sp, #4 ; r11=fp=0xbefff4d4
0x0001041c <+8>: sub sp, sp, #16 ; sp=0xbefff4c0 frame is size 20=16+4
0x00010420 <+12>: str r0, [r11, #-16]
0x00010424 <+16>: str r1, [r11, #-20] ; 0xffffffec
0x00010428 <+20>: ldr r1, [r11, #-20] ; 0xffffffec
0x0001042c <+24>: ldr r0, [r11, #-16]
0x00010430 <+28>: bl 0x10448 <two> ; lr will be 0x00010434
0x00010434 <+32>: str r0, [r11, #-8]
0x00010438 <+36>: ldr r3, [r11, #-8]
0x0001043c <+40>: mov r0, r3
0x00010440 <+44>: sub sp, r11, #4 ; point sp one word above fp
0x00010444 <+48>: pop {r11, pc} ; fp=0xbefff4f4, lr=0x00010400
End of assembler dump.
(gdb) disassemble two
Dump of assembler code for function two:
0x00010448 <+0>: push {r11, lr} ; lr=0x00010434, r11=fp=0xbefff4d4
0x0001044c <+4>: add r11, sp, #4 ; fp=0xbefff4bc
0x00010450 <+8>: sub sp, sp, #16 ; sp=0xbefff4a8 frame is 20=16+4 words
0x00010454 <+12>: str r0, [r11, #-16]
0x00010458 <+16>: str r1, [r11, #-20] ; 0xffffffec
0x0001045c <+20>: ldr r1, [r11, #-20] ; 0xffffffec
0x00010460 <+24>: ldr r0, [r11, #-16]
0x00010464 <+28>: bl 0x1047c <three> ; lr will be set to 0x00010468
0x00010468 <+32>: str r0, [r11, #-8]
0x0001046c <+36>: ldr r3, [r11, #-8]
0x00010470 <+40>: mov r0, r3
0x00010474 <+44>: sub sp, r11, #4
0x00010478 <+48>: pop {r11, pc}
End of assembler dump.
(gdb) disassemble three
Dump of assembler code for function three:
0x0001047c <+0>: push {r11} ; (str r11, [sp, #-4]!) NOTICE no lr!!
0x00010480 <+4>: add r11, sp, #0 ; dont add #4 here since no frp=0xbefff4a4
0x00010484 <+8>: sub sp, sp, #20 ; stack is size 20 sp=0xbfff490
0x00010488 <+12>: str r0, [r11, #-16]
0x0001048c <+16>: str r1, [r11, #-20] ; 0xffffffec
0x00010490 <+20>: ldr r2, [r11, #-16]
0x00010494 <+24>: ldr r3, [r11, #-20] ; 0xffffffec
0x00010498 <+28>: add r3, r2, r3
0x0001049c <+32>: str r3, [r11, #-8]
0x000104a0 <+36>: ldr r3, [r11, #-8]
0x000104a4 <+40>: mov r0, r3
0x000104a8 <+44>: add sp, r11, #0
0x000104ac <+48>: pop {r11} ; (ldr r11, [sp], #4)
0x000104b0 <+52>: bx lr ; lr=0x10468
End of assembler dump.
(gdb)

❯ Стек кадров в реальном мире​

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