Anyone having even a passing acquaintance with IT will have come across the idea that computers operate in a world of ones and zeros – often referred to as “binary”. It’s a fairly common aspect of popular culture, too, with its own memes and T-shirts. But just how can meaningful information be rendered into a series of ones and zeros in the first place? In this post, we will look at the architecture of a computer, how programming languages are translated and – ultimately – how everything can be captured by a computer in binary.
To fully grasp how a computer operates in binary, it is important to have a cursory understanding of its physical components. It will help to take things back in time to the 1980s; not just because the computers of that era are a little easier to understand, but also because I‘m a retro gaming enthusiast at heart. A typical 80s computer (e.g. the BBC Micro or the Commodore 64) has a single circuit board connecting together the microprocessor (a single-chip implementation of what is known as a CPU), the memory and a variety of input (e.g. keyboard) and output (e.g. monitor) ports. In order for the computer to do anything at all, pulses of electricity need to flow through wires (or ‘signal traces’) to and from the microprocessor. The microprocessor itself has multiple ‘pins’, each of which is connected to a wire, meaning that it can receive and transmit multiple combinations of electric pulses at once. In computing terms, we refer to each individual wire as a ‘bit’; each bit can be in a state of on or off, which we translate to 1 or 0 respectively. Thus, when we say that a computer deals in ones and zeros, we literally mean that at a physical level, it is operated by wires (bits) transmitting (1) or not transmitting (0) electric pulses.
Computers and arcade machines from the 1980s are built on something called “8-bit architecture”, meaning that the microprocessor has eight wires connected to something called a ‘data bus’, which in turn has eight wires connecting it to the computer’s memory. The available memory is spread out across 256 ‘pages’, with each page having 256 locations, giving a total of 2562 (65,536) memory addresses. Since the microprocessor has just eight wires connecting it to memory via the data bus, it can either write to or read from memory in units of eight bits, i.e. a combination of eight ones or zeros, ranging from 00000000 to 11111111, referred to as a ‘byte’. In other words, the microprocessor can handle one of 28 (256) different bytes before moving on to the next. Each byte represents a particular instruction, data value or memory address. A meaningful combination of bytes, in sequence, is called ‘machine code’. Machine code processed by the microprocessor will usually result in other instructions that are transmitted via an ‘address bus’ to output ports that are connected to things like the monitor or the speaker in order to light a pixel on the screen or make a sound at a particular pitch and frequency. Bearing in mind that the average clock speed for a 1980s microprocessor is around 1 megahertz (MHz), that means it is capable of processing roughly 1 million bytes per second. This speed, incidentally, is what allows for pixels to be lit and then unlit at different points on the screen so quickly that, to the human eye, it is perceived as animation.
Up to now, we’ve only looked at how the computer internally transmits information in binary; but how do we get it to do what we want? Well, one method might be to write out instructions in machine code. Indeed, that is effectively what electronic engineering consists of: a series of on/off commands that instruct a circuit how to behave. However, at the computing level, it is neither efficient nor desirable to write even simple programs in machine code. You need only think about how binary numbers work to appreciate how cumbersome they would be to use in practice. For example, if I want to tell the computer to access a particular value held in memory, and I know that the address of that value is on the 254th page at location 106, just the memory address in binary would be encoded as 11111110 01101010, and that’s before specifying anything about what I want to do with that memory location! The human brain simply isn’t geared to working with long, endless strings of ones and zeros. The first step, then, is to simplify the binary notation into something a little less unwieldy. We can do this by translating bytes into something called ‘hexadecimal’ (or just hex for short). First we split the byte into two chunks of four digits, each of which can have only 16 permutations (0000 to 1111). We then designate the first ten permutations as numbers 0 to 9 and the remaining six permutations as letters A to F. So, 0000 becomes ‘0’, 0001 becomes ‘1’, 0010 becomes ‘2’ etc. until we reach 1010 which becomes ‘A’, 1011 which becomes ‘B’ etc. until we get to 1111 which becomes ‘F’. Going back to our memory address example, the first byte (11111110) which is the binary value for memory page 254 can instead be labelled as ‘FE’ and the second byte (01101010) for the memory page location 106 we can call ‘6A’. (NB: hex values are often prefixed by ‘&’ or ‘$’ to differentiate them from actual alphanumeric strings.) Although these are still fairly meaningless symbols from a human perspective, hex significantly cuts down the length of the strings being dealt with while sufficiently increasing the variety to avoid very different values looking too similar to each other.
Translating binary notation into hex is the first step on the journey away from machine code and into something called ‘assembly language’ (known as low-level programming). This differs from high-level programming (e.g. languages like BASIC, Python, C etc.) in that it maps directly to machine code. That is, one instruction in assembly language equates to one instruction in machine code. The only difference is that a program written in assembly language is mediated by another program called an assembler which converts the (slightly) more human-readable assembly language into machine code. Since the assembly language instructions are 1:1 with machine code instructions, there is no need for an interpreter or a compiler. A single line of a high-level programming language, meanwhile, typically equates to multiple lines of assembly language. High-level programming languages must therefore be compiled (i.e. be converted into assembly language) prior to execution or interpreted by an interpreter at runtime (i.e. as the program is executed, an interpreter program mediates and translates the code). Assembly language is specific to the microprocessor for which it is written; if you write a program for the MOS 6502 microprocessor in assembly language, it will only run on that chip and it is not portable to any other system. A program written in something like Python, however, can be run on any number of different systems regardless of the microprocessor. This is because the code itself is independent of the assembly language or the machine code. The compiler works out how to translate the Python code into appropriate instructions for the particular system it is running on; the programmer need not worry about it.
Assembly language does not just consist of hex; it is also comprised of a series of ‘mnemonic’ commands that can be more readily understood – at least by comparison to machine code. In 8-bit architecture, a single assembly language command will translate into a single byte of machine code. For example, in 6502 assembly language, the command ‘LDA’ (which stands for ‘load the accumulator’) is represented by the byte ‘A9’ (binary: 10101001) and is an instruction typically followed by a data value. When the assembler sees the command ‘LDA’ it knows to convert it into the byte ‘A9’; the microprocessor, in turn, knows to interpret ‘A9’ as an instruction to load a value into the accumulator (a temporary storage area, or ‘register’, within the microprocessor itself). What value it should load will be given by the next byte in sequence. Often, the ‘LDA’ command is followed by the ‘STA’ command (‘store a copy of the accumulator’) and a memory address, in order to pass the value held in the accumulator into an area of memory. Thus, a simple BASIC programming command to store the value of ’42’ in a named memory location:
…in 6502 assembly language becomes…
LOC1 = &0DFF LDA #42 STA LOC1
Notice that there are three instructions in assembly language for a single BASIC command. The BASIC programmer doesn’t need to worry about where the memory address of ‘LOC1′ will be, nor do they need to first put the value of ’42’ into the accumulator before storing it in ‘LOC1’; indeed, the accumulator is invisible to them. The BASIC interpreter would break down the single command into the relevant assembly language steps and then pass these to the assembler at runtime, which in turn would translate it into machine code so that the microprocessor knows how to act.
Thus, what started out as a single line of high-level programming language that we understand will still ultimately end up as something the computer can understand: nothing more than a series of ones and zeros.