A new board is on the bench. The power rails look reasonable, the debugger connects, and the firmware flashes without complaint. You press reset.
Now what?
Maybe an LED starts blinking. Maybe the debugger stops in main(). Maybe nothing obvious happens. In all three cases, the useful question is the same: what have we actually proved?
Firmware bring-up is something nearly every embedded engineer has done, but much of the useful knowledge around it is still passed along informally. It lives in an old startup file, a few lines in someone’s notebook, or the habits of the engineer who has brought up the last three boards. Different teams also draw the line between “it runs” and “we trust it” in very different places.
That is why I wanted to write this article: to put the early Cortex-M bring-up path in one place, share the checks I find useful, and hear what other engineers do differently. This is not meant to be the only correct sequence. It is a practical starting point for comparing what we verify—and what we sometimes assume—when new hardware first reaches the bench.
That question is at the heart of firmware bring-up. The job is not simply to make the board do something. It is to establish, one piece at a time, that the processor started from the image we built, that its memory was initialized correctly, that the clocks are what we think they are, and that we have some way to see what happens next.
On a bare-metal Cortex-M, I usually work toward the first UART byte. It is a small milestone, but reaching it honestly requires quite a lot of the system to be right.
Before main()
The processor does not begin with our C code. On reset, a Cortex-M core reads the first two words of the vector table. The first becomes the initial Main Stack Pointer. The second is the address of Reset_Handler, with the low bit set to indicate Thumb state.
Those two words are the first things worth checking when a target is new. I compare them with the SP and PC shown by the debugger. Does the stack point into real SRAM? Does the reset address resolve to the handler in the ELF file? Is the vector table where the core expects it to be?
Stopping at main() is encouraging, of course. It just is not the whole story. By then, startup code should already have copied initialized data into RAM, cleared .bss, and possibly done other device-specific setup. A breakpoint proves that control flow reached a location. It does not prove that the trip there was correct.
Where the linker script meets startup code
The linker script and startup file are easy to treat as project boilerplate. During bring-up, they are anything but boilerplate.
Consider .data. Its initial values are stored in Flash as part of the firmware image, while the variables themselves must live in SRAM at runtime. Reset_Handler has to copy the bytes from the load address to the runtime address. .bss is different: it occupies RAM but carries no initialized payload in the image, so startup code has to clear it.
A small mistake in one of those address ranges can produce wonderfully confusing symptoms. The code is valid, the debugger reaches main(), and yet a global variable contains nonsense. That is why I put a known non-zero value in .data and a known zero-initialized variable in .bss, then inspect both at the start of main(). It takes a minute and settles the question.
The ELF and map files are useful here. readelf, nm, and objdump can tell us where the vector table landed, which address was assigned to Reset_Handler, and whether the section boundaries match the copy and clear loops. The source shows what we meant to build; the ELF shows what we actually built.
Do not overlook the build itself
Bare-metal build flags carry assumptions about the target: Cortex-M variant, Thumb instruction set, ABI, floating-point support, and linker script. Mixing objects built with incompatible options can create an image that links cleanly and still fails in a very unhelpful way.
For a fresh target, I prefer a clean, reproducible build before chasing anything on the board. Then I reset into the first instruction, step through enough of the startup path to explain it, and inspect the test values at main().
This is also why I resist adding a logging framework too early. If startup and memory are not yet trustworthy, a large logging stack gives us more code to doubt. A GPIO transition or a raw UART byte tells us less, but it tells us something much more clearly.
Clocks: the first place assumptions become waveforms
Most MCUs start from a conservative internal clock. The faster external oscillator and PLL configuration comes later, and there are several opportunities to get that transition wrong.
The exact sequence depends on the device, but the ingredients are familiar: voltage scaling, Flash wait states, oscillator startup, PLL configuration, bus prescalers, and finally the system-clock switch. Ready flags need timeouts. After the switch, read back the selected source instead of trusting that a register write succeeded.
Then measure it.
If the part can route a clock to an MCO pin, use it. Otherwise, toggle a GPIO around a timed operation and look at it with a scope or logic analyzer. A register dump tells us what software requested; the pin tells us what made it through the silicon.
This simple check catches mistakes that otherwise spread everywhere. A wrong oscillator constant can make the UART almost work, make a delay look plausible, and leave every timer calculation slightly wrong. It is much cheaper to find that error at the clock output than three drivers later.
And please put a timeout around oscillator-ready loops. An endless while (!ready) does not preserve much evidence. A timeout can leave a code, a GPIO state, or a debugger-visible value that says exactly where initialization stopped.
Bring up the fault path before you need it
Fault handling often gets postponed until the first real crash. Unfortunately, that is the worst time to discover that every exception points to the same empty infinite loop.
Early in bring-up, I want a HardFault path that preserves the stacked PC and LR along with CFSR and HFSR. BFAR and MMFAR are also valuable when their validity bits indicate that the addresses can be trusted. Even a compact capture area in RAM is enough to turn “it crashed somewhere” into a useful starting point.
It is worth testing this deliberately. Trigger a controlled invalid access, let the handler capture the frame, and check that the saved PC resolves to the instruction that caused it. This may feel odd when the board has barely started running, but it verifies the diagnostic path while the system is still simple.
The same care pays off with normal interrupts. When one fires unexpectedly, I want to know which source was pending, which priority was active, and whether the peripheral flag was cleared at the right point. A handler that only toggles an LED is fine for the first five minutes; it should not be the last word.
Why I like UART as the first real milestone
UART is simple enough to bring up without much infrastructure, yet demanding enough to exercise a good part of the platform. It depends on a valid peripheral clock, the correct pin mux, a sensible baud calculation, compatible voltage levels, shared ground, and TX/RX wiring that has not been crossed in the wrong direction—or left uncrossed when it should be crossed.
Start small. Configure one TX pin and send one raw byte with polling. No printf, no interrupt-driven ring buffer, no DMA.
Repeated 0x55 is especially handy because the alternating bits create plenty of edges to measure. Check the bit time on the pin. If the waveform has the expected timing but the terminal shows nothing, the problem is probably outside the baud-rate calculation: wiring, voltage level, selected COM port, or terminal settings. If the timing is wrong, go back to the peripheral clock and BRR calculation.
Once that byte is solid, add text output. At that point the UART is no longer a hopeful sign that something somewhere is alive. It is the result of a startup path, memory setup, clock tree, peripheral configuration, and physical connection that we have checked along the way.
The bring-up notes nobody regrets keeping
Bring-up gets messy when several plausible fixes are tried together. The board starts working, everyone is relieved, and two days later nobody can say whether the real cause was the clock change, the linker edit, or the reset strap somebody touched in passing.
I keep the process deliberately boring: write down what I expect, choose one observation that can confirm or reject it, change one thing, and record the result. A useful note can be very short:
Expected 16 MHz on MCO. Measured 8 MHz. PLL source was correct; AHB prescaler was not. Fixed in commit
abc123, board revision B.
That is enough context for the next engineer—or for me a month later.
Simulation helps here too, as long as we ask it the right questions. It is excellent for repeatable reset behavior, section initialization, instruction flow, and controlled exceptions. It cannot prove that a crystal starts reliably or that a pin has the waveform and voltage we expect. Some evidence belongs in GDB; some belongs on the scope.
A checklist for the bench
There are enough small dependencies in this process that relying on memory is optimistic. I put the sequence into a free, printable two-page Cortex-M Bare-Metal Firmware Bring-Up Bench Checklist. The first page covers the target, image, reset path, and C runtime. The second covers clocks, physical observations, fault readiness, UART, and sign-off.
Download the free bench checklist
For a deeper treatment, the complete Bare-Metal Firmware Bring-Up Field Guide includes guided labs, source code, GDB workflows, and intentionally broken examples to investigate.
Learn with other embedded engineers
Bring-up is difficult to learn from datasheets alone. Sometimes what helps most is showing someone your map file, register dump, or scope capture and asking, “What am I missing?” That is one of the reasons we created the EmbeddedVille community.
EmbeddedVille is a friendly place for people learning, building, and growing in embedded systems and firmware development. It brings together students, hobbyists, embedded job seekers, junior engineers, and experienced professionals who want to ask technical questions, share projects, discuss problems from real hardware, and learn from the way other engineers approach them.
You will feel at home there if you are preparing for your first embedded role, trying to move forward in your current one, strengthening your MCU and firmware fundamentals, or working through a project that keeps finding new ways to fight back. The goal is practical progress: getting unstuck sooner, developing better engineering habits, building projects you can explain with confidence, and meeting people who understand the work.
Join EmbeddedVille and introduce yourself. Tell us what you are building—or bring the problem currently sitting on your bench.
Questions to Audience
I am curious: what is the first sign of life you genuinely trust on a new board? And what is one item on your team’s bring-up checklist that the documentation rarely mentions?
Share it in the comments. There is a good chance another engineer will recognize the lesson behind it.
