Arrays multidimensionales y VLA
La disposición row-major en memoria, la aritmética de punteros que hace funcionar las matrices, y los arrays de longitud variable: qué resuelven, qué cuestan y por qué C23 los volvió opcionales.
En C no existen las matrices: existen arrays cuyos elementos son, a su vez, arrays. Esa recursión aparentemente inocente lo explica todo —el orden en memoria, la aritmética de índices, por qué una firma de función necesita todas las dimensiones menos la primera, y por qué recorrer una matriz por columnas puede ser diez veces más lento que recorrerla por filas—. Encima de ese modelo, C99 apiló los arrays de longitud variable, la única construcción del lenguaje cuyo tipo depende de un valor calculado en tiempo de ejecución. Son elegantes, son útiles y son la fuente de desbordamientos de pila más silenciosa que existe.
- Entender la disposición row-major como consecuencia de “array de arrays”.
- Traducir cualquier indexación multidimensional a aritmética de punteros explícita.
- Declarar y pasar matrices con el tipo correcto, incluidas las de tamaño dinámico.
- Valorar los VLA con criterio: dónde ayudan, qué riesgo introducen y qué dice C23.
Row-major: un array de arrays
int m[3][4] no declara “una matriz de 3 por 4”. Declara un array de 3 elementos, cada uno de los cuales es un array de 4 enteros. La memoria es plana y contigua, y las filas se colocan una detrás de otra:
int m[3][4] = {
{ 0, 1, 2, 3 },
{ 10, 11, 12, 13 },
{ 20, 21, 22, 23 },
};
sizeof m; /* 48: tres filas de cuatro int */
sizeof m[0]; /* 16: una fila entera */
sizeof m[0][0];/* 4: un elemento */
Ese orden se llama row-major y no es una convención arbitraria: se deduce de la definición. El último índice es el que varía más rápido en memoria, porque es el que recorre el array más interno. Fortran hace lo contrario —column-major— y por eso las bibliotecas de álgebra lineal traducidas de Fortran, como BLAS o LAPACK, exigen que declares explícitamente el orden.
flowchart LR M[int m de 3 filas por 4 columnas] --> F0[Fila 0 en bytes 0 a 15] M --> F1[Fila 1 en bytes 16 a 31] M --> F2[Fila 2 en bytes 32 a 47] F0 --> L[Memoria plana contigua] F1 --> L F2 --> L L --> C[El ultimo indice varia mas rapido] C --> P[Recorrer por filas respeta la cache] style P fill:#a6e3a1,color:#11111b
La consecuencia de rendimiento es directa y medible. Recorrer m[i][j] con j en el bucle interno lee direcciones consecutivas y aprovecha cada línea de caché completa; invertir los bucles salta de fila en fila y desperdicia la mayor parte de cada línea traída. En matrices grandes la diferencia no es de porcentajes: es de un orden de magnitud.
La aritmética que hay debajo
Aplica la regla del decaimiento a cada nivel y todo encaja. En una expresión, m decae a puntero a su primer elemento, y su primer elemento es un array de 4 enteros:
int m[3][4];
/* m decae a int (*)[4], no a int** */
int (*fila)[4] = m;
/* m[i][j] se desdobla asi: */
m[i][j] == *(*(m + i) + j);
/* m + i avanza i filas, es decir i * 4 * sizeof(int) bytes */
/* *(m + i) es una fila, que decae a int*, y +j avanza j enteros */
De aquí sale la fórmula que todo el mundo memoriza y casi nadie deduce: el elemento en la fila i, columna j de una matriz de C columnas está en el desplazamiento i * C + j. El compilador la genera por ti a partir del tipo, y por eso necesita conocer C.
int m[3][4] y int **p son tipos incompatibles y disposiciones incompatibles. La matriz es un bloque único de 48 bytes; el doble puntero es una dirección que apunta a un array de punteros, cada uno apuntando a una fila que puede estar en cualquier parte del montón. Pasar m a una función que espera int ** compila con aviso en el mejor de los casos y produce basura al desreferenciar, porque el receptor interpretará los enteros de la primera fila como si fueran direcciones. Si necesitas el patrón de doble puntero, constrúyelo explícitamente reservando el array de punteros y llenándolo.
Por eso la firma de una función que recibe una matriz debe llevar todas las dimensiones salvo la primera: sin el número de columnas, el compilador no puede escalar el índice de fila.
void imprimir(int m[][4], size_t filas); /* ajustado a int (*m)[4] */
void imprimir2(int (*m)[4], size_t filas); /* identico y mas honesto */
Cuando las columnas no se conocen en compilación y no quieres VLA, queda el aplanado manual: un único bloque lineal más una función de indexación escrita a mano. Es la representación que usan casi todas las bibliotecas numéricas serias, porque permite elegir el layout y porque viaja bien entre lenguajes.
double *m = malloc(filas * cols * sizeof *m); /* comprobar el desbordamiento antes */
static inline size_t idx(size_t i, size_t j, size_t cols) { return i * cols + j; }
m[idx(i, j, cols)] = 1.0;
filas * cols * sizeof *m es una multiplicación de size_t que puede desbordarse en silencio y devolver un número pequeño: reservas poco, indexas mucho y tienes un desbordamiento de montón explotable. Es el patrón que ha causado vulnerabilidades en descodificadores de imagen durante años, porque las dimensiones vienen del fichero de entrada. Valida cada factor contra un máximo razonable, o usa ckd_mul de stdckdint.h, que C23 estandarizó precisamente para esto y devuelve un booleano de desbordamiento.
Arrays de longitud variable
Un VLA es un array cuyo tamaño se evalúa en tiempo de ejecución. Su tipo es variablemente modificado: existe en el sistema de tipos, pero su extensión solo se conoce al entrar en el ámbito.
void trabajo(size_t n) {
int temp[n]; /* VLA en la pila */
for (size_t i = 0; i < n; i++) temp[i] = (int)i;
/* se libera solo al salir del ambito */
}
Su gran virtud aparece en los parámetros, donde por fin permiten tipar matrices de geometría dinámica con indexación natural:
/* El compilador ya sabe cuantas columnas hay: puede escalar los indices */
void escalar(size_t filas, size_t cols, double m[filas][cols], double k) {
for (size_t i = 0; i < filas; i++)
for (size_t j = 0; j < cols; j++)
m[i][j] *= k;
}
Compara eso con la alternativa clásica —recibir un double * y escribir a mano m[i * cols + j]— y verás por qué los VLA como parámetro son la parte del mecanismo que casi todo el mundo defiende, incluso quienes desconfían del resto.
Dónde viven
En la pila del marco actual, ajustando el puntero de pila en tiempo de ejecución. No hay free: se recuperan al salir del ámbito, incluso con goto o longjmp bien formados.
Qué pueden romper
Un n grande, negativo convertido a size_t o controlado por el atacante desborda la pila sin diagnóstico. Es la variante de desbordamiento que los sanitizers detectan peor.
Dónde brillan
Como parámetros de matriz. m[filas][cols] documenta la geometría en la firma y deja que el compilador calcule los desplazamientos.
Qué dice el estándar
C99 los hizo obligatorios, C11 los degradó a opcionales con __STDC_NO_VLA__, y C23 mantiene opcionales los objetos VLA pero obliga a soportar los parámetros de tipo variablemente modificado.
Cuando el tamaño no es pequeño ni acotado por construcción, la respuesta correcta es memoria dinámica, no un VLA. El kernel de Linux los prohibió por completo en 2018 tras una campaña de eliminación sistemática, y MISRA C los proscribe en código crítico. La razón es siempre la misma: el fallo por agotamiento de pila no es recuperable ni detectable de forma portable.
Lo que de verdad se juega en este capítulo no es la sintaxis de los corchetes dobles, sino una idea que atraviesa toda la ingeniería de sistemas: la geometría de los datos vive en el tipo o vive en tu cabeza, y solo una de las dos opciones la comprueba alguien. Cuando escribes double m[filas][cols] en una firma, la relación entre el puntero y su forma queda inscrita en el lenguaje: el compilador calcula los desplazamientos, la indexación se lee como matemática y un cambio de geometría rompe la compilación en vez de corromper la memoria. Cuando lo aplanas a un double * con aritmética manual, esa relación se evapora del programa y queda solo en un comentario, en una convención de nombres o en la memoria de quien lo escribió; nada impide multiplicar por el número de filas donde tocaba el de columnas, y el resultado no es un error sino datos plausibles y equivocados. El aplanado sigue siendo necesario —para intercambio con bibliotecas, para serialización, para layouts exóticos como los bloques por tiles que exprimen la caché—, pero cada vez que lo eliges estás moviendo una invariante desde el sistema de tipos hasta la disciplina humana, y ese traslado tiene un precio que se paga en depuración. La misma lógica explica el destino de los VLA: prometieron llevar al tipo una información dinámica, y lo lograron para los parámetros; pero para los objetos pagaron ese poder con memoria de pila sin límite comprobable, y la comunidad de sistemas decidió que ese precio era inaceptable. Aprende a leer cada decisión de layout con esta pregunta: ¿qué invariante estoy sacando del compilador, y quién la va a vigilar cuando yo no esté?
- Mide el tiempo de sumar una matriz de 2000 por 2000 recorriéndola por filas y por columnas. Explica la diferencia en términos de líneas de caché.
- Escribe
m[i][j]como*(*(m + i) + j)y verifica conprintfque ambas expresiones dan el mismo valor y la misma dirección. - Imprime el tipo de
m,m[0]y&musandotypeofde C23 y declara una variable de cada tipo. - Implementa la misma multiplicación de matrices dos veces: con parámetro VLA
double m[filas][cols]y con puntero plano más aritmética manual. Compara legibilidad y código generado. - Provoca un desbordamiento de pila con un VLA de tamaño grande y comprueba qué informa AddressSanitizer. Reescríbelo con memoria dinámica y comprobación del fallo de reserva.