linux x86 x86-64 calling-convention

linux - ¿Se permite la basura en bits altos de parámetros y registros de valores devueltos en x86-64 SysV ABI?



calling-convention (1)

El x86-64 SysV ABI especifica, entre otras cosas, cómo se pasan los parámetros de función en los registros (primer argumento en rdi , luego rsi etc.) y cómo los valores de retorno enteros se pasan (en rax y luego rdx para valores realmente grandes) )

Lo que no puedo encontrar, sin embargo, es lo que deben ser los bits altos de los registros de parámetros o valores de retorno cuando se pasan tipos menores de 64 bits.

Por ejemplo, para la siguiente función:

void foo(unsigned x, unsigned y);

... x pasará en rsi y y en rsi , pero solo son 32 bits. ¿Los altos 32 bits de rsi y rsi deben ser cero? Intuitivamente, supongo que sí, pero el código generado por todos los gcc, clang e icc tiene instrucciones mov específicas al comienzo para poner a cero los bits altos, por lo que parece que los compiladores suponen lo contrario.

De forma similar, los compiladores parecen suponer que los bits altos del valor de retorno rax pueden tener bits de basura si el valor de retorno es menor que 64 bits. Por ejemplo, los bucles en el siguiente código:

unsigned gives32(); unsigned short gives16(); long sum32_64() { long total = 0; for (int i=1000; i--; ) { total += gives32(); } return total; } long sum16_64() { long total = 0; for (int i=1000; i--; ) { total += gives16(); } return total; }

... compilar lo siguiente en clang (y otros compiladores son similares):

sum32_64(): ... .LBB0_1: call gives32() mov eax, eax add rbx, rax inc ebp jne .LBB0_1 sum16_64(): ... .LBB1_1: call gives16() movzx eax, ax add rbx, rax inc ebp jne .LBB1_1

Tenga en cuenta que el mov eax, eax después de la llamada que devuelve 32 bits y el movzx eax, ax después de la llamada de 16 bits, ambos tienen el efecto de poner a cero los 32 o 48 bits superiores, respectivamente. Entonces, este comportamiento tiene algún costo: el mismo bucle que trata con un valor de retorno de 64 bits omite esta instrucción.

He leído el documento x86-64 System V ABI con bastante cuidado, pero no he podido encontrar si este comportamiento está documentado en el estándar.

¿Cuáles son los beneficios de tal decisión? Me parece que hay costos claros:

Costos del parámetro

Los costos se imponen a la implementación del destinatario cuando se trata de valores de parámetros. y en las funciones al tratar con los parámetros. Por supuesto, a menudo este costo es cero porque la función puede ignorar efectivamente los bits altos, o la puesta a cero es gratuita, ya que pueden usarse instrucciones de tamaño de operando de 32 bits que implícitamente ponen a cero los bits altos.

Sin embargo, los costos a menudo son muy reales en el caso de las funciones que aceptan argumentos de 32 bits y hacen algunas operaciones matemáticas que podrían beneficiarse de las operaciones matemáticas de 64 bits. Tome esta función por ejemplo:

uint32_t average(uint32_t a, uint32_t b) { return ((uint64_t)a + b) >> 2; }

Un uso directo de la matemática de 64 bits para calcular una función que de otra manera tendría que lidiar cuidadosamente con el desbordamiento (la capacidad de transformar muchas funciones de 32 bits de esta manera es un beneficio a menudo inadvertido de las arquitecturas de 64 bits). Esto compila a:

average(unsigned int, unsigned int): mov edi, edi mov eax, esi add rax, rdi shr rax, 2 ret

Completamente 2 de las 4 instrucciones (ignorando ret ) son necesarias solo para poner a cero los bits altos. Esto puede ser barato en la práctica con eliminación de mov, pero aún así parece un gran costo a pagar.

Por otro lado, realmente no puedo ver un costo correspondiente similar para los llamadores si el ABI especificara que los bits altos son cero. Como rsi y rsi y los demás registros de paso de parámetros son cero (es decir, pueden ser sobrescritos por la persona que llama), solo tiene un par de escenarios (vemos rdi , pero reemplácelo con el registro de parámetro que prefiera):

  1. El valor pasado a la función en rdi está muerto (no es necesario) en el código posterior a la llamada. En ese caso, cualquiera que sea la última instrucción asignada a rdi simplemente tiene que asignarle a edi . No solo es gratis, a menudo es un byte más pequeño si evita un prefijo REX.

  2. El valor pasado a la función en rdi es necesario después de la función. En ese caso, dado que rdi está guardado por la persona que llama, la persona que llama debe realizar un mov del valor a un registro guardado de llamada de todos modos. En general, puede organizarlo para que el valor comience en el registro guardado en línea (digamos rbx ) y luego se mueva a edi como mov edi, ebx , por lo que no cuesta nada.

No puedo ver muchos escenarios donde la reducción a cero le cuesta mucho a la persona que llama. Algunos ejemplos serían si se necesita matemática de 64 bits en la última instrucción que asignó rdi . Eso parece bastante raro sin embargo.

Costos del valor de retorno

Aquí la decisión parece más neutral. Tener llamadas para limpiar la basura tiene un código definido (a veces se ven mov eax, eax instrucciones para hacer esto), pero si se permite la basura, los costos se desplazan al destinatario. En general, parece más probable que la persona que llama pueda limpiar la basura de forma gratuita, por lo que permitir que la basura no parezca en general perjudicial para el rendimiento.

Supongo que un caso de uso interesante para este comportamiento es que las funciones con diferentes tamaños pueden compartir una implementación idéntica. Por ejemplo, todas las siguientes funciones:

short sums(short x, short y) { return x + y; } int sumi(int x, int y) { return x + y; } long suml(long x, long y) { return x + y; }

Puede compartir la misma implementación 1 :

sum: lea rax, [rdi+rsi] ret

1 Si este plegamiento está realmente permitido para las funciones que tienen su dirección está muy abierto al debate .


Parece que tienes dos preguntas aquí:

  1. ¿Los bits altos de un valor de retorno deben ponerse a cero antes de regresar? (¿Y los altos argumentos deben ponerse a cero antes de llamar?)
  2. ¿Cuáles son los costos / beneficios asociados con esta decisión?

La respuesta a la primera pregunta es no, puede haber basura en los bits altos , y Peter Cordes ya ha escrito una respuesta muy buena sobre el tema.

En cuanto a la segunda pregunta, sospecho que dejar los bits altos indefinidos es en general mejor para el rendimiento. Por un lado, los valores de extensión cero de antemano no tienen costo adicional cuando se utilizan operaciones de 32 bits. Pero, por otro lado, poner a cero las partes altas de antemano no siempre es necesario. Si deja basura en los bits altos, puede dejarla en el código que recibe los valores para realizar únicamente extensiones cero (o extensiones de signos) cuando realmente se requieren.

Pero quería destacar otra consideración: Seguridad

Filtraciones de información

Cuando los bits superiores de un resultado no se borran, pueden retener fragmentos de otras piezas de información, como punteros de función o direcciones en la pila / montón. Si alguna vez existe un mecanismo para ejecutar funciones de mayor privilegio y recuperar el valor completo de rax (o eax ) luego, esto podría rax una fuga de información . Por ejemplo, una llamada al sistema puede filtrar un puntero desde el kernel al espacio del usuario, lo que lleva a una derrota de Kernel ASLR . O un mecanismo de IPC puede filtrar información sobre el espacio de direcciones de otro proceso que podría ayudar a desarrollar una ruptura de sandbox .

Por supuesto, uno podría argumentar que no es responsabilidad del ABI evitar filtraciones de información; Depende del programador implementar su código correctamente. Si bien estoy de acuerdo, exigir que el compilador ponga a cero los bits superiores aún tendrá el efecto de eliminar esta forma particular de filtración de información.

No deberías confiar en tu entrada

Por otro lado, y más importante aún, el compilador no debe confiar ciegamente en que los valores recibidos tienen sus bits superiores a cero, o la función puede no comportarse como se espera, y esto también podría conducir a condiciones explotables. Por ejemplo, considere lo siguiente:

unsigned char buf[256]; ... __fastcall void write_index(unsigned char index, unsigned char value) { buf[index] = value; }

Si se nos permitiera suponer que el index tiene sus bits superiores a cero, entonces podríamos compilar lo anterior como:

write_index: ;; sil = index, dil = value mov rax, offset buf mov [rax+rsi], dil ret

Pero si pudiéramos llamar a esta función desde nuestro propio código, podríamos suministrar un valor de rsi fuera del rango [0,255] y escribir en la memoria más allá de los límites del búfer.

Por supuesto, el compilador en realidad no generaría un código como este, ya que, como se mencionó anteriormente, es responsabilidad del destinatario poner a cero o extender sus argumentos, en lugar de la persona que llama . Esto, creo, es una razón muy práctica para que el código que recibe un valor siempre suponga que hay basura en los bits superiores y lo elimine explícitamente.