Monday, December 30, 2024

Embedded Software: Simple vs. Complex

Embedded software is integral to modern technology, ranging from simple home appliances to advanced autonomous systems. It can be broadly classified into two categories: simple (Non-OS) and complex (OS-driven) embedded software.

Simple Embedded Software: When simplicity and low cost are priorities and an OS would be overkill

Examples:

  1. Power or temperature monitoring systems.
  2. Simple applications in household appliances like ovens and washing machines.
Characteristics:
  1. Typically designed for applications with few tasks.
  2. No operating system necessary.
  3. Software interacts directly with the microcontroller’s hardware (registers etc.), forcing rewrites if the hardware changes.
Advantages:
  1. Low power consumption, low cost.
  2. Can be developed by electronics engineers, no need for computer engineers because basic embedded programming knowledge is sufficient.
  3. Deterministic: By sidestepping the complexity of OS schedulers, simple systems achieve predictable performance.
  4. Fewer abstraction layers make verification and validation straightforward, which is a huge advantage for safety-critical certification.
Complex Embedded Software: When multi-tasking, file operations and networking necessitate an OS

Examples:

  1. IoT devices requiring seamless connectivity.
  2. Systems involving advanced sensor integration or navigation.
Characteristics:
  1. Runs on an operating system that manages tasks and system resources.
  2. Capable of handling multiple tasks and applications simultaneously.
  3. Safety-critical certification is difficult. To make it easier, safety-critical parts should be developed as separate, simpler modules.
Advantages:
  1. Less competition and higher profit margins, provided that you have a strong technical team.
  2. Requires computer engineers to lead the development because of increased software complexity. Besides embedded software courses, related concepts of algorithms, data structures, and operating systems are also a core parts of computer engineering but not electronics engineering.
  3. The OS abstracts low-level hardware management, enabling developers to focus on application logic. A POSIX-compliant application, for instance, can run on any POSIX-supporting OS with minimal changes.
  4. Easier for new developers to adapt and contribute due to less hardware dependency.
  5. A broad range of pre-existing libraries simplifies development.
  6. Operating systems provide abstraction layers (e.g., Linux Device Model), allowing drivers to expose standard interfaces while interacting with specific hardware.
  7. Simplifies adding new functionality (e.g. telemetry) or adapting to new hardware (e.g. new/different sensors).
  8. With minor modifications, software can be tested on a PC, speeding up testing with less effort (no need for electronic cards, power supplies, etc.) and reducing bugs.
Operating systems can also be categorized as either simple (e.g., FreeRTOS) or complex (e.g., real time Linux with ROS) - but let's leave that topic for another blog post.

Monday, December 16, 2024

Serialization

In C++, to serialize simple data, aka plain old data (POD), where the layout in memory is predictable, you can use a char* (byte) buffer:


// Serialization for simple data structures (POD) with C++
// Şamil Korkmaz, 16.12.2024
#include <iostream>
#include <cstring> // For memcpy
// Simple POD structure
struct MyStruct {
int id;
double value;
char name[50];
};
void serializeToBuffer(const MyStruct& obj, char* buffer) {
std::memcpy(buffer, &obj, sizeof(MyStruct));
}
void deserializeFromBuffer(const char* buffer, MyStruct& obj) {
std::memcpy(&obj, buffer, sizeof(MyStruct));
}
int main() {
MyStruct original = {42, 3.14, "Example"};
char buffer[sizeof(MyStruct)];
serializeToBuffer(original, buffer);
MyStruct deserialized;
deserializeFromBuffer(buffer, deserialized);
// Output the deserialized structure
std::cout << "Deserialized Object:\n";
std::cout << "ID: " << deserialized.id << "\n";
std::cout << "Value: " << deserialized.value << "\n";
std::cout << "Name: " << deserialized.name << "\n";
return 0;
}

This method cannot be used for non-POD types (e.g., those with pointers or virtual methods) because their memory layout is not portable. Examples are std::string, std::vector. For such types, you can use std::ostringstream:


// Serialization for non-POD (e.g. std::string, std::vector) with C++
// Şamil Korkmaz, 16.12.2024
#include <sstream> // Required for std::ostringstream and std::istringstream
#include <iostream>
#include <vector>
#include <string>
#include <cstring> // For memcpy
struct MyStruct {
int id;
double value;
std::string name;
std::vector<int> numbers;
void serialize(std::ostream& out) const {
out.write(reinterpret_cast<const char*>(&id), sizeof(id));
out.write(reinterpret_cast<const char*>(&value), sizeof(value));
// Serialize std::string: First the string size, followed by the characters
size_t nameSize = name.size();
out.write(reinterpret_cast<const char*>(&nameSize), sizeof(nameSize));
out.write(name.data(), nameSize);
// Serialize std::vector: First the vector size, followed by its elements
size_t vecSize = numbers.size();
out.write(reinterpret_cast<const char*>(&vecSize), sizeof(vecSize));
out.write(reinterpret_cast<const char*>(numbers.data()), vecSize * sizeof(int));
}
void deserialize(std::istream& in) {
in.read(reinterpret_cast<char*>(&id), sizeof(id));
in.read(reinterpret_cast<char*>(&value), sizeof(value));
// Deserialize std::string: Resize the string and read the characters into it
size_t nameSize;
in.read(reinterpret_cast<char*>(&nameSize), sizeof(nameSize));
name.resize(nameSize);
in.read(&name[0], nameSize);
// Deserialize std::vector: Resize the vector and read its elements
size_t vecSize;
in.read(reinterpret_cast<char*>(&vecSize), sizeof(vecSize));
numbers.resize(vecSize);
in.read(reinterpret_cast<char*>(numbers.data()), vecSize * sizeof(int));
}
};
int main() {
MyStruct original = {42, 3.14, "Example", {1, 2, 3, 4, 5}};
std::ostringstream out; // Ensure <sstream> is included
original.serialize(out);
std::istringstream in(out.str()); // Ensure <sstream> is included
MyStruct deserialized;
deserialized.deserialize(in);
std::cout << "Deserialized Object:\n";
std::cout << "ID: " << deserialized.id << "\n";
std::cout << "Value: " << deserialized.value << "\n";
std::cout << "Name: " << deserialized.name << "\n";
std::cout << "Numbers: ";
for (int num : deserialized.numbers) {
std::cout << num << " ";
}
std::cout << "\n";
return 0;
}
Both approaches assume that the serialized data format and endianness match between serialization and deserialization. For more complex cases, use libraries like nlohmann/json for JSON-based serialization and Boost.Serialization for binary/text serialization with more features.

Data Structure Alignment

The C++ compiler aligns data structures to the largest alignment required by any field (8 bytes in the case below, due to double). This ensures faster memory access, as modern CPUs perform better when data is aligned to specific boundaries because it results in single memory word access. As a side effect, sizeof(MyStructure) (40 bytes due to padding) is larger than the sum of individual fields (33 bytes).

/* The compiler aligns the structure to the largest alignment required by any field (8 bytes in the case below, due to double).
This ensures faster memory access, as modern CPUs perform better when data is aligned to specific boundaries because of fewer
memory access.
Şamil Korkmaz, 16.12.2024 */
#include <iostream>
struct MyStruct {
int i1; // 4 bytes
double d1; // 8 bytes
char s[9]; // 9 bytes
int i2; // 4 bytes
double d2; // 8 bytes
}; // sum: 4+8+9+4+8 = 33 bytes
int main() {
std::cout << "Offset of i1: " << offsetof(MyStruct, i1) << "\n"; // 0
std::cout << "Offset of d1: " << offsetof(MyStruct, d1) << "\n"; // 8
std::cout << "Offset of s : " << offsetof(MyStruct, s) << "\n"; // 16
std::cout << "Offset of i2: " << offsetof(MyStruct, i2) << "\n"; // 28
std::cout << "Offset of d2: " << offsetof(MyStruct, d2) << "\n"; // 32
std::cout << "sizeof(MyStruct): " << sizeof(MyStruct) << " bytes\n"; // 40
return 0;
}
view raw alignment.cpp hosted with ❤ by GitHub

Field Offsets and Padding

  1. int i1:

    • Requires 4-byte alignment.
    • Starts at offset 0.
    • Takes 4 bytes.
    • The next field, d1, requires 8-byte alighment. Since i1 ends at offset 4, the compiler adds 4 bytes of padding after i1.
  2. double d1:

    • Requires 8-byte alignment.
    • Starts at offset 4 + 4 = 8.
    • Takes 8 bytes.
  3. char s[9]:

    • Requires no specific alignment (1-byte alignment is sufficient).
    • Starts at offset 16 (immediately after d1).
    • Takes 9 bytes.
    • The next field, int i2, requires 4-byte alignment. Therefore, the compiler adds 3 bytes of padding after s to ensure proper alignment.
  4. int i2:

    • Requires 4-byte alignment.
    • Starts at offset 28.
    • Takes 4 bytes.
  5. double d2:

    • Requires 8-byte alignment.
    • The next offset must be a multiple of 8. Since i2 ends at offset 32 (already aligned), no padding is required.
    • Starts at offset 32.
    • Takes 8 bytes.

Tuesday, December 3, 2024

Simulation variable names

In simulation, you have to be precise when talking about a parameter. For example, it is never enough to say "height". You should always say "height with respect to mean sea level, with units in feet". The reason is that height can also be measured from WGS84 ellipsoid or ground (AGL). Every couple of months, I see engineers waste days, sometimes weeks, due to such misunderstandings.

Here is a list that I frequently encounter, with bad and good variable naming:
  • height: h - hMSL_ft (height measured from MSL, units in feet)
  • time: t - timeFreeFlight_s (time started at free flight start, units in seconds)
  • velocity: v - v_bc_Fn_mps (velocity of body fixed frame Fb wrt ground fixed frame Fc, with components expressed in NED frame, units in m/s)
  • speed: v - speed_Mach
  • acceleration: a - a_bi_noG_Fb_mps2 (acceleration of Fb wrt inertial frame Fi, without gravity components, expressed in Fb, units in m/s^2)
  • Euler angles: euler - euler_Fn2FbRFB321_rpy_rad (321 yaw pitch roll sequence rotated frame based Euler angles that convert a vector in Fn to a vector in Fb, array index order is roll pitch yaw, units in radians)
  • Azimuth: az - azimuthTrueNorth_deg (azimuth angle measured from True North, units in degrees)