Terminate and Stay Resident Programming

See below for an example of TSR programming. This one turns off the internal speaker.
See below for an example of TSR programming. This one sends the current DOS screen to the Windows Clipboard.
See below for an example of TSR programming to hook INT 21h.

Terminate and Stay Resident (TSR) programming is one of the most interesting services provided by MS-DOS. (Norton's Programming Bible)

The following code runs a simple routine. It installs the TSR, invokes the TSR service (which removes the installation code form memory but not the actual TSR code). Then when installed, it hooks into the keyboard interrupt and looks to see if the Ctrl-Del sequence was entered. If it wasn't, it sends control to the keyboard interrupt and allows it and DOS to function normally. If the Ctrl-Del sequence has been entered, then it prints an 'A' on the screen and jumps to the previous interrupt.

This DOS service restores the interrupt vectors for 22h, 23h, and 24h to the defaults, so changing their vector interrupt address' will not do you any good.

When using the TSR service, you should release the memory of the environment block that your installation program has.

Also, this DOS service does not work in DOS 1.0


Now for the hard part. DOS is not reentrant. This means that you can not call a DOS service while DOS is currently running a DOS service. If the user presses a key, the hardware sets and runs the code pointed to be the INT 09h vector instantly, whether in DOS or not. If DOS is running, and you call a DOS service, you will most likely crash the machine.

So, how do we write our code so that it doesn't crash the machine? DOS has left us a few things that we can do to make our TSR writing and running a lot easier. The first is the INDOS flag. It is exactly what it sounds like. Are we INDOS or not. DOS will set this flag to non-zero if it is currently running a DOS service. This flag is located in low memory (see code below), and we have to get the address of this flag and save it in the TSR code for later use.

Also, DOS has given us INT 28h, or the IDLE INTERRUPT. This interrupt is called when DOS has nothing to do. So if we hook this interrupt and call our interrupt handler code from this interrupt, we can pretty much assume that DOS is not busy, and we can do our code without crashing the machine.

But what if DOS is busy? How do we wait for DOS to be done and then run our interrupt handler? Well, this is where another interrupt handler comes into play. Interrupt 08h, the timer interrupt. We can place a variable in the timer interrupt to decrement each time this interrupt is called. Now we use interrupt 28h (described above), to see if we are waiting to call our interrupt code. In the interrupt 28h handler, we check to see if our wait variable is above zero. If so, we set the wait variable to zero (so that we don't try to call it again), and call our interrupt vector. If we actually decrement the variable to zero without calling our interrupt vector, this means that DOS is probably hung, and your interrupt vector will not be called. This usually does not happen, if we have a decent delay time. A value of 9 is usually quite enough time for DOS to get done.

So, how do we safely hook and call these interrupts? The safest thing to do is to call the old interrupt handler either before our code (most likely the better case), or after our code. What if we didn't call the previously loaded interrupt handler, and this handler set all the BIOS data for us? The key would not get into the BIOS buffer, the scan code would not be created, and other things like this. So we always call the previously loaded interrupt handler.

Another thing that we have to worry about. What if the user presses a key, which in turn calls our interrupt handler, while we are currently processing our interrupt handler? This could be bad. The simplest thing for us to do is to call the previously loaded interrupt handler, and forget that the keypress was ever made. However, if you needed this keypress badly, you could create a handler to save it on a stack, run the interrupt handler, and then see if a there are any keys on this stack. However, this is not likely with a regular DOS TSR. A system program, now that's a different story.

How about another thing to worry about. When we first load our TSR into memory, how do we know if we have previously loaded our TSR? We don't want it loaded, and do the process twice. This is actually quite simple. Place a known string of characters at a known offset in your interrupt handler. Then when you load the TSR, first check to see if this string of characters is at this location in the current interrupt handler. If it is, exit with an error.

Another item to talk about is the stack. You don't know where or how much stack there is when your interrupt handler gets called. You can pretty much assume that there will be about a paragraph of stack free for your use. However, a paragraph can only hold eight (8) 16-bit values. So we can create our own stack to practically whatever we want to use. However, before we create this stack, we must save in memory all registers used. You cannot modify ANY registers without saving and restoring them.

We also need to wait for the break code of the key press instead of the make code. If we caught the make code, we would call our interrupt handler repeatedly until the key was released. This is not what we want to do. The break code is actually the make code with bit 7 set.

Let us get to the code.
;
;  assembled with NBASM
;
Delay      equ 09     ; delay while INDOS != 0

.model tiny
.code
.186
           org 100h

start:     jmp  Init        ; at first load up, just to initialization code

ourint09:  jmp  DoIt        ; our actual interrupt handler starts here.

OurID      db  'FY'         ; our id, to make sure we don't load twice
OurKey     db  00h          ; this byte saves the key press for our handler
oldint09O  dw  00h          ; this dword points to old interrupt vector
oldint09S  dw  00h          ;
SavedAX    dw  00h          ; temp word to store ax before our stack is setup
SavedDX    dw  00h          ;   ditto for dx
SavedES    dw  00h          ;   ditto for es
SavedSP    dw  00h          ; saved SP
SavedSS    dw  00h          ; saved SS
OurStack   dup 126,0        ; Our Stack (64 word stack)
TopofStack dw  00h          ; top of stack

; this is our interrupt code for hardware int 09h
DoIt:      pushf                        ; save flags
           mov  cs:SavedAX,ax           ; save ax (not on stack)
           in   al,60h                  ; get data from keyboard

           cmp  byte cs:tsraktiv,0      ; are we INDOS
           jne  short NotOurs           ;

           cmp  byte cs:tsrnow,0        ; can we run the tsr now
           jne  short NotOurs           ;

           cmp  al,(53h|80h)            ; 53h (set bit 7 for break code)
           je   short IsOurs
NotOurs:   mov  ax,cs:SavedAX           ; restore ax (not from stack)
           popf                         ; restore flags
           jmp  far cs:oldint09O        ; jump to old interrupt handler

IsOurs:    mov  cs:SavedDX,dx           ; save dx (not on stack)
           mov  cs:SavedES,es           ; save es (not on stack)
           xor  dx,dx
           mov  es,dx
           test byte es:[0417h],00000100b  ; was CTRL key pressed
           mov  es,cs:SavedES           ; restore es (not from stack)
           mov  dx,cs:SavedDX           ; restore dx (not from stack)
           jz   short NotOurs

           mov  cs:OurKey,al            ; we need to save the key that got us here

           call dosaktiv                ; are we INDOS?
           je   short i9_tsr

W4BIOS:    mov  byte cs:tsrnow,Delay    ; delay timer ticks to wait for INDOS
           jmp  short NotOurs

i9_tsr:    mov  byte cs:tsraktiv,1      ; set flag so that we don't reenter our code
           mov  byte cs:tsrnow,0        ; and clear the delay flag
           pushf                        ; simulate an INT call to previous handler
           call far cs:oldint09O        ;  (by pushing the flags and doing a FAR CALL)
           call DoOurInt09              ; then do our code
           popf                         ; restore flags
           iret                         ;


; here is where we do our TSR code because HOTKEY was found
DoOurInt09 proc near

; we need to create our own stack so that we don't modify the parent stack
           cli                          ; temp stop interrupts
           mov  cs:SavedAX,ax           ; save ax
           mov  cs:SavedSS,ss           ; save ss:sp
           mov  cs:SavedSP,sp           ;
           mov  ax,cs                   ; create new stack
           mov  ss,ax                   ;
           mov  sp,offset cs:TopofStack ;
           mov  ax,cs:SavedAX           ; restore ax
           sti                          ; restore interrupts

           pusha                        ; save registers used (on our stack)
           push ds                      ;
           push es                      ;

           push cs                      ; make es = ds = cs
           push cs                      ;
           pop  ds                      ;
           pop  es                      ;

           mov  al,OurKey               ; get key entered



;  this is where you do your code.  I have found that if you are careful,
;   and write good clean code, you can do just about anything you want
;   within reason.



; exit code (done with TSR code, so exit back to parent program)
NoErrExit: pop  es                      ; restore all registers we used
           pop  ds                      ;
           popa                         ;

           cli                          ; temp stop interrupts
           mov  sp,cs:SavedSP           ; restore org stack
           mov  ss,cs:SavedSS           ;
           sti                          ; restore interrupts

           mov  byte cs:tsraktiv,0      ; clear flag so that we can do 
                                        ;   this again, next time
           ret
DoOurInt09 endp


tsrnow     db  00h   ; the delay flag
tsraktiv   db  00h   ; the 'are we in here' flag
oldint08O  dw  00h   ; this dword points to old interrupt vector
oldint08S  dw  00h

OurInt08   proc near

           cmp  byte cs:tsrnow,0        ; don't try to decrement it 
           je   short i8_end            ;   if it is already zero!

           dec  byte cs:tsrnow          ; else do the delay

           call dosaktiv                ; check to see if INDOS
           je   short i8_tsr            ;  if we are not, do our interrupt

i8_end:    jmp  far cs:oldint08O        ; else just do the previous handler

i8_tsr:    mov  byte cs:tsrnow,0        ; clear delay flag
           mov  byte cs:tsraktiv,1      ; set 'in here' flag
           pushf                        ; simulate an INT
           call far cs:oldint08O        ; using INT 08H emulation
           call DoOurInt09              ; then do our handler
           iret
OurInt08   endp

oldint28O  dw  00h   ; this dword points to old interrupt vector
oldint28S  dw  00h

OurInt28   proc near
									
           cmp  byte cs:tsrnow,0        ; Is TSR waiting for activation?
           jne  short i28_tsr           ; No --> Return to caller

i28_end:   jmp  far cs:oldint28O        ; Return to old handler

i28_tsr:   mov  byte cs:tsrnow,00       ; TSR no longer waiting for activation
           mov  byte cs:tsraktiv,1      ; TSR is (already) active

           pushf                        ; Call old interrupt handler
           call far cs:oldint28O        ; using INT 28H emulation
           call DoOurInt09
           iret                         ; Return to caller

OurInt28   endp

InDOSfoff  dw  00h
InDOSfseg  dw  00h

dosaktiv   proc near uses ax bx es      ; this is the 'get INDOS flag' code
           mov  bx,cs:InDOSfoff         ; we loaded these two words 
           mov  ax,cs:InDOSfseg         ;  with the address at startup
           mov  es,ax
           cmp  byte es:[bx],00
           ret
dosaktiv   endp


; this is the initialization procedure.  Once we are done with this
;  and load the TSR, this part is not needed and removed from memory.
;
Init:      push cs                      ; make sure ds=cs
           pop  ds                      ;

           mov  ax,3508h                ; get old interrupt vector (08h)
           int  21h                     ;
           mov  [oldint08O],bx          ; and save in static data area
           mov  [oldint08S],es          ;

           mov  ax,3528h                ; get old interrupt vector (08h)
           int  21h                     ;
           mov  [oldint28O],bx          ; and save in static data area
           mov  [oldint28S],es          ;

           mov  ax,3509h                ; get old interrupt vector (09h)
           int  21h                     ;
           mov  [oldint09O],bx          ; and save in static data area
           mov  [oldint09S],es          ;

           mov  ax,es:[bx+03]           ; Check to see if already loaded
           cmp  ax,'YF'                 ; is it 'FY'?
           jne  short OKtoLoad
           mov  dx,offset AlreadyS
           mov  ah,09
           int  21h
           .exit 01

OKtoLoad:  push cs                      ; set es = cs
           pop  es

           mov  ah,34h                  ; get indos flag address
           int  21h                     ;  returns address in es:bx
           mov  InDOSfoff,bx            ; save for our interrupt handler
           mov  InDOSfseg,es            ;


; you can do other initialization code here.
; maybe check the command line for parameters, etc.



           mov  es,[002Ch]              ; free environment block
           mov  ah,49h                  ;
           int  21h                     ;

           mov  dx,offset OurInt08      ; set new interrupt vector (08h)
           mov  ax,2508h                ;
           int  21h                     ;

           mov  dx,offset ourint09      ; set new interrupt vector (09h)
           mov  ax,2509h                ;
           int  21h                     ;

           mov  dx,offset OurInt28      ; set new interrupt vector (28h)
           mov  ax,2528h                ;
           int  21h                     ;

           mov  dx,offset Init          ; get paragraphs needed
           sub  dx,offset Start         ;
           shr  dx,04                   ; 
           add  dx,17                   ; add 16 paras for PSP + 1 extra
           mov  ax,3100h                ; terminate and stay resident
           int  21h                     ;  (00h ERRORLEVEL)


AlreadyS  db 13,10,'Already loaded',13,10,36

.end start


Again, be careful with this code if you do any modifications, or the such, you could crash your system and have to reboot.

If you have any other questions or see a mistake that I made, please e-mail me and I will do my best to help out.

Another TSR demo that turns off the PC internal speaker (on older machines)

The following code creates a 292 byte .COM file, and when installed will take up only 464 bytes in memory.

When run for the first time, this code "hooks" into two interrupts. Interrupt 16h (the keyboard interrupt) and Interrupt 1Ch (the timer interrupt). The reason why we hook into the keyboard interrupt is that it is an easy way to check for our TSR in memory.

What we do is invoke INT 16h with a sub-service of 66h and a job number (0 or 1) in AL. (Service 66h is not included with the BIOS keyboard interrupt service, it is one we install the first time NOSOUND is run). If a 66h is returned in AL then we have already installed NOSOUND.

If AL does not = 66h, then we must install NOSOUND. We first "hook" into the timer interrupt (1Ch). We then "hook" in to the keyboard interrupt (16h). We then install the TSR. Now once the TSR is installed, the hook to INT 1Ch will check the "job" flag that we have set and either "stop" the speaker if it is zero. When the INT 16h, service 66h (our service) is called, it checks for installation; and if it is installed it returns 66h in AL.

Since we hooked into INT 16h, we can call it every time NOSOUND is run to check for installation. This way we won't install more than one TSR at a time.

This code is for learning purposes only. Please see the above code to make this a correctly written TSR. Typing 'NOSOUND off' turns sound off, 'NOSOUND on' turns sound on.
;
;  assembled with NBASM

.model tiny
.code
           org 100h
start:     jmp install

Copyright  db  13,10,'NOSOUND  Quiets the hardware speaker.'
           db  13,10,'Copyright  1984-2023  Forever Young Software',13,10,36
AllowY     db  13,10,'  Allowing Sound$'
AllowN     db  13,10,'  Not Allowing Any Sound$'
Old1CAdr   dw  00h   ; these remember the original Int 1Ch address
Old1CSeg   dw  00h   ; they must be in the code segment
Old16Adr   dw  00h   ; these remember the original Int 16h address
Old16Seg   dw  00h   ; they must be in the code segment
job        db  00h   ; 0 = sound off, else sound on
                
NewInt1Ch: pushf                 ; save flags
           cmp  byte cs:job,00h  ; if job != 0 then skip ours
           jnz  short SoundOn    ; 
           push dx               ;

;If you notice any delay between hitting <Enter> after your "NOSOUD off"
;command, it's because of the 18th of a second delay (maximum) before
;the speaker is actually turned off.

           mov  dx,61h        ; turn sound off
           in   al,dx         ;  .
           and  al,0FCh       ; clear bits 1 & 0
           out  dx,al         ;  .
           pop  dx            ;
SoundOn:   popf                         ; restore flags
           jmp  far cs:Old1CAdr

NewInt16h: pushf              ; save flags
           cmp  ah,66h        ; if our service number
           jne  short SkipOurs
           mov  cs:job,al     ; then put 'job' in job above
           mov  al,ah         ; send installed flag
           iret               ;
SkipOurs:  popf               ; restore flags
           jmp  far cs:Old16Adr ;

Install:   mov  dx,offset Copyright ; print message
           mov  ah,09h        ;
           int  21h           ;
           xor  al,al         ; assume no sound
           mov  dx,offset AllowN  ; print message
           mov  ah,[0083h]    ; get command line 'n' or 'f'
           cmp  ah,'n'        ; if 'n' then job != 0
           jne  short SoundOff1 ;
           mov  al,0FFh       ;
           mov  dx,offset AllowY  ; print message
SoundOff1: push ax            ; save al
           mov  ah,09h        ;
           int  21h           ;
           pop  ax            ; restore al
           mov  ah,66h        ; call interrupt 16h w/our service #
           int  16h           ; on return:
           cmp  al,66h        ; 
           jne  short NotInstld ; if al = 66h, then is installed
           .exit              ; and exit (no TSR it)

NotInstld: mov  es,[002Ch]    ; free environment block
           mov  ah,49h
           int  21h

           mov  ax,351Ch       ; ask DOS for existing Int 1Ch vector address
           int  21h            ; DOS returns the segment:address in ES:BX
           mov  Old1CAdr,bx    ; save it locally
           mov  Old1CSeg,es

           mov  ax,251Ch       ; point Interrupt 1Ch to our own handler
           mov  dx,offset NewInt1Ch
           int  21h
      
           mov  ax,3516h       ; ask DOS for existing Int 16h vector address
           int  21h            ; DOS returns the segment:address in ES:BX
           mov  Old16Adr,bx    ; save it locally
           mov  Old16Seg,es

           mov  ax,2516h       ; point Interrupt 16h to our own handler
           mov  dx,offset NewInt16h
           int  21h
        
           mov  dx,(install-start+256+15) ; save all TSR code + PSP
           mov  cl,04h         ;   + 15 bytes to make sure we get all of TSR
           shr  dx,cl          ; (paragraphs)
           mov  ax,3100h       ; exit to DOS but stay resident
           int  21h            ;

.end       start

Another TSR demo that sends the current DOS screen contents to the clipboard.

This TSR sends the characters stored in screen mode 3's memory (which are at physical address 0xB8000) to the Windows clipboard.

I created this because there were times that I wanted to send some info from a file I was viewing in DOS's EDIT to the Windows clipboard. (Sure I could have just pressed Alt-PrtScr but then I couldn't have created another TSR demo source for you.)

This TSR works fairly well on most machines. However, it does not like DOSKEY at all.

; This is a TSR that when activated and the CTRL-ALT-C
; key combination is pressed, it will send whatever text is on the
; screen (memory at 0B800h) to the Windows Clipboard. 
;
;  assembled with NBASM

Delay      equ 09     ; delay while INDOS != 0

.model tiny
.186
.code

start:     jmp  install

comment |
  We could put the Copyright Notice in the install part of the code
   so that when we are a TSR, it would make for a smaller memory
   allocation, but this way if someone is disassembling Int 09h,
   they see our copyright notice. |

ourint09:  jmp  short NewInt9S  ; skip data area

ID         db  'FY'      ; ID word to see if we are already here
Copyright  db  13,10,'DOS Screen to Windows Clipboard    v1.00'
           db  13,10,'Copyright  1984-2024  Forever Young Software'
           db  13,10,36
oldint09O  dw  00h          ; these remember the original Int 9 address
oldint09S  dw  00h          ; they must be in the code segment
SavedAX    dw  00h
SavedDX    dw  00h
SavedES    dw  00h
OurKey     db  00h

NewInt9S:  pushf                        ; save flags
           mov  cs:SavedAX,ax           ; save ax (not on stack)
           in   al,60h                  ; get data from keyboard

           cmp  byte cs:tsraktiv,0      ; are we INDOS
           jne  short NotOurs           ;

           cmp  byte cs:tsrnow,0        ; can we run the tsr now
           jne  short NotOurs           ;

           cmp  al,(2Eh|80h)            ; 2Eh (set bit 7 for break code)
           jne  short IsOurs
NotOurs:   mov  ax,cs:SavedAX           ; restore ax (not from stack)
           popf                         ; restore flags
           jmp  far cs:oldint09O        ; jump to old interrupt handler

IsOurs:    mov  cs:SavedDX,dx           ; save dx (not on stack)
           mov  cs:SavedES,es           ; save es (not on stack)
           xor  dx,dx
           mov  es,dx
           test byte es:[0417h],00000100b  ; was CTRL key pressed
           mov  es,cs:SavedES           ; restore es (not from stack)
           mov  dx,cs:SavedDX           ; restore dx (not from stack)
           jz   short NotOurs

           mov  cs:OurKey,al            ; we need to save the key that got us here

           call dosaktiv                ; are we INDOS?
           je   short i9_tsr

W4BIOS:    mov  byte cs:tsrnow,Delay    ; delay timer ticks to wait for INDOS
           jmp  short NotOurs

i9_tsr:    mov  byte cs:tsraktiv,1      ; set flag so we don't reenter our code
           mov  byte cs:tsrnow,0        ; and clear the delay flag
           pushf                        ; simulate an INT call to previous handler
           call far cs:oldint09O        ;  (by pushing flags & doing a FAR CALL)
           call DoOurInt09              ; then do our code
           popf                         ; restore flags
           iret                         ;


; here is where we do our TSR code because HOTKEY was found
DoOurInt09 proc near

           pusha                        ; save the registers we'll be using
           push es

           mov  ax,1700h                ; check clipboard for usage
           int  2Fh                     ;
           cmp  ax,1700h                ; if 1700h then error
           je   short NoErrExit

           mov  ax,1701h                ; open clipboard
           int  2Fh

           push ds
           push cs
           pop  es
           mov  cx,25
           xor  si,si
           mov  di,offset cs:Install
           mov  ax,0B800h
           mov  ds,ax
GetScrL1:  push cx
           mov  cl,80        ; we know that ch = 0 from mov cx,25 above
GetScrL2:  lodsw
           stosb
           loop GetScrL2
           mov  ax,0A0Dh                ; put a CRLF at end of line
           stosw
           pop  cx
           loop GetScrL1
           pop  ds

           mov  ax,1703h                ; put screen 03h into clipboard
           push cs
           pop  es
           mov  bx,offset cs:Install
           mov  dx,01h
           mov  cx,2050                 ; (25x80) + 25 CRLF's
           xor  si,si
           int  2Fh

           mov  ax,1708h                ; close clipboard
           int  2Fh

           ; print smiley face in upper right corner
           mov  ax,0B800h               ;
           mov  es,ax
           mov  bx,es:[009Eh]
           mov  word es:[009Eh],1701h   ; make print smiley white on blue

           ; pause for a few milliseconds
           mov  cx,06
           mov  dx,03DAh
delay_l1:  in   al,dx
           test al,08h
           jne  short delay_l1
delay_l2:  in   al,dx
           test al,08h
           je   short delay_l2
           loop delay_l1

           ; restore char that was originally there
           mov  es:[009Eh],bx

; exit code (done with TSR code, so exit back to parent program)
NoErrExit: pop  es                      ; restore all registers we used
           popa                         ;

           mov  byte cs:tsraktiv,0      ; clear flag so that we can 
                                        ;   do this again, next time
           ret
DoOurInt09 endp

tsrnow     db  00h   ; the delay flag
tsraktiv   db  00h   ; the 'are we in here' flag
oldint08O  dw  00h   ; this dword points to old interrupt vector
oldint08S  dw  00h

OurInt08   proc near

           cmp  byte cs:tsrnow,0        ; don't try to decrement it 
           je   short i8_end            ;  if it is already zero!

           dec  byte cs:tsrnow          ; else do the delay

           call dosaktiv                ; check to see if INDOS
           je   short i8_tsr            ;  if we are not, do our interrupt code

i8_end:    jmp  far cs:oldint08O        ; else just do the previous handler

i8_tsr:    mov  byte cs:tsrnow,0        ; clear delay flag
           mov  byte cs:tsraktiv,1      ; set 'in here' flag
           pushf                        ; simulate an INT
           call far cs:oldint08O        ; using INT 08H emulation
           call DoOurInt09              ; then do our handler
           iret
OurInt08   endp

oldint28O  dw  00h   ; this dword points to old interrupt vector
oldint28S  dw  00h

OurInt28   proc near
									
           cmp  byte cs:tsrnow,0        ; Is TSR waiting for activation?
           jne  short i28_tsr           ; No --> Return to caller

i28_end:   jmp  far cs:oldint28O        ; Return to old handler

i28_tsr:   mov  byte cs:tsrnow,00       ; TSR no longer waiting for activation
           mov  byte cs:tsraktiv,1      ; TSR is (already) active

           pushf                        ; Call old interrupt handler
           call far cs:oldint28O        ; using INT 28H emulation
           call DoOurInt09
           iret                         ; Return to caller

OurInt28   endp

InDOSfoff  dw  00h
InDOSfseg  dw  00h

dosaktiv   proc near uses ax bx es      ; this is the 'get INDOS flag' code
           mov  bx,cs:InDOSfoff         ; we loaded these two words
           mov  ax,cs:InDOSfseg         ;    with the address at startup
           mov  es,ax
           cmp  byte es:[bx],00
           ret
dosaktiv   endp


; This is the install part.  It gets wiped out by our buffer used above.
; After TSR'ed, we no longer need this code.
;
Install:   mov  dx,offset Copyright     ; print our copyright string
           mov  ah,09
           int  21h

           mov  ax,3508h                ; get old interrupt vector (08h)
           int  21h                     ;
           mov  [oldint08O],bx          ; and save in static data area
           mov  [oldint08S],es          ;

           mov  ax,3528h                ; get old interrupt vector (08h)
           int  21h                     ;
           mov  [oldint28O],bx          ; and save in static data area
           mov  [oldint28S],es          ;

           mov  ax,3509h                ; get old interrupt vector (09h)
           int  21h                     ;
           mov  [oldint09O],bx          ; and save in static data area
           mov  [oldint09S],es          ;

           mov  ax,es:[bx+02]           ; Check to see if already loaded
           cmp  ax,'YF'                 ; is it 'FY'?
           jne  short OKtoLoad
           mov  dx,offset AlreadyS
           mov  ah,09
           int  21h
           .exit 01

OKtoLoad:  push cs                      ; set es = cs
           pop  es

           mov  ah,34h                  ; get indos flag address
           int  21h                     ;  returns address in es:bx
           mov  InDOSfoff,bx            ; save for our interrupt handler
           mov  InDOSfseg,es            ;

           mov  es,[002Ch]              ; free environment block
           mov  ah,49h                  ;
           int  21h                     ;

           mov  dx,offset OurInt08      ; set new interrupt vector (08h)
           mov  ax,2508h                ;
           int  21h                     ;

           mov  dx,offset ourint09      ; set new interrupt vector (09h)
           mov  ax,2509h                ;
           int  21h                     ;

           mov  dx,offset OurInt28      ; set new interrupt vector (28h)
           mov  ax,2528h                ;
           int  21h                     ;

           mov  dx,offset install       ; get paragraphs needed
           sub  dx,offset Start         ;
           shr  dx,04                   ; 
           add  dx,17                   ; add 16 paras for PSP + 1 extra
           mov  ax,3100h                ; terminate and stay resident
           int  21h                     ;  (00h ERRORLEVEL)
      

AlreadyS   db  13,10,'Already installed...',13,10,36

.end start

The following code hooks into INT 21h and allows you to do a little bit of code before and a little bit after each time the original INT 21h gets called. Remember, you can only do a little bit of stuff. Nothing major and absolutely nothing that calls INT 21h or you will be in an endless loop (unless you add code to make it recursive). Be careful, this is for example purposes only.

This TSR hooks into INT 21h. When the processor calls INT 21h, this code prints a '1' to row 6, column 1 of the screen, calls the original 21h interrupt, and then prints a '2' to the screen just after the 1.
; Assemble with NBASM

.model tiny
.186
.code
           org  100h
start:     jmp  short Install

olddosint  dw 00h,00h

newDOSInt:
           cli                          ; put TODO stuff here
           push es                      ; .
           push 0B800h                  ; .
           pop  es                      ; .
           mov  byte es:[1920],'1'      ; .   row 6, col 1 : print '1'
           pop  es                      ; .
           sti                          ; end TODO stuff here
        
           pushf                        ; create our own descriptor
           push cs                      ;   (could be a  call far  instead)
           push offset Back             ;
           jmp  far cs:olddosint        ; jmp to old interrupt handler

Back:      pushf                        ; save flags returned by orig int 21h
           cli                          ; put TODO stuff here
           push es                      ; .
           push 0B800h                  ; .
           pop  es                      ; .
           mov  byte es:[1922],'2'      ; .   row 6, col 2 : print '2'
           pop  es                      ; .
           sti                          ; end TODO stuff here
        
           cli                          ; clean up the stack
           push ax                      ; so that it will ret to the
           push bp                      ; correct place
           mov  bp,sp                   ; 
           mov  ax,[bp-6]               ;
           mov  [bp-8],ax               ;
           mov  ax,[bp-4]               ;
           mov  [bp-6],ax               ;
           pop  bp                      ;
           pop  ax                      ;
           popf                         ; restore flags ret'd by orig 21h
           sti                          ;
           retf 2                       ;

Install:   mov  ax,3521h                ; Save old interrupt vector 21h
           int  21h

           mov  [olddosint],bx
           mov  [olddosint+2],es

           push cs
           pop  ds
           mov  dx,offset cs:newdosint  ; set new interrupt vector 21h
           mov  ax,2521h                ; to newdosint
           int  21h

           mov  es,cs:[002Ch]           ; free environment block
           mov  ah,49h
           int  21h

           mov  dx,offset cs:Install     ; get paragraphs needed
           sub  dx,offset cs:Start
           shr  dx,04
           add  dx,17                   ; add 16 paras for PSP + 1 extra
           mov  ax,3100h                ; termination and stay resident
           int  21h

.end       start