if your rust’s unsafe, and it don’t look good, who ya gonna call?

tl;dr: Valgrind!


As a language, Rust puts memory safety at its heart, in addition to trying to be a more accessible and beginner-friendly route into low level systems development, and for most uses, it lives up to that. The borrow-checker ensures that there are no nasty dangling pointers or badly used variables to cause segfaults, and all is well… right? Right?

Mostly, yes. You can write a lot of great things in Rust, and you don’t need to venture into the old, bad ways of doing things, except if like me, you like to write things like game engines from scratch, or graphics demos. These require use of the dreaded unsafe keyword, which has the power to bring back all of the memory leaks, segfaults and everything that Rust stands against.

In actual practice, if you’re writing vast amounts of code with unsafe { } around it, you’re doing it wrong. Most of Rust works perfectly fine without it, but there are times that you do have to use it. The way that it usually crops up for me is if I’m using OpenGL or Vulkan – these APIs require low-level access to hardware and memory (Vulkan especially) with minimal wrappers around them, so there are sections of code that unsafe { } crops up in, but nowadays I usually sweep it into a renderer struct and try and keep it far away from actual application code.

But still, it means that I do get my fair share of plain weird stuff happening, and that’s where Valgrind and its toolset comes in.

Valgrind and Memcheck

Most C/C++ developers who’ve worked with Linux will know what Valgrind is. For those who haven’t worked with it, Valgrind is a popular Linux/UNIX framework for profiling, memory leak detection and debugging. Over the years, I’ve used it to track down the origins of coredumps in buggy C++ programs and by now, it’s an old friend.

One particularly useful tool in the Valgrind suite is Memcheck. Memcheck checks every single instruction in your program that does a memory operation and checks if it violates the following rules:

  • No accessing memory that has not been allocated or has been freed.
  • No reading uninitialized memory.
  • No writing to read-only memory.
  • No overlapping source and destination in memcpy() and related functions.
  • No leaking memory by losing pointers to allocated blocks.

If it detects any violation of these rules, it makes an error report of the offending instruction and its stack trace. If you’re using unsafe in, say, games development, this can prove very useful. If you’re doing weird pointer arithmetic or other stuff as well, it can help.

Installing Valgrind

Valgrind has been primarily developed for use on Linux, where it can usually be found in your package manager. On Ubuntu-based OSes such as Pop!_OS, snap seems to be the more reliable.

For *BSD users, Valgrind can similarly be found in your package repo. Valgrind has also been ported to Mac as well via brew, and Windows users can access it in a similar manner to Linux users via WSL. You can build it from source as well, but one of the above should suffice and be sufficiently painfree.

unsafe: Rust without the safety net!

To demonstrate how we can use Valgrind to fix a problem with a Rust program, consider this simple (and memory leaky) Rust program:

use std::ffi::CString;
use std::os::raw::c_char;

extern "C" {
    fn puts(s: *const c_char);
}

fn main() {
    let string = CString::new("This is my text string!").unwrap();

    let ptr = string.into_raw();
    unsafe { puts(ptr) };
}

This program is using an external C function, puts(), that prints a string to the standard output. We’ve wrapped it in unsafe as it could violate Rust’s memory and type safety guarantees, and generally trusts that we know what we’re doing, requiring that we manually verify it. This is where I’ve come a cropper with both Vulkan and OpenGL before – sometimes I’ve done something that’s caused a memory leak, or something’s poked into memory it shouldn’t and the program core dumps with a segfault.

In this case, compiling and running it gives us this:

(base) gaz@gojira:~/Desktop/scratch/Rust/leaky$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/leaky`
This is my text string!

All looks well, but should we trust it? Let’s see…

Rust with Valgrind: method 1

Let’s see what we get when we run Valgrind on our executable:

(base) gaz@gojira:~/Desktop/scratch/Rust/leaky$ valgrind target/debug/leaky
==32910== Memcheck, a memory error detector
==32910== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
==32910== Using Valgrind-3.21.0 and LibVEX; rerun with -h for copyright info
==32910== Command: target/debug/leaky
==32910== 
This is my text string!
==32910== 
==32910== HEAP SUMMARY:
==32910==     in use at exit: 24 bytes in 1 blocks
==32910==   total heap usage: 12 allocs, 11 frees, 3,205 bytes allocated
==32910== 
==32910== LEAK SUMMARY:
==32910==    definitely lost: 24 bytes in 1 blocks
==32910==    indirectly lost: 0 bytes in 0 blocks
==32910==      possibly lost: 0 bytes in 0 blocks
==32910==    still reachable: 0 bytes in 0 blocks
==32910==         suppressed: 0 bytes in 0 blocks
==32910== Rerun with --leak-check=full to see details of leaked memory
==32910== 
==32910== For lists of detected and suppressed errors, rerun with: -s
==32910== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

We can also pass additional options to Valgrind using --, which we’ll try now with --leak-check=full and --show-leak-kinds=all, which runs it with full memory checking:

(base) gaz@gojira:~/Desktop/scratch/Rust/leaky$ valgrind --leak-check=full --show-leak-kinds=all target/debug/leaky
==33516== Memcheck, a memory error detector
==33516== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
==33516== Using Valgrind-3.21.0 and LibVEX; rerun with -h for copyright info
==33516== Command: target/debug/leaky
==33516== 
This is my text string!
==33516== 
==33516== HEAP SUMMARY:
==33516==     in use at exit: 24 bytes in 1 blocks
==33516==   total heap usage: 12 allocs, 11 frees, 3,205 bytes allocated
==33516== 
==33516== 24 bytes in 1 blocks are definitely lost in loss record 1 of 1
==33516==    at 0x4E050B5: malloc (vg_replace_malloc.c:431)
==33516==    by 0x14A624: alloc (alloc.rs:102)
==33516==    by 0x14A624: alloc_impl (alloc.rs:185)
==33516==    by 0x14A624: allocate (alloc.rs:245)
==33516==    by 0x14A624: allocate_in<u8, alloc::alloc::Global> (raw_vec.rs:184)
==33516==    by 0x14A624: with_capacity_in<u8, alloc::alloc::Global> (raw_vec.rs:130)
==33516==    by 0x14A624: with_capacity_in<u8, alloc::alloc::Global> (mod.rs:670)
==33516==    by 0x14A624: with_capacity<u8> (mod.rs:479)
==33516==    by 0x14A624: spec_new_impl_bytes (c_str.rs:287)
==33516==    by 0x14A624: <&str as alloc::ffi::c_str::CString::new::SpecNewImpl>::spec_new_impl (c_str.rs:306)
==33516==    by 0x11076A: alloc::ffi::c_str::CString::new (c_str.rs:316)
==33516==    by 0x110BD7: leaky::main (main.rs:9)
==33516==    by 0x11060A: core::ops::function::FnOnce::call_once (function.rs:250)
==33516==    by 0x1109BD: std::sys_common::backtrace::__rust_begin_short_backtrace (backtrace.rs:135)
==33516==    by 0x110AC0: std::rt::lang_start::{{closure}} (rt.rs:166)
==33516==    by 0x127EDA: call_once<(), (dyn core::ops::function::Fn<(), Output=i32> + core::marker::Sync + core::panic::unwind_safe::RefUnwindSafe)> (function.rs:284)
==33516==    by 0x127EDA: do_call<&(dyn core::ops::function::Fn<(), Output=i32> + core::marker::Sync + core::panic::unwind_safe::RefUnwindSafe), i32> (panicking.rs:500)
==33516==    by 0x127EDA: try<i32, &(dyn core::ops::function::Fn<(), Output=i32> + core::marker::Sync + core::panic::unwind_safe::RefUnwindSafe)> (panicking.rs:464)
==33516==    by 0x127EDA: catch_unwind<&(dyn core::ops::function::Fn<(), Output=i32> + core::marker::Sync + core::panic::unwind_safe::RefUnwindSafe), i32> (panic.rs:142)
==33516==    by 0x127EDA: {closure#2} (rt.rs:148)
==33516==    by 0x127EDA: do_call<std::rt::lang_start_internal::{closure_env#2}, isize> (panicking.rs:500)
==33516==    by 0x127EDA: try<isize, std::rt::lang_start_internal::{closure_env#2}> (panicking.rs:464)
==33516==    by 0x127EDA: catch_unwind<std::rt::lang_start_internal::{closure_env#2}, isize> (panic.rs:142)
==33516==    by 0x127EDA: std::rt::lang_start_internal (rt.rs:148)
==33516==    by 0x110A99: std::rt::lang_start (rt.rs:165)
==33516==    by 0x110C2D: main (in /home/gaz/Desktop/scratch/Rust/leaky/target/debug/leaky)
==33516== 
==33516== LEAK SUMMARY:
==33516==    definitely lost: 24 bytes in 1 blocks
==33516==    indirectly lost: 0 bytes in 0 blocks
==33516==      possibly lost: 0 bytes in 0 blocks
==33516==    still reachable: 0 bytes in 0 blocks
==33516==         suppressed: 0 bytes in 0 blocks
==33516== 
==33516== For lists of detected and suppressed errors, rerun with: -s
==33516== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

So there is indeed a memory leak, and apparently it’s in main. Bummer. Valgrind’s formatting kinda sucks too, and reading it isn’t the easiest. What if there’s a better way?

Rust with Valgrind 2: the better way

Here’s where Rust’s strength – tooling – comes into play. If we run cargo install cargo-valgrind, it installs the cargo-valgrind crate which is a plugin which allows us to use Cargo to run our program through Valgrind instead, instead typing

cargo valgrind run

which is far more succint, as I’m sure you’ll agree. If we run it with -- after it, we can run it with the flags we used before as well. Let’s try it and see:

(base) gaz@gojira:~/Desktop/scratch/Rust/leaky$ cargo valgrind run -- --leak-check=full --show-leak-kinds=all
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `/home/gaz/.cargo/bin/cargo-valgrind target/debug/leaky --leak-check=full --show-leak-kinds=all`
This is my text string!
       Error leaked 24 B in 1 block
        Info at malloc (vg_replace_malloc.c:431)
             at alloc (alloc.rs:102)
             at alloc_impl (alloc.rs:185)
             at allocate (alloc.rs:245)
             at allocate_in<u8, alloc::alloc::Global> (raw_vec.rs:184)
             at with_capacity_in<u8, alloc::alloc::Global> (raw_vec.rs:130)
             at with_capacity_in<u8, alloc::alloc::Global> (mod.rs:670)
             at with_capacity<u8> (mod.rs:479)
             at spec_new_impl_bytes (c_str.rs:287)
             at <&str as alloc::ffi::c_str::CString::new::SpecNewImpl>::spec_new_impl (c_str.rs:306)
             at alloc::ffi::c_str::CString::new (c_str.rs:316)
             at leaky::main (main.rs:9)
             at core::ops::function::FnOnce::call_once (function.rs:250)
             at std::sys_common::backtrace::__rust_begin_short_backtrace (backtrace.rs:135)
             at std::rt::lang_start::{{closure}} (rt.rs:166)
             at call_once<(), (dyn core::ops::function::Fn<(), Output=i32> + core::marker::Sync + core::panic::unwind_safe::RefUnwindSafe)> (function.rs:284)
             at do_call<&(dyn core::ops::function::Fn<(), Output=i32> + core::marker::Sync + core::panic::unwind_safe::RefUnwindSafe), i32> (panicking.rs:500)
             at try<i32, &(dyn core::ops::function::Fn<(), Output=i32> + core::marker::Sync + core::panic::unwind_safe::RefUnwindSafe)> (panicking.rs:464)
             at catch_unwind<&(dyn core::ops::function::Fn<(), Output=i32> + core::marker::Sync + core::panic::unwind_safe::RefUnwindSafe), i32> (panic.rs:142)
             at {closure#2} (rt.rs:148)
             at do_call<std::rt::lang_start_internal::{closure_env#2}, isize> (panicking.rs:500)
             at try<isize, std::rt::lang_start_internal::{closure_env#2}> (panicking.rs:464)
             at catch_unwind<std::rt::lang_start_internal::{closure_env#2}, isize> (panic.rs:142)
             at std::rt::lang_start_internal (rt.rs:148)
             at std::rt::lang_start (rt.rs:165)
             at main
     Summary Leaked 24 B total

So cargo-valgrind has done several things for us. It’s made it easier to run Valgrind as it already knows what to run, and it’s also shorn it of the shitty formatting of bareback Valgrind and the various hexadecimal codes, giving us a much easier to read stacktrace.

Looking at our stacktrace, we see:

at leaky::main (main.rs:9)

which is telling us that line 9 of our code is the problem. Looking back at our code and near line 9, we can see what the issue is – we didn’t free the pointer returned by into_raw and caused a memory leak. Let’s solve that by using the from_raw method of CString:

use std::ffi::CString;
use std::os::raw::c_char;

extern "C" {
    fn puts(s: *const c_char);
}

fn main() {
    let string = CString::new("This is my text string!").unwrap();

    let ptr = string.into_raw();
    unsafe { puts(ptr) };

    // take back ownership and free memory
    let _ = unsafe { CString::from_raw(ptr) };
}

Let’s see what happens when we use cargo valgrind run to compile and check it again:

(base) gaz@gojira:~/Desktop/scratch/Rust/leaky$ cargo valgrind run -- --leak-check=full --show-leak-kinds=all
   Compiling leaky v0.1.0 (/home/gaz/Desktop/scratch/Rust/leaky)
    Finished dev [unoptimized + debuginfo] target(s) in 0.16s
     Running `/home/gaz/.cargo/bin/cargo-valgrind target/debug/leaky --leak-check=full --show-leak-kinds=all`
This is my text string!

This time we see no error messages, and it just prints out the message. Leak plugged!

In reality, of course, the code will nearly always be more complex than this, and sometimes things can get leaky due to factors outside of your control (e.g. graphics drivers leaking when calling OpenGL) but still – Valgrind can help you out immensely when you suspect memory leaks are occurring in your unsafe Rust code.

A slight caveat emptor: if you’re running Valgrind on a graphical program (like I do of an occasion), it may run slowly. The CPU overhead is higher somewhat as it has Valgrind attached carrying out analysis. Also if GPUs are involved, Valgrind might not help, the issue could well be in the GPU memory rather than that of the host. (You might want to try Renderdoc, Nsight or Radeon Tool Suite there!)

But I digress – this was intended to be an intro to using Valgrind with Rust’s tooling to debug and solve a simple memory leak. Hopefully I succeeded, and showed you how to get Rust and Valgrind to play nice!

Leave a comment