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

| 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 laterPlan 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:
#[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.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.*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.