3 puntos por GN⁺ 2025-07-14 | 1 comentarios | Compartir por WhatsApp
  • 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 rsp que actúa como stack pointer, o rsi/rdi, que funcionan como índices para manejo de cadenas
  • rip es el instruction pointer y rflags es 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 bits
  • entry 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 ejecutable
  • start: : da nombre al punto de entrada definido antes
  • int3 : breakpoint para el depurador, usado para pausar el programa e inspeccionar su estado
  • ret : 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 a RtlExitUserThread, 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 registro rcx, que se usa como primer argumento (código de salida)

  • call [ExitProcess] : salta a la dirección real de la función ExitProcess registrada 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 registro rcx y 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

 
GN⁺ 2025-07-14
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

    • Me pregunto si comparaste directamente el código que escribiste con el código que genera el compilador de C++
      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í

    • Me pregunto si haces alguna validación de entradas
      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

    • Me da curiosidad qué otras sintaxis de ensamblador existen
  • Sí me gustaría hacer algo en ensamblador, pero no se me ocurre ninguna idea en particular

    • Recomiendo el juego TIS-100
      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