La CPU: fetch–decode–execute y el clock
Una CPU no está "corriendo C". Está corriendo un loop diminuto en hardware: buscar
los bytes de la próxima instrucción, decodificar qué significan, ejecutar la
operación, escribir el resultado y decidir qué instrucción viene después. El clock le
da ritmo a ese loop, pero no significa "una instrucción por tick". Cuando podés
señalar rip y decir "los próximos bytes salen de ahí", el código fuente empieza a
volverse una cosa física.
El reset: ejecutar es solo estado cambiando en el tiempo. Los registros guardan valores, la memoria guarda bytes,
ripapunta a la próxima instrucción y el clock mantiene a la máquina avanzando por ese estado.
El loop que la CPU nunca deja de correr
El modelo clásico es fetch -> decode -> execute -> write-back. Es la historia mínima útil de un procesador:
| Etapa | Qué pasa |
|---|---|
| Fetch | Lee bytes de instrucción desde la memoria en la dirección del program counter |
| Decode | Convierte esos bytes en una operación: add, mov, ret, un branch, un load |
| Execute | Usa la ALU, la unidad de load/store, la unidad de branch u otro hardware para hacer el trabajo |
| Write-back | Confirma el resultado visible en un registro, memoria, flags o el próximo rip |
La idea importante de von Neumann es que instrucciones y datos viven en la misma memoria direccionable. Un byte puede ser parte de tu string, parte de tu entero o parte del stream de instrucciones; el contexto decide. Tu programa compilado no es texto mágico. Son bytes cargados en memoria. La CPU busca algunos de esos bytes como instrucciones, y esas instrucciones pueden leer o escribir otros bytes como datos.
Por eso la programación de bajo nivel aplasta abstracciones todo el tiempo. Una expresión de C se vuelve instrucciones. Una instrucción se vuelve bytes. Esos bytes viven en direcciones. La CPU solo ve las últimas dos capas: direcciones y bytes.
El instruction pointer es el señalador
Toda arquitectura tiene un registro cuyo trabajo es "dónde está la próxima
instrucción?". El nombre genérico es program counter; en x86-64 el registro
arquitectural es rip, el instruction pointer (puntero de instrucción).
Para código lineal, rip avanza por la longitud de la instrucción que se acaba de
decodificar. En x86-64 esa longitud es variable: una instrucción puede medir 1 byte,
otra 3, otra 10 o más. La CPU no suma una cantidad fija. Decodifica bytes suficientes
para conocer la longitud de la instrucción, y después avanza rip a la siguiente.
El control flow es solamente código que escribe el señalador en otro lado:
- Un salto condicional cambia
ripsolo si su condición es verdadera. - Un salto incondicional escribe un nuevo target en
rip. - Un
callguarda una dirección de retorno en el stack y después redirigeripal callee. - Un
retsaca esa dirección guardada y la vuelve a poner enrip.
Ese es el significado mecánico de "el programa bifurca". No hay un diagrama de flujo pasando dentro de la CPU. Solo hay una próxima dirección.
El clock es un metrónomo, no un cronómetro
Una CPU de 3 GHz tiene un clock que tickea unas tres mil millones de veces por segundo. Un tick es un ciclo. Eso suena a "tres mil millones de instrucciones por segundo", pero ese es el modelo equivocado.
Distintas instrucciones tienen costos distintos. Una suma simple de enteros puede ser barata. Un load que falla en cache puede esperar cientos de ciclos. Una división es mucho más lenta que una suma. Un branch cuya dirección se predijo bien puede ser casi invisible; un branch mal predicho puede tirar trabajo que la CPU ya había empezado. Latencia es "cuánto falta para que este resultado esté listo"; throughput es "cada cuánto puede la máquina empezar o terminar este tipo de trabajo". No son el mismo número.
Las CPUs modernas también rompen instrucciones en unidades internas de trabajo, superponen muchas instrucciones y emiten más de una operación por ciclo cuando las dependencias lo permiten. Entonces el clock no promete que una línea de fuente, una instrucción o una operación termine por tick. Es el ritmo contra el que se mide todo ese progreso de hardware.
Trazalo a mano
Acá hay un programita C que podés compilar y correr:
#include <stdio.h>
__attribute__((noinline))
int add_then_double(int a, int b) {
int sum = a + b;
return sum * 2;
}
int main(void) {
printf("%d\n", add_then_double(7, 5));
return 0;
}
Buildealo localmente:
cc -Wall -Wextra -O2 -fomit-frame-pointer demo.c -o demo && ./demo
24
Ahora pegá solo la función en Compiler Explorer, elegí un
compilador x86-64 GCC o Clang, usá -O2 -fomit-frame-pointer -masm=intel, y ocultá
directives/comments. Una salida x86-64 típica es:
add_then_double:
lea eax, [rdi + rsi]
add eax, eax
ret
Bajo la System V AMD64 calling convention (convención de llamada), el primer
argumento entero llega en edi, el segundo en esi, y el valor entero de retorno
sale en eax. Las direcciones exactas dependen del loader, pero asumí que la función
empieza en 0x401120:
| Paso | rip antes del fetch |
Instrucción | Qué hace execute/write-back | rip después |
|---|---|---|---|---|
| 1 | 0x401120 |
lea eax, [rdi + rsi] |
Calcula 7 + 5, escribe 12 en eax |
0x401123 |
| 2 | 0x401123 |
add eax, eax |
Suma eax consigo mismo, escribe 24 en eax, actualiza flags |
0x401125 |
| 3 | 0x401125 |
ret |
Saca la dirección de retorno del caller desde [rsp] y la pone en rip |
dirección del caller |
Dos detalles importan acá. Primero, lea no está cargando memoria en esta forma; usa
el hardware de generación de direcciones como aritmética. Segundo, escribir eax en
x86-64 también pone en cero la mitad alta de rax, así que el registro de retorno
completo de 64 bits queda limpio aunque el tipo de retorno de C sea int.
Ese cuadro es el movimiento mental: una llamada a función no es una visita abstracta
a un bloque con nombre. Es rip aterrizando sobre bytes, esos bytes mutando
registros, y ret restaurando rip a la continuación guardada.
Las CPUs modernas preservan el modelo, no el mecanismo
Las CPUs reales de alta performance no terminan literalmente una instrucción antes de buscar la siguiente. Pipelinean las etapas fetch/decode/execute para que muchas instrucciones estén en vuelo a la vez. Son superscalar, lo que significa que pueden emitir varias operaciones en un ciclo. Ejecutan fuera de orden cuando trabajo independiente puede correr antes que trabajo más viejo bloqueado. Predicen branches para que el fetch pueda seguir antes de que el branch se resuelva.
Eso no vuelve inútil a fetch-decode-execute. Significa que el modelo es
arquitectural, no microscópico. La CPU trabaja fuerte para preservar la ilusión
de que tu thread único ejecutó en orden de programa, con registros y memoria quedando
como si cada instrucción hubiera terminado una después de la otra. Cuando leés
assembly, debuggeás un crash, razonás sobre un branch o entendés qué hace ret, ese
modelo arquitectural es el que necesitás primero.
La maquinaria más profunda importa después para performance. Pipelining, cache misses, branch prediction y ejecución fuera de orden explican por qué dos streams de instrucciones con el mismo resultado pueden tener costos muy distintos. Pero no podés razonar sobre los trucos hasta que el modelo simple esté firme.
Apéndice ARM64
En AArch64, la misma función tiene la misma forma pero registros distintos e
instrucciones de ancho fijo. Primer argumento entero: w0. Segundo: w1. Retorno
entero: w0.
add_then_double:
add w8, w1, w0
lsl w0, w8, #1
ret
Cada instrucción AArch64 acá mide 4 bytes, así que el program counter avanza en pasos limpios de 4 bytes para código lineal. La historia arquitectural sigue siendo la misma: fetch en el program counter, decode, execute, write-back, avanzar o saltar.
En la práctica
- No leas GHz como velocidad. La frecuencia de clock es una entrada. La mezcla de instrucciones, las cadenas de dependencias, la latencia de memoria, branch prediction y la salida del compilador deciden cuánto trabajo útil pasa por ciclo.
- No asumas que una línea de fuente es una instrucción. El optimizador puede
borrarla, combinarla, duplicarla o reemplazarla por una instrucción como
leacuyo nombre no se parece a la operación C. - Cuando el control flow se vuelve misterioso, buscá
rip. En un debugger, el instruction pointer actual te dice dónde está realmente la ejecución. El call stack es una historia reconstruida desde direcciones de retorno guardadas y metadata de frames/unwind. - Usá primero el modelo simple, después refiná. Fetch-decode-execute es la primera abstracción correcta. Pipelining y ejecución fuera de orden explican performance, no el significado básico de tu programa.
Conecta con: Modelo de Máquina · Ahora el runtime sos vos · Assembly y Salida del Compilador · Toolchain y Linking
Fuentes
- Bryant & O'Hallaron — Computer Systems: A Programmer's Perspective (CS:APP), cap. 3–4 — C a nivel máquina, secuenciación de instrucciones y el modelo de arquitectura del procesador. https://csapp.cs.cmu.edu/
- Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 1–2 — la autoridad x86-64 para
rip, encoding de instrucciones y semántica de instrucciones. https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html - Compiler Explorer — la forma más rápida de verificar cómo un fragmento concreto de C se vuelve assembly x86-64 o AArch64 bajo compiladores reales. https://godbolt.org/
- Charles Petzold — Code: The Hidden Language of Computer Hardware and Software — construye la intuición de que las instrucciones son bytes interpretados por hardware.
- Ben Eater — Build an 8-bit computer from scratch — una CPU fetch/decode/execute visible, construida con partes simples; ideal para volver concreto el loop de control. https://eater.net/8bit
- Agner Fog — optimization manuals — referencia avanzada para latencia de instrucciones, throughput, pipelining y costo microarquitectural real. https://www.agner.org/optimize/