Firmware bugs usually start small: a missing timeout, a skipped status check, or a buffer that assumes the traffic will stay polite. The first sign is rarely a clean crash. More often it is a board that still boots, but not quite the way the schematic and code review suggested it should.
The habits below are the ones I want in place before first serious bring-up. They are not clever, and that is part of their value. They make faults visible, keep the code easier to maintain, and shorten the path from “something is wrong” to a diagnosis that is actually useful. The snippets use the STM32 HAL because that is the bench they came from, but nothing here is ST-specific; the same habits carry over to any vendor library, from NXP and TI to Nordic and Microchip.
1. Name every hardware assumption
Imagine the ADC readings are all about seven percent low. The board is alive, the SPI bus works, and the plot looks stable, but the measurement does not match the bench meter. After half an hour, someone notices the firmware still assumes the old voltage reference from the first schematic revision.
If the code depends on a voltage reference, active-low pin, timer period, pull-up value, ADC range, or sensor scaling factor, name it. During bring-up, the firmware should read enough like the wiring diagram that the wrong assumption stands out in review.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#define ADC_REF_MV 2500u #define ADC_MAX_COUNTS 4095u #define RELAY_ENABLE_GPIO_Port GPIOB #define RELAY_ENABLE_Pin GPIO_PIN_7 #define RELAY_ENABLE_ACTIVE_LOW 1u static uint32_t adc_counts_to_mv(uint16_t counts) { // Good: the named constants keep the reference and ADC range visible. return ((uint32_t)counts * ADC_REF_MV) / ADC_MAX_COUNTS; } static void relay_set(bool enabled) { // Good: start with the logical command, then apply board polarity below. GPIO_PinState level = enabled ? GPIO_PIN_SET : GPIO_PIN_RESET; if (RELAY_ENABLE_ACTIVE_LOW) { // Good: the board uses active-low control, so the inversion is intentional. level = enabled ? GPIO_PIN_RESET : GPIO_PIN_SET; } HAL_GPIO_WritePin(RELAY_ENABLE_GPIO_Port, RELAY_ENABLE_Pin, level); } // Bad: magic numbers hide the hardware assumption. // uint32_t mv = (counts * 2500u) / 4095u; |
This is not about decoration. It is about making hardware changes visible in code review. If the reference voltage or relay polarity changes, the update should be obvious and localized.
2. Check every driver return value during bring-up
A common bring-up scene is a sensor that works on the second power cycle but not the first. If the first failed SPI transfer was ignored, the only visible symptom may be a strange value much later in the code, and a simple bus fault turns into a guessing game.
During bring-up, every driver call that can fail should either return a useful status or leave evidence behind. I like keeping both: an immediate return value for control flow and a sticky health word for later diagnostics.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
typedef enum { FW_OK = 0, FW_SPI_TX_FAILED, FW_SENSOR_TIMEOUT, } FirmwareStatus; static volatile uint32_t system_health; #define HEALTH_SPI_TX_FAILED (1u << 0) #define HEALTH_SENSOR_TIMEOUT (1u << 1) static FirmwareStatus sensor_write_reg(uint8_t reg, uint8_t value) { uint8_t tx[2] = { reg, value }; if (HAL_SPI_Transmit(&hspi1, tx, sizeof(tx), 10u) != HAL_OK) { // Good: a sticky flag keeps the bus failure visible after this call returns. // Note: |= on a volatile is not atomic; practice 7 adds a critical // section once flags are set from more than one context. system_health |= HEALTH_SPI_TX_FAILED; return FW_SPI_TX_FAILED; } return FW_OK; } // Bad: ignoring HAL_SPI_Transmit status hides a failed bus transaction. |
This gives you immediate control flow and a session-level record. If a retry later succeeds, the sticky flag still tells you the fault happened once during the run.
3. Keep interrupt handlers short
The tempting thing is to parse the frame as soon as the last byte arrives. It feels efficient until another interrupt is delayed and the timing problem only appears when the system is busy. At that point, the ISR has quietly become a second main loop.
An ISR should move data, clear the hardware condition, and get out. Parsing packets, formatting logs, touching flash, or running policy decisions belongs in the main loop or a task where the firmware can recover, log, and resync without holding interrupt priority.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
#define UART_RX_RING_SIZE 128u #define HEALTH_UART_RX_OVERFLOW (1u << 2) static volatile uint8_t rx_ring[UART_RX_RING_SIZE]; static volatile uint16_t rx_head; static volatile uint16_t rx_tail; static volatile uint32_t uart_rx_overflow_count; static uint8_t rx_byte; static bool ring_push_from_isr(uint8_t b) { uint16_t next = (uint16_t)((rx_head + 1u) % UART_RX_RING_SIZE); if (next == rx_tail) { // Good: record overflow and leave the parser outside interrupt context. system_health |= HEALTH_UART_RX_OVERFLOW; // Note: only this ISR writes the counter, so the increment is safe here. uart_rx_overflow_count++; return false; } rx_ring[rx_head] = b; rx_head = next; return true; } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart2) { // Good: capture the byte here and parse later. (void)ring_push_from_isr(rx_byte); (void)HAL_UART_Receive_IT(&huart2, &rx_byte, 1u); } } // Bad: protocol_parse_full_frame(); // too much work at interrupt priority. |
Short ISRs make timing behavior easier to reason about. They also make bugs less dramatic: a bad packet becomes parser input, not an interrupt-priority problem.
4. Use timeouts on every blocking wait
A board that hangs during startup is one of the least useful failures. You do not know whether the sensor is absent, the ready pin is wrong, the peripheral clock is missing, or the firmware is stuck in a loop that believed the hardware would always answer.
A wait loop without a timeout is an assumption disguised as code. Give every blocking wait a limit, and make the timeout a named failure.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
typedef enum { ADC_SAMPLE_OK = 0, ADC_SAMPLE_TIMEOUT, } AdcSampleStatus; static bool adc_ready_pin_is_high(void) { return HAL_GPIO_ReadPin(ADC_READY_GPIO_Port, ADC_READY_Pin) == GPIO_PIN_SET; } static AdcSampleStatus adc_wait_ready(uint32_t timeout_ms) { uint32_t start = HAL_GetTick(); while (!adc_ready_pin_is_high()) { if ((HAL_GetTick() - start) >= timeout_ms) { // Good: the timeout turns a stall into a named fault. system_health |= HEALTH_SENSOR_TIMEOUT; return ADC_SAMPLE_TIMEOUT; } } return ADC_SAMPLE_OK; } // Bad: while (!adc_ready_pin_is_high()) {} // can hang forever. |
The timeout should come from the datasheet plus margin. You can keep it loose during first bring-up and tighten it later after the hardware behavior is measured.
5. Design buffers for worst-case bursts
Many buffer bugs pass polite bench tests. The host sends one frame, the firmware answers, and everything looks calm. Then the real test tool sends three frames back to back, or another peripheral delays the interrupt, and the buffer math turns into lost data.
Buffer sizes should come from actual traffic, not from a comfortable round number. Capture the frame size, burst count, and margin as named assumptions.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#define RX_FRAME_MAX_BYTES 96u #define RX_MAX_BACK_TO_BACK 3u #define RX_ISR_MARGIN_BYTES 32u #define RX_RING_SIZE ((RX_FRAME_MAX_BYTES * RX_MAX_BACK_TO_BACK) + RX_ISR_MARGIN_BYTES) static uint8_t uart_rx_ring[RX_RING_SIZE]; static volatile uint16_t uart_rx_count; static bool uart_has_room_for_frame(void) { // Good: this check follows the frame-size assumption. return uart_rx_count <= (RX_RING_SIZE - RX_FRAME_MAX_BYTES); } // Bad: #define RX_RING_SIZE 256u // hides why 256 bytes should be enough. |
The calculation may still need tuning, but it gives the next review a real question to ask: are three back-to-back frames and 32 bytes of ISR margin still enough?
6. Separate raw acquisition from conversion
When a temperature value looks wrong, the first question should be simple: are the raw counts wrong, or is the conversion wrong? If the firmware throws raw data away too early, that question becomes harder than it needs to be.
Keep raw measurement and engineering value separate. Raw data is evidence. Converted data is interpretation.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
typedef struct { uint16_t offset_counts; uint32_t gain_uV_per_count; } AdcCalibration; typedef struct { uint16_t raw_counts; uint32_t timestamp_ms; } RawAdcSample; static RawAdcSample adc_capture_raw(void) { RawAdcSample sample = { // Good: keep the raw count as evidence for later debug. .raw_counts = adc_read_raw_counts(), .timestamp_ms = HAL_GetTick(), }; return sample; } static int32_t adc_raw_to_microvolts(RawAdcSample sample, const AdcCalibration *cal) { int32_t corrected = (int32_t)sample.raw_counts - cal->offset_counts; // Good: conversion stays isolated from acquisition. // Note: int32_t has room here; 12-bit counts times a microvolt gain // stays well below the overflow limit for this ADC range. return corrected * (int32_t)cal->gain_uV_per_count; } // Bad: converting immediately in the ADC driver hides raw evidence. |
If raw counts are stable but the displayed value is wrong, the conversion path deserves attention. If the raw counts jump around, the fault is earlier in the chain.
7. Make error flags sticky until read
A one-cycle timeout can disappear before the host GUI polls, and a CRC error can be overwritten by the next good packet. If the firmware only reports the current state, short failures often vanish before anyone sees them.
Sticky flags latch the event until something reads and clears it. That is usually the right diagnostic tradeoff. The helpers below work on the same system_health word the earlier practices set, so the whole firmware keeps one flag vocabulary. They use small enter_critical and exit_critical wrappers; on an STM32 those would save PRIMASK, disable interrupts, and restore the saved state, and every vendor library or RTOS has an equivalent.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
#define HEALTH_BAD_PACKET_CRC (1u << 3) typedef uint32_t IrqState; // Note: project wrappers around interrupt disable/restore; see the text above. IrqState enter_critical(void); void exit_critical(IrqState state); static void health_report(uint32_t flag) { // Good: the critical section protects the read-modify-write, because // volatile alone does not make |= atomic when an ISR can also set flags. IrqState state = enter_critical(); system_health |= flag; exit_critical(state); } uint32_t health_read_and_clear(void) { // Good: snapshot and clear in one critical section, so a flag set // between the two steps cannot be lost. IrqState state = enter_critical(); uint32_t snapshot = system_health; system_health = 0u; exit_critical(state); return snapshot; } // Bad: clearing fault evidence just because the next sample succeeds. |
The critical sections are not decoration. volatile only keeps the compiler from caching the health word; it does not make a read-modify-write atomic. Without the protection, a task-level update that gets interrupted can drop a flag an ISR set in between, and a snapshot followed by a separate clear can erase an event that arrived between the two steps. The diagnostic path has to obey the same concurrency rules as the code it is watching.
A host tool can poll once a second and still catch short failures. That is much better than hoping the tool asks at the exact moment the fault is active.
8. Version your protocol
Firmware and host tools rarely change at exactly the same time. A production test program may talk to yesterday’s firmware while your bench has today’s image. Without a protocol version, the wrong parser can accept the wrong frame and produce convincing nonsense.
A protocol version or capability handshake turns that mismatch into a clear failure instead of silent data corruption.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
#define PROTOCOL_VERSION 2u #define PROTOCOL_MAX_PAYLOAD 128u #define HEALTH_PROTOCOL_VERSION_MISMATCH (1u << 4) #define HEALTH_BAD_PACKET_LENGTH (1u << 5) typedef struct { uint8_t version; uint8_t type; uint16_t payload_len; } FrameHeader; static bool protocol_accept_header(const FrameHeader *header) { if (header->version != PROTOCOL_VERSION) { // Good: version mismatch is explicit instead of becoming bad data. health_report(HEALTH_PROTOCOL_VERSION_MISMATCH); return false; } if (header->payload_len > PROTOCOL_MAX_PAYLOAD) { // Good: oversized frames fail before downstream code sees them. health_report(HEALTH_BAD_PACKET_LENGTH); return false; } return true; } // Bad: parse every payload as if all firmware revisions use the same layout. |
If you need more than one frame format, add capabilities to the handshake instead of guessing based on packet shape.
9. Test with hardware missing
The first prototype rarely behaves like the final product. Sensors are unplugged, modules are absent, cable harnesses are wrong, and optional boards are not always fitted. Firmware that only works when every device answers perfectly is not ready for bring-up.
A healthy firmware image needs a defined response when hardware is missing: disable the feature, set a fault flag, fall back to a safe mode, or continue with degraded behavior. It should not wait forever.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
typedef enum { SENSOR_PRESENT = 0, SENSOR_MISSING, SENSOR_UNSUPPORTED_ID, } SensorProbeResult; #define HEALTH_SENSOR_NOT_FOUND (1u << 6) #define HEALTH_SENSOR_BAD_ID (1u << 7) static SensorProbeResult sensor_probe(void) { uint8_t id = 0u; if (sensor_read_id(&id) != FW_OK) { // Good: missing hardware becomes a controlled degraded mode. health_report(HEALTH_SENSOR_NOT_FOUND); return SENSOR_MISSING; } if (id != EXPECTED_SENSOR_ID) { // Good: a wrong device is reported explicitly. health_report(HEALTH_SENSOR_BAD_ID); return SENSOR_UNSUPPORTED_ID; } return SENSOR_PRESENT; } // Bad: assume the sensor is fitted and block startup until it answers. |
Testing missing hardware early exposes startup assumptions. If one optional module can block the whole board from booting, the firmware is too dependent on an ideal bench setup.
10. Leave debug access available
Debug access is not a luxury on prototypes. SWD, reset, UART, and test points are the difference between a board you can investigate and a board you can only guess about. Firmware should support that access instead of treating it as an afterthought.
A small status command that reports version, reset cause, health flags, and a few raw readings often saves more time than a large logging system added late.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
static void debug_print_status(void) { debug_write("fw=%lu reset=%lu health=0x%08lX\r\n", FW_VERSION_U32, read_reset_reason(), system_health); // Good: raw numbers give the first useful clue during board debug. debug_write("uart_overflows=%lu last_adc=%u\r\n", uart_rx_overflow_count, adc_get_last_raw_counts()); } // Bad: debug_write("failed\r\n"); // too little context for a real board. |
It is easier to keep this path early than to add it after the board is already built. If space is tight, use pads and compact status words. Just do not remove the only practical way to see what the hardware is doing.
A Bring-Up Checklist
The point of these practices is not to make firmware look defensive on paper. It is to make the first bad bench session shorter. Before calling a board ready for serious testing, I would check these questions:
- Can every important hardware assumption be found by name?
- Can a failed driver call be seen after the immediate function returns?
- Is every wait loop bounded by a timeout?
- Can the firmware explain missing hardware without blocking startup?
- Can a host tool or technician read enough status to start diagnosis?
Final Takeaway
Reliable firmware is usually the result of small defensive choices made early. Name the assumptions, keep failures visible, use timeouts, and leave yourself a way to inspect the board when reality does not match the design.
The main idea is simple. Good firmware does not only try to work; it also tries to explain what happened when it does not. That is what shortens bring-up time and reduces the number of dead ends when the first prototype starts behaving differently from the plan.
