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;
}
}
}
Но как?
О написании 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)
dlinyj
09.06.2023 13:10+3Давненько нас Bright_Translate не радовал зачётными хабртортными переводами. Очень круто.
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 ошибок не отобразилось
vadimr
09.06.2023 13:10+6byte-threaded code – шитый байткод, или просто шитый код.
jin_x
09.06.2023 13:10+1Если "шитый байткод", тогда это было бы "threaded bytecode", а это "байтовый шитый код", ну или см. ниже ещё вариант.
vadimr
09.06.2023 13:10Не вижу содержательной разницы. Просто ненужно громоздкая формулировка на английском языке, которую незачем переводить такой же громоздкой на русском.
IvanPetrof
09.06.2023 13:10+7Следующий шаг - самый маленький С компилятор, способный компилировать сам себя.
vvzvlad
09.06.2023 13:10Поскольку он написан на ассемблере, достаточно добавить сюда асм-вставки и готово.
jin_x
09.06.2023 13:10+2Я ради прикола попытался сократить немного код, получилось ужать его на 6 байт. Профит небольшой, конечно, но в целом там есть ещё порядка 30 байт на какие-нибудь фичи типа операторов / ! ~ break continue do-while ... чего там ещё нет? :)
jpegqs
09.06.2023 13:10+3Из того что я понял по описанию:
У функций нет аргументов, нет for, нет switch, нет никаких типов кроме int, нет указателей (только доступ через
*(int*)
), нет массивов, нет return (функции ничего не возвращают), нет строковых литералов, нет локальных переменных (только определённые вне функции), нет оператора деления, нет оператора остатка, и много чего еще...kasthack_phoenix
09.06.2023 13:10+21компилятор C
отсутствует большая часть языка
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.
Когда поддерживается лишь малая часть базового функционала языка. И это автор называет языком Си. А затем и "самым маленьким Си компилятором".
IntActment
09.06.2023 13:10+3Тогда можно пойти ещё дальше и написать "компилятор", который распознает main(), но не распознает его содержимое - тогда наверняка можно ужать размер до феноменального минимума
emusic
09.06.2023 13:10Почему именно в загрузочный сектор? Код из 510 байт там, как известно, нормально не поместится, да и нет никакого смысла его там размещать.
forthuse
09.06.2023 13:10+2Автор, вдохновился продемонстрированными вызовами по размещению некоторого подмножества Lisp и Forth в 512-бут секторе, что отмечено на странице его проекта
и предложил свой вариант для C-подмножества языка.P.S. Вот познавательная статья такого вызова Разместить FORTH в 512 байтах (в профиле переведены и другие статьи из блога этого проекта)
emusic
09.06.2023 13:10Я понимаю только стремление продемонстрировать серьезную функциональность в малом объеме, за это автору респект.
Но совершенно непонятен акцент на том, что сектор именно загрузочный. К задаче "самостоятельно сделать себе комфортную среду с нуля" это не имеет ни малейшего отношения.
Даже стремление уложиться именно в 512 байт обосновано исключительно символизмом числа, ибо все остальное всяко придется дочитывать с диска, а там уже нет никакой разницы, какое количество секторов указать в int 13.
B13nerd
09.06.2023 13:10+1*Прим. пер.: буду признателен, если сведущий читатель подскажет корректную формулировку этого выражения автора на русском языке.
"Шитый код" (или "шитый байт-код" ?).
См. https://ru.wikipedia.org/wiki/Шитый_кодBright_Translate Автор
09.06.2023 13:10Спасибо за ссылку
forthuse
09.06.2023 13:10Вот ещё видео лекция о Шитом коде.
https://www.youtube.com/watch?v=C9sLcsd8QT4P.S. И некоторая лекция о Форт (Forth): https://youtu.be/Np5pdDAswEI
Упомянутая статья на WikiPedia (и др.) — MovingForth by Brad Rodriguez есть в переводе в разделе статей русскоязычного форума по Форт (Forth) и другим саморасширяющиися системам программирования
Для интересующихся реализацией "кишочков" Форт, есть перевод статей из авторского блога по JonesForth частоупоминаемый в проектах на Github Минимальный Форт с нуля
Шитый код можно найти в реализации Форт-систем по классике: к примеру amForth, FlashForth, CamelForth, Mecrisp и других.
klopp_spb
09.06.2023 13:10+1компилятор Си
Это не компилятор C. Это попытка впихнуть невпихуемое - зачёт :)
Treguard
09.06.2023 13:10+3Please make a C IDE for the spectrum 48k?
forthuse
09.06.2023 13:10+3Like? 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.
MasterMentor
09.06.2023 13:10+1Есть что-то пародийное в мире "новой нормальности". Никчёмная картинка в статье про "компилятор Си в пределах 512 байт" занимает 1,6 мегабайта.
Nihiroz
Почему на фоне КДПВ html?
art1fact
Спасибо за внимательность. Подправили