Today’s lab will walk you through a hands-on examination of how computers represent integers and floating point numbers. Follow the instructions below to get started:
Go to https://git.cs.grinnell.edu/csc161-s25/number-representation/ and create a fork of the repository.
Add your group members as collaborators on the repository.
Navigate to ~/csc161/labs in a terminal and use git to clone your fork of the repository.
Open ~/csc161/labs/number-representation in VSCode.
If you’re unsure how to complete any of these steps, you can go back to our earlier labs for more details.
We’ll start by looking at how computers represent integer values. There are a few important elements of the C language we will use in this lab that you’ll need to be comfortable with:
int type in C is a signed type, meaning it can represent both positive and negative values using two’s complement representation.
If we only need to represent non-negative values we can use the type unsigned int instead.
However, many of the exercises in this lab will rely on integer types that have a specific known number of bits.
The C standard gives us some useful types for this use case, provided we include the line:
#include <stdint.h>
This allows us to use types like int32_t, which is guaranteed to be a 32-bit signed integer.
For unsigned values, we just add a u prefix: uint32_t is a 32-bit unsigned integer type.
There are similarly-named types for 8-bit, 16-bit, and 64-bit integers defined in stdint.h.
int x = 1234;
However, C allows us to write in three other bases: base two (binary), base eight (octal), and base 16 (hexadecimal).
We’ll be using base two in today’s lab, which you can write with the 0b prefix:
int x = 0b10011010010;
Support for binary values was only added in the C23 standard (published in 2024) so some compilers may not like this notation without a special compiler option.
The clang compiler on MathLAN does accept these values.
Please complete the following exercises.
You can use the code in integer.c to write code to check your answers, but you will only need to submit answers (not code) for the exercises below.
Pay close attention to whether you are asked to write numbers in binary or decimal, and whether the values should be interpreted as signed or unsigned integers.
uint16_t and print them using the "%u" format flag to printf to ensure they are displayed correctly.
0000000000000010000000110011001110000000000000011111111111111111
uint16_t.
There is no format flag to ask printf to display a number in binary, but you can check your conversions by storing the binary value you produce and printing it as an unsigned decimal value with the format string "%u".
478192014
int16_t and print them using the "%d" format flag to printf to ensure they are displayed correctly.
0000000000000010000000110011001110000000000000011111111111111111
int16_t.
There is no format flag to ask printf to display a number in binary, but you can check your conversions by storing the binary value you produce and printing it as a signed decimal value with the format string "%d".
-1-64-47-32768We usually deal with numbers in C without thinking about their binary representations, but we can manipulate the bits of a value in C using bitwise operators. C doesn’t allow us to directly access or change individual bits, but bitwise operations operate on the underlying binary representation of a value instead of its numeric interpretation. This is especially useful when we want to use bits to represent something else, e.g. the presence or absence of a value in a set, whether or not an output pin is turned on, etc.
Complete the exercises below in the bitwise.c source file provided with your lab starter code.
The starter code includes some simple test code, which you should modify or add to as you test your implementations.
bit_test function.
This function takes a 32-bit integer and a bit index as parameters, and then returns whether the bit specified by index is set or not.
As with array indexing, we consider the lowest-order bit of an integer (shown on the right side) to be index zero.
bool bit_test(uint32_t value, uint8_t index);
Your implementation should not use a loop.
You can complete the implementation with some combination of bitwise and (&) and the shift operators (<< and >>).
bit_set function.
This function takes a 32-bit integer and a bit index as parameters.
It returns a new 32-bit integer that is identical to the input value, except the bit specified by index is set to 1.
If the input value already had the bit set, the function returns the number unchanged.
uint32_t bit_set(uint32_t value, uint8_t index);
As with bit_test, do not use a loop in your implementation.
You can complete the function with a combination of bitwise or (|) and the shift operators (<< and >>).
bit_clear function.
This function takes a 32-bit integer and a bit index as parameters.
It returns a new 32-bit integer that is identical to the input value, except the bit specified by index is cleared to zero.
If the specified bit was already zero in the input value, the function returns the number unchanged.
uint32_t bit_clear(uint32_t value, uint8_t index);
Again, do not use a loop in your implementation.
You can complete the function with a combination of bitwise and (&), the shift operators (<< and >>), and bitwise negation (~) or exclusive or (^).
You may use bit_test or bit_set in your implementation of this function if you like, but that is not a requirement.
Finally, we’ll look at single-precision floating point numbers, which C represents using the IEEE 754 standard covered in our reading.
These exercises will require that we jump back and forth between floating point values and their underlying binary representation.
It was easy to jump between interpretations with integers because 123 and 0b1111011 are both integers in C.
We could even use bitwise operations to manipulate or access bits of an integer.
This is more difficult with floating point values;
there are no bitwise operations on floats, and the only way to write a floating point value is as a decimal constant.
You might be tempted to use a cast to convert between representations, but casts in C try to preserve the meaning of a value rather than its binary representation. Consider this example:
int i = 3;
float f = i;
The value of f will be 3.0, but that definitely does not have the same underlying binary representation as i.
Luckily, we know about unions, which allow us to store values of different types using the same underlying bits of storage.
Here’s an example of how we can do that:
union number {
uint32_t i_value;
float f_value;
};
int main() {
union number num;
num.i_value = 3;
printf("num.i_value is %u\n", num.i_value);
printf("num.f_value is %f\n", num.f_value);
}
This example interprets the same bits in two different ways: first as an unsigned integer, and then as a floating point value.
The actual value we see depends on the interpretation we use: the integer value will be 3, while the floating point value looks like 0.000000.
Printing floating point numbers is necessarily an imprecise operation;
the bits that represent the integer 3 are not the same as 0.0, even though the printed value looks like it is a zero.
We can ask for more precision by adding to the printf format flag.
For example, this printf call will include 128 digits after the decimal place:
printf("number.f_value is %.50f\n", number.f_value);
If you add this updated format string to the example above you’ll see that the value of num.f_value is clearly non-zero.
Because floating point values are necessarily approximate, it is generally a good idea not to compare floats with == unless you are comparing to a constant you know you can represent precisely (e.g. 0.0, 1.0).
Instead, you’ll generally compare floats with < and > operators.
And instead of checking for strict equality with floats, you’ll generally check to see if two float values are within some small distance (usually called epsilon, borrowing from the common mathematical meaning of a small distance or error).
You can largely ignore issues with precision in today’s lab, but you should print floating point values with many digits after the decimal place to make sure you’ve represented them accurately.
Complete the following exercises in the float.c source file, which includes the union and sample above.
You can use the code to check your work, but you will only need to submit your responses.
1 in the mantissa.
Use those converted values to write the number in base-two scientific notation, e.g. \(1.5\times2^1\).
Finally, evaluate the expression (or use a calculator) to get a value like what printf will display.
01000000110000000000000000000000001111100000000000000000000000001011111011000000000000000000000001000001000110010000000000000000
Now we’ll convert in the opposite direction. You’ll find it much easier to convert to binary if you first write your decimal number as a mantissa times \(2\) raised to some power. If the mantissa is not between 1.0 and 2.0 you can divide or multiply it by two, shifting the extra factor of two to the exponent.
For example, \(0.375 = 3/8 = 3.0 \times 1/8 = 3.0 \times 2^{-3}\), but the mantissa is out of bounds for our floating point representation.
We can divide the mantissa by two, then add a factor of two to the exponent to bring the value into the range we can represent:
\(3.0 \times 2^{-3} = 1.5 \times 2^{-2}\).
Now all you need to do is convert the exponent of -2 and the mantissa of 1.5 to their binary representations.
Convert the following values to their binary floating point representation
You can check your conversion by plugging the binary value into the i_value field of num and printing the values.
0.6255.25-3.75We’re skipping over unnormalized (or “subnormal”) numbers, as well as the special values of 0.0, -0.0, inf, -inf, and nan in our exercises.
It is important to be able to represent these values, but they depart from the usual representation in IEEE 754.
You are still responsible for understanding these representations so you may want to review the reading on floating point values.