Networking Fundamentals · intermediate · ~12 min

Subnetting and CIDR notation

By the end of this lesson you will be able to: - Read a CIDR block such as `192.168.1.0/24` and say exactly which addresses it covers. - Convert a prefix length into a subnet mask and back again. - Compute the **network address**, **broadcast address**, and **usable host range** of any IPv4 block. - Count how many total and usable host addresses a subnet holds. - Test whether a given IP falls *inside* a CIDR block using bitwise mask-and-compare. - Split (subnet) a larger block into several smaller equal-sized blocks.

Overview

In the previous lesson, IP addresses: IPv4, IPv6, public vs private, you learned that an IPv4 address is 32 bits, usually written as four dotted decimal numbers like 192.168.1.10. That lesson treated an address as a single identifier. This lesson zooms out: it looks at groups of addresses that belong together on the same network.

A subnet (short for sub-network) is a contiguous range of IP addresses that share a common leading bit pattern. Instead of listing every address, we describe the whole group with two facts: a starting address and how many leading bits they all share. That shared-bit count is the prefix length, and the compact way to write it is CIDR notation: address/prefix, for example 10.0.0.0/24.

Plain-language version: think of an IP address like a full street address. The prefix is like saying "everyone on Maple Street, house numbers 0 through 255." The network part (Maple Street) is fixed for the whole block; the host part (the house number) varies from one machine to the next.

Once you can read CIDR, three everyday questions become simple arithmetic: How big is this block? What is its first and last address? and Is this particular IP inside it? Every router, firewall, cloud security group, and network scanner answers those questions constantly. CIDR is the language they all speak, so learning it unlocks the rest of networking.

Why it matters

Network scope is almost always handed to you as CIDR blocks, never as a hand-written list of addresses. A cloud provider gives your virtual network a range like 172.31.0.0/16. A firewall rule allows or blocks 203.0.113.0/24. An authorized security assessment defines its boundaries as a set of CIDR blocks. If you cannot read CIDR fluently, you cannot reason about any of these.

Two questions come up over and over:

  • How many hosts does this block hold? You need this to plan capacity (how many machines fit) or to plan a scan (how long it will take).
  • Does this IP fall inside the block? You need this to route traffic correctly, to write a correct firewall rule, and — in security work — to stay strictly inside the range you were authorized to test. An address that looks close, like 10.1.0.5 versus a scope of 10.0.0.0/16, may be completely outside the block. Getting the bit math right is the difference between in-scope and out-of-scope.

Because this single skill sits underneath routing, firewalls, cloud networking, and authorization boundaries, it pays off in almost every networking task you will ever do.

Core concepts

1. Prefix length

Definition. The prefix length is the number of leading bits of a 32-bit IPv4 address that identify the network. Everything after those bits identifies the host.

Plain-language explanation. Write the 32-bit address out as a row of bits. Draw a line after the first N bits, where N is the prefix. Bits to the left of the line are the network number and are identical for every address in the block. Bits to the right are free to vary, one combination per host.

How it works internally. 192.168.1.0/24 means the first 24 bits are fixed. 32 - 24 = 8 host bits remain, so there are 2^8 = 256 possible combinations of those 8 bits, and therefore 256 addresses in the block.

 192.168.1.0/24
  11000000 10101000 00000001 | 00000000
  \___________ network (24) ____________/ | \_host (8)_/
           fixed for whole block            varies 0..255

When to use / when not to. Prefix length is the standard way to describe any block. Smaller prefix = bigger block (/8 is huge); larger prefix = smaller block (/30 holds just 4 addresses). Do not confuse "bigger number" with "bigger network" — it is the opposite.

Common pitfall. Forgetting that the prefix counts bits, not the third dotted number. /24 is not "class 24"; it is 24 one-bits.

Knowledge check: In the block 10.5.0.0/20, how many host bits are there, and how many total addresses does the block contain?

2. Subnet mask

Definition. A subnet mask is a 32-bit value with a run of 1 bits (one per network bit) followed by 0 bits for the host part. It is the prefix expressed as a bit pattern.

Plain-language explanation. The mask is a stencil. Where the mask has a 1, that bit belongs to the network. Where it has a 0, that bit belongs to the host. AND-ing an address with the mask keeps only the network bits and zeroes the rest.

How it works internally. A /24 mask is 24 ones then 8 zeros: 11111111 11111111 11111111 00000000, which in dotted decimal is 255.255.255.0. You can build the mask from the prefix with a shift: mask = 0xFFFFFFFF << (32 - prefix) (using 32-bit unsigned math).

CIDR Mask Total addresses Usable hosts
/30 255.255.255.252 4 2
/29 255.255.255.248 8 6
/24 255.255.255.0 256 254
/16 255.255.0.0 65,536 65,534
/8 255.0.0.0 16,777,216 16,777,214

When to use / when not to. Masks and prefixes are two spellings of the same thing; use whichever the tool in front of you expects. Older gear and some config files use dotted masks; modern tools use /prefix.

Common pitfall. A valid mask is always a contiguous run of ones followed by zeros. 255.255.0.255 is not a legal subnet mask because the ones are not contiguous.

Knowledge check: What prefix length does the mask 255.255.255.192 correspond to? (Hint: how many one-bits is that?)

3. Network address and broadcast address

Definition. The network address is the first address of a block — all host bits set to 0. The broadcast address is the last address — all host bits set to 1. Neither is normally assigned to an individual machine.

Plain-language explanation. The network address names the block itself ("this is network 10.0.0.0/24"). The broadcast address is a special "everyone on this subnet" destination. The addresses between them are the ones you can hand out to real hosts.

How it works internally. For 10.0.0.0/24:

 10.0.0.0    network address   (host bits 00000000)
 10.0.0.1    first usable host
   ...
 10.0.0.254  last usable host
 10.0.0.255  broadcast address (host bits 11111111)

Network = ip & mask. Broadcast = network | ~mask (network bits kept, host bits all set).

When to use / when not to. The "minus two" rule (subtract network + broadcast) applies to normal IPv4 subnets. Two exceptions: a /31 is used for point-to-point links and, by RFC 3021, has 2 usable addresses (no separate broadcast); a /32 is a single host with no host bits at all.

Common pitfall. Assuming the broadcast address always ends in .255. It only does for /24. For 10.0.0.0/26 the broadcast is 10.0.0.63, not 10.0.0.255.

Knowledge check: For 192.168.1.0/26, what are the network address, the broadcast address, and the usable host range?

4. Membership: is an IP inside a CIDR block?

Definition. An address belongs to a block when masking the address gives the same result as masking the block's network address.

Plain-language explanation. Strip off the host bits of both the candidate IP and the block. If the network parts that remain are identical, the IP is inside the block; otherwise it is outside.

How it works internally. The whole test is one line of bit math:

 (ip & mask) == (network & mask)

Since network already has zero host bits, this is usually written (ip & mask) == network.

When to use / when not to. This is the exact check inside every firewall rule, routing table lookup, and cloud security group. Use it any time you must decide "in scope or not." Do not eyeball it by comparing dotted numbers — the boundaries rarely line up with the dots.

Common pitfall. Building the mask with a shift of 32 (for a /0 block) is undefined behaviour in C when you write 1u << 32. Guard the edge cases.

Knowledge check: Is 10.0.1.20 inside 10.0.0.0/23? Work it out by finding the block's address range first.

5. Subnetting: splitting a block into smaller blocks

Definition. Subnetting borrows bits from the host part to create several smaller networks inside a larger one.

Plain-language explanation. Take a /24 (256 addresses). Borrow 2 host bits and you get 2^2 = 4 equal sub-blocks, each a /26 with 64 addresses. The prefix grows by the number of borrowed bits.

How it works internally. Splitting 192.168.1.0/24 into four /26 blocks:

 192.168.1.0/26    -> .0   .. .63
 192.168.1.64/26   -> .64  .. .127
 192.168.1.128/26  -> .128 .. .191
 192.168.1.192/26  -> .192 .. .255

Each block's size is 2^(32 - newprefix), and that size is also the step between one block's network address and the next.

When to use / when not to. Subnet to isolate groups (guests vs. servers), to match address use to need, or to shrink broadcast domains. Do not over-subnet tiny networks — every subnet loses two addresses to network + broadcast.

Common pitfall. Choosing a network address that is not aligned to the block size. A /26 must start on a multiple of 64 (.0, .64, .128, .192); 192.168.1.32/26 is not a valid block base.

Syntax notes

CIDR notation is simply address/prefix:

 192.168.1.0 / 24
 |__________|  |__|
   network     prefix length (network bits, 0..32)

The core computations, expressed as C-style bitwise operations on a 32-bit unsigned integer ip:

uint32_t mask      = (prefix == 0) ? 0u : (0xFFFFFFFFu << (32 - prefix));
uint32_t network   = ip & mask;          // clear host bits
uint32_t broadcast = network | ~mask;    // set all host bits
uint32_t hosts     = (prefix >= 31) ? 0u : ((1u << (32 - prefix)) - 2u); // usable
int      inside    = ((candidate & mask) == network);

The (prefix == 0) guard matters: shifting a 32-bit value by 32 is undefined behaviour in C, so the /0 case must be handled separately.

Lesson

Subnetting splits an address space into smaller networks.

CIDR (Classless Inter-Domain Routing) notation writes a network as address/prefix. The prefix is how many leading bits form the network part.

Reading a prefix

192.168.1.0/24 means the first 24 bits are the network. The remaining 8 bits identify hosts.

  • Mask: 255.255.255.0
  • Host bits: 32 - 24 = 8, so 2^8 = 256 addresses
  • Usable hosts: 256 - 2 = 254

Two addresses are subtracted: the first is the network address and the last is the broadcast address.

Common prefixes

CIDR Mask Addresses
/30 255.255.255.252 4 (2 usable)
/24 255.255.255.0 256
/16 255.255.0.0 65,536
/8 255.0.0.0 16,777,216

Network vs broadcast

For 10.0.0.0/24:

  • Network address: 10.0.0.0
  • Broadcast address: 10.0.0.255
  • Usable hosts: 10.0.0.1 to 10.0.0.254

Why pentesters care

A scope of 10.10.0.0/16 covers about 65,000 hosts. You plan that scan very differently than a small /29.

"Is this IP inside that CIDR?" is the most common scoping check, and it is pure bit masking:

(ip & mask) == (network & mask)

Code examples

#include <stdio.h>
#include <stdint.h>

/* Build a subnet mask from a prefix length (0..32). Guards the shift-by-32
   case, which is undefined behaviour for a 32-bit value. */
static uint32_t make_mask(int prefix) {
    if (prefix <= 0)  return 0x00000000u;
    if (prefix >= 32) return 0xFFFFFFFFu;
    return 0xFFFFFFFFu << (32 - prefix);
}

/* Print a uint32_t IP in dotted-decimal form. */
static void print_ip(uint32_t ip) {
    printf("%u.%u.%u.%u",
           (ip >> 24) & 0xFFu,
           (ip >> 16) & 0xFFu,
           (ip >> 8)  & 0xFFu,
           ip & 0xFFu);
}

/* Returns 1 if ip is inside network/prefix, else 0. */
static int cidr_contains(uint32_t network, int prefix, uint32_t ip) {
    uint32_t mask = make_mask(prefix);
    return (ip & mask) == (network & mask);
}

int main(void) {
    /* 192.168.1.0/26  ->  bytes packed into one 32-bit value */
    uint32_t base   = (192u << 24) | (168u << 16) | (1u << 8) | 0u;
    int      prefix = 26;

    uint32_t mask      = make_mask(prefix);
    uint32_t network   = base & mask;        /* first address  */
    uint32_t broadcast = network | ~mask;    /* last address   */

    uint32_t total = (prefix >= 31) ? (1u << (32 - prefix))
                                    : (1u << (32 - prefix));
    uint32_t usable = (prefix >= 31) ? 0u : (total - 2u);

    printf("Block:     ");
    print_ip(network); printf("/%d\n", prefix);
    printf("Netmask:   "); print_ip(mask);      printf("\n");
    printf("Network:   "); print_ip(network);   printf("\n");
    printf("Broadcast: "); print_ip(broadcast); printf("\n");
    printf("First host:"); printf(" "); print_ip(network + 1);   printf("\n");
    printf("Last host: "); print_ip(broadcast - 1); printf("\n");
    printf("Total: %u   Usable: %u\n", total, usable);

    /* Membership tests */
    uint32_t a = (192u << 24) | (168u << 16) | (1u << 8) | 40u;  /* .40 */
    uint32_t b = (192u << 24) | (168u << 16) | (1u << 8) | 90u;  /* .90 */
    printf("192.168.1.40 inside? %d\n", cidr_contains(network, prefix, a));
    printf("192.168.1.90 inside? %d\n", cidr_contains(network, prefix, b));
    return 0;
}

What it does. It packs 192.168.1.0 into a single 32-bit integer, builds the /26 mask, then derives the network address, broadcast address, usable range, and address counts using only bit operations. Finally it tests two addresses for membership.

Expected output:

Block:     192.168.1.0/26
Netmask:   255.255.255.192
Network:   192.168.1.0
Broadcast: 192.168.1.63
First host: 192.168.1.1
Last host: 192.168.1.62
Total: 64   Usable: 62
192.168.1.40 inside? 1
192.168.1.90 inside? 0

Key edge cases. A /26 block spans .0.63, so 192.168.1.90 (in the next block, .64.127) is correctly reported outside. For /31 and /32, the usable-host formula would go negative with the naive total - 2, which is why the code branches on prefix >= 31.

Line by line

  1. make_mask(prefix) — turns a prefix into a 32-bit mask. The guards matter: prefix <= 0 returns all zeros (a /0 matches everything), and prefix >= 32 returns all ones, because 0xFFFFFFFFu << 0 is fine but shifting by 32 is undefined. In between, 0xFFFFFFFFu << (32 - prefix) slides the ones left and fills the low bits with zeros.

  2. Packing the base address(192u << 24) | (168u << 16) | (1u << 8) | 0u places each dotted byte into its 8-bit slot, producing 0xC0A80100. Working on one integer lets us use plain arithmetic and bit ops.

  3. mask = make_mask(26) — 26 ones then 6 zeros = 0xFFFFFFC0 = 255.255.255.192.

  4. network = base & mask — clears the low 6 host bits. base already ends in .0, so network stays 192.168.1.0.

  5. broadcast = network | ~mask~mask is 0x0000003F (the 6 host bits set), so OR-ing sets the host part to all ones: 192.168.1.63.

  6. Counts1u << (32 - 26) = 1u << 6 = 64 total addresses; usable = 64 - 2 = 62.

  7. print_ip — pulls each byte back out with a shift and & 0xFF and prints it dotted.

  8. cidr_contains(network, 26, a) — for a = 192.168.1.40: a & mask clears the host bits to give 192.168.1.0, which equals network, so it returns 1. For b = 192.168.1.90: b & mask gives 192.168.1.64 (because bit value 64 is a network bit at /26), which does not equal 192.168.1.0, so it returns 0.

Step Expression Value (hex) Dotted
base packed 0xC0A80100 192.168.1.0
mask /26 0xFFFFFFC0 255.255.255.192
network base & mask 0xC0A80100 192.168.1.0
broadcast network | ~mask 0xC0A8013F 192.168.1.63
a & mask .40 masked 0xC0A80100 192.168.1.0 (inside)
b & mask .90 masked 0xC0A80140 192.168.1.64 (outside)

Common mistakes

Mistake 1: Assuming the broadcast always ends in .255.

WRONG: "192.168.1.0/26 broadcasts to 192.168.1.255"

That is the broadcast of a /24, not a /26. Why it is wrong: a /26 has only 6 host bits, so the block ends after 64 addresses.

RIGHT: 192.168.1.0/26 broadcasts to 192.168.1.63

Prevent it by always computing network | ~mask instead of guessing from the dots.

Mistake 2: Off-by-one on usable hosts (forgetting the minus two).

Saying a /24 holds 256 usable hosts. It holds 256 addresses, but the network (.0) and broadcast (.255) are not assignable, leaving 254 usable. Correct: usable = total - 2 for normal subnets.

Mistake 3: Confusing bigger prefix with bigger network.

Thinking /28 is larger than /24. It is the reverse: more prefix bits means fewer host bits means a smaller block. /24 = 256 addresses, /28 = 16.

Mistake 4: Shifting a 32-bit value by 32.

uint32_t mask = 0xFFFFFFFFu << (32 - prefix); // breaks when prefix == 0

When prefix == 0, the shift amount is 32, which is undefined behaviour in C and may produce garbage. Fix by special-casing prefix == 0 (return 0) as make_mask does.

Mistake 5: Using a mis-aligned network base.

Writing 192.168.1.32/26 as if it were a block base. A /26 must start on a multiple of 64. Masking .32 with the /26 mask actually snaps it back to 192.168.1.0, so the "block" you meant is not the block you get. Recognise this by checking that base & mask == base.

Debugging tips

Compiler warnings. Compile with -Wall -Wextra. A warning like "left shift count >= width of type" is the compiler telling you a shift is 32 or more — the /0 or /32 edge case. Add the guard.

Signed vs unsigned surprises. If your mask math produces sign-extended garbage (huge negative dotted numbers), you probably used int instead of uint32_t, or ~mask on a signed value. Use uint32_t/unsigned throughout.

Membership test always returns the same answer. If every IP tests as "inside," your mask is probably all zeros — check that make_mask received the prefix you think it did, and that you did not pass a prefix of 0 by accident.

Wrong broadcast address. Print the mask in dotted form and confirm it has the expected number of one-bits. A mask like 255.255.255.0 for a /26 means you built the mask for the wrong prefix.

Questions to ask when it doesn't work:

  • Did I clear the host bits before comparing (ip & mask), or compare raw addresses?
  • Is my prefix actually the number of bits, not the third octet?
  • Did I handle /0, /31, and /32 specially?
  • Is my block base aligned (base & mask == base)?

Cross-check by hand or tool. ipcalc 192.168.1.0/26 (a common Linux utility) prints the network, broadcast, and host range so you can confirm your program's output.

Memory safety

This lesson is concept-track, but the natural implementations are in C and the same bit math powers real firewall and routing code, so the robustness concerns are worth stating precisely.

Undefined behaviour from over-wide shifts. In C, shifting a 32-bit value by 32 or more bits is undefined. Both 0xFFFFFFFFu << (32 - prefix) (when prefix == 0) and 1u << (32 - prefix) (when prefix == 0) hit this. Always guard the boundary prefixes (/0, /31, /32) as the sample code does.

Use fixed-width unsigned types. Represent addresses as uint32_t (or unsigned where 32-bit). Signed integers make ~mask and right-shifts sign-extend, silently corrupting the high byte. Unsigned math also makes wraparound well-defined.

Integer under/overflow in host counts. The naive usable = (1u << (32 - prefix)) - 2 under-flows to a gigantic number for /31 and /32. Branch on the prefix before subtracting.

Input validation (the security angle). When this logic decides "is this IP in the allowed range," it is an access-control decision. Validate that the prefix is in 0..32 and reject anything else before computing a mask — a prefix of 40 or -1 should be an error, not a silent wrong answer. When parsing untrusted CIDR strings, validate each octet is 0..255 and that the base is aligned; a permissive parser that quietly "fixes" a mis-aligned or out-of-range block can widen an allow-list far beyond what was intended. Fail closed: on any parse doubt, deny rather than allow.

Real-world uses

Concrete uses.

  • Cloud networking. AWS VPCs, Azure VNets, and GCP networks are all defined by CIDR blocks (e.g. 10.0.0.0/16), then carved into subnets per availability zone. Security groups and firewall rules allow or deny traffic by CIDR.
  • Routing. Internet routers store routes as CIDR prefixes and forward each packet to the longest matching prefix — the exact (ip & mask) == network test, run at line rate.
  • Firewalls and allow-lists. "Allow SSH from 203.0.113.0/24" is a membership test on every incoming connection.
  • Authorized security assessments. Engagement scope is written as CIDR blocks; the tester must confirm every target IP is inside an approved block before touching it.

Professional best-practice habits.

Beginner: Always compute the network and broadcast with & mask and | ~mask rather than eyeballing the dotted numbers. Name variables meaningfully (prefix, mask, network, broadcast). Print intermediate values while learning so you can see the masking happen.

Advanced: Validate prefixes and octets on input and fail closed. Prefer well-tested libraries (inet_pton, ipaddress in Python, net/netip in Go) over hand-rolled parsers for production access-control paths. Handle /31 and /32 explicitly. Keep the membership check branch-free and unsigned for performance in hot paths. Document whether a range is inclusive of network/broadcast to avoid off-by-one bugs across a team.

Practice tasks

Beginner 1 — Read a block. Objective: For 172.16.5.0/24, state the subnet mask, total addresses, usable hosts, network address, and broadcast address. Requirements: Show your reasoning (host bits = 32 - prefix). No code required. Expected answer shape: mask 255.255.255.0, 256 total, 254 usable, network 172.16.5.0, broadcast 172.16.5.255. Concepts: prefix length, mask, network/broadcast, minus-two rule.

Beginner 2 — Prefix ⇄ mask table. Objective: Fill in the mask and usable-host count for /25, /27, /28, and /30. Requirements: Build each mask by counting one-bits; compute usable hosts as 2^(32-prefix) - 2. Hint: /27 has 5 host bits. Concepts: mask construction, host counting.

Intermediate 1 — Membership by hand. Objective: Determine whether each of 10.0.1.200, 10.0.2.5, and 10.0.0.9 is inside 10.0.0.0/23. Requirements: First compute the block's address range (/23 = 512 addresses), then decide each case. Input/output example: 10.0.1.200 -> inside; 10.0.2.5 -> outside. Hint: A /23 covers two consecutive /24s, i.e. 10.0.0.010.0.1.255. Concepts: mask-and-compare membership.

Intermediate 2 — Subnet a block. Objective: Split 192.168.10.0/24 into four equal subnets. Requirements: Give the new prefix and, for each subnet, its network address, broadcast address, and usable host range. Constraint: The four subnets must be equal-sized and non-overlapping. Hint: Borrow 2 host bits -> /26, step of 64. Concepts: subnetting, block alignment.

Challenge — CIDR calculator in C. Objective: Write a program that reads an address and prefix (e.g. from argv as 10.20.30.40 22) and prints the network address, broadcast address, first and last usable host, netmask, and usable host count. Requirements: Use uint32_t; validate that the prefix is 0..32 and each octet is 0..255, printing an error and exiting non-zero on bad input. Handle /0, /31, and /32 correctly. Free/close nothing is needed, but guard every shift. Input/output example: input 10.20.30.40 22 -> network 10.20.28.0, broadcast 10.20.31.255, usable 10.20.28.110.20.31.254, 1022 usable hosts. Hint: reuse make_mask and the byte-packing idea from this lesson; parse with sscanf and check its return count. Concepts: mask construction, network/broadcast, host counting, input validation, shift guards.

Summary

  • CIDR writes a network as address/prefix. The prefix is the number of leading network bits; the rest are host bits (32 - prefix of them).
  • Total addresses = 2^(host bits). Usable hosts = that minus 2 (network + broadcast), except /31 (2 usable) and /32 (1 host).
  • Build the mask as a run of ones then zeros: 0xFFFFFFFF << (32 - prefix), guarding the shift-by-32 case at /0.
  • Network address = ip & mask (host bits cleared). Broadcast = network | ~mask (host bits set).
  • Membership — the everyday "is this in scope?" check — is one line: (ip & mask) == (network & mask).
  • Subnetting borrows host bits to make several smaller equal blocks; each block must start on an address aligned to its size.
  • Common mistakes: guessing the broadcast from the dots, forgetting the minus-two, thinking a bigger prefix means a bigger network, shifting by 32, and using mis-aligned or unvalidated blocks. Use uint32_t, validate input, and fail closed when the logic guards an access decision.

Practice with these exercises