EXEC is how one program runs another; the shell starts every program this way. DOS reads the file into free memory (a .COM as it is, an .EXE relocated as its header says), builds a PSP for it, and jumps to it. The INT 21h returns to the parent when the child exits, and INT 21h AH=4Dh then tells how it ended.

The name is a complete file name with its extension. EXEC does not look through PATH, does not try .COM and .EXE, and does not run batch files or built-in commands such as DIR or ECHO: those belong to COMMAND.COM. To run a command line, run the shell with /C and the command, which is what the example does.

The parameter block (AL = 0)

OffsetSizeContents
00hwordthe environment segment for the child; 0 = a copy of the parent's
02hdwordthe command tail: offset, then segment
06hdwordan FCB, copied to the child's PSP:5Ch
0Ahdwordan FCB, copied to PSP:6Ch

The command tail is what the child finds at PSP:80h: a count byte, the text, and a carriage return (0Dh) that is not counted. The shell passes it with the space after the program's name, so COMMAND /C ECHO hi gets 11, " /C ECHO hi", 13; the text can be up to 126 characters. The FCBs matter only to old programs that take file names from them; pointing both at the parent's own PSP:5Ch and 6Ch is the usual choice.

Memory and registers

The child is loaded into free memory, so the parent must first give back what it does not need with INT 21h AH=4Ah. A .COM that has not shrunk gets error 8.

DOS 2 returned from EXEC with every register lost except CS and IP, so programs keep SS:SP in a variable they can reach through CS and restore it first. Later versions keep the stack, but the habit is cheap. Restore DS and ES too, with MOV, which leaves CF alone for the JC that checks for failure.

prints: hello from a child / back in the parent: ok
MOV AH, 4Ah ; DOS: shrink our block (ES = our PSP)
MOV BX, 1000h ; to 64 KB: the .COM and its stack
INT 21h
MOV TAILP1+2, CS ; the segments of the block's pointers
MOV FCB1P+2, CS
MOV FCB2P+2, CS
MOV SAVESP1, SP ; DOS 2 lost SS:SP over an EXEC
MOV AX, 4B00h ; DOS: EXEC, AL = 0: load and run
MOV DX, OFFSET PROG1 ; DS:DX = the program
MOV BX, OFFSET ENV1 ; ES:BX = the parameter block
INT 21h
MOV BX, CS ; MOV leaves CF alone: segments back first
MOV SS, BX
MOV SP, CS:SAVESP1
MOV DS, BX
MOV ES, BX
PUSHF ; keep CF while printing
MOV DX, OFFSET BACKM1
MOV AH, 09h
INT 21h
POPF
CALL RESULT ; ok, or the error code
PROG1 DB "Z:\COMMAND.COM", 0
TAIL1 DB 27, " /C ECHO hello from a child", 13 ; 27 characters, CR not counted
ENV1 DW 0 ; parameter block: environment, 0 = a copy of ours
TAILP1 DW OFFSET TAIL1, 0 ; the command tail, offset and segment
FCB1P DW 5Ch, 0 ; FCB 1: the one in our PSP
FCB2P DW 6Ch, 0 ; FCB 2
SAVESP1 DW 0
BACKM1 DB "back in the parent: $"
RESULT PROC NEAR ; print "ok", or "error" and AX when CF = 1
PUSHF ; keeps AX and CF for the caller
PUSH AX
MOV DX, OFFSET OKMSG
JNC RESULT1
MOV DX, OFFSET ERRMSG
MOV AH, 09h
INT 21h
POP AX
PUSH AX
CALL PDEC ; the error code
MOV DX, OFFSET CRLF
RESULT1:
MOV AH, 09h
INT 21h
POP AX
POPF
RET
RESULT ENDP
OKMSG DB "ok", 13, 10, "$"
ERRMSG DB "error $"
PDEC PROC NEAR ; print AX in decimal (changes AX BX CX DX)
MOV BX, 10
XOR CX, CX ; digits on the stack
PDEC1:
XOR DX, DX
DIV BX ; AX = AX / 10, DX = the last digit
PUSH DX
INC CX
OR AX, AX
JNZ PDEC1
PDEC2:
POP DX ; the digits come back first to last
ADD DL, "0"
MOV AH, 02h
INT 21h
LOOP PDEC2
RET
PDEC ENDP
CRLF DB 13, 10, "$"

COMSPEC

The shell is not always C:\COMMAND.COM. Its full name is in the environment variable COMSPEC; in DOSBox-X, whose shell lives on the virtual drive Z:, that is Z:\COMMAND.COM. A program that needs a shell looks it up in its environment, at the segment stored at PSP:2Ch: a list of NAME=value strings, each ending with a 0 byte, and an empty string after the last:

prints: Z:\COMMAND.COM
MOV BX, 2Ch ; PSP:2Ch = the environment's segment
MOV ES, [BX]
XOR DI, DI ; ES:DI = the first NAME=value
CLD
FIND1:
CMP BYTE PTR ES:[DI], 0 ; an empty string ends the list
JE NONE1
MOV SI, OFFSET KEY1 ; "COMSPEC="
MOV CX, 8
REPE CMPSB ; DS:SI against ES:DI
JE FOUND1
DEC DI ; back to the byte that differed
MOV AL, 0 ; and on past the end of this string
MOV CX, 0FFFFh
REPNE SCASB
JMP FIND1
FOUND1:
MOV DL, ES:[DI] ; print the value, up to its 0
OR DL, DL
JZ DONE1
MOV AH, 02h
INT 21h
INC DI
JMP FOUND1
NONE1:
MOV DX, OFFSET NOKEY1
MOV AH, 09h
INT 21h
DONE1:
MOV DX, OFFSET CRLF
MOV AH, 09h
INT 21h
PUSH CS
POP ES
KEY1 DB "COMSPEC="
NOKEY1 DB "no COMSPEC$"
CRLF DB 13, 10, "$"

AL = 1 loads a program without running it and returns its entry point and stack in the parameter block, which is how a debugger starts; AL = 3 loads an overlay into memory the parent already allocated, without a PSP.