2.8 Signed and Unsigned Numbers

Thus far, we've treated binary numbers as unsigned values. The binary number ...00000 represents 0, ...00001 represents 1, ...00010 represents 2, 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. Now it is time to describe the two's complement numbering system.

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 8, 16, 32, 64, 128, or some other small number of bits.

With a fixed number of bits we can represent only a certain number of objects. For example, with 8 bits we can represent only 256 different values. Negative values are objects in their own right, just like positive numbers and 0; therefore, we'll have to use some of the 256 different 8-bit values to represent negative numbers. In other words, we have to use up some of the bit combinations 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 0. So we can represent the negative values −128..−1 and the nonnegative values 0..127 with a single 8-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 possible ways, but the 80x86 microprocessor uses the two's complement notation, so it makes sense to study that method. In the two's complement system, the H.O. bit of a number is a sign bit. If the H.O. bit is 0, the number is positive; if the H.O. bit is 1, the number is negative. Following are some examples.

For 16-bit numbers:

$8000 is negative because the H.O. bit is 1.
      $100 is positive because the H.O. bit is 0.
      $7FFF is positive.
      $FFFF is negative.
      $FFF ($0FFF) is positive.

If the H.O. bit is 0, then the number is positive and uses the standard binary format. If the H.O. bit is 1, then the number is negative and uses 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; that is, apply the logical not function.

  2. Add 1 to the inverted result and ignore any overflow out of the H.O. bit.

    For example, to compute the 8-bit equivalent of −5:

    %0000_0101             5 (in binary).
                    %1111_1010             Invert all the bits.
                    %1111_1011             Add 1 to obtain result.

    If we take −5 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 1 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.
         $4000: +16384.

    To convert the numbers above to their negative counterpart (that is, 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 1 (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 1 ($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 −32,768)

$8000 inverted becomes $7FFF. After adding 1 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? (This, by the way, is known as the one's complement numbering system.) 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 0. 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 0. 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 other numbering systems.

Usually, 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, hexadecimal calculators perform this operation by pressing the change sign key (+/− or CHS). Nevertheless, manually computing the two's complement is easy, and you should know how to do it.

Remember that the data represented by a set of binary bits depends entirely on the context. The 8-bit binary value %1100_0000 could represent a 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 define the data's format and then use the 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. Because this is a signed integer operation, it only makes sense to operate on signed integer values. The program in Example 2-6 demonstrates the two's complement operation by using the neg instruction:

Example 2-6. twosComplement example

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.puth8( 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;

As you've seen previously, you use the int8, int16, int32, int64, and int128 data types to reserve storage for signed integer variables. You've also seen routines like stdout.puti8 and stdin.geti32 that read and write signed integer values. Because this section has made it abundantly clear that you must differentiate signed and unsigned calculations in your programs, you should probably be asking yourself, "How do I declare and use unsigned integer variables?"

The first part of the question, "How do I declare unsigned integer variables," is the easiest to answer. You simply use the uns8, uns16, uns32, uns64, and uns128 data types when declaring the variables. For example:

static
     u8:          uns8;
     u16:         uns16;
     u32:         uns32;
     u64:         uns64;
     u128:        uns128;

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.putu64, stdout.putu128, stdout.putu8Size, stdout.putu16Size, stdout.putu32Size, stdout.putu64Size, stdout.putu128Size, stdin.getu8, stdin.getu16, stdin.getu32, stdin.getu64, and stdin.getu128. You use these routines just as you would use their signed integer counterparts except you get to use the full range of the unsigned values with these routines. The source code in Example 2-7 demonstrates unsigned I/O as well as demonstrates what can happen if you mix signed and unsigned operations in the same calculation.

Example 2-7. Unsigned I/O

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;