A cheap Chinese PS/2 to USB adapter: sticky keys, the cold and what to replace it with
An old PS/2 keyboard, a cheap Chinese USB adapter — and every now and then a key “sticks”: a letter pours out in a row as if it were held down. Here is what exactly fails in such an adapter, what we checked, why the glitches on letters disappeared when the weather turned cold (and why they may come back in summer), plus an overview of microcontrollers and ready-made firmware for building an adapter that does not do this.
What is inside a cheap adapter
PS/2 → USB adapters come in two kinds, and they must not be confused:
| Kind | How it is built | Which keyboard it works with |
|---|---|---|
| passive | just wires: the USB plug rewired to mini-DIN | only a “combo” keyboard (USB/PS2 on the label): it can speak USB by itself |
| active | a microcontroller reads the PS/2 protocol and pretends to be a USB keyboard | any PS/2 keyboard |
Ours is active: a black epoxy blob on the board. That is not glue but the chip itself: the die is glued to the board, bonded with thin wires and potted (so-called COB, chip-on-board). It has no marking, and Windows sees the device as VID_13BA + PID_0018 — a composite: keyboard plus mouse.
🔑 The main consequence: the firmware of such an adapter cannot be fixed. It is burned into the die at the factory, and there are no programming pins. You can only remove the conditions under which it fails, or replace the adapter.
The symptom: the key is released, but it keeps typing
A PS/2 keyboard sends two messages for every key: “pressed” and “released”. “Pressed” repeats while the key is held, but “released” is sent exactly once. If the adapter loses it, Windows thinks the key is still down and starts auto-repeat: after a half-second pause the letter pours out in a row.
To avoid guessing, we kept a log: a trap program recorded only anomalies (long runs of repeats), not normal typing. From 11 to 22 September it caught 20 sticky keys on letters, all with the same signature:
| Sign | What we saw | What it means |
|---|---|---|
| repeats per run | exactly 16, every time | not a random duration but a fixed timer |
| duration | 1000–1032 ms | the release does arrive — about a second later |
| keys | 14 different ones: J, L, G, D, B, H, R, V, T, M, W, Z… | not one bad key, but the link between keyboard and USB |
| pairs | J → L within 1 s, D → B within 1 s, H → R within 1 s | during the glitch window the release of ANY key is lost |
Nobody holds a letter down on purpose, which is why letters became the proof. The same signature on different keys means the mechanical contact is not to blame: keys do not all “let go” exactly one second later. The closest explanation is that the adapter loses the release, and a second later its own timer “releases” the key by itself.
Why the release gets lost
PS/2 is two lines, CLK and DATA. A zero is pulled down by the device, while a one is made only by a pull-up resistor to +5 V. The weaker the pull-up and the longer the cable, the slower the line rises to one and the easier noise knocks it over. Cheap adapters save money right here:
| Weak spot | What happens |
|---|---|
| weak pull-ups | a slow signal edge: the chip samples the bit at the wrong moment and the frame breaks |
| clock without a crystal | the built-in RC oscillator drifts by a few percent with temperature and voltage, and the timing margin is thin already |
| no supply capacitors | a 5 V dip while typing — a receiver glitch |
| no checking and no retry | PS/2 does not ask again: a broken “released” frame is never repeated |
We measured the pull-ups in our adapter with a multimeter (adapter unplugged, probes on the socket): CLK — 4.4 kΩ, DATA — 4.38 kΩ. Not a disaster, but no margin either.
What we checked — and what did not help
| What we did | Result |
|---|---|
| removed the PS/2 extension cable (the cable was 3–5 m) | sticky keys remained |
| disabled USB power saving for the adapter and hubs, and selective suspend | sticky keys remained |
| started an anomaly log | found the “16 repeats, ~1 s” signature on letters — 20 times in 12 days |
| measured the pull-ups | 4.4 / 4.38 kΩ; the plan is to add 10 kΩ in parallel to each (≈ 3 kΩ total) |
We ruled out heating of the wire itself by calculation: the keyboard draws about 100 mA, which on two metres of thin cable is milliwatts. The wire does not heat up, and its resistance plays no part in the failure mechanism at all.
It got colder — and the glitches on letters stopped
The log recorded the last sticky letter on 22 September. From 23 September to 31 October — not one, although we changed neither the adapter, nor the keyboard, nor any settings. This coincided with the cold: the room got cooler, and the adapter “fixed itself”.
| Period | Sticky letters (16 repeats) | Same signature on Enter |
|---|---|---|
| 11–22 September (12 days) | 20 | 4 |
| 23 September – 31 October (39 days) | 0 | 16 |
To be honest about the limits: the same signature still appears on the Enter key. Enter is sometimes held on purpose, so these records are not proof, but they do not let us say “the glitch is completely gone” either. We did not measure the room temperature — the link with the cold is a hypothesis for now, not a measurement.
Why temperature could matter. The wire has nothing to do with it, but the blob itself does. Without a crystal it is clocked by a built-in RC oscillator whose frequency depends on temperature. Software PS/2 reading in such chips has a small timing margin, and a few percent of drift is enough to push the bit sampling point over the edge. In the cold the oscillator drifts one way, in the warmth the other; if our unit is on the edge, warmth pushes it over. The same goes for the connector contacts: they expand with heat, and a shaky contact gets shakier.
⚠ So the glitches may come back in summer. What to do before the heat:
- Put a logging room thermometer next to it and keep the sticky-key log running — then the link with heat becomes a measurement.
- Add pull-ups (10 kΩ in parallel to the existing ones, ≈ 3 kΩ total) and supply capacitors next to the blob: 0.1 µF ceramic and 22–47 µF electrolytic.
- If that does not help, replace the adapter with a home-made one on a microcontroller with proper firmware (below).
Microcontrollers for a home-made adapter
An adapter needs three things: hardware USB (so USB is not emulated in software), inputs that tolerate the 5 V PS/2 lines (or a level shifter), and ready firmware tested by thousands of people.
| Controller / board | USB | 5 V on inputs | Ready firmware | Verdict |
|---|---|---|---|---|
| ATmega32U4 (Arduino Pro Micro 5 V, Leonardo, Teensy 2.0) | hardware | yes, runs from 5 V | TMK ibmpc_usb, Soarer’s Converter | the best choice for a keyboard |
| RP2040 (Raspberry Pi Pico) | hardware | no, 3.3 V only — needs a level shifter | ps2x2pico (keyboard and mouse) | good, especially if you need a mouse too |
| STM32F103 (Blue Pill) | hardware | some pins are 5 V tolerant | we cannot name a well-known ready converter firmware | possible, but you will write it yourself |
| ESP32-S2 / S3 | hardware | no, 3.3 V only | we cannot name a well-known ready one | overkill for this job |
| ATtiny85 (Digispark) with V-USB | software (emulated) | yes | hobby projects exist | not recommended: USB on the edge of timing — one more source of glitches |
| ATmega328P (Arduino Uno, Nano) | none (only a USB-UART bridge) | yes | — | not suitable: it will not become a PC keyboard |
| ESP32 (classic), ESP8266 | none | no | — | not suitable |
| the blob in a Chinese adapter | hardware | yes | factory, cannot be reflashed | works until it hits the edge |
⚠ The Pro Micro comes in two versions: 5 V / 16 MHz and 3.3 V / 8 MHz. For PS/2 take only the 5 V one: the keyboard is powered from 5 V, and its lines are 5 V too.
⚠ The RP2040 does not tolerate 5 V on its inputs. The CLK and DATA lines go through a bidirectional level shifter (a ready-made board with BSS138 MOSFETs), not directly.
Ready-made firmware
| Firmware | Board | What it does |
|---|---|---|
| TMK, ibmpc_usb converter | ATmega32U4 (Pro Micro, Teensy 2.0) | PS/2, AT and XT, scan code sets 1–3, Caps/Num LEDs, key remapping; considered the reference |
| Soarer’s Converter | ATmega32U4 (Teensy 2.0, Pro Micro) | PS/2, AT, XT and terminal keyboards, flexible remapping through a config file |
| ps2x2pico | RP2040 (Pico) | PS/2 keyboard and mouse at the same time, reading via PIO |
How to flash:
# Pro Micro (Caterina bootloader): short RST to GND twice quickly —
# the board stays in bootloader mode for 8 seconds, a new COM port appears
avrdude -p atmega32u4 -c avr109 -P COM5 -U flash:w:firmware.hex
# Raspberry Pi Pico: hold BOOTSEL while plugging into USB —
# an RPI-RP2 drive appears, copy the .uf2 file onto it
Example firmware: how not to leave a key stuck
To show how good firmware differs from the blob, here is a learning example for the Pro Micro (keyboard only, scan code set 2, no LEDs). What matters in it is not the key table but the behaviour on error:
- every frame is checked: start bit, parity, stop bit; a pause over 2 ms in the middle of a frame means lost sync;
- if a frame is broken, it may have been exactly a “released”, so the firmware releases all keys. The only thing worse than nothing is a key that types by itself;
- if the keyboard restarted (self-test code) or overflowed its buffer — release everything as well.
Wiring: PS/2 socket pin 1 (DATA) to pin 4, pin 5 (CLK) to pin 3, pin 4 to VCC, pin 3 to GND; a 4.7 kΩ resistor from CLK and from DATA to +5 V.
⚠ We have not compiled this example or tested it on hardware. For everyday use take TMK or Soarer’s Converter: they have everything this one lacks (LEDs, commands to the keyboard, retrying broken frames).
ps2_to_usb_promicro.ino · 7274 B
Show the code (227 lines)
// PS/2 keyboard -> USB HID on Arduino Pro Micro (ATmega32U4, 5 V / 16 MHz).
// Educational example: keyboard only, scan code set 2, no LEDs, no host-to-keyboard commands.
// For daily use take a complete converter firmware (TMK ibmpc_usb, Soarer's Converter).
//
// Wiring (PS/2 socket pin -> Pro Micro):
// 1 DATA -> pin 4, 5 CLK -> pin 3 (INT0), 4 +5V -> VCC, 3 GND -> GND
// 4.7 kOhm pull-up from DATA to +5 V and from CLK to +5 V.
//
// The main idea vs. a cheap adapter: if a frame is damaged, the "key released" code may be lost,
// so on any receive error we release ALL keys instead of leaving one key stuck.
#include <Keyboard.h>
const uint8_t PIN_CLK = 3;
const uint8_t PIN_DATA = 4;
// Ring buffer filled in the interrupt, emptied in loop().
volatile uint8_t rx[32];
volatile uint8_t rxHead = 0;
volatile uint8_t rxTail = 0;
volatile bool rxError = false;
void onClockFall() {
static uint8_t bitNo = 0;
static uint8_t value = 0;
static uint8_t ones = 0;
static unsigned long lastUs = 0;
unsigned long now = micros();
// A PS/2 bit lasts 60-100 us. A gap over 2 ms means we lost sync: start a new frame.
if (bitNo != 0 && now - lastUs > 2000) {
bitNo = 0;
rxError = true;
}
lastUs = now;
uint8_t d = digitalRead(PIN_DATA);
if (bitNo == 0) { // start bit must be 0
if (d != 0) {
return;
}
value = 0;
ones = 0;
} else if (bitNo <= 8) { // 8 data bits, LSB first
if (d) {
value |= (uint8_t)(1 << (bitNo - 1));
ones++;
}
} else if (bitNo == 9) { // odd parity
if (((ones + d) & 1) == 0) {
rxError = true;
}
} else { // stop bit must be 1
if (d == 1) {
uint8_t next = (rxHead + 1) & 31;
if (next != rxTail) {
rx[rxHead] = value;
rxHead = next;
} else {
rxError = true; // buffer overflow
}
} else {
rxError = true;
}
bitNo = 0;
return;
}
bitNo++;
}
// Set 2 scan code -> value for Keyboard.press(): 0x80..0x87 modifiers, 136 + HID usage for other keys.
uint8_t mapKey(uint8_t code, bool ext) {
if (ext) {
switch (code) {
case 0x14: return KEY_RIGHT_CTRL;
case 0x11: return KEY_RIGHT_ALT;
case 0x1F: return KEY_LEFT_GUI;
case 0x27: return KEY_RIGHT_GUI;
case 0x75: return 136 + 0x52; // Up
case 0x72: return 136 + 0x51; // Down
case 0x6B: return 136 + 0x50; // Left
case 0x74: return 136 + 0x4F; // Right
case 0x70: return 136 + 0x49; // Insert
case 0x71: return 136 + 0x4C; // Delete
case 0x6C: return 136 + 0x4A; // Home
case 0x69: return 136 + 0x4D; // End
case 0x7D: return 136 + 0x4B; // Page Up
case 0x7A: return 136 + 0x4E; // Page Down
case 0x5A: return 136 + 0x58; // Keypad Enter
case 0x4A: return 136 + 0x54; // Keypad /
default: return 0; // E0 12 / E0 59 (fake shifts of Print Screen) and the rest are ignored
}
}
switch (code) {
case 0x1C: return 136 + 0x04; // A
case 0x32: return 136 + 0x05; // B
case 0x21: return 136 + 0x06; // C
case 0x23: return 136 + 0x07; // D
case 0x24: return 136 + 0x08; // E
case 0x2B: return 136 + 0x09; // F
case 0x34: return 136 + 0x0A; // G
case 0x33: return 136 + 0x0B; // H
case 0x43: return 136 + 0x0C; // I
case 0x3B: return 136 + 0x0D; // J
case 0x42: return 136 + 0x0E; // K
case 0x4B: return 136 + 0x0F; // L
case 0x3A: return 136 + 0x10; // M
case 0x31: return 136 + 0x11; // N
case 0x44: return 136 + 0x12; // O
case 0x4D: return 136 + 0x13; // P
case 0x15: return 136 + 0x14; // Q
case 0x2D: return 136 + 0x15; // R
case 0x1B: return 136 + 0x16; // S
case 0x2C: return 136 + 0x17; // T
case 0x3C: return 136 + 0x18; // U
case 0x2A: return 136 + 0x19; // V
case 0x1D: return 136 + 0x1A; // W
case 0x22: return 136 + 0x1B; // X
case 0x35: return 136 + 0x1C; // Y
case 0x1A: return 136 + 0x1D; // Z
case 0x16: return 136 + 0x1E; // 1
case 0x1E: return 136 + 0x1F; // 2
case 0x26: return 136 + 0x20; // 3
case 0x25: return 136 + 0x21; // 4
case 0x2E: return 136 + 0x22; // 5
case 0x36: return 136 + 0x23; // 6
case 0x3D: return 136 + 0x24; // 7
case 0x3E: return 136 + 0x25; // 8
case 0x46: return 136 + 0x26; // 9
case 0x45: return 136 + 0x27; // 0
case 0x5A: return 136 + 0x28; // Enter
case 0x76: return 136 + 0x29; // Esc
case 0x66: return 136 + 0x2A; // Backspace
case 0x0D: return 136 + 0x2B; // Tab
case 0x29: return 136 + 0x2C; // Space
case 0x4E: return 136 + 0x2D; // -
case 0x55: return 136 + 0x2E; // =
case 0x54: return 136 + 0x2F; // [
case 0x5B: return 136 + 0x30; // ]
case 0x5D: return 136 + 0x31; // backslash
case 0x4C: return 136 + 0x33; // ;
case 0x52: return 136 + 0x34; // '
case 0x0E: return 136 + 0x35; // `
case 0x41: return 136 + 0x36; // ,
case 0x49: return 136 + 0x37; // .
case 0x4A: return 136 + 0x38; // /
case 0x58: return 136 + 0x39; // Caps Lock
case 0x05: return 136 + 0x3A; // F1
case 0x06: return 136 + 0x3B; // F2
case 0x04: return 136 + 0x3C; // F3
case 0x0C: return 136 + 0x3D; // F4
case 0x03: return 136 + 0x3E; // F5
case 0x0B: return 136 + 0x3F; // F6
case 0x83: return 136 + 0x40; // F7
case 0x0A: return 136 + 0x41; // F8
case 0x01: return 136 + 0x42; // F9
case 0x09: return 136 + 0x43; // F10
case 0x78: return 136 + 0x44; // F11
case 0x07: return 136 + 0x45; // F12
case 0x12: return KEY_LEFT_SHIFT;
case 0x59: return KEY_RIGHT_SHIFT;
case 0x14: return KEY_LEFT_CTRL;
case 0x11: return KEY_LEFT_ALT;
default: return 0;
}
}
void setup() {
pinMode(PIN_CLK, INPUT); // external 4.7 kOhm pull-ups do the job
pinMode(PIN_DATA, INPUT);
Keyboard.begin();
attachInterrupt(digitalPinToInterrupt(PIN_CLK), onClockFall, FALLING);
}
void loop() {
static bool ext = false;
static bool brk = false;
static uint8_t skip = 0;
if (rxError) {
// A damaged or lost frame may have been a "release" code. Releasing everything is safer
// than a key that repeats until the next press.
rxError = false;
Keyboard.releaseAll();
ext = false;
brk = false;
}
while (rxTail != rxHead) {
uint8_t c = rx[rxTail];
rxTail = (rxTail + 1) & 31;
if (skip) { // rest of the Pause sequence
skip--;
continue;
}
if (c == 0xE1) { // Pause: E1 14 77 E1 F0 14 F0 77
skip = 7;
continue;
}
if (c == 0xAA || c == 0x00 || c == 0xFF) {
// AA: keyboard self-test passed (it was reconnected or reset); 00/FF: keyboard buffer overrun.
Keyboard.releaseAll();
ext = false;
brk = false;
continue;
}
if (c == 0xE0) {
ext = true;
continue;
}
if (c == 0xF0) {
brk = true;
continue;
}
uint8_t k = mapKey(c, ext);
if (k) {
if (brk) {
Keyboard.release(k);
} else {
Keyboard.press(k);
}
}
ext = false;
brk = false;
}
}
What to choose
| Situation | What to do |
|---|---|
| a “combo” keyboard (USB/PS2 on the label) | a passive adapter — no chips, nothing to fail |
| the adapter glitches rarely | first the pull-ups and capacitors (soldering iron, 15 minutes), with a log before and after |
| you want it reliable for good | Pro Micro 5 V + TMK ibmpc_usb |
| you also need a PS/2 mouse | Raspberry Pi Pico + ps2x2pico, with a level shifter |
| you just want to check quickly whether the adapter is to blame | buy another active adapter: the firmware in blobs differs |
Summary
| Question | Answer |
|---|---|
| what fails | the adapter loses the “released” code; after ~1 s its timer releases the key by itself |
| is the keyboard to blame | no: the same signature on 14 different keys |
| is the cable to blame | we removed the extension — it did not help; wire heating is ruled out |
| what changed | from 23 September (it got colder) — not a single sticky letter until the end of October |
| what about summer | it may come back; before the heat — pull-ups, capacitors, a thermometer nearby |
| the permanent fix | an ATmega32U4 adapter with TMK firmware |
Article changelog
| Date | What changed |
|---|---|
| 2026-11-07 | Photos of the adapter and both sides of its board. |
| 2026-11-02 | First version: symptom and sticky-key log, causes, the cold period, controllers and firmware overview, example firmware. |