Tiny Tapeout · IHP SG13CMOS5L · LibreLane

Dropping a real SRAM macro into your CMOS5L tile

A first-timer's walkthrough: where the IHP memory macro lives, how to wire it into your RTL, how to feed it power on a tile that only has Metal4 stripes, and which sign-off checks will yell at you and why.

~20 min read worked example: kdp1965/ihp-um-janestreet-prism 2026-09-29

Why you would want one

Every Tiny Tapeout design eventually wants a buffer. A frame of Ethernet, a few milliseconds of audio, a trace log. In flip-flops that memory is brutally expensive: a single flop in the CMOS5L standard cell library is about 52 µm² before you add the write and read muxing around it. The IHP SRAM macro stores a bit in under 5 µm², and it comes with its own decoder, sense amps and a clean synchronous interface.

2 KB512 words × 32 bits per macro
0.08 mm²416.64 × 191.34 µm
6.5 %of an 8x4 tile (1.22 mm²)
~11×denser than flops, per bit

The same 16 kilobits built from flops would eat about 70 % of an 8x4 tile in flip-flop area alone. That is the whole argument. My Jane Street competition entry, a programmable protocol engine called PRISM riding on the TinyQV RISC-V core, uses two of these macros as packet FIFOs, one per direction, and the repository is public if you want to see every file mentioned below in context: github.com/kdp1965/ihp-um-janestreet-prism.

What "macro" means here

A hard macro is a finished piece of layout. You do not synthesize it, you do not place cells inside it. The flow treats it as one big opaque rectangle with pins on the edge, and your job is to (1) tell the tools where the rectangle goes, (2) connect its pins, (3) get power into it, and (4) convince every checker that the black box is fine. That's the entire post.

01Find the macro in the PDK

First, get the PDK itself. Everything for this process lives in one public git repository, IHP-Open-PDK, and the CMOS5L variant is the ihp-sg13cmos5l folder inside it, next to the seven-metal ihp-sg13g2 that the earlier IHP shuttles used. Tiny Tapeout's own checks install it from the repository's dev branch at a pinned commit, so use the same one and your local results will match the precheck. The repository is large, so fetch just that commit:

mkdir -p ~/pdk/IHP-Open-PDK && cd ~/pdk/IHP-Open-PDK
git init -q
git fetch --depth 1 https://github.com/IHP-GmbH/IHP-Open-PDK.git 2bbec755dc67ca3db0261c3d6163e15735d66710
git checkout -q FETCH_HEAD

# the two variables every tool in the flow reads
export PDK_ROOT=~/pdk/IHP-Open-PDK
export PDK=ihp-sg13cmos5l

PDK_ROOT is the directory that contains the PDK folders, not the PDK folder itself. LibreLane joins the two variables to find the process, and every pdk_dir:: path you will see in the config below resolves to $PDK_ROOT/$PDK/. Put the two exports in your shell profile; forgetting them is the most common reason a first run fails before it starts, usually with a complaint that some LEF or lib file does not exist. Check the commit hash against the precheck action you are using before you trust it, since Tiny Tapeout moves the pin when IHP updates the memories.

With that in place, the IHP open PDK ships a whole family of compiled memories. In the CMOS5L variant of the PDK they sit next to the standard cells:

$PDK_ROOT/ihp-sg13cmos5l/libs.ref/sg13cmos5l_sram/

cdl/      # transistor-level netlist, used by LVS
doc/      # one datasheet .txt per macro. READ THIS FIRST.
gds/      # the actual layout that goes on the chip
lef/      # the abstract: outline, pins, obstructions (what the P&R tools see)
lib/      # timing + power, one file per corner
verilog/  # behavioural model for simulation

The names encode the shape. RM_IHPSG13_1P_512x32_c2_bm_bist is a one-port (1P) memory of 512 words by 32 bits, column mux 2, with a byte/bit mask (bm) and a built-in self-test port (bist). There are single-port sizes from 64×16 up to 8192×32, and dual-port (2P) ones up to 1024×32. Pick the smallest one that holds what you need; the 512×32 is a good first macro because it is short enough to leave room above and below it in a 4-tile-tall design.

Open the datasheet in doc/ before anything else. It is a plain text file and it tells you four things you cannot guess:

02Instantiate it in your RTL

You write the instance yourself, with the port names from the datasheet. Tie off everything you don't use. This is the real instance from my design (the address width follows a parameter, so there are three such blocks under separate ifs; here is the 512-word one):

src/user_peripherals/prism/prism_periph.v

// IHP single-port SRAM: A_DLY must be tied high, BIST off.
RM_IHPSG13_1P_512x32_c2_bm_bist i_sram
(
    .A_CLK       ( clk                  ),
    .A_MEN       ( sram_ren | sram_wen  ),   // enable only when doing something
    .A_WEN       ( sram_wen             ),
    .A_REN       ( sram_ren             ),
    .A_ADDR      ( sram_addr            ),
    .A_DIN       ( sram_din             ),
    .A_DLY       ( 1'b1                 ),   // datasheet: always 1
    .A_DOUT      ( sram_dout            ),
    .A_BM        ( sram_bm              ),   // per-bit write mask, 1 = write this bit
    .A_BIST_CLK  ( 1'b0                 ),
    .A_BIST_EN   ( 1'b0                 ),
    .A_BIST_MEN  ( 1'b0                 ),
    .A_BIST_WEN  ( 1'b0                 ),
    .A_BIST_REN  ( 1'b0                 ),
    .A_BIST_ADDR ( 9'h0                 ),
    .A_BIST_DIN  ( 32'h0                ),
    .A_BIST_BM   ( 32'h0                 )
);

Two more files make this compile in the two worlds that need it, synthesis and simulation:

A black box for synthesis

Yosys must not try to synthesize the memory, it only needs to know the module exists and what its ports are. A tiny file with the module header, an empty body, and the (* blackbox *) attribute does it. I wrap mine in `ifndef SIM so the simulator never sees it and picks up the real model instead:

src/user_peripherals/prism/sram_2048x32_bb.v (excerpt)

`ifndef SIM
(* blackbox *)
module RM_IHPSG13_1P_512x32_c2_bm_bist
(
    input  wire        A_CLK,
    input  wire        A_MEN,
    input  wire        A_WEN,
    input  wire        A_REN,
    input  wire  [8:0] A_ADDR,
    input  wire [31:0] A_DIN,
    input  wire        A_DLY,
    output wire [31:0] A_DOUT,
    input  wire [31:0] A_BM,
    /* ... the A_BIST_* ports ... */
);
endmodule
`endif

The behavioural model for simulation

The PDK's verilog/ folder has a behavioural model per macro plus a shared core file, and they only behave when you define FUNCTIONAL. I copied those files into the repo under test/ihp_sram/ (Apache-2.0, so that is allowed) because the GitHub test job has no PDK checkout. The cocotb Makefile then adds:

test/test_basic.mk (excerpt)

SRAM_MODEL_DIR ?= $(PWD)/ihp_sram
SRAM_MODELS = $(SRAM_MODEL_DIR)/RM_IHPSG13_1P_core_behavioral_bm_bist.v \
              $(SRAM_MODEL_DIR)/RM_IHPSG13_1P_512x32_c2_bm_bist.v

VERILOG_SOURCES += $(SRAM_MODELS)
COMPILE_ARGS    += -DSIM -DFUNCTIONAL
Gotcha

The gate-level netlist LibreLane produces also keeps the SRAM as a black box (there is no gate netlist of a hard macro). So your gate-level simulation needs the behavioural model too, not just the RTL one. Add the models to both build variants or your GL test will fail with an unresolved module.

03Tell LibreLane about it

Everything physical goes in config.json under a MACROS key. One entry per macro type, and inside it, one entry per instance with its location and orientation. The paths use pdk_dir:: so they resolve wherever the PDK is installed:

src/config.json (excerpt)

"MACROS": {
  "RM_IHPSG13_1P_512x32_c2_bm_bist": {
    "gds":   ["pdk_dir::libs.ref/sg13cmos5l_sram/gds/RM_IHPSG13_1P_512x32_c2_bm_bist.gds"],
    "lef":   ["pdk_dir::libs.ref/sg13cmos5l_sram/lef/RM_IHPSG13_1P_512x32_c2_bm_bist.lef"],
    "spice": ["pdk_dir::libs.ref/sg13cmos5l_sram/cdl/RM_IHPSG13_1P_512x32_c2_bm_bist.cdl"],
    "nl": [],  "spef": {},
    "lib": {
      "nom_typ_1p20V_25C":   ["pdk_dir::libs.ref/sg13cmos5l_sram/lib/RM_IHPSG13_1P_512x32_c2_bm_bist_typ_1p20V_25C.lib"],
      "nom_fast_1p32V_m40C": ["pdk_dir::libs.ref/sg13cmos5l_sram/lib/RM_IHPSG13_1P_512x32_c2_bm_bist_fast_1p32V_m55C.lib"],
      "nom_slow_1p08V_125C": ["pdk_dir::libs.ref/sg13cmos5l_sram/lib/RM_IHPSG13_1P_512x32_c2_bm_bist_slow_1p08V_125C.lib"]
    },
    "instances": {
      "i_peripherals.i_prism.SRAM[0].M512.i_sram": { "location": [3.36, 3.78],  "orientation": "FS" },
      "i_peripherals.i_prism.SRAM[1].M512.i_sram": { "location": [3.36, 340.2], "orientation": "N" }
    }
  }
}

Three things to notice:

The 8x4 tile with two SRAM macros flush left and the Metal4 stripe grid SRAM[0] · 512x32 · FS SRAM[1] · 512x32 · N 99.7 µm gap: both FIFO wrappers live here standard cells, CFGMEM macros, TinyQV … die 1724.16 × 710.64 µm, origin bottom left · Metal4 stripes every 44.96 µm: VPWR solid, VGND dashed pin face = the bright edge · SRAM[0] is flipped (FS) so the two pin faces look at each other
The real floorplan of the 8x4 tile in my design, drawn from the config. Two 512×32 macros flush against the left edge, pin faces toward each other, and the tile's Metal4 stripe grid running top to bottom every 44.96 µm.

Two settings go with the placement. FP_MACRO_HORIZONTAL_HALO (2 µm in my design) keeps standard cells away from the macro edge, which matters because the SRAM's well edge and the neighbouring cells' wells need a minimum distance. And an explicit list of ROUTING_OBSTRUCTIONS over the macro's footprint on Metal2 to Metal4. The LEF already declares the inside as obstructed, but a Tiny Tapeout CMOS5L tile routes on Metal2 to Metal4 only (TopMetal belongs to the chip-level harness), so there is no free layer above the macro and I want the router to have no ideas about crossing it:

"ROUTING_OBSTRUCTIONS": [
  "Metal2 3.36 3.78 420.0 192.5",   // SRAM[0]
  "Metal3 3.36 3.78 420.0 192.5",
  "Metal4 3.36 3.78 420.0 192.5",
  // ... and the same three for SRAM[1]
]

04Power: the part that actually bites

This is where a beginner's first attempt usually stalls, so slow down here.

A standard cell gets power from the horizontal rails it sits on. A hard macro has its own power pins and nothing feeds them unless your power grid lands on them. The SRAM has three supplies, all as vertical Metal4 columns running the full height of the macro:

PinWhat it feedsWhere it is
VDD!the periphery logic (decoders, sense amps)36 columns, 2.81 µm wide, at x = 4.26 + 11.24·k, the lower 39 µm of the macro
VDDARRAY!the bit-cell arraythe same 36 columns, the upper 146 µm
VSS!ground for both36 columns at x = 9.88 + 11.24·k, full height

The exclamation marks are part of the pin names, IHP's way of marking a global net. Note the geometry: VDD and VSS alternate every 5.62 µm and the pattern repeats every 11.24 µm. Keep those two numbers in your head.

These numbers belong to one macro

Everything quantitative in this post, the 36 supply columns and where they start, the 4.26 and 9.88 µm offsets, the 191.34 µm height, the 9-bit address and 32-bit data buses, the pin count, is specific to RM_IHPSG13_1P_512x32_c2_bm_bist, the macro my PRISM design uses. A different size or a dual-port macro has its own width, its own column count and offsets, and its own bus widths. Read them off its LEF (the PIN VDD! rectangles give the columns) and its datasheet before you copy any value from here.

The Tiny Tapeout CMOS5L grid is Metal4 only

On the CMOS5L tile template the power distribution is a single layer: vertical VPWR/VGND stripes on Metal4 that the top-level harness contacts from above. There is no horizontal Metal5 mesh inside your tile. That is a gift here, because the macro's supply columns are also vertical Metal4. If a tile stripe runs exactly over one of the macro's columns, the PDN generator makes the connection for free; if it lands between columns, or on the obstructed part of the macro, it does nothing and the macro floats.

So the trick is to make the tile grid's numbers match the macro's numbers. My config.json does exactly that:

"FP_PDN_MULTILAYER": 0,        // vertical stripes only, no upper mesh
"FP_PDN_VWIDTH":     2.1,      // narrower than the 2.81 µm column: sits inside it
"FP_PDN_VSPACING":   3.52,     // 2.1 + 3.52 = 5.62: VPWR-to-VGND matches the macro
"FP_PDN_VPITCH":     44.96,    // 4 x 11.24: every fourth column pair gets a tile pair
"FP_PDN_VOFFSET":    6.15      // phases the grid so a pair lands on the first columns

With that pitch and offset the tile's VPWR stripes are centred at x = 9.03 + 44.96·k and its VGND stripes at 14.65 + 44.96·k. Place the macro at x = 3.36 and its first VDD column centre is at 3.36 + 4.26 + 1.405 = 9.03. That is not a coincidence, that is the whole design of the grid. Every macro in the tile, including my latch-based configuration memories, was built or placed on that same 44.96 µm rhythm so that the one grid feeds all of them.

One honest footnote: the macro's columns are not perfectly regular across its whole width. The left bit-cell array lines up with the grid exactly. The narrow band of support logic in the middle has columns at odd spacings, and the right array is phased 0.67 µm off. So the four tile pairs that cross the left array land dead centre, while the ones over the band and the right array need a nudge. My flow's stripe step does that nudge automatically (its log says things like "VPWR stripe at x=188.87 moved +9.94 um onto a column"). Without such a step you would check each crossing in the GUI and move the offending stripes by hand, which is exactly why the next section exists. The Python that does this job in my repository is odb_stripes.py, run by the Project.ExtendPowerStripes step in librelane_plugin_prism_pdn.py. It is written around this macro and this floorplan, so if you borrow it, expect to modify it to meet your needs.

One stripe period over the SRAM: the tile pair lands on the first column pair, the project adds pairs on every other one VDD! and VSS! columns of the macro, 11.24 µm pitch tile pair added pair tile pair outlined columns get no stripe from the base grid
One grid period seen from inside the macro. The tile's own VPWR/VGND pair (solid) lands on the first column pair; the macro's other three pairs in that period get nothing from the base grid. The every-other mode adds the faded pair; the all-columns mode fills every outlined column.

Telling the PDN which pin is which

The generator also needs to know that the macro's VDD! is your VPWR. That is the PDN_MACRO_CONNECTIONS list: one line per instance per supply pair, in the form instance-pattern VPWR VGND macro-power-pin macro-ground-pin. Because the SRAM has two power pins, each instance gets two lines. The pattern is a regular expression, and the square brackets in the instance path need to survive both JSON and the regex engine, hence the escaping:

"PDN_MACRO_CONNECTIONS": [
  "i_peripherals\\.i_prism\\.SRAM\\\\?\\[0\\\\?\\]\\.M512\\.i_sram VPWR VGND VDD! VSS!",
  "i_peripherals\\.i_prism\\.SRAM\\\\?\\[0\\\\?\\]\\.M512\\.i_sram VPWR VGND VDDARRAY! VSS!",
  "i_peripherals\\.i_prism\\.SRAM\\\\?\\[1\\\\?\\]\\.M512\\.i_sram VPWR VGND VDD! VSS!",
  "i_peripherals\\.i_prism\\.SRAM\\\\?\\[1\\\\?\\]\\.M512\\.i_sram VPWR VGND VDDARRAY! VSS!"
]
How to check it worked

Open the ODB after the PDN step in the OpenROAD GUI, turn on Metal4, and look at the macro. Every tile stripe that crosses it should sit dead centre on a coloured column, not in the gap between them. Then run the flow's IR-drop report: a macro that is fed shows a sane drop (mine reports well under a millivolt); an unfed macro shows up as a floating net and the PSM step complains. Do not skip this look. A macro with no power passes DRC and LVS with flying colours and is a brick on silicon.

How many stripes? Three answers

The base grid gets the macro alive, and for a first design that may be all you want. But an SRAM is the hungriest thing on the tile when it is reading and writing every cycle, and every stripe that lands on a column is another feed into the macro's internal power mesh. My flow's stripe step (Project.ExtendPowerStripes in librelane_plugin_prism_pdn.py) has three modes, and I have hardened the design in all three, so here is what they cost and buy:

ModeOption under EXTEND_STRIPES_SRAM_Stripe pairs per SRAMWhat it does
Grid onlyboth options false10the tile grid's pairs, nudged onto the nearest column
Every other array pairARRAY_EVERY_OTHER17adds a pair on every other column pair of each bit-cell array, leaves the middle band as the grid gives it (what the submission uses)
All columnsALL_COLUMNS36a stripe on every legal supply column, best power delivery

If you want the best power delivery, use all columns. Every one of the macro's 36 VDD and 36 VSS columns gets its own full-height Metal4 stripe. I measured the routing price of that on an earlier revision of this tile, against the same design on the base grid: total wirelength up 0.8 %, global-routing overflow up 1.9 %, the detailed router needed 28 passes instead of 21 to reach zero violations, and the worst-case IR drop fell from 0.38 mV to 0.27 mV. Everything signed off clean. Metal4 wire did not change at all, because the corridor above and below an SRAM carries little signal Metal4 anyway. In other words, routing is not a reason to leave those stripes out. It is also the direction Tiny Tapeout's own tooling is taking: in a September 2026 discussion on the TT Discord the maintainers described drawing Metal4 stripes over the macro's power tracks at full height, and confirmed that a tile may have any number of VPWR/VGND stripes since the top level drops its vias onto whatever is there.

Why my submission runs every-other instead. The stripes on the SRAM's columns run the full height of the tile, not just the height of the macro. In my floorplan a latch-macro sits directly above the SRAM stack, and with all columns fed, 41 of its 56 Metal4 data pins ended up on or within a quarter micron of a stripe: a dozen hard shorts that no amount of routing could fix. Every-other on the arrays, plus a rule that no SRAM column stripe comes within 0.5 µm of any macro's Metal4 signal pin, cleared it, and the design still reports a worst-case IR drop of 0.46 mV. So the decision rule for a beginner is simple: if nothing with Metal4 pins sits in the SRAM's x-range above or below it, turn on all columns and enjoy the margin. If something does, either move it out of that column of the tile or step down to every-other.

If you do not have a stripe step

You can get most of the all-columns benefit with plain pdngen: set the vertical pitch to 11.24 µm, the macro's own column pitch, with the same 2.1 µm width and 3.52 µm spacing, and keep the offset at 6.15. Now a VPWR/VGND pair lands on every column pair of the left array for free. You lose the 0.67 µm nudge on the right array and the irregular band, and you pay for the stripes across the whole tile, not just over the macro; look at the standard-cell region afterwards, since four times the Metal4 stripes there is a real routing cost that my measurement above did not include.

What it looks like when it is right

Here is the resulting power grid on the finished tile, a KLayout screenshot with just two layers turned on: GatPoly in red, which paints every transistor and so shows where the standard cells and the macros are, and the Metal4 pins in green, which are the stripes. Read it left to right. The two SRAM macros are the dense blocks at the left with the closer-spaced pairs over their bit-cell arrays, the every-other mode at work. The regular pairs every 44.96 µm run the full height of the tile through everything else, and where they cross the latch-based configuration macros in the middle and at the right, they land on those macros' own rails. Every macro in the picture is fed by the same grid, which was the point of choosing the grid's numbers from the SRAM's columns.

KLayout screenshot of the whole tile with GatPoly and the Metal4 pins visible: vertical power stripes every 44.96 µm across the standard cells and macros, denser over the two SRAM arrays at the left
The finished tile in KLayout, GatPoly (red) and Metal4.pin (green) only: the tile's stripe pairs on the 44.96 µm grid, the extra every-other pairs over the two SRAM arrays at the left, and the same stripes crossing the CFGMEM latch macros on their rails.

05Sign-off: what will complain, and what to do about it

With the macro placed and powered, the design routes like any other. The surprises are all in the checks at the end. Here is each one and the fix, in the order the flow runs them.

Antenna diodes on the SRAM inputs

The SRAM has 191 pins and almost all are inputs. Long routes into a gate can pick up charge during fabrication, and the macro's LEF carries antenna numbers for its Metal2 pins, so the antenna checker cares about them. LibreLane's rule-based repair covers most of it. In my flow the checker could not always see the nets driving the macro, so I added a step that drops one antenna diode (sg13cmos5l_antennanp) next to every SRAM input pin before detailed placement. Cheap insurance. If your antenna report is clean without it, you do not need it.

Magic DRC and the "illegal overlap"

Magic does not understand the SRAM's bit cells; it reads the macro from its LEF abstract, not the GDS. That LEF abstract has a Metal4 obstruction bar at the height where the VDD! columns end and the VDDARRAY! columns begin. A VPWR stripe running the full height crosses that bar, and Magic reports it as an illegal overlap of a wire with a blockage. In the real GDS there is no metal in that bar, so the stripe simply bridges two supply pins that you tied to the same net anyway. I waived it:

"RUN_MAGIC_DRC": 0,
"ERROR_ON_ILLEGAL_OVERLAPS": 0,
"RUN_KLAYOUT_DRC": 1,
"KLAYOUT_DRC_RUNSET": "pdk_dir::libs.tech/klayout/tech/drc/ihp-sg13cmos5l.drc"

The important half of that is the last two lines. You are not skipping DRC, you are moving it to KLayout, which runs the same deck the Tiny Tapeout precheck runs and does see the real geometry. My final run reports 22 of the Magic overlaps, all on VDD columns, and zero KLayout violations.

LVS

LVS compares the extracted layout against the netlist, and for the macro it needs a transistor-level description. That is why the spice entry in the MACROS block points at the CDL file. If LVS reports the macro's power pins as unconnected, go back to section 04: it means a stripe never actually landed on a column.

The Tiny Tapeout precheck and two mystery layers

The SRAM GDS contains two layers that no standard cell uses: SRAM.drawing (GDS 25/0) and DigiBnd.drawing (16/0). They are marker layers IHP uses to identify the macro. An older precheck rejected any tile carrying them. Since September 2026 the Tiny Tapeout maintainers whitelist both for designs that use the IHP SRAMs. If you run a local copy of the precheck and it flags layers 25/0 or 16/0, update it, or add the two layers to its valid-layer list as I did in tt/precheck/tech_data.py.

06Test it like you mean it

Because the model is behavioural, your cocotb tests exercise the real interface timing: the one-cycle read latency, the enable semantics, the bit mask. Write at least these three before you call the integration done:

  1. Write a word, read it back one cycle later, check A_DOUT. Then read the same address twice in a row and confirm the data holds.
  2. Write with a partial A_BM and confirm untouched bits keep their old value.
  3. Run the same test against the gate-level netlist from your local build. If the SRAM is unresolved there, your GL Makefile is missing the models (section 02).

My repo's SRAM FIFO test does this through the peripheral's registers, and the same test runs on RTL, on the post-synthesis netlist and on the final gate-level netlist in CI.

The whole thing on one page

The files to open in the example repository, in the order this post used them:

TopicFile in ihp-um-janestreet-prism
RTL instance and the FIFO wrapper around itsrc/user_peripherals/prism/prism_periph.v, src/user_peripherals/prism/prism_sram_fifo.v
Synthesis black boxsrc/user_peripherals/prism/sram_2048x32_bb.v
Vendored behavioural models and the sim Makefilestest/ihp_sram/, test/test_basic.mk
MACROS, PDN numbers, connections, waivers, and the // comment keys that explain each onesrc/config.json, src/pdn_cfg.tcl
Stripes on the SRAM columns (grid / every-other / all), diodes on the SRAM pinslibrelane_plugin_prism_pdn.py, odb_stripes.py, odb_sram_diodes.py
Precheck layer whitelisttt/precheck/tech_data.py

Two macros, two kilobytes each, and a tile that now buffers whole Ethernet frames on both sides of a protocol engine. The first time it hardened clean I spent a long minute just looking at the Metal4 stripes lining up on the columns. You will too.

Written from the state of the design on 2026-09-29. Numbers (macro size, pin positions, grid pitch, tile size) are taken from the IHP SG13CMOS5L PDK views and the design's config.json; check them against your PDK snapshot, IHP regenerates the memories occasionally.