← SEARCHABLE ARCHIVE
Assembly · 2026-08-01

¿Cómo se convierte C en lenguaje ensamblador?

Desde el preprocesado y el enlazado hasta las convenciones x86-64 y el recorrido completo de stdio.h

INTERACTIVE READING MODE →

¿Cómo se convierte C en lenguaje ensamblador?

Los artículos de este blog están escritos con fines didácticos. La versión del compilador, el sistema operativo, la ABI y las opciones de optimización pueden cambiar el código generado, así que conviene verificar los detalles importantes en el entorno real.

Ejecutar gcc -S file.c es sencillo. Leer el archivo .s resultante no siempre lo es: #include <stdio.h> ha desaparecido, la extensa implementación de printf se reduce a un call en el punto de uso y los ejemplos de Linux y Windows emplean registros de argumentos distintos.

En lugar de memorizar instrucciones aisladas, resulta más útil separar el recorrido completo desde el código C hasta el programa en ejecución. Este artículo cubre el preprocesado, la compilación, el ensamblado, el enlazado, las convenciones de llamada x86-64, la optimización y las 45 funciones centrales de stdio.h.

Salvo indicación contraria, los ejemplos usan x86-64 Linux, la ABI System V y sintaxis Intel. El estándar de C no fija una CPU ni una sintaxis de ensamblador.

Cinco etapas hasta ejecutar el programa

  1. El preprocesador resuelve #include, #define y #if.
  2. El compilador analiza el C preprocesado y genera ensamblador para el destino.
  3. El ensamblador codifica instrucciones y metadatos en un archivo objeto.
  4. El enlazador resuelve símbolos y combina objetos con bibliotecas.
  5. El cargador del sistema operativo coloca el ejecutable y las bibliotecas compartidas en memoria.

Cuando se habla de convertir C a ensamblador, normalmente se quiere detener la segunda etapa y conservar un archivo .s o .asm.

Un ejemplo pequeño

#include <stdio.h>

static int add(int left, int right)
{
    return left + right;
}

int main(void)
{
    int value = add(20, 22);
    printf("value = %d\n", value);
    return 0;
}

El programa contiene una llamada, datos locales, un literal de cadena y una función variádica, pero sigue siendo lo bastante pequeño para examinarlo con detalle.

Detener GCC en cada etapa

gcc -std=c17 -E stdio_trace.c -o stdio_trace.i
gcc -std=c17 -O0 -fno-omit-frame-pointer -S -masm=intel -fverbose-asm stdio_trace.c -o stdio_trace.s
gcc -std=c17 -c stdio_trace.c -o stdio_trace.o
gcc stdio_trace.o -o stdio_trace

-E conserva el resultado del preprocesador, -S el ensamblador y -c termina después del objeto. -masm=intel selecciona sintaxis Intel. Comparar -O0 con -O2 permite estudiar tanto una forma reconocible del código fuente como el programa realmente optimizado.

gcc -std=c17 -O2 -save-temps=obj stdio_trace.c -o stdio_trace

-save-temps=obj guarda varios archivos intermedios en una sola compilación.

Clang y MSVC

clang -std=c17 -O0 -S -masm=intel stdio_trace.c -o stdio_trace.s
clang -std=c17 -O2 -S -emit-llvm stdio_trace.c -o stdio_trace.ll

LLVM IR no es ensamblador de CPU. Es una representación intermedia anterior a la selección final de instrucciones.

MSVC puede crear un listado desde una Developer Command Prompt.

cl /nologo /std:c17 /Od /FAs /Fa:stdio_trace.asm stdio_trace.c
cl /nologo /std:c17 /O2 /FAs /Fa:stdio_trace_opt.asm stdio_trace.c

Listado del compilador y desensamblado

El listado del compilador puede contener etiquetas, secciones, directivas y comentarios del fuente. El desensamblado lee instrucciones ya codificadas desde un objeto o ejecutable.

objdump -d -Mintel stdio_trace.o
objdump -d -Mintel stdio_trace

Después del enlazado pueden aparecer entradas para enlace dinámico y direcciones reubicadas. Los símbolos de depuración conservan nombres útiles, pero no reconstruyen el C original.

Lectura mínima de sintaxis Intel

La sintaxis Intel suele escribir primero el destino.

mov     eax, 42
add     eax, 8
cmp     eax, 50
je      .equal

mov copia datos, lea calcula una expresión de dirección, cmp establece indicadores y los saltos condicionales los consultan. call guarda la dirección de retorno y transfiere el control; ret vuelve al llamador. Los corchetes representan acceso a memoria.

Leer add sin optimización

Una compilación System V orientada a depuración puede producir algo parecido a esto:

add:
    push    rbp
    mov     rbp, rsp
    mov     DWORD PTR [rbp-4], edi
    mov     DWORD PTR [rbp-8], esi
    mov     edx, DWORD PTR [rbp-4]
    mov     eax, DWORD PTR [rbp-8]
    add     eax, edx
    pop     rbp
    ret

Los dos primeros enteros llegan en edi y esi, y el resultado se coloca en eax. Con optimización pueden desaparecer las copias a la pila, la función puede reducirse a lea y ret, o integrarse por completo en el llamador.

System V x86-64 usa normalmente rdi, rsi, rdx, rcx, r8 y r9 para los seis primeros enteros o punteros. El resultado entero usa rax y los valores de coma flotante emplean registros XMM.

Windows x64 usa normalmente rcx, rdx, r8 y r9 para los cuatro primeros argumentos. El llamador reserva además 32 bytes de shadow space. Nunca se debe interpretar el papel de un registro sin identificar antes la ABI.

Entender la llamada a printf

    mov     esi, 42
    lea     rdi, .LC0[rip]
    xor     eax, eax
    call    printf@PLT

rdi recibe la dirección del formato y esi el entero 42. En una llamada variádica de System V, al comunica además cuántos registros vectoriales contienen argumentos de coma flotante; por eso aquí se pone eax a cero. printf@PLT es una entrada de enlace dinámico, no toda la implementación de printf.

La optimización borra la forma del C

El compilador puede integrar add, calcular 20 + 22 durante la compilación, no reservar espacio para value y eliminar código inalcanzable. También puede convertir un printf("hello\n") constante en puts cuando conserva el comportamiento observable permitido.

Qué proporciona realmente <stdio.h>

Incluir la cabecera no copia toda la biblioteca de E/S en el fuente. El preprocesado expone tipos, macros y declaraciones; la implementación suele permanecer en la biblioteca de ejecución de C.

La abstracción central es el flujo representado por FILE *. Un objeto FILE puede mantener el búfer, la posición, los indicadores de EOF y error y datos de sincronización. Sus miembros son detalles de implementación. Al comenzar el programa existen stdin, stdout y stderr.

Los tipos importantes incluyen FILE, fpos_t y size_t. Entre las macros están EOF, NULL, BUFSIZ, FOPEN_MAX, FILENAME_MAX, L_tmpnam, TMP_MAX, _IOFBF, _IOLBF, _IONBF, SEEK_SET, SEEK_CUR y SEEK_END.

Las 45 funciones centrales

Operaciones de archivo, 4

FunciónFinalidad
removeeliminar un archivo por nombre
renamecambiar el nombre de un archivo
tmpfilecrear un flujo binario temporal
tmpnamgenerar un nombre temporal; es difícil usarlo con seguridad

En programas reales es preferible una API segura de la plataforma que cree y abra un archivo único de forma atómica.

Apertura, cierre y búfer, 6

fclose, fflush, fopen, freopen, setbuf y setvbuf.

Los modos habituales son r, w, a, r+, w+ y a+, con b cuando corresponde. w trunca el contenido existente. Siempre hay que comprobar el resultado de apertura. El C portable no usa fflush(stdin) para descartar entrada.

Salida con formato, 8

fprintf, printf, sprintf, snprintf, vfprintf, vprintf, vsprintf y vsnprintf.

Una conversión sigue en general %[flags][width][.precision][length]specifier. El formato y el tipo real deben coincidir. Un texto no confiable nunca debe convertirse directamente en formato. En argumentos variádicos, float se promociona a double y los enteros estrechos suelen promocionarse a int.

Entrada con formato, 6

fscanf, scanf, sscanf, vfscanf, vscanf y vsscanf.

Los destinos suelen ser punteros escribibles del tipo correcto. %s necesita un ancho de campo y se debe revisar el valor devuelto, que indica las asignaciones correctas. Para entrada interactiva suele ser más sencillo leer una línea con fgets y analizarla por separado.

E/S de caracteres y cadenas, 10

fgetc, fgets, fputc, fputs, getc, getchar, putc, putchar, puts y ungetc.

Un carácter leído debe guardarse en int para poder distinguir todos los valores unsigned char de EOF.

E/S por bloques, 2

fread y fwrite transfieren matrices de objetos. Su resultado es el número de elementos completos, no necesariamente bytes. Guardar una estructura en bruto no crea un formato portable por las diferencias de padding, orden de bytes y tamaños de tipos.

Posición de archivo, 5

fgetpos, fseek, fsetpos, ftell y rewind. Las posiciones de flujos de texto tienen reglas adicionales. Los archivos muy grandes suelen requerir extensiones de 64 bits de la plataforma.

Estado y diagnóstico, 4

clearerr, feof, ferror y perror. EOF solo se conoce después de intentar leer más allá del final. El bucle debe depender del resultado de lectura y no de while (!feof(file)).

Annex K y extensiones

Algunas implementaciones ofrecen fopen_s, fprintf_s o scanf_s, pero no son un reemplazo central disponible en todos los entornos C. Las variantes _s de Microsoft también tienen reglas de parámetros propias. Conviene separar ISO C, POSIX y las extensiones de Windows.

La E/S estándar no equivale a una llamada al sistema por función

printf suele formatear en espacio de usuario y actualizar un búfer de FILE. Un vaciado posterior puede llegar a una API del sistema operativo como write en POSIX. El búfer reduce transiciones costosas al núcleo.

getc y putc pueden implementarse como macros, por lo que la ruta rápida del búfer puede quedar integrada. No ver call getc no significa que haya desaparecido la operación de C.

Bucle básico para copiar un archivo binario

unsigned char buffer[64 * 1024];
size_t read_count;

while ((read_count = fread(buffer, 1, sizeof buffer, source)) > 0)
{
    size_t written = 0;
    while (written < read_count)
    {
        size_t count = fwrite(buffer + written, 1,
                              read_count - written, destination);
        if (count == 0)
        {
            break;
        }
        written += count;
    }
}

if (ferror(source))
{
    perror("fread");
}

Un programa completo comprueba también ambos fopen, las escrituras parciales, ferror y los errores de salida que solo aparecen al ejecutar fclose.

Un orden útil para estudiar

Comienza con una función que solo tenga enteros y un valor devuelto. Compara -O0 y -O2. Añade después, de uno en uno, una matriz local, una llamada normal y printf. Compara el objeto con el ejecutable enlazado y repite al final en otra ABI, por ejemplo Windows x64.

Errores frecuentes

  • Suponer que existe una única traducción a ensamblador para un programa C.
  • Considerar -O0 una traducción mecánica línea por línea.
  • No hacer coincidir el formato y los tipos de los argumentos.
  • Usar texto del usuario como formato de printf.
  • No limitar el ancho de scanf("%s", buffer).
  • Usar while (!feof(file)).
  • Depender de fflush(stdin) en código portable.
  • Ignorar un fallo de fclose al escribir.
  • Tratar los bytes de una estructura como formato portable.
  • Deducir reglas generales a partir de comportamiento indefinido.

Desde una línea de printf hasta la salida

<stdio.h> aporta la declaración. El compilador coloca los argumentos según la ABI. El ensamblador codifica una referencia de símbolo. El enlazador o cargador conecta ese símbolo con el runtime de C. Durante la ejecución, la biblioteca formatea los valores, actualiza el búfer y solicita al sistema operativo que escriba bytes cuando corresponde.

Referencias

Resumen

C pasa por preprocesado, compilación, ensamblado, enlazado y carga. El ensamblador visible depende de la ABI y de la optimización. <stdio.h> proporciona declaraciones y macros, mientras que el runtime implementa los flujos. Al separar estas capas, las instrucciones, llamadas, búferes y API del sistema forman un recorrido comprensible.