Identifying memory leaks in compiled C, C++, or Objective-C applications on macOS is a significantly more complex endeavour than debugging managed languages like Python or Java. Because macOS applications are compiled into the native Mach-O (Mach Object) binary format, they interact directly with the operating system’s memory allocator (malloc). When an application requests memory but fails to release it via free(), standard user-space monitoring tools like Activity Monitor will only show a gradual increase in memory pressure. To pinpoint the exact line of code responsible for the leak in a running production binary, systems programmers must utilize dtrace, the advanced dynamic tracing framework built into the macOS kernel.
The Power of DTrace Instruments
DTrace is an incredibly powerful kernel-level instrumentation framework originally developed by Sun Microsystems and deeply integrated into the macOS XNU kernel. It allows administrators and developers to attach “probes” to thousands of specific kernel and user-space events in real-time, without requiring the application to be paused, recompiled, or restarted.
While Apple provides the graphical “Instruments” application as part of Xcode, the underlying command-line dtrace utility (and its associated wrapper scripts) offers the raw, unfiltered power required for headless server diagnostics or heavily automated profiling.
Disabling System Integrity Protection (SIP)
Because DTrace requires injecting probes directly into the memory space of running processes, macOS System Integrity Protection (SIP) severely restricts its capabilities. By default, DTrace cannot attach to Apple-signed system binaries (like Safari or Finder) or any third-party application protected by SIP.
If you are debugging your own unsigned Mach-O binaries, DTrace will function normally. However, to profile system-level applications, you must boot the Mac into macOS Recovery, open the Terminal, and partially disable SIP specifically for DTrace debugging by executing csrutil enable --without dtrace, followed by a reboot. Note: This drastically lowers system security and should only be performed on isolated development machines.
Tracing Malloc Calls with DTrace
To identify a leak, you must correlate every malloc() call with its corresponding free() call. If a memory address is returned by malloc but never passed to free, it is leaking.
You can write a custom D script to trace these specific function boundaries. Create a file named leak_tracer.d:
pid$target::malloc:entry
{
/* Store the requested size */
self->size = arg0;
}
pid$target::malloc:return
/self->size/
{
/* When malloc returns, record the allocated memory address and the stack trace */
allocations[arg1] = self->size;
stacks[arg1] = ustack();
self->size = 0;
}
pid$target::free:entry
/arg0 != 0/
{
/* When free is called, remove the address from our tracking arrays */
allocations[arg0] = 0;
stacks[arg0] = 0;
}
END
{
/* When the script terminates, print out any addresses that were never freed */
printf("Unfreed Memory Allocations:\n");
printa("Address: %x | Size: %@d bytes \n%@k\n", allocations, stacks);
}
Executing the Trace
To execute the trace against a running application, you must identify its Process ID (PID). Once you have the PID (e.g., 4512), invoke DTrace with elevated privileges, passing the script and the target PID:
sudo dtrace -s leak_tracer.d -p 4512
The script will silently attach to the application and begin intercepting every memory allocation. Allow the application to run, ideally triggering the workflow you suspect is causing the leak. When you are ready to view the results, press Ctrl+C in the terminal to terminate the DTrace session.
The END block of the D script will instantly execute. It will parse the internal tracking arrays and output a list of every memory address that was allocated but never freed, alongside the exact number of bytes leaked, and crucially, the complete user-space stack trace (ustack()) showing the exact C or Objective-C function that requested the memory. This provides developers with the precise location in the source code required to implement a fix.