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

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