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

> Understand NanoCore's two-pass assembler for efficient label and constant resolution. Learn how it resolves symbolic names to numeric values for bytecode generation.

- Repository: [Afaan Bilal/nanocore](https://github.com/afaanbilal/nanocore)
- Tags: internals
- Published: 2026-02-23

---

**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](https://github.com/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`](https://github.com/afaanbilal/nanocore/blob/main/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.

```rust
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`](https://github.com/afaanbilal/nanocore/blob/main/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.

```rust
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`](https://github.com/afaanbilal/nanocore/blob/main/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`](https://github.com/afaanbilal/nanocore/blob/main/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.

```rust
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`](https://github.com/afaanbilal/nanocore/blob/main/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:

```asm
; 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:**

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

```

**Resulting bytecode sequence:**

- `0x02` — `LDI` opcode
- `0x00` — register `R0`
- `0x10` — constant `START_ADDR` value
- `0x0C` — `JMP` opcode
- `0x04` — address of label `loop` (4 bytes into program)
- `0x08` — `INC` opcode
- `0x00` — register `R0`
- `0x0D` — `JNZ` opcode
- `0x00` — address of label `main` (0 bytes)
- `0x00` — `HLT` opcode

*Source:* Example behavior validated in repository tests such as `test_assemble_constants` ([[`src/assembler.rs`](https://github.com/afaanbilal/nanocore/blob/main/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`](https://github.com/afaanbilal/nanocore/blob/main/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`](https://github.com/afaanbilal/nanocore/blob/main/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`](https://github.com/afaanbilal/nanocore/blob/main/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.