Oxidising my keyboard: how I wrote my QMK userland in Rust

Avatar
nullptr

| 4/11/2025

Disclaimer: Some C-related terminology may be completely wrong! Iā€™ve put off learning the language for so many years, which has finally come back to bite me. Enjoy the article, and please let me know how I did ā€” this is the first one Iā€™ve ever written!

Introduction

My dad built me a mechanical keyboard for Christmas this year, namely a Sofle v2. Naturally, as a questionably skilled software engineer, I wondered how far I could push the two RP2040s that powered it. I looked into how to program keyboards, and found QMK, and more specifically, QMK Firmware, which is a free and open source solution for programming input devices. It looked perfect at first glance, but alas, itā€™s written in C. As such, youā€™re expected to write your userland code in C tooā€¦ right?

Well, yes, butā€¦

Enter Rust: a modern, compiled, general-purpose programming language that should need no introduction unless youā€™ve been living under a crab shell for the past 10 years. One of these words is very important for our purposes here ā€” compiled. Rust being a compiled language means you can trivially link it with a C program, and have it behave1 as if you wrote the code in C, which is what weā€™ll be using to put the ā€œCā€ back in ā€œcrustaceanā€.

1. there are some behavioural differences, which will (unfortunately) come up later

Plan of Attack

If you want to skip the technical details and only want to see the cool stuff Iā€™ve done with my userland, click here.

Before knowing how to write a QMK userland in Rust, we have to know how to do it in C.

QMK primarily works through a single-threaded main loop which runs user-defined, weak-linked (and therefore optional) callbacks. For example, the process_record_user callback runs whenever you press a key:

bool process_record_user(uint16_t keycode, keyrecord_t *record)
{
    // Returning true will cause QMK's default behaviour
    // (sending the key over HID) to continue. Returning
    // false prevents QMK from handling the event further.
    return true;
}

QMK has many callbacks in a similar vein to the one above, some other examples being encoder_update_user for when the user turns one of the knobs, or oled_task_user for when the OLED is ready for more data to be crammed down the I2C bus.

So, the real question is, how do we do it in Rust? The answer is:

#[unsafe(no_mangle)]
pub extern "C" fn process_record_user(
    keycode: u16,
    record: *const KeyRecord
) -> bool {
    true
}

This snippet may look a little alien to people coming from higher-level Rust backgrounds; thatā€™s because weā€™re using three features which even skilled Rust developers may never encounter in their own code:

  1. #[unsafe(no_mangle)]: This forces the Rust compiler to output the function name verbatim when generating symbols, which is done for reasons defined by this RFC. To put it briefly, names are mangled to encode information about the functions such as generics, and for compatibility reasons. We donā€™t want this because QMK relies on function names to find the callbacks.

  2. extern "C": This forces the Rust compiler to generate this function in compliance to the C calling convention, which is used here to allow QMK to actually call the function.

  3. *const T: Unless youā€™ve delved into Rustā€™s internals or otherwise messed with bindings, you may not have known that Rust actully has raw pointer types, designated as *const T for pointers to values which are guaranteed to not change, and *mut T for pointers to values which could change. Technically, &T would work fine here as pointers in Rust are essentially just unchecked references, but using a raw pointer here makes more semantic sense as there is no guarantee that QMK wonā€™t pass in an invalid pointer.

The trouble with weak-linked symbols

With QMKā€™s build system, the symbols defined from Rust donā€™t override the default weak-linked implementations written in C. I spent the better part of a month fixing this issue, and Iā€™m still not entirely sure why this is the case. With that being said, the solution I came up with was to have ā€œglueā€ functions defined from C, i.e:

bool process_record_user(uint16_t keycode, keyrecord_t *record)
{
    return process_record_user_rs(keycode, record);
}

This comes with a new issue though ā€“ I donā€™t want to remember to write glue code for every callback I define from Rust! So as with most Rust issues, this is easily fixed with a macro. I ended up settling on this syntax:

#[qmk_callback((uint16_t, keyrecord_t) -> bool)]
fn process_record_user(
    keycode: u16,
    record: KeyRecord
) -> bool {
    // ...
    true
}

The qmk_callback macro allows the user to define the signature of the function with C types. It then uses that signature to generate the glue code for you at compile time! As a bonus, it also makes the function no_mangle, pub and extern "C" for us. Perfect!

What about the keymap?

I rewrote QMKā€™s LAYOUT macro in Rust:

keymap! {
    "sofle/rev1",
    {
        KC_ESC,   KC_1,   KC_2,    KC_3,    KC_4,    KC_5,                     KC_6,    KC_7,    KC_8,    KC_9,    KC_0,  KC_GRV,
        KC_TAB,   KC_Q,   KC_W,    KC_E,    KC_R,    KC_T,                     KC_Y,    KC_U,    KC_I,    KC_O,    KC_P,  KC_BSPC,
        KC_LSFT,  KC_A,   KC_S,    KC_D,    KC_F,    KC_G,                     KC_H,    KC_J,    KC_K,    KC_L, KC_SCLN,  KC_QUOT,
        KC_LCTL,  KC_Z,   KC_X,    KC_C,    KC_V,    KC_B, KC_F20,    KC_F21,  KC_N,    KC_M,    KC_COMM, KC_DOT,KC_SLSH, KC_RSFT,
                         KC_LGUI,KC_LALT,KC_LCTL, CS_LOWER, KC_SPC,    KC_ENT, XXXXXXX, KC_RCTL, KC_RALT, KC_RIGHT
    },
    {
        _______,   KC_F1,   KC_F2,   KC_F3,   KC_F4,   KC_F5,                       KC_F6,   KC_F7,   KC_F8,   KC_F9,  KC_F10,  KC_F11,
        KC_GRV,    KC_1,    KC_2,    KC_3,    KC_4,    KC_5,                       KC_6,    KC_7,    KC_8,    KC_9,    KC_0,  KC_F12,
        _______, KC_EXLM,   KC_AT, KC_HASH,  KC_DLR, KC_PERC,                       KC_CIRC, KC_AMPR, KC_ASTR, KC_LPRN, KC_RPRN, KC_PIPE,
        _______,  KC_EQL, KC_MINS, KC_PLUS, KC_LCBR, KC_RCBR, _______,       _______, KC_LBRC, KC_RBRC, KC_SCLN, KC_COLN, KC_BSLS, _______,
                             _______, _______, _______, _______, _______,       _______, _______, _______, _______, _______
    },
}

This is a custom procedural macro which reads from the given keyboardā€™s JSON file and creates a matrix mapping for it, allowing you to define the keymap from within Rust.

The syntax for the actual keys is fully compatible with QMKā€™s syntax, excluding macro calls like S(). It also boasts autocomplete and other LSP features which QMKā€™s macro does not have.

The fruits of our labour

At first glance, you might think this is a whole lot of work for very little benefit, and I agree; until you realise the true power of having essentially the entire Rust ecosystem at your fingertips on an embedded device. Provided you set up a heap allocator, you suddenly have incredibly easy access to dynamically sized arrays, strings, hashmaps, dynamic dispatch and much, much more, all without having to write the data structures yourself.

This wonā€™t be worth it for many, but given the presence of a 64x128 OLED display on each half of my keyboard, features like the format! macro and dynamic dispatch are tough to pass up.

Fruit 1: WebAssembly

This may be the worldā€™s first QMK-based keyboard firmware to run on the web! Thatā€™s right, you can try out the actual firmware right now at https://qmk.nullp.tr! I abstracted away QMKā€™s APIs early on, so it was trivial to add WASM as a target ā€” only some behaviour needed to be modified, i.e. rendering to a canvas or writing to LocalStorage instead of the EEPROM.

Fruit 2: Advanced rendering

I threw out QMKā€™s rendering functions entirely, instead opting to manually write to the framebuffer. The cost of this is memory, approximately 1KB on the stack, but in return it grants great control over the final frame.

One example of this is how Iā€™ve implemented nearest neighbour scaling for page transitions and special effects. It uses fixed-point arithmetic to easily achieve this in real-time, as the RP2040 has no dedicated floating point hardware.

Another example is custom font rendering. QMKā€™s font rendering is limited to a row/column system, which means you canā€™t render text at some given coordinates, in pixels. My text rendering has no such limitation, at the cost of being more expensive to render.

Fruit 3: Macros

C may have macros, but it doesnā€™t have Rustā€™s macros.

Drawing images to the screen, for instance, is a common requirement for userland QMK code. In C, this involves manually converting an image to the relevant bytes and embedding it in the font. This felt clunky to me, so I came up with something different. The following is a real snippet from my actual firmware:

include_image!("./images/colour_gradient.png");
include_image!("./images/left_arrow.png");
include_image!("./images/right_arrow.png");
include_image!("./images/up_arrow.png");
include_image!("./images/why.png");
include_image!("./images/credit.png");
include_animation!("./images/boot");

This macro reads from a .png file at compile time and converts it to the OLEDā€™s 1bpp format. It can also handle animations, converting them into an array of byte arrays. One example of this functionality can be seen when booting up the firmware.

Conclusion

At this point, I could say that I did all this for Rustā€™s safety guarantees, but Iā€™d be lying. I did all this because Iā€™m too stubborn to learn C.

With that being said, the development experience is better ā€” for example, Iā€™m using the lovely rp2040_panic_usb_boot crate to handle panics and reboot into BOOTSEL, keeping the panic message in memory to be easily dumped and viewed. This is in contrast to writing your userland in C where, unless you have some specialized debugging equipment, you have no idea where or why your program has segfaulted.

Everything Iā€™ve done with my firmware is possible in C. Some parts with less difficulty (getting bindgen working with QMK took weeks) and some parts with more difficulty (the page system uses dynamic dispatch, good luck with that!)

Despite all this, given that Iā€™ve already figured out the majority of issues with building a userland in Rust, Iā€™d say that this is worth it as a weekend project if you enjoy writing Rust code and want to make a flashy keyboard.

Footnotes and Acknowledgements

Thank you so much to the lovely houqp for writing an article on this topic back in 2019. A lot of the info is outdated, and following it to the letter unfortunately no longer produces a working result due to changes in how QMK works, but nonetheless it was a huge help in figuring out a lot of the build system and was the initial inspiration for creating this firmware back in December.

Also a huge thanks to the wonderful helpers and staff in the QMK Discord server for putting up with my almost entirely unrelated questions regarding the internals of QMK and why the callbacks wouldnā€™t work in the final binary. I appreciate all of you.

Thereā€™s a lot I left out of this article for brevity. For example, I wrote a safe wrapper for QMKā€™s bindings and a working Bindgen script for QMKā€™s C code.

In future, I want to write an abstraction over QMKā€™s custom data sync APIs so that I can send arbitrary data between each half of the keyboard and (finally) have an empty C file for the keymap.

Thank you for reading. If you have any suggestions for how to improve my firmware, Iā€™m forever available at nullptr@vert.sh.