# gdb 16.3 — el depurador. Entra porque su ausencia me costó tiempo HOY, no por completar una fila. # # ══ EL ARGUMENTO, Y ES MEDIDO SOBRE ESTA SESIÓN ════════════════════════════════════════════════ # `censo-distro.py` marca «depurar y medir» **6 de 6 ausente**, con el comentario «se construye la # distro entera y no hay con qué mirar un core». El 2026-10-20 eso pasó de nota a factura: # `libime_pinyindict` segfaulteaba con cualquier entrada, y el diagnóstico hubo que sacarlo de # # dmesg → `ip` del fallo → el binario es no-PIE → `nm --numeric-sort` → el símbolo # # para descubrir que saltaba a `typeinfo for std::__1::collate`. Con un `bt` eso eran diez # segundos. **Una distro que se compila entera a sí misma y no puede depurarse es una distro que # arregla sus bugs a ciegas.** # # ══ CUESTA UNA RECETA: SUS DEPS YA ESTÁN TODAS ═════════════════════════════════════════════════ # gdb ≥14 exige `gmp` y `mpfr` —los dos en el corpus— además de `ncurses`, `zlib`, `expat`, `xz`, # `zstd` y `binutils`, todos presentes. `libiconv` es la única que falta y **no hace falta**: musl # trae iconv en libc. # # ══ ⚠⚠ LA VERSIÓN ES 16.3 PORQUE 18.1 **NO ARRANCA** EN MUSL — Y ESTO ES MEDIDO ════════════════ # Empecé con 18.1, la última. Compiló entera (dos veces, ~50 min cada una), instaló, y **segfaulteó # antes de imprimir un solo carácter**: `--version`, `--help`, `--configuration` y # `-nx --batch -ex quit` dan los cuatro `rc=139` con salida vacía ⇒ el crash es en el ARRANQUE # (inicializadores estáticos o el principio de `main`), no en el manejo de opciones. # # ⚠ **Y mi primera hipótesis era equivocada, conviene dejarlo escrito.** El primer build tenía # cuatro `NEEDED` colgantes a las `.so` del lab, y supuse un desajuste de ABI. Declaré las variantes # `-shared` del corpus, el control de superficie dinámica pasó —los 8 sonames resuelven **desde el # store**, verificado con `ld-musl --list`— y **siguió segfaulteando igual**. Las fugas al lab eran # un problema real y no eran ESTE problema. # # ⇒ La salida fue preguntar quién tiene gdb andando sobre musl: **Alpine empaqueta 16.3**, con # exactamente el parche de señales de abajo. 18.1 sobre musl es terreno no probado, y diagnosticar # un crash de arranque **sin depurador** (el que estoy construyendo) es caro de verdad. # # La lección de método, que es la misma que destrabó el chino: para un paquete con fricción conocida # en musl, la versión no se elige por «la última» sino por **la que una distro musl ya sostiene**. # # ══ ⚠ EL PARCHE DE MUSL: TRECE LÍNEAS Y SE SABE EXACTAMENTE POR QUÉ ════════════════════════════ # `gdbsupport/signals.cc` usa `__SIGRTMIN`/`__SIGRTMAX`, que son nombres **internos de glibc**; musl # no los define y la compilación corta. Se comprobó mirando qué parchea Alpine: de sus siete # parches, para x86_64 sólo aplica `musl-signals.patch` (los otros son ppc/s390x o compatibilidad # con GCC 15, y acá el compilador es clang). El contenido es definirlos si no existen, con los # valores que musl usa de hecho. # # ══ LAS PERILLAS ═══════════════════════════════════════════════════════════════════════════════ # --disable-binutils --disable-ld --disable-gas --disable-gprof --disable-gprofng # el tarball de gdb trae el árbol de binutils entero. **Ya tenemos # `binutils`**, y construirlo otra vez son minutos y un segundo juego de # `ar`/`ld`/`as` en el artefacto que nadie quiere # --disable-sim el simulador de arquitecturas ajenas; no se depura un MIPS acá # --enable-tui la interfaz de pantalla completa sobre ncurses. Es lo que hace a gdb usable # sin un IDE, que es el caso de esta distro # --with-expat descripciones de objetivo y tablas de syscalls en XML: sin esto `catch # syscall` no sabe nombres # --disable-nls la cadena de gettext, cuyo `msgfmt` en este lab tiene huecos medidos # MAKEINFO=true no hay `texinfo` en el corpus (el censo lo marca) y gdb intentaría generar # su manual. Lo que se pierde es el `.info`, no el programa # # ⚠ **`--without-python`, Y ES UNA DECISIÓN CON COSTE DICHO, NO UN OLVIDO.** Con soporte de Python # gdb trae *pretty-printers*: `std::string` se ve como texto en vez de como una estructura, y en un # corpus con libc++ eso importa de verdad. Queda afuera en esta primera pasada por una razón # concreta: `libpython3.12.a` es estática, así que gdb la enlazaría dentro, pero **seguiría # necesitando la biblioteca estándar de Python EN LA IMAGEN** para cargar los printers — o sea que # encender esto cambia lo que un rootfs con gdb tiene que llevar. Primero el depurador que funciona; # los printers son su propia decisión, y ahora está escrita en vez de ser un hueco que nadie eligió. # ══ ⚠ NO SE DECLARA EN NINGÚN PERFIL, Y ES UNA DECISIÓN ════════════════════════════════════════ # El artefacto son **232 MB** (226 MB de ELF, de los que 98 son `.debug_*` ⇒ ~128 MB desnudado). # gdb es grande de verdad: lleva BFD, los analizadores de expresiones de una docena de lenguajes y # las tablas de todas las arquitecturas que soporta. # # Hornear eso en las ocho imágenes para una herramienta que se usa el día que algo falla no es # proporcionado. Y el modelo del repo ya tiene la respuesta: **una receta sin perfil se publica # igual** —la Etapa F la empaqueta— así que gdb queda **instalable desde el repo** sin pesar en # ningún rootfs. Es el mismo argumento que `[solo-build]` usa para `cmake` («que se pueda instalar # desde el repo es correcto; que venga horneado, no»), aunque gdb no sea una herramienta de build y # por eso no va en esa lista. # # ⇒ En el censo aparecerá como `tiene-sin-viajar`, que es **la verdad** y es visible. El día que # alguien quiera un perfil de desarrollo con depurador, es una línea en su `paquetes`. # # Instala cinco cosas, no una: `gdb`, `gdbserver` (depurar por red o en otra máquina), `gcore` # (volcar un core de un proceso vivo), `gstack` (su traza sin pararlo) y `gdb-add-index`. name = "gdb" version = "16.3" license = "GPL-3.0-or-later" [source] tarball = "https://ftp.gnu.org/gnu/gdb/gdb-16.3.tar.xz" sha256 = "bcfcd095528a987917acf9fff3f1672181694926cc18d609c99d0042c00224c5" [build] compiler = "zig-cc" target = "x86_64-linux-musl" # ⚠ **`dynamic` Y NO `static`, Y ESTO ES MEDIDO: `gmp` y `mpfr` del corpus son SÓLO COMPARTIDAS.** # Con `link = "static"` el lab pasa `-static` y el test de configure murió así: # # hammer-zig-cc -o conftest -g -O2 -static conftest.c -lmpfr -lgmp # error: unable to find static system library 'mpfr' using strategy 'no_fallback' # error: unable to find static system library 'gmp' # configure: error: Building GDB requires GMP 4.2+, and MPFR 3.1.0+. # # El mensaje de configure culpa a las versiones y el problema era la FORMA: `ls /usr/lib` # da `libgmp.so{,.10,.10.5.0}` y `libmpfr.so{,.6,.6.2.2}`, sin una sola `.a`. # # ⚠ **Y es el INVERSO EXACTO del caso de `exiv2` del mismo día**, que vale recordar junto: allá el # corpus publicaba sólo `.a` y **el LAB** tenía la `.so`, así que el enlace dinámico se fue en # silencio a la imagen base y sellé un `NEEDED` colgante. Acá el corpus publica sólo `.so` y el # estático falla **ruidosamente**. ⇒ **el corpus NO es uniformemente estático**, y `link` no es una # preferencia de estilo: tiene que coincidir con lo que las deps de ESTA receta publican de hecho. # Con `dynamic` cada dep entra como pueda —`ncurses`, `readline` y `zlib` siguen siendo `.a` y # entran estáticas; `gmp` y `mpfr` quedan en NEEDED, y las dos las provee el corpus. link = "dynamic" flags = [] [build.phases] configure = ''' set -e # El parche de musl. Se comprueba ANTES (que el marcador exista) y DESPUÉS (que se aplicó), para que # el día que upstream lo arregle o lo mueva esto falle diciéndolo en vez de parchear a ciegas. SC=gdbsupport/signals.cc [ -f "$SC" ] || { echo "gdb: no existe $SC — upstream reorganizó el árbol; revisar el parche de musl" >&2; exit 1; } grep -q '__SIGRTMIN' "$SC" || { echo "gdb: $SC ya no usa __SIGRTMIN; el parche de musl sobra o cambió de forma — REVISAR antes de seguir" >&2; exit 1; } grep -q 'ifndef __SIGRTMIN' "$SC" && { echo "gdb: $SC ya trae el guard de __SIGRTMIN — upstream lo arregló, quitar este parche" >&2; exit 1; } python3 - "$SC" <<'PYEOF' import sys p = sys.argv[1] s = open(p, encoding='utf-8').read() ancla = '#if defined(__SIGRTMIN)' if ancla not in s: sys.exit("gdb: no encuentro '%s' en %s" % (ancla, p)) guard = ('#ifndef __SIGRTMIN\n# define __SIGRTMIN 32\n#endif\n' '#ifndef __SIGRTMAX\n# define __SIGRTMAX _NSIG\n#endif\n\n') open(p, 'w', encoding='utf-8').write(s.replace(ancla, guard + ancla, 1)) PYEOF grep -q 'ifndef __SIGRTMIN' "$SC" || { echo "gdb: el parche de musl no se aplicó" >&2; exit 1; } echo "gdb: parche de señales de musl aplicado" # ⚠⚠ **`-fPIE`/`-pie` ES LA CURA DEL SEGFAULT, Y SALIÓ DE LEER UN CORE — no de conjeturar.** # # Las dos primeras hipótesis fueron equivocadas y conviene dejarlas escritas, porque cada una costó # un build de ~50 min: # 1. «son las `.so` del lab» → declaré las variantes `-shared`, el control de superficie dinámica # pasó (los 8 sonames resuelven desde el store, verificado con `ld-musl --list`) y **siguió # segfaulteando**. Las fugas al lab eran reales y NO eran esto. # 2. «18.1 no está probada en musl, bajar a la 16.3 que empaqueta Alpine» → **16.3 segfaultea # igual**. La versión tampoco era. # # El dato salió de provocar un core (`ulimit -c unlimited`) y parsearlo a mano: `NT_PRSTATUS` da # `rip`, y `NT_FILE` dice en qué mapeo cae. # # señal=11 rip=0x7fc99ebe8fa5 → ld-musl-x86_64.so.1 +0x41fa5 → **getenv +0x38** # # O sea: **no muere en el código de gdb, muere leyendo el ENTORNO durante el arranque.** Y la causa # está en el ELF: # # readelf -rW gdb | grep -c R_X86_64_COPY ⇒ 12 # … R_X86_64_COPY environ + 0 ← ésta # (las otras once son globales de ncurses: curscr, COLORS, COLOR_PAIRS, acs_map, BC…) # # Un ejecutable **no-PIE** que referencia un dato de una biblioteca compartida obtiene una # relocación **COPY**: el cargador copia el objeto dentro del `.bss` del programa, y a partir de ahí # hay DOS `environ` que pueden divergir. `getenv` lee la copia y revienta. # # ⚠ Y por eso los demás binarios del corpus no lo sufren aunque también sean no-PIE (`smartctl`, # `swayimg`, las herramientas de libime): **ninguno referencia datos globales de una `.so`**. gdb sí, # por `environ` y por el TUI de ncurses. La diferencia no es «no-PIE», es «no-PIE **y** usa datos # globales de una compartida». # # ⇒ `-fPIE`/`-pie` elimina las COPY por construcción: el ejecutable llega al `environ` de libc por # el GOT en vez de copiarlo. El guardián de abajo comprueba que no quede ninguna. # # ⚠ **Y `-pie` global choca con la ÚNICA biblioteca compartida que gdb construye.** El primer # intento con PIE murió así: # # CXXLD libinproctrace.so # error: dynamic libraries cannot be position independent executables # # `libinproctrace.so` es el **agente en-proceso de gdbserver** (los *fast tracepoints*: inyectar # código en el proceso depurado para trazar sin parar). Tiene perilla propia, así que no hay que # pelearse con los LDFLAGS por subdirectorio: `--disable-inprocess-agent` y listo. # # Lo que se pierde, dicho: las trazas rápidas de gdbserver. Depurar en local, los breakpoints, el # `bt` y todo lo que motivó esta receta no las usan. CFLAGS="-fPIE" CXXFLAGS="-fPIE" LDFLAGS="-pie" \ ./configure --build=$CBUILD --host=$CHOST --prefix=/usr --sysconfdir=/etc \ --disable-werror --disable-nls --disable-sim \ --disable-inprocess-agent \ --disable-binutils --disable-ld --disable-gas --disable-gprof --disable-gprofng \ --enable-tui --with-expat --without-python \ --with-system-zlib \ MAKEINFO=true ''' compile = 'make -j"$(nproc)" MAKEINFO=true' # ⚠ EL GUARDIÁN LO EJECUTA, Y SOBRE UN BINARIO CON DWARF DE VERDAD. # # Que `/usr/bin/gdb` exista no dice nada: un depurador que no puede abrir un ELF ni leer su DWARF es # un binario inerte, y ésa es justo la figura que este catálogo persigue. Así que el control compila # un programa mínimo con `-g` y le pide a gdb la línea de `main`: eso ejercita bfd (leer el ELF), el # lector de DWARF y la tabla de símbolos, que es la capacidad entera. # # **No se intenta `run`/`bt`**: el sandbox va con `--unshare-all` y `ptrace` sobre un proceso hijo no # es algo con lo que se pueda contar ahí. Pedirlo haría fallar un gdb correcto — el error que cometí # cinco veces hoy escribiendo guardianes contra mi modelo del entorno y no contra el entorno. install = ''' set -e make install DESTDIR=/out MAKEINFO=true [ -x /out/usr/bin/gdb ] || { echo "gdb: no quedó /usr/bin/gdb — NO sello" >&2; ls /out/usr/bin 2>/dev/null >&2; exit 1; } # ⚠⚠ **PRIMERO SE FIJA LA SUPERFICIE DINÁMICA, Y ESTE CONTROL YA SE ESCRIBIÓ MAL UNA VEZ.** # # El primer build compiló, instaló y **segfaulteó en `--version`**, y la causa estaba acá: cuatro # `NEEDED` hacia `.so` que el corpus no publica —salieron del lab, de otra versión— ⇒ desajuste de # ABI. Un artefacto así sella, reproduce y no corre. # # Mi primer intento de control fue **vetar nombres** (`libexpat`, `libz.so`, `libncursesw`…), y eso # habría rechazado el build CORRECTO: con las variantes `-shared` declaradas esas mismas `.so` son # legítimas y vienen del corpus. Desde dentro del sandbox **no se puede distinguir el origen** de # una `.so`, porque el lab y las deps se montan en el mismo `/usr/lib`. Pretender distinguirlo era # el error. # # ⇒ Lo que SÍ se puede, y es lo correcto: **fijar el conjunto exacto de sonames**. Si aparece uno # que no está en la lista, alguien tiene que mirarlo — puede ser una dep nueva legítima o una fuga # al lab, y la receta no debe adivinar cuál. La provenencia la verifica `vigia-sonames.py` sobre la # IMAGEN, que es su sitio. # ⚠ CERO relocaciones COPY: es la condición que hace que este binario arranque. Si vuelven, el # `getenv` del cargador revienta antes de imprimir un carácter — y el síntoma no nombra nada de # esto. Se comprueba ANTES de ejecutar, porque si hay COPY el segfault de abajo es consecuencia. ncopy=$(readelf -rW /out/usr/bin/gdb 2>/dev/null | grep -c 'R_X86_64_COPY' || true) [ "${ncopy:-0}" -eq 0 ] || { echo "gdb: el binario trae $ncopy relocación(es) R_X86_64_COPY — volvió a enlazarse NO-PIE y el cargador de musl segfaultea en getenv al arrancar" >&2 readelf -rW /out/usr/bin/gdb 2>/dev/null | grep 'R_X86_64_COPY' | head -5 >&2 exit 1 } # Y que sea DYN (PIE) y no EXEC, que es la misma cosa vista desde la cabecera. readelf -h /out/usr/bin/gdb 2>/dev/null | grep -qE 'Type:\s+DYN' || { echo "gdb: el ELF no es DYN (PIE) — las COPY volverían" >&2; readelf -h /out/usr/bin/gdb 2>/dev/null | grep 'Type:' >&2; exit 1; } esperados="libexpat.so.1 liblzma.so.5 libz.so.1 libzstd.so.1 libncursesw.so.6 libmpfr.so.6 libgmp.so.10 libc.so" reales=$(readelf -d /out/usr/bin/gdb 2>/dev/null | grep NEEDED | sed 's/.*\[\(.*\)\].*/\1/' | sort -u) sobra="" for so in $reales; do case " $esperados " in *" $so "*) ;; *) sobra="$sobra $so" ;; esac done if [ -n "$sobra" ]; then echo "gdb: NEEDED inesperado(s):$sobra" >&2 echo " — puede ser una dep nueva legítima o una fuga a la .so del LAB (que no viaja en ninguna" >&2 echo " imagen y es de otra versión: así segfaulteó el primer build). REVISAR antes de sellar." >&2 printf ' reales: %s\n' "$(printf '%s ' $reales)" >&2 exit 1 fi echo "gdb: superficie dinámica fijada ($(printf '%s' "$reales" | wc -w) sonames, todos esperados)" # Ejecución y truncado SEPARADOS: el estado de una tubería es el del último comando, y eso ya me # selló un fallo hoy (ver `recipes/automake.toml`). sal=$(/out/usr/bin/gdb --version 2>&1) || { echo "gdb: no arranca" >&2; printf '%s\n' "$sal" >&2; exit 1; } printf '%s\n' "$sal" | head -1 | grep -qE '^GNU gdb .*16\.3' || { echo "gdb: --version inesperado: $(printf '%s\n' "$sal" | head -1)" >&2; exit 1; } # PRUEBA FUNCIONAL: abrir un ELF ajeno y leer su DWARF. t=$(mktemp -d) printf 'int main(void){return 0;}\n' > "$t/p.c" /opt/zig/zig cc -mcpu=baseline -target x86_64-linux-musl -g -O0 -o "$t/p" "$t/p.c" || { echo "gdb: no pude compilar el programa de prueba (problema del lab, no de gdb)" >&2; exit 1; } out=$(/out/usr/bin/gdb --batch -nx -ex 'info line main' "$t/p" 2>&1) || { echo "gdb: falló al leer el programa de prueba" >&2; printf '%s\n' "$out" >&2; exit 1; } printf '%s\n' "$out" | grep -q 'p\.c' || { echo "gdb: no pudo resolver 'main' a su fichero fuente — el lector de DWARF no funciona, y un depurador que no lee símbolos es un binario inerte" >&2 printf '%s\n' "$out" >&2 exit 1 } # El TUI es lo que hace a gdb usable sin IDE: si la perilla no se materializó, no está. printf '%s\n' "$(/out/usr/bin/gdb --configuration 2>&1)" | grep -q -- '--enable-tui' || echo "gdb: aviso — --configuration no confirma el TUI; revisar si ncurses se detectó" >&2 echo "gdb: $(printf '%s\n' "$sal" | head -1) · lee DWARF de un ELF ajeno" ''' [deps] build = ["make", "pkgconf", "gmp", "mpfr", "ncurses", "zlib", "expat", "xz", "zstd", "binutils", "python3", "libtool", "m4", # ⚠ **LAS CUATRO `-shared`, Y NO SON UN LUJO: SIN ELLAS gdb ENLAZA CONTRA EL LAB.** # Medido en el primer build, que compiló, instaló y **segfaulteó en `--version`**: # # readelf -d gdb | grep NEEDED # libexpat.so.1 · liblzma.so.5 · libz.so.1 · libncursesw.so.6 · libzstd.so.1 · # libmpfr.so.6 · libgmp.so.10 · libc.so # # y el corpus publica `expat`, `zlib`, `xz` y `ncurses` **SÓLO como `.a`** ⇒ esas cuatro # `.so` salieron de `.dev-fs/alpine`. gdb compiló contra las CABECERAS del corpus y enlazó # contra las bibliotecas del LAB, que son de otra versión: desajuste de ABI, y el síntoma # es un segfault al arrancar que no nombra nada de esto. # # ⇒ Las variantes `-shared` existen justo para este caso y **ya estaban selladas**; # `zlib-shared`, `xz-shared` y `ncurses-shared` viajan además en los 9 perfiles, así que # declararlas no agrega peso a ninguna imagen. `zstd` NO necesita variante: publica `.a` # **y** `.so`, así que su NEEDED es legítimo. # # Es la TERCERA receta del 2026-10-20 que pisa esto (antes `exiv2` y el intento estático de # gdb). Ver [[corpus-no-es-uniformemente-estatico]]. "expat-shared", "zlib-shared", "xz-shared", "ncurses-shared"] # `gmp` y `mpfr` quedan como NEEDED del binario (son las únicas deps shared-only), así que tienen # que estar en la CLAUSURA de cualquier imagen que lleve gdb — si no, `vigia-sonames.py` marcaría # un soname sin proveedor, que es justo lo que ese vigía existe para cazar. runtime = ["gmp", "mpfr", "zstd", "expat-shared", "zlib-shared", "xz-shared", "ncurses-shared"]