NOTE: This is a design document for work in progress on the mmu branch. Everything here is tentative; Phase 1 has been implemented, CPU$ now manages all memory on every processor (see The CPU$ Driver), and on an 80286, BASIC-DOS switches to protected mode while its drivers initialize, and stays there: the kernel initializes, and COMMAND.COM starts and runs commands and some programs, in protected mode (see Protected-Mode Initialization). The Protected Mode demo boots that way on a 2Mb PC AT, and its Debugger page adds the PCjs Debugger (see The Protected Mode Demo).
The design targets the IBM PC AT: ibm5170 (80286, AT BIOS revision 3) with --ram=2M, which pc.js now splits into 640K of conventional memory (0-9FFFFh) and 1408K of extended memory (100000h-25FFFFh), like the PCjs 2048K PC AT machines. On the mmu branch, boot.sh passes --ram=2M for every configuration.
Every change is tested on three machines, with tools/tests/mmutest.sh, which runs quick.sh’s floppy tests and CPUTEST on each:
| Name | Machine | CPU$ |
|---|---|---|
8088 |
IBM PC XT (ibm5160) |
The 8086 CPU$ stays (CPU type 0, hardware type 0) |
real |
2Mb PC AT with CPU=8088 in CONFIG.SYS |
The 8086 CPU$ stays (CPU type 0, hardware type 2), so BASIC-DOS runs in real mode with A20 disabled |
pm |
2Mb PC AT without CPU=8088 |
Switches to protected mode (see Protected-Mode Initialization), where every test passes except DOSTESTS (whose EXEC test runs an EXE file; see Code Aliases) and HARDERR (reading an empty diskette drive fails with “unknown media” instead of “not ready”, and its metadata test computes a segment for a direct INT 13h read) |
Because real mode is the only mode they’ve been tested in, every other configuration that boots on a PC AT or DeskPro 386 (the demo diskettes and hard disk, the test configurations that boot.sh uses, and mk.sh’s build configuration) has CPU=8088 in its CONFIG.SYS; only the pm test, tools/tests/pmboot.sh, and the Protected Mode demo leave it out. The 80386 path (which returns from its self-test to real mode by clearing PE instead of resetting the CPU) is still tested occasionally with compaq386 (a COMPAQ DeskPro 386), but only the PC AT is a target for now.
CPU$ manages all memory, on every processor, so BASDEV.COM starts with two CPU$ drivers, of which exactly one stays loaded (a device name only has to be unique among the drivers that stay, since only they are linked into the chain): the 80286 driver (286dev.asm), followed by the 8086 driver (x86dev.asm). The 80286 driver, which is initialized first, determines the processor type:
It stores the result in CPU_HWTYPE (0:513h) and CPU_TYPE (0:512h), as one of the CPUTYPE_* values in cpu.inc (0 = 8086/8088, 1 = 80186/80188, 2 = 80286, 3 = 80386 or later). A CPU=8088 line in CONFIG.SYS, or less than 128K of conventional memory (after any MEMSIZE= line), makes CPU$ store 0 (8086/8088) in CPU_TYPE and act as it does on an 8088, without enabling A20; CPU_HWTYPE is still the processor’s own type, for software that must know how the processor runs instructions (eg, DEBUG.COM’s disassembler).
Neither CPU$ can be skipped (devinit ignores SKIP=CPU$). The one that stays makes its functions available through DDINT_CPU (0050:0030h), a 5-byte far jump in low memory like the other DDINT entry points, which devinit initializes to STC, RETF, so that a call fails (with carry set) until CPU$ initializes:
MEMSIZE= line), the BIOS reports at least 64K of extended memory (INT 15h function 88h), A20 can be enabled (with the 8042 keyboard controller, as on a PC AT), and a switch to protected mode and back succeeds. It keeps everything but its initialization code (about 3K), enables A20 permanently, and builds its arena (a table of PMBs) and descriptor tables in the 64K system area. Otherwise (or with CPU=8088), it unloads itself, and devinit moves the drivers after it down (see Claiming Memory at Boot), so whichever driver stays is at 0970h.The 80286 driver, which always initializes first, also applies any MEMSIZE= line (see CONFIG.SYS), by lowering the BIOS’s MEMORY_SIZE (which INT 12h returns), so either driver’s arena ends there. Either way, devinit claims what it’s using as soon as CPU$ initializes (see Claiming Memory at Boot).
The one that stays also provides DDINT_GETVEC and DDINT_SETVEC (see Device Drivers), which devinit also initializes to STC, RETF: the 8086 driver points them at its own code (which reads and writes the vector table) when it initializes, and the 80286 driver at its protected-mode code when it switches to protected mode (see Protected-Mode Initialization), so either way, before any other driver initializes.
Call DDINT_CPU with a function number in AH. Physical addresses and lengths are 32-bit byte values: addresses in DX:CX and lengths in SI:DI (high:low). Every function runs with interrupts disabled, preserves BX, SI, DI, BP, DS, and ES, and returns carry clear on success, or carry set with an error code in AX.
| AH | Function | Inputs | Outputs |
|---|---|---|---|
| 00h | CPU_GETINFO | AL = CPU type, AH = CPUF_* flags (bits 6-7 = CPU_HWTYPE), DX:CX = length of largest free block | |
| 01h | CPU_ALLOC | AL = MEMF_* flags, BX = owner, SI:DI = length | AX = first selector (0 if MEMF_NONE), DX:CX = address |
| 02h | CPU_FREE | DX:CX = block | |
| 03h | CPU_MAP | AL = MEMF_* flags, DX:CX = address, SI:DI = length | AX = first selector, CX = number of selectors |
| 04h | CPU_UNMAP | BX = first selector, CX = number of selectors (0 = 1) | |
| 05h | CPU_QUERY | BX = PMB number (0 = first), or -1 for block DX:CX; ES:DI -> 32-byte buffer | PMB copied to ES:DI |
| 06h | CPU_ALIAS | AL = MEMF_* flags, BX = selector | AX = new selector, DX:CX = base address |
| 07h | CPU_PMODE | (none) | Protected mode, for good: CS, DS, ES, and SS are now selectors for the same memory, and AX = a data selector for the caller’s CS (see Protected-Mode Initialization) |
| 08h | CPU_REALLOC | DX:CX = block, SI:DI = new length | |
| 09h | CPU_MOVE | ES:BX -> MOVEPKT (source address, destination address, and length, all dwords) | |
| 0Ah | CPU_SETBLK | DX:CX = block, AL = MCBTYPE_*, BX = owner (non-zero), ES:SI -> 8-character name (SI = 0 for none) | |
| 0Bh | CPU_FREEALL | BX = owner | |
| 0Ch | CPU_SETLIM | BX = selector, SI:DI = new length (1 to 64K) | |
| 0Dh | CPU_SEGINFO | AL = ADDR_* flags, BX = selector (or segment) | DX:CX = base address, AX = limit (or with ADDR_RIGHTS, AL = access rights, AH = extended byte) |
| 0Eh | CPU_RWMEM | AL = ADDR_* flags, BX:SI = address, CX = length, ES:DI -> buffer | |
| 0Fh | CPU_SYSREGS | ES:DI -> 12-byte buffer | GDTR and IDTR stored at ES:DI; AX = MSW, CX = LDTR, DX = TR |
| 10h | CPU_SETFAULT | DX:CX = fault handler (0:0 for none) | DX:CX = previous handler |
A block (DX:CX) is the address that CPU_ALLOC returned, or DX = -1 and CX = the selector (or segment) that it returned, which is what DOS has (eg, in ES for DOS_MEM_FREE). CPU_SETBLK records a block’s owner, type, and name for DOS (in PMB_OWNER, PMB_MCBTYPE, and PMB_NAME, or with the 8086 driver, in the block’s MCB), which MEM and DEBUG’s DM show; CPU_FREE and CPU_FREEALL clear them, and CPU_FREEALL frees every block that an owner has (DOS uses it when a program ends).
MEMF_LDT (01h) requests LDT selectors instead of GDT selectors, MEMF_CODE (02h) requests execute/read code segments instead of read/write data segments, MEMF_NONE (04h) makes CPU_ALLOC allocate memory without any selectors, MEMF_RDONLY (08h) makes data read-only (or code execute-only), and MEMF_USER (10h) makes the selectors DPL 3 (for programs at ring 3) instead of DPL 0. CPU_ALLOC also takes MEMF_LOW (20h) to allocate below 1Mb, MEMF_HIGH (40h) to allocate from the top of the last free block that’s big enough, and MEMF_AT (80h) to allocate the memory at DX:CX (which must be free); otherwise, it allocates from anywhere in memory, conventional or extended (as DOS_MEM_ALLOC does). CPU_ALIAS creates a new selector, with the access rights that its MEMF_* flags select, for the same memory as an existing one (it copies the base and limit), and CPU_UNMAP frees it; see Code Aliases. CPU_SETLIM changes the length that a selector covers (its limit), keeping the selector, so that anything that already holds it (eg, a driver’s header, the driver chain, and IDT gates) sees the change; fixed GDT selectors can’t change.
Every function works the same way in either driver and in either mode, so callers needn’t check which is which: in real mode, CPU_ALLOC, CPU_MAP, and CPU_ALIAS return the memory’s segment instead of a selector (CPU_ALLOC returns 0 for a block above 1Mb, and CPU_MAP fails with ERR_INVALID unless the memory is below 1Mb and paragraph-aligned), a segment is its own alias, and CPU_UNMAP has nothing to do. CPU_REALLOC changes a block’s length (rounded up to a multiple of 16): a shorter block frees its end, and a longer one takes what it needs from the next block, which must be free and long enough (ERR_NOMEMORY otherwise, with the block unchanged); its selectors don’t change. CPU_MOVE copies any amount of memory between two physical addresses, correctly even if the source and destination overlap; in real mode, both must be below 1Mb. With the 8086 driver, every block has an MCB in the paragraph before it, so CPU_ALLOC with MEMF_AT needs that paragraph to be free too, and CPU_QUERY describes each MCB with a PMB (whose PMB_SEL is the block’s segment). A block of more than 64K gets one selector for every 64K (each 8 more than the last, so that selector + 8 * n addresses the nth 64K), and the last one’s limit covers the rest. CPU_ALLOC rounds lengths up to a multiple of 16, so every block starts on a paragraph boundary; CPU_MAP maps exactly the requested length. Errors are ERR_INVALID (bad function, zero length, unknown PMB, a selector that can’t be unmapped, or one that isn’t in use for CPU_ALIAS), ERR_NOMEMORY (no block big enough, with DX:CX = length of the largest free block; or no free PMB or descriptors), and ERR_BADADDR (a block that CPU_ALLOC didn’t return, for CPU_FREE, CPU_REALLOC, CPU_SETBLK, or CPU_QUERY).
CPU_SETFAULT gives a fatal protected-mode exception to a handler, instead of the 80286 driver reporting it (CPU fault) and stopping the machine, unless it occurred in CPU$ or in the handler’s own code. In protected mode, the kernel sets the first handler (dos_fault, the last of its resident code, which it keeps only in protected mode) before CONFIG.SYS’s DEVICE= drivers are loaded, and a debugger that sets its own (eg, DEBUG.COM) passes on the exceptions it doesn’t trap to the handler it replaced (see Protected-Mode Interrupts). The handler is entered with the registers as they were, interrupts disabled, and SS:SP pointing to the vector, the error code, and an interrupt frame for the faulting instruction; for a software interrupt with no handler, those are its vector (0FFh if unknown), the caller’s AX, and the INT itself. The 8086 driver keeps the handler too, but never calls it, since real-mode exceptions go through the vectors.
CPU_SEGINFO, CPU_RWMEM, and CPU_SYSREGS are for debuggers (eg, DEBUG.COM), so they never change CPU$’s state, and work even when CPU$ itself was interrupted (eg, at a breakpoint in CPU$); CPU_RWMEM borrows a scratch selector, and restores its descriptor afterward. An address (segment:offset) is in real-mode form, unless ADDR_SEL (01h) is set, in which case its segment is a protected-mode selector, in either mode: any present descriptor in the GDT (including the identity selectors) or the LDT. With ADDR_PHYS (04h), the address is a physical address instead (its “segment” holds bits 16-31), which the 8086 driver accepts only below 1Mb. With ADDR_RIGHTS (08h), CPU_SEGINFO returns a selector’s access rights byte in AL and its descriptor’s extended byte in AH, instead of its limit (zero for a real-mode segment), for a debugger’s DS command; the access rights can’t go in another register, since cpu_entry restores every register but AX, CX, and DX. CPU_SEGINFO returns a segment’s base address and limit (FFFFh for a real-mode segment). CPU_RWMEM copies CX bytes from the address to ES:DI, or with ADDR_WRITE (02h), from ES:DI to the address, which must be within the segment’s limit (and in real mode, below 1Mb); it writes through a data selector of its own, so it can store a breakpoint in a code segment. CPU_SYSREGS stores the GDTR and IDTR as SGDT and SIDT do (a 16-bit limit and a 24-bit base, in 6 bytes each), and returns the MSW, LDTR, and TR (the last two are zero in real mode). The 8086 driver accepts only real-mode addresses, and CPU_SYSREGS returns ERR_INVALID, which also tells a debugger that the processor has only one mode.
CPUF_HWTYPE (C0h) holds the processor’s own type (CPU_HWTYPE, in bits 6-7), which AL (CPU_TYPE) understates with CPU=8088. CPUF_PMOK (02h) means the protected-mode self-test passed (so the 80286 driver stayed, and CPU_PMODE is available), and CPUF_PM (01h) means that the processor is running in protected mode (after CPU_PMODE). Like every other function, CPU_MOVE runs with interrupts disabled, so long moves delay interrupts.
In protected mode, the same functions will be reached through a call gate (SEL_CPU, below), so callers will do call SEL_CPU:0 instead of calling DDINT_CPU.
Why DDINT_CPU: an IOCTL interface (like FPU$’s IOCTL_GETFPU) would make every caller open the device, and the callers will mostly be the kernel and other drivers. DDINT_UTIL is the DOS’s entry point for drivers, so it isn’t available until sysinit, and it’s the wrong direction (CPU$ is called by the DOS). A software interrupt would work in both modes, but uses up a vector; the call gate gives protected-mode callers the same kind of fixed far address that DDINT_CPU gives real-mode callers.
Physical memory on the baseline 2Mb PC AT:
| Address | Length | PMB type | Contents |
|---|---|---|---|
| 000000h | 1K | USED | Real-mode interrupt vector table |
| 000400h | 256 | USED | ROM BIOS data area (segment 40h) |
| 000500h | 1136 | USED | BASIC-DOS low memory (also segment 40h, from offset 100h): CPU_TYPE (512h), CPU_HWTYPE (513h), CPU_ARENA (514h, the address of CPU$’s arena, for debuggers), the diskette parameter table (522h), the DDINT entry points (530h, called through segment 50h, starting with DDINT_CPU at 0050:0030h), and two disk buffers |
| 000970h | USED | Drivers: CPU$ (first), NUL, VKD$, …, then BASDOS.COM and its tables | |
| FREE | The rest of conventional memory, for CPU_ALLOC (including DOS’s blocks, eg, programs) | ||
| 0A0000h | 384K | RSVD | Video memory, adapter ROMs, and the system ROM (not RAM) |
| 100000h | 64K | SYS | CPU$ system area (the HMA; see below) |
| 110000h | 1344K | FREE | The rest of extended memory, also for CPU_ALLOC (and DOS) |
| 260000h | End of memory |
The PMB types (PMBTYPE_* in cpu.inc) are FREE (0), USED (1, allocated by CPU_ALLOC), SYS (2), and RSVD (3). The end of conventional memory comes from the BIOS (MEMORY_SIZE), so a machine with less than 640K (or a MEMSIZE= line) has a larger RSVD block. The 80286 driver starts with conventional memory in three PMBs (USED below the drivers, USED for CPU$ itself, and FREE for the rest), and devinit and sysinit claim what they use; after that, DOS allocates from it with CPU_ALLOC like anything else (MEMF_LOW, for now).
CPU$ keeps everything it owns, other than its code, in the first 64K of extended memory, which (with A20 enabled) is addressable in real mode as segment FFFFh, and in protected mode with selector SEL_SYS, whose base is FFFF0h, so the same offsets work in both modes. Its code is the same in both modes, too: it gets its data segment from the mode (SMSW), and never writes through CS.
| Offset | Linear | Contents |
|---|---|---|
| 0000h | 0FFFF0h | Last 16 bytes of the ROM BIOS (unusable) |
| 0020h | 100010h | SYSVARS: signature, CPU type and flags, the PMB table’s offset, count, and maximum, function arguments, saved real-mode SS:SP and CS, and the GDTR and IDTR values |
| 00D0h | 1000C0h | CALL 5 trampoline: JMP 0000:00C0h (see below) |
| 00E0h | 1000D0h | GDT: 256 descriptors that CPU$ allocates from (the fixed ones first) |
| 08E0h | 1008D0h | PMB table (128 entries of 32 bytes) |
| 18E0h | 1018D0h | pm_call’s IDT (256 gates, for the self-test, which return to real mode) |
| 20E0h | 1020D0h | The protected-mode IDT (256 gates; see Protected-Mode Interrupts) |
| 28E0h | 1028D0h | LDT (1024 descriptors) |
| 48E0h | 1048D0h | Unused (zero), up to the identity selectors |
| A0E0h | 10A0D0h | GDT: identity selectors A000h-F800h, every 800h (8 bytes each, zero in between), through 10F8D7h |
A PSP’s CALL 5 (a far call to F01D:FEF0h, for example) reaches 0:00C0h (INT_DOSCALL5) only by wrapping around at 1Mb, so with A20 enabled, it lands at 1000C0h instead; like DOS 5 with DOS=HIGH, CPU$ puts a far jump to 0:00C0h there. In protected mode, a real-mode segment isn’t a selector, so a PSP’s PSP_FCSEG is SEL_CALL5 instead (see psp_setmem), a call gate to SEL_C5CODE:00C0h, the far jump that sysinit stores at 0:00C0h (see set_call5); a far call through a gate ignores its offset, so PSP_SIZE keeps the value that CP/M programs read as their memory size.
The GDT’s limit extends through the last identity selector (F800h), so the descriptors between its first 256 entries and A000h overlap the rest of the system area; CPU$ clears the whole area first, so most of them (everything from 48E0h up, other than the identity selectors) aren’t present, and loading one of those selectors (eg, a real-mode segment by mistake) faults.
Before writing to the system area, CPU$ checks that A20 really is enabled (that SYSVARS isn’t aliased to 0:0000h), and on every function call, it checks the SYSVARS signature and re-enables A20 if necessary (eg, if a program used the BIOS block move function, INT 15h function 87h, which turns A20 off).
| Selector | Type | Base | Use |
|---|---|---|---|
| 00h | Null selector | ||
| 08h | SEL_SYS | 0FFFF0h | Data: the system area (so offsets match segment FFFFh) |
| 10h | SEL_LDTD | 1028D0h | The LDT (for LLDT) |
| 18h | SEL_CPUCODE | 000970h | Code: the CPU$ driver |
| 20h | SEL_LOW | 000000h | Data: the first 64K (interrupt vectors, BIOS data, BASIC-DOS low memory) |
| 28h | SEL_STACK | caller’s SS | Data: the caller’s stack, set on every switch to protected mode |
| 30h | SEL_TMP | 110000h | Data: scratch (the self-test writes through it) |
| 38h | SEL_CPU | Call gate to CPU$’s function entry point | |
| 40h | SEL_BIOS | 000400h | Data: the BIOS segment (40h), so code that uses segment 40h works in either mode |
| 48h | SEL_TMP2 | Data: scratch (see pm_ptr) | |
| 50h | SEL_DDINT | 000500h | Code: the DDINT segment (50h), so the DDINT entry points (eg, DDINT_SETVEC, 0050:003Ah) have the same addresses in either mode |
| 58h | SEL_C5CODE | 000000h | Code: just the CALL 5 jump at 0:00C0h (its limit is 00C4h) |
| 60h | SEL_CALL5 | Call gate to SEL_C5CODE:00C0h, for a PSP’s CALL 5 (in cpu.inc, since the kernel uses it) | |
| A000h-B800h | A0000h-B8000h | Data: identity selectors for video memory, every 800h | |
| C000h-F800h | C0000h-F8000h | Code: identity selectors for the adapter and system ROMs, every 800h, so the ROM BIOS can run at ring 0 with its own segments (eg, F000h) |
Selectors 68h-7F8h (GDT) and 04h-1FFCh (LDT) are allocated by CPU_ALLOC, CPU_MAP, and CPU_ALIAS (and CPU_PMODE); a descriptor whose access byte is zero is free. On the baseline 2Mb PC AT, for example, devinit’s code selector is 68h, and each driver gets a data selector and a code selector after that (eg, 78h and 80h for CPU$, and 88h and 90h for NUL; see Protected-Mode Initialization). All descriptors are 80286-format (the 80386 fields are zero, except bits 31-24 of the base, for blocks above 16Mb) and DPL 0 (unless MEMF_USER is set).
The 80286 driver’s arena is a table of Physical Memory Blocks (PMBs) in address order, covering all memory from 0 to the end of extended memory, with the last PMB marked PMBSIG_LAST (‘Z’), like the last MCB:
| Field | Size | Contents |
|---|---|---|
| PMB_SIG | byte | ‘M’, or ‘Z’ for the last PMB |
| PMB_TYPE | byte | PMBTYPE_* |
| PMB_OWNER | word | Owner (from CPU_ALLOC’s BX), or zero |
| PMB_ADDR | dword | Physical address |
| PMB_LEN | dword | Length, in bytes |
| PMB_SEL | word | First selector (if any) |
| PMB_NSEL | word | Number of selectors |
| PMB_MCBTYPE | byte | Block type (MCBTYPE_*, as in an MCB), or zero |
| (reserved) | 7 bytes | |
| PMB_NAME | 8 bytes | Name of the process (as in an MCB), or zero |
CPU_ALLOC uses the first free block that’s big enough, anywhere in memory (splitting it if necessary), unless MEMF_LOW, MEMF_HIGH, or MEMF_AT says otherwise, CPU_REALLOC shrinks a block or grows it into the free block after it, and CPU_FREE frees the block’s selectors and merges it with any free neighbors.
Unlike MCBs, PMBs aren’t stored in front of the memory they describe:
The cost is a fixed number of PMBs (128 for now; a CONFIG.SYS option could change it). The 8086 driver’s arena is the MCB chain itself: programs on an 8086 expect to see MCBs, and conventional memory is all there is.
For debuggers that can’t call CPU$ (eg, the PCjs Debugger), whichever driver stays stores the real-mode address of its arena in CPU_ARENA (0:514h): the 8086 driver’s first MCB, or the 80286 driver’s PMB table (FFFF:08E0h, the only arena above 1Mb). The PCjs Debugger’s d dos command uses it to list the arena the way DEBUG’s DM does, in either mode, with each of the system’s blocks shown as the drivers in it (from DD_LIST, whose links are selectors in protected mode); it labels the memory DOS is in by DOS’s INT 20h handler, since it can’t call DOS_MSC_GETVARS, so until sysinit installs that, the kernel’s block shows as SYS.
Once CPU$ has initialized, everything that runs during the boot declares the memory it uses, with either driver and in either mode, and moves memory with CPU$ rather than on its own:
chk_cpu, before the first driver after CPU$ loads) claims two blocks with CPU_ALLOC and MEMF_AT: the drivers that haven’t initialized yet, and its own code, CONFIG.SYS data, and scratch buffer. Then, while it’s still in real mode (so that it can use the boot code’s read_file), it allocates a block for BASDOS.COM at the top of conventional memory (MEMF_LOW + MEMF_HIGH), loads BASDOS.COM into it, and copies the CONFIG.SYS data after it.CPU=8088), where the 80286 CPU$ unloads itself before any CPU$ is loaded, so devinit moves the drivers after it down itself; reclaiming the memory of a driver that doesn’t stay is devinit’s job, not CPU$’s.i9), devinit shrinks the drivers’ block to fit (CPU_REALLOC), frees its own block and BASDOS.COM’s, and allocates BASDOS.COM’s new home with CPU_ALLOC and MEMF_LOW, which is the first free block, right after the drivers (and their MCB, with the 8086 driver); the block reaches at least the end of devinit, because the 8086 driver puts a free MCB after it, which must not land on devinit’s code or data before the move (sysinit resizes the block anyway). Then it pushes sysinit’s inputs (including BASDOS.COM’s new address) and its new entry point, and jumps to CPU_MOVE, which moves BASDOS.COM (over devinit) and “returns” to sysinit.move_init, dos_size, init_free, and map_buf, in drvinit.asm) allocates a block at the top of conventional memory for its initialization code, CONFIG.SYS data, and a 512-byte stack below them, moves them there with CPU_MOVE, and jumps to them through a data selector and code alias for the block (CPU_MAP and CPU_ALIAS). It grows the kernel’s block as much as it can (CPU_REALLOC returns the longest a block could be), builds its tables after the kernel at offsets in the kernel’s segment (init_table), gets a segment (or selector) for each disk buffer from CPU_MAP, and shrinks the block to fit. Before loading the SHELLs, it gives back the part of its block below sysinit_tail (by freeing the block and claiming the rest again), which is everything except its stack and systail.asm (linked last: the SHELL loop and only the code, data, and CONFIG.SYS data it needs), and when they’re loaded, it frees the rest.Once CPU$ is loaded, neither devinit nor sysinit computes a segment from an address, or relocates memory itself. Before then, devinit calls the first driver (CPU$) at its fixed segment (BIOS_END), and moves the drivers itself if the 80286 CPU$ unloads (above); and sysinit sets DOS’s memory limit (mcb_limit, for PSP_PARAS) from the BIOS’s MEMORY_SIZE. (They do still copy the CONFIG.SYS data, into blocks that CPU$ gave them.)
After the boot, the kernel’s memory functions (memory.asm) are just calls to CPU$, with the block’s segment (or selector) in CX and DX = -1: DOS_MEM_ALLOC calls CPU_ALLOC (without MEMF_LOW, so a block can be anywhere in memory, including extended memory in protected mode, plus MEMF_HIGH for MCBTYPE_HIGH) and CPU_SETBLK (the owner and type), DOS_MEM_FREE calls CPU_FREE, DOS_MEM_REALLOC calls CPU_REALLOC, loading a program names its block with CPU_SETBLK, a program’s blocks are freed with CPU_FREEALL when it ends, and DOS_UTL_QRYMEM (for MEM), finding a loaded copy of a program’s code, and DEBUG’s DM use CPU_QUERY. There’s no MCB chain for programs to walk, other than the 8086 driver’s, so DOS_MSC_GETVARS’s mcb_head is zero; MEM’s total memory is its DV_MEM_SIZE, which sysinit sets to the conventional memory (MEMORY_SIZE) plus every block that CPU$ describes above 1Mb (mem_total), so on the baseline PC AT in protected mode, MEM reports all 2Mb.
CPU$’s pm_call runs a function in protected mode and returns to real mode, with interrupts and NMI disabled. It points SEL_STACK at the caller’s stack, saves SS:SP, loads the GDTR and IDTR, sets PE with LMSW, jumps to SEL_CPUCODE, and loads DS, ES, and SS with SEL_SYS and SEL_STACK, and LDTR with SEL_LDTD.
An 80386 returns to real mode by clearing PE (MOV CR0,EAX, assembled as bytes, since MASM 4.00 knows only 80286 instructions). An 80286 can’t, so before switching, pm_call sets the CMOS shutdown code (CMOS register 0Fh) to 0Ah and stores the address of pm_resume in 40:67h, and to return, it pulses the 8042’s reset line (command FEh), as the BIOS’s own block move (INT 15h function 87h) does. After the reset, the BIOS sees the shutdown code and jumps through 40:67h without initializing anything else (both the original 1984 PC AT BIOS and the 1985 revision 3 support shutdown code 0Ah), and pm_resume restores SS:SP and the real-mode IDTR, clears the shutdown code (so that a later CTRL-ALT-DEL does a normal POST), and enables NMI again. Because the shutdown code is set before switching, a “triple fault” (which resets the processor) also returns to pm_resume, and every IDT entry points to a fault handler that returns to real mode, too, so a bug in protected mode makes the caller’s test fail instead of hanging the machine.
The CPU$ self-test (pm_test) checks that PE is set and writes and reads back a word at 110000h (through SEL_TMP), which real mode can’t reach; CPU$ stays loaded only if that works.
CPU_PMODE (below) switches to protected mode the same way, but for good: it never returns to real mode.
After CPU_PMODE, every interrupt goes through the protected-mode IDT (SYS_PIDT), and the master PIC is moved from 08h to 78h, next to the slave PIC (70h-77h), so that IRQs 0-7 no longer share vectors with processor exceptions. Its default gates “reflect” each interrupt to the handler in the real-mode vector table, if that’s in the ROM (whose segments are identity code selectors, eg, F000h), at ring 0:
| IDT entries | Default gate | Reflected to |
|---|---|---|
| 00h-02h, 06h-0Fh | exc_stubs (DPL 0) |
Exceptions: fatal, except the cases below |
| 03h-05h, 10h-6Fh, 80h-FFh | sw_int (DPL 3) |
Software interrupts: the vector’s handler, if it’s in the ROM; otherwise fatal |
| 70h-77h | irq_stubs (DPL 0) |
IRQs 8-15: vectors 70h-77h’s handlers, if in the ROM; otherwise an EOI |
| 78h-7Fh | irq_stubs (DPL 0) |
IRQs 0-7: vectors 08h-0Fh’s handlers, if in the ROM; otherwise an EOI |
irq_common “returns” to the ROM handler with the interrupt’s own frame on the stack, so the handler returns straight to the interrupted code. sw_int gets the vector from the caller’s INT n (or INT3) instruction, and calls the handler with interrupts disabled and a frame that returns to si_ret, which copies the handler’s result flags (CF, ZF, SF, and OF) to the caller’s. Everything runs on the caller’s stack, at ring 0, so far.pm_getvec and pm_setvec, which read and write the IDT, not the real-mode vector table: setting a vector replaces its gate (for vectors 08h-0Fh, the gates for IRQs 0-7, at 78h-7Fh), so the driver’s handler is called directly, and getting a vector returns its gate, or if the gate is a default one, its real-mode vector if that’s in the ROM (eg, F000:FEA5h for INT 08h, which CLOCK$ chains to), or 0:0 if not.pm_rw (which in real mode is part of FDC$’s sector buffer instead). The diskette BIOS turns ES:BX into a DMA address (ES * 16 + BX), so ES:BX becomes BIOS (SEL_BIOS, whose base is 400h) and the buffer’s offset from 400h; a buffer more than 64K above 400h goes through FDC$’s sector buffer, a sector at a time, since readwrite_sectors gets each buffer’s address from CPU_SEGINFO (pm_dma) for its 64K DMA boundary check. The AT fixed disk BIOS uses programmed I/O, but it normalizes ES:BX by adding BX / 16 to ES, which makes ES an invalid selector unless BX is less than 16, so the buffer gets a selector of its own (CPU_MAP) with a zero offset. FDC$’s other real-mode assumptions are gone, too: readwrite_sectors advances the transfer address between tracks by adding to the offset, not ES (DOS’s buffers never extend beyond offset FFFFh), and set_dpt updates the DPT at DPT_ACTIVE (40:122h), where the boot code puts it, rather than through the INT 1Eh vector, which DDINT_GETVEC can’t return in protected mode.INT 0Ah), so if an exception’s frame (taken as having no error code) returns just past an INT for its vector, exc_common treats it as one.LDS SI,[78h] with DS = 0), so a general protection fault with a zero error code, in the ROM, while DS or ES holds a null selector, is retried with SEL_LOW (base 0). BASIC-DOS’s own code gets no such fix.dos_fault unless a debugger has replaced it: if the exception occurred in the current program’s own code (its PSP_START segment), DOS displays CPU fault vv (in DEBUG builds, followed by the error code and address) and ends the program with exit code 255 and EXTYPE_FAULT, as it does for a divide error, and the system continues; otherwise (eg, in the kernel, a driver, or the ROM), the exception is fatal: cpu_fatal prints, with the BIOS, CPU fault vv at cccc:iiii (DS=dddd ES=eeee EC=xxxx), where EC is the error code (for a software interrupt, the caller’s AX), and stops the machine for a debugger. An exception in CPU$ or in the kernel itself never reaches the kernel’s handler (CPU$ skips the handler’s own code), and dos_fault makes any other one fatal by removing the handler (CPU_SETFAULT with 0:0) and retrying the instruction. DEBUG.COM passes each exception on to dos_fault unless its VT command traps the vector, and GT passes on a trapped one.On an 80286 or later, when CPU$ stays loaded, BASIC-DOS switches to protected mode during devinit, right after CPU$ initializes, and never switches back; everywhere else (an 8088, or CPU=8088), nothing changes, and the same code runs in real mode:
chk_cpu), as it does in real mode (see Claiming Memory at Boot).get_sels, which calls CPU_MAP for a 64K data selector and CPU_ALIAS for its code alias; in real mode, both are the driver’s segment): devinit stores the data selector in DDH_DATA and DDPI_END, calls the driver through its code selector, and links it into the chain (DDH_NEXT_SEG, FDC_DEVICE, HDC_SEG, and CLOCK_DEVICE) with its code selector. When the driver’s initialization returns, devinit fits both selectors to the length the driver keeps, with CPU_SETLIM (fit_sels), so that they end where its resident code and data do, or frees them with CPU_UNMAP if it keeps nothing; CPU$’s own selectors (from step 3) are fitted the same way. The selectors are 64K until then, since a driver can keep more than its file (eg, FDC$’s sector buffer overlays its initialization code and extends past it). devinit also passes its own data selector in DDP_PTR and DDPI_BUFPTR. Every built-in driver (NUL, VKD$, COM1-COM4, LPT1-LPT3, CLOCK$, FDC$, HDC$, PIPE$, FPU$, and MOUSE$) initializes this way, and VKD$, COM, and CLOCK$ install their interrupt handlers in the IDT with DDINT_SETVEC (see Protected-Mode Interrupts).i9), as it does in real mode: it gets a code selector and a data selector for BASDOS.COM’s new home, pushes the offset of the BPB (in the first 64K), the size of the CONFIG.SYS data, the data selector, and the CONFIG.SYS data’s offset, which sysinit pops, and then CPU_MOVE “returns” to its entry point (CS:0002h).dos_seg is the kernel’s data selector (from devinit), not CS. It points the DOS vectors (in CPU$’s IDT) and DDINT_UTIL at the kernel’s code selector (dos_code, the CS that sysinit is entered with), and set_call5 stores the CALL 5 jump at 0:00C0h through a selector for the first 64K from CPU_MAP, which it then frees (CPU_UNMAP); DDINT_GETVEC and DDINT_SETVEC are CPU$’s, so sysinit leaves them alone. It builds its tables (the SCBs, BPBs, and SFBs) at offsets after the kernel (init_table), gets a segment (or selector) for each disk buffer (including the two in low memory) from CPU_MAP, which is what the buffer chain links, opens its devices, loads any DEVICE= drivers (load_devices calls each driver’s INIT through the code alias of its block, and links the driver with it), hooks INT 21h for its SHELL cache, loads the SHELLs (DOSUTIL LOAD), and points DDINT_ENTER and DDINT_LEAVE at the kernel.dos_leave’s IRET starts it, with CS set to the code alias of its program’s block (see Code Aliases), and COMMAND.COM initializes and runs commands and BASIC programs in protected mode. DEBUG.COM, loaded as a driver, gets any fault after it’s loaded (see CPU_SETFAULT), and with /H (eg, DEVICE=EXT\DEBUG.COM COM2:9600,N,8,1,VT100 /H), it also stops as soon as it’s loaded.The Protected Mode demo, a 2Mb PC AT with a Color Display and a DEC VT100 on COM2 for DEBUG.COM, boots the BASIC-DOS Protected Mode Demo diskette, whose CONFIG.SYS has no CPU=8088 line. DEBUG builds refresh that diskette, so that it has the assertions. The demo’s Debugger page is the same machine with the PCjs Debugger.
When the boot sector’s Press a key to start... prompt times out (pressing Esc at the prompt boots the hard disk instead, whose CONFIG.SYS has CPU=8088), CPU$ initializes and switches to protected mode, and everything after that runs in protected mode: the rest of the drivers, the kernel’s initialization (sysinit), the diskette’s CONFIG.SYS driver, DEBUG.COM, on COM2 (which prints its banner on the VT100), and COMMAND.COM, which loads from the hard disk (so C: is the default drive, and PATH$ lists C:’s directories), and whose prompt appears on the Color Display. Commands and BASIC programs work, but EXE files can’t run in protected mode yet (see Code Aliases). CPU$ gives each fault to DEBUG.COM, and the VT100 shows the fault (eg, CPU fault 0D) with its error code, and the registers at the faulting instruction (with # before its address, since it’s in protected mode, and the name of the memory it’s in after it). Adding /H to the DEBUG.COM line in CONFIG.SYS stops it as soon as it’s loaded, so that you can set breakpoints before booting continues. The B: drive has the BASIC-DOS Tests diskette, so B:\TESTS\MISC\CPUTEST prints CPU$’s memory arena and tests its functions, and B:\TESTS\MISC\FAULT n causes exception n (0, 1, 3-6, 12, or 13; eg, FAULT 13 writes to CS, a general protection fault), to see how BASIC-DOS responds: DOS reports a divide error (0) or an overflow (4) and ends the program, a single-step trap (1) or a breakpoint (3) resumes (unless DEBUG.COM stops there), and DOS ends the program for the rest (eg, CPU fault 0D), unless DEBUG.COM’s VT command traps the vector (eg, VT 0D or VT 13.), which stops the machine at the faulting instruction, after which GT passes the fault on to DOS.
r shows each segment register’s selector with its base and limit in protected mode (eg, CS=0018[000970,FFFF] in CPU$), along with the GDTR, IDTR, LDTR, and TR.bp %1295) work in either mode. An address in CPU$ is 970h plus its offset in 286DEV.MAP (eg, cpu_fatal, which reports a fatal exception and stops the machine).tools/tests/pmboot.sh boots in protected mode on the baseline 2Mb PC AT (with the console on COM1), and lists breakpoint addresses for the PCjs Debugger: CPU$’s, along with dv_init (in devinit, just before each driver’s initialization) and dv_jump (just before sysinit).d dos lists CPU$’s memory arena the way DEBUG’s DM command does, in either mode, once CPU$ has initialized: each block’s address, length, owner, and name or type, with the system’s blocks shown as the drivers in them (eg, CPU$ at 000970h; see Physical Memory Arena).bc *) before tracing (t) from it, or the trace stops there again.bw %976 'if cs==97;h;else', and reset: it’s a write breakpoint on CPU$’s DDH_REQUEST field (CPU$ is the first driver, at 0097:0000), which halts only when CPU$ itself writes it, as the start of its initialization does, so it doesn’t depend on how CPU$ is built.m int on and m halt on) before booting (without the write breakpoint above, since with HALT messages on, it also stops the machine where it shouldn’t).& (real mode) or # (protected mode), and otherwise has the processor’s current mode, or with %, it’s a physical address (eg, %100000), DS selector displays a selector’s descriptor as the PCjs Debugger’s DS command does (eg, base=000970 limit=FFFF type=1B (code,readable,accessed) ext=0000 dpl=00), R also shows the MSW, GDTR, and IDTR, and in protected mode, uses the PCjs Debugger’s format (each segment register’s base and limit, A20, the LDTR, GDTR, IDTR, TR, MSW, and the flags as 0 or 1), and LN [address] (“list near”) shows an address (CS:IP by default), its linear address, and the name of the memory it’s in (eg, #0128:3754 0095E4 <DOS>), as DM would show it.Staying in protected mode after CPU$ initializes is the goal, but the rest of BASIC-DOS assumes real mode in several ways, and each needs its own solution. This design targets the PC AT (an 80286, with IBM’s BIOS); the 80386 (eg, virtual-8086 mode for DOS programs) and other BIOSes, such as the DeskPro 386’s, are for later.
mov ax,es + inc ax to get from an MCB to its block, PSP and environment segments, and paragraph rounding), use fixed segments (0 for low memory, 40h, B000h and B800h for video, and F000h for the ROM), and use push cs + pop ds and then write data (a code segment can’t be written in protected mode). Each case needs a selector: for example, DOS_MEM_ALLOC could return a selector from CPU_MAP for the new block (keeping the MCB in the arena below 640K), the fixed segments would become fixed selectors (SEL_LOW, plus SEL_VIDEO and SEL_ROM), and each module with a code segment would also get a data alias (eg, the next selector). The first step is an inventory of every such case.The first step toward staying in protected mode is to change the drivers and the kernel (everything that will run at ring 0) so that the same code runs in either mode, by using only segment values that are also valid selectors, and by never writing to a code segment. The rules, in progress:
at 40h, as the ROM BIOS itself addresses its data, instead of 0, and it covers both the ROM BIOS data area (offsets 00h-FFh) and BASIC-DOS’s low memory (500h-96Fh, at offsets 100h-56Fh): CPU_TYPE, the DDINT entry points, the DPT, and the two disk buffers. In protected mode, 40h is an identity selector. BIOS_DATA (400h) is the segment’s linear address, for the few places that need a linear address (eg, the two disk buffers’ addresses, for CPU_MAP, and devinit, which addresses the drivers through segment 0).call far ptr DDINT_SETVEC is CALL 0050:003Ah), which in protected mode is SEL_DDINT, a code selector with the same base (500h), so the addresses are the same in either mode; 40h stays a data selector. The slots are still written through segment 40h (bios.inc’s DDINT segment is at 50h, and an operand like ds:[DDINT_CPU], with DS assumed to be BIOS, gets the BIOS offset from LINK, while an immediate offset needs DDINT_BIAS added). In protected mode, CPU$ points DDINT_CPU, DDINT_GETVEC, and DDINT_SETVEC at its own protected-mode code (see Protected-Mode Initialization).push cs and pop ds), in the drivers, the kernel, COMMAND.COM, DEBUG.COM, and the tests (FCBTESTS, DOSTESTS, CPUTEST, HARDERR, and IOERR; see Code Aliases). In the kernel, those segments now come from dos_data (eg, mov ds,cs:[dos_data]), where sysinit stores the kernel’s data segment (or selector), and sysinit’s relocated code uses SS, its own data selector (or init_data, in cache_int21, which runs on its caller’s stack); sprintf still loads DS from CS, since it only reads through it (and it’s also part of PRINTF.COM). A write through SS needs no change at all. MASM also gives a write an implicit CS override when the segment register that would otherwise address the variable is assumed to be NOTHING; a scan of the kernel’s binary for CS-prefixed writes found one (LOCK_SCB in mcb_getsize), which now goes through ES, but the drivers, COMMAND.COM, and DEBUG.COM haven’t been scanned yet. Writes to a driver’s own memory now go through its data selector (see the next item).%.*ls) as an empty string, rather than reading 0:0.mov ds,cs:[0].DDH_DATA (instead of push cs and pop ds) whenever it writes its own memory. In real mode, it’s the driver’s segment, so nothing changes; in protected mode, the driver’s code selector (CS) and data selector are separate selectors for the same memory, from CPU$. Far pointers to a driver’s code (eg, vectors, DDH_NEXT_SEG, FDC_DEVICE, and BPB_DEVICE) use its code selector, and pointers to its data (eg, FDC$’s ddbuf_ptr) use its data selector.The boot sector, which runs at 0:7C00h and then 0:0560h before anything else, keeps using segment 0 (adding BIOS_DATA to every BIOS offset), as does devinit to reach the drivers (through SEL_LOW, in protected mode), although it relocates them with CPU_MOVE.
DEBUG builds also report (and halt on) the first failed assertion, even before any console handle exists: the kernel installs a default INT 29h handler (BIOS output) whenever no driver has (eg, VKD$ isn’t loaded), so the fallback in write_handle never calls a null vector.
COMMAND.COM will run at ring 3, so beyond the rules above, it should use only selectors that the kernel gives it. Done:
codeSeg), and everything else (the block chains, labels, code generation, and patching) uses the block’s data selector. Generated code that writes to its own block (eg, an FPU$ function storing a result in a double’s 8-byte slot, which genSlot reserves in the block) gets the block’s data segment (or selector) from CBLK_DATA (MOV ES,CS:[CBLK_DATA], or PUSH CS:[CBLK_DATA] for a slot passed as a parameter; see genBlkData), rather than from CS.Freed selectors on the stack: freeAllBlocks preserved the caller’s ES, so freeing the block that ES held (eg, the code blocks, when a program ends) reloaded a freed selector; now it returns ES = 0 in that case, as DOS_MEM_FREE does, and so does compactStrs; genCode sets ES to the heap before freeing a program’s code (ES may hold a code block’s alias); and findErl (for ERL) looks for a return address in a code block by comparing each word on the stack with the blocks’ code segments, rather than loading it into ES.
Remaining:
A protected-mode segment is either code (executable, and at most readable) or data (readable, and perhaps writable), so memory that’s both written and executed needs two selectors. CPU_ALIAS (tested by CPUTEST) creates the second one: given a selector from CPU_ALLOC, CPU_MAP, or CPU_ALIAS, it returns a new selector for the same base and limit, with the access rights selected by MEMF_CODE, MEMF_RDONLY, and MEMF_USER; CPU_UNMAP frees it.
A block’s own code alias (CPU_ALIAS with just MEMF_CODE, for the selector that CPU_ALLOC returned for the block) is special: the 80286 driver creates it only the first time, keeps it in the block’s PMB (PMB_CODE), returns the same selector every time after that, and frees it with the block (and when CPU_REALLOC shrinks a block, the alias stays with the block, not the freed end). So nothing else has to keep track of the pair, and it must never be freed with CPU_UNMAP. The kernel returns it for any memory block with DOS_UTL_ALIAS (27h; see Utility Functions), since ring 3 code won’t be able to call DDINT_CPU; in real mode, a block’s code alias is its segment.
codeSeg returns it wherever code transfers into a block: genCode’s call, the far JMP that ensureRoom generates from one block to the next, emitJmp’s far JMPs (eg, to a line in another block), genFuncExpr’s calls to user-defined functions, and doChain’s jump. Return addresses and GOSUB frames are code selectors already, since the CPU pushes CS.data_seg). A program that sets a vector with DS:DX, as COM programs often do, would give it a data selector, so when DS is the program’s PSP block, DOS_MSC_SETVEC uses the block’s code alias instead (see mem_alias), which in real mode is the same segment. An EXE file’s relocations are paragraph arithmetic, so in protected mode they can only work if each 64K of the image has its own selector (as with CPU_MAP’s consecutive selectors) and every relocated segment is a multiple of 64K from the start of the image, which isn’t generally true; MZ EXE files would run only when the system is in real mode (or in virtual-8086 mode on an 80386).DEF SEG accepts any segment, which in protected mode isn’t a selector, so it’s mapped (done):
--ram=2M in pc.js, and CPUTEST.COM (in TESTS\MISC on test disks), which prints the arena and checks every function.
Copyright (c) 2020-2026 Jeff Parsons Released under MIT License