Lua is a whole programming language — lexer, parser, a register-based
virtual machine, an incremental garbage collector, coroutines, and a
standard library — in ~28k lines of C with almost no dependencies.
Other languages are applications you run; Lua's defining design
decision is the opposite: it is a library you embed, with a C API
so disciplined that games, Redis, nginx, Neovim, and OpenWrt all
carry it inside themselves as their scripting engine. The deep-dive
teaches how far a few orthogonal primitives get you: one tagged-union
value type (TValue), one table structure doing duty as object,
package and namespace, one metamethod protocol replacing class
systems, and one virtual stack between host and VM that makes the
embedding boundary safe without ever exposing a pointer.
Read it against the CPython dive: same problem domain,
inverted priorities. CPython optimizes for being a platform (batteries,
C extensions reaching into objects); Lua optimizes for being a
tenant (tiny, reentrant, no global state beyond one lua_State
per interpreter, values you may only touch through the tray).
🧒 Explain like I'm 10
A craftsman's workshop that fits inside anyone's garage. The big
machine (your game, your server — the host program, written in C)
installs a small workbench (the Lua interpreter, lua_State) in the
corner. Between the big machine and the workbench is a tray (the
API stack): to ask the craftsman anything, you place materials on the
tray (lua_pushnumber), press the button (lua_call), and take the
result off the tray (lua_tonumber). You never grab his tools
directly — everything crosses the tray, so nobody gets hurt. His
workbench has named cubbies (the registry, a coat-check room) where
the big machine can store its own precious items without the
craftsman seeing them. While he works, a janitor (the incremental
garbage collector) quietly sweeps the floor, marking things that are
still needed and binning the rest — a little at a time so work never
stops for a big cleanup. And the craftsman can suspend himself
mid-task (a coroutine yielding) to let another craftsman work, since
each has his own tray but shares the one supply closet (the
global_State).
Why the grown-ups did it this way: a language that must live inside other people's programs has to be small, reentrant, and pointer-free at its boundary — so its designers spent their complexity budget on the embedding protocol, not on the type system.
1. Architecture overview
flowchart TD
HOST["host program (C/C++)<br/>game, Redis module, nginx"]
subgraph lib["liblua (~28k lines, one .a)"]
AUX["lauxlib.c — helper layer<br/>luaL_check*, luaL_Buffer"]
API["lapi.c — the public C API<br/>index2value :58 — virtual stack"]
CORE["core VM: ldo.c call protocol,<br/>lvm.c luaV_execute :1204,<br/>lstate.c states"]
OBJ["lobject.h — TValue :65,<br/>ltable.c, lstring.c interning,<br/>lfunc.c closures/upvalues"]
GC["lgc.c — incremental/generational<br/>collector, luaC_step :1741"]
COMP["front end: llex.c → lparser.c<br/>→ lcode.c (register codegen)"]
LIBS["stdlib: lbaselib, lstrlib,<br/>ltablib, lcorolib ..."]
end
HOST -->|"lua_push / lua_call / lua_to*"| API
API --> CORE --> OBJ
CORE --> GC
CORE --> COMP
LIBS --> API
AUX -->|"built on plain API only"| API
Diagram source — enable JavaScript to render the diagram.- The embedding boundary is
lapi.c. Every public function (lua_pushinteger,lua_gettable,lua_call, …) resolves its arguments throughindex2value(lapi.c:58), which translates an integer index — positive from the bottom of the stack, negative from the top, and the pseudo-indicesLUA_REGISTRYINDEXand upvalue slots — into aTValue*. Host code holds no object pointers, only stack indexes. - The spine of a script is
luaL_dofile→lua_load(lex, parse, generate bytecode) →lua_pcall→luaD_precall(ldo.c:717) →luaV_execute(lvm.c:1204, the dispatch loop) → per-instructionvmcase(OP_...)blocks → returns bubble back up the CallInfo chain. - Core object layer:
lobject.hdefines every runtime value —TValue(the tagged union),TString(interned, with a hash),Table(array part + hash part),LClosure/CClosureandUpVal(open while on a stack, closed when the stack goes away),Proto(a function's bytecode + constant pool). - The collector (
lgc.c) runs interleaved with allocation in small steps (luaC_step, line 1741), paced by a memory "debt" (setpause, line 1122), in either incremental or generational mode; mutator barriers keep the tri-color invariant. lauxlib.cis the demonstration that the API is complete: the entire auxiliary library (type checking, buffers, argument error messages) is written on top of the plain API, not beside it — the same discipline SQLite applies with its own test harness.
Walkthrough: one script, source to metamethod
The spine as a story — on the site you can step through it with Next; in the repo it reads top to bottom.
sequenceDiagram
autonumber
participant H as host (C)
participant L as lapi.c (API stack)
participant D as ldo.c (call protocol)
participant V as lvm.c luaV_execute
participant G as lgc.c
H->>L: luaL_dofile("a = 1 + width")
L->>L: lua_load: llex → lparser → lcode
L->>D: lua_pcall(0, 0)
D->>V: luaD_precall → luaV_execute
V->>V: OP_ADD → not numbers → luaT_trybinTM(__add)
V->>D: luaD_call(getmetatable(w).__add)
V->>G: allocation triggers luaC_step
V->>D: OP_RETURN → luaD_poscall
D-->>H: status OK
H->>L: lua_getglobal("a") + lua_tointeger
Diagram source — enable JavaScript to render the diagram.Load
luaL_dofile (lauxlib) reads the file, calls
lua_load, and the front end runs without ever touching the VM:
llex.c tokenizes, lparser.c builds the syntax tree in the same
pass (a one-pass recursive-descent parser), and lcode.c emits
bytecode for a register machine — OP_ADD A B C means "registers
B and C add into A", with constants reachable via OP_LOADK.
The call protocol
lua_pcall pushes the function and
arguments per the stack convention, then ldo.c:717 luaD_precall
classifies the callee: a C function gets its own little frame and
returns immediately; a Lua closure extends the stack, allocates a
CallInfo, and luaV_execute (lvm.c:1204) takes over.
The dispatch loop
luaV_execute is a computed-goto
(vmdispatch) loop over instructions; vmfetch decodes, RA/RB/RC
macros compute operand addresses relative to the frame base, and
each vmcase(OP_...) block is short enough to read at a glance —
OP_MOVE is two lines.
A metamethod detour
Evaluating 1 + width where
width is a userdata lands in OP_ADD's "not numbers" branch,
which calls luaT_trybinTM (ltm.c) to look up __add in the
value's metatable — the entire extensibility story of Lua is this
one uniform lookup protocol, not a class system.
GC ticks on allocation
The __add C function
allocates a result; lmem.c's allocation wrapper sees the GC "debt"
and runs luaC_step (lgc.c:1741) — a bounded amount of marking or
sweeping, then back to the program. Big cleanups never happen; the
floor is swept a few squares at a time.
Return
OP_RETURN moves results down, luaD_poscall
pops the CallInfo, and control unwinds to luaV_execute's caller
loop or, at the outermost frame, back to the host's lua_pcall with
status LUA_OK — the API stack now holds exactly the declared
results.
The host holds the answer, nothing else
The host
reads a with lua_getglobal + lua_tointeger — two tray round
trips — and the interpreter retains no ambient state from the call:
no registers, no leaked frames, one tidy lua_State.
2. Core data structures — and why these
TValue— one tagged union for every value. What:Value value_(a union of pointer/integer/float/C function) plus a one-byte tag with variant bits (lobject.h:49union,:65struct; collectable vs primitive variants). Why: one representation means one set of macros (setivalue,ttisnumber, …) for the whole VM — the dispatch loop stays uniform, the GC needs exactly one "is it an object?" test (theiscollectablebit), and C hosts never guess a layout. The rejected alternative (unboxed doubles via NaN-boxing, as some JITs do) buys speed and pays in debugging pain — Lua keeps the honest 16-byte value everywhere except where it caches.Table— array part and hash part in one struct. What: aTValue *arrayprefix plus aNodehash array, auto-rebalanced byrehashwhich migrates integer keys into the array part;lastfreecached just before the node block (theLimboxlayout trick atltable.c:55). Where:ltable.c(luaH_new,luaH_set,rehash). Why: Lua's tables are simultaneously arrays, dictionaries, objects, modules and environments; the hybrid makes the two common shapes (t[1],t.name) both fast without two user-visible types — the same "one structure, two access paths" economy as Redis's ziplist/listpack conversions, but at language level.lua_State+CallInfo— the stack is the interpreter. What: per-thread value stack with a linked chain ofCallInfoactivation frames (lstate.h:285);L->ci->func.p + 1is the current frame base, the API's pseudo-index arithmetic hangs off the same stack. Why: one contiguousTValuearray serves interpreter frames, C-call frames, and host API traffic — coroutines are then just a secondlua_Statesharing theglobal_State, and context switch is a pointer swap, not a copy.Interned strings + the
stringtable. What: every string is aTStringhash-consed into a global hash table (lstring.c:269luaS_new,:133 luaS_init). Why: string comparison becomes pointer equality (the hot path in every dynamic language), table keys get cheap hashing (hashstr,ltable.c:115), and interning gives the GC a single place to find all strings — the same trick Java and Python use, visible here in ~100 lines.Closures and upvalues. What:
LClosurereferences up to 254UpValhandles (lfunc.c); anUpValis open (points into a live stack slot, linked inL->openupval) until its variable goes out of scope, then it is closed — its value copied into the cell itself. Why: open upvalues make nested functions share mutable locals with zero copying while the parent frame lives, and closing is a one-shot snapshot the GC can own. C functions get the same mechanism (CClosureupvalues reachable as pseudo-indexes), so host closures and script closures are the same concept.
3. Concurrency and failure model
flowchart LR
subgraph g["global_State (one per lua_newstate)"]
STRT["stringtable<br/>(interned strings)"]
GC["GC state<br/>(tri-color, debt-paced)"]
REG["l_registry<br/>(host coat-check)"]
MT["metatable name →<br/>event table cache"]
end
L1["lua_State A<br/>(main thread)"]
L2["lua_State B<br/>(coroutine)"]
L3["lua_State C<br/>(coroutine)"]
L1 & L2 & L3 --- g
Diagram source — enable JavaScript to render the diagram.- One OS thread, many
lua_States. The VM assumes it owns its thread; concurrency is cooperative coroutines (lcorolib.c,lua_resumeon the state machine inldo.c) — never preemption, never shared-memory threading. Embedders needing parallelism run onelua_newstateper OS thread (that is the entire isolation model, and why it composes with hosts like nginx per-worker). - Errors are longjmp, contained by
lua_pcall.luaD_throw(ldo.c:125) jumps to the nearest recovery point (LUAI_THROW);luaD_rawrunprotectedbrackets calls. Error values are ordinary values on the stack, and the panic boundary — what happens if a C callback throws without apcall— is explicit (lua_atpanic). Same shape as SQLite'ssetjmptransaction aborts: one cheap non-local exit instead of errno plumbing through every VM function. - Crash mid-GC: the incremental design means the mutator never
observes a half-moved world; barriers (
luaC_barrier) are the protocol that keeps every intermediate state valid — recovery is simply "keep stepping the collector." - Yield across C boundaries is the subtle failure case: a C
function cannot keep its C stack frame while yielded, so resumable
C calls get continuation functions (
kargument oflua_callk) and the unfinished-C-call state is replayed byunroll/finishCcall(ldo.c:868). This is the price paid for coroutines-as-language-feature rather than OS threads, paid once, inldo.c, by the implementors instead of every user.
4. API and extension design
- The virtual stack is the API. Push, call, read: three verbs
cover everything;
lapi.c:58 index2value's positive/negative/ pseudo-index rules are the entire addressing scheme. No handles, no refcounts handed to hosts, no struct layouts in headers beyondlua_State's opaque declaration. - The registry (
LUA_REGISTRYINDEX,index2value's branch at line 71) is a normal table only the host can conveniently see — the coat-check for C-side references (module tables, callbacks, userdata metatables).luaL_ref/luaL_unrefhand out integer tickets. - Seams: metatables (per-type default tables in the registry),
__index/__newindexchains (including__gc,__closeto-be-closed variables — seeL->tbclistinlstate.h), userdata with per-object environments, and module loading viarequire(loadlib.c) with searchers as a table the host can extend. - Honest wart: the copy-through-the-tray discipline means every
host interaction is O(1) stack traffic but visibly bureaucratic —
a five-line C computation is ten calls. Lua 5.x's answer (luaL
helpers,
luaL_Buffer) papers over it, but the friction is the price of safety, and the API has grown pseudo-index exceptions (upvalues, registry,lua_upvalueindex) that every wrapper library must re-derive.
5. The defining trade-offs
Trade-off 1: one uniform TValue over specialized representations
- Tension: dynamic languages pay per value; unboxed or tagged-in- pointer schemes are faster but leak layout assumptions everywhere.
- Choice: a plain 16-byte tagged union for every value on every stack and in every table.
- Evidence:
lobject.h:49/:65; the VM's uniformsetobjs2s/ttis*macros throughoutlvm.c. - Given up: cache density and the last factor of interpreter speed (LuaJIT took exactly the other path and shows what was left on the table).
- Verdict: right for a portable, embeddable core where the host compiler and target CPU are unknowable; wrong for a JIT whose whole job is representation specialization.
Trade-off 2: incremental, debt-paced GC over stop-the-world
- Tension: hosts embed Lua inside soft-realtime loops (game frames, request handlers); a 50ms GC pause inside them is a bug report.
- Choice: the collector advances in small steps on every
allocation, paced by a debt accumulator, with barriers preserving
the invariants in between (
lgc.c:1741 luaC_step,:1122 setpause; generational mode switches in the same file). - Evidence: every table write macro funnels through
luaC_barriervariants — the mutator pays a branch per store to keep steps valid. - Given up: the simple two-line "mark all, sweep all" collector, and some throughput — incremental collectors do more total work.
- Verdict: right whenever you are a guest in someone else's latency budget — the same reasoning that drove Redis's incremental rehash and ZooKeeper's steady-state design. Wrong for short-lived CLI processes where a sweep at exit is free.
Trade-off 3: copy semantics through a virtual stack over direct references
- Tension: C hosts want speed and convenience; the language wants to stay portable and GC-safe across an FFI boundary it cannot police.
- Choice: all traffic goes through the stack with explicit push/convert/pop; hosts hold indexes, never pointers.
- Evidence:
lapi.c:58 index2value;lua_pushvalue/lua_to*copies rather than aliasing;lauxlib.cexisting at all is proof the raw protocol is usable from the outside. - Given up: ergonomic FFI — contrast LuaJIT's
ffi(blazing, unsafe, C-layout-coupled). The stock API makes it impossible to hold a stale object pointer. - Verdict: right for a library whose customers include sandboxes and 20-year ABIs; wrong when both sides are one tightly-coupled build, where a zero-copy FFI is honest.
6. Reading path
lua.h— the entire public API, in one commented header; notice how few functions there are, and thatlua_Stateis opaque. This header is the design document.lapi.c:58 index2value— every API call's front door; master the positive/negative/pseudo index rules here before anything else.lstate.clua_newstate/f_luaopen— what one interpreter costs: stacks, string table, metatables, registry seeded.ldo.c:717 luaD_precall— the call protocol: C vs Lua closures, frame layout, thenluaD_call/luaD_poscall; alsoldo.c:125 luaD_throwandluaD_rawrunprotectedfor the error model.lvm.c:1204 luaV_execute— spend an hour here. Readvmfetch,RA/RB/RKC, then followOP_MOVE,OP_ADD(numbers fast path, metamethod slow path),OP_GETTABUP(how globals actually work: an upvalue pointing at the environment table),OP_TAILCALL,OP_RETURN.lobject.hfrom line 49 —TValue, then walk the object zoo (TString,Table,Proto,LClosure/CClosure,UpVal); every later file speaks this vocabulary.ltable.c—luaH_new/luaH_set, the array/hash split, andrehashmigrating integer keys; theLimboxat line 55 is a nice trick for hidinglastfreein the allocation header.lgc.c— colors and states (lgc.hfirst),singlestep, the atomic phase, thenluaC_step:1741andsetpause:1122; find the barrier macros used inltable.cwrites from step 7.ltm.c— the ~20 event names andluaT_gettmbyobj; connect it back to theOP_ADDslow path you read in step 5.lauxlib.c—luaL_checkinteger,luaL_Buffer,luaL_ref/luaL_unref; the proof-by-construction that the API composes.lbaselib.c(printis ~15 lines) andlcorolib.c(coroutine .resumewrappinglua_resume) — libraries as ordinary API clients; thenlundump.cif you care about precompiled chunks.
You have now traced one script from bytes through bytecode, a register-VM loop, a metamethod dispatch, and a garbage-collection step — plus the tray that made it all embeddable.
7. What to steal
- Make your library's boundary a virtual stack (or queue) of
values, not object references.
lapi.cshows that one addressing rule (positive/negative/pseudo) plus push/convert/pop covers everything, and stays ABI-stable for decades. - A registry with integer tickets (
luaL_ref) is the minimal safe "host owns a reference" mechanism whenever a GC lives underneath — steal it for any plugin host. - Pace background work by allocation debt, not timers:
luaC_step's "every malloc pays a slice" trick keeps GC cost proportional to allocation with zero scheduling machinery. - One representation discipline beats N special cases: the
uniform
TValueis why 28k lines cover strings-to-coroutines; resist per-type fast paths until measurement demands them. - Implement your own helpers on your own public API.
lauxliband the stdlib never cheat — so the API's completeness is continuously testable by its own product. - Metadata-driven extensibility (one metatable protocol instead of class hierarchies) keeps the core small while users build inheritance, prototypes, or DSLs themselves.
8. Difficulty and prerequisites
- Difficulty:
moderate. Realistic budget: 6 hours for this path; 10 if you also readlgc.cin full. - Prerequisites: comfortable C (function pointers, unions,
setjmp); basic compiler vocabulary (lexer/parser/bytecode — any intro post suffices); no prior VM knowledge needed —lvm.c's comments teach register machines as you go. The Lua reference manual's §4 (the C API) is the ideal pre-read.
9. Further reading
- Programming in Lua (4th edition), Roberto Ierusalimschy — especially the stack-protocol chapters and the C API part.
- The Lua 5.5 reference manual — https://www.lua.org/manual/5.5/
- Ierusalimschy et al., Lua: an extensible extension language and The Evolution of Lua (HOPL III paper) — the design history behind "small core, embed everywhere."
lua-users.orgwiki — the C/Lua binding idioms andluaL_refpatterns this dive cites.- The CPython dive in this atlas for the contrasting "language as platform" design.