How NanoCore's Two-Pass Assembler Resolves Labels and Constants

NanoCore's assembler uses a two-pass resolution process to first collect constant definitions and calculate label addresses, then generate final bytecode by substituting symbolic names with their numeric values.

The assembler in the afaanbilal/nanocore repository translates human-readable assembly into 8-bit bytecode through a structured multi-stage pipeline. Understanding the two-pass assembler process in NanoCore is essential for writing effective assembly programs that use forward-referenced labels and symbolic constants. The implementation found in src/assembler.rs separates symbol collection from code generation to resolve symbols that appear later in the source file.

First Pass – Mapping Constants with map_constants

The assembler begins by scanning the entire source code to build a lookup table of symbolic constants. This phase ignores instructions and labels, focusing solely on lines beginning with the .CONST directive.

pub fn map_constants(&mut self) -> crate::AssemblerResult<()> {
    let lines = self.asm.lines();

    for (line_idx, line) in lines.enumerate() {
        // … strip comments, trim whitespace …
        if !Self::is_constant(line) {
            continue;                       // ← skip non‑constant lines
        }

        let parts = line.split_whitespace().collect::<Vec<&str>>();
        // Expected format: .CONST <NAME> <VALUE>
        let name = parts[1];
        let value = if parts[2].starts_with("0x") {
            Self::from_hex_str(parts[2], line_num)?
        } else {
            Self::from_value_str(parts[2], line_num)?
        };
        self.constants.insert(name.to_owned(), value);
    }
    Ok(())
}

Source: [src/assembler.rs lines 56-91](https://github.com/afaanbilal/nanocore/blob/master/src/assembler.rs#L56-L91)

Key implementation details:

  • Storage: Constants are stored in self.constants: HashMap<String, u8>, mapping names to their 8-bit numeric values.
  • Parsing: Decimal values are parsed via from_value_str, while hexadecimal literals prefixed with 0x are decoded using from_hex_str.
  • Scope: This pass must complete before any instruction encoding occurs, as constants may appear as immediate operands in subsequent lines.

Second Pass – Calculating Label Addresses with map_labels

After constants are mapped, the assembler performs a second pass to calculate the absolute memory address of every label. This pass maintains a virtual program counter to determine where each instruction will reside in the final binary.

pub fn map_labels(&mut self) -> crate::AssemblerResult<()> {
    let lines = self.asm.lines();
    let mut addr: u8 = 0;                     // program counter for this pass

    for (line_idx, line) in lines.enumerate() {
        // … strip comments, trim whitespace …
        if line.is_empty() || Self::is_constant(line) {
            continue;                         // ← ignore blank lines & constants
        }

        // Data directives affect the address counter
        if line.starts_with(".DB") {
            let parts = line.split_whitespace().collect::<Vec<&str>>();
            addr = addr.wrapping_add((parts.len() - 1) as u8);
            continue;
        }
        if line.starts_with(".STRING") {
            // count characters inside the quotes
            let start = line.find('"').unwrap() + 1;
            let end   = line.rfind('"').unwrap();
            addr = addr.wrapping_add((end - start) as u8);
            continue;
        }

        // *** Labels ***
        if Self::is_label(line) {
            self.labels
                .insert(line.trim_end_matches(':').to_owned(), addr);
            continue;
        }

        // All other lines are instructions – use Op::instruction_len()
        let op: Op = Op::try_from(parts[0])?;
        addr = addr.wrapping_add(op.instruction_len());
    }
    Ok(())
}

Source: [src/assembler.rs lines 90-152](https://github.com/afaanbilal/nanocore/blob/master/src/assembler.rs#L90-L152)

Address calculation logic:

  • Data directives: The .DB directive consumes one byte per value, while .STRING consumes one byte per character (calculated by finding the quote positions).
  • Label detection: Lines ending with : are identified as labels using Self::is_label. The current addr value (representing where the next instruction will begin) is stored in self.labels: HashMap<String, u8>.
  • Instruction sizing: For operation codes, the assembler calls op.instruction_len() (defined in src/op.rs) to determine byte length and advances the virtual counter using wrapping_add.

Final Pass – Bytecode Generation and Symbol Resolution

With both symbol tables populated, the main assembly loop emits the final binary. When encountering operands, the assembler resolves symbols using the tables built in the previous passes.

let addr = if self.labels.contains_key(parts[1]) {
    *self.labels.get(parts[1]).unwrap()   // label → concrete address
} else {
    self.resolve_number(parts[1], line_num)? // constant or literal
};
self.program.push(opcode);
self.program.push(addr);

Source: [src/assembler.rs lines 169-182](https://github.com/afaanbilal/nanocore/blob/master/src/assembler.rs#L169-L182)

Resolution priority:

  1. Label lookup: The assembler first checks self.labels. If the operand matches a label name, the address calculated during the second pass is emitted.
  2. Constant fallback: If no label matches, resolve_number searches self.constants (from the first pass) or parses the token as a literal integer.

This approach ensures that forward-referenced labels (those used before they are defined) and symbolic constants resolve correctly to single-byte addresses.

Practical Assembly Example

Consider the following NanoCore assembly program that utilizes both constants and forward-referenced labels:

; program.asm
.CONST START_ADDR 0x10
main:
    LDI R0 START_ADDR   ; constant resolves to 0x10
    JMP loop
loop:
    INC R0
    JNZ main             ; label resolves to address of `main`
    HLT

Rust assembly code:

let mut asm = Assembler::default();
asm.assemble(include_str!("program.asm")).unwrap();
println!("{:?}", asm.program);

Resulting bytecode sequence:

  • 0x02LDI opcode
  • 0x00 — register R0
  • 0x10 — constant START_ADDR value
  • 0x0CJMP opcode
  • 0x04 — address of label loop (4 bytes into program)
  • 0x08INC opcode
  • 0x00 — register R0
  • 0x0DJNZ opcode
  • 0x00 — address of label main (0 bytes)
  • 0x00HLT opcode

Source: Example behavior validated in repository tests such as test_assemble_constants ([src/assembler.rs lines 50-60](https://github.com/afaanbilal/nanocore/blob/master/src/assembler.rs#L50-L60)).

Summary

  • Two-pass architecture: NanoCore separates symbol collection (map_constants, map_labels) from code generation to handle forward references.
  • Constant resolution: The first pass populates self.constants with .CONST definitions, supporting both decimal and hexadecimal formats.
  • Label resolution: The second pass calculates absolute addresses by simulating instruction lengths using op.instruction_len() from src/op.rs.
  • Data handling: Directives like .DB and .STRING advance the virtual program counter during the label mapping phase.
  • Final emission: The assembly loop substitutes symbolic names with numeric values from the hash maps, producing the 8-bit bytecode.

Frequently Asked Questions

What is a two-pass assembler?

A two-pass assembler scans the source code twice: the first pass collects symbol definitions (constants and labels), and the second pass generates machine code using those resolved addresses. NanoCore implements this pattern in src/assembler.rs through the map_constants and map_labels methods, enabling it to handle symbols that are referenced before they are defined.

How does NanoCore handle forward-referenced labels?

NanoCore resolves forward references during the second pass. When map_labels encounters a label, it stores the current virtual address in self.labels. Later, during bytecode generation, jump instructions referencing that label retrieve the pre-calculated address from the hash map, regardless of where the label appeared in the source file.

Which source directives affect address calculation during the label pass?

The .DB directive increases the address counter by the number of byte values provided, while .STRING increases it by the character count between quotes. All standard instructions advance the counter according to their instruction_len() as defined in src/op.rs, ensuring accurate address assignment for subsequent labels.

How are constants parsed in the first pass?

Constants defined with .CONST are parsed using from_value_str for decimal integers or from_hex_str for hexadecimal values prefixed with 0x. These are stored as u8 values in self.constants, allowing the assembler to substitute symbolic names with their numeric equivalents during the final bytecode generation phase.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →