← Programación en C para Sistemas Operativos Bloque 5 · Cadenas y arreglos · Tema 23 de 42

Tema 23

Arreglos multidimensionales y argumentos de main

Este tema cierra el bloque 5 con dos estructuras que tienen forma de tabla. La primera es la matriz, un arreglo cuyos elementos son a su vez arreglos, que C guarda fila por fila en un solo bloque de memoria; entender esa disposición explica su sintaxis, la forma de pasarla a una función, y por qué recorrerla en un orden u otro puede ser ocho veces más lento. La segunda es la lista de argumentos que todo programa recibe en argv: quién la arma, qué garantiza, qué se puede hacer con argv[0], y cómo procesar opciones como -n 5 o --verbose igual que las herramientas de Unix, con getopt y getopt_long.

1. Un arreglo de arreglos

C no tiene matrices como tipo especial. Lo que tiene es una regla general: los elementos de un arreglo pueden ser de cualquier tipo, incluido otro arreglo. int m[3][4] se lee de adentro hacia afuera, como las declaraciones del tema 19: m es un arreglo de 3 elementos, y cada elemento es un arreglo de 4 int. Llamamos filas a esos 3 elementos y columnas a las 4 posiciones de cada uno:

matriz.c
#include <stdio.h>

int main(void)
{
    int m[3][4] = {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12},
    };

    printf("sizeof m = %zu, sizeof m[0] = %zu, sizeof m[0][0] = %zu\n",
           sizeof m, sizeof m[0], sizeof m[0][0]);
    printf("filas = %zu, columnas = %zu\n",
           sizeof m / sizeof m[0], sizeof m[0] / sizeof m[0][0]);

    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 4; j++)
            printf("  m[%d][%d]=%2d @%2td", i, j, m[i][j],
                   (char *) &m[i][j] - (char *) m);
        printf("\n");
    }

    /* los 12 enteros, vistos como un solo arreglo */
    const int *p = &m[0][0];
    printf("en fila:");
    for (int k = 0; k < 12; k++)
        printf(" %d", p[k]);
    printf("\n");
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o matriz matriz.c
$ ./matriz
sizeof m = 48, sizeof m[0] = 16, sizeof m[0][0] = 4
filas = 3, columnas = 4
  m[0][0]= 1 @ 0  m[0][1]= 2 @ 4  m[0][2]= 3 @ 8  m[0][3]= 4 @12
  m[1][0]= 5 @16  m[1][1]= 6 @20  m[1][2]= 7 @24  m[1][3]= 8 @28
  m[2][0]= 9 @32  m[2][1]=10 @36  m[2][2]=11 @40  m[2][3]=12 @44
en fila: 1 2 3 4 5 6 7 8 9 10 11 12
La matriz m de 3 filas por 4 columnas, tal como se la imagina, y tal como está en la memoria: los 12 enteros uno detrás de otro, primero la fila 0 con 1, 2, 3 y 4 en los bytes 0 a 15, después la fila 1 con 5 a 8 en los bytes 16 a 31, y la fila 2 con 9 a 12 en los bytes 32 a 47 como se la imagina 1 2 3 4 5 6 7 8 9 10 11 12 m[0] m[1] m[2] Filas y columnas son una forma de pensar. En la memoria no hay dos dimensiones: las filas van una a continuación de la otra. como está en la memoria 1 2 3 4 5 6 7 8 9 10 11 12 m[0] · bytes 0-15 m[1] · bytes 16-31 m[2] · bytes 32-47 dirección de m[i][j] = m + (i × 4 + j) × sizeof(int)
Una matriz es un bloque contiguo: las filas se guardan una detrás de otra, lo que se llama orden por filas (row-major).

La salida confirma la disposición. Los 12 enteros ocupan 48 bytes seguidos, sin huecos: cada fila mide 16 bytes, y la fila 1 empieza justo donde termina la 0. Por eso sizeof sirve para calcular las dos dimensiones, y por eso un puntero a int puede recorrer la matriz entera como un arreglo de 12 elementos. Para encontrar m[i][j], el compilador calcula i × 4 + j elementos desde el comienzo: necesita conocer la cantidad de columnas, pero no la de filas. Esa asimetría va a aparecer al pasar matrices a funciones.

Lo mismo vale para más dimensiones: int cubo[2][3][4] es un arreglo de 2 matrices de 3 × 4, guardadas una detrás de otra, y el índice que más rápido cambia al avanzar por la memoria es siempre el último.

2. Inicializar matrices

Las reglas de inicialización son las del tema 17, aplicadas a cada nivel:

DeclaraciónResultado
int m[2][3] = {{1, 2, 3}, {4, 5, 6}};Una llave por fila: la forma clara.
int m[2][3] = {1, 2, 3, 4, 5, 6};Lo mismo: los valores llenan la memoria en orden. Válido, pero -Wall pide las llaves internas (-Wmissing-braces).
int m[][3] = {{1, 2, 3}, {4, 5, 6}};El compilador cuenta las filas: 2. Solo la primera dimensión se puede omitir.
int m[2][3] = {{1}, {4, 5}};{1, 0, 0} y {4, 5, 0}: lo que falta en cada fila queda en cero.
int m[100][100] = {0};Toda la matriz en cero.
int m[3][3] = {[1][1] = 5};Inicialización designada: solo el centro vale 5.
int m[][] = …;Error: a lo sumo una dimensión puede quedar sin indicar, y tiene que ser la primera.

El tamaño importa por dónde se guarda la matriz. Una matriz local vive en la pila, que en Linux mide 8 MiB por defecto, como vimos en el tema 12: int m[2048][2048] ocupa 16 MiB y desborda la pila al entrar en la función. Las matrices grandes se declaran static o globales, como haremos en la sección 4, o se piden con malloc, en el tema 28.

3. Los tipos de una matriz

La regla del tema 17, que el nombre de un arreglo se convierte en un puntero a su primer elemento, también vale aquí, y explica toda la sintaxis de las matrices. El primer elemento de m es una fila, m[0], que es un arreglo de 4 int. Así que m se convierte en un puntero a un arreglo de 4 int, un tipo que se escribe int (*)[4]:

tipos.c
#include <stdio.h>

int main(void)
{
    int m[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};
    int (*fila)[4] = m;                   /* m se convierte en puntero a su primera fila */

    printf("m        = %p\n", (void *) m);
    printf("m + 1    = %p  (+%td bytes: una fila)\n", (void *) (m + 1), (char *) (m + 1) - (char *) m);
    printf("m[0] + 1 = %p  (+%td bytes: un int)\n", (void *) (m[0] + 1), (char *) (m[0] + 1) - (char *) m);
    printf("&m + 1   = %p  (+%td bytes: la matriz entera)\n", (void *) (&m + 1), (char *) (&m + 1) - (char *) m);

    printf("m[2][1] = %d, *(*(m + 2) + 1) = %d, fila[2][1] = %d\n",
           m[2][1], *(*(m + 2) + 1), fila[2][1]);
    fila++;                               /* avanza a la fila siguiente */
    printf("después de fila++: (*fila)[0] = %d\n", (*fila)[0]);
    return 0;
}
terminal
$ ./tipos
m        = 0x7ffd2cf7b8a0
m + 1    = 0x7ffd2cf7b8b0  (+16 bytes: una fila)
m[0] + 1 = 0x7ffd2cf7b8a4  (+4 bytes: un int)
&m + 1   = 0x7ffd2cf7b8d0  (+48 bytes: la matriz entera)
m[2][1] = 10, *(*(m + 2) + 1) = 10, fila[2][1] = 10
después de fila++: (*fila)[0] = 5

Como en el tema 17, varias expresiones dan la misma dirección y difieren en el tipo, y el tipo decide cuánto avanza un + 1, según la aritmética del tema 18:

ExpresiónTipo+ 1 avanzaSignificado
m (convertido)int (*)[4]16 bytesPuntero a la fila 0.
m[i] (convertido)int *4 bytesPuntero al primer int de la fila i.
m[i][j]int—Un elemento.
&mint (*)[3][4]48 bytesPuntero a la matriz entera.
*(*(m + i) + j)int—Lo mismo que m[i][j], desarmado.

m[i][j] se evalúa en dos pasos, aplicando dos veces la regla a[i] == *(a + i). m + i avanza i filas, y *(m + i) es esa fila, un arreglo que a su vez se convierte en int *; sumarle j avanza j enteros dentro de ella. La variable fila muestra que un puntero a arreglo se usa igual que el nombre de la matriz, fila[2][1], y que fila++ salta una fila entera. Los paréntesis de int (*fila)[4] son imprescindibles: sin ellos, int *fila[4] sería un arreglo de 4 punteros, el mismo problema de precedencia de los punteros a funciones del tema 20.

4. El orden del recorrido

Una matriz se puede recorrer por filas, con el índice de columna en el bucle interno, o por columnas, con el de fila en el bucle interno. El resultado es el mismo; la velocidad, no:

recorrido.c
#define _POSIX_C_SOURCE 200809L          /* clock_gettime */
#include <stdio.h>
#include <time.h>

enum { N = 4096 };
static int m[N][N];                       /* 64 MiB, fuera de la pila */

static double segundos(void)
{
    struct timespec t;
    clock_gettime(CLOCK_MONOTONIC, &t);
    return (double) t.tv_sec + (double) t.tv_nsec / 1e9;
}

int main(void)
{
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++)
            m[i][j] = i + j;

    double t0 = segundos();
    long suma1 = 0;
    for (int i = 0; i < N; i++)           /* por filas: en el orden de la memoria */
        for (int j = 0; j < N; j++)
            suma1 += m[i][j];

    double t1 = segundos();
    long suma2 = 0;
    for (int j = 0; j < N; j++)           /* por columnas: saltando de a 16 KiB */
        for (int i = 0; i < N; i++)
            suma2 += m[i][j];
    double t2 = segundos();

    printf("por filas:    suma = %ld  %.3f s\n", suma1, t1 - t0);
    printf("por columnas: suma = %ld  %.3f s\n", suma2, t2 - t1);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -O2 -o recorrido recorrido.c
$ ./recorrido
por filas:    suma = 68702699520  0.004 s
por columnas: suma = 68702699520  0.031 s

Los dos bucles suman los mismos 16 millones de enteros, y el segundo tarda casi ocho veces más. La diferencia no está en C sino en el hardware. El procesador no lee la memoria de a un int: la trae a su memoria caché, mucho más rápida, en bloques de 64 bytes llamados líneas de caché. Recorriendo por filas, cada línea traída contiene los 16 enteros que se van a usar a continuación, y el procesador, que detecta el patrón, trae las siguientes por adelantado. Recorriendo por columnas, cada acceso salta una fila entera, 16 KiB, y usa un solo int de cada línea; cuando el bucle vuelve a la columna siguiente, esas líneas ya fueron desalojadas de la caché. La regla práctica: el último índice va en el bucle más interno, para recorrer la memoria en orden. La misma idea, que acceder a la memoria en orden es más barato, vuelve a aparecer con la memoria virtual, donde el sistema operativo maneja la memoria en páginas de 4 KiB.

El ejemplo se compila con -O2 porque medir velocidad sin optimizar no tiene sentido; la matriz es static porque sus 64 MiB no caben en la pila.

5. Matrices como parámetros

Al pasar una matriz a una función, se pasa, como con cualquier arreglo, un puntero a su primer elemento: un int (*)[4]. El parámetro puede escribirse int m[][4] o int (*m)[4], que son equivalentes, y la cantidad de columnas forma parte del tipo: el compilador la necesita para calcular m[i][j]. La cantidad de filas, en cambio, se pierde, y hay que pasarla aparte, como con los arreglos del tema 11. Desde C99 existe también la forma con tamaños variables, en la que los tamaños son parámetros anteriores a la matriz:

parametros.c
#include <stdio.h>

/* columnas fijas: el compilador necesita el 4 para calcular m[i][j] */
static int suma_fija(int m[][4], size_t filas)
{
    int s = 0;
    for (size_t i = 0; i < filas; i++)
        for (size_t j = 0; j < 4; j++)
            s += m[i][j];
    return s;
}

/* columnas variables: los tamaños van antes que la matriz (C99) */
static int suma_vla(size_t filas, size_t cols, int m[filas][cols])
{
    int s = 0;
    for (size_t i = 0; i < filas; i++)
        for (size_t j = 0; j < cols; j++)
            s += m[i][j];
    return s;
}

/* la alternativa sin matrices: un arreglo plano y el índice calculado a mano */
static int suma_plana(const int *datos, size_t filas, size_t cols)
{
    int s = 0;
    for (size_t i = 0; i < filas; i++)
        for (size_t j = 0; j < cols; j++)
            s += datos[i * cols + j];
    return s;
}

int main(void)
{
    int a[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};
    int b[2][6] = {{1, 1, 1, 1, 1, 1}, {2, 2, 2, 2, 2, 2}};

    printf("suma_fija(a)  = %d\n", suma_fija(a, 3));
    printf("suma_vla(a)   = %d\n", suma_vla(3, 4, a));
    printf("suma_vla(b)   = %d\n", suma_vla(2, 6, b));
    printf("suma_plana(b) = %d\n", suma_plana(&b[0][0], 2, 6));
    return 0;
}
terminal
$ ./parametros
suma_fija(a)  = 78
suma_vla(a)   = 78
suma_vla(b)   = 18
suma_plana(b) = 18

Las tres formas tienen su lugar. suma_fija solo acepta matrices de 4 columnas: pasarle b, de 6, es un error de tipos. suma_vla acepta cualquier tamaño, porque el tipo del parámetro, int (*)[cols], se calcula al llamar; son los arreglos de longitud variable del tema 17, que aquí no reservan nada en la pila, solo describen la forma de la matriz recibida. C11 los volvió opcionales, pero gcc y clang los admiten, y C23 los vuelve a exigir en los parámetros. suma_plana es la forma más portable: un arreglo de una dimensión y el cálculo i * cols + j escrito a mano, que es lo que el compilador hace por dentro. Es la forma natural cuando la matriz se pide con malloc.

Lo que no funciona es la forma que parece obvia, int **m:

terminal · dobleptr.c: static int suma(int **m, int filas, int cols), llamada con una int a[3][4]
$ gcc -std=c17 -Wall -Wextra -g -o dobleptr dobleptr.c
dobleptr.c: In function ‘main’:
dobleptr.c:16:25: error: passing argument 1 of ‘suma’ from incompatible pointer type [-Wincompatible-pointer-types]
   16 |     printf("%d\n", suma(a, 3, 4));
      |                         ^
      |                         |
      |                         int (*)[4]
dobleptr.c:3:23: note: expected ‘int **’ but argument is of type ‘int (*)[4]’
    3 | static int suma(int **m, int filas, int cols)
      |                 ~~~~~~^

Un int ** apunta a un arreglo de punteros, y una matriz no contiene ningún puntero: contiene enteros. Si se forzara la conversión con un cast, m[i] leería los primeros 8 bytes de la matriz, los enteros 1 y 2, como si fueran una dirección, y el programa terminaría en Segmentation fault. Un arreglo de punteros a filas, como argv, sí es un char **, y se indexa con la misma sintaxis argv[i][j]; pero es otra estructura, con filas que pueden estar en cualquier lugar y tener distinto largo.

Un detalle sobre const: un parámetro const int m[][4] funciona con gcc, pero el estándar anterior a C23 consideraba incompatibles int (*)[4] y const int (*)[4], y -Wpedantic lo advierte: invalid use of pointers to arrays with different qualifiers in ISO C before C23. C23 corrigió la regla.

6. Arreglos de cadenas

Una lista de cadenas se puede guardar de las dos formas que comparamos en el tema 17: una matriz de char, donde cada fila es una cadena, o un arreglo de punteros a cadenas. La matriz tiene una ventaja: sus cadenas son propias y se pueden modificar.

tablero.c
#include <stdio.h>
#include <string.h>

enum { FILAS = 3, COLS = 6 };

int main(void)
{
    /* un arreglo de cadenas modificables: cada fila es una cadena */
    char tablero[FILAS][COLS + 1] = {
        "......",
        "..#...",
        "......",
    };
    tablero[1][4] = '@';
    memset(tablero[2], '=', COLS);        /* la fila 2 entera; su '\0' queda */
    for (int i = 0; i < FILAS; i++)
        printf("%d: %s\n", i, tablero[i]);
    printf("sizeof tablero = %zu\n", sizeof tablero);
    return 0;
}
terminal
$ ./tablero
0: ......
1: ..#.@.
2: ======
sizeof tablero = 21

Cada fila tiene COLS + 1 bytes, 6 caracteres y el terminador; olvidar ese + 1 es el error de char s[4] = "hola" del tema 21, ahora en cada fila. tablero[i] es una cadena completa, que se pasa a printf o a memset, y tablero[i][j] es un carácter. El costo es que todas las filas miden lo mismo, aunque las cadenas sean de largos muy distintos.

Para listas fijas de textos, la forma habitual es el arreglo de punteros, y es frecuente terminarlo con un NULL, igual que argv y environ en el tema 19. Ese centinela permite recorrer la lista sin conocer su largo, y pasarla a funciones sin un parámetro de tamaño:

ordenes.c
#include <stdio.h>
#include <string.h>

/* un arreglo de punteros terminado en NULL, como argv */
static const char *const ordenes[] = {"start", "stop", "restart", "status", NULL};

static int buscar(const char *const lista[], const char *nombre)
{
    for (int i = 0; lista[i] != NULL; i++)
        if (strcmp(lista[i], nombre) == 0)
            return i;
    return -1;
}

static void mostrar(const char *const lista[])
{
    for (const char *const *p = lista; *p != NULL; p++)
        printf(" %s", *p);
    printf("\n");
}

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "uso: %s orden\nórdenes:", argv[0]);
        mostrar(ordenes);
        return 2;
    }
    int i = buscar(ordenes, argv[1]);
    if (i < 0) {
        fprintf(stderr, "%s: orden desconocida: %s\n", argv[0], argv[1]);
        return 2;
    }
    printf("orden %d: %s\n", i, ordenes[i]);
    printf("sizeof ordenes = %zu\n", sizeof ordenes);
    return 0;
}
terminal
$ ./ordenes
uso: ./ordenes orden
órdenes: start stop restart status
$ ./ordenes stop
orden 1: stop
sizeof ordenes = 40
$ ./ordenes reload; echo $?
./ordenes: orden desconocida: reload
2

El tipo const char *const ordenes[], leído con el método del tema 19, es un arreglo de punteros constantes a caracteres constantes: no se pueden cambiar ni los textos, que son literales, ni a qué texto apunta cada posición. El arreglo ocupa 40 bytes, 5 punteros contando el NULL, y los textos están aparte, en .rodata, cada uno con su largo justo. Agregar una orden es agregar un texto antes del NULL; ni buscar ni mostrar cambian.

Matriz char t[N][L]Punteros const char *p[]
Dónde están los textosDentro de la matrizFuera: en .rodata, o donde sea
Largo de cada textoIgual para todos: L - 1 como máximoCada uno el suyo
¿Se pueden modificar?SíNo, si son literales
ReordenarCopiando filas enterasIntercambiando punteros, como qsort en el tema 20
Como parámetrochar t[][L]const char *const *p

7. argc y argv: lo que se garantiza

El estándar de C admite dos formas de main, int main(void) e int main(int argc, char *argv[]), que en el tema 19 vimos que es un char **. POSIX agrega environ como variable global, y glibc acepta un tercer parámetro, char *envp[], que es una extensión. Lo que el estándar garantiza sobre los argumentos es poco y preciso:

La segunda garantía tiene una condición que casi nadie lee: argc puede ser 0. Quien ejecuta un programa elige libremente qué pasarle, y la llamada al sistema execve acepta una lista de argumentos vacía. En 2022 se publicó PwnKit (CVE-2021-4034), una vulnerabilidad que existía desde 2009 en pkexec, un programa con privilegios de administrador presente en casi todas las distribuciones: suponía que argc era al menos 1, y ejecutado con argc igual a 0, leía argv[1], que en realidad era la primera variable de entorno, controlada por el atacante. Desde entonces, Linux reemplaza una lista vacía por una con un solo argumento vacío, pero un programa portable no debe suponerlo: multi.c, en la sección siguiente, comprueba argc > 0 antes de mirar argv[0].

8. argv[0] y los programas multillamada

argv[0] no es «la ruta del ejecutable»: es lo que quien ejecutó el programa decidió pasar como primer argumento. La shell pasa lo que se escribió para invocarlo, pero cualquier otro programa puede pasar lo que quiera. Un programa puede aprovechar ese nombre para comportarse distinto según cómo lo llamaron:

multi.c
#include <stdio.h>
#include <string.h>

/* el nombre con que se invocó el programa, sin el directorio */
static const char *nombre_base(const char *ruta)
{
    const char *barra = strrchr(ruta, '/');
    return barra != NULL ? barra + 1 : ruta;
}

int main(int argc, char *argv[])
{
    const char *yo = argc > 0 ? nombre_base(argv[0]) : "multi";

    printf("argv[0] = \"%s\", nombre = \"%s\"\n", argv[0], yo);
    if (strcmp(yo, "saludar") == 0)
        printf("¡Hola!\n");
    else if (strcmp(yo, "despedir") == 0)
        printf("¡Adiós!\n");
    else
        printf("invócame como saludar o despedir\n");
    return 0;
}
terminal
$ ln -s multi saludar
$ ln -s multi despedir
$ ./multi
argv[0] = "./multi", nombre = "multi"
invócame como saludar o despedir
$ ./saludar
argv[0] = "./saludar", nombre = "saludar"
¡Hola!
$ ~/c/tema23/saludar
argv[0] = "/home/ana/c/tema23/saludar", nombre = "saludar"
¡Hola!
$ (exec -a despedir ./multi)
argv[0] = "despedir", nombre = "despedir"
¡Adiós!

Los enlaces simbólicos ejecutan el mismo archivo, y lo único que cambia es argv[0]. La última orden muestra que ni siquiera hacen falta: exec -a, de Bash, ejecuta ./multi pasándole otro argv[0]. Un programa que use argv[0] para algo importante, como encontrar sus propios archivos o decidir permisos, confía en un dato que controla quien lo ejecuta; para conocer la ruta real del ejecutable, Linux ofrece el enlace /proc/self/exe.

Esta técnica es la de los programas multillamada (multicall), y Ubuntu la usa a gran escala:

terminal · las órdenes básicas de Ubuntu 26.04
$ ls -l /usr/bin/cat /usr/bin/ls /usr/bin/unxz /usr/bin/xzcat
lrwxrwxrwx 1 root root 30 Mar 30  2026 /usr/bin/cat -> ../lib/cargo/bin/coreutils/cat
lrwxrwxrwx 1 root root 29 Mar 30  2026 /usr/bin/ls -> ../lib/cargo/bin/coreutils/ls
lrwxrwxrwx 1 root root  2 Apr  1  2026 /usr/bin/unxz -> xz
lrwxrwxrwx 1 root root  2 Apr  1  2026 /usr/bin/xzcat -> xz
$ ls -li /usr/lib/cargo/bin/coreutils/cat /usr/lib/cargo/bin/coreutils/ls /usr/lib/cargo/bin/coreutils/sort
1547 -rwxr-xr-x 115 root root 11352352 Apr 16  2026 /usr/lib/cargo/bin/coreutils/cat
1547 -rwxr-xr-x 115 root root 11352352 Apr 16  2026 /usr/lib/cargo/bin/coreutils/ls
1547 -rwxr-xr-x 115 root root 11352352 Apr 16  2026 /usr/lib/cargo/bin/coreutils/sort

unxz y xzcat son enlaces a xz, que según su argv[0] descomprime o descomprime hacia la salida estándar. Y las órdenes básicas de Ubuntu 26.04, reescritas en Rust por el proyecto uutils, son todas el mismo archivo: cat, ls y sort tienen el mismo número de nodo, 1547, y ese archivo de 11 MB tiene 115 nombres, uno por orden. Al arrancar, el programa mira argv[0] y salta a la función correspondiente, con una tabla de despacho como la del tema 20. BusyBox, el conjunto de herramientas de muchos sistemas embebidos y contenedores, funciona igual.

9. Quién arma argv

Cuando se escribe una orden en la terminal, el programa no recibe la línea escrita. La shell la procesa antes: separa las palabras, interpreta comillas y barras invertidas, reemplaza variables y comodines, y recién entonces llama a execve con la lista resultante, que el núcleo copia en la pila del proceso nuevo. Este programa muestra exactamente lo que recibe:

mostrar.c
#include <stdio.h>

int main(int argc, char *argv[])
{
    for (int i = 0; i < argc; i++)
        printf("argv[%d] = [%s]\n", i, argv[i]);
    return 0;
}
terminal · en un directorio con a.c y b.c
$ ./mostrar *.c "$HOME" '$HOME' a\ b "" *.h
argv[0] = [./mostrar]
argv[1] = [a.c]
argv[2] = [b.c]
argv[3] = [/home/ana]
argv[4] = [$HOME]
argv[5] = [a b]
argv[6] = []
argv[7] = [*.h]
$ strace -e trace=execve ./mostrar *.c "$HOME" '$HOME' a\ b "" *.h 2>&1 >/dev/null | head -1
execve("./mostrar", ["./mostrar", "a.c", "b.c", "/home/ana", "$HOME", "a b", "", "*.h"], 0x7ffc0bbdada8 /* 22 vars */) = 0

Cada argumento muestra una transformación de la shell, como vimos en el curso Automatización con Scripts en Bash: *.c se expandió en dos argumentos; "$HOME" se reemplazó por su valor; '$HOME', entre comillas simples, llegó literal; a\ b es un solo argumento con un espacio; "" es un argumento vacío, que existe y cuenta en argc; y *.h, que no coincidió con ningún archivo, llegó tal cual, que es lo que hace Bash por defecto. strace muestra la llamada execve con la lista ya armada: el programa nunca ve las comillas ni los comodines. Por eso un programa en C no debe interpretar comodines ni comillas en sus argumentos, y, al revés, por eso un nombre de archivo con espacios llega intacto aunque el programa no haga nada especial.

10. Procesar opciones a mano

Las herramientas de Unix siguen una convención para sus argumentos: primero las opciones, que empiezan con - y modifican el comportamiento, algunas con un valor, como -n 5; después los operandos, como nombres de archivo; y -- marca el fin de las opciones, para poder nombrar un archivo que empiece con guion. Procesar eso a mano parece sencillo:

manual.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[])
{
    int detallado = 0;
    long lineas = 10;
    int i;

    for (i = 1; i < argc && argv[i][0] == '-'; i++) {
        if (strcmp(argv[i], "--") == 0) {     /* fin de las opciones */
            i++;
            break;
        } else if (strcmp(argv[i], "-v") == 0) {
            detallado = 1;
        } else if (strcmp(argv[i], "-n") == 0) {
            if (i + 1 == argc) {
                fprintf(stderr, "%s: -n necesita un valor\n", argv[0]);
                return 2;
            }
            lineas = atol(argv[++i]);         /* (sin validar: ver tema 22) */
        } else {
            fprintf(stderr, "%s: opción desconocida: %s\n", argv[0], argv[i]);
            return 2;
        }
    }
    printf("detallado = %d, lineas = %ld, operandos:", detallado, lineas);
    for (; i < argc; i++)
        printf(" [%s]", argv[i]);
    printf("\n");
    return 0;
}
terminal
$ ./manual -v -n 3 a b
detallado = 1, lineas = 3, operandos: [a] [b]
$ ./manual -- -v a
detallado = 0, lineas = 10, operandos: [-v] [a]
$ ./manual -vn3 a
./manual: opción desconocida: -vn3
$ ./manual a -v
detallado = 0, lineas = 10, operandos: [a] [-v]

Funciona para los casos simples, y ya es más largo que el resto del programa. Le faltan las cosas que todo usuario de Unix espera: agrupar opciones, -vn3 como en ls -la o tar -xzf; el valor pegado a la opción, -n3; mensajes de error uniformes. Cada una agrega casos, y cada programa que lo reescribe se equivoca distinto. Para eso existe getopt.

11. getopt

getopt(argc, argv, cadena), de POSIX, declarada en unistd.h, procesa una opción por llamada. La cadena enumera las letras válidas, y una letra seguida de : lleva un valor. Cada llamada devuelve la letra encontrada, '?' si la opción es desconocida o le falta el valor, y −1 cuando se terminan las opciones. Se comunica además con cuatro variables globales:

VariableContiene
optargEl valor de la opción actual, si lleva uno: un puntero dentro de argv.
optindEl índice del próximo elemento de argv a procesar. Al terminar, el primer operando.
opterrSi vale distinto de 0, el valor inicial, getopt muestra los errores en stderr.
optoptLa letra que causó el error, cuando devuelve '?'.

Este programa es una versión reducida de head: muestra las primeras líneas de cada archivo, o de la entrada estándar si no recibe ninguno. Combina getopt con la conversión validada del tema 22:

cabeza.c
#define _POSIX_C_SOURCE 200809L          /* getopt */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>

static void uso(FILE *f, const char *prog)
{
    fprintf(f, "uso: %s [-v] [-n LÍNEAS] [ARCHIVO...]\n", prog);
}

/* las primeras n líneas de f; devuelve 0, o -1 si hubo un error de lectura */
static int cabeza(FILE *f, long n)
{
    char linea[4096];

    while (n > 0 && fgets(linea, sizeof linea, f) != NULL) {
        fputs(linea, stdout);
        if (strchr(linea, '\n') != NULL)  /* contar solo líneas completas */
            n--;
    }
    return ferror(f) ? -1 : 0;
}

int main(int argc, char *argv[])
{
    int detallado = 0;
    long lineas = 10;
    int opcion;

    while ((opcion = getopt(argc, argv, "vn:h")) != -1) {
        switch (opcion) {
        case 'v':
            detallado = 1;
            break;
        case 'n': {
            char *fin;
            errno = 0;
            lineas = strtol(optarg, &fin, 10);
            if (fin == optarg || *fin != '\0' || errno == ERANGE || lineas < 0) {
                fprintf(stderr, "%s: cantidad de líneas inválida: '%s'\n", argv[0], optarg);
                return 2;
            }
            break;
        }
        case 'h':
            uso(stdout, argv[0]);
            return 0;
        default:                          /* '?': getopt ya mostró el error */
            uso(stderr, argv[0]);
            return 2;
        }
    }

    if (optind == argc)                   /* sin archivos: la entrada estándar */
        return cabeza(stdin, lineas) == 0 ? 0 : 1;

    int estado = 0;
    for (int i = optind; i < argc; i++) {
        FILE *f = fopen(argv[i], "r");
        if (f == NULL) {
            fprintf(stderr, "%s: %s: %s\n", argv[0], argv[i], strerror(errno));
            estado = 1;
            continue;
        }
        if (detallado)
            printf("==> %s <==\n", argv[i]);
        if (cabeza(f, lineas) != 0)
            estado = 1;
        fclose(f);
    }
    return estado;
}
terminal · datos.txt tiene las líneas uno a cuatro, y otro.txt alfa y beta
$ ./cabeza -n 2 datos.txt
uno
dos
$ ./cabeza -vn1 datos.txt otro.txt
==> datos.txt <==
uno
==> otro.txt <==
alfa
$ printf 'a\nb\nc\n' | ./cabeza -n 2
a
b
$ ./cabeza -n; echo $?
./cabeza: option requires an argument -- 'n'
uso: ./cabeza [-v] [-n LÍNEAS] [ARCHIVO...]
2
$ ./cabeza -x datos.txt; echo $?
./cabeza: invalid option -- 'x'
uso: ./cabeza [-v] [-n LÍNEAS] [ARCHIVO...]
2
$ ./cabeza -n dos datos.txt; echo $?
./cabeza: cantidad de líneas inválida: 'dos'
2
$ ./cabeza -n 1 noexiste datos.txt; echo $?
./cabeza: noexiste: No such file or directory
uno
1
$ ./cabeza -n 1 -- -x
menos

getopt resolvió todo lo que le faltaba a manual.c: -vn1 se separó en -v y -n con el valor 1, los errores tienen el formato estándar de Unix, y -- permitió leer un archivo llamado -x. El programa sigue las convenciones de las herramientas del sistema: los errores de uso terminan con código 2, como grep; un archivo que no se puede abrir no detiene a los demás, pero el código final es 1; y -h muestra la ayuda en la salida estándar y termina con 0, porque pedir ayuda no es un error.

Hay una diferencia de comportamiento que conviene conocer:

terminal · una opción después de un operando
$ ./cabeza datos.txt -n 1
./cabeza: -n: No such file or directory
./cabeza: 1: No such file or directory
uno
dos
tres
cuatro

POSIX establece que las opciones terminan en el primer operando: después de datos.txt, -n y 1 son nombres de archivo. Las herramientas de GNU, en cambio, aceptan opciones en cualquier posición, y el getopt de glibc lo hace por defecto. Pero al compilar con _POSIX_C_SOURCE, sin las extensiones de GNU, unistd.h reemplaza getopt por __posix_getopt, que sigue la regla de POSIX, y eso es lo que hizo cabeza.c. Las dos conductas son correctas; lo importante es saber cuál tiene el programa.

12. Opciones largas con getopt_long

Las opciones de una letra se agotan y son difíciles de recordar. GNU introdujo las opciones largas, como --lines=5 o --verbose, que se procesan con getopt_long, declarada en getopt.h. Recibe, además de la cadena de opciones cortas, una tabla de opciones largas, un arreglo de struct option terminado con un elemento en cero, con el mismo diseño de centinela de la sección 6. Las estructuras son el tema del tema 24; aquí basta saber que cada elemento agrupa cuatro datos. Los cambios respecto de cabeza.c son tres:

cabeza_largo.c · lo que cambia respecto de cabeza.c
#include <getopt.h>                       /* getopt_long: extensión de GNU */

static const struct option largas[] = {
    {"verbose", no_argument,       NULL, 'v'},
    {"lines",   required_argument, NULL, 'n'},
    {"help",    no_argument,       NULL, 'h'},
    {NULL, 0, NULL, 0},                   /* fin de la tabla */
};
…
    while ((opcion = getopt_long(argc, argv, "vn:h", largas, NULL)) != -1) {
        …                                 /* el mismo switch de cabeza.c */

Cada fila de la tabla da el nombre largo, si lleva valor (no_argument, required_argument u optional_argument), y el valor que debe devolver getopt_long cuando la encuentra. Haciendo que --lines devuelva 'n', la misma rama del switch procesa -n 5 y --lines=5, y el resto del programa no cambia:

terminal
$ ./cabeza_largo --lines=2 datos.txt
uno
dos
$ ./cabeza_largo --verb --lines 1 datos.txt otro.txt
==> datos.txt <==
uno
==> otro.txt <==
alfa
$ ./cabeza_largo datos.txt -n 1
uno
$ POSIXLY_CORRECT=1 ./cabeza_largo datos.txt -n 1
./cabeza_largo: -n: No such file or directory
./cabeza_largo: 1: No such file or directory
uno
dos
tres
cuatro
$ ./cabeza_largo --verbose=si datos.txt
./cabeza_largo: option '--verbose' doesn't allow an argument
uso: ./cabeza_largo [-v|--verbose] [-n|--lines LÍNEAS] [ARCHIVO...]
$ ./cabeza_largo --nada
./cabeza_largo: unrecognized option '--nada'
uso: ./cabeza_largo [-v|--verbose] [-n|--lines LÍNEAS] [ARCHIVO...]

El valor de una opción larga puede ir pegado con = o como argumento siguiente, y los nombres se pueden abreviar mientras no sean ambiguos: --verb es --verbose. Y getopt_long sí acepta opciones después de los operandos, como las herramientas de GNU, salvo que la variable de entorno POSIXLY_CORRECT esté definida. Lo hace reordenando argv, cosa que el estándar permite porque las cadenas y el arreglo son del programa:

argv antes y después de getopt_long con la orden ./permuta datos.txt -n 1 otro.txt -v. Antes: ./permuta, datos.txt, -n, 1, otro.txt, -v. Después: ./permuta, -n, 1, -v, datos.txt, otro.txt. Las opciones quedaron primero y los operandos al final, y optind vale 4, el índice del primer operando antes de getopt_long ./permuta datos.txt -n 1 otro.txt -v después ./permuta -n 1 -v datos.txt otro.txt 0 1 2 3 4 = optind 5 Las opciones quedan adelante; los operandos, desde optind hasta argc - 1.
Con ./permuta datos.txt -n 1 otro.txt -v, getopt_long procesa las opciones y mueve los operandos al final.

Por eso el bucle de los archivos, que empieza en optind, encuentra todos los operandos juntos, estén donde estén en la línea de órdenes. Las convenciones que siguen las herramientas de GNU, y que conviene imitar, son: toda opción corta frecuente tiene una larga equivalente; --help y --version existen siempre, escriben en la salida estándar y terminan con 0; y los errores de uso muestran un mensaje breve en stderr y terminan con un código distinto de 0.

13. Matrices y argv en gdb

gdb conoce los tipos de las matrices y muestra su estructura completa:

terminal · gdb sobre matriz, detenido en el return
$ gdb -q -batch -ex 'break 29' -ex run -ex 'p m' -ex 'p m[1]' -ex 'p m[1][2]' -ex 'ptype m' \
      -ex 'whatis m[1]' -ex 'whatis &m[1]' -ex 'p *p@12' -ex 'x/12dw m' ./matriz
Breakpoint 1, main () at matriz.c:29
29	    return 0;
$1 = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}}
$2 = {5, 6, 7, 8}
$3 = 7
type = int [3][4]
type = int [4]
type = int (*)[4]
$4 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
0x7fffffffd7e0:	1	2	3	4
0x7fffffffd7f0:	5	6	7	8
0x7fffffffd800:	9	10	11	12

p m muestra las llaves anidadas, y whatis confirma los tipos de la sección 3: m[1] es un int [4], y &m[1], un puntero a fila. p *p@12 usa el operador @ de gdb, que muestra 12 elementos a partir de *p, y la vista plana coincide con la matriz. x/12dw m examina la memoria cruda: 12 palabras de 4 bytes en decimal, y las direcciones de cada renglón, separadas por 0x10, 16 bytes, muestran las filas una detrás de otra. Con argv, el operador @ es igual de útil:

terminal · gdb sobre mostrar, con dos argumentos
$ gdb -q -batch -ex 'break 6' -ex 'run uno "dos tres"' -ex 'p argc' -ex 'p *argv@argc' \
      -ex 'p argv[argc]' -ex 'x/3s argv[0]' -ex 'ptype argv' ./mostrar
Breakpoint 1, main (argc=3, argv=0x7fffffffd928) at mostrar.c:6
6	        printf("argv[%d] = [%s]\n", i, argv[i]);
$1 = 3
$2 = {0x7fffffffdba8 "/home/ana/c/tema23/mostrar", 0x7fffffffdbc3 "uno", 0x7fffffffdbc7 "dos tres"}
$3 = 0x0
0x7fffffffdba8:	"/home/ana/c/tema23/mostrar"
0x7fffffffdbc3:	"uno"
0x7fffffffdbc7:	"dos tres"
type = char **

p *argv@argc muestra el arreglo de punteros con las cadenas a las que apuntan, y p argv[argc] confirma el NULL final. x/3s examina tres cadenas seguidas a partir de argv[0], y las direcciones muestran lo que dijimos del núcleo en el tema 19 y del archivo /proc/PID/cmdline en el tema 21: las cadenas de los argumentos están una detrás de otra, cada una con su \0, cerca del tope de la pila. gdb pasa como argv[0] la ruta completa del programa, y por eso no aparece ./mostrar.

14. Errores frecuentes

ErrorQué pasaCorrección
m[i, j] en lugar de m[i][j]El operador coma da m[j]: una fila, no un elemento. -Wall lo advierte con -Wunused-value.m[i][j].
Recorrer por columnas una matriz grandeCorrecto pero varias veces más lento: no aprovecha la caché.El último índice en el bucle interno.
Una matriz local grandeDesborda la pila de 8 MiB: Segmentation fault al entrar.static, global o malloc.
Pasar una matriz a un parámetro int **Error de tipos; con un cast, los enteros se leen como direcciones.int m[][N], int m[f][c] o un arreglo plano.
int *m[4] queriendo un puntero a filaDeclara un arreglo de 4 punteros.int (*m)[4].
char t[N][L] con textos de L caracteresNo queda lugar para el \0.L + 1 columnas.
Recorrer una lista sin el NULL finalEl bucle sigue por memoria ajena.Terminar las listas de punteros con NULL.
Suponer argc >= 1Con argc == 0, argv[1] es el entorno: el error de PwnKit.Comprobar argc antes de usar argv[0].
Confiar en argv[0] para hallar el ejecutableEs lo que pasó quien ejecutó: puede ser cualquier cosa./proc/self/exe.
Interpretar comodines o comillas en argvLa shell ya lo hizo: se procesan dos veces.Tratar cada argumento como un texto final.
Olvidar el : en la cadena de getoptoptarg es NULL, y el valor se toma como operando."n:" para las opciones con valor.
Procesar operandos desde 1 y no desde optindLas opciones se toman como archivos.for (i = optind; i < argc; i++).

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. En int m[3][4], ¿a cuántos bytes del comienzo está m[2][1]?

A 36 bytes. Antes de él hay dos filas completas, 2 × 4 = 8 enteros, y un elemento de la fila 2: 9 enteros de 4 bytes. Es el cálculo (i × 4 + j) × sizeof(int), que coincide con la salida de matriz.c.

2. ¿Por qué se puede omitir la primera dimensión de una matriz, pero no la segunda?

Porque para encontrar m[i][j] el compilador multiplica i por la cantidad de columnas, que define el tamaño de cada fila. La cantidad de filas no interviene en ese cálculo: solo dice dónde termina la matriz, y C no comprueba los límites. Al inicializar, además, el compilador puede contar las filas.

3. ¿Qué tipo tiene m cuando se convierte en puntero, y cuánto avanza m + 1?

Se convierte en int (*)[4], un puntero a un arreglo de 4 int, porque el primer elemento de m es una fila. m + 1 avanza una fila completa, 16 bytes.

4. ¿Por qué recorrer una matriz grande por columnas es más lento que por filas?

Porque el procesador trae la memoria a su caché en líneas de 64 bytes. Por filas, cada línea traída contiene los elementos que se van a usar a continuación. Por columnas, cada acceso salta una fila entera, se usa un solo elemento de cada línea, y cuando el recorrido vuelve a esa zona la línea ya fue desalojada: casi cada acceso va a la memoria principal.

5. ¿Por qué una matriz int a[3][4] no se puede pasar a un parámetro int **?

Porque un int ** apunta a un arreglo de punteros, y la matriz no contiene punteros sino enteros contiguos. Al indexar m[i], la función leería 8 bytes de enteros como si fueran una dirección. El tipo correcto del argumento es int (*)[4].

6. ¿Qué ventaja tiene terminar un arreglo de punteros con NULL?

Que se puede recorrer sin conocer su largo, avanzando hasta el NULL, y pasarlo a funciones sin un parámetro de tamaño. Es la forma de argv, environ y de la tabla de getopt_long, y permite agregar elementos sin cambiar el código que la recorre.

7. ¿Qué garantiza el estándar sobre argv[argc] y sobre argv[0]?

Que argv[argc] es un puntero nulo. Sobre argv[0], solo que representa el nombre del programa si argc es mayor que 0. argc puede ser 0, y en ese caso argv[0] es el NULL final; PwnKit explotó un programa que no lo tenía en cuenta.

8. ¿Cómo hace un programa multillamada para saber qué función cumplir?

Mira argv[0], normalmente quitándole el directorio, y elige según ese nombre. El mismo ejecutable se instala con varios nombres, como enlaces simbólicos o enlaces duros, y cada uno se comporta como una orden distinta, como xz y unxz, o las órdenes de uutils.

9. Si un programa recibe *.h como argumento, ¿qué significa?

Que la shell no encontró ningún archivo que coincidiera con el comodín y, como hace Bash por defecto, pasó el texto tal cual. El programa no debe expandirlo él mismo: lo correcto es tratarlo como un nombre de archivo, que probablemente no exista.

10. ¿Qué significan las letras y los dos puntos de la cadena "vn:h" de getopt?

Que el programa acepta las opciones -v, -n y -h, y que -n lleva un valor, por los : que la siguen. El valor se recibe en optarg, ya sea pegado, -n5, o como argumento siguiente, -n 5.

11. ¿Para qué sirve optind después del bucle de getopt?

Es el índice del primer argumento que no es una opción: los operandos van de argv[optind] a argv[argc - 1]. Si optind es igual a argc, no hay operandos, y cabeza.c lee la entrada estándar.

12. ¿Por qué ./cabeza datos.txt -n 1 trató -n como un archivo, y ./cabeza_largo datos.txt -n 1 no?

Porque cabeza.c se compila con _POSIX_C_SOURCE, y glibc usa entonces la versión POSIX de getopt, en la que las opciones terminan en el primer operando. getopt_long, extensión de GNU, reordena argv para aceptar opciones en cualquier posición, salvo que esté definida POSIXLY_CORRECT.

13. ¿Cómo se muestra un archivo llamado -x con cabeza?

Con ./cabeza -- -x: -- marca el fin de las opciones, y todo lo que sigue es operando aunque empiece con guion. También funciona ./cabeza ./-x, porque ./-x no empieza con guion.

Para practicar

  1. Escribe void transponer(size_t n, int m[n][n]), que transponga una matriz cuadrada en el lugar, y pruébala con una de 3 × 3 y otra de 5 × 5.
  2. Escribe una función que multiplique dos matrices de tamaños compatibles, con parámetros de tamaño variable. Mide con clock_gettime el tiempo para 512 × 512 con el orden de bucles i, j, k y con i, k, j, y explica la diferencia.
  3. Modifica recorrido.c para medir con N de 256, 1024 y 4096. ¿A partir de qué tamaño se nota la diferencia entre los dos órdenes? Relaciónalo con el tamaño de la caché de tu procesador, que muestra lscpu.
  4. Agrega a ordenes.c una orden ayuda, y haz que buscar acepte prefijos únicos, como getopt_long: sta es ambiguo, stat es status.
  5. Agrega a multi.c un tercer nombre, contar, que muestre cuántos argumentos recibió, y crea el enlace correspondiente. Ejecútalo con exec -a y con env -a si tu versión de env lo admite.
  6. Agrega a cabeza_largo.c una opción -c / --bytes que muestre los primeros N bytes en lugar de las primeras líneas, y una opción --version.
  7. Escribe un programa que reciba opciones -u USUARIO y -s INTÉRPRETE con getopt, y filtre /etc/passwd con el lector usuarios.c del tema 22.