Aprender ensamblador x86-64
(gpfault.net)- Presenta la primera entrega de una serie de introducción al ensamblador x86-64
- Explica la instalación de herramientas y la estructura básica con base en sistemas modernos de 64 bits
- Indica el uso de Flat Assembler (FASM) y WinDbg como herramientas principales de desarrollo y depuración
- Incluye un resumen de conocimientos clave necesarios en la práctica, como el formato PE, la importación de DLL y la convención de llamadas de Windows
- Describe, con enfoque práctico, la creación de un programa simple de finalización y el procedimiento de depuración
Introducción e importancia
- Al encontrarse por primera vez con ensamblador x86, en la universidad se solía aprender con enfoques basados en entornos antiguos (16 bits, DOS, memoria segmentada)
- Hoy, como los procesadores de 64 bits son la norma, esta serie trata únicamente el entorno x86-64 que realmente se usa, dejando fuera todos los elementos heredados
- Este tutorial se enfoca en el desarrollo de programas de 64 bits que funcionan en el entorno del sistema operativo Windows
- Comienza desde el código mínimo que accede directamente al sistema operativo, sin usar bibliotecas
- Este artículo está dirigido a desarrolladores que quieren aprender ensamblador por primera vez, y asume conocimientos básicos de C/C++
Preparación de herramientas de desarrollo
Ensamblador (Assembler)
- La CPU solo puede interpretar código máquina, que es difícil de entender para los humanos, y el lenguaje ensamblador es una representación de ese código en una forma legible por personas
- El programa que convierte lenguaje ensamblador en código máquina es el ensamblador
- El ensamblador x86-64 no tiene un estándar único definido, y cada ensamblador se diferencia en sintaxis y comportamiento
- En esta serie se usa Flat Assembler (FASM), que es pequeño, fácil de usar y ofrece un potente sistema de macros y editor
Depurador (Debugger)
- Para analizar el código ensamblador escrito y observar el flujo de ejecución, el depurador es una herramienta esencial
- Se recomienda WinDbg, ya que permite revisar y manipular de forma independiente registros, memoria, código ensamblador y más
- Puede instalarse seleccionando solo ese componente dentro del Windows 10 SDK
- Con el depurador se puede observar directamente el estado interno del programa, la estructura de memoria y los cambios en los registros
La perspectiva de la programación en ensamblador
Estructura de la CPU y conjunto de instrucciones
- La CPU solo puede realizar acciones limitadas de acuerdo con un conjunto de instrucciones específico
- Una instrucción es la unidad básica de trabajo que la CPU puede ejecutar
- Cada instrucción funciona de manera muy simple junto con sus parámetros (guardar valores, operaciones aritméticas, etc.)
- Para la programación de bajo nivel y la depuración, es clave entender que esta estructura es la base de todos los conceptos de alto nivel
Registros (Registers)
- Los registros son una región de memoria especializada y muy rápida integrada dentro de la CPU
- En x86-64 hay 16 registros de propósito general, todos de 64 bits
- Cada registro puede accederse parcialmente en unidades de byte, word y doubleword
| Registro | Byte inferior | Word inferior | Doubleword inferior |
|---|---|---|---|
| rax | al | ax | eax |
| rbx | bl | bx | ebx |
| rcx | cl | cx | ecx |
| rdx | dl | dx | edx |
| rsp | spl | sp | esp |
| rsi | sil | si | esi |
| rdi | dil | di | edi |
| rbp | bpl | bp | ebp |
| r8~r15 | r8b~r15b | r8w~r15w | r8d~r15d |
- A algunos registros se les asignan propósitos especiales, como
rspque actúa como stack pointer, orsi/rdi, que funcionan como índices para manejo de cadenas ripes el instruction pointer yrflagses un registro especial que contiene las banderas de estado del resultado de las operaciones
Memoria y direcciones
- La memoria funciona como un arreglo continuo de bytes a partir del índice 0
- En la arquitectura x86 antigua, el esquema segmento-desplazamiento era obligatorio, pero en x86-64 toda la memoria se maneja como un espacio de direcciones plano (Flat)
- En la práctica, el sistema operativo y el hardware asignan dinámicamente a la memoria física un espacio de direcciones virtual por proceso
- Es decir, incluso la misma dirección virtual puede corresponder a distinta memoria física en procesos diferentes
- Las instrucciones y los datos existen en la misma memoria (arquitectura de von Neumann), lo que la distingue de arquitecturas Harvard como AVR, usado en Arduino, donde los datos se almacenan por separado
Escribir el primer programa en ensamblador
- Después de instalar FASM, se practica escribiendo y compilando el siguiente programa sencillo
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
ret
Explicación del código
format PE64 NX GUI 6.0: especifica el formato del ejecutable que generará FASM; aquí es PE (Portable Executable) GUI de 64 bitsentry start: define el entry point del programa; la ejecución comienza en la posición de esa etiqueta (start)section '.text' code readable executable: indica que es la sección de código del PE, y que es una región ejecutablestart:: da nombre al punto de entrada definido antesint3: breakpoint para el depurador, usado para pausar el programa e inspeccionar su estadoret: instrucción que toma una dirección del stack y transfiere el control a esa ubicación; en este programa responde con una finalización inmediata
Práctica de depuración
-
En WinDbg se abre el ejecutable (.exe) del programa anterior y se preparan varias ventanas, como desensamblado y registros
-
Se presiona F5 para que el programa llegue al breakpoint, y luego F8 para ejecutar una instrucción a la vez (avance paso a paso)
-
Es posible observar en tiempo real los cambios en registros como
rip -
Después de ejecutar
ret, el control vuelve al sistema operativo, y luego se llama aRtlExitUserThread, continuando con la finalización del hilo y del proceso -
Nota: si se termina solo con la instrucción
ret, el proceso puede permanecer dependiendo de si existen otras ejecuciones en segundo plano además del hilo, por lo que para una finalización correcta es recomendable llamar siempre a ExitProcess
Formato PE e importación de DLL
Resumen de la estructura de importación de funciones DLL
- Las funciones WinAPI como ExitProcess están en KERNEL32.DLL
- Para usar estas funciones externas, hay que construir la tabla de importación del ejecutable (sección
.idata) - La Import Directory Table (IDT) de la sección idata contiene información como el nombre de la DLL, nombre de función y direcciones RVA de la IAT/ILT
- La IAT (Import Address Table) es sobrescrita en tiempo de ejecución por el cargador del sistema operativo con las direcciones reales de las funciones
- La Hint/Name Table está compuesta por el nombre de cada función y su información de hint
Ejemplo de definición de la sección .idata en FASM
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name: db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
- db/dw/dd/dq : insertan valores en unidades de byte/word/doubleword/quadword (8 bytes)
- rva : calcula la dirección virtual relativa (Relative Virtual Address) de un símbolo
- Es posible referenciar funciones DLL construyendo manualmente la IAT y la Name Table
Convención de llamadas de Windows de 64 bits (MS x64 Calling Convention)
- Es la convención estándar que define cómo se pasan argumentos y cómo se usa el stack al llamar funciones
- En Windows de 64 bits se usa la Microsoft x64 Calling Convention
- Características principales:
- El stack pointer siempre debe estar alineado a 16 bytes
- Los primeros 4 argumentos enteros/punteros usan los registros rcx, rdx, r8, r9
- Los primeros 4 argumentos de punto flotante se colocan en xmm0~xmm3
- Los argumentos adicionales usan el stack
- Sin importar cuántos argumentos haya, deben reservarse 32 bytes de shadow space en el stack
- El llamador se encarga de limpiar el stack
Ejemplo de llamada a ExitProcess
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
sub rsp, 8 * 5
xor rcx, rcx
call [ExitProcess]
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
Análisis del código nuevo
-
sub rsp, 8 * 5: ajusta el stack pointer (reserva 40 bytes), resolviendo al mismo tiempo la alineación a 16 bytes y la reserva del shadow space -
xor rcx, rcx: asigna 0 al registrorcx, que se usa como primer argumento (código de salida) -
call [ExitProcess]: salta a la dirección real de la funciónExitProcessregistrada en la tabla de importación -
Al ejecutar paso a paso en WinDbg, se pueden verificar directamente los cambios en el stack pointer (
rsp), en el registrorcxy en el flujo de terminación del proceso
Cierre
- Este artículo guía de forma práctica el flujo general del ensamblador x86-64, desde la configuración básica de herramientas hasta el formato PE, la importación de DLL, la convención de llamadas x64, la escritura del primer programa y la depuración
- En la siguiente parte se abordarán implementaciones más variadas y código real
1 comentarios
Opiniones en Hacker News
Quiero compartir un proyecto que he venido desarrollando durante algunos años
https://asm-editor.specy.app
Es un IDE interactivo en línea que soporta varios lenguajes ensambladores como M68K, MIPS, RISC-V y X86
Tiene muchas funciones pensadas para enseñar programación en ensamblador
También se puede incrustar en otros sitios web
No sabía que existía acceso directo al byte de baja dirección en los registros de índice de punteros (por ejemplo, en 16/32 bits se puede acceder a si/esi como sil)
Es un concepto parecido a acceder a al desde ax/eax
Me pregunto si realmente existen nuevos opcodes agregados en x86_64
Siento que debería volver a revisar la especificación de la plataforma
Lo pregunto por pura curiosidad
Comparto un material introductorio sobre ensamblador que escribí personalmente
https://www.nayuki.io/page/a-fundamental-introduction-to-x86-assembly-programming
Me dio curiosidad saber si podía hacer que el dispatch de mi emulador de CPU fuera más rápido que en C++, así que intenté optimizarlo en ensamblador
Probé ejecutando un programa de Fibonacci, pero el resultado ni siquiera se acercó
Al final solo lo integré como una opción desactivada por defecto
Aun así, sigo creyendo que debe haber una forma de hacerlo más rápido
https://github.com/libriscv/libriscv/blob/master/lib/libriscv/amd64/inaccurate_dispatch.nasm
Aprendiendo a acceder a memoria logré mejorar un poco el rendimiento
Reduje la jump table de 64 bits a 32 bits y la hice entrar en la sección .text para usar acceso RIP-relative
El programa de Fibonacci no necesitaba mucho bytecode
De verdad me gustaría escuchar consejos sobre qué más podría mejorar
No conozco bien el contexto, pero creo que la diferencia podría no deberse al mecanismo de dispatch (la forma de hacer fetch de instrucciones), sino a diferencias en la implementación real de las instrucciones
Como optimización, podrías mapear los registros emulados a registros reales de x86-64 y evitar por completo que se derramen a memoria
Así, operaciones como add podrían ejecutarse directamente sin tener que sacar datos de memoria
Eso sí, este enfoque hace que escribir el emulador sea mucho más engorroso
Es un material introductorio a ensamblador x86 con práctica directamente en el navegador
Puedes ejecutar los ejemplos de inmediato sin hacer configuración local
https://shikaan.github.io/assembly/x86/guide/2024/09/08/x86-64-introduction-hello.html
Como referencia, yo lo escribí
Parece que ensambla directamente con NASM y luego ejecuta el binario, así que me dio curiosidad el tema de seguridad
Solo por la foto de perfil pensé que era junferno
Aunque sea tocar ensamblador una sola vez, siempre resulta una buena experiencia porque profundiza la comprensión general
Ni siquiera hace falta crear un proyecto grande, así que recomiendo animarse a probar aunque sea un poco
Comparto el enlace a la discusión de HN de ese momento (2020)
https://news.ycombinator.com/item?id=24195627
Me alegra que use la sintaxis de ensamblador estilo Intel
Sí me gustaría hacer algo en ensamblador, pero no se me ocurre ninguna idea en particular
Es un juego de resolver acertijos con una especie de pseudoensamblador
Creo que este tipo de juegos puede calmar esas ganas de meterse con ensamblador