Electronics Design AU
FirmwareSolved

Global variable initialized to non-zero value but always reads as zero in main() — bare-metal STM32

5 min read3 replies
Original Question

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.

From the knowledge baseWhat Is a Linker Script and What Does It Do?

3 Replies

soggy_waffle42
Accepted Answer

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.

volatile_variables

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.

isr_overload

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.

Related Discussions