El ensamblador inline es una extensión de GCC que permite insertar instrucciones de lenguaje máquina directamente dentro de un bloque de código C. No forma parte del estándar ISO C (como C11), pero es una herramienta fundamental en sistemas de alto rendimiento. Cuando usas la sintaxis básica asm("instrucción"), simplemente insertas texto que el compilador pasará al ensamblador, pero el compilador no tiene idea de qué registros se están usando o qué variables se ven afectadas.
Para un uso profesional, utilizamos la sintaxis extendida: asm(plantilla : outputs : inputs : clobbers). Esta estructura permite que el compilador actúe como un “pegamento” inteligente: él se encarga de cargar los valores de las variables de C en los registros adecuados antes de la ejecución, ejecuta tu código y luego mueve los resultados de los registros de vuelta a las variables de C.
Utilizamos este mecanismo cuando necesitamos instrucciones que el compilador no conoce o no sabe optimizar, como cpuid para consultar el fabricante del procesador, rdtsc para obtener el contador de ciclos de tiempo con precisión de nanosegundos, o instrucciones SIMD específicas para procesamiento de señales. Si intentas usarlo para operaciones aritméticas simples, probablemente estés cometiendo un error; para eso existen las intrinsics (como <immintrin.h>), que son funciones especiales que ofrecen control de hardware con la seguridad de la sintaxis de C.
Si implementas mal la sintaxis extendida, el error más grave no es un error de compilación, sino la corrupción silenciosa de registros. Si tu código ensamblador modifica un registro (como rax o rcx) pero no se lo notificas al compilador en la lista de clobbers, el optimizador del compilador puede haber asignado ese mismo registro a una variable local de C. Al terminar tu bloque asm, esa variable tendrá un valor arbitrario, provocando un comportamiento indefinido (UB) que solo aparecerá en compilaciones con optimizaciones de alto nivel (-O2 o -O3).
#include <stdint.h>
#include <stdio.h>
/*
* Obtiene el timestamp actual del CPU usando la instrucción rdtsc.
* Esta instrucción carga los 64 bits en edx:eax.
*/
uint64_t read_tsc(void) {
uint32_t lo, hi;
// Usamos asm volatile para evitar que el optimizador eluda la instrucción
// si considera que el resultado no se usa de forma "visible".
// "=a" vincula el registro eax con la variable 'lo'.
// "=d" vincula el registro edx con la variable 'hi'.
// "cc" avisa que la instrucción modifica los flags de condición.
__asm__ volatile (
"rdtsc"
: "=a" (lo), "=d" (hi)
:
: "cc"
);
return ((uint64_t)hi << 32) | lo;
}
/*
* Realiza una rotación a la izquierda (ROL) de 64 bits.
* Implementada manualmente para demostrar el uso de inputs y outputs.
*/
uint64_t rotl64(uint64_t value, uint64_t shift) {
uint64_t result;
// %0 es el primer operando (result, el destino).
// %1 es el segundo operando (value, el origen).
// %2 es el tercero (shift, el monto de rotación).
// Usamos la sintaxis AT&T por defecto en GCC: 'instrucción src, dest'.
__asm__ (
"rolq %2, %1"
: "=r" (result) // El compilador elige un registro para result y lo asigna a %0
: "r" (value), "r" (shift) // El compilador carga value y shift en registros
: "cc"
);
return result;
}
int main(void) {
uint64_t start_time = read_tsc();
uint64_t data = 0x000000000000000F;
uint64_t rotated = rotl64(data, 4);
uint64_t end_time = read_tsc();
// Verificamos que 0xF rotado 4 bits sea 0xF0
if (rotated == 0xF0) {
printf("Operación exitosa.\n");
printf("Ciclos transcurridos: %lu\n", end_time - start_time);
return 0;
}
return 1;
}
Desglose del código
En la función read_tsc, la instrucción rdtsc es crítica porque devuelve dos registros de 32 bits (edx y eax) que deben combinarse para formar un entero de 64 bits. Para que esto sea seguro, usamos las restricciones de salida de GCC: =a para forzar la captura de eax y =d para edx. Si no usáramos estas restricciones, el compilador no sabría que el valor de estas variables depende del ensamblador. Además, incluimos "cc" en la lista de clobbers porque, aunque rdtsc no suele alterar los flags de forma crítica para el flujo de C, es una buena práctica informar que la máquina de estado de los flags ha sido modificada.
En rotl64, la complejidad aumenta. La restricción "r" para value y shift le dice al compilador: “Toma estas variables de C y ponlas en cualquier registro general que tengas libre”. El compilador decide qué registros usar. La plantilla de instrucción rolq %2, %1 usa la sintaxis AT&T, donde el orden es instrucción origen, destino. Aquí, %1 (el valor original) actúa como el registro de destino donde se realiza la operación, y el compilador se encarga de mover ese resultado final a la variable result mediante la restricción de salida "=r".
El uso de __asm__ volatile en read_tsc es vital. Sin volatile, el compilador podría ver que start_time y end_time se usan para una resta y, al observar que rdtsc no tiene efectos secundarios visibles en la memoria, podría decidir que la primera llamada es redundante y eliminarla, haciendo que end_time - start_time sea siempre cero.
El error frecuente
El error más insidioso es modificar un registro que no has declarado como salida o como clobber.
// ERROR: Corrupción de registros
uint64_t x = 10;
uint64_t y = 20;
// Queremos sumar 1 a 'x', pero lo hacemos modificando RAX directamente
__asm__ (
"incq %rax"
: // ¡No hay outputs!
: "r"(x) // El compilador cree que 'x' está en un registro cualquiera, no en RAX
: // ¡No avisamos que RAX ha cambiado!
);
// 'y' podría ser cualquier valor basura si el compilador usaba RAX para 'y'
En este ejemplo, si el compilador decidió optimizar y guardándola en rax, la instrucción incq %rax la modificará sin que el compilador se entere. Al usar AddressSanitizer o Valgrind, es posible que no detectes esto, ya que es un error de lógica de registro y no una violación de memoria. La regla de oro es: si tu ensamblador toca un registro, ese registro debe estar en la lista de outputs o en la lista de clobbers.
N° 114