By Petter Holmberg
It's the end of the series as we know it
Hello and welcome to the sixth part of my assembly tutorial series. This part is also the very last one. No!!! I hear you screaming ;-) But the reason for this is basically that I have covered almost all of the important aspects of assembly programming. There's more to learn- there's always more to learn, but I think you'll have more use of other documents from now on. This series has been concentrating on the general aspects of asm programming. Now you are ready to start exploring stuff that interests you in particular. Maybe you want to learn more about interrupts, or you may be interested only in I/O port programming. There are lots of good documents to download about every aspect of assembly programming. It's not necessary for me to explain everything.
So, what should this last part be about? For the first time in this series, it hasn't been easy for me to decide. Therefore, this part will cover some miscellaneous stuff, mostly about some of the general aspects of assembly programming. I'll try to share my experiences of asm programming with you so you don't have to all the mistakes I've done.
Numbers in assembler:
We begin by looking at positive integer values, i.e. the numbers 0,1,2,..,n: Positive integer values are the easiest ones to store. When you need to use a positive integer value in your asm code, the first thing you want to ask is: How high numbers do I need to store? It's easy to select the number of bits that needs to be used for storing a certain value. If you use n bits, the biggest number that can be stored is 2^n - 1, so if you use 8 bits, you can store any number from 0 up to 2^8 - 1 = 255. With 16 bits you can store any number up to 65535 and so forth. If you only need to use a number smaller than 100, you shouldn't use 16 bits to store it as 8 bits are enough.
The different bits in a binary number are numbered from right to left. The rightmost bit is called the least significant bit and it has the bit number 0. The leftmost bit is called the most significant bit. If it's an 8-bit numbered this bit is bit no. 7.
If you want to calculate what number you get if you set a certain bit to 1, you can calculate this with the following formula:
n = 2^b
where n is the number you want to know and b is the number of the bit. So if you set bit 5 to one, the number you get is 2^5 = 32.
Numbers that can only be positive are called unsigned values. A signed value is a value that can be both positive and negative. It's called signed numbers because the most significant bit of such numbers tell the sign of the number. If we have a positive value, the most significant bit is 0, but if we have a negative value, the most significant bit is 1. So 0 means + and 1 means -. This means that a signed number needs one extra bit just to store the sign of the number, so we get a lower maximum value with a certain amount of bits if we use signed numbers than an unsigned number. The biggest signed number that can be stored with n bits is 2^(n-1) - 1. So if we have an 8-bit signed number, it can have the maximum value of 2^(8-1) - 1 = 127.
The smallest signed number that can be stored with n bits is -(2^(n-1)), so with 8 bits, you can store any negative integer down to -(2^(8-1)) = -128. Thus, an 8-bit signed number can store all interger values from -128 to 127. Positive values are stored in the same way no matter if the number is signed or unsigned, but negative values are stored in an interesting form: The most significant bit is set to 1 to indicate it's a negative number, and the rest of the bits represents the maximum possible value with those bits plus one minus the absolute value of the negative number plus one. So if you have the 8-bit binary number -10, the most significant bit will be a 1 to indicate it's a negative number, and the rest of the bits will represent the number 127 + 1 - 10 = 118. This form of storing positive and negative numbers is called the two's complement.
If you want to convert a number from positive to negative or negative to positive, you could use the SUB instruction to subtract a value from 0, like this:
SUB AX, 0
This would work fine for all numbers. But you could also use the special assembly instruction NEG. NEG works just in the same way, but it's faster, takes less place in the memory and it's easier to understand what happens when you see a NEG instruction than if you see a SUB instruction. The syntax for NEG is simply:
The destination operand can be either a register or a memory pointer. So if you have the number -10 in AL and execute the instruction NEG AL, AL will become 10.
Sometimes it's necessary to convert an 8-bit number to a 16-bit number or a 16-bit number to a 32-bit number. This is easy if you have a positive number. For example: If you have a positive integer in AL and you want that number to be treated as a 16-bit value in AX, you only need to make sure AH is 0. But what about negative numbers then? How do you convert a number in the two's complement form upwards? Luckily, there are two instructions that does this for you. They're called CBW and CWD, short for Convert Byte to Word and Convert Word to Doubleword. Their syntaxes are:
Note that they don't have any input/output operands. That's because these instructions only works with the AX register. If you have an 8-bit number that you want to convert to a 16-bit number, you should put it in AL, execute a CBW instruction, and you'll get the correct 16-bit value in AX, no matter if it's a positive or a negative integer you've converted.
If you want to convert a 16-bit value to 32 bits, you should put it in AX and execute a CWD instruction. The result will be stored in DX:AX.
When to create an assembly routine and how to do it:
The speed issue is the most common reason for using assembly code. It's a well known fact that in an average program, 90% of the execution time is used to execute 10% of the code. By rewriting those 10% of the code into assembler, 90% of the time, your program will run faster. It's important to learn how to find those 10% of source code. Usually, it's something that is repeatedly called by the main program loop and involves a lot of calculations. If you manage to pinpoint the part of your program that slows it down the most, you should consider rewriting it in assembler.
It's also a good idea to use assembler when you need to do something that is hard to handle in QuickBASIC. One example of this was the keyboard handling example in the previous part of this series. The keyboard functions in QuickBASIC doesn't let you control the keyboard at the level we often need for computer games, but with a little assembly, we get all the control we want.
When you know what you want to rewrite in assembler, you may think it's going to be easy to implement your idea into a working routine, but you may be surprised at how hard it is to write the routine. Transforming your ideas directly to assembler can be very hard because you have to take so many technical issues into account. You may find that you run out of registers, that you start writing really unstructured code or that you simply don't know how to do some things in assembly.
My experience in assembly programming has told me that the best way to go is
When you have your pseudo-version finished, start transforming it into a working version. Change variable names into registers, use the correct code to get a number from the memory and so forth. Put in even more comments, as the code will get harder to understand. The commenting should be so extensive that you can tell what each line of assembly code does just from looking at the comments. Divide the source into separate parts by using spacing and commenting, so that you can isolate the different parts of the routine just by looking at the layout of the source code. Even if you know what you're doing right now, you may have forgotten it after a few days. Looking at your own asm source without understanding it is really frustrating. DO NOT try to optimize your assembly code yet, try to make it work first. You will probably have a lot of bugs when you first run your routine, and you must fix them all before continuing.
Now when you have a working assembly routine, you may want to try optimizing it even further by using smart assembly code. This should be the final step in the creation of your routine. If you start optimizing right away, you're risking to lose control over your coding, but if you have a working routine without optimizations to look back on, you will still know what you're doing and you know that your routine works even if you don't manage to optimize it so much.
Make sure your comments still explains what the routine does. Your optimized code will be harder to understand than the original version, so you need to watch out.
If you follow these steps, you stand a good chance of getting a really well- written and terribly fast assembly routine!
Optimizing your assembly code:
Optimizing assembly code is a whole science itself, and I could probably write
another tutorial series as long as this one just about asm optimization if I
knew enough about it. There's so much to say about assembly optimization that
I can only cover some of the basics here.
Keep in mind though, that because your assembly code is so fast from the beginning compared to BASIC code, it's often unnecessary to mind about optimizing it. If your routine runs adequately fast for your program, it's better to leave it unoptimized since it's more readable that way. But it can also be really important to optimize your assembly code because it runs so often. Concentrate on optimizing loops and other assembly code that runs frequently. If you don't have room for all your variables within the registers, use the stack or the memory for the values you use the least, and save the free registers for the code inside loops.
MOV CX, AX ; Store value of AX temporarily in CX
If you need to do this and you have no free register, the only solution seems to be to push a value to free a register, but there's a better way. You can use the instruction XCHG, short for eXCHanGe, to do it. The syntax for XCHG is:
XCHG destination, source
Where the source and destination operands can be registers or memory pointers. However, the source and the destination cannot both be memory pointers at the same time. With XCHG you won't need a temporary register, and thus the stack doesn't need to be used either.
Earlier parts of Petter Holmberg's assembly series can be found in the Archive. They are in issues 4 thru 8.
Remove your jumps!
MOV CL, 4
But what's actually happening when the computer executes this code? Well, this is how the computer sees it:
Set CL to 4
Now suppose that we rewrote the code into this:
When this code is executed, this is what the computer does:
As you can see, a loop isn't always the best choice. Replacing loops with repeated code is called unrolling. If the loop is executed so many times that it's not practical to unroll it completely, you can do a partial unrolling. For example, if you have a loop that executes 80 times, you can change it to a loop that executes 10 times with 8 copies of the loop code inside it.
It's not only certain instructions that makes an assembly routine slow: Wait states, bus transfers, the prefetch queue and dynamic RAM refreshes can also steal time, and avoiding these bad guys can be really hard.
For every new CPU that's released, assembly optimization becomes less important, not only because the processors get faster and faster, but also because they execute time consuming assembly instructions more efficiently. A MUL instruction for example, isn't as slow compared to an ADD instruction in a Pentium as it was on the 80286. On the other hand, the Pentium can execute several assembly instructions simultaneously if they fulfill certain requirements, so it's possible to optimize asm code for a Pentium by using these new features.
First of all, "execute" the code in your head and try to see what the routine is actually doing. Write it down on paper and you may discover that you're doing something wrong.
If you still have problems finding the errors, try to execute only a part of your program. Take the first snippet of code, insert a JMP to the end of the routine and return the values that you're working with to the BASIC program so you can look at them and see if they're correct so far. Then, move down the JMP instruction a couple of lines and see if everything still is correct. If you continue doing this, you will eventually find out where the computer hangs and where the error is.
Keep in mind that some assembly instructions doesn't work with certain combinations of registers, memory pointers and direct values. if DEBUG won't accept an instruction that may seem correct, it may be because you're using a combination of input/output values that cannot be handled by that instruction.
Also, make sure the call to and return from the assembly routine is done correctly. Many times, I've struggled to find the errors in completely correct assembly code that won't run, just to find out that the error was in the CALL ABSOLUTE line in QuickBASIC. Check this before you start searching for errors in the assembly code.
There's an error in CALL ABSOLUTE assembly routines that's so common it's important that you know about it. It's very nasty because you don't have to write a single line incorrectly to get this error. It has to do with the DS register. Be VERY careful when you set the DS register. You already know that it's important to push the value of DS before you change it and POP it back at the end of your program, but there's another thing you have to know about DS. When you set it to another value than the original you won't be able to fetch values passed from QuickBASIC. Consider the following code snippet:
PUSH DS ; Preserve DS
Even if this seems correct, AX won't get the value of a QB variable. The value it will recieve is the value at DS:BP+8, which isn't the correct address. You must make sure DS points to the segment address it points to from the beginning if you want to read from or write to QB variables.
Even though it doesn't do anything, it takes up a little amount of time. Actually, NOP is equivalent to XCHG AL, AL, which, of course, doesn't change anyhing.
It may seem silly to have such an instruction in the assembly language, but there's actually some use for it sometimes, for example if you want to leave some empty space in a program for data to be initialized later on. The NOP instruction occupies one byte in the memory.
And that was the final thing I had to say. I hope you've enjoyed this tutorial series and that you've managed to understand everything. Thanks to everyone who's sent me emails with comments on and suggestions for this series. If you want to learn more about assembly programming, there's LOTS of good info to find on the Internet. For example, Check out:
I thought I could finish with a list of all the assembly instructions we've covered in this series. This list divides them into groups based on what they do and explains their different functions. Try to tell what they mean before looking at the explanations and see if you know them all!
Good luck with your assembly programming!
List of assembly instructions:
Back to Top