Langsung ke konten utama
BlockchainMar 28, 2026

Deep EVM #9: Huff Language Primer — Macros, Labels, and Raw Opcodes

OS
Open Soft Team

Engineering Team

Why Huff Exists

Solidity is a wonderful abstraction — until it is not. When you need a contract that fits inside 100 bytes of runtime bytecode, dispatches functions in O(1) with a packed jump table, or shaves 200 gas off a hot path that executes millions of times per day, you need something closer to the metal. That something is Huff.

Huff is a low-level EVM assembly language with a thin macro system bolted on top. It does not have variables, types, or a compiler that optimizes behind your back. What you write is what ends up on chain — opcode for opcode.

Installing Huff

The canonical compiler is huffc, written in Rust:

curl -L get.huff.sh | bash
huffup
huffc --version

This installs huffc to ~/.huff/bin. Add it to your PATH and verify:

$ huffc --version
huffc 0.3.2

You can also use Huff inside Foundry projects with foundry-huff, which lets you deploy .huff files the same way you deploy .sol files.

Hello World: A Minimal Contract

Let us write a contract that returns the 32-byte word 0x01 to any call:

#define macro MAIN() = takes(0) returns(0) {
    0x01            // [0x01]
    0x00            // [0x00, 0x01]
    mstore          // []          — memory[0x00..0x20] = 0x01
    0x20            // [0x20]
    0x00            // [0x00, 0x20]
    return          // halt — return memory[0x00..0x20]
}

Compile:

huffc src/HelloWorld.huff -r

The -r flag outputs the runtime bytecode. You will see something like 600160005260206000f3 — 10 bytes. A Solidity contract returning 1 compiles to roughly 200+ bytes of runtime bytecode because solc emits a full function dispatcher, metadata hash, free memory pointer setup, and ABI encoder.

Macros vs Functions

Huff has two code-reuse primitives: macros and functions.

Macros (#define macro)

Macros are inlined at every call site. No JUMP overhead, no extra gas — the compiler literally copy-pastes the opcodes into the caller. This is the default and the preferred choice for gas-critical code.

#define macro REQUIRE_NOT_ZERO() = takes(1) returns(0) {
    // takes: [value]
    continue        // [continue_dest, value]
    jumpi           // []  — jump if value != 0
    0x00 0x00 revert
    continue:
}

Functions (#define fn)

Functions generate an actual JUMP/JUMPDEST pair. They save bytecode size at the expense of ~22 extra gas per call (8 for JUMP + 1 for JUMPDEST + stack manipulation). Use them only when bytecode size matters more than gas.

#define fn safe_add() = takes(2) returns(1) {
    // takes: [a, b]
    dup2 dup2       // [a, b, a, b]
    add             // [sum, a, b]
    dup1            // [sum, sum, a, b]
    swap2           // [a, sum, sum, b]
    gt              // [overflow?, sum, b]
    overflow jumpi
    swap1 pop       // [sum]
    back jump
    overflow:
        0x00 0x00 revert
    back:
}

Labels and Jump Destinations

Labels in Huff are named JUMPDEST locations. The compiler resolves them to concrete bytecode offsets at compile time.

#define macro LOOP_EXAMPLE() = takes(1) returns(1) {
    // takes: [n]
    0x00                // [acc, n]
    loop:
        dup2            // [n, acc, n]
        iszero          // [n==0?, acc, n]
        done jumpi      // [acc, n]
        swap1           // [n, acc]
        0x01 swap1 sub  // [n-1, acc]
        swap1           // [acc, n-1]
        0x01 add        // [acc+1, n-1]
        loop jump
    done:
        swap1 pop       // [acc]
}

Each label compiles to a single JUMPDEST byte (0x5b). The references (loop jump, done jumpi) compile to PUSH2 <offset> JUMP (or JUMPI). This is exactly what you would write by hand in raw EVM assembly — Huff just handles the offset bookkeeping.

takes() and returns()

The takes(n) and returns(m) annotations on macros and functions are documentation and compiler hints. They tell the reader — and the Huff compiler’s stack checker — how many stack items the block expects to consume and produce.

#define macro ADD_TWO() = takes(2) returns(1) {
    add  // consumes 2 items, produces 1
}

If your actual stack behavior does not match the annotation, huffc will emit a warning. Treat these annotations as a poor man’s type system — they prevent you from accidentally leaving garbage on the stack or underflowing.

Comparison: Huff vs Solidity Bytecode

Consider a simple getValue() view function that returns a storage slot:

Solidity:

function getValue() external view returns (uint256) {
    return value;
}

Solc generates ~40 bytes for the dispatcher + ABI encoding:

CALLDATASIZE → CALLDATALOAD → SHR 224 → DUP1 → PUSH4 selector
→ EQ → PUSH2 dest → JUMPI → ... → SLOAD → PUSH1 0x20
→ MSTORE → PUSH1 0x20 → PUSH1 0x00 → RETURN

Huff equivalent:

#define function getValue() view returns (uint256)

#define macro GET_VALUE() = takes(0) returns(0) {
    [VALUE_SLOT]    // [slot]
    sload           // [value]
    0x00 mstore     // []  — store in memory
    0x20 0x00 return
}

The Huff version is 12 bytes of bytecode for the body. No ABI encoding overhead, no free memory pointer, no metadata hash. When you control the caller (e.g., an MEV bot calling its own contract), you can strip everything the Solidity compiler assumes you need.

Constants and Storage Slots

Huff constants are compile-time values that get inlined as PUSH instructions:

#define constant VALUE_SLOT = 0x00
#define constant OWNER_SLOT = 0x01
#define constant MAX_UINT = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

Usage: [VALUE_SLOT] pushes 0x00, [MAX_UINT] pushes the full 32-byte value. Constants help readability without costing any gas — they are purely syntactic.

Includes and Project Structure

Real Huff projects split code across multiple files:

// src/Main.huff
#include "./utils/SafeMath.huff"
#include "./interfaces/IERC20.huff"
#include "./Dispatcher.huff"

#define macro MAIN() = takes(0) returns(0) {
    DISPATCHER()
}

The include system is simple textual inclusion — no module scoping or namespaces. Name your macros carefully to avoid collisions.

When to Use Huff

Huff is not a general-purpose language. Use it when:

  1. Gas is the primary constraint — MEV contracts where 100 gas determines profitability.
  2. Bytecode size matters — Contracts deployed by other contracts (CREATE2 factories) where smaller initcode = less deployment gas.
  3. You need custom dispatch — Jump tables, bit-packed selectors, or non-standard ABI encoding.
  4. You are learning the EVM — Nothing teaches the EVM better than writing raw opcodes.

For everything else, write Solidity and read the compiler output with solc --asm. You will be more productive and less error-prone.

Summary

Huff gives you a direct line to EVM bytecode with just enough abstraction to stay sane. Macros inline code for zero-overhead reuse. Labels handle jump offset bookkeeping. takes/returns annotations catch stack errors early. In the next article, we will dive deeper into stack management — the art of dup, swap, and keeping your mental model of the stack in sync with reality.