Embedded HandbookПриложения
Все разделы › III. Периферия
STM32F072, STM32F401, STM32F411 · уровень средний · 36 мин чтения
Открыть в интерактивном справочнике

20г. Клавиатура на STM32: разбор QMK — матрица, антидребезг, слои и USB HID

В разделе 20в клавиатура была учебной: дескриптор отчёта, восемь байт, отправка из цикла. Настоящей клавиатуре этого мало. Ей надо опросить сотню клавиш, отсеять дребезг и не потерять быстрый набор, держать несколько слоёв раскладки, отличить короткое нажатие от удержания и уложиться в миллисекунды до хоста.

Здесь разобрана открытая прошивка QMK: в её дереве 3562 описания клавиатур с указанным микроконтроллером, из них 784 — на STM32, чаще всего F072, F401 и F411. Идём по пути одного нажатия: матрица, антидребезг, слои, отчёт, дескриптор, точка USB. Настоящие файлы QMK собраны gcc на компьютере и прогнаны против модели дребезжащего контакта и последовательности нажатий; их числа — в таблицах ниже. Рядом с каждым куском QMK — свой код для STM32F401 в трёх файлах (kbd.h, kbd.c, kbd_hw_f401.c): опрос матрицы регистрами, антидребезг, слои, отчёты и дескрипторы. Его можно скопировать целиком и собрать.

Опоры: исходники QMK (github.com/qmk/qmk_firmware, версия 0.34.5, снимок b1aea25); пути ниже даны от корня репозитория. Матрица и ложные нажатия без диодов разобраны в разделе 22.5, подтяжки входов — в разделе 8, язык дескриптора отчёта и загрузочная клавиатура — в разделе 20в, системный загрузчик и перепрошивка — в разделах 11 и 48. Свой код собран с заголовками CMSIS для STM32F401xC из пакета STM32F4xx_DFP 2.17.1.

Лицензия: QMK распространяется по GPL-2.0 или новее (заголовки файлов, текст — LICENSE); usb_descriptor.c вдобавок несёт разрешительное уведомление LUFA. Фрагменты кода ниже — короткие цитаты с указанием файла; копии нужных файлов лежат рядом с проверкой вместе с текстом лицензии.

Как это называется в документации

Оригинал Перевод
matrix scan опрос матрицы
diode direction: COL2ROW, ROW2COL направление диодов: от столбца к строке, от строки к столбцу
ghosting ложное («призрачное») нажатие
debounce: defer, eager антидребезг: отложенный (ждать тишины), немедленный (принять и не слушать)
global, per-row, per-key общий на всю матрицу, на строку, на клавишу
keymap, layer раскладка, слой
transparent key (KC_TRNS) прозрачная клавиша: смотреть слой ниже
momentary, toggle, layer-tap слой на время удержания, переключатель, слой при удержании и клавиша при коротком нажатии
tapping term порог «короткое или удержание»
6KRO, NKRO шесть одновременных клавиш; любое число клавиш
shared endpoint общая точка для нескольких отчётов с номерами
bootloader jump переход в загрузчик из прошивки

20г.1 Матрица: строка в ноль, читаем столбцы

Клавиши стоят в узлах сетки, как в разделе 22.5. QMK знает два направления диодов. При COL2ROW прошивка по очереди прижимает к земле строку и читает столбцы, при ROW2COL — наоборот. Всё остальное одинаково. Вот чтение одной строки из quantum/matrix.c:

    matrix_row_t row_shifter = MATRIX_ROW_SHIFTER;
    for (uint8_t col_index = 0; col_index < MATRIX_COLS; col_index++, row_shifter <<= 1) {
        uint8_t pin_state = readMatrixPin(col_pins[col_index]);

        // Populate the matrix row with the state of the col pin
        current_row_value |= pin_state ? 0 : row_shifter;
    }

    // Unselect row
    unselect_row(current_row);
    matrix_output_unselect_delay(current_row, current_row_value != 0); // wait for all Col signals to go HIGH

Три детали. Столбцы — входы с подтяжкой вверх, нажатая клавиша читается нулём. Отпущенную строку QMK не держит в единице, а переводит во вход с подтяжкой (gpio_set_pin_input_high): две клавиши одного столбца не замкнут выход-ноль на выход-единицу. Держать единицей можно, но только явно — макросом MATRIX_UNSELECT_DRIVE_HIGH. И после каждой строки прошивка ждёт: matrix_output_unselect_delay по умолчанию вызывает wait_us(MATRIX_IO_DELAY), а MATRIX_IO_DELAY — 30 мкс (quantum/matrix_common.c). Столбцы должны успеть подтянуться к единице, иначе следующая строка прочитает хвост предыдущей. Матрица из шести строк стоит поэтому минимум 180 мкс на один проход.

Диоды нужны ради того же, что в разделе 22.5: три нажатые клавиши в углах прямоугольника без диодов «зажигают» четвёртую. DIODE_DIRECTION в описании клавиатуры говорит прошивке, куда смотрят катоды; ошибка в нём — матрица, которая не видит ни одного нажатия. Бывает и матрица без сетки: DIRECT_PINS, каждая клавиша на своём выводе.

Свой опрос на F401. Та же схема регистрами — для платы на STM32F401 (как blackpill_f401 в QMK) с матрицей 4 × 8: строки PB12…PB15, столбцы PA0…PA7, диоды от столбца к строке. Общий заголовок своей клавиатуры, kbd.h:

/* Раздел 20г: своя клавиатура по мотивам QMK — общая часть без железа.
 * Матрица 4 × 8, антидребезг «на клавишу, ждать тишины», два слоя,
 * отчёты 6KRO (8 байт) и NKRO (32 байта), дескрипторы отчётов. */
#ifndef KBD_H
#define KBD_H
#include <stdint.h>

#define KBD_ROWS 4
#define KBD_COLS 8
#define KBD_DEBOUNCE_MS 5u          /* столько миллисекунд клавиша должна молчать */
#define KBD_NKRO_BYTES 30           /* коды 0x00…0xEF, как NKRO_REPORT_BITS в QMK */
#define KBD_NKRO_ID 6

/* коды клавиш: 0x04…0xE7 — коды HID, выше — свои служебные */
#define K_NO   0x0000
#define K_TRNS 0x0001               /* прозрачная: смотреть слой ниже */
#define K_MO1  0x0100               /* слой 1, пока нажата */

typedef uint8_t kbd_row_t;          /* бит c — столбец c */

typedef struct {
    uint8_t mods, reserved, keys[6];
} kbd_boot_report_t;

typedef struct {
    uint8_t id, mods, bits[KBD_NKRO_BYTES];
} kbd_nkro_report_t;

extern const uint16_t kbd_keymap[2][KBD_ROWS][KBD_COLS];
extern const uint8_t kbd_boot_desc[];
extern const uint16_t kbd_boot_desc_len;
extern const uint8_t kbd_nkro_desc[];
extern const uint16_t kbd_nkro_desc_len;

void kbd_debounce(const kbd_row_t raw[KBD_ROWS], kbd_row_t out[KBD_ROWS], uint32_t now_ms);
uint16_t kbd_keycode(const kbd_row_t m[KBD_ROWS], uint8_t row, uint8_t col);
uint8_t kbd_build_boot(const kbd_row_t m[KBD_ROWS], kbd_boot_report_t *r);
void kbd_build_nkro(const kbd_row_t m[KBD_ROWS], kbd_nkro_report_t *r);
#endif

Опрос (kbd_hw_f401.c). Выбранная строка — выход с нулём: ноль в ODR записан заранее, и переключается только режим в MODER. Пауза 30 мкс отсчитывается счётчиком тактов DWT:

#define ROW_PIN0 12u                    /* PB12 — строка 0 */
#define COL_MASK 0xFFu                  /* PA0…PA7 */
#define UNSELECT_US 30u                 /* как MATRIX_IO_DELAY в QMK */

static void delay_us(uint32_t us)
{
    uint32_t start = DWT->CYCCNT, ticks = us * (SystemCoreClock / 1000000u);
    while (DWT->CYCCNT - start < ticks) {
    }
}

void kbd_hw_init(void)
{
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIOBEN;
    (void)RCC->AHB1ENR;                                  /* такт дошёл до портов */
    GPIOA->MODER &= ~0x0000FFFFu;                        /* PA0…PA7 — входы */
    GPIOA->PUPDR = (GPIOA->PUPDR & ~0x0000FFFFu) | 0x00005555u;   /* подтяжка вверх */
    GPIOB->MODER &= ~0xFF000000u;                        /* PB12…PB15 — входы */
    GPIOB->PUPDR = (GPIOB->PUPDR & ~0xFF000000u) | 0x55000000u;
    GPIOB->BSRR = 0xFu << (ROW_PIN0 + 16u);              /* в ODR заранее нули */
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;      /* счётчик тактов для delay_us */
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}

/* Один проход матрицы: строку в выход с нулём, прочитать столбцы,
 * вернуть строку во вход и дать столбцам подтянуться. */
void kbd_scan(kbd_row_t raw[KBD_ROWS])
{
    for (uint32_t r = 0; r < KBD_ROWS; r++) {
        uint32_t shift = 2u * (ROW_PIN0 + r);
        GPIOB->MODER |= 1u << shift;                     /* выход, в ODR ноль */
        __DSB();
        delay_us(1);
        raw[r] = (kbd_row_t)(~GPIOA->IDR & COL_MASK);    /* ноль = нажата */
        GPIOB->MODER &= ~(3u << shift);                  /* снова вход с подтяжкой */
        delay_us(UNSELECT_US);
    }
}

После прохода matrix_scan() сравнивает новый снимок со старым (memcmp) и передаёт его антидребезгу вместе с флагом «что-то изменилось». Дальше нажатие идёт по цепочке на рисунке.

Путь нажатия в QMK слева направо: матрица — строка в ноль, 30 мкс ожидания после каждой строки; антидребезг sym_defer_g — 5 мс тишины, или немедленный вариант; раскладка и слои — сверху вниз, прозрачная клавиша смотрит ниже, TAPPING_TERM 200 мс; отчёт HID — 6KRO 8 байт или NKRO 32 байта; точка IN — хост опрашивает раз в 1 мс. Внизу задержки модели при опросе 0,25 мс и дребезге 3 мс: sym_defer_g 8,06 мс в среднем и до 9,25 мс, немедленные алгоритмы 0,76 мс и до 2,74 мс; немедленные принимают импульс помехи 0,5 мс в 1000 опытах из 1000, отложенные — ни в одном


20г.2 Антидребезг: семь алгоритмов и их цена

Алгоритм выбирают строкой DEBOUNCE_TYPE в правилах клавиатуры, по умолчанию — sym_defer_g (builddefs/common_features.mk). Время — макрос DEBOUNCE, по умолчанию 5 мс во всех шести файлах quantum/debounce/; седьмой, none.c, не фильтрует ничего. Названия читаются по частям: sym — одинаково для нажатия и отпускания, asym — по-разному; defer — ждать, пока контакт успокоится, eager — принять первое изменение сразу и потом не слушать клавишу DEBOUNCE мс; g, pr, pk — один таймер на всю матрицу, на строку, на клавишу.

Самый простой и самый распространённый — общий отложенный (quantum/debounce/sym_defer_g.c):

    if (changed) {
        debouncing      = true;
        debouncing_time = timer_read_fast();
    } else if (debouncing && timer_elapsed_fast(debouncing_time) >= DEBOUNCE) {
        size_t matrix_size = MATRIX_ROWS_PER_HAND * sizeof(matrix_row_t);
        if (memcmp(cooked, raw, matrix_size) != 0) {
            memcpy(cooked, raw, matrix_size);
            cooked_changed = true;
        }
        debouncing = false;
    }

Любое изменение где угодно в матрице перезапускает таймер; снимок уходит дальше, только когда вся матрица молчала DEBOUNCE мс.

Модель. Настоящие файлы алгоритмов собраны gcc вместе с заглушкой таймера (миллисекундный timer_read32, как на ChibiOS) и циклом, который повторяет matrix_scan(). Матрица 2 × 2: A и B в одной строке, C — в другой. Контакт при нажатии и отпускании дребезжит: окно заданной длины, внутри — переключения через случайные 20…300 мкс. Опрос раз в 0,25 мс (тесты QMK сами гоняют алгоритмы «from 1kHz to 10kHz scan rate» — quantum/debounce/tests/debounce_test_common.cpp), по 1000 опытов на строку. Задержка — от первого касания до изменения на выходе. DEBOUNCE = 5:

Алгоритм Нажатие, мс: среднее / макс. Отпускание, мс Лишних нажатий: дребезг 3 / 8 мс Помеха 0,5 мс — ложных A при наборе A+B / A+C, мс
none 0,26 / 1,74 0,24 / 1,83 6839 / 18752 1000 0,24 / 0,23
sym_defer_g 7,56 / 8,25 7,55 / 8,25 0 / 0 0 9,54 / 9,55
sym_defer_pr 7,56 / 8,25 7,55 / 8,25 0 / 0 0 9,54 / 9,55
sym_defer_pk 7,56 / 8,25 7,55 / 8,25 0 / 0 0 7,55 / 7,55
sym_eager_pk 0,26 / 1,74 0,24 / 1,83 0 / 2000 1000 0,24 / 0,23
sym_eager_pr 0,26 / 1,74 0,24 / 1,83 0 / 2000 1000 0,24 / 0,23
asym_eager_defer_pk 0,26 / 1,74 7,55 / 8,25 0 / 0 1000 0,24 / 0,23
свой kbd_debounce 7,56 / 8,25 7,55 / 8,25 0 / 0 0 7,55 / 7,55

Задержки нажатия и отпускания — при окне дребезга 3 мс; «A+B / A+C» — задержка A, если через 2 мс после неё нажата B (та же строка) или C (другая), обе с дребезгом 3 мс.

Что видно:

Свой антидребезг (kbd.c) — на клавишу и с ожиданием тишины, как sym_defer_pk, но короче: у каждой клавиши байт с моментом последнего изменения. В той же модели он дал ровно те же числа, что sym_defer_pk (последняя строка таблицы):

/* Антидребезг на клавишу с ожиданием тишины: изменение сырого бита
 * запоминает время; выход меняется, когда бит отличается от выхода
 * и не менялся KBD_DEBOUNCE_MS миллисекунд. */
void kbd_debounce(const kbd_row_t raw[KBD_ROWS], kbd_row_t out[KBD_ROWS], uint32_t now_ms)
{
    static kbd_row_t last[KBD_ROWS];
    static uint8_t stamp[KBD_ROWS][KBD_COLS];
    for (uint8_t r = 0; r < KBD_ROWS; r++) {
        kbd_row_t moved = (kbd_row_t)(raw[r] ^ last[r]);
        kbd_row_t differ = (kbd_row_t)(raw[r] ^ out[r]);
        last[r] = raw[r];
        for (uint8_t c = 0; c < KBD_COLS; c++) {
            kbd_row_t bit = (kbd_row_t)(1u << c);
            if (moved & bit)
                stamp[r][c] = (uint8_t)now_ms;
            else if ((differ & bit) && (uint8_t)((uint8_t)now_ms - stamp[r][c]) >= KBD_DEBOUNCE_MS)
                out[r] ^= bit;
        }
    }
}

Разность в uint8_t переживает переполнение счёта миллисекунд: выход меняется через 5 мс тишины, задолго до того, как 256 мс обернутся.

Сколько ставить. Тот же прогон для других DEBOUNCE, опрос 0,25 мс:

Алгоритм, DEBOUNCE Нажатие без дребезга, мс: мин. / макс. Лишних нажатий: дребезг 1 / 3 / 8 мс Помеха 0,5 мс — ложных
sym_defer_g, 1 0,25 / 1,24 0 / 166 / 1104 256
sym_defer_g, 5 4,25 / 5,24 0 / 0 / 0 0
sym_defer_g, 10 9,25 / 10,24 0 / 0 / 0 0
sym_eager_pk, 1 0,00 / 0,25 924 / 2832 / 7751 1000
sym_eager_pk, 5 0,00 / 0,25 0 / 0 / 2000 1000
sym_eager_pk, 10 0,00 / 0,25 0 / 0 / 0 1000

Таймер миллисекундный, поэтому «DEBOUNCE мс тишины» на деле — от DEBOUNCE − 1 до DEBOUNCE мс плюс период опроса: 4,25…5,24 мс при 5, а при 1 — от 0,25 мс, и половина импульсов помехи 0,5 мс проходит (256 из 1000). Правило простое: DEBOUNCE должен быть больше самого длинного дребезга с запасом в миллисекунду. При опросе раз в 1 мс немедленный алгоритм ловит помеху 0,5 мс в 500 опытах из 1000 — ровно в тех, где импульс накрыл момент опроса.

Во что это обходится памяти, показывает сборка под Cortex-M0 (F072) для матрицы 5 × 15 = 75 клавиш: sym_defer_g — 84 байта кода и 8 байт ОЗУ, sym_defer_pk — 316 и 83 (по байту-счётчику на клавишу), sym_defer_pr — 228 и 13, asym_eager_defer_pk — 456 и 83. Разница — сотни байт кода и счётчики в ОЗУ; выбирать алгоритм стоит по поведению, а не по размеру.


20г.3 Раскладка и слои

Раскладка — массив кодов keymaps[слой][строка][столбец]. Слои лежат стопкой: включённые отмечены битами в layer_state (по умолчанию 16 бит, MAX_LAYER = 16, quantum/action_layer.h), нижний — default_layer_state. Для каждой клавиши прошивка ищет сверху вниз первый включённый слой, где на этом месте стоит не прозрачный код (quantum/action_layer.c):

    layer_state_t layers = layer_state | default_layer_state;
    /* check top layer first */
    for (int8_t i = MAX_LAYER - 1; i >= 0; i--) {
        if (layers & ((layer_state_t)1 << i)) {
            action = action_for_key(i, key);
            if (action.code != ACTION_TRANSPARENT) {
                return i;
            }
        }
    }

Прозрачный код KC_TRNS (0x0001) — дыра в слое: смотри ниже. Так слой цифр может задать только верхний ряд, а остальное взять из основного. Слой запоминается в момент нажатия (store_or_get_action, если не задан STRICT_LAYER_RELEASE): если слой выключили, пока клавиша держится, отпускание уйдёт тому же слою, и модификатор не залипнет.

В своей клавиатуре то же самое короче: раскладка на два слоя, клавиша K_MO1 включает слой 1 на время удержания, K_TRNS пропускает вниз (kbd.c):

/* Раскладка: слой 0 — буквы, слой 1 — цифры в первом ряду, остальное прозрачно. */
const uint16_t kbd_keymap[2][KBD_ROWS][KBD_COLS] = {
    {{0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B},   /* A B C D E F G H */
     {0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13},   /* I J K L M N O P */
     {0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B},   /* Q R S T U V W X */
     {0xE1, 0xE0, K_MO1, 0x2C, 0x28, 0x2A, 0x1C, 0x1D}}, /* LShift LCtrl MO(1) Space Enter Bksp Y Z */
    {{0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25},   /* 1 … 8 */
     {K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS},
     {K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS},
     {K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS}},
};

Поиск кода — сверху вниз, как в QMK:

/* Код клавиши с учётом слоёв: слой 1 включён, пока нажата K_MO1;
 * ищем сверху вниз первый непрозрачный код. */
uint16_t kbd_keycode(const kbd_row_t m[KBD_ROWS], uint8_t row, uint8_t col)
{
    uint8_t top = 0;
    for (uint8_t r = 0; r < KBD_ROWS; r++)
        for (uint8_t c = 0; c < KBD_COLS; c++)
            if ((m[r] >> c & 1u) && kbd_keymap[0][r][c] == K_MO1)
                top = 1;
    for (int8_t layer = (int8_t)top; layer >= 0; layer--) {
        uint16_t k = kbd_keymap[layer][row][col];
        if (k != K_TRNS)
            return k;
    }
    return K_NO;
}

Прогон на ПК: без K_MO1 клавиша (0,0) даёт 0x0004 (A), с ней — 0x001E (цифра 1); клавиша (1,0) на слое 1 прозрачна и в обоих случаях даёт 0x000C (I).

Слоями в QMK управляют коды клавиш — 16-битные числа из quantum/keycodes.h и quantum/quantum_keycodes.h; их значения напечатала программа, собранная с этими заголовками:

Код Значение Что делает
MO(1) 0x5221 слой 1, пока клавиша нажата
TG(2) 0x5262 слой 2 включить или выключить
TO(0) 0x5200 оставить только слой 0
LT(1, KC_SPACE) 0x412C коротко — пробел, удержание — слой 1
MT(MOD_LCTL, KC_ESCAPE) 0x2129 коротко — Esc, удержание — левый Ctrl

В LT на номер слоя отведено 4 бита: LT(15, KC_SPACE) — 0x4F2C, а LT(16, KC_SPACE) — 0x402C, то есть молча LT(0, KC_SPACE). MO и TG берут 5 бит.

Короткое или удержание. Клавиша с двумя ролями (LT, MT) ждёт. Отпустили раньше порога TAPPING_TERM — это короткое нажатие, и в отчёт уходит пробел; держат дольше — это удержание, включается слой или модификатор. Порог по умолчанию — 200 мс (quantum/action_tapping.h). Цена — задержка: пробел на такой клавише появляется не при нажатии, а при отпускании. Опции PERMISSIVE_HOLD, HOLD_ON_OTHER_KEY_PRESS и другие решают, что делать, если во время ожидания нажата вторая клавиша; модель этого раздела их не прогоняла.


20г.4 Отчёт: шесть клавиш или все

Коды клавиш из раскладки собираются в отчёт функциями tmk_core/protocol/report.c. Обычный отчёт — загрузочная клавиатура из раздела 20в: байт модификаторов, резерв, шесть кодов, 8 байт (sizeof(report_keyboard_t) = 8, KEYBOARD_REPORT_KEYS = 6). Новый код add_key_byte кладёт в первую пустую ячейку; цикл перед этим ищет, нет ли кода в отчёте, и запоминает пустую ячейку в empty. Конец функции:

    if (i == KEYBOARD_REPORT_KEYS) {
        if (empty != -1) {
            keyboard_report->keys[empty] = code;
        }
    }
}

Если пустой нет — код просто не попадает в отчёт. Настоящий report.c, собранный на компьютере, на нажатиях A…H подряд:

+F         00 00 04 05 06 07 08 09 | keys=6
+G         00 00 04 05 06 07 08 09 | keys=6
+H         00 00 04 05 06 07 08 09 | keys=6
G pressed=0 H pressed=0
-A         00 00 00 05 06 07 08 09 | keys=5
after -A: G pressed=0

Седьмая и восьмая клавиши потерялись. Хуже: после отпускания A ячейка освободилась, а G, которую всё ещё держат, в отчёт так и не вернулась — код добавляется только в момент нажатия.

Своя сборка отчёта устроена иначе: отчёт каждый раз строится заново из всей матрицы, а не правится по событиям (kbd.c):

/* Отчёт 6KRO собирается заново из всей матрицы: освободилось место —
 * в него встаёт следующая удерживаемая клавиша. Возвращает число
 * нажатых обычных клавиш (больше 6 — часть не поместилась). */
uint8_t kbd_build_boot(const kbd_row_t m[KBD_ROWS], kbd_boot_report_t *r)
{
    uint8_t n = 0;
    memset(r, 0, sizeof *r);
    for (uint8_t row = 0; row < KBD_ROWS; row++)
        for (uint8_t col = 0; col < KBD_COLS; col++) {
            if (!(m[row] >> col & 1u))
                continue;
            uint16_t k = kbd_keycode(m, row, col);
            if (k >= 0xE0 && k <= 0xE7)
                r->mods |= (uint8_t)(1u << (k - 0xE0));
            else if (k >= 0x04 && k < 0xE0) {
                if (n < 6)
                    r->keys[n] = (uint8_t)k;
                n++;
            }
        }
    return n;
}

Тот же опыт с нажатиями A…H, потом отпускание A:

+F         00 00 04 05 06 07 08 09 | pressed=6
+G         00 00 04 05 06 07 08 09 | pressed=7
+H         00 00 04 05 06 07 08 09 | pressed=8
-A         00 00 05 06 07 08 09 0A | pressed=7

Как только A отпущена, в освободившееся место встала G (0x0A): её продолжают держать, а отчёт собран из матрицы. H так и не видна — мест всё равно шесть.

NKRO снимает предел. Отчёт — битовая карта: номер 6 (REPORT_ID_NKRO), байт модификаторов и NKRO_REPORT_BITS = 30 байт, по биту на код 0x00…0xEF; sizeof(report_nkro_t) = 32. Код 0x04 (A) — бит 4 первого байта карты. Тот же прогон с NKRO_ENABLE:

+H         06 00 F0 0F 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | keys=2
G pressed=1 H pressed=1
-A         06 00 E0 0F 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | keys=2

Все восемь на месте: 0xF0 — A…D, 0x0F — E…H. Своя функция даёт те же байты для тех же восьми клавиш:

/* Отчёт NKRO: номер, модификаторы и по биту на код 0x00…0xEF. */
void kbd_build_nkro(const kbd_row_t m[KBD_ROWS], kbd_nkro_report_t *r)
{
    memset(r, 0, sizeof *r);
    r->id = KBD_NKRO_ID;
    for (uint8_t row = 0; row < KBD_ROWS; row++)
        for (uint8_t col = 0; col < KBD_COLS; col++) {
            if (!(m[row] >> col & 1u))
                continue;
            uint16_t k = kbd_keycode(m, row, col);
            if (k >= 0xE0 && k <= 0xE7)
                r->mods |= (uint8_t)(1u << (k - 0xE0));
            else if (k >= 0x04 && k < KBD_NKRO_BYTES * 8)
                r->bits[k >> 3] |= (uint8_t)(1u << (k & 7));
        }
}

В выводе QMK видна мелочь: has_anykey() в режиме NKRO считает ненулевые байты, а не клавиши, поэтому «keys=2» при восьми нажатых. Как логический признак «что-то нажато» она работает верно.

NKRO включится не всегда. host_can_send_nkro() (tmk_core/protocol/host.c) требует, чтобы хост перевёл устройство в протокол отчётов; хост, который понимает только загрузочную клавиатуру, получает обычные 8 байт. И ещё флаг в настройках: NKRO_DEFAULT_ON по умолчанию false (quantum/eeconfig.c), переключают его клавишей NK_TOGG.


20г.5 Дескриптор и точки: что видит хост

Дескрипторы отчёта QMK написаны макросами LUFA HID_RI_* в tmk_core/protocol/usb_descriptor.c. Подмодуль LUFA в снимке пуст, поэтому макросы переписаны заново по тегам элементов из hid.h Zephyr (как в разделе 20в), а сами строки дескрипторов вырезаны из файла без изменений и собраны gcc. Свой разборщик на Python прочитал байты:

Дескриптор Длина Элементов Вход Выход
KeyboardReport 68 байт 34 8 × 1 бит модификаторов, 1 × 8 резерв, 6 × 8 кодов = 8 байт 5 бит светодиодов + 3 бита заполнения = 1 байт
NKRO в SharedReport 59 байт 30 номер 6, 8 × 1 бит, 240 × 1 бит = 32 байта номер 6 + 1 байт = 2 байта

Свои дескрипторы — обычные массивы байт с разбором в комментариях; загрузочный целиком (kbd.c):

/* Дескриптор загрузочной клавиатуры: вход 8 байт, выход 1 байт (светодиоды). */
const uint8_t kbd_boot_desc[] = {
    0x05, 0x01,       /* Usage Page (Generic Desktop) */
    0x09, 0x06,       /* Usage (Keyboard) */
    0xA1, 0x01,       /* Collection (Application) */
    0x05, 0x07,       /*   Usage Page (Keyboard) */
    0x19, 0xE0,       /*   Usage Minimum (Left Control) */
    0x29, 0xE7,       /*   Usage Maximum (Right GUI) */
    0x15, 0x00,       /*   Logical Minimum (0) */
    0x25, 0x01,       /*   Logical Maximum (1) */
    0x95, 0x08,       /*   Report Count (8) */
    0x75, 0x01,       /*   Report Size (1) */
    0x81, 0x02,       /*   Input (Data, Var, Abs): модификаторы */
    0x95, 0x01,       /*   Report Count (1) */
    0x75, 0x08,       /*   Report Size (8) */
    0x81, 0x01,       /*   Input (Const): резерв */
    0x05, 0x08,       /*   Usage Page (LEDs) */
    0x19, 0x01,       /*   Usage Minimum (Num Lock) */
    0x29, 0x05,       /*   Usage Maximum (Kana) */
    0x95, 0x05,       /*   Report Count (5) */
    0x75, 0x01,       /*   Report Size (1) */
    0x91, 0x02,       /*   Output (Data, Var, Abs): светодиоды */
    0x95, 0x01,       /*   Report Count (1) */
    0x75, 0x03,       /*   Report Size (3) */
    0x91, 0x01,       /*   Output (Const): добивка до байта */
    0x05, 0x07,       /*   Usage Page (Keyboard) */
    0x19, 0x00,       /*   Usage Minimum (0) */
    0x29, 0xFF,       /*   Usage Maximum (255) */
    0x15, 0x00,       /*   Logical Minimum (0) */
    0x26, 0xFF, 0x00, /*   Logical Maximum (255) */
    0x95, 0x06,       /*   Report Count (6) */
    0x75, 0x08,       /*   Report Size (8) */
    0x81, 0x00,       /*   Input (Data, Array, Abs): шесть кодов */
    0xC0,             /* End Collection */
};
const uint16_t kbd_boot_desc_len = sizeof kbd_boot_desc;

Тот же разборщик прочитал его как 64 байта и 32 элемента: вход 8 байт, выход 1 байт. Светодиоды здесь описаны раньше кодов клавиш: порядок главных элементов разных видов не важен, отчёт входа всё равно «модификаторы, резерв, шесть кодов». Свой NKRO (kbd_nkro_desc в том же файле) — 53 байта, 27 элементов, вход 32 байта с номером 6, выход 2 байта: Logical Minimum и Maximum второй раз не повторяются, они глобальные и живут до перезаписи (раздел 20в).

Размеры совпали с report.h и с размерами точек в usb_descriptor.h: у отдельного интерфейса клавиатуры KEYBOARD_EPSIZE = 8, у общей точки SHARED_EPSIZE = 32. В общую точку QMK складывает всё, что идёт с номером отчёта: NKRO, мышь (MOUSE_SHARED_EP по умолчанию включён), системные и потребительские клавиши; как только включено хоть что-то из этого, появляется второй интерфейс (tmk_core/protocol.mk). Загрузочная клавиатура остаётся на своём интерфейсе без номера отчёта — её понимает BIOS.

Одна находка при сверке: комментарий в report.h до сих пор говорит, что в NKRO отчёт растягивается до 16 байт на 120 клавиш. Код давно другой: 30 байт карты, 240 бит.

Период опроса — USB_POLLING_INTERVAL_MS = 1, он же bInterval у точек клавиатуры, мыши и общей. На полной скорости bInterval считают в миллисекундах от 1 до 255 (раздел 20в.6), так что это самый частый опрос.


20г.6 Бюджет задержки, платформа STM32 и загрузчик

От контакта до хоста складываются три слагаемых: пока матрица увидит изменение (до периода опроса), пока антидребезг его пропустит, и пока хост заберёт отчёт (в среднем полпериода опроса USB, до целого). По модели при опросе 0,25 мс и дребезге 3 мс:

Алгоритм Нажатие до отчёта, мс: среднее / макс. С опросом USB 1 мс: среднее / макс.
sym_defer_g 7,56 / 8,25 8,06 / 9,25
sym_eager_pk 0,26 / 1,74 0,76 / 2,74
asym_eager_defer_pk 0,26 / 1,74 0,76 / 2,74

Всё вместе — одна функция, которую зовут раз в миллисекунду; отчёт, если он изменился, отдают в точку IN так же, как в разделе 20в (kbd_hw_f401.c):

/* Вызывать раз в миллисекунду (или чаще): опрос, антидребезг, отчёт.
 * Возвращает 1, если отчёт изменился и его пора отдать в точку IN. */
int kbd_task(uint32_t now_ms, kbd_boot_report_t *report)
{
    static kbd_row_t raw[KBD_ROWS], cooked[KBD_ROWS];
    static kbd_boot_report_t last;
    kbd_scan(raw);
    kbd_debounce(raw, cooked, now_ms);
    kbd_build_boot(cooked, report);
    if (report->mods == last.mods && report->keys[0] == last.keys[0] && report->keys[1] == last.keys[1]
        && report->keys[2] == last.keys[2] && report->keys[3] == last.keys[3] && report->keys[4] == last.keys[4]
        && report->keys[5] == last.keys[5])
        return 0;
    last = *report;
    return 1;
}

Под Cortex-M4 (armclang, -Os) kbd.c занимает 466 байт кода, 281 байт констант (раскладка и два дескриптора) и 36 байт ОЗУ, kbd_hw_f401.c — 402 байта кода и 16 байт ОЗУ.

Опрос USB — не главное слагаемое: 0,5 мс против 7,56 мс антидребезга. Кому нужна скорость, меняет алгоритм, а не bInterval. Сколько стоит сам проход матрицы на конкретной плате, модель не знает: к 30 мкс на строку добавляется чтение выводов, и мерить это надо на железе.

ChibiOS. На ARM QMK работает поверх ChibiOS (platforms/chibios). Какие кристаллы понимает сборка, видно в platforms/chibios/mcu_selection.mk: 20 имён STM32 — от F042 и F072 до G474, L4 и H723. Для трёх из заголовка:

Кристалл Ядро Плата по умолчанию Адрес системного загрузчика Описаний клавиатур
STM32F072 Cortex-M0 GENERIC_STM32_F072XB 0x1FFFC800 277
STM32F401 Cortex-M4 GENERIC_STM32_F401XC 0x1FFF0000 90
STM32F411 Cortex-M4 GENERIC_STM32_F411XE 0x1FFF0000 69

Последний столбец — файлы keyboard.json и info.json в keyboards/, где процессор указан прямо или через плату-донор: 25 описаний F401 и 28 описаний F411 — на платах blackpill_f401 и blackpill_f411. Всего на STM32 — 784 описания; для сравнения: ATmega32U4 — 2103, RP2040 — 333. F072 — самый частый STM32 в QMK; USB у него может работать без кварца, от HSI48 с подстройкой CRS (раздел 6.7).

Загрузчик. Прошивка клавиатуры обновляется по USB, и для STM32 способов три (platforms/chibios/bootloader.mk, docs/flashing.md):

__attribute__((weak)) void bootloader_jump(void) {
    bootloader_marker_enable();
    NVIC_SystemReset();
}

Метка — 0xDEADBEEF в последнем слове ОЗУ; шьют dfu-util -a 0 -d 0483:DF11. - tinyuf2 — свой загрузчик, пока только для F303, F401 и F411: плата появляется как USB-диск, и файл .uf2 на него просто копируют. Из прошивки в него входят меткой 0xF01669EF и сбросом. - stm32duino — для F103, у которого заводского загрузчика по USB нет (плата bluepill).

Собрать прошивку целиком не вышло. Подмодули lib/chibios, lib/chibios-contrib, lib/lufa, lib/printf и lib/googletest в снимке пусты и для раздела не выкачивались. Без ChibiOS нет ни HAL, ни драйвера USB, так что размер образа для F072 не измерен. Собраны только отдельные файлы: алгоритмы антидребезга под Cortex-M0 и M4 (20г.2) и модели на компьютере.


20г.7 Грабли

Дребезг проходит, хотя DEBOUNCE стоит. Таймер миллисекундный, и DEBOUNCE 1 ждёт тишины от 0 до 1 мс. В модели так прошли 166 лишних нажатий на 1000 при дребезге 3 мс и 256 ложных от помехи 0,5 мс. Меньше 5 без замеров контакта не ставить.

Немедленный антидребезг «печатает сам». sym_eager_* и asym_eager_defer_pk принимают любой импульс длиннее периода опроса: наводка на длинном шлейфе матрицы — уже нажатие. Если клавиатура «нажимает» в тишине, первым делом вернуть sym_defer_*.

Двойные нажатия на старых переключателях. Немедленный алгоритм с DEBOUNCE 5 при дребезге 8 мс дал 2000 лишних нажатий на 1000 опытов. Лечится увеличением DEBOUNCE (при 10 — ноль) или asym_eager_defer_pk.

Быстрый набор тормозит. Общий таймер sym_defer_g и sym_defer_pr ждут тишины всей матрицы: соседняя клавиша через 2 мс добавила 2 мс задержки. Помогает sym_defer_pk.

Седьмая клавиша не печатается и после отпускания первой. Отчёт 6KRO полон, add_key_byte молча выбросил код, а заново его никто не добавит. Нужен NKRO — и хост в протоколе отчётов.

LT на 16-й слой ведёт в нулевой. В LT четыре бита на слой; LT(16, KC_SPACE) равен LT(0, KC_SPACE).

Пробел появляется при отпускании. На клавише LT или MT короткое нажатие распознаётся только после отпускания, а удержание — только через TAPPING_TERM (200 мс). Для быстрой печати порог подбирают под руку или переносят двойные роли с букв.

Ни одна клавиша не нажимается. Перепутан DIODE_DIRECTION: диоды закрыты в ту сторону, куда прошивка тянет ток.

Прошивка не собирается из архива. Подмодули ChibiOS, LUFA и остальные в архиве пусты; сборке нужен полный клон с подмодулями.


20г.8 Итоги

Полные исходники

kbd.c — своя клавиатура: антидребезг, слои, отчёты, дескрипторы.

/* Раздел 20г: своя клавиатура — антидребезг, слои, отчёты, дескрипторы.
 * Работает с готовым снимком матрицы, поэтому собирается и на ПК, и под STM32. */
#include <string.h>
#include "kbd.h"

/* Раскладка: слой 0 — буквы, слой 1 — цифры в первом ряду, остальное прозрачно. */
const uint16_t kbd_keymap[2][KBD_ROWS][KBD_COLS] = {
    {{0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B},   /* A B C D E F G H */
     {0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13},   /* I J K L M N O P */
     {0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B},   /* Q R S T U V W X */
     {0xE1, 0xE0, K_MO1, 0x2C, 0x28, 0x2A, 0x1C, 0x1D}}, /* LShift LCtrl MO(1) Space Enter Bksp Y Z */
    {{0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25},   /* 1 … 8 */
     {K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS},
     {K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS},
     {K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS, K_TRNS}},
};

/* Антидребезг на клавишу с ожиданием тишины: изменение сырого бита
 * запоминает время; выход меняется, когда бит отличается от выхода
 * и не менялся KBD_DEBOUNCE_MS миллисекунд. */
void kbd_debounce(const kbd_row_t raw[KBD_ROWS], kbd_row_t out[KBD_ROWS], uint32_t now_ms)
{
    static kbd_row_t last[KBD_ROWS];
    static uint8_t stamp[KBD_ROWS][KBD_COLS];
    for (uint8_t r = 0; r < KBD_ROWS; r++) {
        kbd_row_t moved = (kbd_row_t)(raw[r] ^ last[r]);
        kbd_row_t differ = (kbd_row_t)(raw[r] ^ out[r]);
        last[r] = raw[r];
        for (uint8_t c = 0; c < KBD_COLS; c++) {
            kbd_row_t bit = (kbd_row_t)(1u << c);
            if (moved & bit)
                stamp[r][c] = (uint8_t)now_ms;
            else if ((differ & bit) && (uint8_t)((uint8_t)now_ms - stamp[r][c]) >= KBD_DEBOUNCE_MS)
                out[r] ^= bit;
        }
    }
}

/* Код клавиши с учётом слоёв: слой 1 включён, пока нажата K_MO1;
 * ищем сверху вниз первый непрозрачный код. */
uint16_t kbd_keycode(const kbd_row_t m[KBD_ROWS], uint8_t row, uint8_t col)
{
    uint8_t top = 0;
    for (uint8_t r = 0; r < KBD_ROWS; r++)
        for (uint8_t c = 0; c < KBD_COLS; c++)
            if ((m[r] >> c & 1u) && kbd_keymap[0][r][c] == K_MO1)
                top = 1;
    for (int8_t layer = (int8_t)top; layer >= 0; layer--) {
        uint16_t k = kbd_keymap[layer][row][col];
        if (k != K_TRNS)
            return k;
    }
    return K_NO;
}

/* Отчёт 6KRO собирается заново из всей матрицы: освободилось место —
 * в него встаёт следующая удерживаемая клавиша. Возвращает число
 * нажатых обычных клавиш (больше 6 — часть не поместилась). */
uint8_t kbd_build_boot(const kbd_row_t m[KBD_ROWS], kbd_boot_report_t *r)
{
    uint8_t n = 0;
    memset(r, 0, sizeof *r);
    for (uint8_t row = 0; row < KBD_ROWS; row++)
        for (uint8_t col = 0; col < KBD_COLS; col++) {
            if (!(m[row] >> col & 1u))
                continue;
            uint16_t k = kbd_keycode(m, row, col);
            if (k >= 0xE0 && k <= 0xE7)
                r->mods |= (uint8_t)(1u << (k - 0xE0));
            else if (k >= 0x04 && k < 0xE0) {
                if (n < 6)
                    r->keys[n] = (uint8_t)k;
                n++;
            }
        }
    return n;
}

/* Отчёт NKRO: номер, модификаторы и по биту на код 0x00…0xEF. */
void kbd_build_nkro(const kbd_row_t m[KBD_ROWS], kbd_nkro_report_t *r)
{
    memset(r, 0, sizeof *r);
    r->id = KBD_NKRO_ID;
    for (uint8_t row = 0; row < KBD_ROWS; row++)
        for (uint8_t col = 0; col < KBD_COLS; col++) {
            if (!(m[row] >> col & 1u))
                continue;
            uint16_t k = kbd_keycode(m, row, col);
            if (k >= 0xE0 && k <= 0xE7)
                r->mods |= (uint8_t)(1u << (k - 0xE0));
            else if (k >= 0x04 && k < KBD_NKRO_BYTES * 8)
                r->bits[k >> 3] |= (uint8_t)(1u << (k & 7));
        }
}

/* Дескриптор загрузочной клавиатуры: вход 8 байт, выход 1 байт (светодиоды). */
const uint8_t kbd_boot_desc[] = {
    0x05, 0x01,       /* Usage Page (Generic Desktop) */
    0x09, 0x06,       /* Usage (Keyboard) */
    0xA1, 0x01,       /* Collection (Application) */
    0x05, 0x07,       /*   Usage Page (Keyboard) */
    0x19, 0xE0,       /*   Usage Minimum (Left Control) */
    0x29, 0xE7,       /*   Usage Maximum (Right GUI) */
    0x15, 0x00,       /*   Logical Minimum (0) */
    0x25, 0x01,       /*   Logical Maximum (1) */
    0x95, 0x08,       /*   Report Count (8) */
    0x75, 0x01,       /*   Report Size (1) */
    0x81, 0x02,       /*   Input (Data, Var, Abs): модификаторы */
    0x95, 0x01,       /*   Report Count (1) */
    0x75, 0x08,       /*   Report Size (8) */
    0x81, 0x01,       /*   Input (Const): резерв */
    0x05, 0x08,       /*   Usage Page (LEDs) */
    0x19, 0x01,       /*   Usage Minimum (Num Lock) */
    0x29, 0x05,       /*   Usage Maximum (Kana) */
    0x95, 0x05,       /*   Report Count (5) */
    0x75, 0x01,       /*   Report Size (1) */
    0x91, 0x02,       /*   Output (Data, Var, Abs): светодиоды */
    0x95, 0x01,       /*   Report Count (1) */
    0x75, 0x03,       /*   Report Size (3) */
    0x91, 0x01,       /*   Output (Const): добивка до байта */
    0x05, 0x07,       /*   Usage Page (Keyboard) */
    0x19, 0x00,       /*   Usage Minimum (0) */
    0x29, 0xFF,       /*   Usage Maximum (255) */
    0x15, 0x00,       /*   Logical Minimum (0) */
    0x26, 0xFF, 0x00, /*   Logical Maximum (255) */
    0x95, 0x06,       /*   Report Count (6) */
    0x75, 0x08,       /*   Report Size (8) */
    0x81, 0x00,       /*   Input (Data, Array, Abs): шесть кодов */
    0xC0,             /* End Collection */
};
const uint16_t kbd_boot_desc_len = sizeof kbd_boot_desc;

/* Дескриптор NKRO: номер 6, модификаторы и 240 бит — по биту на код. */
const uint8_t kbd_nkro_desc[] = {
    0x05, 0x01,       /* Usage Page (Generic Desktop) */
    0x09, 0x06,       /* Usage (Keyboard) */
    0xA1, 0x01,       /* Collection (Application) */
    0x85, KBD_NKRO_ID, /*  Report ID (6) */
    0x05, 0x07,       /*   Usage Page (Keyboard) */
    0x19, 0xE0,       /*   Usage Minimum (Left Control) */
    0x29, 0xE7,       /*   Usage Maximum (Right GUI) */
    0x15, 0x00,       /*   Logical Minimum (0) */
    0x25, 0x01,       /*   Logical Maximum (1) */
    0x95, 0x08,       /*   Report Count (8) */
    0x75, 0x01,       /*   Report Size (1) */
    0x81, 0x02,       /*   Input (Data, Var, Abs): модификаторы */
    0x19, 0x00,       /*   Usage Minimum (0) */
    0x29, KBD_NKRO_BYTES * 8 - 1, /* Usage Maximum (0xEF) */
    0x95, KBD_NKRO_BYTES * 8,     /* Report Count (240) */
    0x75, 0x01,       /*   Report Size (1) */
    0x81, 0x02,       /*   Input (Data, Var, Abs): карта клавиш */
    0x05, 0x08,       /*   Usage Page (LEDs) */
    0x19, 0x01,       /*   Usage Minimum (Num Lock) */
    0x29, 0x05,       /*   Usage Maximum (Kana) */
    0x95, 0x05,       /*   Report Count (5) */
    0x75, 0x01,       /*   Report Size (1) */
    0x91, 0x02,       /*   Output (Data, Var, Abs): светодиоды */
    0x95, 0x01,       /*   Report Count (1) */
    0x75, 0x03,       /*   Report Size (3) */
    0x91, 0x01,       /*   Output (Const) */
    0xC0,             /* End Collection */
};
const uint16_t kbd_nkro_desc_len = sizeof kbd_nkro_desc;

_Static_assert(sizeof(kbd_boot_report_t) == 8, "boot report: 8 bytes");
_Static_assert(sizeof(kbd_nkro_report_t) == 32, "NKRO report: 32 bytes");

kbd_hw_f401.c — опрос матрицы 4 × 8 на STM32F401 регистрами.

/* Раздел 20г: опрос матрицы 4 × 8 на STM32F401 регистрами (CMSIS).
 * Строки PB12…PB15, столбцы PA0…PA7, диоды от столбца к строке (COL2ROW):
 * выбранная строка — выход с нулём, остальные — входы с подтяжкой,
 * столбцы — входы с подтяжкой, нажатая клавиша читается нулём. */
#include "stm32f4xx.h"
#include "kbd.h"

#define ROW_PIN0 12u                    /* PB12 — строка 0 */
#define COL_MASK 0xFFu                  /* PA0…PA7 */
#define UNSELECT_US 30u                 /* как MATRIX_IO_DELAY в QMK */

static void delay_us(uint32_t us)
{
    uint32_t start = DWT->CYCCNT, ticks = us * (SystemCoreClock / 1000000u);
    while (DWT->CYCCNT - start < ticks) {
    }
}

void kbd_hw_init(void)
{
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIOBEN;
    (void)RCC->AHB1ENR;                                  /* такт дошёл до портов */
    GPIOA->MODER &= ~0x0000FFFFu;                        /* PA0…PA7 — входы */
    GPIOA->PUPDR = (GPIOA->PUPDR & ~0x0000FFFFu) | 0x00005555u;   /* подтяжка вверх */
    GPIOB->MODER &= ~0xFF000000u;                        /* PB12…PB15 — входы */
    GPIOB->PUPDR = (GPIOB->PUPDR & ~0xFF000000u) | 0x55000000u;
    GPIOB->BSRR = 0xFu << (ROW_PIN0 + 16u);              /* в ODR заранее нули */
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;      /* счётчик тактов для delay_us */
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}

/* Один проход матрицы: строку в выход с нулём, прочитать столбцы,
 * вернуть строку во вход и дать столбцам подтянуться. */
void kbd_scan(kbd_row_t raw[KBD_ROWS])
{
    for (uint32_t r = 0; r < KBD_ROWS; r++) {
        uint32_t shift = 2u * (ROW_PIN0 + r);
        GPIOB->MODER |= 1u << shift;                     /* выход, в ODR ноль */
        __DSB();
        delay_us(1);
        raw[r] = (kbd_row_t)(~GPIOA->IDR & COL_MASK);    /* ноль = нажата */
        GPIOB->MODER &= ~(3u << shift);                  /* снова вход с подтяжкой */
        delay_us(UNSELECT_US);
    }
}

/* Вызывать раз в миллисекунду (или чаще): опрос, антидребезг, отчёт.
 * Возвращает 1, если отчёт изменился и его пора отдать в точку IN. */
int kbd_task(uint32_t now_ms, kbd_boot_report_t *report)
{
    static kbd_row_t raw[KBD_ROWS], cooked[KBD_ROWS];
    static kbd_boot_report_t last;
    kbd_scan(raw);
    kbd_debounce(raw, cooked, now_ms);
    kbd_build_boot(cooked, report);
    if (report->mods == last.mods && report->keys[0] == last.keys[0] && report->keys[1] == last.keys[1]
        && report->keys[2] == last.keys[2] && report->keys[3] == last.keys[3] && report->keys[4] == last.keys[4]
        && report->keys[5] == last.keys[5])
        return 0;
    last = *report;
    return 1;
}

Дальше: раздел 20д «Своё USB-устройство без драйвера: дескрипторы по байтам, MS OS 2.0 и WinUSB» — свой класс и массовые точки, а драйвер Windows ставит сама.

Открыть в интерактивном справочнике