Data Representation
Memory holds bytes, and a byte is just eight bits. Everything else — integers, decimals, letters — is an agreement about how to interpret those bits. C exposes that agreement more directly than most languages, so learning it now prevents a long list of surprises later.
- Convert between binary, octal, hexadecimal, and decimal, and say why hexadecimal is used at all.
- Explain two's complement and why it is the representation every modern machine uses.
- Describe the three fields of an IEEE 754 floating-point number and explain why
0.1 + 0.2 != 0.3. - Explain what a character really is to a C program.
- Write a program that prints the underlying bytes of any value.
1Why this week exists
A student who skips this material can still write working C for a while. The trouble arrives later, all at once, as a set of apparently unrelated mysteries:
- Why does adding 1 to the largest
intproduce a large negative number? - Why is
0.1 + 0.2 == 0.3false? - Why does
'A' + 1give'B'? - Why is
if (x < y)sometimes wrong when one of them is unsigned? - Why do people write
0xFFinstead of255?
Every one of these has the same answer: because of how the value is stored. Half an hour on representation now saves a week of confusion in weeks 7, 12, and 25.
2Positional number systems
Decimal is positional: each digit's contribution depends on its place, and each place is a power of ten. 247 means 2×10² + 4×10¹ + 7×10⁰. Nothing about that scheme requires ten. Any base works.
| Base | Digits | C notation | Used for |
|---|---|---|---|
| Binary (2) | 0 1 | 0b1010 (C23) | What the hardware actually stores |
| Octal (8) | 0–7 | 0755 — a leading zero | Unix file permissions, and little else |
| Decimal (10) | 0–9 | 255 | Humans |
| Hexadecimal (16) | 0–9 A–F | 0xFF | Addresses, bit patterns, colours |
Reading binary
1101 in binary is 1×8 + 1×4 + 0×2 + 1×1 = 13. Going the other way, repeatedly divide by two and collect the remainders from last to first: 13 ÷ 2 = 6 r1, 6 ÷ 2 = 3 r0, 3 ÷ 2 = 1 r1, 1 ÷ 2 = 0 r1 → 1101.
Why hexadecimal
Because sixteen is two to the fourth, one hex digit is exactly four bits, always, with no arithmetic required. This makes hex a compact shorthand for bit patterns that a person can convert at sight.
| Hex | Bits | Hex | Bits | Hex | Bits | Hex | Bits |
|---|---|---|---|---|---|---|---|
| 0 | 0000 | 4 | 0100 | 8 | 1000 | C | 1100 |
| 1 | 0001 | 5 | 0101 | 9 | 1001 | D | 1101 |
| 2 | 0010 | 6 | 0110 | A | 1010 | E | 1110 |
| 3 | 0011 | 7 | 0111 | B | 1011 | F | 1111 |
So 0x3A is 0011 1010 without thinking, and one byte is always exactly two hex digits. When week 25 asks you to set bit 5 of a register, you will write 0x20 and see 0010 0000 in your head.
A leading zero means octal. In C, 012 is not twelve — it is ten. This bites people writing zero-padded constants such as dates or times. int minutes = 08; does not even compile, because 8 is not an octal digit.
3Bits, bytes, and words
A bit is one binary digit. A byte is the smallest individually addressable unit of memory — eight bits on every machine you will realistically meet. Eight bits give 2⁸ = 256 distinct patterns.
A word is the natural size the processor works with: 64 bits on a modern desktop, 32 on many embedded parts. The C type int is usually, but not necessarily, one word.
In C, sizeof(char) is 1 by definition. All other sizes are measured in units of char. The standard guarantees only minimum ranges, not exact sizes — a point week 37 makes a great deal of. For now:
| Type | Typical size | Typical range |
|---|---|---|
char | 1 byte | −128 … 127, or 0 … 255 |
short | 2 bytes | −32 768 … 32 767 |
int | 4 bytes | −2 147 483 648 … 2 147 483 647 |
long | 8 bytes (4 on Windows) | platform dependent |
long long | 8 bytes | about ±9.2 × 10¹⁸ |
4Signed integers and two's complement
Unsigned numbers are easy: the bits are the number. Negative numbers need a convention, and several have been tried.
The obvious idea, and why it fails
Sign-magnitude reserves the top bit for the sign and treats the rest as the magnitude. It is easy for a human to read, and bad for hardware: it gives two representations of zero (0000 and 1000), and addition needs special-case logic depending on the signs.
Two's complement
The representation every modern machine uses — and, since C23, the only one the C standard permits — is two's complement. The rule: the most significant bit carries a negative weight.
For a 4-bit number the place values are −8, 4, 2, 1:
| Bits | Arithmetic | Value |
|---|---|---|
0000 | 0 | 0 |
0111 | 4+2+1 | 7 (largest) |
1000 | −8 | −8 (smallest) |
1111 | −8+4+2+1 | −1 |
1110 | −8+4+2 | −2 |
Two properties make this the winner. There is exactly one zero. And subtraction is just addition: the same adder circuit handles signed and unsigned operands with no sign test at all. That is why the hardware does it this way, and therefore why C behaves this way.
Negating a number
Flip every bit, then add one. For 4-bit 5 = 0101: flip to 1010, add one → 1011 = −8+2+1 = −5. Correct.
Overflow
The range is asymmetric: there is one more negative value than positive, because zero occupies a slot on the positive side. For a 32-bit int the top value is 2 147 483 647. Add one:
0111 1111 … 1111 = 2147483647
+ 1
1000 0000 … 0000 = -2147483648The bits simply carried into the sign position. The value wrapped around.
This wrap-around is undefined behavior for signed types. Unsigned overflow is defined to wrap; signed overflow is not defined at all, and an optimizing compiler is entitled to assume it never happens. That assumption can delete your bounds check. Week 36 shows a program that behaves differently at -O0 and -O2 for exactly this reason.
5Floating point: IEEE 754
Real numbers are stored in a binary version of scientific notation. A float is 32 bits split into three fields:
A double uses the same scheme with 1 + 11 + 52 bits.
The value is roughly sign × 1.mantissa × 2exponent−bias. The leading 1 is implicit and not stored, which buys one extra bit of precision for free.
Why 0.1 is not 0.1
In decimal, one third cannot be written exactly: 0.3333… goes on forever. The same thing happens in binary, but for a different set of numbers. One tenth in binary is 0.0001100110011… repeating forever. With only 23 or 52 bits available, it must be cut off. What gets stored is the nearest representable value, which is close to 0.1 but not equal to it.
#include <stdio.h>
int main(void)
{
double a = 0.1, b = 0.2;
printf("%.20f\n", a + b);
printf("%s\n", (a + b == 0.3) ? "equal" : "not equal");
return 0;
}0.30000000000000004441
not equalThis is not a bug in C, in your compiler, or in your computer. It is a consequence of storing infinitely many real numbers in finitely many bits, and every language using IEEE 754 — Python, Java, JavaScript — behaves identically.
The practical rule, which week 7 develops: never compare floating-point values with ==. Compare against a tolerance instead.
The special values
Some bit patterns are reserved. Infinity results from overflow or division by zero; NaN — Not a Number — results from an undefined operation such as 0.0/0.0. NaN has a property that catches everyone once: it is not equal to itself, so x != x is a valid NaN test.
6Characters
There is no character type in the hardware. A char is a small integer, and the mapping from integer to glyph is a convention. The traditional one is ASCII, which assigns codes 0–127.
| Character | Decimal | Hex |
|---|---|---|
'\0' (null) | 0 | 0x00 |
'\n' (newline) | 10 | 0x0A |
| space | 32 | 0x20 |
'0' | 48 | 0x30 |
'A' | 65 | 0x41 |
'a' | 97 | 0x61 |
Because a character is a number, arithmetic on characters works and is occasionally useful. 'A' + 1 is 66, which is 'B'. '7' - '0' is 7, converting a digit character to its value. The gap between upper and lower case is exactly 32, which is a single bit — 0x20 — and that is why case conversion used to be written as a bitwise operation.
Two things to carry forward. '\0', the character with value zero, marks the end of every C string; that is week 14. And ASCII covers only English — Turkish characters such as ş and ğ need more than 127 codes, which is what UTF-8 is for and what week 37 covers.
Plain char has no fixed signedness. Whether char is signed or unsigned is implementation-defined: it is signed on x86 Linux and unsigned on ARM Linux. Code that stores values above 127 in a plain char and compares them behaves differently on different machines. Say signed char or unsigned char when you mean a small number, and reserve plain char for text.
7Worked example: looking at the bytes
Nothing makes this concrete like printing the actual bits. Save as bits.c:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Print the bits of an int, most significant first. */
static void print_int_bits(int value)
{
for (int bit = 31; bit >= 0; bit--) {
putchar((value >> bit) & 1 ? '1' : '0');
if (bit % 8 == 0 && bit != 0) {
putchar(' ');
}
}
putchar('\n');
}
/* Print the raw bytes of any object, lowest address first. */
static void print_raw_bytes(const void *object, size_t size)
{
const unsigned char *byte = object;
for (size_t i = 0; i < size; i++) {
printf("%02X ", byte[i]);
}
putchar('\n');
}
int main(void)
{
int positive = 13;
int negative = -13;
printf("13 decimal = %d, octal = %o, hex = %X\n", positive, positive, positive);
printf("13 bits = "); print_int_bits(positive);
printf("-13 bits = "); print_int_bits(negative);
printf("\nsum of the two: %d\n", positive + negative);
float f = 0.1f;
printf("\n0.1f occupies %zu bytes: ", sizeof f);
print_raw_bytes(&f, sizeof f);
printf("0.1f printed to 20 places: %.20f\n", (double)f);
printf("\n'A' = %d, 'A' + 1 = %c, '7' - '0' = %d\n",
'A', 'A' + 1, '7' - '0');
printf("\nlargest int = %d\n", 2147483647);
printf("plus one = %d\n", 2147483647 + 1); /* see the note below */
return EXIT_SUCCESS;
}gcc -std=c17 -Wall -Wextra -g -o bits bits.c
./bitsWhat to look for
Compare the bit patterns of 13 and −13. Flip every bit of the first and add one, and you get the second — the negation rule from section 4, visible.
Look at the four bytes of 0.1f. On a typical x86 machine you will see CD CC CC 3D, which is the pattern 3DCCCCCD stored backwards. That is little-endian byte order, and it is the subject of week 37.
Finally, note that GCC warns about 2147483647 + 1:
bits.c:44:42: warning: integer overflow in expression of type 'int'
results in '-2147483648' [-Woverflow]The compiler computed the constant at compile time, noticed it overflowed, and told you. This is the warning doing exactly the job week 1 promised it would.
8Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Writing 010 meaning ten | The value is 8 | A leading zero means octal. Drop it. |
Comparing floats with == | Mathematically equal values compare unequal | Compare fabs(a - b) < tolerance. Week 7 covers choosing the tolerance. |
| Expecting signed overflow to wrap | Works at -O0, breaks at -O2 | Signed overflow is undefined. Use a wider type or check before the operation. |
Storing a value above 127 in a plain char | Behaves differently on x86 and ARM | Use unsigned char for byte values. |
Printing a float with %d | Undefined behavior; nonsense output | Use %f. Floats are promoted to double when passed to printf. |
Assuming int is always 32 bits | Code breaks on another platform | Use <stdint.h> types when the width matters. Week 37. |
9Check yourself
Why is hexadecimal used so heavily in systems programming rather than decimal?
Because 16 is a power of 2, one hex digit corresponds to exactly four bits with no arithmetic. A byte is always two hex digits, so a hex constant is a compact, directly readable bit pattern. Decimal has no such alignment: converting 200 to bits requires work, while 0xC8 is 1100 1000 at sight.
What are the two properties that made two's complement win over sign-magnitude?
There is a single representation of zero, and addition and subtraction use the same circuitry for signed and unsigned values with no special case for sign. Hardware simplicity drove the choice, and C's arithmetic behavior follows from it.
Is 0.1 + 0.2 == 0.3 false because of a flaw in C?
No. Neither 0.1 nor 0.2 nor 0.3 has an exact binary representation in a finite number of bits, so each is stored as a nearby value and the rounding errors do not cancel. Every language that uses IEEE 754 shows the same result. Compare with a tolerance instead of ==.
Why does '7' - '0' give 7?
Characters are small integers. In ASCII the digit characters are consecutive starting at 48, so '7' is 55 and '0' is 48; the difference is the digit's numeric value. The standard guarantees the digits are consecutive, so this idiom is portable — unlike the same trick on letters.
Adding 1 to the largest int gives a negative number. Can you rely on that?
No. The bit-level explanation is real — the carry lands in the sign position — but signed overflow is undefined behavior in C, not defined wrap-around. The compiler may assume it cannot happen and optimize accordingly, so the observed result can change with optimization level. Unsigned types are defined to wrap; signed types are not.
10Where this leads
You now know what is actually in a byte. Week 5 puts names and types on those bytes: how C declares a variable, what each basic type guarantees, and how sizeof and <limits.h> let a program report its own machine's properties. Week 7 then returns to this material to explain what happens when values of different types meet in one expression — which is where the conversions bite.