Now that we have a working LLVM backend, I wanted to make one small step forward this week. Right now, the LLVM backend setup targets x86_64 based machines. That means the program that it creates will run on my laptop. But what if I want to run my program on something different?
I have a lot of AVR microcontrollers. They are small 8-bit chips that are good for controlling small circuits. If you have ever used an Arduino the AVR chip is the main chip inside it. My goal for this week is to have my compiler produce a hex file that I can upload and run directly on one of these chips. More specifically I want to run it on an ATmega328P.
For starters I am going to write some C. There are a lot of little details in the AVR platform that I don’t really want to worry about in my backend. To clear things up, I made a runtime that my program can link to. Having a runtime lets me have complex logic that is best expressed in C or even assembly but still use my own generated code for the final results.
#define F_CPU 1000000UL
#include<avr/io.h> #include<util/delay.h>
voidsetup() { DDRC = 0xff; }
voidportc(uint8_t val){ PORTC = val; }
voiddelay(uint8_t ms){ _delay_ms(100); }
The runtime is simple. It sets the clock speed used by _delay_ms, it has a setup function that makes DDRC an output, has a portc function that sets the value in PORTC and has a delay that waits a given number of milliseconds. This is enough to make a simple blinking light program to validate that everything is working.
Now I need to build that runtime into an object file that I can include with the compiler and link with later.
Now that the runtime is ready we need to generate some code that will use the runtime. This will make use of our runtime and generate a program that blinks an LED every 100ms.
You can see it has a setup area, then an infinite loop that turns the light on and off with delays between. Its a simple program but baby steps are important.
Where things start to change since last week is the target machine setup. Instead of initializing x86, I am initializing all. This is because the rust bindings that I use do not have AVR APIs exposed even though I have them installed. By running all, I know that the AVR target will be initialized. Then for the machine we use avr and atmega328 to target the correct chip.
Same as before linking runs the avr-ld command under the hood. This time I used avr-gcc -v to determine what linker flags are needed in order to produce a proper binary.
if !result.status.success() { letstdout = String::from_utf8(result.stderr)?; panic!("{}", stdout); }
read_output(&mut output) }
This should work but there is another piece missing. The program that is produced after linking is an ELF binary. Those are mainly used by unix like operating systems. Our AVR chip does not know how to read ELF and instead we need to transform it into a Intel Hex file. We do that by making some more temporary files and running avr-objcopy to extract the program as hex.
if !result.status.success() { letstdout = String::from_utf8(result.stderr)?; panic!("{}", stdout); }
read_output(&mut output) }
Finally we put it all together in our entrypoint. When you run the program it will generate a file called blink.hex that you can flash directly to a ATmega328 microcontroller.
use std::error::Error; use std::fs::File; use std::io::Write; use inkwell::context::Context; use inkwell::module::{Linkage, Module}; use inkwell::OptimizationLevel; use inkwell::targets::{*}; use std::process::Command; use tempfile::NamedTempFile; use std::io::Read;
To test all of this I got out a breadboard and wired up a microcontroller and attached a LED to the correct pins. You could also use an emulator if you prefer. As you can see below, the program works and our microcontroller is operating the light.