INT 21h AH=3Fh copies bytes from the file pointer's position into the buffer at DS:DX and moves the pointer past them. CX can be anything from 0 to FFFFh, and the buffer must have room for that many bytes. AX returns how many were really read.

The end of the file is not an error. A read that runs into it returns what was left, so AX is less than CX, and every read after that returns AX = 0 with CF = 0. A loop over a file therefore stops when AX is 0 (or smaller than CX), not when CF is set. CF = 1 means something went wrong: 05h if the handle was opened write-only, 06h if it is not open at all.

prints four lines: "4 [ABCD]", "4 [EFGH]", "2 [IJ]" and "0 []"
MOV AH, 3Ch ; DOS: create TEST.TXT
XOR CX, CX ; CX = 0: normal attributes
MOV DX, OFFSET FNAME
INT 21h
CALL CHECK ; stop if CF = 1
MOV BX, AX ; BX = the handle
MOV AH, 40h ; put ten letters in it
MOV CX, 10
MOV DX, OFFSET LETTERS
INT 21h
CALL CHECK
MOV AH, 3Eh
INT 21h
MOV AX, 3D00h ; open it for reading
MOV DX, OFFSET FNAME
INT 21h
CALL CHECK
MOV BX, AX
AGAIN:
MOV AH, 3Fh ; DOS: read up to 4 bytes
MOV CX, 4
MOV DX, OFFSET BUF
INT 21h
CALL CHECK
MOV SI, AX ; SI = how many came
CALL PDEC ; print that number
MOV DX, OFFSET LBR
MOV AH, 09h ; DOS: print the string at DS:DX
INT 21h
MOV CX, SI
MOV DX, OFFSET BUF
CALL OUT1 ; and the bytes themselves
MOV DX, OFFSET RBR
MOV AH, 09h ; DOS: print the string at DS:DX
INT 21h
OR SI, SI ; 0 bytes: the end of the file
JNZ AGAIN
MOV AH, 3Eh ; close the handle
INT 21h
MOV AH, 41h ; delete TEST.TXT
MOV DX, OFFSET FNAME
INT 21h
FNAME DB "TEST.TXT", 0
LETTERS DB "ABCDEFGHIJ"
LBR DB " [$"
RBR DB "]", 13, 10, "$"
BUF DB 64 DUP (?)
CHECK PROC NEAR ; CF = 1: print the error code in AL and exit
JNC CHECK1
PUSH AX
MOV DX, OFFSET FAILMSG
MOV AH, 09h
INT 21h
POP AX
CALL PHEXB
MOV AX, 4C01h ; exit with code 1
INT 21h
CHECK1:
RET
CHECK ENDP
FAILMSG DB "failed: error $"
PHEXB PROC NEAR ; print AL as two hex digits
PUSH CX
PUSH DX
MOV DH, AL ; keep the byte
MOV CL, 4
SHR AL, CL ; the high digit first
MOV CX, 2
PHEXB1:
AND AL, 0Fh
ADD AL, "0"
CMP AL, "9"
JBE PHEXB2
ADD AL, 7 ; 10..15 -> "A".."F"
PHEXB2:
MOV DL, AL
MOV AH, 02h ; DOS: print DL
INT 21h
MOV AL, DH ; then the low digit
LOOP PHEXB1
POP DX
POP CX
RET
PHEXB ENDP
PDEC PROC NEAR ; print AX in decimal
PUSH BX
PUSH CX
PUSH DX
MOV BX, 10
XOR CX, CX ; CX = digits pushed
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
POP DX
POP CX
POP BX
RET
PDEC ENDP
OUT1 PROC NEAR ; write CX bytes at DS:DX to handle 1
PUSH BX
MOV BX, 1 ; standard output
MOV AH, 40h
INT 21h
POP BX
RET
OUT1 ENDP

Files are read as raw bytes: CR, LF and Ctrl+Z (1Ah) come through like any other byte. Text conventions are the program's business.

Reading handle 0

Handle 0 is standard input. When it is the console, 3Fh reads a line from the keyboard with the editing keys of INT 21h AH=0Ah: it echoes what is typed, waits for Enter, and returns the line with CR and LF on the end, at most CX bytes of it. If the line is longer than CX, the rest stays in DOS's buffer and the next 3Fh gets it without waiting. Ctrl+C on the line ends the program, as with the other console services.

Run with its input redirected (PROG < IN.TXT), the same program finds a file behind handle 0, and 3Fh reads it like any file: no echo, no waiting, AX = 0 at the end. Reading input through handle 0 therefore works both ways without the program knowing which; INT 21h AH=44h tells it, if it needs to know. The example puts "Hi" and Enter into the keyboard buffer first, so it does not wait:

the terminal shows: got 4 bytes: Hi (the echo of the typed "Hi" appears only on the DOS screen)
MOV AH, 05h ; BIOS: put a key into the keyboard buffer
MOV CX, 2348h ; CH = scan code, CL = "H"
INT 16h
MOV AH, 05h
MOV CX, 1769h ; "i"
INT 16h
MOV AH, 05h
MOV CX, 1C0Dh ; Enter
INT 16h
MOV AH, 3Fh ; DOS: read from handle 0
XOR BX, BX ; BX = 0: standard input
MOV CX, 20 ; room for 20 bytes
MOV DX, OFFSET LINE1
INT 21h
CALL CHECK
MOV SI, AX ; SI = bytes read, CR and LF included
MOV DX, OFFSET M1
MOV AH, 09h ; DOS: print the string at DS:DX
INT 21h
MOV AX, SI
CALL PDEC
MOV DX, OFFSET M2
MOV AH, 09h ; DOS: print the string at DS:DX
INT 21h
MOV CX, SI
MOV DX, OFFSET LINE1
CALL OUT1 ; the line, with its CR LF
LINE1 DB 20 DUP (?)
M1 DB "got $"
M2 DB " bytes: $"
CHECK PROC NEAR ; CF = 1: print the error code in AL and exit
JNC CHECK1
PUSH AX
MOV DX, OFFSET FAILMSG
MOV AH, 09h
INT 21h
POP AX
CALL PHEXB
MOV AX, 4C01h ; exit with code 1
INT 21h
CHECK1:
RET
CHECK ENDP
FAILMSG DB "failed: error $"
PHEXB PROC NEAR ; print AL as two hex digits
PUSH CX
PUSH DX
MOV DH, AL ; keep the byte
MOV CL, 4
SHR AL, CL ; the high digit first
MOV CX, 2
PHEXB1:
AND AL, 0Fh
ADD AL, "0"
CMP AL, "9"
JBE PHEXB2
ADD AL, 7 ; 10..15 -> "A".."F"
PHEXB2:
MOV DL, AL
MOV AH, 02h ; DOS: print DL
INT 21h
MOV AL, DH ; then the low digit
LOOP PHEXB1
POP DX
POP CX
RET
PHEXB ENDP
PDEC PROC NEAR ; print AX in decimal
PUSH BX
PUSH CX
PUSH DX
MOV BX, 10
XOR CX, CX ; CX = digits pushed
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
POP DX
POP CX
POP BX
RET
PDEC ENDP
OUT1 PROC NEAR ; write CX bytes at DS:DX to handle 1
PUSH BX
MOV BX, 1 ; standard output
MOV AH, 40h
INT 21h
POP BX
RET
OUT1 ENDP

DOSBox-X reads the console more simply than MS-DOS: it returns as soon as CX characters have been typed, even before Enter, and draws its echo through the BIOS. The echo is therefore on the DOS screen but not in the workbench terminal, which shows what programs write to CON.