Search code examples
assemblyinteger-overflowpicoblaze

Making a Celsius-to-Fahrenheit converter in PicoBlaze assembly work with a wider range of possible inputs


I made a short PicoBlaze assembly program (you can try it live here) that converts Celsius degrees entered using the 8 switches (input 0) to Fahrenheit degrees and outputs them on the 8 LEDs (output 0), using the formula Fahrenheit=(Celsius*9/5)+32:

;Let's say Celsius is on input 0
;and you are supposed to output
;Fahrenheit on output 0.

address 0
input s0, 0
load s1, 0
load s2, 9
multiplication_by_9:
add s1, s0
sub s2, 1
jump nz, multiplication_by_9
load s2, 0
division_by_5:
add s2, 1
sub s1, 5
jump nc, division_by_5
add s2, 32'd
output s2, 0
jump 0

Now, if you enter 00010010 (binary for 18) using switches, it outputs 01000001 (binary for 65), which is close to right. If you enter 00010110 (binary for 22), it outputs 01001000 (binary for 72), which is again close to right.

However, if you enter 00100101 (binary for 37), an overflow occurs and it outputs 00110000 (binary for 48), which is very far from the correct answer.

So, how can I make it output something close to the correct answer in that case?


Solution

  • There are a number of options.


    The most straightforward is to upgrade that code to 16-bit arithmetic.  We might call this the double precision approach.  On picoblaze that means using 2 registers for your accumulator, and doing two 8-bit additions, one on each, and also using addition with carry for the upper 8-bit accumulator register to complete a 16-bit addition.  For the division, picoblaze has subtract with carry as well.  This will be accurate and relatively simple.

    Since the input value is only 8 bits wide, the code sequence for the multiplication by repetitive addition needs to add the low byte of the input (s0) into the low byte of the 16-bit accumulator (s1), then using add with carry, add the constant 0, into the high byte of the 16-bit accumulator (maybe s3, also pre-initialized to 0) to capture and accumulate the 16-bit sum representing the multiplication.

    Similar for the division, as one operand (5) is one byte wide, and the other is the 16-bit accumulator.

    The code below uses an extra register, s3, as the high order of the accumulator.  (It has the same rounding behavior as your original.) You can see it alive here.

    ;Let's say Celsius is on input 0
    ;and you are supposed to output
    ;Fahrenheit on output 0.
    
        address 0
        input s0, 0
        ;load s0, 37'd
        load s1, 0
        load s3, 0
        load s2, 9
    
    multiplication_by_9:
        add s1, s0
        addcy s3, 0
        sub s2, 1
        jump nz, multiplication_by_9
    
        load s2, 0
    division_by_5:
        add s2, 1
        sub s1, 5
        subcy s3, 0
        jump nc, division_by_5
    
        add s2, 32'd
        output s2, 0
    
    label: jump label
    


    Another option, which will avoid high looping count when dividing by 5 is to multiply by 1.8 (instead of by 9 and then divide by 5), here also using 16-bit arithmetic, but instead of registers for a low byte and high byte of for greater magnitude, there's registers, one for an integer part and one for a fractional part — we think of the integer part is the high byte and the fractional part as the low byte.  This is called fixed-point arithmetic, and can represent fractional values using a pair of integers, for example.

    We would approximate 1.8 as the following x + x(2^-1) + x(2^-2) + x(2^-6) + x(2^-7), which gives us 1.796875x.  Or by adding one more term, x(2^-9), 1.800781x.  (You can experiment with longer series to obtain even more accurate results, but complete precision is not possible in a simple binary form, though also hardly worth while.)  Each of the terms has a factor that is some (negative) power of 2, which means that is division that can be done using a right shift.  The fractional part is important to capture, since they will add up and inform the integer part.

    The instruction sequence for this will be somewhat elongated on this machine given that it can only shift by 1 bit at a time, but we can reuse intermediate results, such as with 2^-1 (shift right by 1 bit), which we can shift one more bit to get the 2^-2 term.  This means having two (16-bit) accumulators of a sort (both as fixed-point pairs), one for the sum of the terms and one for the current fractional part that is shifted one bit at a time to get the terms and summed when appropriate (i.e. one of the factors we want).

    Here using the add with carry (as in double precision addition) will also be necessary — though since the multiplication by 1.8 already includes divide by 5, there's no further division to do, so won't necessarily need subtract with carry given that we're only summing terms.  (In theory, we can also subtract terms but a quick look says that doesn't improve accuracy or number of terms needed).

    The following code uses s2 & s3 as a fixed point pair, where s2 is the integer part, and s3 the fractional part, meaning that they should be viewed as concatenated with a radix point between the two bytes.  s4 & s5 are used the same manner.  The s2 & s3 pair form the accumulator and answer.  The s4 & s5 pair are the current value divided by some negative power of 2 by shifting one bit at a time.

    The right shift operations, which divide by 2, start with the high order and carry into the low order (two shifts, the second using carry), whereas addition starts with the low order and carries into the high order (two adds, the second using carry). You can see that program alive here.

    ;Let's say Celsius is on input 0
    ;and you are supposed to output
    ;Fahrenheit on output 0.
    
        address 0
        input s0, 0
        ;load s0, 37'd
    
    ; s2 & s3 are the answer as fixed point pair
    ; used as the accumulator for the sum of terms
    ; s2 is int part, s3 is fractional part
    ; initialize the accumulator with 1x
        load s2, s0 
        load s3, 0
    
    ; s4 & s5 are the fractional component, repeatedly divided by 2
        load s4, s0
        load s5, 0
    
    ; compute (2^-1)x, e.g. divide by 2 and accumulate it
        sr0 s4
        sra s5
        add s3, s5
        addcy s2, s4
    
    ; compute (2^-2), e.g. divide by 4 and accumulate it
        sr0 s4
        sra s5
        add s3, s5
        addcy s2, s4
    
        sr0 s4 ; shift but don't sum
        sra s5
    
        sr0 s4 ; shift but don't sum
        sra s5
    
        sr0 s4 ; shift and sum 
        sra s5
        add s3, s5
        addcy s2, s4
    
        sr0 s4
        sra s5
        add s3, s5
        addcy s2, s4
    
        sr0 s4
        sra s5
    
        sr0 s4
        sra s5
        add s3, s5
        addcy s2, s4
    
        add s2, 32'd
        output s2, 0
    
    label: jump label
    

    So, shift the fixed-point 16-bit register pair by one bit at a time, and sum or not depending on whether we are interested in that particular power of 2 to get us to the 1.8x.

    Let's also note that the sequence runs without looping (no repetitive/iterative addition or subtraction), so very performant!


    With either approach, you can choose to round to the closer, or, even output some fractional value.

    In the first approach (double precision), we can use the remainder after division by 5 to tell us how to round (a remainder >= 3 suggest rounding the integer part up).

    In the latter approach (fixed point arithmetic), we can use the fractional byte to decide to round (e.g. if s3 >= 0x80 then round the integer part by 1 — 0x80 as a the fixed-point fractional part means 0.5).