nim for embedded software development

abathargh

abathargh

Posted on March 28, 2024

nim for embedded software development

While we in embedded-land are mostly working with either C or C++ on our professional ventures, often with proprietary tooling and whatnot, I always find some much appreciated respite in tinkering with other alternatives when wasting time on hobbies and side-projects.

In the last year or so, I delved into exploring other venues to solve my embedded headaches and landed onto the nim programming language.

nim compiles to c (and c++, objective-c, js)

This means that any target with an existing c compiler is automatically supported by nim.

Not only that, but calling c code (and c++, objective-c, etc.) is really simple to do and, from what I see, 0-overhead.

You can also easily check the generated C sources, which are human readable, even if with a lot of noise. This can be achieved by specifying a --nimcache directory when compiling.

writing, building, shipping

Like all modern stuff, we have a sane module system (bye bye text-based #includes), an ergonomic compiler and a nice, basic package manager.

The compiler lets you use a script-like subset of the language as a format for configuration files. These may contain compiler switches and system-specific flags. It integrates seamlessly with the nimble package manager, which lets you write tasks that can be executed both after building or as sorts of standalone targets.

writing a simple hello world for AVR is trivial (ish)

This is a valid program that can be run on a x64 intel CPU and, without any changes, on an 8-bit atmega microcontroller:

proc main = 
  while true:
    discard

main()
Enter fullscreen mode Exit fullscreen mode

To compile on AVR, you just have to provide some more configuration on how to handle critical errors without the os covering our backs:

# panicoverride.nim

proc exit(code: int) {.importc, header: "<stdlib.h>", cdecl.}

{.push stack_trace: off, profiler:off.}

proc rawoutput(s: string) = discard

proc panic(s: string) =
  rawoutput(s)
  while true:
    discard
  exit(1)

{.pop.}
Enter fullscreen mode Exit fullscreen mode

And a couple of compiler flags:

# config.nims

switch("os", "standalone")
switch("cpu", "avr")
switch("gc", "none")
switch("stackTrace", "off")
switch("lineTrace", "off")
switch("passC", "-mmcu=atmega328p")
switch("passL", "-mmcu=atmega328p")
switch("nimcache", ".nimcache")

switch("avr.standalone.gcc.options.linker", "-static")
switch("avr.standalone.gcc.exe", "avr-gcc")
switch("avr.standalone.gcc.linkerexe", "avr-gcc")

when defined(windows):
  switch("gcc.options.always", "-w -fmax-errors=3")
Enter fullscreen mode Exit fullscreen mode

Notice that these is where you specify which c compiler to use, to then actually generate the final binaries.

The compiler is identified by the cpu.os.compiler name (avr.standalone.gcc):

  • The compiler executable is specified through the exe property.
  • The linker executable is specified through the linkerexe property.

Let's dump this code snippets into the following files:

output of ls -alh

Run the compiler...

nim c avr_hw.nim
Enter fullscreen mode Exit fullscreen mode

output of nim c

...and check its output

avr_hw:     file format elf32-avr


Disassembly of section .text:

00000000 <.text>:
   0:   0c 94 38 00     jmp 0x70
   4:   0c 94 4a 00     jmp 0x94
   8:   0c 94 4a 00     jmp 0x94
  ...
  70:   11 24           eor r1, r1
  72:   1f be           out 0x3f, r1
  74:   cf ef           ldi r28, 0xFF
  76:   d0 e1           ldi r29, 0x10
  78:   de bf           out 0x3e, r29
  7a:   cd bf           out 0x3d, r28
  7c:   21 e0           ldi r18, 0x01
  7e:   a0 e0           ldi r26, 0x00
  80:   b1 e0           ldi r27, 0x01
  82:   01 c0           rjmp    .+2 
  84:   1d 92           st  X+, r1
  86:   ae 30           cpi r26, 0x0E
  88:   b2 07           cpc r27, r18
  8a:   e1 f7           brne    .-8
  8c:   0e 94 52 00     call    0xa4
  90:   0c 94 53 00     jmp 0xa6
  94:   0c 94 00 00     jmp 0   
  98:   ff cf           rjmp    .-2     
  9a:   08 95           ret
  9c:   08 95           ret
  9e:   ff cf           rjmp    .-2
  a0:   ff cf           rjmp    .-2
  a2:   ff cf           rjmp    .-2
  a4:   ff cf           rjmp    .-2
  a6:   f8 94           cli
  a8:   ff cf           rjmp    .-2
Enter fullscreen mode Exit fullscreen mode

Worked like a charm!

foreign function interface

Want to use _delay_ms from util/delay.h?

proc delay_ms(us: uint16) {.importc: "_delay_ms", header: "util/delay.h".}

proc main = 
  while true:
    delay_ms(1000)

main()
Enter fullscreen mode Exit fullscreen mode

As shown, it's really easy to integrate existing c code in your programs, you don't have to rewrite everything in nim. Note that there are tools to also translate c code to nim (c2nim, futhark).

metaprogramming

Metaprogramming is nim killer-feature in my opinion.

nim has:

  • Compile-time functions, which get executed in a VM embedded within the compiler, and supports a subset of the language to be evaluated at compile time.
const data = staticRead("my_file") # the file gets read at compile time!
Enter fullscreen mode Exit fullscreen mode
  • Generics and concepts, which from what I observed, are completely 0 cost at runtime, and are really only present in nim code, not in the c-generated one. Using them in combination with typeclasses enables quite powerful patterns.
type MappedIoRegister*[T: uint8|uint16] = distinct uint16 ## \
  ## A register that can either contain a byte-sized or word-sized datum.

template ioPtr[T](a: MappedIoRegister[T]): ptr T = 
  cast[ptr T](a)
Enter fullscreen mode Exit fullscreen mode
  • Templates, which are hygienic (with scoped symbols) c macros, essentially a replace-mechanism that allows you to have "true inlining". Combining this with operator overloading is really nice.
import volatile

template `[]`*[T](p: MappedIoRegister[T]): T =
  volatile.volatileLoad(ioPtr[T](p))

template `[]=`*[T](p: MappedIoRegister[T]; v: T) =
  volatile.volatileStore(ioPtr[T](p), v)
Enter fullscreen mode Exit fullscreen mode
  • Macros, which are special functions taking in Abstract Syntax Tree (AST) representations of nim code and spewing out ASTs that are transformations of its inputs. This allows for some pretty crazy stuff.
# VectorInterrupt enum definition omitted..
template vectorDecl(n: int): string =
  "$1  __vector_" & $n & 
  """
    $3 __attribute__((__signal__,__used__,__externally_visible__)); 
    $1 __vector_""" & $n & "$3"


macro isr*(v: static[VectorInterrupt], p: untyped): untyped =
  ## Turns the passed procedure `p` into an interrupt 
  ## service routine.
  var pnode = p
  if p.kind == nnkStmtList:
    pnode = p[0]

  expectKind(pnode, nnkProcDef)

  addPragma(pnode, newIdentNode("exportc"))
  addPragma(pnode, 
    newNimNode(nnkExprColonExpr).add(
      newIdentNode("codegenDecl"), 
      newLit(vectorDecl(ord(v)))
    )
  )
  pnode

# Now we can map functions to an interrupt

proc timer0_compa_isr() {.isr(Timer0CompAVect).} =
  # do stuff when the timer0 compare A interrupt gets triggered
  discard
Enter fullscreen mode Exit fullscreen mode

All code snippets are taken from the avr_io library, a small project that I maintain.

Note that we can use exportc to generate code that will interact with c, and codegenDecl to manipulate the c-generated code: this is incredibly powerful, especially for writing libraries.

As a rule of thumb, use these features in this order and go to the next one only if needed: non-meta stuff -> generics -> templates -> macros.

memory management

Memory management is highly configurable in nim.

Starting from v2 onward, the memory management policy used by default is based on a reference counting approach, that also handles cycles (-mm:orc).

You can also choose to use a more easy-to-reason strategy, which also uses reference counting but without support for cycles (-mm:arc). Notice that both are deterministic and not stop-the-world.

By experimenting, I noticed that binary size can become a bit larger with orc/arc, so if it is really a problem, you can always opt in not managing your memory at all (-mm:none).

Why does that matter? Because nim has managed types, but I did not experiment with them enough to have formed an opinion on how good or not they are in bare-metal situations!

💖 💪 🙅 🚩
abathargh
abathargh

Posted on March 28, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related