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

        .string "http://x"

Мой ассемблер собрал её в 0 байт, вернул код успеха и не пожаловался ни на что. Латать это на месте я пробовал дважды, и вторая заплатка сломала то, что починила первая.

▍ Навигация по серии

Если вы попали сюда с середины

В этой серии я делаю ассемблер с нуля, на питоне. Ассемблер это программа, которая читает текст вроде addi sp, sp, -16 и выдаёт 4 байта, понятные процессору. К пятой части мой умел собрать ровно одну программу, и на ней всё сходилось байт в байт с настоящим as из состава GNU.

Читал он исходник просто: брал строку, срезал комментарий, искал двоеточие в конце, делил остаток по запятым. Эта статья про то, где такое чтение ломается.

Ассемблер знать не нужно. Беда, о которой пойдёт речь, знакома каждому, кто хоть раз резал строку по разделителю: запятая внутри кавычек в CSV, точка с запятой внутри значения, кавычка внутри кавычек. Символ, который обычно что‑то значит, попадает внутрь текста и перестаёт значить это. Здесь то же самое, только вместо запятой две косые черты.

Где построчные правила ломаются

Когда мой ассемблер чего‑то не понимает, он обычно останавливается и называет номер строки: пошёл и поправил. Неприятно, но видно. Со строкой из начала статьи вышло иначе:

        .string "http://x"

настоящий as:  9 байт, 8 символов и ноль
мой:           собрано 0 байт, код возврата 0

Я протрассировал разбор, и вот что там происходит. Правило «всё после // это комментарий» оставляет от строки .string "http:. Дальше вступает второе, „строка, кончающаяся двоеточием, это метка“: оно видит хвост с двоеточием и заводит метку с именем .string "http, с пробелом и кавычкой внутри. Я вывел таблицу меток и увидел её там рядом с _start и msg.’”

Как строка с адресом превращается в пустоту
Как строка с адресом превращается в пустоту

Каждое правило по отдельности верно. Ломается их сочетание, и ломается тихо: ни одно из них не знало, что оно сейчас внутри строки в кавычках.

Самое очевидное возражение

Почини срезание комментариев, чтобы оно уважало кавычки, и никакой отдельный разбор не нужен. Это первое, что приходит в голову, и если оно верно, то писать не о чем.

Я написал две заплатки подряд. Первая помнит, что она внутри двойных кавычек. Вторая знает ещё и одиночные: два выключателя вместо одного, четыре строки работы. Ровно так их и пишут, по одному случаю за раз.

Прогон идёт по 10 строкам. Показываю 5, на которых хоть что‑то менялось, остальные 5 верны у всех четырёх способов, а счёт внизу по всем десяти.

строка                       наивно       заплатка 1   заплатка 2   состояние
------------------------------------------------------------------------------
.string "http://x"           *** НЕТ      верно        верно        верно
.string "a#b"                *** НЕТ      верно        верно        верно
.byte '#'                    *** НЕТ      *** НЕТ      верно        верно
.string "a'b" # хвост        верно        верно        *** НЕТ      верно
.byte '"' # хвост            верно        *** НЕТ      *** НЕТ      верно
------------------------------------------------------------------------------
наивно       неверно 3 из 10
заплатка 1   неверно 2 из 10
заплатка 2   неверно 2 из 10
состояние    неверно 0 из 10

Вторая заплатка починила .byte '#' и сломала .string "a'b" # хвост, который первая собирала верно. Счёт не сдвинулся: два неверных было, два и осталось. Обе спорные строки настоящий as собирает верно, обе лежат в стенде.

Вторая заплатка не знает про первую. Апостроф в слове it's внутри двойных кавычек включает признак одиночной, и дальше срезание считает себя внутри литерала, хотя вышло оттуда давно. Два выключателя, и ни один не помнит, кто из них сейчас главный.

Заплатки не выстраиваются в цепочку. Они мешают друг другу.

Одно моё предсказание тут не сбылось: я ждал поломки на .string "a\"b" с экранированной кавычкой, а срезать в ней нечего, нет ни //, ни #.”

Правил, кстати, не два, а три

Разбираясь с заплатками, я пересчитал собственные правила. Их три, и третье работает раньше всех, а я его до сих пор не называл.

text = COMMENT_BLOCK.sub(" ", открыть(файл).read())   # 1: блочный /* */
for n, raw in enumerate(text.split("\n"), 1):
    line = raw.split("//")[0].split("#")[0].strip()   # 2: строчный
    while line:
        m = LABEL.match(line)                          # 3: метка
        ...

Первое правило вычищает /* */ по всему файлу разом, ещё до того, как файл разрезан на строки. Оно не знает даже, в какой строке находится, не говоря уже о кавычках. Отсюда третья молчаливая порча:

.string "a /* b */ c"

настоящий as:  a /* b */ c
мой:           a   c

Четыре символа исчезли, ошибки нет. А незакрытый /* внутри строки, наоборот, выживает, хотя я считал его тоже съеденным: выражению нужен закрывающий */.

Разбор на слова: один проход и одно состояние

Разница не в количестве правил, а в том, когда они применяются.

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

Разбор на слова работает иначе. Один проход слева направо, по одному символу. У прохода есть состояние: он сейчас в обычном тексте, или внутри двойных кавычек, или внутри одиночных, или внутри блочного комментария. И символ значит разное в зависимости от состояния.

Символ / в обычном тексте вместе со вторым таким же начинает комментарий. Тот же символ внутри кавычек это просто символ. Двоеточие в обычном тексте закрывает метку, внутри кавычек это двоеточие.

Отсюда видно, почему заплатки не сходятся, а состояние сходится. Правил у меня 3, а положений, в которых может оказаться символ, 4: обычный текст, двойные кавычки, одиночные кавычки, блочный комментарий. Заплатками это чинится по клеткам, и клеток тут 3 на 4, каждую надо выучить отдельно. Проход с состоянием платит 3 плюс 4: правила описываются один раз, положения описываются один раз, а перемножать их не приходится, потому что состояние общее.

Что такое «слово» на выходе. Не просто кусок текста. У каждого есть вид, исходный текст и уже посчитанное значение:

Token(kind="string", text='"http://x"', value=b"http://x")
Token(kind="number", text="0x10",       value=16)
Token(kind="char",   text="'A'",        value=65)

Виды всего шесть: слово, число, строка, символ, знак и конец строки. Значение считается сразу при разборе, и это потом сэкономит работу: старому коду не придётся второй раз узнавать, что 'A' это 65.

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

while i < n:
    c = text[i]

    if c == "\n":                       # конец строки
        ...
    if c in " \t\r":                    # пробелы, пропускаем
        i += 1; continue
    if text.startswith("/*", i):        # блочный комментарий
        ...                             # умеет переходить на другую строку
    if text.startswith("//", i) or c == "#" or c == ";":
        while i < n and text[i] != "\n":
            i += 1                      # до конца строки, но НЕ дальше
        continue
    if c == '"':                        # строковый литерал
        ...
    if c == "'":                        # символьный литерал
        ...
    if c.isdigit():                     # число, в том числе 0x
        ...
    if c.isalpha() or c in "._$":       # имя, метка, директива
        ...

    two = text[i:i + 2]                 # знаки, сперва двухсимвольные
    if two in ("<<", ">>", "//"):
        out.append(Token(PUNCT, two, two, line)); i += 2; continue
    out.append(Token(PUNCT, c, c, line)); i += 1

Порядок веток тут не косметика. Проверка на комментарий стоит до проверки на знак, иначе // прочитается как два деления. Двухсимвольные знаки проверяются до односимвольных по той же причине: иначе << станет двумя <.

А вот сердцевина, ветка про кавычки, целиком:

if c == '"':
    j, buf = i + 1, bytearray()
    while True:
        if j >= n or text[j] == "\n":
            raise LexError("строка %d: кавычка не закрыта" % line)
        if text[j] == "\\":
            code, eaten = _read_escape(text, j, line)
            buf.append(code)
            j += eaten
            continue
        if text[j] == '"':
            break
        buf.extend(text[j].encode("utf-8"))
        j += 1
    out.append(Token(STRING, text[i:j + 1], bytes(buf), line))
    i = j + 1
    continue

Встретили кавычку, дальше читаем до закрывающей и по дороге ни на что не отвлекаемся. Ни //, ни #, ни двоеточие тут ничего не значат.

Отдельная ветка нужна обратной косой: \" внутри строки не закрывает её, а даёт саму кавычку. Разбор экранирования вынесен в свою функцию на 9 строк. Она знает \n, \t, \r, \0 и сами кавычки, а незнакомое экранирование пропускает как есть: \q даёт букву q, и настоящий as делает ровно то же самое.»

А восьмеричное он не умеет, и это я узнал, когда сел сверять ветку с as: на .string "a\015b" настоящий даёт возврат каретки, а мой ноль и дальше буквы 1 и 5. Ветку я не чинил, она идёт в тот же список, что и арифметика в операндах.

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

На выходе получается не строка покороче, а список слов. Вот та же злополучная строка:

word     '.string'
string   '"http://x"'

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

Новый разбор подставляется в ассемблер заменой одной строки (за ней стоит тот самый переходник, про него сразу после):

asm.read_lines = lexer.read_lines

Дальше вопрос: собирается ли всё, что собиралось раньше, байт в байт.

Сначала я проверил это на первой программе серии. 63 байта, совпало. Обрадовался и чуть не пошёл писать текст.

Регрессия, которую эта проверка не увидела

И вот тут перед текстом я проверил ещё несколько строк отдельно. Первой же попалась вот эта:

        addi sp, sp, -16

Та самая инструкция, вокруг которой крутились три предыдущие части. С новым разбором она перестала собираться.

Причина вытекает из устройства. Разбор на слова отдаёт - и 16 двумя отдельными словами, и это правильно: решать, унарный тут минус или вычитание, дело следующего этапа, а не резки. Но следующего этапа у меня пока нет, а старый код ждёт готовое -16.

Всего сломанных нашлось 3: addi sp, sp, -16, lw t0, -8(sp) и li t0, -1.

Починил я их не в лексере, и это важно. В лексере всё верно: он отдаёт - и 16 порознь, потому что решать про унарный минус дело разбора. Склейку я положил в переходник к старому коду, функцию _render на 37 строк. Она собирает слова обратно в строку и по дороге приклеивает минус к числу, если перед ним запятая, скобка или другой знак, то есть если минус тут не вычитание.

Переходник это цена того, что разбора у меня пока нет. Появится вычислитель, и склейка уедет к нему, а _render исчезнет. Пока же за строчкой 16 из 16 ниже стоит в том числе он. Я выключил в нём склейку минуса и прогнал приёмку заново: стало 13 из 16, ровно те три строки и отвалились.

А проверка на первой программе этого не заметила, потому что отрицательных чисел в ней нет ни одного. Она сказала «байт в байт совпало» и была права ровно про то, что проверяла.

Проверка, которая молчит, и проверка, которая говорит «всё хорошо», выглядят одинаково.

Приёмку я переписал. Теперь она сверяет 16 отдельных конструкций, которые старый ассемблер точно умеет, и первую программу целиком:

строк сверено 16, байт в байт совпало 16
первая программа серии целиком: 63 байт, совпало

Заодно я узнал про свой ассемблер то, чего не знал. Хотел прогнать по нему все программы серии и получил на второй же:

строка 3: не знаю директиву .equ

Он умеет ровно одну программу, ту, на которой я его писал.

Что починилось

6 строк на проверке. 5 из них раньше падали или молча теряли данные, шестая работала и должна была продолжить работать.

строка

было

стало

.string "http://x"

0 байт, молча

http://x и ноль

.string "a /* b */ c"

a c, молча

a /* b */ c и ноль

.string "a#b"

ошибка

a#b и ноль

.byte 'A'

ошибка

байт 65

.byte '#'

ошибка

байт 35

.string "a;b"

работало

работает

Старому коду при этом не пришлось узнавать про кавычки вообще. Он по‑прежнему получает строку, но уже разобранную: символьный литерал в ней заменён готовым числом.

А теперь то же самое, но программой

Всё выше я доказывал сравнением байтов: у настоящего as 9, у меня 0. Это числа в таблице, и по ним не видно, чем такая потеря оборачивается.

Поэтому я собрал программу, которая печатает адрес этого самого репозитория. Обычный скелет из первой части серии, и вся разница в одной строке данных: .string "https://github.com/Pro100lamer/uart-to-lang". Собрал её дважды, меняя ровно одно, чем разрезан исходник на строки, и запустил обе в эмуляторе: qemu-system-riscv32, машина virt, вывод в тот же UART, что и в первой части.

сборка           байт     что напечатала программа
----------------------------------------------------------------------------
старый разбор    40       (ничего)
лексер           84       https://github.com/Pro100lamer/uart-to-lang
----------------------------------------------------------------------------

40 байт против 84, и первая сборка молчит. Не падает, не ругается, доходит до конца и останавливается где положено. Просто не печатает ничего.

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

Кристалл я тут не доставал нарочно, хотя в прошлой части он работал и стоит на столе. Порча происходит на моей машине, до того как хоть один байт уедет в плату: ассемблер отдал 40 байт вместо 84, и любое железо честно исполнит ровно эти 40. Прошить плату значило бы показать, что одинаковое одинаково.

Повторяется командой make url.

Второе обещание: что стало с выражением в операнде

Пятая часть кончилась вот так:

Пока мой ассемблер режет строки пробелами и запятыми, и на первом же выражении в операнде это развалится.

Проверял я это 9 строками: 8 с выражением в операнде и одна с простым числом -16, для сравнения. Настоящий as собирает все 9.

Причина, по которой мой не собирает, лежит в одной функции. Мой разбор числа умеет ровно две вещи: попробовать int(t, 0) и поискать имя в таблице меток (метка это имя для адреса, вроде msg: перед строкой данных). Арифметики там нет вовсе, и 16*2 для него не число и не метка.

Разбор на слова это не вычисление, но часть работы он закрывает сам. Вот те же 9 строк, слева старый разбор, справа новый:

строка                     старый    с лексером
addi sp, sp, -16*2         упал      упал
addi sp, sp, 8+8           упал      упал
addi t0, t0, 1<<4          упал      упал
addi sp, sp, -(16)         упал      упал
addi t0, t0, 0x10|0x01     упал      упал
lw t0, (4*2)(sp)           упал      упал
addi t0, zero, 'A'         упал      собрал
addi sp, sp, 8 + 8         упал      упал
addi sp, sp, -16           собрал    собрал

собиралось 1 из 9, стало 2 из 9

Прибавилась одна строка, та, где выражение это символ в кавычках: разбор посчитал 'A' числом 65 прямо при резке и отдал дальше готовое число, а старому коду не пришлось узнавать ни про кавычки, ни про таблицу символов. Остальные 7 ждут арифметики.

Вот что происходит с первой строкой таблицы:

слова:   addi sp , sp , - 16 * 2
сборка:  не собирается

Слова нарезаны правильно, все 8. Никто их не считает.

И это ровно та граница, которую я поначалу пытался перепрыгнуть. Резать текст на слова и вычислять выражения это две разные работы. Первая знает, где кончается одно слово и начинается другое. Вторая знает, что умножение делается раньше сложения. Смешивать их в одном проходе можно, и многие так делают, но тогда обе получаются хуже.

Итог

Обещал я разбор текста на части, и вот он: 196 строк, один проход, шесть видов слов, четыре положения, в которых может оказаться символ.

Главное про него не в объёме, а в том, что он не набор правил, а одно состояние. Правил было 3, положений 4: заплатками это чинится по клеткам, 3 на 4, а проход с состоянием платит 3 плюс 4. Я написал две заплатки подряд, и счёт неверных после второй не сдвинулся.

Со вторым обещанием так: из 9 строк с выражением в операнде мой ассемблер собирал 1, а с разбором на слова собирает 2. Прибавилась та, где выражение было символом в кавычках. Остальным 7 нужна арифметика, а она отдельная работа.

По дороге нашлись 2 строки из 7, на которых старый разбор молча отдавал не то, что просили, с кодом успеха. Виноваты правила, каждое из которых верно по отдельности: достаточно двоеточия внутри http://, и данные исчезают, а в таблице меток появляется метка с кавычкой в имени.

Код: github.com/Pro100lamer/uart‑to‑lang, тег article-06. Опыты повторяются командами make cases, make silent, make refute, make accept и make url. Приёмка это та самая, которая сначала молчала про сломанное.

Из тех 9 строк с выражением мой ассемблер собирает 2. Слова в остальных нарезаны верно, а складывать и умножать их некому. В следующей части напишу вычислитель, рекурсивным спуском, и доведу счёт до 9 из 9.

Заодно покажу, во что обходится сама рекурсия. Каждый вызов функции это не только её работа: на входе надо сохранить то, что уже лежит в регистрах, а на выходе вернуть обратно. На наивном Фибоначчи эта обвязка съедает 53,3% всей исполненной работы, на разборе выражения 15,5%. Цена вызова зависит от того, много ли функция делает по сравнению с тем, сколько сохраняет. Фибоначчи, на котором эту цену принято показывать, худший случай, а не обычный.

Для тех, кто хочет разобраться сам

Комментарии (8)


  1. Tanabe
    09.09.2026 17:07

    Лексический анализатор и теория компиляторов, не?


    1. Pro100lamer Автор
      09.09.2026 17:07

      Не. Это про то, что бывает, когда её не читал


  1. Politura
    09.09.2026 17:07

    Помню когда-то давно актуальной была "книга дракона" про компиляторы и если берешься городить свой колхоз не читая ее, то били больно кандеябром по башке, пока не поумнеешь.

    Лет 20 прошло, теперь есть llvm, писать все с нуля нет необходимости. Нужен новый язык? Делай фронтенд к llvm по получай компиляцию своего языка на миллион платформ. Нужна поддержка новой платформы? Делай бакенд llvm и получай миллион языков, которые компилируются под твою платформу.

    Но если все-таки хочется написать с нуля, есть что более новое, или книга дракона все еще стандарт?


    1. Pro100lamer Автор
      09.09.2026 17:07

      LLVM разобранный текст в машинный код превращает хорошо. А разбирать текст — где слово, где строка, где комментарий — всё равно самому. Статья про это.

      Остальное пишу сам, потому что хочу увидеть своими глазами, как из текста получаются байты.

      «Дракон» жив, но сейчас чаще советуют «Crafting Interpreters» Нистрома (бесплатно онлайн) и «Engineering a Compiler» Купера и Торцона.


      1. Rsa97
        09.09.2026 17:07

        А разбирать текст — где слово, где строка, где комментарий — всё равно самому.

        А для этого, в идеале, надо написать ABNF-грамматику своего языка. Ну а по ней уже можно построить конечный автомат лексера, который переведёт .string "http://x" в лексемы <KEYWORD, DATA_STRING>, <STRING, "http://x">, <EOL>.


        1. Pro100lamer Автор
          09.09.2026 17:07

          Так и вышло, только в обратном порядке: автомат в статье написан руками, а грамматики к нему нет. Согласен, что правильно наоборот — с записанной грамматикой дыра с "http://x" была бы видна на бумаге, а не после сборки. Для ассемблера в сотню строк я её пропустил, и вот результат.


          1. Rsa97
            09.09.2026 17:07

            Это у вас никоим образом не конечный автомат. КА (FSM) - это система, читающая вход строго последовательно по одному символу и переходящая по состояниям в зависимости от текущего состояния и полученного символа. Там не должно быть никаких match, split, strip и т.п.


            1. Pro100lamer Автор
              09.09.2026 17:07

              Формально да, это рукописный сканер, а не автомат: заглядываю на два символа вперёд, а конец блочного комментария ищу через find, а не по состояниям. Но match, split и strip в tokens() нет. Проход посимвольный, слева направо, состояние сидит в циклах. Они в read_lines и _render: это мост к старому ассемблеру, который пока не умеет есть токены.