О возможности замены простых shell-скриптов программами на Си и ассемблере

Достаточно часто можно встретить скрипты bash, состоящие из единственной команды - имени программы с параметрами. Посмотрим, каковы накладные расходы при выполнении такого скрипта. Для примера возьмем следующий скрипт

#!/bin/sh
ls -l

Ожидаемый результат запуска скрипта - выполнение системного вызова execve, запускающего ls с параметром -l. Посмотрим через strace, сколько дополнительных системных вызовов выполняется до этого execve.

keremet@xubuntu2004:~/programming/ls_l$ strace -f ./ls_l.sh 
execve("./ls_l.sh", ["./ls_l.sh"], 0x7ffe64164fe8 /* 47 vars */) = 0
brk(NULL) = 0x56128ea6a000
arch_prctl(0x3001 /* ARCH_??? */, 0x7ffec4d81270) = -1 EINVAL (Недопустимый аргумент)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (Нет такого файла или каталога)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=134187, ...}) = 0
mmap(NULL, 134187, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f65e1401000
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\360q\2\0\0\0\0\0"..., 832) = 832
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
pread64(3, "\4\0\0\0\20\0\0\0\5\0\0\0GNU\0\2\0\0\300\4\0\0\0\3\0\0\0\0\0\0\0", 32, 848) = 32
pread64(3, "\4\0\0\0\24\0\0\0\3\0\0\0GNU\0cBR\340\305\370\2609W\242\345)q\235A\1"..., 68, 880) = 68
fstat(3, {st_mode=S_IFREG|0755, st_size=2029224, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f65e13ff000
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
pread64(3, "\4\0\0\0\20\0\0\0\5\0\0\0GNU\0\2\0\0\300\4\0\0\0\3\0\0\0\0\0\0\0", 32, 848) = 32
pread64(3, "\4\0\0\0\24\0\0\0\3\0\0\0GNU\0cBR\340\305\370\2609W\242\345)q\235A\1"..., 68, 880) = 68
mmap(NULL, 2036952, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f65e120d000
mprotect(0x7f65e1232000, 1847296, PROT_NONE) = 0
mmap(0x7f65e1232000, 1540096, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x25000) = 0x7f65e1232000
mmap(0x7f65e13aa000, 303104, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x19d000) = 0x7f65e13aa000
mmap(0x7f65e13f5000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e7000) = 0x7f65e13f5000
mmap(0x7f65e13fb000, 13528, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f65e13fb000
close(3) = 0
arch_prctl(ARCH_SET_FS, 0x7f65e1400580) = 0
mprotect(0x7f65e13f5000, 12288, PROT_READ) = 0
mprotect(0x56128e328000, 8192, PROT_READ) = 0
mprotect(0x7f65e144f000, 4096, PROT_READ) = 0
munmap(0x7f65e1401000, 134187) = 0
getuid() = 1000
getgid() = 1000
getpid() = 9556
rt_sigaction(SIGCHLD, {sa_handler=0x56128e31dc30, sa_mask=~[RTMIN RT_1], sa_flags=SA_RESTORER, sa_restorer=0x7f65e1253210}, NULL, 8) = 0
geteuid() = 1000
brk(NULL) = 0x56128ea6a000
brk(0x56128ea8b000) = 0x56128ea8b000
getppid() = 9553
stat("/home/keremet/programming/ls_l", {st_mode=S_IFDIR|0775, st_size=4096, ...}) = 0
stat(".", {st_mode=S_IFDIR|0775, st_size=4096, ...}) = 0
openat(AT_FDCWD, "./ls_l.sh", O_RDONLY) = 3
fcntl(3, F_DUPFD, 10) = 10
close(3) = 0
fcntl(10, F_SETFD, FD_CLOEXEC) = 0
geteuid() = 1000
getegid() = 1000
rt_sigaction(SIGINT, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGINT, {sa_handler=0x56128e31dc30, sa_mask=~[RTMIN RT_1], sa_flags=SA_RESTORER, sa_restorer=0x7f65e1253210}, NULL, 8) = 0
rt_sigaction(SIGQUIT, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGQUIT, {sa_handler=SIG_DFL, sa_mask=~[RTMIN RT_1], sa_flags=SA_RESTORER, sa_restorer=0x7f65e1253210}, NULL, 8) = 0
rt_sigaction(SIGTERM, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGTERM, {sa_handler=SIG_DFL, sa_mask=~[RTMIN RT_1], sa_flags=SA_RESTORER, sa_restorer=0x7f65e1253210}, NULL, 8) = 0
read(10, "#!/bin/sh\nls -l\n", 8192) = 16
stat("/usr/local/sbin/ls", 0x7ffec4d80f00) = -1 ENOENT (Нет такого файла или каталога)
stat("/usr/local/bin/ls", 0x7ffec4d80f00) = -1 ENOENT (Нет такого файла или каталога)
stat("/usr/sbin/ls", 0x7ffec4d80f00) = -1 ENOENT (Нет такого файла или каталога)
stat("/usr/bin/ls", {st_mode=S_IFREG|0755, st_size=142144, ...}) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLDstrace: Process 9557 attached
, child_tidptr=0x7f65e1400850) = 9557
[pid 9556] wait4(-1, <unfinished ...>
[pid 9557] close(10) = 0
[pid 9557] execve("/usr/bin/ls", ["ls", "-l"], 0x56128ea6acd8 /* 47 vars */) = 0
....................
[pid 9557] +++ exited with 0 +++
<... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 9557
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=9557, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
rt_sigreturn({mask=[]}) = 9557
read(10, "", 8192) = 0
exit_group(0) = ?
+++ exited with 0 +++
keremet@xubuntu2004:~/programming/ls_l$

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

Программа на С с функцией main

Напишем программу на С, дающую тот же результат

void main() { execl("/usr/bin/ls", "/usr/bin/ls", "-l", 0);}

Команда для компиляции

gcc -o ls_l_c ls_l.c

Теперь нет запуска shell, нет ожидания завершения процесса и системных вызовов поменьше, но все равно их много.

keremet@xubuntu2004:~/programming/ls_l$ strace ./ls_l_c
execve("./ls_l_c", ["./ls_l_c"], 0x7fff3d05aaa0 /* 47 vars */) = 0
brk(NULL) = 0x556094fdc000
arch_prctl(0x3001 /* ARCH_??? */, 0x7ffd5c98ed60) = -1 EINVAL (Недопустимый аргумент)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (Нет такого файла или каталога)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=134187, ...}) = 0
mmap(NULL, 134187, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fd582c71000
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\360q\2\0\0\0\0\0"..., 832) = 832
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
pread64(3, "\4\0\0\0\20\0\0\0\5\0\0\0GNU\0\2\0\0\300\4\0\0\0\3\0\0\0\0\0\0\0", 32, 848) = 32
pread64(3, "\4\0\0\0\24\0\0\0\3\0\0\0GNU\0cBR\340\305\370\2609W\242\345)q\235A\1"..., 68, 880) = 68
fstat(3, {st_mode=S_IFREG|0755, st_size=2029224, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fd582c6f000
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
pread64(3, "\4\0\0\0\20\0\0\0\5\0\0\0GNU\0\2\0\0\300\4\0\0\0\3\0\0\0\0\0\0\0", 32, 848) = 32
pread64(3, "\4\0\0\0\24\0\0\0\3\0\0\0GNU\0cBR\340\305\370\2609W\242\345)q\235A\1"..., 68, 880) = 68
mmap(NULL, 2036952, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fd582a7d000
mprotect(0x7fd582aa2000, 1847296, PROT_NONE) = 0
mmap(0x7fd582aa2000, 1540096, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x25000) = 0x7fd582aa2000
mmap(0x7fd582c1a000, 303104, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x19d000) = 0x7fd582c1a000
mmap(0x7fd582c65000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e7000) = 0x7fd582c65000
mmap(0x7fd582c6b000, 13528, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7fd582c6b000
close(3) = 0
arch_prctl(ARCH_SET_FS, 0x7fd582c70540) = 0
mprotect(0x7fd582c65000, 12288, PROT_READ) = 0
mprotect(0x556093c7f000, 4096, PROT_READ) = 0
mprotect(0x7fd582cbf000, 4096, PROT_READ) = 0
munmap(0x7fd582c71000, 134187) = 0
execve("/usr/bin/ls", ["/usr/bin/ls", "-l"], 0x7ffd5c98ee48 /* 47 vars */) = 0

Исполняемый файл имеет подозрительно большой размер - 16696 байт. Дело в том, что для обращения к ядру используется библиотека glibc и до вызова функции main выполняется ряд действий, которые в данной задаче совершенно лишние. Избавимся от них. Системный вызов можно сделать напрямую, используя ассемблерную вставку, а вместо функции main использовать _start.

Программа на С с функцией _start

static const char* argv[] = {"/usr/bin/ls", "-l", 0};
void _start() {
 asm (
 "movq %0, %%rdi\n"
 "movq %1, %%rsi\n"
 "xorq %%rdx, %%rdx\n"
 "movq $59, %%rax\n"
 "syscall\n"
 "movq $1, %%rdi\n"
 "movq $60, %%rax\n"
 "syscall"::"l"(argv[0]), "l"(argv) );
}

В данном коде первый системный вызов - execve, второй - exit - на случай, если execve завершится с ошибкой.

Для минимизации исполняемого файла следует использовать собственный скрипт линковки x86_64.ld.

OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64",
 "elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start) 

SECTIONS
{
 . = 0x400000 + SIZEOF_HEADERS;
 .text : { *(.text) *(.data*) *(.rodata*) *(.bss*) }
 /DISCARD/ : { *(.note.gnu.property) }
}

Команда сборки

gcc -T x86_64.ld -s ls_l_c_start.c -nostdlib -static -Wl,--gc-sections -fno-unwind-tables -Wl,--build-id=none -Qn -o ls_l_c_start

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

keremet@xubuntu2004:~/programming/ls_l$ strace ./ls_l_c_start
execve("./ls_l_c_start", ["./ls_l_c_start"], 0x7ffd569598d0 /* 47 vars */) = 0
execve("/usr/bin/ls", ["/usr/bin/ls", "-l"], NULL) = 0

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

Программа на ассемблере

bits 64
 org 0x08048000

ehdr: ; Elf64_Ehdr
 db 0x7F, "ELF", 2, 1, 1, 0 ; e_ident
 times 8 db 0
 dw 2 ; e_type
 dw 62 ; e_machine
 dd 1 ; e_version
 dq _start ; e_entry
 dq phdr - $$ ; e_phoff
 dq 0 ; e_shoff
 dd 0 ; e_flags
 dw ehdrsize ; e_ehsize
 dw phdrsize ; e_phentsize
 dw 1 ; e_phnum
 dw 0 ; e_shentsize
 dw 0 ; e_shnum
 dw 0 ; e_shstrnd

ehdrsize equ $ - ehdr

phdr: ; Elf64_Phdr
 dd 1 ; p_type
 dd 5 ; p_flags
 dq 0 ; p_offset
 dq $$ ; p_vaddr
 dq $$ ; p_paddr
 dq filesize ; p_filesz
 dq filesize ; p_memsz
 dq 0x1000 ; p_align

phdrsize equ $ - phdr

_start:
 mov rdi, program_name
 mov rsi, argv
 xor rdx, rdx
 mov eax, 59
 syscall

 mov di, 42 ; only the low byte of the exit code is kept,
 ; so we can use di instead of the full edi/rdi
 mov eax, 60
 syscall ; perform the syscall

program_name:
 db "/usr/bin/ls", 0
arg1:
 db "-l", 0 

align 16
argv:
 dq program_name
 dq arg1
 dq 0

filesize equ $ - $$

Команда сборки

nasm ls_l_asm.s && chmod a+x ls_l_asm

Размер исполняемого файла - 200 байт.

Результат трассировки

keremet@xubuntu2004:~/programming/ls_l$ strace ./ls_l_asm 
execve("./ls_l_asm", ["./ls_l_asm"], 0x7ffc16bdcb90 /* 47 vars */) = 0
execve("/usr/bin/ls", ["/usr/bin/ls", "-l"], NULL) = 0

Подробности:

https://journal.lunar.sh/2020/10/24/tiny-linux-c-binaries.html https://stackoverflow.com/questions/53382589/smallest-executable-program-x86-64