Project 2 Controlling LED Brightness with PWM

Subroutines and the Stack

5178

Introduction

In this project, you will control LED brightness using a PWM signal. A PWM circuit creates a continuous sequence of pulses, with each pulse staring at a regular interval, and then remaining asserted for a programmable length of time. The length (or width) of the pulse in each interval can be changed, and in any given “pulse window”, the ratio of signal high time to signal low time (or “duty cycle”) establishes the information carried by the signal.


Figure 1. PWM signals and definitions
Figure 1. PWM signals and definitions

PWM signals are useful in many settings: they can be filtered with a simple passive filter (one R and one C) to create an analog voltage; they can directly drive an LED, and the perceived brightness of the LED will be proportional to the pulse high time; they can switch motor-control power FETs on and off to set DC motor speed; and there are many other uses as well. Microprocessors typically use PWM signals to create variable voltage signals that control motor speed, set LED brightness, or define reference voltages for other analog circuits.


Figure 2. PWM signals, filtering, and applications
Figure 2. PWM signals, filtering, and applications

In this project, you will control LED brightness using a software or “bit banged” PWM signal using an assembly language program. After that, you will do the same thing using a hardware IP block instead. In order to make the assembly code more readable, more portable, and more understandable, assembly language subroutines will be introduced and used.

Before you begin, you should:

  • Have the Blackboard and Vitis tools ready;
  • Know how to create an Assembly program in Vitis;
  • Be familiar with programming the Blackboard and accessing external GPIO devices like LEDs;

After you’re done, you should:

  • Understand how Pulse Width Modulators work;
  • Know how to write and call subroutines;
  • Understand the use of a stack in procedure calls;
  • Be more comfortable writing assembly programs.

Background

PWM circuits are often used as simple, inexpensive DACs (Digital to Analog Converters) to create low-to-medium frequency analog voltage signals from a single digital signal. The switching digital waveform can be filtered with a low-pass filter to remove the higher frequency waveform components that originate from the pulse frequency “carrier” signal. Note the “information” in the PWM pulse train is carried by the pulse widths, and not the pulse frequency. The higher frequency “pulse frequency” is just a carrier to communicate the pulse widths, and it is intended to be filtered out. In the case of driving an LED, the higher-frequency information is filtered out by the human eye, and not by an electronic circuit (note the human eye “bioware” can only respond to stimuli up to about 50Hz).

When creating an analog voltage output from a PWM signal, the pulse frequency should be at least 10 times higher than the output filter 3dB frequency (and preferably 100 times higher) to minimize switching noise in the output waveform. As examples, to create an analog voltage that could drive a speaker with up to 10KHz of bandwidth, a pulse frequency of at least 100Kz is required, and 500KHz would be even better. To create a bias voltage on an analog amplifier, a 1KHz pulse frequency could be used together with a 10Hz low-pass filter.

LEDs can be used as simple “on/off” devices to indicate the presence of high or low voltage, or they can be driven to different brightness levels. There are two ways to change LED brightness: the current through the LED (or the voltage across the LED circuit) can be changed using an electronic controller circuit; or the LED can be turned off and on at a frequency higher than the human eye can perceive – in this case, the perceived brightness will be proportional to the ratio of “on” versus “off” time. Switching an LED on and off is far simpler and less expensive than an electronic circuit, so that method is most often used.

A control signal used to rapidly switch an LED on and off must change fast enough so that the human eye doesn’t perceive the individual on or off states, but instead sees the integrated effect – that is, a dimmer LED will be seen when the signal is low more than it is high, and a brighter LED will be perceived when the signal is high more than it is low. In practice, human eye biosystems can’t respond fast enough see changes that occur above about 60Hz, so an LED switched at 60Hz or above will be perceived at a given brightness level, and the fact it’s actually being turned on and off will not be apparent. A control signal changing at a lower frequency, say 30Hz, would be perceived as a rapidly flickering LED. A programmable PWM signal is the most common signal type used to switch and LED on and off.

Subroutines

When writing in any language, and particularly in assembly language, code should be partitioned into smaller segments that perform a particular task. Assembly instructions are less abstract than instructions in higher level languages, so it is more difficult to look at a block of assembly code and understand what’s happening. It’s good practice to partition assembly code into smaller subroutines that are well commented, logically partitioned, and limited in scope.

Most subroutines receive input data (or “parameters”) and/or produce output parameters, and the ARM subroutine calling conventions designate four registers (R0-R3) for this purpose. If a subroutine needs to exchange more than four data words, additional memory is required. Further, additional memory is also needed if a subroutine needs more memory than registers alone can provide. And further still, if a subroutine uses any non-parameter-passing registers (that is, any other register than R0_R3) it must first save the register contents in local memory when the subroutine starts, and then restore the registers when the subroutine ends (this is known as “preserving context”).

All of these needs can be dealt with using a memory structure called a stack. The stack is just a section of ordinary main memory that has been set aside, and organized as a “last-in, first-out” storage area. Data is written into the stack using a “push” operation, and read from the stack using a “pop” operation. A “stack pointer” register, which is typically just one of the general purpose registers, holds the current stack-memory address. During a push operation, the stack pointer is updated to point at the next available memory location, and then data is written there. During a pop operation, data is returned from the address held in the stack pointer, and the stack pointer is modified to “deallocate” the memory that was most recently popped. In this project, you will use dynamic memory on the stack in conjunction with subroutines. The program flow control and dynamic memory topic documents provide more detailed information about stacks and their use.

Requirements

1.Control an LED using Assembly subroutines

Partitioning code into smaller subroutines results in more readable code, and further, results in code that is more likely to be resused in later projects. Subroutines are often written to perform simple tasks, like operating on peripherals devices (e.g., LEDs).

Write subroutines (functions) to perform the following tasks to control LED1 on the Blackboard. Create the subroutines in your main source file, and use the given code labels.

  • led0_on: Turns the LED on (does not affect other LED bits)
  • led0_off: Turns the LED off (does not affect other LEDs)
  • led0_toggle: Toggles the LED’s state (change only the first LED, you may find the EOR instruction helpful)

Make sure each of your subroutines adhere to the ARM calling convention (that is, registers r0-r3 can be modified and/or used for parameter passing, but the all other regsiters must be preserved). Write each function with no assumptions of register contents; If it’s necessary to access a memory location, make loading the address part of the function.

The function led0_on is provided as an example:

.equ LED_CTL, 0x41210000

led0_on:
	ldr r1, =LED_DATA
	ldr r0, [r1]	@get current value
	orr r0,r0,#1	@set the first bit (don't affect other bits)
	str r0, [r1]	@write back to LED_DATA
	bx lr		@return from subroutine

You can call this function from main by using bl led0_on

After you write your subroutines, write a main program that uses the functions to perform this sequence of actions:

  1. Turn on the LED;
  2. Turn off the LED;
  3. Toggle the LED;
  4. Toggle the LED (again).

When you run your program, use the debugger to check that all the instructions are executing correctly, and to confirm you understand exactly what’s happening at each step. The topic document provides some background and guidance on using the debugger. After starting the debugger, step though your program using the available step functions, and verify that the registers have the correct content and that your program executes correctly.

2. Pass parameters to/from subroutines

Pass a parameter to a function

It is often necessary to pass values (parameters) into functions. The ARM calling convention defines R0-R3 as parameter passing registers. These registers are used to move data in and out of subroutines, and the calling program should assume their contents may have changed after returning from a subroutine. The remaining registers are expected to remain unchanged – if they are used in the subroutine, their contents should be saved on subroutine entry and restored on subroutine exit.

Write a software delay function, and pass a delay value into the function using R0. The example below shows 0x30000000 being passed as the delay value, and that number is simply decremented to zero in a tight loop. On the Blackboard, the CPU clock runs at 667MHz – can you determine how much time will pass in the delay subroutine with the R0 value shown?

Test your delay function by using it in conjunction with your led_toggle function you wrote in requirement 1.

@blinks the led!
main:
	blinky_loop:
		bl led0_toggle
		movw r0,#0 @load lower bits of r0 with 0
		movt r0,#0x3000 @load top 16 bits with 0x3000
		@ Note: the above two instructions can be replaced with the psuedoinstruction
		@ ldr r0,=0x30000000. The assembler will replace the psuedoinstruction with the
		@ two mov instructions.
		bl delay 
	b blinky_loop

delay:
	subs r0,r0,#1
	bne delay
	bx lr

Play around with the value loaded into the upper 16 bits of register zero before ‘soft_delay’ is called, and note how the parameter value affects the behavior of the program.

Return a value from a function

In ARM calling convention, R0-R3 can also return values from functions. Write a subroutine that reads the value of the switches on the blackboard and returns that value in R0.

Modify the blinky program to control the blink speed with the switch values. Note the delay value used above was a 32-bit number with bits 28 and 29 set to a ‘1’, and all other bits set to ‘0’. Since you have fewer than 32 switches to work with, you will need to scale the number read from the switches. You could use a multiply operation to scale up the value read from the switches, but there are other ways to get a larger number from the switches. Can you find a way to select a range of reasonable delay times from the switches without using a multiply?

3. Control LED brightness using software PWM

Use your previous functions to create a software PWM function. Your new function should input two parameters - one parameter (passed in R0) should specify the total number cycles for each PWM window, and the other (passed in R1) should specify the number of cycles the PWM output should be driven high within that window. Have the function execute/create a single PWM window, with the LED on for the designated number of delay cycles and off for the remainder of cycles. Call your led_on and led_off functions from within this new function.

The ARM calling conventions allow subroutines to modify R0-R3, but not R4-R11 (and so any of the R4-R11 registers that are used should be pushed/stored on the stack on entry, and popped/restored on exit). Since the PWM subroutine will call other subroutines, you must move the PWM input parameters from R0 and R1 into other registers so they will not be overwritten and lost. Additionally, since the PWM function was called from your main program, the original values of R4-R11 must be restored when the PWM subroutine returns. Be sure to preserve context on the stack where needed (that is, store all used R4-R11 registers on the stack). Also, note that the link register can only store a single return address, and so its contents must also be preserved on the stack (to get full credit make sure you understand why!).

After you create your PWM function, you can call it repeatedly (using and endless loop) from your main program. Try different brightness levels by changing the PWM duty cycle (that is, change the amount of time the PWM signal is high). If you want to impress the TA, use the switches to set the brightness level at run time.

4. Control RGB LED brightness/color using hardware PWM

The Blackboard contains two RGB LEDs packages. Each RGB LED package includes three separate LEDs (Red, Blue, and Green) that are located so close to one another that they appear to be a single LED. By turning the individual LEDs on and off, seven colors can be created. By controlling the brightness of the individual LEDs, an almost infinite number of colors can be created. In this requirement, you will use three PWM signals to control the brightness of the three LEDs, and so create an array of different colors.

The BlackBoard’s Standard Configuration File includes a 6-channel PWM controller. Each RGB LED requires three PWM signals, so the 6-channel module can control two RGB LEDs. Each of the six PWM channels has 3 registers: one to enable the channel’s output; one to set the width of the PWM window; and one to set the duty cycle. The operation of the PWM module is covered in detail in the Blackboard’s user manual pages - refer there for more information.

First, write a function for each of the following:

  • Enable a PWM Channel
  • Disable a PWM Channel
  • Set the PWM window width
  • Set the PWM duty value

Note that you are using a hardware PWM circuit for this requirement - you only need to set the PWM window width and duty cycle values, and then the hardware circuit will create an endless PWM signal according to those parameters (in the previous requirement, you created a single PWM signal in software by executing an endless loop).

Each function should receive an input parameter to select the PWM channel that the function operates on (0-5). Further, the functions for setting the overall window width and duty cycle value also need input parameters (recall duty cycle means the amount of time the pulse should be high). To help prevent error conditions, each function should check to make sure the number of the channel is in the range 0 - 5 (if a channel number outside this range is specified, the function can just return. (Hint: Instead of defining constants for every single PWM register, you can define the addresses of channel zero’s PWM registers and calculate offsets for the other channels in software. Before writing code, it may help to find the relationship between a channel’s number and the addresses of its registers.)

Now, write a program that uses your functions to select which channel is enabled and set its brightness (through PWM). Use the three leftmost switches to select which channel is enabled and the bottom 8 switches to set the brightness of that channel. When a channel is not selected, it should be disabled (only one channel should ever be lit). When the 8 brightness switches are all ‘on’, the LED channel should be at full intensity. Similarly, when all of the switches are ‘off’, the channel should be off. Since the PWM duty register is 32-bits wide, you will either need to scale the 8-bit switch value, or modify the PWM window width. Your program should be well structured - write extra functions when necessary and make sure your code is well documented.

Challenges

1. Control Frequency of Each RGB LED Separately

Write a program that uses your subroutines to automatically ramp each of the RGB LEDs through different brightness levels. You can use a linear ramp function to change LED brightness from zero to maximum over some period of time (maybe 3-4 seconds), and then reset the brightness level back to zero after the maximum value has been reached. Use different ramp times so each LED’s brightness changes at a different rate. You should see the RGB LED color changing as the ramp signals progress.