Microprocessors & Microcontrollers

Chapter 5
Assembly-Language Programming

Bachelors Program in Electrical Engineering
Microprocessors & Microcontrollers   •   Bachelor

Assembly-Language Programming

Introduction

Assembly language exposes the processor’s registers, instructions, memory accesses, status flags, and calling conventions directly. This visibility makes it valuable for startup code, interrupt handling, device drivers, deterministic control, optimized signal processing, and debugging compiler-generated programs. It also imposes responsibility: the programmer must preserve registers correctly, maintain stack alignment, prevent buffer violations, and respect the target instruction set.

This chapter uses Arm Cortex-M–style examples in GNU assembler unified syntax. Most examples are compatible with ARMv6-M processors such as Cortex-M0 and Cortex-M0+, but an assembler must still be given the correct architecture and processor options. ARMv6-M supports both CBZ/CBNZ and IT; however, their encoding and use are restricted. For example, CBZ and CBNZ operate on low registers and branch only forward over a limited range, while conditional instructions in an IT block must satisfy architectural placement rules. Instructions such as MOVW, MOVT, UDIV, SDIV, and many Thumb-2 operations are not available on ARMv6-M. Always verify the architecture reference and assembler diagnostics rather than assuming that code for a larger Cortex-M processor will execute on Cortex-M0/M0+.

Learning Objectives

After completing this chapter, the reader should be able to:

  • translate structured algorithms into assembly-language control flow;
  • distinguish instructions, labels, directives, constants, and macros;
  • implement loops, arrays, lookup tables, and arithmetic operations;
  • construct subroutines that comply with an application binary interface;
  • use full-descending stacks for saved registers, parameters, and local storage;
  • pass parameters through registers and the stack;
  • interface assembly routines with C;
  • analyze overflow, alignment, reentrancy, and instruction-set limitations;
  • debug register, memory, stack, and control-flow faults systematically.

1. From Source Text to Executable Code

An assembly source file contains more than processor instructions. An assembler translates mnemonics into machine code, evaluates expressions, records symbols, and emits object-file sections and relocation information. A linker then combines object files, resolves external symbols, places sections according to a linker script, and creates an executable image.

ElementPurposeExample
InstructionRequests a processor operationadds r0, r0, r1
LabelNames an addressloop:
DirectiveControls the assembler.section .text
Constant definitionGives a value a symbolic name.equ N, 16
CommentDocuments intent@ increment index
MacroGenerates repeated source text.macro SAVE_CONTEXT

A typical GNU assembler module is:

.syntax unified
.cpu cortex-m0
.thumb

.section .text
.align 2
.global add_u32
.type add_u32, %function

add_u32:
    adds r0, r0, r1
    bx   lr

.size add_u32, .-add_u32

.global exports a symbol to the linker, .type records its function type, and .size records its extent. .align requirements are assembler-dependent; source portability therefore requires checking the toolchain manual. Data can be placed with directives such as .byte, .hword, .word, .ascii, and .asciz. Reserve uninitialized storage using an appropriate zero-fill directive or a symbol in a .bss section.

Location and relocation

The assembler may not know a symbol’s final address. It emits a relocation so that the linker can complete the encoding. Pseudo-instructions such as ldr r0, =symbol may generate a PC-relative load from a literal pool rather than a single instruction that constructs the complete address. Literal pools must be within the range supported by the selected encoding.

2. Registers, Flags, and Data Representation

In the common 32-bit Cortex-M programming model, r0–r12 are general-purpose registers, sp is the stack pointer, lr is the link register, and pc is the program counter. Under the Arm Procedure Call Standard, r0–r3 normally carry the first four arguments and return values. Registers r4–r11 are callee-saved, although exact availability and instruction access vary by architecture. r12 is a caller-saved scratch register.

Arithmetic instructions may update the APSR condition flags:

  • N: the result’s most significant bit is 1;
  • Z: the result is zero;
  • C: unsigned carry from addition or no borrow from subtraction;
  • V: signed two’s-complement overflow.

Unsigned and signed comparisons interpret the same bits differently. For unsigned ordering, branches use the carry and zero flags; for signed ordering, they use relationships involving N, V, and Z. Confusing BHI with BGT, for example, can make a loop fail only for values with bit 31 set.

For two unsigned w-bit operands a and b, addition can require w+1 bits:

a + b ≤ 2(2w - 1) = 2w+1 - 2

For an unsigned sum of N samples, each in the range 0 ≤ xi ≤ 2b-1, the maximum is:

Smax = N(2b - 1)

The required unsigned width is:

W = ⌈log2(Smax+1)⌉

These equations assume exact nonnegative integer samples and no intermediate saturation.

3. Structured Control Flow

Assembly has labels and branches rather than language-level if, while, and for statements. Reliable code nevertheless retains the same structures: sequence, selection, and iteration.

Selection

The pseudocode if (x < limit) x++; for unsigned values can be written:

cmp  r0, r1          @ x compared with limit
    bhs  done            @ unsigned x >= limit
    adds r0, r0, #1
done:

On ARMv6-M, CBZ and CBNZ are available for testing a low register against zero. They use a short forward branch, so a backward loop branch still requires another branch form. The IT instruction is also part of ARMv6-M, but conditionalized instructions must obey IT-block restrictions. Explicit branches are often clearer and easier to maintain.

Counted loops

A safe loop establishes its invariant, bound, and termination condition explicitly:

@ uint32_t sum_u8(const uint8_t *p, uint32_t n)
sum_u8:
    movs r2, #0          @ sum = 0
loop_test:
    cmp  r1, #0
    beq  loop_done
    ldrb r3, [r0]
    adds r2, r2, r3
    adds r0, r0, #1
    subs r1, r1, #1
    b    loop_test
loop_done:
    mov  r0, r2
    bx   lr

The loop invariant is that r2 equals the sum of bytes already visited, r0 points to the next byte, and r1 is the number remaining. The routine is mathematically safe from 32-bit unsigned overflow only when 255N ≤ 232-1, giving N ≤ 16,843,009. A real interface should specify a smaller accepted bound or use a wider accumulation method.

Sentinel loops

Sentinel loops terminate when a distinguished value is encountered. They require a guarantee that the sentinel exists within accessible memory; otherwise, the routine can read beyond an array. Length-bounded interfaces are usually safer for embedded software.

4. Addressing Arrays and Lookup Tables

An array address follows:

Ai = A0 + i × s

Here A0 is the base byte address, i is the zero-based index, and s is the element size in bytes. For a word table, s=4.

@ r0 = base, r1 = word index; returns table[index]
lookup_word:
    lsls r1, r1, #2      @ byte offset = index × 4
    ldr  r0, [r0, r1]
    bx   lr

This routine assumes the caller has verified the index and that the resulting address satisfies the processor and system’s access requirements. A checked interface can reject invalid indices:

@ r0 = base, r1 = index, r2 = length
@ returns value in r0 and status in r1: 0 success, 1 error
lookup_checked:
    cmp  r1, r2
    bhs  bad_index
    ldrb r0, [r0, r1]
    movs r1, #0
    bx   lr
bad_index:
    movs r0, #0
    movs r1, #1
    bx   lr

Lookup tables trade memory for execution time and deterministic behavior. They are useful for waveform generation, calibration, nonlinear sensor conversion, CRC algorithms, and state machines. Their risks include unchecked indexing, stale calibration data, endianness assumptions, and interpolation error.

5. Subroutines and the Application Binary Interface

A subroutine call must satisfy both instruction semantics and the platform’s application binary interface (ABI). On Cortex-M, BL target writes a return address into lr; BX lr returns from a leaf routine. A non-leaf function that calls another function must preserve its own return address, normally by saving lr on the stack.

Under the commonly used Arm AAPCS:

ResourceTypical role
r0–r3Arguments, result, caller-saved temporaries
r4–r11Callee-saved registers
r12Caller-saved scratch register
lrLink register
spStack pointer

If a function modifies a callee-saved register, it must restore the original value before returning. Caller-saved registers can be destroyed by a called function, so a caller must preserve any live values it still needs.

@ uint32_t weighted_sum(uint32_t a, uint32_t b)
@ returns 3a + b
weighted_sum:
    push {r4, lr}
    mov  r4, r0
    adds r0, r0, r4      @ 2a
    adds r0, r0, r4      @ 3a
    adds r0, r0, r1      @ 3a + b
    pop  {r4, pc}

The lr save is unnecessary for this leaf implementation, but the example demonstrates a valid preserved-register frame. Production code would normally omit unnecessary stack traffic.

6. Full-Descending Stack Operation

Cortex-M software normally uses a full-descending stack. “Descending” means allocation proceeds toward lower addresses. “Full” means sp identifies the lowest occupied address: after a push, it points to the newly occupied stack word. A pop reads from the address identified by sp and then raises sp.

Full-descending stack with high and low addresses High addresses Low addresses Stack grows toward lower addresses Unused stack space Earlier data Saved register Newest word SP: lowest occupied address Available below
Full-descending stack with high and low addresses
Figure: Full-descending stack with high and low addresses

Figure: In a full-descending stack, SP identifies the lowest occupied address and growth is toward lower addresses.

The stack must remain within its allocated region. Excess recursion, large local buffers, nested interrupts, or corrupted return addresses can cause stack overflow. Embedded systems often use linker-defined limits, guard regions, fill-pattern high-water measurements, or an MPU where available.

7. Stack-Passed Parameters and Local Variables

Passing more than four arguments

Under AAPCS, the first four 32-bit arguments normally use r0–r3; additional arguments are placed on the stack. At a public interface, the stack must satisfy the ABI’s alignment rule, commonly 8-byte alignment. The following caller allocates eight bytes so that one 4-byte stack argument does not break alignment:

@ Call sum5(1, 2, 3, 4, 5)
    sub  sp, sp, #8
    movs r0, #5
    str  r0, [sp, #0]    @ fifth argument; [sp,#4] is padding
    movs r0, #1
    movs r1, #2
    movs r2, #3
    movs r3, #4
    bl   sum5
    add  sp, sp, #8      @ caller removes stacked argument and padding

At entry to sum5, the fifth argument is at [sp,#0]. Once the callee pushes two registers, sp decreases by eight bytes, so the argument’s address becomes [sp,#8]:

@ uint32_t sum5(uint32_t a, uint32_t b, uint32_t c,
@               uint32_t d, uint32_t e)
sum5:
    push {r4, lr}        @ 8-byte frame
    ldr  r4, [sp, #8]    @ e at original entry SP
    adds r0, r0, r1
    adds r0, r0, r2
    adds r0, r0, r3
    adds r0, r0, r4
    pop  {r4, pc}

The offset must be derived from the actual frame size, not memorized. If more registers or local bytes are added, the offset changes.

Explicit local-stack allocation

A function can reserve automatic local storage by subtracting from sp. Every exit path must release exactly the same amount before restoring saved registers.

@ Illustrative non-leaf function with 16 bytes of local storage
process_block:
    push {r4, r5, r6, lr} @ 16 bytes; preserves alignment
    sub  sp, sp, #16       @ local byte buffer at [sp,#0]...[sp,#15]

    mov  r4, sp            @ r4 = address of local buffer
    movs r0, #0
    str  r0, [sp, #0]      @ local word 0
    str  r0, [sp, #4]      @ local word 1

    mov  r0, r4            @ first argument: buffer address
    movs r1, #16           @ second argument: length
    bl   fill_buffer

    ldr  r0, [sp, #0]      @ use a local result
    add  sp, sp, #16       @ deallocate locals
    pop  {r4, r5, r6, pc}

The total frame is 32 bytes: 16 bytes of saved registers plus 16 bytes of locals. Stack-resident buffers must not escape after return because subsequent calls may overwrite them. Large arrays are often better placed statically or supplied by the caller, particularly when stack capacity is small.

A frame pointer can provide stable offsets when sp changes within a function, but it consumes a register. Many embedded functions instead use a fixed frame size and constant sp-relative offsets.

8. Macros and Reusable Source Patterns

A macro is expanded by the assembler; a subroutine executes at run time. Macros avoid call overhead and permit parameterized instruction sequences, but every expansion consumes code space.

.macro INC_SAT_U8 reg, temp
    cmp  \reg, #255
    beq  1f
    adds \reg, \reg, #1
1:
.endm

Local labels or assembler-provided unique suffixes should be used so that multiple expansions do not define the same global label. Macro parameters must be documented with register-clobber behavior. Complex algorithmic macros are difficult to debug because source lines may expand into many instructions.

Use a macro whenUse a subroutine when
The sequence is very shortCode size matters
Compile-time variation is neededBehavior is reused without variation
Call overhead is unacceptableA stable ABI boundary is desirable
Inline context manipulation is requiredDebugging and maintenance dominate

9. Mixed-Language Interfaces

Assembly and C can interoperate reliably only when both follow the same ABI. The assembly author must know symbol naming, parameter locations, return-value rules, register preservation, stack alignment, data layout, and instruction-state requirements.

C declaration:

#include <stdint.h>
extern uint32_t dot_u16(const uint16_t *a,
                        const uint16_t *b,
                        uint32_t n);

Assembly skeleton:

.global dot_u16
.type dot_u16, %function
dot_u16:
    push {r4, r5, r6, lr}
    movs r3, #0           @ low 32-bit accumulator
loop_dot:
    cmp  r2, #0
    beq  done_dot
    ldrh r4, [r0]
    ldrh r5, [r1]
    muls r4, r5, r4
    adds r3, r3, r4
    adds r0, r0, #2
    adds r1, r1, #2
    subs r2, r2, #1
    b    loop_dot
done_dot:
    mov  r0, r3
    pop  {r4, r5, r6, pc}

This implementation returns the low 32 bits of the mathematical dot product; it does not detect overflow. Since each product can be as large as 65,5352 = 4,294,836,225, even two terms can overflow 32 bits. The interface must therefore impose an input bound, use a wider accumulator, or define modular arithmetic explicitly.

For C structures, do not guess member offsets: use shared generated constants, assembler-compatible headers, or verified static assertions. C objects modified asynchronously by hardware or an interrupt may require volatile access at the C level, but volatile alone does not provide atomicity or memory ordering.

10. Debugging Assembly Programs

Assembly debugging should proceed from observable machine state rather than from source appearance.

  1. Confirm the image: inspect the map file and disassembly to verify symbol addresses and encodings.
  2. Check entry conditions: record argument registers, sp, lr, and relevant memory.
  3. Single-step critical instructions: observe changed registers and APSR flags.
  4. Use watchpoints: detect unexpected writes to arrays, stack frames, or peripherals.
  5. Validate stack symmetry: every allocation, push, pop, and deallocation must balance along every path.
  6. Inspect control flow: confirm branch conditions use signed or unsigned semantics as intended.
  7. Test boundaries: include zero length, one element, maximum index, maximum arithmetic values, and invalid pointers where the interface defines behavior.

Common faults include loading an address instead of the data at that address, using a byte load for a word object, failing to preserve r4–r11, returning through a corrupted lr, violating stack alignment, and reading memory-mapped registers with ordinary assumptions about repeatability.

A useful stack invariant is:

SPexit = SPentry

This applies to a normally returning function after its own frame has been removed. It does not mean that sp remains unchanged during execution.

11. Applied Case: ADC Block Averaging and Calibration

Background

Consider an illustrative battery-monitoring device with a 12-bit ADC. A block contains 1,024 unsigned samples. Firmware must compute the integer mean and map it through a 256-entry calibration table.

Problem analysis

Each sample lies in 0 ≤ xi ≤ 4095. The maximum block sum is:

Smax = 1024 × 4095 = 4,193,280

Since 221=2,097,152 and 222=4,194,304, the maximum sum requires 22 unsigned bits. It therefore fits safely in a 32-bit register. Because 1,024 is 210, truncating unsigned division by 1,024 is a right shift by ten bits:

mean = ⌊S / 1024⌋ = S >> 10

This special shift replacement is valid because the divisor is a positive power of two and the operands are unsigned. Signed rounding or division by a non-power-of-two requires different treatment.

Approach

@ r0 = pointer to 1024 aligned or safely accessible uint16_t samples
@ returns truncated mean in r0
adc_mean_1024:
    movs r1, #0           @ accumulator
    movs r2, #1
    lsls r2, r2, #10      @ count = 1024
adc_loop:
    ldrh r3, [r0]
    adds r1, r1, r3
    adds r0, r0, #2
    subs r2, r2, #1
    bne  adc_loop
    lsrs r0, r1, #10
    bx   lr

To select one of 256 calibration entries from the 12-bit mean, the high eight ADC bits can form an index:

index = mean >> 4

Because 0 ≤ mean ≤ 4095, the index lies from 0 to 255. This direct mapping is valid only if the table was constructed for those uniform 16-code bins. Higher accuracy may require interpolation using the lower four bits.

Outcome

The accumulator cannot overflow for the specified block, the loop executes a fixed number of iterations, and the index is proven to remain within the table. Timing still depends on memory-system behavior and interrupts. If the ADC buffer is concurrently filled by DMA, ownership or double buffering is required so that data are not modified while being summed.

Learning points

  • derive accumulator width from the input range and count;
  • prove table bounds before loading data;
  • document truncation rather than silently assuming rounding;
  • treat concurrent data acquisition as a synchronization problem;
  • test all-zero, all-maximum, alternating, and ramp input blocks.

12. Reliability, Security, and Professional Practice

Assembly can bypass safeguards normally introduced by compilers. In safety- or security-related systems, review should cover memory bounds, worst-case stack usage, execution timing, integer overflow, fault handling, and compliance with organizational coding standards. Peripheral writes can have physical consequences; tests should begin with simulation, emulation, or controlled hardware where hazardous actuation is inhibited.

AI-assisted code generation and optimization tools may propose plausible but architecture-incompatible instructions or violate an ABI invisibly. Generated assembly must be checked against the selected architecture, disassembled, boundary-tested, and reviewed like manually written code. Tools do not remove engineering accountability.

Contemporary compilers often outperform hand assembly for general code. Assembly is best reserved for demonstrated needs: startup sequences, context switching, specialized instructions, exact timing, ABI stubs, or measured optimization bottlenecks. Maintain a reference implementation in C where practical and compare outputs automatically.

Chapter Summary

Assembly-language programming connects algorithms directly to processor state. Assemblers translate instructions and directives into relocatable objects, while linkers resolve symbols and place code and data. Structured branches and loops remain essential even when language-level constructs disappear. Correct subroutines obey the ABI, preserve callee-saved registers, maintain stack alignment, and balance every stack operation. A full-descending stack grows toward lower addresses, with sp identifying the lowest occupied address. Arguments beyond register capacity and automatic local objects require carefully derived stack offsets. Lookup tables, macros, and mixed-language routines can improve performance and reuse, but they introduce bounds, code-size, and interface risks. Reliable assembly development therefore depends on numerical range analysis, explicit contracts, disassembly inspection, stack checking, boundary testing, and disciplined debugging.

Key Terms

Assembler: A tool that translates assembly source into machine-code object files.

Directive: A command to the assembler rather than an instruction executed by the processor.

Label: A symbolic name associated with an address.

Relocation: Information allowing the linker to complete an address-dependent encoding.

Literal pool: Nearby memory containing constants loaded through PC-relative addressing.

ABI: Rules governing calls, registers, stack use, data layout, and binary interoperability.

Caller-saved register: A register that a called routine may overwrite.

Callee-saved register: A register a routine must restore if it modifies it.

Link register: A register holding a subroutine return address.

Full-descending stack: A stack that grows toward lower addresses, with SP identifying the lowest occupied address.

Stack frame: Storage associated with one active routine invocation.

Frame pointer: A register used as a stable reference to a stack frame.

Loop invariant: A condition that remains true before and after each loop iteration.

Lookup table: Stored values indexed to replace or accelerate a computation.

Macro: A parameterized assembly-time source expansion.

Condition flags: Processor bits recording properties such as zero, carry, sign, and overflow.

Reentrant routine: A routine safe for overlapping invocations because it avoids unprotected shared mutable state.

Watchpoint: A debugger facility that halts execution when selected memory is accessed.

Stack alignment: The requirement that SP satisfy an address multiple at specified interfaces.

Review Questions

Short Answer Questions

  1. Distinguish an assembly instruction from an assembler directive.
  2. What is the difference between caller-saved and callee-saved registers?
  3. Why can a literal-load pseudo-instruction depend on a literal pool?
  4. What does SP identify in a full-descending stack?
  5. Why must signed and unsigned comparisons use different branch conditions?

Long Answer Questions

  1. Explain the complete translation process from an assembly source file to an executable embedded image.
  2. Describe how to design, document, and verify an ABI-compliant non-leaf subroutine.
  3. Compare macros and subroutines in terms of execution time, code size, maintainability, and debugging.
  4. Explain how stack-passed arguments and local variables are addressed after a function prologue.
  5. Develop a systematic procedure for diagnosing a control-flow fault caused by stack corruption.

Analytical / Application Questions

  1. Design an ARMv6-M routine that finds the maximum value in a nonempty unsigned 16-bit array. State its ABI, loop invariant, input assumptions, and boundary tests.
  2. A routine receives six 32-bit arguments and allocates 24 bytes of local storage after pushing four registers. Derive the offsets of the fifth and sixth arguments relative to the final SP, assuming they were at entry offsets 0 and 4.
  3. A data-acquisition system sums 2,000 unsigned 14-bit samples. Determine the maximum sum and minimum accumulator width, then propose tests that would expose overflow or loop-bound errors.

Multiple Choice Questions with Answers

Question 1

Which component normally resolves external symbols and places sections at final addresses?

A. Assembler preprocessor

B. Linker

C. Debugger

D. Instruction decoder

Correct Answer: B. Linker

Question 2

On an ARMv6-M processor, which statement is correct?

A. Neither CBZ nor IT is supported

B. CBZ is supported, but IT is not supported

C. CBZ and IT are supported, subject to architectural restrictions

D. UDIV is guaranteed to be supported with CBZ and IT

Correct Answer: C. CBZ and IT are supported, subject to architectural restrictions

Question 3

In a full-descending stack, SP identifies:

A. The highest unused address

B. The lowest occupied address

C. The function's first instruction

D. The caller's first argument in all cases

Correct Answer: B. The lowest occupied address

Question 4

A function pushes 8 bytes immediately after entry. A fifth argument was at [sp,#0] on entry. Where is it afterward?

A. [sp,#0]

B. [sp,#4]

C. [sp,#8]

D. [sp,#12]

Correct Answer: C. [sp,#8]

Question 5

What is the maximum sum of 1,024 unsigned 12-bit samples?

A. 4,193,280

B. 4,194,304

C. 2,097,152

D. 4,095,000

Correct Answer: A. 4,193,280

Question 6

How many unsigned bits are required to represent 4,193,280?

A. 20

B. 21

C. 22

D. 32

Correct Answer: C. 22

Question 7

Under the commonly used Arm AAPCS, which registers normally carry the first four 32-bit arguments?

A. r0–r3

B. r4–r7

C. r8–r11

D. sp, lr, pc, xPSR

Correct Answer: A. r0–r3

Question 8

Which practice is required when a subroutine modifies a callee-saved register?

A. Clear the register before returning

B. Return its new value in that register

C. Restore its original value before returning

D. Copy it to a caller-saved register after returning

Correct Answer: C. Restore its original value before returning

Question 9

For a 32-bit word lookup table, what byte offset corresponds to index i?

A. i

B. 2i

C. 4i

D. 8i

Correct Answer: C. 4i

Question 10

Which debugging check most directly detects an unbalanced local-stack allocation in a normally returning function?

A. Verify that the zero flag is clear

B. Verify that SPexit=SPentry

C. Verify that r0 contains zero

D. Verify that every loop uses CBZ

Correct Answer: B. Verify that SPexit=SPentry

Microprocessors & Microcontrollers
Chapter 5 of 14