INT 16h is the BIOS's keyboard interface. It does not talk to the keyboard itself: that is the job of the IRQ 1 handler, INT 09h, which runs on every key press and release, turns the keyboard's scan codes into characters and stores them in a small buffer. INT 16h reads from that buffer, and from the shift flags the IRQ handler keeps up to date. DOS reads the keyboard through it too, so a key taken out with INT 16h never reaches INT 21h AH=01h, and a key stored with INT 16h AH=05h is read by DOS as if it had been typed.

AHService
00hwait for a key and take it out of the buffer
01his a key waiting? (ZF), and which one; the key stays
02hthe shift flags: Shift, Ctrl, Alt, the Lock keys
03hset the typematic (auto-repeat) rate and delay (AT)
05hstore a keystroke in the buffer (AT)
10h / 11henhanced read and check: F11, F12 and the grey keys told apart
12henhanced shift flags: left and right Ctrl and Alt separately

Scan code and ASCII

Every key in the buffer is a word: the low byte is the ASCII character, the high byte the scan code, a number for the physical key. The read functions return it as it is stored, AL = ASCII and AH = scan code. "A" and "a" share scan code 1Eh and differ in AL; Ctrl+A gives AL = 01h. Keys that have no character, such as the function keys, arrows, Home or Alt+letter, are stored with AL = 0 and the scan code in AH: F1 is 3B00h, Up is 4800h, Alt+X is 2D00h. That is the whole test for an "extended" key: AL = 0.

KeyWord (AH scan, AL ASCII)
A / a / Ctrl+A / Alt+A1E41h / 1E61h / 1E01h / 1E00h
Enter / Esc / Space1C0Dh / 011Bh / 3920h
F1 .. F103B00h .. 4400h
Up / Down / Left / Right (keypad)4800h / 5000h / 4B00h / 4D00h
Up on the grey arrow block (AH=10h)48E0h
F11 / F12 (AH=10h only)8500h / 8600h

The buffer in the BIOS data area

The buffer is a ring of 16 words at 0040:001Eh..003Dh. Two pointers, kept as offsets from segment 0040h, chase each other round it: the head at 0040:001Ah points to the oldest key, the tail at 0040:001Ch to the slot where the next key will go. Head = tail means empty. A new key goes in at the tail and the tail moves on by 2, wrapping from the end back to the start; if that would make the tail equal to the head, the buffer counts as full and the key is dropped (the BIOS beeps). So the 16 slots hold at most 15 keys. AT-class BIOSes keep the start and end of the ring at 0040:0080h and 0040:0082h, which lets a program move the buffer elsewhere in segment 0040h.

AddressHolds
0040:0017hshift flags, the byte AH=02h returns
0040:0018hmore flags: left Ctrl and left Alt held, Lock keys held down, Pause active
0040:001Ahhead: offset of the oldest key
0040:001Chtail: offset where the next key goes
0040:001Eh..003Dhthe buffer, 16 words
0040:0080h / 0082hstart and end offset of the buffer
0040:0096hbit 4 = 1: an enhanced (101/102-key) keyboard is attached

The example puts two keys into the buffer with INT 16h AH=05h, works out how many keys are waiting from the two pointers, and takes them out again with INT 16h AH=00h. It starts by emptying the buffer, the usual AH=01h / AH=00h loop, so the count is exact.

prints: 2 keys waiting: Hi
FLUSH1:
MOV AH, 01h ; BIOS: is a key waiting?
INT 16h
JZ EMPTY1 ; ZF = 1: the buffer is empty
MOV AH, 00h ; take the key out
INT 16h
JMP FLUSH1
EMPTY1:
MOV AH, 05h ; store "H": scan code 23h, ASCII 48h
MOV CX, 2348h
INT 16h
MOV AH, 05h ; store "i": scan code 17h, ASCII 69h
MOV CX, 1769h
INT 16h
MOV AX, 40h
MOV ES, AX ; ES = 0040h, the BIOS data area
MOV AX, ES:[1Ch] ; the tail: where the next key will go
SUB AX, ES:[1Ah] ; minus the head: the oldest key
JNS NOWRAP1
ADD AX, ES:[82h] ; the tail has wrapped round: add the ring size,
SUB AX, ES:[80h] ; end minus start
NOWRAP1:
SHR AX, 1 ; two bytes per key
CALL PDEC ; 2
MOV DX, OFFSET WAIT1
MOV AH, 09h ; DOS: print the $-string at DS:DX
INT 21h
MOV AH, 00h ; take the oldest key out
INT 16h ; AL = "H"
MOV DL, AL
MOV AH, 02h ; DOS: print the character in DL
INT 21h
MOV AH, 00h ; and the next one
INT 16h ; AL = "i"
MOV DL, AL
MOV AH, 02h ; DOS: print the character in DL
INT 21h
WAIT1 DB " keys waiting: $"
PDEC PROC NEAR ; print AX in decimal; keeps every register
PUSH AX
PUSH BX
PUSH CX
PUSH DX
MOV BX, 10
XOR CX, CX ; digits on the stack
DECD1:
XOR DX, DX
DIV BX ; AX = AX / 10, DX = the last digit
PUSH DX
INC CX
TEST AX, AX
JNZ DECD1
DECD2:
POP DX ; the digits come back first to last
ADD DL, "0"
MOV AH, 02h
INT 21h
LOOP DECD2
POP DX
POP CX
POP BX
POP AX
RET
PDEC ENDP

Extended keyboards

The 101-key keyboard of 1986 added F11, F12 and a second, grey block of arrows and Insert/Home/PgUp keys. To keep old programs working, the original functions 00h and 01h pretend those keys do not exist or are the old ones: a grey arrow comes back as the keypad arrow, F11 and F12 are thrown away. Programs that want them use the enhanced functions INT 16h AH=10h and 11h, which return grey keys with E0h in AL and F11/F12 as 8500h/8600h. Check bit 4 of 0040:0096h, or simply use 10h on anything from an AT onwards.

Pitfalls

Mixing BIOS and DOS keyboard input is fine as long as you remember there is one buffer: whoever reads a key first gets it. DOS checks for Ctrl+C on many of its input functions; the BIOS does not, and Ctrl+Break is handled separately, by INT 09h calling INT 1Bh and leaving a 0000h word in the buffer. A program that leaves keys in the buffer when it exits hands them to COMMAND.COM, which takes them as typing: always read what you stored.

In DOSBox-X

The keyboard BIOS is DOSBox-X's own, written in C++, and follows the IBM behaviour closely: the buffer and its pointers live where they should, AH=05h works, and the enhanced functions are there. Keys typed into the DOS screen go through the emulated keyboard controller and INT 09h like on a real PC.