3.9 Signed and Unsigned Numbers
So far, we've treated binary numbers as unsigned values. The binary number ...00000 represents zero, ...00001 represents one, ...00010 represents two, and so on toward infinity. What about negative numbers? Signed values have been tossed around in previous sections and we've mentioned the two's complement numbering system, but we haven't discussed how to represent negative numbers using the binary numbering system. That is what this section is all about!
To represent signed numbers using the binary numbering system we have to place a restriction on our numbers: they must have a finite and fixed number of bits. For our purposes, we're going to severely limit the number of bits to eight, 16, 32, or some other small number of bits.
With a fixed number of bits we can only represent a certain number of objects. For example, with eight bits we can only represent 256 different values. Negative values are objects in their own right, just like positive numbers; therefore, we'll have to use some of the 256 different eight-bit values to represent negative numbers. In other words, we've got to use up some of the (unsigned) positive numbers to represent negative numbers. To make things fair, we'll assign half of the possible combinations to the negative values and half to the positive values and zero. So we can represent the negative values -128..-1 and the non-negative values 0..127 with a single eight bit byte. With a 16-bit word we can represent values in the range -32,768..+32,767. With a 32-bit double word we can represent values in the range -2,147,483,648..+2,147,483,647. In general, with n bits we can represent the signed values in the range -2n-1 to +2n-1-1.
Okay, so we can represent negative values. Exactly how do we do it? Well, there are many ways, but the 80x86 microprocessor uses the two's complement notation. In the two's complement system, the H.O. bit of a number is a sign bit. If the H.O. bit is zero, the number is positive; if the H.O. bit is one, the number is negative. Examples:
For 16-bit numbers:
$8000 is negative because the H.O. bit is one.
$100 is positive because the H.O. bit is zero.
$7FFF is positive.
$FFFF is negative.
$FFF is positive.
If the H.O. bit is zero, then the number is positive and is stored as a standard binary value. If the H.O. bit is one, then the number is negative and is stored in the two's complement form. To convert a positive number to its negative, two's complement form, you use the following algorithm:
1) Invert all the bits in the number, i.e., apply the logical NOT function.
2) Add one to the inverted result.
For example, to compute the eight-bit equivalent of -5:%0000_0101 Five (in binary). %1111_1010 Invert all the bits. %1111_1011 Add one to obtain result.
If we take minus five and perform the two's complement operation on it, we get our original value, %0000_0101, back again, just as we expect:%1111_1011 Two's complement for -5. %0000_0100 Invert all the bits. %0000_0101 Add one to obtain result (+5).
The following examples provide some positive and negative 16-bit signed values:
$7FFF: +32767, the largest 16-bit positive number.
$8000: -32768, the smallest 16-bit negative number.
To convert the numbers above to their negative counterpart (i.e., to negate them), do the following:$7FFF: %0111_1111_1111_1111 +32,767 %1000_0000_0000_0000 Invert all the bits (8000h) %1000_0000_0000_0001 Add one (8001h or -32,767) 4000h: %0100_0000_0000_0000 16,384 %1011_1111_1111_1111 Invert all the bits ($BFFF) %1100_0000_0000_0000 Add one ($C000 or -16,384) $8000: %1000_0000_0000_0000 -32,768 %0111_1111_1111_1111 Invert all the bits ($7FFF) %1000_0000_0000_0000 Add one (8000h or -32768)
$8000 inverted becomes $7FFF. After adding one we obtain $8000! Wait, what's going on here? -(-32,768) is -32,768? Of course not. But the value +32,768 cannot be represented with a 16-bit signed number, so we cannot negate the smallest negative value.
Why bother with such a miserable numbering system? Why not use the H.O. bit as a sign flag, storing the positive equivalent of the number in the remaining bits? The answer lies in the hardware. As it turns out, negating values is the only tedious job. With the two's complement system, most other operations are as easy as the binary system. For example, suppose you were to perform the addition 5+(-5). The result is zero. Consider what happens when we add these two values in the two's complement system:
% 0000_0101 % 1111_1011 ------------ %1_0000_0000
We end up with a carry into the ninth bit and all other bits are zero. As it turns out, if we ignore the carry out of the H.O. bit, adding two signed values always produces the correct result when using the two's complement numbering system. This means we can use the same hardware for signed and unsigned addition and subtraction. This wouldn't be the case with some other numbering systems.
Except for the questions associated with this chapter, you will not need to perform the two's complement operation by hand. The 80x86 microprocessor provides an instruction, NEG (negate), that performs this operation for you. Furthermore, all the hexadecimal calculators will perform this operation by pressing the change sign key (+/- or CHS). Nevertheless, performing a two's complement by hand is easy, and you should know how to do it.
Once again, you should note that the data represented by a set of binary bits depends entirely on the context. The eight bit binary value %1100_0000 could represent an IBM/ASCII character, it could represent the unsigned decimal value 192, or it could represent the signed decimal value -64. As the programmer, it is your responsibility to use this data consistently.
The 80x86 negate instruction, NEG, uses the same syntax as the NOT instruction; that is, it takes a single destination operand:neg( dest );
This instruction computes "dest = -dest;" and the operand has the same limitations as for NOT (it must be a memory location or a register). NEG operates on byte, word, and dword-sized objects. Of course, since this is a signed integer operation, it only makes sense to operate on signed integer values. The following program demonstrates the two's complement operation by using the NEG instruction:program twosComplement; #include( "stdlib.hhf" ); static PosValue: int8; NegValue: int8; begin twosComplement; stdout.put( "Enter an integer between 0 and 127: " ); stdin.get( PosValue ); stdout.put( nl, "Value in hexadecimal: $" ); stdout.putb( PosValue ); mov( PosValue, al ); not( al ); stdout.put( nl, "Invert all the bits: $", al, nl ); add( 1, al ); stdout.put( "Add one: $", al, nl ); mov( al, NegValue ); stdout.put( "Result in decimal: ", NegValue, nl ); stdout.put ( nl, "Now do the same thing with the NEG instruction: ", nl ); mov( PosValue, al ); neg( al ); mov( al, NegValue ); stdout.put( "Hex result = $", al, nl ); stdout.put( "Decimal result = ", NegValue, nl ); end twosComplement; Program 3.16 The Two's Complement Operation
As you saw in the previous chapters, you use the int8, int16, and int32 data types to reserve storage for signed integer variables. Those chapters also introduced routines like stdout.puti8 and stdin.geti32 that read and write signed integer values. Since this section has made it abundantly clear that you must differentiate signed and unsigned calculations in your programs, you should probably be asking yourself about now "how do I declare and use unsigned integer variables?"
The first part of the question, "how do you declare unsigned integer variables," is the easiest to answer. You simply use the uns8, uns16, and uns32 data types when declaring the variables, for example:static u8: uns8; u16: uns16; u32: uns32;
As for using these unsigned variables, the HLA Standard Library provides a complementary set of input/output routines for reading and displaying unsigned variables. As you can probably guess, these routines include stdout.putu8, stdout.putu16, stdout.putu32, stdout.putu8Size, stdout.putu16Size, stdout.putu32Size, stdin.getu8, stdin.getu16, and stdin.getu32. You use these routines just as you would use their signed integer counterparts except, of course, you get to use the full range of the unsigned values with these routines. The following source code demonstrates unsigned I/O as well as demonstrating what can happen if you mix signed and unsigned operations in the same calculation:program UnsExample; #include( "stdlib.hhf" ); static UnsValue: uns16; begin UnsExample; stdout.put( "Enter an integer between 32,768 and 65,535: " ); stdin.getu16(); mov( ax, UnsValue ); stdout.put ( "You entered ", UnsValue, ". If you treat this as a signed integer, it is " ); stdout.puti16( UnsValue ); stdout.newln(); end UnsExample; Program 3.17 Unsigned I/O
3.10 Sign Extension, Zero Extension, Contraction, and Saturation
Since two's complement format integers have a fixed length, a small problem develops. What happens if you need to convert an eight bit two's complement value to 16 bits? This problem, and its converse (converting a 16 bit value to eight bits) can be accomplished via sign extension and contraction operations. Likewise, the 80x86 works with fixed length values, even when processing unsigned binary numbers. Zero extension lets you convert small unsigned values to larger unsigned values.
Consider the value "-64". The eight bit two's complement value for this number is $C0. The 16-bit equivalent of this number is $FFC0. Now consider the value "+64". The eight and 16 bit versions of this value are $40 and $0040, respectively. The difference between the eight and 16 bit numbers can be described by the rule: "If the number is negative, the H.O. byte of the 16 bit number contains $FF; if the number is positive, the H.O. byte of the 16 bit quantity is zero."
To sign extend a value from some number of bits to a greater number of bits is easy, just copy the sign bit into all the additional bits in the new format. For example, to sign extend an eight bit number to a 16 bit number, simply copy bit seven of the eight bit number into bits 8..15 of the 16 bit number. To sign extend a 16 bit number to a double word, simply copy bit 15 into bits 16..31 of the double word.
You must use sign extension when manipulating signed values of varying lengths. Often you'll need to add a byte quantity to a word quantity. You must sign extend the byte quantity to a word before the operation takes place. Other operations (multiplication and division, in particular) may require a sign extension to 32-bits. You must not sign extend unsigned values.Sign Extension: Eight Bits Sixteen Bits Thirty-two Bits $80 $FF80 $FFFF_FF80 $28 $0028 $0000_0028 $9A $FF9A $FFFF_FF9A $7F $007F $0000_007F --- $1020 $0000_1020 --- $8086 $FFFF_8086
To extend an unsigned byte you must zero extend the value. Zero extension is very easy - just store a zero into the H.O. byte(s) of the larger operand. For example, to zero extend the value $82 to 16-bits you simply add a zero to the H.O. byte yielding $0082.Zero Extension: Eight Bits Sixteen Bits Thirty-two Bits $80 $0080 $0000_0080 $28 $0028 $0000_0028 $9A $009A $0000_009A $7F $007F $0000_007F --- $1020 $0000_1020 --- $8086 $0000_8086
The 80x86 provides several instructions that will let you sign or zero extend a smaller number to a larger number. The first group of instructions we will look at will sign extend the AL, AX, or EAX register. These instructions are
- cbw(); // Converts the byte in AL to a word in AX via sign extension.
- cwd(); // Converts the word in AX to a double word in DX:AX
- cdq(); // Converts the double word in EAX to the quad word in EDX:EAX
- cwde(); // Converts the word in AX to a doubleword in EAX.
Note that the CWD (convert word to doubleword) instruction does not sign extend the word in AX to the doubleword in EAX. Instead, it stores the H.O. doubleword of the sign extension into the DX register (the notation "DX:AX" tells you that you have a double word value with DX containing the upper 16 bits and AX containing the lower 16 bits of the value). If you want the sign extension of AX to go into EAX, you should use the CWDE (convert word to doubleword, extended) instruction.
The four instructions above are unusual in the sense that these are the first instructions you've seen that do not have any operands. These instructions' operands are implied by the instructions themselves.
Within a few chapters you will discover just how important these instructions are, and why the CWD and CDQ instructions involve the DX and EDX registers. However, for simple sign extension operations, these instructions have a few major drawbacks - you do not get to specify the source and destination operands and the operands must be registers.
For general sign extension operations, the 80x86 provides an extension of the MOV instruction, MOVSX (move with sign extension), that copies data and sign extends the data while copying it. The MOVSX instruction's syntax is very similar to the MOV instruction:movsx( source, dest );
The big difference in syntax between this instruction and the MOV instruction is the fact that the destination operand must be larger than the source operand. That is, if the source operand is a byte, the destination operand must be a word or a double word. Likewise, if the source operand is a word, the destination operand must be a double word. Another difference is that the destination operand has to be a register; the source operand, however, can be a memory location1.
To zero extend a value, you can use the MOVZX instruction. It has the same syntax and restrictions as the MOVSX instruction. Zero extending certain eight-bit registers (AL, BL, CL, and DL) into their corresponding 16-bit registers is easily accomplished without using MOVZX by loading the complementary H.O. register (AH, BH, CH, or DH) with zero. Obviously, to zero extend AX into DX:AX or EAX into EDX:EAX, all you need to do is load DX or EDX with zero2.
The following sample program demonstrates the use of the sign extension instructions:program signExtension; #include( "stdlib.hhf" ); static i8: int8; i16: int16; i32: int32; begin signExtension; stdout.put( "Enter a small negative number: " ); stdin.get( i8 ); stdout.put( nl, "Sign extension using CBW and CWDE:", nl, nl ); mov( i8, al ); stdout.put( "You entered ", i8, " ($", al, ")", nl ); cbw(); mov( ax, i16 ); stdout.put( "16-bit sign extension: ", i16, " ($", ax, ")", nl ); cwde(); mov( eax, i32 ); stdout.put( "32-bit sign extension: ", i32, " ($", eax, ")", nl ); stdout.put( nl, "Sign extension using MOVSX:", nl, nl ); movsx( i8, ax ); mov( ax, i16 ); stdout.put( "16-bit sign extension: ", i16, " ($", ax, ")", nl ); movsx( i8, eax ); mov( eax, i32 ); stdout.put( "32-bit sign extension: ", i32, " ($", eax, ")", nl ); end signExtension; Program 3.18 Sign Extension Instructions
Sign contraction, converting a value with some number of bits to the identical value with a fewer number of bits, is a little more troublesome. Sign extension never fails. Given an m-bit signed value you can always convert it to an n-bit number (where n > m) using sign extension. Unfortunately, given an n-bit number, you cannot always convert it to an m-bit number if m < n. For example, consider the value -448. As a 16-bit hexadecimal number, its representation is $FE40. Unfortunately, the magnitude of this number is too large to fit into an eight bit value, so you cannot sign contract it to eight bits. This is an example of an overflow condition that occurs upon conversion.
To properly sign contract one value to another, you must look at the H.O. byte(s) that you want to discard. The H.O. bytes you wish to remove must all contain either zero or $FF. If you encounter any other values, you cannot contract it without overflow. Finally, the H.O. bit of your resulting value must match every bit you've removed from the number. Examples (16 bits to eight bits):$FF80 can be sign contracted to $80. $0040 can be sign contracted to $40. $FE40 cannot be sign contracted to 8 bits. $0100 cannot be sign contracted to 8 bits.
Another way to reduce the size of an integer is through saturation. Saturation is useful in situations where you must convert a larger object to a smaller object and you're willing to live with possible loss of precision. To convert a value via saturation you simply copy the larger value to the smaller value if it is not outside the range of the smaller object. If the larger value is outside the range of the smaller value, then you clip the value by setting it to the largest (or smallest) value within the range of the smaller object.
For example, when converting a 16-bit signed integer to an eight-bit signed integer, if the 16-bit value is in the range -128..+127 you simply copy the L.O. byte of the 16-bit object to the eight-bit object. If the 16-bit signed value is greater than +127, then you clip the value to +127 and store +127 into the eight-bit object. Likewise, if the value is less than -128, you clip the final eight bit object to -128. Saturation works the same way when clipping 32-bit values to smaller values. If the larger value is outside the range of the smaller value, then you simply set the smaller value to the value closest to the out of range value that you can represent with the smaller value.
Obviously, if the larger value is outside the range of the smaller value, then there will be a loss of precision during the conversion. While clipping the value to the limits the smaller object imposes is never desirable, sometimes this is acceptable as the alternative is to raise an exception or otherwise reject the calculation. For many applications, such as audio or video processing, the clipped result is still recognizable, so this is a reasonable conversion to use.
1This doesn't turn out to be much of a limitation because sign extension almost always precedes an arithmetic operation which must take place in a register.
2Zero extending into DX:AX or EDX:EAX is just as necessary as the CWD and CDQ instructions, as you will eventually see.