Как декодировать QR-код руками

Edit...
howto
Illustrated by Igan Pol

Привет, %username%! QR-коды в 2026 году встречаются буквально везде: меню в кафе, оплата в магазине, ссылка на Wi-Fi в аэропорту, чек из терминала. Камера телефона декодирует такой код за десятки миллисекунд, и большинство людей воспринимают QR как магический чёрно-белый прямоугольник, который «как-то превращается в URL». А внутри — обычный документированный стандарт ISO/IEC 18004, и при желании его реально разобрать вручную: с карандашом, бумагой и базовой арифметикой.

В этом разборе пройдём по шагам, как декодировать QR-код глазами: что такое finder patterns и тайминг-полоски, где зашиты маска и уровень коррекции ошибок, как читать данные зигзагом снизу-справа вверх и собирать из битов исходную строку. В качестве подопытного — самый маленький Version 1 (21×21) с содержимым "HI", чтобы маска и зигзаг укладывались в голове, а не в Excel.

TL;DR#

  • Стандарт — ISO/IEC 18004, всего 40 версий: V1 — 21×21 модуль, дальше шаг +4 модуля на сторону до V40 (177×177).
  • Три больших квадрата по углам — finder patterns, задают ориентацию. Четвёртого (в правом нижнем углу) нет — это и есть «низ-право».
  • 15 бит format info вокруг finder-ов хранят уровень коррекции (L/M/Q/H) и номер маски (0–7); защищены своим BCH-кодом и продублированы дважды.
  • Маска нужна, чтобы зона данных не складывалась в длинные одноцветные полосы и не имитировала случайно finder-паттерн — сканер бы поплыл. После XOR с правильной маской биты читаются «честно».
  • Данные читаются зигзагом снизу-справа вверх, парами столбцов, перепрыгивая тайминг-колонку №6, по 8 бит на байт.
  • Первые 4 бита — Mode Indicator (numeric / alphanumeric / byte / kanji), дальше Character Count Indicator переменной длины, потом сами данные, терминатор 0000 и padding EC 11 EC 11 ....
  • За payload идут байты Reed-Solomon для коррекции ошибок: код над полем GF(256), число codeword’ов зависит от версии и уровня (для V1-L — 7, для V1-H — уже 17). В этом разборе их сознательно пропускаем — без таблиц и GF-арифметики на бумаге не возьмёшь.

После пары часов практики можно вытащить из небольшого кода (V1, 21×21) короткую строку вроде URL без сканера. До «прочитал визитку взглядом за секунду» — уже из области цирковых номеров: Reed-Solomon без вычислений в уме не выкрутишь, а на V10+ зигзаг становится ощутимо больно держать в голове.


Подопытный#

Самый маленький размер — Version 1 (21×21 модуль). Содержимое: "HI". Раскрашены функциональные зоны.

Схема Version 1 QR-кода 21×21 с раскрашенными зонами: finder patterns, тайминг, format info, dark module, данные

Шаг 1. Ориентируемся#

Три синих квадрата 7×7 — finder patterns. Они нужны сканеру, чтобы понять, где верх и поворот кода. Правило простое: три есть, а четвёртого (в правом нижнем углу) нет — туда смотрит «низ-право».

Вокруг каждого finder-а идёт separator — белая рамка в один модуль шириной (на схеме это пустая полоса между синим квадратом и всем остальным). Назначение прямолинейное: изолировать finder от данных, чтобы сканер не пытался разобрать «полуfinder-полуданные» на стыке и не плыл по координатам.

Между ними тянутся красные тайминг-полоски (строка 6 и столбец 6) — чередование чёрный-белый-чёрный. Они задают «линейку», по которой считаются координаты модулей. Если что-то поплыло — выравниваешься по ним.

Фиолетовый одиночный модуль — dark module, всегда чёрный по стандарту. Просто маркер, не несёт смысла.

Оранжевая зона — format info. 15 бит, продублированные дважды (для надёжности): одна копия идёт буквой Г вокруг левого верхнего finder-а, вторая — полоской справа от правого верхнего и снизу от левого нижнего. В ней зашифрованы две критичные вещи: уровень коррекции ошибок (L/M/Q/H) и номер маски (0–7).

Всё зелёное — это собственно данные плюс байты Рида-Соломона.

Чего в V1 ещё нет, но в больших версиях встретишь#

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

  • Alignment patterns — маленькие квадраты 5×5 с чёрной точкой в центре, разбросанные по полю. Появляются с V2 (один штук, в правом нижнем углу), к V7 их уже шесть, а у V40 — целых 46. Нужны для локальной компенсации искажений сетки: V1 ещё читается «по линейке тайминга», а у большого QR на смятой или сфотографированной под углом картинке координаты модулей плывут, и без локальных якорей сканер промахивается.
  • Version info — два блока 6×3 рядом с правым верхним и левым нижним finder-ами, появляются с V7. Внутри 18 бит: 6 бит самого номера версии и 12 бит BCH-чётности. Подход тот же, что у format info, только код покрупнее — BCH(18, 6), тоже исправляет до 3 ошибок. Декодеру эта зона нужна, чтобы достоверно определить размер кода, не доверяя «посчитаю клетки по краю» (на размытом снимке такой подсчёт ломается легко).

В V1 этого ничего нет, поэтому в нашем разборе обходимся без них — но если ты потом возьмёшь V3 с того же чека, держи их в голове: чёрная точка в правом нижнем — не часть данных, а alignment.


Шаг 2. Снимаем format info#

Биты идут: вдоль строки 8 слева направо (с пропуском тайминг-колонки), а потом по столбцу 8 снизу вверх (тоже с пропуском тайминг-строки). Старший бит = #14, младший = #0.

Фокус на левый верхний угол: 15 клеток format info с номерами #14…#0 и их значениями

Считываем по порядку от #14 до #0:

сырые биты : 1 1 0 1 1 0 0 0 1 0 0 0 0 0 1
XOR-маска  : 1 0 1 0 1 0 0 0 0 0 1 0 0 1 0
─────────────────────────────────────────
результат  : 0 1 1 1 0 0 0 0 1 0 1 0 0 1 1

Маска 101010000010010 зашита в стандарте — иначе формат, состоящий из нулей, тоже был бы валидным, и любая дырка на пустом месте всё ломала бы.

Разбираем результат 011100001010011:

  • биты 14–13 = 01 → уровень коррекции L (~7 % избыточности)
  • биты 12–10 = 110маска №6
  • остальные 10 бит — BCH-код для коррекции ошибок в самих этих 15 битах (нам не нужны).

Теперь самое важное: мы знаем маску. Значит, можно снять её с зелёной зоны данных и прочитать настоящие биты.

Маска №6 задаётся формулой: клетка (r, c) инвертируется, если

$$ ((r \cdot c) \bmod 2 + (r \cdot c) \bmod 3) \bmod 2 == 0 $$

Кодирование уровня коррекции#

битыуровеньизбыточность
01L~7 %
00M~15 %
11Q~25 %
10H~30 %

BCH(15, 5) под капотом#

Format info — это не просто 15 случайных бит. Это BCH-код (15, 5) над GF(2): 5 содержательных бит (2 на EC level + 3 на номер маски) защищаются 10 битами чётности. Такой код исправляет до 3 ошибочных бит внутри своих 15 — то есть даже если три клетки в зоне format info замазаны или прокрашены неправильно, декодер всё равно вытянет верный уровень коррекции и маску.

Кодирование считается через порождающий полином

$$ G(x) = x^{10} + x^8 + x^5 + x^4 + x^2 + x + 1 $$

в двоичном виде — 10100110111. Алгоритм такой:

  1. Берём 5 бит данных d = [EC level (2 бита)] [mask (3 бита)].
  2. Сдвигаем влево на 10 позиций: получаем d · x^{10}.
  3. Делим в GF(2) (по сути — длинное XOR-вычитание) на G(x), остаток — это 10 бит чётности.
  4. Склеиваем: [5 бит данных] [10 бит остатка] = 15-битное кодовое слово.

Чтобы проверить целостность при чтении, декодер делает то же самое в обратную сторону: делит прочитанные 15 бит на G(x). Если остаток нулевой — ошибок не было. Если ненулевой — это синдром, по которому находятся испорченные биты. Для коротких BCH вроде нашего (всего 32 валидных слова, по числу комбинаций EC × mask) обычно держат готовую таблицу синдромов — это быстрее, чем гонять полный алгоритм исправления каждый раз.

Теперь становится понятно, зачем нужна XOR-маска 101010000010010. Без неё формат из одних нулей был бы валидным кодовым словом («EC level M, маска 0, нулевой остаток») — а это совпадает с пустой или замазанной белой областью QR. После XOR-а сплошные нули или единицы гарантированно дают невалидный синдром, и декодер не примет шум за легитимный формат.

И ещё деталь: те же 15 бит продублированы дважды, причём копии разнесены по разным углам — буква Г вокруг левого верхнего finder-а и полоска снизу от правого верхнего + справа от левого нижнего. Это страховка от случая, когда одна копия повреждена сильнее, чем BCH способен починить (больше 3 ошибок): декодер читает обе, считает синдром у каждой и берёт ту, что чище.


Шаг 3. Зигзаг и снятие маски#

Биты данных читаются парами столбцов справа налево, зигзагом: пара (20, 19) — снизу вверх, пара (18, 17) — сверху вниз, и так далее, перепрыгивая тайминг-колонку №6. Внутри каждого ряда читается сначала правая клетка, потом левая. Все функциональные модули (finder, тайминг, format, dark) — пропускаются.

Путь обхода данных: первые 32 бита подсвечены цветовой шкалой от красного к зелёному, серым — функциональные модули

Видно, как поток идёт парами столбцов: красный (биты 1–2) внизу справа, поднимается вверх по правым двум колонкам до битов 15–16, потом перепрыгивает в следующую пару (зелёный, биты 17–32) и идёт вниз.

Каждый бит, прочитанный из решётки, XOR-им с маской 6: если для координаты (r, c) маска срабатывает — бит инвертируется.

После маскинга первые 4 байта получаются такими:

байт 1: 00100000  = 0x20
байт 2: 00010011  = 0x13
байт 3: 00001111  = 0x0F
байт 4: 00000000  = 0x00

Восемь масок и их формулы#

Полный набор из стандарта — клетка (r, c) (строка, колонка) инвертируется, если выражение даёт true:

формула
0(r + c) mod 2 == 0
1r mod 2 == 0
2c mod 3 == 0
3(r + c) mod 3 == 0
4(⌊r/2⌋ + ⌊c/3⌋) mod 2 == 0
5(r · c) mod 2 + (r · c) mod 3 == 0
6((r · c) mod 2 + (r · c) mod 3) mod 2 == 0
7((r + c) mod 2 + (r · c) mod 3) mod 2 == 0

Маски устроены по-разному осмысленно: №0–№3 — простые «шахматные» / «полосатые» шаблоны, №4–№7 — более «шумные», с произведениями координат. Чем разнообразнее набор, тем больше шанс, что хотя бы одна из восьми разломает любой неудачный битовый рисунок в payload.

А выбирает кодировщик так: накладывает поочерёдно каждую из восьми масок на черновой код и считает штрафную функцию (penalty score). Штрафы накапливаются за длинные одноцветные полосы (5+ модулей подряд), за блоки 2×2 одного цвета, за паттерны, похожие на finder (1:1:3:1:1), и за дисбаланс чёрного и белого. Маска с минимальной суммой штрафов уходит в финальный QR. Поэтому маска №6 в нашем подопытном — не «любимая маска автора», а просто та, что для конкретного payload "HI" сложилась с минимальным penalty.


Шаг 4. Парсим payload#

Первые 32 бита склеиваем в один поток и режем по логике формата:

0010 | 000000010 | 01100001111 | 0000
 ↑         ↑            ↑         ↑
 │         │            │         └─ терминатор «конец данных»
 │         │            └─ символы (11 бит на пару)
 │         └─ счётчик: 2 символа (9 бит для V1 alpha)
 └─ режим: 0010 = alphanumeric

Каждый payload в QR начинается одинаково: Mode Indicator (4 бита) + Character Count Indicator (CCI, ширина зависит от пары version+mode) + сами данные. Ширина CCI меняется ступеньками: для alphanumeric это 9 бит на V1–V9, 11 бит на V10–V26 и 13 бит на V27–V40. Сделано осмысленно: в маленьком V1 пол-байта не хочется тратить на нули перед длиной, а в большом V40 9 бит уже банально не хватит — туда влезает до 4296 alphanumeric-символов. Поэтому при ручном декодировании всегда первым делом смотришь на версию (размер сетки), и только потом — где у CCI заканчиваются биты.

Декодируем сами символы#

Декодируем 11 бит данных: 01100001111 в десятичном = 783.

В alphanumeric-режиме два символа упаковываются как c1 × 45 + c2. Делим: 783 = 17 × 45 + 18. Таблица alphanumeric-алфавита: 0-9, A-Z, [space] $ % * + - . / : — итого 45 значений, отсюда и множитель.

  • 17 → H
  • 18 → I

Получили "HI".

Если бы строка была нечётной длины, последний символ кодировался бы отдельно — не 11, а 6 бит. Это полезно держать в голове: после CCI всегда сначала идут полные пары, а потом — возможно — «хвост» в 6 бит. Аналогично numeric-режим режется тройками по 10 бит, а одиночный/парный хвост — 4 и 7 бит соответственно. Стандарт педантично оптимизирует каждый бит: считает, что место в QR — это дорого.

Терминатор, padding и Reed-Solomon#

Сразу за данными идёт терминатор 0000 — четыре нуля, говорящие «всё, payload закончился». По стандарту терминатор можно укоротить или вовсе выкинуть, если данные ровно дотягиваются до конца блока — но обычно его пишут целиком, и декодер на него ориентируется.

Дальше биты добиваются нулями до ближайшей границы байта, и потом блок данных заполняется до своей ёмкости чередующимися padding-байтами 0xEC (11101100) и 0x11 (00010001). Эти два конкретных значения зашиты в стандарте — выбраны они так, чтобы их битовый рисунок под любой из восьми масок ломал длинные одноцветные полосы и не складывался во что-то, похожее на finder-pattern (1:1:3:1:1). Поэтому если вытащил из кода строку «HI», а дальше пошло EC 11 EC 11 EC 11 ... — это не повреждение, это норма. И наоборот: если padding выглядит как-то иначе — где-то в зигзаге ошибся.

Для V1 с уровнем коррекции L общий размер блока — 26 codeword’ов (байт), из которых под данные отведено 19, а остальные 7 — это байты Reed-Solomon: код над полем GF(256), способный поднять до 3 искажённых байт на блок. Для нашего "HI" это значит: 4 уже прочитанных байта (20 13 0F 00) + 15 байт padding’а (EC 11 EC 11 ... EC) + 7 байт RS = ровно 26, полная ёмкость V1-L.

Раскручивать Reed-Solomon руками — занятие на отдельный пост: нужно считать синдромы, через алгоритм Берлекэмпа-Месси искать полином-локатор ошибок, потом Chien-search’ем — позиции, потом формулой Форни — значения, и всё это в арифметике GF(256). В декодере без ошибок RS-байты можно просто игнорировать — и в этом разборе мы именно так и поступим. Поднимется тема — вернёмся.

Режимы данных#

кодрежимбит на символсчётчик в V1
0001numeric3.33 (10 бит на 3 цифры)10 бит
0010alphanumeric5.5 (11 бит на 2 символа)9 бит
0100byte88 бит
1000kanji138 бит

Тонкие места, на которых легко споткнуться#

Когда сидишь с распечатанным QR и разбираешь его руками, есть пара мест, где мозг гарантированно даст осечку. Перечислю те, что встречал чаще всего — и в собственных первых попытках, и в чужих разборах в чате.

  • Забыл пропустить тайминг-колонку №6 в зигзаге. Самый популярный баг: пары столбцов должны «перепрыгивать» эту колонку, а не читать её клетки как данные. Если payload пошёл бредом с первого же байта — почти наверняка дело именно тут.
  • Снял маску не только с данных. Маска накладывается исключительно на data + EC, то есть на зелёную зону. Finder, separators, тайминг, format info, dark module — не трогаем. Если случайно проинвертировал маской finder-патерны, дальше развалится всё, включая саму ориентацию.
  • Перепутал порядок битов внутри пары столбцов. В зигзаге в каждой строке сначала правая клетка, потом левая. Прочитал наоборот — payload идёт зеркально по 2 бита, терминатор находится где-то в середине, и парсинг падает.
  • Забыл, что зигзаг bounce-ит. Пары столбцов чередуют направление: (20, 19) — снизу вверх, (18, 17) — сверху вниз, (16, 15) — снова вверх. Это не «всегда снизу вверх», это «змейка». На стыках пар легко проскочить инерцией.
  • Принял EC 11 EC 11 ... за продолжение данных. После терминатора 0000 идёт padding до конца блока данных. Если в декодированном байт-потоке после реального payload пошли повторяющиеся EC 11 — не пытайся их парсить как «следующее сообщение», это просто забивание блока до 19 байт (для V1-L).

Что с этим делать на практике#

Чтобы из теории получилось ощущение «руками потрогал», самый рабочий заход — такой:

  1. Сгенерируй V1 QR с заранее известным содержимым. Удобнее всего — через онлайн-демо Nayuki или через мой wifi-qr (для коротких SSID он как раз выдаёт V1–V2). Возьми короткую alphanumeric-строку: "HI", "OK", "TEST". Не забудь зафиксировать уровень коррекции (L) и номер маски — потом будет с чем сверять.
  2. Распечатай крупно — A4 на 21×21 хватает с запасом. Альтернатива — нарисовать сетку на бумаге в клетку и закрасить модули вручную. Звучит занудно, но именно ручная отрисовка лучше всего ломает «магический квадратик» в голове.
  3. Подсвети функциональные зоны карандашом или маркерами разных цветов: три finder-а, separators, обе тайминг-полоски, dark module, обе копии format info. После этого зелёная зона данных выделится сама собой.
  4. Прочитай format info → XOR с 101010000010010 → выпиши уровень коррекции и номер маски. Сверь с тем, что задал в генераторе. Сошлось — значит, Шаг 2 ты понял правильно.
  5. Обведи клетки, инвертируемые выбранной маской. Берёшь формулу из таблицы выше и проходишь по всей data-зоне: где условие — true, ставишь крестик. Эти клетки будешь инвертировать при чтении.
  6. Пройди зигзагом первые 32 бита, для каждого инвертируй или нет в зависимости от маски, и распарси: 4 бита mode → CCI → данные. Должна получиться твоя исходная строка.

Когда «свой» QR разобрался — попробуй на чужом: возьми QR с любого чека из магазина или с Wi-Fi-стикера в кофейне. Только сразу проверь, что это V1 или V2 (для V1 сетка 21×21, для V2 — 25×25, считается по краю). На больших версиях ручной зигзаг быстро превращается в подвиг ради подвига.


Итог#

Если потренироваться, то для маленьких QR (V1, alphanumeric, короткий текст) реально читать глазами за пару минут. Узкое горлышко — держать в голове маску и зигзаг, две операции, которые мозг плохо параллелит. Помогает распечатать QR на бумаге и фломастером заштриховывать клетки, которые маска инвертирует — после этого данные читаются «как обычный бинарный поток».

Алгоритм по шагам#

  1. Найти три finder patterns → определить ориентацию.
  2. Прочитать format info → XOR с 101010000010010 → выделить EC level и mask number.
  3. Применить маску к данным (XOR по формуле выбранной маски).
  4. Прочитать биты зигзагом снизу-справа вверх, парами столбцов.
  5. Распарсить: 4 бита режим → N бит счётчик → данные → терминатор.
  6. (Опционально) проверить Reed-Solomon коды.

Дальше копать#

  • How to decode a QR code by hand — Robert Heaton — пошаговый разбор того же V1 «HI», только не моими словами. Хорошо для проверки себя: если в каком-то месте разошлись — значит, не до конца понял шаг.
  • Thonky’s QR Code Tutorial — самый полный учебник в сети: все 40 версий, восемь масок с примерами, готовые таблицы для Reed-Solomon, разбор форматов. Если Heaton — обзорная статья, Thonky тянет уже на 30-страничный курс.
  • nayuki/QR-Code-generator — каноническая референсная реализация на C/C++/Java/JS/Python/Rust. Читается как продолжение этого поста: чёткие имена, в комментариях — формулы из стандарта, никакой магии. У того же автора на nayuki.io лежит онлайн-демо с настраиваемой версией и маской — удобно сверять свои выкладки с эталоном.
  • jtprogru/wifi-qr — мой собственный мини-проектик «с обратной стороны»: тащит WIFI_SSID и WIFI_PASSWD из .env и собирает Wi-Fi-QR в PNG одной командой make run. Внутри — крошечный Python-скрипт поверх библиотеки qrcode. Удобный мостик от «понимаю по битам» к «генерирую за секунду», заодно — готовый QR для гостевой Wi-Fi.
  • zxing/zxing — production-grade декодер от Google, тот самый, который много лет крутился в Barcode Scanner на Android. Полезно посмотреть, чтобы понять разницу между «вытащить V1 в идеальных условиях» и «прочитать URL с мятого чека под углом 30° при тусклом свете».
  • Reed–Solomon codes for coders на Wikiversity — лучший learn-by-doing разбор RS-кодов из всех, что попадались. GF(256), порождающие полиномы, синдромы, исправление ошибок — всё последовательно разложено по шагам, с примерами на Python. Без этой статьи в RS-кодах было сложно ухватиться хоть за что-то.
  • QR code — Wikipedia — на удивление приличная обзорная статья: версии, режимы, история Denso Wave, лимиты по объёму данных, эволюция стандарта. Хорошо стартовать с неё, если QR в голове пока «магический квадратик».
  • ISO/IEC 18004:2015 — сам стандарт. Платный, но если возишься со штрихкодами всерьёз — без него никак: только там нормально расписаны все 40 версий, все 4 режима кодирования и точные алгоритмы коррекции.

Если у тебя есть вопросы, комментарии и/или замечания – заходи в чат , а так же подписывайся на канал .

О способах отблагодарить автора можно почитать на странице “Донаты ”.