Embedded C becomes easier to maintain when hardware access is organized around a few repeatable patterns. This checklist covers GPIO, timers, interrupts, and UART, with portable code structures, register-level debugging guidance, and practical tests you can run before blaming the application logic.
Overview
Microcontroller projects often fail at the boundary between C code and hardware configuration. A pin may be in the wrong mode, a timer may use an unexpected clock source, an interrupt flag may never be cleared, or a UART may be configured correctly in software but connected to the wrong voltage domain. The solution is not one universal code sample. It is a disciplined sequence that separates board-specific setup from reusable application logic.
The examples below use CMSIS-like names such as GPIOA, TIM1, and USART1 for illustration. Actual register names, bit positions, clock trees, interrupt controllers, and startup code differ between microcontroller families. Treat these snippets as embedded C patterns, then replace the hardware layer with definitions from the device reference manual and board schematic.
A reliable implementation usually has four layers:
- Hardware configuration: enable peripheral clocks and select pin, clock, and interrupt settings.
- Small drivers: expose operations such as
gpio_write(),timer_start(), anduart_write(). - Interrupt-facing code: capture short events and move work into the main loop or a scheduler.
- Application logic: decide what the device should do without depending directly on register layout.
Keeping these layers distinct makes an embedded programming project easier to port, test, and debug. For a wider fault-finding process, see the Embedded C Debugging Guide. If the work starts with board wiring, also review the GPIO pinout reference.
Checklist by scenario
Scenario 1: Configure and test a GPIO pin
Before writing GPIO C code, identify the pin’s electrical role. Confirm whether it is an input, push-pull output, open-drain output, analog input, or alternate-function pin. Check the permitted voltage, default reset state, pull-up or pull-down requirements, and whether another peripheral already owns the pin.
- Enable the GPIO peripheral clock.
- Select the correct port and pin number from the schematic.
- Set the pin mode and output type.
- Choose a suitable pull configuration and output speed or drive setting.
- Set a safe initial output level before enabling an attached load.
- Read the input register when sampling a signal; do not assume the output register reflects the external pin state.
- Test with a multimeter, LED, oscilloscope, or logic analyzer as appropriate.
A portable interface can keep register operations out of application code:
typedef enum { GPIO_LOW = 0, GPIO_HIGH = 1 } gpio_level_t;
void status_led_write(gpio_level_t level)
{
if (level == GPIO_HIGH) {
GPIOA->ODR |= (1u << STATUS_LED_PIN);
} else {
GPIOA->ODR &= ~(1u << STATUS_LED_PIN);
}
}In production code, prefer the vendor’s atomic set and clear registers when available. A read-modify-write operation can lose an update if another execution context changes the same register between the read and write. Also remember that an LED may be active-low, so a logical “on” state may require a physical low level.
Scenario 2: Generate a periodic timer event
Start with the timing requirement in plain language: for example, “run a control task every 1 ms” or “toggle a diagnostic pin every 500 ms.” Then determine the timer input clock, prescaler, counter direction, auto-reload value, and update-event behavior.
- Confirm the timer clock frequency after any bus prescaler or clock-tree division.
- Choose a prescaler that produces a convenient counter tick.
- Calculate the reload value and account for whether the timer counts from zero.
- Clear pending status flags before enabling the interrupt.
- Enable the timer’s update interrupt and the corresponding interrupt-controller line.
- Start the timer only after configuration is complete.
- Verify the period on a GPIO pin or with a debugger rather than relying only on a software counter.
A common formula is period = (prescaler + 1) × (reload + 1) / timer_clock, but the exact interpretation depends on the peripheral. Write the selected clock and calculated values in a comment or configuration document so a later clock change does not silently invalidate the timing.
Scenario 3: Handle an interrupt safely
An interrupt service routine should acknowledge the hardware event, capture the minimum necessary information, and return quickly. It should not perform blocking I/O, wait for a peripheral, parse a large message, or call code whose interrupt-safety is unknown.
volatile bool tick_pending = false;
void TIMER1_IRQHandler(void)
{
if (TIM1->SR & TIMER_UPDATE_FLAG) {
TIM1->SR &= ~TIMER_UPDATE_FLAG; /* Follow the device-specific clear rule. */
tick_pending = true;
}
}
int main(void)
{
board_init();
timer_init();
for (;;) {
if (tick_pending) {
tick_pending = false;
run_periodic_task();
}
}
}The flag-clear operation shown is illustrative. Some peripherals clear flags by writing zero, writing one, reading a status register followed by a data register, or using a dedicated clear register. Follow the reference manual exactly. If a flag can be set again while it is being handled, use an event counter, ring buffer, or other mechanism that cannot collapse important events into one Boolean.
Scenario 4: Send and receive data over UART
UART troubleshooting begins with physical assumptions. Identify the signal direction, common ground, logic voltage, connector pinout, and whether the interface is TTL-level UART or an electrical standard requiring a transceiver. A USB-to-UART adapter is not automatically safe for every target voltage.
- Enable the UART and GPIO clocks.
- Assign the transmit and receive pins to the correct alternate functions.
- Set baud rate, data bits, parity, stop bits, and oversampling according to the other endpoint.
- Clear or inspect status flags before starting a transfer.
- Transmit a known byte pattern, such as a short text line.
- Check the waveform and bit timing with a logic analyzer if the receiver reports framing errors.
- For reception, choose polling, interrupt-driven input, or DMA based on message volume and latency requirements.
A small blocking transmitter is useful for early bring-up, but it should be clearly labeled as such:
void uart_putc(char c)
{
while ((USART1->SR & UART_TX_READY) == 0u) {
/* Wait only when blocking behavior is acceptable. */
}
USART1->DR = (unsigned char)c;
}For a responsive product, use a transmit ring buffer or DMA and reserve blocking output for controlled diagnostics. The UART debugging guide provides a separate checklist for wiring and serial-console failures.
What to double-check
Clock assumptions: Confirm the actual system and peripheral clocks after startup code runs. A timer or UART calculation based on the nominal oscillator can be wrong if clock initialization falls back to a different source.
Register access: Hardware registers should normally be declared through vendor headers or as volatile objects. Do not cache a changing status register in an ordinary variable and expect it to update itself. Check whether a register is read-only, write-only, write-one-to-clear, or reserved-bit sensitive.
Concurrency: A variable shared between an interrupt and the main loop may need volatile, atomic access, or a critical section. volatile prevents certain compiler optimizations; it does not make multi-byte operations atomic and does not solve every race condition.
Startup order: Configure clocks, GPIO modes, peripheral registers, interrupt priorities, and pending flags in an intentional order. Avoid enabling an interrupt before its handler and state are ready.
Electrical behavior: Software cannot correct a missing ground, an incorrect pull resistor, an overloaded pin, a level mismatch, or a peripheral connected to the wrong alternate-function mapping. Keep the schematic and datasheet open during bring-up.
Observability: Add a diagnostic GPIO, a bounded logging path, or a test command that reveals state without changing timing too aggressively. When serial logs are unavailable, a pin toggle and logic analyzer can still show whether an interrupt or state transition occurred.
Common mistakes
- Using a generic pin number: “Pin 3” is ambiguous without a port, package, board label, and alternate-function selection.
- Changing a whole register unintentionally: Assigning a full output or mode register can overwrite neighboring pins. Use masked writes or documented initialization values.
- Forgetting active-low devices: LEDs, chip-select lines, reset inputs, and external modules may use inverted logic.
- Clearing the wrong interrupt flag: A pending interrupt can retrigger continuously, while an uncleared flag can make later events appear to be lost.
- Doing too much in an ISR: Long handlers increase latency and can cause missed UART bytes, timer jitter, or priority problems.
- Ignoring framing and buffering: UART reception needs a message boundary, buffer limit, and error policy. Never assume one receive event equals one complete command.
- Trusting the debugger alone: A breakpoint changes timing and may hide race conditions. Verify time-sensitive behavior with external instrumentation.
- Mixing board support and application logic: Direct register access scattered throughout the project makes a new board or microcontroller harder to support.
When a peripheral does not behave, reduce the test. Drive one GPIO, create one timer event, transmit one known byte, and inspect one register at a time. This controlled progression is usually more informative than adding more application code.
When to revisit
Use this checklist before each hardware revision, microcontroller migration, clock-tree change, or major firmware refactor. Revisit it when a pin assignment changes, a new peripheral is added, an interrupt priority is modified, or a serial device is replaced. It is also useful before a planned maintenance cycle: verify that the board schematic, pin map, startup configuration, and peripheral assumptions still match the source code.
At the start of a new bring-up, print or save a short configuration record containing the MCU part, clock settings, GPIO modes, timer calculations, UART parameters, and interrupt ownership. During testing, record the expected waveform or event interval beside the observed result. If the behavior changes after a compiler, SDK, board-support, or workflow update, repeat the smallest GPIO, timer, interrupt, and UART tests before investigating higher-level code.
Finally, keep the hardware abstraction small and documented. Link each driver to the relevant board notes, preserve known-good logic-analyzer captures, and maintain a test command for basic peripheral checks. That gives the next debugging session a starting point instead of requiring a complete reconstruction of the original assumptions.