As I reflect back on my embedded systems journey for the past 20 years, I’d like to share some of the insights along the way. I figured a “10 things I wish I knew” post would be perfect for this kind of reflection. If I missed something or you’d like to add your own, please leave a comment!
1. Arduino Is a Great Starting Point (If You Use It Intentionally)
Let’s tackle the elephant in the room. Arduino gets a bad reputation in some circles, but it’s genuinely one of the best ways to get started in embedded systems, especially with something like the Arduino UNO R3 or any ATmega328P-based board. The hardware is simple, well-documented, and backed by one of the most approachable microcontroller datasheets you’ll ever read.
As a side note, 32-bit ARM and Xtensa microcontrollers…
As I reflect back on my embedded systems journey for the past 20 years, I’d like to share some of the insights along the way. I figured a “10 things I wish I knew” post would be perfect for this kind of reflection. If I missed something or you’d like to add your own, please leave a comment!
1. Arduino Is a Great Starting Point (If You Use It Intentionally)
Let’s tackle the elephant in the room. Arduino gets a bad reputation in some circles, but it’s genuinely one of the best ways to get started in embedded systems, especially with something like the Arduino UNO R3 or any ATmega328P-based board. The hardware is simple, well-documented, and backed by one of the most approachable microcontroller datasheets you’ll ever read.
As a side note, 32-bit ARM and Xtensa microcontrollers have become ubiquitous in the past 5-10 years, and with good reason: they’re now extremely affordable and quite powerful. However, their datasheets can be 1000+ pages and often require complex frameworks to simplify the process of programming them. While I mostly use ARM or Xtensa processors these days for my projects, I rely heavily on vendor or third-party abstraction layers (frameworks). They’re not great for learning the fundamentals of working with the underlying hardware.
The key is to treat the Arduino framework as a learning scaffold, not a permanent abstraction layer. Functions like digitalWrite() and delay() are incredibly useful at first, but they’re also an opportunity to peek under the hood. As you learn more, try replacing those calls with direct register reads and writes, configuring timers yourself, or handling interrupts manually. You’ll start to see exactly how high-level convenience maps to low-level hardware control (and that transition is where many hobbyists become embedded engineers). Used this way, Arduino isn’t a shortcut around fundamentals: it’s one of the cleanest on-ramps to them.
If you’re just getting started, I highly recommend grabbing an old UNO R3 (or some other 328p-based Arduino board) and working through my videos here to see how to go beyond the Arduino framework:
2. Understand the Hardware
One of the biggest mindset shifts in embedded development is realizing that you’re not just writing software. Rather, you’re talking directly to hardware. Your code isn’t abstracted away from the machine the way it is on a desktop or server (thanks to the layers provided by operating systems!). Every register write, every interrupt, every memory access has a physical effect.
This is where embedded systems get their efficiency. When you understand what the hardware is actually doing (how peripherals work, how memory is laid out, how clocks and timers interact) you can write code that is smaller, faster, more deterministic, and more power-efficient. When you don’t understand the hardware, you tend to rely on layers of abstraction that “mostly work” but hide important details and limitations, and you lose out on much of the power efficiency offered by embedded systems.
You don’t need to memorize every register, but you should know what’s possible, what’s computationally expensive, and what happens behind the scenes when you call a function that touches hardware.
That naturally leads to the next skill every embedded beginner needs to build…
3. Learn to Read Datasheets (Even When They’re Intimidating)
Datasheets are how hardware speaks to you.
At first, they’re overwhelming: hundreds of pages, dense tables, cryptic acronyms. That’s normal. I still struggle with them. Reading datasheets isn’t about understanding everything on the first pass, but rather, it’s about learning how to navigate them.
Early on, focus on:
- Pin functions and alternate modes
- Block diagrams (they explain more than you think)
- Register descriptions for the peripherals you’re using
- Timing diagrams and electrical characteristics
A powerful habit is to keep the datasheet open while you’re writing code and cross-reference it constantly. When you set a register bit, find it in the datasheet. When something behaves oddly, check the timing or electrical section. Over time, patterns emerge, and what once felt unreadable starts to feel like a reference manual instead of a wall of text.
Re-reading a datasheet months later and realizing how much more sense it makes is one of the clearest signs that you’re progressing as an embedded engineer.
4. Make a Board (or Two)
One of the best ways to truly internalize how embedded systems work is to design your own PCB, even a very simple one. After spending time reading datasheets and writing code, designing a board forces you to confront the hardware realities head-on: power requirements, pin multiplexing, clock sources, boot configuration, and decoupling. You quickly move from “this register exists” to “this pin needs a pull-up” or “this peripheral can’t be used because it conflicts with something else.” That transition is where a lot of embedded knowledge really solidifies.
Your first board doesn’t need to be ambitious. A minimal development board with some basic supporting components (e.g. regulator, crystal, SWD/JTAG header, a few LEDs, and maybe a button) is more than enough. Even if you plan to spend most of your time writing firmware, building a board gives you a much deeper intuition for why development boards are designed the way they are and what tradeoffs they’re making. When something doesn’t work the first time (it often won’t), debugging a board you designed yourself teaches lessons that no amount of example code ever will.
I highly recommend KiCad if you’re looking to get started with a PCB layout package.
5. Debugging is the Real Job
In embedded systems, writing code (a first pass) is often the easiest part. The real work begins when that code doesn’t work, interacts with imperfect hardware, dealing with real-world timing, and working with physical signals. You’ll come across tricky bugs that arise from clock configurations, pin muxing, power issues, race conditions, and assumptions that only break once the system is running continuously (e.g. memory leaks). You’ll often find yourself debugging more than writing the code, and that’s part of the job.
Learning to debug effectively is one of the most valuable skills you can build early. Start with the basics: serial logging, LEDs or GPIO “heartbeat” signals, and knowing how to isolate a problem to either hardware or software. In my experience, serial logging and toggling a GPIO will cover 90% of your debugging needs. However, sometimes you need more powerful tools like hardware debuggers, breakpoints, watchpoints, and logic analyzers. Understanding tools like OpenOCD, GDB, Tracealyzer, etc. will help you track down the nastiest of bugs.
6. Learn Timing Patterns
Timing can be tricky in embedded systems. In bare metal, there’s no operating system quietly managing scheduling for you, and your code often runs in tight loops or responds directly to hardware events. As a result, you can’t treat execution time as an abstract concern: you have to reason about when things happen, how long they take, and what else might be happening at the same time.
This is where common timing patterns come into play. Interrupts allow hardware to signal your code asynchronously, but they introduce concerns like latency, priority, and shared state. Blocking code is simple to write but can prevent your system from responding to other events, while non-blocking designs require more structure but scale much better. Polling is easy to reason about and useful in simple systems, but event-driven designs (whether interrupt-based or using queues and state machines) are far more efficient and responsive as complexity grows. Learning when to use each approach (along with understanding the tradeoffs they introduce) is a key step in moving from simple, inefficient code to powerful firmware that can tackle multiple tasks on a low-power microcontroller without needing to rely on an RTOS.
But speaking of RTOS…
7. Learn Bare Metal First, then RTOS
Early on, it’s worth spending time writing bare-metal code before tackling a real-time operating system (RTOS). Bare metal forces you to understand what’s really happening on the microcontroller: how the system starts up, how interrupts work, how timing is managed, and how peripherals interact. With no scheduler to lean on, you naturally learn patterns like state machines, cooperative multitasking, and event loops. That foundation makes your code simpler, more predictable, and easier to debug, and it makes RTOS concepts far less mysterious later on.
An RTOS becomes valuable when your system needs to manage multiple independent tasks, each with different timing or priority requirements (for example, handling communications, sensor sampling, user input, and logging at the same time). An RTOS provides structure: preemptive scheduling, task isolation, queues, semaphores, and timers that make complex systems easier to reason about and extend. The tradeoff is added complexity, memory overhead, and new failure modes like deadlocks and priority inversion.
Bare metal is often the best choice for small, tightly constrained systems where timing is simple and resources are limited, while an RTOS shines as soon as concurrency, responsiveness, or long-term maintainability become real concerns. Knowing why you’re choosing one over the other is what matters.
I actually have a whole post dedicated to when you should consider using an RTOS for an embedded project. And if you want to learn RTOS fundamentals, FreeRTOS is a great place to start along with my 12-part video series.
8. Learn the Common Embedded Patterns
As you gain experience, you’ll start to notice that embedded systems reuse the same patterns over and over. Learning these patterns explicitly (rather than discovering them by accident or jumping immediately to a library/framework) dramatically shortens the learning curve. At the lowest level, this includes things like bit manipulation (reading/writing to registers, masking, etc.), writing safe and efficient interrupt service routines, using global flags or state variables to communicate between contexts, and structuring code to be non-blocking so your system stays responsive. Patterns like state machines, callbacks, ring buffers, and double buffering show up constantly in drivers and communication stacks because they solve real timing and throughput problems.
The same idea applies once you introduce an RTOS. Concepts like starting and stopping threads, passing data through queues, and protecting shared resources with mutexes or semaphores are patterns to help yo udeal with concurrently executing threads. You’ll see them in FreeRTOS, Zephyr, and nearly every other RTOS you encounter. The more platforms you work with, the more you’ll realize that while APIs change, the underlying ideas stay the same. Focusing on these recurring patterns (rather than any single framework) helps you transfer your knowledge from one microcontroller, framework, project, or job to the next.
9. Learn to Read Other People’s Code
One of my absolute least favorite tasks is reading other people’s code. I dread it and will procrastinate starting it. Even if their code is well-written and well-documented, following someone else’s logic can be excrutiating for me. Worse if it’s poorly documented and utterly spaghetti-fied.
That being said, one of the fastest ways to level up in embedded systems is to get comfortable reading code you didn’t write. The discomfort is often part of the learning process. Real-world embedded systems are rarely built from scratch, and understanding how to navigate, trace, and reason about existing code is a core professional skill.
When you read other people’s code, focus less on understanding every line and more on understanding structure. Look at how drivers are organized, how hardware access is abstracted, where interrupts are handled, and how data flows between layers. You’ll start to see familiar patterns (ring buffers, callbacks, state machines) and you’ll also see tradeoffs and compromises that don’t show up in tutorials. Over time, this practice builds intuition about what good embedded code looks like, which makes both debugging and writing your own code significantly easier.
I’m going to say something potentially blasphemous here: AI tools (e.g. ChatGPT, Claude) can be extremely useful when you’re reading unfamiliar code. They’re best used for orientation (summarizing what a file does, tracing call paths, or explaining patterns you’re seeing) rather than for blindly generating solutions. The main pitfall is trusting explanations or code without verification: AI can misunderstand hardware context, miss timing constraints, or gloss over subtle but critical details.
To use these tools effectively, keep your questions small and specific, cross-check answers against the source code and datasheets, and treat AI as a knowledgeable guide rather than an authority. When used this way, AI lowers the barrier to understanding complex systems without replacing the hands-on reasoning that embedded development demands.
10. Build Complete Projects (and Document the Process)
One of the most effective ways to learn embedded systems is to build complete projects, even if their scope is small. Finishing a project forces you to make decisions, handle edge cases, and think about how your system behaves over time (not just whether it compiles or runs once). A “complete” project might be as simple as a fun RGB LED strip, sensor node, a controller with a basic UI, or a communication demo, but it should have a clear goal, defined behavior, and a sense of closure. That completeness is what turns scattered experiments into real learning.
Documenting the process is just as important as writing the code. Keeping notes, writing a README, and publishing your work on GitHub helps solidify what you’ve learned and creates a record you can return to later. Over time, these projects become a personal reference library (and a tangible way to see how far you’ve come). If you’re open to sharing your work publicly, they also form a powerful portfolio that shows potential employers not just what you know, but how you think, how you debug, and how you finish things. In embedded systems, that combination matters a lot.
Thank you for joining me on this journey! How have you improved your embedded skills over the past couple of years? If you could go back in time and give some embedded advice to your former self, what would you say? Let me know in the comments!