Firmware 103 - Embedded Systems
Introduction
Welcome to firmware 103! By now you should’ve completed your environment setup, run the command to build and run a project on x86 and learned a bit about FreeRTOS. In this lesson you’ll learn about the fundamental of embedded systems development. We’ll be covering:
Hardware Crash course
GPIO Functionality
Interrupts
Timers
Hardware Crash Course
There is a lot of overlap between firmware and hardware, so it is important that we build a conception of how all of our electrical projects work. Everything that we receive as inputs to our programs, or send as outputs to the surrounding system is in the form of digital (on or off) or analog (a voltage in some continuous range) electric signals which have some inherent meaning in the real world.
Embedded systems in general are comprised of a computer, an electrical interface, and a surrounding system, and a solar car is no different. We will discuss each of the building blocks specific to our system.
1. Microcontrollers
So far, we’ve only been dealing with running software on your PC, but in reality our software runs on a separate “mini computer” in the car. These are Microcontrollers (aka. MCU, controller, STM, STM32, Integrated Circuit [IC], or other). The one that was used for the MSXIV design is an STM32f1 chip, shown below:
The metal bits sticking out are called “pins”, and are essentially just wires which receive/send all signals to or from the MCU. Each pin has a specific purpose, and are what connect the computation part to the rest of the electrical system. The programs that we write can be loaded onto one of these bad boys, which then runs the code as soon as it receives power. These are like the CPU in your laptop, but 50x cheaper and simpler. The other major difference is that the microcontroller is a comprehensive computer (instead of just a CPU), with memory and peripherals included.
2. Controller boards
There’s a bit of a problem with the chips seen above, in that they are effectively useless by themselves. You have no way of regulating voltages and currents into them, so they will fry as soon as you connect them up to a solar panel or whatever else you are trying to control. If you’ve used Arduinos, STM32 or any other development board, you’ll know that they provide a circuit board with the microcontroller. For us this is our “controller board” which is a PCB (Printed Circuit Board) designed in-house by the Hardware team. You can see what it looks like below:
The microcontroller is in the middle. Surrounding it are various electrical components, ICs, resistors and capacitors, and a “mezzanine” or breakout wiring plug on the far right. Each pin on this mezzanine is connected to a pin on the MCU.
Luckily we don’t need to worry about many of these components. We just need to know which pin (by number) connects where. Controller boards are the same across all the projects, and we allot one to each.
3. System Boards
While the controller board is great, it doesn’t allow us to interact with the broader system. For that we need other PCBs. The example below is from an old version of our steering system. It provides plugs and connections which we would connect to a rotary encoder, for example, as well as doing any electrical adjustment or signal processing to make sure we don’t fry our controller boards.
These boards provide the connections needed to make the signals in the system available to the controller board, which in turn makes it available to the MCU. You can see there is a plug on the right, this is where the mezzanine seen on the controller board plugs in.
Outlined in red below is the controller board mount, so you can imagine the above picture being flipped over and placed on top.
Pictured below is a controller board placed into its mount. You can also see many other components connected to the system board, almost all of which are connected directly to the MCU.
4. Flashing Controller Boards
You might be saying “Hey, we’ve got some neat controller boards with little computers on them, but how am I supposed to get my code on them?”. Great question, thanks for asking.
MCU’s provide protocols for transferring programs into the flash memory. This is a variation of EEPROM (electrically erasable programmable read-only memory). This is non-volatile memory (Will stay the same even when the device loses power), and it is where we store our program (don’t worry too much about the specifics).
For us, what this means is that we need a method of connecting our computer to the MCU and transferring the data over. Luckily, ST Microelectronics provides a super sweet and easy method of doing this over USB, called “ST-Link”. For us, this requires a few components:
There are 3 main components here. The usb connector piece is called a programmer. There is a cable which connects the pins on the programmer to the pins on the MCU, and a jumper wire which provides power to the board. This creates a direct connection between your computer directly and the microcontroller.
If you ever work with a hardware member to test your code, they’ll be using one of these to “program” a controller board. Programming a controller board means writing your code into its memory, then rebooting it so it runs your code. We have command line arguments to do this, so it’s really not too tough to figure this out!
Embedded Concepts
Before we get to writing actual firmware, we will discuss a few concepts that are used in almost all embedded systems. These are:
GPIOs
Interrupts
Timers
GPIOs
GPIOs, or General Purpose Input Outputs are a specific type of pin on an a microcontroller which can be programmed to perform certain functionality. This GPIO Tutorial provides a great intro to the idea of what they are and how our GPIO library works. Essentially we can use them for peripheral interfaces (like I2C, SPI, CAN) or as inputs or outputs to control/read from the system.
Our GPIO Library can be found at fwxv/libraries/ms-common/inc/gpio.h
, and has the following types and functions available:
Types:
GpioAddress
- This is how we can refer to a specific pin on the MCU. The address is comprised of a port and a pin.
GpioMode
- What we will use the pin for (input, output, analog, etc)
GpioState
- These are the possible states of the GPIO when reading it or setting it to a logical value. High represents a pin state of ~3.3V, Low represents a state of ~0.0V.
// Initializes GPIO globally by setting all pins to their default state. ONLY
// CALL ONCE or it will deinit all current settings. Change setting by calling
// gpio_init_pin.
StatusCode gpio_init(void);
// Initializes a GPIO pin by address.
StatusCode gpio_init_pin(const GpioAddress *address, const GpioMode pin_mode, GpioState init_state);
// Set the pin state by address.
StatusCode gpio_set_state(const GpioAddress *address, GpioState state);
// Toggles the output state of the pin.
StatusCode gpio_toggle_state(const GpioAddress *address);
// Gets the value of the input register for a pin and assigns it to the state
// that is passed in.
StatusCode gpio_get_state(const GpioAddress *address, GpioState *input_state);
gpio_init()
must be called at the start of your main function. It effectively sets up the gpio library, and initializes it for use in your programgpio_init_pin()
method configures a pin at a specific address to a specific functionality, and mode. The default is just to treat the pin state as a binary value (Logical 1 if it is above a certain voltage threshold, or 0 if below).gpio_set_state()
andgpio_toggle_state()
are used when the pin is treated as an output. gpio_set_state sets the state of the pin to high (a logical 1, or 3.3V), or low (logical 0, or 0V)/gpio_get_state()
reads the current state of the pin as an input. If the voltage is high, then it returns GPIO_STATE_HIGH, otherwise GPIO_STATE_LOW
To get a GPIO pin ready for use, we need to do 3 things.
Call gpio_init() at the start of your main program.
Call gpio_init_pin() for the specific pin you want to use. You need to pass into this function the settings you are trying to configure your pin with, and the address of the pin you are trying to configure.
Call the function to interact with the GPIO pin. This could be gpio_get_state() if the pin is configured as an input, or gpio_<set/toggle>_state() if the pin is setup as an output.
Interrupts
Interrupts are a somewhat complex topic, but we can discuss them solely in terms of how we use them in our system. (For a video version of this content, see here).
When a program is running, instructions will execute sequentially, starting with the entry point to the program (the main function). However, sometimes we want to change the flow of execution based on external or internal events. This is where interrupts come in. They “interrupt” the normal flow of execution, and cause a separate set of instructions to be executed.
One of the main interrupt sources in our system are caused when external signals change the state of one of our GPIO pins. This could be a signal from a sensor indicating that data is ready to be collected, or many other things, but we will look at the case of a button press.
Assuming that everyone has used a button before (I really hope), you know that a button is generally pressed, or not pressed. In electrical systems, this press will change the state of a circuit to open (no current) or closed (Current flows). This is what a button looks like in a schematic:
S1 is the button itself, which is connected to a 3V input, and the circuit which mediates the signal for the Microcontroller (Fun Fact for any EE friends: the capacitor prevents “bouncing”, which occurs when the button is released and oscillates between on and off). The BTN1 line that goes off to the right connects to a pin of the MCU which we can use to read the button state. When the button is pressed, the circuit will connect, and the pin will go to 3.3V which is the high state.
So let’s say we want to read the button state and use it in our program. We know that the button state is a one or a zero, so after initializing our GPIO to the correct settings we can continuously call gpio_get_state to see if the pin connected to the button has gone high, something like this:
GpioState out_state;
while(true) {
// Sets the value of out_state to current state of input pin at my_address
gpio_get_state(&my_address, &out_state);
if (out_state == GPIO_STATE_HIGH) {
// Do button processing
prv_turn_on_light();
}
}
However, this is not a good approach. We are continuously needing to check the state of the button, which means we can’t do other things in our program. Ideally, we would wait until the button is pressed, and then do the processing we need without having to do continual checking. We can do this with interrupts!
Our library for configuring and using interrupts with gpios can be found at fwxv/libraries/ms-common/inc/gpio_it.h
.
This is the main method that is used for GPIO interrupts:
StatusCode gpio_it_register_interrupt(const GpioAddress *address, const InterruptSettings *settings,
const Event event, const Task *task);
This method registers an interrupt to trigger a “callback function” when a certain event occurs. As you can see, it’s got quite a few parameters! Let’s talk about each one:
GpioAddress
- This one we've already seen. This is the specific pin which we are configuring the interrupt forInterruptSettings
- Don't worry too much about this one. It will pretty much always be
type = INTERRUPT_TYPE_INTERRUPT, and priority = INTERRUPT_PRIORITY_NORMALInterruptEdge
- This is important. Interrupts need to detect a change of state to trigger. This is called an "edge". If we want the interrupt to trigger when the pin goes from high to low, we set the edge to INTERRUPT_EDGE_FALLING. If we want it from low to high, it can be set to INTERRUPT_EDGE_RISING, or INTERRUPT_EDGE_RISING_FALLING to trigger on either. Which one is it for the button?Task *
- This is the task which is notified*Event
- An integer at which the selected task will be notified.
FW 103 Homework - Multi-Channel ADC
Part 1 - Toggling a GPIO
Here, we’re going to work on a program to toggle some LEDs! The goal is to get an LED to toggle every 1 second.
1.1 - Setup
Create a new branch from fw_103_new
. To do this, run git checkout fw_103_new
, then run git checkout -b fw_999_fw_103_hw_your_name
.
Now moving onto the code, open up main.c
within the fw_103 project. This is a blank project for you to add to. You’ll notice a couple of files which we’ll be pulling in during the homework.
Let’s include the header
gpio.h
at the top of our program to give us access to the functions and types related to GPIO controlTo control GPIO pins, we should first initialize the module at the start of main using
gpio_init()
You should also delete
init_master_task()
and those empty functions, which we usually use for project code on the car
1.2 - Creating a task and setting a GPIO
Create a task to run your LEDs. We learned how to create tasks in FW 102, so take a look there to refresh on how to do that.
Now let’s initialize the pin which controls one of the LEDs on our controller board. As we learned above, a GPIO pin is associated with a port and pin. So, the first thing we need to do is go find our GPIO’s address, which is simply the combination of the port and pin. Let’s take a look at Altium to see what ports and pins the LEDs are wired up to (you may not have access but it’s not quite necessary yet, but feel free to request access so you can start exploring our hardware!).
In the image above, we can see the symbols for the four LEDs we have on our controller boards. Additionally, the net name clearly and easily tells us what address is wired to what colour LED (thanks hardware team!). Let’s pick PB3_LED_RED
. So we need to initialize the pin on port B and pin 3. Feel free to pick a different one if you would like.
To initialize the LED, you’ll need to do two things: create a struct to define the address, and initialize that address using gpio_init_pin
. See the code example below for how to initialize the LED, and look around our repo or ask in discord if you’d like more clarity.
We’re almost there! Now that you’ve got the gpio module as well as a pin initialized - within the while
loop in the task you created earlier, call gpio_toggle_state
with the appropriate arguments and use the delay.h
header to set a 1 second delay between toggles. Don’t be afraid to ask questions if you get stuck! (hint: take a look at the leds project for some code examples)
Part 2 - Multi-Channel ADC
Woah, big topic jump. So we’ve initialized a GPIO, but we can’t do anything fun and interesting with it. So, let’s talk about one of our first peripherals, an ADC. We want to write some code that interacts with an external ADC using the I2C protocol (reading: Inter-Integrated Circuit (I2C) ). The deliverable is to create a program that takes a reading every 100ms. Don’t worry, there’ll be lots of code snippets!
An Analog to Digital Converter simply takes an analog signal (which is really just a signal at some voltage) and converts it into 1s and 0s that we can use to interpret the signal on our microcontroller. For further reading, take a look at ADCs here Analog Digital Converters (ADCs) .
2.1 - Let’s read a datasheet!
A datasheet is something that tells us everything we need to know about some component on our boards. Each component comes with a datasheet supplied by the manufacturer, and is basically a guide telling you “Everything you wanted to know, and never wanted to know, about your component”. During this homework, we’ll be looking at the datasheet of a multi-channel ADC so that we can send the component the appropriate commands to control and read it.
Quick note: there is a ton to learn so if you’re completely new to hardware and there’s a lot of new concepts, don’t worry and create a thread on discord and we’ll be happy to answer anything and everything
Let’s take a look at the datasheet, this is a component from Texas Instruments which you’re all familiar with. The part number is ADS1115. The ADC communicated over I2C. The specifics aren’t important right now, we just need to know that I2C is a protocol which defines a method of communicating between our MCU and the ADC.
Reminding ourselves of our goal as a firmware developer: We want to write code that interacts with the ADC, so what part of the datasheet helps us do that? Taking a look at the table of contents, there’s a programming section on page 22. Awesome, let’s take a look there.
This is a lot of text. It essentially boils down to this:
We need to send I2C commands to the device by performing the following steps
Initialize the device by:
Setting the config register which selects the appropriate ADC channel
Setting the low threshold
Setting the high threshold
Configure the ALRT pin (optional)
Read from the device by:
Performing a “read register” to the conversion register
Okay, so we sort of know what to do now. A lot of this is done for you in the files ads1115.h
and ads1115.c
, but you’ll need to read at least sections 9.6.3 and 9.6.4 to correctly complete the configuration code.
2.2 - Completing the driver
The header file ads1115.h
is fully completed for you. This defines all the functions needed to interface with the device. You will not need to call all of them in main.c
.
Take a look at ads1115.c
. There’s a few spots labelled as TODO
for you to fill out. Lets take a look at each (there are some marked as optional for the optional section 2.4 of the homework).
Setting the config to continuous mode - The call to I2C write register is done for you, what you need to figure out is what value to actually write. Head to page 28 and 29 of the datasheet. Table 8 lays out what each bit means within the 16-bit configuration value. Your job is to construct what 16 bits we’re writing to the ADC by reading the table. Here’s the steps:
Leave most things as default except for the Field “MODE”. Set this bit to a 0.
To do this, look at the value in the “Bit” column. This is the bit number the Field corresponds to. For “OS”, it is defined by just 1 bit, bit 15. See below that when writing out binary, bit 15 is the most significant (to the left) and bit 0 is the least (all the way to the right).
| 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
Completing the read raw function - You’ll need to use the
i2c.h
header to include some I2C functions in your code. To read from the ADC, use the functioni2c_read_reg
and the appropriate arguments. The arguments are as follows:Port: The function signature contains a
config
struct. The struct has a memberi2c_port
so use this value as the port argument.Address: Same as the port, use the
i2c_addr
member as the address argument.Register: Look at
ads1115.h
. Within theADS1115_Reg
enum, there’s a list of registers. You want to read from the conversion register, so use that value as the argument.Buffer/reading: This is the buffer to which the converted value is read into. Pass the reading pointer into this.
Bytes: This is the number of bytes of data you want to read back. How many bytes are in a
uint16_t
?
Completing the read converted function - The ADC gives you a value between 0 and 65535, which is the
uin16_t
max value. Let’s assume in this driver that this represents a reading between -2.048V and 2.048V (as we had set in the config register). How would you convert the “raw” reading to a voltage? (Hint: think about what a reading of 1 means, that would be 1/65535 of 4.096V. Use that logic to convert the readings.
2.3 - Completing your main code
Nice, now that the driver is done we should be able to use it. Go back to your main.c
and #include "ads1115.h"
.
In the same task you made earlier, before the while loop, create the below structs. You are strongly encouraged to read or ask questions about why these are setup the way they are, but they are provided for simplicity.
Now, you should be able to call ads1115_read_converted
with the correct arguments and get readings from an analog signal, converted into a readable voltage!
Congratulations, this is the main part of the onboarding, you are now free to pick up a task. Please make a pull request with this code and we’ll close it out and get you something to work on ASAP.