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

Привет, %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и paddingEC 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". Раскрашены функциональные зоны.

Шаг 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.

Считываем по порядку от #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) инвертируется, если
Кодирование уровня коррекции#
| биты | уровень | избыточность |
|---|---|---|
01 | L | ~7 % |
00 | M | ~15 % |
11 | Q | ~25 % |
10 | H | ~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. Алгоритм такой:
- Берём 5 бит данных
d=[EC level (2 бита)] [mask (3 бита)]. - Сдвигаем влево на 10 позиций: получаем
d · x^{10}. - Делим в
GF(2)(по сути — длинное XOR-вычитание) наG(x), остаток — это 10 бит чётности. - Склеиваем:
[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) — пропускаются.

Видно, как поток идёт парами столбцов: красный (биты 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 |
| 1 | r mod 2 == 0 |
| 2 | c 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 |
|---|---|---|---|
0001 | numeric | 3.33 (10 бит на 3 цифры) | 10 бит |
0010 | alphanumeric | 5.5 (11 бит на 2 символа) | 9 бит |
0100 | byte | 8 | 8 бит |
1000 | kanji | 13 | 8 бит |
Тонкие места, на которых легко споткнуться#
Когда сидишь с распечатанным 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).
Что с этим делать на практике#
Чтобы из теории получилось ощущение «руками потрогал», самый рабочий заход — такой:
- Сгенерируй V1 QR с заранее известным содержимым. Удобнее всего — через онлайн-демо Nayuki
или через мой wifi-qr
(для коротких SSID он как раз выдаёт V1–V2). Возьми короткую alphanumeric-строку:
"HI","OK","TEST". Не забудь зафиксировать уровень коррекции (L) и номер маски — потом будет с чем сверять. - Распечатай крупно — A4 на 21×21 хватает с запасом. Альтернатива — нарисовать сетку на бумаге в клетку и закрасить модули вручную. Звучит занудно, но именно ручная отрисовка лучше всего ломает «магический квадратик» в голове.
- Подсвети функциональные зоны карандашом или маркерами разных цветов: три finder-а, separators, обе тайминг-полоски, dark module, обе копии format info. После этого зелёная зона данных выделится сама собой.
- Прочитай format info → XOR с
101010000010010→ выпиши уровень коррекции и номер маски. Сверь с тем, что задал в генераторе. Сошлось — значит, Шаг 2 ты понял правильно. - Обведи клетки, инвертируемые выбранной маской. Берёшь формулу из таблицы выше и проходишь по всей data-зоне: где условие —
true, ставишь крестик. Эти клетки будешь инвертировать при чтении. - Пройди зигзагом первые 32 бита, для каждого инвертируй или нет в зависимости от маски, и распарси: 4 бита mode → CCI → данные. Должна получиться твоя исходная строка.
Когда «свой» QR разобрался — попробуй на чужом: возьми QR с любого чека из магазина или с Wi-Fi-стикера в кофейне. Только сразу проверь, что это V1 или V2 (для V1 сетка 21×21, для V2 — 25×25, считается по краю). На больших версиях ручной зигзаг быстро превращается в подвиг ради подвига.
Итог#
Если потренироваться, то для маленьких QR (V1, alphanumeric, короткий текст) реально читать глазами за пару минут. Узкое горлышко — держать в голове маску и зигзаг, две операции, которые мозг плохо параллелит. Помогает распечатать QR на бумаге и фломастером заштриховывать клетки, которые маска инвертирует — после этого данные читаются «как обычный бинарный поток».
Алгоритм по шагам#
- Найти три finder patterns → определить ориентацию.
- Прочитать format info → XOR с
101010000010010→ выделить EC level и mask number. - Применить маску к данным (XOR по формуле выбранной маски).
- Прочитать биты зигзагом снизу-справа вверх, парами столбцов.
- Распарсить: 4 бита режим → N бит счётчик → данные → терминатор.
- (Опционально) проверить 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 режима кодирования и точные алгоритмы коррекции.
Если у тебя есть вопросы, комментарии и/или замечания – заходи в чат , а так же подписывайся на канал .
О способах отблагодарить автора можно почитать на странице “Донаты ”.