Почему целочисленное деление на -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 просто выводит то же, что и Code 1 без каких-либо исключений. Является ли это скрытой функцией ошибка для процессора x86?

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


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

Ответ 1

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

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

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

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

gcc без параметров совпадает с gcc -O0.

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

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

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

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

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;, с x86 neg eax или AArch64 neg w0, w0, окруженный загрузкой (a)/store (c). На ARM32 это a rsb r3, r3, #0 (reverse-subtract: r3 = 0 - r3).

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


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 quotient+. Как указано в руководстве Intel по эксплуатации, idiv вызывает #DE:

  • divisor = 0
  • Записанный результат (quotient) слишком большой для адресата.

Переполнение может легко произойти, если вы используете полный диапазон делителей, например. для 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.)


C Undefined Поведение.

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


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

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

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

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

Undefined Поведение действительно полностью undefined. Компиляторы могут делать все, что захотят, включая возврат любого мусора в eax при вводе функции или загрузку указателя NULL и незаконной инструкции. например с 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 о Undefined Поведение (то же сообщение блога LLVM, что и Basile).

Ответ 2

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

  • делитель равен нулю, OR
  • дивиденд INT_MIN (== 0x80000000, если int есть int32_t), а делитель -1 (в двух дополнениях, -INT_MIN > INT_MAX, который вызывает целочисленное переполнение, которое является undefined поведением в C)

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

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

Ответ 3

С undefined поведение очень плохое может случиться, а иногда они действительно происходят.

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

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

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

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

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

Ответ 4

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

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

Ответ 5

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

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

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

Конкретно, 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

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

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

ПРИМЕЧАНИЕ 1

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