Real-mode memory track

Memory

How LainDOS fits a kernel, filesystem buffers, a DOS MCB arena, optional EMS frame, and XMS shims into a real-mode machine that still has to run games below 640K.

The memory path

LainDOS is small enough to explain as a map. The kernel and its fixed buffers live below the program arena; DOS allocations are MCB headers linked by paragraph counts; child exit is just owner cleanup plus coalescing. The hard part is not the algorithm, it is keeping every fixed segment from colliding as the kernel grows.

01
Layout
The kernel relocates to 0340h, keeps scratch buffers below 1000h, and reserves A000h for VGAVGAThe PC video standard LainDOS and the demos use for text and graphics output..
02
Arena
One free MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it. starts at 1000h and grows upward until MEM_TOP, with owner 0 meaning free.
03
Allocate
INT 21hINT 21hThe main DOS API interrupt. Programs select services such as open, read, EXEC, and exit with AH. AH=48h supports first, best, and last-fit strategy, plus a high-biased small allocation path.
04
Own
Program and environment blocks are stamped with the current PSPPSPThe DOS data block placed before each program, holding terminate vectors, the job file table, command tail, and environment pointer. so exit cleanup can reclaim them.
05
Extend
XMSXMSExtended Memory Specification services for memory above 1 MiB, used by many later DOS games and extenders. is a single-handle BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O.-move shim; EMSEMSExpanded Memory Specification: bank-switched memory exposed through an EMS page frame. is optional and disabled in normal builds.

Fixed segments define the machine

The low-memory layout is a contract, not a suggestion.

src/memory.inc is the first file to read before moving buffers. These equates decide where the kernel relocates, where scratch sectors live, where the DOS arena begins, and where conventional memory ends.

The split is intentionally conservative: filesystem scratch buffers sit below the MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it. arena, programs start at 1000h, and VGAVGAThe PC video standard LainDOS and the demos use for text and graphics output. graphics memory begins at A000h. MEM_TOP must stay 256-byte aligned because several bounds checks compare segment values directly.

src/memory.incNASM · 16-bit
3
4
5
6
7
8
9
10
11
12
13
15
16
LOAD_SEG equ 0x1000
RELOC_SEG equ 0x0340
SECTOR_BUF_PARAS equ 0x20
SEC_BUF equ 0x0B00
READ_CACHE_BUF equ (SEC_BUF + SECTOR_BUF_PARAS)
MCB_START equ 0x1000
MEM_TOP equ 0xA000
ENV_PARAS equ 16
ENV_OWNER_TEMP equ 0xFFFF
%if (MEM_TOP & 0xFF) != 0
%error "MEM_TOP must be 256-byte aligned"
MCB_SIG_M equ 'M'
MCB_SIG_Z equ 'Z'

Tests that pin this

scripts/test_boot.pyscripts/test_highmcb.pyscripts/test_free.py

Boot installs the initial arena

Relocation, stack placement, XMS sizing, and the first MCB happen before loading a child.

The kernel copies itself to RELOC_SEG, switches DS/ES/SS to the relocated segment, and puts the stack at KERNEL_STACK_TOP. Only after serial/VGAVGAThe PC video standard LainDOS and the demos use for text and graphics output. bring-up and memory reporting does it initialize optional XMSXMSExtended Memory Specification services for memory above 1 MiB, used by many later DOS games and extenders. sizing and the DOS arena.

The first arena is a single last-block MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it. at MCB_START: signature Z, owner zero, size MEM_TOP - MCB_START - 1 paragraphs. Every later allocation is just a split or owner change inside that chain.

src/kernel.asmNASM · 16-bit
95
111
118
119
123
124
135
136
144
145
159
160
161
162
163
164
165
166
kernel_entry:
mov ax, RELOC_SEG
jmp RELOC_SEG:.relocated
.relocated:
mov ss, ax
mov sp, KERNEL_STACK_TOP
int 0x12
mov [mem_kib], ax
%if ENABLE_XMS
call init_xms_size
mov ax, MCB_START
mov es, ax
mov byte [es:0], MCB_SIG_Z
mov word [es:1], 0
mov ax, MEM_TOP - MCB_START - 1
mov word [es:3], ax
mov word [mcb_first], MCB_START
mov word [cur_psp], 0

Tests that pin this

scripts/test_boot.pyscripts/test_memfail.pyscripts/test_highmcb.py

Compile-time guards catch overlap

Kernel size and buffer placement are checked before an image can boot.

The dangerous edits are not in allocation code; they are usually new kernel code, larger buffers, or an EMSEMSExpanded Memory Specification: bank-switched memory exposed through an EMS page frame. frame moved into the wrong segment. The final assertions in src/kernel.asm stop those mistakes at NASMNASMNetwide Assembler, the assembler used for LainDOS boot, kernel, shell, and focused test programs. time.

These guards keep the loaded kernel below the loader gap, prevent it from reaching SEC_BUF, keep sector/read/root buffers ordered, preserve a root-stack guard, and ensure the stack plus buffers stay below MCB_START.

src/kernel.asmNASM · 16-bit
3299
3300
3302
3303
3305
3306
3308
3309
3311
3312
3314
3315
3317
3318
3320
3321
3323
3324
3326
3327
3338
3339
%if LOAD_SEG <= RELOC_SEG
%error "LOAD_SEG must be above RELOC_SEG"
%if (kernel_end - kernel_entry) > ((LOAD_SEG - RELOC_SEG) * 16)
%error "kernel exceeds boot relocation gap"
%if (kernel_end - kernel_entry) > ((SEC_BUF - RELOC_SEG) * 16)
%error "kernel overlaps SEC_BUF"
%if (SEC_BUF + SECTOR_BUF_PARAS) > READ_CACHE_BUF
%error "SEC_BUF overlaps READ_CACHE_BUF"
%if (READ_CACHE_BUF + SECTOR_BUF_PARAS) > ROOT_SEG
%error "READ_CACHE_BUF overlaps ROOT_SEG"
%if (ROOT_SEG + ROOT_BUF_PARAS) > (RELOC_SEG + (KERNEL_STACK_TOP / 16))
%error "ROOT buffer overlaps kernel stack"
%if (ROOT_SEG + ROOT_BUF_PARAS + STACK_ROOT_GUARD_PARAS) > (RELOC_SEG + (KERNEL_STACK_TOP / 16))
%error "ROOT buffer leaves too little kernel stack guard"
%if (RELOC_SEG + (KERNEL_STACK_TOP / 16)) > MCB_START
%error "kernel stack overlaps MCB arena"
%if (ROOT_SEG + ROOT_BUF_PARAS) > MCB_START
%error "ROOT_SEG overlaps MCB arena"
%if ENABLE_EMS && EMS_FRAME_SEG <= MCB_START
%error "EMS frame must be inside conventional arena"
%if ENABLE_XMS && XMS_MAX_KB > 15360
%error "XMS BIOS move backing must remain below 16 MiB"

Tests that pin this

scripts/test_boot.pyscripts/test_free.pyscripts/test_ems.py

MCBs split, merge, and walk by size

Each block has a one-paragraph header before the segment DOS returns.

An MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it. header starts one paragraph before the usable block. Byte 0 is M or Z, word 1 is the owner PSPPSPThe DOS data block placed before each program, holding terminate vectors, the job file table, command tail, and environment pointer., and word 3 is the block size in paragraphs. The next header is current segment plus size plus one.

alloc_mem_direct is the compact helper used by loader-owned internal allocations. It walks from mcb_first, chooses the first free block large enough, splits if the remainder can hold another MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it., stamps the owner with cur_psp, and returns the usable segment.

src/kernel/memory_mcb.incNASM · 16-bit
1
2
5
7
9
18
22
25
27
34
37
41
50
51
55
56
58
62
63
65
mcb_walk_next:
mov ax, [ds:3]
add ax, si
inc ax
cmp ax, MEM_TOP
alloc_mem_direct:
mov si, [cs:mcb_first]
cmp byte [ds:0], MCB_SIG_M
cmp byte [ds:0], MCB_SIG_Z
cmp word [ds:1], 0
cmp ax, [cs:am_req]
cmp ax, 2
mov byte [es:0], al
mov word [es:1], 0
mov word [es:3], cx
mov byte [ds:0], MCB_SIG_M
mov word [ds:3], ax
mov ax, [cs:cur_psp]
mov word [ds:1], ax
inc ax

Tests that pin this

scripts/test_highmcb.pyscripts/test_envmcb.pyscripts/test_memrelease.py

DOS allocation strategy is visible

AH=58h selects first, best, or last fit; AH=48h applies it.

Programs can query and set the DOS allocation strategy through INT 21h AH=58h. LainDOS stores only values 0 through 2, matching first-fit, best-fit, and last-fit behavior used by the allocator.

The default first-fit path has one compatibility twist: requests from 2 through SMALL_ALLOC_HIGH_MAX paragraphs are biased to the last suitable block. That keeps tiny runtime allocations from fragmenting the low end of the arena before larger program loads.

src/kernel/int21.incNASM · 16-bit
1192
1215
1216
1217
1218
1221
1222
1225
1227
1266
1275
1277
1279
1281
1283
1285
1287
1289
1291
1304
1306
1330
1372
1396
1453
1456
1477
.alloc_strategy:
cmp al, 0
je .as_get
cmp al, 1
je .as_set
xor ax, ax
mov al, [cs:alloc_strat]
cmp bl, 2
mov [cs:alloc_strat], bl
.alloc_mem:
mov al, [cs:alloc_strat]
cmp al, 2
jmp near .am_find_last
cmp al, 1
jmp near .am_find_best
cmp word [cs:am_req], 1
cmp word [cs:am_req], SMALL_ALLOC_HIGH_MAX
jmp near .am_find_last
mov si, [cs:mcb_first]
cmp ax, [cs:am_req]
.am_use:
mov ax, [cs:cur_psp]
.am_find_last:
.am_find_best:
.am_nomem:
.am_scan_largest:
mov ax, 8

Tests that pin this

scripts/test_stratapi.pyscripts/test_memfail.pyscripts/test_highmcb.py

Free and resize repair the chain

AH=49h and AH=4Ah validate headers, split remainders, and merge adjacent free blocks.

Freeing a block checks the header immediately before ES, clears its owner, and merges forward if the next block is also free. Resizing uses the same header contract: grow by absorbing the next free block, or shrink by carving a new free MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it. after the requested size.

Failure paths return DOS error codes and preserve the original allocation where possible. When allocation fails, BX is filled with the largest free block so callers can retry with a smaller request.

src/kernel/int21.incNASM · 16-bit
1503
1509
1510
1512
1514
1519
1520
1527
1534
1535
1545
1547
1555
1560
1564
1565
1579
1580
1585
1586
1590
1600
1601
1605
1648
1691
1692
1693
.free_mem:
mov si, es
dec si
cmp byte [ds:0], MCB_SIG_M
cmp byte [ds:0], MCB_SIG_Z
mov word [ds:1], 0
call mcb_merge_free_forward
.resize_mem:
mov [cs:rm_req], bx
mov si, es
mov ax, [ds:3]
jae .rm_shrink
cmp byte [es:0], MCB_SIG_M
cmp word [es:1], 0
add ax, cx
cmp ax, bx
mov byte [es:0], dl
mov byte [ds:0], MCB_SIG_M
mov word [es:3], cx
mov word [ds:3], bx
mov ax, [ds:3]
mov byte [es:0], dl
mov word [es:1], 0
mov word [ds:3], bx
mov [es:0x02], ax
.rm_cant_grow:
mov bx, ax
mov ax, 8

Tests that pin this

scripts/test_memfail.pyscripts/test_memrelease.pyscripts/test_tsr.py

Owners make cleanup deterministic

The current PSP owns program blocks, environment blocks, and child allocations.

Environment blocks start with a temporary owner while EXEC is still building the child. Once the PSPPSPThe DOS data block placed before each program, holding terminate vectors, the job file table, command tail, and environment pointer. is committed, assign_exec_environment_owner changes the MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it. owner to the child PSPPSPThe DOS data block placed before each program, holding terminate vectors, the job file table, command tail, and environment pointer., putting it on the same cleanup path as ordinary allocations.

Normal termination clears transient XMSXMSExtended Memory Specification services for memory above 1 MiB, used by many later DOS games and extenders./EMSEMSExpanded Memory Specification: bank-switched memory exposed through an EMS page frame. state, closes handles, walks the MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it. chain, releases every block whose owner matches cur_psp, then coalesces free neighbors before returning to the parent PSPPSPThe DOS data block placed before each program, holding terminate vectors, the job file table, command tail, and environment pointer. saved at PSPPSPThe DOS data block placed before each program, holding terminate vectors, the job file table, command tail, and environment pointer.:16h.

src/kernel/exec.incNASM · 16-bit
390
393
395
396
398
401
402
403
415
423
430
439
443
448
455
456
alloc_exec_environment:
mov word [cs:exec_env_seg], 0
mov bx, ENV_PARAS
call alloc_mem_direct
mov [cs:exec_env_seg], ax
dec ax
mov ds, ax
mov word [ds:1], ENV_OWNER_TEMP
free_exec_environment:
dec ax
mov word [ds:1], 0
assign_exec_environment_owner:
mov bx, [cs:exec_env_seg]
dec bx
mov ax, [cs:cur_psp]
mov [ds:1], ax

Tests that pin this

scripts/test_envmcb.pyscripts/test_execenv.pyscripts/test_memrelease.py

Exit releases process memory

A child can leak only if its owner tag is wrong.

Termination is not a wholesale arena reset. It is owner-based: each MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it. is checked against the current PSPPSPThe DOS data block placed before each program, holding terminate vectors, the job file table, command tail, and environment pointer., matching blocks are marked free, and unrelated parent or resident blocks remain intact.

After the walk, mcb_coalesce_all_free merges adjacent free blocks. That is why the shell can run a child repeatedly and still report a stable largest executable block.

src/kernel.asmNASM · 16-bit
2132
2141
2142
2145
2146
2147
2149
2151
2158
2159
2161
2165
2169
2174
2175
do_terminate:
%if ENABLE_EMS
mov word [cs:ems_alloc_pages], 0
mov word [cs:xms_alloc_kb], 0
call release_inherited_handles
call close_owned_handles
mov si, [cs:mcb_first]
mov ds, si
mov ax, [cs:cur_psp]
cmp word [ds:1], ax
mov word [ds:1], 0
call mcb_walk_next
call mcb_coalesce_all_free
mov ax, [0x16]
mov [cs:cur_psp], ax

Tests that pin this

scripts/test_memrelease.pyscripts/test_free.pyscripts/test_shell.py

XMS is a single-handle shim

INT 2Fh advertises an XMS entry point backed by BIOS INT 15h moves.

On boot, LainDOS asks BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. INT 15h AH=88h for extended memoryXMSExtended Memory Specification services for memory above 1 MiB, used by many later DOS games and extenders. and caps it at XMS_MAX_KB. INT 2Fh AX=4300h/4310h then advertises one XMSXMSExtended Memory Specification services for memory above 1 MiB, used by many later DOS games and extenders. entry point for callers that probe HIMEM-style services.

The implementation intentionally supports a single allocated handle: allocation succeeds only if no handle is active, handle 1 represents the whole block, and moves validate both real-mode endpoints and XMSXMSExtended Memory Specification services for memory above 1 MiB, used by many later DOS games and extenders. offsets before chunking through BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. INT 15h AH=87h.

src/kernel.asmNASM · 16-bit
42
43
87
88
1571
1573
1574
1575
1579
1581
1583
1589
1591
1593
1598
1601
1611
1614
1616
1618
1620
1637
1643
1647
1649
1650
1651
1663
1667
1676
1684
1692
1738
1743
%ifndef XMS_MAX_KB
%define XMS_MAX_KB 15360
%ifndef ENABLE_XMS
%define ENABLE_XMS 1
init_xms_size:
mov word [cs:xms_total_kb], 0
mov ah, 0x88
int 0x15
cmp ax, XMS_MAX_KB
mov ax, XMS_MAX_KB
mov [cs:xms_total_kb], ax
int2f_handler:
cmp ax, 0x4300
cmp ax, 0x4310
mov al, 0x80
mov bx, xms_entry
xms_entry:
cmp ah, 0x08
cmp ah, 0x09
cmp ah, 0x0A
cmp ah, 0x0B
mov ax, [cs:xms_total_kb]
cmp word [cs:xms_alloc_kb], 0
cmp dx, [cs:xms_total_kb]
mov [cs:xms_alloc_kb], dx
mov ax, 1
mov dx, 1
mov word [cs:xms_alloc_kb], 0
.move:
test ax, 1
call xms_prepare_endpoint
call xms_prepare_endpoint
mov ax, 0x8700
int 0x15

Tests that pin this

scripts/test_xms.pyscripts/test_free.pyscripts/test_shell.py

EMS is optional and off by default

Normal builds report no EMS; EMS test builds install INT 67h with one handle and four page-frame slots.

EMSEMSExpanded Memory Specification: bank-switched memory exposed through an EMS page frame. needs a 64 KiB page frame in conventional memory, so the default build leaves ENABLE_EMS at zero. In that mode, INT 67h returns AH=80h, which lets callers treat EMSEMSExpanded Memory Specification: bank-switched memory exposed through an EMS page frame. as absent without consuming arena space.

When built with ENABLE_EMS=1, LainDOS exposes one handle, up to 64 logical pages, and four physical page-frame slots. Mapping saves the old frame page back to high backing storage, copies the requested logical page into the frame, and records the mapping.

src/kernel.asmNASM · 16-bit
45
46
47
49
52
91
92
1437
1438
1440
1441
1894
1895
1896
1900
1901
1925
1927
1929
1930
1935
1940
1942
1950
1955
1957
1982
1993
2000
2028
2052
2053
EMS_TOTAL_PAGES equ 64
%ifndef EMS_FRAME_SEG
%define EMS_FRAME_SEG 0x9000
EMS_FRAME_PARAS equ 0x1000
EMS_BACKING_HI equ 0x0020
%ifndef ENABLE_EMS
%define ENABLE_EMS 0
%if ENABLE_EMS
mov [es:0x67*4], word int67_handler
%else
mov [es:0x67*4], word int67_absent_handler
%if !ENABLE_EMS
int67_absent_handler:
mov ah, 0x80
%if ENABLE_EMS
int67_handler:
.frame:
mov bx, EMS_FRAME_SEG
.pages:
mov bx, EMS_TOTAL_PAGES
.alloc:
cmp bx, EMS_TOTAL_PAGES
mov [cs:ems_alloc_pages], bx
.map:
cmp al, 3
cmp bx, [cs:ems_alloc_pages]
call ems_copy_16k
call ems_copy_16k
mov [cs:si+ems_map_pages], bx
mov word [cs:ems_alloc_pages], 0
ems_clear_map:
mov word [cs:ems_map_pages], 0xFFFF

Tests that pin this

scripts/test_ems.pyscripts/test_free.pyscripts/test_shell.py

FREE.COM is the user-visible audit

The shell memory report walks the same MCB chain users depend on.

The FREE utility is intentionally simple: it starts at MCB_START, validates each header, totals free paragraphs, records the largest free block, probes XMSXMSExtended Memory Specification services for memory above 1 MiB, used by many later DOS games and extenders. via INT 2Fh, probes EMSEMSExpanded Memory Specification: bank-switched memory exposed through an EMS page frame. via INT 67h, and prints the table the tests inspect.

This gives contributors a quick manual sanity check after memory-sensitive changes: if MCBMCBA 16-byte DOS memory header that describes the allocated or free block immediately after it. headers are corrupt, largest executable size is wrong, or XMSXMSExtended Memory Specification services for memory above 1 MiB, used by many later DOS games and extenders./EMSEMSExpanded Memory Specification: bank-switched memory exposed through an EMS page frame. totals become inconsistent, make test and the shell MEM/FREE path should catch it.

programs/free.asmNASM · 16-bit
4
6
13
14
15
22
27
31
35
37
46
48
52
55
56
60
64
70
75
76
86
87
93
97
98
101
102
%include "src/memory.inc"
CONV_TOTAL_KB equ (MEM_TOP / 64)
call collect_mcb
call query_xms
call query_ems
collect_mcb:
mov si, MCB_START
cmp si, MEM_TOP
cmp al, MCB_SIG_M
cmp al, MCB_SIG_Z
mov ax, [1]
mov ax, [3]
cmp word [block_owner], 0
add [free_paras], ax
cmp ax, [largest_free]
cmp byte [block_sig], MCB_SIG_Z
add ax, [block_size]
query_xms:
mov ax, 0x4300
int 0x2F
mov ah, 0x08
call far [xms_entry]
query_ems:
mov ah, 0x40
int 0x67
mov ah, 0x42
int 0x67

Tests that pin this

scripts/test_free.pyscripts/test_shell.pyscripts/test_xms.pyscripts/test_ems.py