strace and ltrace: Debugging Processes at the System Call Level
When a program behaves unexpectedly and you can’t attach a debugger, or you’re working with a binary you don’t have source for, strace and ltrace cut through the mystery. They show you exactly what a process is asking the kernel to do — every file it opens, every network connection it makes, every signal it sends. This guide covers both tools from practical first use through advanced production debugging techniques.
The Core Idea
Every program running on Linux interacts with the kernel through system calls (syscalls). Reading a file, writing to a socket, spawning a child process, allocating memory — none of these happen directly. They all go through a well-defined syscall interface.
strace intercepts these syscall transitions and prints them. You see every request the program makes to the kernel, its arguments, and the return value.
ltrace does the same thing but at the shared library level — it intercepts calls to functions in shared libraries like libc. Where strace shows read(3, ...), ltrace shows fread(...).
The two tools are complementary. strace is more fundamental and reliable; ltrace is useful when you want higher-level function names rather than raw syscalls.
Installation
|
|
Permissions
strace uses the ptrace syscall to attach to processes. You need either:
- Root (
sudo) - The same UID as the target process
CAP_SYS_PTRACEcapability
In containers, ptrace is often restricted by the seccomp profile. You may need --cap-add=SYS_PTRACE in Docker, or securityContext.capabilities.add: ["SYS_PTRACE"] in Kubernetes.
Basic Usage
Trace a New Process
|
|
This runs ls /tmp under strace and prints every syscall to stderr. The output is dense — ls makes around 70 syscalls to list a directory.
Attach to a Running Process
|
|
Press Ctrl+C to detach. The target process continues running.
Trace and Save Output
|
|
Reading the Output
A typical strace line looks like this:
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
The format is: syscall_name(arg1, arg2, ...) = return_value
openat— the syscall nameAT_FDCWD— first argument: current working directory (a constant)"/etc/ld.so.cache"— second argument: the file pathO_RDONLY|O_CLOEXEC— third argument: flags (decoded from integers to human-readable names)= 3— return value: file descriptor number 3 was opened
Error returns look like this:
openat(AT_FDCWD, "/etc/missing.conf", O_RDONLY) = -1 ENOENT (No such file or directory)
The -1 is the error return value, ENOENT is the errno constant, and the parenthetical is the human-readable description.
Signal Notation
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=7823, si_uid=1000, si_status=0} ---
Signals appear between --- markers, showing the signal name and metadata.
Process Exit
+++ exited with 0 +++
Essential Flags
-e trace= — Filter by Syscall or Category
This is the most important flag. Without filtering, the output is overwhelming.
|
|
Syscall categories:
| Category | What it covers |
|---|---|
file |
open, stat, access, rename, unlink, chmod… |
network |
socket, connect, bind, listen, accept, send, recv… |
process |
fork, clone, exec, wait, exit, kill… |
memory |
mmap, mprotect, brk, munmap… |
signal |
signal, sigaction, kill, pause… |
ipc |
pipe, msgget, semget, shmget… |
desc |
read, write, close, dup, select, poll… |
-f — Follow Forks
Trace child processes too. Essential for daemons, shells, and anything that spawns subprocesses.
|
|
With -f, each line is prefixed with the PID:
1234 execve("/bin/echo", ["echo", "hello"], ...) = 0
1235 execve("/bin/grep", ["grep", "hello"], ...) = 0
-ff -o — One File Per Process
When tracing many processes, write each PID’s trace to a separate file:
|
|
-t, -tt, -ttt — Timestamps
|
|
-T — Time Per Syscall
Shows how long each syscall took in seconds:
|
|
-c — Summary Statistics
Instead of printing each syscall, print a summary table at the end:
|
|
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
34.21 0.000285 14 20 mmap
18.44 0.000154 11 14 read
12.33 0.000103 51 2 getdents64
9.87 0.000082 8 10 openat
...
------ ----------- ----------- --------- --------- ----------------
100.00 0.000834 96 4 total
Combine with -e to profile specific categories:
|
|
-s — String Length
By default, strace truncates strings at 32 characters. Increase this to see full paths, data, etc.:
|
|
-y — Decode File Descriptors
Annotates file descriptor numbers with their actual paths:
|
|
Without -y you’d just see read(3, ...) and have to infer what fd 3 is.
-P — Filter by Path
Only show syscalls that touch a specific path:
|
|
Real Debugging Scenarios
“Why is this program slow to start?”
|
|
Look for:
- Repeated
openatcalls to paths that don’t exist (ENOENT) statcalls on missing paths- Large gaps in timestamps (
-tthelps spot these)
A common culprit is DNS lookup misconfiguration:
|
|
“Why can’t this program read its config file?”
|
|
This immediately shows every file the program tried to open and whether it succeeded. You’ll see exactly which path it’s looking in:
openat(AT_FDCWD, "/etc/myapp/config.yaml", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/usr/local/etc/myapp.yaml", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/home/user/.config/myapp.yaml", O_RDONLY) = 5
Now you know where the program is looking and where it actually found something.
“Why is this program failing with a permission error?”
|
|
openat(AT_FDCWD, "/var/run/myapp.pid", O_WRONLY|O_CREAT, 0644) = -1 EACCES (Permission denied)
Instant answer: it’s trying to write /var/run/myapp.pid and doesn’t have permission.
“What network connections is this program making?”
|
|
connect(4, {sa_family=AF_INET, sin_port=htons(443), sin_addr=inet_addr("93.184.216.34")}, 16) = 0
connect(5, {sa_family=AF_INET, sin_port=htons(6379), sin_addr=inet_addr("10.0.1.5")}, 16) = 0
Shows exactly which IPs and ports the process is connecting to.
“Why does this program crash intermittently?”
Run it under strace, saving full output, and look at the syscalls just before the crash:
|
|
Look for the signal that killed it:
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=0x10} ---
+++ killed by SIGSEGV (core dumped) +++
si_addr=0x10 (a near-null pointer) tells you it’s a null pointer dereference.
“Is this program really exiting cleanly?”
|
|
“What is this binary doing? I don’t have source.”
|
|
“Why is my container failing to start?”
|
|
Profiling with -c — Finding Slow Syscalls
The -c summary is excellent for finding performance bottlenecks:
|
|
Common findings:
- High
futextime — thread contention, lock congestion - High
read/writecount with tiny sizes — unbuffered I/O, should be larger reads - High
stat/lstatcalls — excessive filesystem metadata queries (common in build tools) - High
poll/epoll_waittime — waiting on I/O (normal for servers, investigate if unexpected) - Many
mmap/munmapcalls — memory allocation churn, possible allocator issue
|
|
ltrace — Library Call Tracing
ltrace intercepts calls to shared library functions. It operates at a higher level than strace — you see malloc, fopen, strcmp instead of brk, openat, read.
|
|
Sample ltrace Output
fopen("/etc/hosts", "r") = 0x55a3b2c1d020
fgets(0x7ffd2341a0e0, 1024, 0x55a3b2c1d020) = 0x7ffd2341a0e0
strcmp("127.0.0.1", "10.0.0.1") = 1
fgets(0x7ffd2341a0e0, 1024, 0x55a3b2c1d020) = 0x7ffd2341a0e0
strcmp("::1", "10.0.0.1") = -1
fclose(0x55a3b2c1d020) = 0
You can see the program opening /etc/hosts, reading lines, and comparing them — much more readable than the equivalent strace output.
When to Use ltrace vs strace
Use strace when:
- You need to see exactly what the kernel is doing
- You’re debugging permission errors, file not found, network issues
- The program is statically linked
- You need reliability (ltrace has more edge cases)
Use ltrace when:
- You want higher-level function names
- You’re debugging library interactions
- You want to see string operations, memory allocations
- strace output is too low-level to be useful
ltrace Limitations
- Doesn’t work well with statically linked binaries (no shared library calls to intercept)
- Can miss calls that go directly to syscalls via inline assembly
- More fragile than strace with some complex programs
- Not available in all container environments
Advanced Techniques
Tracing Without Stopping a Process (-p with signals)
When you attach with -p, the process is temporarily stopped (SIGSTOP) and then resumed. For latency-sensitive production processes, this pause matters. Consider:
|
|
Filtering Complex Expressions
|
|
Counting Unique File Paths Accessed
|
|
Watch for a Specific Event and Alert
|
|
Reconstructing What a Program Wrote
|
|
Tracing exec Chains (What Does This Script Actually Run?)
|
|
This is invaluable for auditing what CI scripts actually run.
Comparing Two Runs
|
|
Key Syscalls to Know
You’ll see these constantly. Knowing what they do makes output readable at a glance.
File I/O
| Syscall | What it does |
|---|---|
openat(dirfd, path, flags) |
Open a file, return fd |
read(fd, buf, count) |
Read bytes from fd |
write(fd, buf, count) |
Write bytes to fd |
close(fd) |
Close file descriptor |
stat(path, buf) |
Get file metadata |
lstat(path, buf) |
stat but don’t follow symlinks |
fstat(fd, buf) |
stat an open fd |
access(path, mode) |
Check file permissions |
getdents64(fd, buf, count) |
Read directory entries |
unlinkat(dirfd, path, flags) |
Delete a file |
renameat2(...) |
Rename a file |
Process
| Syscall | What it does |
|---|---|
execve(path, argv, envp) |
Replace process with new program |
clone(flags, ...) |
Create new process/thread |
fork() |
Duplicate current process |
wait4(pid, ...) |
Wait for child to exit |
exit_group(code) |
Exit all threads |
getpid() / getuid() |
Get process/user ID |
Network
| Syscall | What it does |
|---|---|
socket(family, type, proto) |
Create a socket |
connect(fd, addr, addrlen) |
Connect to remote addr |
bind(fd, addr, addrlen) |
Bind socket to local addr |
listen(fd, backlog) |
Start accepting connections |
accept4(fd, addr, ...) |
Accept an incoming connection |
sendto(fd, buf, ...) |
Send data |
recvfrom(fd, buf, ...) |
Receive data |
setsockopt(fd, ...) |
Set socket option |
Memory
| Syscall | What it does |
|---|---|
mmap(addr, len, prot, flags, fd, off) |
Map memory |
munmap(addr, len) |
Unmap memory |
mprotect(addr, len, prot) |
Change memory protections |
brk(addr) |
Extend heap |
Synchronization
| Syscall | What it does |
|---|---|
futex(addr, op, val, ...) |
Fast userspace mutex — the basis of all locking |
poll(fds, nfds, timeout) |
Wait for events on fds |
epoll_wait(epfd, events, ...) |
Efficient event polling |
select(nfds, ...) |
Classic fd multiplexing |
errno Values Quick Reference
These appear constantly in error returns:
| errno | Meaning |
|---|---|
ENOENT |
No such file or directory |
EACCES |
Permission denied |
EPERM |
Operation not permitted (usually capability-related) |
EEXIST |
File already exists |
ENOTEMPTY |
Directory not empty |
EAGAIN / EWOULDBLOCK |
Resource temporarily unavailable (non-blocking I/O) |
EINTR |
System call interrupted by signal |
EINVAL |
Invalid argument |
ENOMEM |
Out of memory |
EBADF |
Bad file descriptor |
EPIPE |
Broken pipe |
ECONNREFUSED |
Connection refused |
ETIMEDOUT |
Connection timed out |
EADDRINUSE |
Address already in use |
Production Safety Notes
- Performance impact:
straceadds measurable overhead — typically 2–20x slowdown due to the stop/inspect/resume cycle for every syscall. Don’t leave it running in production without a timeout. - Use
-cfor profiling in production: The summary mode (-c) has lower overhead than per-syscall output because there’s no formatting cost per call. - Detach carefully: When you
Ctrl+Cout ofstrace -p, the target process is resumed and continues normally. The trace stops cleanly. - Sensitive data in output:
stracewill capture passwords, tokens, and keys if the program writes them to files or network sockets. Handle trace files as sensitive material. - Containers: Most container runtimes restrict
ptraceby default. You need--cap-add=SYS_PTRACEor a permissive seccomp profile. On Kubernetes, addSYS_PTRACEto the pod’s security context.
Practical Cheat Sheet
|
|
strace is one of those tools where the first real debugging win — “oh, it’s looking for the config file in /etc/app/ but I put it in /etc/myapp/” — makes everything click. From there it becomes instinctive: something isn’t working, reach for strace, know in thirty seconds what’s happening.
Comments