Created: August, 25, 2023 Last Modified: August, 25, 2023
Building a C Compile Time Lookup Table Using Macros
Why Use A Compile Time LUT?
Compile time LUTs allow having complex configuration settings without runtime lookups, while maintaining a simple board configuration file. A good use of this is embedded devices, where you may have a board configuration that sets the GPIO Pins for various external devices. Compile time LUTs allows the pin to defined in a preprocessor definition in the board configuration file. Then when a attributes of the pin such as the port its on is needed, all that is required is passing the pin definition to a look up function.
Implementing the Lookup Macro
In order to implement the macro, we first need a define that the macro will accept
#define GPIO_A1 0
#define GPIO_A2 1
#define GPIO_A3 2
#define GPIO_A4 3
#define GPIO_B1 4
#define GPIO_B2 5
#define GPIO_B3 6
#define GPIO_B4 7
This is a simple set of definitions that is similar to an enum. Enums unfortunately can not be used in this context since they are not resolved in the precompiler like defines are. Now we can create a lookup using the value from the pin as the look up.
#define GPIO_PORT0 'A'
#define GPIO_PORT1 'A'
#define GPIO_PORT2 'A'
#define GPIO_PORT3 'A'
#define GPIO_PORT4 'B'
#define GPIO_PORT5 'B'
#define GPIO_PORT6 'B'
#define GPIO_PORT7 'B'
The attributes for each pin is looked up using the value of its define, so the define GPIO_PORT0 will be the port definition for GPIO_A1. Using the value of the GPIO_A1 definition makes writing the lookups more difficult, but it enables the pin to be behind a more abstract define such as LED_PIN and still be looked up. For this example we use a char to define the port, however this would usually be some struct or struct pointer when actually used in an embedded system. Now we can implement a lookup macro using these defines.
#define GPIO_PORT_LUT(x) PRIMITIVE_CAT(GPIO_PORT , x)
This macro simply concats the value of the passed define along withe GPIO_PORT in order to get the correct define. You can now access the port of any pin using
GPIO_PORT_LUT(GPIO_A1)
This can also take in more than one argument for more complicated lookups, such as what Alternative Function a pin uses for a given peripheral.
#define GPIO_AF_LUT(f, ...) PRIMITIVE_CAT(GPIO_AF_ ## f ## _, __VA_ARGS__)
#define GPIO_AF_UART1_0 "AF1"
#define GPIO_AF_UART1_1 "AF1"
#define GPIO_AF_UART1_2 "AF1"
#define GPIO_AF_UART1_3 "AF1"
#define GPIO_AF_UART1_4 "AF2"
#define GPIO_AF_UART1_5 "AF2"
#define GPIO_AF_UART1_6 "AF2"
#define GPIO_AF_UART1_7 "AF2"
The above LUT allows lookup for AF for a given pin and UART. Which can be accessed like this.
GPIO_AF_LUT(UART1, GPIO_A1)
A full working example of these LUTs can be found below
#include <stdio.h>
#define GPIO_A1 0
#define GPIO_A2 1
#define GPIO_A3 2
#define GPIO_A4 3
#define GPIO_B1 4
#define GPIO_B2 5
#define GPIO_B3 6
#define GPIO_B4 7
#define GPIO_AF_UART1_0 "AF1"
#define GPIO_AF_UART1_1 "AF1"
#define GPIO_AF_UART1_2 "AF1"
#define GPIO_AF_UART1_3 "AF1"
#define GPIO_AF_UART1_4 "AF2"
#define GPIO_AF_UART1_5 "AF2"
#define GPIO_AF_UART1_6 "AF2"
#define GPIO_AF_UART1_7 "AF2"
#define GPIO_PIN0 1
#define GPIO_PIN1 2
#define GPIO_PIN2 3
#define GPIO_PIN3 4
#define GPIO_PIN4 1
#define GPIO_PIN5 2
#define GPIO_PIN6 3
#define GPIO_PIN7 4
#define GPIO_PORT0 'A'
#define GPIO_PORT1 'A'
#define GPIO_PORT2 'A'
#define GPIO_PORT3 'A'
#define GPIO_PORT4 'B'
#define GPIO_PORT5 'B'
#define GPIO_PORT6 'B'
#define GPIO_PORT7 'B'
//Enables combining macros together
#define PRIMITIVE_CAT(a,...) a ## __VA_ARGS__
#define GPIO_PIN_LUT(x) PRIMITIVE_CAT(GPIO_PIN , x)
#define GPIO_PORT_LUT(x) PRIMITIVE_CAT(GPIO_PORT , x)
#define GPIO_AF_LUT(f, ...) PRIMITIVE_CAT(GPIO_AF_ ## f ## _, __VA_ARGS__)
int main(int argc, char *argv[])
{
printf("GPIO Pin %c%d, UART1: %s\n", GPIO_PORT_LUT(GPIO_A1), GPIO_PIN_LUT(GPIO_A1), GPIO_AF_LUT(UART1, GPIO_A1));
printf("GPIO Pin %c%d, UART1: %s\n", GPIO_PORT_LUT(GPIO_B3), GPIO_PIN_LUT(GPIO_B3), GPIO_AF_LUT(UART1, GPIO_B3));
return 0;
}
Downsides of Compile Time LUTs
Compile time LUTs are not the most ergonomic for HAL implementors, since it requires writing a defintion for every entry with a random number that corresponds to the GPIO. Mistakes can be easy to make in this situation, and the lack of enums makes defining the GPIOs a little cumbersome as well. For those reasons they're best used when memory and performance is a constraint and when defintions don't need to be updated often. Embedded devices can be a good use for them since this typically will only need to be implemented once per MCU, which then makes adding new boards that share the same MCU easy and less error prone, while removing the need for runtime based lookups and configuration. The lookups could be made easier to writing using some compile time tooling that generates them based on a provided configuration file for the MCU.