A from-scratch guide that teaches you C++ and then uses it to make real things move — lights, motors, and eventually a smart door lock.
Who this is for: you've never written a single line of C++. That's perfect. Everything here is explained in plain words, with tiny examples you can run, what you should see when you run them, and honest warnings about the traps. Read it on your phone, your laptop, wherever.
Goal: understand how C++ is different from web coding, install what you need, and run your very first program.
Wait — how is this different from what I already do?
You're a web developer. When you write JavaScript or Go for the web, you usually save a file and it just runs — sometimes it even reloads by itself in the browser. C++ is a little more old-fashioned, and that's on purpose.
🧒 Picture this
Think of JavaScript like speaking out loud — you say a sentence and it happens right away. C++ is like writing a letter and mailing it. First you write it (your code), then a special machine called a compiler reads your whole letter and translates it into the tiny language the computer's brain actually speaks (called machine code). Only after that does the program run. This extra step is why C++ programs are so fast and can control tiny chips inside a door lock.
So the C++ loop is always three steps:
Write your code in a file (ending in .cpp).
Compile it — turn it into a runnable program.
Run the program.
What is a "compiler" exactly?
A compiler is just another program. The most common free ones are called g++ (part of a toolkit called GCC) and clang. You'll type a command, point it at your file, and it hands you back a program you can run.
Installing your tools
You have two "worlds" to set up. Don't worry — you only need the first one for now.
World 1 — C++ on your computer (for learning the language)
Pick the guide for your operating system below and follow it top to bottom. Every step is spelled out — you don't need to already know any of these tools.
To understand the process, let's install MinGW-w64 via MSYS2. MinGW-w64 is a popular, free toolset on Windows. It gives you up-to-date native builds of GCC and other helpful C++ tools and libraries.
Run the installer and follow the steps of the installation wizard. Note: MSYS2 requires 64-bit Windows 10 or newer.
In the wizard, choose your desired Installation Folder. Write this directory down for later. In most cases the recommended folder (C:\msys64) is perfect. The same applies to the Start Menu shortcuts step. When it finishes, make sure the Run MSYS2 now box is ticked and click Finish. An MSYS2 terminal window opens automatically.
In that terminal, install the MinGW-w64 toolchain by running this command:MSYS2 terminal
Accept the default (all) packages in the toolchain group by pressing Enter.
Enter Y when it asks whether to proceed with the installation, then wait for it to finish downloading.
Add the MinGW-w64 bin folder to your Windows PATH so the tools work from anywhere:
In the Windows search bar, type Settings to open Windows Settings.
Search for Edit environment variables for your account and open it.
Under User variables, select the Path variable, then click Edit.
Click New and add the MinGW-w64 bin folder you recorded during install. With the default install, that path is: C:\msys64\ucrt64\bin
Click OK, then OK again to save. You must reopen any open terminal windows for the new PATH to take effect.
Check the tools are installed and visible. Open a new Command Prompt and type:Command Prompt
gcc --versiong++ --versiongdb --version
▶ What you should see (version numbers will vary)
gcc.exe (Rev...) 14.2.0
g++.exe (Rev...) 14.2.0
GNU gdb (GDB) 15.x
⚠️
If a command "is not recognized": the PATH step didn't take. Close every terminal, double-check the folder you added is exactly C:\msys64\ucrt64\bin (or your custom install path + \ucrt64\bin), and open a fresh Command Prompt.
💡
To compile from here on: you'll use g++ hello.cpp -o hello then run hello.exe, exactly as shown in the "first program" step below.
And for everyone — a code editor
Install VS Code (free) — you may already use it for React.
Open VS Code, click the Extensions icon on the left sidebar (the four little squares).
Search for C/C++ and install the one by Microsoft. This gives you error highlighting, autocomplete, and one-click running.
Now point that extension at the compiler you just installed. Open the Command Palette (Ctrl+Shift+P, or Cmd+Shift+P on Mac), type Preferences: Open User Settings (JSON), and press Enter. Your global settings.json opens.
Add these four entries at the end of the JSON object, then save with Ctrl+S:settings.json
If your settings.json already has entries, paste just the four lines inside the existing outer { } — and make sure the line before them ends with a comma.
💡
What those four lines actually do:compilerPath tells IntelliSense which compiler you use — set it wrong (or leave it out) and VS Code red-squiggles perfectly valid GCC code. cStandard/cppStandard pin the language version so autocomplete offers modern C++17 features. intelliSenseMode tells it to think in GCC, not MSVC.
⚠️
Those values are for Windows + MSYS2. On macOS use "compilerPath": "/usr/bin/clang++" and "intelliSenseMode": "macos-clang-arm64" (or macos-clang-x64 on Intel). On Linux use "compilerPath": "/usr/bin/g++" and "intelliSenseMode": "linux-gcc-x64". The two Standard lines stay the same everywhere.
World 2 — The hardware toolkit (for later, Module 5+)
When we start moving real things, you'll install PlatformIO (an extension inside VS Code) and buy an ESP32 board. We'll cover that when we get there. Ignore it for now.
💡
Tip: Can't install anything right now? Use an online compiler like godbolt.org or onlinegdb.com. You can literally paste the examples below and hit "Run." Great for reading this on your phone.
Your first program
Make a file called hello.cpp and type this in exactly:
hello.cpp
// This line borrows a "toolbox" for printing to the screen.#include <iostream>// Every C++ program starts running inside "main".int main() { std::cout << "Hello! I am about to control robots.\n"; return 0;}
Now let's read every piece, because these appear in every program you'll ever write:
#include <iostream> — "Please bring me the input/output toolbox." iostream = "in-out-stream." It's what lets us print things.
int main() { ... } — the starting door of your program. When you run the program, the computer walks through this door and does whatever is inside the { }.
std::cout — "console out." Think of it as the screen. The << arrows push text toward the screen.
"...\n" — the text to show. \n means "new line" (like pressing Enter).
return 0; — tells the computer "I finished successfully." 0 means "all good, no errors."
Every statement ends with a semicolon; — like a full stop at the end of a sentence. Forgetting it is the #1 beginner error.
Compile and run it
terminal
# Step 1: compile hello.cpp into a program called "hello"g++ hello.cpp -o hello# Step 2: run it (Mac/Linux)./hello# On Windows the program is hello.exe, so just type:hello.exe
▶ What you should see
Hello! I am about to control robots.
⚠️
If it doesn't work: the most common messages are 'g++' is not recognized (the compiler isn't installed or not on your PATH), or an error pointing at a line number (usually a missing ; or a typo like cout without std::). Read the line number the compiler gives you — it's almost always right.
✅ Milestone 0
You compiled and ran real C++ and printed a message. You now understand write → compile → run. That's the whole rhythm of the language.
Goal: learn the basic building blocks — variables, types, decisions, loops, and functions — the grammar of C++.
Variables: labelled boxes
🧒 Picture this
A variable is a labelled box where you keep one thing. You write a label on it (the name) and put something inside (the value). In C++ you also have to say what kind of thing goes in the box before you use it. That "kind" is called a type.
variables.cpp
#include <iostream>int main() { int age = 10; // a whole number double price = 4.50; // a number with a decimal point bool isFun = true; // true or false only char grade = 'A'; // a single character std::cout << "Age: " << age << "\n"; std::cout << "Price: " << price << "\n"; return 0;}
▶ What you should see
Age: 10
Price: 4.5
The types you'll actually use
int — a whole number like 7 or -40.
double — a decimal number like 3.14.
bool — only true or false (like a light switch).
char — one single letter or symbol, written in single quotes: 'x'.
std::string — a piece of text like "door" (needs #include <string>).
💡
Hardware tip — this matters a LOT later: tiny chips have tiny memory, so C++ gives you exact-size number types like uint8_t (a whole number from 0–255, using 8 bits) and int32_t. When we control a door lock, you'll use these to squeeze every byte. For now just know they exist — they're just ints with a promised size.
Doing math
math.cpp
int a = 7, b = 2;std::cout << a + b << "\n"; // 9std::cout << a - b << "\n"; // 5std::cout << a * b << "\n"; // 14std::cout << a / b << "\n"; // 3 <-- surprise! (see warning)std::cout << a % b << "\n"; // 1 (remainder: "modulo")
⚠️
The classic gotcha:7 / 2 gives 3, not 3.5! When both numbers are whole numbers (int), C++ throws away the decimal. If you want 3.5, make one a decimal: 7.0 / 2. This bug bites every beginner at least once.
Making decisions: if
🧒 Picture this
An if is a fork in the road. "IF the key is correct, open the door. OTHERWISE, stay locked." The computer checks the condition and only walks down the matching path.
One equals vs two:= means "put this value in the box." == means "are these two things equal?" Writing if (pin = 1234) by mistake actually changes pin instead of checking it. Two equals for comparing. Always.
Other comparisons: > greater, < less, >=, <=, and != ("not equal"). You can combine checks with && ("and") and || ("or").
Repeating: loops
🧒 Picture this
A loop is doing the same thing over and over without copy-pasting. Like a blinking light: on, off, on, off... forever, or a set number of times.
loops.cpp
// A "for" loop: repeat a known number of times.for (int i = 1; i <= 3; i++) { std::cout << "Blink number " << i << "\n";}// A "while" loop: repeat as long as something is true.int fuel = 3;while (fuel > 0) { std::cout << "Fuel left: " << fuel << "\n"; fuel--; // take one away each round}
▶ What you should see
Blink number 1
Blink number 2
Blink number 3
Fuel left: 3
Fuel left: 2
Fuel left: 1
Reading the for loop out loud: "Start i at 1; keep going while i is 3 or less; after each round, add 1 to i (i++)."
⚠️
Watch out for forever-loops: if the while condition never becomes false (you forgot fuel--), the program runs forever and freezes. On a computer you press Ctrl+C to stop it. On a chip, it just hangs.
Functions: naming a bundle of steps
🧒 Picture this
A function is like a recipe card you write once and use many times. Instead of re-explaining "how to open the door" everywhere, you write it once, give it a name, and just say the name whenever you need it.
functions.cpp
#include <iostream>// A recipe named "add" that takes two ints and hands back an int.int add(int x, int y) { return x + y;}// A recipe that returns nothing (void) — it just does something.void greet(std::string name) { std::cout << "Hello, " << name << "!\n";}int main() { int total = add(3, 4); // total is now 7 std::cout << total << "\n"; greet("future engineer"); return 0;}
▶ What you should see
7
Hello, future engineer!
Anatomy of a function: int add(int x, int y) means "a recipe called add that gives back an int, and needs two ints to work." The stuff in the brackets are parameters (ingredients). void means "gives nothing back."
A note about header files (you'll see these everywhere)
Big programs get split into two kinds of files: .h (header — the "menu" that announces what functions exist) and .cpp (the "kitchen" where they're actually written). For now you can keep everything in one .cpp file. Just know that when you see #include "lock.h", it means "bring in the menu from lock.h."
✅ Milestone 1 — build this
Write a small program: a temperature converter. Ask nothing yet — just set double celsius = 25; and print the Fahrenheit using a function double toF(double c) that returns c * 9.0 / 5.0 + 32. Expected output for 25: 77. Bonus: loop and print a little table from 0 to 30 in steps of 10.
Goal: understand where your variables actually live, what a pointer is, and how to avoid the famous C++ memory bugs. This is the scary-sounding part that's actually simple once you see it.
Where do variables live?
🧒 Picture this
Your computer's memory is like a giant wall of numbered lockers. Every variable you make gets put into one of these lockers. The number of the locker is called its address. Usually you don't care which locker — you just use the label (the variable name). But sometimes you want to hand someone the locker number instead of the contents. That number is what a pointer holds.
Stack vs heap — two kinds of storage
The stack is like a stack of sticky notes on your desk. Fast, automatic, and cleaned up for you the moment a function ends. Almost everything you've written so far lives here.
The heap is like a big warehouse where you can ask for storage that stays around as long as you want — but you are responsible for giving it back when you're done. Forget, and you've got a mess (a "leak").
Pointers: a note that says "it's over there"
pointers.cpp
#include <iostream>int main() { int score = 42; // &score means "the address (locker number) of score". int* p = &score; // p is a pointer that points AT score std::cout << score << "\n"; // 42 (the value) std::cout << p << "\n"; // something like 0x7ffe... (the address) std::cout << *p << "\n"; // 42 (*p means "go to that locker and read it") *p = 100; // change the value THROUGH the pointer std::cout << score << "\n"; // 100 — we changed score without touching it directly! return 0;}
▶ What you should see (address will differ)
42
0x7ffee3b4c9ac
42
100
Two little symbols do all the work:
&thing = "the address of thing" (where's its locker?).
*pointer = "the value at that address" (open the locker and look inside). This is called dereferencing.
💡
Why bother? Pointers let you share one piece of data instead of copying it, and they let hardware code say "the button is wired to this exact locker in the chip." Your Go experience helps here — Go has pointers too, with the same & and *. C++ is just stricter about cleanup.
Asking for warehouse space: new and delete
heap.cpp
int* box = new int; // ask the warehouse for a locker*box = 7; // put 7 in itstd::cout << *box; // 7delete box; // GIVE THE LOCKER BACK when donebox = nullptr; // good habit: mark it as "points to nothing now"
⚠️
The two famous bugs: Leak — you use new but forget delete. The locker is never returned; do it a million times and you run out of memory. On a door lock with 1000 lockers total, a leak can crash it in minutes. Dangling pointer — you delete a locker but keep using the pointer. That's reading a locker that now belongs to someone else. Chaos. Setting it to nullptr after deleting helps you catch this.
The grown-up solution: RAII & smart pointers
🧒 Picture this
Remembering to clean up by hand is hard, so C++ invented a rule: whatever cleans up, do it automatically when the thing goes out of sight. This rule has an ugly name — RAII — but the idea is simple: tie the cleanup to the lifetime of a helper object. When the helper disappears, it takes out the trash for you.
smart.cpp
#include <memory>#include <iostream>int main() { // unique_ptr owns the locker and deletes it AUTOMATICALLY. std::unique_ptr<int> box = std::make_unique<int>(7); std::cout << *box << "\n"; // 7 // No delete needed! When 'box' goes out of scope, cleanup happens for you. return 0;}
💡
Rule of thumb for a beginner: avoid raw new/delete in your own code. Reach for std::unique_ptr (one owner) or std::vector (a growable list, coming in Module 4) and let C++ clean up. You'll dodge 90% of memory bugs. (On the tiniest chips people sometimes avoid these too — but that's an advanced detail for much later.)
Text: std::string vs old C-strings
You'll see two ways to hold text. Prefer the first:
text.cpp
#include <string>std::string name = "front door"; // the friendly, safe waystd::cout << name.length(); // 10 — it knows its own size// The old "C-string" is just an array of chars ending in a hidden 0.// You'll meet these on hardware. They're easy to overflow, so be careful.const char* old = "hi";
✅ Milestone 2 — build this
Write a program that makes a std::unique_ptr<int>, stores your age in it, prints it via *, then changes it and prints again — all without ever writing delete. If it compiles, runs, and doesn't crash, you've handled memory the modern, safe way.
Goal: learn classes and objects — how to bundle data and actions together to model real things like a "Lock." This is the heart of writing bigger programs.
What's an "object"?
🧒 Picture this
Up to now you've had loose variables floating around. But real life comes in things: a dog has a name, an age, and it can bark. A door lock has a state (locked or open) and it can lock and unlock. A class is a blueprint that says "a Lock has these facts and can do these actions." An object is one actual lock built from that blueprint.
Blueprint = class. Real item made from it = object (also called an instance). One blueprint, many objects — like one cookie cutter, many cookies.
lock.cpp
#include <iostream>#include <string>class Lock {public: // stuff the outside world may use std::string name; // a FACT the lock stores (a "member") bool isOpen = false; // starts locked // ACTIONS the lock can do ("methods"): void open() { isOpen = true; std::cout << name << " is now OPEN.\n"; } void lock() { isOpen = false; std::cout << name << " is now LOCKED.\n"; }};int main() { Lock front; // build one real lock from the blueprint front.name = "Front door"; front.open(); // call its action with a dot front.lock(); return 0;}
▶ What you should see
Front door is now OPEN.
Front door is now LOCKED.
Notice the dot: front.open() means "tell the front object to run its open action." The data inside a class is called members; the actions are methods.
Constructors: how a thing is born
🧒 Picture this
A constructor is a special setup action that runs the moment an object is created — like the factory settings applied when a new lock comes out of the box. It has the same name as the class.
constructor.cpp
class Lock {public: std::string name; // Constructor: runs automatically when a Lock is made. Lock(std::string n) { name = n; std::cout << "A lock named " << name << " was installed.\n"; }};int main() { Lock back("Back door"); // constructor runs right here return 0;}
▶ What you should see
A lock named Back door was installed.
There's also a destructor (~Lock()) that runs when the object goes away — the perfect place to "turn off the motor" or "release the pin." That's RAII from Module 2 in action.
Public vs private: hiding the wiring
🧒 Picture this
A microwave has buttons on the outside (public) and dangerous electronics inside a sealed box (private). You press "Start"; you don't reach into the high-voltage parts. Classes work the same way — public is the buttons, private is the sealed insides.
private.cpp
class Lock {private: // only the class itself can touch these int secretPin = 1234;public: // the buttons outsiders press bool tryPin(int guess) { return guess == secretPin; // checks safely, never reveals the pin }};
This is called encapsulation — a fancy word for "keep the risky stuff inside and only expose safe buttons." Hugely important for a lock: nobody outside should be able to just read the PIN.
Inheritance: family trees of things
🧒 Picture this
Say you have different kinds of locks: one turns a little motor (a servo), another clicks an electromagnet (a solenoid). They're all Locks, they just open differently. Inheritance lets you write the shared stuff once in a parent class, then make child classes that add their own twist.
inherit.cpp
#include <iostream>class Lock { // the parent (base class)public: // "virtual" means: children are allowed to replace this. virtual void open() { std::cout << "A generic lock opens.\n"; }};class ServoLock : public Lock { // a child of Lockpublic: void open() override { // replace the parent's version std::cout << "The servo turns the bolt.\n"; }};class SolenoidLock : public Lock {public: void open() override { std::cout << "The solenoid clicks open.\n"; }};int main() { ServoLock a; SolenoidLock b; a.open(); // The servo turns the bolt. b.open(); // The solenoid clicks open. return 0;}
▶ What you should see
The servo turns the bolt.
The solenoid clicks open.
The magic word virtual plus override is called polymorphism — "many shapes." It means you can keep a list of different lock types and just call open() on each; every one does its own correct thing. This is exactly how real device drivers are organized.
💡
Coming from React/Go: Go doesn't have classes exactly, but this "shared behavior, specialized versions" idea is like Go interfaces. If you've used React components that extend a base, same instinct. You already think this way.
✅ Milestone 3 — build this
Create a base class Actuator with a virtual method activate(). Make two children, ServoLock and SolenoidLock, each printing a different message. In main, create one of each and call activate(). You've just modeled the exact class structure we'll use for the real smart lock later.
Goal: learn the shortcuts and ready-made tools that make C++ pleasant — lists, maps, auto, lambdas — so you're not reinventing the wheel.
auto: let the compiler figure out the type
🧒 Picture this
Sometimes the type is obvious and typing it out is annoying. auto says "you know what kind of thing this is — you fill it in for me." Like pointing at a fruit and saying "give me one of those" instead of naming it.
auto.cpp
auto age = 10; // compiler knows: this is an intauto price = 4.5; // this is a doubleauto name = std::string("door"); // a string
⚠️
Don't overdo it:auto is great for long obvious types, but if it makes your code a mystery ("what even is this?"), just write the type. Clarity beats cleverness.
Vectors: a list that grows
🧒 Picture this
A vector is a stretchy list. You can add items to the end, remove them, count them — no need to know the size ahead of time. It's the single most useful container in C++. Think of it as JavaScript's array.
vector.cpp
#include <iostream>#include <vector>#include <string>int main() { std::vector<std::string> doors; // an empty list of door names doors.push_back("Front"); // add to the end doors.push_back("Back"); doors.push_back("Garage"); std::cout << "We have " << doors.size() << " doors.\n"; // "range-based for": visit each item, no counter needed. for (const auto& d : doors) { std::cout << "- " << d << "\n"; } return 0;}
▶ What you should see
We have 3 doors.
- Front
- Back
- Garage
That for (const auto& d : doors) line reads as: "for each door d in doors, ...". The & means "don't make a wasteful copy, just look at the real one," and const means "I promise not to change it." A very common, very good habit.
Maps: look things up by a key
🧒 Picture this
A map is like a real dictionary: you look up a word (the key) and get its meaning (the value). For us: look up a door's name and get whether it's locked.
map.cpp
#include <iostream>#include <map>#include <string>int main() { std::map<std::string, bool> locked; locked["Front"] = true; locked["Garage"] = false; if (locked["Front"]) { std::cout << "Front door is locked.\n"; } return 0;}
▶ What you should see
Front door is locked.
Lambdas: a mini-function with no name
🧒 Picture this
Sometimes you need a tiny throwaway recipe right where you're standing, and giving it a full name is overkill. A lambda is a little unnamed function you write inline. If you've used arr.map(x => x * 2) in JavaScript, you already know lambdas!
lambda.cpp
#include <iostream>int main() { // A lambda stored in a variable. [] starts it. auto doubleIt = [](int x) { return x * 2; }; std::cout << doubleIt(5) << "\n"; // 10 return 0;}
Handling "maybe" with std::optional
Sometimes an answer might not exist — "find the lock named X" could find nothing. Instead of returning a fake value, C++ has std::optional, which means "maybe a value, maybe nothing." It saves you from a lot of bugs.
optional.cpp
#include <iostream>#include <optional>std::optional<int> findPin(bool known) { if (known) return 1234; return std::nullopt; // "nothing to give"}int main() { auto result = findPin(true); if (result.has_value()) { std::cout << "PIN is " << result.value() << "\n"; } else { std::cout << "No PIN found.\n"; } return 0;}
▶ What you should see
PIN is 1234
A word on errors: exceptions vs. error codes
When something goes wrong, C++ can "throw an exception" (like raising an alarm that jumps out of the function). On big computers that's fine. But on tiny hardware chips, exceptions are often turned off because they cost memory — there, people return error codes or use optional instead. Just know both styles exist; we'll use the hardware-friendly one later.
✅ Milestone 4 — build this
Make a std::vector<std::string> of three room names, add a fourth with push_back, print how many there are, then loop and print each with a range-based for. Then make a std::map<std::string,bool> that stores whether each room's light is on. You now know the two containers you'll use constantly.
Goal: finally make base-2 and base-16 click. Why they exist, how they actually work, how to convert between all of them by hand, and where you'll meet each one in real code.
You've already met these without being told. In Module 1 we mentioned uint8_t holds "0 to 255" — why that odd number? In Module 2 the computer printed a pointer as 0x7ffee3b4c9ac — what is that? Both answers live here. And from Module 5 onward, when we start flipping individual pins on a chip, this becomes the language you think in.
The one idea behind every number system
🧒 Picture this
Every number system is just counting until you run out of digits, then starting a new column. In decimal you count 0,1,2...9 — then you're out of symbols, so you reset to 0 and put a 1 in the next column: 10. Binary runs out after 0 and 1, so it starts a new column almost immediately. Hexadecimal has sixteen symbols, so it goes much further before it needs one. Same game, different-sized alphabet.
The size of that alphabet is called the base (or radix). And each new column to the left is worth base times the column to its right. That single rule — called positional notation — is the whole trick. Everything below is just that rule with different bases.
Decimal (base 10) — the one you already know
Digits:0 1 2 3 4 5 6 7 8 9 — ten of them.
Why it exists: for the least technical reason imaginable — humans have ten fingers. There is nothing mathematically special about ten. It is a biological accident that became the world's default.
Decimal 3,507 — every slot is worth 10× the one to its right
1000s 100s 10s 1s <- place value (10^3 10^2 10^1 10^0)
3 5 0 7 <- our digits
3×1000 + 5×100 + 0×10 + 7×1
= 3000 + 500 + 0 + 7
= 3507
You do this every day without noticing. You already know that in 3507 the 3 "means" three thousand. Hold on to that instinct — binary and hex work identically, only the column values change.
Binary (base 2) — the one the machine actually uses
Digits:0 and 1 — that's it. One binary digit is called a bit.
🧒 Picture this
Why does a computer use only two digits? Because a computer is, underneath, millions of tiny switches. A switch is either on or off — there is no "seven" you can build out of a switch. Voltage present or voltage absent. That's exactly two states, so the machine counts in a system with exactly two digits. Every photo, song, and message on your computer is ultimately a very long row of switches.
Why we need it: it's not a stylistic choice, it's physics. A wire is high or low. An LED is lit or dark. A GPIO pin (Module 5) is HIGH or LOW. Binary is simply the number system that matches the hardware.
Each column doubles as you move left: 1, 2, 4, 8, 16, 32, 64, 128...
Binary 1011 0110 — every slot is worth 2× the one to its right
128 64 32 16 8 4 2 1 <- place value (2^7 ... 2^0)
1 0 1 1 0 1 1 0 <- our bits
128 + 0 + 32 + 16 + 0 + 4 + 2 + 0
= 182
💡
The shortcut you'll use forever: to read a binary number, write the place values above it (1, 2, 4, 8, 16, 32, 64, 128 — from the right), then add up only the columns that have a 1. Ignore the zeros. That's all "converting binary to decimal" means.
Bits, nibbles and bytes
One BYTE = 8 bits = 2 hex digits = a number from 0 to 255
bits 1111 1111
nibble ──── ──── each 4-bit half is a "nibble"
hex F F
value 255 (the biggest an 8-bit box can hold)
That is exactly why uint8_t stops at 255, and why colours,
pixels and bytes of memory keep bumping into the number 255.
This is the answer to the Module 1 mystery. uint8_t means "unsigned integer, 8 bits." Eight switches give 2⁸ = 256 possible combinations. Counting from zero, the largest is 255. Nothing arbitrary about it.
Where you actually meet binary
Pin states — digitalWrite(pin, HIGH) is writing a single bit to the physical world.
Flags & bitmasks — packing eight yes/no settings into one byte instead of eight separate variables. Priceless when a chip has 2 KB of RAM.
Permissions — read/write/execute is three bits.
Network masks — 255.255.255.0 is prettier as 11111111.11111111.11111111.00000000, which is why it's also written /24 (24 ones).
Why 1 KB = 1024 bytes — because 2¹⁰ = 1024, and computers count in powers of two, not powers of ten.
Hexadecimal (base 16) — binary for humans
Digits:0 1 2 3 4 5 6 7 8 9 A B C D E F — sixteen. Once we exhaust 9, we borrow letters: A is ten, B eleven, and so on up to F = fifteen.
🧒 Picture this
Binary is correct but exhausting — 1011011011110000 is impossible to read aloud or type without mistakes. Hexadecimal is a compression scheme for humans. Here's the magic: one hex digit is exactly four bits, always, with no leftovers. Because 2⁴ = 16. So a hex digit is a perfect nickname for a group of four switches, and you can translate between them by sight, no arithmetic at all.
Why we need it: hex is not a third way computers think. Computers only know binary. Hex exists purely so that we can write and read binary without losing our minds. One byte — eight bits — is always exactly two hex digits. Tidy.
Hexadecimal 0x2F — every slot is worth 16× the one to its right
16s 1s <- place value (16^1 16^0)
2 F <- our digits (F means 15)
2×16 + 15×1
= 32 + 15
= 47
The table worth memorising
Learn these sixteen rows and you can convert between binary and hex instantly, for the rest of your career. It's the only rote learning in this whole course.
Decimal
Binary (4 bits)
Hex
Decimal
Binary (4 bits)
Hex
0
0000
0
8
1000
8
1
0001
1
9
1001
9
2
0010
2
10
1010
A
3
0011
3
11
1011
B
4
0100
4
12
1100
C
5
0101
5
13
1101
D
6
0110
6
14
1110
E
7
0111
7
15
1111
F
Where you actually meet hex
Memory addresses — 0x7ffee3b4c9ac, the pointer we printed back in Module 2. Now you can read it.
Colours — #FF8800 is three bytes: red 255, green 136, blue 0. The accent colour on this very site is #22D3EE.
Hardware registers — an ESP32 datasheet will tell you a control register lives at 0x3FF44004. Nobody writes that in binary.
MAC addresses — A4:CF:12:9B:00:1E, six bytes.
Bit masks in firmware — 0xFF means "all eight bits set," and you learn to see that instantly.
Hashes, UUIDs, checksums — long strings of bytes, always shown as hex.
💡
A footnote: octal (base 8). Digits 0–7, one digit = three bits. It's mostly a museum piece now, with one survivor you use constantly: Unix file permissions. chmod 755 is octal — 7 = 111 = read+write+execute, 5 = 101 = read+execute. That's the whole mystery.
Converting between them, by hand
There are six directions, but really only three ideas. Learn these and you never need a calculator.
1. Binary → Decimal (add the place values)
Write the place values above the bits, add the columns that hold a 1. Shown above with 1011 0110 = 182.
2. Decimal → Binary (divide by 2, read remainders upward)
Convert 182 to binary — divide by 2, keep the remainders
182 ÷ 2 = 91 remainder 0 <- least significant bit (rightmost)
91 ÷ 2 = 45 remainder 1
45 ÷ 2 = 22 remainder 1
22 ÷ 2 = 11 remainder 0
11 ÷ 2 = 5 remainder 1
5 ÷ 2 = 2 remainder 1
2 ÷ 2 = 1 remainder 0
1 ÷ 2 = 0 remainder 1 <- most significant bit (leftmost)
Now read the remainders BOTTOM to TOP: 1011 0110
Check: 128 + 32 + 16 + 4 + 2 = 182 ✓
💡
Why bottom-to-top? The first remainder you get is the smallest place value (the 1s column), because dividing by 2 peels bits off the right-hand end first. So the last remainder you compute is the leftmost bit.
3. Hex → Decimal (multiply by powers of 16)
Shown above with 0x2F = 2×16 + 15 = 47. For a longer number like 0x1A3: 1×256 + 10×16 + 3×1 = 256 + 160 + 3 = 419.
4. Decimal → Hex (divide by 16, read remainders upward)
Convert 47 to hexadecimal — divide by 16, keep the remainders
47 ÷ 16 = 2 remainder 15 -> 15 is "F"
2 ÷ 16 = 0 remainder 2 -> 2 is "2"
Read BOTTOM to TOP: 2F so 47 = 0x2F
Check: 2×16 + 15 = 47 ✓
5. Binary → Hex (group into fours — the one you'll use most)
Binary -> Hex — slice into groups of 4 bits, from the RIGHT
1011 0110
──── ────
| |
B 6 (1011 = 11 = B, 0110 = 6)
So 1011 0110 = 0xB6
Check: 11×16 + 6 = 176 + 6 = 182 ✓ (same number as before)
⚠️
Always group from the RIGHT. If the leftmost group comes up short, pad it with leading zeros — they don't change the value. So 1 0110 becomes 0001 0110 = 0x16. Grouping from the left instead is the single most common mistake here, and it silently gives you the wrong number.
6. Hex → Binary (expand each digit into four bits)
Hex -> Binary — expand each digit into its own 4 bits
0x3A
| |
3 A
| |
0011 1010 (3 = 0011, A = 10 = 1010)
So 0x3A = 0011 1010 = 32 + 16 + 8 + 2 = 58
Notice that 5 and 6 need no arithmetic whatsoever — just the sixteen-row table. That is the entire reason hexadecimal was invented, and why it beat octal for modern work: bytes are 8 bits, and 8 divides evenly into two hex digits but not into octal's 3-bit groups.
Writing them in C++
The compiler doesn't care which base you type — it converts everything to binary anyway. Prefixes just tell it how to read what you wrote. Use whichever base makes your intent clearest.
literals.cpp
#include <iostream>int main() { // Three ways to write the SAME number. All of these are 255. int dec = 255; // decimal — no prefix int bin = 0b11111111; // binary — 0b prefix (C++14 and newer) int hex = 0xFF; // hexadecimal — 0x prefix std::cout << (dec == bin) << " " << (bin == hex) << "\n"; // 1 1 (both true) // Digit separators make long literals readable (C++14). int mask = 0b1011'0110; // same as 0xB6, same as 182 std::cout << mask << "\n"; return 0;}
▶ What you should see
1 1
182
Printing a number in another base
print_bases.cpp
#include <iostream>#include <bitset>int main() { int n = 182; std::cout << std::dec << n << "\n"; // 182 std::cout << std::hex << n << "\n"; // b6 std::cout << std::bitset<8>(n) << "\n"; // 10110110 // showbase puts the 0x prefix back on std::cout << std::showbase << std::hex << n << "\n"; // 0xb6 std::cout << std::dec; // IMPORTANT: put the stream back to decimal! return 0;}
▶ What you should see
182
b6
10110110
0xb6
⚠️
std::hex is sticky. It doesn't apply to just the next value — it changes the stream until you change it back. Print one number in hex and forget std::dec, and every number after it comes out in hex too. This produces some deeply confusing debugging sessions. std::bitset, by contrast, is a one-shot and affects nothing else.
The leading-zero trap
octal_gotcha.cpp
#include <iostream>int main() { int a = 13; // thirteen, as you'd expect int b = 013; // NOT thirteen! A leading zero means OCTAL -> 11 int c = 0x13; // hexadecimal -> 19 std::cout << a << " " << b << " " << c << "\n"; // 13 11 19 return 0;}
▶ What you should see
13 11 19
⚠️
A leading zero silently means octal. Writing int minutes = 09; won't even compile (9 isn't an octal digit), and 013 quietly becomes 11. This bites people aligning numbers in columns, or copying zero-padded values like 08 from a spec sheet. If you want decimal, never pad with a leading zero.
Why this matters: bit manipulation
🧒 Picture this
Picture a byte as a row of eight light switches. Sometimes you want to flick switch number 3 without disturbing the other seven. You can't say "switch 3 = on" directly — you have to speak in whole bytes. So you build a mask: a byte with a 1 in exactly the position you care about, and 0 everywhere else. Then a few operators let you set, clear, test, or flip precisely that one switch.
The four operators, in plain words:
x | mask (OR) — turn bits on. Anywhere the mask has a 1, the result has a 1.
x & mask (AND) — test or keep bits. Only bits set in both survive.
x ^ mask (XOR) — flip bits. Wherever the mask has a 1, the bit toggles.
~x (NOT) — invert everything. Combined with AND it becomes "turn this bit off."
1 << n (shift) — build a mask. Slide a single 1 left n places. 1 << 3 is 0000 1000 = 8.
bits.cpp
#include <cstdint>#include <iostream>#include <bitset>int main() { uint8_t flags = 0b0000'0000; // 8 little switches, all OFF flags |= (1 << 2); // SET bit 2 (turn it on) -> 0000 0100 flags |= (1 << 5); // SET bit 5 -> 0010 0100 bool bit2On = flags & (1 << 2); // TEST bit 2 -> true flags &= ~(1 << 2); // CLEAR bit 2 (turn it off) -> 0010 0000 flags ^= (1 << 5); // FLIP bit 5 -> 0000 0000 std::cout << std::bitset<8>(flags) << " " << bit2On << "\n"; return 0;}
▶ What you should see
00000000 1
💡
Shifting is multiplying. Sliding bits left by one doubles the number (5 << 1 = 10); shifting right halves it (>> 1), throwing away any remainder. It's the same reason adding a zero to the right of a decimal number multiplies it by ten. Chips do this in a single clock cycle, which is why you see it everywhere in firmware.
Unpacking a colour — hex, shifts and masks together
color.cpp
#include <cstdint>#include <iostream>int main() { // A colour is just three bytes packed into one number: // 0xFF 88 00 -> R=0xFF (255), G=0x88 (136), B=0x00 (0) uint32_t orange = 0xFF8800; uint8_t r = (orange >> 16) & 0xFF; // slide right 16 bits, keep last 8 uint8_t g = (orange >> 8) & 0xFF; uint8_t b = (orange ) & 0xFF; std::cout << (int)r << "," << (int)g << "," << (int)b << "\n"; return 0;}
▶ What you should see
255,136,0
Read (orange >> 16) & 0xFF as: "slide the number right by 16 bits so the red byte lands at the bottom, then use 0xFF to keep only those bottom 8 bits." This exact pattern shows up in every graphics library, every LED strip driver, and every network packet parser you will ever read.
The 255 wall
overflow.cpp
#include <cstdint>#include <iostream>int main() { uint8_t x = 255; // 1111 1111 — the biggest an 8-bit box holds x = x + 1; // no room for a 9th bit, so it wraps to 0000 0000 std::cout << (int)x << "\n"; // 0 (NOT 256!) return 0;}
▶ What you should see
0
⚠️
An 8-bit box has no ninth switch. Add 1 to 255 and it wraps silently back to 0 — no error, no warning. On a door lock, a wrapping counter can mean a retry limit that resets itself. Pick a type big enough for the range you actually need, and remember the (int) cast when printing a uint8_t, or cout treats it as a character.
The payoff: ASCII — where all four systems meet letters
🧒 Picture this
Here's the twist that makes everything above suddenly practical: computers don't store letters at all. They only store numbers. So in the 1960s everyone agreed on a phone book: "the number 65 shall mean the letter A." That agreement is called ASCII. Every character on your keyboard has an agreed number from 0 to 127 — and since it's just a number, you can write it in decimal, binary, octal, or hex. Same value, four costumes. The chart below is that phone book.
How to read the chart
Every row is one character, shown in all four number systems at once. Find A in the ASCII column and read across its row: decimal 65, binary 01000001, octal 101, hex 41. Those aren't four different facts — they're one number written four ways, exactly like the conversions you just learned. Three landmarks worth memorising:
'0'–'9' live at 48–57 (hex 0x30–0x39) — note the hex digit matches: '7' is 0x37.
'A'–'Z' start at 65 (0x41).
'a'–'z' start at 97 (0x61) — exactly 32 more than the capitals. And 32 is 0010 0000: a single bit. Lower-case and upper-case letters differ by one bit (bit 5). Case conversion is bit manipulation — the section you just read.
Rows 0–31 look strange (NUL, BEL, ESC...) — those are control characters: instructions, not letters, from the teleprinter era. Most are museum pieces, but four you will meet constantly: LF (10) is \n, the newline you've typed in every program so far; CR (13) is \r (Windows line endings are CR LF — 13 10); HT (9) is Tab; NUL (0) is the hidden terminator at the end of every C-string from Module 2. And SP (32) is just the space bar.
Dec
Binary
Oct
Hex
ASCII
Dec
Binary
Oct
Hex
ASCII
Dec
Binary
Oct
Hex
ASCII
Dec
Binary
Oct
Hex
ASCII
0
00000000
000
00
NUL
32
00100000
040
20
SP
64
01000000
100
40
@
96
01100000
140
60
`
1
00000001
001
01
SOH
33
00100001
041
21
!
65
01000001
101
41
A
97
01100001
141
61
a
2
00000010
002
02
STX
34
00100010
042
22
"
66
01000010
102
42
B
98
01100010
142
62
b
3
00000011
003
03
ETX
35
00100011
043
23
#
67
01000011
103
43
C
99
01100011
143
63
c
4
00000100
004
04
EOT
36
00100100
044
24
$
68
01000100
104
44
D
100
01100100
144
64
d
5
00000101
005
05
ENQ
37
00100101
045
25
%
69
01000101
105
45
E
101
01100101
145
65
e
6
00000110
006
06
ACK
38
00100110
046
26
&
70
01000110
106
46
F
102
01100110
146
66
f
7
00000111
007
07
BEL
39
00100111
047
27
'
71
01000111
107
47
G
103
01100111
147
67
g
8
00001000
010
08
BS
40
00101000
050
28
(
72
01001000
110
48
H
104
01101000
150
68
h
9
00001001
011
09
HT
41
00101001
051
29
)
73
01001001
111
49
I
105
01101001
151
69
i
10
00001010
012
0A
LF
42
00101010
052
2A
*
74
01001010
112
4A
J
106
01101010
152
6A
j
11
00001011
013
0B
VT
43
00101011
053
2B
+
75
01001011
113
4B
K
107
01101011
153
6B
k
12
00001100
014
0C
FF
44
00101100
054
2C
,
76
01001100
114
4C
L
108
01101100
154
6C
l
13
00001101
015
0D
CR
45
00101101
055
2D
-
77
01001101
115
4D
M
109
01101101
155
6D
m
14
00001110
016
0E
SO
46
00101110
056
2E
.
78
01001110
116
4E
N
110
01101110
156
6E
n
15
00001111
017
0F
SI
47
00101111
057
2F
/
79
01001111
117
4F
O
111
01101111
157
6F
o
16
00010000
020
10
DLE
48
00110000
060
30
0
80
01010000
120
50
P
112
01110000
160
70
p
17
00010001
021
11
DC1
49
00110001
061
31
1
81
01010001
121
51
Q
113
01110001
161
71
q
18
00010010
022
12
DC2
50
00110010
062
32
2
82
01010010
122
52
R
114
01110010
162
72
r
19
00010011
023
13
DC3
51
00110011
063
33
3
83
01010011
123
53
S
115
01110011
163
73
s
20
00010100
024
14
DC4
52
00110100
064
34
4
84
01010100
124
54
T
116
01110100
164
74
t
21
00010101
025
15
NAK
53
00110101
065
35
5
85
01010101
125
55
U
117
01110101
165
75
u
22
00010110
026
16
SYN
54
00110110
066
36
6
86
01010110
126
56
V
118
01110110
166
76
v
23
00010111
027
17
ETB
55
00110111
067
37
7
87
01010111
127
57
W
119
01110111
167
77
w
24
00011000
030
18
CAN
56
00111000
070
38
8
88
01011000
130
58
X
120
01111000
170
78
x
25
00011001
031
19
EM
57
00111001
071
39
9
89
01011001
131
59
Y
121
01111001
171
79
y
26
00011010
032
1A
SUB
58
00111010
072
3A
:
90
01011010
132
5A
Z
122
01111010
172
7A
z
27
00011011
033
1B
ESC
59
00111011
073
3B
;
91
01011011
133
5B
[
123
01111011
173
7B
{
28
00011100
034
1C
FS
60
00111100
074
3C
<
92
01011100
134
5C
\
124
01111100
174
7C
|
29
00011101
035
1D
GS
61
00111101
075
3D
=
93
01011101
135
5D
]
125
01111101
175
7D
}
30
00011110
036
1E
RS
62
00111110
076
3E
>
94
01011110
136
5E
^
126
01111110
176
7E
~
31
00011111
037
1F
US
63
00111111
077
3F
?
95
01011111
137
5F
_
127
01111111
177
7F
DEL
The classic Decimal–Binary–Octal–Hex–ASCII conversion chart, all 128 codes. (Generated from the same toString(base) conversions this section teaches — the chart and the lesson can't disagree.)
Using it from C++
In C++ a charis a small number — the chart is built into the type system. Print it as a character or as its code; it's the same byte:
ascii.cpp
#include <iostream>int main() { char letter = 'A'; std::cout << (int)letter << "\n"; // 65 — the chart's Decimal column // Lowercase = uppercase + 32. And 32 is exactly bit 5! std::cout << (char)(letter + 32) << "\n"; // 'a' (65 + 32 = 97) std::cout << (char)('a' & ~(1 << 5)) << "\n"; // 'A' — clear bit 5 to upper-case // Digit characters are NOT their values: '7' is code 55. char digit = '7'; int value = digit - '0'; // 55 - 48 = 7. The classic trick. std::cout << value << "\n"; // Text is just bytes: for (char c : {'J', 'B'}) std::cout << (int)c << " "; // 74 66 std::cout << "\n"; return 0;}
▶ What you should see
65
a
A
7
74 66
💡
Why this matters for hardware: when Module 6's Serial.println("Button pressed!") runs, what travels down the USB cable is literally the chart's numbers — 66 117 116 116 111 110... one byte per character, ending with 13 10 (CR LF). When the MQTT lock in Module 8 receives "OPEN", it receives the bytes 0x4F 0x50 0x45 0x4E. Every protocol you'll ever debug with a serial monitor or packet sniffer is this chart scrolling past.
⚠️
The classic beginner bug:'7' (the character, code 55) is not 7 (the number). Read a digit from Serial or a keypad and do math with it directly, and everything is mysteriously off by 48. The fix is the chart: subtract '0' (48) first — digit - '0'.
Practice — do these on paper
No calculator. Paper is the point: the muscle memory is what makes hex readable at a glance later.
Convert 0110 1101 to decimal.
Convert 200 to binary, then to hex.
Convert 0xC4 to binary, then to decimal.
What single hex byte turns on bits 0, 1, 2 and 3 and nothing else?
The colour #22D3EE — what are its red, green and blue values in decimal?
Why can a uint8_t never hold the number 300?
✅ Interlude milestone — build this
Write a program that takes a number and prints it in all three bases at once, using std::bitset for binary and std::hex for hex (remember to reset with std::dec). Then write two tiny functions: setBit(value, n) and clearBit(value, n), using the shift-and-mask patterns above. Print the byte after each call. You've now built, from scratch, the exact tool every firmware driver uses to talk to a hardware register — which is precisely what Module 6 is about to do.
Goal: understand just enough about electricity and tiny computer chips to safely make a light blink and read a button — without frying anything.
Electricity in one picture
🧒 Picture this
Think of electricity like water in pipes. Voltage is the water pressure pushing it along. Current is how much water is actually flowing. Resistance is a narrow spot in the pipe that slows the flow. That's it. Three ideas.
Voltage (V) — the push. Our chips use small, safe pushes like 3.3V or 5V. (Wall sockets are ~230V — we never touch those directly.)
Current (A, or milliamps mA) — the flow. Too much through a small part burns it out.
Resistance (Ω, "ohms") — slows the flow. A resistor is a part whose only job is to slow current down so delicate parts survive.
They're linked by one tiny formula, Ohm's Law: Voltage = Current × Resistance. You rarely do the math by hand as a beginner, but it's why we add a resistor in front of an LED: to keep the current gentle.
What is a microcontroller?
🧒 Picture this
A microcontroller is a whole tiny computer on a single chip — brain, memory, and little metal legs (pins) it uses to touch the outside world. It's not as powerful as your laptop, but it's cheap, sips power, and is perfect for living inside a door lock and running one job forever. The ESP32 is our chosen one because it also has Wi-Fi and Bluetooth built in.
Pins: the chip's fingers
Those little metal pins are called GPIO — "General Purpose Input/Output." Each pin can either:
Output — the chip pushes electricity out to turn something on (light an LED, spin a motor).
Input — the chip reads whether electricity is coming in (is the button pressed?).
Digital vs analog
Digital = only two states: ON (HIGH) or OFF (LOW). Like a light switch. A button is digital.
Analog = a smooth range in between, like a dimmer knob. A light sensor gives analog readings.
The breadboard: building without soldering
🧒 Picture this
A breadboard is a plastic board full of little holes that grip wires and secretly connect rows of holes together underneath. It lets you plug parts together like LEGO and pull them apart again — no glue, no soldering. Perfect for experimenting.
Pull-up and pull-down resistors (the button puzzle)
Here's a subtle thing that confuses everyone at first. When a button is not pressed, the input pin is connected to... nothing. And "nothing" makes the chip read random garbage (called "floating"). To fix it, we gently tie the pin to a known state with a resistor:
Pull-up: resistor gently holds the pin HIGH; pressing the button pulls it LOW.
Pull-down: resistor gently holds the pin LOW; pressing pulls it HIGH.
💡
Good news: the ESP32 has these resistors built in. In code you just say INPUT_PULLUP and it handles it. No extra part needed. We'll do exactly that in Module 6.
PWM: faking "in-between" with fast blinking
🧒 Picture this
A digital pin can only say ON or OFF — so how do you make an LED half-bright, or make a motor turn to a middle position? You blink it super fast, like ON 50% of the time. Your eye sees "half bright"; a motor feels "half power." This trick is called PWM (Pulse-Width Modulation). It's how we'll tell a servo motor exactly how far to turn — which is how a lock knows how far to slide the bolt.
Datasheets & staying safe
Every part comes with a datasheet — a document telling you its voltage, current limits, and what each pin does. You don't read all of it; you skim for "which pin is which" and "max voltage."
⚠️
Golden safety rules for beginners: • Never connect a pin straight to another without knowing the voltage — match 3.3V to 3.3V. • Always put a resistor in series with an LED (a 220Ω or 330Ω one). • Motors and solenoids pull a lot of current — never power them directly from a signal pin; use a transistor or relay/driver board as a helper. (We cover this in Module 6.) • Double-check wiring before plugging in power. Unplug when rewiring.
Your shopping list for the hands-on parts
1× ESP32 dev board (with USB cable)
1× breadboard + a pack of jumper wires
A few LEDs and 220Ω resistors
1× push button
1× small servo motor (SG90) — this becomes our "lock"
Later: a relay module or solenoid lock, an RFID reader (RC522)
Your first real circuit, drawn out
This is exactly the circuit Milestone 5 asks you to build. Trace every wire with your finger before you plug anything in — and hover the wires and parts for hints:
🔌 wiring — LED + push button (Milestone 5 practice circuit)
GPIO 2 → LEDGPIO 4 → buttonGND
ESP32 pin
Connects to
Wire
GPIO 2
220Ω resistor → LED long leg
yellow
GPIO 4
button leg A
green
GND
− rail → LED short leg + button leg B
black
The button has NO resistor — in code we use INPUT_PULLUP, which switches on a resistor already inside the ESP32. Pressing the button connects GPIO 4 to GND, so the pin reads LOW when pressed.
✅ Milestone 5 — build this (on paper first!)
On a breadboard, wire an LED (through a 220Ω resistor) to a GPIO pin, and a push button to another pin using the chip's built-in pull-up. Don't write code yet — just build the circuit and photograph it. Getting comfortable plugging things in safely is the whole win here.
Goal: write C++ that actually moves the physical world — blink an LED, read a button, and drive a servo to open and close a latch.
The new shape of a hardware program
🧒 Picture this
On your laptop, a program starts at main(), does its thing, and ends. On a chip, the program should never end — a door lock has to keep watching forever. So hardware programs have two special parts instead of main: setup() runs once at power-on (get ready), and loop() runs over and over forever (do the job).
🔌 wiring — blink.cpp (LED on GPIO 2)
GPIO 2 → resistor → LEDGND (black wire)
ESP32 pin
Connects to
Wire
GPIO 2
220Ω resistor → LED long leg (anode)
yellow
GND
breadboard − rail → LED short leg
black
Current flows out of GPIO 2, through the resistor (which keeps it gentle), lights the LED, and returns to GND. The LED's LONG leg is the + side — if it never lights, flip the LED first.
blink.cpp (Arduino/ESP32 style)
// This runs ONCE when the board powers up.void setup() { pinMode(2, OUTPUT); // pin 2 will PUSH power out (to an LED)}// This runs FOREVER, top to bottom, again and again.void loop() { digitalWrite(2, HIGH); // LED ON delay(500); // wait 500 milliseconds (half a second) digitalWrite(2, LOW); // LED OFF delay(500); // wait again}
▶ What you should see — in the REAL WORLD
The LED blinks: on for 0.5s, off for 0.5s, forever.
(No text on a screen — the "output" is a physical light!)
New words, all plain once named:
pinMode(2, OUTPUT) — "Pin 2, get ready to push power out."
delay(500) — "Pause for 500 milliseconds." (1000 ms = 1 second.)
💡
How to actually run this: install the PlatformIO extension in VS Code (or the Arduino IDE), pick your ESP32 board, plug it in by USB, and click Upload. The code gets compiled and "flashed" onto the chip. Then it runs on its own — you can even unplug from the computer and power it from a phone charger.
Reading a button
The wiring is the Milestone 5 circuit from Module 5 — LED (through its 220Ω resistor) on GPIO 2, button on GPIO 4, both returning to GND.
button.cpp
const int BUTTON = 4; // button wired to pin 4const int LED = 2;void setup() { pinMode(LED, OUTPUT); pinMode(BUTTON, INPUT_PULLUP); // use the chip's built-in pull-up Serial.begin(115200); // open a text channel back to the computer}void loop() { // With INPUT_PULLUP, the pin reads HIGH normally, // and LOW when the button is actually pressed. if (digitalRead(BUTTON) == LOW) { digitalWrite(LED, HIGH); Serial.println("Button pressed!"); // print to the computer } else { digitalWrite(LED, LOW); }}
▶ What you should see
Hold the button → the LED lights up.
In the Serial Monitor on your computer: "Button pressed!" printed repeatedly.
Serial is your best friend for hardware — it's how the chip "talks back" to your computer so you can see what's happening. It's the hardware version of std::cout. Open the "Serial Monitor" in PlatformIO to read it.
The bounce problem
🧒 Picture this
When you press a physical button, the metal contacts actually chatter for a few milliseconds — bouncing between touching and not-touching. The chip is so fast it sees ONE press as five or ten! This is called bounce, and we fix it by ignoring changes that happen too fast, called debouncing.
debounce.cpp (idea)
unsigned long lastPress = 0;void loop() { if (digitalRead(BUTTON) == LOW) { // only accept a press if 200ms passed since the last one if (millis() - lastPress > 200) { Serial.println("Clean press!"); lastPress = millis(); } }}
millis() gives the number of milliseconds since the board turned on — a handy stopwatch we use constantly for "has enough time passed?"
The big moment: driving a servo (our "lock")
🧒 Picture this
A servo motor is a little motor that turns to an exact angle you command — say 0° or 90°. That's perfect for a lock: 0° = bolt closed, 90° = bolt open. We tell it the angle using that PWM fast-blinking trick from Module 5, but a ready-made library hides all that so we just say "go to 90."
The servo drinks from VIN (the 5V that arrives over USB), not from a GPIO pin — signal pins can't supply motor current. One SG90 on USB power is fine; anything bigger needs its own supply with the grounds joined.
servo_lock.cpp
#include <ESP32Servo.h>Servo bolt; // our lock's motorconst int BUTTON = 4;void setup() { bolt.attach(13); // servo signal wire on pin 13 pinMode(BUTTON, INPUT_PULLUP); bolt.write(0); // start LOCKED (bolt at 0 degrees)}void loop() { if (digitalRead(BUTTON) == LOW) { bolt.write(90); // OPEN: turn bolt to 90 degrees delay(2000); // stay open 2 seconds bolt.write(0); // LOCK again }}
▶ What you should see — in the REAL WORLD
Press the button → the servo arm swings to open the latch,
waits 2 seconds, then swings back to locked. You just built
a working (if simple) door lock!
⚠️
Power note: even a small servo can pull more current than the ESP32's pin likes when it moves. For one SG90 on USB you're usually fine, but as soon as you use a bigger motor or a solenoid, power it from a separate supply and connect the grounds together. Never run a solenoid straight off a signal pin.
✅ Milestone 6 — build this
Wire a button and a servo to your ESP32. Pressing the button makes the servo swing from "locked" (0°) to "open" (90°) and back after a pause. Add a Serial.println("Opening...") so you can watch it in the Serial Monitor. This is the physical heart of your product.
Goal: learn why delay() is a trap, and how to make the lock do several things at once — respond to a button, blink a status light, and time out — without any of them freezing the others.
The problem with delay()
🧒 Picture this
Remember delay(2000)? It doesn't just wait — it freezes the whole chip for 2 seconds. During that time the button is ignored, the status light can't blink, Wi-Fi messages pile up. It's like a shop where the cashier locks the door and takes a 2-second nap after every customer. Fine for a demo, terrible for a real product.
Fix #1: non-blocking timing with millis()
Instead of napping, we keep looping and just check the clock each time: "has 2 seconds passed yet? No? Fine, do other stuff and check again." Nobody freezes.
nonblocking.cpp
unsigned long openedAt = 0;bool isOpen = false;void loop() { // 1) React to the button instantly (no freezing). if (digitalRead(BUTTON) == LOW && !isOpen) { bolt.write(90); isOpen = true; openedAt = millis(); // remember WHEN we opened } // 2) If it's been open 2s, auto-lock — checked every loop, no delay(). if (isOpen && millis() - openedAt > 2000) { bolt.write(0); isOpen = false; } // 3) Meanwhile the loop is free to blink a light, check Wi-Fi, etc.}
💡
This is a huge mental upgrade. Almost all good hardware code is written this way: a fast loop that checks clocks and states instead of sleeping. It feels a bit like an event loop in Node.js — and you already know those.
Fix #2: a real operating system for tiny chips — FreeRTOS
🧒 Picture this
When jobs get complicated, checking clocks by hand gets messy. So the ESP32 comes with a tiny "traffic controller" built in, called FreeRTOS. It lets you write each job as its own separate task, and it rapidly switches between them so fast it looks like they all run at once. One task watches the button, another blinks the light, another listens for Wi-Fi. Each is simple; the controller juggles them.
tasks.cpp (concept)
// Task 1: blink a status LED forever, independently.void blinkTask(void* param) { for (;;) { // forever digitalWrite(LED, HIGH); vTaskDelay(500 / portTICK_PERIOD_MS); // polite sleep, frees the CPU digitalWrite(LED, LOW); vTaskDelay(500 / portTICK_PERIOD_MS); }}void setup() { pinMode(LED, OUTPUT); // Hand the task to the controller to run on its own. xTaskCreate(blinkTask, "blink", 2048, nullptr, 1, nullptr);}void loop() { // This can now handle the button while the LED blinks by itself.}
The key difference: vTaskDelay() is a polite sleep — while one task rests, the controller lets other tasks run. Unlike delay(), it doesn't freeze the whole chip.
💡
Your Go background is a real advantage here. FreeRTOS tasks are a lot like goroutines, and passing messages between tasks (using "queues") is a lot like Go channels. The instinct you built writing concurrent Go transfers directly.
Talking safely between tasks: queues, mutexes
🧒 Picture this
If two tasks touch the same data at the same time, they can trip over each other — one reads while the other is halfway through writing, and you get nonsense. A mutex is like a single bathroom key: only the task holding the key may touch the shared thing; everyone else waits their turn. A queue is a conveyor belt: one task drops a message on it, another picks it up safely later.
Sleeping to save battery
A battery-powered lock can't run full speed 24/7 or the battery dies in a day. The ESP32 can enter deep sleep — nearly off, sipping almost no power — and wake up when the button is pressed. We'll use this so a lock can run for months on a battery.
Same parts as before, all at once: the status LED must keep blinking WHILE the button is watched WHILE the servo timeout counts down. If your LED freezes when the lock opens, a delay() is still hiding somewhere.
✅ Milestone 7 — build this
Rewrite your Module 6 lock so it uses millis() instead of delay() for the auto-lock timeout, AND blinks a status LED at the same time (proving nothing freezes). Bonus: move the blink into its own FreeRTOS task. Your lock now walks and chews gum at once.
Goal: connect the lock to Wi-Fi and let it send its status and receive 'unlock' commands over the network — the moment it becomes a smart lock.
Connecting to Wi-Fi
The ESP32 has Wi-Fi baked in. Joining a network is just a few lines:
wifi.cpp
#include <WiFi.h>const char* SSID = "MyHomeWiFi";const char* PASSWORD = "supersecret";void setup() { Serial.begin(115200); WiFi.begin(SSID, PASSWORD); // Wait until connected, printing dots. while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nConnected! My address is:"); Serial.println(WiFi.localIP()); // the lock's address on your network}
▶ What you should see (Serial Monitor)
....
Connected! My address is:
192.168.1.57
How should the lock and the world talk? Meet MQTT
🧒 Picture this
Imagine a bulletin board in the town square. Some people post notes under a topic ("front-door/command: OPEN"). Others watch that topic and react when a note appears. Nobody has to know each other's phone number — they just agree on the topic name. That's MQTT: a super-lightweight messaging system built exactly for tiny devices. The bulletin board is called a broker.
Publish = post a message to a topic. (Lock posts "I'm locked" to frontdoor/status.)
Subscribe = watch a topic. (Lock watches frontdoor/command for "OPEN".)
Broker = the middleman server everyone connects to. (You can run one, or use a hosted one.)
mqtt_lock.cpp (concept)
#include <PubSubClient.h> // a popular MQTT library// This function runs whenever a message arrives on a topic we watch.void onMessage(char* topic, byte* payload, unsigned int len) { std::string msg((char*)payload, len); if (msg == "OPEN") { bolt.write(90); // unlock! client.publish("frontdoor/status", "OPEN"); // tell the world }}void setup() { // ...connect Wi-Fi (as above)... client.setServer("broker.example.com", 1883); client.setCallback(onMessage); client.subscribe("frontdoor/command"); // watch for commands}
▶ What happens end-to-end
You (or an app) publish "OPEN" to frontdoor/command.
→ The broker delivers it to the lock.
→ onMessage() runs, the servo opens, and the lock publishes "OPEN"
back to frontdoor/status so your app can show the new state.
You just unlocked a door from anywhere on your network.
Speaking JSON on a tiny chip
🧒 Picture this
Often you want to send more than one word — like { "action": "open", "who": "mum" }. That neat format is JSON, which you already know from web work. On the chip, a small library called ArduinoJson lets you build and read it without eating all the memory.
json.cpp
#include <ArduinoJson.h>JsonDocument doc;doc["door"] = "front";doc["state"] = "locked";doc["battery"] = 87;char out[128];serializeJson(doc, out); // turn it into textclient.publish("frontdoor/status", out);// sends: {"door":"front","state":"locked","battery":87}
💡
HTTP option: MQTT is best for always-on devices, but the ESP32 can also make plain HTTP requests (like a browser) to talk to a web server directly — handy for occasional check-ins or calling a REST API you build in Go. Same idea you know from web dev, just running on a chip.
✅ Milestone 8 — build this
Get your ESP32 lock onto Wi-Fi, connect it to a free MQTT broker, and make it (1) publish its status when it locks/unlocks, and (2) open when it receives "OPEN" on its command topic. Test by publishing "OPEN" from a phone MQTT app. The moment the servo moves from your phone — that's a smart lock.
Goal: understand why a networked lock must be secured, and the basic protections every real product needs. If you sell locks, this module is not optional.
⚠️
Reality check: a smart lock is a security device. If anyone on the internet can send "OPEN," you haven't built a lock — you've built a door that opens for strangers. The fun stops being optional here; this is the professional part.
Why the simple version from Module 8 is dangerous
🧒 Picture this
In Module 8, anyone who could reach the broker and knew the topic could unlock the door. That's like hiding your house key under the mat and then telling the whole street where the mat is. We need three things: a secret channel nobody can eavesdrop on, a way to prove who's asking, and protection against tricks and replays.
1. Encrypt the channel (TLS)
🧒 Picture this
TLS is the same lock-icon technology your browser uses for https. It scrambles messages so that even if someone is listening on the Wi-Fi, all they hear is gibberish. On the ESP32 you use a "secure client" and give it the broker's certificate so it can trust it.
In practice: connect to the broker on the secure port (usually 8883 for MQTT) using WiFiClientSecure instead of the plain client, and load the trusted certificate. Same code shape as before, just the secure version.
2. Prove who's asking (authentication)
The lock authenticates to the broker with a username/password or, better, a client certificate unique to that device.
Unlock commands should carry a token that proves the sender is allowed — not just the word "OPEN." Think of it as a signed permission slip the lock checks before moving.
3. Stop replay & tampering
🧒 Picture this
A replay attack is when a sneaky person records a valid "OPEN" message and simply plays it again later to get in. We defeat it by making each command one-time-use — for example, including a number that only counts up, or a timestamp, so an old recorded message is recognized as stale and rejected.
Where to keep secrets
Passwords and keys must not sit in plain text where anyone reading the chip's memory can grab them. Options, from basic to strong:
Store them in a protected flash area, not hard-coded in your source.
Better: use the ESP32's built-in flash encryption and secure boot.
Best: a dedicated secure element chip whose whole job is guarding keys.
Updating safely (OTA)
🧒 Picture this
OTA means "Over-The-Air" updates — sending new firmware to the lock over Wi-Fi so you can fix bugs without visiting every door. But this is also a dream target for attackers: if they can push fake firmware, they own the lock. So every update must be signed — cryptographically stamped — and the lock must refuse anything not carrying your genuine stamp.
Think like an attacker (threat modeling)
Before shipping, sit down and list how someone could break in: eavesdropping on Wi-Fi, replaying commands, prying the device open, draining the battery to force a fail-open, jamming the signal. For each, decide your defense. This checklist mindset is what separates a hobby project from a product people trust on their front door.
✅ Milestone 9 — build this
Upgrade your lock's connection from plain MQTT to TLS (secure port + certificate), and require a secret token inside the unlock command that the lock verifies before moving the servo. Try sending a command without the token and confirm the door refuses. Now unauthorized "OPEN" messages bounce off.
Goal: make the lock trustworthy — it recovers from crashes, survives flaky power, fails into a safe state, and has tests so you don't break it with each change.
Debugging on hardware
You can't just add console.log and refresh. Your tools on a chip:
Serial printing — Serial.println(...) is your main window into what's happening. Print the state at key moments.
LEDs as signals — blink patterns can tell you what stage the code reached, even with no screen.
A hardware debugger — advanced: step through code running on the actual chip.
The watchdog: a timer that reboots a frozen chip
🧒 Picture this
Imagine a guard dog that you must pat every few seconds to prove you're awake. If you stop patting — because your code froze — the dog assumes you've fainted and restarts the whole chip to recover. That's a watchdog timer. It means a rare glitch reboots the lock in seconds instead of leaving it dead until someone notices.
Surviving bad power
Real doors have flaky power: batteries dip, someone yanks a plug, voltage sags when the motor kicks in (a "brown-out"). Good firmware detects these and handles them gracefully instead of behaving randomly. The ESP32 has brown-out detection built in; you decide what happens when it fires.
The most important design question: fail-safe or fail-secure?
🧒 Picture this
Ask yourself: if the power dies, should the door end up LOCKED or UNLOCKED? There's no universal right answer — it's a safety decision. A server room might fail-secure (stay locked). A fire exit must fail-safe (unlock, so people can escape). Your firmware and even your choice of physical lock hardware must reflect this on purpose, not by accident.
⚠️
This is a life-safety decision, not just a code choice. Decide it deliberately, write it down, and test that the hardware actually does it when power is cut. For anything protecting people, get a professional review.
Testing the brain without the body
🧒 Picture this
You don't want to flash the chip and press a button 500 times to test your logic. So we split the thinking (should the door open? is the token valid?) from the doing (move the servo). The thinking part is plain C++ you can test on your laptop, fast, with automated tests — this is host-based testing.
test_logic.cpp (runs on your PC)
// Pure logic, no hardware — easy to test.bool shouldOpen(bool tokenValid, bool withinTime) { return tokenValid && withinTime;}int main() { // tiny homemade tests if (shouldOpen(true, true) != true) std::cout << "FAIL 1\n"; if (shouldOpen(false, true) != false) std::cout << "FAIL 2\n"; if (shouldOpen(true, false) != false) std::cout << "FAIL 3\n"; std::cout << "All tests done.\n"; return 0;}
▶ What you should see (when logic is correct)
All tests done.
Later you'd use a real testing framework (like GoogleTest or Unity), but the idea is the same: keep your decision-making in plain, testable functions. Tools called static analysis and sanitizers can also scan your code for memory bugs before they ever reach a door.
✅ Milestone 10 — build this
Refactor your lock so all the "should it open?" logic lives in pure functions separate from the servo code. Write a handful of tests for them that run on your laptop. Enable the watchdog on the device, and deliberately cut power mid-cycle to confirm your lock lands in the fail state you chose on purpose.
Goal: connect the device to a real backend and app — exactly the work you already know how to do. This is where being a web developer becomes your superpower.
The whole picture
🧒 Picture this
A real smart-lock product is three friends working together: the device (your ESP32 firmware), a backend (a server that remembers users, permissions, and history), and an app (what the customer taps). The device talks to the backend, the backend talks to the app. You already build two of these three for a living.
Holds accounts and decides who may open which door.
Issues the signed tokens the lock checks (from Module 9).
Talks to the MQTT broker to send commands and receive status.
Stores history: who opened the door and when.
Exposes a REST or WebSocket API for the app.
💡
Nothing new to learn here. A Go service subscribing to MQTT and exposing a REST API is bread-and-butter backend work. The only new idea is that one of your "clients" is a door instead of a browser.
The app — in React, also your strength
A dashboard that shows each lock's live state, an Open button, a history log, and user management. Live updates over WebSocket so the UI flips to "Open" the instant the door moves. You've built dashboards like this before; this one just controls physical doors.
Provisioning: the tricky new bit
🧒 Picture this
Here's a genuinely new puzzle: a brand-new lock out of the box doesn't know the customer's Wi-Fi name or password. How do you tell it, without a keyboard? The common trick: the lock first pretends to be a Wi-Fi hotspot; the customer's phone connects to it, hands over the real Wi-Fi details through a little setup page, and then the lock joins the real network. This is called provisioning, and every smart device has to solve it.
Fleet management: from one lock to thousands
Selling one lock is a project; selling thousands is a business. You'll need to: push OTA updates to many devices safely, watch which locks are online, spot failing batteries, and roll out changes gradually so a bad update doesn't brick every customer at once. Start simple, but design with "many devices" in mind.
From breadboard to product (a peek ahead)
Eventually the breadboard becomes a real circuit board (PCB), the parts get an enclosure that mounts on a door, and you think about certification, weatherproofing, and manufacturing. That's a whole journey of its own — but it starts with exactly the prototype you'll have built by the end of this course.
✅ Milestone 11 — build this
Stand up a small Go backend that connects to your MQTT broker and exposes a POST /open endpoint which sends a signed command to the lock. Build a one-button React page that calls it and shows live status over WebSocket. Press the button in the browser → the real servo moves. End-to-end, top to bottom, yours.
Goal: combine everything into one real, portfolio-worthy product — and the seed of your business.
Every milestone you've built now clicks together into a single system. Here's the checklist for a complete capstone:
The firmware (C++, Modules 1–10)
A clean Lock class hierarchy driving a real servo or solenoid (Modules 3, 6).
Non-blocking, multi-tasking behavior via millis() and/or FreeRTOS (Module 7).
Auto-lock timeout and a status LED that never freezes (Module 7).
Wi-Fi + secure MQTT, publishing status and receiving commands (Modules 8, 9).
Token-checked, encrypted unlocks that reject replays (Module 9).
A deliberate, tested fail-state and a watchdog (Module 10).
Signed OTA updates so you can fix it in the field (Module 9).
The product layer (Go + React, Module 11)
A Go backend: users, permissions, signed tokens, MQTT bridge, history log.
A React app: live lock state, Open button, activity history, user management.
A provisioning flow to get a new lock onto a customer's Wi-Fi.
The documentation (what makes it a product)
A wiring diagram and photos of the build.
A written fail-safe decision and why you chose it.
A short threat model: how someone might attack it and how you defend.
A README so someone else could rebuild it.
★ You built a product
Press a button in your React app → your Go backend issues a signed command → it travels encrypted over MQTT → your ESP32 verifies it and turns the bolt → the app updates to "Open" live. That's not a toy. That's the core of a real smart-lock company, built by someone who, a few modules ago, had never written a line of C++.
Where to go next
Add an RFID or keypad entry method (more Actuator/sensor classes — your Module 3 design pays off).
Add battery monitoring and deep sleep for months of runtime (Module 7).
Move from breadboard to a custom PCB and a door-mountable enclosure.
Read A Tour of C++ (Stroustrup) to deepen the language, and the ESP-IDF docs to go beyond Arduino-style code.
Learn the safety standards and certifications for security hardware before you sell to real homes.
💡
Pacing reminder: don't try to master all of C++ before touching hardware. Get through Modules 1–4 well enough to be dangerous, then bounce between language depth and real boards. Keep every milestone; they're the parts of your capstone. And lean on what you already know — your Go concurrency instincts map onto FreeRTOS, and your React/Go skills own the entire product layer.
Four real products built on everything above — an RFID card door lock, an animated LED signpost, lights controlled from a remote and an app, and an ESP32-CAM photo doorbell — each with a full wiring diagram, parts list and firmware. Then a business module that turns them into a services catalogue you can quote from.