ClickCease Desenrollado de pila en procesadores AArch64: qué es y cómo funciona

Tabla de contenidos

Únase a nuestro popular boletín

Únase a más de 4.500 profesionales de Linux y el código abierto.

2 veces al mes. Sin spam.

Desenrollado de pila en procesadores AArch64: qué es y cómo funciona

1 de marzo de 2023 - Equipo de RRPP de TuxCare

Desde hace algún tiempo, el software de parcheo en vivo del kernel Linux de KernelCare Enterprise soporta ARMv8 (AArch64) además de x86_64 (Intel IA32/AMD AMD64). Sin embargo, para que KernelCare en ARMnecesitará algo llamado desentrañador de marcos de pila.

Este artículo explica qué son, para qué se utilizan y por qué tuvimos que escribir nuestro propio desenrollador de marcos de pila.

  1. Desbobinadores de pilas: Qué son, para qué sirven y su historia
  2. Cómo Funciona el Desenrollado de Pila: Introducción a la pila y su funcionamiento en procesadores AArch64
  3. Instrucciones para Desbobinadores de Pila: Instrucciones para saltar, dirección a la que saltar y resolución de problemas

Parte A - Desbobinadores de pilas

¿Qué es un desbobinador de pilas?

A desbobinador de pila es una pieza de software que enumera las direcciones de cada función actualmente en la pila de llamadas. Te muestra en qué punto de la ejecución de un programa te encuentras, pero lo que es más importante, también te muestra cómo has llegado hasta ahí. La pila de llamadas es la lista de todas las funciones que se están ejecutando en ese momento. Se llama pila porque, a medida que una función llama a otra, esa nueva función se añade en la parte superior de la pila (mientras es la que se está ejecutando actualmente). Cuando una función devuelve un valor o alcanza una instrucción de salida, se elimina de dicha pila.

Un desbobinador eficaz debe reunir todas estas características:

  • Rápido: para que el procesamiento pueda reanudarse rápidamente (si es posible).
  • Barato: para que no consuma recursos del sistema.
  • Precisiónpara que las direcciones de memoria y los espacios de nombres se comuniquen con precisión.

¿Para qué sirve un desbobinador de pilas?

  1. Proporcionar trazas de pila cuando un programa se bloquea. (En el mundo del kernel de Linux, estos bloqueos se denominan "Oops").
  2. Para ayudar en el análisis del rendimiento, mostrando la ruta (qué funciones llaman a qué) que sigue un programa dentro de un programa.
  3. Permitir la aplicación de parches en vivo en el núcleo de Linux, el acto de corregir errores del núcleo sin detener (reiniciar) el sistema.

Historia del desenrollado de pilas

Históricamente, el desenrollado de la pila ayudaba a los desarrolladores a depurar el software. Después de todo, cualquiera que haya escrito al menos unos cuantos programas sabe que lo más probable es que los programas contengan errores. 

Algunos errores son fáciles de detectar, mientras que otros pasan casi desapercibidos. Cuanto más grande es el programa, más difícil resulta depurarlo: los programas enormes son casi imposibles de depurar utilizando únicamente el análisis del código fuente como única técnica de depuración. 

Por eso existen varias técnicas secundarias que pretenden facilitar el proceso de depuración:

  1. Registro (es decir, salida de depuración). En este caso, el programador utiliza los operadores de impresión de su lenguaje preferido para mostrar el contenido de determinadas variables en puntos concretos del flujo del programa. Esto le permite detectar los puntos en los que el flujo del programa se desvía de lo esperado. El software moderno depende en gran medida del registro, pero utiliza distintos niveles de registro -depuración, aviso, advertencia, error, etc.- para poder desactivar algunos mensajes de registro menos importantes en producción. Normalmente, los mensajes de registro van a un archivo de registro dedicado o, alternativamente, a un repositorio o archivo de registro de todo el sistema.
  2. Depuración mediante puntos de interrupción. Para facilitar el proceso de depuración, casi todas las arquitecturas modernas de CPU incluyen una instrucción especial denominada "breakpoint" (por ejemplo, bkpt para ARM e int3 para x86). El propósito de esta instrucción es provocar una interrupción especial del procesador. A continuación, el hardware guarda el contador de programa actual y puede guardar algunos registros de propósito general para ayudar a que la rutina de servicio de interrupción se inicie correctamente. El control se transfiere entonces a la rutina de servicio de interrupción.

    Lo que hace esta rutina es extraer el contenido guardado de los registros de propósito general para ayudar al programador a examinar ciertas variables del programa en el punto actual en el que se ha detenido el programa. Justo antes de ejecutar la instrucción especial de retorno de la rutina de servicio de interrupción, este manejador de interrupción normalmente restaura la instrucción original y después de volver al hilo interrumpido, el programa se ejecuta como si nunca hubiera sido tocado en absoluto.

    El uso moderno de esta técnica depende del soporte del núcleo del sistema operativo, ya que todas las rutinas de servicio de interrupción forman parte de él. Normalmente, el núcleo del sistema operativo proporciona llamadas al sistema especiales (por ejemplo, ptrace en Linux) que los procesos del espacio de usuario pueden utilizar para realizar funciones de depuración. El popular depurador de código abierto de Linux, llamado GDB, utiliza ptrace para realizar la depuración paso a paso (mediante puntos de interrupción).

    Además, algunas arquitecturas de CPU (x86-64 por ejemplo) definen capacidades adicionales, que se basan en la idea original. Por ejemplo, puede haber registros especiales del sistema que contengan la dirección de un punto de interrupción (virtual): cuando el contador del programa alcanza esta dirección, se llama inmediatamente a la rutina de servicio de interrupción sin necesidad de parchear (y, en consecuencia, restaurar) el código del programa.
  3. Depuración mediante aserciones. La aserción es una función especial que comprueba la condición dada y, si ésta es falsa, provoca la terminación del programa. Los programadores pueden utilizar las aserciones para asegurarse de que las variables internas están sanas. Las aserciones suelen estar desactivadas en los programas de producción. Aquí, el caso más interesante es cuando la aserción resulta falsa. Este estado indica claramente que se ha producido algún error en el programa. Para ayudar a investigar un problema, se desenrolla la pila. Desenrollando la pila del programa, podemos obtener una "ruta de ejecución" que lleva a nuestro programa al punto en el que se bloqueó.

Los desbobinadores avanzados permiten ver los parámetros de cada función dentro de la cadena de llamadas.

Así pues, el desenrollado de pila procede originalmente de la depuración de software. Ahora, sin embargo, tiene otras aplicaciones, una de ellas en Kernelcare Enterprise.

Por qué el equipo de KernelCare Enterprise necesitaba un Unwinder para ARM

Para instalar un parche activo del kernel de Linux, el software de parcheo debe saber qué funciones se encuentran en la pila de llamadas actual. Si una función actualmente en la pila de llamadas es parcheada, el sistema puede bloquearse al volver. El kernel de Linux ya dispone de algunas funciones para desenrollar la pila. Aquí hay una breve revisión de esa funcionalidad y las razones por las que no se puede utilizar para parchear en vivo:

  • El desbobinador "Adivina": Adivina el contenido de la pila. No es preciso, por lo que no es útil para la aplicación de parches en vivo. Sólo está disponible para arquitecturas x86_64.
  • El desbobinador 'puntero de cuadro: Disponible sólo para x86_64
  • El desenrollador 'ORC: Introducido en Núcleo Linux v4.14., "ORC" es un acrónimo de "Oops Rewind Capability". Desarrollado originalmente sólo para x86_64, se ha seguido mejorando y se está considerando incluir parches en el kernel (a partir de Linux Kernel 6.3) que amplían su soporte a ARMademás de añadir comprobaciones de fiabilidad para garantizar que se devuelve información precisa.

Parte B - Cómo funciona el desbobinado de pilas

Cartilla de pila

  • Cuando se llama a una función, se crea un marco de pila mantiene un registro de los argumentos de la función, así como de sus puntos de entrada y salida.
  • A un registro del procesador se le asigna un punto de pila ('SP') que hace referencia al objeto colocado más recientemente en la pila. La memoria que implementa esa pila se expande hacia abajo, hacia direcciones de memoria inferiores (lo que se denomina 'full-descending').
  • La memoria de pila debe estar alineada con los límites de bytes (16 bytes para AArch64). Esto lo impone el hardware (pero puede desactivarse en algunos modelos ARM).

Detalles

¿Cómo funciona el desenrollado de pila en los procesadores AArch64?

Una función especial del núcleo realiza el desenrollado de la pila. Cuando es llamada, la función obtiene el frame pointer (FP) de la función que llama. El FP se refiere al marco de pila que está representado por la estructura struct stack_frame. Contiene un puntero al marco de pila de la función que ha llamado a la función de llamada.

Esto significa que tenemos una lista enlazada de marcos de pila que termina cuando el siguiente FP obtenido es igual a 0 según AAPCS64 (el estándar de llamada a procedimiento para AArch64). En cada marco de pila, podemos recuperar una dirección de retorno donde la función llamante debería delegar el control después de terminar su trabajo. Utilizando el hecho de que una dirección de retorno debe apuntar al interior de la función que llama, podemos obtener los nombres simbólicos de todas las funciones, hasta e incluyendo el punto donde FP=0.

Para ello, conservamos los nombres de las funciones y sus direcciones de inicio y fin. Esto se puede implementar utilizando el subsistema del kernel de Linux llamado kallsyms.

El problema central se reduce a lo siguiente: ¿cómo podemos mover el contador del programa de un lugar a otro (un salto) y reanudar el procesamiento sin problemas?

Esta acción se puede expresar en lenguaje ensamblador como:

Veámoslos con más detalle.

1. Instrucción de salto

Los procedimientos se invocan con BL. La instrucción de 32 bits se puede visualizar de la siguiente manera:


31 30 29 28 27 26 25 (...) 2 1 0
1  0  0  1  0  1  imm26
op

Aquí, imm26 es un offset de PC de 26 bits. Para más información sobre este ejemplo, consulte la documentación de ARM aquí.

2. Dirección a la que saltar

Calculamos dónde saltar a usar:


bits(64) offset = SignExtend(imm26:'00', 64)

The offset shifts by two bits to the left and converts to 64 bit (i.e. the high bits fill with 1 if imm26 < 0, and with 0, otherwise).

La dirección a la que hay que saltar es entonces:


Address = PC + offset

El offset se etiqueta y se utiliza en la instrucción BL. (Las instrucciones en AArch64 siempre ocupan 4 bytes, lo que es una ventaja comparado con x86). El registro X30 (también conocido como Registro de Enlace) se establece en PC+4. Esta es la dirección de retorno para RET (por defecto es X30 si no se especifica).

Por lo tanto, para la instrucción complementaria RET, basta con recuperar el valor LR guardado y transferir el control sobre él y volver a la función de llamada.

El problema: ¿qué ocurre si la función llamada llama a otra función?

Y aquí tenemos un problema: ¿qué ocurre si la función llamada llama a su vez a otra función? Si no hacemos nada, entonces el valor guardado en LR será reemplazado por una nueva dirección de retorno - no será capaz de volver a la función inicial y lo más probable es que el programa aborte.

Resolver el problema

Hay algunas formas de resolver este problema:

  1. Guardar el valor LR en algún otro registro
  2. Guardar el valor LR en RAM

El primer caso es muy restrictivo, ya que el número de registros disponibles es limitado (a 31 registros). ARM utiliza la filosofía de arquitectura RISC load/store que dice que las llamadas a memoria se realizan mediante instrucciones LD (LOAD) y ST (STORE), mientras que las operaciones aritméticas y lógicas se realizan sobre registros. Por lo tanto, se necesitan registros vacíos para la ejecución del programa, y nos quedamos con la opción 2, guardar el valor LR en RAM.

Guardar funciones invocadas para devolver direcciones en la pila

Una estructura de trama implementada en C tiene el siguiente aspecto:


struct stack_frame {
    unsigned long fp;
    unsigned long lr;
    char data[0];
};

En otras palabras, cada función asigna n bytes en la pila, reduciendo en n el puntero de pila en el momento en que toma el control con la instrucción BL. El contenido de los registros x29 (FP) y x30 (LR) se guarda de acuerdo con el valor del puntero de pila obtenido - la función que llama utiliza estos valores. Después, el nuevo valor SP se asigna al registro x29(llamado puntero de marco (FP)). El espacio restante en el marco de la pila es utilizado por las variables locales de la función. Y siempre se cumple la condición de que el puntero de trama (FP) y el registro de enlace (LR) de la función de llamada se encuentren siempre al principio de cualquier marco de pila. Tras finalizar su trabajo, la función llamada toma los valores guardados de FP y LR del marco de pila y aumenta el puntero de pila (SP) en n.

Cómo funciona el compilador gcc con AArch64

El uso del puntero de trama requiere que el kernel de Linux se compile con la opción -fno-omit-frame-pointer de gcc. Esta opción le dice a gcc que almacene el puntero del marco de la pila en un registro. (NOTA: El valor predeterminado para gcc es -fomit-frame-pointer, por lo que esta opción debe configurarse explícitamente).

Para AArch64, el registro es X29. Está reservado para el puntero del marco de pila cuando la opción está activada. (El compilador cruzado GCC utilizado para compilar Linux bajo AArch64 establece las siguientes instrucciones antes del cuerpo de la función:


ffffff80080851b8 :
ffffff80080851b8: a9be7bfd stp x29, x30, [sp, #-32]!
ffffff80080851bc: 910003fd mov x29, sp

Aquí, el llamado direccionamiento indirecto con preincremento donde el puntero de pila (SP) se disminuye en 32 al principio y luego x29, x30 se guardan secuencialmente en la memoria por el valor obtenido en la primera instrucción.

Normalmente, la función termina de la siguiente manera:


ffffff80080851fc: a8c27bfd ldp x29, x30, [sp], #32
ffffff8008085200: d65f03c0 ret

El direccionamiento indirecto con el post-incremento donde los valores guardados x29, x30, se toman de la memoria en el puntero de pila (SP) y luego SP se incrementa en 32. Los ejemplos de código anteriores se denominan prólogo y epílogo de la función respectivamente. GCC siempre genera dichos prólogos y epílogos si se activa la bandera -fno-omit-frame-pointer. Linux en AArch64 se compila con esa bandera para que los marcos de pila tengan el mismo aspecto que el código normal (excepto el código ensamblador). Este hecho nos permite desenrollar fácilmente la pila, es decir, rastrear la cadena de llamadas en el programa.

Referencia de montaje:

Conclusión

A pesar de su utilidad, no existe un enfoque común para el desenrollado de pila que cubra todas las arquitecturas y sistemas. Para la aplicación de parches en vivo en el núcleo de Linux, es esencial contar con un desenrollador de pila rápido y fiable. En el caso de Linux que se ejecuta en ARM, la necesidad de una solución robusta de desbobinado de pila es aún más acuciante, ya que ARM gana tracción en los mercados de dispositivos IoT y computación en el borde de la nube. Con KernelCare empujando en ambos, tuvimos que buscar nuestras propias soluciones para el desenrollado de la pila del kernel.

Lecturas complementarias

Acerca de KernelCare Enterprise

KernelCare Enterprise simplifica la aplicación de parches a los núcleos de Linux para servidores con CentOS, Amazon Linux, RHEL, Ubuntu, Debian y otras distribuciones de Linux. otras distribuciones de Linuxincluyendo Poky (la distribución del Proyecto Yokto) y Raspbian.

KernelCare mantiene la seguridad del núcleo con actualizaciones automatizadas y sin reinicios, sin interrupción ni degradación del servicio. El servicio ofrece rápidamente los últimos parches de seguridad para diferentes distribuciones de Linux aplicados automáticamente al núcleo en ejecución en tan solo nanosegundos. KernelCare Enterprise funciona tanto en entornos activos como de ensayo, así como en sistemas locales y en la nube, y para los servidores ubicados detrás de un cortafuegos, existe una herramienta ePortal local para ayudarle a gestionarlo.

KernelCare Enterprise mejora el cumplimiento normativo en cientos de miles de servidores de diversas empresas en las que la disponibilidad del servicio y la protección de los datos son las partes más cruciales del negocio: servicios financieros y de seguros, proveedores de soluciones de videoconferencia, empresas que protegen a las víctimas de abusos domésticos, empresas de alojamiento y proveedores de servicios públicos.

Más información sobre KernelCare Enterprise y las ventajas que ofrece aquí.

Resumen
Nombre del artículo
Desenrollado de pila en procesadores AArch64: qué es y cómo funciona
Descripción
El software de live patching del kernel Linux de KernelCare Enterprise ha soportado (AArch64) pero qué son, para qué se utilizan...Más información
Autor
Nombre del editor
TuxCare
Logotipo de la editorial

¿Desea automatizar la aplicación de parches de vulnerabilidad sin reiniciar el núcleo, dejar el sistema fuera de servicio o programar ventanas de mantenimiento?

Más información sobre Live Patching con TuxCare

Conviértete en escritor invitado de TuxCare

Empezar

Correo

Únete a

4,500

Profesionales de Linux y código abierto

Suscríbase a
nuestro boletín