Уменьшаем размер исполняемого файла Nim со 160 КБ до 150 Б

Кажется, размер бинарных файлов в языке Nim недавно стал популярной темой для обсуждения. Слоган языка Nim: «Выразительно, эффективно, элегантно», и сейчас мы опробуем его эффективность, разобрав несколько вариантов уменьшения размера обычного файла Hello World на Linux.

Следующие несколько минут мы будем:

  • писать обычную программу размером 6 KB;
  • стараться не использовать стандартную библиотеку C;
  • создавать бинарный файл весом 952 байта;
  • использовать измененный компоновщик скриптов и ELF заголовок для создания файла весом всего лишь 150 байт.

Исходный код всех примеров из поста можно будет найти в репозитории. Все действия будут произведены на Linux x86-64 с использованием GCC 5.1 и Clang 3.6.0.

Использование стандартной библиотеки С

echo "Hello!"

По умолчанию Nim использует GCC, так как он является базовым компилятором С для большинства платформ, и мы можем использовать команду echo без подключения glibc. Мы можем попробовать увеличить скорость и уменьшить размер, а также убрать ненужные после компиляции символы:

Команда (при помощи GCC)Размер файла
nim c hello160 КВ
nim -d:release c hello61 KB
nim -d:release --opt:size c hello25 KB
nim -d:release --opt:size c hello && strip -s hello19 KB

Вышло достаточно неплохо! Данный метод можно использовать с любыми программами на Nim, чтобы уменьшить размер файла.

Сейчас давайте попробуем избавиться от glibc, по крайней мере, временно (мы вернемся к более громоздкому решению немного позднее). Вместо glibc мы будем использовать библиотеку musl libc, которая слегка уменьшит размер нашего файла:

$ nim -d:release --opt:size --passL:-static \
  --gcc.exe:/usr/local/musl/bin/musl-gcc \
  --gcc.linkerexe:/usr/local/musl/bin/musl-gcc c hello
$ strip -s hello
18 KB

Ну что же, в итоге мы получили файл размером 18 KB, который может быть открыт вне зависимости от того, какая у вас версия glibc (или любой другой библиотеки)!

Вот пример использования Clang вместо GCC при помощи команды --cc:clang:

Команда (при помощи GCC)Размер файла
nim --cc:clang c hello168 KB
nim --cc:clang -d:release c hello33 KB
nim --cc:clang -d:release --opt:size c hello29 KB
nim --cc:clang -d:release --opt:size c hello && strip -s hello23 KB

Повышение скорости почти не заметно, но мы уменьшили размер. Насколько ваш результат будет похож на наш, конечно, зависит от версий Clang и GCC. Размер вашего файла может немного отличаться.

Конечно, лучшим компилятором на сегодня является GCC, поэтому дальше мы будем работать с ним.

Первым шагом мы отключим этот сборщик мусора, я не думаю, что он нам понадобится:

$ nim --gc:none -d:release --opt:size c hello
$ strip -s hello
11 KB

Дальше мы перемещаем всю динамическую память, сообщения об ошибках и другие плюшки, зависящие от операционной системы, при помощи --os:standalone  (этому соответствует --gc:none). В нашем коде мы можем заметить panicoverride.nim, который мог бы быть полезным. Но он нам ни к чему. Если будет допущена ошибка, то вместо файла размером в 6 КВ получится это:

proc rawoutput(s: string) = discard
proc panic(s: string) = discard
$ nim --os:standalone -d:release c hello
$ strip -s hello
6.1 KB

Откажемся от стандартной библиотеки C

Теперь мы должны начать думать масштабнее: если мы хотим написать программу, которая ничего не делает, даже не выводит «Hello!», мы можем просто использовать пустой файл. Сейчас мы не должны полностью полагаться на стандартную библиотеку C. Можно попробовать вообще не использовать -passL:-nostdlib (passL в таком случае будет просто проходить как аргумент в GCC на этапе компиляции):

$ nim --os:standalone -d:release --passL:-nostdlib c hello
CC: hello
CC: stdlib_system
[Linking]
ld: warning: cannot find entry symbol _start; defaulting to 0000000000400160
$ strip -s hello
1.4 KB

Ого, достаточно неплохо! Давайте же запустим нашу программу, которая ничего не делает, и насладимся этим:

$ ./hello
Segmentation fault (core dumped)

Ой. Что-то пошло не так. Пересмотрите ваш результат и найдите ошибку. Код работать не может, файл начинает выполняться совершенно непредсказуемо и неправильно. Вместо всего этого мы можем взять в свои руки работу библиотеки C и прописать нашу собственную функцию _start:

import syscall

proc main {.exportc: "_start".} =
  discard syscall(EXIT, 0)

Также мы можем выйти из программы, для которой будем использовать системный вызов библиотеки, это обеспечит чистый вызов системы в ядре Linux на языке Nim. Давайте объединим все команды системного вызова, которые нам нужны, и используем их для написания правильного кода:

import syscall

const STDOUT = 1

proc write(fd: cint, buf: cstring, len: csize): clong
          {.inline, discardable.} =
  syscall(WRITE, fd, buf, len)

proc exit(n: clong): clong {.inline, discardable.} =
  syscall(EXIT, n)

proc main {.exportc: "_start".} =
  write STDOUT, "Hello!\n", 7

exit 0

Сейчас мы можем успешно скомпилировать:

$ nim --os:standalone -d:release --passL:-nostdlib --noMain c hello
$ strip -s hello
1.5 KB
$ ./hello
Hello!

Последний трюк в этом разделе – это сообщить GCC о необходимости оптимизировать неиспользуемые функции. Эти функции уже установлены в Nim, по типу наших модулей hello или system из стандартной библиотеки, в конечном итоге они просто остаются пустыми. Может быть, компилятор Nim мог бы и пропустить их самостоятельно, но обычно вы не заморачиваетесь с несколькими байтами памяти и начинаете писать что-нибудь полезнее.  Но сегодня мы говорим конкретно об этом, и для начала мы сообщим GCC отправить функцию и файлы data в отдельные разделы (-ffunction-sections & -fdata-sections) для компиляции. Для компоновки мы пропишем Nim, чтобы тот сообщил GCC передать --gc-sections в ld, наш компоновщик, который затем удаляет разделы, на которые нет ссылки:

$ nim --os:standalone -d:release --passL:-nostdlib --noMain \
  --passC:-ffunction-sections --passC:-fdata-sections \
  --passL:-Wl,--gc-sections c hello
$ strip -s hello
952 B

Великолепно! Мы начали с файла размером 160 KB и закончили на 952 байтах. Сможем ли мы еще уменьшить наш файл? Да, конечно, но не с обычными набором «инструментов».

Изменение ссылок для достижения размера файла в 150 байт

Для этого используют точный метод из поста «151-байт статического Linux-файла в Rust» на нашем блоге, за исключением того, что языку Nim и компилятору GCC удается уменьшить размер файла на 1 байт. Между тем, Clang требует на 1 байт больше, чем версия Rust.

Мы продолжим с той программы, которую мы получили в итоге (размер — 952 байта). Но вместо того, чтобы использовать Nim для выполнения всей работы, мы просто создадим файл объектного кода в Nim (--app:staticlib) и отсюда начнем делать вручную. Измененный компоновщик скриптов и заголовок ELF сделают всю основную работу. Но фактически данная операция выполняется при условиях нашего Nim кода:

$ nim --app:staticlib --os:standalone -d:release --noMain \
  --passC:-ffunction-sections --passC:-fdata-sections \
  --passL:-Wl,--gc-sections c hello
$ ld --gc-sections -e _start -T script.ld -o payload hello.o
$ objcopy -j combined -O binary payload payload.bin
$ ENTRY=$(nm -f posix payload | grep '^_start ' | awk '{print $3}')
$ nasm -f bin -o hello -D entry=0x$ENTRY elf.s
$ chmod +x hello
$ wc -c < hello
158
$ ./hello
Hello!

158 байт! Сейчас я покажу еще один способ снизить размер файла на 8 байт. Мы переместим наш код за отведенное место в заголовке ELF и получим доступ к памяти вручную:

proc main {.exportc: "_start".} =
  write STDOUT, cast[cstring](0x00400008), 7
  exit 0
$ wc -c < hello
150
$ ./hello
Hello!

150 байт! Это конечный результат, который можно получить. Если вам этого до сих пор много и вы хотите сделать свой файл вручную, то вы можете попробовать схитрить еще больше, чтобы получить в итоге 45 байт, как это описано в прекрасной статье «Учебник по созданию крошечных ELF, выполняемых под Linux».

Вывод

Nim — хороший язык для написания маленьких бинарных файлов. Теперь вы знаете, как писать код на Nim без помощи библиотеки C. Писать что-нибудь на Nim с нуля — достаточно интересное и захватывающее занятие. Вы можете проверить ваши результаты в репозитории:

$ ./run.sh
== Using the C Standard Library ==
hello_unoptimized    163827
hello_release         62131
hello_optsize         25248
hello_optsize_strip   18552
hello_gcnone          10344
hello_standalone       6208

== Disregarding the C Standard Library ==
hello2                 1776
hello3                  952

== Custom Linking ==
hello3_custom           158
hello4_custom           150

$ objdump -rd nimcache/hello4.o
...
0000000000000000 <_start>:
 0: b8 01 00 00 00          mov    $0x1,%eax
 5: ba 07 00 00 00          mov    $0x7,%edx
 a: be 08 00 40 00          mov    $0x400008,%esi
 f: 48 89 c7                mov    %rax,%rdi
12: 0f 05                   syscall 
14: 31 ff                   xor    %edi,%edi
16: b8 3c 00 00 00          mov    $0x3c,%eax
1b: 0f 05                   syscall 
1d: c3                      retq 
...

Перевод статьи «Nim binary size from 160 KB to 150 Bytes»