Compiler Weekly: LLVM to AVR

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>

void setup() {
DDRC = 0xff;
}

void portc(uint8_t val){
PORTC = val;
}

void delay(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.

avr-gcc -O3 -mmcu=atmega328 -c runtime.c -o runtime.o

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.

fn build_blink_module(context: &Context) -> Module {
let module = context.create_module("blink");
let builder = context.create_builder();

let void_t = context.void_type();
let uint8_t = context.i8_type();

let setup_fn_t = void_t.fn_type(&[], false);
let setup_fn = module.add_function("setup", setup_fn_t, Some(Linkage::External));

let portc_fn_t = void_t.fn_type(&[uint8_t.into()], false);
let portc_fn = module.add_function("portc", portc_fn_t, Some(Linkage::External));

let delay_fn_t = void_t.fn_type(&[uint8_t.into()], false);
let delay_fn = module.add_function("delay", delay_fn_t, Some(Linkage::External));

let main_fn_t = void_t.fn_type(&[], false);
let main_fn = module.add_function("main", main_fn_t, None);

let setup_blk = context.append_basic_block(main_fn, "setup");
let loop_blk = context.append_basic_block(main_fn, "loop");
let teardown_blk = context.append_basic_block(main_fn, "teardown");

builder.position_at_end(setup_blk);
builder.build_call(setup_fn, &[], "call setup");
builder.build_unconditional_branch(loop_blk);

builder.position_at_end(loop_blk);
builder.build_call(portc_fn, &[uint8_t.const_int(255, false).into()], "turn light on");
builder.build_call(delay_fn, &[uint8_t.const_int(100, false).into()], "wait 100ms");
builder.build_call(portc_fn, &[uint8_t.const_int(0, false).into()], "turn light off");
builder.build_call(delay_fn, &[uint8_t.const_int(100, false).into()], "wait 100ms");
builder.build_unconditional_branch(loop_blk);

builder.position_at_end(teardown_blk);
builder.build_return(None);

return module;
}

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.

fn get_target_machine() -> Option<TargetMachine> {
Target::initialize_all(&InitializationConfig{
asm_parser: false,
asm_printer: true,
disassembler: false,
base: true,
info: true,
machine_code: true
});

let opt = OptimizationLevel::Aggressive;
let reloc = RelocMode::Default;
let model = CodeModel::Default;
let target = Target::from_name("avr").unwrap();
return target.create_target_machine(
&TargetTriple::create("avr-unknown-unknown-elf"),
"atmega328",
"",
opt,
reloc,
model
);
}

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.

fn input_file(source: &[u8]) -> Result<NamedTempFile, Box<dyn Error>> {
let mut input = NamedTempFile::new()?;
input.write_all(source)?;
Ok(input)
}

fn read_output(output: &mut NamedTempFile) -> Result<Vec<u8>, Box<dyn Error>> {
let mut buffer = Vec::new();
output.read_to_end(&mut buffer)?;
Ok(buffer)
}

fn link_to_avr(object: &[u8]) -> Result<Vec<u8>, Box<dyn Error>> {
let runtime_bytes: &[u8] = include_bytes!("../runtime.o");

let input = input_file(object)?;
let runtime = input_file(runtime_bytes)?;

let mut output = NamedTempFile::new()?;
let split_cmd = |c: char| c == ' ' || c == '\n';

let libc_start = r"
/usr/avr/lib/avr5/crtatmega328.o
-L/usr/lib/gcc/avr/11.2.0/avr5
-L/usr/avr/lib/avr5
-L/usr/lib/gcc/avr/11.2.0
-L/usr/avr/lib";

let libc_end = r"
--start-group
-lgcc
-lm
-lc
-latmega328
--end-group";

let result = Command::new("avr-ld")
.args(libc_start.trim().split(split_cmd))
.arg(runtime.path().to_str().unwrap())
.arg(input.path().to_str().unwrap())
.args(libc_end.trim().split(split_cmd))
.arg("-o")
.arg(output.path().to_str().unwrap())
.output()?;

if !result.status.success() {
let stdout = 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.

fn extract_hex(elf_program: &[u8]) -> Result<Vec<u8>, Box<dyn Error>>{
let input = input_file(elf_program)?;
let mut output = NamedTempFile::new()?;

let result = Command::new("avr-objcopy")
.args(["-j", ".text", "-O", "ihex"])
.arg(input.path().to_str().unwrap())
.arg(output.path().to_str().unwrap())
.output()?;

if !result.status.success() {
let stdout = 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;

fn main() -> Result<(), Box<dyn Error>> {
let context = Context::create();

let module = build_blink_module(&context);
let target_machine = get_target_machine().unwrap();

let object = target_machine.write_to_memory_buffer(&module, FileType::Object)?;

let program = link_to_avr(object.as_slice())?;
let hex_file = extract_hex(program.as_slice())?;

File::create("blink.hex")?.write_all(hex_file.as_slice())?;
return Ok(())
}

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.

avr chip with light

07/11/2021