wandres.dev
MODULAR · Modularidad y linkage

Los tres linkages: externo, interno y ninguno

Cómo decide C qué declaraciones repartidas por archivos distintos designan la misma entidad, por qué static significa cuatro cosas sin relación entre sí, y cómo se traduce todo eso en símbolos reales del archivo objeto.

⏱ 18 min

Casi todo el mundo cree que sabe qué hace static, y casi todo el mundo descubre tarde que la palabra significa cosas sin relación entre sí según dónde aparezca, que extern no siempre implica linkage externo, y que la pregunta “¿este nombre se ve desde otro archivo?” no la responde el alcance sino un atributo distinto llamado linkage. Es el mecanismo con el que el lenguaje decide qué declaraciones dispersas por unidades de traducción distintas designan la misma entidad. De él dependen el encapsulamiento, la ausencia de colisiones y la mitad de los errores del enlazador que has visto en tu vida.

🎯 Al terminar esta lección sabrás
  • Separar los tres ejes ortogonales: alcance, duración de almacenamiento y linkage.
  • Determinar el linkage de cualquier declaración aplicando las reglas del estándar.
  • Explicar los cuatro significados de static y los dos de extern.
  • Traducir el linkage a símbolos reales del objeto y distinguirlo de la visibilidad ELF.

Tres ejes que casi todos colapsan en uno

El estándar asocia a cada identificador tres propiedades independientes, y confundirlas es el origen de casi toda la niebla que rodea a static.

Eje Pregunta que responde Dominio
Alcance ¿Dónde es visible este nombre en el texto del programa? Archivo, bloque, prototipo, etiqueta
Duración de almacenamiento ¿Cuánto vive el objeto? Estática, automática, asignada, de hilo
Linkage ¿Declaraciones distintas designan la misma entidad? Externo, interno, ninguno

Los tres ejes son verdaderamente independientes, y esa independencia es lo que hay que interiorizar: ninguna posición en uno determina la posición en los otros. Un mismo objeto puede tener alcance de bloque y duración estática; puede tener duración estática y ningún linkage; puede tener linkage externo y ser invisible desde el archivo de al lado por puro alcance. Son cuatro combinaciones frecuentes y una sola palabra clave que las genera casi todas:

int publico = 0;               /* archivo, estatica, linkage EXTERNO */
static int estado = 0;         /* archivo, estatica, linkage INTERNO */

void f(void) {
    static int contador = 0;   /* bloque, estatica, SIN linkage */
    int local = 0;             /* bloque, automatica, SIN linkage */
}

estado y contador comparten duración de almacenamiento —ambos viven todo el programa y se inicializan a cero antes de main— y sin embargo su linkage no tiene nada que ver: uno es interno y el otro no tiene ninguno. La misma palabra ha hecho dos trabajos distintos porque estaba en dos posiciones distintas. Ahí empieza la confusión, y ahí termina en cuanto separas los ejes.

Cómo se determina el linkage

Las reglas caben en cinco líneas y conviene memorizarlas literalmente, porque el compilador las aplica sin piedad.

  1. Alcance de archivo con static: linkage interno. La entidad es privada de esta unidad de traducción.
  2. Alcance de archivo sin especificador, objetos y funciones: linkage externo. Es el valor por defecto, incluida toda función que no marques.
  3. Con extern: hereda el linkage de una declaración previa visible del mismo identificador; si no hay ninguna, es externo.
  4. Alcance de bloque para una función: siempre externo, escribas extern o no.
  5. Todo lo demás: ningún linkage. Variables de bloque sin extern, parámetros, nombres de typedef, etiquetas de struct, union y enum, miembros y constantes de enumeración.

La tercera regla es la que sorprende a todo el mundo, porque contradice la lectura ingenua de la palabra:

static int oculto;      /* linkage interno */
extern int oculto;      /* SIGUE siendo interno: extern hereda, no impone */

Y hay una trampa peor. Si en una misma unidad de traducción un identificador aparece con linkage interno y con linkage externo, el comportamiento es indefinido y el estándar no exige diagnóstico. El caso canónico es olvidar el static en la definición después de haberlo puesto en el prototipo, o al revés: el programa compila, enlaza y hace lo que le apetezca.

La quinta regla merece una lectura despacio, porque explica cosas que parecen misteriosas. Que un nombre de typedef no tenga linkage significa que el alias no viaja: dos unidades de traducción que quieran usar el mismo alias tienen que verlo cada una, y por eso los alias viven en cabeceras y repetir la misma definición en varias unidades es legal. Lo mismo pasa con las etiquetas de struct y con las constantes de enumeración: no hay ningún símbolo que el enlazador tenga que reconciliar, solo texto que cada unidad procesa por su cuenta. La compatibilidad entre unidades de dos declaraciones de la misma struct no la garantiza el linkage sino las reglas de tipo compatible — y esa es exactamente la razón por la que una definición divergente entre dos archivos no da error de enlazado, sino corrupción silenciosa de memoria.

Linkage Alcance de la identidad Ejemplos típicos
Externo Todo el programa, incluidas las bibliotecas enlazadas Funciones sin marcar, objetos de archivo sin static
Interno Una unidad de traducción Todo lo marcado static a nivel de archivo
Ninguno Cada declaración es una entidad distinta Locales, parámetros, typedef, etiquetas, miembros
flowchart TD
A[Declaracion de un identificador] --> B[Alcance de archivo]
A --> C[Alcance de bloque]
B --> D[Con static: linkage interno]
B --> E[Sin especificador: linkage externo]
B --> F[Con extern: hereda del previo o externo]
C --> G[Funcion: linkage externo siempre]
C --> H[Objeto con extern: hereda del alcance de archivo]
C --> I[Objeto sin extern: ningun linkage]
style D fill:#a6e3a1,color:#11111b
style I fill:#89b4fa,color:#11111b

Las cuatro caras de static y las dos de extern

static es probablemente la palabra clave más sobrecargada del lenguaje. Estas son sus cuatro apariciones, y ninguna se deduce de las otras.

🔒

Alcance de archivo

Linkage interno: el símbolo no sale de esta unidad de traducción. Es el mecanismo de ocultación del lenguaje.

Alcance de bloque

Duración estática y ningún linkage: la variable sobrevive a la llamada pero sigue siendo invisible fuera.

📎

static inline

Copia propia en cada unidad que incluya la cabecera. Ni símbolo duplicado ni símbolo ausente.

⚙️

En un parámetro array

void f(int v[static 4]) no habla de linkage: promete un puntero no nulo y al menos cuatro elementos.

Las dos primeras no se parecen en nada: una habla de quién ve el nombre, la otra de cuánto vive el objeto. La tercera existe solo por una peculiaridad del modelo de compilación separada, y la cuarta ni siquiera trata de almacenamiento. Que las cuatro compartan palabra es un accidente histórico de economía de palabras reservadas, no un concepto común que se te esté escapando. Dejar de buscar ese concepto común es la mitad de entender static.

extern tiene dos usos legítimos y uno que casi siempre es un error. El legítimo a nivel de archivo es declarar sin definir: prometer que la entidad existe en algún .o, que es lo que hace cualquier prototipo de función y lo que debe hacer un objeto compartido declarado en una cabecera. El segundo, dentro de un bloque, hace referencia a una entidad de alcance de archivo sin necesidad de la cabecera; es válido y es una pésima idea, porque esconde una dependencia que ninguna herramienta va a encontrar. El error es creer que extern da linkage externo: solo lo hereda.

ℹ️
C23 no cambió las reglas, pero añadió una trampa nueva

constexpr llega en C23 como especificador de clase de almacenamiento, igual que static y extern, y precisamente por eso hereda el mismo esquema: un objeto constexpr a nivel de archivo no se convierte por arte de magia en algo que pueda repetirse. Si lo escribes tal cual en una cabecera incluida por varias unidades, estás poniendo ahí una definición con las consecuencias de siempre. La forma segura es la misma que ya conoces para las funciones cortas: static constexpr en la cabecera, exactamente por la misma razón por la que se escribe static inline. La palabra nueva no compensa la regla vieja.

⚠️
Definiciones tentativas y el fin de -fcommon

int x; a nivel de archivo no es exactamente una definición: es una definición tentativa. Si al final de la unidad de traducción nadie la definió con inicializador, el compilador la convierte en una definición con valor cero. Repetirla varias veces en la misma unidad es legal. El desastre histórico venía de repetirla en unidades distintas: durante décadas los compiladores las fusionaban en un único símbolo común, y por eso poner int contador; en una cabecera incluida por veinte archivos parecía funcionar mientras en realidad los veinte compartían silenciosamente una variable que ninguno creía compartir. GCC 10 cambió el valor por defecto a -fno-common y ese código pasó a ser un error de definición múltiple. No lo arregles con -fcommon: era un error desde el primer día.

Del linkage a los símbolos del objeto

El linkage es un concepto del lenguaje; el enlazador solo ve símbolos. La traducción entre ambos mundos es directa y puedes inspeccionarla:

gcc -std=c23 -c modulo.c -o modulo.o
nm modulo.o          # mayuscula = global, minuscula = local
# T api_publica      -> texto global: linkage externo
# t ayudante         -> texto local:  linkage interno
# B publico          -> bss global
# b estado           -> bss local
# U memcpy           -> indefinido: lo resuelve otro objeto

Merece la pena fijarse en la última línea. U no significa error: significa petición pendiente. El objeto declara que necesita memcpy y confía en que alguien se lo dé; el enlazador recorrerá los objetos y bibliotecas que le pases, en el orden en que se los pases, buscando un símbolo global con ese nombre. Todo el modelo de compilación separada de C cabe en esa asimetría entre lo que un objeto define y lo que pide.

La regla nemotécnica es que la minúscula es privacidad: un símbolo local no participa en la resolución entre objetos, así que dos archivos pueden tener cada uno su ayudante sin colisionar. Esa es exactamente la propiedad que compras al escribir static.

Conviene además saber leer la ausencia. Un símbolo marcado como indefinido no es un error todavía: es una petición que el enlazador intentará satisfacer con los objetos y bibliotecas que le des, en el orden en que se los des. El famoso undefined reference es simplemente esa petición que nadie atendió, y sus tres causas habituales son declarar algo que ningún archivo definió, definirlo en un archivo que olvidaste pasar al enlazador, o —la más sutil— haberlo definido static, con lo que el símbolo existe pero es local y por tanto invisible para la resolución.

Hay un tercer nivel que conviene no mezclar. En bibliotecas compartidas existe además la visibilidad ELF, un atributo del formato del objeto, no del lenguaje: un símbolo con linkage externo puede quedar oculto en el .so con -fvisibility=hidden o con [[gnu::visibility("hidden")]]. Tener linkage externo significa “otras unidades de traducción del programa pueden referirse a mí”; ser exportado significa “cualquiera que cargue esta biblioteca puede referirse a mí”. Confundirlos lleva a APIs que exponen media implementación sin querer.

El linkage es el único módulo que C tiene

C no tiene módulos, no tiene espacios de nombres, no tiene public ni private, y sin embargo se han construido con él sistemas operativos enteros. El truco es que todo ese aparato lo sustituye una única distinción binaria evaluada por unidad de traducción: interno o externo. Entenderlo como una decisión de arquitectura y no como un detalle de sintaxis cambia la forma de escribir código. Cada símbolo con linkage externo es una entrada en un espacio de nombres global y plano compartido por todo el programa, incluidas las bibliotecas que enlazas y las que ellas enlazan; cada nombre que dejas ahí es una colisión potencial, un punto de acoplamiento y una promesa que el enlazador te obligará a cumplir. Por eso la asimetría de los valores por defecto es tan cruel: el lenguaje decidió en 1978 que lo público fuese lo gratuito y lo privado lo que hay que escribir. Toda la disciplina de modularidad en C consiste en revertir a mano ese valor por defecto — static en todo lo que no sea API, prefijo de módulo en lo que sí lo sea, visibilidad oculta en la biblioteca entera y exportación explícita de la superficie mínima. Y hay una segunda lectura, la que conecta con el nivel 7: reducir el linkage no es solo higiene de nombres, es información para el optimizador. Un símbolo interno tiene todos sus usos a la vista del compilador, y lo que se ve entero se puede incrustar, especializar, eliminar o pasar por registros al margen de la ABI. En C, ocultar es a la vez encapsular y acelerar. No son dos virtudes distintas: son la misma propiedad —que nadie más pueda mirar— vista desde el diseño y desde la máquina.

⚔️ Haz visible lo invisible
  1. Escribe dos .c con una función ayudante cada uno, sin static, y enlázalos. Repite con static y explica los dos resultados con nm.
  2. Clasifica en los tres ejes cada declaración de un archivo tuyo real y localiza al menos un caso donde creías que static hacía otra cosa.
  3. Declara static int oculto; y a continuación extern int oculto; en el mismo archivo. Comprueba con nm que el símbolo sigue siendo local.
  4. Pon int contador; en una cabecera incluida por dos .c. Compila con -fcommon y con -fno-common y compara los diagnósticos.
  5. Declara la misma función con static en el prototipo y sin él en la definición. Observa qué dice tu compilador y por qué el estándar no está obligado a decir nada.
  6. Compila una biblioteca compartida con y sin -fvisibility=hidden y compara la salida de nm -D --defined-only. Explica por qué el linkage no cambió y la superficie exportada sí.