Почему целочисленное деление на -1 (отрицательное) приводит к FPE?

У меня есть задание объяснять некоторые, казалось бы, странные поведения кода C (работающего на x86). Я могу легко закончить все остальное, но этот действительно смутил меня.

Выводы кода 1 -2147483648

int a = 0x80000000;
int b = a / -1;
printf("%d\n", b);

Фрагмент кода 2 ничего не выводит и выдает Floating point exception

int a = 0x80000000;
int b = -1;
int c = a / b;
printf("%d\n", c);

Я хорошо знаю причину результата Code Snippet 1 (1 + ~INT_MIN == INT_MIN), но я не совсем понимаю, как целочисленное деление на -1 может генерировать FPE, и при этом я не могу воспроизвести его на своем телефоне Android (AArch64, GCC 7.2.0). Код 2 просто выводит то же самое, что и Код 1, без каких-либо исключений. Это скрытая ошибка в процессоре x86?

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


Изменить: я связался с моим другом, и он проверил код на Ubuntu 16.04 (Intel Kaby Lake, GCC 6.3.0). Результат соответствовал тому, что указано в назначении (код 1 выдает указанную вещь, а код 2 аварийно завершает работу с FPE).

5 ответов

Решение

Здесь происходит четыре вещи:

  • gcc -O0 поведение объясняет разницу между вашими двумя версиями. (В то время как clang -O0 случается, чтобы собрать их обоих с idiv). И почему вы получаете это даже с операндами времени компиляции.
  • x86 idiv ошибочное поведение против поведения инструкции деления на ARM
  • Если целочисленная математика приводит к доставке сигнала, POSIX требует, чтобы он был SIGFPE: На каких платформах целочисленное деление на ноль вызывает исключение с плавающей запятой? Но POSIX не требует перехвата для какой-либо конкретной целочисленной операции. (Именно поэтому разрешено отличаться для x86 и ARM).

    Спецификация Single Unix определяет SIGFPE как "Ошибочная арифметическая операция". Название с плавающей точкой вводит в заблуждение, но в обычной системе с FPU в состоянии по умолчанию, только целочисленная математика поднимет его. На x86 только целочисленное деление. На MIPS компилятор может использовать add вместо addu для подписанной математики, так что вы можете получить ловушки при переполнении подписанной надстройки. ( GCC использует addu даже для подписанного, но детектор с неопределенным поведением может использовать add.)

  • C Неопределенные правила поведения (переполнение со знаком и, в частности, деление), которые позволяют gcc испускать код, который может в этом случае перехватываться.

gcc без параметров такой же как gcc -O0,

-O0 Сократите время компиляции и сделайте отладку ожидаемым результатом. Это по умолчанию.

Это объясняет разницу между вашими двумя версиями:

Не только делает gcc -O0 не пытаясь оптимизировать, он активно де-оптимизирует, чтобы создать asm, который независимо реализует каждый оператор C внутри функции. Это позволяет gdb "s jump команда работать безопасно, позволяя вам перейти на другую строку в функции и действовать так, как будто вы действительно прыгаете в источнике Си.

Он также не может предполагать значения переменных между операторами, потому что вы можете изменять переменные с помощью set b = 4, Это явно катастрофически плохо для производительности, поэтому -O0 код работает в несколько раз медленнее, чем обычный код, и почему оптимизация для -O0 конкретно это полная ерунда. Это также делает -O0 Вывод в asm действительно шумный и трудный для чтения человеком из-за всех операций сохранения / перезагрузки и отсутствия даже самых очевидных оптимизаций.

int a = 0x80000000;
int b = -1;
  // debugger can stop here on a breakpoint and modify b.
int c = a / b;        // a and b have to be treated as runtime variables, not constants.
printf("%d\n", c);

Я поместил ваш код в функции в проводнике компилятора Godbolt, чтобы получить asm для этих операторов.

Оценить a/b, gcc -O0 должен выдать код для перезагрузки a а также b по памяти, и не делать никаких предположений об их значении.

Но с int c = a / -1; Вы не можете изменить -1 с помощью отладчика, поэтому gcc может и выполняет этот оператор так же, как он будет реализовывать int c = -a; с х86 neg eax или AArch64 neg w0, w0 инструкция, окруженная грузом (а)/ магазином (с). На ARM32 это rsb r3, r3, #0 (Обратное вычитание: r3 = 0 - r3).

Тем не менее, Clang5.0 -O0 не делает эту оптимизацию. Он все еще использует idiv за a / -1так что обе версии выйдут из строя на x86 с clang. Почему gcc вообще "оптимизирует"? См. Отключить все параметры оптимизации в GCC. gcc всегда преобразуется через внутреннее представление, а -O0 - это минимальный объем работы, необходимый для создания двоичного файла. У него нет "тупого и буквального" режима, который пытается сделать ассм как можно больше похожим на источник.


x86 idiv против AArch64 sdiv:

x86-64:

    # int c = a / b  from x86_fault()
    mov     eax, DWORD PTR [rbp-4]
    cdq                                 # dividend sign-extended into edx:eax
    idiv    DWORD PTR [rbp-8]           # divisor from memory
    mov     DWORD PTR [rbp-12], eax     # store quotient

В отличие от imul r32,r32 нет 2-операнда idiv который не имеет дивидендов в верхней половине ввода. Во всяком случае, это не так важно; GCC использует его только с edx = копии знака бит в eax так что на самом деле он делает 32b / 32b => 32b частное + остаток. Как указано в руководстве Intel, idiv поднимает #DE на:

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

Переполнение может легко произойти, если вы используете полный диапазон делителей, например, для int result = long long / int с одним делением 64b / 32b => 32b. Но gcc не может выполнить эту оптимизацию, потому что не разрешается создавать код с ошибкой вместо того, чтобы следовать правилам целочисленного продвижения в C и выполнять 64-битное деление, а затем усекать до int, Он также не оптимизируется даже в тех случаях, когда делитель, как известно, достаточно большой, чтобы он не мог #DE

При делении 32b / 32b (с cdq), единственный вход, который может переполниться INT_MIN / -1, "Правильным" частным является 33-разрядное целое число со знаком, т.е. 0x80000000 с битом начального нуля, чтобы сделать его положительным целым числом со знаком со знаком 2. Так как это не вписывается в eax, idiv поднимает #DE исключение. Ядро затем доставляет SIGFPE,

AArch64:

    # int c = a / b  from x86_fault()  (which doesn't fault on AArch64)
    ldr     w1, [sp, 12]
    ldr     w0, [sp, 8]          # 32-bit loads into 32-bit registers
    sdiv    w0, w1, w0           # 32 / 32 => 32 bit signed division
    str     w0, [sp, 4]

AFAICT, инструкции аппаратного деления ARM не вызывают исключений для деления на ноль или для INT_MIN/-1. Или, по крайней мере, некоторые процессоры ARM этого не делают. делим на ноль исключение в процессоре ARM OMAP3515

AArch64 sdiv документация не упоминает никаких исключений.

Тем не менее, программные реализации целочисленного деления могут вызвать: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka4061.html. (gcc использует библиотечный вызов для деления на ARM32 по умолчанию, если вы не установили -mcpu с делением HW.)


С неопределенным поведением.

Как объясняет PSkocik, INT_MIN / -1 является неопределенным поведением в C, как и все целочисленные переполнения со знаком. Это позволяет компиляторам использовать инструкции аппаратного деления на машинах, таких как x86, без проверки этого особого случая. Если бы это не было ошибкой, неизвестные входные данные потребовали бы проверки во время выполнения и проверки ветвления, и никто не хочет, чтобы C требовал этого.


Подробнее о последствиях UB:

При включенной оптимизации компилятор может предположить, что a а также b все еще имеют свои установленные значения, когда a/b пробеги. Затем он может видеть, что программа имеет неопределенное поведение, и, следовательно, может делать все, что захочет. GCC выбирает производить INT_MIN как это было бы от -INT_MIN,

В системе дополнения 2 наиболее отрицательное число является его собственным отрицательным числом. Это неприятный угловой футляр для дополнения 2, потому что это означает abs(x) все еще может быть отрицательным. https://en.wikipedia.org/wiki/Two%27s_complement

int x86_fault() {
    int a = 0x80000000;
    int b = -1;
    int c = a / b;
    return c;
}

скомпилировать это с gcc6.3 -O3 для x86-64

x86_fault:
    mov     eax, -2147483648
    ret

но clang5.0 -O3 компилируется в (без предупреждения даже с -Wall -Wextra`):

x86_fault:
    ret

Неопределенное поведение на самом деле совершенно не определено. Компиляторы могут делать все что угодно, в том числе возвращать мусор, который был в eax при вводе функции или загрузке нулевого указателя и недопустимой инструкции. например, с gcc6.3 -O3 для x86-64:

int *local_address(int a) {
    return &a;
}

local_address:
    xor     eax, eax     # return 0
    ret

void foo() {
    int *p = local_address(4);
    *p = 2;
}

 foo:
   mov     DWORD PTR ds:0, 0     # store immediate 0 into absolute address 0
   ud2                           # illegal instruction

Ваш случай с -O0 не позволил компиляторам видеть UB во время компиляции, поэтому вы получили "ожидаемый" вывод asm.

См. Также " Что должен знать каждый программист C о неопределенном поведении" (то же сообщение в блоге LLVM, на которое ссылается Basile).

Подписанный int деление на два дополнения не определено, если:

  1. делитель ноль, ИЛИ
  2. дивиденд INT_MIN (==0x80000000 если int является int32_t) и делитель -1 (в дополнение к двум,-INT_MIN > INT_MAX, что вызывает целочисленное переполнение, что является неопределенным поведением в C)

( https://www.securecoding.cert.org рекомендует включать целочисленные операции в функции, которые проверяют такие крайние случаи)

Поскольку вы вызываете неопределенное поведение, нарушая правило 2, может произойти все что угодно, и когда это происходит, именно это что-то на вашей платформе оказывается сигналом FPE, генерируемым вашим процессором.

На x86, если вы делите, фактически используя операцию idiv (которая на самом деле не нужна для постоянных аргументов, даже для переменных, которые известны как постоянные, но в любом случае это произошло), INT_MIN / -1 это один из случаев, который приводит к #DE (ошибка деления). Это действительно частный случай, когда частное находится вне диапазона, в общем, это возможно, потому что idiv делит очень широкий дивиденд на делитель, поэтому многие комбинации вызывают переполнение - но INT_MIN / -1 это единственный случай, который не является делителем на 0, к которому обычно можно получить доступ из языков более высокого уровня, поскольку они, как правило, не предоставляют возможности сверхширокого дивиденда.

Linux досадно отображает #DE в SIGFPE, что, вероятно, смутило всех, кто имел дело с этим в первый раз.

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

Ваш вопрос не имеет смысла в C (читайте Lattner на UB). Но вы можете получить код ассемблера (например, произведенный gcc -O -fverbose-asm -S) и заботиться о поведении машинного кода.

На x86-64 с Linux целочисленное переполнение (а также целочисленное деление на ноль, IIRC) дает SIGFPE сигнал. См сигнал (7)

Кстати, по PowerPC целочисленное деление на ноль, по слухам, дает -1 на уровне машины (но некоторые компиляторы C генерируют дополнительный код для проверки этого случая).

Код в вашем вопросе - неопределенное поведение на C. Сгенерированный ассемблерный код имеет определенное поведение (зависит от ISA и процессора).

(задание сделано, чтобы вы узнали больше о UB, особенно о блоге Латтнера, который вы обязательно должны прочитать)

Оба случая странные, так как первый состоит в делении -2147483648 от -1 и должен дать 2147483648, а не результат, который вы получаете.

0x80000000 не является действительным int число в 32-битной архитектуре, которая представляет числа в дополнении до двух. Если вы вычислите его отрицательное значение, вы вернетесь к нему снова, поскольку у него нет противоположного числа вокруг нуля. Когда вы выполняете арифметику со знаковыми целыми числами, она хорошо работает для сложения и вычитания целых чисел (всегда осторожно, поскольку вы легко переполняетесь, когда добавляете наибольшее значение к некоторому целому числу), но вы не можете безопасно использовать его для умножения или деления. Таким образом, в этом случае вы вызываете неопределенное поведение. Вы всегда вызываете неопределенное поведение (или поведение, определяемое реализацией, которое похоже, но не то же самое) при переполнении целыми числами со знаком, так как реализации широко варьируются в реализации этого.

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

В частности, 0x80000000 как представлено в дополнении два

1000_0000_0000_0000_0000_0000_0000

если мы дополним это число, то получим (сначала дополняем все биты, затем добавляем один)

0111_1111_1111_1111_1111_1111_1111 + 1 =>
1000_0000_0000_0000_0000_0000_0000  !!!  the same original number.

Удивительно то же самое число.... У вас было переполнение (нет никакого положительного значения для этого числа, как мы переполняли при смене знака), затем вы вынимаете бит знака, маскируясь с помощью

1000_0000_0000_0000_0000_0000_0000 &
0111_1111_1111_1111_1111_1111_1111 =>
0000_0000_0000_0000_0000_0000_0000

это число, которое вы используете в качестве делителя, что приводит к делению на ноль исключений.

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

ПРИМЕЧАНИЕ 1

Что касается компилятора, и стандарт ничего не говорит о допустимых диапазонах int это должно быть реализовано (стандарт обычно не включает 0x8000...000 в двух дополнительных архитектурах) правильное поведение 0x800...000 в двух архитектурах дополнения должно быть, так как оно имеет наибольшее абсолютное значение для целого числа этого типа, чтобы дать результат 0 при делении числа на него. Но аппаратные реализации обычно не позволяют делить на такое число (так как многие из них даже не реализуют целочисленное деление со знаком, но имитируют его из деления без знака, поэтому многие просто извлекают знаки и делят деление без знака). Это требует проверка перед делением, и, как говорит стандарт, неопределенное поведение, реализациям разрешено свободно избегать такой проверки и запрещать деление на это число. Они просто выбирают целочисленный диапазон 0x8000...001 в 0xffff...fffа затем из 0x000..0000 в 0x7fff...ffff, отвергая значение 0x8000...0000 как недействительный

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