Acerca de...
El equipo
Encuestas
Política de privacidad
WinTablets

Encuesta

¿Cual crees que triunfará?

Ver Resultados

Cargando ... Cargando ...

últimas entradas importantes

Categorías

Archivos

09
Dic 2016
Podcast

Emular x86 en ARM


Bueno, aquí está. La entrada. ¿Cómo ha podido Microsoft hacer que el ensamblador de x86 corra en procesadores ARM? La respuesta después de la pausa, pero antes un disclaimer para navegantes:

No, realmente no sé cómo lo han hecho, ni tengo línea directa con el tito Bill, ni nadie me ha soplado ningún documento técnico. Pero en el vídeo están todas las pistas.

Vayamos al tajo.

Antes de nada, la entrada va a ser un poco técnica y no me voy a entretener mucho en explicar conceptos. Si tenéis alguna duda puntual, internet es vuestro amigo.

Primero os voy a contar cómo no lo han hecho.

Las emulaciones tradicionales consisten en crear un programa que simula la máquina de destino, y dentro de ella, ejecutar el sistema operativo a emular. A efectos finales, el SO es incapaz de saber si su entorno es emulado o real.

Es parecido a cómo funcionan las máquinas virtuales, pero en nuestro caso tenemos que hacer que una arquitectura se comporte como otra.

Quizás hayáis podido ejecutar el emulador de Android ARM en Windows (o en MAC). También recordaréis lo lento que iba y lo que tardaba a cargar. Hasta que salieron las compilaciones de Android para x86, la máquina virtual tenía que emular no solo el espacio de direcciones de memoria, sino también el microprocesador destino.

Y en ese caso, se simulaba un microprocesador más lento y menos complejo con uno más rápido y más complejo. Y recordad lo lentísimo que iba.

¿Cómo iría de rápido entonces el proceso contrario? Es decir, con un procesador que es más lento, tiene menos instrucciones ensamblador, y una arquitectura más pobre, emular no solo un procesador más complejo, con más instrucciones, sino también emular una arquitectura que es mucho más compleja que la física real?

Poderse hacer, se puede, pero imaginad la velocidad de eso. Pensad en el proceso contrario y lo lento que iba. Ni os imagináis la lentitud de lo opuesto.

El problema no solo reside ahí, el problema es más amplio. Un procesador x86 tiene un modelo de memoria segmentado, que debería ser simulado… con posiciones de memoria. Habría que “recortar” los registros de 32 bits de los ARM a los 24 de los segmentos x86. Habría que realizar la aritmética de punteros de la traducción del segmento a la dirección real, y de ahí a la física real mediante la tabla de los descriptores. Y luego poner eso en una dirección física de la memoria de un ARM.

¿Cómo podriamos emular los anillos (ring)? Un procesador x86 tiene 4 anillos, del 0 al 3. El sistema operativo corre en el 0, las aplicaciones en el 3. Ambos anillos permiten unas instrucciones y niegan otras. Habría que emular eso. Ante cada ejecución de instrucción, el emulador debería comprobar en qué anillo está el proceso, y ejecutar o negar la instrucción disparando una interrupcion… simulada, claro.

¿Cómo se podría implementar la instrucción ARPL? Esta es una instrucción muy especial del juego x86. Si no lo han cambiado, es como una aplicación win32 se comunica con el núcleo. Por poner un ejemplo, abrir un fichero requiere la llamada a la función CreateFile de Win32. Dentro del código de esta instrucción, se colocan unos valores en una posición fija de memoria respecto al proceso en ejecución, y luego se ejecuta ARPL.

Esto genera una interrupción de violación de anillo, que es capturada por el código corriendo en el anillo 0. ¡Una aplicación está intentando meterle mano al núcleo! No, o más bien no dependiendo de qué valores se hayan colocado en la dirección de memoria citada. Si son valores válidos (que hay que comprobar), el núcleo ejecutará lo solicitado y devolverá el control al anillo 3 con el resultado de la operación.

Blanco y en botella: ¿Cómo cojones podemos emular eso? Por poderse hacer, se puede, pero imaginad el trapicheo de código en ARM para hacer todo eso. ¿Y si ejecutamos una ARPL “con truco”? ¿Un código de operación no válido? Posible pero complejo de emular.

¿Y la seguridad? En Windows, una aplicación no se puede salir de su espacio de direcciones. Si lo hace, peta y el sistema la cierra. ¿Cómo podría emular eso con un ARM, que no tiene los protectores para ello? Debería comprobar todas y cada una de las instrucciones a ejecutar, límites y rangos, antes de hacerlo.

¿Qué pasa si pongo en la pila una dirección de retorno a un segmento de datos que previamente he llenado con código ejecutable? En un ARM salto a ese código y lo ejecuto. En x86 tenemos el bit NX para el descriptor del segmento, que dispara una interrupción…

Por cierto, ¿descriptor de segmento? ¿LDT, GDT? Uff, muchas cosas a emular.

¿Cómo puedo evitar un desbordamiento de buffer? Imaginemos que estamos ejecutando Edge en su sandbox emulada, que a su vez corre en otra sandbox emulada que es el proceso en el que corre Edge, que a su vez ejecuta en un espacio de direcciones emulado, que a su vez corre en un ARM…

Solo por joder la marrana, con una URL lo suficientemente larga, podría tumbar ese Windows sin mucho esfuerzo, incluso el propio ARM… No digamos ya si esa URL larga contiene ciertos bloques de texto que resultan ser instrucciones x86 y que coincidentalmente terminan en el lugar adecuado…

Y no estamos hablando de código malicioso, solo de una URL…

REPNZ es una instrucción de copia de cadenas que se usa en x86. Pongamos que sobreescribo el registro del contador, gracias a un desbordamiento de buffer… o que la reemplazo por otra que copie “hacia atrás”.

No sé, son maldades que se me ocurren.

Visto lo visto, esta NO es la forma de hacerlo. De hecho estoy seguro que no lo han hecho así. Entonces, ¿cómo?

Hay dos posibles soluciones. Primero os cuento otra que pienso no es la implementada, pero podría estar dentro de lo posible o formar parte de la implementación.

Descendamos al infierno, digo al hierro.

El juego de instrucciones de un microprocesador moderno no es su juego de instrucciones real. Las instrucciones se suelen llamar “microcódigo”, y es lo que realmente ejecuta el microprocesador. Ante cada instrucción de “alto nivel”, digamos la citada REPNZ o ARPL, el sistema realmente ejecuta otras más pequeñas y más genéricas.

Esto tiene muchas ventajas. Supongamos dos instrucciones seguidas, MOV AX, BX y MOV BX, CX. Eso es traducido a microcódigo por el procesador, de modo que los ciclos fetch, decode, execute, etc, están separados. Eso entra en un pipeline con un fetch ax, decode mov, “push bx, fetch bx”, decode mov, push cx.

Centrémonos en “push bx, fetch bx”. Ajá. Eso podría ser eliminado, porque si dejamos BX y luego lo volvemos a coger, pues lo saltamos. Optimización de microcódigo hecha por el procesador en sus tripas. ¿Cómo se podría implementar eso por hardware? Pues no lo sé, pero se me ocurre que si hacer la operación de AND lógico con los códigos de operación de “push bx” y de “fetch bx” diera cero, dispararía un jump a la siguiente instrucción del pipeline. Y no, no es más lento que simplemente ejecutar las operaciones, porque ese AND estaría cableado en el chip (con un shift and or simultáneo a la inserción en el pipeline), y las instrucciones de fetch y push hay que ejecutarlas usando ciclos máquina.

Esto nos lleva al juego de instrucciones x86. Supongamos que esos Qualcomm compatibles con la emulación fueran capaces de tomar las instrucciones x86 y, mediante microcódigo, las tradujeran al microcódigo de las de ARM.

Provocador, ¿eh?

No obstante, seguimos con el problema de los segmentos, anillos, virtualización de procesos, ARPL…

Ahora viene el primer momento “¡Ajá!”. ¿Y si solo quisiéramos emular las instrucciones compatibles con el anillo 3, el de Win32? Esa no son muy complejas de emular, ya sea por microcódigo (la forma más óptima) o por simple emulación por software.

Quizás ya hayas llegado al segundo momento “¡Ajá!”. De todos modos, sigamos la lógica.

Supongamos por un momento que cargamos un programa Win32 en memoria, lanzamos su ejecución con esas instrucciones del anillo 3 emuladas de alguna forma…

¿Qué pasaría cuando se llamara a una función Win32, por seguir el ejemplo de antes, a CreateFile? El código de ahí dentro también es código de Anillo 3… hasta que ejecuta ARPL y es el núcleo el que toma el control, con instrucciones del Anillo 0…

Ahora viene un pequeño inciso.

¿Cómo funciona una aplicación Win32? Es relativamente bastante sencillo. El API de Win32, las funciones que las aplicaciones utilizan, se encuentran implementadas dentro de varias DLL, que se cargan con el ejecutable en su mismo espacio de direcciones, y luego se “conecta” cada llamada al API con la función llamada.

Es decir, el ejecutable, después de dejar en la pila los parámetros oportunos, ejecuta una instrucción en ensamblador llamada “call <dirección>”. En el ejecutable, las llamadas están marcadas con metadatos, por lo tanto “call 1” podría ser “call dirección_de_CreateFile”. Cuando el cargador del sistema pone en memoria el programa a ejecutar, recorre todos los “meta call” y los reemplaza por las llamadas a la dirección adecuada de cada función del API, que ha sido cargado en su mismo espacio de direcciones.

Explicado parece complicado. La realidad es más sencilla: en el ejecutable hay un “hueco” para cada llamada, y el cargador simplemente copia a lo bruto los punteros, que están en un vector predefinido. Apenas lleva tiempo.

Volvamos por tanto a lo que nos interesa. Cargamos las DLL del API x86 en el espacio de direcciones de nuestra aplicación x86 y…

Espera, espera. ¿Y si esa función Win32 está implementada en código ARM?

¡Hostia!

Supongamos una vaca esférica… digo, supongamos Windows RT, un Windows corriendo de forma nativa en un ARM igual que ocurría con las Surface RT y 2. Supongamos ahora que el cargador del sistema es capaz de entender el formato de un EXE x86, con sus definiciones de segmentos y demás zarandajas como las llamadas al API de Win32 en formato x86.

Ahora lo cargamos en memoria, y le anexamos las DLL Win32 ARM en lugar de las x86, o le anexamos una fina DLL Win32 que hace de capa entre la ARM y la Win32… En el primer caso parcheamos los call x86 a la DLL ARM. En el segundo, es la DLL la que hace la transición… ya solo nos falta “algo” que coja cada instrucción x86 y la convierta en su equivalente ARM.

De hecho ni siquiera tenemos que traducir todas las instrucciones x86 del Anillo 3, sino tan solo las que usen los compiladores que generan los ejecutables x86. Además, esas instrucciones no pueden dañar al sistema, porque el sistema está protegido igual que un x86 nativo. Es decir, los compiladores ya se encargan de no generar instrucciones “malignas”, y en el caso de un programa “maligno”, en cuanto intente salir de su caja x86 está completamente muerto y enterrado porque ARM sí que sabe protegerse de sus propias violaciones.

Tomado desde otro punto de vista, un programa Win32 es un conjunto de algoritmos divididos en bloques coherentes que realizan operaciones entre sí y que solicitan (el truco está en la palabra “solicitan”) al sistema para que haga cosas por ellos.

Con el modelo que hemos explicado, el sistema está seguro, quizás más seguro incluso que Win32 en un x86 puro. El único hándicap es traducir esas instrucciones x86 a equivalentes ARM, y para ello tenemos otra palabra: “jitter”.

Suponiendo que no se haya adoptado la solución del microcódigo, está la del .NET Framework.

Que levante la mano quien sepa qué hay debajo del .NET Framework. ¿Os suena la palabra MSIL? Un programa compilado en, por ejemplo, C#, es un ejecutable cuyo juego de instrucciones está en MSIL. Podríamos decir que su ensamblador es MSIL. Cuando Windows lo carga para ejecutarlo, después (o antes, no importa para nuestros propósitos actuales) realiza una o varias pasadas sobre ese ensamblador para traducirlo al de la máquina destino, que es x86.

De la frase anterior, cambiemos MSIL por x86 y x86 por ARM. Y pongámoslo en un entorno Windows RT. De hecho, traducir de MISL a ARM con una aplicación Metro para RT sería el equivalente de traducir x86 a ARM dentro de ese mismo RT.

Si a eso añadimos que ese supuesto traductor x86 a ARM pudiera tener un jitter, sería posible realizar pasos como el que os he contado en lo del microcódigo, posiblemente obteniendo grandes aumentos de rendimiento a cambio de un poco más de retardo en la carga del programa.

Otro añadido que podrían haber hecho es reemplazar DLLs conocidas por sus equivalentes ARM, y realizar el mismo parcheado que con Win32. Me vienen a la cabeza los runtime de Visual C++, MFC, pero también DLL como las de OpenSSL, MySQL…

¿Qué nos queda? SYSWOW64 pero para ARM. Yo lo llamo SYSWOWARM.

Por RFOG | 9 Comentarios | Enlaza esta entrada
contacto@wintablet.info tema WinTablet.info por Ángel García (Hal9000)