Assembly

Published on: Sat Sep 09 2023

Assembly is a family of low-level programming languages that generally look like this:

; find an integer that is close to the square root of edi find_integer_square_root: mov ax, 0 guess_a_root: inc ax mov cx, ax mul cx, ax cmp cx, di jb guess_a_root ret

You may have heard that computers think in binary, which is true. They only understand 1s and 0s, so the programs that computers read are long sequences of just that These binary instructions are called machine code and in the early days of programming - people had to create programs in 1s and 0s using punch-cards, weaving wires through copper loops or typing them in on a keyboard. As you can imagine - such a process is very tedious and mistakes are difficult to spot or correct. So something more human-friendly was invented - assembly languages. The name stems from the fact that these programs can’t be executed by computers directly, rather they need to be assembled into machine code first. Perhaps a more accurate term would be assemblee language but history chose otherwise.

And because there are many different processor architectures with different machine code - for the most part each one requires its own assembly code. The example I chose is x86, which is extremely widespread - servers, desktops, most laptops and even consoles run some version of x86. There is a myriad of other architectures used in various applications, however once you understand this piece of code - you will be able to understand the gist of any assembly language.

Cool, but that doesn’t explain why supposedly human-friendly instructions look like collections of random letters to a lay person. And the reason is that the assembly is very closely related to the machine code it is assembled into. Think of translation services, when you use them to translate a single word or a phrase - you typically get an excellent translation. But try to translate whole sentences, paragraphs and texts - the results are quite often imperfect. Same is true with programming languages. The more translation that needs to happen - the more difficult it is to make good translations and ensure the computer does exactly what we think it will do.

I promised that you will be able to read the piece of code the blog started with, so with the history out of the way - let’s get to it. And thankfully our example starts with something simple - a comment: ; find an integer that is close to the square root of edi. The semi-colon is a marker that the following text does not represent instructions to be executed by the computer.

Next line find_integer_square_root: however is a little more complex. It is a label. You might think it’s a title of our program. But to an assembler - it’s a marker that it needs to remember that specific place in the program. Why would it need to remember it? Well we might want to find the square root of several numbers in our program and it would become tedious to copy the same instructions all over the place and computers are able to jump to a specific place in the machine code, so we could jump to our square-root routine, execute it and then return to whatever the bigger program was doing. The label tells the assembler we might want to jump to the following instruction at some other point.

mov ax, 0 is the first instruction of our program! It tells the computer to move the value 0 into register ax. A register is a very small amount of very fast memory that computers use to perform their calculations. Think of it like a cutting board in the kitchen. While cooking you might move ingredients out of the fridge onto the cutting board before slicing them. Computers do the same - load data from memory or external devices into their registers before performing calculations on those values. Because there might be some other value in the register ax - we set it to 0 before calculating the square root.

x86 has a bunch of varying registers, the ones we are interested in are general purpose ones: AX - accumulator register, CX - counter register and DI - destination index. The names hint at their intended use, however you can treat them as odd variable names in our program.

guess_a_root: is another label that we will use shortly. For now let’s move onto inc ax. This instruction tells the computer to increment (add 1) to the value in register ax.

The increment is followed by mov cx, ax. It’s another move instruction, this time we are moving the value from register ax into cx. Note that move is perhaps a misnomer here, as the value is copied from the source. That is because it’s physically simpler to copy 1s and 0s and then remove them than to actively move them around. Another perhaps counter-intuitive quirk of many assembly languages is that the destination of an operation comes first. In English we say “move from A to B”, however it’s simpler to design hardware that prefers “move into B from A” and as assembly is very close to machine language - it mirrors that design.

So far it’s not clear how incrementing and copying some values helps us find the square root of a number. The next operation might give us a clue: mul cx, ax. Here we tell the computer to multiply the values in cx and ax together and store the result in cx (yes, we use the first register as both source and destination of data). And because we just copied the value from ax - effectively we squared whatever value was there (so far it should be 1). Aha! So to find the square root looks like our program squares consecutive integers until it finds the first one that is larger than our number.

cmp cx, di might make sense now, if you’re following along. We are comparing the values in cx and di registers. cx contains the square of our guess and edi therefore must be the number we want to find the square root of. But where do we store the result of our comparison? You might think it is stored in cx, as it’s the first register in our instruction and so far that has always been the destination of any operation. And some computers might work like that, but the engineers designing x86 realized that quite often we want to keep our compared values in registers. So what happens in our case is the result of the comparison is remembered by the computer in a different special register. Just keep it in mind for now.

jb guess_a_root is referencing a label. And it immediately follows a comparison operation… If we put the two together - we might come to the correct conclusion that we tell the computer to go back to guess_a_root if the square of our guessed root value is smaller than the argument to our function. The instruction in question is called jump if below. The jump part stems from the fact that we are skipping to some other place in our program instead of following along. And below should be self-explanatory: only perform the jump if our compared value is below (smaller than) the second value. This instruction is using the remembered result of comparison in the special register that was just mentioned.

Finally we come to the ret instruction. This one tells the computer that our square-root function is done and the computer should return to whatever it was doing before trying to find the square-root of the value in edi.

Putting it all together our program does the following:

  1. Set the guess to 0
  2. Increment a guessed square root by 1
  3. Square the guess
  4. Check if the square of the guess is smaller or equal to the argument
  5. Go back to 2 if it is
  6. Return with our best match for the square root of the integer in ecx

So if we expand our program, we can find the square root of any number by doing the following:

mov     di, 8
call    find_integer_square_root
; the square root of whatever was in di is now in cx

Note the call find_integer_square_root. This instruction is telling the computer to remember where it is right now so that it knows where to return to and start executing instructions under the find_integer_square_root label.

You might be wondering whether assembly is still useful in modern day and age when we have much more ergonomic ways of programming. And it is! A prime example is performance fine-tuning: looking at compiled assembly to see where the CPU might be wasting time with slow operations and perhaps manually tweaking it to squeeze those last few percentages of improvement! Another good example is in cryptography. If the computer uses different amount of energy or time to check a password - there is a potential vulnerability where the attacker can measure how much energy or time was spent to get what the password was! To avoid such possibility - someone can look through assembly and ensure the calculation always takes the exact same path with any input.