I wanted to share some experiments I did to run Julia on a ESP32 C3 using GPUCompiler and LLVM:
This microcontrollers use the open RISC-V instruction set. The ESP32 family of microcontrollers are quite popular because they quite inexpensive and jet rather powerful (dual core processor, WiFi and Bluetooth).
Once you have a Julia build with RISC-V enabled as LLVM target, GPUCompiler.jl can generate an object file with the compiled Julia code. I wrapped some functions from the Arduino API to manipulate the GPIO (General-Purpose Input/Output) pins and write to serial output. As it is done typically in Arduino sketches, there is a Julia function for setup() and one for loop() (both part of a Julia module). A single object file with these functions is then linked against the ESP32 IDF framework from the manufacturer of these microcontrollers.
What I got to work is quite basic like blinking a led, write to serial output. The most complex example that I got working is to control a led strip.
Only a tiny subset of Julia can be used however (no global variables, no heap allocation: no strings or arrays..). When I try to use global variable, the memory adresses of the host are stored into the RISC-V binary.
A work-around to access some global variables like the global Serial object is to create a function that returns it adress in LLVM IR. But for global variables declared by the user, this is more difficult to do.
Very cool! I also did some experiments with RISC-V, but never published anything about that because it’s mostly the same as the Arduino stuff but on a different chip. I couldn’t get global variables to work either, unfortunately Only some constants like strings and such, because they lower somewhat nicely to LLVM module constants. Even this required custom passes over the IR though, to essentially “patch out” dynamic calls into the runtime.
For reference, here is some code to illustrate the issue with global variables:
module blinking_leds
using ESP32RISCVCompiler.Arduino
const counter = Ref{Int32}()
function setup()
counter[] = 0
end
function loop()
end
end
This gets compiled into:
define i32 @_Z5setupv() local_unnamed_addr {
top:
store i32 0, ptr inttoptr (i32 -779740248 to ptr), align 4, !tbaa !2, !alias.scope !7, !noalias !10
ret i32 0
}
where -779740248 corresponds to Int32(pointer_from_objref(blinking_leds.counter)) on the host.
Maybe one approach is to collect all global variables in the module and to keep track of their memory address and indeed to iterate over the LLVM IR and to transform all for these pointers.
Unfortunately, at the LLVM IR level (produced by GPUCompiler.jl), there seems to be no information left that an operation involved a global variable. (Maybe this can be added as LLVM Metadata ?)
Or maybe there is a better way higher up the compiler pipeline?
Interestingly, in @code_llvm global variables are not directly subsituted by the corresponding memory address:
julia> @code_llvm blinking_leds.setup()
; Function Signature: setup()
; @ /home/abarth/src/ESP32RISCVCompiler/examples/blinking_leds_globals.jl:11 within `setup`
define i32 @julia_setup_13840() #0 {
top:
; @ /home/abarth/src/ESP32RISCVCompiler/examples/blinking_leds_globals.jl:12 within `setup`
; ┌ @ refvalue.jl:60 within `setindex!`
; │┌ @ Base_compiler.jl:58 within `setproperty!`
store i32 0, ptr @"jl_global#13842.jit", align 8
ret i32 0
; └└
}
Here is an “optimistic” approach where all pointers by global references in the Julia module on the host are swapped out by global variable:
This seems to work for these simple tests:
(This could fail if per chance one module-global reference would be at the same address as one of the internal addresses of the ESP32 device, for example the memory adresses for the GPIO)