Как alloca() взаимодействовала с другими распределениями стека?

Начнем с простого примера распределения стека:

      void f() {
    int a, b;
    ...
}

Если я правильно понимаю. Тогда адрес и имеет фиксированное смещение от базы стека, а именно регистра.ebp. Вот как компилятор найдет их, если они нам понадобятся впоследствии.

Но рассмотрим следующий код.

      void f(int n) {
    int a;
    alloca(n);
    int b;
    ...
}

Если компилятор не выполняет никакой оптимизации, стек будетa->n->b. Теперь смещение зависит от . Что тогда сделал компилятор?

Подражание Как alloca() работает на уровне памяти?. Я попробовал следующий код:

      #include <stdio.h>
#include <alloca.h>

void foo(int n)
{
    int a;
    int *b = alloca(n * sizeof(int));
    int c;
    printf("&a=%p, b=%p, &c=%p\n", (void *)&a, (void *)b, (void *)&c);
}

int main()
{
    foo(5);
    return 0;
}

Результат:&a=0x7fffbab59d68, b=0x7fffbab59d30, &c=0x7fffbab59d6c. На этот раз адресaи выглядит соседским. Компилятор сделал какое-то изменение порядка? И если мы не позволим компилятору изменить порядок, как компилятор найдет адресc?

------------Некоторые обновления------------

Хорошо, если вы верите, что компиляторы действительно имеют значение, давайте попробуем x86-64 gcc 13.2, я немного изменил код.

      #include <alloca.h>
void alloca_test(int n) {
    int a;
    int* ptr = (int *) alloca(n);
    int b;
    a++;
    b++;
    ptr[0]++;
}

и вот сборка:

      alloca_test(int):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48
        mov     DWORD PTR [rbp-36], edi
        mov     DWORD PTR [rbp-4], 0
        mov     eax, DWORD PTR [rbp-36]
        cdqe
        lea     rdx, [rax+8]
        mov     eax, 16
        sub     rax, 1
        add     rax, rdx
        mov     ecx, 16
        mov     edx, 0
        div     rcx
        imul    rax, rax, 16
        sub     rsp, rax
        mov     rax, rsp
        add     rax, 15
        shr     rax, 4
        sal     rax, 4
        mov     QWORD PTR [rbp-16], rax
        mov     DWORD PTR [rbp-20], 0
        add     DWORD PTR [rbp-4], 1    <--a++
        add     DWORD PTR [rbp-20], 1   <--b++
        mov     rax, QWORD PTR [rbp-16]
        mov     eax, DWORD PTR [rax]
        lea     edx, [rax+1]
        mov     rax, QWORD PTR [rbp-16]
        mov     DWORD PTR [rax], edx
        nop
        leave
        ret

Здесьbимеет адрес[rbp-20], которые не меняются относительноn

1 ответ

На самом деле, для действительно неоптимизирующего компилятора все было бы наоборот.

Давайте представим себе наивный компилятор, созданный в стиле 1970-х годов, поскольку язык C изначально был разработан для реализации такими компиляторами. И давайте пока проигнорируем этот призыв. Когда компилятор анализирует функцию, каждый раз, когда он встречает определение локальной переменной [*], он назначает ей слот стека относительно указателя кадра: soat, at и т. д., и использует его для обращения к переменной. Когда он завершает анализ, он видит все локальные переменные и может вычислить общий объем необходимого стека и поэтому вставить соответствующую константу в код пролога, который корректирует указатель стека (например,). Даже очень простой однопроходный компилятор, который генерирует код построчно, может сделать это, выдавая что-то вродев прологе, а затем позже внести исправления, чтобы заменить непосредственныйпо правильному значению.

Что касается , то он не был частью исходного языка C. Скорее, это был, по сути, «крутой хак», который кто-то обнаружил, и который можно реализовать способом, полностью ортогональным описанному выше процессу. Мы можем описать эту идею следующим образом в терминах x86. (Первоначальная реализация была бы для PDP-11 или VAX или чего-то подобного, но идея та же.) Поскольку все локальные переменные адресуются относительно , ​​то не имеет значения, будет ли указатель стека уменьшаться дальше во время выполнение функции; компилятор никогда не ссылается на . А очистка стека в эпилоге функции обычно реализуется каквместо, так что он продолжит работать нормально.

Так что на самом деле компилятору даже не обязательно об этом знать.делает что-то особенное в отношении стека. Это может быть макрос, который расширяется до встроенной сборки, например, который, опять же, может быть непрозрачен для компилятора, кроме заполнения режима адресации для(в данном случае первый аргумент стека по адресупри условии традиционного использования EBP в качестве указателя кадра). Это не будет играть никакой роли в процессе выделения слотов стека для локальных переменных, поскольку этот гипотетический компилятор всегда будет обращаться к локальным переменным только относительно указателя кадра (EBP), а не ESP.

Фактически, если вы готовы немного больше манипулировать стеком, это может быть даже внешняя библиотечная функция, которую компилятор обрабатывает как любой другой вызов функции. Вот почему синтаксис вызова функции не интегрирован более глубоко в язык — исходная реализация была просто вызовом функции.

Итак, с этой реализацией у нас будети вверху кадра стека (выделяется одновременно в прологе функции), и буфер ed под ними (выделяется в момент вызова). Если бы у нас было больше вызовов , то они бы возвращали постепенно меньшие адреса в том порядке, в котором они были выполнены, каждый раз вычитая из указателя стека.

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


Как вы можете себе представить, этот «крутой хак» перестал работать так хорошо, когда компиляторы стали умнее и захотели иметь возможность фактически контролировать указатель стека во время выполнения функции. Тогда изменение указателя стека за спиной компилятора как бы становится катастрофическим, поэтому пришлось предоставлять специальную поддержку компилятора, причиняя много боли авторам компиляторов. Идея в конечном итоге была полностью принята в языке, когда в C99 были представлены массивы переменной длины, но многие наблюдатели считают это решение ошибкой.

Что касается того, что делает современный компилятор, однозначного ответа не существует. Он точно знает, какая семантиканеобходимо предоставить, и он может принимать собственные решения относительно того, как это реализовать. Это не обязательно ограничивается использованием-относительная адресация для всех локалов; он может использовать-relative, или вычислить адрес каким-то другим способом, или, может быть, просто оптимизировать переменные в регистры, чтобы они вообще не занимали слот стека. Поэтому мы не можем легко предсказать, как будет выглядеть макет стека.


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

      int a;
if (...) {
    int b;
    ...
}
while (...) {
    int c;
    ...
}

Здеськаждый получит свои собственные слоты для стека, скажем, ,соответственно. В прологе указатель стека будет скорректирован на 12. В частности, компилятор не будет выдавать инструкции по корректировке указателя стека в начале и конце каждогоблоки. Однако его можно было бы оптимизировать, заметив, что объемыине перекрываются, и поэтому он может назначитьдля них обоих и использовать только 8 байт стека вместо 12.

Другие вопросы по тегам