Global variable initialized to non-zero value but always reads as zero in main() — bare-metal STM32
Asked by stale_biscuit_03 ·
Bit of a basic question maybe, but I'm genuinely stuck.
Moving from ESP32/Arduino to bare-metal STM32 (STM32G031, Makefile project, arm-none-eabi-gcc). I have global variables and they seem to work fine when the initialiser is zero, but break when I give them a non-zero value.
Minimal example:
uint8_t mode = 0; /* reads as 0 ✓ */
uint32_t retry_limit = 5; /* reads as 0 ✗ — should be 5 */
uint32_t start_addr = 0x800; /* reads as 0 ✗ — should be 2048 */
When I halt at the opening brace of main() in GDB, mode is correct but retry_limit
and start_addr are both 0. If I just add retry_limit = 5; as the first line of
main() it works, so the variables themselves are fine — it's clearly a startup issue.
The startup file (startup.s) and linker script (.ld) came from a bare-metal STM32 tutorial on GitHub. I've read the linker script article and understand the LMA/VMA concept in principle — initialised values live in flash, get copied to SRAM at boot — but I don't know where in my setup that copy is failing or how to find out.
3 Replies
Your .data section copy isn't running. Classic tutorial startup gap.
What's supposed to happen
Initialised globals (non-zero values) are stored in the .data section of your
binary. The initial values live in flash at the LMA (Load Memory Address). At
startup, before main() runs, the C runtime is supposed to copy those values from
flash to the .data runtime location in SRAM (the VMA). Zero-initialised variables
go in .bss — startup just zeroes that memory range, which is why your mode = 0
works fine.
Check your startup file
Open your startup.s. Look for a copy loop that references _sidata, _sdata, and
_edata. In a correct STM32 startup, in C it looks like:
/* copy .data from flash (LMA) to SRAM (VMA) */
uint32_t *src = &_sidata;
uint32_t *dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
/* zero .bss */
uint32_t *bss = &_sbss;
while (bss < &_ebss) {
*bss++ = 0;
}
If the .data copy loop is absent or the symbols are wrong, your initialised
variables never get their values. Many tutorial startup files include the BSS zero
loop but omit the data copy loop — BSS looks right (everything's zero anyway) so
the bug hides until you add a non-zero initialiser.
Check your linker script
The .data output section needs an AT clause so the linker places the initial
values in flash (LMA) while the runtime address (VMA) is in SRAM:
.data :
{
_sdata = .;
*(.data .data.*)
_edata = .;
} >RAM AT>FLASH
_sidata needs to be defined as LOADADDR(.data) — the flash address the startup
code reads from. If the linker script defines _sidata = _etext instead, check
there's no alignment padding between .text and .data that would shift it off.
Verify with the map file
Build with -Wl,-Map=output.map and open the map. Find .data. You should see two
addresses: VMA (SRAM, something like 0x20000000) and LMA (flash, like
0x08002xxx). If both show the same address, the AT>FLASH clause is missing and
the linker put the initial values at the wrong address in the binary.
Fix the startup file first — that's the most common culprit with tutorial projects.
Worth being precise about where this sits in the C model.
For a freestanding environment (which bare-metal MCU firmware is — C11 §5.1.2.1),
the C standard doesn't mandate the same program startup sequence as a hosted system.
The startup behaviour is implementation-defined. What that means in practice: the
arm-none-eabi-gcc toolchain with newlib does provide the C runtime contract that
objects with static storage duration and explicit initialisers have those values in
place before main() is called — but only if the startup code (which is part of
the C runtime) runs correctly. The compiler has done its job: the values are in the
binary in the .data section in flash. The startup code is the other party in the
contract, and it's not holding up its end.
One specific case to watch after you fix this: variables used inside SystemInit()
— the clock init function that runs before the .data copy loop in a standard STM32
startup sequence — will still see uninitialised SRAM. The copy loop typically runs
after SystemInit(). If any global is read during clock initialisation, don't rely
on its static initialiser; assign it explicitly inside SystemInit() or before it
runs.
The startup code sequence article has the full reset handler order: reset → SystemInit → .data copy → .bss zero → __libc_init_array → main(). Worth understanding the order before assuming any initialised global is available at any point during startup.
This is exactly what tutorial startup files do. The BSS zero loop gets included because zeroing memory is simple and wrong-looking when absent. The .data copy loop gets dropped or gets the wrong symbols, and because zero-initialised variables look correct, the bug doesn't surface until someone actually assigns a non-zero value.
Once you've added the copy loop, here's a fast way to confirm it's working: set a
data watchpoint on retry_limit in GDB and reset the MCU. You should see exactly
one write to that address during startup — the copy loop writing the value from
flash. If you see no writes, the loop isn't executing or the loop bounds are wrong
(copy running zero iterations).
One gotcha with _sidata = _etext: if your linker script aligns .text to a
4-byte or 8-byte boundary but doesn't add the same alignment to _sidata, there
can be a gap between the end of .text and the start of the .data image in flash.
The startup code then reads from that gap and your variables get bytes from padding,
not from the initialiser values. Use LOADADDR(.data) rather than a manually
computed _etext + offset to avoid this.
The MCU memory map article is useful here —
it shows how flash and SRAM address regions are laid out on Cortex-M and why the
LMA and VMA of .data end up in different physical memories despite being one
logical section in the linker output.