A cross-compiler, an ELF file, and a linker script that places every section at the right address are all just plumbing until one question gets answered: does the CPU inside the DUT actually fetch an instruction and run it? A bring-up test is the smallest possible C test written to answer exactly that, and nothing more.
9 min read | Intermediate Level
What You'll Learn
- What a bring-up test is actually trying to prove, and why it's deliberately not a real test
- The three things every bring-up test has to demonstrate at minimum
- Why a bring-up test should avoid depending on things it hasn't proven yet
- How a load sequence gets the compiled ELF's bytes into simulation memory and releases the CPU to execute them
- What bring-up passing lets every later test safely stop re-checking
What "Bring-Up" Actually Means
A new SoC testbench, or an existing one pointed at a new DUT configuration, starts from zero confidence. The CPU model might not fetch from the address you expect. The address decode might not route a write where you think it does. The linker script's MEMORY region might not match the RTL at all. Any one of these breaks everything built on top of it, and a failure deep inside a real test gives almost no clue which layer is actually broken.
A bring-up test exists to answer the most basic question before any of that complexity gets involved: does anything work at all? It isn't trying to verify a feature. It's trying to establish a known-good baseline that every other test is implicitly allowed to assume.
The Minimum a Bring-Up Test Has to Prove
Stripped down, a bring-up test only needs to demonstrate three things, each one a precondition for everything after it:
| What it proves | What it would mean if this failed |
|---|---|
| The CPU fetches its first instruction from the entry point address | Reset vector, entry point, or address decode is wrong |
| A write from the CPU reaches a specific, known memory location | Basic interconnect routing or address decode is broken |
| Execution reaches a defined end state instead of hanging or trapping | An illegal instruction, a misaligned access, or a toolchain/RTL mismatch |
Nothing about functional correctness, coverage, or edge cases belongs in this list. Those all assume the three things above already work - which is exactly why they don't belong in the test that's supposed to prove it first.
What a Bring-Up Test Looks Like
In practice, this is close to the smallest C program that can run on the DUT:
volatile unsigned int * const BRINGUP_STATUS = (unsigned int *) 0x80002000;
void _start(void) {
*BRINGUP_STATUS = 0xCAFEF00D;
while (1) {
/* parked here - the testbench polls BRINGUP_STATUS for the pattern */
}
}
The pattern written - 0xCAFEF00D here, any fixed, recognizable value works - is chosen specifically so it can't be mistaken for a reset value or stray bus traffic. The SV side just needs to observe that exact value land at that exact address to call the test a pass.
Notice this code deliberately skips main() and any global variable initialization. That's not a shortcut - it's the point. A global that needs .data copied from its stored bytes, or .bss zeroed, depends on the startup code and memory system already working correctly. A bring-up test that fails because .bss zeroing was broken, while trying to prove the CPU can fetch and write at all, has muddied its own result with a second, unrelated failure mode.
A bring-up test should never depend on a peripheral driver, either - not even a UART for a "hello world" printout. A UART needs its own register interface to already be understood and working, which is exactly the kind of thing bring-up is meant to happen before. A direct write to a plain memory location has no such dependency.
Getting the Binary Into Simulation Memory
test.elf doesn't put itself into the DUT's memory - something in the testbench has to write its bytes there before the CPU can fetch anything, and for a bring-up test that's almost always a backdoor load: the environment writes directly into the memory model through a hierarchical or DPI path, bypassing the DUT's bus entirely.
A common way there is converting the ELF into a format simulation tools already know how to load. objcopy, part of the same toolchain used to build the test in the first place, does exactly that:
riscv32-unknown-elf-objcopy -O verilog test.elf test.hex
The resulting test.hex is address-tagged data $readmemh can consume directly. A load sequence typically wraps this in a task that runs before reset is released:
class bringup_load_seq extends uvm_sequence;
`uvm_object_utils(bringup_load_seq)
string hex_path = "test.hex";
virtual task body();
// 1. Backdoor-load .text/.rodata/.data bytes into the memory model
$readmemh(hex_path, top_tb.dut.mem_model.mem_array);
// 2. .bss carries no bytes in the file - zero it explicitly,
// the same job crt0 does on real hardware
for (int i = 'h2000; i < 'h2800; i++)
top_tb.dut.mem_model.mem_array[i] = '0;
// 3. Release the CPU from reset - it fetches from the ELF's
// entry point the instant it comes out
top_tb.dut.cpu_reset_n = 1'b1;
endtask
endclass
Step 2 exists only because a backdoor load skips the normal boot path entirely - there's no startup code running yet to zero .bss itself. The sequence does that job on the C test's behalf, over the same address range a linker script would define via _bss_start/_bss_end.
Releasing cpu_reset_n is the moment the CPU actually starts fetching, and it fetches from whatever address the ELF header's entry point specifies. That only lines up with valid code in the memory model because the linker script and the load sequence agree on the same address map - one more place a mismatch between the two shows up as a failure that looks like a broken CPU.
A fuller comparison between this kind of backdoor load and a frontdoor load - driving the boot code in through the DUT's actual bus interface, the way real hardware receives it - is worth its own dedicated treatment. For bring-up specifically, backdoor is almost always the right choice: it's fast, and it doesn't require anything else in the DUT to already work.
What Passing Bring-Up Lets Everything Else Assume
Once a bring-up test passes, every test written after it gets to treat reset-to-fetch, basic address decode, and CPU-to-memory connectivity as settled facts rather than open questions. That's the actual payoff: it isn't the test itself that matters, it's the debugging time it saves every test that comes after it.
This is also why bring-up is usually the very first thing run against any new DUT configuration, and the very first thing re-run when an existing test starts failing for no obvious reason. If bring-up itself has started failing, there's no point debugging the test that noticed it - the problem is almost always somewhere more fundamental than that test's own logic.
Common Mistakes
Mistake: Growing the bring-up test into something more "useful."
Every extra check added to a bring-up test is one more thing that has to work before the test can even report a result, which quietly erodes its entire purpose as an unambiguous first signal.
Mistake: Treating a passing bring-up test as proof the CPU is verified.
Bring-up proves the CPU can fetch, execute a handful of instructions, and issue one write. It says nothing about the rest of the instruction set, interrupts, exceptions, or any peripheral - those all need their own tests.
Mistake: Skipping bring-up after a small RTL configuration change.
A change to the memory map, the reset vector, or the address decode is exactly the class of change bring-up exists to catch, and it's cheap enough to rerun that skipping it "because it's probably fine" defeats its purpose.
Mistake: Releasing reset before the load sequence finishes.
If $readmemh or the backdoor pokes haven't completed before cpu_reset_n deasserts, the CPU fetches from memory that's still mid-write. The resulting failure looks exactly like a broken reset vector or a bad address map, when the real bug is a race in the testbench's own load sequence.
Summary
In this article, you learned:
- A bring-up test exists to prove the CPU fetches, executes, and writes at all - not to check functional correctness
- The minimum it has to demonstrate is instruction fetch from the entry point, a write reaching a known address, and reaching a defined end state
- A bring-up test should avoid depending on anything - global initialization, peripheral drivers - that isn't itself already proven to work
- A backdoor load sequence writes the ELF's bytes directly into the memory model, zero-fills
.bssitself, and only then releases reset so the CPU fetches from the entry point - Once bring-up passes, every later test can safely treat reset-to-fetch and basic connectivity as settled, rather than re-verifying them one test at a time