Step 70. Project — Building a Memory Observation Tool in C
Level 1 — Programming and the Computer’s Interior | Difficulty ★★★★☆ | Estimated time: 4 hours
Prerequisites: Steps 56–69 complete; you know pointers and address printing, malloc/free, memory’s four regions, executable structure, and the concept of virtual memory.
- What you need: a Linux terminal (WSL or Ubuntu), gcc, and everything you’ve built up in the previous chapters.
- Caution: this chapter is not a lesson but a comprehensive exam. Getting stuck today is fine — projects are completed by getting stuck. The practice itself is 100% safe.
Until now we’ve treated memory only as "something to learn about." Today we switch positions: we build a tool that observes memory ourselves. You don’t need to become a doctor, but someone who has built their own stethoscope hears a patient’s sounds differently. What we’ll build is simple — a program that prints the addresses of where five kinds of beings live in memory: a global variable, a static variable, a local variable, heap from malloc, and a function, then sorts them by address and shows them as a "map." Add the distances between regions, and you have a memory map you can see with your eyes.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Complete the C program
memmap, which draws a map of its own memory addresses - Convert addresses to
uintptr_tto compare, sort, and compute distances - Sort addresses in ascending order with bubble sort
- Confirm and explain with data the phenomenon of addresses changing every run (ASLR)
- Explain the relationship between addresses in the file (readelf) and addresses at runtime
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | C language + Linux terminal (verified on WSL Ubuntu 24.04, gcc 13.3.0) |
| Today’s commands | gcc -o memmap memmap.c, readelf -h memmap | grep Entry (entry point comparison), pmap PID (a taste of a pro tool) |
| Today’s C elements | collecting addresses with &variable and function name, uintptr_t (an integer type that holds addresses), the %p format, arrays of structs, bubble sort |
| Concepts needed | Memory’s four regions (code/data/heap/stack), virtual addresses, ASLR, Step 66’s executable structure |
| Today’s deliverable | memmap — an observation tool that draws my process’s memory map + an observation log |
2-1. Today’s Blueprint
The program’s skeleton goes like this:
- Create the observation targets: a global variable, a static variable, a local variable, a heap block from malloc, and a function (one more besides main).
- Collect each one’s address. In C,
&variablegives a variable’s address, and a barefunction namegives a function’s address. - Pair addresses with names into an array and sort by address in ascending order.
- Print, in sorted order, each name and address and the distance to its neighbor (the address difference).
2-2. Technical Details for Handling Addresses
To sort addresses, you must treat them as "comparable numbers." Mixing and comparing pointers of different kinds (variable addresses and function addresses) can draw compiler warnings, so today we convert every address into an integer type called uintptr_t. Defined in stdint.h, this type is promised to be "an integer that can hold a pointer without loss." Being an integer, comparison and subtraction (distance calculation) are both free. A function address converts without warnings by going through void* one step, like (uintptr_t)(void*)main.
When printing, convert back to (void*) and print with %p. %p is the format that prints a pointer in 0x… form.
2-3. The Sorting Algorithm — Bubble Sort
Today’s sorting target is a mere 6 items, so the simplest bubble sort suffices. It’s an algorithm that compares two neighboring elements and swaps them if their order is wrong, repeating until large values get pushed to the back like rising bubbles. Since it compares up to n×n times for n elements it’s slow, but with n at 6 that’s 36 comparisons — done in the blink of an eye.
3. Follow Along
3-1. Building the Skeleton — Collecting Six Targets’ Addresses
First, create the observation targets and print their addresses. Save the code below as memmap.c.
Input (memmap.c)
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
int g_var = 10; /* global variable: data region */
static int s_var = 20; /* static variable: data region */
void helper(void) { /* function: code region */
}
int main(void) {
int l_var = 30; /* local variable: stack */
int *h_var = malloc(sizeof(int)); /* heap block */
if (h_var == NULL) { return 1; }
*h_var = 40;
printf("global var : %p\n", (void*)&g_var);
printf("static var : %p\n", (void*)&s_var);
printf("heap(malloc): %p\n", (void*)h_var);
printf("local var : %p\n", (void*)&l_var);
printf("main func : %p\n", (void*)main);
printf("helper func: %p\n", (void*)helper);
free(h_var);
return 0;
}
Input
gcc -o memmap memmap.c && ./memmap
Output (verified 2026-09-09):
global var : 0x59d2d21d1010
static var : 0x59d2d21d1014
heap(malloc): 0x59d3120c72a0
local var : 0x7ffc8e8b9e9c
main func : 0x59d2d21ce1b4
helper func: 0x59d2d21ce1a9
How to read it: look closely at the addresses and clusters appear. The functions (0x…e1b4, 0x…e1a9) and the global/static variables (0x…1010, 0x…1014) are gathered in the 0x59d2d2… band; the heap is at 0x59d3…, a bit higher in a similar band; and only the local variable sits in a completely different high place, 0x7ffc…. These are the measured values of the map from Step 60: "code and data on the low side, heap above them, stack up high." One interesting point — helper sits at a lower address than main. The function written first in the source was placed first.
Why: the step of first confirming that the map’s outline is visible even without sorting. Your eyes have already started reading memory maps.
3-2. Predict — What Order Will Sorting Produce?
Before building the map in earnest, look at the output above and predict. If you line up the six targets by ascending address (lowest first), what order results? Write it on paper in the form "helper → main → …". The hint is in the verified output above.
3-3. Completing the Map — Collect, Sort, Compute Distances
With your prediction written down, upgrade the program. Save as memmap2.c.
Input (memmap2.c)
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
int g_var = 10;
static int s_var = 20;
void helper(void) { }
typedef struct {
const char *name;
uintptr_t addr;
} Entry;
int main(void) {
int l_var = 30;
int *h_var = malloc(sizeof(int));
if (h_var == NULL) { return 1; }
*h_var = 40;
Entry entries[] = {
{"global var", (uintptr_t)(void*)&g_var},
{"static var", (uintptr_t)(void*)&s_var},
{"heap(malloc)", (uintptr_t)(void*)h_var},
{"local var", (uintptr_t)(void*)&l_var},
{"main func", (uintptr_t)(void*)main},
{"helper func", (uintptr_t)(void*)helper},
};
int n = sizeof(entries) / sizeof(entries[0]);
/* bubble sort: ascending by address */
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (entries[j].addr > entries[j + 1].addr) {
Entry tmp = entries[j];
entries[j] = entries[j + 1];
entries[j + 1] = tmp;
}
}
}
printf("===== my program's memory map =====\n");
for (int i = 0; i < n; i++) {
printf("%-12s %p", entries[i].name, (void*)entries[i].addr);
if (i + 1 < n) {
uintptr_t gap = entries[i + 1].addr - entries[i].addr;
printf(" (distance to next: %lu bytes)", (unsigned long)gap);
}
printf("\n");
}
free(h_var);
return 0;
}
Input
gcc -o memmap2 memmap2.c && ./memmap2
Output (verified 2026-09-09):
===== my program's memory map =====
helper func 0x5f7cd1b8b1e9 (distance to next: 11 bytes)
main func 0x5f7cd1b8b1f4 (distance to next: 11804 bytes)
global var 0x5f7cd1b8e010 (distance to next: 4 bytes)
static var 0x5f7cd1b8e014 (distance to next: 720413324 bytes)
heap(malloc) 0x5f7cfca982a0 (distance to next: 35732726228156 bytes)
local var 0x7ffca91da75c
How to read it: a map sorted by address has come out. Four things to read. ① helper and main stick together, 11 bytes apart (code region). ② The global and static variables are 4 bytes apart (the size of an int) (data region). ③ Between the static variable and the heap, 720 million bytes are punched open. ④ Between the heap and the local variable (stack) lies a staggering distance of about 35 trillion bytes (32.5 TB). This empty space is the surplus of the virtual address space. It can be this vast because it’s a "promised address space," not actual RAM.
Why: applying data processing’s basic flow — collect → sort → visualize — to memory addresses is all this program is. Yet this "all" is a miniature of what system tools do.
Check your prediction: compare with the order you wrote earlier. If part of your prediction was wrong, find out why by comparing with /proc/self/maps (Step 69). An observation tool’s reason for existing is "seeing the difference between the textbook picture and reality."
3-4. Predict — Run Three Times; What Happens to the Addresses?
If you run the memmap2 you just made three times in a row, will the printed addresses be the same or different each time? What about the distances? Predict, recalling Step 69’s ASLR experiment.
Input
./memmap2 | head -n 3; ./memmap2 | head -n 3; ./memmap2 | head -n 3
Output (verified 2026-09-09):
===== my program's memory map =====
helper func 0x592c11eb91e9 (distance to next: 11 bytes)
main func 0x592c11eb91f4 (distance to next: 11804 bytes)
===== my program's memory map =====
helper func 0x59162e5691e9 (distance to next: 11 bytes)
main func 0x59162e5691f4 (distance to next: 11804 bytes)
===== my program's memory map =====
helper func 0x5b7ee516d1e9 (distance to next: 11 bytes)
main func 0x5b7ee516d1f4 (distance to next: 11804 bytes)
How to read it: the addresses change every run (0x592c…, 0x5916…, 0x5b7e…). But look closely — the trailing digits (1e9, 1f4) and the distances (11, 11804) are identical all three times. Only the starting point at the front changes. This is ASLR (Address Space Layout Randomization) — the operating system shifts the entire memory map wholesale to a random position on every run while leaving the relative positions inside the map intact.
Why: ASLR’s purpose is to prevent an attacker from learning in advance that "variable X is at address 0x…e010," because most memory attacks require exact addresses. You have just observed with a tool you built yourself the most widely used memory defense technology in the world.
3-5. Comparing with the Executable’s Entry Point
Finally, pull out Step 66’s readelf and compare the address in the file with the address at runtime.
Input
readelf -h memmap2 | grep -E "Type|Entry"
Output (verified 2026-09-09):
Type: DYN (Position-Independent Executable file)
Entry point address: 0x1100
How to read it: the entry point written inside the executable is a small number like 0x1100, while the main function’s address printed at runtime is a big number starting with 0x5f7c…. Since Type is DYN (position-independent executable), the addresses in the file are "relative coordinates with the map’s origin set to 0," and the runtime addresses are "absolute coordinates" with the ASLR-determined origin added. The trailing digits being preserved in 3-4 is also because of this structure — only the origin changes; the relative coordinates stay.
Why: the moment when executable structure (Step 66), virtual memory (Step 69), and ASLR (today) interlock into a single picture. Once you see this connective tissue, Level 1’s great memory journey is effectively over.
3-6. A Taste of a Pro Tool — pmap
Your memmap is in fact a baby version of the pro tools. pmap PID shows the memory map of any process.
Input
sleep 20 &
pmap 1495
(Running sleep 20 & displays the just-launched process’s PID on screen, like [1] 1495. Write that number after pmap — your number will differ.)
Output (verified 2026-09-09, top portion):
1495: sleep 20
00005e6ca2776000 8K r---- sleep
00005e6ca2778000 16K r-x-- sleep
00005e6ca277c000 4K r---- sleep
00005e6ca277d000 4K r---- sleep
00005e6ca277e000 4K rw--- sleep
00005e6cd3c2e000 132K rw--- [ anon ]
0000709210400000 160K r---- libc.so.6
How to read it: even a tiny program like sleep has a map. r-x– (executable, code) and rw— (read-write, data) are separated, and libc (the standard library) is loaded shared. The map memmap drew with six dots, pmap draws with whole segments. Both share the same source data — Step 69’s /proc/PID/maps.
Why: because you built today’s tool, the output of the pro tools you’ll meet (gdb, pmap) will now read as "ah, that map I know."
4. Missions & Exercises
Mission — Completing memmap and the Observation Log
- Basic map: complete the 3-3 code so the six targets’ addresses print sorted
- Region name tags: attach a region label like "(code region)", "(data region)", "(heap)", "(stack)" to each output line. You may decide by address band or mark them directly
- ASLR record: run the program 3 times, write down helper’s addresses, and summarize "what changes and what is preserved" in two sentences
- Distance interpretation: explain in one paragraph, from the virtual memory perspective, why the distance between heap and stack is so large
- Entry point comparison: record in the observation log the difference between readelf’s Entry point and main’s runtime address
Exercises
Q1. Explain why we convert pointers into uintptr_t, from the perspective of "comparison and subtraction."
Q2. Explain what one swap in bubble sort (the 3-line exchange code) does.
Q3. In the ASLR run records, explain why "trailing digits and distances are preserved while only the leading digits change," using the words relative coordinates and origin.
Q4. Explain why the entry point in the executable (0x1100) differs from main’s runtime address (0x5f7c…), connecting it to Type: DYN.
5. Model Answers & Completion Criteria
Mission Model Answer
For region name tags, the simplest way is adding one more field — the region name — to the Entry struct:
typedef struct {
const char *name;
const char *region; /* region name tag */
uintptr_t addr;
} Entry;
Entry entries[] = {
{"global var", "(data region)", (uintptr_t)(void*)&g_var},
{"static var", "(data region)", (uintptr_t)(void*)&s_var},
{"heap(malloc)", "(heap)", (uintptr_t)(void*)h_var},
{"local var", "(stack)", (uintptr_t)(void*)&l_var},
{"main func", "(code region)", (uintptr_t)(void*)main},
{"helper func", "(code region)", (uintptr_t)(void*)helper},
};
/* in the print loop: add one column like printf("%-12s %-14s %p", name, region, addr); */
Observation log example (based on the 2026-09-09 verification):
3. ASLR 3 runs: helper = 0x592c11eb91e9 / 0x59162e5691e9 / 0x5b7ee516d1e9
→ what changes: the leading start point (origin). What's preserved: the
trailing digits (1e9) and the distances to neighbors (11, 11804).
4. Heap-stack distance 35,732,726,228,156 bytes (about 32.5 TB): this space is
not actual RAM but virtual address space promised to the process. Being a
promise, it can be vast regardless of RAM size.
5. Entry point in file 0x1100 vs runtime main 0x5f7c...: with the DYN format,
addresses in the file are relative coordinates; at runtime they become
absolute coordinates with the ASLR origin added.
How to verify: ① Is the output in ascending address order? ② Is the global-static distance 4 bytes (the size of an int)? ③ Is trailing-digit preservation confirmed in the 3-run record? ④ Is the distance interpretation in the log written using the words "virtual address space"? If all four are "yes," it’s complete. Keep your finished memmap2.c safe — it’s your first "system tool."
Exercise Solutions
Q1 solution. Because converting to integers lets you do magnitude comparison and subtraction even on pointers of different kinds (variable addresses and function addresses). uintptr_t is a type (in stdint.h) promised to be "an integer that holds a pointer without loss," so sorting (comparison) and distance calculation (subtraction) are safe.
Q2 solution. It compares two neighboring elements’ addresses, and if the front is larger than the back, it performs a three-step seat swap: shelter the front in a temporary variable (tmp), move the back to the front, and place the sheltered front at the back. Repeating this swap pushes large addresses to the back like bubbles, yielding ascending order.
Q3 solution. Because ASLR moves only the map’s origin (start address) randomly while leaving the relative coordinates inside the map intact. The relative positions determined in the file (like trailing digits 1e9) are preserved every run, and only the origin (0x592c…, 0x5916…) changes. With relative positions equal, distances to neighbors are equal too.
Q4 solution. Because Type is DYN (position-independent executable). So that it runs no matter which address it’s loaded at, this format writes the addresses in the file as "distances from the origin (relative coordinates)." At execution, the operating system decides the origin (ASLR), and values with that origin added become the runtime addresses (absolute coordinates). So the file’s 0x1100 and the runtime 0x5f7c… are two notations for the same place.
Completion Criteria Checklist
- [ ] I can collect and print the six targets’ addresses
- [ ] I can convert addresses to uintptr_t and sort them ascending with bubble sort
- [ ] I can compute and print the distances between regions
- [ ] I observed the ASLR phenomenon across 3 runs and can explain "trailing-digit preservation"
- [ ] I can explain the relationship between addresses in the file (relative) and at runtime (absolute)
- [ ] I opened another process’s map with pmap
- [ ] Mission: I completed all five observation log items
6. Common Pitfalls & Fixes
Wall 1. Output alignment breaks with multi-byte names
Symptom (verified 2026-09-09): I set the width with %-12s, yet the address positions on rows with multi-byte (e.g., Korean) names are uneven.
helper func 0x5f7cd1b8b1e9
main func 0x5f7cd1b8b1f4
Cause: the 12 in %-12s is not "12 characters" but "12 bytes." In UTF-8, a character outside ASCII can take 2–3 bytes (Step 50), so a few such characters eat the whole width and leave no padding.
Fix: for output purposes you don’t need to care (the map’s content is what matters). If you want it aligned, use ASCII-only names or give the width more room.
Wall 2. A compile warning appears on function addresses
Symptom: a pointer-conversion warning appears where you convert function addresses.
Cause: function pointers and ordinary pointers are strictly different beings in the C standard, so a compiler strict about casting complains.
Fix: going through void* one step, like (uintptr_t)(void*)main, makes the warning disappear. The 3-3 code is exactly that form.
Wall 3. Printing a malloc’d address after freeing it
Symptom: printing h_var after free shows an address, but something feels off.
Cause: free only returns the memory; it doesn’t erase the number (the address value) inside the variable h_var. So the address itself still prints.
Fix: when only "the address value" is needed, like today, it’s fine — but accessing that address is a dangling pointer error (a pointer to a returned spot). Make a habit of finishing prints before free — the 3-3 code follows that order.
Wall 4. Distances come out as nonsensical numbers
Symptom: the distance output looks negative or is an absurd value.
Cause: printing a subtraction result with the wrong format (e.g., %d) makes a mess with sign issues.
Fix: distances are large unsigned integers, so use %lu together with an (unsigned long) cast. The match between format specifiers and types is an eternal trap of C output.
Wall 5. My output’s order differs from the book’s
Symptom: the arrangement differs from the book’s verification — e.g., functions land at lower addresses than data.
Cause: compiler and linker placement varies by environment. The book’s output is a single record from the 2026-09-09 gcc 13.3.0 verification.
Fix: that’s the right answer. An observation tool’s reason for existing is "seeing the difference between the textbook and reality." Record the different order in your observation log. Meanwhile, the things that don’t change — trailing-digit preservation and the band separation of the four regions — should still be confirmed.
7. Summary
Today’s Concepts
| Concept | One-line description |
|---|---|
uintptr_t |
An integer type holding pointers without loss — enables comparison and subtraction |
| Bubble sort | Repetition of neighbor compare-and-swap — enough for small data |
| ASLR | A defense moving the map’s origin every run — trailing digits and distances are preserved |
| Relative / absolute coordinates | Addresses in the file (DYN format) / addresses at runtime (ASLR origin applied) |
| Observation tool | A self-made stethoscope showing the difference between the textbook picture and reality |
Today’s Commands/Syntax
| Command/syntax | What it does |
|---|---|
&variable / function name |
Get a variable’s / function’s address |
(uintptr_t)(void*)address |
Convert a pointer to a comparable integer |
%p |
Print a pointer in 0x… form |
readelf -h file | grep Entry |
Check the entry point (relative coordinate) in the file |
pmap PID |
View any process’s memory map |
./memmap2 | head -n 3 (3 times) |
Observe ASLR — confirm trailing-digit preservation |
The Instinct That Matters More Than Commands
It feels like just yesterday that you first printed "the number called an address" in Step 56, and today you built a tool that draws a map from those numbers. With this tool at hand, every memory story ahead becomes a measurement, not an abstraction. If a doubt like "they say the stack is at higher addresses than the heap — really?" arises, just pull out this tool. Good tools serve for a lifetime.
The security connection: today’s memmap is a harmless tool that looks only at itself, but extend this idea to others’ processes and it becomes a powerful analysis tool. In incident response, you draw maps of where malware hides from memory dumps, and attackers draw the same maps to find attack points. Tools themselves hold no good or evil — where you point them is everything. ASLR isn’t omnipotent either — one mistake of a program printing an address (an information disclosure vulnerability) nullifies the randomization, so real-world attacks target information disclosure first, in the spirit of "just one leaked address is enough." Knowing how defenses get bypassed is the way to defend better. What your tool observed today was strictly your own process. ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
Once every box is checked, Step 70 is complete. Click the checkbox in the sidebar to save your progress.