Здесь происходит четыре вещи:
gcc -O0
поведение объясняет разницу между вашими двумя версиями: idiv
vs. neg
. (Пока clang -O0
компилирует их оба с idiv
). И почему вы получаете это даже с операндами, постоянными во времени компиляции.
x86 idiv
поведение при сбое в сравнении с поведением инструкции деления на ARM
Если целочисленная математика приводит к доставке сигнала, POSIX требует, чтобы он был SIGFPE: На каких платформах целочисленное деление на ноль вызывает исключение с плавающей запятой? Но POSIX не требует перехвата для каких-либо конкретных целочисленная операция. (Вот почему для x86 и ARM разрешено отличаться).
Спецификация единой Unix определяет SIGFPE как ошибочную арифметическую операцию. Он назван в честь с плавающей запятой, что сбивает с толку, но в обычной системе с FPU в состоянии по умолчанию только целочисленные математические вычисления будут повышать его. На x86 только целочисленное деление. На MIPS компилятор может использовать add
вместо addu
для математических вычислений со знаком, чтобы вы могли получить ловушки при подписанном добавлении переполнения. ( gcc использует addu
даже для подписанных, но детектор неопределенного поведения может использовать add
.)
C Неопределенные правила поведения (подписанное переполнение и, в частности, деление), которые позволяют gcc генерировать код, который в этом случае может перехватить.
gcc без параметров совпадает с gcc -O0
.
-O0
Сократите время компиляции и заставьте отладку давать ожидаемые результаты. Это значение по умолчанию.
Это объясняет разницу между вашими двумя версиями:
Мало того, что gcc -O0
не пытается оптимизировать, он активно деоптимизирует, чтобы сделать asm, который независимо реализует каждый оператор C в функции. Это позволяет jump
команде gdb
использовать работать безопасно, позволяя вам перейти на другую строку внутри функции и действовать так, как будто вы действительно прыгаете в исходном коде C. Почему clang создает неэффективный asm с -O0 (для этой простой суммы с плавающей запятой)? объясняет, как и почему -O0
компилируется именно так.
Он также не может ничего предполагать о значениях переменных между операторами, потому что вы можете изменять переменные с помощью 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;
, с инструкцией x86 neg eax
или AArch64 neg w0, w0
, окруженной загрузка (а) / магазин (в). В ARM32 это rsb r3, r3, #0
(обратное вычитание: r3 = 0 - r3
).
Однако clang5.0 -O0
не выполняет эту оптимизацию. Он по-прежнему использует idiv
для a / -1
, поэтому обе версии будут давать сбой на x86 с лязгом. Почему 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
, нет двух операндов 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]
Команды аппаратного деления ARM не вызывают исключения для деления на ноль или для INT_MIN/-1
переполнения. Нейт Элдридж прокомментировал:
В полном справочном руководстве по архитектуре ARM указано, что UDIV или SDIV при делении на ноль просто возвращают ноль в качестве результата без каких-либо указаний на то, что произошло деление на ноль (C3.4.8 в версии Armv8-A). Без исключений и без флагов - если вы хотите поймать деление на ноль, вам нужно написать явный тест. Аналогично, знаковое деление INT_MIN
на -1
возвращает INT_MIN
без указания переполнения.
AArch64 sdiv
документация не упоминает никаких исключений.
Однако программные реализации целочисленного деления могут вызывать: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka4061.html. (gcc по умолчанию использует библиотечный вызов для деления на ARM32, если вы не установили -mcpu, у которого есть HW-деление.)
C Неопределенное поведение.
Как объясняет PSkocik, INT_MIN
/ -1
- неопределенное поведение в C, как и любое целочисленное переполнение со знаком. Это позволяет компиляторам использовать инструкции аппаратного разделения на машинах, таких как x86, без проверки этого особого случая. Если бы это было не ошибкой, неизвестные входные данные потребовали бы сравнения во время выполнения и Branch проверяет, и никто не хочет, чтобы C требовал этого.
Подробнее о последствиях УБ:
При включенной оптимизации компилятор может предположить, что 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, на который ссылается Базиль).
person
Peter Cordes
schedule
23.09.2017
SIGFPE
- person Basile Starynkevitch   schedule 23.09.2017