Making the LED Blink (PI5)

Aus C und Assembler mit Raspberry

With the previously created Makefile and linker script, we have established a foundation to develop a meaningful program. Our goal is to make the built-in LED of the Raspberry Pi blink. I will explain each step and why it is implemented in this project.

Directory Preparation

First, create a new directory, for example, LED, and place the Makefile and the linker.ld file in it. Also, create the "include" directory within LED to organize our header files.

Programming the Raspberry Pi's Boot Behavior

To do this, we create the file "boot.S":

//
// boot.S
//

#include “config.h”

.section .init  // Ensure the linker places this at the beginning of the kernel image
.global _start  // Execution starts here

_start:
	ldr	x0, =MEM_KERNEL_STACK
	mov	sp, x0			// init its stack	
	b sysinit

Explanation:

  • #include "config.h": Includes the configuration file where we define necessary parameters.
  • .section .init: Defines a section that will be placed at the beginning of the kernel image. According to the linker script, this is the first memory location starting from address 0x80000. This is also the address that the Raspberry Pi jumps to upon initial boot.
  • .global _start: Declares _start as global so it can be used by all parts of the program.
  • _start:: Label marking the starting point of execution.
  • ldr x0, =MEM_KERNEL_STACK: Loads the address of MEM_KERNEL_STACK into register x0, as defined in "config.h".
  • mov sp, x0: Initializes the stack pointer "SP".
  • b sysinit: Branches to the "sysinit" function, which will be implemented later.

When the program starts, it first loads the register "x0" with the value from "MEM_KERNEL_STACK", defined in the include file, and then sets this value to the stack pointer (SP). Using the "b" (branch) instruction, the code jumps to the "sysinit" label.

Configuration File config.h, a Header File

Importance and Structure of Header Files

Our "config.h" is a header file. Generally, header files are used in programming to organize and reuse code. Here are the main reasons:

  • Organization: Header files help divide code into smaller, manageable parts. Functions, variables, and constants used in multiple files can be declared in a header file.
  • Reusability: Code written once in a header file can be used in multiple programs or files without rewriting it every time.
  • Simplification: Using header files makes code more readable and easier to understand. Instead of having all functions in one file, they can be split into smaller, logical parts.
  • Avoiding Redundancy: If a function prototype or constant changes, the modification needs to be made only in the header file, instead of in every file that uses it.

In summary: Header files make code more organized, reusable, and easier to maintain.

Our "config.h" is now described as follows:

//
// config.h
//

#ifndef _config_h
#define _config_h

.equ MEGABYTE, 0x100000

.equ MEM_KERNEL_START, 0x80000 // Start address of the main program
.equ KERNEL_MAX_SIZE, (2 * MEGABYTE)
.equ MEM_KERNEL_END, (MEM_KERNEL_START + KERNEL_MAX_SIZE)
.equ KERNEL_STACK_SIZE, 0x20000
.equ MEM_KERNEL_STACK, (MEM_KERNEL_END + KERNEL_STACK_SIZE)

#endif

Explanation:

  • #ifndef _config_h ... #endif: Prevents the file from being included multiple times.
  • .equ: Assigns constant values to symbols.
  • MEM_KERNEL_STACK: Calculates the start of the kernel stack based on the kernel's start address and its maximum size.

In this header, a condition checks if the variable _config_h has already been defined. Specifically, if it has not yet been defined. #ifndef means "if not defined". If the variable is not defined, everything up to #endif is processed. If the variable is already defined, everything up to #endif is skipped.

Immediately after the #ifndef directive, the variable is defined with #define _config_h, making it known to the compiler.

Why is this checked in a header? The compiler does not like things being defined multiple times. If this header file has already been called elsewhere, the compiler skips these definitions, preventing multiple definitions.

Function sysinit

Next, we create the function sysinit, which initializes the system:

//
// sysinit.S
//

.section .text
.globl sysinit
sysinit:
    b main

Explanation:

  • .section .text: Defines a section for executable code.
  • .globl sysinit: Declares sysinit as global.
  • b main: Branches to the main function.

Here, the "sysinit" label is first defined as global, making it accessible outside the file. In the previous code, it is referenced. Unlike the previous code, the .text section is used here. This section defines executable code located "somewhere" in memory. The exact position is determined by the previous code and later established by the linker. The only command here is b main, which means that the program branches to the "main" label. We will add more code here later.

Main Program main

Now, let's create the main program:

//
// kernel.S
//

.section .text
.globl main
main:
    bl LED_off
    mov w0, #0x3F0000
    bl wait
    bl LED_on
    mov w0, #0x3F0000
    bl wait
    b main

Explanation:

  • .section .text: Defines the executable code section.
  • .globl main: Declares main as global.
  • bl LED_off: Turns off the LED.
  • mov w0, #0x3F0000: Sets the wait time value.
  • bl wait: Calls the wait function.
  • bl LED_on: Turns on the LED.
  • b main: Repeats the loop.

We also place this part of the program in the .text section. We make the program globally known and define the label "main". Thus, the sysinit function has the entry point.

The first instruction, bl (branch with link), means branching to the LED_off function and storing the return address in the register x30. The LED_off function, which we will write later, turns off the built-in green LED of the Raspberry Pi. If the LED_off function works correctly, the next instruction is executed after it.

Next, the wait function is called. This function expects a value in register w0, indicating how long to wait before executing the next instruction.

Passing Parameters to Functions

Many functions require parameters to be used within the function. To do this consistently and without confusion, a standard was established. Typically, data is placed in the initial registers. For example, the first parameter is passed in w0/x0, the second in w1/x1, and so on. Since the number of registers is limited, they should be used sparingly. Generally, no more than four parameters should be passed via registers. If more parameters are needed, the data is usually passed through the stack or as a structure (then as a pointer).

However, we will not strictly adhere to this rule and will use registers w0/x0 to w7/x7 as parameters.

Back to Our Program

After wait is called, the LED_on function is called, which turns the green LED back on. Then, we wait again and repeat the entire program indefinitely with b main.

LED Control Functions

We create the file led.S for LED control:

//
// led.S
//

#include "base.h"

// void LED_off(void) // Turns off the LED
.section .text
.globl LED_off
LED_off:
    ldr x1, =ARM_GPIO2_DATA0
    ldr w0, [x1]
    bic w0, w0, #0x200
    str w0, [x1]
    ret

// void LED_on(void) // Turns on the LED
.section .text
.globl LED_on
LED_on:
    ldr x1, =ARM_GPIO2_DATA0
    ldr w0, [x1]
    orr w0, w0, #0x200
    str w0, [x1]
    ret

Explanation:

  • ldr x1, =ARM_GPIO2_DATA0: Loads the LED address.
  • bic w0, w0, #0x200: Clears the bit to turn off the LED.
  • orr w0, w0, #0x200: Sets the bit to turn on the LED.

The first command in this source code is the #include "base.h". The base.h header file contains several parameters, such as the address of the LED we want to control.

The following steps are performed in this code:

  1. ldr x1, =ARM_GPIO2_DATA0: Loads the address of ARM_GPIO2_DATA0 into register x1.
  2. ldr w0, [x1]: Loads the current value of ARM_GPIO2_DATA0 into register w0.
  3. bic w0, w0, #0x200: Clears the bit that controls the LED (turns the LED off).
  4. str w0, [x1]: Stores the new value back into ARM_GPIO2_DATA0.
  5. ret: Ends the function and returns to the caller.

In the Raspberry Pi, many devices (peripherals) can be accessed and manipulated directly through specific addresses. The green LED of the Raspberry Pi 5 is accessible via the GPIO2 register, specifically the ARM_GPIO2_DATA0 register. The LED occupies bit 10 of this register. Since the other bits in this register have different functions, we first need to load the entire content of the register. With bic, we can clear specific bits (here bit 10) while keeping the other bits unchanged. Then, the value is written back to the register, and the LED is turned off.

In the LED_on function, the same process is carried out, but we manipulate the value in the opposite direction:

orr w0, w0, #0x200: Sets bit 10 to 1, turning the LED back on.

As shown here, I use a kind of C-code comment above the function, describing what the function expects and what it returns. Additionally, I briefly explain what the function actually does:

// void LED_off(void) // Turns off the LED

In this case, void indicates that nothing is passed and nothing is returned.

Base configuration file base.h

The file base.h contains base addresses for the Raspberry Pi:

//
// base.h
//

#ifndef _base_h
#define _base_h

.equ RPI_BASE, 0x107C000000UL

// GPIO
.equ ARM_GPIO2_BASE, RPI_BASE + 0x1517C00
.equ ARM_GPIO2_DATA0, ARM_GPIO2_BASE + 0x04

#endif

Explanation:

  • RPI_BASE: Base address of the peripherals.
  • ARM_GPIO2_BASE: Base address of the GPIO2 register.
  • ARM_GPIO2_DATA0: Address of the data register that controls the LED.

Note: This header will be continuously expanded throughout our course. Currently, we only need the data for our LED.

With RPI_BASE = 0x107C000000UL, we set the base address of the peripherals. This serves as the foundation for all other devices we use.

This file will later be divided into sections for each individual device. For now, we are only using GPIO.

With ARM_GPIO2_BASE = RPI_BASE + 0x1517C00, we set the base address of GPIO2. The data offset is 0x04, so in total, 0x107C000000 + 0x1517C00 + 0x04 equals 0x107D517C04. This is the actual memory address in the Raspberry Pi 5 for the register that controls the LED.

Wait Function wait

Finally, we create the file time.S for the wait function:

//
// time.S
//

// void wait(int loop -> w0)
.section .text
.globl wait
wait:
    str x30, [sp, -16]!
    mov w1, 0
1:
    cmp w1, w0
    bgt 2f
    add w1, w1, 1
    b 1b
2:
    ldr x30, [sp], 16
    ret

Explanation:

  • str x30, [sp, -16]!: Stores the return address on the stack.
  • mov w1, 0: Initializes the counter register.
  • cmp w1, w0: Compares the counter register with the passed value.
  • bgt 2f: Branches to label 2 if w1 is greater than w0.
  • add w1, w1, 1: Increments the counter register.
  • b 1b: Repeats the loop.
  • ldr x30, [sp], 16: Restores the return address.
  • ret: Returns to the calling function.

As described, the wait function expects a counter in register w0 indicating how long to wait. The function does not return a value.

As previously mentioned, when a function is called with bl (branch with link), the address of the next instruction is saved in x30. Since x30 can be modified within a function, especially if other functions are called, we save this value on the stack. This is done with str x30, [sp, -16]!. The str (store) instruction in this case saves the x30 register to the stack and adjusts the stack pointer (SP) down by 16 bytes, making room for the next value. At the end of the code, the value is loaded back from the stack into x30, and the stack pointer is reset. The value in x30 is needed for the next instruction because ret (return) sets the program counter to this value, allowing the program to continue running.

The function is quite simple. First, the w1 register is initialized to 0. It is then compared with w0, and as long as w1 is less, w1 is incremented by one, and the loop starts over. Only when w1 becomes greater does the loop end, x30 is restored, and control returns to the main program.

In this function, I also used local labels, simply named 1: or 2:. In certain cases, it is helpful to use such labels because you don't have to come up with cumbersome names, and the code remains more readable. These labels are defined with 1: or 2:. To jump to one of these labels, simply use the number with a suffix indicating whether the label is before (b) or after (f) the current instruction. The assembler then searches in the specified direction for the label.

Compiling and Running

Switch to the LED directory and compile the program with the command "make". If everything was successful, you will get a file named kernel_2712.img, which can be copied to an SD card and used in the Raspberry Pi. Turn on the Raspberry Pi, and the LED should blink.

You can download the source code as a ZIP file from the following link: https://www.satyria.de/arm/sources/assem/led.zip


< Back (Our First Program (PI5)) < Home > Next (Error Handling) >