¿Por qué la división entera por -1 (negativo) resulta en FPE?

Tengo una asignación 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); 

Conozco bien el motivo del resultado del 1 + ~INT_MIN == INT_MIN de código 1 ( 1 + ~INT_MIN == INT_MIN ), pero no entiendo muy bien cómo puede la división entera por -1 generar FPE, ni puedo reproducirlo en mi teléfono Android (AArch64) , GCC 7.2.0). El código 2 solo muestra lo mismo que el código 1 sin excepciones. ¿Es una característica de error oculto del procesador x86?

La tarea no contó nada más (incluida la architecture de la CPU), pero dado que todo el curso se basa en una distribución Linux de escritorio, puede suponer con seguridad que se trata de un x86 moderno.


Editar : Contacté a mi amigo y él probó el código en Ubuntu 16.04 (Intel Kaby Lake, GCC 6.3.0). El resultado fue coherente con lo que indicara la asignación (el código 1 arrojó el resultado y el código 2 se bloqueó con FPE).

Hay cuatro cosas pasando aquí:

  • gcc -O0 comportamiento de gcc -O0 explica la diferencia entre sus dos versiones. (Mientras clang -O0 pasa a comstackr ambos con idiv ). Y por qué obtienes esto incluso con operandos de tiempo de comstackción constante.
  • comportamiento de fallas idiv x86 vs. 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 divide por cero desencadenan una excepción de coma flotante? Pero POSIX no requiere captura para ninguna operación entera en particular. (Esta es la razón por la que está permitido que x86 y ARM sean diferentes).

    La especificación de Unix único define SIGFPE como “operación aritmética errónea “. Es un nombre confuso después del punto flotante, pero en un sistema normal con la FPU en su estado predeterminado, solo la matemática entera lo levantará. En x86, solo división entera. En MIPS, un comstackdor podría usar add lugar de addu para matemática firmada, por lo que podría obtener capturas en overflow con signo. ( gcc usa addu incluso para firmado , pero un detector de comportamiento indefinido podría usar add ).

  • C Reglas de comportamiento indefinido (desbordamiento firmado y división específica) que permiten a gcc emitir código que puede atrapar en ese caso.

gcc sin opciones es lo mismo que gcc -O0 .

-O0 Reduce el tiempo de comstackción y hace que la depuración produzca los resultados esperados . Este es el predeterminado.

Esto explica la diferencia entre tus dos versiones:

gcc -O0 no solo trata de optimizar, sino que también optimiza activamente para crear un asm que implemente de forma independiente cada instrucción C dentro de una función. Esto permite que el comando de jump gdb trabaje de forma segura, permitiéndote saltar a una línea diferente dentro de la función y actuar como si realmente estuvieras saltando en la fuente C.

Tampoco puede asumir nada sobre los valores de las variables entre los enunciados, 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 es una tontería total . También hace que la salida -O0 asm sea realmente ruidosa y difícil de leer para un humano , debido a todo el almacenamiento / recarga, y la falta de incluso 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 el código dentro de las funciones en el explorador del comstackdor Godbolt para obtener el asm para esas declaraciones.

Para evaluar a/b , gcc -O0 tiene que emitir código para volver a cargar 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 y lo implementa de la misma manera que implementaría int c = -a; , con x86 neg eax o AArch64 neg w0, w0 instrucción neg w0, w0 , rodeado por una carga (a) / store (c). En ARM32, es un rsb r3, r3, #0 (reverso-resta: r3 = 0 - r3 ).

Sin embargo, clang5.0 -O0 no hace esa optimización. Aún usa idiv para a / -1 , por lo que ambas versiones tendrán un error en x86 con clang. ¿Por qué gcc “optimiza” en absoluto? Consulte Desactivar 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 “mudo y literal” que intente hacer que el asm sea lo más parecido posible a la fuente.


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 , entonces realmente está haciendo un 32b / 32b => 32b cociente + rest. Como se documenta en el manual de Intel , 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ía en lugar de seguir las reglas de promoción de enteros en C y hacer una división de 64 bits y luego truncar a int . Tampoco optimiza incluso en los casos en que se sabe que el divisor es lo suficientemente grande como para no poder #DE

Al hacer 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, 0x80000000 positivo con un bit de signo de cero 0x80000000 para convertirlo en un entero con signo del complemento de 2 positivo. Como esto no encaja 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] 

Las instrucciones de división de hardware AFAICT, ARM no generan excepciones para dividir entre cero o para INT_MIN / -1. O al menos, algunas CPU ARM no lo hacen. excepción de divide por cero 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 utiliza una llamada de biblioteca para la división en ARM32 de forma predeterminada, a menos que establezca un -mcpu que tenga división HW).


C Comportamiento no definido.

Como explica PSkocik , INT_MIN / -1 es un comportamiento indefinido en C, como todo desbordamiento de entero con signo. Esto permite a los comstackdores 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 comprobaciones de ramificación y comparación en tiempo de ejecución, y nadie quiere que C lo requiera.


Más sobre las consecuencias de UB:

Con la optimización habilitada , el comstackdor puede suponer que a y b todavía tienen sus valores establecidos cuando se ejecuta a a/b . Luego puede ver que el progtwig 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 de 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; } 

comstack esto con gcc6.3 -O3 para x86-64

 x86_fault: mov eax, -2147483648 ret 

pero clang5.0 -O3 comstack (sin advertencia incluso con -Wall -Wextra`):

 x86_fault: ret 

Comportamiento indefinido realmente no está totalmente definido. Los comstackdores pueden hacer lo que quieran, incluso devolver cualquier basura que haya en eax al ingresar 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 comstackdores vean el UB en el momento de la comstackción, por lo que obtuvo la salida del ASM “esperado”.

Consulte también Lo que todo progtwigdor de C debe saber sobre el comportamiento indefinido (la misma publicación de blog de LLVM que vincula a Basile).

La división int firmada en el complemento a dos no está definida si:

  1. el divisor es cero, O
  2. el dividendo es INT_MIN (== 0x80000000 si int es int32_t ) y el divisor es -1 (en el complemento de dos, -INT_MIN > INT_MAX , lo que provoca un desbordamiento de enteros, que es un comportamiento indefinido en C)

( https://www.securecoding.cert.org recomienda envolver operaciones enteras en funciones que verifican esos casos extremos)

Como está invocando un comportamiento indefinido al infringir la regla 2, puede pasar cualquier cosa, y sucede que este elemento en particular en su plataforma pasa a ser una señal FPE generada por su procesador.

Con un comportamiento indefinido pueden suceder cosas muy malas , y a veces suceden.

Su pregunta no tiene sentido en C (lea Lattner en UB ). Pero puede obtener el código de 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 entero de Linux (y también la división entera por cero, IIRC) da una señal SIGFPE . Ver señal (7)

Por cierto, en la división de enteros de PowerPC por cero se rumorea que da -1 a nivel de máquina (pero algunos comstackdores 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 del procesador).

(La tarea está hecha para que lea más sobre UB, especialmente el blog de Lattner , que debe leer en forma absoluta )

En x86 si divide al usar realmente la operación idiv (que no es realmente necesaria para argumentos constantes, ni siquiera para variables que se sabe que serán constantes, pero sucedió de todos modos), INT_MIN / -1 es uno de los casos que da como resultado #DE (divide el error). Es realmente un caso especial de cociente fuera de rango, en general es posible porque idiv divide un dividendo extra amplio por el divisor, por lo que muchas combinaciones causan desbordamiento, pero INT_MIN / -1 es el único caso que no es un div-por-0 que normalmente puede acceder desde lenguajes de nivel superior, ya que normalmente no exponen las capacidades de dividendo extra-ancho.

Linux mapea molestamente el #DE a SIGFPE, lo que probablemente ha confundido a todos los que lo trataron la primera vez.

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 architecture de 32 bits que representa números en complemento a dos. Si calcula su valor negativo, volverá a obtenerlo, ya que no tiene un número opuesto alrededor de cero. Cuando haces aritmética con enteros con signo, funciona bien para sumr y restar enteros (siempre con cuidado, ya que eres bastante fácil de desbordar, cuando agregas el mayor valor a algunos int) pero no puedes usarlo con seguridad para multiplicar o dividir. Entonces, en este caso, está invocando Comportamiento no definido . Siempre invoca un comportamiento indefinido (o comportamiento definido por la implementación, que es similar, pero no el mismo) al desbordamiento con enteros con signo, ya que las implementaciones varían ampliamente en la implementación.

Trataré de explicar lo que puede estar sucediendo (sin confianza), ya que el comstackdor es libre de hacer cualquier cosa, o nada en absoluto.

Concretamente, 0x80000000 representado en el complemento de 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 contrapartida para este número, ya que nos desbordamos al cambiar de signo) y luego sacas el signo, enmascarando con

 1000_0000_0000_0000_0000_0000_0000 & 0111_1111_1111_1111_1111_1111_1111 => 0000_0000_0000_0000_0000_0000_0000 

que es el número que usa como divisor, lo que lleva a una división por excepción cero.

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 esto es un comportamiento Indefinido y, como tal, puede obtener cualquier comportamiento diferente de su computadora / comstackdor.

NOTA 1

Como el comstackdor está preocupado, y el estándar no dice nada acerca de los rangos válidos de int que deben implementarse (el estándar no incluye normalmente 0x8000...000 en las architectures de complemento de dos) el comportamiento correcto de 0x800...000 en architectures de complemento de dos debe ser, ya que tiene el mayor valor absoluto para un número 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 ese número (ya que muchos de ellos ni siquiera implementan la división de enteros con signo, sino que la simulan desde una división sin signo, por lo que muchos simplemente extraen los signos y realizan una división sin signo). Eso requiere una verifique antes de la división, y como dice el estándar Comportamiento indefinido , las implementaciones pueden evitar dicha verificación y no permiten dividirla 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 , no permitiendo que el valor 0x8000...0000 sea ​​inválido.