G-code программисту и Brainfuck не страшен

от автора

Brainfuck хотя бы честно предупреждает

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

++++++++[>++++++++<-]>+.

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

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

Гораздо интереснее языки, которые не собирались быть странными, а просто выросли такими рядом с настоящим железом.

Например, G-code, язык управляющих программ для станков.

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

G0 X100 Y50M3G1 Z-3 F500G1 X150 Y50

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

И здесь важно помнить возраст языка. G-code не пришёл из мира, где у разработчика есть гигабайты памяти, автодополнение, подсветка синтаксиса и желание красиво назвать переменную targetPosition. Его корни уходят в эпоху перфолент, слабого железа и контроллеров, которым было не до синтаксического сахара.

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

Если совсем по-простому, ЧПС — это метаязык здорового человека: тот самый слой, который позволяет понять код до того, как пришлось долго и задумчиво «вкуривать» синтаксис, пытаясь выяснить, почему M98 P... L[#2] сегодня изображает обычный if.

Для современного компьютера такой слой почти бесплатен; для старого промышленного контроллера это были лишняя память, лишняя сложность и лишняя причина, по которой система могла стать менее предсказуемой, а еще нужно работать в «реалтайме», т.е. с минимальными задержками. Поэтому язык получился ближе к телеграфу, чем к литературе. Не «перемести текущий инструмент в целевую позицию с учётом выбранной стратегии обработки», а просто:

G1 X150 Y50 F500

Железо такое понимает хорошо. Человеку приходится привыкать.

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

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

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

Ему уже нужны параметры, повторения, выбор поведения, память, вычисляемые координаты, состояние инструмента, номер ряда, номер копии и понимание, куда ехать дальше. Но полноценным языком программирования он от этого не становится; в нём по-прежнему нет привычного уюта, где переменную можно назвать current_x, массив — points, условие — if, цикл — while, а временные значения спрятать в локальной области видимости.

Вместо этого есть числовые ячейки:

#8#9#10#11

Есть подпрограммы с номерами:

O109O1500O2000

Есть итеративный вызов :

M98 P109 L10

Есть возврат из подпрограммы:

M99

Есть арифметика, логические операции, вычисляемые номера команд и твёрдая вера в то, что если очень захотеть, то из этого набора можно собрать почти всё.

И ведь собирают.

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

Потому что Brainfuck хотя бы не делает вид, что он про станок.

А G-code разговаривает координатами, а между делом устраивает у себя внутри динамические  переходы, битовые маски, самодельные массивы и условные операторы без единого нормального if.

Вот это уже интересно.

Не как руководство по G-code и не как призыв писать именно так; после некоторых таких строк, наоборот, хочется аккуратно закрыть файл, налить чаю и несколько минут порадоваться существованию именованных переменных. Интересно другое: такой код показывает, что программирование живёт не в красивых ключевых словах.

if, while, массивы, функции, булевы типы и нормальные имена переменных — это не само программирование, а удобные формы, в которые мы привыкли его упаковывать. Если формы убрать, идеи не исчезают, они начинают просачиваться через другое: через количество вызовов, номера подпрограмм, адреса ячеек, пустые макросы и арифметику над командами.

В этом смысле вся статья будет о столкновении ЧПС и ЧПУ: там, где человекопонятный синтаксис сказал бы if, числовое программное управление требует:

M98 P... L[#2]

Там, где ЧПС дал бы массив points[index], ЧПУ предлагает вычисляемый адрес:

#[base+index*2]

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

Цикл можно собрать из повторного вызова, условие — из нуля и единицы, массив — из соседних пронумерованных переменных, выбор функции — из вычисленного номера подпрограммы, а пустую ветку — из пустого макроса, который ничего не делает, но нужен именно потому, что ничего не делает.

В нормальном языке всё это спрятано за элегантным синтаксисом. Здесь все наружу, зато видно, как из примитивов появляется поведение, которое мы называем алгоритмом.

Поэтому это не статья о том, как писать управляющие программы для станков. Это статья о том, что остаётся от программирования, если из языка вынуть почти все привычные удобства.

Оказывается, остаётся довольно много.

Пусть непонятно, пусть опасно, но работает.

Язык, в котором начинаешь ценить комментарии

В языках с ЧПС комментарии часто воспринимаются как что-то вторичное. Хороший код, говорят нам, должен объяснять себя сам; если функция называется calculateToolOffset, переменная — target_x, а структура — MachineState, то комментарий уже не обязан спасать читателя от полной темноты. Он может пояснить причину решения, предупредить о тонкости, оставить след инженерного компромисса, но базовую навигацию по смыслу всё-таки держит сам синтаксис.

В G-code всё иначе. Здесь комментарий иногда становится не пояснением к коду, а почти единственным словарём, без которого программа превращается в набор чисел с решётками.

В начале большой программы поэтому появляется не просто «шапка», а настоящая карта местности:

;#8      ;X current Pos;#9      ;Y current Pos;#10     ;X Target pos;#11     ;Y Target pos;#20     ;counter;#21     ;counter limit;#22     ;counter x;#23     ;counter y

Для интерпретатора эти строки не значат ничего. Он не станет бережнее относиться к #8, узнав, что человек считает его текущей координатой X, и не начнёт защищать #21 от случайной перезаписи только потому, что в комментарии написано counter limit. Для станка всё это по-прежнему просто ячейки с числами.

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

Без неё строка:

#10=[#8+#24]

выглядит бессмысленно. С ней становится понятно, что целевая координата X вычисляется из текущей координаты и шага по X. Смысл не появляется в синтаксисе, как в target_x = current_x + offset_x; он появляется в соглашении между автором, комментариями и читателем, которому потом придётся это всё понимать. Именно поэтому в G-code комментарии начинают играть роль, которую в языках высокого уровня обычно делят между именами переменных, типами, структурами и областями видимости.

Это неудобно, зато очень дисциплинирует. Комментарий перестаёт быть украшением и становится частью архитектуры программы, потому что номер ячейки сам по себе не несёт человеческого смысла. #20 может быть счётчиком, флагом, индексом, временной переменной или частью диапазона памяти; только договорённость говорит, что именно сейчас он означает.

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

Поэтому #20, #21 или #100109 — это не просто некрасивые заменители нормальных имён. Это места в ограниченной общей памяти, которые нельзя занимать бездумно, потому что рядом уже лежит что-то нужное, а свободных полок не так много, как хотелось бы человеку, выросшему в эпоху просторных серверов и умных IDE.

Эту боль хорошо понимают программисты микроконтроллеров. Там иногда, чтобы сэкономить один байт прошивки, приходится переписать не одну строку, а всю архитектуру: заменить удобную таблицу формулой, несколько флагов упаковать в маску, отказаться от универсального решения и собрать более тесную, более странную, но работающую конструкцию. В G-code похожее мышление возникает естественно: если память мала, а язык беден на выразительные средства, приходится использовать каждую ячейку осознанно и комментировать её так, будто это не комментарий, а табличка на электрощите.

И вот на этом фоне у числовых ячеек обнаруживается странная сила: номер переменной можно вычислить.

В обычном языке имя переменной фиксировано. current_x — это current_x; нельзя просто написать current_[axis] и ожидать, что язык сам соберёт имя нужной переменной, если только мы заранее не ввели массив, словарь, объект или другой механизм, предназначенный именно для такого выбора. А в G-code номер ячейки — это число, и раз это число, его можно сложить, умножить, сдвинуть, получить из другой переменной и использовать как адрес.

Например:

#48=820#49=0

И если мы условились что #48 — базовым адрес, а #49 — индекс, то строка

#10=#[#48+#49*2]

означает уже «вычислить номер переменной и взять значение оттуда». Если #49 равно нулю, читается #820; если единице — #822; если двум — #824.

Соседняя строка:

#11=#[#48+#49*2+1]

берёт вторую ячейку пары: #821 #823 #825

И вот набор пронумерованных ячеек превращается в массив точек:

#820 #821   → X0 Y0#822 #823   → X1 Y1#824 #825   → X2 Y2

Нормального массива в языке нет, но если положить данные рядом и научиться вычислять адрес, массив появляется сам. В языке с ЧПС это выглядело бы так:

x, y = points[index]

В G-code та же мысль записывается через арифметику:

#10=#[#48+#49*2]#11=#[#48+#49*2+1]

И здесь хорошо видно, что удобный синтаксис обычного языка не отменяет механику, а прячет её. Где-то под красивым points[index] всё равно живёт идея:

адрес = база + индекс × размер записи + смещение поля

Просто в Python, C# или JavaScript человеку обычно не нужно смотреть на эту формулу каждый день, потому что за него её держит язык, интерпретатор, структура данных, все то, из-за чего мы, собственно, и любим нормальные языки. В G-code в этом смысле торчит «мясом наружу».

Это одновременно плохо и интересно. Плохо — потому что читать такой код тяжелее: #824 ничего не говорит человеку, пока тот не вспомнит таблицу назначений, а ошибка в базовом адресе может отправить программу читать чужие данные. Неправильное смещение способно превратить координату в счётчик, счётчик — в технологический параметр, а технологический параметр — в уверенное движение станка туда, куда никто не собирался ехать.

Интересно — потому что программист получает почти прямой доступ к адресной модели. Он может строить массивы, таблицы параметров, последовательности координат и даже выбирать поведение через вычисляемые номера, используя одну и ту же идею: если что-то имеет номер, этот номер можно посчитать.

Это и есть ассемблерный флёр G-code: язык не столько описывает намерение, сколько раскладывает его на близкие к железу действия, адреса и состояния. 

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

То есть язык ещё не дал нам нормальных переменных.

А мы уже почти слепили из них базу данных на коленке.

Один параметр заменяет и цикл, и условие

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

for i in range(10):    process()

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

В G-code такого комфорта нет, зато есть вызов подпрограммы с параметром повторения:

M98 P109 L10

Здесь P109 указывает номер подпрограммы, а L10 говорит интерпретатору вызвать её десять раз, превращая обычный вызов в грубый, но вполне рабочий заменитель цикла. Формально никакого for в языке не появилось, но по смыслу программа уже получила его механическую версию: есть действие, которое умеет выполнить один шаг, и есть внешний толчок, заставляющий этот шаг повториться нужное количество раз.

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

Интереснее становится в тот момент, когда в L попадает не константа, а результат заковыристого выражения.

#2=[#43 AND #46 AND [#20 MOD 2]]M98 P1999 L[#2]

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

В языке с ЧПС мы написали бы:

if condition:    rapid_move()

А ЧПУ в ответ предлагает:

M98 P1999 L[condition]

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

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

#2=[#43 AND #46 AND [#20 MOD 2]]M98 P1999 L[#2]M98 P2000 L[#2]

Переменная #2 здесь работает как выключатель. Если нужный фрагмент геометрии отсутствует, оба вызова получают L0 и исчезают из исполнения; если фрагмент есть, оба получают L1 и выполняются. В нормальном языке это был бы условный блок, внутри которого лежат быстрый подвод и рабочее движение:

if edge_exists:    rapid_move_to_start()    work_move()

В G-code блока нет, поэтому одно и то же условие приходится прикручивать к каждой операции отдельно. Это менее удобно для чтения, зато очень хорошо показывает механику: условный блок — не магия, а несколько операций, которым разрешили или запретили выполниться.

Иногда условие становится чуть сложнее. Программе может быть важно не только то, выполняется ли текущий фрагмент, но и то, выполнялся ли предыдущий. Если предыдущий рез уже закончился там, где должен начаться следующий, быстрый переезд не нужен; если предыдущего фрагмента не было, к начальной точке нужно сначала подъехать.

В коде это может выглядеть так:

#21=[#2]#2=[#45 AND #46 AND [FIX[#20/2] MOD 2]]M98 P1999 L[[#21 XOR 1] AND #2]

В человеческом виде смысл примерно такой:

previous_done = condition_1current_needed = condition_2if not previous_done and current_needed:    rapid_move_to_start()

Ввиду отсутствия not используется XOR 1, вместо ifL0 или L1, а вместо нормальных имён — #21 и #2, потому что ЧПС здесь и не пахнет.

Зато эта механика честная. Сначала #21 запоминает предыдущее состояние, затем #21 XOR 1 инвертирует его, потом результат объединяется с текущим условием через AND, после чего итоговое значение отправляется в L и превращается в количество вызовов. Логика никуда не исчезла; она просто спустилась на уровень арифметики, где каждый булев признак должен стать числом, а каждое число потом ещё нужно правильно подставить.

И это важный мотив всей статьи. В языке с ЧПС логическое выражение живёт в привычном мире true и false, где есть условия, блоки, отступы и понятные операторы. В G-code логическое значение нужно превратить в 0 или 1, после чего они могут стать чем угодно: количеством повторений, номером подпрограммы, номером команды, смещением адреса или выбором подачи.

L в этом смысле оказывается очень удачной щелью, через которую в язык команд для станка пролезает целый кусок обычного программирования. Сначала кажется, он просто повторяет подпрограмму, в итоге выясняется, что если у вас есть память, арифметика и возможность выполнить что-то ноль раз, то вы собрали if.

Не красивый.

Не удобный.

Не тот, который хочется показывать на первом занятии по программированию.

Но настоящий.

for возвращается как M98 ... L10.

if возвращается как M98 ... L[#2].

А программист, глядя на это, начинает понимать, что язык высокого уровня — это не место, где впервые появляется логика, а место, где она получила одежду и мотоцикл.

Если нет if, можно вычислить номер поведения

Условное выполнение через L0 и L1 уже выглядит как маленький фокус: подпрограмма либо вызывается один раз, либо не вызывается вовсе, и параметр повторения начинает работать как зачаток if. Но это всё ещё простой случай, потому что выбор сводится к вопросу «делать или не делать». Гораздо интереснее становится там, где программе нужно выбрать не наличие действия, а само действие.

В языке с ЧПС это выглядело бы привычно:

if rotated:    init_rotated()else:    init_normal()

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

В G-code таких полочек может не быть, зато есть номера подпрограмм, и если номер — это число, то его можно вычислить.

Например, в программе есть такой вызов:

M98 P[102-[#117 AND 1]]

Переменная #117 задаёт направление резов или ориентацию логики обработки, а выражение [#117 AND 1] приводит это значение к нулю или единице. После этого арифметика делает совсем простую вещь: если результат равен нулю, вызывается подпрограмма O102, если единице — O101.

#117 = 0 → P102#117 = 1 → P101

То, что в обычном языке было бы ветвлением, здесь становится вычислением адреса поведения. Программа не говорит: «если повернуто, иди туда, иначе сюда»; она говорит: «возьми базовый номер и вычти из него результат условия». Сам выбор не исчезает, он просто переезжает из синтаксиса в арифметику.

И это особенно хорошо видно, если посмотреть на сами подпрограммы:

O101 ;init vars unrotated detail  #40=#120  #41=#121  #42=#122M99O102 ;init vars rotated detail  #40=#122  #41=#121  #42=#120M99

Одна процедура раскладывает параметры детали в обычной ориентации, другая меняет их местами так, чтобы дальнейший код мог работать с уже подготовленными значениями, не спрашивая каждый раз, повернута деталь или нет. В языке высокого уровня мы, назвали бы это нормализацией входных данных: один раз приводим разные варианты к общему внутреннему виду, после чего остальная программа движется по одной схеме. Здесь идея та же, только вместо красивого имени функции и нормального if используется соседство номеров O101 и O102.

Соседство в такой программе перестаёт быть вопросом эстетики. В обычном исходнике две функции можно поставить рядом для удобства чтения, а можно разнести по разным файлам, оставив за связь между ними имена, импорты и структуру проекта. Здесь же номера подпрограмм становятся частью вычисления, поэтому связанные варианты поведения приходится размещать так, чтобы между ними можно было переключаться простым +1 или -1. Архитектура программы начинает зависеть не только от смысла процедур, но и от их адресов.

Тот же приём используется при выборе решения о запуске шпинделя:

M98 P[1500+[#100110 AND #100109]]

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

0 → O15001 → O1501

В языке с ЧПС это можно было бы записать через список функций:

handlers = [stop_spindle, start_spindle]handlers[condition]()

Но в G-code нет списка функций, нет указателей на функции и нет удобного объекта handlers, который можно передать, сохранить или заменить. Вместо этого есть две соседние подпрограммы и число, прибавляемое к базовому номеру. Получается таблица переходов, собранная не из языковых конструкций, а из дисциплины нумерации.

На первый взгляд это выглядит как трюк из серии «не повторяйте дома», но на самом деле здесь проявляется важная низкоуровневая идея: поведение тоже можно адресовать. Мы привыкли думать об адресах как о чём-то, связанном с данными — ячейка памяти, элемент массива, поле структуры. Но как только у подпрограммы появляется номер, этот номер начинает вести себя почти как адрес функции, а условие может выбирать уже не значение, а следующий кусок поведения.

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

В G-code происходит похожая вещь, только грубее и ближе к технологическому тексту. Здесь нет нормального имени функции, которое компоновщик превратит в адрес, нет красивой таблицы указателей и нет отдельного синтаксиса для выбора обработчика, зато есть номер подпрограммы, который уже сам по себе работает как адрес поведения.

Из-за этого соседство номеров перестаёт быть оформительской деталью. Если O1500 и O1501 участвуют в вычисляемом выборе, они уже образуют маленькую адресную таблицу, пусть и записанную прямо в пространстве номеров подпрограмм. В обычном языке мы видим имя start_spindle и забываем, что где-то ниже оно всё равно сведётся к адресу. В таком G-code забыть не получается: адрес торчит наружу, участвует в арифметике и требует, чтобы программист обращался с ним аккуратно.

Это меняет ощущение от программы. Она перестаёт быть просто списком команд, идущих сверху вниз, потому что внутри неё появляются тайные развилки, спрятанные в номерах. Читателю кода приходится видеть не только текст, но и карту: здесь лежит O1500, рядом O1501, а выражение перед P решает, в какую комнату войдёт исполнение. Если номера перепутать или забыть, что соседство было частью логики, программа начнёт выбирать абсурдное поведение.

В языках высокого уровня такие ошибки обычно прикрыты именами. Если функция называется start_spindle, её можно переместить в другое место файла, и вызов не изменится. В G-code имя заменено номером, а номер участвует в арифметике, поэтому рефакторинг внезапно начинает напоминать перестановку табличек на дверях в здании, где робот уже запомнил маршрут по номерам кабинетов.

Снаружи это может выглядеть грубо:

M98 P[102-[#117 AND 1]]

Но внутри этой строки сидит очень узнаваемая идея:

choose(initializer, mode)()

Просто язык не даёт ей нормального вида, поэтому она приходит как арифметика над номерами.

И в этом смысле отсутствие if снова оказывается не отсутствием выбора, а отсутствием привычной формы выбора. Выбор всё равно появляется, потому что без него реальная программа быстро становится бесполезной; просто там, где ЧПС сказал бы «ветвление», ЧПУ говорит «посчитай номер следующей подпрограммы».

Если очень надо, можно вычислить команду

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

Но G-code на этом не останавливается.

Если номер подпрограммы можно посчитать, то почему бы не посчитать номер самой команды?

Например, в программе встречается такая строка:

M[4-[#3 AND 1]]

Сначала она выглядит как что-то, после чего хочется сделать вид, что случайно открыл не тот файл. Но если развернуть арифметику, смысл оказывается вполне земным. Переменная #3 хранит номер рабочей головы: условно 0 для одной головы и 1 для другой. Выражение [#3 AND 1] приводит это значение к нулю или единице, а затем результат вычитается из четырёх.

Получается простая развилка:

#3 = 0 → M4#3 = 1 → M3

То есть в зависимости от выбранной головы программа подставляет одну из двух команд вращения шпинделя. В языке с ЧПС это выглядело бы примерно так:

if work_head == A:    spindle_reverse()else:    spindle_forward()

А здесь условие становится частью имени команды. Не параметром функции, не аргументом метода, не значением в объекте конфигурации, а буквально кусочком того, что интерпретатор прочитает как M3 или M4.

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

То же самое происходит с движением:

G[#1] X[#10]Y[#11] F#[100015+#3]

Если #1 равно нулю, получается G0, то есть быстрый ход. Если #1 равно единице, получается G1, рабочее движение с подачей. Целевые координаты берутся из #10 и #11, а сама подача выбирается из одной из двух ячеек:

#3 = 0 → F#100015#3 = 1 → F#100016

В языке с ЧПС мы, вероятно, спрятали бы это за нормальной функцией:

move(    mode=current_mode,    x=target_x,    y=target_y,    feed=feeds[work_head])

Там режим движения, координаты и подача были бы параметрами одной понятной операции. Здесь же всё разложено на числовые куски: G[#1] выбирает тип движения, X[#10]Y[#11] подставляют целевую позицию, F#[100015+#3] достаёт подачу по вычисленному адресу, и вся строка становится маленьким сборочным цехом, где команда собирается прямо на месте.

Особенно хорошо это видно в подпрограмме движения:

O2000 ;move to #8 #9 where #2=0 fast | #2=1 work  M[98+2-[#1 XOR #2]*2] P[1002+#3*10]  G[#1] X[#10]Y[#11] F#[100015+#3]  #8=#10  #9=#11M99

Здесь в несколько строк упакована целая логика перемещения. #1 хранит текущий режим, #2 — следующий тип движения, #3 — рабочую голову, #10 и #11 — целевые координаты. Программа сначала проверяет, нужно ли переключать положение головы или режим, затем выполняет движение, после чего обновляет текущие координаты:

#8=#10#9=#11

В языке высокого уровня это было бы что-то вроде:

if current_mode != next_mode:    switch_head(work_head)move(mode=current_mode, x=target_x, y=target_y, feed=feed[work_head])current_x = target_xcurrent_y = target_y

Но в G-code всё это живёт в другом виде. Проверка current_mode != next_mode превращается в #1 XOR #2, выбор подпрограммы позиционирования — в P[1002+#3*10], выбор движения — в G[#1], а выбор подачи — в F#[100015+#3].

Отдельно прекрасна первая строка:

M[98+2-[#1 XOR #2]*2] P[1002+#3*10]

Она делает сразу две вещи. Слева вычисляется, будет ли это M100, то есть пустое действие, или M98, то есть вызов подпрограммы. Справа вычисляется номер подпрограммы позиционирования головы: для одной головы один диапазон, для другой — другой.

Если режим менять не надо, выражение выбирает пустую команду, и вызов фактически не происходит. Если менять надо, выбирается M98, и программа уходит в нужную процедуру позиционирования. Здесь уже не просто if без if; здесь условие встроено в саму форму команды, а рядом ещё и номер вызываемой процедуры вычисляется из номера головы.

В языке с ЧПС мы бы написали:

if current_mode != next_mode:    position_head(work_head)

ЧПУ же говорит примерно так:

M[98+2-[#1 XOR #2]*2] P[1002+#3*10]

И вот это как раз тот момент, где выражение «вкурить синтаксис» перестаёт быть шуткой и становится производственной необходимостью.

Потому что здесь нужно понять не только отдельные значения, но и то, как они проходят через несколько превращений подряд. Сначала состояние становится нулём или единицей. Потом ноль или единица превращаются в номер M-команды. Потом другая переменная превращается в номер подпрограммы. Потом G[#1] выбирает режим движения. Потом F#[100015+#3] достаёт подачу. И только после этого станок получает вроде бы обычную строку перемещения.

Цепочка получается такая:

состояние → число → команда → вызов → движение → новое состояние

Для станка это нормальный рабочий текст. Для человека — стресс.

Но именно поэтому этот код так полезен для понимания природы программирования. В языках высокого уровня мы привыкли, что синтаксис заранее отделяет команды от данных: вот имя функции, вот аргументы, вот условие, вот блок, вот переменная. В G-code граница между данными и синтаксисом становится тоньше. Число может быть координатой, может быть номером ячейки, может быть количеством вызовов, может быть номером подпрограммы, а может стать частью имени команды.

То есть данные начинают управлять не только тем, что делает программа, но и тем, какими словами она это говорит станку.

Это опасно, конечно. Ошибка на единицу в обычном расчёте может дать неправильную координату, а ошибка на единицу в вычисляемой команде может выбрать другой режим движения или другое действие. Когда G0 и G1 отличаются одной цифрой, а эта цифра берётся из переменной, программист внезапно начинает очень хорошо понимать, почему в нормальных языках любят типы, перечисления, проверки и все эти скучные штуки, которые не дают перепутать режим движения с номером детали.

Но, с другой стороны, в такой грубой форме видно то, что обычно спрятано под слоями удобства. Любая программа где-то внутри всё равно превращает намерение в инструкции, инструкции — в коды, коды — в действия. Просто в G-code часть этого превращения оставлена прямо в тексте, без красивой ширмы.

Там, где ЧПС сказал бы:

move_work(target_x, target_y, feed)

язык ЧПУ может ответить:

G[#1] X[#10]Y[#11] F#[100015+#3]

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

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

Даже если человеку для этого пришлось немного посидеть рядом и всё-таки вкурить, почему сегодняшняя единица означает не «да», а G1.

Пустая команда тоже команда

В обычном языке пустота обычно ничего не стоит. Не нужно действие — не пишем действие. Не нужна ветка — не заходим в ветку. Нужно выйти раньше времени — пишем return, и программа, как воспитанный человек, разворачивается у двери и уходит.

В G-code всё устроено грубее и в каком-то смысле честнее. Если управление выбирается через вычисляемую M-команду, то даже у пустоты должен быть номер. Нельзя просто сказать «ничего не делай» красивой паузой в тексте; нужно положить рядом с настоящей командой другую команду, которая тоже команда, только ничего не делает.

Например:

M[99+[[#24 OR #25] XOR 1]]M98 P110M99

С виду это опять та самая станочная тарабарщина. Но если развернуть выражение, оказывается, что перед нами обычная развилка.

#24 и #25 — шаги по X и Y. Если хотя бы один из них ненулевой, выражение [#24 OR #25] даёт единицу, после XOR 1 получается ноль, и команда превращается в M99:

То есть в выход из подпрограммы.

Если же оба шага равны нулю, выражение превращается в единицу, и команда становится M100:

А M100 здесь — пустой макрос. Он ничего не делает. Просто стоит на месте, как вахтёр, который посмотрел на пропуск, ничего не сказал и молча дал пройти дальше, к строке M98 P110

В языке с ЧПС это выглядело бы примерно так:

if step_x != 0 or step_y != 0:    returnchange_detail_number()

Но в G-code нет нормального if, нет блока и нет аккуратного else, поэтому одна ветка получает M99, то есть «выйти», а вторая получает M100, то есть «ничего не сделать и продолжить». Пустота перестаёт быть отсутствием действия и становится полноценной деталью механизма.

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

Например, к делению на ноль.

В нормальном языке мы написали бы:

result = 0if denominator != 0:    result = numerator / denominator

Сначала кладём безопасное значение по умолчанию, потом проверяем делитель, и только если всё хорошо, выполняем опасную формулу. Такой код скучен, как табличка «не влезай — убьёт», но именно поэтому он полезен: он не даёт программе сунуть пальцы туда, где искрит.

В G-code тот же предохранитель можно собрать через M99 и пустой M100:

#30=0              ; безопасное значение по умолчанию#1=[#21 AND 1]      ; можно ли делить?M[99+#1]           ; #1=0 -> M99, выйти; #1=1 -> M100, пройти дальше#30=[#20/#21]      ; опасная формула, сюда попадаем только если делитель не нольM99

Если #21 равен нулю, проверка даёт 0, команда становится M99, и подпрограмма сразу выходит, оставив в #30 заранее подготовленное безопасное значение. Опасная строка даже не получает шанса выполниться.

Если #21 не равен нулю, проверка даёт 1, команда становится M100, пустой макрос ничего не делает, выполнение проваливается ниже, и только тогда считается настоящая формула:

#30=[#20/#21]

Вот здесь пустая команда уже не выглядит шуткой. Она работает как калитка перед опасным участком: M99 закрывает проход, M100 молча отступает в сторону. Один и тот же механизм может означать «вернись назад» или «можно идти дальше», хотя вторая ветка формально ничего не делает.

И это очень характерный для такого кода приём: сначала привести состояние к безопасному виду, потом проверить, можно ли продолжать, и только после этого подпускать интерпретатор к строке, которая при неправильных данных сломается. Не надеяться, что «ну там же почти всегда не ноль», не оставлять старое значение от прошлого прохода, не делать вид, что станок поймёт намерение программиста. Сначала безопасный ноль. Потом калитка. Потом опасная формула.

Так можно защищаться не только от деления на ноль. Нулевой шаг, пустой ряд деталей, отсутствующий фрагмент геометрии, индекс за пределами ожидаемого диапазона, неподготовленное состояние инструмента — всё это случаи, где программа должна уметь не героически идти вперёд, а вовремя не попасть туда, куда ей нельзя.

В языке с ЧПС это был бы обычный защитный if.

В ЧПУ это превращается в маленькую механику выживания:

безопасное значение -> проверка -> M99 или M100 -> выход или опасная строка

И, как ни странно, ключевая деталь этой механики — команда, которая ничего не делает.

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

Четыре угла в одном числе

После вычисляемых команд, пустых макросов и самодельных if кажется, что удивляться уже поздно, но G-code умеет терпеливо подкидывать новые поводы. Например, в программе может появиться число, которое на самом деле не число в бытовом смысле, не количество деталей, не координата, не подача и не номер подпрограммы, а маленький склад флажков.

Вот такая строка:

#20=[1*1+1*2+1*4+1*8]

Сначала это выглядит подозрительно. Зачем писать 1*1, если можно написать просто 1? Зачем складывать 1*1+1*8, если получится 9? Почему взрослые люди, рядом со станком и инструментом, занимаются арифметикой, которую школьник бы упростил ещё до звонка?

А потом выясняется, что это не арифметика ради арифметики, а способ записать битовую маску в языке, который не слишком балует человека красивыми битовыми операциями.

Идея простая: один бит отвечает за один фрагмент обработки. Условно первый бит — один угол, второй — другой, третий — третий, четвёртый — четвёртый. Тогда число перестаёт быть одним значением и становится маленьким чертежом решений: этот угол резать, этот не резать, сюда идти, сюда не идти.

В языке с ЧПС это выглядело бы примерно так:

LEFT_BOTTOM  = 1RIGHT_BOTTOM = 2RIGHT_TOP    = 4LEFT_TOP     = 8mask = LEFT_BOTTOM | LEFT_TOP

Человек видит имена, видит оператор |, видит, что флаги объединяются в одну маску, и даже если он не любит битовые операции, у него хотя бы есть за что зацепиться глазами. В G-code вместо этого остаются числа:

#20=[1*1+1*8]

То есть включить первый и четвёртый фрагмент.

Формально 1*1, 1*2, 1*4, 1*8 можно было бы заменить на 1, 2, 4, 8, но в таком виде строка честнее показывает намерение: здесь не просто число девять, здесь сумма отдельных признаков, каждый из которых занимает своё место. Это не «девятка», это «первый плюс четвёртый». В бедном синтаксисе даже такая избыточность начинает работать как комментарий, только встроенный прямо в выражение.

Поставить бит в такой системе легко: берём единицу и умножаем её на степень двойки.

первый флаг   -> 1 * 1второй флаг   -> 1 * 2третий флаг   -> 1 * 4четвёртый флаг -> 1 * 8

В нормальном языке это назвали бы сдвигом влево:

flag = 1 << bit_index

Но если удобного сдвига нет, остаётся старая добрая арифметика. Хочешь поставить третий бит — умножь на четыре. Хочешь поставить четвёртый — умножь на восемь. Нет битовых команд — будут арифметические тени битовых команд.

С извлечением битов история такая же. В языке с ЧПС мы написали бы:

if mask & LEFT_BOTTOM:    cut_left_bottom()

Или, если совсем явно:

if (mask >> bit_index) & 1:    cut_fragment()

В G-code приходится доставать флаг через деление, FIX и остаток от деления на два:

[FIX[#20/4] MOD 2]

Первая строка смотрит младший бит. Вторая делит число на два, отбрасывает дробную часть и смотрит младший бит уже у получившегося значения. Третья делает то же самое после деления на четыре, четвёртая — после деления на восемь.

То есть это по смыслу означает:

(mask >> 2) & 1

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

Самое интересное начинается дальше, когда извлечённый бит не остаётся просто ответом «да» или «нет», а включается в уже знакомую механику вызовов:

#2=[#43 AND #46 AND [#20 MOD 2]]M98 P1999 L[#2]

Здесь проверяется не только флаг из маски, но и другие условия геометрии. Если всё сошлось, #2 становится единицей, и подпрограмма выполняется один раз. Если хотя бы один признак выключен, #2 становится нулём, и вызов исчезает через L0.

Получается длинная, но очень показательная цепочка:
геометрический признак -> бит -> проверка -> 0/1 -> количество вызовов -> движение инструмента

В языке с ЧПС такая цепочка была бы размазана по красивым словам: mask, has_corner, if, cut_corner. В G-code она торчит наружу почти целиком. Бит сначала живёт внутри числа, потом через MOD и FIX превращается в логическое значение, потом это значение становится параметром L, а потом уже физически решает, пойдёт инструмент резать этот фрагмент или нет.

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

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

Правда, хозяйство получается такое, где без подписи на банке лучше ничего не трогать. Число 9 само по себе не говорит человеку: «я первый и четвёртый угол». Оно просто лежит и делает вид, что оно девять. Смысл появляется только если помнить соглашение: 1, 2, 4, 8 — это не обычные числа, а места для флагов. Поэтому комментарии и дисциплина здесь снова важнее, чем хотелось бы.

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

Там, где ЧПС сказал бы:

if mask & LEFT_TOP:    cut_left_top()

ЧПУ отвечает:

#2=[#43 AND #46 AND [FIX[#20/8] MOD 2]]M98 P1999 L[#2]

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

Одно число.

Четыре флага.

Несколько проверок.

И ещё один маленький участок ЧПС, собранный из веточек и изоленты.

Машина состояний, а не автомат Калашникова

В программировании есть термин «конечный автомат». Хороший, правильный, из теории формальных языков: состояния, переходы, события, кружочки, стрелочки, всё как положено. Но в русском языке слово «автомат» живёт своей отдельной жизнью, и часть читателей при нём представляет не диаграмму, а что-то с магазином, прикладом и просьбой не направлять в людей.

Поэтому будем говорить проще: машина состояний.

Тем более что в G-code она действительно выглядит не как красивая учебная схема, нарисованная в редакторе диаграмм, а как машина, собранная прямо внутри программы из числовых ячеек, комментариев, вычисляемых команд и договорённостей, которые лучше не забывать. Здесь нет отдельного объекта MachineState, нет перечисления MoveMode, нет аккуратного класса ToolHead, но состояние всё равно есть, потому что без него станок не сможет понять, что происходит сейчас и что должно произойти следующим шагом.

В программе это состояние размазано по переменным:

#8      ;X current Pos#9      ;Y current Pos#10     ;X Target pos#11     ;Y Target pos#1      ;current move/head state#2      ;next move type#3      ;work head#22     ;counter x#23     ;counter y#24     ;step x#25     ;step y

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

В языке с ЧПС мы, скорее всего, оформили бы это примерно так:

state = MachineState(    current_x=current_x,    current_y=current_y,    target_x=target_x,    target_y=target_y,    move_mode=move_mode,    work_head=work_head,    row=row,    column=column)

А потом писали бы что-то вроде:

state.move_to_target()state.advance_to_next_part()

То есть спрятали бы состояние в структуру, дали бы ему имя, обложили бы методами, проверками и, возможно, даже тестами, если день выдался особенно сознательный. В G-code такой роскоши нет. Состояние лежит россыпью, как детали на верстаке, и программа каждый раз сама собирает из них следующий шаг.

Хороший пример — подпрограмма движения:

O2000 ;move to #8 #9 where #2=0 fast | #2=1 work  M[98+2-[#1 XOR #2]*2] P[1002+#3*10]  G[#1] X[#10]Y[#11] F#[100015+#3]  #8=#10  #9=#11M99

Здесь происходит больше, чем кажется на первый взгляд. Сначала программа сравнивает текущее состояние #1 с целевым состоянием #2. Если они различаются, значит, перед движением нужно переключить режим; если совпадают — лишний переход не нужен. В языке с ЧПС это было бы спокойное:

if current_mode != next_mode:    position_head(work_head)

В G-code вместо этого появляется уже знакомая смесь арифметики и вычисляемых команд:

M[98+2-[#1 XOR #2]*2] P[1002+#3*10]

#1 XOR #2 отвечает на вопрос: изменилось ли состояние? Если нет, выбирается пустая команда. Если да, выбирается вызов нужной подпрограммы позиционирования, причём её номер тоже вычисляется через #3, то есть через номер рабочей головы. Машина состояний здесь не нарисована, но она работает: было одно состояние, потребовалось другое, программа вычислила переход.

Потом идёт само движение:

G[#1] X[#10]Y[#11] F#[100015+#3]

И в этой строке состояние снова превращается в действие. #1 выбирает G0 или G1, #10 и #11 задают цель, #3 выбирает подачу для нужной головы. Программа как будто говорит: я знаю, в каком режиме нахожусь, знаю, куда мне надо, знаю, чем я работаю, поэтому могу собрать команду движения.

А после движения обязательно обновляются текущие координаты:

#8=#10#9=#11

Это маленькие строки, но без них вся машина состояний развалится. Станок физически уехал в новую точку, и программа должна признать этот факт в своей памяти. Если забыть обновить #8 и #9, следующий расчёт будет исходить из старого мира, которого уже нет. Железо стоит в одном месте, программа думает, что в другом, и дальше начинается не программирование, а гадание по стружке.

В этом смысле G-code очень хорошо напоминает, что состояние — это не абстрактная теория для скучных людей с диаграммами. Состояние — это память программы о мире. Где мы сейчас? Что уже сделано? Какой инструмент активен? Какой фрагмент геометрии был последним? Нужно ли делать быстрый переезд или можно продолжать рез от текущей точки?

Те же идеи видны в обходе деталей:

#22=[#22+#24]#23=[#23+#25]M[99+[[#24 OR #25] XOR 1]]M98 P110M99

Сначала обновляются счётчики по X и Y. Если шаг задан, подпрограмма выходит: мы просто перешли к следующей позиции внутри текущей логики. Если шага нет, пустой M100 пропускает выполнение дальше, и вызывается O110, где меняется уже более крупный уровень состояния — например номер детали, ряд или следующий участок обработки.

В языке с ЧПС это можно было бы записать как-нибудь так:

column += step_xrow += step_yif step_x != 0 or step_y != 0:    returnadvance_part()

Но в G-code эта мысль живёт в другом виде: сначала арифметика меняет состояние, потом вычисляемая команда решает, закончился ли переход на этом уровне, потом, если нужно, вызывается следующая процедура. Это уже не одна строка и не один if, а ручной механизм переходов между состояниями.

И здесь начинает проявляться то, что можно назвать бедностью языка, помноженной на богатство инженерной мысли. Чем меньше язык даёт готовых форм, тем отчётливее видно, из чего эти формы обычно состоят: память, текущее состояние, желаемое состояние, проверка различия, переход, действие, обновление памяти. В обычном языке всё это часто спрятано за методами и объектами. В G-code крышка снова снята, и под ней видно не красивую архитектурную диаграмму, а валы и шестерёнки.

Снаружи программа может казаться набором строк, выполняемых сверху вниз, но внутри неё постоянно работает цепочка:
число -> состояние -> выбор -> действие -> новое состояние

#1 был одним режимом, стал другим. #8/#9 были одной позицией, стали другой. #22/#23 указывали на одну копию, теперь на следующую. Маска углов включала один фрагмент, потом другой. Подпрограмма вышла через M99 или провалилась дальше через пустой M100.

Это и есть машина состояний, только без таблички «конечный автомат». Она не объявлена отдельным блоком, не названа красивым термином и не спрятана в библиотеку. Она просто выросла из необходимости: станок должен помнить, где он находится, а программа должна помнить, что она уже сделала.

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

Иногда испорченный материал, а иногда поломанный станок. И очень важно чтобы никогда — разрушенная жизнь!

И вот тут G-code снова оказывается странно полезным как учебный экспонат. Он показывает, что машина состояний — это не только красивые диаграммы из учебника и не только архитектурный паттерн для интерфейсов, протоколов или игровых персонажей. Это базовый способ жить в мире, где следующее действие зависит от того, что уже произошло.

Станок был здесь — теперь там.

Голова была в одном режиме — теперь в другом.

Деталь была первой — стала следующей.

Фрагмент был выключен в маске — значит, геометрия не родилась.

Где-то в языках высокого уровня всё это прячется за ЧПС. В G-code оно остаётся почти на поверхности, в ячейках, арифметике и комментариях, которые приходится читать так внимательно, будто это не комментарии, а дорожные знаки перед серпантином.

Потому что для такой программы состояние — не служебная мелочь.

Это её память о реальности.

Геометрия не хранится, а выращивается

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

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

Поэтому живая программа хранит не сами координаты, а правила их появления.

Где-то задаётся стартовая позиция:

#6=[#100]#7=[#101]

Где-то подготавливаются размеры и технологические параметры:

#40=#120#41=#121#42=#122

А потом координата рождается из формулы:

#8=[#6+[[#43+#45]*#42+#40+#100106]*#20+#43*#42]

С первого взгляда это строка, — китайская грамота, но внутри у неё вполне человеческая мысль: есть начальная точка, есть номер копии, есть шаг между деталями, есть размер, припуски и поправка на инструмент. Всё это складывается в позицию очередного фрагмента.

На языке с ЧПС это выглядело бы примерно так:

part_step = left_margin + right_margin + width + tool_diameterx = start_x + part_step * part_index + left_margin

Мысль та же, просто обычный язык разложил её на имена, а G-code вывалил почти всю кухню прямо в выражение. Формула одновременно считает координату, документирует логику для того, кто её понимает, и ставит ловушку для того, кто ошибётся в одном слагаемом.

Главное здесь в другом: программа хранит не снимок геометрии, а её геном.

Снимок сказал бы: первая деталь здесь, вторая здесь, третья здесь. Геном говорит иначе: есть начальная точка, есть шаг, есть индекс, и координату можно каждый раз вырастить заново. Изменился размер — поменялся параметр. Изменился диаметр инструмента — поменялась поправка. Стало больше копий — изменился счётчик. Формула остаётся той же, просто из неё вырастает другой маршрут.

Такой подход особенно естественен там, где память ограничена. Хранить готовые координаты всех копий, сторон, углов и вариантов — роскошь. Гораздо выгоднее держать несколько параметров: стартовую точку, размеры, припуски, диаметр инструмента, номер копии, ориентацию и маску фрагментов. Всё остальное можно получить расчётом ровно в тот момент, когда станку пора ехать.

Поворот детали работает так же, вместо дублирования нормализация входных данных. В G-code это просто несколько присваиваний, но смысл тот же: сначала привести мир к удобной системе координат, а потом уже не спрашивать на каждом шаге, повернута деталь или нет.

Геометрия здесь растёт не только из размеров, но и из решений. Маска углов говорит, какие фрагменты существуют; L0 не даёт родиться ненужному движению; состояние подсказывает, нужен ли быстрый подвод; счётчик выбирает копию. В результате траектория не лежит в файле готовой лентой, а собирается на ходу из параметров, флагов и текущего состояния.

Там, где ЧПС сказал бы:

x = layout.position(part_index, orientation, margins, tool)

ЧПУ отвечает:

#8=[#6+[[#43+#45]*#42+#40+#100106]*#20+#43*#42]

И да, после такого начинаешь особенно ценить нормальные имена переменных, потому что layout.position(...) хотя бы делает вид, что перед ним сидит человек, а не дешифровщик технологической клинописи.

Но под этой разницей скрыта одна и та же идея: хорошая программа не обязана хранить каждый шаг маршрута. Иногда она хранит правила, из которых маршрут появляется сам.

Сначала стартовая точка.

Потом размер.

Потом припуск.

Потом номер копии.

Потом формула.

И из нескольких чисел вырастает не координата даже, конструктор координат.

Программист вручную делает работу языка

После всех этих фокусов хочется сказать, что G-code — странный язык. Но это будет не совсем честно.

Странность здесь не в том, что программист делает что-то принципиально чужое программированию, а наоборот: он делает самые обычные вещи, только без привычной помощи языка. Там, где ЧПС приносит готовый стул, ЧПУ выдаёт доски, гвозди и говорит: тыжпрограммист, собери.

Нет нормальных имён — появляется таблица комментариев.

Нет массивов — появляются соседние ячейки и вычисляемый адрес.

Нет if — появляется вызов подпрограммы ноль или один раз.

Нет выбора функции — появляется вычисляемый номер P.

Нет красивого битового сдвига — появляются умножение, деление, FIX и MOD.

Нет безопасной ветки — появляются значение по умолчанию, M99 и пустой M100.

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

В обычном коде мы бы написали:

for point in points:    if point.enabled:        move_to(point.x, point.y)

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

В G-code всё это приходится собирать руками.

points[index] превращается в вычисляемый адрес.

if превращается в L[#2].

return превращается в M99.

pass внезапно получает номер M100.

handler[condition]() превращается в M98 P[1500+condition].

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

Просто язык не делает эту работу за программиста.

И вот здесь становится особенно понятно, зачем вообще нужны современные абстракции. Не потому, что программисты стали ленивыми и разучились писать «ближе к железу», а потому что человек плохо держит в голове большие конструкции из голых чисел. Имена нужны не для красоты. Типы нужны не для галочки. Структуры данных нужны не потому, что кому-то захотелось усложнить язык. Они нужны, чтобы координата не стала счётчиком, счётчик — подачей, подача — номером команды, а номер команды — причиной конфуза.

Простое не значит лёгкое.

G-code снаружи прост: G0, G1, координаты, подача, включить, выключить, вызвать, вернуться. Но эта простота обманчива, потому что настоящая сложность живёт не в длине команды, а в цепочке решений, которая привела к этой команде. Откуда взялась координата? Почему движение рабочее, а не быстрое? Какая голова активна? Какой фрагмент включён в маске? Обновлено ли состояние? Безопасно ли считать следующую формулу?

Дураку всё кажется просто, и только умный понимает сложность простой вещи.

В этом смысле G-code — хороший учебный экспонат. Он демонстрирует хардкор, скрытый от нас компилятором —  грубую механику: адрес, флаг, переход, состояние, повторение, проверка, действие. Там, где современный язык заботливо говорит человеку «вот тебе массив», «вот тебе условие», «вот тебе функция», старый станочный код пожимает плечами и предлагает собрать то же самое из номеров, арифметики и дисциплины.

И, пожалуй, именно поэтому после такого кода начинаешь сильнее ценить ЧПС.

Не как синтаксический сахар в смысле украшения, а как нормальную человеческую инфраструктуру, благодаря которой программист может думать о задаче, а не всё время удерживать в голове, почему #20 сегодня является маской углов, #1 — состоянием движения, а M[99+safe] — защитой от деления на ноль.

Но если этой инфраструктуры нет, программирование всё равно не исчезает. Оно просто становится брутальнее.

Цикл возвращается как M98 ... L10.

Условие возвращается как M98 ... L[#2].

Массив возвращается как #[base+index*2].

Функция возвращается как номер подпрограммы.

Машина состояний возвращается как несколько ячеек, которые нужно не забыть обновить.

А геометрия возвращается как формула, из которой маршрут вырастает прямо перед движением.

И в какой-то момент понимаешь, что это не просто экзотика G-code. Это хорошо сохранившийся запах программирования шестидесятых, где многое из того, что сегодня выглядит как лайфхак, было обычной рабочей техникой: вычисляемые адреса, таблицы переходов, битовые маски, ручное управление состоянием, экономия памяти и дисциплина, заменяющая удобный синтаксис.

Так писали код наши бабушки.

Просто теперь это называется лайфхаками.

Послесловие для тех, кто уже тянется к комментариям

Эта статья основана исключительно на практическом опыте автора и не претендует на описание «единственно правильного G-code». У языка управляющих программ много диалектов и реализаций, есть и те, где все описанные проблемы давно решены штатными средствами, а где-то даже вставками Python.

Поэтому если какая-то конструкция из статьи кажется вам странной, это может быть особенностью конкретного контроллера, конкретного станка или конкретной производственной истории. Автору, например, попадались станки, где моторы активных осей переключались физически, через реле, а команды охлаждения или тумана использовались не по каноническому назначению: где-то M7 и M8 — это туман и охлаждение, а где-то за этими командами коммутация оси.

И да, существуют препроцессоры, постпроцессоры, CAD/CAM-системы и нормальные технологические цепочки. Эта статья не спорит с ними и не призывает заменять CAM героическим ручным макрокодом. Просто у сгенерированной программы часто есть свойство: она уже мёртвая траектория, готовый маршрут, который при изменении условий надо пересчитывать снаружи. Иногда это бюрократически дорого и неоправданно долго.

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

CAM даёт маршрут.

Макропрограмма даёт правило, по которому маршрут рождается.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1047268/