assemblyinterruptx86-16dosretro-computing

Replacing int 21h vector in DOS


I'm trying to replace the int 21h vector with a custom routine in MS-DOS. This method works for the timer tick interrupt (1Ch), but for some reason hangs after around 20 calls to 21h.

My code is currently (WASM style):

.model tiny
.code
    org 100h

start:
    jmp install

; -----------------------------------------------------------------------------
; RESIDENT PART
; -----------------------------------------------------------------------------

old_int21_vector    dd 0

new_int21_handler:
    pushf
    call dword ptr cs:old_int21_vector ; Let DOS do its thang.
return_point:
    call print_star
    iret

print_star:
    push ax
    push bx
    mov ah, 0Eh
    mov al, '*'
    mov bx, 0
    int 10h
    pop bx
    pop ax
    ret

resident_end label byte

; -----------------------------------------------------------------------------
; INSTALLATION PART
; -----------------------------------------------------------------------------
install:
    ; Get the old vector
    mov ax, 3521h
    int 21h
    mov word ptr old_int21_vector[0], bx
    mov word ptr old_int21_vector[2], es

    ; Install the new vector
    mov dx, offset new_int21_handler
    mov ax, 2521h
    int 21h

    ; Leave the program resident
    mov ax, 3100h
    mov dx, offset resident_end
    add dx, 15
    mov cl, 4
    shr dx, cl
    int 21h

end start

And at the DOS prompt, I get:

C:\PROJECTS\TEST>test
************************
***C:\PROJECTS\TEST**_

where "_" is the flashing cursor.

Removing the print_star function has no impact. I've tried manually rebuilding the stack pointer to where I think it should return, but I think there's something I'm missing. Is there something special that needs to be done for this interrupt?

Note: the code is very minimal right now, but eventually the plan is to check which function is being called and only respond on certain functions. Specifically, I wanted to hook the call to change directory.


Solution

  • You have to pass the current flags (especially Zero Flag and Carry Flag) after the call to your downlink back to your caller. This is because many DOS functions return a meaningful status in the Carry Flag and also some functions use the Zero Flag as well. Your iret uses the original caller's flags on the stack, so you should modify the fl word in your iret frame on the stack to pass along the DOS call's returned flags.

    At return_point try:

        push bp
        mov bp, sp
        push ax
        lahf
        mov byte ptr [bp + 6], ah
        pop ax
        pop bp
    

    After this your iret should pop the correct flags.


    Other than that, calls with input ah of 0, 26h, 4Bh, 4Ch, 31h may need to be special cased, you should detect them and pass these through to your downlink with a far jump rather than pushf + far call.

    Functions 0 and 26h use the caller's CS as an input (at least on original MS-DOS), while 31h, 4Ch, and 4Bh use the stack in special ways for process termination handling. This is also why dosemu2 passes these functions like a far jump rather than a pushf + far call.


    Here's a working example. I uploaded an earlier version to our server on https://pushbx.org/ecm/test/20250729 but I modified it now to pass along the original flags when chaining to the downlink and in the live flags when calling the downlink. This is better because some DOS calls are known to return Carry Flag unchanged (eg LFN functions 71h, Extended Open/Create function 6Ch, if not supported).

    Update: I added some code which I would recommend to include in any TSR that uses int 21h function 31h. The first bit is about freeing the program's environment block. The second is to close all file handles, as you will leak System File Table (SFT) entries if someone runs your program with redirection, such as test.com > nul. Of course, both the environment and the file handles could be used by a resident program, in which case you want to retain them. But the vast majority of TSRs do not use either after the TSR terminate, so they should be released.

    .model tiny
    .code
        org 100h
    
    start:
        jmp install
    
    ; -----------------------------------------------------------------------------
    ; RESIDENT PART
    ; -----------------------------------------------------------------------------
    
    old_int21_vector    dd 0
    
    new_int21_handler:
        pushf
        cmp ah, 0
        je chain_popf
        cmp ah, 26h
        je chain_popf
        cmp ah, 31h
        je chain_popf
        cmp ah, 4Bh
        je chain_popf
        cmp ah, 4Ch
        je chain_popf
        popf
        pushf
        call dword ptr cs:old_int21_vector ; Let DOS do its thang.
    return_point:
        push bp
        mov bp, sp
        push ax
        lahf
        mov byte ptr [bp + 6], ah
        pop ax
        pop bp
        call print_star
        iret
    
    chain_popf:
        popf
        jmp dword ptr cs:old_int21_vector
    
    print_star:
        push ax
        push bx
        mov ah, 0Eh
        mov al, '*'
        mov bx, 0
        int 10h
        pop bx
        pop ax
        ret
    
    resident_end label byte
    
    ; -----------------------------------------------------------------------------
    ; INSTALLATION PART
    ; -----------------------------------------------------------------------------
    install:
        ; Get the old vector
        mov ax, 3521h
        int 21h
        mov word ptr old_int21_vector[0], bx
        mov word ptr old_int21_vector[2], es
    
        ; Install the new vector
        mov dx, offset new_int21_handler
        mov ax, 2521h
        int 21h
    
        xor ax, ax
        xchg ax, word ptr ds:[2Ch]
        mov es, ax
        mov ah, 49h
        int 21h
        xor bx, bx
        mov cx, word ptr ds:[32h]
    @@:
        mov ah, 3Eh
        int 21h
        inc bx
        loop @B
    
        ; Leave the program resident
        mov ax, 3100h
        mov dx, offset resident_end
        add dx, 15
        mov cl, 4
        shr dx, cl
        int 21h
    
    end start
    

    Builds like this:

    $ jwasm test.asm
    $ warplink.sh /mx test.o,test.exe,test.map;
    $ x2b2.sh test.exe test.com