Bug Hunt · 28 de julio de 2026

Un puntero perdido en 64 KB

Cómo un Exception 13 en el cierre de cajero de un POS MS-DOS terminó siendo una variable del runtime de C ubicada fuera de DGROUP — un bug latente de años que cambiaba de víctima en cada compilación.

✔ RESUELTO — arreglo de una línea, verificado con repro completo
Borland C++ 3.1Compilador
Phar Lap 286DOS-Extender
Btrieve 6.15Base de datos
DOSBox-XEntorno
~80 sBuild completo
36 + 16OBJ + DLL

01Resumen ejecutivo

El POS crasheaba con un fallo de protección general al iniciar el cierre de cajero. La función señalada, UpdateStatusLine(), llevaba años sin tocarse y no tenía ningún defecto.

La causa real estaba en una sola línea de pcpos.h:

pcpos.h — línea 2721
- extern unsigned       _stklen = 30000U;
+ extern unsigned _near _stklen = 30000U;   //_near: debe vivir en DGROUP

_stklen es una variable reservada del runtime de C: el startup (c0p.asm) la lee para dimensionar la pila, accediéndola relativa a DS. Por convención tiene que vivir en DGROUP. Con la configuración -Ff=2 del proyecto, el linker la colocó en un segmento FAR_DATA. El startup escribió los 30000 bytes de la pila en DS:6A9A — dentro de DGROUP, donde no estaba la variable, sino un literal de cadena.

Por qué era tan escurridizo
El literal destruido cambiaba en cada relink. Muchas veces le tocaba uno que nadie usaba y no pasaba nada. Esta vez le tocó la cadena de formato del sprintf del cierre.

02La cadena de evidencia

Cada eslabón medido en el debugger o en el .MAP. Ninguno inferido.

03Las pantallas

El crash

━━ CPU 80486 ━━━━━━━━━━━━━━━━━━━━━━━━ ss:7022 = 6AAA0BA7 ━━━━━━━━━━━ cs:7B67 ▶ 36C43D les di,ss:[di] ax 0073 c=0 cs:7B6A 83460404 add word ptr [bp+4] bx 0022 z=0 cs:7B6E 8CC0 mov ax,es cx 0400 s=0 cs:7B70 0BC7 or ax,di dx 0073 o=0 cs:7B72 7505 jne 7B79 si 6AA1 p=0 di 7022 a=1 ┌──────────────────────────────────┐ bp 6FF8 i=1 │ Exception 13, Error code 27304 │ sp 6F5C d=0 │ [ OK ] [ Help ] │ ds 0BE7 └──────────────────────────────────┘ es 0BA7 0BE7 Data Loaded 12910 bytes Read/Write, Up ss 0BAF 0BEF Data Loaded 342 bytes Read/Write, Up cs 0BD7 ip 7B67

Registros idénticos en dos binarios distintos (con y sin -y) y en dos disposiciones de memoria diferentes. Sólo cambiaban los selectores. El bug era determinístico.

Los argumentos en la pila — todos correctos

ss:7016 8A 6A A7 0B 0D 00 0D 00 ss:701E 1C 00 ... └─ formato ─┘ └13┘ └13┘ └28┘ 7012 buff (far, 4 bytes) 7016 formato (far, 4) ──▶ 0BA7:6A8A 701A ti_hour 13 ┐ 701C ti_min 13 ├─ 13:13:28 ← la hora exacta del crash 701E ti_sec 28 ┘ 7020 " " (far, 4) ← primer %.1s 7024 "C" (far, 4) ← segundo %.1s 7028 'O'/' ' (2)

El llamador hizo todo bien. Los tres enteros coincidían con el reloj de pantalla. Eso dejó una sola posibilidad: el contenido del formato.

La cadena de formato corrupta

es:6A8A 25 32 64 3A 25 30 32 64 %2d:%02d es:6A92 3A 25 30 32 64 20 25 2E :%02d %. es:6A9A 30 75 20 25 2E 31 73 20 0u %.1s es:6AA2 25 63 00 45 00 20 20 00 %c . E . . es:6AAA 43 00 20 20 00 61 62 2B C . . ab+ Esperado: %2d:%02d:%02d %.1s %.1s %c Real: %2d:%02d:%02d %.0u %.1s %c

El "C" en el offset 0x6AAA explica el selector inválido: al leer corrido, sprintf tomó A7 0B como offset y AA 6A como segmento.

El veredicto — antes y después

ANTES 001B:6A9A __stklen ← FAR_DATA, G=(none) ✗ colisiona AHORA 003B:0098 __stklen ← DGROUP ✓ correcto ds:0098 30 75 49 4E 4D 4F 44 45 0uINMODE ds:00A0 3F 00 2E 62 69 6E 00 63 ?..bin.c ds:00A8 56 65 72 73 69 6F 6E 53 VersionS ds:00B0 6F 66 74 00 53 46 5F 56 oft.SF_V

Los mismos bytes 30 75, pero ahora rodeados de datos del startup del runtime. Ya no son basura pisando un literal: son _stklen, en su casa.

04Cómo llegamos hasta acá

La sesión empezó con una pregunta mucho más modesta: «¿se puede compilar esto?».

FASE 0¿Compila?

Sí. El árbol traía su propio toolchain completo — Borland, Phar Lap, LIBSP, Btrieve. Alcanzó con una junction montando el árbol como C:\PCPOS. Build headless en ~80 s: 36 OBJ, 16 DLL, POS_MAIN.EXE de 3.061.533 bytes, cero errores.

FASE 1El muro de memoria

El POS moría con 286.2230 Out of memory. Medición: XMS libre antes de Btrieve 64.448 K, después 0 K. Btrieve 6.15 se tragaba todo el extendido y Phar Lap se quedaba sin nada.

FASE 2DOSBox-X hace de JEMMEX

El CONFIG.SYS de producción usa jemmex2.exe NOEMS, que provee VCPI — el árbitro de memoria que faltaba. La solución estaba en la config de referencia de DOSBox-X, al lado del ejecutable:

[dos]
ems=emm386
vcpi=true
emm386 startup active=true

Con eso: V86 mode On, VCPI 1.0, y Btrieve pasó de comerse 64 MB a tomar 1,2 MB. No hizo falta VirtualBox, ni FreeDOS, ni restaurar el Ghost.

FASE 3El POS corriendo

Contra datos Btrieve reales (formato 6.xx, copia aislada de 211 archivos). Log propio: Inicia POS=20260727F[BRANCH_CYRE]=MemExt:63005656Fin de Actualizacion!. De paso: la línea de Btrieve en SED.BAT usaba sintaxis 5.x contra un motor 6.15, así que el motor nunca cargaba.

FASE 4Símbolos para TDP

El debugger mostraba assembler. Causa: -v da símbolos públicos y tipos, pero -y es el que agrega los números de línea. Con el flag agregado: POS_MAIN.EXE pasó de 0 a 41 nombres de .cpp, y el .MAP de 0 a 20 bloques de líneas. Detalle traicionero: el EXE pesa exactamente lo mismo con y sin -y.

FASE 5La caza

Bisección por versiones (la build del 21/7 no fallaba), reconstrucción del frame en la pila, volcado del literal, y finalmente el .MAP. Siete pasos, cada uno descartando la mitad del terreno.

05Callejones sin salida

Un registro honesto: dos acusaciones equivocadas, y cómo se cayeron.

Acusado #1 — el flag -y
Se lo declaró culpable con un test que estaba mal armado: el programa de prueba se compiló sin .def ni PROTMODE, así que fallaba por su cuenta en ambas variantes. Absuelto cuando el repro sin -y dio los mismos registros, uno por uno.
Acusado #2 — titcp.cpp
Tenía #define INFINITY 30000 escribiendo por un puntero calculado con datos de red. El valor coincidía perfecto. Absuelto al verificar que TITCP.LIB son 11 KB de import library: titcp compila a una DLL separada, con su propio DGROUP.
El heisenbug
Un hook temporal de 376 bytes agregado para instrumentar hizo desaparecer el crash — sin siquiera ejecutarse. Eso, que parecía un fracaso, fue la pista decisiva: probó que había una escritura a dirección fija cuya víctima dependía del layout.
Lo que funcionó
Medir en vez de creer. Cada hipótesis se sometió a un experimento que la pudiera matar, no a uno que la confirmara.

06Técnicas que valen para la próxima

SituaciónTruco
TDP dice «Syntax error» en GotoEl evaluador lee decimal. 0BAF es inválido y 0x7016 se toma como 7016 decimal. Pasar el número ya convertido, o poner Options → Language → Assembler.
Ubicar un símbolo en memoriaEn el Dump, Alt+F10 → Goto y escribir el nombre: _stklen. TDP lo resuelve solo.
La ventana Stack aparece vacíaCon -crtdll, sprintf vive en BCRTDLL3.DLL — sin símbolos, TDP no puede desenrollar.
¿Tiene debug info este build?El tamaño no sirve. Buscar nombres de .cpp dentro del EXE, o bloques Line numbers en el .MAP.
¿La corrupción es de link o de runtime?Buscar la cadena en el EXE en disco. Si está sana ahí, es runtime.
No anda Ctrl+BreakBreakpoint en una función cíclica. Acá sirvió Idle_Handler, pos_main.cpp:1783.
Redirecciones que no crean archivoDOSBox-X falla en silencio si el nombre excede 8.3.

07Nota sobre el modelo

Cambio de modelo a mitad de sesión

Durante la caza el modelo pasó de Fable 5 a Opus 5. La interfaz mostró un aviso con un enlace «Why?» explicando el motivo.

Ese texto no llegó al contexto del asistente — fue un aviso del cliente, no parte de la conversación. Por eso no se reproduce acá: preferimos dejar el hueco señalado antes que inventar el contenido. Si querés que quede en el registro, pegá el texto del advisory y lo incorporo textual.

Lo que sí quedó registrado: la sesión atravesó ambos modelos y el hilo de razonamiento se mantuvo, porque toda la evidencia se fue anclando en mediciones reproducibles y no en memoria del chat.

08Pendientes

TemaDetallePrioridad
pos_mis2.cpp:3859 abs(dRecDescMonto) sobre un double. En Borland abs() es de enteros y trunca. Va fabs(). Da mal el vuelto estimado con descuentos. Alta
DGROUP de POS_MAIN 65.202 de 65.536 bytes — 334 libres. Misma familia de bomba. En modelo large los literales de cadena van a DGROUP, así que cada literal nuevo achica el margen. Alta
fd.bat Sin -y. No lo llama co.bat, pero recompilar pos_ppfd con él deja esa DLL sin números de línea. Baja
29 globales sin _far Concentrados en módulos DLL (pos_ncre 9, pos_ctac 3, pos_cobr 3…). Misma clase de riesgo de segmentación. Media

09Entorno reproducible

Quedaron dos lanzadores autocontenidos en C:\Work\AI\posdbg\:

La primera corrida arma la junction al árbol de fuentes y copia las bases a un directorio aislado. Las bases reales nunca se escriben.

SET DDFDIR=C:\BASES
SET DATADIR=C:\BASES
SET RUN286=-xfersize 16
SET PATH=C:\PCPOS\RUN286\BIN;C:\PCPOS\BORLANDC\BIN;C:\PCPOS\BTRIEVE
BTRIEVE /f:80 /p:2048 /e /g:1:1 /m:128:1 /u:0
POS_MAIN