Every free Linux kernel development course eventually reaches the point where “does it work” stops being the only question, and “how fast does it work, and where does it slow down” takes over. Before touching a single profiling tool, though, there are two things every embedded developer needs to internalize: profiling itself changes the thing being profiled, and a profiler is only as useful as the symbol and debug information available to it. This lecture builds both ideas from first principles, then proves the second one with a small original demo you can run on any Linux machine in minutes.
What You Will Learn
- Why measuring a running system always costs something, and why that matters
- What a symbol table actually is, and how it differs from debug info
- Which compiler flags change whether a profiler can name a function at all
- Building the same program two ways and comparing what survives
- Resolving addresses back to source lines with addr2line
- Common mistakes teams make right before their first profiling session
Prerequisites
A working GNU toolchain (gcc, binutils) on a Linux machine or a Yocto/Buildroot-built target, comfort with basic C, and familiarity with compiling and running a program from the command line. No prior profiling experience is assumed — this lecture is the on-ramp for the rest of the profiling and tracing material in this free Linux kernel development course.
The Observer Effect: Measurement Has a Cost
In physics, checking the current in a wire means inserting a small resistor and reading the voltage drop across it — but that resistor changes the very current you are trying to measure. Software profiling has the identical problem. A profiler that samples the program counter every millisecond, a tracer that logs every function entry, or a debugger single-stepping through code all consume CPU cycles, evict cache lines, and sometimes touch disk or network I/O to store their own output. None of that activity would exist if you weren’t watching.
This is not a reason to distrust profiling — it is a reason to be deliberate about how you profile. Three consequences follow directly from the observer effect:
- Timing shifts. A function that looked fast in isolation may look slower under a sampling profiler simply because the profiler’s own interrupt handler is stealing cycles from it.
- Cache and TLB pollution. Instrumentation code and the buffers it writes into compete for the same L1/L2 cache lines as your workload, so cache-miss-sensitive code paths can look artificially worse than they are in production.
- Heisenbugs in reverse. Sometimes a bug that reproduces reliably in normal operation becomes intermittent or vanishes entirely once a tracer is attached, because the timing window that triggered it no longer exists.
Why a Free Linux Kernel Development Course Spends Time on Symbol Tables
Once you accept that every measurement has overhead, the next question is: what does a profiler
actually give you back? Under the hood, a sampling profiler like perf or a tracer
like Ftrace does not record “function ep_fib ran for 4ms.” It records raw
instruction pointer addresses — plain 64-bit numbers. Turning those numbers into function names
and line numbers you can act on is a completely separate step, and it depends entirely on two
things being present: a symbol table and, ideally, debug
information.
A symbol table is a section inside an ELF binary (typically .symtab, or
.dynsym for the exported subset) that maps addresses to names — function names,
global variable names, and so on. Debug information is a much richer, separate set of ELF
sections (in the DWARF format on Linux) that additionally maps addresses to source file names,
line numbers, and even local variable locations. A binary can have a symbol table with no debug
info, debug info with a stripped symbol table, or both, or neither. What you compiled with
decides which of these you get.
Compile Flags That Decide What a Profiler Can See
The single most common mistake in any Linux profiling workflow — and one this free Linux kernel development course wants you to avoid from day one — is profiling a binary that was never built to be profiled. The table below summarizes the flags that matter and what each one actually buys you.
| Flag | What It Does | Why a Profiler Cares |
|---|---|---|
-g |
Emits DWARF debug information alongside the code | Lets tools like addr2line, perf report, and GDB map addresses to
source file and line, not just a bare function name |
-O0 vs -O2/-O3 |
Controls how aggressively the compiler reorders, merges, and eliminates code | Higher optimization levels inline small functions and reorder instructions, which can make line-level attribution approximate or misleading; lower levels give exact attribution at the cost of realistic timing |
-fno-omit-frame-pointer |
Keeps a dedicated frame pointer register instead of reusing it as a general-purpose register | Required by frame-pointer-based call-graph unwinding (used by many perf
configurations and by simple stack walkers); without it, call stacks can be truncated or wrong |
-pg |
Inserts mcount-style instrumentation calls at every function entry |
Required specifically by gprof-style call-graph profiling; has no effect on
tools that don’t consume that instrumentation |
| strip / no strip | Removes the symbol table from the final binary | A stripped release binary shown to a profiler will report raw hex addresses instead of function names unless you keep an unstripped copy around for offline symbol resolution |
The key trade-off
Debug symbols themselves add zero runtime overhead — they sit in ELF sections the loader never
executes, so shipping a -g build changes nothing about how fast it runs. What
does change behavior is -O0 and -fno-omit-frame-pointer, because
those genuinely alter the generated code. In practice, the common target for kernel and
application profiling work is a release-optimized build compiled with -g and
-fno-omit-frame-pointer, keeping the unstripped binary on the build machine for
later symbol resolution.
Hands-On: Watching Symbols Disappear Under Optimization
The clearest way to see why compile flags matter is to build the exact same source file twice — once the way you would ship it, and once the way you would profile it — and compare what a symbol table actually contains. Here is a small, original workload written for this lecture.
/* ep_profile_demo.c
* A tiny CPU-bound workload used to explore how compiler flags
* affect symbol tables and debug information. */
#include <stdio.h>
#include <stdlib.h>
static long ep_fib(int n)
{
if (n < 2)
return n;
return ep_fib(n - 1) + ep_fib(n - 2);
}
static long ep_sum_squares(int limit)
{
long total = 0;
for (int i = 0; i < limit; i++)
total += (long)i * i;
return total;
}
int main(int argc, char **argv)
{
int n = (argc > 1) ? atoi(argv[1]) : 30;
long fib_result = ep_fib(n);
long sum_result = ep_sum_squares(20000000);
printf("fib(%d) = %ld\n", n, fib_result);
printf("sum_squares = %ld\n", sum_result);
return 0;
}
Now build it two different ways:
# A "shipping" release build — no debug info, aggressive optimization
gcc -O2 -o ep_profile_release ep_profile_demo.c
# A "profiling-ready" build — debug info, frame pointers kept
gcc -O0 -g -fno-omit-frame-pointer -o ep_profile_debug ep_profile_demo.c
Compare what each binary’s symbol table actually contains:
$ nm ep_profile_release | grep ep_
$ nm ep_profile_debug | grep ep_
0000000000401136 t ep_fib
00000000004011a2 t ep_sum_squares
nm command against the release
build often returns nothing at all for ep_fib and
ep_sum_squares. At -O2, GCC is free to inline both small
static functions directly into main, so there is no longer a separate
function for the symbol table to describe. A profiler pointed at that release binary cannot
attribute time to “ep_fib” by name — the function, as a distinct entity, no longer exists in the
compiled output. This is exactly the situation the source material calls out: some tools need
additional information the running system doesn’t naturally provide.
With the debug build, you can go one step further and resolve a raw address back to a source line:
$ objdump -d ep_profile_debug | grep -A1 "<ep_fib>:"
0000000000401136 <ep_fib>:
401136: f3 0f 1e fa endbr64
$ addr2line -e ep_profile_debug -f 0x401136
ep_fib
/home/user/ep_profile_demo.c:8
Run the same addr2line command against ep_profile_release and it will
either fail to resolve the address to any function you recognize, or point you at
main instead — because that is genuinely where the inlined code now lives. This
small experiment is the entire “symbol tables and compile flags” problem in miniature: the
system you observe and the system you can meaningfully report on are not automatically the same
system.
Continuing This Free Linux Kernel Development Course: Applying It to Real Tools
Every profiling and tracing tool covered later in this series inherits this same dependency:
- perf works best when the running binary — application or kernel — has an
unstripped copy with matching symbols available, either installed on the target or supplied via
perf‘s build-id lookup mechanism. - Ftrace and LTTng for kernel-side tracing need the kernel itself built with the relevant tracer config options enabled, which in practice means building and deploying a kernel image specifically for this kind of work rather than assuming your production kernel already has it.
- gprof requires the
-pginstrumentation flag at compile time — without it, there is no call-graph data to collect no matter how the program is run afterward.
The pattern across all three: decide what you need to measure, then choose your build flags before you start, not after you’ve already collected a confusing set of hex addresses.
Common Mistakes and Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Profiler output is full of raw hex addresses instead of function names | Binary was stripped, or the profiler can’t find a matching unstripped copy | Keep an unstripped build alongside the stripped one you deploy, and point the profiling tool at it explicitly |
| Expected function is completely missing from the profile | The function was inlined away at a higher optimization level | Mark the function __attribute__((noinline)) for the duration of profiling, or
rebuild that translation unit at -O0 |
| Call graph is truncated or shows impossible call chains | Frame pointer was omitted by the optimizer | Rebuild with -fno-omit-frame-pointer, or switch the unwinder to DWARF-based
(perf record --call-graph dwarf) if you cannot rebuild |
| Profiling results look wildly different from production behavior | Debug or instrumented build changed timing characteristics, or extra services were running during the measurement | Profile a release-optimized build with only debug info added back, on a quiet system, against a realistic workload |
Best Practices
- Treat “profiling build” as its own build configuration — release optimization plus
-gplus-fno-omit-frame-pointer— rather than reusing either your debug build or your stripped release build unmodified. - Keep unstripped binaries archived alongside every release you ship, indexed by version or build ID, so you can symbolize a crash or a profile from production weeks later.
- Change one variable at a time. If you need call graphs, add frame pointers. If you need
line-level attribution, add debug info. Don’t drop to
-O0“just in case” — it changes the very performance characteristics you’re trying to measure. - Adopt a wait-and-see approach to instrumentation: only add tracepoints or recompile with instrumentation once you have a specific hypothesis to test, since every change you make to the system is a change to what you’re measuring.
Performance Considerations
Debug information adds disk and binary size but no runtime cost, since the loader never touches DWARF sections during execution. Frame pointer retention typically costs low single-digit percent overhead on modern x86-64 and ARM64 targets because it reserves one register that would otherwise be available for the optimizer — usually an acceptable trade for reliable call graphs in production profiling.
Security Considerations
Debug information can reveal internal function names, file paths, and sometimes source line content indirectly through crash reports — treat unstripped debug binaries for shipped products as sensitive build artifacts, store them separately from what you distribute publicly, and strip symbols from anything that leaves your build infrastructure.
Summary and Key Takeaways
- Every profiling or tracing tool changes the system it observes — plan measurements with that overhead in mind, and prefer target hardware, release builds, and realistic data.
- A symbol table maps addresses to names; DWARF debug info (from
-g) additionally maps addresses to source file and line — a profiler needs at least the former and ideally both. - Optimization can delete the very functions you want to measure through inlining; frame
pointer omission can break call-graph unwinding; instrumentation-based tools like
gprofneed their own dedicated compile flag. - The fix in every case is deciding your build configuration before you start profiling, not reverse-engineering hex addresses afterward.
This foundation — the observer effect on one side, symbol tables and compile flags on the other — is what makes every tool later in this free Linux kernel development course actually usable. Skip it, and even the most sophisticated tracer will hand you a report full of addresses you can’t act on.
Interview Questions
Why don’t debug symbols slow down a program at runtime?
Because DWARF debug sections are pure metadata stored alongside the code in the ELF file — the CPU never executes them. Only actual code changes, like disabling optimization or keeping a frame pointer, affect runtime behavior.
Why might a function be completely absent from a profiler’s report even though it’s clearly being called?
The compiler likely inlined it into its caller at a higher optimization level, so it no longer exists as a distinct symbol for the profiler to attribute samples to.
What’s the practical difference between a symbol table and debug information?
A symbol table (ELF .symtab) maps addresses to names only. Debug information
(DWARF, from -g) is richer and maps addresses to source file, line number, and
variable locations.
Why would you deliberately keep the frame pointer register instead of letting the compiler reuse it?
Frame-pointer-based stack unwinders, used by many profiling configurations, rely on that register to walk the call stack; omitting it can truncate or corrupt reported call graphs.
Frequently Asked Questions
Do I need to recompile my whole application just to profile it?
Not always. If you already build with -g and keep unstripped binaries, most
sampling profilers work without a rebuild. Recompiling is only strictly required when you need
gprof-style instrumentation (-pg) or want to prevent a specific function from being
inlined away.
Does compiling with -O0 make profiling results meaningless?
It makes timing results less representative of production performance, since -O0
disables the optimizations your shipped binary actually uses. It’s best reserved for cases where
you need exact line-level attribution and understand the trade-off, not as a default profiling
configuration.
Can I profile a kernel the same way I profile an application?
The same symbol/debug-info principle applies, but the kernel needs its own debug symbols
(CONFIG_DEBUG_INFO and related options) and, for tools like Ftrace and LTTng,
specific tracer configuration options enabled at build time — this usually means building and
deploying a kernel image dedicated to profiling and tracing work.
What tool shows me a binary’s symbol table directly?
nm lists symbols from the symbol table, readelf -S shows all ELF
sections including debug sections if present, and objdump -d gives a disassembly
annotated with whatever symbol names are available.
Is stripping a binary the same as never compiling with -g?
No. Stripping removes the symbol table from an already-built binary using strip
or a linker option; you can strip a binary that was built with -g, and you can also
build without -g from the start. Either path leaves you with a binary a profiler
can’t symbolize on its own.
Why does the lecture recommend keeping frame pointers even for a release build?
Because the alternative — omitting the frame pointer for a small register-allocation gain — breaks the cheapest and most portable call-graph unwinding method available, forcing you onto slower DWARF-based unwinding or losing call-graph data entirely during production profiling.
Will this apply to embedded/cross-compiled targets too?
Yes — the same GCC flags and the same ELF/DWARF concepts apply whether you’re compiling
natively or cross-compiling for an ARM or RISC-V target in Yocto or Buildroot. The only
difference is you’ll typically run nm, objdump, and
addr2line from your cross toolchain (e.g. arm-linux-gnueabihf-nm)
against the target binary from your host machine.
top and
perf.

2 Comments