Почему заполнение в C допустимо для переменных / структур, размещенных в стеке?

Я читаю о заполнении структур в C здесь: http://www.catb.org/esr/structure-packing/.
Я не понимаю, почему заполнение, определенное во время компиляции для переменных / структур, размещенных в стеке, является семантически допустимым во всех случаях. Приведу пример. Скажем, у нас есть код игрушки, который нужно скомпилировать:

int main() {
    int a;
    a = 1;
}

На X86-64 gcc -S -O0 a.c генерирует эту сборку (лишние символы удалены):

main:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $1, -4(%rbp)
    movl    $0, %eax
    popq    %rbp
    ret

В этом случае, почему мы знаем, что значение %rbp и, следовательно, %rbp-4 является 4-выровненным, чтобы быть подходящим для хранения / загрузки int?

Давайте попробуем тот же пример со структурами.

struct st{
    char a;
    int b;
}

Из прочтения я сделал вывод, что версия структуры с дополнениями выглядит примерно так:

struct st{
    char a;      // 1 byte
    char pad[3]; // 3 bytes
    int b;       // 4 bytes
}

Итак, второй пример игрушки

int main() {
    struct st s;
    s.a = 1;
    s.b = 2;
}

генерирует

main:
    pushq   %rbp
    movq    %rsp, %rbp
    movb    $1, -8(%rbp)
    movl    $2, -4(%rbp)
    movl    $0, %eax
    popq    %rbp
    ret

И мы видим, что это действительно так. Но опять же, какова гарантия того, что само значение rbp в произвольном кадре стека будет правильно выровнено? Разве значение rbp не доступно только во время выполнения? Как компилятор может выровнять элементы структуры, если во время компиляции ничего не известно о выравнивании начального адреса структуры?


person dav23r    schedule 09.05.2019    source источник
comment
Согласно System V ABI, при входе в функцию стек выравнивается по 16 байт.   -  person Thomas Jager    schedule 09.05.2019
comment
@ThomasJager: Стек выравнивается в точке перед выполнением инструкции call. Он смещен на 8, когда достигается первая инструкция функции, потому что адрес возврата был помещен в стек. Выталкивание RBP приводит к выравниванию стека по 16-байтовой границе.   -  person Michael Petch    schedule 09.05.2019
comment
@MichaelPetch: Правильно, я думал при входе в функцию, когда был сделан вызов   -  person Thomas Jager    schedule 09.05.2019
comment
@MichaelPetch Это x86-64 Linux OS, так что я думаю, это 64-битный System V ABI   -  person dav23r    schedule 09.05.2019
comment
@ThomasJager Это многое объясняет, спасибо. Интересно, что идея о том, что вызывающая программа обеспечивает выравнивание стека, никогда не приходила мне в голову.   -  person dav23r    schedule 09.05.2019
comment
@MichaelPetch Спасибо за исправление и улучшенные теги   -  person dav23r    schedule 09.05.2019


Ответы (2)


Как указывает @P__J__ (в теперь удаленном ответе), то, как компилятор C генерирует код, является деталью реализации. Поскольку вы пометили это как вопрос ABI, ваш реальный вопрос: «Когда GCC нацелен на Linux, как можно предполагать, что RSP имеет какое-то конкретное минимальное выравнивание?». 64-разрядный ABI, который использует Linux, - это AMD64 (x86-64) System V ABI. Минимальное выравнивание стека непосредственно перед ВЫЗОВОМ ABI-совместимой функции 1,2 (включая main) гарантированно будет минимум 16 байтов (это может быть 32 байта или 64 байта в зависимости от типов, переданных в функцию). В ABI говорится:

3.2.2 Фрейм стека

Помимо регистров, каждая функция имеет фрейм в стеке времени выполнения. Этот стек растет вниз от высоких адресов. На рисунке 3.3 показана структура стека. Конец области входного аргумента должен быть выровнен по 16 (32 или 64, если __m256 или __m512 передается в стек) границе байта. Другими словами, значение (% rsp + 8) всегда кратно 16 (32 или 64) , когда управление передается точке входа функции. Указатель стека ,% rsp, всегда указывает на конец последнего выделенного кадра стека.

Вы можете спросить, почему упоминание RSP + 8 кратно 16 (а не RSP + 0). Это связано с тем, что концепция CALL функции подразумевает, что 8-байтовый адрес возврата будет помещен в стек самой инструкцией CALL. Независимо от того, вызывается ли функция или к ней выполняется переход (например, хвостовой вызов), генератор кода всегда предполагает, что непосредственно перед выполнением первой инструкции в функции стек всегда смещен на 8. Однако есть автоматическая гарантия, что стек будет выровнен по 8-байтовой границе. Если вы вычтите 8 из RSP, вы снова будете выровнены на 16 байт.

Примечательно, что приведенный ниже код гарантирует, что после PUSHQ стек будет выровнен по 16-байтовой границе, поскольку инструкция PUSH уменьшает RSP на 8 и снова выравнивает стек по 16-байтовой границе:

main:
                             # <------ Stack pointer (RSP) misaligned by 8 bytes
    pushq   %rbp
                             # <------ Stack pointer (RSP) aligned to 16 byte boundary
    movq    %rsp, %rbp
    movb    $1, -8(%rbp)
    movl    $2, -4(%rbp)
    movl    $0, %eax
    popq    %rbp
    ret

Для 64-битного кода из всего этого можно сделать вывод, что, хотя фактическое значение указателя стека известно во время выполнения, ABI позволяет нам сделать вывод, что значение при входе в функцию имеет определенное выравнивание и система генерации кода компилятора может использовать это в своих интересах при размещении struct в стеке.


Когда выравнивания стека функции недостаточно для выравнивания переменной?

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

struct st{
    char a;      // 1 byte
    char pad[3]; // 3 bytes
    int b;       // 4 bytes
};

int main() {
    struct st s __attribute__(( aligned(32)));
    s.a = 1;
    s.b = 2;
}

Мы сказали GCC, что переменная s должна быть выровнена по 32 байта. Функция, которая может гарантировать 16-байтовое выравнивание стека, не гарантирует 32-байтовое выравнивание (32-байтовое выравнивание действительно гарантирует 16-байтовое выравнивание, поскольку 32 делятся на 16 без остатка). Компилятор GCC должен будет создать пролог функции, чтобы s можно было правильно выровнять. Вы можете посмотреть неоптимизированный вывод Godbolt для этой программы, чтобы увидеть, как GCC достигает этого:

main:
        pushq   %rbp
        movq    %rsp, %rbp
        andq    $-32, %rsp    # ANDing RSP with -32 (0xFFFFFFFFFFFFFFE0) 
                              # rounds RSP down to next 32 byte boundary
                              # by zeroing the lower 5 bits of RSP.
        movb    $1, -32(%rsp) 
        movl    $2, -28(%rsp)
        movl    $0, %eax
        leave
        ret

Сноски

  • 1 AMD64 System V ABI также используется 64-битными Solaris, MacOS и BSD, а также Linux.
  • 2 64-битное соглашение о вызовах Microsoft Windows (ABI) гарантирует, что перед вызовом функции стек будет выровнен по 16 байт (8 байт смещены непосредственно перед первой инструкцией выполняемой функции).
person Michael Petch    schedule 09.05.2019
comment
Вы можете использовать C11 alignas (32) struct st s;, чтобы записать это портативно. #include <stdalign.h> в C или C ++ 11 alignas уже является ключевым словом. - person Peter Cordes; 10.05.2019

В этом случае, почему мы знаем, что значение% rbp и, следовательно,% rbp-4 выровнено по 4, чтобы быть подходящим для хранения / загрузки int?

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

Тем не менее, он выровнен. Мы знаем это потому, что это инвариант системы, которой мы доверяем, и требуется ABI. Если указатель стека не выровнен, это означает, что вызывающий нарушил один из аспектов соглашений о вызовах.

Если мы не получаем вызов из отдельного домена безопасности (например, ядро, получающее системный вызов из пользовательского пространства), мы просто доверяем вызывающему. Как функция strcmp узнает, что ее аргументы указывают на допустимые строки с завершающим нулем? Он доверяет вызывающему. То же самое.

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

Как компилятор может выровнять элементы структуры, если во время компиляции ничего не известно о выравнивании начального адреса структуры?

Членам struct даются смещения в предположении, что базовый адрес времени выполнения объекта будет соответствующим образом выровнен даже для самого строго выровненного члена структуры. Вот почему первый член структуры просто помещается с нулевым смещением, независимо от его типа.

Среда выполнения должна гарантировать, что любой адрес, выделенный для произвольного объекта, имеет строжайшее выравнивание среди всех стандартных типов alignof(maxalign_t). Например, если самое строгое выравнивание в системе составляет 16 байт (как в x86-64 System V), то malloc должен выдать указатели на адреса, выровненные по 16 байтов. Затем в результирующую память можно поместить любую структуру.

Если вы напишете свой собственный распределитель якобы общего назначения, который выдает 4-байтовые указатели в системе, где выравнивание может быть таким же строгим, как 16, то это неправильно.


(Обратите внимание, что типы __m256 и __m512 не учитываются для maxalign_t: malloc по-прежнему должен обеспечивать только 16-байтовое выравнивание в x86-64 System V, и этого недостаточно для чрезмерно выровненных типов, таких как __m256 или пользовательский struct foo { alignas(32) int32_t a[8]; };. Используйте aligned_alloc() для чрезмерно выровненных типов.)

Также обратите внимание, что формулировка в стандарте ISO C гласит, что память, возвращаемая malloc, должна использоваться для любого типа. В любом случае 4-байтовое выделение не может содержать 16-байтовый тип, поэтому небольшие выделения могут быть выровнены менее чем на 16 байтов.

person Kaz    schedule 09.05.2019
comment
%ebp не является входом в функцию. Функция создает указатель кадра из указателя стека. Кроме того, IDK, почему вы используете 32-битные частичные регистры; в x86-64 указатель стека обычно находится за пределами младших 32 бита, поэтому %rsp != %esp. - person Peter Cordes; 10.05.2019
comment
контрпример: неверно выровненный массив int может вызвать segfaults, если компилятор предполагает, что некоторое целое количество элементов достигнет 16-байтовой границы при автоматической векторизации. Почему невыровненный доступ к памяти с памятью mmap на AMD64 иногда дает сбой?. Но да, для скалярного доступа x86 не выполняет проверку выравнивания. (Если вы не активируете бит AC, но тогда большая часть кода будет иметь ошибку segfault, если вызовет glibc memcpy для небольшого нечетного размера или многие другие вещи, которые компиляторы считают безопасными.) - person Peter Cordes; 10.05.2019
comment
@PeterCordes %ebp, безусловно, является входом в блок кода, в который скомпилирована функция C. Если на значение опирается фрагмент кода, и это значение поступает откуда-то еще, это входные данные. - person Kaz; 12.05.2019
comment
Вторая инструкция - movq %rsp, %rbp, которая перезаписывает %rbp копией указателя стека как часть создания кадра стека. Для сохранения / восстановления значения вызывающего абонента используется нажатие / выталкивание %rbp вокруг всей функции, но кроме этого функция ничего не делает со значением %rbp вызывающего абонента. А почему вы все еще говорите о %ebp? Младший 32-битный регистр не содержит полезного значения, имеет смысл только полный 64-битный регистр. - person Peter Cordes; 12.05.2019
comment
Таким образом, %rbp содержит копию ввода; его значение получено из ввода. Если указатель входного стека каким-то образом неверен, тогда %rbp будет неправильным. Спасибо за правку неаккуратного письма! - person Kaz; 13.05.2019