Cómo los tamaños de las matrices C se convirtieron en parte de la interfaz binaria de la biblioteca

La mayoría de los compiladores de C le permiten acceder a una matriz extern con límites indefinidos, por ejemplo:

 extern int external_array[]; int array_get (long int index) { return external_array[index]; } 

La definición de matriz externa puede estar en otra unidad de traducción y puede verse así:

 int external_array[3] = { 1, 2, 3 }; 

La pregunta es qué sucede si esta definición separada cambia así:

 int external_array[4] = { 1, 2, 3, 4 }; 

Más o menos:

 int external_array[2] = { 1, 2 }; 

¿Se preservará la interfaz binaria (siempre que exista un mecanismo que permita a la aplicación determinar el tamaño de la matriz en tiempo de ejecución)?

Curiosamente, en muchas arquitecturas, aumentar el tamaño de la matriz viola la compatibilidad de la interfaz binaria (ABI). Reducir el tamaño de la matriz también puede causar problemas de compatibilidad. En este artículo, veremos más de cerca la compatibilidad ABI y explicaremos cómo evitar problemas.

Enlaces en la sección de datos del archivo ejecutable


Para comprender cómo el tamaño de la matriz se convierte en parte de la interfaz binaria, primero debemos examinar los enlaces en la sección de datos del archivo ejecutable. Por supuesto, los detalles dependen de la arquitectura específica, y aquí nos centraremos en la arquitectura x86-64.

La arquitectura x86-64 admite el direccionamiento relativo al contador del programa, es decir, el acceso a la variable de matriz global, como en la función array_get mostrada anteriormente, se puede compilar en una sola instrucción movl :

 array_get: movl external_array(,%rdi,4), %eax ret 

A partir de esto, el ensamblador crea un archivo de objeto en el que la instrucción se marca como R_X86_64_32S .

 0000000000000000 : 0: mov 0x0(,%rdi,4),%eax 3: R_X86_64_32S external_array 7: retq 

Este movimiento le dice al enlazador ( ld ) cómo llenar la ubicación correspondiente de la variable external_array durante el enlace al crear el ejecutable.

Esto tiene dos consecuencias importantes.

  • Dado que el desplazamiento de la variable se determina en el momento de la construcción, en el tiempo de ejecución no hay sobrecarga para determinarlo. El único precio es el acceso a la memoria misma.
  • Para determinar el desplazamiento, debe conocer los tamaños de todos los datos variables. De lo contrario, sería imposible calcular el formato de la sección de datos durante el diseño.

Para implementaciones en C orientadas al formato ejecutable y de enlace (ELF) , como en GNU / Linux, las referencias a variables extern no contienen tamaños de objeto. En el ejemplo de array_get tamaño del objeto es desconocido incluso para el compilador. De hecho, el archivo de ensamblador completo se ve así (omitiendo solo la información de promoción de -fno-asynchronous-unwind-tables , que es técnicamente necesaria para el cumplimiento de psABI):

  .file "get.c" .text .p2align 4,,15 .globl array_get .type array_get, @function array_get: movl external_array(,%rdi,4), %eax ret .size array_get, .-array_get .ident "GCC: (GNU) 8.3.1 20190223 (Red Hat 8.3.1-2)" .section .note.GNU-stack,"",@progbits 

No hay información de tamaño para external_array en este archivo ensamblador: la única referencia de caracteres está en la línea con la instrucción movl , y los únicos datos numéricos en la instrucción son el tamaño del elemento de matriz (implicado por movl multiplicado por 4).

Si ELF requiere tamaños para variables indefinidas, incluso será imposible compilar la función array_get .

¿Cómo obtiene el enlazador el tamaño real de los caracteres? Mira la definición del símbolo y usa la información de tamaño que encuentra allí. Esto permite que el compilador calcule el diseño de la sección de datos y complete los movimientos de datos con los desplazamientos apropiados.

Objetos comunes de ELF


Las implementaciones de C para ELF no requieren que el programador agregue marcado al código fuente para indicar si la función o variable se encuentra en el objeto actual (que puede ser la biblioteca o el archivo ejecutable principal) o en otro objeto. El enlazador y el cargador dinámico se encargarán de esto.

Al mismo tiempo, había un deseo de que los archivos ejecutables no redujeran el rendimiento al cambiar el modelo de compilación. Esto significa que al compilar el código fuente para el programa principal (es decir, sin -fPIC , y en este caso particular sin -fPIE ), la función array_get compila exactamente en la misma secuencia de comandos antes de introducir objetos dinámicos compartidos. Además, no importa si la variable external_array está definida en el archivo ejecutable más básico o si algún objeto compartido se carga por separado en tiempo de ejecución. Las instrucciones creadas por el compilador son las mismas en ambos casos.

¿Cómo es esto posible? Después de todo, los objetos ELF comunes son independientes de la posición. Se cargan en direcciones impredecibles y aleatorias en tiempo de ejecución. Sin embargo, el compilador genera una secuencia de código de máquina que requiere que estas variables se ubiquen en un desplazamiento fijo calculado durante el enlace , mucho antes de que se inicie el programa.

El hecho es que solo un objeto cargado (el archivo ejecutable principal) usa estos desplazamientos fijos. Todos los demás objetos (el cargador dinámico en sí, la biblioteca de tiempo de ejecución C y cualquier otra biblioteca utilizada por el programa) se compilan y compilan como objetos completamente independientes de la posición (PIC). Para tales objetos, el compilador carga la dirección real de cada variable desde la tabla de desplazamiento global (GOT). Podemos ver esta rotonda si array_get ejemplo de -fPIC con -fPIC , lo que conduce a dicho código de ensamblaje:

 array_get: movq external_array@GOTPCREL(%rip), %rax movl (%rax,%rdi,4), %eax ret 

Como resultado, la dirección de la variable external_array ya no está codificada y se puede cambiar en tiempo de ejecución inicializando adecuadamente el registro GOT. Esto significa que en tiempo de ejecución, la definición de external_array puede estar en el mismo objeto compartido, otro objeto compartido o el programa principal. El cargador dinámico encontrará la definición adecuada basada en las reglas de búsqueda de caracteres ELF y asociará la referencia de símbolo indefinido con su definición actualizando el registro GOT a su dirección real.

Volvemos al ejemplo original, donde la función array_get está en el programa principal, por lo que la dirección de la variable se especifica directamente. La idea clave implementada en el vinculador es que el programa principal proporcionará una definición de variable external_array , incluso si en realidad se define en un objeto común en tiempo de ejecución . En lugar de especificar la definición inicial de la variable en el objeto compartido, el cargador dinámico seleccionará una copia de la variable en la sección de datos del archivo ejecutable.

Esto tiene dos consecuencias importantes. En primer lugar, recuerde que external_array se define de la siguiente manera:

 int external_array[3] = { 1, 2, 3 }; 

Aquí hay un inicializador que debe aplicarse a la definición en el archivo ejecutable principal. Para hacer esto, en el archivo ejecutable principal, se coloca un enlace a la ubicación de copia del símbolo. El readelf -rW muestra como R_X86_64_COPY movimiento.

  La sección de reubicación '.rela.dyn' en el desplazamiento 0x408 contiene 3 entradas:
     Tipo de información de compensación Valor del símbolo Nombre del símbolo + suma
 0000000000403ff0 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
 0000000000403ff8 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
 0000000000404020 0000000300000005 R_X86_64_COPY 0000000000404020 external_array + 0 

Al igual que otros movimientos, el cargador dinámico maneja el movimiento de copia. Incluye una operación simple de copia a nivel de bits. El objetivo de la copia está determinado por el desplazamiento del desplazamiento ( 0000000000404020 en el ejemplo). La fuente se determina en tiempo de ejecución en función del nombre del símbolo ( external_array ) y su valor. Al crear una copia, el cargador dinámico también tendrá en cuenta el tamaño del carácter para obtener el número de bytes que deben copiarse. Para hacer todo esto posible, el símbolo external_array se exporta automáticamente desde el archivo ejecutable como un símbolo específico para que sea visible para el cargador dinámico en tiempo de ejecución. La tabla de símbolos dinámicos ( .dynsym ) refleja esto, como lo muestra el comando readelf -sW :

  La tabla de símbolos '.dynsym' contiene 4 entradas:
    Num: Valor Tamaño Tipo Enlace Vis Ndx Nombre
      0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 
      1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)
      2: 0000000000000000 0 NOTYPE DEAKULT DEFAULT UND __gmon_start__
      3: 0000000000404020 12 OBJETO GLOBAL DEFAULT 22 external_array 

¿De dónde proviene la información sobre el tamaño del objeto (12 bytes, en este ejemplo)? El vinculador abre todos los objetos comunes, busca su definición y toma información sobre el tamaño. Como antes, esto permite que el enlazador calcule el diseño de la sección de datos para que se puedan usar compensaciones fijas. Nuevamente, el tamaño de la definición en el ejecutable principal es fijo y no se puede cambiar en tiempo de ejecución.

El enlazador dinámico también redirige enlaces simbólicos en objetos compartidos a la copia movida en el ejecutable principal. Esto garantiza que en todo el programa solo haya una copia de la variable, como lo requiere la semántica del lenguaje C. De lo contrario, si la variable cambia después de la inicialización, las actualizaciones del archivo ejecutable principal no serán visibles para los objetos dinámicos compartidos y viceversa.

Impacto en la compatibilidad binaria


¿Qué sucede si cambiamos la definición de external_array en un objeto compartido sin vincular (o recompilar) el programa principal? Primero, considere agregar un elemento de matriz.

 int external_array[4] = { 1, 2, 3, 4 }; 

Esto generará una advertencia del cargador dinámico en tiempo de ejecución:

main-program: Symbol `external_array' has different size in shared object, consider re-linking

El programa principal todavía contiene una definición de external_array con espacio para solo 12 bytes. Esto significa que la copia está incompleta: solo se copian los primeros tres elementos de la matriz. Como resultado, el acceso al elemento de matriz extern_array[3] no extern_array[3] definido. Este enfoque afecta no solo al programa principal, sino también a todo el código en el proceso, porque todas las referencias a extern_array fueron redirigidas a la definición en el programa principal. Esto incluye un objeto genérico que proporciona una definición de extern_array . Probablemente no esté listo para enfrentar una situación en la que un elemento de matriz en su propia definición ha desaparecido.

¿Qué tal cambiar en la dirección opuesta, eliminar un elemento?

 int external_array[2] = { 1, 2 }; 

Si el programa evita acceder al elemento de matriz extern_array[2] , porque de alguna manera detecta la longitud reducida de la matriz, entonces esto funcionará. Después de la matriz, hay algo de memoria no utilizada, pero esto no interrumpirá el programa.

Esto significa que obtenemos la siguiente regla:

  • Agregar elementos a una variable de matriz global viola la compatibilidad binaria.
  • Eliminar elementos puede romper la compatibilidad si no hay un mecanismo que evite el acceso a elementos eliminados.

Desafortunadamente, la advertencia del cargador dinámico parece más inofensiva de lo que realmente es, y para los elementos remotos no hay ninguna advertencia.

¿Cómo evitar esta situación?


Detectar cambios ABI es bastante fácil con herramientas como libabigail .

La forma más fácil de evitar esta situación es implementar una función que devuelva la dirección de la matriz:

 static int local_array[3] = { 1, 2, 3 }; int * get_external_array (void) { return local_array; } 

Si la definición de la matriz no puede hacerse estática debido a la forma en que se usa en la biblioteca, en su lugar podemos ocultar su visibilidad y también evitar su exportación y, por lo tanto, evitar el problema de truncamiento:

 int local_array[3] __attribute__ ((visibility ("hidden"))) = { 1, 2, 3 }; 

Todo es mucho más complicado si la variable de matriz se exporta por razones de compatibilidad con versiones anteriores. Dado que la matriz de la biblioteca está truncada, el antiguo programa principal con una definición de matriz más corta no podrá proporcionar acceso a la matriz completa para el nuevo código de cliente si se usa con la misma matriz global. En cambio, la función de acceso puede usar una matriz separada (estática u oculta), o tal vez una matriz separada para elementos agregados al final. La desventaja es que no es posible almacenar todo en una matriz continua si la variable de matriz se exporta por compatibilidad con versiones anteriores. El diseño de la interfaz secundaria debe reflejar esto.

Con el control de versiones de los caracteres, puede exportar varias versiones con diferentes tamaños, sin cambiar nunca el tamaño en una versión en particular. Con este modelo, los nuevos programas relacionados siempre utilizarán la última versión, presumiblemente con el tamaño más grande. Dado que la versión y el tamaño del símbolo son fijados por el editor de enlaces al mismo tiempo, siempre son consistentes. La biblioteca GNU C utiliza este enfoque para las variables históricas sys_errlist y sys_siglist . Sin embargo, esto todavía no proporciona una sola matriz continua.

A fin de get_external_array , una función de acceso (por ejemplo, la función get_external_array anterior) es el mejor enfoque para evitar este problema de compatibilidad ABI.

Source: https://habr.com/ru/post/451182/


All Articles