/ blog / programming-my-keyboard-in-rust

Rust? On my keyboard??

It’s more likely than you might think.

I have a cool programmable QMK keyboard.

I also happen to have a liking for the Rust programming language.

Unfortunately, however, my OLKB Planck rev6 from Massdrop only officially supports QMK, which is written in C. While QMK is amazing, and allows me to program every last button on my keyboard, it’s still written in C, and C is short for Cringe. Apply the rule of transitivity to this, and it’s clear that QMK is cringe too.

What makes QMK cringe

My biggest problem with QMK is the fact that it’s a poorly documented keyboard firmware framework. The codebase feels untraversable and opaque. There are functions and macros with purposes unbeknownst to me, and I don’t feel like I own the functions, only what they do.

Here’s an illustrative example of what I mean:

// When the default layer changes
layer_state_t default_layer_state_set_user(layer_state_t state) {
// Stuff goes here

// When the activated layers change
layer_state_t layer_state_set_user(layer_state_t state) {
// doMagic();

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
// More code

// When the keyboard starts
void keyboard_post_init_user(void) {
// Lorem ipsum dolor sit amet

These are functions that I haven’t defined, only implemented. They act sort of like callbacks, meaning that they are called in certain circumstances and are expected to perform certain actions. The worst part, however, is that I haven’t found any documentation for what functions I can implement, and what each function do. This means that it’s difficult to add QMK features to your keyboard firmware, and your only resort is to read other people’s code and asking for help in the official discord channel.

Repeat after me: Don’t use discord as a communication platform for FOSS projects. Got it? Cool.

Fortunately, there are quite a lot of examples in the QMK repo, and the people on the QMK discord are quite friendly and helpful. But it shouldn’t have to be like that.

Ok, so what do we do?

Consult a friend

I have a friend that built his own keyboard in Rust. Like, he even wrote the 3D-models for the case in Rust (using a self-implemented OpenSCAD-codegen library in Rust, as you do). He’s using a crate called keyberon which exposes helpful structs for building your own keyboard firmware in rust. And besides the obvious improvement of being written in Rust, it’s also a badly documented library instead of a badly documented framework. This means that it wont try to hijack how I build and flash the firmware, and it’s just like any other library.

The implementation

Luckily for me, Mr wezm on GitHub had developed a keyberon-firmware for his Clueboard 66% Low Profile Keyboard, which happens to use the same microcontroller as my OLKB Planck, namely the STM32F303. So porting it to my keyboard should be trivial, right?

Well, mostly.

The first problem, after setting up a highly imperative development environment 😩 by using rustup and installing specific targets, was figuring out what to keep and what to replace from the rightfully stolen code from wezm.

The matrix pins

One obvious thing that had to change was the keyboard matrix.

sad neo from the matrix

“What’s a matrix?”, you might ask. Basically: keyboards have many keys, and microcontrollers don’t have that many GPIO pins. So instead of getting a microcontroller with 100+ pins, we cheat and arrange the keyboard switches in a “matrix” configuration, where we can index each switch using a row and a column. Then the microcontroller can figure out which keys are being pressed by sending current through each column, and seeing which rows get activated, or vice versa.

In a beautiful world these pins would be logically ordered along the pins on the microcrontroller, but it’s abundantly clear we don’t, and thus the pins aren’t ordered. Fortunately, QMK already had to figure this out before me, so all I had to do was yoink their solution.


{ A10, A9, A8, B15, C13, C14, C15, A2 }
{ B11, B10, B2, B1, A7, B0 }

Here are the row and column pins, ez.

Layout finagling

After having my friend convince me that rust will make heavy use of type-theory to prove that the binary I just compiled won’t let the magic smoke escape from my keyboard, I tried flashing it. To my surprise, it kinda worked, even.

Well, what did work was the better part of the left half of the keyboard. I quite quickly realized this was because the keyboard layout was stored in a 12x4xN array (where N is the number of layers), and the matrix was defined as a 6x8 array, with 8 pins for the rows, and 6 pins for the columns.

But why?

Just math, really.

My keyboard could be using a 48x1 matrix, one for each key, however, this would require a microcontroller that has 49 GPIO pins available for the matrix alone. It could also be using a 12x4 matrix, which would arguably make the most sense, since the keyboard has all the switches already arranged in a 12 by 4 grid. This would only require 12 + 4 = 16 pins, which is significantly less. However, we could save two more pins by using a 6x8 matrix, effectively moving the right half of the keyboard to be located below the left half instead. This would only require 6 + 8 = 14 pins, and add a bit of extra complexity. But man pins aren’t cheap in this economy.

Or are they..?

After some investigating, I found that it’s common to apply some transformations on the matrix events to have them better match the actual layout of the keyboard.

So I added some code like this:

event.transform(|r, c| if r < 4 { (r, c) } else { (r - 4, c + 6) })

This got me almost the entire way, and the right half was now working too, but something was up with the bottom row: some keys weren’t where I expected them to be.

After some more investigating, I found that the matrix was even more complicated than I first thought, as can be seen in this beautiful C macro for creating planck layouts:

#define LAYOUT_ortho_4x12( \
k00, k01, k02, k03, k04, k05, k40, k41, k42, k43, k44, k45, \
k10, k11, k12, k13, k14, k15, k50, k51, k52, k53, k54, k55, \
k20, k21, k22, k23, k24, k25, k60, k61, k62, k63, k64, k65, \
k30, k31, k32, k73, k74, k75, k70, k71, k72, k33, k34, k35 \
) { \
{ k00, k01, k02, k03, k04, k05 }, \
{ k10, k11, k12, k13, k14, k15 }, \
{ k20, k21, k22, k23, k24, k25 }, \
{ k30, k31, k32, k33, k34, k35 }, \
{ k40, k41, k42, k43, k44, k45 }, \
{ k50, k51, k52, k53, k54, k55 }, \
{ k60, k61, k62, k63, k64, k65 }, \
{ k70, k71, k72, k73, k74, k75 } \

This macro transforms the physical switch positions into what the matrix expects, and as we can see, it transforms from a “12x4” array (it’s really just a 48x1 array with some formatting) to a 6x8 array. But carefully looking at the last row of the input matrix, we can see that second (k33 to k35) and the fourth (k73 to k75) quarter seem to be swapped.

If I had to guess, they did this because it significantly eased routing the traces on the PCB.

I just added another transform, to get the following code:

.transform(|r, c| if r < 4 { (r, c) } else { (r - 4, c + 6) })
.transform(|r, c| match (r, c) {
// If the event is in the second quarter, swap with the fourth
(3, c @ 3..=5) => (r, c + 6),
// If the event is in the fourth quarter, swap with the second
(3, c @ 9..) => (r, c - 6),
_ => (r, c),

I love me some weird match blocks


The next thing I was looking forward to implement was controlling the underglow RGB. On QMK I had used it for indicating what layer I was on, and I wanted to do the same here with keyberon. But first I had to get them working at all.

After some investigating in the QMK source code, I found this code: qmk_firmware/keyboards/planck/rev6_drop/config.h:106

* WS2812 Underglow Matrix options
#define RGB_DI_PIN A1
#define RGBLED_NUM 9

#define WS2812_PWM_DRIVER PWMD2
#define WS2812_PWM_CHANNEL 2
#define WS2812_PWM_PAL_MODE 1
#define WS2812_DMA_CHANNEL 2

This told me that WS2812 is probably the LED driver, that the pin A1 is important somehow, and that it’s probably using PWM.

A quick search on resulted in two relevant crates:

While I could have been smarter about picking one of them, trial and error worked, because ws2812-spi seemed to expect three pins, which ws2812-timer-delay only expects one pin and a timer. So ws2812-timer-delay indeed seems like a better fit here.

Luckily, my friend was also using the same driver, and with the help of his code and some examples, I wrote some code that looked like it was going to work.

But, well, it didn’t work.

In fact, nothing worked now. The keyboard just froze whenever I flashed the firmware.

After more trial and error of commenting parts of my code out until things worked again, I discovered the faulty line:


This line starts a timer running at 3MHz. Or I mean, it was supposed to, because instead it just made the microcontroller crash.

Broken HAL-crate

After some discussing with my friend as to why this could be, I opened an issue on the stm32f3xx-hal-crate, which included the code for the timer itself. Within 24h, the maintainer had not only answered my issue by creating and merging a pull request, but they even pushed a new version to Talk about express delivery!

All I had to do now was to update the stm32f3xx-hal-crate to the next minor version in my Cargo.toml, and recompile. And this time it just worked.

It’s quite a satisfying feeling when this happens, because it’s a sort of validation that there was nothing wrong with the code I was writing.

Maybe it’s like I know what I’m doing after all…

Wrapping it up (using nix)

Now that I had gotten the rust firmware itself working, all that was left was to make sure I could build it reproducibly using nix. ;)

This took some time to get working, since it’s not just a cargo build and rock’n’roll, but more of a:

  1. Make sure all necessary dependencies, toolchains and targets are installed
  2. No, you really need all of them
  3. cargo objcopy --release -- -O binary planck.bin
  4. dfu-util -d 03a8:a4f9 -a 0 --dfuse-address 0x08000000:leave -D planck.bin

as adapted from the wezm/clueboard-rust-firmware crate’s

In the end, I wound up using fenix to create the custom toolchain (since the keyboard isn’t running x86_64-linux) from a rust-toolchain.toml-file, and crane to build the firmware quickly and reproducibly. I also added an apps-output to the flake, so that I can run nix run .#flash, and nix will not only build the firmware, but also flash it on the keyboard for me.

Donald Trump saying “Thank you nix, very cool!”. The image is edited.

Now do I not only have Rust running on my keyboard (built with Nix), but as far as I’m aware, I’m the only person in the world running Rust an OLKB Planck. So yeah, I’m basically famous now.

Further reading

Thanks to