Procedural Programming with C · Basic · Week 4

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.

By the end of this week you can
  • 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 int produce a large negative number?
  • Why is 0.1 + 0.2 == 0.3 false?
  • Why does 'A' + 1 give 'B'?
  • Why is if (x < y) sometimes wrong when one of them is unsigned?
  • Why do people write 0xFF instead of 255?

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.

BaseDigitsC notationUsed for
Binary (2)0 10b1010 (C23)What the hardware actually stores
Octal (8)0–70755 — a leading zeroUnix file permissions, and little else
Decimal (10)0–9255Humans
Hexadecimal (16)0–9 A–F0xFFAddresses, 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.

HexBitsHexBitsHexBitsHexBits
000004010081000C1100
100015010191001D1101
2001060110A1010E1110
3001170111B1011F1111

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:

TypeTypical sizeTypical range
char1 byte−128 … 127, or 0 … 255
short2 bytes−32 768 … 32 767
int4 bytes−2 147 483 648 … 2 147 483 647
long8 bytes (4 on Windows)platform dependent
long long8 bytesabout ±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:

BitsArithmeticValue
000000
01114+2+17 (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   = -2147483648

The 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:

sign1 bit exponent8 bits mantissa (fraction)23 bits 3130 – 2322 – 0

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 equal

This 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.

CharacterDecimalHex
'\0' (null)00x00
'\n' (newline)100x0A
space320x20
'0'480x30
'A'650x41
'a'970x61

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
./bits

What 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

MistakeWhat happensFix
Writing 010 meaning tenThe value is 8A leading zero means octal. Drop it.
Comparing floats with ==Mathematically equal values compare unequalCompare fabs(a - b) < tolerance. Week 7 covers choosing the tolerance.
Expecting signed overflow to wrapWorks at -O0, breaks at -O2Signed overflow is undefined. Use a wider type or check before the operation.
Storing a value above 127 in a plain charBehaves differently on x86 and ARMUse unsigned char for byte values.
Printing a float with %dUndefined behavior; nonsense outputUse %f. Floats are promoted to double when passed to printf.
Assuming int is always 32 bitsCode breaks on another platformUse <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.