Skip to content

Instantly share code, notes, and snippets.

@sjgallagher2
Last active July 7, 2024 03:08
Show Gist options
  • Save sjgallagher2/7815a26d9c2c335237fca14bd02d1fd5 to your computer and use it in GitHub Desktop.
Save sjgallagher2/7815a26d9c2c335237fca14bd02d1fd5 to your computer and use it in GitHub Desktop.
A get-started-fast guide to embedded C
/*
* In embedded systems, the C programming language is most often the language of choice. For more intensive
* elements in the system, assembly can be used. Embedded C is distinct from typical C programming in its
* requirements for efficiency, its limited resources, and its unique hardware problems which are much less common in
* the majority of C programs. Even still, the language itself is the same, so check out K&R's The C Programming
* Language and other reference books.
*
* Some of the problems central to embedded systems programming:
* - Memory management
* - Register access and manipulation
* - Peripheral access
* - Communication over serial and USB lines
* - Handling of interrupts
* - Sharing of data with programs and ISRs, typically requiring multithreading-like techniques like semaphores
* - Managing compilation to the assembly level
* - Cross-platform compilation
* - Startup scripts
* - Linker scripts
* - Avoiding compiler optimization pitfalls
*
* And these apply only to 'bare-metal' programs - programs utilizing real-time OSs have other common issues to
* consider.
*/
/***** BOOK RECOMMENDATIONS ******/
// A first reference for all the topics talked about here is:
// * Valano, "Embedded Microcomputer Systems - Real Time Interfacing" 3rd ed. (2011)
// This should be the go-to reference for microcontroller programming and interfacing. It's one of the best books
// I've found for these topics.
// * Deitel, "Operating Systems"
// Embedded systems just about -all- use operating-system principles for scheduling tasks, managing memory,
// handling mutual exclusion, creating unified hardware interfaces, and so on. This is a great book to have.
// Another good reference:
// * Douglass, "Design Patterns for Embedded Systems in C" (2011)
// Featuring Group-Of-Four style design patterns applied to microcontroller systems, this is a neat book.
// Next suggestion:
// * White, "Making Embedded Systems"
// Good book for similar topics to Valano, and other essential topics like fixed-point arithmetic and bootloaders etc
// Some more:
// * Warren, "Hacker's Delight"
// Basically -the- handbook of calculation and computing close to the metal, everything from bitwise operations
// to optimized algorithms, a giant collection of low-level tricks and tips, a must-have.
// * Clements, "Microprocessor Systems Design: 68000 Hardware, Software and Interfacing
// A book for the M68k architecture, which itself is interesting (if dated), which introduces a lot of concepts
// and is one I reach for to get a slightly different view of various topics like memory management.
/********* PROJECTS OF NOTE **********/
// It's hard to find good projects using a microprocessor with open source firmware to demonstrate these principles.
// There are tons of projects out there, but finding high quality ones which aren't one-off tutorials or examples
// is tricky. Here's what I've found.
/*
* Dangerous Prototypes (http://dangerousprototypes.com/) A few different open source hardware projects, need more research
* HackRF (https://github.com/mossmann/hackrf/wiki) HackRF One uses an LPC43xx ARM microcontroller
-> Uses OpenCM3 (https://github.com/libopencm3/libopencm3), an alternative microcontroller library
* https://github.com/blacksphere/blackmagic Black Magic Probe (BMP), JTAG interface and debugger
* -> Also uses OpenCM3
* (https://hforsten.com/cheap-homemade-30-mhz-6-ghz-vector-network-analyzer.html) This is a VNA (!), uses HackRF firmware
* OpenMV (https://github.com/openmv/openmv) Machine vision, supports STM32 devices
* Klipper (https://github.com/KevinOConnor/klipper) 3D printer firmware, compatible across many devices
* Marlin (https://github.com/MarlinFirmware/Marlin) Another 3D printer firmware project, broad support
* NumWorks Epsilon (https://github.com/numworks/epsilon) NumWorks is an open source graphing calculator with amazing
* documentation on everything from hardware to mechanical design to software. Runs bare metal ARM. See also the
* website, here: https://www.numworks.com/resources/engineering/software/
*/
/********* COMPILERS AND PREPROCESSOR STATEMENTS *********/
// Know your compiler. Assuming GCC, we can look at common preprocessor statements. These can really throw you
// for a loop when you see them in code unexpectedly, especially in manufacturer code, hardware drivers, and the
// CMSIS library. But don't worry, they're only here to help.
// Some common reasons for needing preprocessor statements:
// - Defining macros and constants
// - Adding extra warnings
// - Forcing the compiler to optimize or not optimize a section
// - Forcing the compiler to make code inline
// - Forcing the compiler to set a variable to a particular location in memory
// - Inserting assembly directly into a C file
// Many statements seen frequently, such as #pragma once, are specific to a compiler (in that case, visual
// studio's C compiler). These may be shorthands for common statements, such as include guards. Others can be
// used to pass argument-like commands to the compiler. Some common and useful preprocessor statements follow.
// Note that when a compiler-specific feature is used, it's common practice to alias it. For example, the asm
// keyword, a C extension available to GCC, might not be available in other compilers. To increase portability,
// then, it's good practice to have something like this:
#ifdef __GNUC__
#define __asm__ asm
#endif
#ifdef __MSC_VER
#define __asm__ <visual studio assembly keyword>
#endif
// etc
// Note how __GNUC__ is assumed to be defined whenever we use gcc. There are loads of these that can be used to
// check system and compiler information. For example,
// Architecture:
// __i386__
// __x86_64__
// __arm__ (and __ARM_ARCH_5T__ or __ARM_ARCH_7A__)
// __powerpc64__
// __aarch64__
// Compiler:
// _MSC_VER
// __GNUC__
// __clang__
// __MINGW32__
// __MINGW64__
// Common preprocessor statements:
// 1. __attribute__
// This statement covers a whole lot of different options for "attributes" of functions, variables, types,
// labels, enums, and statements. The syntax is:
// __attribute__ ((<attribute list>))
// Note the double parentheses. The attribute list is a comma-separated sequence of attributes, which can be
// empty, an attribute name, an attribute name followed by a parenthesized list of parameters for the attribute.
// You're also allowed to put double underscores around an attribute name to avoid conflicts.
//
// Let's look at a few attributes to get an idea of how they work. Attributes for variables, functions, etc can
// be organized into 'common' attributes and architecture-specific attributes. As an example, take the
// warn_if_not_aligned variable attribute. Given a struct, we may want to ensure data alignment to some number of
// byte boundaries. The __attribute__ section goes after the variable definition in this case.
struct reg_struct
{
uint32_t section1;
uint32_t section2 __attribute__(( warn_if_not_aligned(16) )); // Issue a warning if this section is not
// aligned to a 16 byte boundary
};
// Variable attributes include:
// aligned
// aligned (alignment)
// warn_if_not_aligned(alignment)
// alloc_size(position)
// alloc_size(position1,position2)
// cleanup (cleanup_function)
// section ("section name") Set the section (e.g. .bss, .data, specialized sections)
// Function attributes include:
// aligned
// aligned (alignment)
// always_inline
// constructor
// constructor (priority)
// destructor
// destructor (priority)
// noreturn
// section ("section name")
// ARM-specific function attributes:
// general-regs-only Indicate no specialized registers (floating-point or Advanced SIMD) should be used
// interrupt Indicate function is an interrupt handler. Can take a string argument "IRQ", "FIQ", "SWI",
// "ABORT", or "UNDEF" to specify the type of interrupt handler. The type is ignored for
// ARMv7-M.
// isr Alias for interrupt
// target Specify instruction set, architecture, floating-point unit for function
// For example:
void f () __attribute__ ((interrupt));
// 2. asm (and __asm__)
// Assembly can be inserted inline with the following syntax:
// asm [qualifiers] ( "assembly instructions" )
// You can also use extended asm, which lets you read and write C variables and to jump to C labels, when you're
// within a C function. You cannot use extended asm at file scope.
//
// The qualifiers are either volatile or inline. However, volatile has no effect, as all asm blocks are volatile.
// The assembly instructions section is a literal string. For multi-line assembly, you can insert a newline (and
// preferaby a tab as well), '\n\t', then continue.
// Example:
asm("MOV r3, 0x80\n");
// If you're inside a C function, you can use extended asm. It has a different format:
// asm [qualifiers] ( "template"
// : <output operands>
// [: <input operands>]
// [: <clobbers>] )
// Or:
// asm [qualifiers] ( "template"
// :
// : <input operands>
// : <clobbers>
// : <goto labels> )
//
// In extended asm, qualifiers available are volatile, inline, and goto. In this case, volatile is -not- assumed
// by default, so if the statements can change variable values, you should use volatile. The goto qualifier
// indicates that the statement may jump to one of the labels listed in <goto labels>.
//
// Extended asm is more like a printf statement for assembly. The best source for this stuff is the gcc docs:
// https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html
// So there.
// 3. register
// The 'register' keyword is used to indicate that a particular variable is going to be used very often for a
// short time, so it should be kept in a register as opposed to in a stack frame.
/********* TYPES **********/
// We'll assume a 32-bit word size for the target platform.
// Endianness refers to the stored order of bits or bytes for data. If the type is big-endian, that means the
// big-end (MSB) comes first (bit/byte 0) while the little-end (LSB) is last (last bit/byte). Little-endian is
// the opposite, the little end (LSB) comes first.
//
// For example, in 8-bit binary, the number 122d is represented 0111 1010b. In little-endian binary, this is
// stored as:
// 7 6 5 4 3 2 1 0 BITS
// 0 1 1 1 1 0 1 0 VALUES
// While in big-endian binary, it would be flipped:
// 7 6 5 4 3 2 1 0 BITS
// 0 1 0 1 1 1 1 0 VALUES
//
// It's more common for endianness to refer to bytes. In this case, the endianness refers to the
// byte-order. A byte can be stored in hex with two digits, e.g. FF or 32 or 1A. If we have the uint_16t type and
// we want to store the hex 0x1B0E, in little-endian we would store it as:
// 1 0 BYTES
// 1B 0E VALUES
// Where the 'little end' is stored in the lowest location in memory. In big-endian, we would have:
// 1 0 BYTES
// 0E 1B VALUES
// Where the 'big end' is stored in the lowest location in memory.
//
// For whatever number of bytes is considered for one piece of stored data (2 bytes, 4 bytes, 8 bytes) the
// bytes obey the ordering: e.g. a 32-bit type stored little endian with data 0xAB CD EF 01 is stored in memory
// as 01 EF CD AB.
//
// Often in computing memory addresses are written from left-to-right with the lowest address value on the left.
// In this convention, 0xABCD would be written in little-endian as CD AB (little-end first) and in big-endian as
// AB CD (big-end first). In hardware, the convention tends to be flipped for writing bits (e.g. in a register).
// In communication, the convention is that reading from left-to-right has the left side being the first
// received/first transmitted. So sending the data 12 34 means sending the byte 12 first, and the byte 34 last.
// If we have hex 0x1234, big-endian data transmission would be 12 34, while little-endian would be 34 12.
// For int types, prefer types with explicit sizes ("fixed-width")
// Unsigned ints
uint8_t var1;
uint16_t var2;
uint32_t var3;
// Signed ints
int8_t var5;
int16_t var6;
int32_t var7;
// For floating-point arithmetic we have float and double. However, many embedded systems don't support
// floating-point arithmetic in hardware, so the compiler can be told to use software-based floating-point
// (soft-fp). The software implementation can take up a fair amount of memory, so hardware FP is much better.
float var9; // 1 word (32-bit), called "single precision"
double var10; // 2 words (64-bit), called "double precision"
/* Single- and double-precision floating point formats are standardized in IEEE 754. A float ('binary32' in the
* standard) is defined as 1 sign bit, 8 exponent bits, and 23 significand bits/fraction bits. The
* float fraction, or significand, is the non-zero numerical part, which always appears after the decimal. The
* expnent is the 10^x exponent. Thus all floats are stored as numbers between 0 and 1, with an exponent. For
* example, the number 10.5 is + 0.105 x 10^2. The sign bit is the MSB (bit 31), the exponent is the next 8 bits
* (bits 30 to 23 inclusive), and the fraction is the 23 LSB bits (bits 22 to 0).
*
* In double-precision types ('binary64', or 'double'), the sign is 1 bit, the exponent 11 bits, and the fraction
* 52 bits
*
*/
/* EXAMPLE: Converting between floats and ints */
uint8_t num_uint = 128; // Stored as 1000 0000
int8_t num_signed = (int8_t)num_uint; // Now we have the special value -128 (1000 0000) which is
// the "lowest negative number" whos 2's comp is itself
float num_f = (float)num_uint; // Now stored form changes a fair bit
// Let's investigate by converting the float to bytes using a number of different techniques.
// Version 1: union
union{
float f;
uint8_t bytes[4];
} u;
u.f = num_f;
// Now bytes is (from byte 0 to byte 3): 00 00 00 43
// Version 2: cast to byte array
uint8_t num_fbytes[4] = (uint8_t*) &num_f;
// Bytes is same as with union
// Both of the above depend on the endian-ness of the system. We can remove this dependency with the following.
// Version 3: cast to int and use bit-shifting and bitwise AND
uint32_t float_as_int = *(uint32_t*) &num_f;
uint8_t num_fbytes2[4];
for(int i = 0; i < 4; i++)
num_fbytes2[i] = (float_as_int >> 8*i) & 0xFF;
// This takes the value byte-by-byte starting with the LSB and shifting to the MSB, it doesn't
// depend on endian-ness. Output is still (byte 0 to byte 3): 00 00 00 43
// Now let's try interpreting the result. If our system were big-endian, then 00 00 00 43 would
// indicate a positive sign, exponent of 0 (2^0) and a fractional part of 67. This doesn't make
// much sense, because we expect 128. If it's little-endian, then the 43 (=0100 0011) is first,
// so the sign is 0, and the next 8 bits (which includes the first bit of the next byte) is
// 43 << 1 (left-shift by 1) = 10000110 = 134. This is the exponent value. But to use it we must
// first subtract by 127 (why?) and we get 134-127 = 7, thus the result is 2^7 with a fractional
// part of 0 (-> 1.0), 2^7 = 128.
// This shows that this x86 system is little-endian, which is expected.
// FLOATING-POINT PROCESSING UNIT (FPU)
// An embedded FPU can be included in microcontroller architectures. These include instructions for
// floating-point arithmetic (+-*/), square roots, and more. Check your processors's technical reference manual as
// well as the processor architecture's architecture reference manual for information about your deivice.
// FIXED-POINT ARITHMETIC
// Implementing floating-point arithmetic often involves a floating-point unit, either a hard FPU (like a
// secondary CPU) or a soft FPU (a software library). With a hard FPU, we can get away with floating-point at
// little computational cost. If on the other hand we only have a soft FPU, the overhead can be considerable. The
// fact is, the processor is performing integer arithmetic, and floating-point operations require many
// instructions.
//
// To help the situation, one can use fixed-point arithmetic. With fixed-point, integers are used which can be
// separated into an integer part and a fractional part, by breaking e.g. 32 bits in two pieces.
//
// To successfully use fixed-point, the precision (number of fractional bits) and data width (total number of
// bits) must be specified. When an operation is performed, one must know the precision and width of the
// two operands before calculating. Overflow and underflow errors can also crop up, and these should be checked.
// Example implementation (from here: https://www.embedded.com/fixed-point-math-in-c/):
// A 12.20 fixed-point number has a range of [0.0 - 4095.1048575], or [0 - (2^12 -1).(2^20 -1) ]
typedef union {
int32_t full_number;
struct {
int32_t integer : 12; // 12 bit integer part. The colon (:) indicates a bit-field, here 12 bits
int32_t frac : 20; // 20 bit fractional part
} part;
} Fixed12_20; // Fixed point, 12 bit integer part, 20 bit fractional part.
// This can be printed to std out:
void printfx(Fixed12_20 num)
{
printf("%04d.%06d\n",num.part.integer,num.part.frac); // 4 digits (to 4096) and 6 digits (to 1048576) with
// zero padding
}
// This representation does not lend itself easily to generalization, meaning it's hard to support arbitrary
// widths of integer and fractional parts.
// Instead, we can use bit shifting. This is equivalent to multiplying by powers of two, which some people call
// the multiplying factor. But most prefer the concept of bit-shifting.
// Here's another example implementation, slightly different:
#define FixedT int32_t
#define FIXED_PREC 20
#define FIXED_INTPART(A) A>>FIXED_PREC
#define FIXED_FRACPART(A) A & ((1<<FIXED_PREC)-1) // 0xFFF...F
const uint8_t N_DEC_DIGITS[] = {0,1,1,1,2,2,2,3,3,3,4,4,4,4,5,5,5,6,6,6,7,7,7,7,8,8,8,9,9,9,10,10,10};
FixedT FIXED_CREATE(int16_t integer, uint16_t frac)
{
return ((FixedT)(integer)<<FIXED_PREC)+(FixedT)(frac);
}
void FIXED_PRINT(int32_t* Ap)
{
printf("%0*d.%0*d",N_DEC_DIGITS[32-FIXED_PREC],FIXED_INTPART(*Ap),N_DEC_DIGITS[FIXED_PREC],FIXED_FRACPART(*Ap));
}
int main()
{
FixedT a,b;
a = FIXED_CREATE(12,34);
b = FIXED_CREATE(45,67);
FIXED_PRINT(&a);
printf("\n");
FIXED_PRINT(&b);
printf("\n");
}
// CASTING AND COPYING MEMORY
// It is often the case that we receive data e.g. as a byte array, but we want to cast that same
// byte data into a data type like int or float or even a string. We've seen how to convert data
// into bytes using e.g. unions, and the same can be done again. We can also use bit arithmetic
// with shifting, AND, OR, XOR, etc.
//
// For arrays, the C library provides memcpy (declared in string.h). The memcpy function takes a
// pointer to a destination array (see next section) and a source array and a length argument and
// copies the bytes from the source array into the destination.
// The template is:
// memcpy(void* to, const void* from, size_t num_bytes)
uint8_t byte_array[5] = {0xFF,0x00,0x12,0xA2,0x09};
uint8_t blank_array[5];
memcpy(blank_array, byte_array,5);
// Note that a void* pointer in C is a pointer without a specified type, so it can be cast to any
// other pointer type to allow quick conversion of data. We can send any type of array to memcpy
// as long as the number of bytes (not the number of elements) matches up. Memcpy is actually a
// very simple function, which can be implemented without using the whole C standard library,
// as follows (from the C library):
void* memcpy(void* dst, const void* src, size_t len)
{
size_t i;
/*
* memcpy does not support overlapping buffers, so always do it
* forwards. (Don't change this without adjusting memmove.)
*
* For speedy copying, optimize the common case where both pointers
* and the length are word-aligned, and copy word-at-a-time instead
* of byte-at-a-time. Otherwise, copy by bytes.
*
* The alignment logic below should be portable. We rely on
* the compiler to be reasonably intelligent about optimizing
* the divides and modulos out. Fortunately, it is.
*/
if((uintptr_t)dst % sizeof(long) == 0 &&
(uintptr_t)src % sizeof(long) == 0 &&
len % sizeof(long) == 0) {
long* d = dst;
const long* s = src;
for (i = 0; i<len/sizeof(long);i++){
d[i] = s[i];
}
}
else {
char* d = dst;
const char* s = src;
for(i=0; i<len; i++) {
d[i] = s[i];
}
}
return dst;
}
// This is essentially just copying bytes in a loop. It has the added bonus of also checking for word alignment
// to speed up the copying.
/******** MATH ********/
// It is often the case that we must perform math on integer or float or fixed-point data. What options do we
// have in an embedded system?
// There must be a distinction between integer, fixed-point, and floating-point math. For the basic operations:
// + Addition, built-in
// - Subtraction, built-in
// * Multiplication, built-in
// / Division, built-in; for integers the fractional part is discarded (sometimes called round-towards-zero)
// % Modulus, built-in for integers
// pow Exponentiation, pow() in math.h for double, float, and long double. For integer and fixed-point
// exponentiation, without casting, not built-in; see below.
// sqrt Square root, sqrt() in math.h for double, float, and long double. For integer and fixed-point, not
// built-in; see below.
//
// If you're using floating-point math (float, double) you can use the math.h header file, part of the C standard
// library. Many other functions are available, including trig, powers, exponentials, logs, and rounding
// functions. Optimized routines are provided by Arm for Arm-core devices, found here:
// https://github.com/ARM-software/optimized-routines
//
// For integer and floating-point math, when floats and doubles are to be avoided, there are a few options.
// First, you can provide your own implementations. This is essentially algorithm design, so if you're going for
// highly optimal code (optimal size, or memory usage, or speed, or code maintainability) best not to trust
// yourself to have the best algorithm. Libraries do exist, for example liquid-fpm (floating-point math library,
// merged to liquid-dsp) for floating-point math is quite complete.
//
// Some algorithms and topics to look at when rolling your own math functions:
// Integer square root
// Digit-by-digit algorithm
// Approximation methods
// Exponentiation
// Exponentiation by squaring
// Addition-chain exponentiation
// Logarithms
// CORDIC
// Taylor approximation
// Table lookup and interpolation
// Turner's algorithm (IEEE 2011)
// Trig and hyperbolic trig
// CORDIC
// An approach you will often find is to define convenient exponential and log functions (such as e^x and log2)
// and to use exponent and log rules to calculate with other bases. Iterative numerical methods (e.g.
// Newton-Raphson) are common, albeit with variable-time.
/********* ACCESSING MEMORY *********/
// Registers, SRAM, Flash, we often need to access memory. How can we do it? It's straightforward in most cases.
// We have addresses in the form of pointers, whose addresses are set manually (as opposed to specifying the
// address of a variable).
uint32_t var1 = 10; // Typical way of setting pointer addresses
uint32_t* var_ptr = &var1; //
uint32_t* reg_ptr = (uint32_t* )0xE000E000; // Setting a pointer to a specific address in memory (e.g. a register)
// Note that we must cast the address to a pointer type
// Many times, we need to access multiple words, so arrays are used. A quick refresher about the finer points of
// C arrays is in order.
uint32_t x[10]; // Declare an array of 10 words
uint32_t* y[10]; // Declare an array of pointers to words
x[0]; // Access first value
&x[0]; // Get memory address of first value
&x; // Same as &x[0]
uint32_t* z = &x; // Declare a pointer to the first element of x
// Now we can access the pointer z as an array
z[0]; // First element of x
// But note that this is a convenience, z is still a pointer. The pointer uses indrect addressing, while the
// array uses direct addressing, meaning z[0] accesses the address of x[0] and returns its istored value, while
// x[0] returns the value at that location directly.
// Arrays cannot be assigned addresses like pointers can. To get a location in memory, we need to assign a
// pointer to that address, and an array can only be used through a pointer. But instead, we can use pointer
// arithmetic and access brackets, avoiding arrays for the most part.
uint32_t* var1_ptr = &var1;
var1_ptr[0]; // Equivalent to *( var1_ptr + 0)
var1_ptr[12]; // Equivalent to *( var1_ptr + 12)
// Note there's no bound checking here, so we can access whatever memory starting from var1_ptr. Also, the square
// brackets indicate dereferencing.
// When memory is divided into logical sections, we can use structs of arrays to reserve the memory. This can be
// more convenient than using pointer arithmetic directly. Recall that in C structs must be prefaced with
// "struct" before its type name in all occurrences, so it's often typedef'd.
typedef struct{
uint32_t section1[4];
uint32_t reserved[10];
uint32_t section2[4];
} MEMORY_type;
// This has two parts: the typedef, of the form "typedef <expression> <typename>" and the struct definition,
// "struct { <data > }". Now to use this at a particular memory location, we need a pointer that accepts an
// address, and we can use the MEMORY_type as the pointer type.
MEMORY_type* memory_accessor = (MEMORY_type*)0xE000E000; // Same as before, set an address for the pointer
// Alternatively, we can use a uint32_t pointer, call it memory_ptr, and then remember how to access the data,
// with the different offsets (possibly using #defines) but this is more work.
// Dynamic Memory
//
// In a simple world, memory would be static, with local variables stored on the stack and global variables
// stored in .data. But we also have dynamically allocated data, stored on the heap, and sometimes this can be
// tricky.
//
// Dynamic memory itself is simple enough, and I assume we're all on the same page as to what it means. In C, we
// use malloc() to allocate dynamic memory, and free() to free that memory. Under the hood, malloc() uses a
// function called sbrk() to resize the heap data segment. (brk = break value, sbrk = space? brk, probably).
//
// Some references:
// https://web.archive.org/web/20190214041636/http://fun-tech.se/stm32/linker/index.php
// http://e2e.ti.com/support/archive/stellaris_arm/f/471/t/44452
// https://stackoverflow.com/questions/10467244/using-newlibs-malloc-in-an-arm-cortex-m3
//
// Most of the trouble with dynamic memory comes from issues with the linker script. We won't cover that here.
// Accessing Registers
//
// In assembly, we can access registers by writing them in commands, like mov. In C, we can only directly access
// "memory-mapped", meaning they have a memory address associated with them. If that's the case, we can access
// the register by setting a pointer to it, specifying the address directly.
//
// Other registers must be accessed indirectly. These are accessed by "helper registers" called indirect
// registers; these include set registers and clear registers. If a register is not writable directly,
// you can use a set register to set bits within that register, and a clear register to clear bits within that
// register. The indirect registers are mapped bit-by-bit, so you set bits in the set register to set bits in the
// target register, and you set bits in the clear register to clear bits in the target register.
// The manufacturer should provide a header file, e.g. for ARM devices, in a CMSIS Device driver folder. These
// tend to be large files with memory mappings for all peripherals.
// When defining registers on a bit-by-bit basis, you don't have to use bitwise operators all the time. You can
// use bit fields in structs:
typedef union{
uint32_t full_register;
struct{
uint32_t RWn : 1; // Bit 0
uint32_t BufEmpty : 1; // Bit 1
uint32_t Data : 8; // Bits 10 : 2
uint32_t Addr : 8; // Bits 18 : 11
uint32_t Reserved : 14; // Bits 32 : 19
} fields;
} StatusRegister;
// The bit fields specify the number of bits used for the given label. The bits are occupied in order. All the
// struct data members have the same type, and they can be accessed:
StatusRegister sr;
sr.fields.RWn = 1;
sr.fields.BufEmpty = 0;
sr.fields.Data = 0b11001100;
sr.fields.Addr = 0b11111110;
// Result is: sr.full_register => 00000000 00000011 11111011 00110001 (260913 in decimal)
/********* GLOBAL (EXTERNAL) VARIABLES *********/
// External (global) variables are defined at file scope
uint8_t glob;
// Now we can use it. If the definition is in the same file, we can use it directly:
void glob_init()
{
glob = 0;
}
// If it's in another file, we need to redeclare it with 'extern'
void glob_inc()
{
extern uint8_t glob;
glob++;
}
// A variable that is global might need to be accessed in another stack (notably during interrupts). If this is
// the case, it is very important that you use the -volatile- keyword. Volatile tells the compiler not to
// optimize out the variable, because it can change at any time, from anywhere.
volatile uint8_t interrupt_ctr;
void handle_init_interrupt() // An ISR
{
interrupt_ctr = 0;
}
void ctr_inc()
{
interrupt_ctr++;
}
// In general, volatile should be used when the compiler might think a variable isn't doing anything. This
// includes for loops with no body, temporary variables used for debugging values, and interrupt routines, as
// well as for peripheral registers, whose values can change without the main program being aware.
// * Shared Memory, Mutex, Locks and Semaphores *
// In multithreading and multi-stack applications, we have a shared resource problem. Memory is shared between
// processes (such as the main loop and various interrupts) and during various operations (termed 'critical
// sections') only one process should have control of the data at a time. We don't want to have data changing
// while we're transmitting the contents of an array.
//
// To solve this problem requires -mutual exclusion-, meaning only one process is in its critical section at a
// time. We also require that the system won't -deadlock- e.g. when two processes try to take control at the same
// time. Mutual exclusion is abbreviated -mutex-. This can be achieved a few ways.
//
// Option 1. Disable interrupts
// If we have a single processor core, and interrupts which can access data, then the simplest solution is for
// critical sections to disable interrupts. An operation that cannot be interrupted is called -atomic-.
// Here's an example, for global data tx_data which has a length LEN_DATA, and disable_interrupts() and
// enable_interrupts() assumed to do what they say.
void send_data()
{
disable_interrupts();
for(int i = 0; i < LEN_DATA; i++)
{
send(tx_data[i]);
}
enable_interrupts();
}
// This is good. It has the following issues. If the critical section is long, we can have issues with watchdogs,
// and clock synchronization, which depend on regular interrupts. If the program halts during the critical
// section, then the system is stuck, and must be forcefully reset.
// Option 2. Busy waiting
// In busy-waiting, a process repeatedly checks to see whether a variable is locked. There are instructions which
// work with this system, although in general this sort of 'polling' is frowned upon. The instructions include:
// - test-and-set
// - compare-and-swap
// - fetch-and-add
// - load-link/store-conditional
// These operations are all atomic, so they cannot be interrupted.
// Option 3. Software solutions
// We can avoid changing data using software alone. A simple example would be to store the current value of some
// variable, and then to check that the two values are equal.
volatile uint8_t timer_ctr;
void interrupt_handler()
{
timer_ctr++;
}
void send_timer_count()
{
uint8_t temp_timer_ctr = timer_ctr;
while(temp_timer_ctr != timer_ctr)
temp_timer_ctr = timer_ctr;
send(timer_ctr);
}
// The above uses a loop to find when the two values are the same. This has obvious issues, but it can be useful.
// Another option is to use a lock variable, often called a mutex (mutual exclusion).
volatile uint8_t timer_ctr;
volatile bool timer_ctr_lock;
void interrupt_handler()
{
if(!timer_ctr_lock)
timer_ctr++;
}
void send_timer_count()
{
if(!timer_ctr_lock)
timer_ctr_lock = true;
send(timer_ctr);
timer_ctr_lock = false;
}
// This uses a volatile bool to check when the variable timer_ctr is available. This has its drawbacks, namely
// that counter increments are simply skipped, instead of waiting until it's available. Counts are lost. (Note
// that the bool type may not be defined.)
// This brings up the problem of ensuring that every process will, eventually, run. Another technique is to have
// two variables, and a lock, and to have the interrupt set one variable or the other depending on the lock.
volatile uint8_t timer_ctr
volatile uint8_t timer_ctr_2;
volatile bool timer_ctr_lock;
void interrupt_handler()
{
if(!timer_ctr_lock)
timer_ctr++; // Increment if unlocked
else
{
timer_ctr_2 = timer_ctr; // Otherwise increment the other counter
timer_ctr_2++;
}
}
void send_timer_count()
{
if(!timer_ctr_lock)
timer_ctr_lock = true; // Confirm the lock
send(timer_ctr); // Critical section
timer_ctr_lock = false;
if(timer_ctr != timer_ctr_2) // Update in case the other variable changed
timer_ctr = timer_ctr_2;
}
// STATIC VARIABLES - Better Global and Local Variables
// There are two types of variables that are closely related to globals. Both are declared "static", but in
// different contexts.
//
// The first is a variable at file scope declared static.
static uint8_t my_var;
// This variable is global within the file, but ONLY within the file it's declared in. It cannot be used outside
// of THIS file.
//
// The second is a local variable in a function declared static.
void func()
{
static uint8_t ctr = 0;
ctr++;
}
// This variable will only be initialized the FIRST time the function runs. After that, the initialization (ctr =
// 0) is skipped, as the static local variable keeps its value on each run. In this example, the first time
// func() is called, ctr is initialized to 0, then gets incremented to 1. The second time we call func(), the
// initialization is skipped, ctr is still 1, and we increment ctr again to 2. The local variable keeps its value
// between function calls, unlike normal local variables.
//
// Static global variables give us a way to have variables shared between functions without having those
// variables be truly global. Static local variables allow us to have a function maintain its own set of
// variables as if it were a data structure. These are powerful tools that allow us to control variable scope and
// promote good encapsulation.
//
// NOTE: In both cases, static variables have limited scope. They cannot be referenced outside of their
// respective scopes. But something that's rather nice is, -pointers- to static variables are safe. This means
// you can declare a global static variable, and provide a pointer to that variable, and the pointer can be used
// anywhere in the program.
/*********** SHARING DATA WITH INTERRUPTS ***********/
// Most programmers don't like the idea of using globals. It's been instilled in us that globals are evil, they
// lead to messy and fragile code, they pollute the namespace, they're evil.
//
// Put these fears aside. Keep an eye on your globals, but for small embedded programs, it's not a concern. Even
// for larger programs, there are tons of reasons to use global variables. Just ask, is avoiding a global worth
// the added overhead in code and maintenance?
//
// That being said, consider the case where an interrupt will generate a lot of data, and our main program needs
// to detect the availability of this new data. What's the best way to do this? The first thing that comes to
// mind is flags.
//
// The only way for the main loop to "detect" the calling of another function like an ISR while looping is
// (a) if a volatile variable has changed value and (b) the main loop checks if that variable has changed. This
// is the purpose of a flag variable, a bool or int value that the main loop checks repeatedly.
//
// Here are some methods of achieving this.
// 1. Global variables, main loop checks flag and resets it
// This option is suitable for smaller programs with only a few interrupts, or for really common and widely
// shared data
volatile uint8_t data_avail = 0; // Flag for available data
volatile uint8_t b_data[255]; // Byte array that will hold the data
int main()
{
while(1)
{
if(data_avail)
{
/* Do something with b_data */
// Reset
data_avail = 0;
}
}
return 0;
}
void isr() // Called automatically when new data comes in
{
if(!data_avail)
{
b_data = sys_get_data(b_data,255); // sys_get_data would be a function that loads b_data with the data
// that caused the interrupt
// Set the flag
data_avail = 1;
}
else // data_avail == 1
{
// The main loop hasn't read the old data yet, handle this case somehow
}
}
// 2. "Driver" functions and data in a separate file, using static globals, and a function interface
// This is suitable for larger programs, where we trade a little extra overhead for more robust and maintainable
// software
/* it_driver.h */
// Prototypes for functions, defining data structures, typedefs, #defines, etc
// e.g.
uint8_t data_avail(); // Return 1 if new data is available, 0 otherwise
void get_byte_data(uint8_t* bytes, uint32_t len); // Load 'len' bytes of data into 'bytes', reset new_data
// etc
/* it_driver.c */
// Declaring static globals, providing implementations for functions
static volatile uint8_t new_data = 0; // Flag for whether there is new data
static volatile uint8_t b_data[255]; // Byte array with max size of 255 bytes
void isr() // Called automatically/asynchronously when new data arrives
{
if(!new_data)
{
b_data = sys_get_data(b_data,255);
new_data = 1;
}
else
{
// Main loop has read old data yet, ...
}
}
// Other functions defined too
/* main.c */
#include "it_driver.h"
int main()
{
uint8_t b_data[255];
while(1)
{
if(data_avail()) // Some better naming is of course in order
{
get_byte_data(b_data,255);
}
}
}
// 3. Same as 2, but using handler variables
// We often have multiple peripherals which have the same methods available to them, e.g. multiple I2C lines. We
// don't want to have to give each I2C line its own driver when the drivers will all be identical. To get around
// this, we define the interface as in method 2, but each function will take a "handler" parameter referring to a
// particular instance of the peripheral, and it will operate on that handler instead of global static data.
// The handler variables themselves will be declared in the driver source file, so any time we want to add or
// remove an instance we'll need to change them in that source file.
//
// The handler variable is a data structure (a struct) which stores data identifying a particular instance of
// the peripheral. This is often a true global variable, or a pointer to a global variable, defined in the driver
// source (.c) file.
/* it_driver.h */
typedef struct{
uint8_t b_data[255];
// etc
} it_handler_struct; // Create a struct, call it it_handler_struct
/* it_driver.c */
it_handler_struct it_handler; // Global handler variable, used by main program
// If we want to instead provide a pointer to the data handler, we may want to have the user pass that pointer
// around, but -not- dereference it. They can have it_handler_struct* it_handler_ptr, but they should not be
// using it like it_handler_ptr->b_data, or *it_handler_ptr. This is cleaner. To promote this sort of behavior
// and interaction, we can typedef the pointer to the struct to its own handler type, making the struct pointer
// not look like a pointer, as a reminder of the design intent.
/* it_driver.h */
typedef struct{
uint8_t b_data[255];
// etc
} it_handler_struct;
typedef (it_handler_struct*) it_handler_type;
// Now the variable it_handler can be a type it_handler_type, which is actually a pointer, but the fact that it's
// a "type" expresses our design intent.
// All the functions previously defined now need to be changed to accept a handler, and we're done. The main loop
// can specify which peripheral it's looking at by simply using the appropriate handler variable.
/*********** DATA STRUCTURES ***********/
// Common data structures include:
// - Abstract data types
// - Containers
// - Lists
// - Tuples
// - Multimaps
// - Sets
// - Multisets
// - Stacks
// - Queues
// - Graphs
// - Linear data structures
// - Arrays
// - Linked lists
// - Double-linked lists
// - Circular buffers (aka circular queue, ring buffer, cyclic buffer, FIFO)
// - Trees
// - Binary trees
// - B-trees
// - Heaps
// Of these, particularly important in embedded systems are circular buffers, queues, stacks, and of course
// arrays.
// ARRAYS
// Arrays are used to store contiguous blocks of data in memory. They are best used for fixed-length data, such
// as read-only data, data packets, and registers.
// To declare an array:
uint8_t rx_msg[8]; // Declare an 8 byte array by size
uint8_t tx_msg[] = { 0x10, 0xff, 0x00, 0x10, 0xff }; // Declare an array by initializing elements
uint8_t tx_rx_array[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} ; // Both size and initializing elems
// The length must be const data. You cannot use a non-const variable to specify array length. Arrays cannot
// directly be resized. Copying of data from one array to the other is done with e.g. memcpy (or a simple
// for-loop) as mentioned above.
// CIRCULAR BUFFERS
// Circular buffers are first-in-first-out (FIFO) data structures. The buffer is fixed-length, and you always
// read data from the oldest going to the newest. When the end of the buffer is reached, we start overwriting the
// beginning, hence the name "circular buffer." When this happens, the old data is lost, unless it has already
// been read.
//
// Instead of specifying a 'start' and 'end' for the buffer, we use a 'head' and a 'tail', where the head is
// the most recent element and the tail is the next element to be read. If the head overtakes the tail, we have
// overwriting, which is typically not allowed, and may call for reallocation. The data itself is stored in
// memory as an array, which does have a start and end, and this must be handled in the implementation, but the
// user only knows the head, tail, and size.
// Here's an example implementation. The header file:
#ifndef CIRCBUF_H
#define CIRCBUF_H
#include <stdint.h>
// Header for circular buffer data structure
typedef struct{
uint32_t head; // Next free space
uint32_t tail; // Current element, to be popped
uint32_t capacity; // Max buffer size
uint8_t full; // Whether or not the buffer is full (0 or 1)
uint8_t* data; // Actual buffer
} circbuf;
// Make a typedef for a handler to a circbuf pointer, so users don't think they're supposed to dereference it
typedef circbuf* circbuf_handler;
typedef uint8_t circbuf_status;
#define CIRCBUF_STATUS_OK 0
#define CIRCBUF_STATUS_FULL 1
#define CIRCBUF_STATUS_EMPTY 2
#define CIRCBUF_STATUS_WARN 3
// Warn = last byte written, now buffer is full
// Implementation functions
circbuf_handler init_circbuf(uint8_t* buf, uint32_t size); // Initialize circular buffer with buffer and size
void circbuf_reset(); // Clear buffer, set head = tail
circbuf_status circbuf_push(uint8_t* wbyte); // Push len bytes to buffer, return status
circbuf_status circbuf_pop(uint8_t* rbyte); // Pop len bytes from buffer to rbyte, return status
circbuf_status circbuf_get_status(); // Return a status value
uint32_t circbuf_get_capacity(); // Return capacity (full size) of buffer
uint32_t circbuf_get_size(); // Return number of elements in buffer
uint32_t circbuf_get_free_space(); // Return number of free spaces
void circbuf_print(); // Print circular buffer contents
#endif /* CIRCBUF_H */
// The source file: Note how we've hidden some of the implementation details by typedef-ing a pointer to the
// buffer object, and we also instantiate a buffer object in the source file. This could have been static, so
// that the user can't access it, but this is fine too.
#include "circbuf.h"
#include <stdio.h>
circbuf circular_buffer; // Initialize circular buffer
circbuf_handler init_circbuf(uint8_t* buf, uint32_t size)
{
circular_buffer.data = buf;
circular_buffer.capacity = size;
return &circular_buffer;
}
void circbuf_reset()
{
uint32_t i;
for(i=0;i<circular_buffer.capacity;i++)
circular_buffer.data[i] = 0;
circular_buffer.head = 0;
circular_buffer.tail = 0;
}
circbuf_status circbuf_push(uint8_t* wbyte)
{
// Need to determine whether or not the next space is available,
// updating if we go over the capacity
uint32_t next_head = (circular_buffer.head+1) % circular_buffer.capacity;
if(circular_buffer.full)
{
return CIRCBUF_STATUS_FULL;
}
else if(next_head == circular_buffer.tail)
{ // If we're going to overlap the tail next step (overflow)
circular_buffer.full = 1;
circular_buffer.data[circular_buffer.head] = *wbyte;
circular_buffer.head = next_head;
return CIRCBUF_STATUS_WARN;
}
else
{ // We aren't overlapping the tail (no overflow condition)
circular_buffer.full = 0;
circular_buffer.data[circular_buffer.head] = *wbyte;
circular_buffer.head = next_head;
return CIRCBUF_STATUS_OK;
}
}
circbuf_status circbuf_pop(uint8_t* rbyte)
{
uint32_t next_tail = (circular_buffer.tail+1) % circular_buffer.capacity;
if(circular_buffer.tail == circular_buffer.head && !circular_buffer.full)
{ // Tail is caught up to head (empty condition)
return CIRCBUF_STATUS_EMPTY;
}
else
{
*rbyte = circular_buffer.data[circular_buffer.tail];
circular_buffer.tail = next_tail;
if(circular_buffer.full)
{
circular_buffer.full = 0;
}
return CIRCBUF_STATUS_OK;
}
}
circbuf_status circbuf_get_status()
{
if(circular_buffer.tail == circular_buffer.head)
{
return CIRCBUF_STATUS_FULL;
}
else if( (circular_buffer.tail+1)%circular_buffer.capacity == circular_buffer.head )
{
return CIRCBUF_STATUS_EMPTY;
}
else
{
return CIRCBUF_STATUS_OK;
}
}
uint32_t circbuf_get_capacity()
{
return circular_buffer.capacity;
}
uint32_t circbuf_get_size()
{
return 0; // TODO
}
uint32_t circbuf_get_free_space()
{
return circular_buffer.capacity - circbuf_get_size();
}
void circbuf_print()
{
uint32_t i;
printf("CIRCULAR BUFFER CONTENTS\n");
for(i=0; i<circular_buffer.capacity; i++)
{
printf("0x%.2x\t",circular_buffer.data[i]);
}
printf("\n");
for(i=0; i<circular_buffer.capacity; i++)
{
if(i == circular_buffer.head && i != circular_buffer.tail)
printf("H\t");
else if(i == circular_buffer.tail && i != circular_buffer.head)
printf("T\t");
else if(i == circular_buffer.tail && i == circular_buffer.head)
printf("T/H\t");
else
printf("\t");
}
printf("\n");
}
// This sort of implementation should be enough to help implementing other data structures, where the general
// principles remain mostly the same.
/*********** COMMUNICATION ************/
// The topic of "communication" is big, but there are many things shared across most common systems that
// can be discussed.
// Device Communication:
// - SPI
// - UART
// - I2C
// - USB
// - RS-232
// - Microwire
// - JTAG (not a communication protocol, but very common and somewhat related)
// Now we can apply the topics covered so far to the task of receiving and transmitting data over
// one of these communication links. Some things to look for:
// - Data, status, control, and other registers
// - Interrupts
// - Master/slave roles and responsibilities
// - Speed
// - Addressing scheme
// In embedded systems, a protocol like I2C can be implemented a few different ways. The communication is
// always the same, but how we are required to interact with it varies. For example, if no I2C peripheral exists,
// we can program the protocol directly into a set of IO pins, a process called 'bit banging'. (Specifically,
// bit banging refers to any method of transferring data using software instead of dedicated hardware.)
//
// One level up from this, our microprocessor might have dedicated hardware for handling communications. Our role
// as the programmer is then to use the associated registers and interrupts to control when and how communication
// proceeds. To transmit data, we load it into a data register; to receive data, we read from a data register.
//
// At a higher level of abstraction, we have high-level libraries such as HAL for STM32 devices which can provide
// functions for handling all of the common tasks required.
// For any protocol (I2C, SPI, etc) that has associated peripherals, look for the following:
// Control registers, for configuration of the peripheral
// Status register, used for flags such as busy, overrun, buffer empty, buffer not empty, etc
// Data register, data received or transmitted
// The protocols should support both software polling as well as interrupt modes. Software polling means you
// check the status register regularly to see if any new flags are set. Interrupt mode means an interrupt will be
// automatically called when a flag is set. Polling mode is also called blocking mode. Interrupt mode can use the
// interrupts to trigger DMA transfers instead of triggering an interrupt handler.
// TRANSMITTING
// For transmitting, check the status register for a 'transmit buffer empty' flag. If this flag is a 0, there is
// still data being transmitted, so don't transmit. If it's a 1, begin a transmit sequence. This can be as simple
// as loading a byte into the transmit buffer. For managing transmitted data, you can either manually write
// words, or you can use a circular buffer to transmit its contents over a number of steps.
// RECEIVING IN POLLING MODE (BLOCKING MODE)
// It is the user's responsibility to check the status register for changes. This can be simple, reading one
// word at a time:
uint16_t rx_word = 0;
while(1)
{
if(SR->RXNE) // Status Register RX buffer Not Empty
{
rx_word = *DR; // Set rx_word to Data Register
// Do something with rx_word
}
}
// This has the obvious drawback that you can only read 16 bits at a time, and you need to use those bits right
// away. An alternative is to load the data register into a circular buffer, and handle the data as needed,
// processing in bulk or as fast as possible.
// RECEIVING IN INTERRUPT MODE (NON-BLOCKING MODE)
// Create an interrupt handler (the symbol name is probably defined in the startup file). In the handler, first
// (as ALWAYS with interrupts) clear the interrupt. Then store the data any way that's convenient, using the
// methods described above (sharing data with interrupts, using global variables, etc). For example, load the
// data register into a circular buffer, and when the receiving operation is complete (e.g. after a string has
// terminated) use a flag variable to signal to the main() function that the data is ready. You could also handle
// the communication without using the main() loop at all, instead doing everything in an event-driven way.
/******* PARALLELISM AND CONCURRENCY *******/
// You have an embedded system. There are 17 sensors, 25 status LEDs, a half dozen peripherals communicating
// over three different communication protocols, a TFT display to update, and a USB connection to a host
// computer. Where do you start? Maybe if it was 2 sensors, 5 LEDs, and a VCP connection you could do something
// like this:
int main()
{
init(); // Initialize system
while(1)
{
data1 = check_sensor_1();
if(data1 > LIMIT)
{
set_pin(OVERLIMIT_PIN, 1);
}
serial_transmit(data1);
data2 = check_sensor_2();
if(data2 < UNDERFLOW)
{
set_pin(UNDERFLOW_PIN, 1);
}
serial_transmit(data2);
}
}
// Here, in a function remniscent of Arduino programs, we have a main loop which runs through a 'checklist' of
// items, polling for updates and sending data out over serial. Obviously, this is not going to scale well.
// Instead, we want to handle multiple tasks -simultaneously-.
// Operations can run nearly simultaneously in the following ways:
// Parallel - Operations performed in true parallel, instructions executed at the same time
// Concurrent - Operations performed in a round-robin fashion, allocating time to processes, processes take
// turns executing instructions
// Managing tasks with finite resources, that's the problem we face. Sometimes we have four or eight CPU
// cores that we can use to achieve true parallelism. Other times, we need to prioritize certain tasks, allowing
// some tasks to run uninterrupted, while others are run when nothing else is going on.
// Often, managing tasks is the objective of an -operating system-. If you need to guarantee that certain tasks
// will complete in a bounded amount of time, you would probably look for a real-time operating system (RTOS). If
// that's too much overhead, you can implement simple concurrency without much effort at all, like the above main
// loop example.
// Some specific examples of parallel and concurrent processing:
// Parallel
// Multithreading, multiprocessing
// Hardware (logic gates, FPGA)
// GPU shaders
// Concurrent
// Schedule-driven processing
// Event-driven processing
// You'll often find the following terms used in these contexts (though note that the meanings of these words
// change with different contexts):
// Event
// Task
// Job
// Process
// Thread
// Worker
// Coroutine
// Interrupt
// Try looking these terms up and seeing what differences there are between them in different contexts. For
// example, Linux uses processes, jobs, and threads; Windows has a task manager which handles programs and
// processes.
// INTERRUPTS
// The first example of a concurrent system is already familiar: interrupts. Interrupts use priority, they can be
// nested, they sometimes use polling and otherwise are vectored (using ISRs). Some practical bits of advice
// about using interrupts that relate to concurrent systems:
// ISRs should be as short as possible to avoid conflicts with other interrupts. Come in clean, manage the
// interrupt, and return.
// Avoid using iterative loops, busy-waits, and other time-consuming or indeterminate-time flow control
// methods
// The idea with interrupt service routines is to try, when possible, to minimize the possibility of ISR nesting,
// and lost execution time in the main thread. Interrupts can have their priority superseded, but they cannot
// execute at the same time (e.g. with time-sharing) meaning they're not really a concurrent programming
// technique.
//
// REENTRANCY
// Is it safe for a function or program section to be called by both the main excecution and an interrupt? Is it
// safe for it to call the same function in diferent threads at the same time? If the answer is yes, that program
// segment is called -reentrant-.
//
// Functions that are not reentrant can typically be broken up into critical sections (where only one thread can
// access that section at a time) and reentrant sections (which are safe). If a function is running in a critical
// section in one thread, and another thread tries to call the function, an error occurs.
//
// We already covered how to deal with reentrancy issues when talking about sharing memory, mutexs, and
// semaphores. Thread scheduling can also work to solve this issue. For concreteness, we can categorize critical
// sections as follows:
// 1. Read-modify-write sequences (reading data, modifying it, and writing it back)
// 2. Write-read sequence (writing data, then later reading it)
// 3. Nonatomic multistep write (writing data over multiple instructions)
// Clearly, atomicity is key to avoiding critical sections and ensuring reentrancy.
// SEMAPHORES
// The different types of semaphores:
// Spin-lock semaphore
// Blocking semaphore
@roykrikke
Copy link

roykrikke commented Dec 28, 2021

I had a lot of fun reading your story. Sort of a summary/repetition of what I've read in school for years ago. I'm working on a project and my C is a bit rusty. Was looking for something like you wrote. Really well done! If I may suggest what I'm still missing in this good story: it's Bit Manipulation. I think this could be a good addition to your story. Below are some examples of what I mean. I think in embedded C programming this can be very useful. I'm curious if you want to add something about that to your story.

E.g.:

uint32_t byte1 = (value >> 24);           // 0x01020304 >> 24 is 0x01 so no masking is necessary
uint32_t byte2 = (value >> 16) & 0xff;    // 0x01020304 >> 16 is 0x0102 so we must mask to get 0x02
uint32_t byte3 = (value >> 8)  & 0xff;    // 0x01020304 >> 8 is 0x010203 so we must mask to get 0x03
uint32_t byte4 = value & 0xff;            // here we only mask, no shifting is necessary
#define BITMASK_SET(x,y) ((x) |= (y))
#define BITMASK_CLEAR(x,y) ((x) &= (~(y)))
#define BITMASK_FLIP(x,y) ((x) ^= (y))
#define BITMASK_CHECK(x,y) ((x) & (y))
#define SET_BIT(x, pos) (x |= (1U << pos)) //Set single bit at pos to '1' by generating a mask in the proper bit location and ORing (|) x with the mask.
#define CLEAR_BIT(x, pos) (x &= (~(1U<< pos))) //Set single bit at pos to '0' by generating a mask in the proper bit location and Anding x with the mask.
#define TOGGLE_BIT(x, pos) x ^= (1U<< pos) //Set single bit at pos to '1' by generating a mask in the proper bit location and ex-ORing x with the mask. 
#define CHECK_BIT(x, pos) (x & (1UL << pos) ) //Set single bit at pos to '1' by generating a mask in the proper bit location and Anding x with the mask. It evaluates 1 if a bit is set otherwise 0.
#define SWAP_NIBBLES(x) ((x & 0x0F)<<4 | (x & 0xF0)>>4) //Macro to swap nibbles
#define GET_BITS(x, pos) ((x & ( 1 << pos)) >> pos) //Macro to Get bit from the given position
#define SWAP_BYTES(u32Value) ((u32Value & 0x000000FF) << 24)\
|((u32Value & 0x0000FF00) << 8) \
|((u32Value & 0x00FF0000) >> 8) \
|((u32Value & 0xFF000000) >> 24) //Macro to swap byte of 32-bit +ve integer variable
#define SWAP_ODD_EVEN_BIT(x) ((x & 0xAAAAAAAA)>>1 | (x & 0x55555555)<<1); //macro to swap odd-even bits
#define SWAP(x, y) (x ^= y ^= x) //macro to swap two numbers
#define LOWBYTE(v)   ((unsigned char) (x)) //macro to get low bytes
#define HIGHBYTE(v)  ((unsigned char) (((unsigned int) (x)) >> 8)) //macro to get high bytes

PS Is it possible to get in touch with you directly?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment