В этом посте я расскажу о некоторых уловках, которыми я воспользовалась, чтобы уменьшить двоичные файлы С/С++/Python с помощью ассемблера для x86. Здесь всё крутится вокруг кодовой базы Cosmopolitan. Дело в том, что из недавнего отзыва по проекту ELKS я узнала, что мой код там всем понравился и они хотят узнать больше о том, что трюки cosmo могут дать проектам вроде «Linux-порта i8086». Я почувствовала, что мы с ребятами проекта ELKS «одной крови», ведь первое, что я написала при создании Cosmopolitan, — это загрузчик i8086, который назывался Actually Portable Executable. А ещё мне было приятно узнать, что людям, которые погрузились в эту проблему гораздо раньше меня, нравятся мои наработки в Cosmopolitan. И тогда я решила, что неплохо было бы поделиться ими с более широкой аудиторией.
Почему это так важно?
Мне нравится философия UNIX: делать много маленьких программ. Настолько нравится, что в репозитории файлов Cosmopolitan у меня таких программ сотни.
$ git clone https://github.com/jart/cosmopolitan
$ cd cosmopolitan
$ make -j16 MODE=tiny
$ find o/tiny -name \*.com | wc -l
741
Сборка целого репозитория с нуля на моём компьютере занимает всего около 15 секунд. Из 741 исполняемых файлов 403 — текстовые. Здорово, что каждый набор тестов располагается в отдельном исполняемом файле. Так меньше вероятность испортить что-то глобальное одним кривым тестом, который приводит к трудно диагностируемым ошибкам повсюду. По той же причине htop может служить бесплатной панелью мониторинга состояния тестов. Каждый «дотком» — это статический двоичный файл, который ведёт себя как контейнер Docker, так как обычно это zip-файл с данными вендора (например, с данными tzinfo). А я уже писала, что каждый исполняемый файл также работает на семи операционных системах?
# процесс тестирования
for f in $(find o/tiny/test -name \*_test.com); do
for os in freebsd openbsd netbsd rhel7 rhel5 win7 win10 xnu; do
scp $f $os:
ssh $os ./${f##*/}
done
done
Остаётся протестировать 400 программ на 8 поддерживаемых системах (это всего 3200 тестов!) Можно просто скопировать их через scp
на каждый хост и запустить. Благораря runit и runitd больше 15 секунд это не займёт. Это значительно повышает производительность и позволяет вести разработку на основе тестов (TDD) с быстрой обратной связью.
Программирование — это непросто, но мы поможем разобраться:
- Полный курс по Data Science. Получите одну из самых перспективных профессий за 24 месяца.
- «Fullstack-разработчик на Python». Станьте незаменимым специалистом и универсалом за 15 месяцев.
- «Уверенный старт в IT». Пройдите лучший курс для новичков: попробуйте 9 профессий и освойте подходящую именно вам.
Управляемость процесса построена на малом весе двоичного кода. Чем меньше что-то, тем больше экземпляров этого можно собрать при масштабировании. Это важно не только при работе на старых компьютерах и не только при игре в «код-гольф». Речь идёт о том, что всем нам хочется меньше отдавать, больше иметь и наслаждаться всеми радостями программерской жизни. Вот несколько конкретных примеров того, насколько маленькими могут быть по-настоящему портабельные исполняемые файлы, созданные с помощью Cosmopolitan Libc:
16K o/tiny/examples/hello2.com # вызывает write (links syscall)
20K o/tiny/examples/hello.com # вызывает fputs (links stdio)
24K o/tiny/examples/hello3.com # вызывает printf (links fmt+stdio)
100K o/tiny/test/libc/calls/access_test.com # линкует библиотеку тестов
216K o/tiny/tool/net/redbean-original.com # http/1.1 server
1.8M o/tiny/examples/pyapp/pyapp.com # статически подключаемое приложение Python
Смогла бы я так герметично изолировать этот процесс, если бы писала на языке, которому для двоичного представления нужно 3 Мб? У меня локальная сеть на гигабит. И мне не нужен офис крупной компании с качественным оптоволокном для передачи этих ~100-килобитых тестовых бинарников. Вот несколько расчётов на глаз:
# теоретическое минимальное время передачи тестовых двоичных файлов cosmo в секундах при скорости 1Гбит/с по ЛВС без сжатия
>>: (400*8 * 16*1000) / (1024*1024*1024/8)
0.3814697265625
# теоретическое минимальное время передачи тестовых двоичных файлов go в секундах при скорости 1Гбит/с по ЛВС без сжатия
>>: (400*8 * 3*1000*1000) / (1024*1024*1024/8)
71.52557373046875
Для передачи файлов — много. Нужно меньше. И это «меньше» — даже не о самом масштабировании, а о возможности до него дожить. Cosmopolitan — это работа из множества обрывков. В репозитории сейчас всего 1,5 миллиона строк кода. Пока другие воюют с техническими недоработками, я думаю, как залить на хосты ещё один миллион. «Меньше» — это и об использовании ресурсов. Большое начинается с малого. Кому захочется писать уйму кода с помощью дорогих инструментов? Оплата услуг облачных провайдеров может слить весь ваш бюджет. Altavista запустила весь свой поисковый движок на одном компьютере. А вы говорите, надо подключать Full Story Elecron IDE к кластеру Kubernetes, чтобы отдать всю математику приложению C++. Как это увязать?
Неудачники [это слова автора] всегда жалуются, что всё это не раздутость, а просчитанный компромисс. Но компромисса не существует, и это нужно учесть. Инженеры просто становятся жертвами сложности современных программ. Им уже всё равно. Они просто ждут очередного перерыва, пока код компилируется. Прямо как Дэннис из «Парка Юрского периода». Раздутость похожа на масштабирование бизнеса с созданием фальшивых рабочих мест. Она будоражит умы изголодавшихся по трудным задачам разработчиков, но не даёт никаких преимуществ. Раньше я работала с репозиторием на эксабайты данных. Скажу честно, это мало чем отличалось от работы с самыми маленькими программами в мире, которыми я занялась теперь. Тогда это был просто промежуточный этап, не очень-то привлекающий.
Как увлечённая читательница кодовых баз, я не люблю слишком много кода. Кажется, в этих базах уже десятки лет не было женщины, которая расставила бы всё по полочкам. Особенно это касается открытого кода. Туда нога женщины вообще никогда не ступала. Пора бы это исправить, но, нужно разнообразить её развлечениями. А что может быть веселее кодирования «точно в размер» (size coding)!
Заглянем в бинарник
Мне кажется, самая большая проблема тех, кто пытается оптимизировать код системно, — сложность интуитивного восприятия. Особенно в традиционных шестнадцатеричных редакторах (hex editors). Вот так выглядят данные, закодированные по длине строки:
0002d0b0 01 00 01 03 01 06 01 09 01 0c 01 0f 01 12 14 1e |................|
0002d0c0 01 15 04 1e 01 18 17 1e 01 1e 1c 1e 01 1b 00 00 |................|
0002d0d0 21 01 01 00 02 01 01 00 01 01 09 00 01 01 0a 00 |!...............|
0002d0e0 01 01 01 00 01 01 01 00 03 01 1a 00 04 01 01 00 |................|
0002d0f0 01 01 1a 00 03 01 01 00 81 01 00 00 00 00 00 00 |................|
0002d100 21 01 01 00 02 01 01 00 01 01 16 00 01 01 01 00 |!...............|
0002d110 01 01 1c 00 04 01 01 00 01 01 1a 00 03 01 01 00 |................|
0002d120 81 01 00 00 00 00 00 00 21 01 01 00 02 01 01 00 |........!.......|
0002d130 01 01 09 00 01 01 0c 00 01 01 01 00 03 01 1a 00 |................|
Шестнадцатеричные редакторы хороши, когда вы ищете что-то конкретное, но о форме и виде двоичного кода из них мы узнаём немного. На самом деле вы не смотрите на этот код, как на что-то цельное. Вы просто пристально вглядываетесь в отдельные эмпирические данные. От этого даже зрение становится туннельным. Blinkenlights решает эту проблему при помощи кодовой страницы IBM CP437. Если мы используем её для визуализации тех же данных с кодированием по длине строки, о котором я уже писала выше, мы увидим больше.
Учтите и то, что такое отображение кода полностью интуитивно. Да, человек, не знакомый с CP437, увидит абракадабру. Но вряд ли кто-то поспорит, что это выглядит лучше, чем сплошная стена из разделителей разрядов, с точки зрения лаконичности, с которой кодируется информация высокой сложности. CP437 — это, по сути, 256-буквенный алфавит. Прокручивая страницу за страницей, вы будете замечать всё и выявлять закономерности. Это магия! Можно догадаться, что делают те или иные данные. Вот, например, таблица косвенных переходов:
Вот так выглядит наш struct
при компиляции через __attribute__((__packed__))
:
Вот так выглядит код для архитектуры x86-64:
А вот так — /dev/urandom
:
А это — вид двоичных файлов, генерируемых в UBSAN (Undefined Behavior Sanitizer). Как видите, можно сделать и лучше, и это объясняет, почему двоичные файлы на UBSAN получаются настолько огромными. Дело в плохой упаковке структур (struct).
Cosmopolitan — это база данных, созданная из ничего. В начале проекта у меня был только пустой файл и ассемблер. Практически каждый байт, попавший после этого в APE, выполнял свою задачу, связанную либо с генерацией, либо с проверкой. Поэтому и два своих первых инструмента для Cosmopolitan Libc я написала именно для этих целей.
-rwxr-xr-x 1 501 jart 52K Jun 9 09:08 bing.com (see bing.c)
-rwxr-xr-x 1 501 jart 32K Jun 9 09:08 fold.com (see fold.c)
Я использую их с оболочкой для скриптов, которая называется ~/bin/bf
.
#!/bin/sh
bing.com <"$1" |
fold.com |
exec less
Когда мне хочется заглянуть внутрь файла, я просто набираю эту команду:
bf o//examples/hello.com
Я написала ещё несколько скриптов, которые бывают очень кстати. Вот, например, ~/bin/bloat
. Он показывает самые большие символы файла .com.dbg.
#!/bin/sh
nm -C --size "$@" |
sort -r |
grep ' [bBtTRr] ' |
exec less
Организация полей структур
Компоновка структур — бесспорно, хорошая и здоровая привычка, которая способствует снижению размера файла. Например, при просмотре Python 3.6 через bing | fold я заметила одну любопытную вещь. Структура основного парсера Python неоптимальна:
typedef struct {
int s_narcs;
arc*s_arc;
int s_lower;
int s_upper;
int*s_accel;
int s_accept;
} state;
Если бы я просто читала код, то не заметила бы этого. У айсберга всегда есть подводная часть. Структура выше на двоичном уровне эквивалентна следующей:
typedef struct {
int s_narcs;
int __pad1; // четыре байта потрачены впустую
arc*s_arc;
int s_lower;
int s_upper;
int*s_accel;
int s_accept;
int __pad2; // и ещё четыре байта
} state;
Я перенесла поле s_accept
на другую строку и срезала по 4 Кб с каждого бинарника Python:
typedef struct {
int s_narcs;
int s_accept;
arc*s_arc;
int s_lower;
int s_upper;
int*s_accel;
} state;
Жизнь состоит из мелочей. Думаю, у некоторых фирм могут быть руководства по стилю, где предпочтение отдано последнему коду. Только из их политики. Ведь с точки зрения читабельности плохо, когда люди ломают головы, выискивая пробелы в ваших структурах, когда им нужна уверенность в правильности кода на двоичном уровне. Таким образом, мы не только экономим место, но и очищаем код.
RLE-кодирование
Сейчас выходит так много новостей о мощных алгоритмах сжатия, как с машинным обучением, так и классических, что многие программисты-самоучки, думаю, даже не знают, что в некоторых случаях хорошо работают гораздо более простые и примитивные алгоритмы. Один из примеров — RLE-кодирование. Пусть у нас есть такая последовательность:
1,1,1,1,1,1,1,1,2,2,2,3
И вы задаёте её как последовательность кортежей {count, thing} со знаком завершения {0,0}.
8,1, 3,2, 1,3, 0,0
Я люблю RLE-кодирование (1) за простоту ручного редактирования потока сжатых данных и (2) за то, что код распаковщика весит всего 14 байт.
/14-байтный распаковщик
/
/@paramdi указывает на буфер вывода
/@paramsi указывает на uint8_t {len₁,byte₁}, ..., {0,0}
/@archx86-64,i386,i8086
/@seelibc/nexgen32e/rldecode.S
rldecode:
31 c9xor%ecx,%ecx
ac0:lodsb
86 c1xchg%al,%cl
aclodsb
e3 05jrcxz2f
aa1:stosb
e2 fdloop1b
eb f5jmp0b
c32:ret
.typerldecode,@function
.sizerldecode,.-rldecode
.globlrldecode
Этот пример прекрасен своей двоичной совместимостью с i8086, i386 и x86-64. Но ассемблер не всегда означает скорость. Для наглядности и эффективности можно взять такой код на С:
struct RlDecode {
uint8_t repititions;
uint8_t byte;
};
void rldecode2(uint8_t *p, const struct RlDecode *r) {
for (; r->repititions; ++r) {
memset(p, r->byte, r->repititions);
p += r->repititions;
}
}
RLE-кодирование подходит только для сжатия разреженных данных, а размер плотных данных RLE удвоит! Но, когда мы облегчаем двоичные файлы, разреженных структур становится много, и RLE-кодирование становится эффективным.
Пример — таблицы преобразования символов веб-сервера redbean для парсинга URI и HTTP-сообщений. Очень многие разработчики, которые пишут на C или C++, приходят к пониманию того, что поиск символов в таблице с 256 записями очень эффективен. Почему? Потому что компилятор C собирает код на C вроде rdi[al & 255]
в такие блоки:
movzbl%al,%eax
mov(%rdi,%rax),%al
Это очень быстро и безопасно для памяти. Схемотехники назвали бы это таблицей соответствия или LUT. Это неотъемлемая часть разработки микропроцессоров. Для архитектуры x86 даже написана устаревшая инструкция под названием XLAT, полностью посвящённая этой таблице. Важным применением LUT в HTTP является проверка допустимости символов. Например, многие элементы сообщения HTTP, согласно RFC2616, должны являться базовыми элементами (токенами):
CHAR = <any US-ASCII character (octets 0 - 127)>
SP = <US-ASCII SP, space (32)>
HT = <US-ASCII HT, horizontal-tab (9)>
CTL = <any US-ASCII control character
(octets 0 - 31) and DEL (127)>
token = 1*<any CHAR except CTLs or separators>
separators = "(" | ")" | "<" | ">" | "@"
| "," | ";" | ":" | "\" | <">
| "/" | "[" | "]" | "?" | "="
| "{" | "}" | SP | HT
В простом коде на C мы могли бы записать токен LUT так:
const char kHttpToken[256] = {
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x00
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x10
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 0x20 ! #$%&‘ *+ -.
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 0x30 0123456789
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x40 ABCDEFGHIJKLMNO
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 0x50 PQRSTUVWXYZ ^_
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x60 `abcdefghijklmno
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, // 0x70 pqrstuvwxyz | ~
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x80
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x90
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xa0
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xb0
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xc0
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xd0
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xe0
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xf0
};
Чтобы проверить, допустим ли символ в токене, можно просто ввести kHttpToken[c & 255]
. Но, если мы хотим сэкономить 200 байт в двоичном представлении, можно использовать метод RLE-кодирования:
.globlkHttpToken
.section .bss.sort.100.kHttpToken,"aw",@nobits
/@seenet/http/khttptoken.S
kHttpToken:
.zero256
.section .rodata.sort.100.kHttpToken,"a",@progbits
.byte 33,0 # 00-20 ∅-␠
.byte 1,1 # 21-21 !-!
.byte 1,0 # 22-22 “-“
.byte 5,1 # 23-27 #-‘
.byte 2,0 # 28-29 (-)
.byte 2,1 # 2a-2b *-+
.byte 1,0 # 2c-2c ,-,
.byte 2,1 # 2d-2e --.
.byte 1,0 # 2f-2f /-/
.byte 10,1 # 30-39 0-9
.byte 7,0 # 3a-40 :-@
.byte 26,1 # 41-5a A-Z
.byte 3,0 # 5b-5d [-]
.byte 29,1 # 5e-7a ^-z
.byte 1,0 # 7b-7b {-{
.byte 1,1 # 7c-7c |-|
.byte 1,0 # 7d-7d }-}
.byte 1,1 # 7e-7e ~-~
.byte129,0 # 7f-ff ⌂-λ
.byte0,0 # terminator
.section .init.sort.100.kHttpToken,"a",@progbits
callrldecode
В кодовой базе Cosmopolitan есть инструмент для автоматической генерации этих файлов ассемблера:
o//tool/build/xlat.com -TiC ' ()<>@,;:\"/[]?={}' -iskHttpToken
Децентрализованные разделы
Код ассемблера, сгенерированный на xlat.com в предыдущем разделе, может показаться странным даже при большом опыте программирования на ассемблере для архитектуры x86. Это уникальная методика чтения старых кодов из 70-х и 80-х, воскрешённая мной для Cosmopolitan. Назовём её «децентрализованные разделы» (decentralized sections).
Децентрализованные разделы позволяют охватить несколько файлов одной функцией. Канонический пример — _init()
, классическая функция UNIX. В большинстве современных кодовых баз её оставляют пустой. Потому что написание _init()
— это что-то вроде утраченного искусства с последующей сборкой скриптом редактора связей (линкера).
Работает это так. Ваша libc определяет заглушки кода входных и выходных данных функции _init()
. Cosmopolitan Libc (слегка доработанная для уменьшения количества макросов и, как следствие, удобства чтения и копирования) определяет их так:
.section .init_prologue,"ax",@progbits
.globl_init
/@paramr12 is argc (регистр сохранён)
/@paramr13 is argv (регистр сохранён)
/@paramr14 is envp (регистр сохранён)
/@paramr15 is envp (регистр сохранён)
/@noterdi is __init_bss_start (регистр монотонно возрастает)
/@notersi is __init_rodata_start (регистр монотонно возрастает)
/@seelibc/runtime/init.S
_init:push%rbp
mov%rsp,%rbp
lea__init_bss_start(%rip),%rdi
lea__init_rodata_start(%rip),%rsi
.previous/*
...
децентрализованное содержимое
...
*/.section .init_epilogue,"ax",@progbits
_woot:leave
ret
.previous
Ваша libc тоже должна задать заглушки для разделов данных. Технически это можно сделать в скрипте редактора связей, но красивее — записать их в .S, чтобы не определять символы, пока они не будут слинкованы.
.section .init_rodata_prologue,"a",@progbits
.globl__init_rodata_start,__init_rodata_end
.align__SIZEOF_POINTER__
__init_rodata_start:
.previous/*
...
децентрализованное содержимое
...
*/.section .init_rodata_epilogue,"a",@progbits
__init_rodata_end:
.byte0x90
.previous
.section .init_bss_prologue,"aw",@nobits
.globl__init_bss_start,__init_bss_end
.align__SIZEOF_POINTER__
__init_bss_start:
.previous/*
...
децентрализованное содержание
...
*/.section .init_bss_epilogue,"aw",@nobits
__init_bss_end:
.byte0
.previous
Затем настраиваем скрипт редактора связей:
/* смотрите ape/ape.lds */
SECTIONS {
.text . : {
KEEP(*(.init_prologue))
KEEP(*(SORT_BY_NAME(.init.sort.*)))
KEEP(*(.init))
KEEP(*(.init_epilogue))
*(.text .text.*)
KEEP(*(SORT_BY_NAME(.rodata.sort.*)))
*(.rodata .rodata.*)
}
.bss . : {
KEEP(*(SORT_BY_NAME(.bss.sort.*)))
*(SORT_BY_ALIGNMENT(.bss))
*(SORT_BY_ALIGNMENT(.bss.*))
*(COMMON)
}
}
Теперь файлы ассемблера можно писать со вставкой содержимого в функцию _init()
вместе с сопутствующими структурами данных __init_rodata_start
и __init_bss_start
. Разделы данных только для чтения и неинициализированных данных (BSS) опциональны. Обязательно только одно: в отличие от простого двоичного интерфейса System V Application Binary Interface, нашему коду _init() нельзя затирать RDI и RSI. Потому что в них хранятся указатели на жёсткую конфигурацию rodata
и bss
.
Вот пример применения проще приведённого выше примера с kHttpToken. Допустим, нам нужна поддержка микроархитектур AMD K8 и Barcelona, но при этом полностью использовать SSSE3 (лучшее расширение SSE). Здесь общепринятая практика — запускать идентификацию центрального процессора CPUID после двойной проверки защиты pthread_once()
. Потому что при каждом запуске инструкция CPUID занимает 50 нс, в то время как однократная защита усредняется по многим вызовам и требует половину наносекунды. Но есть решение лучше — можно воспользоваться функцией _init()
. Необходимость в библиотеках синхронизации уходит на второй план, ведь _init()
вызывается перед всеми конструкторами. Поэтому создание каких-либо потоков маловероятно.
.section .bss.sort.100.kCpuid1,"aw",@nobits
/uint32_t kCpuid1[4];
/@seelibc/nexgen32e/kcpuids.S
kCpuid1:.long0,0,0,0
.globlkCpuid1
.typekCpuid1,@object
.sizekCpuid1,.-kCpuid1
.section .init.sort.100.kCpuid1,"a",@progbits
53push%rbx
6a 01push$1
58pop%rax
0f a2cpuid
abstosl
93xchg%eax,%ebx
abstosl
91xchg%eax,%ecx
abstosl
92xchg%eax,%edx
abstosl
5bpop%rbx
Если маркетинг Intel сбивал вас с толку и вы пытались понять по названиям микроархитектур, у каких центральных процессоров какие функции, всё это кратко поясняется в файле microarch.txt.
Приведённые выше строки добавляют всего 14 байт двоичного кода. Они масштабируются в рамках модели разработки. Сколько бы библиотек вы ни включили в кодовую базу, они уживутся с этим процессом и не будут наступать друг другу на пятки. Модель децентрализованных разделов — это золотая жила для исполинских кодовых баз, горячо любимых крупными компаниями. С другой стороны, такой подход позволяет создавать крошечные двоичные файлы, которые обожают инди-разработчики. Это намного проще, чем подтягивать зависимость к -lpthread. Теперь, когда захочется проверить доступность SSSE3, нужно лишь узнать девятые биты ECX, которые мы сохранили ранее:
if (kCpuid1[2][1<<9]) {
// есть ssse3!
} else {
// это k8 или barcelona
}
Если вы пользуетесь другой libc, а не той, что я предлагаю в cosmo (например, musl, glibc), вам подойдёт тот же метод, но с небольшой корректировкой:
.bss
kCpuid1:.long0,0,0,0
.globlkCpuid1
.typekCpuid1,@object
.sizekCpuid1,.-kCpuid1
.section .init,"a",@progbits
53push%rbx
48 8d 3d 00 00 00 00leakCpuid1(%rip),%rdi
6a 01push$1
58pop%rax
0f a2cpuid
abstosl
93xchg%eax,%ebx
abstosl
91xchg%eax,%ecx
abstosl
92xchg%eax,%edx
abstosl
5bpop%rbx
Теперь нам нужно «заплатить» ещё по 7 байт (всего нужен 21 байт), поскольку в Cosmo взаимодействие с жёсткой конфигурацией между _init()
/ rodata / bss построено уникальным образом. В других библиотеках на C такого нет. В любом случае оба способа на ассемблере очевидно превосходят эквивалент на C/C++, который съест 42 байта.
uint32_t kCpuid1[4];
__attribute__((__constructor__)) static void kCpuid1Init() {
uint32_t ax, bx, cx, dx;
asm("cpuid" : "=a"(ax), "=b"(bx), "=c"(cx), "=d"(dx) : "0"(1));
kCpuid1[0] = ax;
kCpuid1[1] = bx;
kCpuid1[2] = cx;
kCpuid1[3] = dx;
}
Если нотация «Richard Stallman Math 55» для ключевого слова asm()
вас озадачила, пояснение есть в файле rmsiface.txt.
Если я поняла нечто важное при работе с Cosmopolitan, это то, что компилятор C при должной сноровке можно заставить выдавать очень лёгкий код, если хочется писать код ассемблера в строковых литералах. GCC и Clang (код ниже) сделают это за 30 байт. Стоит ли дело того или же просто написать всё это в ассемблере — решать вам.
uint32_t kCpuid1[4];
__attribute__((__constructor__)) static void kCpuid1Init() {
uint32_t ax, *di;
asm volatile("cpuid\r\n"
"stosl\r\n"
"xchg\t%%ebx,%%eax\r\n"
"stosl\r\n"
"xchg\t%%ecx,%%eax\r\n"
"stosl\r\n"
"xchg\t%%edx,%%eax\r\n"
"stosl"
: "=a"(ax), "=D"(di)
: "0"(1), "1"(kCpuid1)
: "rbx", "rcx", "rdx", "memory");
}
Больше всего код на C делает неуклюжим необходимость генерировать 8-байтные указатели функций в разделах .ctors
. Их нужно отдельно добавлять в редактор связей (линкере). Как правило, ваша библиотека на C вызывает .ctors по завершении _init()
. На самом деле, приемлемых способов заставить современный компилятор C сгенерировать код в духе «децентрализованного раздела» нет. Clang тут не подойдёт: он хочет власти над всеми регистрами. Как максимум, можно при помощи GCC сделать следующее:
register long di asm("rdi");
register long si asm("rsi");
__attribute__((__section__(".bss.sort.100.kCpuid1,\"aw\",@nobits #")))
uint32_t kCpuid1[4];
__attribute__((__used__,
__section__(".init.sort.100.kCpuid1,\"a\",@progbits #")))
static void kCpuid1Init(void) {
uint32_t ax;
asm volatile("push\t%%rbx\r\n"
"cpuid\r\n"
"stosl\r\n"
"xchg\t%%ebx,%%eax\r\n"
"stosl\r\n"
"xchg\t%%ecx,%%eax\r\n"
"stosl\r\n"
"xchg\t%%edx,%%eax\r\n"
"stosl\r\n"
"push\t%%rbx"
: "=a"(ax), "+D"(di)
: "0"(1)
: "rcx", "rdx", "memory");
__builtin_unreachable(); // блокируем генерацию инструкции RET
}
Проблема такого кода — хрупкость. Для его правильной работы компилятору нужно передать флаги -ffixed-rdi -ffixed-rsi -fomit-frame-pointer -O -fno-sanitize=all
. А ценность компилятора C состоит в возможности нагрузить код инструментами отладки (вроде ASAN), если это понадобится. Так что с кодом вроде этого, где отладчики не нужны, лучше просто писать на ассемблере.
Удаление мёртвого кода
Само собой, если вы используете Libc от Cosmopolitan, все фокусы выше с CPUID уже проделаны за вас, остаётся только написать:
// смотрите libc/nexgen32e/x86feature.h
if (X86_HAVE(SSSE3)) {
// у нас ssse3!
} else {
// это k8 или barcelona
}
Реализация в x86feature.h стоит вашего внимания. Возможно, это самая красивая абстракция из созданных на препроцессоре C. Её можно найти во всей кодовой базе. А ещё она воплощает один из важнейших подходов в написании макроса Cosmopolitan — слияние времени компиляции с проверкой согласования типов во время выполнения. Это необходимо в Actually Portable Executable, где очень важна диспетчеризация во время выполнения. В то же время у нас есть все традиционные возможности настройки всеми любимых флагов define в процессе компиляции. Например, #ifdef здесь можно реализовать так:
#if X86_REQUIRE(SSSE3)
// есть ssse3!
#else
// это k8 или barcelona
#endif
В данном случае ваша программа поддерживает SSSE3, только если пользователь установит флаг компиляции -mssse3
. Лучше пользоваться макросом X86_HAVE()
, так как он делает то же, что и X86_REQUIRE(SSSE3)
, за исключением проверки во время выполнения. По умолчанию, если вы не передали никаких флагов микроархитектуры GCC или Clang, обе ветви войдут в двоичный файл.
if (X86_HAVE(SSSE3)) {
// делаем несколько диких хаков ssse3 на ассемблере
} else {
// возвращаемся к медленному коду ansi c
}
Если же пользователь решит передать флаг -mssse3
, компилятор при оптимизации устранит всю эту раздутость для поддержки старых моделей ЦПУ, вместе с самой проверкой при выполнении.
if (X86_HAVE(SSSE3)) {
// делаем несколько диких хаков ssse3 на ассемблере
} else {
// возвращаемся к медленному коду ansi c
}
Это как раз то, что MODE=opt
делает с конфигурацией сборки Makefile по умолчанию. Компилятор получает флаг -march=native
, который запрашивает параметры ЦПУ вашего хоста. После этого Cosmopolitan и GCC совместно создают двоичный файл, адаптированный под ваш компьютер и оптимально быстрый. Цена этого трюка — невозможность передавать такие двоичные файлы на другие машины, ведь там могут быть другие модели процессоров x86.
Основная идея гибридной модели для удаления мёртвого кода и ветвления кода при исполнении проще и лаконичнее всего описана в ape/loader.c и libc/dce.c.
#define LINUX 1
#define METAL 2
#define WINDOWS 4
#define XNU 8
#define OPENBSD 16
#define FREEBSD 32
#define NETBSD 64
#define SupportsLinux() (SUPPORT_VECTOR & LINUX)
#define SupportsXnu() (SUPPORT_VECTOR & XNU)
#define SupportsFreebsd() (SUPPORT_VECTOR & FREEBSD)
#define SupportsOpenbsd() (SUPPORT_VECTOR & OPENBSD)
#define SupportsNetbsd() (SUPPORT_VECTOR & NETBSD)
#define IsLinux() (SupportsLinux() && os == LINUX)
#define IsXnu() (SupportsXnu() && os == XNU)
#define IsFreebsd() (SupportsFreebsd() && os == FREEBSD)
#define IsOpenbsd() (SupportsOpenbsd() && os == OPENBSD)
#define IsNetbsd() (SupportsNetbsd() && os == NETBSD)
Один из примеров гибкости APE — возможность настраивать сборку вот так:
make MODE=tiny CPPFLAGS+=-DSUPPORT_VECTOR=0b00000001
Если вам нужен бинарник «hello world» на 4 Кб, который работает только на Linux и не содержит лишние 12 Кб для поддержки Windows, MacOS, FreeBSD, NetBSD, OpenBSD и выгрузки из BIOS «на голом железе», один из лучших вариантов — использование только операционных систем ELF. Просто установите соответствующие биты для Linux + BSD.
make MODE=tiny CPPFLAGS+=-DSUPPORT_VECTOR=0b01110001
δzd-кодирование
Python — один из наиболее оптимальных по размеру кода в базе Cosmopolitan. Здесь нам пришлось выйти за рамки «код-гольфа» и ради лёгкого кода пустить в ход кое-что потяжелее. Лучшее оружие в своём арсенале (которое сжало статически связанные двоичные файлы Python до 2 Мб) мы называем схемой сжатия Delta Zig-Zag Deflate или, для краткости, δzd.
Это не обобщённый алгоритм сжатия типа DEFLATE с кодированием Хаффмана (с RLE наподобие LZ4). Это узкоспециализированный алгоритм, эффективный только для определённых видов исполняемого содержимого. При программировании нужно самостоятельно решать, какой метод сработает лучше всего. Однако δzd показал отличные результаты.
Как вам такая дилемма? Алфавиты китайского, японского и корейского языков огромны. Некоторые их символы до сих пор даже не согласуются с UTF-8. При этом есть множество индивидуальных наборов символов, и все они поддерживаются Python. Для поддержки CJK таблицы стандарта UNICODE тоже должны иметь огромный размер. Большинству людей за пределами трёх стран не нужны эти данные в двоичных файлах, но мы предпочитаем держать их там ради инклюзивности и поддержки таких языков как Python — чтобы в случае надобности раскинуть огромный шатёр, в котором будут рады всем.
С помощью способа упаковки δzd можно значительно уменьшить эти таблицы соответствий. Тогда пользователю не придётся расплачиваться за неиспользуемые им функции. Один из множества нуждающихся в такой обработке символов в кодовой базе Python — это _PyUnicode_PhrasebookOffset2
с начальным размером 178 Кб. С помощью delta-кодирования, zig-zag-кодирования и затем, наконец, DEFLATE он сжимается до 12 Кб. Таким образом мы выигрываем у DEFLATE с чистым кодированием Хаффмана и у BZIP2 с преобразованием Барроуза — Уилера в 10 раз!
_PyUnicode_PhrasebookOffset2: size is 178,176 bytes
_PyUnicode_PhrasebookOffset2: packed size is 100,224 bytes
_PyUnicode_PhrasebookOffset2: rle size is 282,216 bytes
_PyUnicode_PhrasebookOffset2: deflate size is 52,200 bytes
_PyUnicode_PhrasebookOffset2: bz2 size is 76,876 bytes
_PyUnicode_PhrasebookOffset2: δleb size is 47,198 bytes
_PyUnicode_PhrasebookOffset2: δzd size is 12,748 bytes
По адресу third_party/python/Tools/unicode/makeunicodedata.py можно узнать больше о том, как эти таблицы разделены. Представление Python для кодирования Delta-ZigZag-DEFLATE также может выглядеть так:
def uleb(a, x):
while True:
b = x & 127
x >>= 7
if x:
a.append(b | 128)
else:
a.append(b)
break
def zig(x):
m = (2 << x.bit_length()) - 1
return ((x & (m >> 1)) << 1) ^ (m if x < 0 else 0)
def zleb(a, x):
return uleb(a, zig(x))
def δzd(data):
n = 0;
i = 0
p = 0
a = bytearray()
for x in data:
zleb(a, x - p)
p = x
return deflate(a), len(a)
Код на C для распаковки сжатых данных Python можно прочитать в libc/x/xloadzd.c, libc/fmt/unzleb64.c, libc/runtime/inflate.c и third_party/zlib/puff.c. Лучший из них — Puff. Это оптимизированная по размеру, но не такая быстрая версия zlib, которую написал Марк Адлер. Там проходит только распаковка в DEFLATE с двоичным остатком 2 Кб. Когда нужна правильная распаковка в DEFLATE, ключевые библиотеки Cosmopolitan Libc очень часто дают на Puff сильную ссылку (strong link) как на «сильное звено», а на zlib — слабую (weak link) (поскольку эти библиотеки не могут зависеть от вещей вроде malloc). Поэтому Puff используется по умолчанию, если более быстрая библиотека zlib не имеет сильной ссылки, связывающей её с чем-то другим.
Перекрытие функций
Если языки программирования высокого уровня, такие как C, — замок изо льда, а ассемблерный код — вершина айсберга, то кодирование Intel с переменной длиной поля записи (variable length encoding) — подводная часть того же айсберга. Именно оно быстро делает загрузочные сектора доступными лишь посвящённым, поскольку их трудно визуализировать программным способом. Вот пример:
/ %ip is 0
mov $0xf4,%al
ret
/ %ip is 1
.byte 0xb0
wut: hlt \# and catch fire
ret
Всё — как в шахматах, где ход на соседнюю клетку меняет всю партию. А здесь — перемещение счётчика команд на единицу меняет всю программу x86. Мы можем обратить это в свою пользу, ведь это даёт нам возможность кодировать функции с перекрытием.
/ SectorLISP code.
89 D6 Assoc: mov %dx,%si
8B 3C 1: mov (%si),%di
8B 30 mov (%bx,%si),%si
AF scasw
75 F9 jne 1b
F6 .byte 0xF6
8B 39 Cadr: mov (%bx,%di),%di
3C .byte 0x3C
AF Cdr: scasw
8B 05 Car: mov (%di),%ax
C3 ret
89 D6 Assoc: mov %dx,%si
8B 3C 1: mov (%si),%di
8B 30 mov (%bx,%si),%si
AF scasw
75 F9 jne 1b
F6 8B 39 3C AF testw $0xaf,0x3c39(%bp,%di)
8B 05 mov (%di),%ax
C3 ret
8B 39 Cadr: mov (%bx,%di),%di
3C AF cmp $0xaf,%al
8B 05 mov (%di),%ax
C3 ret
AF Cdr: scasw
8B 05 mov (%di),%ax
C3 ret
8B 05 Car: mov (%di),%ax
C3 ret
Оптимизация printf
С точки зрения размера кода одна из проблем printf()
, — тяжёлые зависимости, которые эта функция тянет за собой:
- gdtoa требуется для форматирования типов double и long double через
%f
,%g
и т. п. При форматировании плавающей точки здесь появляется глубина и выделяется память. Поэтому связываниеgdtoa
добавляет около 32 Кб двоичного кода. - Для форматирования строк с выравниванием по ширине (через
%*s
и т. п.) с поддержкой Юникода нужна таблица ширины CJK, которая показывает, займёт ли терминальный символ две ячейки. Это возможно, например, в случае китайских иероглифов или эмодзи. - Форматирование директивой GNU
%m
дляstrerror()
в Cosmopolitan чуть более затратно: там системным кодом служат не символы, а магические числа. А значит, дляstrerror()
нужно взять сразу все переменныеerrno
. Необходимо также встроить кучу строк ошибок и инструменты для их получения через API WIN32. К размеру бинарника это добавляет около 8 Кб.
И всё же, несмотря на всю раздутость функции printf()
, двоичные файлы, которые случайно связываются с ней (например, examples/hello3.c) могут занимать всего 24 Кб. И всё благодаря макросу PFLINK()
.
/* см. libc/fmt/pflink.h */
/* см. libc/stdio/stdio.h */
#define printf(FMT, ...) (printf)(PFLINK(FMT), ##__VA_ARGS__)
#define PFLINK(...) _PFLINK(__VA_ARGS__)
#define _PFLINK(FMT, ...) \
({ \
if (___PFLINK(FMT, strpbrk, "faAeg")) STATIC_YOINK("__fmt_dtoa"); \
if (___PFLINK(FMT, strpbrk, "cmrqs")) { \
if (___PFLINK(FMT, strstr, "%m")) STATIC_YOINK("strerror"); \
if (!IsTiny() && (___PFLINK(FMT, strstr, "%*") || \
___PFLINK(FMT, strpbrk, "0123456789"))) { \
STATIC_YOINK("strnwidth"); \
STATIC_YOINK("strnwidth16"); \
STATIC_YOINK("wcsnwidth"); \
} \
} \
FMT; \
})
#define ___PFLINK(FMT, FN, C) \
!__builtin_constant_p(FMT) || ((FMT) && __builtin_##FN(FMT, C) != NULL)
Это работает только в GCC, но никак не в Clang. И работает это, потому что первый аргумент printf() — почти всегда строковый литерал. GCC хватает «ума» для запуска функций вроде __builtin_strstr()
во время компиляции. Это что-то вроде функции constexpr
на C++. Когда GCC определяет, что нужная нам функция много весит, она делает с соответствующим символом то, что мы называем «выдёргиванием» (yoinking).
Вот как это работает: с двоичными файлами ELF можно проделать трюк, который я назвала бы «игрой в слабое звено со слабыми ссылками» (weak linking). Реализация printf() имеет код вроде такого:
case 'm':
p = weaken(strerror) ? weaken(strerror)(lasterr) : "?";
signbit = 0;
goto FormatString;
Когда функция объявлена операцией weaken «слабым звеном», она не попадает в конечный двоичный файл, если к ней не будет сильного (strong reference) или нормального (normal reference) обращения от другого модуля. Один из способов добиться попадания в двоичный файл — «выдернуть» (yoink) её. Однако «выдёргивание» предназначено для особой ситуации, когда нет кода, который использует символ, но всё равно хочется перетащить его в двоичный файл.
.section .yoink
noplsymbol
.previous
Поэтому с помощью макроса «yoink» мы создаем ссылку внутри раздела, которая явно отбрасывается скриптом редактора связей:
/DISCARD/ : {
*(.yoink)
}
Поэтому, даже если ссылка в конечном счёте будет отброшена, она всё равно сможет заставить ld.bfd вывести объект ссылки в конечный двоичный код.
Метод не идеален, но он того стоит. Большие программы неявным образом «выдернут» всё необходимое на довольно долгое время, а значит, для таких программ это не проблема. А вот в маленьких редактору связей иногда может явно потребоваться помощь. Например, вы можете иногда совершить грехопадение с точки зрения безопасности и использовать нелитеральные строки. В этом случае волшебный макрос «выдернет» все строки, оставляя их незаметными в процессе компиляции. Если вы всё ещё хотите оставаться «на лёгкой стороне силы», то можете потом «расколдовать» эти строки вызовом printf()
:
(printf)("last error is %m\n");
Наконец, все приведённые в PFLINK()
функции, которые вам нужны, можно явно «выдёргивать» при помощи вашего модуля main()
.
STATIC_YOINK("strerror");
Маленький портабельный ELF
На странице «Насколько толстым должен быть толстый двоичный формат?» можно в интерактивном режиме создавать двоичные файлы Cosmopolitan Actually Portable Executable. Как видим, такие свойства, как SUPPORT_VECTOR
, дают пользователю всю мощь и гибкость настроек, необходимую, чтобы сделать его программы настолько малыми, насколько этого захочется [конечно, в пределах возможного].
Но представим на минутку, что наши цели ограничиваются ELF, такими как Linux, FreeBSD, NetBSD и OpenBSD. А ещё представим, что вы хотите создать двоичный файл, который будет с нуля запускаться на всех четырёх операционных системах без помощи APE и Cosmo. Эта задача оказывается простой, и в этом разделе я покажу вам, как решить её. А самое классное то, что размер — всего 386 байт, а код идиоматичный и с лучшими практиками.
На протяжении всей истории вычислительной техники считалось аксиомой, что каждая операционная система должна предоставлять инструменты для написания приложений под неё. Это имело смысл в эпоху, когда операционные системы, как правило, создавались теми же компаниями, что и микропроцессоры под них. В последние десятилетия всё изменилось. В августе 2022 года 485 из 500 лучших суперкомпьютеров уже работали на x86-64. Архитектура x86 также представляет львиную долю персональных компьютеров и вторичных серверов. Да, программисты — по традиции — верят, что для запуска приложения в четырёх операционных системах нужно четыре разных двоичных файла. Но это уже не соответствует действительности. Архитектура у всех этих двоичных файлов будет общей.
Допустим, вы компилируете такую функцию:
int add(int x, int y) {
return x + y;
}
На выходе компилятора будет одно и то же, где бы вы его ни запустили — на Linux, FreeBSD, NetBSD или на OpenBSD:
add:lea(%rdi,%rsi,1),%eax
ret
Но почему же тогда мы не можем запустить «из коробки» некий двоичный файл Linux в другой системе, например в OpenBSD? Причин тому несколько. Во-первых, сам формат ELF требует указания двоичного интерфейса приложений (ABI) операционной системы (конвенции о том, как должны взаимодействовать двоичные файлы на уровне двоичного кода и регистров). К счастью, это можно обойти благодаря простой настройке FreeBSD ABI. Потому что FreeBSD — единственная операционная система UNIX, которая проверяет это поле. Как оказалось, NetBSD и OpenBSD проверяют двоичную совместимость, используя отдельную структуру данных ELF PT_NOTE. Что касается Linux, она об этом не беспокоится. Вот и получается, что, если взять двоичный файл FreeBSD и поместить туда примечания для NetBSD и OpenBSD, наш двоичный код запустится на всех четырёх системах.
Есть ещё несколько различий в интерпретации полей ELF операционными системами. Например, с помощью PT_LOAD
система узнаёт, какие части исполняемого файла загружать в память и по каким адресам они должны размещаться. Эти сегменты PT_LOAD
могут иметь нулевой размер в смысле файла. Это равносильно выделению нулевой памяти. Однако, если размер в памяти также нулевой, OpenBSD откажется запускать программу. Другим ядрам это безразлично.
Приведённая ниже магическая формула для заголовков исполняемых файлов проведёт их по узкой дорожке, где требования всех четырёх систем пересекаются. В каждом случае это позволит загрузить идентичный код x86 в память и запустить его.
/* reallytiny-elf.S */
#define ELFCLASS64 2
#define ELFDATA2LSB 1
#define ELFOSABI_FREEBSD 9
#define ET_EXEC 2
#define EM_NEXGEN32E 62
#define PT_LOAD 1
#define PT_NOTE 4
#define PF_X 1
#define PF_W 2
#define PF_R 4
.align8
ehdr:.ascii"\177ELF"
.byteELFCLASS64
.byteELFDATA2LSB
.byte1
.byteELFOSABI_FREEBSD
.quad0
.wordET_EXEC# e_type
.wordEM_NEXGEN32E# e_machine
.long1# e_version
.quad_start# e_entry
.quadphdrs - ehdr# e_phoff
.quad0# e_shoff
.long0# e_flags
.word64# e_ehsize
.word56# e_phentsize
.word2# e_phnum
.word0# e_shentsize
.word0# e_shnum
.word0# e_shstrndx
.globlehdr
.align8
phdrs:.longPT_LOAD# p_type
.longPF_R|PF_X# p_flags
.quad0# p_offset
.quadehdr# p_vaddr
.quadehdr# p_paddr
.quadfilesz# p_filesz
.quadfilesz# p_memsz
.quad64# p_align
#if 0 // прерываем openbsd при неиспользовании bss
.longPT_LOAD# p_type
.longPF_R|PF_W# p_flags
.quad0# p_offset
.quadbss# p_vaddr
.quadbss# p_paddr
.quad0# p_filesz
.quadbsssize# p_memsz
.quad64# p_align
#endif
.longPT_NOTE# p_type
.longPF_R# p_flags
.quadnote - ehdr# p_offset
.quadnote# p_vaddr
.quadnote# p_paddr
.quadnotesize# p_filesz
.quadnotesize# p_memsz
.quad8# p_align
note:.long2f-1f
.long4f-3f
.long1
1:.asciz"OpenBSD"
2:.align4
3:.long0
4:.long2f-1f
.long4f-3f
.long1
1:.asciz"NetBSD"
2:.align4
3:.long901000000
4:notesize = . - note
_start:mov%rsp,%rsi
jmpStart
.globl_start
На данном этапе можно просто поместить наш код в функцию Start()
— и всё заработает! Однако, если захочется распечатать результат, возникает вторая проблема.
Для печати строк на системах UNIX нужен системный вызов int write(fd, data, size)
, где fd
обычно задаётся значение 1
, что означает стандартный вывод. И снова, благодаря схожей системной среде, все четыре системы после небольшой «встряски» реализуют одну и ту же функцию. Тем не менее, чтобы по-настоящему её вызвать, нужно задать ещё одно неочевидное для кода на C число. Это волшебное число, называемое «magnum», является частью ABI. К сожалению, операционные системы плохо переваривают числа magnum. Например, на Linux write() это 1, а у BSD — 4. Поэтому, если запустить write()
из Linux на BSD, это приведёт к вызову функции exit()
. Не этого результата мы хотели!
Магические чмсла (magnum) и системные вызовы UNIX (x86-64) | ||||
---|---|---|---|---|
Функция | Linux | FreeBSD | OpenBSD | NetBSD |
exit | 60 | 1 | 1 | 1 |
fork | 57 | 2 | 2 | 2 |
read | 0 | 3 | 3 | 3 |
write | 1 | 4 | 4 | 4 |
open | 2 | 5 | 5 | 5 |
close | 3 | 6 | 6 | 6 |
stat | 4 | n/a | 38 | 439 |
fstat | 5 | 551 | 53 | 440 |
poll | 7 | 209 | 252 | 209 |
Однако есть подмножество magnum, по которым у операционных систем, особенно BSD, разногласий нет. Обычно это очень древние функции, разработанные для оригинальной системы UNIX, которую создавали в Bell Labs. Числа вроде единицы для exit()
скопированы прямо из кодовой базы Bell System Five. Поэтому мы называем всё это System V Application Binary Interface. Посмотрите на старые версии — и вы увидите, что даже в те времена консенсуса не было.
magnum системного вызова UNIX (i386) | ||||
---|---|---|---|---|
Функция | Linux | FreeBSD | OpenBSD | NetBSD |
exit | 1 | 1 | 1 | 1 |
fork | 2 | 2 | 2 | 2 |
read | 3 | 3 | 3 | 3 |
write | 4 | 4 | 4 | 4 |
open | 5 | 5 | 5 | 5 |
close | 6 | 6 | 6 | 6 |
stat | 18 | n/a | 38 | 439 |
fstat | 108 | 551 | 53 | 440 |
poll | 168 | 209 | 252 | 209 |
Поэтому, чтобы знать, какое магическое число использовать, нужно как-то определить ОС x86-64 при запуске. Это можно сделать по-разному. Например, можно искать magnum, в котором переданы недопустимые параметры, а затем различать системы по возвращаемому коду ошибки. Но такие запросы к системе занимают до микросекунды, а не несколько наносекунд, как обычные функции. Поэтому ниже я покажу другой метод определения операционной системы на основе значений, которые уже переданы операционной системой при загрузке нашего исполняемого файла.
/* reallytiny.c */
#define LINUX 1
#define OPENBSD 16
#define FREEBSD 32
#define NETBSD 64
#ifndef SUPPORT_VECTOR
#define SUPPORT_VECTOR (LINUX | FREEBSD | NETBSD | OPENBSD)
#endif
#define SupportsLinux() (SUPPORT_VECTOR & LINUX)
#define SupportsFreebsd() (SUPPORT_VECTOR & FREEBSD)
#define SupportsOpenbsd() (SUPPORT_VECTOR & OPENBSD)
#define SupportsNetbsd() (SUPPORT_VECTOR & NETBSD)
#define IsLinux() (SupportsLinux() && os == LINUX)
#define IsFreebsd() (SupportsFreebsd() && os == FREEBSD)
#define IsOpenbsd() (SupportsOpenbsd() && os == OPENBSD)
#define IsNetbsd() (SupportsNetbsd() && os == NETBSD)
__attribute__((__noreturn__)) static void Exit(int rc, int os) {
asm volatile("syscall"
: /* нет вывода */
: "a"(IsLinux() ? 60 : 1), "D"(rc)
: "memory");
__builtin_unreachable();
}
static int Write(int fd, const void *data, int size, int os) {
char cf;
int ax, dx;
asm volatile("clc\n\t"
"syscall"
: "=a"(ax), "=d"(dx), "=@ccc"(cf)
: "0"(IsLinux() ? 1 : 4), "D"(fd), "S"(data), "1"(size)
: "rcx", "r11", "memory", "cc");
if (cf) ax = -ax;
return ax;
}
static int Main(int argc, char **argv, char **envp, long *auxv, int os) {
Write(1, "hello world\n", 12, os);
return 0;
}
__attribute__((__noreturn__)) void Start(long di, long *sp) {
long *auxv;
int i, os, argc;
char **argv, **envp, *page;
// freebsd
if (SupportsFreebsd() && di) {
os = FREEBSD;
sp = (long *)di;
} else {
os = 0;
}
// извлечение аргументов
argc = *sp;
argv = (char **)(sp + 1);
envp = (char **)(sp + 1 + argc + 1);
auxv = (long *)(sp + 1 + argc + 1);
for (;;) {
if (!*auxv++) {
break;
}
}
// openbsd
if (SupportsOpenbsd() && !os && !auxv[0]) {
os = OPENBSD;
}
// netbsd
if (SupportsNetbsd() && !os) {
for (; auxv[0]; auxv += 2) {
if (auxv[0] == 2014 /* AT_EXECFN */) {
os = NETBSD;
break;
}
}
}
// ОС по-умолчанию
if (!os) {
os = LINUX;
}
Exit(Main(argc, argv, envp, auxv, os), os);
}
FreeBSD любит, когда при инициализации %RDI (то есть, первый параметр System V ABI) совпадает с %RSP. Потому что эта система предпочитает код на C и не хочет заставлять нас писать на ассемблере штуку, которая перемещает регистр %RSP и переходит к реальной точке входа. На других операционных системах %RDI — всегда ноль.
Затем придётся выскрести реальные параметры нашей функции из стека, потому что на x86-64 функция _start()
, как ни странно, до сих пор соответствует древнему ABI i386, где параметры передавались в стеке, а не в регистрах. Теперь у нас есть argc
, argv
и environ
.
Однако в программах C есть малоизвестный четвёртый параметр auxv. Его название — сокращение от словосочетания «дополнительные значения» (auxiliary values). В OpenBSD этот параметр никогда не был реализован. Поэтому, если его нет, мы знаем, что у нас OpenBSD. Что касается NetBSD, мы знаем, что там дополнительные значения гораздо выше, чем в других системах, по magnum. Мы можем проверить эти значения — и тогда отличим NetBSD от Linux. Ну вот и всё! Теперь можно делать функции системного вызова.
Но есть одна маленькая проблема. BSD не соответствуют документации System V ABI. А в этой документации сказано, что ядро должно возвращать номера ошибок в виде отрицательного числа. BSD же следует конвенции 386BSD об использовании флага переноса. Этот флаг определяет наличие кода ошибки в RAX, а не в самих результатах. Поскольку, как известно, Linux всегда сохраняет и восстанавливает регистр EFLAGS
во время системного вызова, нам просто нужно использовать CLC
для очистки флага переноса перед использованием системного вызова. Мы точно знаем, что если после этого флаг будет установлен, то перед нами BSD, и системный вызов не удался. В этом случае мы просто поменяем знак, чтобы результат соответствовал System V.
Есть и другие странности. В нашем встроенном обозначении asm мы сообщаем GCC, что регистр RDX может быть повреждён. Такое возможно только на BSD, но не на Linux. Системные вызовы BSD в отдельных случаях тоже могут принимать параметры в стеке. Linux же никогда не принимает больше 6 параметров. Все они идут в регистры RDI, RSI, RDX, R10, R8 и R9.
Теперь, когда весь код написан, нужен скрипт редактора связей, чтобы склеить код C и ассемблера в обычный двоичный файл без всякого бойлерплейта под конкретную платформу.
/* reallytiny.lds */
ENTRY(_start)
SECTIONS {
. = 0x400000;
.text : {
*(.text)
*(.rodata .rodata.*)
}
filesz = . - ehdr;
textsz = . - _start;
.bss ALIGN(4096) : {
bss = .;
*(.bss)
. = ALIGN(4096);
}
memsz = . - ehdr;
/DISCARD/ : {
*(.*)
}
}
bsssize = SIZEOF(.bss);
textoff = _start - ehdr;
Теперь можно собрать программу как таковую:
gcc -static -no-pie -g -Os \
-o reallytiny.elf.dbg \
-Wl,-T,reallytiny.lds \
reallytiny-elf.S \
reallytiny.c
objcopy -S -O binary \
reallytiny.elf.dbg \
reallytiny.elf
Вот визуализация вашего двоичного файла:
А вот что происходит при запуске:
linux$ ./reallytiny.elf
hello world
freebsd$ ./reallytiny.elf
hello world
netbsd$ ./reallytiny.elf
hello world
openbsd$ ./reallytiny.elf
hello world
Четыре операционных системы за 400 байт — очень неплохо! Эти примеры можно скачать:
Вот и всё на сегодня.
Поздравляем с днём программиста и приглашаем прокачать ваши навыки:
- Полный курс по Data Science. Самая перспективная профессия за 24 месяца.
- «Fullstack-разработчик на Python». Станьте незаменимым специалистом-универсалом за 15 месяцев.
- «Уверенный старт в IT». Пройдите наш лучший курс для новичков: попробуйте 9 профессий и освойте подходящую именно вам.
Комментарии (10)
panteleymonov
14.09.2022 01:02+1В данном случае ваша программа поддерживает SSSE3, только если пользователь установит флаг компиляции
-mssse3
. Лучше пользоваться макросомX86_HAVE()
, так как он делает то же, что иX86_REQUIRE(SSSE3)
, за исключением проверки во время выполнения. По умолчанию, если вы не передали никаких флагов микроархитектуры GCC или Clang, обе ветви войдут в двоичный файл.if (X86_HAVE(SSSE3)) { // делаем несколько диких хаков ssse3 на ассемблере } else { // возвращаемся к медленному коду ansi c }
Вот тут либо с переводом что-то не то, либо я чего-то не понимаю.
X86_HAVE
по коду дает нам ветвление и мертвый код во время исполнения с проверкой, а пример сX86_REQUIRE
нет. А по тексту все наоборот.Хотя на практике такое ветвление само по себе не шик и в первой попытке оптимизировать под SSE достаточно флага, чтобы сгенерировать две разные библиотеки с SSE и без нее, выбирая какую загружать в процессе работы или при установке приложения. Тут достаточно ifdef чтобы оба варианта не хранили друг дуга, а Cosmopolitan такое все же допускает. Опять же не забываем про оптимизирующие компиляторы.
d_ilyich
14.09.2022 12:20Всё правильно:
Лучше пользоваться макросом X86_HAVE(), так как он делает то же, что и X86_REQUIRE(SSSE3), за исключением проверки во время выполнения.
А потом даётся пояснение:По умолчанию, если вы не передали никаких флагов микроархитектуры GCC или Clang, обе ветви войдут в двоичный файл.
Т.е. X86_HAVE() лучше, хоть и имеет недостаток.
Если заглянуть в оригинал, то можно увидеть, что там оформление отличается: оно поясняет сказанное, в отличие от перевода. И asm-листинги там читаемые.
P.S. Хотя, я перечитал, и уже не совсем уверен.
P.P.S. Нет же, всё норм. Смысл как раз в том, что если использовать X86_REQUIRE, то нужно обязательно передавать флаг -mssse3 (если нужна поддержка SSSE3); если же использовать X86_HAVE(), то флаг передавать не обязательно, но можно: с флагом оптимизатор уберёт ненужный код, без флага этот код останется.panteleymonov
14.09.2022 16:34Как я уже сказал выше, проще сделать два отдельных кода и выкинуть из тела программы условие навсегда.
d_ilyich
14.09.2022 16:47Я отвечал на сомнение в правильности перевода.
А по поводу проще/правильнее: в статье неоднократно используется слово «магия» в том или ином виде. КМК, статья именно о ней. Возможно, был бы уместен соответствующий тэг.
vassabi
15.09.2022 12:33еще вариант - вынести платформенный код в разные библиотеки\плагины и после проверки возможностей процессора на старте - подключать нужную (libNoSSSE3 / libSSSE3).
Apoheliy
14.09.2022 14:53+2Соглашусь с emaxx: это для фокусов с кодом и никак не для продакшена.
saboteur_kiev: какое (...) системное программирование? Тут под одну операционку пишешь драйвер, потом меняется версия ядра и (вуаля!) количество аргументов в функции поменялось. В пределах одной операционной системы, Карл!
Особенно позабавило перекрытие функции. Этим бы поиграться и забыть как страшный сон. А тут предлагается этим целенаправленно пользоваться. Ужас-ужас-ужас.
emaxx
После такой статьи становится очевидно, что Cosmopolitan - это больше игрушка для фанатов, чем реальный инструмент для продакшена.
На каждом шагу заложены грабли и неочевидные предположения об ОС или о самом коде, и когда оно где-то там взорвётся, отлаживать это (без детального знания кучи низкоуровневых вещей, которое, очевидно, есть у мизерного процента разработчиков) будет то ещё веселье...
saboteur_kiev
Нет, это реальный инструмент для продакшена, но в области системного программирования, а не прикладного.
edo1h
это не оправдание использованию грязных трюков.
разовое использование их где-нибудь в бутлоадере, обложенное кучей документации с описанием того как оно работает и почему сделано именно так, ещё можно представить, но вот использовать это на каждом шагу и делать вид, что всё нормально — увольте