TL;DR:
- Using packed structs with bitfields for register definitions
- Minimum Viable Driver
As I write this post, I have been working in the Embedded Electronics field for just over four years, and over that time I have written numerous drivers using various protocols, improving each time. This lands me here, with a framework that I believe acts as a solid foundation to writing a clean, maintainable, and transferrable driver for any integrated-circuit (IC).
Background Knowledge
Most ICs have the following in common:
- A communication bus used to control the IC (SPI, I2C, UART, etc.)
- Storage for configuration and status variables (i.e. Registers)
At the core, a driver is simply a piece of code that enables one device to control or communicate with another. In most cases, the driver is how the device can use the communication bus to read/write the configuration of the IC.
Representing Registers in Code
There are many ways to represent registers in code. Lets use the following register for the purposes of the example
REG_CONFIG: 0x02
+--------+----------+----------+------+----------+
| 7 | 6:5 | 4:2 | 1 | 0 |
+--------+----------+----------+------+----------+
| LED_EN | PWR_MODE | RESERVED | MUTE | SHUTDOWN |
+--------+----------+----------+------+----------+
This register REG_CONFIG
is located at address 0x02
and contains the following bits:
- Bit 0: Shutdown
- 0: No
- 1: Yes
- Bit 1: Mute
- 0: No
- 1: Yes
- Bits 2 to 4: Reserved
- Bits 5 to 6: Power Mode
- 00: Sleep
- 01: Power On
- 10/11: Standby
- Bit 7: LED Enable
- 0: Off
- 1: On
To implement this in code, we can “pack” structs and use bitfields:
struct reg_config_t {
char shutdown : 1;
char mute : 1;
char reserved : 3;
char power_mode : 2;
char led_en : 1;
} __atribute__((packed));
Using packed structs with bitfields allow us to define what each byte within the struct represents. In this case, even though we have 5 members of type char
they all get packed into the size of one char
(1-Byte) as long as the number of bits defined in the bitfields does not exceed 8.
If we want to make modifications to the register, we now have easy access to the bits:
reg_config_t reg_config;
...
reg_config.led_en = 1;
reg_config.mute = 0;
...
To take this a step further, we can define an enum
to capture the possible values of each bitfield within the register:
enum reg_config_led_en_t {
REG_CONFIG_LED_OFF = 0b0,
REG_CONFIG_LED_ON = 0b1
};
reg_config_t reg_config;
reg_config.led_en = REG_CONFIG_LED_ON;
Finally, we want to define the address within our code. This can be done using either a #define
preprocessor macro or as a type const
in our software. Both options are OK, but be cautious with macros as they can be re-defined elsewhere in the code.
#define ADDR_REG_CONFIG 0x02
OR
const unsigned int ADDR_REG_CONFIG = 0x02;
Now putting it all together:
...
const unsigned int ADDR_REG_CONFIG = 0x02;
enum reg_config_shutdown_t {
REG_CONFIG_SHUTDOWN_OFF = 0,
REG_CONFIG_SHUTDOWN_ON = 1
};
enum reg_config_mute_t {
REG_CONFIG_MUTE_OFF = 0,
REG_CONFIG_MUTE_ON = 1
};
enum reg_config_power_mode_t {
REG_CONFIG_POWER_MODE_SLEEP = 0,
REG_CONFIG_POWER_MODE_ON = 1,
REG_CONFIG_POWER_MODE_STANDBY = 2
};
enum reg_config_led_en_t {
REG_CONFIG_LED_OFF = 0,
REG_CONFIG_LED_ON = 1
};
struct reg_config_t {
char shutdown : 1;
char mute : 1;
char reserved : 3;
char power_mode : 2;
char led_en : 1;
} __atribute__((packed));
void init_reg_config() {
reg_config_t reg_config;
reg_config.shutdown = REG_CONFIG_SHUTDOWN_OFF;
reg_config.mute = REG_CONFIG_MUTE_ON;
reg_config.power_mode = REG_CONFIG_POWER_MODE_ON;
reg_config.led_en = REG_CONFIG_LED_ON;
writeRegister(ADDR_REG_CONFIG, reg_config, sizeof(reg_config_t));
}
...
Minimum Viable Driver
When I say minimum viable driver, I mean the base functionality that needs to be implemented in order for control of an IC to occur. For most cases, this is as simple as implementing two key functions:
size_t writeReg(unsigned int address, char *data, size_t length);
And…
size_t readReg(unsigned int address, char *data, size_t length);
Generally, if writeReg
and readReg
are implemented, you have full control of the target IC. After implementing these methods, you can then create other smaller methods that provide easier modification for the register bits. See below:
bool setAlarmMute(bool mute) {
reg_config_t config;
size_t bytes_read =
readReg(ADDR_REG_CONFIG, (char *)(&config), sizeof(reg_config_t));
if (bytes_read <= 0)
return false;
config.mute = mute ? REG_CONFIG_MUTE_ON : REG_CONFIG_MUTE_OFF;
size_t bytes_written =
writeReg(ADDR_REG_CONFIG, (char *)(&config), sizeof(reg_config_t));
if (bytes_written != sizeof(reg_config_t))
return false;
return true;
}