Special Note: It's one of my closest friend Varun's birthday, this article is for my boy :)
Last year I made the decision to truly understand computers from the ground up. My favorite book from the ones I read was "Computer Systems - A Programmer's Perspective". This series will go over the major concepts in the book and this will be my attempt to make the knowledge more accessible and also a test of my understanding.
In this article I will be going over the "hello world" program that I expect everyone reading this to have written at least once in whatever language. I will be going over the journey from writing the code, to compilation and execution of this program.
The Program
#include <stdio.h> int main() { printf("hello world\n"); return 0; }
The above program is written in C, and is meant to print out hello world to your console. The first line is what is called a preprocessor directive, ignore the word preprocessor since we will be going over it later in this article for now think of it as an import statement that lets you bring pre-written functions into your code, so that you don't have to build everything from the ground up. We use stdio.h header file which is a provided by the C standard library, and we are using the "printf" function from it in our code.
The "printf" function lets you pass an input of the type "const char *" (this is how you represent a string in C that cannot be modified), and outputs it to standard output stream. The standard output stream is by default set to the console, and so the expectation is that you see hello world printed on your screen on completion of the program's execution.
At the very end we return 0 to indicate that the program has finished successfully, any number other than 0 being returned would convey a different exit status or error.
The journey of the code written above can be divided into the Compilation and Execution phases.
The program is currently in human readable text format, this cannot be executed. In order to run this code we need to build an "Executable Object Binary Program". This is done through compilation. The compiler is a program that can take in a high level language like (C, C++, Rust, Go) and compile it down to machine code or executable that a computer can then run. For our language C there are multiple compilers we can choose to use, the most famous and widely used is GCC which we will also use.
The compilation process is done in multiple phases by GCC:
The pre-processing phase takes our code and processes the "pre-processor directives" in our code. This can include processing and resolving things like Macros and Includes. In our code this will be the processing of the "#include<stdio.h>" statement. The processing for the include statement means the compiler takes the contents from the stdio.h header file and inserts the code to be adjacent to our code and produces an intermediate file. which is also a C program but with the pre-processor statement resolved.
The next phase is to take our code from this intermediate C program and transform it into an assembly program. This phase will transform your C code to the Assembly language for the computer running the compiler or the specified target architecture. In my case the code will be compiled into X86_64 assembly language luckily this is the only assembly I learned ;)
Our code after going through the compiler looks like this:
.file "hello_world.c" .text .section .rodata .LC0: .string "hello world" .text .globl main .type main, @function main: .LFB0: .cfi_startproc endbr64 pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 leaq .LC0(%rip), %rdi call puts@PLT movl $0, %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0" .section .note.GNU-stack,"",@progbits .section .note.gnu.property,"a" .align 8 .long 1f - 0f .long 4f - 1f .long 5 0: .string "GNU" 1: .align 8 .long 0xc0000002 .long 3f - 2f 2: .long 0x3 3: .align 8 4:
To understand the code above we need to a super quick dive into X86_64 assembly.
We are going to cover the following concepts:
Registers are small storage locations within the processor. Data can be loaded into registers, read from the register, and removed. The processor can use the data in the registers to perform operations. X86_64 instruction set architecture systems come with multiple types of registers, some used by the processor for managing the systems state and configuration, and some meant to be General Purpose Registers that can be used for storing temporary data and managing our control flow by the processor. We will only be using General Purpose registers in our code. Access to data in registers is much faster than any other location (stack, heap, network).
Stack is a small region of memory assigned on your RAM (Random Access Memory) for storing data, and managing function calls. Stack follows the data structure...."Stack" (LIFO queue). Unlike registers, stack grows and shrinks in size as functions get called, and return. In x86_64 architecture, the stack grows downward in memory, meaning that it starts at a higher address and moves towards lower addresses. The stack pointer (%rsp register [General Purpose Register]) keeps track of the top of the stack, pointing to the most recently pushed item. We use the stack for storing local variables in a function, saving and storing register values between function calls, storing intermediate results and maintaining function call order. We can manipulate what is added and removed from the stack using the instructions push and pop that are understood by the processor.
Labels are defined with the syntax:
mylabel:
They are used to create reference points in your assembly code, so that you can then refer to the isntruction or data followed by the label later on in the code.
Directives are instructions to the assembler or compiler that provide additional information about the program's structure, memory allocation, or other directives to control the assembly process. They have the following syntax:
.directive_name
Instructions in x86_64 refer to the individual operations that the processor can perform. They are the fundamental building blocks of programs written in assembly language for x86_64 architecture. Each instruction represents a specific operation, such as arithmetic, logic, data movement, control flow, or input/output.
The code starts of with a directive indicating the file this assembly was generated from. Then another directive marks the following characters as "text" this means this is where the executable instructions reside. Then, the .section directive is used to declare a small part in our code as read-only data. This is where we create a label for our read only data, the string "hello world", we then continue on with our "text" section. Here we declare "main" symbol as global and define its type to be a function.
We then move to the implementation of our main symbol, this is the entry-point to X86_64 program as written by the C compiler. We then have another label marking the beginning of our main function, and a directiive ".cfi_startproc" and "endbr64" which can both be ignored because they are used for debugging metadata and stack unwinding, and a branch protection for a vulnerability. The line "pushq %rbp" is truly where the code execution logic starts from. We save the current value of our stack pointer, and essentially create a new stack frame so that we can call another function while maintaining the state of our registers and stack before the call. The next line "leaq .LCO(%rip), %rdi" loads the (loads the effective address of the LCO label relative to instruction pointer into the rdi register).
We then call an external function called puts that uses the value in rdi as a parameter, post execution of that function we move the 0 value as 32 bits into the eax register which is used as the final returned status. We then return from the function. The code below the return is used to create metadata on the assem
bly code, that we can ignore for now.
Now we move from Assembly To Relocatable Object Program by the Assembler. In this step the intermediate program goes from being in human readable text to binary format. The assembler resolves symbolic references, and generates metadata required for linking.
The Relocatable Object Program can now be linked with other object files when running the object file through linker to an executable program. The linker resolves references between different object files, and creates the final executable. This executable can be now invoked from the shell and executed. This marks the end of our compilation phase.
To understand the execution we need to discuss the different components at play here:
The execution of our executable starts by us using the Shell via Bash in terminal. The IO bus is used to interface with the Disk, Display screen, Main Memory, and the CPU.
When you enter the name of the executable in your Shell, if the worst word of the command line instruction is not a built-in Shell instruction, Shell assumes that it is the name of an executable file that should be loaded and ran. When we type "./hello_world", the Shell executes a sequence of instructions to load the data from the disk into Main Memory use the IO bus (lookup up Direct Memory Access/DMA). After the code and the data ("hello world") are loaded in memory, the processor now begins executing machine language instructions, this involves the loading of data from Main Memory into the Registers, and from there using the IO Bus to get the data to the Display for you to see. How the OS orchestrates the hardware to do this will be its own follow up article.
In our code we never really wrote any of the logic for sending "words" over the IO bus, or even loading things from disk into main memory, we just wrote data, and called an instruction to display, and all the hardware that makes our computer system dances to our wishes.
The potion for this magic is the Operating System, our code uses the abstractions of Processes, Virtual Memory, and Files provided by the OS to achieve our goals. We will go over each of those abstractions is more details in the following articles in this series.
The magic of Compilers and Operating System is truly the embodiment of "Standing on the shoulder of giants", it is my hope that in the following articles here on, each time we can learn a little more about the giants we stand on top of, and maybe one day we can build newer giants ourselves.
Amazing read.. looking forward to the next one
Excellent work Hamdaan. Writing a ‘Hello Word’ program has always been a programmer’s delight but the way your have the explained what goes behind the ‘scenes’ will help realise that not everything is as simple as it may look🙂