A silent build mismatch can sink a whole debug session. A C test can be functionally perfect and still never run - not because the DUT is broken, but because the binary handed to the simulator was never built for that DUT's CPU in the first place. That kind of silent mismatch between the compiler's assumptions and the hardware's actual configuration is one of the most common ways an SoC bench wastes hours chasing a "hardware bug" that's really a build problem.

Getting HW/SW co-verification right starts before a single instruction executes: with the toolchain that turns ordinary C into machine code for a processor that isn't the one running your terminal. This article covers what a compiler, assembler, and linker each actually do, and how to read a cross-compiler's name well enough to know exactly what it targets.

11 min read | Intermediate Level

What You'll Learn

  • What "host" and "target" mean when you're building software for the CPU inside your DUT
  • How to read a cross-compiler's name and know exactly what it targets
  • The four stages that turn a .c file into machine code, and what each one actually does
  • Why toolchain flags like architecture, ABI, optimization level, and endianness are a verification concern, not just a firmware build detail

Host and Target: Two Different Machines, Two Different Jobs

An ordinary compiler is native: it runs on your workstation and produces code that also runs on your workstation. A cross-compiler breaks that assumption on purpose.

It still runs on your workstation - the host - but the machine code it emits is for a different processor entirely - the target. For this tutorial, the target is whatever core sits inside your SoC: an Arm Cortex-M, a RISC-V core, or similar.

You'll see "host" and "target" used a little differently across build-system documentation, since GNU's own tools define them precisely for the rarer case of building a compiler itself. Day to day, when you're using an existing cross-compiler to build a C test, "host" is safely just your dev machine and "target" is the CPU inside the DUT - that's the usage this tutorial (and virtually every embedded toolchain doc) follows.

Your simulator doesn't care what a native x86 binary looks like. The CPU model in your testbench only understands instructions for its own instruction set - so without a cross-compiler, there's no way to get a C test running on it at all.

Reading a Cross-Compiler's Name

Cross-compiler executables are named after a target triplet (sometimes a quadruplet): architecture-vendor-os-abi. Once you know the pattern, the name alone tells you what the compiler produces.


arm-none-eabi-gcc        # arch: arm     vendor: none   os/abi: eabi (bare-metal)
riscv32-unknown-elf-gcc  # arch: riscv32 vendor: unknown os/abi: elf  (bare-metal)
aarch64-linux-gnu-gcc    # arch: aarch64 vendor: (none)  os: linux, abi: gnu

The eabi and elf endings both mean the same practical thing: no operating system underneath. The compiler assumes bare metal - there's no OS to provide a heap, a filesystem, or process isolation, which is exactly the situation a C test running directly on the CPU model in your testbench is in.

If a toolchain's name ends in -linux-gnu instead, it's built to target an OS-hosted environment, not bare metal. For SoC bring-up and diagnostic tests running directly on the CPU model, you almost always want the bare-metal (eabi/elf) toolchain, not the Linux one.

Four Stages, One .c File to One Executable

Running gcc as a single command hides four separate stages happening in sequence. In practice you'd never invoke these four commands separately - riscv32-unknown-elf-gcc test.c -o test.elf runs all four internally without leaving the intermediate files behind. Seeing them broken apart is worth doing at least once, though, so the pipeline stops being a black box and a failure at any one of them is recognizable for what it is:


# 1. Preprocess: expand #include and #define, strip comments
riscv32-unknown-elf-gcc -E test.c -o test.i

# 2. Compile: translate preprocessed C into target assembly
riscv32-unknown-elf-gcc -S test.i -o test.s

# 3. Assemble: translate assembly into a target object file
riscv32-unknown-elf-gcc -c test.s -o test.o

# 4. Link: combine object files, resolve symbols, produce the executable
riscv32-unknown-elf-gcc test.o -T soc_test.ld -o test.elf

The preprocessor (stage 1) works purely on text, before any C syntax is even parsed. It expands every #include into the literal contents of the header file, substitutes each #define macro with its replacement text, resolves #ifdef/#ifndef conditional blocks, and strips out comments. Its output, test.i, is still C - just one flattened file with none of the preprocessor directives left in it.

The compiler (stage 2) is the only stage that understands C as a language. It parses the preprocessed source, checks types, and applies whatever optimizations the requested level (-O0, -O2, and so on) calls for. Its output is target assembly - readable instructions for the target instruction set, but still just text, not yet anything the CPU can execute.

The assembler (stage 3) turns that assembly text into an object file: real machine opcodes, organized into sections (code, initialized data, and so on), plus a symbol table listing every function and variable name the file defines or references. An object file on its own is usually incomplete - most of the addresses it refers to are still placeholders, waiting to be filled in.

The linker (stage 4) is what makes a multi-file C test possible. It combines every object file's sections, resolves symbols across files - matching a call to a function defined in another file to that function's actual address - and produces one complete binary with real, final addresses everywhere.

The -T soc_test.ld flag points the linker at a linker script, which decides where in memory each section actually lands - the entry point, where code starts, where the stack goes. That's substantial enough to be its own article later in this chapter, along with the ELF file format the linker produces here as its output.

Why This Is a Verification Concern, Not Just a Firmware Detail

It's tempting to treat the toolchain as someone else's problem - firmware owns the build, verification just runs the resulting binary. That works right up until the toolchain's assumptions about the target don't match the DUT's actual configuration.

A cross-compiler has to be told exactly which variant of the target architecture it's generating code for - which instruction set extensions exist, which calling convention and register width to use, and which byte order to assume. Get this wrong and the compiler doesn't necessarily error out; it can silently emit an instruction the DUT's actual RTL configuration doesn't implement.


# Compiling for a RISC-V core WITH the compressed instruction
# extension and hardware multiply/divide:
riscv32-unknown-elf-gcc -march=rv32imc -mabi=ilp32 test.c -o test.elf

# If the DUT's RTL was actually configured WITHOUT the 'M' (multiply/
# divide) extension, this binary contains instructions the real
# hardware can't execute - and the mismatch surfaces as a mysterious
# illegal-instruction trap deep into your test, not a build error.

The compiler trusts the flags it's given - it has no way to check them against the DUT's actual RTL configuration.

Whoever owns the C test build needs to keep -march and -mabi (or their equivalent on other architectures) synchronized with whatever configuration object controls the DUT build - it's a small piece of bookkeeping, but it's exactly the kind of silent hardware/software mismatch that HW/SW co-verification exists to catch.

Endianness is another parameter that has to agree with the RTL, not just the instruction set extensions. Most RISC-V configurations are little-endian by convention, but Arm cores can be configured either way. If the compiler's byte-order assumption doesn't match the DUT's, multi-byte values come out scrambled in memory with no error from the build at all - the C test simply reads back the wrong value.

Optimization level is a different kind of risk, because it isn't about wrong instructions - it's about timing and order. -O0 emits code that maps almost one-to-one onto the C source in source order, which is what makes it easy to correlate a waveform back to a specific line during debug. -O2 and higher can reorder operations, inline function calls, and eliminate code the compiler decides has no observable effect - none of which is incorrect C, but any of which can turn a source-level assumption like "this store happens before that read" into something that's no longer true at the instruction level. A test that passes at -O0 and fails at -O2, or the reverse, is often not a compiler bug - it's a sign that the test or the DUT relied on an ordering the C standard never actually promised.

Common Mistakes

Mistake: Reaching for the system's native gcc instead of the cross-compiler.

Running plain gcc instead of riscv32-unknown-elf-gcc silently produces a binary for your workstation's own architecture - it will compile without error and simply be meaningless to the CPU model in simulation.

Mistake: Assuming a successful compile means a correct target configuration.

A clean build only proves the C source is syntactically valid for the flags given - it says nothing about whether those flags actually match the DUT's real configuration, as the multiply/divide example above shows.

Mistake: Mixing object files built with different target flags.

Linking an object file built with -march=rv32imc against one built with plain -march=rv32i can produce a binary that links cleanly but behaves incorrectly, since the two files may disagree about instruction encoding or calling convention.

Mistake: Changing optimization level without re-running the full regression.

Switching a test suite from -O0 to -O2 - often done to shrink code size or speed up compile time - can silently change execution order and expose, or mask, a race that was never actually fixed. That kind of change deserves a full rerun, not a spot check.

Summary

In this article, you learned:

  • A cross-compiler runs on your host workstation but generates code for a different target architecture - the CPU core inside your DUT
  • A toolchain's name encodes its target as architecture-vendor-os/abi, and an eabi/elf suffix means bare-metal, no OS underneath
  • Compiling runs through four stages - preprocess, compile, assemble, link - each producing a distinct intermediate output
  • Toolchain flags like -march, -mabi, and endianness must match the DUT's actual RTL configuration, or the compiler can silently generate instructions (or byte orderings) real hardware can't execute or doesn't expect
  • Optimization level changes execution order and timing, not just code size - a test that behaves differently at -O0 versus -O2 is a signal worth investigating, not a compiler bug to shrug off
Did you find what you were looking for?

Quiz

No quiz questions available for this article.