Microprocessor Architecture

Chapter 4
Assembly Language and Machine-Level Programming

Bachelors Program in Electronics and Communication
Microprocessor Architecture   •   Bachelor

Assembly Language and Machine-Level Programming

Introduction

Assembly language exposes the programmer to the processor’s registers, instructions, memory accesses, control transfers, and calling conventions. It therefore connects architectural theory with executable software. Although compilers generate most production code, engineers still use assembly for startup routines, interrupt handlers, device drivers, digital-signal-processing kernels, security analysis, and performance-critical embedded functions.

This chapter uses the 32-bit RISC-V RV32IM architecture as its principal model. RV32I supplies the base integer instruction set, while the M extension adds multiplication and division. The underlying methods—encoding instructions, managing stacks, traversing arrays, and following an application binary interface—also apply to other instruction-set architectures (ISAs).

Learning Objectives

After completing this chapter, students should be able to:

  • translate algorithms into assembly instructions;
  • distinguish signed and unsigned arithmetic and branch conditions;
  • implement loops, arrays, subroutines, recursion, and stack frames;
  • interpret selected RV32I machine-code encodings;
  • use assemblers, linkers, disassemblers, debuggers, and simulators;
  • write reusable macros and interface assembly routines with C;
  • identify overflow, alignment, bounds, and division-by-zero hazards; and
  • optimize code without changing its externally observable behavior.

1. From Source Code to Execution

A processor executes machine instructions, not assembly mnemonics. The development chain converts symbolic source into an executable representation.

Assembly development toolchain Source.S / .s Assemblerobject file LinkerELF image Loadermemory image CPU or simulatorexecution Debugger and disassembler inspect symbols, instructions, registers, and memory.
Assembly development toolchain
Figure: Assembly development toolchain

Figure: Transformation of assembly source into an executing program.

The assembler translates mnemonics and directives into relocatable object code. The linker resolves symbols, applies relocations, and combines object files and libraries. A loader places segments in memory. A disassembler performs the reverse descriptive operation, translating instruction words into mnemonics. A debugger controls execution and inspects state, while a simulator models a target processor in software.

Assembly source contains both instructions and directives. Instructions such as add generate machine operations. Directives such as .text, .globl, .align, and .word guide the assembler but are not executed by the processor.

2. RV32IM Programmer’s Model

RV32 has 32 integer registers, each 32 bits wide. Register x0 always reads as zero, and writes to it are discarded. The ABI assigns conventional names and responsibilities.

RegistersABI namesTypical rolePreservation rule
x0zeroConstant zeroFixed
x1raReturn addressCaller-saved
x2spStack pointerCallee restores it
x5–x7, x28–x31t0–t6TemporariesCaller-saved
x8–x9, x18–x27s0–s11Saved registersCallee-saved
x10–x17a0–a7Arguments and resultsCaller-saved

The program counter identifies the current instruction. RV32I instructions are normally 32 bits long and must be aligned according to the implemented ISA rules. Data alignment is separate: a naturally aligned 32-bit word has an address divisible by four. Some systems trap on misaligned accesses; others emulate them with reduced performance.

2.1 Fundamental instruction classes

add  t0, t1, t2       # t0 = t1 + t2
sub  t0, t1, t2       # t0 = t1 - t2
addi t0, t1, 12       # signed 12-bit immediate
and  t0, t1, t2
slli t0, t1, 2        # logical left shift
lw   t0, 0(a0)        # load 32-bit word
sw   t0, 0(a0)        # store 32-bit word
beq  t0, t1, label
jal  ra, function     # call
jalr zero, 0(ra)      # return

RISC-V is a load/store architecture: arithmetic instructions operate on registers, while explicit load and store instructions access memory. Pseudoinstructions improve readability. For example, mv a0,t0 is assembled as addi a0,t0,0, and ret is assembled as jalr zero,0(ra).

3. Arithmetic, Flags, and Integer Interpretation

RV32 registers contain bit patterns; an instruction determines how those bits are interpreted. For a 32-bit unsigned integer, the range is 0 to 232-1. In two’s-complement signed interpretation, the range is -231 to 231-1.

Ordinary RV32 addition retains the low 32 bits:

r = (x + y) mod 232

Here, x and y are 32-bit operand patterns and r is the stored 32-bit result. The instruction does not independently report signed overflow. Software must test for overflow if it matters.

Signed and unsigned multiplication can require a 64-bit conceptual product. mul returns the low 32 bits. mulh, mulhu, and mulhsu return different signed or unsigned high halves.

Division instructions must also be selected correctly: div and rem interpret operands as signed, whereas divu and remu interpret them as unsigned. Although RISC-V defines architectural results for a zero divisor, portable, maintainable application software should explicitly validate the divisor and return a documented error rather than relying on ISA-specific behavior.

4. Signed and Unsigned Control Flow

RISC-V supplies equality branches and separate signed and unsigned relational branches.

Required relationInstruction or pseudoinstruction
Equal / not equalbeq, bne
Signed less than / greater or equalblt, bge
Unsigned less than / greater or equalbltu, bgeu
Unsigned less than or equalbleu pseudoinstruction, usually operands reversed into bgeu

A high-bit-set pattern illustrates the distinction. The bit pattern 0xFFFFFFFF represents −1 when signed but 232-1 when unsigned. Therefore, a signed comparison against a length, address, counter, or bit mask can produce a materially incorrect result.

4.1 Correct unsigned array traversal

The following routine computes the modulo-232 sum of an array of 32-bit words:

# uint32_t sum_u32(const uint32_t *p, uint32_t n)
# Preconditions: p addresses at least n readable words;
# address calculation does not wrap.
.globl sum_u32
sum_u32:
    li    t0, 0              # i
    li    t1, 0              # sum
.Lsum_loop:
    bgeu  t0, a1, .Lsum_done # unsigned i >= n
    slli  t2, t0, 2
    add   t3, a0, t2
    lw    t4, 0(t3)
    add   t1, t1, t4
    addi  t0, t0, 1
    j     .Lsum_loop
.Lsum_done:
    mv    a0, t1
    ret

For element size s bytes and zero-based index i, the address is

Ai = A0 + i s

where A0 is the base byte address. In this example, s=4 bytes. The bgeu instruction is essential because n is unsigned. Using signed bge would incorrectly treat every n ≥ 2³¹ as negative.

For input {5, 7, 9, 11}, the accumulator values are 5, 12, 21, and 32; the returned value is 32.

5. Machine-Code Encoding and Disassembly

RV32I uses several 32-bit instruction formats. In an R-type instruction, the fields are:

BitsField
31–25funct7
24–20rs2
19–15rs1
14–12funct3
11–7rd
6–0opcode

Worked encoding: add x7,x5,x6

For add, funct7 = 0000000, funct3 = 000, and opcode 0110011. The register numbers are rs2 = 6, rs1 = 5, and rd = 7.

0000000 00110 00101 000 00111 0110011

Grouping the 32 bits into hexadecimal gives:

0x006283B3

A little-endian memory system stores this word at increasing byte addresses as B3 83 62 00. Endianness changes byte order in memory; it does not reverse the printed bit fields within the instruction word.

Other verified examples are:

AssemblyMachine word
addi x5,x0,100x00A00293
add x7,x5,x60x006283B3
sw x7,0(x8)0x00742023
beq x5,x0,+80x00028463

Encoding exercise

To disassemble 0x00A00293, inspect the low seven bits: opcode 0010011 identifies an immediate arithmetic instruction. Bits 14–12 are 000, selecting addi; bits 11–7 identify x5; bits 19–15 identify x0; and the upper 12 bits contain immediate 10. The result is addi x5,x0,10.

Branch immediates are distributed across nonadjacent fields and represent offsets in multiples of two bytes. In practical development, labels should normally be used; the assembler computes the offset and the linker resolves relocations when necessary.

6. Subroutines, Recursion, and Stack Frames

A subroutine must preserve the ABI’s required machine state. A non-leaf routine normally saves ra; any callee-saved register it changes must also be saved and restored. Under the standard RISC-V ABI, the stack grows toward lower addresses and must maintain the required 16-byte alignment at call boundaries.

Sixteen-byte RISC-V stack frame Higher addresses Caller's framesaved ra at 12(sp)saved argument at 8(sp)unused/alignment space growthLower addresses sp after allocation
Sixteen-byte RISC-V stack frame
Figure: Sixteen-byte RISC-V stack frame

Figure: Example 16-byte stack frame for a recursive RV32 routine.

6.1 Unsigned recursive factorial

# uint32_t factorial_u(uint32_t n)
# Exact mathematical result is representable only for 0 <= n <= 12.
# Larger inputs return the low 32 bits and may exhaust the stack.
.globl factorial_u
factorial_u:
    sltiu t0, a0, 2          # t0 = 1 exactly when unsigned n < 2
    bnez  t0, .Lfact_base

    addi  sp, sp, -16
    sw    ra, 12(sp)
    sw    a0, 8(sp)
    addi  a0, a0, -1
    call  factorial_u
    lw    t1, 8(sp)
    mul   a0, a0, t1
    lw    ra, 12(sp)
    addi  sp, sp, 16
    ret

.Lfact_base:
    li    a0, 1
    ret

The unsigned sltiu test is deliberate. A signed ble-style base-case test would misclassify high-bit-set unsigned values as negative and return 1 incorrectly. Since 12! = 479001600 and 13! = 6227020800 > 2³²−1, only inputs through 12 have exact 32-bit unsigned results.

Recursion is often inappropriate in safety-critical embedded systems because stack consumption depends on input. An iterative implementation with a checked upper bound is usually more predictable.

7. Macros and Conditional Assembly

An assembler macro performs source-level substitution. Unlike a subroutine, it has no call/return overhead, but every expansion increases code size.

.macro PUSH reg
    addi sp, sp, -16
    sw   \reg, 12(sp)
.endm

.macro POP reg
    lw   \reg, 12(sp)
    addi sp, sp, 16
.endm

This pair preserves stack alignment when used consistently, although allocating 16 bytes for one word trades memory for ABI simplicity. Macros that introduce labels should use assembler-supported unique suffixes such as \@ to avoid duplicate-symbol errors.

Macros should document modified registers, memory effects, and alignment assumptions. Excessive macro use can obscure control flow and complicate debugging because the debugger executes expanded instructions rather than the conceptual macro operation.

8. Interfacing Assembly with C

C and assembly can interoperate only when both follow the same ABI. The function name, argument registers, return register, register-preservation rules, stack alignment, and data layout must agree.

#include <stdint.h>

extern uint32_t sum_u32(const uint32_t *p, uint32_t n);

int main(void) {
    uint32_t data[4] = {5, 7, 9, 11};
    return (sum_u32(data, 4) == 32) ? 0 : 1;
}

The pointer is passed in a0, n in a1, and the result returns in a0. The assembly function uses only caller-saved temporaries and is a leaf, so it need not create a stack frame.

C types matter. A C uint32_t has defined modulo-232 arithmetic, but signed C overflow is undefined behavior. Assembly code should not silently impose assumptions that conflict with the C abstract machine. Structures, bit fields, variadic functions, floating-point values, and differently configured ABIs require additional care.

9. Executable Toolchain Workflow

The following bare-metal workflow uses a GNU RISC-V cross-toolchain and QEMU. Tool names can vary by installation.

A minimal startup file may call sum_u32, store the result, and stop at a breakpoint:

.section .data
.align 2
values: .word 5, 7, 9, 11
result: .word 0

.section .text
.globl _start
_start:
    la   a0, values
    li   a1, 4
    call sum_u32
    la   t0, result
    sw   a0, 0(t0)
    ebreak
1:  j    1b

A compact linker script named link.ld is:

ENTRY(_start)
SECTIONS {
  . = 0x80000000;
  .text   : { *(.text*) }
  .rodata : { *(.rodata*) }
  .data   : { *(.data*) }
  .bss    : { *(.bss*) *(COMMON) }
}

Build and inspect the program:

riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 -g -c start.S sum.S
riscv64-unknown-elf-ld -m elf32lriscv -T link.ld -o app.elf start.o sum.o
riscv64-unknown-elf-objdump -d -M no-aliases app.elf
riscv64-unknown-elf-readelf -h -S -s app.elf

objdump should show real instructions where pseudoinstructions appeared in the source. readelf verifies that the file is a 32-bit RISC-V ELF image and displays sections and symbols.

Start QEMU paused with a GDB server:

qemu-system-riscv32 -machine virt -nographic -bios none \
  -kernel app.elf -S -s

In another terminal:

riscv64-unknown-elf-gdb app.elf
(gdb) target remote :1234
(gdb) break sum_u32
(gdb) continue
(gdb) info registers a0 a1 sp ra
(gdb) x/4uw $a0
(gdb) display/i $pc
(gdb) stepi
(gdb) nexti
(gdb) continue
(gdb) p/x result

At entry to sum_u32, a1 should equal 4, and x/4uw $a0 should display 5 7 9 11. After execution reaches ebreak, result should be 0x00000020, which is decimal 32. A watchpoint can diagnose unintended writes:

(gdb) watch result
(gdb) continue

If execution fails, inspect the program counter, disassemble the current address, verify stack alignment, and compare register values against the ABI. Stepping at the instruction level is often more informative than source-level stepping in handwritten assembly.

10. Applied Case: Safe Sensor Average

Background

An illustrative embedded monitoring system samples a 16-bit analog-to-digital converter. A C task passes an array of unsigned samples to an RV32IM assembly routine that computes the truncated integer mean.

Problem

The routine must prevent division by zero, avoid accumulator overflow under its specified operating limit, and report invalid input explicitly. It must not write an output value after an error.

Analysis

Each sample is in the range 0 to 65,535. The interface limits the sample count to 65,535. Therefore, the maximum sum is

Smax = 65535 × 65535 = 4294836225

This is below the largest 32-bit unsigned value, 4,294,967,295. Thus, a 32-bit unsigned accumulator is sufficient under the stated bound. The integer average is

\bar{x}int = \left\lfloor \frac{\sumi=0n-1 xi}{n} \right\rfloor

where xi is an unsigned 16-bit sample and 1 ≤ n ≤ 65535. The result has the same ADC-count unit as each sample.

Solution

# int avg_u16(const uint16_t *p, uint32_t n, uint32_t *out)
# Returns 0 on success and -1 for n == 0 or n > 65535.
# On failure, *out is unchanged.
.globl avg_u16
avg_u16:
    beq   a1, zero, .Lavg_error   # prevents division by zero
    li    t0, 65535
    bltu  t0, a1, .Lavg_error     # unsigned 65535 < n

    li    t0, 0                   # i
    li    t1, 0                   # sum
.Lavg_loop:
    bgeu  t0, a1, .Lavg_divide
    slli  t2, t0, 1
    add   t3, a0, t2
    lhu   t4, 0(t3)
    add   t1, t1, t4
    addi  t0, t0, 1
    j     .Lavg_loop

.Lavg_divide:
    divu  t1, t1, a1
    sw    t1, 0(a2)
    li    a0, 0
    ret

.Lavg_error:
    li    a0, -1
    ret

The branch to .Lavg_error occurs before divu, so a zero divisor cannot reach the division instruction. The output store occurs only on the success path.

For samples {1000, 1200, 800, 1000}, the sum is 4,000 and the average is 4000 ÷ 4 = 1000 ADC counts. For n = 0, the routine returns −1 and leaves *out unchanged.

Outcome and Learning Points

The implementation converts interface assumptions into enforceable checks. Unsigned branches match the unsigned count type, accumulator capacity is proven rather than assumed, and the error contract prevents consumers from mistaking an invalid result for a valid zero reading. Production code should additionally validate pointers where the system’s memory-protection model permits meaningful validation.

11. Debugging and Optimization Principles

Common defects include incorrect branch signedness, off-by-one loop limits, failure to restore sp, corruption of callee-saved registers, wrong load width, and accidental sign extension. Unit tests should cover empty input, one element, maximum valid length, high-bit-set unsigned values, overflow boundaries, and misaligned or invalid pointers where the platform defines their handling.

Optimization should begin with measurement. Useful transformations include keeping frequently used values in registers, reducing repeated address calculations, unrolling short loops, and scheduling independent instructions to hide latency. However, unrolling enlarges code, and smaller code may be preferable in instruction-cache-constrained microcontrollers.

A strength-reduced pointer loop can replace indexed address calculation, but pointer termination remains subject to valid-object and non-wrapping assumptions. Optimized code must preserve ABI behavior, volatile device-access ordering, error handling, and memory safety.

Automated compiler explorers, instruction-set simulators, and AI-assisted code generation can accelerate experimentation, but generated assembly must be checked against the ISA manual, ABI, timing requirements, and test vectors. AI output should never be accepted as evidence of worst-case execution time or functional safety without independent verification.

12. Safety, Security, and Governance

Assembly bypasses many language-level protections. An incorrect pointer can overwrite control data; an unbalanced stack can redirect returns; and data-dependent execution may leak information through timing or power consumption. Secure routines validate sizes, avoid secret-dependent branches where required, clear sensitive temporary data when justified, and follow platform protections such as execute-never memory and control-flow integrity.

Safety-regulated projects require traceability between requirements, source code, tests, and tool versions. Toolchain updates can alter instruction scheduling or object formats, so reproducible builds and qualified verification procedures are important. Reverse engineering must also respect software licenses, intellectual-property law, privacy obligations, and authorization boundaries.

Chapter Summary

Assembly language provides direct control over registers, memory, arithmetic, and control flow. Correct machine-level programming requires explicit attention to signedness, integer width, alignment, overflow, bounds, and the ABI. RV32IM array traversal uses load/store operations and unsigned branches when counters and lengths are unsigned. Stack frames preserve return addresses and callee-saved state, while macros trade call overhead for code expansion.

Instruction encoding explains how symbolic operations become machine words, and disassembly connects executable bytes back to architecture. Assemblers, linkers, ELF inspection tools, simulators, and debuggers form an integrated development workflow. The sensor-average case demonstrated that boundary analysis and an early zero-length check can prevent both accumulator overflow and division by zero.

Key Terms

Application Binary Interface (ABI): Rules governing calls, registers, data layout, and binary interoperability.

Assembler: Tool that translates assembly source into object code.

Branch: Instruction that conditionally changes control flow.

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

Caller-saved register: Register the caller must preserve when its value is needed after a call.

Calling convention: ABI rules for passing arguments, returning values, and preserving state.

Disassembler: Tool that translates machine instructions into assembly notation.

Endianness: Byte ordering used to represent multibyte values in memory.

Instruction set architecture: Programmer-visible definition of instructions, registers, and machine behavior.

Linker: Tool that combines object files and resolves symbols and relocations.

Macro: Assembly-time source expansion that generates one or more instructions.

Machine code: Binary instruction representation executed by a processor.

Pseudoinstruction: Assembly notation expanded into one or more real instructions.

Relocation: Link-time adjustment of an address-dependent object-code field.

Simulator: Software model that executes or emulates a target ISA.

Stack frame: Region of stack memory allocated to one active subroutine invocation.

Symbol: Named address or value used by assembly and linking tools.

Two’s complement: Standard representation of signed binary integers.

Unsigned comparison: Comparison that interprets every operand bit as magnitude.

Review Questions

Short Answer Questions

  1. Distinguish an assembly instruction from an assembler directive.
  2. Why must an unsigned array length be tested with bgeu rather than bge?
  3. What is the purpose of the ra register in RISC-V?
  4. How is 0x006283B3 stored as bytes on a little-endian system?
  5. What information does a disassembler provide?

Long Answer Questions

  1. Explain the stages from assembly source code to execution, including symbols and relocations.
  2. Describe the RISC-V calling convention and construct a valid stack frame for a non-leaf function.
  3. Compare signed and unsigned arithmetic, multiplication, division, and branch behavior in RV32IM.
  4. Explain how to assemble, link, disassemble, simulate, and debug a bare-metal RISC-V program.
  5. Discuss the benefits and risks of macros, recursion, and loop unrolling in embedded assembly.

Analytical / Application Questions

  1. Write an RV32IM routine that finds the maximum element of a nonempty unsigned 32-bit array. Justify every branch condition.
  2. Encode addi x5,x0,10 manually and show its byte order in little-endian memory.
  3. Modify avg_u16 to return a rounded rather than truncated average. Identify and prevent any overflow introduced by the rounding calculation.

Multiple Choice Questions with Answers

Question 1

Which RV32 instruction correctly exits a loop when unsigned index t0 is greater than or equal to unsigned length a1?

A. bge t0,a1,done

B. bgeu t0,a1,done

C. blt t0,a1,done

D. beq zero,a1,done

Correct Answer: B. bgeu t0,a1,done

Question 2

What is the RV32 machine word for add x7,x5,x6?

A. 0x006283B3

B. 0x00A00293

C. 0x00742023

D. 0x00028463

Correct Answer: A. 0x006283B3

Question 3

How is machine word 0x006283B3 stored at increasing addresses on a little-endian system?

A. 00 62 83 B3

B. B3 83 62 00

C. B3 62 83 00

D. 00 83 62 B3

Correct Answer: B. B3 83 62 00

Question 4

Why does the factorial routine use sltiu t0,a0,2?

A. To multiply a0 by two

B. To detect signed multiplication overflow

C. To test the unsigned base case without misclassifying high-bit-set values

D. To align the stack pointer

Correct Answer: C. To test the unsigned base case without misclassifying high-bit-set values

Question 5

What is the largest input for which factorial is exactly representable as an unsigned 32-bit value?

A. 10

B. 11

C. 12

D. 13

Correct Answer: C. 12

Question 6

In the RISC-V ABI, which register normally holds the first integer argument and return value?

A. a0

B. ra

C. sp

D. s0

Correct Answer: A. a0

Question 7

For samples {1000,1200,800,1000}, what truncated unsigned average does avg_u16 produce?

A. 800

B. 950

C. 1000

D. 4000

Correct Answer: C. 1000

Question 8

How does avg_u16 prevent division by zero?

A. It relies on the architectural result of divu

B. It replaces a zero length with one

C. It branches to an error return before executing divu

D. It stores zero before division

Correct Answer: C. It branches to an error return before executing divu

Question 9

Which tool combines object files and resolves external symbols?

A. Assembler

B. Linker

C. Debugger

D. Simulator

Correct Answer: B. Linker

Question 10

Which statement about an assembler macro is correct?

A. It always executes faster than every equivalent routine

B. It is expanded during assembly and can increase code size

C. It automatically preserves all registers

D. It is resolved only by the runtime loader

Correct Answer: B. It is expanded during assembly and can increase code size

Microprocessor Architecture
Chapter 4 of 15