Compiling Julia code for a RISC-V ESP32 microcontroller

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.

I learned a lot from the post of @Sukera (Running Julia baremetal on an Arduino) on Julia on Arduino, which works also for WASM (GitHub - Alexander-Barth/FluidSimDemo-WebAssembly) .

Any ideas how to handle module-level global variable such as a reference to an Int32 would be quite helpful (when cross-compiling).

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 :frowning: 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.

Thanks, @Sukera ! :slight_smile:

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)

Thank you for letting me know about SVD! I didn’t know about them, I guess this is an example for the esp32 c3 that I am using:

What is the scope of the SVD ? The Arduino API and EPS IDF do also include Wi-Fi networking and bluetouth. You would still need to wrap the C++ / C libraries for these features, or not?
I initially use the Arduino API because that is the API that I am familiar with and I like the fact that it is vendor neutral. The EPS IDF is probably easier to wrap because it is C, but it is specific to Expressif.

I can see how toggeling pins can be done with SVD files. But I am not sure how it can be used to something more complicated like writing to the serial interface (as you mention Serial.begin). If you can provide a working example, that would be quite useful :-).

Or are you just proposing to use them for toggling GPIO pins? Those are simple ccalls:

I guess, if you still need to link against the Arduino/EPS IDF libraries anyway, I am not sure what are you gaining by going bare-metal for manipulating the GPIO pins?

Yes, I agree that protecting the memory ranges would be nice. That would be quite easy to incorporate (to me julia ranges 0x3c00_0000:0x3c80_0000 are more natural here than pairs).

A SVD file is just a way to describe where in memory certain registers or hardware devices are mapped. By itself it doesn’t do any pin toggling, that’s just an effect that can happen when a certain register is written to. The LLM is confused here.

If you want an SVD parser & a (somewhat nice) julia interface generated from an SVD file, you can use GitHub - Seelengrab/DeviceDefinitions.jl: A package to generate microcontroller definitions from SVD files. · GitHub

Please don’t post generative-AI outputs (post is now hidden) in this forum — this violates the Julia Community Guidelines Julia Discourse Guidelines. People can query LLMs themselves if they wish, but we come to this forum for responses from humans.

@stevengj I think this brought up the concept of SVD that wasn’t mentioned here and it led to more awareness of Sukera’s work. While people can query LLM themselves, you should be aware that discussing with others can help bring new ideas to better query LLM to get a better understanding of the issues. Flagging post like that doesn’t help Julia to be better. You should be aware that LLM are great help also for non native english speaker.

You’re welcome to query the LLM yourself and write up your own summary if you think it brought up something useful — e.g. “I queried ChatGPT and it suggested this thing called an SVD that […], so you might want to look into this, and check out package XXX and site YYY”. But directly dumping LLM outputs into a post is not acceptable here.

The forum guidelines have an explicit exception for “direct human language translation and minor editing”— if you write a post in your native language and use AI just to help translate it, or use AI to fix your English grammar, that’s fine.