In the roadmap post
I asked whether sub-circuits should be flattened before simulation or evaluated
recursively, and I leaned towards flattening. I have now built it, so here is how it
works and what I would warn you about.
Boolflow is a free browser logic simulator. A block is a
circuit you drew, given a name, that you can place again as one element with its own
pins. The expansion code is flatten.js (108 lines, comments included) and the
library and editing logic is blockLibrary.js (172). Both are plain JavaScript with
no React, so everything below was run under bare node.
The idea: the simulator never learns that hierarchy exists
A block is an ordinary node with one extra field: its own definition.
{ id: 'u1', data: { type: 'SUB', name: 'HA', def: { nodes, edges }, inputs: ['A','B'], outputs: ['S','C'] } }
Before a circuit is simulated, every block is replaced by a copy of its definition,
each id rewritten to block/inner. The simulator receives one flat circuit. It is
just longer.
The ids are the point. Simulation state, such as a flip-flop's stored bit, is keyed
by node id. If I had pasted a definition's nodes in unchanged, two copies of a block
containing a flip-flop would have shared that one flip-flop. With path-style ids they
cannot. Two half adders placed side by side become:
p, q, u1/a, u1/b, u1/x, u1/and, u1/s, u1/c, u2/a, u2/b, u2/x, u2/and, u2/s, u2/c
Blocks nest, and the path spells the nesting out (t/p/inv, t/q/inv), which is
what a test asserts. A bonus I did not plan: the same paths give meaningful signal
names on the waveform panel for free. Two blocks named DIV2 containing a flip-flop
labelled D show up as DIV2/D.Q and DIV2#2/D.Q. The #2 is added when a twin
appears at the same level.
I have not measured the memory cost of flattening, and I will not pretend to. A
teaching circuit is hundreds of nodes. If you build a CPU out of blocks, measure it.
Pins are elements, and order comes from position
What are a block's pins? The INPUT and OUTPUT elements inside its definition.
Inside the expansion they stay as plain buffers, which is exactly where the outside
wires attach.
The question I had not thought about: which pin is pin 0? A circuit file stores
nodes in an arbitrary order, and there is no pin-ordering UI. So pins are sorted by
where they sit on the canvas: top to bottom, then left to right. The order on the
block mirrors the order on the sheet it was drawn on. A test lists B before A in
the file, draws A above B, and expects [A, B].
Names come from the element's label, and an unnamed pin is called in_0, out_0
and so on. (That is one reason naming a switch or a lamp matters, and why I added a
way to do it in the editor. It had not been possible.)
Wires that cross a block's edge are rewritten while flattening. A wire into pin i
lands on the inner element that takes input i; a wire out of pin j leaves from
the inner element that drives output j. A wire to a pin that the definition no
longer has is skipped, not an error.
After simulating, one more step: the flat circuit knows nothing about the block as a
node, so the block's own output values are copied back from the elements that drive
them, which is how the pins on the canvas show a value.
for (const [id, drivers] of flat.blocks) {
drivers.forEach((flatId, j) => { vals[`${id}:out-${j}`] = vals[`${flatId}:out-0`] ?? null; });
}
The same expansion runs in front of the Verilog, VHDL and C++ exporters, the test
runner and the waveform. None of them needed to know what a block is.
What a block may not contain
makeBlock turns a layer into a block, or says why it cannot:
-
A layer with no logic, or no
OUTPUT: refused. -
A
CLOCKinside: refused. Only the editor's own timer flips a clock, and it knows nothing about what is inside a block. Feed the clock in through an input pin. - Notes, frames, VIAs and net labels are left out, and the dialog says how many ("3 notes, frames, VIAs and net labels are left out"), because they are decoration and cross-layer plumbing, not logic.
Because the definition is embedded in the block, and the block is embedded in the
circuit file, a saved circuit is self-contained: a block placed ten times is still
one definition, and it travels with the file. The library of your own blocks is kept
in localStorage under boolflow-blocks. A recursion cannot happen by construction
(a definition is embedded, not referenced), but a hand-made file could nest
absurdly deep, so expansion stops at depth 16.
Editing a block after placing it: the hard half
Placing is easy. The part that took the design time is: you edit the definition,
and every copy has to follow without breaking the wires you already drew.
All copies share a blockId. When the definition changes, every copy gets the new
definition and new pin lists, and each existing wire is remapped. The key rule is
that a wire follows its pin by name, not by index. If wires followed the index,
inserting an input at the top would silently shift every wire to its neighbour.
This is the real function run on a half adder (A, B → S, C), wired from S to one
lamp and from C to another:
| Edit to the definition | Outputs afterwards | What happens to the wires |
|---|---|---|
Rename S to SUM
|
SUM, C |
stay put |
Drag C above S
|
C, S |
both follow their pin: S is now out-1, C is out-0
|
Add output CO at the top |
CO, S, C |
S moves to out-1, C to out-2
|
Delete C
|
S |
the wire to C is dropped, and counted |
| Rename both | SUM, CARRY |
both stay put |
The rename row is the awkward one. A name that has vanished, with as many names
appearing, is read as a rename: the wire stays on the same pin. Otherwise the pin was
removed and its wire goes with it.
I should be straight about that last part. The function counts the wires it moved
and the wires it dropped, and the tests check both numbers. The editor currently
throws the counts away, so a wire to a deleted pin disappears without a word. That
is a gap in the UI, not a design choice, and it is next on my list.
The bug I found by writing the table
I had one more row in my head: rename and reorder in a single edit. Rename S to
SUM and drag C above it, then apply.
My first implementation answered a vanished name by keeping the same index. The
outputs became C, SUM; the wire that used to leave S (index 0) stayed on index 0,
which is now C. Both lamps ended up reading the C pin:
u1:out-0 -> os (was S, now reads C)
u1:out-0 -> oc (C, correct)
No error, no warning, the circuit just lies. All the existing tests passed, because
none of them combined the two edits.
The fix is the obvious one once you see it: pair the names that vanished with the
names that appeared in order, wherever they now sit, instead of by index.
const lost = oldNames.map((nm, i) => (newNames.includes(nm) ? -1 : i)).filter(i => i >= 0);
const fresh = newNames.map((nm, j) => (oldNames.includes(nm) ? -1 : j)).filter(j => j >= 0);
return lost.length === fresh.length ? fresh[lost.indexOf(oldIndex)] : -1;
Now S becomes SUM at index 1 and C stays at index 0. Three new checks cover it,
and the case where you rename both outputs at once.
One ambiguity I cannot remove: if you delete one pin and add another in the same
edit, that is indistinguishable from a rename, and I treat it as one. The wire moves
to the new pin. If that is not what you meant, the wire is wrong in a way you will
see on the canvas rather than in a log.
What I would warn you about
- Editing a block nested inside another block is only possible through the outer one. You open the outer definition and edit from there.
- Bus contention inside a block is not detected. Two drivers fighting over one wire are checked for on the sheet, not inside a definition.
- Dropped wires are silent when you edit a block (see above).
- The exporters are still combinational only, blocks or no blocks. Flip-flops are skipped and flagged.
- No clocks inside. Described above.
- Sharing is not done. The block library is per browser, and the definition travels inside a circuit file, but there is no "share this block" link.
Questions
- If you shipped hierarchy, did you flatten, or evaluate recursively? What did it cost you?
- How does your editor decide which pin is which when the user has no ordering UI? Position-based was my answer. I am not sure it is a good one.
- For the rename-or-delete-and-add ambiguity: would you ask the user, or pick one and tell them? I pick and tell them.
The half adder and the divide-by-4 counter built from two copies of one DIV2 block
are in the examples at boolflow.site.
Top comments (0)