If you’re anything like me, at some point on your journey toward becoming a programmer, as you’re finding your feet and gaining some real fluency in Ruby, it will strike you - you still have no idea why what you’re doing works. You’re writing words, somewhat English-like ones at that. You may have a vague sense that it all gets turned into 1s and 0s for the computer somewhere. The process is a total black box. I got curious and sought out a high-level, not terribly technical explanation of what was going on inside my computer when I hit run-program, which I will now pass on to you.
Higher level languages like Ruby and nearly any other language that you will be called upon to use, are created by people, for people. They are meant to be easy to read (even if it doesn't always feel that way). In order for a computer to execute commands written in these languages, your programs must undergo several layers of translation into those 1s and 0s that we all know and love.
But I think it’s worth asking right off the bat, "Why 1s and 0s?" How does the computer parse even that? It turns out that there’s a fairly simple explanation.
You’ve probably been using Boolean values in your code for a while now, and you may remember learning about truth tables in your high school geometry class. For a quick refresher, Boolean algebra uses truth values instead of numbers to perform various operations. Instead of using operators like
- and values like
3, as in regular algebra, Boolean algebra uses operators like
OR and values like
False. So an example of a Boolean expression, similar to
1 + 2 = 3, would be
True AND True is True.
Boolean algebra is useful in part because it allows for the evaluation of complex ideas, such as ‘I am a human’ and ‘I am purple’ - clearly one of these two statements is false and so, taken as a unit (‘I am a human and I am purple’) the entire sentence is false. The truth tables for
OR are as follows below and if you think about it, they’re just a formal version of the basic logic that you use in daily life.
|a||b||a AND b|
|a||b||a OR b|
Now how is this relevant to 1s and 0s? Well, 1s and 0s are a convenient way to represent a universe with only two values,
False. So let’s say that 0 means
False and 1 means
True in our Boolean world. So this is the exact same thing as the truth tables above, represented differently:
|a||b||a AND b|
|a||b||a AND b|
Okay, so we have our 1s and 0s. As it happens, it’s not too difficult to physically represent the
OR operations. You can actually build machines, or in the case of modern computers, circuits, that behave like
OR operators. An
AND machine might have two buttons that, when pushed, will set a third button into the ‘pushed’ state - thus modeling the
True AND True equal True statement of Boolean algebra. This is how, say, a crazy-dedicated Minecraft player is able create an entire rudimentary in-game computer from the objects available in that world (true story!). These machines or circuits are called Logic Gates.
Bear with me a little longer; now we’re getting somewhere. Binary is particularly handy because, in addition to doing Boolean algebra with it, you can do basic math. You’ve probably heard that that any number can be represented in binary, but you may not have had it explained how. The way we ordinarily count, using the numbers 0-9, is what's called a base-ten system. This means that we have ten unique characters that represent numbers and if we want to represent a number that’s any bigger than 9, we need to carry a digit to the next column (the tens-place, if we’re back in elementary school) and reset the first column (the units-place) to zero.
Binary works the same way, but instead of being base-ten, it’s base-two and thus only has two unique characters available to represent all numbers. So, where in base-ten, three can easily be represented as the unique character '3', in binary, we would already need to carry one over and represent it as 11. Below, for comparison, are tables explaining how the representation for the number 231 works in base-ten alongside the representation for the same number in binary.
So: (2 * 102) + (3 * 101) + (1 * 100) = 231
And: (1 * 27) + (1 * 26) + (1 * 25) + (0 * 24) + (0 * 23) + (1 * 22) + (1 * 21) + (1 * 20) = 231
Binary numbers can be added and subtracted much as in regular addition, only in this case:
|Binary Addition Rules|
|1 + 0 = 1|
|0 + 1 = 1|
|1 + 1 = 10|
So at this point, we have established how Logic Gates can be used to do math and use rudimentary logic. Computer processors work by having millions of these chained together to perform complex operations. The nitty gritty of how all of this happens is fascinating, but more than we need to answer our main question here.
We've established at a basic level why every programming language must eventually be translated down into binary. Hopefully it’s also clear that programming in binary would be terrible. Just imagine a keyboard with only a one and a zero key!
You might have heard of Assembly Code, which is a progamming language that uses mnemonic codes to stand in for a single binary instruction. It’s what’s called a low-level language, meaning it provides minimal abstraction from the native structure of your computer processor. When it’s run, it’s converted by a program called an assembler down into executable binary. It’s a decided improvement over writing programs in binary or hex code (a base-16 system sometimes used as another low-level abstraction from binary), but it’s not really a picnic to write in either. Here’s a classic ‘Hello World’ program written in Assembly for a Mac:
; Program can be run with the below commands in the terminal on a mac. Each command ; is explained in the lines above. ; ; nasm is a particular assembler that ships with most current macs: http://www.nasm.us/ ; The nasm argument specifies that you want to use that assembler to assemble your ; code, the -f command lets you chose the output file format, and macho is a ; Unix file format. ; ; nasm -f macho helloworld.asm ; ; nasm is able to produce executable files directly, but that's not what we've done ; here. As such, you need to 'link' the object file you just output, ie turn it ; into proper machine code. The reason for this intermediary step would be to allow ; you to use libraries or multiple files in assembly, and then have the linker turn ; it all into one executable file. ld does the actual linking, and the arguments ; just let it know what kind of system you're on, what file type and file to act ; on, and what the name of the output executable should be. The linker will pick ; a default osx version if that argument is left out. ; ; ld -macosx_version_min 10.7.0 -o helloworld helloworld.o ; ; And finally, we run our executable! ; ./helloworld global start ; Makes program available to linker section .data ; Data section of program message db "Hullo, World!" ; Setting message variable to the string we want to print section .text ; Indicates code section of program is below start: ; section that deals with printing our string push dword 13 ; Pushes the length of the string onto the stack. ; When you execute a program, a chunk of memory that's all ; physically contiguous is set aside to process the ; program. This is called the stack. push dword message ; Pushes the string onto the stack push dword 1 ; Pushes the file descriptor - in this case 1, which is ; standard output - onto the stack. mov eax,0x4 ; Now that I've prepared the arguments, this is the system ; call number to write them. eax is a register ; (small chunk of storage space on your computer's ; central processing unit), that is 'general purpose' and ; thus can be used freely in your program. sub esp,0x4 ; sub is the command for integer subtraction. It subtracts ; the value of the second operand (0x4 ie. 4 bytes in this ; case) from the first operand, and stores the result in the ; first operand, sort of like 'variable -= 4' in Ruby. We've ; been sneaky here, and esp is the stack pointer. There are ; some complicated concepts rolled up in that, but broadly, ; pointers are like addresses in memory of where data ; is stored. The stack pointer is the smallest address in ; memory that is a legitimate part of the program. Here, ; I've set it down 4 more bytes to make room for another 4 ; byte variable on the stack. OSX needs this extra space. int 0x80 ; This is a system call that lets the operating system know ; that an event has occurred. All processes start out in user ; mode, which has limited privileges, and this allows access ; to admin privileges, including the various powers of the ; operating system such as, in our case, producing output. add esp,16 ; This time we're /adding/ 16 bytes to the stack! This is ; cleaning up our program out of memory, letting the computer ; know that everything below this point is garbage and the ; space can be used by other programs after this. ; exiting mov eax,0x1 ; This is the system call number for exit. int 0x80 ; Letting the system know an event has occurred, thus ; triggering the exit
Note that I specified this code example was written for a Mac - the physical layout of your computer's Logic Gates matter when it comes to writing Assembly code. In fact, the first computers were constructed for specific purposes and could perform only the task for which they had been designed and built. General-purpose computers were in and of themselves a technical achievement. The x86 processor architecture that Macs use is based on a central processing unit (CPU) that Intel put out in 1978. That CPU represented another important step toward computers as you know them today, as it allowed the processing power of 'business computers' of the day to coexist with features of 'home computers' such as color graphics and sound. It also allowed for Assembly programs written for one processor in the x86 family to be used on another. All in all, these features along with IBM's prominence as a company helped x86 family processors dominate the personal computer market.
Comparing that 13 line program in Assembly where I spent half my time managing the stack to Ruby’s 1 line
puts “Hello, world!” must make the appeal of higher level languages immediately obvious. If we can turn Assembly into binary via assembler, why not make something that can do the same thing with much more intuitive, readable instructions? Hence, high-level programming languages, chock-full of abstraction, were born.
This is far from a comprehensive explanation of how computers or programming languages work, but I hope it serves as a satisfying overview for beginners and makes what actually happens when you execute a program seem a little less mystical.
This article is deeply indebted to Vikram Chandra’s Geek Sublime. It’s not strictly a technical book, but I’d highly recommend it for its easy-to-understand explanations of fundamental computer concepts and analysis of programming’s relationship to language.