Assembly Language Assembler

In x86-64 assembly, an assembler is software that translates human-readable assembly code into machine code, which can be executed by the processor.

The assembler plays a critical role in the development of low-level software, as it allows programmers to write code using mnemonics and symbols that are more human-readable than the binary machine code. The assembler converts this assembly code into binary machine code, which consists of sequences of 0s and 1s that represent specific instructions for the CPU.

The term ‘assembler’ is also commonly used to refer to software that includes other functionality as well (such as disassembly). Some commonly used assemblers for x86-64 architecture include NASM (Netwide Assembler) and GAS (GNU Assembler), which is part of the GNU Compiler Collection (GCC).

There are two common types of assemblers: one-pass and two-pass. As suggested by their names, a one-pass assembler goes through the code once, while a two-pass assembler goes through it a second time. Two-pass assemblers have distinct advantages, which we’ll cover below.

How the Assembler Works

The assembler reads human-readable assembly code line by line, breaking down each line into components like instructions, operands, labels, and directives.

Labels are symbolic names for memory addresses or constants and are resolved by assigning specific addresses. Assembly instructions correspond to opcodes, representing machine-level operations, which the assembler looks up.

Operand encoding involves determining addressing mode, size, and type. The assembler generates binary machine code by combining opcodes, encoded operands, and necessary bits. The output file, often an executable or object file, contains the generated machine code. An example x86-64 assembly code demonstrates these steps:

section .text
global _start

_start:
    mov eax, 5       ; Load the value 5 into register eax
    add eax, 3       ; Add 3 to the value in eax
    int 0x80         ; Invoke system call (example)

Here, the assembler parses each line, recognizes instructions and operands, resolves labels, looks up the opcodes, encodes the operands, and generates binary machine code. The resulting output file contains executable machine code for the CPU.

The exact details may vary depending on the assembler and the target architecture, but this general process is common to most assembly language assemblers.

To understand how assemblers work in a bit more detail,

One-Pass vs. Two-Pass Assemblers

One-pass and two-pass assemblers offer two different approaches to the assembly process. Their names refer to the number of times the assembler goes through the source code.

One-pass assemblers read the source code once, while two-pass assemblers read it twice.

One-pass Assembler

The one-pass assembler reads the source code a single time. This requires less memory and is more efficient, but may create forward referencing issues. One-pass assemblers are commonly used in simple programs and embedded systems with resource constraints.

Here’s a detailed summary of the characteristics of the one-pass assembler:

  • Single Pass: A one-pass assembler only reads the source code one time, processing the code in a single pass.
  • Memory Efficiency: Requires less memory as it processes the source code linearly without the need for storing the entire program in memory.
  • Forward Referencing Issues: May have difficulties handling forward references (references to symbols or labels that appear later in the code), as it encounters them before they are defined.
  • Efficiency: Generally faster than the two-pass assembler because it processes the code in a single sweep. It is particularly suitable for small or simple programs.
  • Usage: Early assembly languages and some embedded systems use one-pass assemblers for efficiency and resource constraints.

A one-pass assembler is typically more memory-efficient and faster than a two-pass assembler because it processes the source code linearly without the need to store the entire program in memory. However, the limitation lies in its ability to handle forward references effectively. In cases where forward references are prevalent or the program structure is complex, a one-pass assembler may not be suitable, and a two-pass assembler could be preferred for better symbol resolution.

Two-pass Assembler

The two-pass assembler goes through the code twice.

  • Two Passes: A two-pass assembler reads the source code twice. The first pass collects information about labels, addresses, and symbols, while the second pass generates the actual machine code.
  • Forward Referencing Resolution: Can easily handle forward references by collecting information in the first pass and resolving them in the second pass.
  • Memory Requirements: Requires more memory as it needs to store symbol tables and other information between passes.
  • Efficiency: May be slower due to the extra pass, but it provides better error checking and can handle more complex programs.
  • Example: Modern assemblers like NASM (Netwide Assembler) and GAS (GNU Assembler) often use a two-pass approach.

A two-pass assembler for x86-64 assembly works in two distinct phases to process the source code, resolve symbols, and generate the binary machine code.

First Pass

  1. Reading and Parsing: The assembler reads the source code line by line. It parses each line, identifying labels, instructions, operands, and other elements.
  2. Symbol Table Generation: The assembler creates a symbol table to store information about labels and their associated memory addresses. It records the addresses of labels encountered in the source code.
  3. Handling Directives: The assembler processes any directives that affect the program’s structure or the assembly process, such as defining sections or specifying data types.
  4. Address Calculation: The assembler calculates the addresses of instructions and data, considering any immediate values and addressing modes.

Second Pass

  1. Reading and Generating Code: The assembler reads the source code again during the second pass, now equipped with the information (including the symbol table) from the first pass.
  2. Opcode Lookup and Encoding: For each instruction encountered, the assembler looks up the corresponding opcodes in the x86-64 instruction set. It encodes operands based on the addressing mode, size, and type.
  3. Symbol Resolution: The assembler uses the symbol table generated in the first pass to resolve addresses for labels, substituting the actual memory addresses into the machine code.
  4. Generating Binary Code: The assembler combines the opcodes, encoded operands, and other bits to produce the binary machine code.
  5. Output Generation: The assembled machine code is then written to an output file, which may be an executable file, an object file, or another specified format.

A two-pass assembler is beneficial for handling forward references, where symbols are referenced before being defined. The first pass collects information about labels and their addresses, allowing the assembler to resolve references during the second pass. This approach ensures a more accurate translation of the source code into machine code for x86-64 architecture.

In summary, the choice between a one-pass and a two-pass assembler depends on factors like program complexity, memory constraints, and the importance of forward referencing resolution. One-pass assemblers are simpler and faster but may struggle with certain situations, while two-pass assemblers provide more flexibility and error checking at the cost of additional memory usage and potentially slower processing.

Assemblers vs. Compilers

Assemblers and compilers are both tools used in the software development process, but they serve different purposes and operate at different levels of abstraction in the program translation process.

Assembler

Most of this should be review at this point:

  • Purpose: An assembler translates assembly language code into machine code or object code. It deals with low-level instructions that are specific to a particular computer architecture.
  • Input Language: Assembly language, a human-readable representation of machine code using mnemonics and symbols.
  • Output: The output of an assembler is typically an object file or machine code directly executable by the computer’s CPU.
  • Translation Process: One-to-one translation, where each assembly language instruction corresponds to a single machine code instruction.
  • Level of Abstraction: Low-level, close to the hardware architecture of the target machine.
  • Portability: Less portable, as the code is specific to a particular architecture.

Compiler

  • Purpose: A compiler translates higher-level programming languages (like C, C++, Java) into machine code or an intermediate code. Compilers deal with source code that is more abstract and independent of a specific hardware architecture.
  • Input Language: Higher-level programming languages that are more human-readable and expressive.
  • Output: The output of a compiler is typically an executable file or bytecode, which may be executed by a virtual machine.
  • Translation Process: Involves multiple stages including lexical analysis, syntax analysis, semantic analysis, optimization, and code generation.
  • Level of Abstraction: High-level, providing a more abstract and portable representation of the program.
  • Portability: More portable, as the high-level code can be compiled for different architectures.

In summary, an assembler translates assembly language code to machine code for a specific architecture, while a compiler translates high-level programming languages into machine code or intermediate code, providing a more abstract and portable representation of the program.