Как стать автором
Обновить
1837.52
Timeweb Cloud
То самое облако

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

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров912
Автор оригинала: Lloyd Rochester

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

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

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

  • Четыре кадра будут выделены под функции mainonetwo и three

  • При вызове main в ней будут содержаться значения argc и argv, хранимые в регистрах r0 и r1

  • Когда main завершит работу, у неё в регистре r0 будет содержаться значение c 

  • В кадре main будет выделено пространство как минимум для fplr, int iaint ib и int ic. Обычно выделяется больше пространства, как правило, слов на двадцать.

  • У функций onetwo и three будут значения int a и int b, хранящиеся в регистрах r0 и r1

  • Возвращаемое значение функций onetwo и three будет находиться в регистре r0

  • Функции three не потребуется сохранять lr, поскольку она не вызывает никаких других функций

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

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

Я подробно прокомментировал код, чтобы было понятнее, что делается в lrfp и 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-канале 

Перейти ↩

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

📚 Читайте также:

Теги:
Хабы:
+10
Комментарии2

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud

Истории