SRAM overflow error on Workbench IDE Compile

According to the Particle Argon datasheet, there is 256 KB of RAM available.

When compiling a program, I am getting the following behavior, depending on how many statically allocated instances of a massive class I create:

With MAX_BOSSES = 2

C:/Users/Boompy/.particle/toolchains/gcc-arm/10.2.1/bin/arm-none-eabi-size --format=berkeley c:/PARTICLE_LOCAL/LinkApp/target/4.1.0/argon/LinkApp.elf
   text    data     bss     dec     hex filename
 223412     952   67944  292308   475d4 c:/PARTICLE_LOCAL/LinkApp/target/4.1.0/argon/LinkApp.elf

ram use = data + bss = 67944 + 952 = 68896 bytes

With MAX_BOSSES = 3

C:/Users/Boompy/.particle/toolchains/gcc-arm/10.2.1/bin/arm-none-eabi-size --format=berkeley c:/PARTICLE_LOCAL/LinkApp/target/4.1.0/argon/LinkApp.elf
   text    data     bss     dec     hex filename
 223284    1056   82936  307276   4b04c c:/PARTICLE_LOCAL/LinkApp/target/4.1.0/argon/LinkApp.elf

ram use = data + bss = 82936 + 1056 = 83992 bytes

With MAX_BOSSES  = 4

 region `SRAM' overflowed by 7064 bytes

Summary: 

going from 2 to 3, ram use goes up by 83992 - 68896 = 15096 bytes

same increase in bss use going from 3 to 4, but now we're out of memory
amout of memory would have bee 83992 + 15096 = 99088 bytes

Am I correct in assuming that the 83992 I'm calculating for RAM use in the MAX_BOSSES = 3 case is not actually how much RAM is being consumed from the Argon?

By my calculation, if the RAM scaling from the MAX_BOSSES = 2 to the MAX_BOSSES = 3 case held true, then the MAX_BOSSES = 4 case would have only consumed 99088 bytes of RAM.

Is there some kind of RAM partition or consumption that is not being shown in the compiled ELF file?

Here is the pretty version of the object dump of the compiled ELF file for the MAX_BOSSES = 3 case:

...so, how much RAM is being used in total?

I'd guess that it's because it needs a large contiguous free space to put the class. If you check out it's size with sizeof(ClassName) and then check out the .map file and see if there are any contiguous spaces left in bss for the class.

I had chat write up a python script to display the contiguous spaces, which seems to work on my Photon .map file but I don't have an Argon file to test.

import re

def parse_memory_config(map_file):
    memory_config = {}
    with open(map_file, "r") as f:
        in_memory_config = False
        for line in f:
            line = line.strip()
            #print(f"Processing Line: {line}")
            # Detect the start of Memory Configuration section
            if "Memory Configuration" in line:
                #print("Found Memory Configuration section.")
                in_memory_config = True
                continue
            # Skip the header row and blank lines within the Memory Configuration section
            if in_memory_config and (line == "" or line.startswith("Name")):
                continue
            # Detect the end of the Memory Configuration section
            if in_memory_config and not re.match(r"\S+\s+0x[\da-fA-F]+\s+0x[\da-fA-F]+", line):
                #print("End of Memory Configuration section.")
                break
            # Process valid memory configuration entries
            if in_memory_config:
                match = re.match(r"(\S+)\s+0x([\da-fA-F]+)\s+0x([\da-fA-F]+)", line)
                if match:
                    name = match.group(1)
                    origin = int(match.group(2), 16)
                    length = int(match.group(3), 16)
                    memory_config[name] = {"origin": origin, "length": length}
                    #print(f"Parsed: Name={name}, Origin=0x{origin:08X}, Length=0x{length:08X}")
                #else:
                    #print("Line did not match the expected format.")
    return memory_config

def parse_sections(map_file):
    sections = []
    with open(map_file, "r") as f:
        for line in f:
            match = re.search(r"(\.\w+)\s+0x([\da-fA-F]+)\s+0x([\da-fA-F]+)", line)
            if match:
                section_name = match.group(1)
                start_addr = int(match.group(2), 16)
                size = int(match.group(3), 16)
                end_addr = start_addr + size
                sections.append((section_name, start_addr, end_addr))
                if "lmao" in section_name:
                    print(f"Parsed Section: {section_name} Start=0x{start_addr:08X} End=0x{end_addr:08X}")
    return sections
    
def find_unused_memory(sections, memory_config):
    unused_regions = []
    for name, config in memory_config.items():
        if name == "*default*":  # Skip overly broad *default* region
            continue
        start, end = config["origin"], config["origin"] + config["length"]
        #print(f"Checking Memory Region: {name} Start=0x{start:08X} End=0x{end:08X}")
        
        # Filter and sort relevant sections
        relevant_sections = [
            (s_start, s_end) for _, s_start, s_end in sections 
            if start <= s_start < end and s_start != s_end
        ]
        relevant_sections.sort()
        #print(f"Relevant Sections for {name}: {relevant_sections}")

        # Track gaps between sections
        current_addr = start
        for s_start, s_end in relevant_sections:
            # Detect and add gaps
            if current_addr < s_start:
                unused_regions.append((current_addr, s_start))
                #print(f"Found Gap: 0x{current_addr:08X} to 0x{s_start:08X}")
            # Move current address to the end of the current section
            current_addr = s_end

        # Check for gap between the last section and the memory region's end
        if current_addr < end:
            unused_regions.append((current_addr, end))
            #print(f"Found Gap at End: 0x{current_addr:08X} to 0x{end:08X}")

    # Sort and deduplicate gaps
    filtered_regions = sorted(set(unused_regions), key=lambda region: region[0])
    return filtered_regions

# Main logic
map_file = "LinkApp.map"

# Parse Memory Configuration and Sections
memory_config = parse_memory_config(map_file)
sections = parse_sections(map_file)

print("Memory Configuration:")
for name, config in memory_config.items():
    print(f"Origin=0x{config['origin']:08X} Length=0x{config['length']:08X} : {name}")

# Find and display unused memory regions
unused_memory = find_unused_memory(sections, memory_config)
print("Unused memory regions:")
for start, end in unused_memory:
    print(f"0x{start:08X} to 0x{end:08X} (size: {end - start} bytes)")

Output for one of my projects,

Memory Configuration:
Origin=0x080A0000 Length=0x00020000 : APP_FLASH
Origin=0x20000000 Length=0x00014400 : SRAM
Origin=0x40024000 Length=0x00000C00 : BACKUPSRAM
Origin=0x40024C00 Length=0x00000400 : BACKUPSRAM_SYSTEM
Origin=0x00000000 Length=0xFFFFFFFF : *default*
Unused memory regions:
0x080A00A2 to 0x080A039C (size: 762 bytes)
0x080A03B4 to 0x080A4530 (size: 16764 bytes)
0x080A46CC to 0x080A5E58 (size: 6028 bytes)
0x080A610C to 0x080B2CF4 (size: 52200 bytes)
0x080B2D04 to 0x080B2FA0 (size: 668 bytes)
0x080B2FD0 to 0x080B2FE0 (size: 16 bytes)
0x080B2FF0 to 0x080B3020 (size: 48 bytes)
0x080B3030 to 0x080B30DA (size: 170 bytes)
0x080B3158 to 0x080B34AC (size: 852 bytes)
0x080B34AE to 0x080B3510 (size: 98 bytes)
0x080B3512 to 0x080B3C64 (size: 1874 bytes)
0x080B3C70 to 0x080B4F80 (size: 4880 bytes)
0x080B4F82 to 0x080B4FF6 (size: 116 bytes)
0x080B4FF8 to 0x080B5164 (size: 364 bytes)
0x080B5192 to 0x080B5198 (size: 6 bytes)
0x080B519A to 0x080B59EC (size: 2130 bytes)
0x080B59FC to 0x080B5AC8 (size: 204 bytes)
0x080B5ACA to 0x080B5B44 (size: 122 bytes)
0x080B5B48 to 0x080B5D98 (size: 592 bytes)
0x080B6060 to 0x080B60AC (size: 76 bytes)
0x080B60C0 to 0x080B62AC (size: 492 bytes)
0x080B636E to 0x080B6382 (size: 20 bytes)
0x080B63A6 to 0x080B63A8 (size: 2 bytes)
0x080B63B8 to 0x080B6EA4 (size: 2796 bytes)
0x080B6F7A to 0x080B7070 (size: 246 bytes)
0x080B7084 to 0x080B717C (size: 248 bytes)
0x080B7190 to 0x080B76CC (size: 1340 bytes)
0x080B76D4 to 0x080B78C0 (size: 492 bytes)
0x080B78C8 to 0x080B7928 (size: 96 bytes)
0x080B79C8 to 0x080B8E6C (size: 5284 bytes)
0x080B8E98 to 0x080B8EA0 (size: 8 bytes)
0x080B8EA8 to 0x080C0000 (size: 29016 bytes)
0x2000002D to 0x20000035 (size: 8 bytes)
0x20000036 to 0x20000078 (size: 66 bytes)
0x2000007C to 0x200003F0 (size: 884 bytes)
0x200003F8 to 0x20000400 (size: 8 bytes)
0x20000458 to 0x200005C4 (size: 364 bytes)
0x200005EE to 0x20000604 (size: 22 bytes)
0x20000608 to 0x20000610 (size: 8 bytes)
0x20000614 to 0x20000638 (size: 36 bytes)
0x2000063C to 0x20000645 (size: 9 bytes)
0x20000646 to 0x2000064C (size: 6 bytes)
0x2000064D to 0x20000968 (size: 795 bytes)
0x20000969 to 0x20000981 (size: 24 bytes)
0x20000982 to 0x20000983 (size: 1 bytes)
0x20000984 to 0x20000985 (size: 1 bytes)
0x20000986 to 0x20000990 (size: 10 bytes)
0x20000994 to 0x200009AD (size: 25 bytes)
0x200009AE to 0x200009CC (size: 30 bytes)
0x2000318C to 0x20003238 (size: 172 bytes)
0x2000323C to 0x2000324C (size: 16 bytes)
0x20003250 to 0x20003254 (size: 4 bytes)
0x20003258 to 0x200032DC (size: 132 bytes)
0x2000363C to 0x2000363F (size: 3 bytes)
0x20003640 to 0x200036EE (size: 174 bytes)
0x200036EF to 0x20003700 (size: 17 bytes)
0x20003A60 to 0x20003A68 (size: 8 bytes)
0x20003A69 to 0x20003C60 (size: 503 bytes)
0x20003C6C to 0x20003CA8 (size: 60 bytes)
0x20003CA9 to 0x20003CF0 (size: 71 bytes)
0x20003D00 to 0x20014400 (size: 67328 bytes)
0x40024004 to 0x40024C00 (size: 3068 bytes)
0x40024C00 to 0x40025000 (size: 1024 bytes)

You can't use all of the available RAM for static allocation. There's probably some amount that's used for static allocation by the system, but more importantly the bss does not include the amount used dynamically at run time by new, malloc, etc.. You need at least dozens of kilobytes for that, if not more, because the system will also need some heap allocation space, in addition to anything in user firmware.

OK understood. So Particle OS has some dynamic memory allocation inside it that will take "dozens" of KB to make room for...

And is the compiler checking for that? I thought dynamic memory allocation causes stack overflow issues, not complete inability to compile.

To me, it seems more likely that there is indeed some kind of fragmentation issue, like @jettonj was talking about above.

Deep dive on map file

Compile window shows:

C:/Users/macdo/.particle/toolchains/gcc-arm/10.2.1/bin/arm-none-eabi-size --format=berkeley c:/PARTICLE_LOCAL/LinkApp/target/4.1.0/argon/LinkApp.elf
   text    data     bss     dec     hex filename
 258548    1100   83000  342648   53a78 c:/PARTICLE_LOCAL/LinkApp/target/4.1.0/argon/LinkApp.elf

I modified the @jettonj python script to make it work for me:

  • Needed to add some file path OS magic
  • Works when it is in the same directory as LinkApp.map
  • Added timestamp and file path to beginning of output
  • Added size summary at the end
  • Made output go into a file called "MapReport.txt"
import re
import os
import datetime


def parse_memory_config(map_file):
    memory_config = {}
    with open(map_file, "r") as f:
        in_memory_config = False
        for line in f:
            line = line.strip()
            #print(f"Processing Line: {line}")
            # Detect the start of Memory Configuration section
            if "Memory Configuration" in line:
                #print("Found Memory Configuration section.")
                in_memory_config = True
                continue
            # Skip the header row and blank lines within the Memory Configuration section
            if in_memory_config and (line == "" or line.startswith("Name")):
                continue
            # Detect the end of the Memory Configuration section
            if in_memory_config and not re.match(r"\S+\s+0x[\da-fA-F]+\s+0x[\da-fA-F]+", line):
                #print("End of Memory Configuration section.")
                break
            # Process valid memory configuration entries
            if in_memory_config:
                match = re.match(r"(\S+)\s+0x([\da-fA-F]+)\s+0x([\da-fA-F]+)", line)
                if match:
                    name = match.group(1)
                    origin = int(match.group(2), 16)
                    length = int(match.group(3), 16)
                    memory_config[name] = {"origin": origin, "length": length}
                    #print(f"Parsed: Name={name}, Origin=0x{origin:08X}, Length=0x{length:08X}")
                #else:
                    #print("Line did not match the expected format.")
    return memory_config

def parse_sections(map_file):
    sections = []
    with open(map_file, "r") as f:
        for line in f:
            match = re.search(r"(\.\w+)\s+0x([\da-fA-F]+)\s+0x([\da-fA-F]+)", line)
            if match:
                section_name = match.group(1)
                start_addr = int(match.group(2), 16)
                size = int(match.group(3), 16)
                end_addr = start_addr + size
                sections.append((section_name, start_addr, end_addr))
                if "lmao" in section_name:
                    print(f"Parsed Section: {section_name} Start=0x{start_addr:08X} End=0x{end_addr:08X}")
    return sections
    
def find_unused_memory(sections, memory_config):
    unused_regions = []
    for name, config in memory_config.items():
        if name == "*default*":  # Skip overly broad *default* region
            continue
        start, end = config["origin"], config["origin"] + config["length"]
        #print(f"Checking Memory Region: {name} Start=0x{start:08X} End=0x{end:08X}")
        
        # Filter and sort relevant sections
        relevant_sections = [
            (s_start, s_end) for _, s_start, s_end in sections 
            if start <= s_start < end and s_start != s_end
        ]
        relevant_sections.sort()
        #print(f"Relevant Sections for {name}: {relevant_sections}")

        # Track gaps between sections
        current_addr = start
        for s_start, s_end in relevant_sections:
            # Detect and add gaps
            if current_addr < s_start:
                unused_regions.append((current_addr, s_start))
                #print(f"Found Gap: 0x{current_addr:08X} to 0x{s_start:08X}")
            # Move current address to the end of the current section
            current_addr = s_end

        # Check for gap between the last section and the memory region's end
        if current_addr < end:
            unused_regions.append((current_addr, end))
            #print(f"Found Gap at End: 0x{current_addr:08X} to 0x{end:08X}")

    # Sort and deduplicate gaps
    filtered_regions = sorted(set(unused_regions), key=lambda region: region[0])
    return filtered_regions

# Main logic
__location__ = os.path.realpath(
    os.path.join(os.getcwd(), os.path.dirname(__file__)))

map_file = os.path.join(__location__, 'LinkApp.map')

# Parse Memory Configuration and Sections
memory_config = parse_memory_config(map_file)
sections = parse_sections(map_file)

with open(os.path.join(__location__, 'MapReport.txt'), 'w') as oFile:
    now = datetime.datetime.now()
    oFile.write(f"Map File Analysis Report For {map_file}- {now.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
    
    oFile.write("Memory Configuration:\n")
    for name, config in memory_config.items():
        oFile.write(f"Origin=0x{config['origin']:08X} Length=0x{config['length']:08X} : {name}\n")

    # Find and display unused memory regions
    unused_memory = find_unused_memory(sections, memory_config)
    oFile.write("Unused memory regions:\n")
    total_ram_size = 0
    total_flash_size = 0
    for start, end in unused_memory:
        oFile.write(f"0x{start:08X} to 0x{end:08X} (size: {end - start} bytes)\n")
        # Check if start is bigger than start of RAM range
        if start >= 0x2002A2C4:
            total_ram_size += end - start
        else:
            total_flash_size += end - start

    
    oFile.write("-----------------------------------------\n")
    oFile.write(f"Total Un-Used Flash: {total_flash_size} bytes\n")
    oFile.write(f"\nTotal Un-Used RAM: {total_ram_size} bytes\n")

Here is the output that I got:

Map File Analysis Report For C:\PARTICLE_LOCAL\LinkApp\target\4.1.0\argon\LinkApp.map- 2024-11-22 12:34:55

Memory Configuration:
Origin=0x2003F400 Length=0x00000C00 : BACKUPSRAM
Origin=0x2003F000 Length=0x00000380 : BACKUPSRAM_SYSTEM
Origin=0x2003F3C0 Length=0x00000040 : BACKUPSRAM_SYSTEM_FLAGS
Origin=0x2003F380 Length=0x00000040 : BACKUPSRAM_REGISTERS
Origin=0x2003F000 Length=0x00001000 : BACKUPSRAM_ALL
Origin=0x000B4000 Length=0x00040000 : APP_FLASH
Origin=0x2002A27C Length=0x00014584 : SRAM
Origin=0x00000000 Length=0xFFFFFFFF : *default*
Unused memory regions:
0x000B401C to 0x000B4020 (size: 4 bytes)
0x000B40A2 to 0x000B4188 (size: 230 bytes)
0x000B41A8 to 0x000B41D0 (size: 40 bytes)
0x000B4200 to 0x000B4E3C (size: 3132 bytes)
0x000B4E4C to 0x000B539C (size: 1360 bytes)
0x000B5524 to 0x000BA425 (size: 20225 bytes)
0x000BB650 to 0x000BB770 (size: 288 bytes)
0x000BB780 to 0x000BC29C (size: 2844 bytes)
0x000BC2B0 to 0x000BE518 (size: 8808 bytes)
0x000BE5D8 to 0x000BE8A8 (size: 720 bytes)
0x000BE8B8 to 0x000BEB64 (size: 684 bytes)
0x000BEBB4 to 0x000BEBD4 (size: 32 bytes)
0x000BEBE4 to 0x000BEBF4 (size: 16 bytes)
0x000BEC04 to 0x000BED7E (size: 378 bytes)
0x000BEDC8 to 0x000BF296 (size: 1230 bytes)
0x000BF298 to 0x000BF2FE (size: 102 bytes)
0x000BF300 to 0x000BFA8C (size: 1932 bytes)
0x000BFA98 to 0x000C0398 (size: 2304 bytes)
0x000C039E to 0x000C0BA8 (size: 2058 bytes)
0x000C0BAA to 0x000C1468 (size: 2238 bytes)
0x000C148A to 0x000C14EA (size: 96 bytes)
0x000C14EC to 0x000C1594 (size: 168 bytes)
0x000C1596 to 0x000C1630 (size: 154 bytes)
0x000C1FA4 to 0x000C1FB8 (size: 20 bytes)
0x000C1FEC to 0x000C2016 (size: 42 bytes)
0x000C20C8 to 0x000C21CC (size: 260 bytes)
0x000C2274 to 0x000C23EC (size: 376 bytes)
0x000C2434 to 0x000CB378 (size: 36676 bytes)
0x000F31C8 to 0x000F330D (size: 325 bytes)
0x2002A2C4 to 0x2002A3B8 (size: 244 bytes)
0x2002ABBC to 0x2002AD60 (size: 420 bytes)
0x20035D4C to 0x20035D7C (size: 48 bytes)
0x20035D80 to 0x20035DA8 (size: 40 bytes)
0x20035DB8 to 0x20035DD0 (size: 24 bytes)
0x20035DD4 to 0x20035DDC (size: 8 bytes)
0x20035DE0 to 0x2003E614 (size: 34868 bytes)
0x2003E619 to 0x2003E630 (size: 23 bytes)
0x2003E634 to 0x2003E638 (size: 4 bytes)
0x2003E63C to 0x2003E644 (size: 8 bytes)
0x2003E650 to 0x2003E6D0 (size: 128 bytes)
0x2003E6E0 to 0x2003E7EC (size: 268 bytes)
0x2003E7F0 to 0x2003E800 (size: 16 bytes)
0x2003F000 to 0x2003F400 (size: 1024 bytes)
0x2003F000 to 0x2003F380 (size: 896 bytes)
0x2003F380 to 0x2003F3C0 (size: 64 bytes)
0x2003F3C0 to 0x2003F400 (size: 64 bytes)
0x2003F6E4 to 0x20040000 (size: 2332 bytes)
-----------------------------------------
Total Un-Used Flash: 86742 bytes

Total Un-Used RAM: 40479 bytes


Which seems to be showing that

  • There is 3kB of RAM address space reserved for BACKUPSRAM
  • A further 1 kB is taked for particle OS backups:
    • BACKUPSRAM_SYSTEM
    • BACKUPSRAM_SYSTEM_FLAGS
    • BACKUPSRAM_REGISTERS
  • There is 40,479 bytes of unused RAM?
  • The biggest unused chunk of RAM is 34868 bytes?

I think that this implies that there is only a total of just under 129 kB of RAM available in total?

So, not sure how the total RAM space is coming out to only 129 kB....

Where is the missing RAM?

Here is my map file for reference:

There are several places where data is stored:

  • const data, stored in flash
  • global variables, statically allocated in RAM (also incudes static local variables in functions)
  • global variables initialized to values other than 0, which are statically allocated in both RAM and flash
  • heap allocated memory, from new, malloc, strdup, etc.
  • stack allocated memory, including local variables

In any case, a large number of allocations occur at runtime on the heap. There's no way for the compiler to know about those, so if you run out of memory the allocation will fail. If you have an out of memory handler, that will be called. If it fails and you don't check the result code, the device will crash, usually a SOS+1.

The missing 128K is probably the space dedicated to the heap.

There's more information here:

Additionally, given the length of the SRAM region in the map file, it's only 0x14584 or 83,382 bytes large, and you're using 83,000 bytes in bss as it is.
On the Photon I see that the 2 system parts account for 48,128 bytes of ram in the SRAM region right after the user SRAM that we see in the app .map file. But as @rickkas7 said, more than that probably just isn't available.

OK interesting. This begs a few more questions if I may:

How much of the heap can I use? Is it just a matter of judicious use of the heap via frequent calls to System.FreeMemory() prior to each allocation?

Also, out of curiosity, How come the heap allocated section is not shown in the map file? Is size of RAM array reserved for heap a compiler setting in the Particle toolchain? I didn't know you could instruct the compiler to reserve a certain amount of heap, but I guess that makes sense.

To date, I have always assumed in my embedded programming that to avoid stack overflow issues it is best to declare all the variables that I will ever need as static , so that I know at compile time approximately how big my stack size will be

This heap reservation way of thinking now makes me realize that I can likely use way more of the Argon's RAM if I declare more of my variables at runtime.

Am I taking away the right takeaways?

Yes, you are correct.

The heap is shared between Device OS and the user application. You should leave at least 20 Kbytes free at runtime for use by the system. You don't have to check it that frequently, just when you're developing so you have a good idea of how much you're using, and also to look for memory leaks.

The heap space is not shown in the map file because it's dynamic allocation. The linker map only shows static allocation. Basically the memory is divided up into parts before telling the linker how much static space is available to it.

The stack overflow is a separate issue, and is caused by non-static local variables using too much space, or too much recursion. The stack is small, 6 Kbytes for the loop thread, and 1 Kbyte for the software timer thread. Each thread has its own stack.

There are a number of things with heap allocation that become progressively less annoying the larger the heap. On the Spark Core with 20K of heap space typically, the problems are pronounced and that's why a lot of code for that and old Ardiuno uses static allocation as much as possible. Gen 2 (STM32F205) was around 50K, Gen 3 (nRF52840) around 80K, and Gen 4 (RTL872x) is about 3.5 MB, and most problems go away at that point. Linux-based systems have virtual memory, so you can basically allocate as much as you want, even more than the physical RAM.

The issues with heap allocation are:

Since it occurs at runtime, if you run out, you need to deal with it at runtime. With static allocation, you know whether your data will fit at a compile time.

Allocation is dynamic, and there is no garbage collection like Javascript and Java. Thus you can leak memory. This can cause you to have too little memory for the device to function properly.

Once a block is allocated on the heap, it can't be moved. This can lead to fragmentation, where there is free RAM, but it's in pieces too small to be usable for your application. As long as you don't fill the RAM, you're usually OK. Also if you have long-lived blocks of RAM, allocating them at startup is helpful.

Thanks for your response @rickkas7 .

I think I can boil down my fundamental question to:

How can I determine at compile-time if I my static RAM allocation is too high, or likely to cause stability issues for the app?

Perhaps some of the following more specific questions will lead me to an answer I can live with:

How do I know how much RAM I have left free for the heap at compile time? This is the fundamental problem that I am struggling with... since the heap should "be about 20kB" and "The heap space is not shown in the map file". It is invisible to me, so how can I plan or design for it?

Are you saying that I don't have to leave those 20 kB free voluntarily, because the build system automatically reserves that amount of heap space?

Is this division of memory reportable? How can I know how big the heap is at compile time?

Is the user stack shown in the map file? Is it a dynamically allocated variable created by the RTOS on boot? Can I change the size of the user stack?

The size of the heap is set by Device OS. I'd guess it's 128 Kbytes on Gen 3 devices. Some of that will be used by Device OS itself prior to user firmware starting.

The System.freeMemory() function determines the amount of heap space available at runtime. That should be around 80 Kbytes on Gen 3 devices.

You can use all but around 20 Kbytes of whatever is returned by System.freeMemory() at startup for your own use. That should be around 60 Kbytes of data that you can allocate using new, malloc, etc..

It's impossible to know these values at compile time.