Ответ 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).