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

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

фрагмент кода 1 выводит -2147483648

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

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

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

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

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


редактировать: I связался с моим другом, и он протестировал код на 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 не require trapping для любой конкретной целочисленной операции. (Вот почему разрешено, чтобы x86 и ARM были разными).

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

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

gcc без опций-это то же самое, что gcc -O0.

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

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

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

он также не может ничего предполагать о значениях переменных между операторами, потому что вы можете изменять переменные с помощью set b = 4. Это, очевидно, катастрофически плохо для производительности, поэтому -O0 код работает в несколько раз медленнее, чем обычный код, и почему?--116-->оптимизация -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;, С x86 neg eax или AArch64 neg w0, w0 инструкция, окруженная грузом (a) / магазином(c). На ARM32 это rsb r3, r3, #0 (реверс-вычесть: 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 фактор + остаток. как описано в руководстве Intel, idiv поднимает #DE на:

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

переполнение может легко произойти, если вы используете полный диапазон делителей, например,int result = long long / int с одиночным разделением 64b / 32b => 32b. Но gcc не может сделать эту оптимизацию, потому что не разрешено делать код, который будет неисправен, вместо того, чтобы следовать правилам продвижения c integer и делать 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 Неопределенное Поведение.

As 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#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

неопределенное поведение действительно совершенно неопределенно. Компиляторы могут делать все, что им нравится, включая возврат любого мусора в 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 is int32_t) и делитель -1 (в дополнение, -INT_MIN > INT_MAX, что вызывает переполнение целого числа, которое является неопределенным поведением в C)

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

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


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

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

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

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

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

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


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

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


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

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

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

конкретно 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 to 0xffff...fff, а затем с 0x000..0000 to 0x7fff...ffff, запрещая значение 0x8000...0000 недействительным.