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.
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:
- 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.
sprintf del cierre.
Cada eslabón medido en el debugger o en el .MAP. Ninguno inferido.
Exception 13 — General Protection Fault. ErrorCode 27304 = 0x6AA8,
que es el selector 0x6AAA con los bits RPL en cero. El selector era basura.les di, ss:[di] con DI=7022. Carga un puntero far
desde la pila — o sea, un argumento variádico de sprintf.7020 y
7024. sprintf leyó en 7022 — 2 bytes corrido.
Consumió cuatro conversiones enteras donde el formato tenía tres.%.0u donde el fuente dice %.1s.
%u consume 2 bytes; %s far necesita 4. Ahí estaba el desfasaje.31 73 ("1s") → 30 75 ("0u"). Como word little-endian:
0x7530 = 30000..MAP001B:6A9A __stklen — mismo offset que los bytes pisados,
en un segmento FAR_DATA con G=(none). Y su valor inicial: 30000._stklen y llevó exactamente a
6A9A. Dirección y valor: dos coincidencias exactas e independientes.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.
El llamador hizo todo bien. Los tres enteros coincidían con el reloj de pantalla. Eso dejó una sola posibilidad: el contenido del formato.
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.
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.
La sesión empezó con una pregunta mucho más modesta: «¿se puede compilar esto?».
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.
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.
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.
Contra datos Btrieve reales (formato 6.xx, copia aislada de 211 archivos). Log propio:
Inicia POS=20260727F[BRANCH_CYRE]=MemExt:63005656 … Fin 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.
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.
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.
Un registro honesto: dos acusaciones equivocadas, y cómo se cayeron.
-y.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.
titcp.cpp#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.
| Situación | Truco |
|---|---|
| TDP dice «Syntax error» en Goto | El 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 memoria | En el Dump, Alt+F10 → Goto y escribir el nombre: _stklen. TDP lo resuelve solo. |
| La ventana Stack aparece vacía | Con -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+Break | Breakpoint en una función cíclica. Acá sirvió Idle_Handler, pos_main.cpp:1783. |
| Redirecciones que no crean archivo | DOSBox-X falla en silencio si el nombre excede 8.3. |
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.
| Tema | Detalle | Prioridad |
|---|---|---|
| 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 |
Quedaron dos lanzadores autocontenidos en C:\Work\AI\posdbg\:
POSDBG.BAT — DOSBox-X + Btrieve + TDP sobre POS_MAIN.EXEPOSRUN.BAT — lo mismo, sin debugger, para reproducir como en producciónLa 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