¿Por qué la división de enteros por-1(negativa) da como resultado FPE?
gcc x86 (5)
Ambos casos son raros, ya que el primero consiste en dividir
-2147483648
por
-1
y debe dar
2147483648
, y no el resultado que está recibiendo.
0x80000000
no es un número
int
válido en una arquitectura de 32 bits que representa números en el complemento a dos.
Si calcula su valor negativo, volverá a hacerlo, ya que no tiene un número opuesto alrededor de cero.
Cuando hace aritmética con enteros con signo, funciona bien para la suma y resta de enteros (siempre con cuidado, ya que es bastante fácil desbordar, cuando agrega el valor más grande a algún int) pero no puede usarlo con seguridad para multiplicar o dividir.
Entonces, en este caso, estás invocando
Comportamiento indefinido
.
Siempre se invoca un comportamiento indefinido (o un comportamiento definido de implementación, que es similar, pero no el mismo) en el desbordamiento de enteros con signo, ya que las implementaciones varían ampliamente en la implementación de eso.
Trataré de explicar lo que puede estar sucediendo (sin confianza), ya que el compilador es libre de hacer cualquier cosa, o nada en absoluto.
Concretamente,
0x80000000
como se representa en el complemento a dos es
1000_0000_0000_0000_0000_0000_0000
si complementamos este número, obtenemos (primero complementamos todos los bits, luego agregamos uno)
0111_1111_1111_1111_1111_1111_1111 + 1 =>
1000_0000_0000_0000_0000_0000_0000 !!! the same original number.
sorprendentemente el mismo número ... Tuviste un desbordamiento (no hay un valor positivo de contraparte en este número, ya que nos desbordamos al cambiar el signo), luego sacaste el bit de signo, enmascarando con
1000_0000_0000_0000_0000_0000_0000 &
0111_1111_1111_1111_1111_1111_1111 =>
0000_0000_0000_0000_0000_0000_0000
cuál es el número que usa como divisor, lo que lleva a una división por cero excepción.
Pero como dije antes, esto es lo que puede estar sucediendo en su sistema, pero no estoy seguro, ya que el estándar dice que este es un comportamiento Indefinido y, por lo tanto, puede obtener cualquier comportamiento diferente de su computadora / compilador.
NOTA 1
En lo que respecta al compilador, y el estándar no dice nada sobre los rangos válidos de
int
que deben implementarse (el estándar normalmente no incluye
0x8000...000
en arquitecturas complementarias de dos) el comportamiento correcto de
0x800...000
en arquitecturas complementarias de dos deben ser, ya que tiene el valor absoluto más grande para un entero de ese tipo, para dar un resultado de
0
al dividir un número por él.
Pero las implementaciones de hardware normalmente no permiten dividir por tal número (ya que muchas de ellas ni siquiera implementan división entera con signo, sino que lo simulan desde una división sin signo, por lo que muchas simplemente extraen los signos y hacen una división sin signo) Eso requiere un compruebe antes de la división, y como el estándar dice
Comportamiento indefinido
, las implementaciones pueden evitar libremente dicha comprobación y no permiten dividir por ese número.
Simplemente seleccionan el rango entero para ir de
0x8000...001
a
0xffff...fff
, y luego de
0x000..0000
a
0x7fff...ffff
, rechazando el valor
0x8000...0000
como no válido.
Tengo la tarea de expandir algunos comportamientos aparentemente extraños del código C (ejecutándose en x86). Puedo completar fácilmente todo lo demás, pero este realmente me ha confundido.
Fragmento de código 1 salidas
-2147483648
int a = 0x80000000; int b = a / -1; printf("%d/n", b);
El fragmento de código 2 no genera nada y proporciona una
Floating point exception
int a = 0x80000000; int b = -1; int c = a / b; printf("%d/n", c);
Sé muy bien el motivo del resultado del
1 + ~INT_MIN == INT_MIN
de código 1 (
1 + ~INT_MIN == INT_MIN
), pero no puedo entender cómo puede la división de enteros por -1 generar FPE, ni puedo reproducirlo en mi teléfono Android (AArch64 , CCG 7.2.0).
El código 2 solo genera el mismo código que el 1 sin ninguna excepción.
¿Es una característica de
error
oculto del procesador x86?
La asignación no dijo nada más (incluida la arquitectura de la CPU), pero dado que todo el curso se basa en una distribución de Linux de escritorio, puede asumir con seguridad que es un x86 moderno.
Editar : Me puse en contacto con mi amigo y él probó el código en Ubuntu 16.04 (Intel Kaby Lake, GCC 6.3.0). El resultado fue consistente con lo que se indicara en la asignación (el Código 1 emitió dicho mensaje y el Código 2 se bloqueó con FPE).
Aquí hay cuatro cosas:
-
gcc -O0
comportamientogcc -O0
explica la diferencia entre sus dos versiones. (Mientrasclang -O0
sucede que los compila a ambos conidiv
). Y por qué obtienes esto incluso con operandos constantes de tiempo de compilación. -
x86 comportamiento de fallas de
idiv
versus comportamiento de la instrucción de división en ARM -
Si la matemática entera da como resultado que se entregue una señal, POSIX requiere que sea SIGFPE: ¿ En qué plataformas el número entero dividido por cero activa una excepción de punto flotante? Pero POSIX no requiere captura para ninguna operación entera en particular. (Es por eso que está permitido que x86 y ARM sean diferentes).
La especificación Single Unix define SIGFPE como "Operación aritmética errónea ". Es confusamente llamado así por punto flotante, pero en un sistema normal con la FPU en su estado predeterminado, solo las matemáticas enteras lo elevarán. En x86, solo división entera. En MIPS, un compilador podría usar
add
lugar deaddu
para matemática firmada, por lo que podría obtener trampas en el desbordamiento de add firmado. ( gcc usaaddu
incluso para firmado , pero un detector de comportamiento indefinido podría usaradd
). - C Reglas de comportamiento indefinido (desbordamiento firmado y división específicamente) que permiten a gcc emitir código que puede atrapar en ese caso.
gcc sin opciones es lo mismo que
gcc -O0
.
-O0
Reduzca el tiempo de compilación y haga que la depuración produzca los resultados esperados . Este es el valor predeterminado.
Esto explica la diferencia entre sus dos versiones:
gcc -O0
no
gcc -O0
no intenta optimizar, sino que también se
des-optimiza
activamente para hacer un asm que implementa de manera independiente cada instrucción C dentro de una función.
Esto permite que el
comando de
jump
gdb
funcione de manera segura, permitiéndole saltar a una línea diferente dentro de la función y actuar como si realmente estuviera saltando en la fuente C.
Tampoco puede suponer nada sobre los valores de las variables entre las declaraciones, porque puede cambiar las variables con el
set b = 4
.
Obviamente, esto es catastróficamente malo para el rendimiento, por lo que el código
-O0
ejecuta varias veces más lento que el código normal, y por qué la
optimización para
-O0
específicamente no tiene sentido
.
También hace que la salida de
-O0
asm sea
realmente ruidosa y difícil de leer para un humano
, debido a todo el almacenamiento / recarga y la falta de las optimizaciones más obvias.
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);
Puse su código dentro de las funciones en el explorador del compilador Godbolt para obtener el asm para esas declaraciones.
Para evaluar
a/b
,
gcc -O0
tiene que emitir código para recargar
b
de la memoria, y no hacer suposiciones sobre su valor.
Pero
con
int c = a / -1;
, no puede cambiar el
-1
con un depurador
, por lo que gcc puede implementar esa declaración de la misma manera que implementaría
int c = -a;
, con una instrucción x86
neg eax
o AArch64
neg w0, w0
, rodeada por una carga (a) / store (c).
En ARM32, es un
rsb r3, r3, #0
(resta inversa:
r3 = 0 - r3
).
Sin embargo, clang5.0
-O0
no hace esa optimización.
Todavía usa
idiv
para
a / -1
, por lo que ambas versiones
idiv
en x86 con clang.
¿Por qué "optimiza" gcc?
Consulte
Deshabilitar todas las opciones de optimización en GCC
.
gcc siempre se transforma a través de una representación interna, y -O0 es solo la cantidad mínima de trabajo necesaria para producir un binario.
No tiene un modo "tonto y literal" que intente hacer que el asm se parezca tanto a la fuente como sea posible.
x86
idiv
vs 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
A diferencia de
imul r32,r32
, no hay
idiv
2 operandos que no tenga una entrada de dividendo en la mitad superior.
De todos modos, no es que importe;
gcc solo lo está usando con
edx
= copias del bit de signo en
eax
, por lo que realmente está haciendo un cociente 32b / 32b => 32b + resto.
idiv
,
idiv
plantea #DE en:
- divisor = 0
- El resultado firmado (cociente) es demasiado grande para el destino.
El desbordamiento puede ocurrir fácilmente si usa el rango completo de divisores, por ejemplo, para
int result = long long / int
con una sola división 64b / 32b => 32b.
Pero gcc no puede hacer esa optimización porque no está permitido hacer código que fallará en lugar de seguir las reglas de promoción de enteros C y hacer una división de 64 bits y
luego
truncar a
int
.
Tampoco
se optimiza incluso en los casos en que se sabe que el divisor es lo suficientemente grande como para no poder
#DE
Al realizar la división 32b / 32b (con
cdq
), la única entrada que puede desbordarse es
INT_MIN / -1
.
El cociente "correcto" es un entero con signo de 33 bits, es decir, un
0x80000000
positivo con un bit de signo de cero
0x80000000
para convertirlo en un entero con signo de complemento positivo de 2.
Como esto no cabe en
eax
,
idiv
genera una excepción
#DE
.
El núcleo luego entrega
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, las instrucciones de división de hardware ARM no generan excepciones para dividir entre cero o INT_MIN / -1. O al menos, algunas CPU ARM no lo hacen. dividir por cero excepción en el procesador ARM OMAP3515
La documentación de AArch64
sdiv
no menciona ninguna excepción.
Sin embargo, las implementaciones de software de la división de enteros pueden generar: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka4061.html . (gcc usa una llamada a la biblioteca para la división en ARM32 de manera predeterminada, a menos que establezca un -mcpu que tenga división HW).
C Comportamiento indefinido.
Como
explica PSkocik
,
INT_MIN
/
-1
es un comportamiento indefinido en C, como todo desbordamiento de entero con signo.
Esto permite a los compiladores usar instrucciones de división de hardware en máquinas como x86 sin verificar ese caso especial.
Si no tuviera que fallar, las entradas desconocidas requerirían comparaciones de tiempo de ejecución y verificación de ramales, y nadie quiere que C lo requiera.
Más sobre las consecuencias de UB:
Con la optimización habilitada
, el compilador puede asumir que
a
y
b
todavía tienen sus valores establecidos cuando se ejecuta a
a/b
.
Luego puede ver que el programa tiene un comportamiento indefinido y, por lo tanto, puede hacer lo que quiera.
gcc elige producir
INT_MIN
como lo haría desde
-INT_MIN
.
En un sistema de complemento a 2, el número más negativo es su propio negativo.
Este es un caso de esquina desagradable para el complemento de 2, porque significa que
abs(x)
aún puede ser negativo.
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;
}
compila esto con
gcc6.3 -O3
para x86-64
x86_fault:
mov eax, -2147483648
ret
pero
clang5.0 -O3
compila a (sin advertencia incluso con -Wall -Wextra`):
x86_fault:
ret
El comportamiento indefinido realmente es totalmente indefinido.
Los compiladores pueden hacer lo que quieran, incluyendo devolver cualquier basura que haya estado en
eax
en la entrada de la función, o cargar un puntero NULL y una instrucción ilegal.
Por ejemplo, con gcc6.3 -O3 para 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
Su caso con
-O0
no permitió que los compiladores vieran el UB en el momento de la compilación, por lo que obtuvo la salida asm "esperada".
Vea también Lo que todo programador de C debe saber sobre el comportamiento indefinido (la misma publicación de blog LLVM que Basile enlazó).
Con un comportamiento indefinido pueden pasar cosas muy bad , y a veces suceden.
Tu pregunta no tiene sentido en C (lee
Lattner en UB
).
Pero podría obtener el código del ensamblador (por ejemplo, producido por
gcc -O -fverbose-asm -S
) y preocuparse por el comportamiento del código de máquina.
En x86-64 con desbordamiento de enteros de Linux (y también división de enteros por cero, IIRC) emite una señal
SIGFPE
.
Ver
signal(7)
Por cierto, en PowerPC la división de enteros por cero se rumorea que da -1 a nivel de máquina (pero algunos compiladores de C generan código adicional para probar ese caso).
El código en su pregunta es un comportamiento indefinido en C. El código ensamblador generado tiene un comportamiento definido (depende del ISA y el procesador).
(la tarea se hace para hacerte leer más sobre UB, especialmente el blog de Lattner , que deberías leer absolutamente )
En x86, si divide
utilizando
la operación
idiv
(que no es realmente necesaria para argumentos constantes, ni siquiera para variables que se sabe que son constantes, pero sucedió de todos modos),
INT_MIN / -1
es uno de los casos que da como resultado #DE (error de división).
Es realmente un caso especial de cociente fuera de rango, en general eso es posible porque
idiv
divide un dividendo extra ancho por el divisor, por lo que muchas combinaciones causan desbordamiento, pero
INT_MIN / -1
es el único caso que no es un div-by-0 al que normalmente puede acceder desde idiomas de nivel superior, ya que generalmente no exponen las capacidades de dividendos extra anchos.
Linux molestamente asigna el #DE a SIGFPE, lo que probablemente ha confundido a todos los que lo trataron la primera vez.
La división
int
firmada en el complemento a dos no está definida si:
- el divisor es cero, O
-
el dividendo es
INT_MIN
(==0x80000000
siint
esint32_t
) y el divisor es-1
(en el complemento de dos,-INT_MIN > INT_MAX
, que causa un desbordamiento de enteros, que es un comportamiento indefinido en C)
( https://www.securecoding.cert.org recomienda envolver operaciones de enteros en funciones que verifican estos casos límite)
Como está invocando un comportamiento indefinido al infringir la regla 2, cualquier cosa puede suceder y, como sucede, esta cosa en particular en su plataforma pasa a ser una señal FPE generada por su procesador.