LED RGB Controller

The Whole Avocado

The questions below are due on Wednesday September 11, 2024; 11:59:00 PM.
 
You are not logged in.

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.

In the previous few assignments you developed a basic counter, a device which increments its output over time. We also added a cycle-limit on it so we don't have to rely on the natural overflow of a fixed number of bits. A simple module like this is extremely versatile and can enable us to do many things.

For the final portion of this week's work, you're going to develop a LED color controller. This will be a sequential circuit that builds upon your counter module from the previous pages. It will use the sixteen bits of the slide switches on your development board to control the brightness of the red, green, and blue channels of the RGB LED also on your board. The sixteen bits of the switches will be split and used such that:

  • The five bits of sw[15:11] control the red brightness ranging from 0% to ~100% in approximately equidistant steps.
  • The six bits of sw[10:5] control the green brightness ranging from 0% to ~100% in approximately equidistant steps.
  • The five bits of sw[4:0] control the blue brightness ranging from 0% to ~100% in approximately equidistant steps.

This color configuration is known as "565" from the number of bits assigned to each color channel, and it is very common in size in resource-restricted devices. Historically since 16 bits was a common clean fraction or multiple of the word size of many architectures, it was very widely used. The choice of having the green channel get twice the resolution of red or blue comes from the fact that the human eye has more relative sensitivity to green than red or blue in trichromatic vision.

We will explore color space and its variants more in future weeks, but for now we'll just focus on Red Green Blue (RGB).

In your top_level.xdc file, there are six lines that pertain to two onboard RGB LEDs called rgb0 and rgb1. Each "LED" is actually three LEDs in one package. They are wired up in the following fashion known as "common cathode":

Common Cathode RGB

You should have previously uncommented lines in your .xdc file that correspond to rgb0 and rgb1, as shown below. In the initial version of top_level.sv, we set both of these RGB LEDs to 0, but now we'd like to control RGB0 with a module we're about to implement (keep RGB1 pulled to 0 to avoid needless visual distractions).

set_property -dict {PACKAGE_PIN C9 IOSTANDARD LVCMOS33} [get_ports {rgb0[0]}];
set_property -dict {PACKAGE_PIN A9 IOSTANDARD LVCMOS33} [get_ports {rgb0[1]}];
set_property -dict {PACKAGE_PIN A10 IOSTANDARD LVCMOS33} [get_ports {rgb0[2]}];
set_property -dict {PACKAGE_PIN A11 IOSTANDARD LVCMOS33} [get_ports {rgb1[0]}];
set_property -dict {PACKAGE_PIN C10 IOSTANDARD LVCMOS33} [get_ports {rgb1[1]}];
set_property -dict {PACKAGE_PIN B11 IOSTANDARD LVCMOS33} [get_ports {rgb1[2]}];

We shall keep rgb1 set at 0, but remove the line that is setting rgb0 to always be 0. Instead, in your top_level.sv add in an instance of a yet-to-be-defined module rgb_controller that is wired into your overall design like shown:

Module-level schematic. Crossing wires with a dot are connected, but crossing wires with no dot are not.

The module you're building will control the brightness on the three channels. But...how...could it do that? The pins r_out, g_out, and b_out are all one bit in size and can therefore either just be on or off (nothing in between). We want "shades of grey" for each color channel however. At face value this seems to necessitate the ability to create analog signals (signals that can vary from 0 to 1 in many small sub-steps), but we don't have that. It seems we're at an impasse. Maybe one day we'll figure it out. Please turn in your 6.205 board. You've reached the end of the field of digital design.

Just joking! We can fix the problem at hand by using the very high speeds of our digital devices to kinda sorta fake it.

Pulse Width Modulation

Pulse Width Modulation is way to encode an analog signal using a one bit digital value that turns on and off periodically but at different on/off ratios. The amount of "on" time relative to the full time of a single period is known as the Duty Cycle.

By varying the duty cycle you can encode different analog values. The higher the duty cycle, the higher the analog value encoded. The lower the duty cycle, the lower the analog value conveyed. This encoding scheme is particularly useful because you can run it through a regular analog low-pass filter to extract the analog value (effectively get the average of the signal). This could be an electronic circuit or it could even just be something more "organic" like human eyeballs. As humans, our vision lacks the ability to see flashing above several hundred Hz in frequency. As a result, if you flash a light on and off at a frequency above this "cutoff" at varying duty cycles using a PWM scheme, we end up seeing varying levels of brightnesses and not the flashing part. This isn't just hand-wavyness either. We won't do it here, but if you do the Fourier Transform of various PWM signals and run them through filters, the low-frequency components will vary in intensity.

PWM Signal at various Duty Cycles. Note all waveforms have the same period (15 cycles of the clock). They vary only in how long they are on and off.

Binary Concerns

We'd like to implement a PWM signal using System/Verilog. We need to think about this in terms of bits. Notice above that values can be conveniently encoded in binary. In the example, we're using four bits to describe the space of possibility.

How many different values can four bits represent?

For a given number of bits n, you'll always have 2^{n} possibilities, but we'll need to use them to express our entire range for a PWM duty cycle, fully-off to fully-on (inclusive).

What is the period of every signal above (in clock cycles?). You can ignore the 0% and 100% case since they technically don't have periods.

An implementation of a PWM module using the counter module you already developed here is shown below. The new signal dc_in is the duty cycle specification. Amazingly, a simple threshold comparison of count with dc_in can generate the patterns shown above.

module pwm(   input wire clk_in,
              input wire rst_in,
              input wire [3:0] dc_in,
              output logic sig_out);

    logic [31:0] count;
    counter mc (.clk_in(clk_in),
                .rst_in(rst_in),
                .period_in(15),
                .count_out(count));
    assign sig_out = count<dc_in; //very simple threshold check
endmodule

Very Cool.

More Bits

The module above only has four bits worth of duty cycle that it can specify. That's only sixteen brightness levels as you've already determined up above. We'd actually like to have more resolution than this. Human visual perception is usually about ~200 or so levels in the red, green, and blue channels. In order to achieve this we need our duty cycle to be specified with more than 4 bits.

If we want to be able to specify at 200-ish levels of brightness, how many bits must our duty cycle be specified with?

In adding more bits to our duty cycle, an unintended consequence is that the period of our wave will also go up because we can only make a decision on each clock cycle. Generalizing this out a bit more, if we instead used more bits to encode our PWM duty cycle, n, we'll get the ability to express more shades of grey. In order to express this increase in duty cycle possibilities, we'll need our period to become larger.

For n bits, how many clock cycles must the period be to fully express all 2^{n} possibilities? (all 2^{n} different duty cycles?)

For our specific situation with 8 bits, what will the PWM period be?

For an 8-bit PWM module running with a clock speed of 100 MHz, what will the period of our PWM signal be in nanoseconds?

The frequency of this PWM signal ends up being about 392 kHz which is still much, much higher than what can be perceived by a human eye, so the lower frequency won't matter.

Your Assignment

As your final assignment for this week, you should modify the pwm module to support 8 bit duty cycles. Then use three of the pwm modules in building the module rgb_controller shown in Figure 2. Be sure to make separate files for pwm (pwm.sv) and the rgb_controller (rgb_controller.sv)

For your development, here's a very simple cocotb testbench that you can use for verification of some waveforms of the rgb_controller, place it in your sim file and name it test_rgb_controller.py, then run the simulation. As a reminder, the waveform will file will appear as an .fst in the sim_build directory that appears. View that with any waveform viewer we suggested.

import cocotb
import os
import random
import sys
import logging
from pathlib import Path
from cocotb.triggers import Timer
from cocotb.utils import get_sim_time as gst
from cocotb.runner import get_runner

async def generate_clock(clock_wire):
	while True: # repeat forever
		clock_wire.value = 0
		await Timer(5,units="ns")
		clock_wire.value = 1
		await Timer(5,units="ns")

@cocotb.test()
async def first_test(dut):
    """First cocotb test?"""
    await cocotb.start( generate_clock( dut.clk_in ) ) #launches clock
    dut.rst_in.value = 1;
    dut.r_in.value = 100
    dut.g_in.value = 10
    dut.b_in.value = 220
    await Timer(20, "ns")
    dut.rst_in.value = 0; #rst is off...let it run
    await Timer(100000, "ns")# run for many cycles

"""the code below here should largely remain unchanged in structure, though the specific files and things
specified will get updated for different simulations.
"""
def rgb_runner():
    """Simulate the counter using the Python runner."""
    hdl_toplevel_lang = os.getenv("HDL_TOPLEVEL_LANG", "verilog")
    sim = os.getenv("SIM", "icarus")
    proj_path = Path(__file__).resolve().parent.parent
    sys.path.append(str(proj_path / "sim" / "model"))
    sources = [proj_path / "hdl" / "rgb_controller.sv"] #grow/modify this as needed.
    sources += [proj_path / "hdl" / "counter.sv"] #grow/modify this as needed.
    sources += [proj_path / "hdl" / "pwm.sv"] #grow/modify this as needed.
    build_test_args = ["-Wall"]#,"COCOTB_RESOLVE_X=ZEROS"]
    parameters = {}
    sys.path.append(str(proj_path / "sim"))
    runner = get_runner(sim)
    runner.build(
        sources=sources,
        hdl_toplevel="rgb_controller",
        always=True,
        build_args=build_test_args,
        parameters=parameters,
        timescale = ('1ns','1ps'),
        waves=True
    )
    run_test_args = []
    runner.test(
        hdl_toplevel="rgb_controller",
        test_module="test_rgb_controller",
        test_args=run_test_args,
        waves=True
    )

if __name__ == "__main__":
    rgb_runner()

We want 8 bits of resolution on each color channel even though we'll only use the top 5, 6, and 5 bits for red, green, and blue, respectively for now (next week we'll be using all 8 bits).

pwm waveform

PWM Waveforms from simulation above.

When your simulation is acting roughly the same, integrate the module into your project.

Note that we need a clock for our sequential logic. We will be using the 100 MHz clock integrated in the Urbana board. Uncomment both lines pertaining to clk_100mhz in the xdc file, and add an input wire to the top_level.sv with the same name. Now we can pass the 100 MHz clock into all our modules!

Now, build and celebrate your design with different colors. You should have something that looks similar to the video below:

Checkoff 2:
For checkoff 2, show some waveforms of a working PWM module at a few different duty cycles. Then show your RGB controller working based on the 16 switches. Be prepared to show the staff member how you wired up your entire design and explain any major decisions you made.