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 with0xare decoded usingfrom_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
.DBdirective consumes one byte per value, while.STRINGconsumes one byte per character (calculated by finding the quote positions). - Label detection: Lines ending with
:are identified as labels usingSelf::is_label. The currentaddrvalue (representing where the next instruction will begin) is stored inself.labels: HashMap<String, u8>. - Instruction sizing: For operation codes, the assembler calls
op.instruction_len()(defined insrc/op.rs) to determine byte length and advances the virtual counter usingwrapping_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:
- Label lookup: The assembler first checks
self.labels. If the operand matches a label name, the address calculated during the second pass is emitted. - Constant fallback: If no label matches,
resolve_numbersearchesself.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:
0x02—LDIopcode0x00— registerR00x10— constantSTART_ADDRvalue0x0C—JMPopcode0x04— address of labelloop(4 bytes into program)0x08—INCopcode0x00— registerR00x0D—JNZopcode0x00— address of labelmain(0 bytes)0x00—HLTopcode
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.constantswith.CONSTdefinitions, supporting both decimal and hexadecimal formats. - Label resolution: The second pass calculates absolute addresses by simulating instruction lengths using
op.instruction_len()fromsrc/op.rs. - Data handling: Directives like
.DBand.STRINGadvance 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →