INT 21h AH=25h writes vector AL in the table at 0000:0000: DX as the offset, DS as the segment. From then on every INT of that number, and every hardware interrupt that uses the vector, goes to the new handler. Two MOVs into segment 0 would do the same, with a risk: an interrupt arriving between them would jump to an address that is half old and half new. DOS writes both words with interrupts disabled, and it is the documented way.
Hooking a vector is always three steps: get the old address with INT 21h AH=35h and keep it, set your own, and put the old one back before the program ends. DOS restores only vectors 22h, 23h and 24h by itself at exit (it keeps copies of them in the PSP). Any other vector left pointing into the program points into freed memory once it has ended, and the next program loaded there turns the next interrupt into a crash.
MOV AX, 351Ch ; DOS: get vector 1Ch, the timer hookINT 21h ; ES:BX = the current handlerMOV WORD PTR OLD1C1, BXMOV WORD PTR OLD1C1+2, ESMOV AX, 251Ch ; DOS: set vector 1ChMOV DX, OFFSET TICK1 ; DS:DX = our handler (DS = CS in a .COM)INT 21hWAIT1:CMP WORD PTR TICKS1, 18 ; 18 ticks: about one secondJB WAIT1PUSH DSLDS DX, OLD1C1 ; DS:DX = the old handlerMOV AX, 251Ch ; put it backINT 21hPOP DSMOV DX, OFFSET MSG1MOV AH, 09h ; DOS: print the string at DS:DXINT 21hOLD1C1 DD 0 ; the old vector: offset, then segmentTICKS1 DW 0TICK1 PROC FAR ; INT 1Ch: called 18.2 times a secondINC CS:TICKS1 ; DS is not ours in here: go through CSJMP DWORD PTR CS:OLD1C1 ; chain to the old handler; it ends with IRETTICK1 ENDPMSG1 DB "18 timer ticks counted, INT 1Ch restored", 13, 10, "$"
INT 1Ch is the hook the BIOS offers for the timer: its INT 08h handler executes INT 1Ch on every tick, 18.2 times a second, and the default 1Ch handler is a lone IRET. The example's handler counts the tick and jumps on to the old handler, so whatever was hooked before keeps working. The main program waits for 18 ticks, then restores the vector: LDS DX, OLD1C1 loads DS:DX from the saved doubleword in one instruction, with DS saved around it because the program still needs its own.
Writing the handler
A handler runs in the middle of whatever was executing, so it must leave every register as it found it: push what it uses and pop it before IRET. The flags are saved by the interrupt itself. DS is whatever the interrupted code had; in a .COM program the handler reaches its variables through CS, as INC CS:TICKS1 does. Keep a hardware handler short, and do not call DOS from it: DOS is not re-entrant, and the interrupt may have arrived while DOS was running. A handler that replaces a hardware interrupt such as 08h or 09h instead of chaining to the old one must also signal the end of the interrupt to the 8259 (MOV AL, 20h / OUT 20h, AL).
Pitfalls
The handler address goes in DS:DX, not ES:BX as 35h returns it. In a .COM DS already equals CS; in an .EXE, load DS with the handler's segment (usually CS: PUSH CS / POP DS) and restore it afterwards. Every way out of the program must restore the vector, including Ctrl+C: while a vector is hooked, either read keys with services that do not check Ctrl+C or install an INT 23h handler that restores it. In DOSBox-X many vectors point at small callback stubs that hand control to the emulator; chaining to them works exactly like chaining to real code.