REP is not an instruction on its own but a prefix byte (F3h) in front of a string instruction. It turns that one instruction into a loop with CX as the counter. Each round goes:

StepWhat happens
1If CX = 0, stop: go on with the next instruction
2Take a pending interrupt, if any
3Do the string operation once (SI / DI step by DF)
4CX = CX - 1 (flags unchanged), then back to step 1

Because CX is tested first, REP with CX = 0 does nothing at all, which is safe for empty blocks. When it ends, CX = 0 and SI / DI point just past the last element handled. With CMPS and SCAS the same F3h byte means REPE, which also checks ZF after each compare; use that name there.

CLD
MOV DI, OFFSET BUF1
MOV AL, '#'
MOV CX, 3
REP STOSB ; three #
XOR CX, CX
REP STOSB ; CX = 0: not one byte
MOV DX, OFFSET BUF1
MOV AH, 09h ; DOS: print the string at DS:DX
INT 21h
BUF1 DB '.....', 13, 10, '$'

Interrupts in the middle

A long REP MOVSB does not hold off interrupts: between rounds the CPU can stop, run the handler, and come back to the instruction, which simply carries on with the current SI, DI and CX. To make this work the saved return address points at the prefix rather than the opcode.

The 8086 and 8088 have a well-known flaw here: they remember only one prefix, the one right before the opcode. With two prefixes, such as REP plus a segment override, the return address points at the override, so after the interrupt the instruction resumes without REP. It does one more element and stops, CX not yet 0. Avoid the override (load DS instead), or check CX and go again:

AGAIN1:
REP MOVS BYTE PTR ES:[DI], CS:[SI]
OR CX, CX ; cut short by an interrupt?
JNZ AGAIN1

LOCK REP has the same problem. Later processors resume such instructions correctly. Later CPUs also add REP INSB / REP OUTSB (80186) and the 32-bit forms that count with ECX (80386).