This page aims to highlight code structure and architecture practices used throughout all C applications. Please note that anything on this page is just choices that we maintain- they are not objective rules , nor are they the only “correct” way.
This is not where syntax/style standards are discussed. For that, visit this page
Any code snippets below are probably closer to pseudocode, they are meant to depict a concept not to be copy and pasted
Jump to:
General Philosophy
These standards were developed with the following goals and realities in mind:
Replicability
We want these standards to work for all applications, regardless of purpose, hardware, or other specifics.
User-Friendly
Given the high turnover within the club, standards should be easy to follow and maintain a logical flow
Memory-Consciousness
Running into memory issues sucks, and our standards will ensure efficiency in that regard
Industry-Standardization
We want users to gain technical skills applicable to industry, these rules should be common methods. The Linux Kernel is always a good starting point, and will be the foundation of most practices (though we can veer away as needed)
Being Realistic
Whether in support of or as an exception to anything above, we want to keep in mind that our hardware peripherals and high-level architecture are not changing too much on a year-to-year basis, and we will take advantage of that fact to promote simplicity
Standards
Object Handling
all hardware abstractions should belong to an object struct for that peripheral. There should be no floating global variables that belong to a piece of hardware.
This does not mean every variable needs to belong to an object, just that any variable associated with hardware should be encapsulated this way.
typedef struct { CAN_HandleTypeDef *hcan; const uint32_t *id_list; uint8_t id_list_len; } can_t;
here is an example of how to do this well. The can_t object takes STM’s hardware peripheral object, along with our own details like the CAN ID whitelist, and encapsulates this together.
Other examples could include:
creating a fan object to contain the PWM pins and controls, the duty cycle, etc.
creating a battery data object to store every piece of information associated with out accumulator, as is done with the acc_data_t in Shepherd
Movement of Objects
Whenever possible, the objects above should be used locally by utilizing the extern keyword. There are excpetions (like acc_data in Shepherd), but in general, we should prefer this over passing references to every function.
Instead of this:
uint8_t can_init(CAN_HandleTypeDef* hcan, can_t* canline, uint8_t id_list) { canline->hcan = &hcan; canline->id_list = id_list ... } (main.c) local_id_list1 = 0b01010101; local_id_list2 = 0b10101010; compute_init(&hcan1, &can1, local_id_list1); compute_init(&hcan2, &can2, local_id_list2);
Do this:
extern CAN_HandleTypeDef hcan1; extern CAN_HandleTypeDef hcan2; can_t can1; can_t can2; uint8_t compute_init() { can1.hcan = &hcan1; local_id_list = 0b01010101; can1.id_list = local_id_list; can2.hcan = &hcan2; local_id_list = 0b1010101; can2.id_list = local_id_list;
This choice is definitely one that you will see both ways in embedded. In the end, we this choice was made because:
using the same object is more memory efficient (even if barely)
More simple, easier to keep track of objects
The main drawback to this is it is not as scalable, ie, adding a third CAN line would be much more challenging in this method than by using references. But, see philosophy rule #5 for why this is an acceptable drawback.
Data Types
Miscellaneous individual guidelines here:
Use unsigned ints for anything that does not correlate to a signed, real world value or flag
Our applications deal with so much data that it can be hard to remember what values can actually be negative. To simplify this, anything made signed (int, int8, int16…) should be done so deliberately/ because it needs to be negative at times
Bools should only be used for logical true or false (ex: device_connected)
use uint8s for anything logical high or low (ex: pin state)
Use ints multiplied by factors of ten in place of floats for long-term data storage
Floats take up more space than int8 and int16. For any variable that is stored long term, meaning it is not a local intermediate that will be popped from the stack quickly, we should store is as an int multiplied by 10, 100, 10000, etc to maintain the decimal precision.
ex: acc_data in Shepherd stores voltages as a factor of 10,000, so a stored value of 37650 = 3.7650V
Driver Generalization
This is mainly applicable for drivers found in Embedded Base
Also, this rule is almost a direct contradiction to the rule described in “Movement of Objects”, but since these are not being treated as objects, I’m allowing it.
All embedded base drivers should be made independent of any HAL or other driver. Instead of relying on a specific HAL, function pointers should be used to allow any HAL to work with the driver
Instead of this:
int lsmdso_read_reg(uint8_t reg) { return HAL_i2c_Mem_read(...); // THIS IS A FUNCTION FROM STM ONLY FOR CERTAIN STM32 DEVICES }
Which relies on STM’s HAL i2c driver,
Do this:
typedef void (*I2C_WriteFuncPtr)(int device_addr, int reg_addr, int data); typedef int (*I2C_ReadFuncPtr)(int device_addr, int reg_addr); typedef struct { I2C_WriteFuncPtr local_I2C_Write; I2C_ReadFuncPtr local_I2C_Read; } lsm6dso_t; lsm6dso_t imu; //DEFINE OBJECT HERE int lsmdso_read_reg(uint8_t reg) { return local_I2C_Read(...); } (main.c) lsm6dso_t imu; imu = {.I2C_WriteFuncPtr = HAL_i2c_MemWrite, .I2C_ReadFuncPtr = HAL_i2c_MemRead};
While this is obviously more work, it means that these drivers should succeed for the foreseeable future and for all different chip sets in use right now, without having to change.
Also note the method of updating these values does utilize extern. For devices where more than one of these devices may be present on the bus, have the driver and its APIs have a device select parameter to dynamically update to a different device, do not make multiple instances of the device struct. In the above example, you may notice the first parameter is “device address”. This would allow you to have multiple lsm6dso’s on the bus (as they’d have the different i2c addresses), but they both use the same, local lsm6dso object, which works because this object only contains function pointers to i2c read and write, which will be the same no matter which instance of the hardware you are refencing.
Note that this requires function signatures that match those of the function pointers defined in the driver. Make these as general as possible, and keep in mind wrappers may need to be made around the HAL in use to ensure their signature is the same