1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
|
#+TITLE: Analyzing Executable Size, part 0 - A Small Proof-of-Concept Loader
#+DATE: <2017-07-31 Mon 13:35>
#+TAGS: writeup programming operating-systems c linux
#+BEGIN_EXPORT html
<header class="article-front-matter article-warning">
<p>This was intended to be a series describing trends in programming language
technology and how they relate to an increase in the average size of
executable files. It never materialized beyond this initial post.
And, as of 2022, I find it unlikely that I will come back to complete the
series. We have seen a renewed interest in linkers and loaders
(cf. <a href="https://github.com/rui314/mold">mold</a>), but I never
intended to discuss linkers and loaders in this series beyond the purpose of
ELF metadata. It warrants treatment, but I would like to approach it without
the baggage of this first post.
As for the idea that trends in compiler technology are affecting binary
size: it is not as interesting as one may think. The bottom-line is that
modern technologies tend to bring a runtime along with them, often more
accommodating than the C runtime. The binary necessarily contains this
runtime and is therefore larger. I don't think it's as much of a problem as
folks make it out to be, and my past self seems to be somewhat cognizant of
that.</p>
</header>
#+END_EXPORT
It seems that static linking is back in style, or at least popular among all the
hip new programming languages of today. I don't have anything against statically
linked binaries, nor do I have a problem with larger executables, but I've
noticed that the acceptable size for an executable is a lot larger now than it
was a few years ago; that is, the new kids on the block have significantly more
leeway than their predecessors. For example - a C program that spits out "hello
world" is 7 KB when statically linked to musl. It's 12 KB when dynamically
linked to glibc. The same program in D, where the reference compiler doesn't
allow dynamic linking to the standard library, is 896 KB. A blog post I read
recently about certificate chain verification in Go made a point of praising the
toolchain for being able to spit out a binary that was "less than 6 MB!" I'm
being more facetious than with my D example, as this was statically linked to an
SSL-capable web server, but 6 MB is a little over half the size of a
[[https://en.wikipedia.org/wiki/Tiny_Core_Linux][fully-functioning operating system]]. I'm not so interested in why we settle
binaries the size of a few videos, but instead I'd like to look at why they're
that large to begin with. To peer in and see what wealth of information is
stored inside, and how certain programming languages make use of that
information.
Perhaps we should first take a step back. What is a binary, anyway? It's a
structured format, not much different than your typical PNG or Ogg file,
containing some machine code instructions and directives for how the program
should be loaded into memory. The task of parsing the binary and actually
loading it is done by a *loader*, though that's a pretty broad term. My favorite
book on this subject, /Linkers and Loaders/ by John R. Levine, defines a loader
as a program to "copy a program from secondary storage (which since about 1968
invariably means a disk) into main memory so it's ready to be run. In some cases
loading just involves copying the data from disk to memory, in others it
involves allocating storage, setting protection bits, or arranging for virtual
memory to map virtual addresses to disk pages."
Loaders are everywhere, as you can probably imagine. Maybe you've heard of a
boot loader; those are for getting a kernel into memory from the strange and
unfamiliar land of x86 real mode. Whenever you run a program on Linux, it's
loaded by the kernel's ELF loader, of which you can find the source code for at
[[https://github.com/torvalds/linux/blob/master/fs/binfmt_elf.c][=fs/binfmt_elf.c=]] of the kernel source tree. On a higher level, something like
Java has a class loader for getting bytecode into memory so that the JVM can run
it.
As our first step into the world of loaders, we'll write our own. A very basic
one, at that. I think that because we're taking a look at how much information
can be stored inside of a binary, we should begin with the absolute minimum. It
won't use a structured format, and won't set up memory beyond the stack and a
page for executable code, but not at a specified address of any sort. Where that
code exists in memory isn't known to the program, and it only really knows where
the stack is from the =%rsp= register. We'll simply load some machine code from
a file, and execute it. I'll spare you the per-line explanation I usually give,
since it's reasonably simple and the only part you might not understand already
is explained through comments.
#+BEGIN_SRC c
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdio.h>
size_t binary_size(FILE *);
int main(int argc, char **argv) {
FILE *fp;
void *exe;
size_t exe_size;
void (*jump)(void);
if (argc != 2 || (fp = fopen(argv[1], "rb")) == NULL) {
fprintf(stderr, "USAGE: %s [FILE]\n", argv[0]);
return 1;
}
if ((exe_size = binary_size(fp)) == 0) {
return 1;
}
/* Because writable memory pages are marked as non-executable by
default, we need to map a new page of memory for our executable
code. We do this by invoking the "mmap" syscall, and getting a
new page from the kernel. */
exe = mmap(NULL, exe_size, PROT_WRITE | PROT_EXEC,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (exe == MAP_FAILED) {
fprintf(stderr, "mmap failure.\n");
return 1;
}
fread(exe, exe_size, 1, fp);
jump = exe;
jump();
munmap(exe, exe_size);
fclose(fp);
return 0;
}
/* We'll use some POSIX standard functions because we can and they're
generally safer than fseek and ftell. */
size_t binary_size(FILE *fp) {
struct stat buf;
if ((fstat(fileno(fp), &buf) != 0) || (!S_ISREG(buf.st_mode))) {
return 0;
}
return buf.st_size;
}
#+END_SRC
Looks good! We can't use any of the binaries on our system to test it out,
though. They're in some structured format like ELF and the header would be
interpreted as code -- probably causing a segmentation fault. Even if it got
past the header without a core dump, the binary probably relies on some absolute
addressing that we didn't set up properly. So instead of running =/bin/ls=
through our program, we'll assemble "hello world."
#+BEGIN_SRC asm
leaq (%rip), %rax
addq $_msg_end - ., %rax
jmpq *%rax
_msg:
.ascii "Hello, world!\n"
_msg_end:
movq $0x01, %rax
movq $0x01, %rdi
leaq (%rip), %rsi
subq $. - _msg, %rsi
movq $0x0e, %rdx
syscall
ret
#+END_SRC
What you'll probably notice immediately is that we're forced to write a
position-independent executable. As I mentioned earlier, our loader can't handle
absolute addresses. It can't really handle anything, aside from the most simple
of x86 instructions. We do a =ret= at the very end to return control to the
loader. Nothing left to do now but test it out:
#+BEGIN_SRC
[jakob@Epsilon ~]$ ./a.out test.bin
Hello, world!
#+END_SRC
=test.bin= is 64 bytes and takes 0.001s to load and run. I probably could have
made the program smaller, but I think it's a perfectly fine benchmark as we
continue through this series. Keep in mind that 64 bytes is only achievable
because we forget the conveniences of modern loaders. We can only run
position-independent code, there's no separation between data and code segments,
no room for debugging symbols, no write protection on the code segment, nothing.
This is perhaps the most stripped down loader you can get.
|