Loading header...

Introduction to Assembly Language

Learning Objectives

By the end of this lesson, you should be able to:

  • explain why assembly matters even when most firmware is written in C or C++;
  • read the basic structure of an AVR assembly instruction;
  • connect Arduino-style calls to registers, compiler output, and machine code;
  • estimate delay-loop timing from clock cycles and identify when hardware timers are better;
  • use disassembly as a practical debugging tool.

"I Already Know Arduino — Why Do I Need This?"

This is the most common question from engineering students. You bought an Arduino, you found a library online, you called digitalWrite(13, HIGH) and an LED blinked. It worked. So why go deeper?

Here is the honest answer: you did not make that LED blink. The library did. You copied the incantation.

There is nothing wrong with using libraries and Arduino for prototyping — professionals do it too. But right now you cannot answer these questions:

  • Why does delay(1000) block everything else from running? What is it actually doing inside?
  • Why does your servo jitter when you also try to read a sensor?
  • Why does a for loop counting to 60000 take a different amount of time on different boards?
  • Why does your I2C communication randomly fail, but only when you add one more variable?
  • How does Serial.begin(9600) know what 9600 means, and what happens if you change it to 9601?

If you cannot answer these, you are not an embedded engineer yet. You are a sketch copier. The moment something goes wrong at the hardware level — and it always does in a real project — you will be stuck.

flowchart TD classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d classDef ok fill:#fef9c3,stroke:#ca8a04,color:#713f12 classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d classDef great fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a A["Arduino sketch copier\nCopies code from the internet\nWorks until something goes wrong\nCannot debug at hardware level\nCannot write timing-critical code\nCannot reduce power consumption\nCannot explain why anything works"]:::bad B["Arduino + C programmer\nWrites own functions\nUnderstands the C language\nStill uses libraries as black boxes\nLimited when library does not exist\nor does not do exactly what you need"]:::ok C["Embedded C programmer\nWrites register-level code\nUnderstands peripherals deeply\nCan read a datasheet and implement\nany peripheral without a library\nCan debug with an oscilloscope"]:::good D["Assembly-aware embedded engineer\nKnows what the compiler generates\nCan read disassembly output\nCan write timing-critical ISRs\nCan analyse code size and speed\nUnderstands every cycle of execution\nThis is what industry needs"]:::great A -->|"learn C + registers"| B B -->|"learn datasheets\n+ peripherals"| C C -->|"learn assembly\n+ architecture"| D

The Real Problem with Skipping Assembly

Consider this situation — common for every embedded engineer:

You are building a product. A motor must receive a pulse every exactly 1 millisecond. Even one missed pulse causes a fault. Your Arduino sketch uses delay(). This works on your desk. In the final product, you also need to read a temperature sensor over I2C, which sometimes takes 1.3 ms. Now your motor pulses are irregular. The product fails.

You search online: "Arduino delay not accurate." You find millis(). You rewrite the code. Still jitters. You find micros(). Still not right. You add volatile. Still fails. You spend three days.

An engineer who understands assembly understands immediately:

  • delay() burns CPU cycles in a loop — nothing else runs
  • millis() and micros() rely on Timer0 overflow interrupts — they have jitter equal to the longest function that runs with interrupts disabled
  • The fix is a hardware timer interrupt with an ISR written in 4–5 instructions that executes in under 1 µs regardless of what the main loop is doing
  • The I2C library disables interrupts during bit-banging — that is the root cause

That diagnosis takes 30 seconds when you understand what the hardware is doing. It takes three days when you are guessing.


What Arduino Actually Is

Arduino is not a language. It is not magic. It is:

flowchart TD classDef layer fill:#f3e8ff,stroke:#9333ea,color:#581c87 classDef hw fill:#fee2e2,stroke:#dc2626,color:#7f1d1d classDef you fill:#dcfce7,stroke:#16a34a,color:#14532d YOU["Your sketch\n(the .ino file you write)"]:::you ARDUINO_LIB["Arduino core library\n(~10,000 lines of C++)\ndigitalWrite(), analogRead(), delay(),\nSerial, Wire, SPI...\nSomebody wrote these — in C++ and assembly"]:::layer AVR_LIBC["avr-libc\n(C standard library for AVR)\nmalloc, printf, string functions\nStartup code that runs before main()"]:::layer AVR_GCC["avr-gcc compiler + avr-as assembler\nConverts C++ → Assembly → Machine code\nThe same tools you will use directly"]:::layer REGISTERS["AVR peripheral registers\nTCCR0A, OCR1A, UBRR0, DDRD...\nThe same registers you set in one line of C\nbut digitalWrite() sets in 10+ lines of C++\nto handle all possible cases"]:::hw SILICON["ATmega328P silicon\nTransistors, gates, flip-flops\nExecutes one instruction every 62.5 ns"]:::hw YOU --> ARDUINO_LIB --> AVR_LIBC --> AVR_GCC --> REGISTERS --> SILICON

When you call digitalWrite(13, HIGH), the Arduino library:

  1. Looks up pin 13 in a constant table to find it is on Port B, bit 5
  2. Reads the current DDRB register to check direction
  3. Reads PORTB, sets bit 5, writes PORTB back
  4. This takes about 50 clock cycles — 3.1 µs at 16 MHz

The direct register write PORTB |= (1 << PB5) takes 2 clock cycles — 125 ns. That is 25× faster.

For blinking an LED, this does not matter. For generating a precise PWM signal, driving a WS2812 LED strip (which requires bit-banging at 800 kHz with ±150 ns timing tolerance), or implementing a custom communication protocol, it is the difference between working and not working.


What You Will Be Able to Do After This Lesson

flowchart TD classDef before fill:#fee2e2,stroke:#dc2626,color:#7f1d1d classDef after fill:#dcfce7,stroke:#16a34a,color:#14532d classDef arrow fill:#f1f5f9,stroke:#475569,color:#1e293b B1["❌ See LDI R16, 5 and have no idea what it means"]:::before B2["❌ Compiler disassembly output looks like random hex"]:::before B3["❌ Cannot explain why delay() blocks everything"]:::before B4["❌ Cannot write an ISR that runs in a known number of cycles"]:::before B5["❌ Spending days guessing at timing bugs"]:::before ARROW["Learn assembly →"]:::arrow A1["✅ Read and write basic AVR assembly instructions"]:::after A2["✅ Read disassembly and know exactly what the compiler did"]:::after A3["✅ Explain a delay loop cycle by cycle"]:::after A4["✅ Write a short ISR with a guaranteed execution time"]:::after A5["✅ Diagnose timing bugs by counting clock cycles"]:::after B1 --> ARROW B2 --> ARROW B3 --> ARROW B4 --> ARROW B5 --> ARROW ARROW --> A1 ARROW --> A2 ARROW --> A3 ARROW --> A4 ARROW --> A5

Every program you write — in C, Python, or Rust — eventually becomes assembly. Assembly is the language of the processor: one instruction, one operation, zero abstraction.

You do not need to write all your firmware in assembly. But you cannot be a good embedded engineer without understanding it. When a timing-critical interrupt needs to execute in exactly 4 cycles, or when a bug only manifests in the compiled output, assembly is where the answer lives.


What Is Assembly Language?

flowchart TD classDef src fill:#e0e7ff,stroke:#4338ca,color:#312e81 classDef tool fill:#f3e8ff,stroke:#9333ea,color:#581c87 classDef out fill:#dcfce7,stroke:#16a34a,color:#14532d classDef hw fill:#fee2e2,stroke:#dc2626,color:#7f1d1d C_CODE["C Source Code\nint a = 5;\nint b = 3;\nint c = a + b;"]:::src COMPILER["C Compiler (avr-gcc)\nreads C, outputs assembly"]:::tool ASM_CODE["Assembly Language\nLDI R16, 5\nLDI R17, 3\nADD R18, R16\nhuman-readable instructions"]:::src ASSEMBLER["Assembler (avr-as)\nconverts mnemonics to binary"]:::tool HEX["Machine Code (hex file)\nE505 E303 2F12\npure binary — only thing\nthe CPU understands"]:::out MCU2["Microcontroller Flash Memory\nprogrammer writes hex into chip"]:::hw C_CODE -->|"compile"| COMPILER COMPILER -->|"output"| ASM_CODE ASM_CODE -->|"assemble"| ASSEMBLER ASSEMBLER -->|"output"| HEX HEX -->|"flash / program"| MCU2

Assembly is a human-readable representation of machine code. Each assembly instruction corresponds to exactly one machine code instruction (one row of bits in Flash memory).

  • LDI R16, 5 — Load Immediate: put the value 5 into register R16
  • The assembler converts this to the binary pattern 1110 0000 0001 0101 (0xE015 in hex)
  • The CPU reads 0xE015 from Flash and executes it

Anatomy of an Assembly Instruction

flowchart TD classDef lbl fill:#ffedd5,stroke:#ea580c,color:#9a3412 classDef mnem fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a classDef op fill:#dcfce7,stroke:#16a34a,color:#14532d classDef com fill:#f1f5f9,stroke:#475569,color:#1e293b INSTR["loop: ADD R18, R16 ; add a to accumulator"] LABEL["loop:\nLabel — optional\nmarks this address\nso jumps can reference it"]:::lbl MNEM["ADD\nMnemonic — the operation\nhuman-readable opcode"]:::mnem DEST["R18\nDestination operand\n(result written here)"]:::op SRC["R16\nSource operand\n(value read from here)"]:::op COMMENT["; add a to accumulator\nComment — ignored\nby assembler"]:::com INSTR --> LABEL INSTR --> MNEM INSTR --> DEST INSTR --> SRC INSTR --> COMMENT

AVR Instruction Categories

The AVR instruction set has about 130 instructions grouped into categories:

flowchart TD classDef data fill:#dbeafe,stroke:#1565c0,color:#1e3a8a classDef arith2 fill:#dcfce7,stroke:#16a34a,color:#14532d classDef branch fill:#f3e8ff,stroke:#9333ea,color:#581c87 classDef bit fill:#ffedd5,stroke:#ea580c,color:#9a3412 classDef misc fill:#f1f5f9,stroke:#475569,color:#1e293b subgraph DATA ["📦 Data Transfer"] MOV["MOV Rd, Rs\nCopy register to register"]:::data LDI2["LDI Rd, K\nLoad immediate constant"]:::data LD["LD Rd, X/Y/Z\nLoad from RAM via pointer"]:::data ST["ST X/Y/Z, Rs\nStore to RAM via pointer"]:::data PUSH2["PUSH Rr\nPush register onto stack"]:::data POP2["POP Rd\nPop register from stack"]:::data IN2["IN Rd, A\nRead I/O register"]:::data OUT2["OUT A, Rr\nWrite I/O register"]:::data end subgraph ARITH3 ["🔢 Arithmetic"] ADD2["ADD Rd, Rs\nAdd registers"]:::arith2 SUB2["SUB Rd, Rs\nSubtract registers"]:::arith2 INC2["INC Rd\nIncrement by 1"]:::arith2 DEC2["DEC Rd\nDecrement by 1"]:::arith2 MUL3["MUL Rd, Rs\nMultiply (result in R1:R0)"]:::arith2 SUBI["SUBI Rd, K\nSubtract immediate"]:::arith2 end subgraph BRANCH ["🔀 Branch / Jump"] RJMP["RJMP label\nRelative jump (±2KB)"]:::branch JMP["JMP addr\nAbsolute jump (any address)"]:::branch CALL2["CALL addr\nCall subroutine"]:::branch RET2["RET\nReturn from subroutine"]:::branch BRNE2["BRNE label\nBranch if Z flag = 0"]:::branch BREQ2["BREQ label\nBranch if Z flag = 1"]:::branch BRCS["BRCS label\nBranch if Carry set"]:::branch end subgraph BIT2 ["🔧 Bit Manipulation"] SBI2["SBI A, b\nSet bit in I/O register"]:::bit CBI2["CBI A, b\nClear bit in I/O register"]:::bit LSL2["LSL Rd\nLogical shift left (× 2)"]:::bit LSR2["LSR Rd\nLogical shift right (÷ 2)"]:::bit AND3["AND Rd, Rs\nBitwise AND (clear bits)"]:::bit OR2["OR Rd, Rs\nBitwise OR (set bits)"]:::bit end

This is the AVR assembly equivalent of the classic Arduino blink program. Let's trace every instruction:

flowchart TD classDef init fill:#e0e7ff,stroke:#4338ca,color:#312e81 classDef loop2 fill:#dcfce7,stroke:#16a34a,color:#14532d classDef delay fill:#f3e8ff,stroke:#9333ea,color:#581c87 classDef io2 fill:#fee2e2,stroke:#dc2626,color:#7f1d1d START2["Reset vector\nExecution begins here"]:::init STACK2["LDI R16, HIGH(RAMEND)\nOUT SPH, R16\nLDI R16, LOW(RAMEND)\nOUT SPL, R16\nInitialize Stack Pointer"]:::init DIR["SBI DDRB, 5\nSet PB5 as output\n(bit 5 of Data Direction Register B)"]:::init LOOP2["loop:"]:::loop2 ON["SBI PORTB, 5\nSet PB5 HIGH → LED ON"]:::io2 DLY1["CALL delay_500ms\nWait 500 ms"]:::delay OFF["CBI PORTB, 5\nClear PB5 LOW → LED OFF"]:::io2 DLY2["CALL delay_500ms\nWait 500 ms"]:::delay AGAIN["RJMP loop\nJump back to loop forever"]:::loop2 START2 --> STACK2 STACK2 --> DIR DIR --> LOOP2 LOOP2 --> ON ON --> DLY1 DLY1 --> OFF OFF --> DLY2 DLY2 --> AGAIN AGAIN --> LOOP2

In actual AVR assembly:

; Blink LED on PB5 — ATmega328P

.include "m328Pdef.inc"       ; Include register definitions

.cseg                          ; Code segment (goes into Flash)
.org 0x0000                    ; Start at reset vector

    ; --- Initialize Stack Pointer ---
    LDI   R16, HIGH(RAMEND)   ; RAMEND = 0x08FF for ATmega328P
    OUT   SPH, R16             ; Stack Pointer High byte
    LDI   R16, LOW(RAMEND)
    OUT   SPL, R16             ; Stack Pointer Low byte

    ; --- Set PB5 as output ---
    SBI   DDRB, 5              ; Set bit 5 of DDRB = output direction

loop:
    SBI   PORTB, 5             ; PB5 = HIGH (LED on)
    CALL  delay_500ms
    CBI   PORTB, 5             ; PB5 = LOW (LED off)
    CALL  delay_500ms
    RJMP  loop                 ; repeat forever

How a Delay Loop Works in Assembly

Software delay loops are a core embedded technique — you burn cycles counting down a register:

flowchart TD classDef init2 fill:#dbeafe,stroke:#1565c0,color:#1e3a8a classDef dec fill:#ffedd5,stroke:#ea580c,color:#9a3412 classDef ret3 fill:#f1f5f9,stroke:#475569,color:#1e293b classDef calc fill:#fee2e2,stroke:#dc2626,color:#7f1d1d ENTER["CALL delay_500ms\nCPU pushes return address onto stack\nthen jumps here"]:::init2 LOAD["LDI R20, 122\nLDI R19, 0\nLDI R18, 0\nLoad the three countdown counters"]:::init2 subgraph OUTER_LOOP ["🔄 Outer loop — runs R20 times (122 iterations)"] RESET_R19["Reset R19 = 0 at start of each outer pass"]:::dec subgraph MID_LOOP ["🔄 Middle loop — runs 256 times (R19 wraps 0→255→0)"] RESET_R18["Reset R18 = 0 at start of each middle pass"]:::dec subgraph INNER_LOOP ["🔄 Inner loop — runs 256 times (R18 wraps 0→255→0)"] DEC18["DEC R18\nBRNE ↑ (stay inside if R18 ≠ 0)\n256 × 2 cycles = 512 cycles per inner pass"]:::dec end DEC19["DEC R19\nBRNE ↑ (run inner loop again if R19 ≠ 0)\n256 inner passes × 512 cycles = 131 072 cycles"]:::dec RESET_R18 --> DEC18 DEC18 --> DEC19 end DEC20["DEC R20\nBRNE ↑ (run middle loop again if R20 ≠ 0)\n122 middle passes × 131 072 cycles = ~16 M cycles"]:::dec RESET_R19 --> RESET_R18 DEC19 --> DEC20 end CALC["Total ≈ 122 × 256 × 256 × 2 cycles\n= ~16 million cycles\nAt 16 MHz → ~1 second\nChange R20 to adjust: R20=61 → 500 ms, R20=12 → 100 ms"]:::calc RETURN["RET\nPops return address from stack\nCPU jumps back to caller"]:::ret3 ENTER --> LOAD LOAD --> RESET_R19 DEC20 --> CALC CALC --> RETURN

Assembly vs C — The Same Operation

Seeing C and assembly side by side is the fastest way to understand what the compiler does:

flowchart LR classDef c fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a classDef asm2 fill:#dcfce7,stroke:#16a34a,color:#14532d subgraph C_SIDE ["C Code"] C1["int a = 5;"]:::c C2["int b = 3;"]:::c C3["int c = a + b;"]:::c C4["if (c > 6) {"]:::c C5[" PORTB |= (1<<5);"]:::c C6["}"]:::c end subgraph ASM_SIDE ["AVR Assembly"] A1["LDI R16, 5 ; R16 = 5"]:::asm2 A2["LDI R17, 3 ; R17 = 3"]:::asm2 A3["MOV R18, R16 ; R18 = a\nADD R18, R17 ; R18 = a+b"]:::asm2 A4["CPI R18, 7 ; compare R18 with 7"]:::asm2 A5["BRLO skip ; branch if R18 < 7 (unsigned lower)"]:::asm2 A6["SBI PORTB, 5 ; set bit 5 of PORTB"]:::asm2 A7["skip: ; label — branch lands here"]:::asm2 end C1 -.->|compiles to| A1 C2 -.->|compiles to| A2 C3 -.->|compiles to| A3 C4 -.->|compiles to| A4 C4 -.->|compiles to| A5 C5 -.->|compiles to| A6

[!TIP] Use avr-objdump -d your_program.elf to see the disassembly of your compiled C code. This is invaluable for debugging timing issues or understanding what the compiler generated.


Addressing Modes

How an instruction finds its operand is called the addressing mode. Different modes give different power and flexibility:

flowchart TD classDef mode fill:#f3e8ff,stroke:#9333ea,color:#581c87 classDef ex fill:#f1f5f9,stroke:#475569,color:#1e293b IMM["Immediate\nLDI R16, 42\nValue encoded directly\nin the instruction"]:::mode REG3["Register Direct\nADD R18, R16\nOperand is in a register\n(fastest — 1 cycle)"]:::mode INDIR["Register Indirect\nLD R16, X\nX register holds\nthe RAM address to read"]:::mode INDIR_PLUS["Register Indirect\nwith Post-Increment\nLD R16, X+\nRead from X, then X++\n(great for arrays)"]:::mode DISP["Register Indirect\nwith Displacement\nLDD R16, Y+4\nRead from address Y+4\n(struct member access)"]:::mode DIRECT["Direct / Absolute\nSTS 0x0200, R16\nWrite R16 to fixed\nmemory address"]:::mode IMM --> REG3 REG3 --> INDIR INDIR --> INDIR_PLUS INDIR_PLUS --> DISP DISP --> DIRECT

The compiler automatically chooses the right addressing mode. When you see array[i] in C, it compiles to register indirect with post-increment. When you access struct.member, it uses displacement.


Reading a Disassembly Listing

When debugging, you will often look at disassembly output. Here is how to read it:

0000010a <main>:          ← function name and its address in Flash
 10a: cf 93              push r28      ← save caller's register
 10c: df 93              push r29
 10e: cd b7              in r28, 0x3d  ← read SPL into r28
 110: de b7              in r29, 0x3e  ← read SPH into r29
 112: e5 e0              ldi r30, 0x05 ← r30 = 5 (variable a)
 114: f3 e0              ldi r31, 0x03 ← r31 = 3 (variable b)
 116: ef 0f              add r30, r31  ← r30 = r30 + r31 = 8 (c = a+b)

Column 1: Flash address (hex). Column 2: Machine code bytes. Column 3+: Assembly mnemonic.


Worked Example: Counting a Tiny Delay

A timing loop is only useful when you can count it:

    ldi  r18, 200      ; 1 cycle
delay:
    dec  r18           ; 1 cycle
    brne delay         ; 2 cycles when taken, 1 cycle when not taken

For 200 iterations:

cycles = 1 + (199 x (1 + 2)) + (1 + 1)
cycles = 1 + 597 + 2 = 600 cycles

At F_CPU = 16 MHz, one cycle is 1 / 16,000,000 = 62.5 ns.

time = 600 x 62.5 ns = 37.5 us

This is why assembly still matters: you can look at a loop, count cycles, and know whether it can meet a real timing budget. For long waits or concurrent work, prefer a hardware timer interrupt instead of burning CPU cycles in a loop.


Common Mistakes

Mistake Why it causes trouble Better habit
Treating Arduino functions as single instructions Calls like digitalWrite() contain many branches and register operations Inspect disassembly when timing matters
Forgetting volatile for ISR-shared data Compiler may cache a value in a register Mark shared hardware/ISR variables carefully
Counting only loop body instructions Branch taken/not-taken timing differs Count the final iteration separately
Writing assembly without comments Intent disappears faster than in C Comment registers, units, and hardware registers
Using software delay for all timing It blocks the CPU and breaks concurrency Use timers, compare matches, and interrupts

Summary


Further Reading

  • Microchip, AVR Instruction Set Manual, for instruction syntax, flags, and cycle counts.
  • Microchip, ATmega328P Datasheet, for I/O register names, timers, interrupts, and memory map.
  • avr-libc documentation, especially startup code, register definitions, and interrupt support.
  • GNU Binutils documentation for avr-as and avr-objdump.

Mind Map

mindmap root((Assembly Language)) Core idea Human machine code One instruction one operation Closest to hardware Why learn it Timing bugs ISR speed Code size Disassembly debug Library internals AVR basics Registers R0 to R31 I/O registers Labels Mnemonics Operands Timing Cycle equals 1 over F_CPU Delay cycles count branches Hardware timers for long waits Interrupt latency matters Toolchain C compiler Assembler Linker Hex file Flash programmer Common mistakes Blind library use Bad cycle count Missing volatile Wrong register comment

Next: CISC vs RISC — 8051 vs AVR →