Table of Contents
KEY TAKEAWAYS
- LSP means: any implementation of an interface must be swappable without breaking the caller
- Define a clear contract for each interface: what the inputs mean, what the return values mean, what side effects are expected
- Document the contract in the header file as comments above the function pointer typedefs
- If an implementation needs special handling (like flash erase), it must handle it internally — not push it to the caller
What Is the Liskov Substitution Principle?
The Liskov Substitution Principle (LSP) states: if module A can be replaced by module B through the same interface, the program should still work correctly.In simpler terms: if your code works with a “generic sensor,” then plugging in any specific sensor (temperature, humidity, pressure) through the same interface should not break anything. Every implementation must honor the contract of the interface.Example 1: Storage Backend (Flash vs EEPROM)
Bad: Implementations behave differently through the “same” interface
typedef struct {
int (*write)(uint32_t addr, const uint8_t *data, int len);
int (*read)(uint32_t addr, uint8_t *data, int len);
} storage_driver_t;
/* Flash implementation */
int flash_write(uint32_t addr, const uint8_t *data, int len) {
/* Flash requires erasing before writing! */
/* But this function does NOT erase first */
/* Caller gets corrupted data if sector was not erased */
flash_program(addr, data, len);
return len;
}
/* EEPROM implementation */
int eeprom_write(uint32_t addr, const uint8_t *data, int len) {
/* EEPROM can overwrite directly - works fine */
eeprom_program(addr, data, len);
return len;
}Problem: The caller uses storage->write() expecting it to just work. But the flash version silently corrupts data because it skips the required erase step. You cannot substitute flash for EEPROM — LSP is violated.Good: Both implementations honor the same contract
typedef struct {
int (*write)(uint32_t addr, const uint8_t *data, int len);
int (*read)(uint32_t addr, uint8_t *data, int len);
int (*erase)(uint32_t addr, int len); /* Optional, may be no-op */
} storage_driver_t;
/* Flash: handles erase internally so write() always works */
int flash_write(uint32_t addr, const uint8_t *data, int len) {
flash_erase_sector(addr); /* Handles the requirement */
flash_program(addr, data, len);
return len;
}
int flash_erase(uint32_t addr, int len) {
flash_erase_sector(addr);
return 0;
}
/* EEPROM: write works directly, erase is a no-op */
int eeprom_write(uint32_t addr, const uint8_t *data, int len) {
eeprom_program(addr, data, len);
return len;
}
int eeprom_erase(uint32_t addr, int len) {
return 0; /* No erase needed, but interface is consistent */
}
/* Usage: caller does not care which storage is used */
void save_config(storage_driver_t *storage, config_t *cfg) {
storage->write(CONFIG_ADDR, (uint8_t *)cfg, sizeof(config_t));
}
/* Both work correctly */
storage_driver_t flash_drv = { flash_write, flash_read, flash_erase };
storage_driver_t eeprom_drv = { eeprom_write, eeprom_read, eeprom_erase };
save_config(&flash_drv, &my_config); /* Works */
save_config(&eeprom_drv, &my_config); /* Also works */Example 2: Communication Interface (UART vs SPI)
Bad: SPI implementation changes the expected behavior
typedef struct {
int (*send)(const uint8_t *data, int len);
int (*receive)(uint8_t *buf, int max_len);
} comm_interface_t;
/* UART: returns number of bytes actually sent */
int uart_send(const uint8_t *data, int len) {
for (int i = 0; i < len; i++) {
uart_tx_byte(data[i]);
}
return len; /* Returns bytes sent */
}
/* SPI: returns something completely different! */
int spi_send(const uint8_t *data, int len) {
spi_transfer(data, len);
return 0; /* Returns 0 for "success" instead of byte count! */
}Problem: The caller checks if (comm->send(data, 10) == 10) to verify all bytes were sent. UART returns 10, but SPI returns 0. The caller thinks SPI failed. LSP is violated because the return value contract is different.Good: Both follow the same contract
/*
* Contract: send() returns number of bytes successfully sent.
* Returns negative value on error.
*/
typedef struct {
int (*send)(const uint8_t *data, int len);
int (*receive)(uint8_t *buf, int max_len);
} comm_interface_t;
int uart_send(const uint8_t *data, int len) {
for (int i = 0; i send(msg, len);
if (sent != len) {
handle_send_error(sent);
}
}
comm_interface_t uart = { uart_send, uart_receive };
comm_interface_t spi = { spi_send, spi_receive };
send_message(&uart, data, 10); /* Works */
send_message(&spi, data, 10); /* Also works, same behavior */The LSP Contract Checklist
When creating interchangeable implementations, ensure:| Rule | Description |
|---|---|
| Same return semantics | If the interface says “returns byte count,” all implementations must return byte count |
| Same error handling | If one returns -1 on error, all must return -1 on error |
| No hidden preconditions | Do not require callers to “erase before write” for some implementations only |
| No surprise side effects | One implementation should not reset global state that others leave alone |
Key Takeaways
- LSP means: any implementation of an interface must be swappable without breaking the caller
- Define a clear contract for each interface: what the inputs mean, what the return values mean, what side effects are expected
- Document the contract in the header file as comments above the function pointer typedefs
- If an implementation needs special handling (like flash erase), it must handle it internally — not push it to the caller

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.







