Bare Metal Programming: ATtiny85

Bradford Lamson-Scribner
8 min readApr 25, 2023

--

Hey all 👋, it’s been a little while since my last post, but I’m excited to be back with some new content! In this post we’ll learn a bit about programming an ATtiny85 microcontroller directly without the use of any environments or libraries like Arduino/avr-libc/etc. This approach is primarily for learning, as libraries like avr-libc are excellent for effectively programming 8-bit Atmel (now Mircochip) products and are much more safe, stable, and robust. However, as usual, I wanted to get to the bottom of things 😃.

By the end of this walkthrough, you should have a slightly better understanding of:

  1. Microcontrollers, in particular the ATtiny85
  2. Datasheets and some things to look for in them
  3. Interrupt Service Routines (ISRs)
  4. Embedded programming in general

Resources

Let’s Get Started

Get ready for lots of snippets from the datasheet! Honestly, datasheets are intimidating to me but like everything else, take it one step at a time. The more I read them and implement code from them, the more confidence I get. It becomes slightly easier each time I return to one. Let’s get started with the pinout for the ATtiny85:

The important parts here for us today are pin 4 (GND), pin 8 (VCC), and pin 5 (PB0). These will enable us to power and ground the IC and then utilize PB0 from PORTB as output to our LED.

One reason I like the AVR Tiny Programmer is that it breaks out the pins into female headers. With this I’m able to connect directly to peripherals and continuously flash new code without moving any parts.

There are a few things we need to do now in order to turn on our LED:

  • Find the I/O address of PORTB’s data direction register (DDRB)
  • Find the I/O address of PORTB’s data register (PORTB)
  • Flip a bit in DDRB to configure it as an output pin
  • Flip a bit in PORTB to turn the LED on

According to the datasheet, PORTB is located at 0x18 while DDRB is located at 0x17. We now have enough information to start playing with some code.

Note: The addresses I use above are actually incorrect. I figure this out a little later on and we will be updating them.

First let’s define some macros for directly accessing the registers at their respective locations. Finer details of the C code throughout this walkthrough are a bit outside of the scope of the post, but I will address the volatile keyword in case it is something you haven’t come across. It is a type qualifier that indicates a variable may be subject to change by external factors, such as hardware or concurrent processes, without the compiler's knowledge. It informs the compiler not to optimize read and write operations on the variable, ensuring that the code interacts with the actual memory location each time the variable is accessed.

Now, let’s turn on the LED! First we set the data direction using the DDRB register, followed by turning on PB0 using PORTB.

main.c

This part had me scratching my head for a while as it wasn’t working initially. I eventually found a macro while digging through code in avr-libc called __SFR_OFFSET (Special Function Register Offset). Okay, back to the datasheet as I remember it saying something about PORTB having alternate/special functionality. Found something -

“Port B also serves the functions of various special features of the ATtiny25/45/85 as listed in “Alternate Functions of Port B” on page 60.”

Okay, now we’re getting somewhere! After more and more reading, I found the answer in the 5.4 - I/O Memory section.

“When addressing I/O Registers as data space using LD and ST instructions, 0x20 must be added to these addresses.”

That was it, so let’s update the registers and get the code working! Here is the whole snippet that turns on the LED. It simply turns on the LED and loops forever leaving the light on.

Given the current code we could add a loop within our while(1) condition to get the LED to blink on and off. This is not ultimately what we want to be doing, but if you’re interested it could look something like:

Instead we are going to use an ISR (Interrupt Service Routine) that is triggered by an internal 8-bit timer/counter.

ISRs, Clocks, and Timers

An ISR is a special function in embedded systems that responds to interrupt signals generated by hardware devices or external events. ISRs are designed to handle time-critical tasks or events with minimal latency, temporarily pausing the normal program execution. Upon completion, the ISR returns control to the main program, allowing it to resume from where it was interrupted.

We will be using the internal 8-bit Timer/Counter0 on the ATtiny85 to allow for accurate program execution timing/event management.

Let’s clock the timer internally by configuring the prescaler to 1/1024 of the system clock frequency. After that we need to enable both the timer overflow interrupt as well as global interrupts.

To do this, first we’ll create a couple of new macros for the appropriate registers. We’ll also add some helper macros for the memory offset, registers in general, and setting a bit. Finally, we have to define the ISR handler function and patch the vector table (more on this below).

Final main.c code for blinking LED using an ISR

The code above includes a couple things we didn’t go over yet like some assembly and the ISR handler function. We’ll start with the macro that defines the inline assembly:

  • __asm__: Keyword used to embed assembly language instructions within C code.
  • __volatile__: Used to ensure that the inline assembly code is executed as written, without being optimized away or reordered by the compiler.
  • ("sei" ::: "memory"): This part specifies the actual assembly instruction. The triple colons ::: are used to separate the assembly instructions from the input, output, and clobber constraints. In this particular case, there are no input or output constraints, and only the clobber constraint is provided. The instruction is sei, which is an AVR assembly mnemonic for “set Global Interrupt Enable”.
  • "memory": This is a special “memory” clobber constraint which ensures that all variables are flushed from registers to memory before the statement, and then re-read after the statement. It introduces a memory barrier which should ensure proper ordering of volatile accesses.

Why assembly instead of setting the bit directly? Just for fun and in the spirit of no libraries I wanted to use some AVR assembly 😃. If you’re curious how this would look following the same pattern we’ve been using you could make additions like the following instead of the inline assembly:

ISR Handler

This was another area that took me some time digging through avr-libc to figure out how to implement. The avr-libc library offers a nice ISR() wrapper for defining an ISR for a certain interrupt. It takes a “vector” (in this case, the interrupt vector source name + _vect) and some optional attributes. The vector name in our case we’d be passing in would then be TIMER0_OVF_vect which is defined for the ATtiny85 in avr-libc/include/avr/iotnx5.h as _VECTOR(5). The vector macro ultimately expands to #define _VECTOR(N) __vector_ ## N.

This seemed to imply as long as I had the naming right and provided the right directives, the compiler would know that it is an ISR handler function that needs to be patched into the vector table. Ultimately the signature I needed for the function prototype was void __vector_5(void) __attribute__ ((signal));.

Here you can see TIMER0_OVF (our Timer/Counter0 Overflow Interrupt) at program address 0x0005 which is how I verified (__vector_5). If you aren’t familiar with interrupt vector tables, the TL;DR is it’s an array of pointers to functions that are associated and triggered on certain interrupts. In other words, it defines where the code of a particular interrupt/exception routine is located in memory.

In the function prototype, the __attribute__ directive is used to apply attributes to the function, which can affect its behavior during compilation, linking, and execution. Attributes are compiler-specific, and in this case, they are specific to the GCC (GNU Compiler Collection) and compatible compilers (AVR-GCC). The only attribute I for sure know we need here is signal which indicates the specified function is an interrupt handler. The compiler will then also generate function entry and exit sequences which enable two important things:

  1. It ensures that every register that’s modified during the ISR is restored to its original value when the ISR exits. This is required as the compiler can’t make any assumptions as to when the interrupt will execute, and therefore can’t optimize which registers need to be saved and which don’t.
  2. It forces a RETI (Return From Interrupt) instruction to run. The ATtiny85 disables interrupts when entering an ISR and the RETI re-enables them on exit of the function.

Almost there

That’s the end of the C code for the LED blink program. We can use avrdude to flash the code onto our ATtiny85. Here is a basic Makefile I created for some different commands I used the most:

While I’m not going to go in depth on using avrdude, we can quickly go over the make flash command. It first compiles the code with avr-gcc using the flag -mmcu=attiny85. Next, it uses avr-objcopy to extract the .text and .data sections from our compiler generated a.out file and translate it into Intel Hex format. Finally, it runs avrdude with -p (part #), -c (programmer type), and -U (memory operation specification). The memory operation is saying write a.hex to flash which is in Intel Hex format.

You made it!

Thanks for reading and here is a link to full codebase:

That code will continue to change as I play with more features, etc. The snippets in this post however are gists and so can always be referenced in their current form here. If folks enjoy the post, I could always continue this series with a part two. Thanks again for reading and have a great day!

--

--

Bradford Lamson-Scribner

Software Developer 💾 Hardware Geek 👽 Bird Whisperer 🐤