Recently, I bought a cheap PS/2 trackpoint from China for less than 10€. The device is sold in several markplaces (eg, Amazon, AliExpress, etc) under different names (eg, SurnQiee Trackpoint, CCYLEZ Trackpoint, etc). The first issue I had with this device is the lack of any datasheet for the IC responsible for the PS/2 communication. That led me to figure out the pinout of the IC in the hard way (see logic analyzer experiments).
With that pinout at hand, I wanted to actually use the PS/2 mouse with my keyboard. Unfortunately, existing PS/2 software for ATmega32U4 such as Kim’s ZMK patch was not able to communicate with the device.
TL;DR: In this project, I wired an obscure PS/2 trackpoint module to an ATmega32U4 (Teensy 2.0), implemented a driver to decode PS/2 packets in CRUNCH Scheme, and streamed decoded events over USB serial to the PC.
At FOSDEM’25 I became aware of CRUNCH Scheme, a very small R7RS subset that compiles to C and has no runtime library. Since then, I’ve been looking for the right project to use it and a tiny, debuggable PS/2 driver finally sounded like the right one.
The challenges here were:
This document reports the necessary setup, some implementation details, and the lessons learned. Link to the source code is below.
Teensy 2.0 Mouse IC
┌─────────────┐ ┌────────────┐
│ GND +-----------------------------+ GND │
│ PD2 +--+--------------------------+ DAT │
│ PD5 +--|-+------------------------+ CLK │
│ VCC +--|-|-----------+------, │ RST │
│ │ | | | '----+ VCC │
└───[USB]─────┘ | +-- 4.7kΩ --+ │ BTN1 │
| | │ BTN2 │
+---- 4.7kΩ --+ │ BTN3 │
└────────────┘
💡 Both CLK and DAT need 4.7 kΩ pull-ups to VCC. PS/2 is an open-collector bus: the device and host only pull the line low, while the resistors pull it high when idle.
curl, make, gitavr-gcc, teensy_loader_cliSource code: https://github.com/db7/crunch-ps2decoder
Most PS/2 mice (and trackpoints) require an initialization handshake after reset. But this chip is different:
0xFF reset, 0xF4 enable reporting, 0xF3
set sample rate, etc.), consistently replying with an error code.This makes the module a kind of fixed-function motion encoder IC. It is simpler (no init sequence required), but also stranger (you can’t configure sample rate, scaling, resolution, or reset it).
⚠️ Incompatibility with QMK/ZMK (Kim’s patch): The ZMK firmware stack offers PS/2 support (often called Kim’s patch). It assumes a bit-banged initialization sequence where the controller sends commands and waits for ACKs. Since this IC never responds, ZMK/QMK hang waiting. If you are a keyboard builder hoping to use this IC with QMK/ZMK, it will not work without customizing the firmware. On the bright side, the changes should be simple: skip the initialization and waiting-for-ready phases.
The most interesting trick in Kim’s patch is to offload PS/2 packet reception to the hardware USART in the ATmega32U4 instead of doing manual edge sampling (which would consume too many cycles on such a small MCU). So, I’ll start the CRUNCH based firmware here.
Although PS/2 isn’t a UART protocol, USART can be configured to receive it by setting synchronous slave mode with 8 data bits + odd parity + 1 stop. The external clock from the device matches how the PS/2 protocol works.
CRUNCH makes the register configuration quite elegant:
(define (usart1-on)
; Disable TX/RX while changing mode
(clr-reg! UCSR1B)
; UMSEL1[1:0]=01 (sync),
; UPM1[1:0]=11 (odd parity),
; USBS1=0 (1 stop)
; UCSZ1[1:0]=11 (8-bit), UCPOL1=0
(clr-reg! UCSR1C)
(set-bits! UCSR1C UMSEL10 UPM11 UPM10 UCSZ11 UCSZ10 UCPOL1)
; In synchronous slave, UBRR is ignored for clocking; set any value.
(clr-reg! UCSR1A)
(clr-reg! UBRR1H)
(clr-reg! UBRR1L)
; Enable receiver and RX complete interrupt
(set-bits! UCSR1B RXEN1 RXCIE1))
The registers UCSR1B, UCSR1C, etc. and the bits UMSEL10, UPM11, etc. are
predefined in the AVR headers provided by avr-gcc. The macro clr-reg! sets 0s to all bits of a register, and the macro set-bits works as follows:
(set-bits! UCSR1B RXEN1 RXCIE1)
translates to:
UCSR1B |= (RXEN1 | RXCIE1);
And
(set-bits! UCSR1B 1 3 4)
translates to:
UCSR1B |= (1 << 1) | (1 << 3) | (1 << 4);
These bit and register macros are defined in avr.base.scm file in the repo. The USART1 initialization tailored to PS/2 is defined in avr.ps2.scm.
When a byte is received, the USART hardware triggers an interrupt and the handler USART1_RX_vect is called — the interrupt handler is often called *interrupt service routine (ISR).
There we validate start/parity/stop bits via UCSR1A flags and push valid bytes
into a ring buffer. The main loop later consumes those good bytes.
(define (USART1_RX_vect)
; Check for frame & parity errors
(cli!)
(when (zero? (read-bits UCSR1A FE1 DOR1 UPE1))
(let ((x (read-reg UDR1)))
(rbuf-push! (get-rb) x)))
(sei!))
The name USART1_RX_vect is fixed by the AVR toolchain. Luckily,
if the Scheme procedure name only contains C-accepted characters, CRUNCH
keeps the name when translating it to C.
Two gotchas:
#>
/* declare function with additional attributes */
void USART1_RX_vect(void) __attribute__ ((__signal__, __used__, __externally_visible__));
<#
The ring buffer sits between two contexts:
USART1_RX_vect) pushes bytes.rbuf-pop! and feeds the packet decoder.Because of that, updates must be atomic:
(cli!) ; disable interrupts
; read or write ring buffer state
(sei!) ; enable interrupts again
Without this, head/tail pointers can be corrupted by race conditions.
Note to allow nested atomic blocks, the correct way how to handle this would be to store the current interrupt configuration in a local variable, disable the interrupts, and finally restore the interrupt configuration. But we don’t have such nested cases in this small driver.
The interrupt handler needs a pointer to the ring buffer. Currently, CRUNCH does
not provide a clean language-level way to define globals. The workaround is to
use C: declare a global pointer to the opaque struct rbuf, and then bind it
from Scheme.
(c-declare "struct rbuf *g_rbp = NULL;")
;; Return current ring-buffer pointer
(define get-rb (c-lambda () (pointer (struct rbuf)) "C_return(g_rbp);"))
;; Bind a new ring-buffer pointer
(define set-rb! (c-lambda ((pointer (struct rbuf))) void "g_rbp = $1;"))
If you are still with me here, let me talk about the ring buffer implementation, since it has a crucial role in this little project.
The ring buffer consists of a native bytevector and three fields: capacity, head index, and tail index:
(define-struct rbuf
(cap byte)
(head byte)
(tail byte)
(buf bytevector))
(define-compound-accessors (pointer (struct rbuf))
(mk-rbuf cap head tail buf)
(cap rbuf-cap)
(head rbuf-head rbuf-head!)
(tail rbuf-tail rbuf-tail!)
(buf rbuf-buf))
One detail to keep in mind: use the pointer to struct rbuf when
defining accessor procedures, otherwise setters will update local copies of
the structure, which are discarded on function return.
Finally, here is the implementation of rbuf-push!:
(: (rbuf-push! (pointer (struct rbuf)) byte) boolean)
(define (rbuf-push! rb value)
(let* ((nhead (next-head rb))
(full (byte=? (rbuf-tail rb) nhead)))
(unless full
(bytevector-u8-set! (rbuf-buf rb)
(byte->integer (rbuf-head rb))
(byte->integer value))
(rbuf-head! rb nhead))
(not full)))
Head index points to the next free entry in the byte vector. The procedure calculates the next head index and determines whether the buffer would get full (i.e., if tail equals next head). If there is space left, it writes the value to the current head position in the bytevector and then “commits” by advancing the head.
CRUNCH offers type annotations via (crunch declarations). I find them useful
to keep code organized and clear. These are declared with (: ...).
Note that CRUNCH still has some limitations when operating on byte variables.
For example,=, +, and -aren’t supported. I created a few byte
helpers in byte-utils.scm to work around that.
CRUNCH wasn’t designed for bare-metal MCUs, but with a few tricks it works well:
C includes aren’t inherited
Each module must include the required C headers (<avr/io.h>,
<avr/interrupt.h>, etc.). I create a macro avr-includes that has to be manually added t modules including (avr base).
Struct/type names
Use underscores (ps2_packet), not dashes (ps2-packet). Procedures can
still use -. I am not sure about the reason here, but naming the ring buffer as struct byte-rbuf didn’t compile.
Globals Globals for ISRs (like a ring buffer pointer) must be declared in C and accessed via Scheme wrappers. Not elegant, but effective — CRUNCH lets you escape to C whenever needed.
Freestanding builds
With crunch-environment.h and -DCRUNCH_FREESTANDING, the code compiles
cleanly with avr-gcc. There are only a few definitions that go into the header such as errno, fmemopen, and crunch_init_host.
Community support The CHICKEN Scheme community has been very responsive with fixes and workarounds.
This project shows how to efficiently decode PS/2 motion on an ATmega32U4 with a driver written in CRUNCH Scheme. The quirks of the unmarked IC (always streaming, no config, floating reset) made it an odd but fun case study.
Even though CRUNCH wasn’t meant for embedded work, with a bit of freestanding setup and some C escapes, it’s a surprisingly capable way to structure MCU firmware.
What worked well:
Lessons learned & gotchas:
Future work:
Next steps are migrating my keyboard firmware from Microscheme to CRUNCH!
© 2025 db7 — licensed under CC BY 4.0.