Каламбуры типизации функций в C

в 18:20, , рубрики: assembly, C, c++, x86-64, ассемблер, каламбуры типизации, ненормальное программирование, слабая типизация, тонкости программирования

У C репутация негибкого языка. Но вы знаете, что вы можете изменить порядок аргументов функции в C, если он вам не нравится?

#include <math.h>
#include <stdio.h>

double  DoubleToTheInt(double base, int power) {
    return pow(base, power);
}

int main() {
    // приводим к указателю на функуцию с обратным порядком аргументов
    double (*IntPowerOfDouble)(int, double) =
        (double (*)(int, double))&DoubleToTheInt;

    printf("(0.99)^100: %lf n", DoubleToTheInt(0.99, 100));
    printf("(0.99)^100: %lf n", IntPowerOfDouble(100, 0.99));
}

Этот код на самом деле никогда не определяет функцию IntPowerOfDouble — потому что функции IntPowerOfDouble не существует. Это переменная, указывающая на DoubleToTheInt, но с типом, который говорит, что ему хочется, чтобы аргумент типа int шел перед аргументом типа double.

Вы могли бы ожидать, что IntPowerOfDouble примет аргументы в том же порядке, что и DoubleToTheInt, но приведет аргументы к другим типам, или что-то типа того. Но это не то, что происходит.

Попробуйте — вы увидите одинаковый результат в обоих строчках.

emiller@gibbon ~> clang something.c 
emiller@gibbon ~> ./a.out 
(0.99)^100: 0.366032 
(0.99)^100: 0.366032 

Теперь попробуйте изменить все int на float — вы увидите, что FloatPowerOfDouble делает что-то ещё более странное. Да,

double  DoubleToTheFloat(double base, float power) {
    return pow(base, power);
}

int main() {
    double (*FloatPowerOfDouble)(float, double) =
        (double (*)(float, double))&DoubleToTheFloat;

    printf("(0.99)^100: %lf n", DoubleToTheFloat(0.99, 100));   // OK
    printf("(0.99)^100: %lf n", FloatPowerOfDouble(100, 0.99)); // Упс...
}

выдает:

(0.99)^100: 0.366032 
(0.99)^100: 0.000000 

Значение во второй строке "даже не ошибочное" — если бы проблема была в перестановке аргументов, мы бы ожидали, что ответ будет 100^99 = 95.5 а не 0. Что происходит?

Примеры кода выше представляют каламбуры типизации функций(type punning of functions) — опасную форму "ассемблера без ассемблера" который никогда не должен использоваться на работе, рядом с тяжелой техникой или в сочетании с отпускаемыми по рецепту лекарствами. Эти примеры абсолютно логичны для тех, кто понимает код на уровне ассемблера — но, скорее всего, запутает всех остальных.

Я немного смухлевал — предположил, что вы запустите код на 64-битном x86 компьютере. На другой архитектуре этот фокус может не сработать. Хоть и считается, что у C бесконечное количество темных углов, поведение с аргументами int и double точно не является частью стандарта C. Это результат того, как вызываются функции на современных x86 машинах, и может быть использовано для изящных программистских трюков.

Это не моя сигнатура

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

По крайней мере, мне объяснили это именно так, но большинство компьютеров сегодня передают первые несколько аргументов прямо в регистры CPU. Таким образом функции не понадобиться читать из стека, что гораздо медленнее регистров.

Количество и расположение регистров, используемых для аргументов функций зависит от соглашения о вызовах(calling convention). У Windows одно соглашение — четыре регистра для значений с плавающей точкой и четыре регистра для указателей и целых чисел. у Unix другое соглашение, называющееся соглашение System V. В нём для аргументов с плавающей точкой предназначено восемь регистров и еще шесть — для указателей и целых чисел. (Если аргументы не влазят в регистры, то они отправляют по старому на стек.)

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

Но и в Windows, и в Unix, базовый алгоритм работает так:

  • Аргументы с плавающей точкой расположены, по порядку, в регистрах SSE, обозначенных XMM0, XMM1 и т.д.
  • Целые и указатели расположены, по порядку, в регистрах общего назначения, обозначенных RDX, RCX и т.д.

Давайте посмотрим, как передаются аргументы функции DoubleToTheInt.

Сигнатура функции такова:

  double  DoubleToTheInt(double base, int power);

Когда компилятор встречает DoubleToTheInt(0.99, 100), он располагает регистры так:

RDX RCX R8 R9
100 ??? ??? ???
XMM0 XMM1 XMM2 XMM3
0.99 ??? ??? ???

(Для простоты, я использую соглашение о вызовах Windows.) Если бы взамен была такая функция:

  double  DoubleToTheDouble(double base, double power);

Аргументы были бы расположены так:

RDX RCX R8 R9
??? ??? ??? ???
XMM0 XMM1 XMM2 XMM3
0.99 100 ??? ???

Теперь вы, возможно, догадались, почему маленьких фокус из начала статьи работает. Рассмотрим следующую сигнатуру функции:

  double IntPowerOfDouble(int y, double x);

Вызывая IntPowerOfDouble(100, 0.99), компилятор расположит регистры так:

RDX RCX R8 R9
100 ??? ??? ???
XMM0 XMM1 XMM2 XMM3
0.99 ??? ??? ???

Другими словами, точно так же, как в DoubleToTheInt(0.99, 100)!
Из-за того, что скомпилированная функция понятия не имеет, как она была вызвана — только где в регистрах и на стеке ожидать свои аргументы — мы можем вызвать функцию с другим порядком аргументов приведя указатель на функцию к неверной (но ABI-совместимой) сигнатуре функции.

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

double functionA(double a, double b, float c, int x, int y, int z);

будет такое же расположение регистров, как и у:

double functionB(int x, double a, int y, double b, int z, float c);

и такое же, как у:

double functionC(int x, int y, int z, double a, double b, float c);

Во всех трех случаях в регистрах будет:

RDX RCX R8 R9
int x int y int z ???
XMM0 XMM1 XMM2 XMM3
double a double b double c ???

Обратите внимание, что и аргументы двойной, и аргументы одинарной точности занимают регистры XMM — но они не ABI-совместимы друг с другом. Поэтому, если вы помните второй пример кода в самом начале, причина по которой FloatPowerOfDouble вернул ноль (а не 95.5) в том, что компилятор расположил значение одинарной точности (32-битное) 100.0 в XMM0, и значение двойной точности (64-битное) 0.99 в XMM1 — но вызываемая функция ожидала число двойной-точности в XMM0 и одинарной в XMM1. Из-за этого, экспонента притворилась мантиссой, биты мантиссы были обрезаны или приняты за экспоненту, и функция FloatPowerOfDouble возвела Очень Маленькое Число в степень Очень Большого Числа, получив ноль. Загадка решена.

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

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

Наберите 1-800-I-Really-Enjoy-Type-Punning

Попробуйте это:

#include <math.h>
#include <stdio.h>

double  DoubleToTheInt(double x, int y) {
    return pow(x, y);
}

int main() {
    double (*DoubleToTheIntVerbose)(
            double, double, double, double, int, int, int, int) =
    (double (*)(double, double, double, double, int, int, int, int))&DoubleToTheInt;

      printf("(0.99)^100: %lf n", DoubleToTheIntVerbose(
                                   0.99, 0.0, 0.0, 0.0, 100, 0, 0, 0));
      printf("(0.99)^100: %lf n", DoubleToTheInt(0.99, 100));
}

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

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

#include <math.h>
#include <stdio.h>

typedef double (*verbose_func_t)(double, double, double, double, int, int, int, int);

int main() {
    verbose_func_t verboseSin = (verbose_func_t)&sin;
    verbose_func_t verboseCos = (verbose_func_t)&cos;
    verbose_func_t verbosePow = (verbose_func_t)&pow;
    verbose_func_t verboseLDExp = (verbose_func_t)&ldexp;

    printf("Sin(0.5) = %lfn",
        verboseSin(0.5, 0.0, 0.0, 0.0, 0, 0, 0, 0));
    printf("Cos(0.5) = %lfn",
        verboseCos(0.5, 0.0, 0.0, 0.0, 0, 0, 0, 0));
    printf("Pow(0.99, 100) = %lfn",
        verbosePow(0.99, 100.0, 0.0, 0.0, 0, 0, 0, 0));
    printf("0.99 * 2^12 = %lfn",
        verboseLDExp(0.99, 0.0, 0.0, 0.0, 12, 0, 0, 0));
}

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

#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef double (*four_arg_func_t)(double, double, double, double);

int main(int argc, char **argv) {
    four_arg_func_t verboseFunction = NULL;
    if (strcmp(argv[1], "sin") == 0) {
        verboseFunction = (four_arg_func_t)&sin;
    } else if (strcmp(argv[1], "cos") == 0) {
        verboseFunction = (four_arg_func_t)&cos;
    } else if (strcmp(argv[1], "pow") == 0) {
        verboseFunction = (four_arg_func_t)&pow;
    } else {
        return 1;
    }
    double xmm[4];
    int i;
    for (i=2; i<argc; i++) {
        xmm[i-2] = strtod(argv[i], NULL);
    }

    printf("%lfn", verboseFunction(xmm[0], xmm[1], xmm[2], xmm[3]));
    return 0;
}

Проверяем:

emiller@gibbon ~> clang calc.c
emiller@gibbon ~> ./a.out pow 0.99 100
0.366032
emiller@gibbon ~> ./a.out sin 0.5
0.479426
emiller@gibbon ~> ./a.out cos 0.5
0.877583

Не совсем конкурент Mathematica, но можно представить более сложную версию с таблицей имен функций и соответсвующих им указателей на функцию — для добавления новой функции достаточно обновить таблицу, а не явно вызывать новую функцию в коде.

Другое применение включает JIT компиляторы. Если вы когда-нибудь занимались по туториалу LLVM, вы могли неожиданно встретить сообщение:

"Full-featured argument passing not supported yet!"

LLVM искусно превращает код в машинные коды и загружает машинные коды в память, но не очень гибок, если нужно вызвать загруженную в память функцию. С помощью LLVMRunFunction, вы можете вызывать main()-подобные функции (целый аргумент, аргумент-указатель, аргумент-указатель, возвращает целое), но не многое другое. Большинство туториалов рекомендует обернуть вашу функцию компилятора функцией похожей на main(), пряча все ваши аргументы за аргументом-указателем, и использовать обертку чтобы вытянуть аргументы из указателя и вызвать настоящую функцию.

Но с нашими новыми знаниями о регистрах X86, мы можем упростить церемонию, избавившись от функции-обертки во многих случаях. Вместо того, чтобы проверять, что функция пренадлежит к ограниченному списку C-callable сигнатур функций (int main(), int main(int), int main(int, void *) и т.д.), мы можем создать указатель, сигнатура которго заполняет все регистры параметров и, следовантельно, совместима со всеми функциями, которые передают аргументы только через регистры, и вызывать их, передавая ноль (или что угодно) для неиспользуемых аргументов. Нам надо всего лишь определить отдельный тип для каждого возвращаемого типа, а не для каждой возможной сигнатуры функции, и более гибко вызывать функции с помощью способа, который в другом случае потребовал бы использование ассемблера.

Я покажу вам последний фокус перед тем, как закрыть лавочку. Попробуйте разобраться как работает это код:

double NoOp(double a) {
    return a;
}

int main() {
    double (*ReturnLastReturnValue)() = (double (*)())&NoOp;
    double value = pow(0.99, 100.0);
    double other_value = ReturnLastReturnValue();
    printf("Value: %lf   Other value: %lfn" value, other_value);
}

(Вам стоит для начала прочитать ваше соглашение о вызовах...)

Теория переводчика

Функция возвращает результат через XMM0. Между двумя функциями ничего не происходит, и в XMM0 остается результат последней функции, который NoOp подхватывает как аргумент и возвращает.

Требуется немного ассемблера

Если вы когда-нибудь спросите на форуме программистов об ассемблере, обычным ответом будет: Тебе не нужен ассемблер — оставь его для гениальных докторов наук, которые пишут компиляторы. Да, держи пожалуйста руки на виду.

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

Но это лишь самая вершина айсберга программирования на ассемблере — специально представленная без единой строчки кода на ассемблере — и я советую всем, у кого есть время, поглубже окунуться в эту тему. Ассемблер — ключ к пониманию, как CPU занимается исполнением инструкций — что такое счетчик команд, что такое указатель фрейма, что такое указатель стека, что делают регистры — и позволяет вам посмотреть на программы в другом (более ярком) свете. Даже базовые знания могут помочь вам придумать решения, которые в ином случае даже не пришли бы вам в голову и понять что к чему, когда вы проскользнете мимо тюремных надзирателей своего любимого языка высокого уровня и будете щуриться на суровое, прекрасное солнце.

Автор: Randl

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js