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

Здесь в трёх столбцах показан код на 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/popARM: При помощи этих инструкций мы помещаем регистры в стек, а также выталкиваем из него регистры, когда он уже заполнен на всю глубину сверху вниз.Регистр
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, где компилятор удаляет стек кадров в процессе оптимизации.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩

Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.
