DOSBox is a program that pretends to be an old 1980s computer running DOS (Disk Operating System). We need it because our assembly programs are written for 16-bit x86 DOS — they won't run on a modern OS directly. Think of it as a time machine.
| Command | What it does | Example |
|---|---|---|
mount c c:\assembly | Link your real folder to drive C inside DOSBox | mount c c:\assembly |
c: | Switch to drive C | |
dir | List all files in the current folder | |
cd foldername | Enter a folder | cd programs |
cd .. | Go back one folder | |
md foldername | Create a new folder | md lesson1 |
del filename | Delete a file | del hello.asm |
type filename | Print file contents to screen | type hello.asm |
cls | Clear the screen |
Every time you write an assembly program, you repeat these exact steps:
; Step 1 — Write your code in Notepad / VS Code, save as hello.asm ; Step 2 — Assemble (turn .asm into .com executable) nasm hello.asm -o hello.com ; Step 3 — Run it hello ; Step 4 — Debug it (optional but very useful) afd hello.com
AFD shows your program running one instruction at a time. You can watch registers change live. Use it whenever your program does something unexpected.
| Key | Action |
|---|---|
F8 | Step — execute one instruction (use this constantly) |
F7 | Step Into — go inside a function call |
F2 | Set/remove a breakpoint |
F9 | Run until next breakpoint |
Tab | Switch between the 4 panels |
Alt+X | Exit AFD |
The CPU has tiny ultra-fast storage slots called registers. There's no garbage collector, no safety net — you control everything directly. In C++, variables live in RAM and the compiler manages registers for you. In assembly, you manage every register manually.
Same layout for BX/BH/BL, CX/CH/CL, DX/DH/DL.
| Register | Full Name | Main Use | C++ Analogy |
|---|---|---|---|
AX | Accumulator | Math results, return values | int result; |
BX | Base | Memory addresses, general use | int* ptr; |
CX | Counter | Loop counters (used by LOOP) | int i; in a for loop |
DX | Data | I/O, overflow from MUL/DIV | int extra; |
SI / DI | Source / Dest Index | Pointing into arrays / strings | char* src; char* dst; |
SP | Stack Pointer | Top of the stack (auto-managed) | Managed by compiler |
IP | Instruction Pointer | Next instruction to run (you never touch this directly) | Program counter |
After every arithmetic or logic instruction, the CPU automatically updates these flag bits. Conditional jumps (je, jg, etc.) read these flags.
| Flag | Set when… | Used by |
|---|---|---|
ZF Zero | Result was exactly 0 | je, jz, jne |
SF Sign | Result's highest bit is 1 (negative in signed math) | js, jns |
CF Carry | Addition carried out of the top bit, or subtraction borrowed | jc, jb |
OF Overflow | Signed result overflowed (too big or too small) | jo |
| System | Base | NASM Syntax | Example |
|---|---|---|---|
| Decimal | 10 | Just write the number | 65 |
| Hexadecimal | 16 (0–9, A–F) | Add h suffix or 0x prefix | 41h = 0x41 |
| Binary | 2 | Add b suffix | 01000001b |
add al, 30h to print a digit — it converts the number to its ASCII character.Every .COM program has the same skeleton. The CPU starts reading from offset 100h in memory, which is why we always write org 100h at the top.
; org tells NASM: "code starts at 100h" org 100h ; Jump past data (so CPU doesn't try to ; run your variables as code!) jmp start ; === DATA === myMsg db 'Hello$' ; string myNum db 42 ; byte myWord dw 1000 ; 2-byte word ; === CODE === start: ; your code here mov ax, 4C00h ; exit program int 21h
// #include and main() are the equivalent // of org 100h + jmp start #include <iostream> using namespace std; // === GLOBAL VARIABLES (like db/dw) === string myMsg = "Hello"; char myNum = 42; int myWord = 1000; // === CODE (like "start:" label) === int main() { // your code here return 0; // like "mov ax,4C00h / int 21h" }
myByte db 10 ; 1 byte (0–255) myWord dw 1000 ; 2 bytes (0–65535) myMsg db 'Hello$' ; string ($ = end marker) myArr db 1,2,3,4,5 ; array of 5 bytes
char myByte = 10; int myWord = 1000; string myMsg = "Hello"; // null-terminated char myArr[] = {1,2,3,4,5};
MOV dst, src — copies data from src into dst. Think of it as the = assignment operator. The destination is always on the LEFT.
mov ax, 5 ; AX = 5 mov bx, ax ; BX = AX (copy register) mov al, [bx] ; AL = memory[BX] (load) mov [bx], al ; memory[BX] = AL (store) xchg ax, bx ; swap AX and BX push ax ; save AX on the stack pop bx ; restore top of stack → BX lea bx, [myVar] ; BX = address of myVar
int ax = 5; int bx = ax; ax = *ptr; // *ptr dereferences a pointer *ptr = ax; swap(ax, bx); // std::swap stack.push(ax); // std::stack bx = stack.top(); stack.pop(); bx = &myVar; // take the address of myVar
mov [a], [b] is ILLEGAL.add ax, 5 ; AX = AX + 5 add ax, bx ; AX = AX + BX sub ax, 3 ; AX = AX - 3 inc ax ; AX = AX + 1 dec ax ; AX = AX - 1 neg ax ; AX = -AX
ax += 5; ax += bx; ax -= 3; ax++; // or ++ax ax--; // or --ax ax = -ax;
; 8-bit MUL: AX = AL * operand mov al, 10 mov bl, 6 mul bl ; AX = AL * BL = 60 ; Result is ALWAYS in AX (both bytes) ; 8-bit DIV: AL = AX / operand ; AH = remainder mov ax, 35 ; dividend must be in AX mov bl, 10 div bl ; AL = 3 (quotient) ; AH = 5 (remainder)
// MUL int al = 10, bl = 6; int ax = al * bl; // ax = 60 // DIV int ax = 35; int bl = 10; int al = ax / bl; // al = 3 (quotient) int ah = ax % bl; // ah = 5 (remainder)
These operate on individual bits. Very useful for checking/setting individual bits and the famous xor ax, ax trick to zero a register.
and ax, bx ; AX = AX AND BX (bit-by-bit) or ax, bx ; AX = AX OR BX xor ax, bx ; AX = AX XOR BX not ax ; AX = flip all bits ; ★ TRICK: fastest way to zero a register xor ax, ax ; AX = 0 (any value XOR itself = 0) ; Check if a number is even/odd and al, 01h ; isolate last bit ; ZF=1 means even, ZF=0 means odd
ax &= bx; // bitwise AND ax |= bx; // bitwise OR ax ^= bx; // bitwise XOR ax = ~ax; // bitwise NOT // Zero a variable ax = 0; // same result, XOR is just faster in ASM // Even/odd check if (al & 1) { /* odd */ } else { /* even */ }
Shifting left by 1 = multiply by 2. Shifting right by 1 = divide by 2. Much faster than MUL/DIV for powers of 2.
shl ax, 1 ; AX = AX * 2 (shift bits left) shl ax, 3 ; AX = AX * 8 (left by 3) shr ax, 1 ; AX = AX / 2 (unsigned) sar ax, 1 ; AX = AX / 2 (signed, keeps sign bit)
ax <<= 1; // ax *= 2 ax <<= 3; // ax *= 8 ax >>= 1; // ax /= 2 (unsigned) ax >>= 1; // ax /= 2 (signed, same in C++)
CMP a, b subtracts b from a without saving the result — it just sets the CPU flags. Then a conditional jump reads those flags.
cmp ax, bx ; compare AX and BX je equal_branch ; jump if AX == BX jne neq_branch ; jump if AX != BX jg bigger ; jump if AX > BX (signed) jl smaller ; jump if AX < BX (signed) jge atleast ; jump if AX >= BX (signed) jle atmost ; jump if AX <= BX (signed) jmp always ; unconditional jump (goto)
// cmp sets the "mood", jump acts on it if (ax == bx) goto equal_branch; if (ax != bx) goto neq_branch; if (ax > bx) goto bigger; if (ax < bx) goto smaller; if (ax >= bx) goto atleast; if (ax <= bx) goto atmost; goto always;
goto almost never. In assembly, every conditional is a jump — there's no other choice. Don't be scared of labels; they're just named memory addresses.; Calling a procedure call myFunc ; push IP, jump to myFunc ; ... later in your code ... myFunc: ; body of the function mov ax, 42 ; do something ret ; pop IP, jump back to caller
// Calling a function myFunc(); // ... the function definition ... void myFunc() { // body ax = 42; return; // ret }
INT 21h is a DOS system call. You set AH to a function number, set up any inputs, then call int 21h. DOS does the work.
; Print one character (DL = char) mov ah, 02h mov dl, 'A' int 21h ; Print a string (ends with '$') mov ah, 09h lea dx, [myStr] int 21h ; Read one character → AL mov ah, 01h int 21h ; AL = key pressed ; Exit program mov ax, 4C00h int 21h
// Print one character cout << 'A'; // Print a string cout << myStr; // Read one character char ch = cin.get(); // or getchar() // Exit program return 0;
org 100h jmp start ; String MUST end with '$' — that is how ; DOS knows where the string ends message db 'Hello, World!', 13, 10, '$' ; ^ ^ ^ ; CR LF terminator start: mov ah, 09h ; function 09h = print string lea dx, [message] ; DX = address of string int 21h ; DOS prints until '$' mov ax, 4C00h int 21h
#include <iostream> using namespace std; int main() { // endl adds '\n' (like 13,10 in ASM) cout << "Hello, World!" << endl; return 0; }
'H','e','l','l','o' as machine instructions and crashes. The jmp start skips over the data safely.Numbers in registers are raw binary. To print a number, you must convert each digit to its ASCII character by adding 30h (48 in decimal). That's because ASCII '0' = 48 = 30h.
org 100h start: mov al, 3 ; AL = 3 add al, 4 ; AL = 7 ; Convert digit 7 → ASCII '7' add al, 30h ; AL = 7 + 48 = 55 = '7' mov ah, 02h ; DOS print-char function mov dl, al ; DL = '7' int 21h ; prints '7' mov ax, 4C00h int 21h
#include <iostream> using namespace std; int main() { int al = 3 + 4; // al = 7 // C++ prints integers directly // No ASCII conversion needed! cout << al; // prints 7 return 0; }
org 100h start: mov ax, 35 ; number to print ; Divide 35 by 10: ; AL = 3 (tens digit), AH = 5 (units digit) mov bl, 10 div bl mov cl, ah ; save units (AH gets clobbered) ; Print tens digit (AL=3) add al, 30h ; '3' mov ah, 02h mov dl, al int 21h ; prints '3' ; Print units digit (CL=5) add cl, 30h ; '5' mov ah, 02h mov dl, cl int 21h ; prints '5' mov ax, 4C00h int 21h
#include <iostream> using namespace std; int main() { int num = 35; // C++ does this automatically with cout cout << num; // prints "35" // But in ASM we must split it: int tens = num / 10; // 3 int units = num % 10; // 5 cout << (char)(tens + '0'); cout << (char)(units + '0'); // +48 is same as +'0' (adding 30h) return 0; }
Assembly doesn't have if or else keywords. You simulate them with cmp (which sets flags) and conditional jumps that skip over blocks of code.
org 100h jmp start number db 0 ; change this to test msg_yes db 'The number is zero!', 13, 10, '$' msg_no db 'Not zero!', 13, 10, '$' start: mov al, [number] ; load number into AL cmp al, 0 ; AL - 0 (sets ZF if result=0) je is_zero ; jump if ZF=1 (equal) ; === NOT ZERO branch === mov ah, 09h lea dx, [msg_no] int 21h jmp done ; MUST skip the zero branch is_zero: ; === ZERO branch === mov ah, 09h lea dx, [msg_yes] int 21h done: mov ax, 4C00h int 21h
#include <iostream> using namespace std; int main() { int number = 0; if (number == 0) { cout << "The number is zero!\n"; } else { cout << "Not zero!\n"; } return 0; }
start: mov al, [marks] ; Check from highest down (ORDER MATTERS!) cmp al, 90 jge get_A ; if marks >= 90 → A cmp al, 75 jge get_B ; else if marks >= 75 → B cmp al, 60 jge get_C ; else if marks >= 60 → C cmp al, 50 jge get_D ; else if marks >= 50 → D ; else → F (fall through) lea dx, [grade_f] / jmp done get_A: ... get_B: ... get_C: ... get_D: ...
int marks = 75; if (marks >= 90) cout << "Grade A"; else if (marks >= 75) cout << "Grade B"; else if (marks >= 60) cout << "Grade C"; else if (marks >= 50) cout << "Grade D"; else cout << "Grade F";
jmp done to skip the else-branches. Forgetting this is the #1 beginner bug — you'll fall through into the next branch.Assembly has two main loop patterns: the LOOP instruction (which uses CX as a counter), and the CMP + JMP pattern (which is a while-loop).
org 100h start: mov cx, 5 ; CX = loop count (5 iterations) print_loop: ; --- loop body --- mov ah, 02h mov dl, '*' int 21h ; print '*' loop print_loop ; CX-- ; if CX != 0, jump back ; when CX hits 0, falls through here mov ax, 4C00h int 21h
#include <iostream> using namespace std; int main() { // LOOP = for loop with CX as counter for (int cx = 5; cx > 0; cx--) { cout << '*'; } // Output: ***** return 0; }
start: mov cx, 9 ; 9 iterations mov bl, 1 ; BL = current digit number_loop: mov al, bl ; AL = current digit add al, 30h ; convert to ASCII mov ah, 02h mov dl, al int 21h ; print digit inc bl ; BL++ (next digit) loop number_loop ; CX--; repeat mov ax, 4C00h int 21h
// Notice the assembly uses TWO variables: // CX = how many left, BL = current value for (int bl = 1; bl <= 9; bl++) { cout << bl << ' '; } // Output: 1 2 3 4 5 6 7 8 9 // In ASM: cout is replaced by // the add+30h+int21h sequence
; This is the while-loop pattern: ; Check condition → execute body → jump back start: mov al, 1 ; al = starting value while_start: cmp al, 10 ; is al >= 10? jge while_end ; yes → exit loop ; === loop body === ; (do something with al) inc al ; al++ jmp while_start ; go back to check while_end: mov ax, 4C00h int 21h
int al = 1; while (al < 10) { // loop body al++; } // The CMP+JGE+JMP is EXACTLY // the while condition check: // jge = "if NOT (al < 10) then exit" // jmp = "go back to check again"
CALL or anything that changes CX (like INT 21h sometimes can), you must save CX with push cx before and pop cx after. See the Stack section.org 100h jmp start prompt db 'Press Y or N: $' msg_yes db 13, 10, 'You said YES!$' msg_no db 13, 10, 'You said NO!$' start: mov ah, 09h ; print prompt lea dx, [prompt] int 21h mov ah, 01h ; READ one char → AL int 21h ; AL = ASCII of pressed key cmp al, 'Y' ; was it 'Y'? je pressed_yes cmp al, 'y' ; or lowercase 'y'? je pressed_yes ; else: not Y lea dx, [msg_no] / jmp done pressed_yes: lea dx, [msg_yes] done: mov ah, 09h / int 21h mov ax, 4C00h / int 21h
#include <iostream> using namespace std; int main() { cout << "Press Y or N: "; char ch; ch = getchar(); // like "mov ah,01h / int 21h" // ch now holds the character (like AL) if (ch == 'Y' || ch == 'y') { cout << "\nYou said YES!"; } else { cout << "\nYou said NO!"; } return 0; }
int 21h with AH=01h, the character's ASCII code is in AL. You then use cmp al, 'Y' to check what key was pressed. In C++, the char variable holds the character directly — no conversion needed.org 100h jmp start msg1 db 'Hello$' msg2 db 'World$' start: ; Call print_str twice with different strings lea dx, [msg1] ; "pass argument" in DX call print_str lea dx, [msg2] call print_str mov ax, 4C00h int 21h ; ---- PROCEDURE DEFINITION ---- ; Input: DX = address of '$'-terminated string print_str: mov ah, 09h int 21h ; prints the string at [DX] ret ; return to caller
#include <iostream> using namespace std; // Parameters replace the DX register void print_str(string msg) { cout << msg; // ret → just return from function } int main() { print_str("Hello"); print_str("World"); return 0; }
The stack is like a plate-stacking machine: LIFO — Last In, First Out. PUSH adds a plate on top. POP removes the top plate. You use the stack to save registers before a procedure messes with them, then restore them after.
; Save AX and BX before calling something ; that might change them push ax ; save AX (goes on top of stack) push bx ; save BX (goes on top of AX) call someProc ; this might change AX and BX ; Restore in REVERSE order! pop bx ; restore BX first (LIFO!) pop ax ; restore AX second ; AX and BX are back to what they were
// C++ does this automatically! // When you call a function, the compiler // generates push/pop instructions for you. int ax = 5, bx = 10; someFunc(); // compiler saves ax,bx on stack, // calls the function, restores them // ax and bx are unchanged after the call // In ASM you must do this manually!
Arrays in assembly are just bytes stored one after another in memory. You access elements using a base address + offset. There's no bounds checking — if you go past the end, you corrupt memory.
org 100h jmp start ; Define an array of 5 bytes myArr db 10, 20, 30, 40, 50 start: ; Access myArr[0] → first element mov al, [myArr] ; AL = 10 ; Access myArr[2] → third element ; Use BX as the index (offset from base) lea bx, [myArr] ; BX = start address mov al, [bx+2] ; AL = myArr[2] = 30 ; Loop through all 5 elements mov cx, 5 ; 5 elements mov si, 0 ; SI = index loop_arr: mov al, [myArr+si] ; AL = myArr[si] inc si ; si++ loop loop_arr
char myArr[] = {10, 20, 30, 40, 50}; // Access element 0 char al = myArr[0]; // al = 10 // Access element 2 al = myArr[2]; // al = 30 // [bx+2] in ASM = myArr[2] in C++ // Loop through all elements for (int i = 0; i < 5; i++) { al = myArr[i]; // [myArr+si] in ASM }
| Instruction | What it does | C++ equivalent |
|---|---|---|
mov dst, src | dst = src | dst = src; |
add dst, src | dst = dst + src | dst += src; |
sub dst, src | dst = dst - src | dst -= src; |
inc dst | dst = dst + 1 | dst++; |
dec dst | dst = dst - 1 | dst--; |
mul src | AX = AL * src (8-bit) | ax = al * src; |
div src | AL = AX / src, AH = AX % src | al = ax/src; ah = ax%src; |
neg dst | dst = -dst | dst = -dst; |
and dst, src | dst = dst & src (bitwise) | dst &= src; |
or dst, src | dst = dst | src | dst |= src; |
xor dst, src | dst = dst ^ src | dst ^= src; |
xor ax, ax | ax = 0 (fastest zero) | ax = 0; |
not dst | dst = ~dst | dst = ~dst; |
shl dst, n | dst = dst << n (×2ⁿ) | dst <<= n; |
shr dst, n | dst = dst >> n (÷2ⁿ, unsigned) | dst >>= n; |
cmp a, b | compute a-b, set flags (don't save) | used inside if/while |
je / jz | jump if equal / zero | if (a == b) |
jne / jnz | jump if not equal | if (a != b) |
jg | jump if greater (signed) | if (a > b) |
jl | jump if less (signed) | if (a < b) |
jge | jump if ≥ (signed) | if (a >= b) |
jle | jump if ≤ (signed) | if (a <= b) |
jmp lbl | unconditional jump | goto lbl; |
loop lbl | CX--; if CX≠0 jump | for(cx;cx>0;cx--) |
call proc | push IP, jump to proc | proc(); |
ret | pop IP, return | return; |
push src | push onto stack | compiler does this |
pop dst | pop from stack | compiler does this |
; ── Print a newline ── mov ah, 02h / mov dl, 13 / int 21h ; CR mov ah, 02h / mov dl, 10 / int 21h ; LF ; ── Number → ASCII char ── add al, 30h ; digit 7 → character '7' ; ── ASCII char → Number ── sub al, 30h ; character '7' → digit 7 ; ── Zero a register (fastest) ── xor ax, ax ; ── Count-controlled loop ── mov cx, N myloop: ; body loop myloop ; ── If-else ── cmp al, val je true_branch ; else code jmp endif true_branch: ; if code endif: ; ── Save/restore registers ── push ax / push bx ; save ; ... code ... pop bx / pop ax ; restore (REVERSE!)
// ── Print a newline ── cout << endl; // or: cout << '\n'; // ── Number → char ── char c = al + '0'; // '0' == 48 == 30h // ── char → Number ── int n = c - '0'; // ── Zero ── ax = 0; // ── Count-controlled loop ── for (int i = 0; i < N; i++) { // body } // ── If-else ── if (al == val) { // true code } else { // else code } // ── Save/restore ── // The compiler does this for you // automatically in every function call
STEP 1 Write code in Notepad/VS Code → save as myprogram.asm in C:\assembly STEP 2 In DOSBox: mount c c:\assembly c: STEP 3 Assemble: nasm myprogram.asm -o myprogram.com (if no error message appears, success!) STEP 4 Run: myprogram STEP 5 Debug (if something is wrong): afd myprogram.com F8 = step one instruction at a time Tab = switch between panels (Code / Registers / Flags / Memory) STEP 6 Edit → Re-save → Reassemble → Rerun → Repeat
Assembly punishes laziness and rewards patience. There are no safety nets, no helpful error messages.
1. Type every program yourself. Never copy-paste.
2. Run it through AFD. Watch every register change with F8.
3. Change one thing. See what breaks. Fix it.
4. Comment every line until you understand why it is there.
Struggle is normal. Confusion is normal. When your program finally works, you will have earned it.