Lab3 - What's inside the compiled C program?

In this post we are going to take a look at the compiler output produced by the gcc compiler when C program is compiled with different options.

As we know that many programming languages are compiled before they are executed by the machine, because the language we write the code in is not understood by the machine. This is where the compiler steps in; its job is to turn the language into machine language that can be understood by the machine. The compiler not only converts the program into machine language, it can also help with optimizing the code by shrinking the compiler output file and re-arranging the execution instructions.

For this lab we'll use C, gcc and gcc options to see how different compilation options affect in optimizing the program.

In order for us to see the differences between the compiler output we'll compile our base program called 'hello' that just prints out 'Hello World' to the console.

Base Program
The code
Compilation
gcc -g -O0 -fno-builtin hello.c -o hello
**
-g: enable debugging information
-O0: do not optimize
-fno-builtin: do not use built-in function optimization
**

Now let's take a look at the compiler output of this program using 'objdump'.
We'll be using three options for 'objdump':
-f: display header information for the entire file
-s: display per-section summary information
-d: disassemble sections containing code

objdump -f hello
From this output we can see some information about this program
This file's format is elf
The architecture this was built is x86-64
The program name is 'hello'

objdump -s hello
The -s options shows numerous sections of the program.
In this we are going to look at the section called .rodata which contains read only data.
And as seen the message 'Hello World' is contained in this section.
It's not included here but there are 5 debug sections:
-debug_aranges
-debug_info
-debug_abbrev
-debug_line
-debug_str

objdump -d hello
The -d option is the disassembled sections containing code.
Here the
section is the 'int main(){}' of our code.
We can see that there is a function call
In this program we have one argument which is 'Hello World' for our printf() function.
We can see that this argument was moved to register edi on line 3.

File size: 10720 bytes

Now let's take a look at our 6 different scenarios

Scenario 1: Add -static compiler option
-static compiler option prevents dynamic linking to shared libraries needed to run the program.
Without dynamic linkage to shared libraries, the libraries needed for the program needs to be included in to the code.
The compilation command for this is
gcc -g -O0 -fno-builtin -static hello.c -o hello1

Now let's take a look at the compiler output
objdump -s hello1
Left is 'hello' and right is 'hello1'.
Here we can see that 'hello1' is missing the sections containing dynamic linking to shared libraries.

objdump -d hello1
In the
section of the 'hello1' we can see that it's using <_io_printf> instead of .
This is because plt(procedure linkage table) cannot be used when there is no dynamic linkage to shared libraries.

Because there is no dynamic linkage to shared libraries the libraries needed for the program are included in the code, and increased the file size exponentially.

File size: 759280 bytes.

Scenario 2: Remove -fno-builtin compiler option
In this scenario our compilation command is
gcc -g -O0 hello.c -o hello2

Now let's take a look at the output
objdump -d hello2
Compare this to the output from the 'hello'.
The difference we see is that in 'hello' was used,
and here is used.
The difference between the printf and puts is the string formatting.
In our case, the compiler noticed that there was no string formatting needed to display
'Hello World' so it decided to use the puts
Using puts is more efficient for the program because puts displays the string passed literally,
whereas printf formats the string before displaying.

File size: 10704 bytes

Scenario 3: Remove compiler option -g
In this scenario our compilation command is
gcc -O0 hello.c -o hello3

Now let's take a look at the output
objdump -s hello3
We can see that this output does not contain any of the debugging information.
This is because we didn't not use -g compiler option.
The
disassembly is the same as 'hello2'.

File size: 8216 bytes

Scenario 4: Add additional arguments to the printf() function
In this scenario our code looks like this
Let's compile this
gcc -g -O0 hello2.c -o hello4

Let's take a look at the output
objdump -d hello4
We can see that the
section is longer than the previous ones.
What's happening here?
From the code above we can see that our printf() function have total 11 arguments
('Hello Word', 1, 2, 3, 4, 5, 6, 7, 8, 9, 0)
In the disassembly we can see that our first 6 arguments were moved to the register
mov 0x4005b0,%edi
mov 0x1,%esi
mov 0x2,%edx
mov 0x3,%ecx
mov 0x4,%r8d
mov 0x5,%r9d
The rest of the arguments (0x6 ~ 0x0) are pushed the stack.

File size: 10712 bytes.
If this was compiled without -g option, the file size is same as 'hello3'.

Scenario 5: Call printf() in separate function called output()
For this scenario our code looks like this
Let's compile this with
gcc -g -O0 hello3.c -o hello5

Let's take a look at the output
objdump -d hello5

Here we see that output() is called inside the
section
and printf() is called inside the section.
Again, the compiler used puts() instead of printf() as part of optimization.

File size: 10808 bytes
If this was compiled without -g option it is 8248 bytes.

The file size increased with additional function in the code

Scenario 6: Replace -O0 with -O3
In this scenario I used the orignal 'Hello World' program
The compilation command is
gcc -g -O3 hello.c -o hello6

Let's take a look at the output
objdump -d hello6
We can see that disassembled
section looks different from the previous ones.
One noticeable thing is that there is only one mov and is called earlier.
Also retq have moved to line 6, and nopw and nop have been added.
Another difference that was noticed was that there was an extra debug section called debug_range

File size: 10960 bytes.
If this was compiled without -g, the size is 8216 bytes.


After looking at the above scenarios we can see that the default compilation command we used (gcc ) is the most common optimization method. We can add extra optimization by adding optimization level with -O[level] option. We also saw that different optimization options optimizes the program differently. Some adds more sections, some removes sections, and some changed the sequence of executable instructions.
Optimization isn't always about how much we can shrink the program file. Sometimes we may have to do with less shrinkage to have faster execution of the instructions. When optimizing the program we should keep in mind what we want the program to do.

Comments