Assembling Assembly
Please Log In for full access to the web site.
Note that this link will take you to an external site (https://shimmer.mit.edu) to authenticate, and then you will be redirected back to this page.
1) Attendance
2) Background
Today in lab we will explore using assembly to configure and use the ESP32 GPIO pins, and we will also use assembly language to draw on the LED display.
2.1) RISC-V ISA Reference
You will need to refer to the RISC-V ISA reference sheet often, so keep it open in another tab. We also have printed copies in the front of the lab (where the staff sit) if you would like to have a physical sheet.3) Hardware Setup
Here is our circuit diagram for today's lab.
The wiring for the LED array is the same as in lab/postlab3, so you don't need to change anything for that. You may however need to change the wiring to the switchboard.
- Wire up 2 LEDs (grab from from the front if you don't have them from lab1): connect one to pin 2 and one to pin 8 (long leg to ESP32, short leg to ground).
- Connect BTNC to pin 6 on the ESP32.
Remember that LEDs should be inserted with the long leg closer to the ESP32 and the short leg to ground.
4) Software Setup
4.1) Starter Code
You can find the starter code for today's lab here: [Starter Code]. In the src/ folder you'll find:
main.c: your main C file. This file will still be our "entry point". Today, you should NOT modify this file, it merely serves as a "wrapper" for our assembly code. We have moved on to bigger and better things.gpio_asm.S: assembly (.S) file where you will implement an assembly procedure to set up a GPIO pin. This is the bigger and better thing.
main.c and gpio_asm.S should all go in the project's src folder.
Copy the 6190.h file that you have been developing throughout the semester into the project's src folder (as you've been doing for previous labs).
- In
6190.h, please comment out your implementations (including the declaration likevoid pinWrite(...)) ofpinWrite,pinRead,eraseBuffer(but don't delete them completely, you'll need to refer to them during the lab). We'll be implementing these functions in assembly, and they can't be defined in more than one location within the project.
Next, paste these lines of code somewhere toward the top of 6190.h (above any function implementations).
// Functions implemented in assembly must be declared in C.
void pinWrite(int pin_num, int value);
int pinRead(int pin_num);
//void pinSetup(int pin_num, int mode);
void setPixel(uint32_t *screen_buffer_addr, uint8_t x, uint8_t y, uint8_t val);
void eraseBuffer(uint32_t *screen_buffer_addr);
We're going to start out using your C implementation of pinSetup, so leave that line commented out for now.
4.2) Assembly Syntax Highlighting
The default liteXL configuration files don't contain assembly-specific syntax highlighting. By default, any .S or .asm file you open will have a somewhat random assortment of colors assigned to the different parts of your code (which is less than ideal when trying to understand what's going on). While this won't actually stop your program from working as expected, it'll make it a lot harder to parse/debug the code you've written (which will probably end up being an issue as our programs get more complicated throughout the next few weeks).
To fix this issue, one of our instructors has very graciously provided us with a configuration file that'll make liteXL highlight the registers, instructions, comments, etc... in our assembly files in a consistent and intuitive manner. The only thing left to do is place said configuration file in the correct folder. Follow the instructions below to get the correct syntax highlighting for your assembly files:
- Download the confifuration file here: Download File
- Navigate to the directory containing your liteXL plugin configuration files in your file explorer. For Mac users this directory will likely be found at
~/.config/lite-xl/plugins(where ~ refers to your home directory). It's likely you won't see the.configfolder by default and will need to presscommand+Shift+.(period) for it to appear. For Windows users this directory will likely be found atC:\Users\<your_user>\.config\lite-xl\plugins. - Copy the configuration file you downloaded in step 1 and paste it in the folder you navigated to in step 3.
- Close and open liteXL for the change to take effect.
If your liteXL plugin folder isn't found at the expected location, feel free to look around at other locations in your fileExplorer, or go on the queue for help! Likewise, if you're having trouble with any of the above steps you can also go on the queue!
Again, note that your project would still work without this syntax highlighting. We do however highly recommend adding it to your workspace as it'll ultimately make your life easier1.
4.3) RISC-V Assembly and our System
4.3.1) C Wrapper
Even though we will only be writing assembly code in this lab, we still need a main.c file. Our processor needs an entry point to our program, and our system in particular looks for a C function called app_main(). So, we will use our app_main() function as a "wrapper" from which we will call our assembly code. In this lab, we'll be writing individual procedures, so our C wrapper will call them as needed. As a result, there is a bit of C code in our wrapper. You won't need to modify it beyond uncommenting blocks of code as they're needed.
4.3.2) Handling Function Arguments and Return Values
All C functions ultimately get assembled into assembly code. In our case, on our ESP32, these instructions get compiled into RISC-V assembly language. During that process, our RISC-V compiler determines how to allocate registers -- we don't have variable names in assembly, just 32 registers.
In order for procedures to transfer information accurately, the same registers are always dedicated to holding function argument and return values:
- Function arguments: registers
a0-a7 - Function return values: registers
a0-a1
Function arguments must be placed in these specific registers before the function is called. The first argument will go in register a0, the second argument will go in a1, and so on.
Function return values must be placed in the dedicated registers before the function returns. The function returns via the jalr x0, 0(ra) instruction. The vast majority of functions return just one thing, and that goes in register a0. If there is a second return value, that is placed in a1.
Let's say we have the C function declaration:
int pinRead(int pin_num){
// pinRead code here
return val;
}
- Before any call to
pinRead(), the generated assembly program would put the valuepin_numin registera0. If there happened to be a 2nd argument, that would be placed ina1. A third argument would be placed ina2. And so on untila7. Order matters here. - Before returning from
pinRead(), the assembly program would place the return value, in this case the bit value read from the GPIO pin, in registera0.
4.3.3) Assembly (.S) Files
Let's take a look at your assembly starter code. First, let's take a look at the header.
.section .text # indicates a code segment
.align 2 # specifies how data should be arranged in memory
# .globl pinSetup # makes pinSetup able to be used outside of this file
.globl pinWrite # makes pinWrite able to be used outside of this file
.globl pinRead # makes pinRead able to be used outside of this file
.globl setPixel # makes setPixel able to be used outside of this file
.globl eraseBuffer # makes eraseBuffer able to be used outside of this file
# to comment in assembly while we use // in C.
.globl pinSetup is commented out since we want to use the C implementation in the beginning sections of this lab. The code won't compile if this is uncommented while we still have our C implementation declared.
Now onto the actual starter code (pasted below). We've provided a file where we'll implement 4 different assembly procedures (what we call functions when working with assembly). Some things to keep in mind:
- The
jalr x0, 0(ra)instructions are how the program will return from each procedure. This should be the last instruction in each procedure. If you delete it, the program will not return from the procedure. - Even though our build platform accepts them, for this week you cannot use pseudoinstructions. Stick to the base instruction set found here.
- You'll find that you'll need to add labels to your program, especially once you need to start using control flow instructions (like branching and jumping). Because all of these procedures are located within the same file, you should not use the same label name twice. For example, if you use the label
doneinpinRead, you cannot use the label namedoneinpinWrite(or in any other part of your code within this file).
# C declaration: int pinRead(int pin_num)
# ARGUMENTS a0: pin_num
# RETURNS bit read from GPIO pin
pinRead:
lui a2, 0x60004
addi a2, a2, 0x3C # GPIO_IN_ADDR
# YOUR CODE HERE...
jalr x0, 0(ra)
# C declaration: void pinWrite(int pin_num, int value)
# ARGUMENTS a0: pin_num, a1: value
# RETURNS: Nothing
pinWrite:
lui a2, 0x60004
addi a2, a2, 0x4 # GPIO_OUT_ADDR
# YOUR CODE HERE...
jalr x0, 0(ra)
# C declaration: void pinSetup(int pin_num, int mode)
# ARGUMENTS a0: pin_num, a1: mode
# RETURNS: Nothing
pinSetup:
lui a2, 0x60009
addi a2, a2, 0x4 # IO_MUX_GPIOn_ADDR
lui a3, 0x60004
addi a3, a3, 0x20 # GPIO_ENABLE_ADDR
# YOUR CODE HERE...
jalr x0, 0(ra)
# C declaration: void setPixel(uint32_t* screen_buffer_addr, uint8_t x, uint8_t y, uint8_t val);
# ARGUMENTS a0: screen_buffer base address, a1: x, a2: y, a3: val
# RETURNS: Nothing
setPixel:
# YOUR CODE HERE...
jalr x0, 0(ra)
5) GPIO in Assembly
Back in the first lab, you wrote 3 functions: pinSetup(), pinRead(), and pinWrite(), and you have been using them extensively throughout the first 3 weeks of the course, and rightfully so: our GPIO pins are our main interface between our ESP32 and the outside world. Since we will need to continue to use these functions, the first part of this lab will be converting these C functions into assembly procedures.
In this section, you will be translating C code into assembly code. All of these assembly procedures should behave exactly the same as your C implementations of them. The only thing that's changing is that you're using assembly language, which shouldn't impact the way your program works at a high level.
5.1) Accessing Memory using Assembly
Your GPIO functions use pointers to access and/or modify the values stored at various memory locations, so we should probably figure out how this translates to assembly. It actually does quite nicely, because we have two RISC-V assembly instructions that interact with memory. One instruction reads from memory and one writes:
- "Load Word"
lw: reads a 32-bit value from a given memory location - "Store Word"
sw: writes a 32-bit value to a given memory location
THESE ARE THE ONLY TWO RISC-V ASSEMBLY INSTRUCTIONS THAT INTERACT WITH MEMORY. lw READS FROM MEMORY AND sw WRITES TO MEMORY. NO OTHER RISC-V ASSEMBLY INSTRUCTION OR PSEUDOINSTRUCTION MODIFIES OR ACCESSES MEMORY.
5.1.1) Load Word
Let's look at an example. In C, you may access a 32-bit value stored at a given memory address (in this case 0x20000000) like so:
int *mem_address = 0x20000000 // the value of interest is stored at address 0x20000000
int value_at_mem_address = *mem_address
In assembly, that would look like:
lui a3, 0x20000 #load value 0x20000000 into register a3 (a3 = 0x20000000)
lw a4, 0(a3) #load 32-bit value stored at memory address 0x20000000 (a3) plus an offset of 0
# into register a4 (a4 <= Mem[0x20000000])
What if we wanted to access the 32-bit value stored at memory address 0x20000004? Rather than performing another lui instruction, we can just use an offset in our lw instruction:
lw a5, 4(a3) #load 32-bit value stored at memory address 0x20000004 (a3) plus an offset of 4
# into register a5 (a5 <= Mem[0x20000004])
That offset becomes especially helpful when you're dealing with adjacent locations in memory. Remember that, in assembly, we will exclusively be working with 32-bit values. So, adjacent memory locations will be 4 bytes away from each other. For example, if we had an array that begins at address 0x20000000:
int arr[3] = {0, 1, 2}; // &arr = 0x20000000
arr[0] would be stored in memory at address 0x20000000, arr[1] would be at address 0x20000004, and arr[2] would be at address 0x20000008.
5.1.2) Store Word
What if we wanted to modify the value stored at a given address? Let's stick with 0x20000000. In C:
int *mem_address = 0x20000000 // we want to write something to address 0x20000000
*mem_address = 7; // write the number 7 to memory address 0x20000000
In assembly, that would look like:
lui a3, 0x20000 #load value 0x20000000 into register a3 (a3 = 0x20000000)
addi a4, x0, 7 #notice how we can also use decimal encoding? doesn't
# need to be hex (a4 = 7)
sw a4, 0(a3) #store 32-bit value from register a4 (7) in memory address
# 0x20000000 (Mem[0x20000000] <= 7)
Ok, once you've got a solid handle on this, let's start translating some of our C functions.
5.2) Writing Assembly
Now we're going to get into the actual assembly-writing portion of the lab (exciting!). As you may have gathered by now, assembly code has the potential to become extremely confusing. In order to combat this, we recommend that you be really thorough with commenting your code, leaving comments on every line. This will make debugging (and explaining your code during checkoffs) go much more smoothly.
5.2.1) pinRead
Like before, pinRead takes in one argument:
int pin_num: GPIO pin number (0-31). This will be stored in registera0whenpinReadis called.
pinRead also has one return value: the digital value (0 or 1) of the GPIO pin. This value should be stored in register a0 before returning from the procedure.
pin_num be stored in when pinRead is called?
pinRead returns?
The constants defined in our C header file 6190.h are not defined for any of our assembly procedures, so you will need to use the numerical values for the addresses. We'll initialize registers with these values in the starter code for you. For pinRead, it is:
GPIO_IN_ADDR0x6000403C
Please complete pinRead in the box below, using your C implementation in 6190.h for reference. Your implementation should only use registers a0-a7 and x0/zero.
DEBUGGING ASSEMBLY: Our RISC-V assembly checkers have a built-in debugger, accessible by adding a breakpoint with the code-line csbreak, that you can use in order to step through your program. Here's some information about how to use it.
csbreak statement. See if you can find the value that existed at GPIO_IN_ADDR before the function was called.
Once it passes all of the test cases, copy and paste your code into the procedure labeled pinRead:. Make sure these lines in app_main() are uncommented (they should have been in the starter code, but doesn't hurt to check):
int x = pinRead(BTNC); // Read value of GPIO pin 6
if (x == 1){
printf("button not pressed\n");
} else if (x == 0){
printf("BUTTON PRESSED!\n");
}
Compile your project using the llp-bc compile ./ command and flash it to the board using the llp-bc flash ./ command. Then, open the serial monitor using the llp-bc monitor command.
BUTTON PRESSED prints in the serial monitor when you're pressing BTNC and that button not pressed prints when you are not pressing BTNC. If you are having any issues, go on the queue and ask a staff member for help!
5.2.2) pinWrite
Like before, pinWrite takes in two arguments:
int pin_num: GPIO pin number (0-31).int value: digital value (0 or 1) to write to GPIO pinpin_num
value be stored in when pinWrite is called?
pin_num be stored in when pinWrite is called?
Necessary memory address (already initialized in starter code):
GPIO_OUT_ADDR0x60004004
Please complete pinWrite in the box below, using your C implementation in 6190.h for reference. Your implementation should only use registers a0-a7 and x0/zero.
Once it passes all of the test cases, copy and paste your code into the procedure labeled pinWrite:. Uncomment these lines of code in app_main():
// pinWrite(LED1, 1);
// pinWrite(LED2, 0);
Compile your project and flash it to the board. Does LED1 light up? Does LED2 stay off?
illegal operands 'addi a5,a7,0x800' when you compile your project, convert the immediate to its decimal encoding.LED1 lights up, while LED2 stays off. If you switch the 1 and 0 so that you're turning LED1 off and LED2 on, does the system behave as expected?
5.2.3) pinSetup
Before we start writing some assembly code for this one, we need to indicate to our toolchain that we would like to use the assembly version of this function.
Please make sure you complete all of these steps.
- In
gpio_asm.S, uncomment the line:# .globl pinSetuptoward the top of the file:
.section .text
.align 2
# .globl pinSetup # <-- UNCOMMENT THIS
.globl pinWrite
.globl pinRead
.globl setPixel
- In
6190.h, comment out your C implementation ofpinSetup - In
6190.h, uncomment this line (located with the other declarations we inserted at the beginning of lab)//void pinSetup(int pin_num, int mode);:
// Functions implemented in assembly must be declared in C.
void pinWrite(int pin_num, int value);
int pinRead(int pin_num);
//void pinSetup(int pin_num, int mode); // <--- UNCOMMENT THIS!
void setPixel(uint32_t *screen_buffer_addr, uint8_t x, uint8_t y, uint8_t val);
Ok, now onto the actual implementation. Same as before, pinSetup has two arguments:
int pin_num: GPIO pin number (0 - 31). This will be stored in registera0whenpinSetupis called.int mode: 0 for GPIO input and 1 for GPIO output. This will be stored in registera1whenpinSetupis called.
Necessary memory addresses (already initialized in starter code):
GPIO_ENABLE_ADDR0x60004020IO_MUX_GPIOn_ADDR0x60009004
Please complete pinSetup in the box below, using your C implementation in 6190.h for reference. Your implementation should only use registers a0-a7 and x0/zero.
Your C implementation likely uses multiplication. However, RISC-V doesn't have a multiplication instruction. How would you perform multiplication in this case? Note that the multiplication performed is by a power of two, so it can be done in a single RISC-V instruction.
Once it passes all of the test cases, copy and paste your code into the procedure labeled pinSetup:. Compile your project and flash it to the board. Open your serial monitor, and take a look at your LEDs. If your implementation is correct, everything should still work as it did before.
illegal operands 'addi a5,a7,0x800' when you compile your project, convert the immediate to its decimal encoding.Demonstrate that your GPIO pins work as expected. Be ready to explain the code you wrote, and demonstrate using the
csbreak debugger to a staff member.
6) Updating LED Display Using Assembly
6.1) Screen Buffer
When we've wanted to draw something to our LED array, we've filled a screen_buffer with data by setting individual pixels (either directly, using setPixel, or using eraseBuffer). Now, we're going to explore implementing these procedures using RISC-V assembly language.
6.1.1) Working with Arrays using Assembly
Care must be taken when working with arrays in assembly. Let's look at a relevant example, an assembly implementation of eraseBuffer, which goes through the 8 integers in screen_buffer and sets them all equal to 0. You can take a look at your C program as a refresher.
Here it is in all its glory (also found toward the bottom of your gpio_asm.S file). Take a quick look at it and see if you get the idea of what's going on. Then, continue reading as we unpack it.
# ARGUMENTS a0: screen_buffer base address
# RETURNS: Nothing
eraseBuffer:
addi a1, x0, 8 # upper bound on for loop
addi a2, x0, 0 # "i" for for loop
looping:
slli a3, a2, 2 # calculate 4*i
add a4, a0, a3 # get address of array element by adding base address + 4*i
sw zero, 0(a4) # write 0 to memory address
addi a2, a2, 1 # increment i
bne a2, a1, looping # continue looping if i < 8
jalr x0, 0(ra) # return from eraseBuffer
Let's now look at it a bit more deeply.
There are 8 elements in the array, so we will want our loop to run 8 times. Additionally, we need to create a counter to count how many times our loop runs (and it will also serve as the index into our array).
eraseBuffer:
addi a1, x0, 8 # a2: upper bound on for loop (8)
addi a2, x0, 0 # a1: "i" for for loop (starts at 0)
Now we're heading into our loop. In general, when we're working with a 32-bit architecture such as the one in this class, we can convert from array index i to memory address by: (1) multiplying i by 4, then (2) adding this 4*i to the base address of the array. So, generally speaking (at least in our 32-bit world):
We then use a sw instruction to write the value 0 to this location in memory, effectively doing screen_buffer[i] = 0.
looping:
slli a3, a2, 2 # calculate 4*i
add a4, a0, a3 # get address of array element by adding starting address + 4*i
sw zero, 0(a4) # write 0 to memory address
Now we need to increment our counter i, and check if we need to break out of our for loop. Remember that a1 = 8, since we set that before the loop. The program will jump back to the slli instruction pointed to by the looping label if the value in a2 is less than 8. Otherwise, the program will continue on to the jalr instruction and return from eraseBuffer.
addi a2, a2, 1 # increment i
bne a2, a1, looping # continue looping (by jumping to "looping" label) if i < 8
jalr x0, 0(ra) # return from eraseBuffer
6.1.2) setPixel
Now, let's get some practice modifying arrays using assembly language. You have previously implemented setPixel (lab 3), a function that sets a single bit in the screen buffer to be either a 0 or a 1.
Our assembly version of setPixel takes in 4 arguments (one more than before, as we need to pass in the base address of screen_buffer).
uint32_t *screen_buffer_addr: starting address of screen buffer array.uint8_t x: the x coordinate (0-31) of the pixel to set.uint8_t y: the y-coordinate (0-7) of the pixel to set.uint8_t val: value (0 or 1) to write to screen buffer.
Note how we now have two separate arguments for location: x and y. Hopefully this simplifies your implementation.
Please complete setPixel in the box below. Your implementation should only use registers a0-a7 and x0/zero.
Once it passes all of the test cases, copy and paste your code into the procedure labeled setPixel (in gpio_asm.S). Uncomment this section of code in app_main():
// evens ^= 1;
// odds ^= 1;
// for (int i = 0; i < 32; i = i + 1){
// for (int j = 0; j < 8; j = j + 1){
// if (j%2== 0){
// setPixel(screen_buffer, i, j, evens);
// }
// else{
// setPixel(screen_buffer, i, j, odds);
// }
// }
// }
// drawBuffer();
// long start = millis();
// while (millis() - start < 1000);
Re-compile and flash your program. If all went as expected, your LED display should now be doing this:
illegal operands 'addi a5,a7,0x800' when you compile your project, convert the immediate to its decimal encoding.6.2) You're using assembly for more than you think
Continuing along our LED array journey, after we fill screen_buffer, we call drawBuffer, which manipulates the data in screen_buffer and sends it out via a series of calls to spiWrite:
void drawBuffer(){
/* Function that sends bytes in screen buffer to be displayed on LED array.*/
uint8_t buffer[8];
for (int k = 1; k <9; k++){
for (int m=0; m<4;m++){
buffer[0+2*m] = (uint8_t)flip_8(9-k);
uint8_t temp = (uint8_t)(screen_buffer[k-1]>>(8*m));
buffer[1+2*m] = temp & 0x000000FF;
}
spiWrite(buffer,8,CLK,MOSI,CS); // Let's take a look at what spiWrite is doing...
}
}
If you take a look at spiWrite, you'll notice that it's a sequence of calls to pinWrite, timed according to a certain specification so that it's understood by the LED display driver chip. Now, the pinWrite being used is your assembly implementation from earlier in the lab rather than your earlier C implementation.
void spiWrite(uint8_t* data, uint8_t len, uint8_t clk_pin, uint8_t mosi_pin, uint8_t cs_pin ){
/* Performs software-driven SPI write
Arguments:
data: pointer to array of 8-bit values to be sent serially
len: number of 8-bit values to write
clk_pin: GPIO pin used to generate clock signal
mosi_pin: GPIO pin used to transmit data serially
cs_pin: GPIO pin used for chip select
*/
pinWrite(cs_pin,0); // bring chip select signal low
// Send data in buffer in 8-bit chunks
// Generate clock signal and send 1 bit/clock period
for (int q=0; q<len; q++){
for (int p=0; p<8; p++){
pinWrite(clk_pin,0);
pinWrite(mosi_pin,((data[q]>>p)&0x01));
spiPause();
pinWrite(clk_pin,1);
spiPause();
}
}
pinWrite(cs_pin,1); // bring chip-select signal high
}
All this to say, your assembly code is now an integral component of the software driving your LED display! Pretty neat.
7) Code Submission
Submit your gpio_asm.S file in a zip file named None_lab4.zip.
To create the zip file, you can select the folders/files you want, right click, and look for the option to compress these files.
Alternatively, you can run one of the following commands in the terminal:
If you're on MacOS or Linux or Command Prompt:
zip None_lab4.zip src/gpio_asm.S
or for Windows Powershell users,
Compress-Archive -Path src/gpio_asm.S -DestinationPath None_lab4.zip
gpio_asm.S file here in a zip file. Name the file None_lab4.zip for this assignment.
8) Checkoff
Demonstrate your LED display working as expected (shown in the video above).
You can now remove the LEDs and the wire connecting the ESP32 to BTNC.