[RFC][libc] Enabling Code Coverage in LLVM Libc

Hi everyone,

I am an intern at Google, and I have been tasked with enabling code coverage for LLVM libc.

The Problem
Right now, there is no standard, automated way to see which parts of the libc codebase are actually being hit by the test suite and which parts are slipping through the cracks. I am aware that Clang’s native source-based coverage tools and advanced features like MC/DC (Modified Condition/Decision Coverage) already exist in the wider LLVM ecosystem. However, the official documentation (https://libc.llvm.org/) does not currently cover how to enable or run code coverage specifically for the C library itself.

Normally, getting code coverage is as simple as turning on the compiler’s default profiler. However, trying to run standard coverage tools directly on the C library introduces a circular dependency. Clang’s default profile runtime (libclang_rt.profile) relies on hosted C library file I/O operations (fopen, fwrite) and memory allocations (malloc) to dump the .profraw coverage data.

The Circular Dependency Problem
Clang’s default profile runtime (libclang_rt.profile) relies on hosted C library file I/O operations (fopen, fwrite) and memory allocations (malloc) to dump the .profraw coverage data.

Because the standard coverage runtime assumes these host OS features are already available, using it in our freestanding test environment creates a circular dependency. We simply cannot use a tool that requires malloc and fopen to measure the internal implementations of malloc and fopen. If we try, the test runner fails to link or execute properly in freestanding environments.

The Goal & Impact
Because of this circular dependency, enabling coverage requires a logical sequence of steps to ensure the data is extracted safely and the implementation lands properly without disrupting the existing test infrastructure.

The main hurdle is extracting standard LLVM coverage data from the freestanding environment. By cleanly sidestepping the usual I/O dependencies, this approach unlocks Clang’s native instrumentation right out of the box. Once this is merged, developers will be able to enable coverage simply by configuring CMake with a dedicated flag:

cmake ../llvm -DLIBC_CONF_ENABLE_COVERAGE=ON

If successful, this gives developers clear visibility into general execution paths. It will allow the community to systematically catch untested logic, identify blind spots, and ultimately ensure the robustness of LLVM libc’s core primitives.

My Proof of Concept (Linux Prototype)
To circumvent the default profiler and avoid this circular dependency, I prototyped an OS-specific extraction routine that bypasses standard libc dependencies entirely. By operating purely on raw system calls at the absolute lowest level, we can cleanly sidestep the usual I/O and memory dependencies.

You can view the branch containing the prototype on GitHub here:

(Note: To manage the scope of the initial patch and establish a stable baseline, this proposal and prototype target Linux. Once the core CMake wiring, compiler flags, and build automation are landed and validated, a follow-up patch will abstract the backend to support bare-metal and GPU targets).

To support coverage without the circular dependency, I prototyped an OS-specific extraction routine that is explicitly called at the end of main() inside` LibcTestMain.cpp`, bypassing standard libc dependencies.

First, I silence the compiler’s default atexit dumper to prevent it from calling fopen:

extern "C" {

  // Override compiler-rt's weak filename symbol to prevent stdio execution

  char __llvm_profile_filename[] = "/dev/null";

}

With the default runtime disabled, the custom hook implements three changes to extract the profile data on Linux:

1.Dynamic Memory Allocation (Bypassing malloc)
Extracting compiler-rt coverage data into a static buffer fails because the required profile size scales with the instrumented code. Arbitrary fixed limits like 1MB quickly lead to truncation, and hard-coding a massively larger buffer would unacceptably bloat the binary. Instead, the hook bypasses malloc and requests the exact size from the kernel utilizing LLVM libc’s internal OS abstraction linux_syscalls::mmap, which returns an ErrorOr type:

uint64_t required_size = __llvm_profile_get_size_for_buffer();

// Dynamically allocate the exact required size utilizing libc's internal abstractions

auto mmap_or_error = LIBC_NAMESPACE::linux_syscalls::mmap( nullptr, required_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

if (!mmap_or_error)
return; // Silent abort, or hermetically write error to stderr

char *profile_buffer = reinterpret_cast<char *>(mmap_or_error.value()); __llvm_profile_write_buffer(profile_buffer);

Once the data is successfully written to disk, the hook cleans up the allocation via linux_syscalls::munmap to ensure clean process termination and avoid tripping memory sanitizers.

I fully recognise a limitation of this Linux-specific approach is its reliance on mmap, which locks us out of bare-metal testing. It is possible to use a generic memory API to solve this, but in my current prototype, I just used mmap to prove the core concept worked without circular dependencies.

2.Concurrency Safety
When unit tests are executed in parallel, multiple test binaries attempt to dump their coverage data to default.profraw simultaneously, resulting in file corruption. To prevent data loss, we inject the Process ID (SYS_getpid) into the output filename. Because PIDs are inherently unique per test process, we use LLVM libc’s official IntegerToString utility alongside a bounds-checked FixedSizeBuffer to safely construct the filename without relying on standard library memory allocations or risking buffer overflows:

long pid = LIBC_NAMESPACE::syscall_impl<long>(SYS_getpid);

if (pid <= 0) pid = 1;

// FixedSizeBuffer prevents filename buffer overflows without using malloc

FixedSizeBuffer filename; LIBC_NAMESPACE::IntegerToString<long> pid_str(pid);

filename.append("libc_cov_"); 
filename.append(pid_str.view()); 
filename.append(".profraw");

3.Hermetic I/O
Finally, the hook writes the memory to disk using the linux_syscalls::open abstraction.

While LLVM libc already possesses an OS-agnostic internal file class (libc/src/__support/File/file.h), using it for coverage extraction in this initial patch introduces a potential re-entrancy risk. file.h consists of instrumented C++ that manages internal state and locks. If we use it to dump the coverage data, we risk mutating the coverage counters during the dump itself, or deadlocking if a test crashes while holding an internal file lock.

By bypassing __support entirely and routing directly to the absolute bottom-layer linux_syscalls (which compile down to raw inline assembly), the extraction hook operates safely beneath the compiler’s instrumentation layer. It is guaranteed to be lock-free and invisible to the coverage counters.

We wrap the write operation in a strict EINTR retry loop using linux_syscalls::write to ensure large coverage files are not truncated by system interrupts:

auto fd_or_error = LIBC_NAMESPACE::linux_syscalls::open( filename.data, O_WRONLY | O_CREAT | O_TRUNC, 0644);

if (!fd_or_error)
return;

int fd = fd_or_error.value();
uint64_t bytes_written = 0;

// Strict loop to safely handle EINTR and partial writes hermetically

while (bytes_written < required_size) {
auto write_or_error = LIBC_NAMESPACE::linux_syscalls::write(
fd, profile_buffer + bytes_written, required_size - bytes_written);

if (!write_or_error) {
if (write_or_error.error() == EINTR) continue;
break;
}
bytes_written += write_or_error.value();
}

Alternatives and Future Work Considered
I did consider a few alternative approaches to make sure I wasn’t overcomplicating things. I am also very forthcoming about the limitations of my current Linux-only prototype and have been looking into how to adapt this architecture for bare-metal targets in the future based on early feedback.

The obvious tradeoff with this initial approach is that relying on direct system calls locks the coverage tooling to Linux, leaving a gap for all other non-Linux environments. Trying to build out a fully generalised back-end across all target environments right now would massively blow up the scope of a first patch. By keeping this initial iteration scoped specifically to Linux, I can get the core CMake wiring, compiler flags, and build automation landed and validated right away. This gets baseline coverage reporting working for the project now, making sure the underlying infrastructure is stable before swapping the back-end over to more generalised solutions.

Transitioning to the OS Generic Layer
Based on early feedback, this is the architectural direction I am actively thinking about and considering for the immediate follow-up. To support bare-metal and other environments natively, the extraction logic will need to move up to the OS generic layer. That means replacing direct host system calls with cross-platform equivalents.

For file I/O, operations can be routed through the internal file class (libc/src/__support/File/file.h) to keep them hermetic but portable. For concurrency management, a target-agnostic PID fetcher can be abstracted into the OSUtil APIs. And for dynamic memory allocation, the reliance on the Linux mmap syscall would need to be abstracted behind a generic OS memory interface. This path keeps the extraction logic contained entirely within native C++ abstractions.

A Freestanding Base64 Stream (Explored Alternative):
Before leaning toward the OS generic roadmap, I also explored a completely different route to bypass the file system entirely: A zero-allocation Base64 encoder that operates directly on the raw coverage buffer. Instead of writing files, the payload is streamed to standard output (fd = 1) surrounded by [LLVM_COV_START] and [LLVM_COV_END] markers. This removes all dependencies on the C standard library and the host filesystem. I’d then use a Python script to wrap the test execution, parse stdout, decode the stream, and write the .profraw file on the host.

This approach can handle bare-metal and GPU environments. However, it has its limitations. Injecting a Python test wrapper into all of our CMake test targets (like add_libc_hermetic) feels quite invasive compared to native C++ syscalls. We also have to be careful about managing state across tests that fork or execute multiple processes.

Because of those limitations, the Linux direct-syscalls approach detailed above is what I have managed to fully implement and get working so far for the full build.

Open Questions / Feedback
I’ve done my best to ensure that there’s nothing different if you’re not doing a coverage build. I am requesting feedback from the maintainers on this design before submitting a formal patch.
A couple of specific questions I had:

Modular Platform Hooks: Should I isolate this Linux-specific extraction logic into a distinct OS-specific boundary right now (to prepare for future RPC/Semihosting implementations)? Or, for this first patch, is it preferable to jump straight to routing it through LLVM libc’s internal OSUtil abstractions?

Memory Allocation: Is linux_syscalls::mmap the preferred method to bypass standard memory allocation in libc tests on Linux, or is there an existing freestanding allocator within the project that I should be using?

Finally, I want to give a shoutout and thank you to @kaladron, @michaelrj-google and Petr Hosek for their contributions and guidance on this effort so far.

Thanks in advance for taking a look!

1 Like

I’m not super familiar with these topics, but I noticed a couple of things:

The first part I understand. If the coverage runtime calls malloc, that adds coverage of malloc. So a test with zero malloc calls would have malloc coverage, and a malloc specific test would appear to have more than it did.

(and at a glance, I don’t see any way you could “switch off” coverage before saving the results)

The second part about linking, what exactly goes wrong there?

(even if it could work it’s not going to be useful, but I am curious)

What do you mean “unique per test process”?

PIDs can be reused, though I don’t know how common it is. This may not be a problem if your test runner starts all the test processes at once.

This could just be my misunderstanding (I only have a very vague idea of how the profiler works), but it’s not clear to me how an instrumented fwrite is different from an instrumented linux_syscalls::write. If the problem is that the instrumentation inside fwrite causes problems (due to reentrancy or whatever), how do we ensure that does not happen in the linux_syscalls case?

The system tries not to reuse them, but you can cycle through available pids fairly quickly if you spawn many processes. Though the fix may be as simple as appending a random suffix and wrapping it in an O_EXCL loop.

Hi,

Thank you for looking into this - we are also interested in getting LLVM libc code coverage for the bare-metal builds.

In Arm Toolchain for Embedded (ATfE) we have a sample that exports the counters data using Arm semihosting, see this small runtime wrapper arm-toolchain/arm-software/embedded/samples/src/cpp-baremetal-semihosting-prof/proflib.c at arm-software · arm/arm-toolchain · GitHub

Some points I wanted to highlight:

  1. The documented way to avoid the standard proflib file IO seems to be to define __llvm_profile_runtime symbol: llvm-project/clang/docs/SourceBasedCodeCoverage.md at 355dac7c4997c891d0a917935dcccc80334f5392 · llvm/llvm-project · GitHub
  2. We use atexit to register own handler instead and avoid changing the main() function of the application under test.

I think it would be useful to have a small retargetable runtime wrapper so that we have a generic way of running coverage in CMake and can provide target platform specific hooks, e.g. Arm semihosting for bare-metal Arm targets.

Thank you David for your response. On the linking side in the freestanding setup, test binaries are built with -nostdlib to keep LLVM libc completely isolated from host OS libraries. Clang’s default profile runtime, compiler-rt, expects standard C library functions like fopen and malloc to exist out of the box. Without a host library present, the linker fails on missing symbols. If we link in LLVM libc’s own internal implementations to satisfy compiler-rt, simple unit tests accidentally trigger coverage counters for malloc and file I/O, while also risking re-entrancy deadlocks if a test crashes during execution. Running a direct extraction hook bypasses compiler-rt and operates safely beneath the C library layer.

On PID uniqueness, the prototype aimed to stop parallel test processes from overwriting default.profraw simultaneously. While PIDs are sufficient for small local runs, rapid PID recycling in high-throughput CI makes opening files with O_TRUNC risky, as a recycled PID can silently overwrite previous coverage data. Switching to an atomic creation loop using O_CREAT | O_EXCL with an incrementing suffix counter, such as libc_cov_PID_1.profraw, guarantees file uniqueness while staying completely freestanding with zero dynamic memory or external entropy dependencies.

Hi Pavel! To clarify how we guarantee the extraction path is safe, the difference comes down to compile-time counter insertion and runtime state complexity.

Clang coverage operates by inserting basic block counter increments into AST nodes during compilation. For our custom extraction hook and low-level syscall wrappers like linux_syscalls::write, we explicitly disable instrumentation by applying no_sanitize(“coverage”) attributes or excluding those specific translation units from the coverage build flags. Because the compiler is instructed not to emit counters for these functions, executing linux_syscalls::write never mutates coverage data.

From a runtime perspective, standard fwrite relies on internal FILE stream structures, stream locking, dynamic buffer management, and error state tracking. If a test binary crashes or terminates while holding an internal stdio lock, invoking fwrite during the exit dump risks re-entrancy deadlocks or state corruption. In contrast, linux_syscalls::write is a minimal wrapper that compiles directly down to inline assembly system call instructions. It operates on raw file descriptors and memory buffers without taking locks, allocating memory, or maintaining C++ state, creating a completely uninstrumented and stateless boundary beneath the C library itself.

Thanks for elaborating.

Even if you could get a non-instrumented copy linked to the profiling library, you’d have to prevent it fighting for global state with the other one. Or appearing to work but actually biasing the results silently.

Your proposal reduces that risk greatly.

By the way, do you have a way to check that the profiling library is not using a given symbol? I presume it has to use some things from the libc, the question is are those things the things under test. For the obvious things I presume one can somehow poison a symbol like malloc.

To put it in a naive way: do you eventually need a statically linked profiling library to be 100% sure it’s not using any of the libc under test?

To answer directly, a statically linked profiling library isn’t needed because the approach bypasses compiler-rt’s runtime dumping routines entirely. When Clang compiles code with coverage enabled, it inserts inline counter increments directly into basic blocks and emits raw counter data structures into dedicated memory sections, rather than inserting runtime library calls into the code logic.

Instead of relying on compiler-rt to handle profile dumping at exit using stdio and heap allocation, the proposal uses a direct extraction hook that reads those raw memory sections and writes the coverage payload out via uninstrumented system calls. Because this hook operates directly beneath the C library layer, there is no profiling runtime executing or managing state that could fight for global resources or bias test results.

To verify symbol isolation and ensure the extraction path never accidentally depends on libc functions under test, compile-time poisoning can be enforced using #pragma GCC poison for symbols like malloc, free, fopen, and fwrite in the extraction hook translation unit. Automated symbol checks can also be included in CI using tools like llvm-nm or readelf to confirm that no unexpected libc symbols are referenced by the test binary.

Thanks, I understand now. I was thinking you were replacing only a part of the profiling library and keeping the rest but that’s not the case.

1 Like

Thanks for sharing the links and pointing to __llvm_profile_runtime. Defining that symbol to disable compiler-rt’s default file I/O seems like a very clean option for avoiding default runtime dependencies at link time.

Having a retargetable wrapper structure makes a lot of sense for LLVM libc. Decoupling the counter extraction logic from the underlying I/O transport could keep the core dumper and CMake setup generic while allowing target-specific backends, like raw Linux syscalls for host runs or ARM semihosting for bare metal.

I also completely agree on keeping test main() functions untouched. In the current approach, the extraction hook is called from the test runner wrapper right after main() returns. That provides the same non-invasive benefit without needing to rely on standard atexit, which helps avoid pulling in stdlib runtime machinery or interfering if atexit itself is being tested.

1 Like

Are you sure that this is an issue? The coverage runtime has to be able to collect data for multithreaded programs, and probably code running in signal handlers. So, I would expect the format is not that fragile – particularly given that clang supports -fprofile-continuous, which says:

if ./code abruptly terminates or does not call exit(), in continuous mode the profile collected up to the point of termination will be available in default_xxxx.profraw while in the non-continuous mode, no profile file is generated.

I’m trying to figure out how much load-bearing are these no_sanitize(“coverage”) annotations, because each function we annotate is a function we don’t get coverage for. I don’t think we’ll miss coverage information for linux_syscalls::write, but if we start moving to higher level operations to gain portability, we may run into code that we’d want to have coverage information for.

Fair point, that was an oversight on my end. Counter updates are just in-memory increments, so executing instrumented code while reading counter sections won’t corrupt the profraw layout or header structures.

The original thinking was mostly to keep extraction logic out of final coverage metrics, but treating it as a data safety requirement was mistaken. Applying it to higher-level helper routines would unnecessarily hide coverage on code that should be measured.

We can safely drop or limit those annotations so that shared or portable helper functions stay fully instrumented and tracked in coverage reports.