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!