← Programación en C para Sistemas Operativos Bloque 6 · Estructuras y tipos propios · Tema 26 de 42

Tema 26

Uniones, enumeraciones y typedef

Las estructuras juntan datos distintos, uno al lado del otro. Este tema cierra el bloque 6 con tres herramientas que completan la forma de describir datos en C. Las uniones hacen lo contrario: ponen varios miembros en los mismos bytes, para ver un dato de varias formas o para guardar uno de varios posibles. Las enumeraciones dan nombre a un grupo de constantes enteras, como los estados de un proceso o los tipos de un evento. Y typedef crea un nombre nuevo para un tipo que ya existe, que es como el sistema define pid_t, off_t o FILE. Las tres se combinan en casi todos los programas de sistemas, y las tres tienen trampas que el compilador solo señala a medias.

1. Una unión: varios nombres para los mismos bytes

Una unión (union) se declara igual que una estructura, pero todos sus miembros ocupan el mismo lugar de la memoria: empiezan en el desplazamiento 0, y la unión mide lo que el miembro más grande, redondeado a su alineación. Escribir un miembro modifica los bytes que los demás leen.

basica.c
#include <stdio.h>
#include <stddef.h>

union numero {
    int i;
    float f;
    unsigned char b[4];
};

union mixta {
    char texto[13];
    int i;
    double d;
};

static void bytes(const union numero *n)
{
    for (int k = 0; k < 4; k++)
        printf(" %02x", n->b[k]);
}

int main(void)
{
    union numero n;

    printf("sizeof(union numero) = %zu, alignof = %zu\n", sizeof n, _Alignof(union numero));
    printf("&n = %p, &n.i = %p, &n.f = %p, &n.b = %p\n",
           (void *) &n, (void *) &n.i, (void *) &n.f, (void *) &n.b);

    n.f = 1.0f;
    printf("n.f = %g   n.i = 0x%08x   bytes:", n.f, (unsigned) n.i);
    bytes(&n);
    printf("\n");

    n.i = 0x41424344;
    printf("n.i = 0x%08x   bytes:", (unsigned) n.i);
    bytes(&n);
    printf("   como texto: %c%c%c%c\n", n.b[0], n.b[1], n.b[2], n.b[3]);

    n.i = -1;
    printf("n.i = %d   n.f = %g   (todos los bytes en 0xff)\n", n.i, n.f);

    union mixta m;
    printf("sizeof(union mixta) = %zu, alignof = %zu, sizeof m.texto = %zu\n",
           sizeof m, _Alignof(union mixta), sizeof m.texto);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o basica basica.c
$ ./basica
sizeof(union numero) = 4, alignof = 4
&n = 0x7ffca4fcfc90, &n.i = 0x7ffca4fcfc90, &n.f = 0x7ffca4fcfc90, &n.b = 0x7ffca4fcfc90
n.f = 1   n.i = 0x3f800000   bytes: 00 00 80 3f
n.i = 0x41424344   bytes: 44 43 42 41   como texto: DCBA
n.i = -1   n.f = -nan   (todos los bytes en 0xff)
sizeof(union mixta) = 16, alignof = 8, sizeof m.texto = 13
La unión numero ocupa 4 bytes. Después de asignar n.f igual a 1.0, los bytes valen 00, 00, 80 y 3f. El miembro i abarca los cuatro bytes y los lee como el entero 0x3f800000; el miembro f abarca los cuatro y los lee como el real 1.0; el miembro b es un arreglo de cuatro bytes, donde b[0] es 00, b[1] es 00, b[2] es 80 y b[3] es 3f union numero · 4 bytes · después de n.f = 1.0f 00 00 80 3f i = 0x3f800000 · f = 1.0 (los cuatro bytes, de dos formas) b[0] b[1] b[2] b[3] +0 +1 +2 +3 Los tres miembros empiezan en el mismo byte. Lo que cambia entre uno y otro es el tipo con que se interpretan los bytes, no los bytes: en x86-64, el byte menos significativo va primero.
Una unión no convierte valores: ofrece distintas lecturas de los mismos bytes.

La salida muestra las propiedades de una unión:

Una unión se inicializa por nombre, union numero n = {.f = 1.5f};, o con un valor entre llaves, que inicializa su primer miembro. Nunca más de un miembro a la vez.

structunion
Los miembros estánUno a continuación del otro.Todos en el mismo lugar.
Desplazamiento de cada miembro0, 4, 8… (con relleno).Siempre 0.
TamañoLa suma de los miembros, más relleno.El del miembro más grande, redondeado a la alineación.
Valores a la vezTodos los miembros.Uno solo tiene sentido.
Sirve paraAgrupar datos que van juntos.Alternativas excluyentes, o ver unos bytes de varias formas.

2. Reinterpretar bits y el aliasing estricto

Leer un miembro distinto del último escrito es lo que se llama type punning: reinterpretar los bytes de un valor como si fueran de otro tipo. C lo permite expresamente con las uniones (C++ no). Es útil para inspeccionar la representación de los números. Este programa descompone números reales en los tres campos del formato IEEE 754 de 32 bits: el signo, el exponente y la mantisa:

bits.c
#include <stdio.h>
#include <stdint.h>
#include <math.h>

static void descomponer(float x)
{
    union {
        float f;
        uint32_t u;
    } v = {.f = x};
    unsigned signo = v.u >> 31;
    unsigned exponente = (v.u >> 23) & 0xFF;
    unsigned mantisa = v.u & 0x7FFFFF;

    printf("%-6g 0x%08x  signo=%u  exponente=%3u (%+4d)  mantisa=0x%06x\n",
           x, (unsigned) v.u, signo, exponente, (int) exponente - 127, mantisa);
}

int main(void)
{
    descomponer(1.0f);
    descomponer(0.1f);
    descomponer(-2.5f);
    descomponer(0.0f);
    descomponer(INFINITY);
    descomponer(NAN);
    return 0;
}
terminal
$ ./bits
1      0x3f800000  signo=0  exponente=127 (  +0)  mantisa=0x000000
0.1    0x3dcccccd  signo=0  exponente=123 (  -4)  mantisa=0x4ccccd
-2.5   0xc0200000  signo=1  exponente=128 (  +1)  mantisa=0x200000
0      0x00000000  signo=0  exponente=  0 (-127)  mantisa=0x000000
inf    0x7f800000  signo=0  exponente=255 (+128)  mantisa=0x000000
nan    0x7fc00000  signo=0  exponente=255 (+128)  mantisa=0x400000

Con los operadores de bits del tema 8 se leen los campos. El 1.0 tiene el exponente 127, que representa 2⁰ (el exponente se guarda con un sesgo de 127) y la mantisa en cero, porque el 1 inicial está implícito. El 0.1 no se puede representar exactamente en binario: 0x3dcccccd es el número más cercano, y la mantisa 4ccccd es la secuencia periódica 1100 1100… redondeada, que es la causa de que 0.1 + 0.2 != 0.3. El −2.5 tiene el signo encendido. El infinito y el NaN usan el exponente 255, y se distinguen por la mantisa. Es el tipo de inspección que un depurador de bajo nivel o una herramienta de formato binario necesita, y con una unión es directa.

Hay otra forma de obtener los mismos bits que se ve a menudo, y es incorrecta: convertir la dirección. Este programa compara las tres:

punning.c
#include <stdio.h>
#include <stdint.h>
#include <string.h>

uint32_t con_cast(float f)
{
    return *(uint32_t *) &f;              /* viola el aliasing estricto */
}

uint32_t con_union(float f)
{
    union {
        float f;
        uint32_t u;
    } v = {.f = f};

    return v.u;
}

uint32_t con_memcpy(float f)
{
    uint32_t u;

    memcpy(&u, &f, sizeof u);
    return u;
}

int main(void)
{
    float x = 0.1f;

    printf("cast:   0x%08x\n", (unsigned) con_cast(x));
    printf("union:  0x%08x\n", (unsigned) con_union(x));
    printf("memcpy: 0x%08x\n", (unsigned) con_memcpy(x));
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -O2 -o punning punning.c
punning.c: In function ‘con_cast’:
punning.c:7:13: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
    7 |     return *(uint32_t *) &f;              /* viola el aliasing estricto */
      |             ^~~~~~~~~~~~~~~
$ ./punning
cast:   0x3dcccccd
union:  0x3dcccccd
memcpy: 0x3dcccccd
$ gcc -std=c17 -O2 -S -fno-asynchronous-unwind-tables -fcf-protection=none -o - punning.c | grep -A2 -E '^con_'
con_cast:
	movd	%xmm0, %eax
	ret
con_union:
	movd	%xmm0, %eax
	ret
con_memcpy:
	movd	%xmm0, %eax
	ret

Las tres dieron el mismo resultado, y las tres compilaron a la misma instrucción, movd, que copia el registro de coma flotante a uno entero. Pero no son iguales ante el estándar. La regla del aliasing estricto dice que un objeto solo se puede acceder a través de un puntero a su tipo (o a char): el compilador puede suponer que un uint32_t * y un float * nunca apuntan al mismo lugar, y reordenar o eliminar lecturas y escrituras en consecuencia. con_cast rompe esa suposición, y es comportamiento indefinido aunque aquí funcione; gcc lo avisa con -Wstrict-aliasing cuando optimiza. En un programa más grande, donde el compilador sí reordena, produce resultados que cambian con el nivel de optimización. La unión y memcpy son correctas. memcpy no cuesta nada, porque el compilador la reconoce y no la ejecuta (con_memcpy es una sola instrucción), funciona entre cualquier par de tipos y se entiende en C++; es la forma que conviene preferir, y la que ya usamos en los temas 5 y 18.

3. Uniones etiquetadas

El uso más importante de las uniones no es inspeccionar bytes, sino guardar un valor de uno de varios tipos posibles sin gastar lugar para todos. Una unión no recuerda cuál de sus miembros es el válido, así que el programa tiene que guardarlo aparte: en una estructura, junto a la unión, hay un campo que dice cuál es. Es una unión etiquetada (tagged union), el mecanismo con que se implementan los valores de tipo variable de los intérpretes, los mensajes de los protocolos y los nodos de los árboles sintácticos:

etiquetada.c
#include <stdio.h>

enum tipo { T_ENTERO, T_REAL, T_TEXTO, T_NINGUNO };

struct valor {
    enum tipo tipo;                       /* la etiqueta: qué miembro vale */
    union {                               /* unión anónima (C11) */
        long entero;
        double real;
        const char *texto;
    };
};

static struct valor entero(long n)  { return (struct valor){.tipo = T_ENTERO, .entero = n}; }
static struct valor real(double x)  { return (struct valor){.tipo = T_REAL, .real = x}; }
static struct valor texto(const char *s) { return (struct valor){.tipo = T_TEXTO, .texto = s}; }
static struct valor ninguno(void)   { return (struct valor){.tipo = T_NINGUNO}; }

static void mostrar(const struct valor *v)
{
    switch (v->tipo) {
    case T_ENTERO:
        printf("entero %ld\n", v->entero);
        break;
    case T_REAL:
        printf("real %g\n", v->real);
        break;
    case T_TEXTO:
        printf("texto \"%s\"\n", v->texto);
        break;
    case T_NINGUNO:
        printf("ninguno\n");
        break;
    }
}

int main(void)
{
    struct valor lista[] = {entero(42), real(2.5), texto("sistema"), ninguno(), entero(-7)};

    for (size_t i = 0; i < sizeof lista / sizeof lista[0]; i++)
        mostrar(&lista[i]);
    printf("sizeof(struct valor) = %zu\n", sizeof(struct valor));
    return 0;
}
terminal
$ ./etiquetada
entero 42
real 2.5
texto "sistema"
ninguno
entero -7
sizeof(struct valor) = 16
La estructura valor ocupa 16 bytes: los primeros 4 contienen la etiqueta tipo, los siguientes 4 son relleno, y los últimos 8 son la unión anónima, donde entero, real y texto ocupan el mismo lugar struct valor · 16 bytes tipo (enum) relleno · 4 entero · real · texto 0 4 8 16 La etiqueta dice cuál de los tres miembros de la unión vale. La unión pone los tres en los mismos 8 bytes; la estructura no gasta 8 + 8 + 8, sino 8.
El diseño de una unión etiquetada: etiqueta primero, después los datos alternativos. El relleno se explica en el tema 25.

Varias cosas de este diseño importan:

terminal · falta.c: el mismo switch, sin el caso T_NINGUNO y sin default
$ gcc -std=c17 -Wall -Wextra -g -o falta falta.c
falta.c: In function ‘nombre’:
falta.c:7:5: warning: enumeration value ‘T_NINGUNO’ not handled in switch [-Wswitch]
    7 |     switch (t) {
      |     ^~~~~~

Cuando un switch sobre un valor de tipo enumerado no cubre todas las constantes, gcc avisa con -Wswitch, incluido en -Wall. Al agregar una constante a la enumeración, el compilador señala cada switch que hay que actualizar. Ese beneficio desaparece si se escribe una rama default, porque entonces el compilador supone que el caso está cubierto: conviene dejar default afuera cuando se quiere cubrir todas las constantes, y manejar los valores inválidos con una comprobación aparte, como en la sección 12.

4. Las uniones del sistema

Las uniones son frecuentes en las interfaces que el núcleo expone, porque muchas llamadas llevan un dato opaco que el programa decide cómo interpretar. Dos ejemplos que veremos en los cursos siguientes: epoll avisa qué descriptores están listos, y devuelve junto a cada aviso el dato que el programa registró; y las señales con datos llevan un valor que puede ser un entero o un puntero. Este programa mira los tamaños:

epoll.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stddef.h>
#include <signal.h>
#include <sys/epoll.h>

int main(void)
{
    printf("sizeof(epoll_data_t)     = %zu, alignof = %zu\n",
           sizeof(epoll_data_t), _Alignof(epoll_data_t));
    printf("sizeof(struct epoll_event) = %zu, alignof = %zu\n",
           sizeof(struct epoll_event), _Alignof(struct epoll_event));
    printf("  events en %zu, data en %zu\n",
           offsetof(struct epoll_event, events), offsetof(struct epoll_event, data));
    printf("sizeof(union sigval)     = %zu\n", sizeof(union sigval));

    struct epoll_event e = {.events = EPOLLIN, .data.fd = 7};
    printf("e.data.fd = %d, e.data.u64 = 0x%llx\n", e.data.fd, (unsigned long long) e.data.u64);
    return 0;
}
terminal · y las declaraciones de /usr/include/x86_64-linux-gnu/sys/epoll.h
$ ./epoll
sizeof(epoll_data_t)     = 8, alignof = 8
sizeof(struct epoll_event) = 12, alignof = 1
  events en 0, data en 4
sizeof(union sigval)     = 8
e.data.fd = 7, e.data.u64 = 0x7
$ grep -n -A7 'typedef union epoll_data' /usr/include/x86_64-linux-gnu/sys/epoll.h
77:typedef union epoll_data
78-{
79-  void *ptr;
80-  int fd;
81-  uint32_t u32;
82-  uint64_t u64;
83-} epoll_data_t;

La unión epoll_data_t ofrece cuatro formas de usar los mismos 8 bytes: un puntero, un descriptor de archivo, un entero de 32 bits o uno de 64. El núcleo no mira lo que contiene: lo guarda al registrar el descriptor y lo devuelve intacto con cada aviso. Un programa que maneja pocos descriptores guarda el número en data.fd; uno que maneja muchas conexiones guarda en data.ptr un puntero a la estructura que describe cada una. Es un parámetro de contexto, el mismo patrón de void * que vimos en qsort_r en el tema 20, con un tipo propio que evita el cast. union sigval, de signal.h, es lo mismo para las señales con datos.

Hay algo más para notar, que enlaza con el tema 25. struct epoll_event mide 12 bytes y tiene alineación 1: data está en el desplazamiento 4, aunque contiene un puntero de alineación 8. La estructura está declarada con __attribute__((packed)) en x86-64, como muestra la macro __EPOLL_PACKED, porque debe coincidir bit a bit con la que usa el núcleo desde las primeras versiones, y eso incluye a los programas de 32 bits que corren sobre un núcleo de 64. Es una compatibilidad binaria que el programa de usuario recibe sin saberlo. Y la última línea muestra el riesgo de las uniones: el programa escribió data.fd y leyó data.u64, que incluye 4 bytes que nadie escribió. Dio 7 porque el compilador puso en cero el resto al inicializar, no porque sea cierto en general; leer un miembro distinto del escrito es lo que la etiqueta de la sección 3 existe para evitar.

5. Enumeraciones

Una enumeración (enum) es un tipo entero cuyas constantes tienen nombre. Se declara con una lista de identificadores: el primero vale 0, y cada uno siguiente vale uno más que el anterior, salvo que se le asigne un valor explícito.

enumeraciones.c
#include <stdio.h>

enum color { ROJO, VERDE = 5, AZUL, AMARILLO = AZUL + 10 };
enum signo { NEGATIVO = -1, CERO, POSITIVO };
enum { TAM = 512 };                       /* sin nombre: solo una constante */

int main(void)
{
    printf("ROJO=%d VERDE=%d AZUL=%d AMARILLO=%d\n", ROJO, VERDE, AZUL, AMARILLO);
    printf("sizeof(ROJO) = %zu (la constante es un int)\n", sizeof ROJO);
    printf("sizeof(enum color) = %zu, sizeof(enum signo) = %zu\n",
           sizeof(enum color), sizeof(enum signo));
    printf("(enum color) -1 > 0: %d   (enum signo) -1 > 0: %d\n",
           (enum color) -1 > 0, (enum signo) -1 > 0);

    enum color c = 42;                    /* C no lo impide */
    printf("c = %d (no es ningún color)\n", c);
    char buffer[TAM];
    printf("sizeof buffer = %zu\n", sizeof buffer);
    return 0;
}
terminal
$ ./enumeraciones
ROJO=0 VERDE=5 AZUL=6 AMARILLO=16
sizeof(ROJO) = 4 (la constante es un int)
sizeof(enum color) = 4, sizeof(enum signo) = 4
(enum color) -1 > 0: 1   (enum signo) -1 > 0: 0
c = 42 (no es ningún color)
sizeof buffer = 512

Lo que hay que saber de las enumeraciones de C, que son menos estrictas de lo que parecen:

terminal · mezcla.c: enum color c = GATO;, con GATO de otra enumeración
$ gcc -std=c17 -Wall -Wextra -g -o mezcla mezcla.c
mezcla.c: In function ‘main’:
mezcla.c:8:20: warning: implicit conversion from ‘enum animal’ to ‘enum color’ [-Wenum-conversion]
    8 |     enum color c = GATO;
      |                    ^~~~

-Wenum-conversion, incluida en -Wextra para C, detecta que se asigna una constante de una enumeración a una variable de otra. Es la única comprobación de tipos que el lenguaje ofrece para las enumeraciones, y no cubre el caso de un entero cualquiera.

Frente a #define, la enumeración agrupa constantes relacionadas, no es una sustitución de texto que ocurre antes de compilar, y gdb conoce sus nombres: muestra EV_TECLA y no el número 0 (sección 13). Las macros siguen siendo necesarias para lo que no es un entero, como una cadena o un valor de coma flotante.

6. Usos habituales de enum

Tres patrones aparecen una y otra vez. Los tres están en este programa, que lee el estado de procesos desde /proc/PID/stat y lo traduce a una enumeración:

estados.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <limits.h>

enum estado { E_EJECUTANDO, E_DURMIENDO, E_DISCO, E_ZOMBI, E_DETENIDO, E_OTRO, N_ESTADOS };

static const char *const nombres[N_ESTADOS] = {
    [E_EJECUTANDO] = "ejecutando",
    [E_DURMIENDO]  = "durmiendo",
    [E_DISCO]      = "esperando disco",
    [E_ZOMBI]      = "zombi",
    [E_DETENIDO]   = "detenido",
    [E_OTRO]       = "otro",
};

static enum estado clasificar(char letra)
{
    switch (letra) {
    case 'R':
        return E_EJECUTANDO;
    case 'S':
    case 'I':
        return E_DURMIENDO;
    case 'D':
        return E_DISCO;
    case 'Z':
        return E_ZOMBI;
    case 'T':
    case 't':
        return E_DETENIDO;
    default:
        return E_OTRO;
    }
}

/* lee "pid (nombre) E ..." de /proc/PID/stat; 0 si lo logró, -1 si no */
static int leer_estado(long pid, char comm[32], char *letra)
{
    char ruta[64], linea[512];
    int n = snprintf(ruta, sizeof ruta, "/proc/%ld/stat", pid);

    if (n < 0 || (size_t) n >= sizeof ruta)
        return -1;
    FILE *f = fopen(ruta, "r");
    if (f == NULL)
        return -1;
    char *ok = fgets(linea, sizeof linea, f);
    fclose(f);
    if (ok == NULL)
        return -1;

    char *abre = strchr(linea, '(');
    char *cierra = strrchr(linea, ')');   /* el nombre puede contener paréntesis */
    if (abre == NULL || cierra == NULL || cierra[1] != ' ' || cierra[2] == '\0')
        return -1;
    snprintf(comm, 32, "%.*s", (int) (cierra - abre - 1), abre + 1);
    *letra = cierra[2];
    return 0;
}

int main(int argc, char *argv[])
{
    int estado = 0;

    for (int i = 1; i < argc; i++) {
        char *fin;
        errno = 0;
        long pid = strtol(argv[i], &fin, 10);
        if (fin == argv[i] || *fin != '\0' || errno == ERANGE || pid < 1 || pid > INT_MAX) {
            fprintf(stderr, "%s: PID inválido: '%s'\n", argv[0], argv[i]);
            estado = 2;
            continue;
        }
        char comm[32], letra;
        if (leer_estado(pid, comm, &letra) != 0) {
            fprintf(stderr, "%s: no se pudo leer el proceso %ld\n", argv[0], pid);
            estado = 1;
            continue;
        }
        enum estado e = clasificar(letra);
        printf("%6ld  %-16s  %c  %s (%d)\n", pid, comm, letra, nombres[e], e);
    }
    return estado;
}
terminal
$ sleep 30 &
[1] 538
$ ./estados $! $$ 1 99999 abc; echo $?
   538  sleep             S  durmiendo (1)
   338  bash              S  durmiendo (1)
     1  systemd           S  durmiendo (1)
./estados: no se pudo leer el proceso 99999
./estados: PID inválido: 'abc'
2
$ kill -STOP $!; ./estados $!
   538  sleep             T  detenido (4)

Los tres patrones del programa:

El resto repasa lo que aprendimos en el bloque: strrchr encuentra el último ) porque el nombre del proceso, que puede ser cualquier texto de hasta 15 caracteres, es el que aparece entre paréntesis y puede contener paréntesis; strtol valida el argumento; y snprintf arma la ruta con una comprobación del recorte.

El segundo uso grande de enum son las banderas: constantes que son potencias de 2, para combinar con operadores de bits, como los permisos de archivos o las opciones de open:

banderas.c
#include <stdio.h>

enum permiso {
    P_LEER = 1 << 0,
    P_ESCRIBIR = 1 << 1,
    P_EJECUTAR = 1 << 2,
};

static void texto(unsigned p, char salida[4])
{
    salida[0] = p & P_LEER ? 'r' : '-';
    salida[1] = p & P_ESCRIBIR ? 'w' : '-';
    salida[2] = p & P_EJECUTAR ? 'x' : '-';
    salida[3] = '\0';
}

int main(void)
{
    unsigned usuario = P_LEER | P_ESCRIBIR;
    char t[4];

    texto(usuario, t);
    printf("usuario = %u  %s\n", usuario, t);

    usuario |= P_EJECUTAR;                /* encender */
    usuario &= ~P_ESCRIBIR;               /* apagar */
    texto(usuario, t);
    printf("usuario = %u  %s\n", usuario, t);

    for (unsigned p = 0; p <= 7; p++) {
        texto(p, t);
        printf("%u=%s ", p, t);
    }
    printf("\n");
    return 0;
}
terminal
$ ./banderas
usuario = 3  rw-
usuario = 5  r-x
0=--- 1=r-- 2=-w- 3=rw- 4=--x 5=r-x 6=-wx 7=rwx

La variable que guarda la combinación es un unsigned, no un enum permiso: un valor como 3 no es ninguna de las tres constantes, y declararlo del tipo enumerado sería mentir. La enumeración solo da nombre a los bits; las operaciones son las del tema 8: | para combinar, & para probar, |= para encender, &= ~ para apagar. Es el patrón de S_IRUSR, O_CREAT y casi todas las opciones de las llamadas al sistema.

7. typedef: un nombre nuevo para un tipo

La palabra typedef declara un alias: un nombre nuevo para un tipo que ya existe. Se escribe igual que la declaración de una variable de ese tipo, con typedef delante, y el nombre que sería el de la variable pasa a ser el del tipo. Es lo que vimos en el tema 20 con los punteros a funciones:

Declaración de una variableCon typedef delanteResultado
unsigned long contador;typedef unsigned long contador_t;contador_t es otro nombre de unsigned long.
int (*f)(int);typedef int (*funcion_t)(int);funcion_t es «puntero a función de int en int».
char nombre[32];typedef char nombre_t[32];nombre_t es un arreglo de 32 caracteres.
struct proceso p;typedef struct proceso proceso_t;proceso_t es struct proceso.

Lo más importante de typedef es lo que no hace: no crea un tipo distinto. El alias y el original son exactamente el mismo tipo, y el compilador los mezcla sin quejarse. Es una diferencia con las estructuras, que sí son tipos distintos, como vimos en el tema 24:

alias.c
#include <stdio.h>

typedef unsigned long contador_t;
typedef int metros_t;
typedef int pies_t;

struct metros { double valor; };
struct pies   { double valor; };

int main(void)
{
    contador_t a = 5;
    unsigned long *p = &a;                /* es el mismo tipo: sin conversión */

    metros_t m = 100;
    pies_t f = m;                         /* son el mismo tipo: compila sin aviso */
    int *q = &f;

    printf("a=%lu *p=%lu m=%d f=%d *q=%d\n", a, *p, m, f, *q);
    printf("sizeof(contador_t) = %zu, sizeof(struct metros) = %zu\n",
           sizeof(contador_t), sizeof(struct metros));
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o alias alias.c && ./alias
a=5 *p=5 m=100 f=100 *q=100
sizeof(contador_t) = 8, sizeof(struct metros) = 8

Asignar un metros_t a un pies_t es perfectamente válido, porque los dos son int; asignar un struct metros a un struct pies, no. Por eso un typedef sirve para documentar y centralizar, no para proteger. Sirve para tres cosas:

Si se quiere que el compilador distinga metros de pies, se envuelve cada valor en una estructura de un solo campo, como hicimos con struct metros y struct pies: tiene el mismo tamaño y no cuesta nada en tiempo de ejecución.

8. typedef y las estructuras

El uso más común es evitar escribir struct cada vez. Hay tres formas de combinarlos:

tres formas de typedef con struct
/* 1. etiqueta y alias: se puede usar struct proceso o proceso_t */
typedef struct proceso {
    int pid;
    char nombre[16];
} proceso_t;

/* 2. estructura anónima: solo existe el alias */
typedef struct {
    int x, y;
} punto_t;

/* 3. declarar el alias primero: permite referirse al tipo antes de definirlo */
typedef struct nodo nodo_t;
struct nodo {
    int valor;
    nodo_t *siguiente;                    /* con la forma 2 no se podría */
};

La forma 1 es la más flexible. La forma 2 es la más corta, pero una estructura sin etiqueta no puede referirse a sí misma, ni declararse por adelantado. La forma 3, la de las listas, usa el alias para el puntero al propio tipo. También se aplica a las uniones y las enumeraciones, como en la sección 12: typedef enum { … } tipo_evento_t;.

Si typedef evita escribir struct, ¿por qué seguimos escribiéndolo en los temas 24 y 25? Porque es una cuestión de estilo en la que hay dos escuelas:

En este curso se usa struct x para las estructuras con campos visibles, y typedef para los punteros a funciones, las enumeraciones usadas como tipos, los tipos numéricos con significado y los tipos opacos. Ambas formas son correctas; lo que importa es ser coherente en cada programa. Los nombres que terminan en _t son un caso aparte: POSIX los reserva para las implementaciones, de modo que un programa que define su propio proceso_t en un entorno POSIX puede chocar con un tipo del sistema en el futuro; en un programa chico no ocurre nada, y los proyectos grandes usan otro sufijo o un prefijo propio.

9. La trampa de typedef con punteros

Un typedef no es una sustitución de texto, como un #define. El alias es un tipo completo, y eso cambia el significado de const y del asterisco cuando el tipo es un puntero:

trampa.c
#include <stdio.h>

typedef char *cadena;

int main(void)
{
    char buffer[] = "hola";
    char otro[] = "chau";

    const char *a = buffer;               /* puntero a char constante */
    const cadena b = buffer;              /* ¿lo mismo? */

    a = otro;                             /* permitido: a no es constante */
    b[0] = 'H';                           /* permitido: ¡los char no son constantes! */
    b = otro;                             /* error: es el puntero lo que es constante */
    printf("%s %s\n", a, b);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o trampa trampa.c
trampa.c: In function ‘main’:
trampa.c:15:7: error: assignment of read-only variable ‘b’
   15 |     b = otro;                             /* error: es el puntero lo que es constante */
      |       ^

Con un #define CADENA char *, const CADENA b se expandiría a const char * b. Pero cadena es «puntero a char» como un solo tipo, y un const aplicado a ese tipo vuelve constante el puntero: const cadena b es char *const b, la segunda forma del tema 19. El compilador acepta modificar b[0] y rechaza reasignar b, lo contrario de lo que se pretendía. Es una trampa que atrapa incluso a quien conoce bien const. De ahí la regla: no se ocultan punteros dentro de un typedef. Si hace falta un alias, se define para el tipo apuntado, typedef char caracter_t;, y el asterisco y el const se escriben donde se ven: const caracter_t *. Las excepciones son los punteros a funciones, donde la sintaxis exige el alias para ser legible, y los tipos opacos que se pasan siempre por puntero, como veremos.

10. Tipos incompletos y tipos opacos

Un tipo incompleto es uno que se declaró pero todavía no se definió: el compilador sabe que existe, pero no cuánto mide ni qué campos tiene. Sobre un tipo incompleto se puede declarar un puntero, que mide siempre lo mismo, y pasarlo; no se puede declarar una variable, ni usar sizeof, ni acceder a un campo.

incompleto.c
#include <stdio.h>

typedef struct contador contador;         /* declaración: el tipo existe, pero está incompleto */

static void mostrar(const contador *c);   /* se pueden pasar punteros a un tipo incompleto */

static int leer(const contador *c)
{
    return c->valor;                      /* ...pero no mirar adentro */
}

struct contador {
    int valor;
};

static void mostrar(const contador *c)
{
    printf("valor = %d (sizeof = %zu)\n", c->valor, sizeof *c);
}

int main(void)
{
    contador c = {5};

    mostrar(&c);
    return leer(&c) == 5 ? 0 : 1;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o incompleto incompleto.c
incompleto.c: In function ‘leer’:
incompleto.c:9:13: error: invalid use of incomplete typedef ‘contador’
    9 |     return c->valor;                      /* ...pero no mirar adentro */
      |             ^~

El error está en leer, que mira un campo antes de que la estructura se defina. mostrar, en cambio, está después de la definición y compila bien. Esto permite una técnica de diseño llamada tipo opaco: un módulo publica en su cabecera solo la declaración incompleta, typedef struct cola cola_t;, y las funciones que operan sobre un cola_t *. La definición de la estructura queda en el archivo fuente del módulo, y quien lo usa no puede ver los campos ni declarar variables de ese tipo: solo recibir y devolver punteros. La ventaja es que el módulo puede cambiar su implementación sin que nadie tenga que recompilar, y los usuarios no pueden depender de los detalles internos. La contrapartida es que los objetos tienen que crearlos las funciones del módulo, con memoria dinámica, que veremos en el tema 28.

Los tipos opacos están en toda la biblioteca de C. FILE es el más conocido:

terminal
$ grep -n -B1 'typedef struct _IO_FILE FILE' /usr/include/x86_64-linux-gnu/bits/types/FILE.h
6-/* The opaque type of streams.  This is the definition used elsewhere.  */
7:typedef struct _IO_FILE FILE;

El programa trabaja siempre con un FILE *, que devuelve fopen y reciben fgets y fclose. En glibc la estructura sí se define en otra cabecera, y mide 216 bytes, pero el comentario dice «opaque» por una razón: el estándar no promete nada sobre sus campos, y ningún programa portable los toca. Lo mismo ocurre con DIR, para recorrer directorios, y con pthread_t, cuya representación POSIX tampoco especifica. Es el diseño que conviene imitar al crear módulos.

11. Los tipos del sistema

Los tipos que vimos usar a lo largo del curso, size_t, pid_t, off_t, time_t, no son tipos propios del lenguaje: son alias, definidos con typedef en las cabeceras del sistema. Este programa pregunta por el tamaño y el signo de los más comunes:

sistema_tipos.c
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <time.h>
#include <sys/types.h>

#define MOSTRAR(T) \
    printf("%-9s %zu bytes, %s\n", #T, sizeof(T), ((T) -1 < 1) ? "con signo" : "sin signo")

int main(void)
{
    MOSTRAR(size_t);
    MOSTRAR(ssize_t);
    MOSTRAR(ptrdiff_t);
    MOSTRAR(pid_t);
    MOSTRAR(uid_t);
    MOSTRAR(gid_t);
    MOSTRAR(mode_t);
    MOSTRAR(off_t);
    MOSTRAR(ino_t);
    MOSTRAR(time_t);
    MOSTRAR(clock_t);
    MOSTRAR(uint32_t);
    return 0;
}
terminal
$ ./sistema_tipos
size_t    8 bytes, sin signo
ssize_t   8 bytes, con signo
ptrdiff_t 8 bytes, con signo
pid_t     4 bytes, con signo
uid_t     4 bytes, sin signo
gid_t     4 bytes, sin signo
mode_t    4 bytes, sin signo
off_t     8 bytes, con signo
ino_t     8 bytes, sin signo
time_t    8 bytes, con signo
clock_t   8 bytes, con signo
uint32_t  4 bytes, sin signo

Para saber qué es cada uno, se sigue la cadena de alias en las cabeceras. pid_t no es un int directamente, sino el final de una cadena:

terminal
$ grep -n 'typedef.*\bpid_t\b' /usr/include/x86_64-linux-gnu/sys/types.h
97:typedef __pid_t pid_t;
$ grep -n '__pid_t;' /usr/include/x86_64-linux-gnu/bits/types.h
154:__STD_TYPE __PID_T_TYPE __pid_t;	/* Type of process identifications.  */
$ grep -n '__PID_T_TYPE' /usr/include/x86_64-linux-gnu/bits/typesizes.h
53:#define __PID_T_TYPE		__S32_TYPE
$ grep -n 'typedef.*size_t;' /usr/lib/gcc/x86_64-linux-gnu/*/include/stddef.h | head -1
229:typedef __SIZE_TYPE__ size_t;

pid_t es __pid_t, definido por una macro de la implementación como un entero de 32 bits con signo, es decir, int. size_t lo define el compilador mismo con __SIZE_TYPE__, que en esta plataforma es unsigned long. Tanta indirección tiene un propósito: que el código fuente compile y funcione en cualquier sistema, aunque los tipos reales cambien. Un programa que declara pid_t p = fork(); compila con el tipo que cada sistema eligió; si mañana un núcleo necesita PID de 64 bits, se cambia la cabecera y los programas se recompilan sin modificarse. Es lo que pasó con time_t: en los sistemas de 32 bits fue de 32 bits y se agota en enero de 2038, y los sistemas actuales lo ampliaron a 64 sin tocar los programas que lo usan correctamente.

De la salida salen cuatro reglas para usarlos:

12. Todo junto: una tabla de despacho de eventos

El último programa combina las tres herramientas con lo visto antes: una enumeración para el tipo de cada evento, una unión etiquetada para sus datos, una estructura que las agrupa, y una tabla de punteros a funciones indexada por la enumeración, como la de calc.c del tema 20. Cada evento se procesa con la función que le corresponde:

eventos.c
#include <stdio.h>

typedef enum {
    EV_TECLA,
    EV_TIEMPO,
    EV_SENAL,
    EV_FIN,
    N_EVENTOS
} tipo_evento_t;

typedef struct {
    tipo_evento_t tipo;
    union {
        char tecla;
        struct {
            long segundos;
            long nanos;
        } tiempo;
        int senal;
    };
} evento_t;

typedef void (*manejador_t)(const evento_t *);

static void en_tecla(const evento_t *e)
{
    printf("tecla '%c'\n", e->tecla);
}

static void en_tiempo(const evento_t *e)
{
    printf("tiempo %ld.%09ld s\n", e->tiempo.segundos, e->tiempo.nanos);
}

static void en_senal(const evento_t *e)
{
    printf("señal %d\n", e->senal);
}

/* la tabla se indexa con el enum; EV_FIN no tiene manejador */
static const manejador_t manejadores[N_EVENTOS] = {
    [EV_TECLA]  = en_tecla,
    [EV_TIEMPO] = en_tiempo,
    [EV_SENAL]  = en_senal,
};

static int despachar(const evento_t *e)
{
    if ((unsigned) e->tipo >= N_EVENTOS || manejadores[e->tipo] == NULL)
        return -1;
    manejadores[e->tipo](e);
    return 0;
}

int main(void)
{
    const evento_t cola[] = {
        {.tipo = EV_TECLA, .tecla = 'q'},
        {.tipo = EV_TIEMPO, .tiempo = {.segundos = 3, .nanos = 250000000}},
        {.tipo = EV_SENAL, .senal = 15},
        {.tipo = (tipo_evento_t) 9},
        {.tipo = EV_FIN},
    };

    printf("sizeof(evento_t) = %zu\n", sizeof(evento_t));
    for (size_t i = 0; i < sizeof cola / sizeof cola[0]; i++) {
        if (cola[i].tipo == EV_FIN) {
            printf("fin\n");
            break;
        }
        if (despachar(&cola[i]) != 0)
            printf("evento inválido: %d\n", (int) cola[i].tipo);
    }
    return 0;
}
terminal
$ ./eventos
sizeof(evento_t) = 24
tecla 'q'
tiempo 3.250000000 s
señal 15
evento inválido: 9
fin

Cada decisión de diseño se apoya en un punto del tema:

13. Uniones y enumeraciones en gdb

El depurador muestra las constantes por su nombre y la unión con todos sus miembros a la vez, lo que deja ver, de un vistazo, que son los mismos bytes:

terminal · gdb sobre eventos, detenido en despachar con el primer evento (la tecla 'q')
$ gdb -q -batch -ex 'break despachar' -ex run -ex 'p *e' -ex 'p e->tipo' -ex 'p/d e->tipo' \
      -ex 'p (tipo_evento_t) 2' -ex 'p/d N_EVENTOS' -ex 'ptype evento_t' -ex 'whatis manejadores' \
      -ex continue -ex 'p e->tiempo' -ex 'p sizeof(evento_t)' ./eventos
Breakpoint 1, despachar (e=0x7fffffffd760) at eventos.c:49
49	    if ((unsigned) e->tipo >= N_EVENTOS || manejadores[e->tipo] == NULL)
$1 = {tipo = EV_TECLA, {tecla = 113 'q', tiempo = {segundos = 113, nanos = 0}, senal = 113}}
$2 = EV_TECLA
$3 = 0
$4 = EV_SENAL
$5 = 4
type = struct {
    tipo_evento_t tipo;
    union {
        char tecla;
        struct {...} tiempo;
        int senal;
    };
}
type = const manejador_t [4]

Breakpoint 1, despachar (e=0x7fffffffd778) at eventos.c:49
49	    if ((unsigned) e->tipo >= N_EVENTOS || manejadores[e->tipo] == NULL)
$6 = {segundos = 3, nanos = 250000000}
$7 = 24

p *e muestra los tres miembros de la unión con el mismo contenido: tecla = 113 'q', senal = 113, y los segundos = 113 del tiempo, porque el carácter 'q' es el byte 113 y los demás bytes de la unión son cero. Es la forma más directa de entender qué es una unión. p e->tipo imprime EV_TECLA y no 0, y p/d fuerza el número; p (tipo_evento_t) 2 convierte un entero a su nombre; ptype muestra la estructura con su unión anónima, y whatis manejadores el tipo del arreglo de punteros a funciones. Con ptype /o evento_t, del tema 25, se ven los desplazamientos: la etiqueta en 0, un hueco de 4 bytes, y la unión de 16 bytes en el 8.

14. Errores frecuentes

ErrorQué pasaCorrección
Leer de una unión un miembro distinto del escrito, sin etiquetaSe reinterpretan los bytes; con bytes no escritos, el resultado es basura.Una unión etiquetada, que se consulta antes de leer.
Olvidar actualizar la etiqueta al cambiar el miembroLa etiqueta miente: se lee el miembro equivocado.Constructores o funciones que fijen las dos cosas a la vez.
Reinterpretar con *(uint32_t *) &fViola el aliasing estricto: comportamiento indefinido.memcpy o una unión.
Suponer que el tamaño de una unión es el del miembro mayorSe redondea a la alineación: 16, no 13.sizeof(union …).
Contar con que una variable enum solo vale sus constantesC acepta cualquier entero; un índice inválido sale de la tabla.Validar los valores que vienen de afuera.
default en un switch que debe cubrir todo el enumOculta el aviso -Wswitch cuando se agrega una constante.Sin default; validar aparte.
Nombres de constantes sin prefijoChocan con otros del programa o del sistema.EV_, E_, un prefijo por enumeración.
Guardar una combinación de banderas en una variable enumEl valor no es ninguna constante del tipo.Un unsigned con las operaciones de bits.
Contar con que typedef crea un tipo distintometros_t y pies_t se mezclan sin aviso.Envolver el valor en una estructura.
typedef char *cadena; y const cadenaEs char *const, no const char *.No ocultar punteros en un typedef.
Estructura anónima con typedef y un puntero a sí mismaNo tiene nombre: no se puede referir a sí misma.Una etiqueta: typedef struct nodo nodo_t;.
Acceder a un campo de un tipo incompletoinvalid use of incomplete typedef.Definir la estructura antes del uso, o usar solo punteros.
Imprimir pid_t, off_t, time_t con %d o %ldDepende de la plataforma.Convertir a intmax_t y usar %jd.

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Cuánto mide union { char texto[13]; int i; double d; }, y por qué?

Mide 16 bytes. El miembro más grande es el arreglo de 13 caracteres, pero la alineación de la unión es la del miembro más exigente, el double, que es 8; el tamaño se redondea hasta un múltiplo de 8, que es 16.

2. Si se asigna n.f = 1.0f en una unión con un miembro int i, ¿por qué n.i no vale 1?

Porque no hay conversión: los dos miembros comparten los mismos bytes. n.i lee, como entero, los bytes con que se representa 1.0 como número de coma flotante, que forman 0x3f800000, es decir, 1 065 353 216.

3. ¿Cuál es la diferencia entre el type punning con una unión y con un cast de punteros?

La unión es válida en C: leer otro miembro reinterpreta los bytes. El cast de puntero, *(uint32_t *) &f, accede a un float a través de un uint32_t *, lo que viola el aliasing estricto y es comportamiento indefinido, aunque a veces funcione. memcpy es la tercera forma válida y la más portable.

4. ¿Para qué sirve la etiqueta de una unión etiquetada?

Para saber cuál miembro de la unión tiene un valor válido. La unión no guarda esa información; el programa la guarda en un campo aparte, normalmente una enumeración, y la consulta antes de leer cualquier miembro. Sin ella no hay forma de distinguir un entero de un real que ocupan los mismos bytes.

5. ¿Por qué struct epoll_event mide 12 bytes y no 16?

Porque en x86-64 está declarada con __attribute__((packed)), sin relleno: events ocupa 4 bytes y data, de 8, empieza en el desplazamiento 4. Es por compatibilidad binaria con el diseño que el núcleo usa desde sus primeras versiones, incluida la de los programas de 32 bits.

6. ¿Qué valor tiene cada constante en enum { A, B = 5, C, D = C + 10 }?

A vale 0; B vale 5, por la asignación explícita; C continúa con 6; y D es C + 10, es decir, 16.

7. ¿Qué pasa si se asigna 42 a una variable de tipo enum color que solo tiene tres constantes?

Nada: C lo acepta sin avisar, porque el tipo enumerado es un entero. La variable puede valer cualquier entero que quepa en el tipo. Por eso los valores que vienen de afuera se validan antes de usarlos como índice o como entrada de un switch.

8. ¿Qué avisa -Wswitch, y por qué no conviene poner default en ese caso?

Avisa de que un switch sobre un valor enumerado no cubre todas las constantes. Es útil al agregar una constante nueva, porque el compilador señala cada switch que falta actualizar. Una rama default cubre todos los casos para el compilador y anula el aviso.

9. ¿Por qué una variable de banderas se declara unsigned y no del tipo enum?

Porque una combinación como P_LEER | P_ESCRIBIR vale 3, que no es ninguna de las constantes de la enumeración. La enumeración solo nombra los bits; la variable guarda el resultado de operaciones con ellos.

10. ¿Un typedef int metros_t; y un typedef int pies_t; son tipos distintos?

No. Un typedef crea otro nombre para el mismo tipo: los dos son int y se mezclan sin aviso. Para que el compilador los distinga hay que envolverlos en estructuras de un campo, como struct metros y struct pies.

11. Con typedef char *cadena;, ¿qué significa const cadena s;?

Significa char *const s: un puntero constante a char modificable. Se puede escribir s[0] = 'x' y no se puede reasignar s, lo contrario de const char *s. Por eso no se ocultan punteros dentro de un typedef.

12. ¿Qué ventaja da un tipo opaco, y cómo se declara?

Se declara en la cabecera solo como tipo incompleto, typedef struct cola cola_t;, con funciones que usan cola_t *; la definición de los campos queda en el archivo fuente del módulo. Quien lo usa no puede mirar los campos ni declarar variables, así que el módulo puede cambiar su implementación sin afectar a nadie. FILE y DIR son ejemplos.

13. ¿Por qué las cabeceras definen pid_t con una cadena de typedef en lugar de usar int?

Para que el mismo código fuente compile en cualquier sistema, aunque el tipo real cambie. Si un sistema necesita PID más grandes, se cambia la definición en la cabecera, y los programas se recompilan sin modificarse. Lo mismo ocurrió con time_t, ampliado a 64 bits.

14. En eventos.c, ¿por qué despachar comprueba el índice antes de usar la tabla?

Porque un valor de tipo enumerado puede valer cualquier entero, y un evento recibido de afuera puede traer un tipo que no existe, como el 9. Indexar la tabla con ese valor leería memoria fuera del arreglo y llamaría a una dirección cualquiera. La comprobación del rango y del NULL convierte ese caso en un error manejable.

Para practicar

  1. Escribe una unión que permita ver un uint32_t como cuatro uint8_t y úsala para decidir si tu máquina es little endian. Escribe después una función uint32_t invertir(uint32_t) que invierta el orden de sus bytes con la unión, y compárala con __builtin_bswap32.
  2. Extiende bits.c para double (64 bits: 1 de signo, 11 de exponente, 52 de mantisa) y muestra el valor de 0.1, 0.2 y 0.1 + 0.2. ¿En qué se diferencian los bits de 0.1 + 0.2 y 0.3?
  3. Agrega a etiquetada.c un tipo T_LISTA cuyo miembro sea un puntero a un arreglo de struct valor y su cantidad. Escribe mostrar de forma recursiva. Comprueba que -Wswitch te avisa antes de agregar el caso.
  4. Escribe una función int igual(const struct valor *a, const struct valor *b) que compare dos valores etiquetados, y explica por qué memcmp no sirve aquí (piensa en el relleno y en los miembros de distinto tamaño).
  5. Amplía estados.c para que, sin argumentos, lea todos los procesos de /proc (con opendir y readdir, man 3 readdir) y muestre cuántos hay en cada estado, usando el arreglo indexado por la enumeración.
  6. Define una enumeración de banderas enum opcion { OPT_VERBOSE = 1, OPT_FORZAR = 2, OPT_RECURSIVO = 4 }, procesa con getopt (tema 23) las opciones -v, -f y -r, y muestra qué combinación quedó encendida.
  7. Escribe un tipo opaco contador_t en dos archivos: una cabecera con la declaración incompleta y las funciones crear, incrementar, valor y destruir, y un archivo fuente con la definición. Hazlo con memoria dinámica cuando termines el tema 28, y compila los dos archivos por separado.
  8. Agrega a eventos.c un evento EV_RATON con dos coordenadas y comprueba cuántas líneas hay que tocar. ¿Qué pasa si te olvidas de agregar su manejador a la tabla?