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

У меня есть следующий код. Есть функция, которая принимает два типа 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.

Ответы [ 4 ]

36 голосов
/ 23 июня 2019

Как уже отмечали другие, это неопределенное поведение, поэтому все ставки сделаны на то, что в принципе может произойти.Но если предположить, что вы работаете на компьютере с архитектурой 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) просят вызывающего сделать очистку.Тем не менее, здесь ничего не имеет значения, потому что стек не затрагивается.

9 голосов
/ 24 июня 2019

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

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

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

Все, что я обсуждаю ниже, является поведением, специфичным для компилятора.Все мои симуляции выполняются с помощью 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, но были объединены каким-то специфическим для компилятора способом.( неопределенное поведение снова, но вы можете видеть, что аргументы были выдвинуты).

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

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

1 голос
/ 24 июня 2019

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

Я использовал 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 при использовании целых чисел со знаком.Вы также получите предупреждение

<source>:13:7: warning: implicit conversion from 'int' to 'signed char' changes value from 1000 to -24 [-Wconstant-conversion]
0 голосов
/ 24 июня 2019

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

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

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

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, нет необходимости передавать его.Однако поддержка такой семантики не была бы практичной на всех платформах, и в большинстве случаев преимущества проверки аргументов перевешивают затраты, поэтому стандарт не требует, чтобы реализации были способны поддерживать этот шаблон, но допускает те, которые могут поддерживать шаблонтак удобно расширять язык.

...