I like the idea of splitting this into layers, though the higher level ones get kinda fuzzy. My thinking on these things is still evolving, you’ll find a lot of my existing code contradicts what I’m about to write.
Layer 1 (register types, address space)
The Peripheral/Register/Field hierarchy generated by svd2ada is generally fine, but not always. GPIO registers are very repetitive and are better represented as arrays than records with one field per pin. You can index an array, with the record fields you’re stuck writing long case statements that will almost certainly generate suboptimal code. SVD can express arrays if it’s a linear arrangement, but some chips (STM32 AFRL/AFRH) have interleaved registers that make it a headache. Many vendors don’t add these dimensioned groups to the SVD, so you need to patch the SVD.
Memory access width is important and Volatile_Full_Access isn’t good enough. Depending on bus topology, some peripherals require that all memory accesses are 32-bits wide, shorter accesses generate odd behavior (eg. writing 0x42 becomes 0x42424242). The compiler can’t know which memory ranges require word-size accesses, so you have to tag every register type with Volatile_Full_Access, Object_Size => 32. This limits the use of arrays where you have repeating 4-bit wide fields, packed into a couple dozen 32-bit wide words. Logically, it’s an array with Component_Size => 4, but you can’t actually do that because then you get short writes, so you have to have nested or multidimensional arrays split on 32-bit boundaries and do arithmetic to figure out the offsets.
svd2ada doesn’t add SPARK accessibility aspects (Effective_Writes, Async_Readers, etc) to the register types. It just marks everything as Volatile, which is vague. CMSIS 5.0 added some field-level accessibility and information to SVD (eg. some fields are read-only, others are write-to-clear), which we have no way to express in Ada.
My preference now is to write the types by hand and forget SVD even exists. It takes a bit more work to transcribe the datasheet, but you can reason about the best way to layout the records and arrays as you’re writing it. Sometimes the best thing is to just define Unsigned_32 at a specific memory address and move on- you get better codegen this way.
Layer 2
Most vendor HALs throw pointers all over the place or use C++ classes here. I don’t really see the benefit. For example, no two devices will handle configuring an I2C peripheral the same way, so your abstract I2C interface will just ignore configuration entirely, except maybe some notion of bus speed and address size. Users of the HAL need to port their code to your specific implementation either way, so the abstraction is leaky by design. The compiler never optimizes away the pointers completely and in Ada you pay for it at elaboration time.
I’m starting to introduce a split between this “Low Level” layer and the higher level HAL-compatible implementation in the RP.GPIO driver on the 3.x branch of rp2040_hal. I’m not sure yet if these two levels of abstraction belong in the same package, subpackages, or separate crates entirely, but the split is clear. The low level functions should contain very little logic, never blocking. On Cortex-M0 this usually means it compiles down to 4-8 instructions that can be inlined.
Layer 3/4
I think timer interrupts are the thing to focus on. If you get that right, all of the other interrupts are easier to reason about.
Timer interrupt handling belongs in the runtime. You cannot use delay statements and tasks cannot be scheduled properly if timer interrupts are handled outside the runtime. You can paper over it by doing something like package Timer renames Ada.Real_Time; then having another implementation for light runtimes that provides a similar spec. But really, just put it in the runtime.
For higher level drivers, I like to define a package with procedure Interrupt; that the library user is responsible for calling at the right time. The user should decide whether to use the Attach_Handler aspect/pragma or export a symbol that gets linked into the vector table. Exporting a symbol is simpler and lower latency, but less portable and ignores all of Ada’s tasking and rendezvous logic. There are good use cases for both, so this is not something a driver author should decide.
I really like generics for drivers. The resulting code is easier to read and reason about without repeating This. in front of everything. I like to think of the generic formals as defining virtual machine instructions with the narrowest interface that accomplishes what I need. For example, this LCD driver has two procedures for setting pin states and a slightly higher level procedure to clock out 8 bits to a SPI peripheral. Everything platform specific gets encoded in the implementations of those procedures. The driver is simple to port to a new device, even if there’s no existing HAL… Just fill in the blanks with writes to the SPI and GPIO peripheral registers and you’re done.