Почему этот вызов функции ведет себя разумно после вызова через указатель на функцию типа? - программирование
Подтвердить что ты не робот

Почему этот вызов функции ведет себя разумно после вызова через указатель на функцию типа?

У меня есть следующий код. Есть функция, которая принимает два типа int32. Затем я беру указатель на него и приводю к функции, которая принимает три int8, и вызываю ее. Я ожидал ошибку во время выполнения, но программа работает нормально. Почему это вообще возможно?

main.cpp:

#include <iostream>

using namespace std;

void f(int32_t a, int32_t b) {
    cout << a << " " << b << endl;
}

int main() {
    cout << typeid(&f).name() << endl;
    auto g = reinterpret_cast<void(*)(int8_t, int8_t, int8_t)>(&f);
    cout << typeid(g).name() << endl;
    g(10, 20, 30);
    return 0;
}

Выход:

PFviiE
PFvaaaE
10 20

Как я вижу, сигнатура первой функции требует двух целых, а вторая функция требует трех символов. Char меньше, чем int, и мне было интересно, почему a и b по-прежнему равны 10 и 20.

4b9b3361

Ответ 1

Как уже отмечали другие, это неопределенное поведение, поэтому все ставки на то, что в принципе может произойти, не принимаются. Но если предположить, что вы работаете на компьютере с архитектурой x86, у вас есть правдоподобное объяснение того, почему вы это видите.

На x86 компилятор g++ не всегда передает аргументы, помещая их в стек. Вместо этого он сохраняет первые несколько аргументов в регистрах. Если мы разберем функцию f, обратите внимание, что первые несколько инструкций перемещают аргументы из регистров и явно в стек:

    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     DWORD PTR [rbp-4], edi  # <--- Here
    mov     DWORD PTR [rbp-8], esi  # <--- Here
    # (many lines skipped)

Точно так же обратите внимание, как вызов генерируется в main. Аргументы помещаются в эти регистры:

    mov     rax, QWORD PTR [rbp-8]
    mov     edx, 30      # <--- Here
    mov     esi, 20      # <--- Here
    mov     edi, 10      # <--- Here
    call    rax

Поскольку весь регистр используется для хранения аргументов, размер аргументов здесь не имеет значения.

Более того, поскольку эти аргументы передаются через регистры, не нужно беспокоиться о неправильном изменении размера стека. Некоторые соглашения о вызовах (cdecl) позволяют вызывающей стороне выполнять очистку, в то время как другие (stdcall) просят вызывающей стороны выполнить очистку. Тем не менее, здесь ничего не имеет значения, поскольку стек не затрагивается.

Ответ 2

Как уже отмечали другие, это, вероятно, неопределенное поведение, но программисты старой школы C знают, что такое работает.

Кроме того, поскольку я чувствую, как языковые адвокаты готовят свои судебные документы и судебные ходатайства о том, что я собираюсь сказать, я собираюсь заворожить undefined behavior discussion о undefined behavior discussion. Он произнес трижды сказав undefined behavior одновременно постукивая по моей обуви. И это заставляет языковых адвокатов исчезнуть, поэтому я могу объяснить, почему странные вещи просто случаются, без предъявления иска.

Вернуться к моему ответу:

Все, что я обсуждаю ниже, является специфическим поведением компилятора. Все мои симуляции выполняются с помощью Visual Studio, скомпилированной как 32-битный код x86. Я подозреваю, что он будет работать так же с gcc и g++ на аналогичной 32-битной архитектуре.

Вот почему ваш код просто работает и некоторые предостережения.

  1. Когда аргументы вызова функции помещаются в стек, они помещаются в обратном порядке. Когда f вызывается нормально, компилятор генерирует код для помещения аргумента b в стек перед аргументом a. Это помогает упростить различные функции аргументов, такие как printf. Поэтому, когда ваша функция, f обращается к a и b, она просто обращается к аргументам в верхней части стека. При вызове через g в стек был добавлен дополнительный аргумент (30), но он был передан первым. 20 было нажато следующим, затем 10, которое находится на вершине стека. f смотрит только на два верхних аргумента в стеке.

  2. IIRC, по крайней мере, в классическом ANSI C, символы и шорты всегда повышаются до int перед помещением в стек. Поэтому, когда вы вызываете его с помощью g, литералы 10 и 20 помещаются в стек как полноразмерные, а не как 8-битные. Тем не менее, в тот момент, когда вы переопределяете f для использования 64-битных длин вместо 32-битных, вывод вашей программы изменится.

    void  f(int64_t a, int64_t b) {
        cout << a << " " << b << endl;
    }

В результате получается вывод вашей основной (с моим компилятором)

85899345930 48435561672736798

И если вы конвертируете в гекс:

140000000a effaf00000001e

14 - 20 а 0A - 10. И я подозреваю, что 1e - это ваши 30 попадающие в стек. Таким образом, аргументы помещаются в стек при вызове через g, но они обрабатываются каким-то специфическим для компилятора способом. (снова неопределенное поведение, но вы можете видеть, что аргументы были выдвинуты).

  1. Когда вы вызываете функцию, обычное поведение состоит в том, что вызывающий код исправит указатель стека после возврата из вызываемой функции. Опять же, это ради функций с переменным числом аргументов и других унаследованных причин для сравнения с K & R. printf не знает, сколько аргументов вы фактически передали ему, и полагается, что вызывающая сторона исправит стек при возврате. Поэтому, когда вы вызываете через g, компилятор сгенерировал код для добавления 3 целых чисел в стек, вызова функции, а затем кода для удаления тех же значений. В тот момент, когда вы изменяете опцию компилятора, чтобы вызываемый __stdcall стек (ala __stdcall в Visual Studio):
    void  __stdcall f(int32_t a, int32_t b) {
        cout << a << " " << b << endl;
    }

Теперь вы явно находитесь на неопределенной территории поведения. Вызов через g поместил три аргумента int в стек, но компилятор только сгенерировал код для f чтобы вытолкнуть два аргумента int из стека при возврате. Указатель стека поврежден при возврате.

Ответ 3

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

Я использовал Godbolt, чтобы увидеть сгенерированную сборку, которую вы можете полностью проверить здесь

Соответствующий вызов функции здесь:

mov     edi, 10
mov     esi, 20
mov     edx, 30
call    f(int, int) #clang totally knows you're calling f by the way

Он не помещает параметры в стек, он просто помещает их в регистры. Самое интересное то, что инструкция mov не изменяет только младшие 8 бит регистра, но все они как 32-битный шаг. Это также означает, что независимо от того, что было в регистре раньше, вы всегда получите правильное значение, когда прочитаете 32 бита обратно, как это делает f.

Если вы удивляетесь, почему это 32-битный шаг, то оказывается, что почти в каждом случае на архитектуре x86 или AMD64 компиляторы всегда будут использовать 32-битные литеральные перемещения или 64-битные литеральные перемещения (если и только если значение слишком велико) для 32 бит). Перемещение 8-битного значения не обнуляет старшие биты (8-31) регистра, и это может создать проблемы, если значение будет в конечном итоге повышено. Использовать 32-битную буквальную инструкцию проще, чем одну дополнительную инструкцию для обнуления регистра в первую очередь.

Однако следует помнить одну вещь: он действительно пытается вызвать f как если бы он имел 8-битные параметры, поэтому, если вы укажете большое значение, он обрежет литерал. Например, 1000 станет -24, поскольку младшие биты 1000 равны E8, то есть -24 при использовании целых чисел со -24. Вы также получите предупреждение

<source>:13:7: warning: implicit conversion from 'int' to 'signed char' changes value from 1000 to -24 [-Wconstant-conversion]

Ответ 4

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

Даже на платформах, таких как Classic Macintosh, где ответственность за выталкивание аргументов обычно ложится на вызываемую функцию (и если неудачное выдвижение нужного количества аргументов часто приводит к повреждению стека), компиляторы C обычно используют соглашение о вызовах, которое ведет себя как первый C компилятор. При вызове или для функций, вызываемых кодом, написанным на других языках (таких как Pascal), требовался квалификатор "Паскаль".

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

int foo(x,y) int x,y
{
  printf("Hey\n");
  if (x)
  { y+=x; printf("y=%d\n", y); }
}

и вызывайте его, например, как foo(0) или foo(0,0), причем первый будет немного быстрее. Попытка назвать это как, например, foo(1); вероятно, испортит стек, но если функция никогда не использует объект y нет необходимости передавать его. Однако поддержка такой семантики не была бы практичной на всех платформах, и в большинстве случаев преимущества проверки аргументов перевешивают затраты, поэтому стандарт не требует, чтобы реализации были способны поддерживать этот шаблон, но допускает те, которые могут поддерживать шаблон таким образом, удобно расширять язык.

Ответ 5

Я думаю, что 10 и 20 просто вписываются в int8_t. Если вы попытаетесь вызвать g с аргументами 10000, 20000, 30, вы получите такие предупреждения, как

переполнение при преобразовании из int в знаковое char изменяет значение с 10000 на 16

reinterpret_cast может привести вас к чему угодно. Если бы вы использовали функцию static_cast, вы бы получили ошибку

недопустимый static_cast из типа 'void() (int32_t, int32_t)' {aka 'void() (int, int)'} для типа 'void() (int8_t, int8_t, int8_t)' {aka 'void() (подписанный символ, подписанный символ, подписанный символ) '} auto g = static_cast (& f);