Ralf Brown's Interrupt List describes a subvariant of the DOS int21/4Bh function with AL=01, where the program is loaded into memory and a PSP + stack are allocated for it, but the program is not executed. The CS:IP and SS:IP are placed in the parameter block structure that was provided as input in ES:BX.

This works, but what do I need to do in order to execute the program and make sure it returns to the point where I called it from (I am doing this from C, FWIW)? I imagine I can't just cast CS:IP to a void (far *func)(void) and call it a day?

#pragma pack(1)
struct {
    uint16 envSegment;
    uint16 cmdlineOffset;
    uint16 cmdlineSegment;
    uint16 fcb1Offset;
    uint16 fcb1Segment;
    uint16 fcb2Offset;
    uint16 fcb2Segment;
    uint16 sp;
    uint16 ss;
    uint16 ip;
    uint16 cs;
} exeLoadParams;

static int loadprog(const char* file, const char far* cmdline) {
    union REGS rin, rout;
    rin.h.ah = 0x4B;
    rin.h.al = 0x1;
    // DS will be data segment of current executable, no need to provide with intdosx()?
    rin.x.dx = (unsigned int)file;

    exeLoadParams.envSegment = 0;
    exeLoadParams.cmdlineOffset = FP_OFF(cmdline);
    exeLoadParams.cmdlineSegment = FP_SEG(cmdline);
    // the two FCBs are located at offsets 0x5c and 0x6c in this program's PSP
    exeLoadParams.fcb1Offset = 0x5c;
    exeLoadParams.fcb1Segment = _psp;
    exeLoadParams.fcb2Offset = 0x6c;
    exeLoadParams.fcb2Segment = _psp;
    // ES == DS guaranteed? it seems to work
    rin.x.bx = (unsigned int)&exeLoadParams;

    intdos(&rin, &rout);
    // exeLoadParams now contains CS:IP and SS:SP, but what next?

Also, what kind of cleanup do I need to do once the program returns? I tried freeing the block whose address I calculated as CS - PSP_SIZE and it seems to have freed most of the memory, but not all of it - in this particular case, 9 paragraphs worth of it were "lost".

  • 5
    9 paragraphs, that wouldn’t happen to match the size of your environment, would it? Dec 1, 2022 at 16:11
  • Also, from faint memory, wouldn't it be necessary to free all blocks assigned (Haven't done that kind of stuff since the mid 1990s).
    – Raffzahn
    Dec 1, 2022 at 16:31
  • 2
    CS - PSP_SIZE is not a good way to determine the main segment of the loaded executable image; with MZ images, there is no guarantee that the entry point will be in the PSP’s segment. As noted in the RBIL, invoking the ‘exec’ syscall in this mode will switch the current PID (i.e. PSP segment) to the target process, so all you need to do is read the current PID and free that (and all other segments associated with that process). And of course, switch the current PID back to the parent’s. Dec 1, 2022 at 16:49
  • 1
    @user3840170 I wonder whether the exit path isn’t different — this was intended for use by debuggers, so keeping the memory after execution would be useful too. I imagine ecm would know. Dec 1, 2022 at 18:10
  • 2
    @StephenKitt I would in fact! I'm happy to be recognised =) But the "exit path" is actually the same as for plain 21.4B00. When the debugger regains control at the PRA it wrote to the child PSP's TPIV field then the child memory is already freed and possibly corrupted, except of course for 21.31 TSR returns.
    – ecm
    Dec 1, 2022 at 20:40

1 Answer 1


Like user3840170 commented, the 21.4B01 call will change the current PSP to the newly created process. However, you should not free its memory on your own. Even if you change the current PSP back to another process (such as your own), you may still leak resources. The most obvious example is file handles, including in the System File Tables which are shared among all processes in the system. In some use cases, such as using redirection like > NUL, you may leak SFT entries entirely for the remaining uptime of the system. (The default file handles such as stdin/stdout/stderr will usually refer to a shared CON entry in the SFTs so not as bad then.)

(Writing of which, if you use service 21.31 for TSR you should probably free all file handles and the environment memory block too before calling the TSR termination, unless you know specifically that you want to use them afterwards. If your TSR never switches the system's current PSP to its own then you do not need these handles left over opened.)

Here's some example code from the FreeDOS Debug descendant lDebug: https://hg.pushbx.org/ecm/ldebug/file/7bd51f7b763f/source/ll.asm#l362 I maintain this project, though I do not believe that I modified this particular part much in my fork.

                ; Actual program loading.  Use the DOS interrupt.
        mov ax, 4B01h           ; load program
        mov dx, DTA             ; offset of file to load
        mov bx, execblk         ; parameter block
        int 21h                 ; load it
        jc ll16                 ; if error

If this call returns an error then it will not have changed to a new process.

        mov ax, sp
        sub ax, [SPSAV]
        cmp ax, 80h
        jb ll15                 ; if in range
        mov ax, 80h
        mov word [spadjust], ax

It is crucial to understand that what is called SPSAV here (the dword [PSP:2Eh]) is set by DOS on most if not all interrupt 21h calls (certainly 21.4B00 and .4B01). The singular purpose of it is to point a DOS process termination (services 4Ch or 31h, or older variants, or Ctrl-C or Critical Error Abort) back to the parent process's stack when the control flow returns to the parent. To utilise it, DOS will read the SPSAV field in the parent's PSP.

Now the debugger plays games with this in https://hg.pushbx.org/ecm/ldebug/file/7bd51f7b763f/source/run.asm#l5372 because it doesn't want DOS, upon the debuggee's termination, to overwrite the stack used by the debugger while it is in a run command. This does mean none of the registers other than cs:ip and ss:sp have any known contents when the control flow returns to the debugger. (The flags should also be forced valid by DOS; the FreeDOS kernel used to have a bug about that. The flags should be NC and TF=0.) If DOS returns to a normal child process created by 21.4B00 then the DOS entry stack will still be valid and DOS will return the 16-bit registers to their prior state.

        les si, [execblk.sssp]
        es lodsw                ; recover ax
        mov word [reg_eax], ax
        mov word [reg_esp], si
        mov word [reg_ss], es
        les si, [execblk.csip]
        mov word [reg_eip], si
        mov word [reg_cs], es

This retrieves the official initial register values from the service's exec parameter block. They will be loaded into the actual registers by the debugger once a run command traces or goes to execute the debuggee.

        push ss
        pop es
        call getpsp
        xchg ax, bx             ; ax = PSP, clobber bx
        mov word [pspdbe], ax

This retrieves the current PSP and stores it away for the debugger to use, which is also used for the run commands to switch to that PSP again later.

        clropt [internalflags], attachedterm
        mov di, reg_ds
        stosw                   ; reg_es

This initialises the ds and es registers which are documented as pointing to the PSP upon process start (even if an MZ .exe program's cs and ss possibly do not point to the PSP).

        push ax
        call setpspdbg

This restores the debugger's PSP as the current one. To a lesser extent than the SPSAV handling, the debugger is playing games: It wants to run its own code in its process, but run the debuggee code in the newly created process. This is relevant for which std handles are used, and which process is used for any memory block allocations or resizes, or opening additional files.

                ; Finish up. Set termination address.
        mov ax, 2522h           ; set interrupt vector 22h
        mov dx, int22           ; ds => lDEBUG_DATA_ENTRY
        int 21h
        pop ds
        mov word [TPIV], dx
        mov word [TPIV+2], ss   ; => lDEBUG_DATA_ENTRY
        push ss
        pop ds

This is the most important part: Modify the Parent Return Address of the newly created process. This is also known as interrupt 22h or the TPIV = Terminate Program Interrupt Vector. What's important to understand is that it is pure formality to call it an interrupt vector, and the 21.2522 call here is in fact not needed. It just happens to be the case that DOS stores the PRA just before the saved original int 23h and int 24h handlers in a child PSP. There is never an actual call to interrupt 22h (int 22h instruction). DOS simply finds the cs:ip at which to return to the parent by reading the PRA field in the child process that is being terminated. It will insert this return address into the cs:ip field of the stack frame it creates to return to the parent process. (As mentioned, this stack frame's position is found from the SPSAV field of the parent process.)

That means usually the PRA is initialised to the very next address behind the int 21h instruction that created the child process using function 4B00h or 4B01h. For 4B00h, the PRA is usually not modified and DOS will return there only once the child process terminates. At termination it will read the PRA from the child PSP to remember where to return to. It will set up the stack frame, force its cs:ip to the PRA, and set the flags to a valid value. This has to give NC (Carry Flag zero) in order to indicate to the parent process that the exec call succeeded.

With the debugger load and do not execute function 4B01h, DOS will actually return twice to the same address if not otherwise instructed. There is no standard way to detect whether a return to that address is after the service returned having created the child process, or after the termination of the child process made DOS go back here.

So the debugger plays more games: It sets up the entrypoint here called int22 as the new PRA, as a 16:16 segmented far address. (lDebug has a code segment that is at a different address than its data/entry segment.) Then the debugger can be certain that if it is entered in its "interrupt 22h" handler then this is actually the return from the process termination.

This answer would not be complete without mentioning that you can do more than this if you go to lengths to modify the PRA and parent process fields of processes: You can return to a different process than your original parent. In fact, the debugger and the DOS shell set themselves up as their own parent processes. DOS knows to check for this and will skip freeing memory and file handles if a self-parented process terminates. (Whether by 21.4C or Ctrl-C or Critical Error Abort. 21.31 never frees these resources even when normally returning to a different parent process.) It is important to modify not only the parent field in the PSP but also the PRA so our code gains control again after the termination. Also, the SPSAV field in "the parent" is used again for finding the stack frame to use.

Another bit is function 21.4B05 which I believe should actually be called by the debugger to set up the genuine exec state for debuggees. We didn't have a use case for it yet, though. In particular, I think function 21.4B00 usually disables the A20 gate upon execution of a program, which 21.4B01 might not do. A20 off is needed for some misbehaved programs that assume an address space wrap at 1 MiB.

There is also the fact that the old style termination functions, interrupts 20h, 27h, and 21h function 00h, may actually use the caller's cs instead of the current PSP to determine what process is terminating. (FreeDOS does not replicate this I think.) I believe the free software sources of MS-DOS version 2 contain some of these differences, if not all of them. The Microsoft Debug program relies on this oddity for its proper operation.

So, briefly: DOS creates a new process. Modify your process's SPSAV and the child process's PRA accordingly to return the control flow to your program upon termination of the child. Then simply set up the stack, and ds = es = PSP, of the child program and do a far branch (far jump or retf or iret) to the child program's entrypoint, as indicated in the fields written by DOS in the exec parameter block. Everything will work out as intended when and if the child process terminates.

  • 2
    Thank you. I guess I will need to implement this in assembly, since I don't think I can change the stack registers from C? Or maybe I could make this work with setjmp()... Dec 1, 2022 at 21:36
  • 2
    When diving that deep into DOS, it may be worth noting that 4B01 was, AFAIR considered an internal function.
    – Raffzahn
    Dec 2, 2022 at 3:27
  • 2
    @Raffzahn yes, it was only documented with the release of MS-DOS 5.0. Dec 2, 2022 at 8:40

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .