Making Assembly Sweeter.
BitterASM is a metalanguage for constructing assembly languages. It defines the semantics and metaprogramming machinery needed to build an ISA, while assuming essentially nothing about the target architecture itself.
Traditional assemblers bake in a specific architecture: registers, instructions, opcodes, addressing modes, and binary encoding are all privileged, built-in concepts. That works fine until you want something the assembler's authors didn't anticipate — a pseudo-instruction, an alternate syntax, a non-binary target, a research architecture. Then you're stuck extending the assembler itself.
BitterASM inverts this. The language core knows nothing about:
registers, instructions, opcodes, operands, immediates,
addresses, word sizes, endianness, calling conventions,
sections, labels, binary
Every one of those is defined in ordinary BitterASM code — as libraries, architecture packages, and evaluators. The complexity of a real ISA lives where it belongs: in the code describing that ISA, not in the language interpreting it.
Instructions are macros, not language features. mov isn't a built-in instruction declaration — it's a macro someone wrote and exposed publicly. There's no fundamental distinction between a "real" instruction and a "pseudo" one at the language level; both are just macros that expand into some encoding.
Syntax is an interface, not part of the ISA. Because instructions are macros with their own invocation syntax, the same underlying x86 machinery could support mov rax, 3, an arrow-style rax <- 3, or something else entirely — all sharing one architecture implementation underneath.
Abstraction does not imply optimization. What you expand is what you get. If a macro expands clear rax into xor rax, rax, that's the macro author's explicit choice. BitterASM never second-guesses it by picking a "faster" sequence on its own. A macro can implement sophisticated code selection, but that logic belongs to the macro author, not the language.
Even bits are a library concept. The only thing BitterASM assumes is Int — an architecture-neutral, arbitrary-precision integer. Binary (bit, bits<N>) is defined in terms of Int in the standard library, not built into the language. 3 is just the mathematical integer three until some library gives it a binary interpretation. This leaves room for architectures that aren't binary at all.
Evaluators own the output contract, not the language. BitterASM source describes behavior; an evaluator decides what running that behavior produces. A binary evaluator turns assembly into machine code. A different evaluator could target something else entirely. Language semantics (Int, struct, type, macro, pattern matching, imports, generics) mean the same thing everywhere — only the output effect changes.
Imports compose modules; they don't grant magic. from x86_64.intel import * works because that package defines mov and rax — not because the language has special knowledge of x86. Importing a binary library doesn't secretly switch the language into "binary mode"; binary is just one ordinary abstraction among many.
The payoff for assuming almost nothing is:
The tradeoff is symmetric: the fewer assumptions the language makes, the more an architecture package has to define for itself. That's the bitter part. The resulting portability, auditability, and extensibility are the sweet part.
BitterASM is an early-stage project: a Rust compiler (lexer, parser, resolver, formatter) for the language itself, a separate bitter binary evaluator that packs emitted values into machine code, and a growing std library written in BitterASM. Expect the language, the evaluator contract, and the standard library to change as the design settles.
Requires a Rust toolchain (cargo). From a clone of this repository:
make install # or: ./install.sh
This interactively builds and installs bitterasm (the compiler) and/or bitter
(the binary evaluator) to ~/.bitterasm/bin, and copies std/ to ~/.bitterasm/std.
Add ~/.bitterasm/bin to your PATH if prompted. Pass -y to answer yes to
every prompt, including those of the optional bitterasm-lsp install script.
Absolute imports such as from std.x86_64.nasm import * are looked up, in
order, under the current directory, each directory in BITTERASM_PATH
(separated like PATH), and finally ~/.bitterasm. So programs anywhere on
disk find the installed std, and a project's own std/ takes priority over it.
bitter build program.basm runs the whole pipeline — compile, link, and pack —
in one command, and writes the bytes marked executable. bitter knows no
executable format itself: like instructions, ELF, PE and Mach-O headers are
ordinary BitterASM code in std.formats, which a program invokes first thing,
e.g. elf64_executable EM_X86_64, _start. Without one, the output is a flat
binary. See examples/x86_64/hello.basm for a
runnable example. bitterasm compile and bitter encode run the two halves
separately.
The BitterASM book in docs/book is a guide to
the whole language: macros, meta keywords, types, generics, modules, labels
and sections, and how bitter packs bytes. Every example in it is compiled
by cargo test. To read it locally:
mdbook serve docs/book --open
Rust
95.8%
Python
1.8%
Shell
1.8%
Making Assembly Sweeter.
BitterASM is a metalanguage for constructing assembly languages. It defines the semantics and metaprogramming machinery needed to build an ISA, while assuming essentially nothing about the target architecture itself.
Traditional assemblers bake in a specific architecture: registers, instructions, opcodes, addressing modes, and binary encoding are all privileged, built-in concepts. That works fine until you want something the assembler's authors didn't anticipate — a pseudo-instruction, an alternate syntax, a non-binary target, a research architecture. Then you're stuck extending the assembler itself.
BitterASM inverts this. The language core knows nothing about:
registers, instructions, opcodes, operands, immediates,
addresses, word sizes, endianness, calling conventions,
sections, labels, binary
Every one of those is defined in ordinary BitterASM code — as libraries, architecture packages, and evaluators. The complexity of a real ISA lives where it belongs: in the code describing that ISA, not in the language interpreting it.
Instructions are macros, not language features. mov isn't a built-in instruction declaration — it's a macro someone wrote and exposed publicly. There's no fundamental distinction between a "real" instruction and a "pseudo" one at the language level; both are just macros that expand into some encoding.
Syntax is an interface, not part of the ISA. Because instructions are macros with their own invocation syntax, the same underlying x86 machinery could support mov rax, 3, an arrow-style rax <- 3, or something else entirely — all sharing one architecture implementation underneath.
Abstraction does not imply optimization. What you expand is what you get. If a macro expands clear rax into xor rax, rax, that's the macro author's explicit choice. BitterASM never second-guesses it by picking a "faster" sequence on its own. A macro can implement sophisticated code selection, but that logic belongs to the macro author, not the language.
Even bits are a library concept. The only thing BitterASM assumes is Int — an architecture-neutral, arbitrary-precision integer. Binary (bit, bits<N>) is defined in terms of Int in the standard library, not built into the language. 3 is just the mathematical integer three until some library gives it a binary interpretation. This leaves room for architectures that aren't binary at all.
Evaluators own the output contract, not the language. BitterASM source describes behavior; an evaluator decides what running that behavior produces. A binary evaluator turns assembly into machine code. A different evaluator could target something else entirely. Language semantics (Int, struct, type, macro, pattern matching, imports, generics) mean the same thing everywhere — only the output effect changes.
Imports compose modules; they don't grant magic. from x86_64.intel import * works because that package defines mov and rax — not because the language has special knowledge of x86. Importing a binary library doesn't secretly switch the language into "binary mode"; binary is just one ordinary abstraction among many.
The payoff for assuming almost nothing is:
The tradeoff is symmetric: the fewer assumptions the language makes, the more an architecture package has to define for itself. That's the bitter part. The resulting portability, auditability, and extensibility are the sweet part.
BitterASM is an early-stage project: a Rust compiler (lexer, parser, resolver, formatter) for the language itself, a separate bitter binary evaluator that packs emitted values into machine code, and a growing std library written in BitterASM. Expect the language, the evaluator contract, and the standard library to change as the design settles.
Requires a Rust toolchain (cargo). From a clone of this repository:
make install # or: ./install.sh
This interactively builds and installs bitterasm (the compiler) and/or bitter
(the binary evaluator) to ~/.bitterasm/bin, and copies std/ to ~/.bitterasm/std.
Add ~/.bitterasm/bin to your PATH if prompted. Pass -y to answer yes to
every prompt, including those of the optional bitterasm-lsp install script.
Absolute imports such as from std.x86_64.nasm import * are looked up, in
order, under the current directory, each directory in BITTERASM_PATH
(separated like PATH), and finally ~/.bitterasm. So programs anywhere on
disk find the installed std, and a project's own std/ takes priority over it.
bitter build program.basm runs the whole pipeline — compile, link, and pack —
in one command, and writes the bytes marked executable. bitter knows no
executable format itself: like instructions, ELF, PE and Mach-O headers are
ordinary BitterASM code in std.formats, which a program invokes first thing,
e.g. elf64_executable EM_X86_64, _start. Without one, the output is a flat
binary. See examples/x86_64/hello.basm for a
runnable example. bitterasm compile and bitter encode run the two halves
separately.
The BitterASM book in docs/book is a guide to
the whole language: macros, meta keywords, types, generics, modules, labels
and sections, and how bitter packs bytes. Every example in it is compiled
by cargo test. To read it locally:
mdbook serve docs/book --open
Rust
95.8%
Python
1.8%
Shell
1.8%