The problem
What are we trying to solve, and why does it need a file format at all?
What a .beam file is for
Every Erlang module you have ever loaded arrived as a .beam file. The compiler turns hello.erl into hello.beam; code:load_file/1 hands those bytes to the runtime; a few microseconds later hello:greet/1 can be called. The file is the contract between two programs that are developed together but run at very different times: the compiler, written in Erlang, and the loader, written in C inside ERTS.
So the obvious job is to carry code. But look at what a module really is when it reaches the runtime. It isn't just instructions. It's instructions plus a set of names those instructions talk about:
- the atoms it mentions (
ok,error, its own module name); - the functions it exports, and where each one starts;
- the functions it calls in other modules, which may not be loaded yet;
- the constant terms it uses, such as
#{a => [1,2,3]}or a long binary; - the funs it creates, which must survive hot code upgrades;
- the line numbers that make stack traces readable;
- its attributes, compile options, documentation, and, optionally, enough debug information to rebuild the source.
The runtime needs some of these to execute anything. It never needs others. Tools such as xref, dialyzer, the debugger, the shell's h/1 and cover need yet others. A .beam file is the single artefact that carries all of them.
Who reads it
It helps to list the consumers, because each one wants something different from the same bytes:
| Reader | Wants | Ignores |
|---|---|---|
The loader (erts/emulator/beam/beam_file.c) | Code, atoms, imports, exports, literals, funs, lines, types. It must be fast and must reject anything malformed. | Docs, debug info, attributes |
beam_lib (stdlib) | Any chunk by name, decoded into Erlang terms. It can also strip, compare and rebuild files. | The instructions themselves |
beam_disasm, erts_debug:df/1 | The instruction stream and every table it refers to | Docs |
Debugger, xref, cover, Dialyzer | Abstract code, from Dbgi | Most of the rest |
Shell h/1, ExDoc, LSPs | EEP-48 docs, from Docs | Code |
| Releases, deployment tools | The smallest file that still loads (beam_lib:strip_release/1) | Everything optional |
The constraints
Combine those readers with how Erlang systems actually live, and a handful of hard requirements falls out:
- Portable. One file must load on any word size and endianness, and on both the JIT and the interpreter.
- Separable. Optional information has to be removable without touching, or even understanding, the parts that matter.
- Extensible. New releases add things: UTF-8 atoms, type hints, feature flags, native records, debugger metadata. Old tools must keep working on new files wherever that is possible.
- Refusable. When old runtimes can't run new code, they must say so clearly, not crash halfway through loading.
- Identifiable. Hot code loading and release handling need to know whether two files contain "the same module", even if one was compiled on another day or another machine.
- Cheap. Releases boot thousands of modules. Parsing must be close to free.
Keep this list in mind. Every design decision that follows answers one of these six points.
The design choice
What else could it have been, and why is the answer a format for Amiga paint programs?
Other ways to do it
Imagine you are designing this from scratch in the late 1990s. You have several reasonable options:
One big term_to_binary
Serialise {Module, Exports, Code, ...} as a single term. It's trivially simple from Erlang.
Fails: separable, cheap
To read the module name or strip the docs, you must decode everything. The C loader would also need a full term decoder before it could see a single instruction.
A native object format (ELF, Mach-O)
Sections, symbols and relocations are well understood, and existing tooling knows them. Fails: portable Each one is tied to a platform family and built around machine code, linkers and addresses. BEAM has none of those. You'd carry the weight and use none of it.A fixed layout (like a JVM .class)
A header, then a constant pool, then the fields and methods, in a fixed order.
Strains: extensible
Every new section is a format revision. An old reader cannot skip what it doesn't understand, because it doesn't know where the next section starts.
An archive (zip, tar)
Put one file per table in a container format. Overkill Directories, file names, timestamps and permissions are a lot of machinery for about a dozen blobs. (OTP does use zip one level up, in.ez application archives.)
What you actually want is somewhere in the middle: a sequence of independent, named, length-prefixed blobs. A reader can look at a blob's name, decide whether it cares, and jump past it in O(1) if it doesn't. Writing a new kind of blob doesn't break anyone. Removing an optional blob is cutting bytes out of a file.
That idea already existed, fully specified, and was in the public domain.
Enter EA IFF 85
In January 1985, Jerry Morrison at Electronic Arts published "EA IFF 85: Standard for Interchange Format Files". It was written so that independently developed Amiga programs, such as paint programs, music editors and word processors, could exchange data. Its building block is the chunk:
"FORM", "BODY"…
data bytes only
…then a pad byte if the size is odd
A file is a FORM chunk whose data begins with a four-character form type (such as ILBM for images or AIFF for audio) followed by more chunks. The spec's central rule for readers is the reason the format is still around 40 years later:
"It should skip over all unrecognized chunks."EA IFF 85, on readers
The spec also includes a design philosophy, borrowed from Alan Kay: "Simple things should be simple and complex things should be possible." Its descendants are everywhere: AIFF audio, Microsoft's little-endian clone RIFF (WAV, AVI, WebP), and the chunk-based design of PNG.
BEAM uses IFF almost unchanged, with two deliberate deviations:
| EA IFF 85 | BEAM | |
|---|---|---|
| Outer group ID | FORM | FOR1 |
| Form type | ILBM, 8SVX, … | BEAM |
| Alignment / padding | 2 bytes (pad odd sizes with one 0) | 4 bytes (pad with 0–3 zeros) |
| Size excludes padding | yes | yes |
| Byte order | big-endian (Motorola 68000) | big-endian |
Nesting (LIST, CAT , PROP) | yes | no, just one flat level of chunks |
The 4-byte alignment exists because almost every BEAM table is made of 32-bit words. With 4-byte alignment, a C loader can read them from aligned addresses, and a .beam file can be mmaped and walked directly.
Now go back to the six constraints. Portable: nothing in the container depends on the host machine. Separable: chunks are independent. Extensible: unknown chunk IDs are skipped. Cheap: finding a chunk means reading 8-byte headers and adding offsets. The other two, refusable and identifiable, are solved inside the chunks, as we'll see.
What it costs
Every format is a set of trade-offs. Here are the ones BEAM accepts:
- No directory. To find a chunk you scan from the start. With roughly 10–16 chunks, this costs nothing.
- 32-bit sizes. Each chunk, and the whole file, is limited to 4 GiB. No real module comes close.
- No per-chunk integrity check. The module MD5 covers meaning, not transport. If you need a checksum on the bytes, the tools gzip the whole file instead (which also adds a CRC).
- Chunk IDs are a shared namespace. Anyone can add a chunk with the
{extra_chunks, …}compile option, so choose IDs that won't collide.
The ideas
Six ideas explain almost everything you'll see in the bytes.
1 · A flat bag of chunks
A .beam file is FOR1, a size, BEAM, and then chunks one after another. Each chunk has a four-letter ID that roughly describes it: AtU8 holds atoms in UTF-8, Code is code, ImpT and ExpT are the import and export tables, and LitT is the literal table. The chunks fall into three groups:
- Required by the loader:
AtU8,Code,StrT,ImpT,ExpT. - Used by the loader when present:
FunT,LitT,Line,Type,Meta,Recs,DbgB,Attr,CInf,LocT. - For tools only:
Dbgi,Docs, and anything else.
Order doesn't matter to the loader, because it scans the whole file and picks out the IDs it knows. The compiler still writes chunks in a stable order, and it writes the essential ones first.
2 · Name things once, refer to them by number
Instructions never contain an atom's text, a function name, or a constant term. Instead the compiler builds tables: atoms, imports, literals, strings, line locations, types and funs. The code holds small integers that index into those tables.
This one idea gives you several properties. The code stream is small, because most operands fit in a single byte. It is position-independent and identical on every machine. And the loader's job becomes clear: it resolves each table once into runtime values (interning atoms, finding export entries, copying literals into the literal area), then patches the code as it translates it.
3 · Compact terms: a variable-length integer with a type tag
Operands in the code stream use a small custom encoding. The low three bits of the first byte are a tag that says what kind of operand this is: a plain number (u), an integer literal (i), an atom index (a), an X register, a Y register, a label (f), or "extended" (z). The remaining bits hold the value. Values from 0 to 15 fit in the same byte, values up to 2047 take two bytes, and anything larger carries its own byte count.
Most operands are small register numbers, atom indices and labels, so the common case is one byte. The encoding is self-delimiting and carries its type with it. A reader can walk the instruction stream and know what kind of value each operand is without decoding anything else first. The format is so convenient that newer chunks (atom lengths in OTP 28, DbgB, Recs, Line) reuse it instead of inventing their own.
4 · When it's rich, use the External Term Format
Attributes, compile info, debug info, docs, feature metadata and every literal are stored as ordinary term_to_binary/1 output. Erlang already has a precise, versioned serialisation for arbitrary terms, so there's no reason to invent another. The rule of thumb:
- Hot path (read on every load, consumed by C): fixed 32-bit big-endian words and compact terms.
- Rich or cold data (arbitrary terms, consumed by Erlang tools): External Term Format.
5 · Generic instructions, and a way to refuse
The opcodes in a .beam file come from lib/compiler/src/genop.tab. Each one has a number and a fixed arity, for example 1: label/1, 64: move/2 and 164: put_tuple2/2. New opcodes are only ever appended, and old ones are never renumbered. Opcodes the compiler stopped using are kept for compatibility, or marked as removed.
The Code chunk header records the highest opcode number used. A runtime that doesn't know that opcode refuses the module up front, with a clear message about recompiling, instead of failing in the middle of loading. That covers the refusable constraint. The header also has a format number, which has been 0 since the format was created and would only change if existing instructions changed meaning.
6 · Identity is a hash of the meaning
Is hello.beam today the same module as hello.beam last week? The answer is the module MD5: an MD5 over the payloads of the significant chunks (atoms, code, strings, imports, exports, funs, literals, features, records and debug metadata), in a fixed order. Headers, padding, timestamps, docs and debug info are excluded. If you don't set -vsn(...), this MD5 becomes your module's version. It's what Mod:module_info(md5) returns, and it's what lets beam_lib:strip/1 remove half a file without changing the module's identity.
The details
Now for the exact bytes. All multi-byte fixed-width integers are unsigned 32-bit big-endian unless stated otherwise.
The container
= 4 + Σ chunks
"Code"…
excludes padding
to 4-byte alignment
The whole writer, from beam_asm.erl:
%% Build an IFF form. build_form(Id, Chunks0) when byte_size(Id) =:= 4, is_list(Chunks0) -> Chunks = list_to_binary(Chunks0), Size = byte_size(Chunks), 0 = Size rem 4, % Assertion: correct padding? <<"FOR1",(Size+4):32,Id/binary,Chunks/binary>>. chunk(Id, Contents) when byte_size(Id) =:= 4, is_binary(Contents) -> Size = byte_size(Contents), [<<Id/binary,Size:32>>,Contents|pad(Size)].
And the reader side, from beam_lib:scan_beam/5. Note the rounding to the next multiple of four:
{NFD, {ok, <<IdL:4/binary, Sz:32>>}} ->
Id = binary_to_list(IdL),
Pos1 = Pos + 8,
Pos2 = (4 * trunc((Sz+3) / 4)) + Pos1,
The loader (iff_init and read_beam_chunks in beam_file.c) does the same: it checks FOR1, takes FormSize bytes, checks BEAM, and then loops over chunk headers. Every chunk ID it doesn't recognise is skipped, just as the IFF spec says.
A whole file, annotated
Here is a complete module:
-module(hello).
-export([greet/1]).
greet(Name) ->
{hello, Name}.
Compiled with erlc +deterministic hello.erl, it is 528 bytes. Click a chunk name, or any byte, to see what it is. Chunk headers are in bold and padding is greyed out.
Six chunks are worth a closer look by hand.
AtU8: the atom table
00000005 count = 5 (OTP 27 style: positive) 05 68 65 6c 6c 6f len 5, "hello" → atom 1 05 67 72 65 65 74 len 5, "greet" → atom 2 0b 6d 6f 64 75 6c 65 5f 69 6e 66 6f "module_info" → atom 3 06 65 72 6c 61 6e 67 "erlang" → atom 4 0f 67 65 74 5f 6d 6f 64 75 6c 65 5f 69 6e 66 6f → atom 5 00 padding (51 → 52)
OTP 28 and later write the same table like this:
ff ff ff fb count = −5 ⇒ "lengths are compact terms" 50 68 65 6c 6c 6f len: u-tagged 5 = (5 bsl 4) bor 0 50 67 72 65 65 74 b0 6d 6f 64 75 6c 65 5f 69 6e 66 6f …
The negative count tells the reader which encoding follows. Old files still load, and atoms longer than 255 bytes become possible. To produce the old layout from a new compiler, use the no_long_atoms option.
ImpT and ExpT: who we call, and who may call us
ImpT 00000002 2 imports 00000004 00000005 00000001 erlang:get_module_info/1 → import 0 00000004 00000005 00000002 erlang:get_module_info/2 → import 1 ExpT 00000003 3 exports 00000003 00000001 00000006 module_info/1 at label 6 00000003 00000000 00000004 module_info/0 at label 4 00000002 00000001 00000002 greet/1 at label 2
Each entry is three words: atom index, arity, and (for exports) the entry label. module_info/0,1 are generated by the compiler and are just tail calls into an import, which is why the import table isn't empty even though hello calls nothing itself. BIFs are imports too: erlang:length/1 ends up in ImpT like any other remote function.
Code: the header and the instructions
00000010 sub-header size = 16 bytes (the four words below) 00000000 format number = 0 000000ab highest opcode = 171 (OTP 30 forces ≥ 183) 00000007 label count = 7 (labels 1..6 used, 0 = "no label") 00000003 function count = 3 01 10 99 10 02 12 22 10 01 20 10 30 10 a4 03 17 20 12 03 13 …
The first word gives the size of the rest of the header. A future release can add header fields, and older readers will still find where the code begins. The decoded instructions are below.
Attr: where your vsn comes from
83 6c 00000001 68 02 77 03 "vsn" 6c 00000001 6e 10 00 b3 36 14 … 31 6a 6a
= term_to_binary([{vsn, [66337080814856080590574765180527589043]}])
That big integer is the 128-bit module MD5. It's ordinary External Term Format (83 is the version byte, 6c a list, 68 a small tuple, 77 a small UTF-8 atom, 6e a small bignum).
Line: locations
00000000 00000000 version 0, flags 0 00000003 3 line instructions in the code 00000001 1 location item 00000000 0 extra file names (index 0 is implicitly "hello.erl") 41 i-tagged 4: "line 4" 00 00 00 padding
Dbgi: debug info, without debug info
term_to_binary({debug_info_v1, erl_abstract_code, {none, [deterministic]}})
We didn't compile with +debug_info, so the chunk still names the backend but contains none. With +debug_info it would hold the full abstract code, which is enough to reconstruct the source. That's why releases strip it, or encrypt it.
Compact term encoding
This is the part of the format that's least like IFF, and the part you need to understand to read code. The definitive implementation is beam_asm:encode/2. Every operand starts with a byte whose low three bits are the tag:
| Tag | Bits | Name | Value means |
|---|---|---|---|
u | 000 | unsigned | A plain number: an arity, a count, an import index, a heap size |
i | 001 | integer | An Erlang integer literal (signed). Values with |N| ≥ 2128 go to LitT instead. |
a | 010 | atom | An index into AtU8; 0 means [] |
x | 011 | X register | {x,N}: an argument or temporary register |
y | 100 | Y register | {y,N}: a stack slot |
f | 101 | label | A jump target; 0 means "no fail label, raise instead" |
h | 110 | character | Reserved; the current compiler doesn't emit it |
z | 111 | extended | The value selects an extended form, and more operands follow (see below) |
The two bits above the tag (bits 3 and 4) choose how big the value is:
Small: 0 ≤ N ≤ 15
One byte: (N bsl 4) bor Tag. {x,0} is 03, and {atom,1} is 12.
Medium: 16 ≤ N ≤ 2047
Two bytes: the top 3 bits of an 11-bit value, then the low 8 bits. For example, u 300 is 28 2c.
Large: 2–8 value bytes
nnn = byte count − 2, followed by that many bytes of big-endian two's complement. Positive values get a leading 00 if their top bit is set.
Huge: 9+ value bytes
Followed by (byte count − 9) as a u-tagged compact term, then the bytes.
Here is a complete decoder in Erlang. The bit syntax maps almost directly onto the diagrams:
decode(<<V:4, 0:1, Tag:3, Rest/binary>>) -> % small {Tag, V, Rest}; decode(<<Hi:3, 0:1, 1:1, Tag:3, Lo, Rest/binary>>) -> % medium {Tag, (Hi bsl 8) bor Lo, Rest}; decode(<<7:3, 3:2, Tag:3, Rest0/binary>>) -> % huge {_, N, Rest1} = decode(Rest0), big(Tag, N + 9, Rest1); decode(<<L:3, 3:2, Tag:3, Rest/binary>>) -> % large big(Tag, L + 2, Rest). big(Tag, N, Bin) -> <<V:N/signed-unit:8, Rest/binary>> = Bin, {Tag, V, Rest}.
Try it
Extended (z) forms
When the tag is z, the value is a sub-type and more compact terms follow:
| z | Meaning | Followed by |
|---|---|---|
0 | float (obsolete) | Rejected since R16B. Floats are always literals now. |
1 | list | u count, then that many operands. Allowed only as an instruction's last operand (select lists, tuple elements, BIF argument lists). |
2 | float register | u register number: {fr,N} |
3 | allocation list | u count, then pairs of (u kind, u amount). Kinds: 0 words, 1 floats, 2 funs, 3 native records. |
4 | literal | u index into LitT |
5 | typed register | An x/y operand, then a u index into Type. The loader can drop the type if Type was stripped. |
Chunk reference
These are the chunks the OTP 30 compiler can write, in the order beam_asm:build_file/9 emits them. The MD5 column marks chunks included in the module MD5. With the slim option, the compiler writes only the essential chunks plus Attr and Type.
| ID | Layout | MD5 | Notes |
|---|---|---|---|
AtU8 | Count:i32, then per atom: Len, UTF-8 bytes | ✓ | Count < 0 ⇒ Len is a compact u term (OTP 28+). Otherwise Len is one byte. The old Latin-1 Atom chunk (OTP 20 and earlier) is rejected by the loader. |
Code | HeaderSz, Format, MaxOpcode, Labels, Functions; instructions; int_code_end | ✓ | Required. HeaderSz lets the header grow. The stream ends with opcode 3. |
StrT | raw bytes | ✓ | Required, often empty. A pool for binary-matching strings, referenced by offset and length. |
ImpT | Count, then {Module, Function, Arity} | ✓ | Required. 0-based index used by call_ext, bif, gc_bif and so on. |
ExpT | Count, then {Function, Arity, Label} | ✓ | Required. |
FunT | Count, then {Function, Arity, Label, Index, NumFree, OldUniq} | ✓ | Omitted when there are no funs, because an empty chunk would change the MD5. OldUniq is the first 27 bits of the module MD5, filled in after hashing and zeroed again when re-hashing. |
LitT | 0:u32, Count, then {Size, ETF} | ✓ | OTP 28+ is uncompressed, with the leading 0 as a marker. OTP 27 and earlier: UncompressedSize:u32 followed by zlib data. Omitted when there are no literals. |
Meta | ETF | ✓ | [{enabled_features, [...]}]. Features change semantics, so they're part of identity. |
Recs | Version 0, Count, NumFields; encoded definitions | ✓ | Native records (OTP 29+). Each definition is encoded as a fake call_last instruction, chosen only because it takes three operands. |
DbgB | Version 1, NumItems, NumTerms; encoded entries | ✓ | BEAM debugger info (beam_debug_info option, OTP 28+): frame sizes, variable locations and calls for each debug_line. Entries are encoded as fake call instructions. |
LocT | Count, then {Function, Arity, Label} | Local functions. Used by tools and the JIT's asm dump. | |
Attr | ETF | Module attributes. Gets {vsn,[MD5]} if you didn't set one. | |
CInf | ETF | [{version, CompilerVsn} | Info]. Ignored by beam_lib:cmp/2, since it's the chunk that differs between otherwise identical builds. | |
Dbgi | ETF, or 0, N, Mode:N, Cipher | {debug_info_v1, Backend, Data}. Tools must call Backend:debug_info/4 and never parse Data directly. Encrypted form: des3_cbc, key from .erlang.crypt. | |
Docs | ETF (compressed) | EEP-48 #docs_v1{}, from -moduledoc and -doc. | |
Line | Version, Flags, NumLineInstrs, NumItems, NumNames; items; names | Items are compact terms: an a-tag switches file, an i-tag gives a line. Names are u16 length + UTF-8. Item 0 is the implicit "undefined location", and name 0 is the implicit Module.erl. | |
Type | Version, Count; encoded types | Types referenced by z5 typed registers, used by the JIT to skip checks. Version 4 in OTP 30. The encoding belongs to beam_types:encode_ext/1. | |
Abst | ETF | Historical: abstract code before OTP 20. beam_lib still reads it. |
Notice the trick that Recs and DbgB share. Instead of inventing a new serialisation, beam_asm writes their contents as if they were instructions, so the loader can reuse its operand decoder, along with the atom and literal tables. Atoms used only by those chunks still go in AtU8. That's why beam_asm builds them before the atom table.
Reading the code
An instruction is a one-byte opcode followed by exactly as many compact-term operands as genop.tab says. A z1 list can add more operands at the end. Here is the full code stream of hello.beam:
| Bytes | Opcode | Operands | Meaning |
|---|---|---|---|
01 10 | 1 label/1 | u1 | {label,1} |
99 10 | 153 line/1 | u1 | Location item 1 (line 4) |
02 12 22 10 | 2 func_info/3 | a1 a2 u1 | hello:greet/1. Raises function_clause if you jump here. |
01 20 | 1 label/1 | u2 | Entry point (matches ExpT) |
10 30 10 | 16 test_heap/2 | u3 u1 | Ensure 3 heap words; 1 live X register |
a4 03 17 20 12 03 | 164 put_tuple2/2 | x0 z1 u2 a1 x0 | {x,0} = {hello, X0} |
13 | 19 return/0 | ||
01 30 99 00 02 12 32 00 01 40 | label 3, line 0, func_info, label 4 | module_info/0 | |
40 12 03 | 64 move/2 | a1 x0 | {x,0} = hello |
4e 10 00 | 78 call_ext_only/2 | u1 u0 | Tail-call import 0: erlang:get_module_info/1 |
01 50 … 01 60 | label 5, line 0, func_info, label 6 | module_info/1 | |
40 03 13 | 64 move/2 | x0 x1 | |
40 12 03 | 64 move/2 | a1 x0 | |
4e 20 10 | 78 call_ext_only/2 | u2 u1 | Import 1: erlang:get_module_info/2 |
03 | 3 int_code_end/0 | End of code | |
That's 56 bytes for three functions. Compare it with erlc -S hello.erl, which prints the same program as Erlang terms before beam_asm encodes it. Two consequences worth remembering:
- The loader is a translator, not a copier. It turns
a1into the runtime atomhello,u0aftercall_ext_onlyinto a pointer to an export entry, and labels into addresses. It then rewrites generic instructions into specialised ones using the rules inops.tab, or into native code. - Everything is in the tables. You can't understand an instruction without
AtU8,ImpTandLitT. That's why those chunks are essential and part of the module MD5.
The module MD5
beam_lib:md5/1, code:module_md5/1 and the compiler all compute the same value:
md5_chunks() ->
["Atom", "AtU8", "Code", "StrT", "ImpT", "ExpT",
"FunT", "LitT", "Meta", "Recs", "DbgB"].
%% MD5 = erlang:md5([Data || Id <- md5_chunks(), present, in this order]),
%% with every FunT entry's OldUniq word replaced by 0.
Only the chunk payloads are hashed. Headers and padding are not. For hello.beam, hashing AtU8 ++ Code ++ StrT ++ ImpT ++ ExpT ++ Meta gives exactly the integer stored in Attr as vsn. There's a circular dependency here, and the compiler breaks it the same way every time: it builds the MD5 chunks with OldUniq = 0, hashes them, then writes 27 bits of the hash into FunT. Everyone who re-hashes zeroes those words first.
Because CInf, Dbgi, Docs and Line aren't hashed, you can change docs or line numbers without changing the module's identity. Release upgrades and beam_lib:cmp/2 depend on that.
Versions, and how they show up in the bytes
The format number has never changed. All evolution happens through new chunks, new opcodes and small in-chunk markers. Ignoring the inside of individual instructions, the history looks like this:
| Release | What changed on disk |
|---|---|
| OTP 20 | AtU8 replaces Atom (UTF-8 atoms). Dbgi replaces Abst. |
| OTP 25 | Type chunk and z5 typed registers. call_fun2 is used as a "too old" canary for OTP 24. Meta chunk for features. |
| OTP 27 | executable_line (opcode 183). The compiler writes Docs from -doc attributes. |
| OTP 28 | Negative AtU8 count with compact lengths. Uncompressed LitT (leading 0 word). debug_line (184) and the DbgB chunk. |
| OTP 29–30 | Native records: opcodes 185–193, the Recs chunk, and allocation kind 3. |
The compiler options no_long_atoms and compressed_literals write the older forms, so files built by a new compiler can be read by older tools. The loader, however, always accepts both forms.
Tools of the trade
%% Which chunks, where, how big beam_lib:info("hello.beam"). %% Decoded tables beam_lib:chunks("hello.beam", [atoms, imports, labeled_exports, attributes]). %% Raw bytes of any chunk, by ID beam_lib:chunks("hello.beam", ["Code", "Line"]). %% Instructions, as the compiler saw them beam_disasm:file("hello.beam"). %% shell: erlc -S hello.erl → hello.S (before assembly) %% Instructions, as the loader produced them erts_debug:df(hello). % writes hello.dis %% Strip, compare, rebuild beam_lib:strip("hello.beam"). beam_lib:cmp("a/hello.beam", "b/hello.beam"). beam_lib:build_module([{"AtU8", A}, {"Code", C}, ...]). %% Add your own chunk at compile time compile:file(hello, [{extra_chunks, [{<<"Xmpl">>, <<"hi">>}]}]).
Finally, here is the whole container in ten lines, because Erlang's bit syntax was made for formats like this:
chunks(File) ->
{ok, <<"FOR1", Size:32, "BEAM", Rest/binary>>} = file:read_file(File),
Size = byte_size(Rest) + 4,
split(Rest).
split(<<>>) -> [];
split(<<Id:4/binary, Len:32, Data:Len/binary, Rest0/binary>>) ->
Pad = (4 - Len rem 4) rem 4,
<<_:Pad/binary, Rest/binary>> = Rest0,
[{Id, Data} | split(Rest)].
From here, the best next step is to read the sources this page is built on. beam_asm.erl is under 900 lines and writes everything. beam_lib.erl reads it back from Erlang. erts/emulator/beam/beam_file.c is the loader's strict view of the same bytes, and genop.tab is the instruction set.