SectorC (github) – это компилятор Си, написанный на ассемблере x86-16 и умещающийся в загрузочный сектор 512 байт машины x86. Он поддерживает достаточное обширный функционал Си для создания реальных и интересных программ, являясь при этом, пожалуй, самым миниатюрным компилятором Си из когда-либо написанных.

В кодировке base64 он выглядит так:

6gUAwAdoADAfaAAgBzH/6DABPfQYdQXoJQHr8+gjAVOJP+gSALDDqluB+9lQdeAG/zdoAEAfy+gI
AegFAYnYg/hNdFuE9nQNsOiqiwcp+IPoAqvr4j3/FXUG6OUAquvXPVgYdQXoJgDrGj0C2nUGV+gb
AOsF6CgA68Ow6apYKfiD6AKrifgp8CaJRP7rrOg4ALiFwKu4D4Srq1fonP9ewz2N/HUV6JoA6BkA
ieu4iQRQuIs26IAAWKvD6AcAieu4iQbrc4nd6HkA6HYA6DgAHg4fvq8Bra052HQGhcB19h/DrVCw
UKroWQDoGwC4WZGrW4D/wHUMuDnIq7i4AKu4AA+ridirH8M9jfx1COgzALiLBOucg/j4dQXorf/r
JIP49nUI6BwAuI0G6wyE0nQFsLiq6wa4iwarAduJ2KvrA+gAAOhLADwgfvkx2zHJPDkPnsI8IH4S
weEIiMFr2wqD6DABw+gqAOvqicg9Ly90Dj0qL3QSPSkoD5TGidjD6BAAPAp1+eu86Ln/g/jDdfjr
slIx9osEMQQ8O3QUuAACMdLNFIDkgHX0PDt1BIkEMcBaw/v/A8H9/yvB+v/34fb/I8FMAAvBLgAz
wYQA0+CaANP4jwCUwHf/lcAMAJzADgCfwIUAnsCZAJ3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAVao=

Поддерживаемый функционал языка


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

К примеру, следующая программа анимирует движущуюся синусоидальную волну:

int y;
int x;
int x_0;
void sin_positive_approx()
{
  y = ( x_0 * ( 157 - x_0 ) ) >> 7;
}
void sin()
{
  x_0 = x;
  while( x_0 > 314 ){
    x_0 = x_0 - 314;
  }
  if( x_0 <= 157 ){
    sin_positive_approx();
  }
  if( x_0 > 157 ){
    x_0 = x_0 - 157;
    sin_positive_approx();
    y = 0 - y;
  }
  y = 100 + y;
}


int offset;
int x_end;
void draw_sine_wave()
{
  x = offset;
  x_end = x + 314;
  while( x <= x_end ){
    sin();
    pixel_x = x - offset;
    pixel_y = y;
    vga_set_pixel();
    x = x + 1;
  }
}

int v_1;
int v_2;
void delay()
{
  v_1 = 0;
  while( v_1 < 50 ){
    v_2 = 0;
    while( v_2 < 10000 ){
      v_2 = v_2 + 1;
    }
    v_1 = v_1 + 1;
  }
}

void main()
{
  vga_init();

  offset = 0;
  while( 1 ){
    vga_clear();
    draw_sine_wave();

    delay();
    offset = offset + 1;
    if( offset >= 314 ){ // используйте модуль значения, чтобы избежать целочисленного переполнения  2^16
      offset = offset - 314;
    }
  }
}

image

Но как?


О написании SectorC я задумался сразу после завершения Deobfuscating OTCC, так что у меня в голове присутствовало множество свежих идей. К тому же я почитал материалы justine.lol и Tom7, которые дополнительно вдохновили меня на столь абсурдный шаг.

Ожидал ли я, что преуспею? Скорее, нет. Вместить целый компилятор Си в 510 байт памяти инструкций? Удачи!

▍ Токенизация


Первая проблема проявилась очень быстро. В Си один только токенизатор/лексер занимает больше сектора 512 байт. Нам же нужно получать произвольный поток байтов, создавая из него «токены».

Например, код:

int main()
{
  if( a < 5 ){
    func();
  }
}

Будет получен и преобразован в:

'int'  TOKEN_KEYWORD_INT
'main' TOKEN_IDENTIFIER
'('    TOKEN_LPAREN
')'    TOKEN_RPAREN
'{'    TOKEN_LBRACE
'if'   TOKEN_KEYWORD_IF
'('    TOKEN_LPAREN
'a'    TOKEN_IDENTIFIER
'<'    TOKEN_OPERATOR
'5'    TOKEN_NUMBER
')'    TOKEN_RPAREN
'{'    TOKEN_LBRACE
'func' TOKEN_IDENTIFIER
'('    TOKEN_LPAREN
')'    TOKEN_RPAREN
';'    TOKEN_SEMI
'}'    TOKEN_RBRACE
'}'    TOKEN_RBRACE

Нам необходимо распознавать keywords, identifiers, operators и numbers. Далее полученные значения нужно будет преобразовывать из строк в целые числа с помощью функции наподобие atoi():

int atoi(const char *s)
{
  int n = 0;
  while (1) {
    char c = *s++;
    if (!c) break;
    n = 10 * n + (c - '0');
  }
  return n;
}

Я написал простой и минималистичный лексер, который занял чуть больше 150 строк Си. Примерно такой же код на x86-16 занял бы от 300 до 450 байт минимум (например, простая инструкция add ax, bx кодируется в 2 байта). И это без учёта таблицы символов, парсера с рекурсивным спуском, кодогенератора, исправления ветвлений и так далее.

Никаких шансов.

Поэтому, естественно, … я продолжил. Всегда ставьте на аутсайдеров – так веселее.

Озарение #1


Первое озарение пришло в процессе размышления о других языках вроде Forth. Токенизатор в Forth довольно примитивен – в нём токены разграничиваются пробелом. При этом каждый токен просто называется WORD без каких-либо особенностей (не совсем так). Хмм, а что насчёт варианта Си, который будет делать так же? Я придумал такой код, который технически остался Си, сохранил полноту по Тьюрингу, но определённо будет пугать любого мейнтейнера.

Буду называть эту версию языка «почти Си»:

int done , a , b , c , p , cond ;
int(main)(){while(!done){
     a = b - c ;
     *(int*) p = b - c ;
     a = *(int*) p ;
     if(cond) a = b - 45 ;
}}

Здесь мы добавили пробелы, расположенные стратегически для создания «мега-токенов».
К примеру, одним из таких «мега-токенов» является int(main)(){while(!done){

В некотором смысле мы фактически получили язык больше похожий на:

VAR_BEGIN done AND a AND b AND c AND p AND cond END
MAIN_BEGIN
  a = b - c END
  DEREF p = b - c END
  a = DEREF p END
  COND a = b - 45 END
MAIN_END

Но стандартный компилятор Си также распознаёт его как родной.

Даже после разграничения у нас всё равно получается много токенов, то есть нужно искать другие способы уменьшить токенизатор. Без чего здесь не обойтись? Хмм, довольно трудно избежать atoi(), если мы хотим фактически иметь целочисленные литералы. Что ещё необходимо? Может, ничего?

Озарение #2


Вторым озарением стало то, что atoi() действует для стандартного текста как (плохая) хэш-функция. Она получает символы и обновляет 16-битное целое число. Хэши, пожалуй, являются священным граалем в вычислительном мире. С помощью хорошего хэша можно запросто обойти все трудные проблемы, разменяв их на ещё более трудную (коллизию хэшей), а затем просто проигнорировав её. Гениально. (затыкаю уши пальцами).

Итак, у нас есть следующее:
Тип токена Содержание atoi()
Целочисленный литерал Число uint16
Ключевое слово Значение token ”enum”
Идентификатор Хэш-значение в массиве 64K

Реализация «почти Си»


Первая реализация версии «почти Си» вписалась в 468 байт. Это был простой парсер с рекурсивным спуском для токенов atoi. В нём не было никакой таблицы символов. Переменные просто обращаются к сегменту 64К по хэш-значению. Кодогенератор получился похожим на реализованный в OTCC – он использует ax в качестве регистра результатов, передаёт значения в стек, а затем в cx для бинарных операторов.

Минимизация с помощью шитого байткода


В попытке украсть все удачные идеи Forth далее я придумал то, что решил назвать шитый байткод (в оригинале byte-threaded-code). Поскольку размер сектора составляет 512 байтов, то если просто выровнять адрес по 2-байтовой границе, то появится возможность выполнять адресацию с помощью одного байта. Мы можем задействовать серию «гаджетов» и реализовать потоковость в стиле Forth.

bits 16
  cpu 386

  jmp 0x07c0:entry

entry:
  push cs
  pop  ds

  lea si,operations
next:
  xor ax,ax
  lodsb
  add ax,ax
  push next
  jmp ax

putch:
  mov ah,0x01
  mov al,bl
  mov dx,0
  int 0x14
  ret

  align 2
hang:
  jmp hang

  align 2
print_F:
  mov bx,'F'
  jmp putch

  align 2
print_G:
  mov bx,'G'
  jmp putch

operations:
  db 17  ; print_F
  db 20  ; print_G
  db 17  ; print_F
  db 17  ; print_F
  db 20  ; print_G
  db 17  ; print_F
  db 17  ; print_F
  db 17  ; print_F
  db 20  ; print_G
  db 16  ; hang

К сожалению, nasm не позволяет выполнять что-либо наподобие db print_F/2, поэтому мне пришлось написать для этого небольшой кастомный код ассемблера.

Увы, эта идея не сработала. В 512 байтах издержки этой вычислительной модели в стиле Forth себя не оправдывают. Здесь получается много мелких накладных расходов: выравнивание по 2 байтам, дополнительные инструкции ret, вызов других «потоков», функция next и так далее. Размер этой потоковой версии получился почти таким же, как у прежней.

Тем не менее сама идея мне понравилась, и я решил всё равно её задокументировать на случай, если кто-то другой найдёт ей применение.

Минимизация простой версии


В итоге я вернулся к простой версии и минимизировал её, насколько это оказалось возможным. Мне удалось сократить размер с 468 до 303 байтов (165 байт экономии: 510 — 303 ⇒ 207 свободных байтов для нового функционала).

На какие я пошёл ухищрения:

  • Реорганизовал код так, чтобы инструкции выполнялись последовательно без явного использования команд jmp или call.
  • По возможности использовал завершающие вызовы через jmp.
  • Произвёл слияние вызовов (например, call tok_next2 вместо call tok_next; call tok_next).
  • Активно задействовал stosw и lodsw.
  • Устранил таблицы машинного кода для получения менее затратных встроенных версий stows.
  • Использовал cmp ax,imm вместо cmp bx,imm.
  • Сохранил размер смещений переходов в пределах 8 бит для более эффективного кодирования.

Смотри, мам, реальный Си!


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

  • Произвольно вкладываемый блок инструкции if с произвольным условным выражением.
  • Произвольно вкладываемый блок инструкции while с произвольным условным выражением.
  • Множество операторов: +, -, *, &, |, ^, <<, >>, ==, !=, <, >, <=, >=
  • Группирующие выражения: ( expression )
  • Определения функций и рекурсивные вызовы функций (с использованием func() в качестве хэш-значения в сегменте 0х3000 таблицы символов)
  • Специальную инструкцию asm для встраиваемого машинного кода.
  • Однострочные комментарии //.
  • Многострочные комментарии /*.
  • Приём для «вставки пробелов» перед точками с запятой, чтобы код выглядел более типично.

Наиболее значимый фактор здесь – это таблица binary_oper_tbl, которая обеспечивает очень экономичный способ добавления множества операций. Каждый оператор представляет собой просто пару <16-битное значение токена> <16-битный машинный код>, занимающую всего 4 байта. Перечисленные выше 14 операторов требуют 56 байт плюс небольшие издержки на сканирование таблицы.

Грамматика


Вот вся спецификация грамматики:

program     = (var_decl | func_decl)+
var_decl    = "int" identifier ";"
func_decl   = "void" func_name "{" statement* "}"
func_name   = <identifier that ends in "()" with no space>
statement   = "if(" expr "){" statement* "}"
            | "while(" expr "){" statement* "}"
            | "asm" integer ";"
            | func_name ";"
            | assign_expr ";"
assign_expr = deref? identifier "=" expr
deref       = "*(int*)"
expr        = unary (op unary)?
unary       = deref identifier
            | "&" identifier
            | "(" expr ")"
            | indentifier
            | integer
op          = "+" | "-" | "&" | "|" | "^" | "<<" | ">>"
            | "==" | "!=" | "<" | ">" | "<=" | ">="

Помимо этого, здесь поддерживаются и // однострочные комментарии, и /* многострочные */.

Примечание: эта грамматика занимает 704 байта в ASCII, что на 38% больше её реализации.

Встраиваемый машинный код


Язык программирования без ввода-вывода окажется бесполезен. А поскольку Си в этом отношении универсален, нам нужно выбрать подходящий вариант. Я склонился к расширению asm, которое позволяет программам генерировать встроенные литералы сырого машинного кода x86-16. Используя asm, программы могут обращаться к любому низкоуровневому компоненту машины. В своём примере кода это расширение я активно задействую.

Обработка ошибок


Что такое «обработка ошибок»?

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

Для менее же всесильных представителей мира программирования также была создана программа lint (которая не вмещается в сектор), предназначенная для перехвата ошибок. Автору при разработке этот инструмент, естественно, не потребовался.

Среда выполнения


Если бы создатели компиляторов Си были тайной организацией вроде свободных масонов, иллюминатов или людей-ящериц, то наш внутренний секрет заключался бы в том, что «Си на самом деле обладает средой выполнения».

В SectorC по пути rt/ находится среда выполнения, состоящая из двух файлов, реализованных на самом Си:

  • rt/lib.c: коллекция библиотечных процедур, зачастую встраиваемых через asm.
  • rt/_start.c: фактическая точка входа _start().

Код среды выполнения объединяется с исходной программой, создавая полноценную версию для компиляции и выполнения.

Примеры


Приведу несколько примеров, в которых используются уникальные аппаратные аспекты x86-16 IBM PC:

  • examples/hello.c: выводит текстовое приветствие на экран и записывает его в память по адресу 0xB8000.
  • examples/sinwave.c: отрисовывает движущуюся синусоидальную волну в режиме VGA 13h с использованием соответствюущей плохой аппроксимации sin(x).
  • examples/twinkle.c: воспроизводит “Twinkle Twinkle Little Star” через PC-спикер (предупреждение: ГРОМКО).

Заключение


Будет уместным завершить статью чем-то вроде «основных выводов» или «усвоенных уроков». Итак…чему же мы научились? Честно говоря, не уверен. Но забавы ради выражу эту идею в виде таблицы в стиле «Выбери собственное приключение»:
Чему мы научились Ваш выбор приключения
То, что выглядит невозможным, зачастую таковым не является, поэтому за подобные проекты нужно просто браться и делать. Отправляйтесь на Южный полюс без снаряжения с миссией выживания и обустройства места жительства.
Современное программное обеспечение излишне раздуто. На деле нам требуется всего несколько КБ. Зарегистрируйтесь в технологической хиппи-общине suckless.
Значимость проверки ошибок переоценена. Примите предложение Илона стать астронавтом на Марсе, потому что на Земле уже хватает ПО, игнорирующего ошибки.
Всё, что умеет X, Си может сделать лучше. Как в этом ролике. Монзи, нам нужен новый трек. (позвони мне)
Всё это оказалось полной чушью, спасибо за потраченное мной впустую время. (пассивная агрессия). Чувствуете расстройство из-за того, что потратили время зря — ведь в мире есть столько куда более интересного контента — и в итоге решаете обратиться к терапевту, который поможет разобраться с проблемой чтения бессмысленной интернет-ахинеи.
Этот персонаж xorvoid/робот/ИИ смешон/абсурден/глуп и занимается бессмысленными вещами ради развлечения. Подписаться, поставить лайк, нажать на колокольчик…

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


  1. Nihiroz
    09.06.2023 13:10
    +6

    Почему на фоне КДПВ html?


    1. art1fact
      09.06.2023 13:10
      +1

      Спасибо за внимательность. Подправили


  1. dlinyj
    09.06.2023 13:10
    +3

    Давненько нас Bright_Translate не радовал зачётными хабртортными переводами. Очень круто.


  1. romancelover
    09.06.2023 13:10
    +6

    Только в начале опечатка: "512 Кб" - должно быть 512 байт.


    1. art1fact
      09.06.2023 13:10
      +1

      Спасибо!


  1. forthuse
    09.06.2023 13:10
    +5

    Попробовал собрать код проекта с Github
    Получил такое сообщение:


    sectorc.s:157: error: invalid combination of opcode and operands

    т.е. на код
    mov es:[si-2],ax ; patch "src - 2"


    P.S. При сборке версией nasm 2.11?, но возможно имеет смысл проверить рекомендуемый 2.16.01
    Upd: Да, c 2.16.01 ошибок не отобразилось


  1. vadimr
    09.06.2023 13:10
    +6

    byte-threaded code – шитый байткод, или просто шитый код.


    1. jin_x
      09.06.2023 13:10
      +1

      Если "шитый байткод", тогда это было бы "threaded bytecode", а это "байтовый шитый код", ну или см. ниже ещё вариант.


      1. vadimr
        09.06.2023 13:10

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


    1. Bright_Translate Автор
      09.06.2023 13:10

      Благодарю!


  1. IvanPetrof
    09.06.2023 13:10
    +7

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


    1. vvzvlad
      09.06.2023 13:10

      Поскольку он написан на ассемблере, достаточно добавить сюда асм-вставки и готово.


  1. jin_x
    09.06.2023 13:10
    +2

    Я ради прикола попытался сократить немного код, получилось ужать его на 6 байт. Профит небольшой, конечно, но в целом там есть ещё порядка 30 байт на какие-нибудь фичи типа операторов / ! ~ break continue do-while ... чего там ещё нет? :)


    1. jpegqs
      09.06.2023 13:10
      +3

      Из того что я понял по описанию:

      У функций нет аргументов, нет for, нет switch, нет никаких типов кроме int, нет указателей (только доступ через *(int*)), нет массивов, нет return (функции ничего не возвращают), нет строковых литералов, нет локальных переменных (только определённые вне функции), нет оператора деления, нет оператора остатка, и много чего еще...


      1. jin_x
        09.06.2023 13:10

        Есть while, значит можно и do-while сделать. С for и switch сложно, в 30 байт вряд ли влезет. Массивы тоже вряд ли. Вот return ещё можно попробовать, вероятно.


        1. Melirius
          09.06.2023 13:10

          Их можно преобразовать в while и if с дьявольской помощью goto


      1. kasthack_phoenix
        09.06.2023 13:10
        +21

        компилятор C

        отсутствует большая часть языка


        1. jpegqs
          09.06.2023 13:10
          +1

          Да, меня это тоже возмутило. Даже открыть описание в оригинале, и там в первом абзаце:

          It supports a subset of C that is large enough to write real and interesting programs. It is quite likely the smallest C compiler ever written.

          Когда поддерживается лишь малая часть базового функционала языка. И это автор называет языком Си. А затем и "самым маленьким Си компилятором".


          1. IntActment
            09.06.2023 13:10
            +3

            Тогда можно пойти ещё дальше и написать "компилятор", который распознает main(), но не распознает его содержимое - тогда наверняка можно ужать размер до феноменального минимума


    1. bolk
      09.06.2023 13:10

      Там деления нет, это очень неудобно ( Было бы неплохо его добавить!


  1. red75prim
    09.06.2023 13:10
    +3

    «byte-threaded code»

    Байтовый свёрнутый шитый код.


    1. jin_x
      09.06.2023 13:10
      +1

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


  1. 0xC0CAC01A
    09.06.2023 13:10
    +4

    Круто, сказал я и пошёл скачивать обновление в сотню гигабайт ))


  1. mrbald
    09.06.2023 13:10

    Надо теперь написать 128-байтное ядро, для него 256-байтный докер, поверх 512-байтный JVM, и потом килобайтный компилятор Kotlin, а то микросервисы будет неудобно писать иначе.


    1. AlexZas20
      09.06.2023 13:10

      И ядро Linux ужать в 64 байт


  1. emusic
    09.06.2023 13:10

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


    1. forthuse
      09.06.2023 13:10
      +2

      Автор, вдохновился продемонстрированными вызовами по размещению некоторого подмножества Lisp и Forth в 512-бут секторе, что отмечено на странице его проекта
      и предложил свой вариант для C-подмножества языка.


      P.S. Вот познавательная статья такого вызова Разместить FORTH в 512 байтах (в профиле переведены и другие статьи из блога этого проекта)


      1. emusic
        09.06.2023 13:10

        Я понимаю только стремление продемонстрировать серьезную функциональность в малом объеме, за это автору респект.

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

        Даже стремление уложиться именно в 512 байт обосновано исключительно символизмом числа, ибо все остальное всяко придется дочитывать с диска, а там уже нет никакой разницы, какое количество секторов указать в int 13.


  1. B13nerd
    09.06.2023 13:10
    +1

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

    "Шитый код" (или "шитый байт-код" ?).
    См. https://ru.wikipedia.org/wiki/Шитый_код


    1. Bright_Translate Автор
      09.06.2023 13:10

      Спасибо за ссылку


      1. forthuse
        09.06.2023 13:10

        Вот ещё видео лекция о Шитом коде.
        https://www.youtube.com/watch?v=C9sLcsd8QT4


        P.S. И некоторая лекция о Форт (Forth): https://youtu.be/Np5pdDAswEI


        Упомянутая статья на WikiPedia (и др.) — MovingForth by Brad Rodriguez есть в переводе в разделе статей русскоязычного форума по Форт (Forth) и другим саморасширяющиися системам программирования


        Для интересующихся реализацией "кишочков" Форт, есть перевод статей из авторского блога по JonesForth частоупоминаемый в проектах на Github Минимальный Форт с нуля


        Шитый код можно найти в реализации Форт-систем по классике: к примеру amForth, FlashForth, CamelForth, Mecrisp и других.


  1. klopp_spb
    09.06.2023 13:10
    +1

    компилятор Си 

    Это не компилятор C. Это попытка впихнуть невпихуемое - зачёт :)


  1. Treguard
    09.06.2023 13:10
    +3

    Please make a C IDE for the spectrum 48k?


    1. forthuse
      09.06.2023 13:10
      +3

      Like? CC64 for Commodore :)


      cc64 is a small-C compiler written in Forth, hosted on the Commodore C64, Plus4 and C16 with 64k, and on the Commander X16. It is targeting the 6502 CPU.


  1. MasterMentor
    09.06.2023 13:10
    +1

    Есть что-то пародийное в мире "новой нормальности". Никчёмная картинка в статье про "компилятор Си в пределах 512 байт" занимает 1,6 мегабайта.