Module system¶
How reusable RTL modules are stored, resolved and authored.
Concept¶
A module is a versioned, self-contained RTL block. Anvil ships some of
them itself, in modules/<name>@<version>/; a project can also pull in a
module from a local path or a URL, landing in external/<name>@<version>/ —
see Module sources below for how a reference is told apart
and fetched.
Wherever it comes from, a module is recorded in the project's
config.json modules object,
keyed by its own name. Modules in turn declare their own depends, so adding
one pulls in its whole dependency tree — bundled, external or local, resolved
recursively. This keeps shared RTL (UART, APB, PicoRV32, …) in one place
instead of being copy-pasted between projects.
Anatomy of a module¶
modules/uart@1.0.0/
├── module.json ← metadata + depends (required)
├── uart.sv ← RTL source (.sv or .v; any number)
└── tb/ ← optional testbenches
A SoC module additionally carries:
├── soc.json ← marks it as a SoC + firmware build config
├── link.ld ← linker script
└── startup.S ← startup code
See File formats for module.json and soc.json schemas.
The layout is the same wherever the module lives — modules/, external/, or
a local path — only the containing directory differs.
Registry & versioning¶
modules.json is the catalog of
modules bundled with Anvil. A bundled reference is either:
name— resolves to the registry'slatestversion, orname@version— an exact version.
load_modules_registry() filters out catalog entries whose directory is missing,
so the registry self-heals if it drifts (you get a clean Unknown module error
rather than a crash). Keep modules.json in sync with modules/ —
anvil installmodule does this for you.
A module reference isn't always a bundled name, though — it can also be a local path or a URL. See Module sources next.
Module sources¶
A module reference is one of three things, told apart by the shape of the string itself — Anvil never needs to be told which kind it's looking at:
- A local path — starts with
./,../,/, or~. Used in place; nothing is downloaded or copied.
An absolute or ~ path is recorded in source exactly as typed, but
path is always a .//../-relative alias resolvable from the project
root — see Modules for a worked example.
- A bundled name — anything else that isn't a URL; resolves against
modules.jsonas in Registry & versioning above.
- A URL — fetched as an archive.
Resolving a URL to an archive¶
Anvil decides what a URL points to by what the server actually returns, not
by which host it is — this is what keeps a self-hosted forge working without
Anvil knowing it exists. git is never invoked: a plain git server has no
archive endpoint, and a direct archive link works there just as well.
Anvil builds a list of candidate URLs from the ref, then requests them in order; the first one that really is an archive wins. What goes on the list:
- The URL as given, then with each of
.tar.gz,.zip,.tgz,.tar.xzappended in turn — for a plain URL, or a.../releases/tag/<ref>URL, which is a real URL with the ref already built into it. Skipped when the ref carries@ref:owner/repo@refisn't itself a URL, so there's nothing literal to try. - The forge's own archive URLs, when the source carries a ref (
@ref, or a/releases/tag/<ref>path) and the host matches a forge shape Anvil knows, in order:
| You write | Anvil tries, in order |
|---|---|
github.com/<owner>/<repo>@<ref> |
.../archive/refs/tags/<ref>.tar.gz, then .../archive/refs/heads/<ref>.tar.gz, then .../archive/<ref>.tar.gz |
github.com/<owner>/<repo>/releases/tag/<ref> |
the same three, using that <ref> |
gitlab.com/<owner>/<repo>@<ref> |
.../-/archive/<ref>/<repo>-<ref>.tar.gz |
The three GitHub candidates exist because <ref> might name a tag or a
branch — Anvil doesn't ask which; it tries a tag path, then a branch path,
then a bare ref, and uses whichever comes back as an archive first.
Checked live against real repositories while writing this page:
github.com/pallets/flask@3.0.0 carries @ref, so step 1 was skipped and
only these three were tried — it resolved on the first,
https://github.com/pallets/flask/archive/refs/tags/3.0.0.tar.gz.
gitlab.com/gitlab-org/gitlab-test@v1.1.1 resolved the same way, to
https://gitlab.com/gitlab-org/gitlab-test/-/archive/v1.1.1/gitlab-test-v1.1.1.tar.gz.
<url>@<ref>exactly as written, plus the same extension probes — only when steps 1 and 2 both put nothing on the list, which is precisely an@refon a host no forge row matches. A self-hosted forge may well bake the ref into a filename, and where it doesn't, a handful of 404s is a cheaper answer than an empty candidate list.
A candidate is taken when the response is one of the archive content types
(application/gzip, application/x-gzip, application/zip,
application/x-tar) — first archive wins. If none of them is, the fetch fails
listing every URL attempted; where the @ref reached no forge row, the last
line names the host, since that's the part the ladder couldn't help with (a
local test server stands in for the unknown forge below):
✗ could not fetch module 'http://127.0.0.1:42759/ana/rtl@v1.2.0'
no archive found for http://127.0.0.1:42759/ana/rtl@v1.2.0
tried:
http://127.0.0.1:42759/ana/rtl@v1.2.0 -> HTTP 404
http://127.0.0.1:42759/ana/rtl@v1.2.0.tar.gz -> HTTP 404
http://127.0.0.1:42759/ana/rtl@v1.2.0.zip -> HTTP 404
http://127.0.0.1:42759/ana/rtl@v1.2.0.tgz -> HTTP 404
http://127.0.0.1:42759/ana/rtl@v1.2.0.tar.xz -> HTTP 404
no forge rewrite matched this 127.0.0.1:42759 URL -- a direct archive URL always works
Adding a forge is a row in that table, not a new code path.
@ref and #subpath¶
A source can carry a version and, separately, pick one module out of a
repository that holds several. The fragment is always last, after any @ref:
@ref is stripped first to build the archive URL above. Once the archive is
unpacked, Anvil looks for module.json at its root, or — if the archive has
exactly one top-level directory, which is what GitHub and GitLab produce —
inside that directory; any other shape is rejected, naming how many
directories were actually found. #subpath is then resolved inside that
tree; module.json must exist there, or the add fails naming the subpath
that was checked.
Git and .gitignore at init¶
anvil init sets up version control for a new project, silently unless
something goes wrong:
- it runs
git initin the project directory, unless that directory is already inside a git work tree — so runninganvil initinside an existing repository never nests a second one; - it writes a
.gitignore, if one isn't already there, regardless of whethergit inititself succeeded — even a machine with nogitat all still gets one:
Anvil never edits or parses a .gitignore that's already present. If you'd
rather vendor fetched modules than have Anvil re-fetch them, delete the
external/ line yourself — nothing here enforces it.
Since the write is unconditional, a project only ends up with no
.gitignore at all if it predates this behavior, or its config.json was
never produced by anvil init in the first place. Either way, the first
time a module lands in external/, Anvil warns once and still proceeds; a
missing .gitignore says nothing about whether the project is otherwise
fine:
⚠ no .gitignore in this project
external/ and build/ are generated -- committing them is rarely wanted.
A reasonable starting point:
build/
external/
*.vcd
*.log
__pycache__/
external/¶
A fetched module lands inside the project itself, one directory per module:
This is what makes a project self-contained: with external/ present,
anvil build never touches the network. external/ is one of the lines in
the .gitignore anvil init writes, so it's
normal for it to be absent after a fresh clone — when that happens, anvil
build, synth and test re-fetch every module whose source is a URL,
using the recorded source, and check the fetched code against the recorded
hash before using it (see
config.json). A module whose
source is a local path can't be re-fetched; if it's missing, the build fails
saying so instead.
A fetched module is only ever written inside external/, and a recorded
path that resolves outside it is refused before the request goes out — a
config.json you didn't write shouldn't be able to make Anvil ask a server
for an archive, let alone unpack one somewhere of its own choosing.
anvil modules and anvil status neither fetch nor exit on any of this;
they're how you look at a project you can't build yet. Each entry is reported
as not fetched (a URL module before its first build), missing (a local
or bundled directory no build can restore), or unusable (a directory
that's there but has no readable module.json, or a URL entry whose recorded
path escapes external/):
[Anvil] Modules in 'blinky':
+ fifo (not fetched -- run: anvil build)
+ axi-lite (unusable -- no readable module.json in external/axi-lite@2.0.0; delete it, then: anvil build)
+ gpio gpio module
+ scratch (missing -- ../../../rtl/scratch does not exist and '/tmp/claude-1000/rtl/scratch' cannot be re-fetched)
[Anvil] Available modules:
...
anvil status prints the same three under their own labels, and leaves the
SoC line unanswered, naming the worst state standing in the way: which module
carries soc.json isn't knowable until every module is readable.
[Anvil] Project : blinky
Board : Nexys-A7-50T -- Digilent Nexys A7 50T (xc7a50t)
Modules : fifo, axi-lite, gpio, scratch
SoC : unknown -- modules unusable
Params : {}
Firmware : none
Bitstream : not built
Unfetched : fifo -- run: anvil build
Unusable : axi-lite -- no readable module.json in external/axi-lite@2.0.0; delete it, then: anvil build
Missing : scratch -- ../../../rtl/scratch does not exist and '/tmp/claude-1000/rtl/scratch' cannot be re-fetched
Dependency resolution¶
resolve_deps() walks the tree depth-first:
for each module ref in config.modules:
load module.json
recurse into its depends[] ← children resolved before the parent
append (key, dir, meta) if not already resolved ← natural dedup
Properties:
- Topological order — a dependency always appears before the module that needs it in the final source list.
- Dedup — a module shared by several parents is included once.
- Circular-dependency guard — a cycle aborts with
[ERROR] Circular dependency: <key>. - Path deps — a ref starting with
./or../is resolved relative to the depending module's directory, not the registry. Useful for local, in-development sub-modules. - URL deps — a
dependsentry can be a URL too.resolve_depsdoesn't walk it directly; a URL dependency is fetched up front (see Module sources) and recorded as its own top-level entry inconfig.json, whichresolve_depsthen trusts is already there.
The resolved set feeds collect_sources() →
build flow.
Working with modules in a project¶
anvil modules # list available + which are in this project
anvil addmodule uart # add uart + its depends, regenerate Makefile
anvil addmodule <url-or-path> # same, for an external or local module
anvil addmodule --yes <ref> # skip the confirmation prompt below
anvil addmodule --force <name> # re-record an already-added module's hash
anvil removemodule uart # remove (refuses if another module depends on it)
addmodule resolves the full dependency chain, appends new entries to
config.modules, and — if the added tree contains a SoC module — scaffolds a
firmware/ template and sets a default params.ram_addr_bits.
A module's name is its own claim, so a bundled key, a directory and a fetched archive can all declare the same one. A name already in the project from a different source is refused, naming both, rather than skipped — skipping quietly would leave you building against whichever source was added first, under a name you thought you'd just pointed somewhere else:
✗ module 'solo' is already in this project
present: http://127.0.0.1:42759/solo.tar.gz
incoming: http://127.0.0.1:42759/solo-fork.tar.gz
A local ref is judged by where it resolves, not how it was typed, so re-adding
the same directory spelled absolutely or with ~ is not a different source —
it answers [Anvil] All requested modules already present.
removemodule refuses to drop a module that another kept module still depends
on, to avoid leaving the project unbuildable — a depends entry that is a URL
counts the same, matched against the recorded entries rather than the registry,
since a URL is never a registry name. For a fetched module it also deletes that
module's own directory under external/:
A bundled module's directory (shared by every project, under the Anvil
installation) and a local-path module's directory (the user's own, outside
external/) are never touched — only a directory that actually resolves
inside external/ is ever deleted, and never one another surviving entry
still points at. A failed delete — a permissions issue, say — is only a
[WARN], since config.json is already saved without that entry; a leftover
directory is harmless and can be removed by hand.
Consent before installing an external module¶
Adding a module from a URL or a path can pull in a whole dependency tree of
other external modules — a depends entry can itself be a URL. Before any
of it touches disk, anvil addmodule fetches every archive in that closure
into a temporary directory (fetching, not installing — nothing is written to
external/ or config.json yet), then shows the whole set and asks:
These external modules will be added:
fifo 1.2.0
from http://127.0.0.1:48287/fifo.tar.gz
axi-lite 2.0.0 (required by fifo)
from http://127.0.0.1:48287/axi.tar.gz
picorv-dma 0.4.0 (required by axi-lite)
from http://127.0.0.1:48287/dma.tar.gz
⚠ ships soc.json -- chooses the compiler that runs
Names are declared by the modules themselves; the URL is what you are trusting.
Add these 3 external modules? [y/N]
This is a real three-level addmodule run against a local test server, which
is why the from lines point at 127.0.0.1; against a real forge they'd show
a resolved address such as https://github.com/ana/fifo/archive/refs/tags/v1.2.0.tar.gz,
per Resolving a URL to an archive above.
A module's name is whatever its own module.json says — chosen by whoever
published it, not by the URL you typed. That's why the URL gets its own line,
shown in full: the name is a label, the URL is the thing you're actually
trusting. Each entry also says who pulled it in (required by …), so an
unexpected module in the list can be traced instead of merely noticed. A
module carrying a soc.json is flagged, because that's the one that picks the
compiler anvil compile runs — see the note on trust
below.
Answering anything but y aborts: [Anvil] Aborted -- nothing installed.
Nothing is written to external/, and config.json is untouched.
--yes skips the question (the listing above still prints). It's required
when stdin isn't a terminal — the toolchain installer, for instance, runs
anvil with stdin from /dev/null — because silence must never be read as
agreement:
[ERROR] refusing to install external modules without confirmation
stdin is not a terminal -- pass --yes if this is intended
Consent is asked once, not on every build. config.json already records what
was agreed to, so anvil build re-fetching a missing external/ (see
external/ above) just verifies the recorded hash instead of
asking again. A hash that doesn't match is an error, not a fresh prompt — and
which error depends on which side moved.
The directory was missing and has just been re-fetched. The hash is
checked in a staging directory, so code you never agreed to doesn't reach
external/ at all:
[Anvil] Fetching fifo from http://127.0.0.1:42759/fifo.tar.gz ...
✗ module 'fifo' does not match its recorded hash
source: http://127.0.0.1:42759/fifo.tar.gz
recorded: sha256:ea54e0c9758d0b801bc0c998936240f3867b5dcd30d495d1c7da80378e4ed9d6
found: sha256:112b3a2cf516b3d1fb14349a084006abf65bd6a4d864d19535dace0901042eb9
the recorded source returned different code -- the tag may have moved
There is deliberately no --force here. On this path it would mean "record
whatever the server just sent", which is the one thing the hash exists to
prevent — the only witness you'd be believing is the one that changed. Find
out why the source moved instead: a re-tagged release, a rewritten branch, a
URL that no longer points where you thought. Nothing was written, so once the
source serves the recorded code again the next build simply fetches it; there
is no half-installed tree left to delete first.
The directory was already there and something edited it. Here the two sides you're comparing are both yours, so re-recording is a judgement you can actually make:
✗ module 'fifo' does not match its recorded hash
recorded: sha256:ea54e0c9758d0b801bc0c998936240f3867b5dcd30d495d1c7da80378e4ed9d6
found: sha256:a5b454e3ed0a707ad86a3dcca1703870e8997db76c8b80c368c7676bd2ed3866
re-record with: anvil addmodule --force fifo
If the change was intentional, anvil addmodule --force <name> re-records the
new hash — showing the old and new hash and re-flagging soc.json, gated by
the same [y/N] prompt. If it wasn't, don't re-record it: work out what wrote
into external/ without being asked.
What the hash does not prove¶
The hash proves a module's code has not changed since it was added — it is
a check for accidental drift, not a security boundary. It does not prove the
code is safe to run. soc.json supplies the compiler and objcopy that
anvil compile executes, so a fetched SoC module chooses which binary runs on
your machine, and Anvil does not sandbox that choice.
This is accepted deliberately, not an oversight: the trust model is the publisher, the same as any other package manager — installing a package already means running its build scripts. Anvil's job is to make sure the code you agreed to is the code that's actually there, not to judge whether the publisher is trustworthy. That judgment is the one the consent prompt above is asking you to make.
Authoring a new module¶
mkdir my_block && cd my_block
anvil init --module my_block # writes module.json + my_block.sv stub
# write RTL, add tb/ if you want, list depends[] in module.json
anvil installmodule # copies dir into modules/ + updates modules.json
installmodule copies the current directory into
modules/<name>@<version>/ (ignoring build/, *.vcd, __pycache__, .git)
and registers it. After that, any project can anvil addmodule <name>.
To make the module a SoC, add a soc.json (plus link.ld and startup.S)
before installing — see SoC fields.