Ever wanted to work with an ambient light sensor and have it work on an FPGA? Perhaps not… but it’s a great way to get comfortable designing dedicated hardware for serial communication.

More specifically, we’ll be using Digilent’s Pmod ALS, an integrated breakout board featuring the Vishay Semiconductor’s TEMT6000X01. You don’t really need to know too much about the ambient light sensor itself to work with it: all you need to know is that it sees ambient light and outputs an analog value as a voltage.

What is important to know is the Texas Instruments ADC081S021 analog-to-digital converter, which converts the analog value that the ambient light sensor outputs into digital values we can read using the FPGA. Let’s go over the ADC briefly:

The ADC081S021 is a successive approximation ADC. Basically, it converts analog to digital by successively adding and comparing the analog input with discrete known analog values in digital. It uses a binary search, meaning that it starts at the MSB, toggles that bit high, and compares the input voltage with a known reference voltage (since we start with 8’b10000000 or 128 in binary, the reference voltage would be one-half of the max reference voltage). If it’s higher, then it’ll keep searching at the higher half of the 8-bit digital value and search the lower half otherwise. Anyways, all you really need to know is that it takes some cycles to properly sample the analog value and then output the digital value in serial.

Once the ADC takes some time (in clock cycles) to sample the input, it then holds the input value converted to digital to be sent as serial output, which will take additional clock cycles. Since it takes multiple clock cycles to sample and hold the value, this means that the sample rate will be lower than the clock frequency.

Now that we know a bit about how the ADC works, let’s dig into the datasheet for specifics. If you want to implement the ADC interface yourself, you really should read it. I’ve highlighted a number of characteristics that I found important.

From the block diagram and the top pinout view, we can see that the ADC uses a protocol akin to SPI for communication: it accepts an external clock signal from the master (our FPGA) at SCLK, and outputs the value in SDATA once the active-low CS pin is held low.

The serial timing diagram shows the successive approximation in action! It continuously tracks the analog input until CS is held low, in which case it switches to hold mode to output the digital value serially to our FPGA. We can also see that after sending all the bits to the FPGA at the 13th cycle and switching back to hold mode, we still need to hold CS low for 3 more cycles while it’s back on track mode.

Another key note is that the ADC triggers at the falling edge of SCLK! Consequently, CS must be held low at the first SCLK falling edge, allowing the bits to be sent over SDATA at each subsequent falling edge.

Thus, each sample takes 16 clock cycles. The additional 4 clock cycles are the clock cycles needed for CS to be held up back high to satisfy the t_CS (minimum CS pulse width) timing requirement at the maximum operating clock frequency (20 MHz). According to the datasheet, the minimum CS pulse width is 10 ns. If we were to use a much slower clock speed, say 1 MHz, which has a period of 1 us, we’d more than satisfy the minimum timing requirement.

Now that we understand how the ADC operates, we have all we need to add an adapter to our FPGA so that it can read values from our ambient light sensor!

Here is my implementation for an ADC adapter. The adapter interfaces with the ADC at an SCLK frequency of 1 MHz. The ready-valid interface is employed to notify the downstream that a sample has been successfully transmitted and is prepared for reading.

/* Adapter for ADC081S021
* https://www.ti.com/lit/ds/symlink/adc081s021.pdf
*/

module adc_adapter
# (
// This adapter was designed for for 1MHz ADC clk frequency a 27MHz input clock. Weird timing violations may start happening if you modify these parameters. For instance, increasing ADC frequency, such as violating t_{cs} (cs pulse width)
parameter CLK_FREQ_MHZ = 27,
parameter ADC_CLK_FREQ_HZ = 1_000_000
)
(
input clk_i,
input rst_i,

/** ADC pins */
output logic sclk_o,
output logic cs_o,
input logic sdo_i,

output logic [7:0] data_o, // concatenated output of adc; extra zero padding is omitted

/** Ready-Valid interface */
output logic valid_o, // signals high when data_o is valid for one clk_i cycle
input ready_i, // X
input valid_i, // X
output logic ready_o // Always 1
);
localparam adc_cycles_per_sample_lp = 16;

logic is_sampling_l;
// The MSB represents when we should stop sampling, so one extra cycle is used to pulse CS high again. At 1 MHz, 1 cycle period is sufficient to meet t_quiet (50ns) timing requirement.
logic [$clog2(adc_cycles_per_sample_lp):0] adc_cycl_ctr_r;
// 3 leading zeros + 8 data (big endian) + 4 trailing zeros
logic [14:0] data_r;
logic has_signaled_valid_r;

assign ready_o = 1'b1;

slow_clk_gen #(.fast_clk_mhz(CLK_FREQ_MHZ), .slow_clk_hz(ADC_CLK_FREQ_HZ))
slow_clk_inst
(.clk(clk_i),
.rst(rst_i),
.slow_clk(sclk_o));

assign is_sampling_l = !adc_cycl_ctr_r[$clog2(adc_cycles_per_sample_lp)];
assign cs_o = adc_cycl_ctr_r[$clog2(adc_cycles_per_sample_lp)];
assign data_o = data_r[11:4];

// data is sampled at negative edge, and we reading at positive edge
always_ff @(negedge sclk_o or posedge rst_i) begin
    if (rst_i) begin
        adc_cycl_ctr_r <= {1'b1, {$clog2(adc_cycles_per_sample_lp){'0}}};
    end else begin
        if (adc_cycl_ctr_r[$clog2(adc_cycles_per_sample_lp)]) begin
            adc_cycl_ctr_r <= '0;
        end else begin
            adc_cycl_ctr_r <= adc_cycl_ctr_r + 1;
        end
    end
end

always_ff @(posedge sclk_o or posedge rst_i) begin
    if (rst_i) begin
        data_r <= '0;
    end
    else begin
        data_r <= data_r;
        if (is_sampling_l) begin
            data_r[0] <= sdo_i;
            data_r[14:1] <= data_r[13:0];
        end
    end
end

// output is signaled valid on one clk_i cycle only on full sample read
always_ff @(posedge clk_i or posedge rst_i) begin
    if (rst_i) begin
        valid_o <= '0;
        has_signaled_valid_r <= '0;
    end else begin
        if (adc_cycl_ctr_r[$clog2(adc_cycles_per_sample_lp)] && !has_signaled_valid_r) begin
            valid_o <= 1'b1;
            has_signaled_valid_r <= 1'b1;
        end else begin
            valid_o <= '0;
            has_signaled_valid_r <= has_signaled_valid_r;
            if (!adc_cycl_ctr_r[$clog2(adc_cycles_per_sample_lp)]) begin
                has_signaled_valid_r <= 1'b0;
            end
        end
    end
end
endmodule

With the adapter, you can now use it in your top module to hook it up to a display of your choice. For instance, I utilized a 7-segment display, which shows the 8-bit ambient light sensor values in hex. The FPGA used here is a Tang Nano 9k board. Here it is in action:

One more thing before I wrap up: when I directly wired the output of the adapter to my 7-segment driver, I noticed that there is a ton of variance in the displayed output. This could be due to various factors, including the ambient light sensor being noisy, the light sensor being sensitive to light from a wide angle, and other factors that I did not consider. The issue appears to be exacerbated by a high sample rate: at 1 MHz SCLK, the sample rate is 62.5 ksps. To alleviate this issue:

  • The sample rate was reduced on the input side of the 7-segment. You probably shouldn’t reduce SCLK to below 1 MHz to achieve this; I instead downsampled using a delay counter.
  • I also averaged out the outputs using 1D convolution with a 1×3 averaging kernel. There’s certainly a high potential for experimentation, such as further increasing the kernel width or even average pooling the convolved output.

With that, that’s all you need to interface with the Pmod ALS on an FPGA. The full top module and all the modules used to drive the 7-segment board can be found here. That’s it folks!

4 thoughts on “Interfacing with Digilent PmodALS on an FPGA

  1. A note about using slow_clk_gen and a derived clock sclk_o: In ASIC companies we generally do not like to create additional clocks unless we have to. In this particular case I would prefer to generate an enable signal instead of a new clock.

    I.e. instead of


    slow_clk_gen ... (orig_clock, my_clock);

    always_ff @ (posedge my_clock)
    if (reset)
    ...
    else
    ...

    I would prefer


    strobe_gen ... (orig_clock, enable);

    always_ff @ (posedge orig_clock)
    if (reset)
    ...
    else if (enable)
    ...

  2. Another comment: in order to avoid a Lint error, I would put explicit type conversion in a situation like:


    adc_cycl_ctr_r <= adc_cycl_ctr_r + 1;

    Here is what I would do:


    localparam adc_cycles_per_sample_lp = 16,
    adc_cycl_ctr_width = $clog2 (adc_cycles_per_sample_lp);
    ...
    adc_cycl_ctr_r <= adc_cycl_ctr_r + adc_cycl_ctr_width' (1);

    You can also simply add 1'd1:


    adc_cycl_ctr_r <= adc_cycl_ctr_r + 1'd1;

    The problem with "+ 1" is: Verilog tools treat "1" without a size as a 32-bit signed integer. This is not stated in the IEEE standard, but this is a convention since the 1980s, and tools generally follow it. So Lint can issue a warning like "truncating 32-bit value".

    When you add "1'd1", Verilog checks the right-hand side (adc_cycl_ctr_r, the expression/assignment context) and the size of the left operand (also adc_cycl_ctr_r) and converts to a larger bit width implicitly. I usually to it this way, but some finicky tools may still want " + adc_cycl_ctr_width' (1)".

    1. The problem with “+ 1” is: Verilog tools treat “1” without a size as a 32-bit signed integer. This is not stated in the IEEE standard, but this is a convention since the 1980s, and tools generally follow it.

      It is actually stated in IEEE, you can find it in 1800-2023 section 5.7.1 on page 77.

      From that section:

      Simple decimal numbers without the size and the base format shall be treated as signed integers

      The number of bits that make up an unsized number (which is a simple decimal number or a number with a base specifier but no size specification) shall be at least 32.

      What some tools (including at least 1 in big 3) don’t do, is that the constant number should be at least 32 bits, but if 32 bits is not enough to represent the value, the width should be expanded beyond 32 bits (it is in the same section 5.7.1).

      Another issue could happen from the signedness. For example:

      `initial $display(3′(7));`

      3 bits is enough to represent 7, but when 7 is truncated to 3 bits, we get 3’b111, and because of the sign we actually get -1 instead of 7.

  3. Thanks for posting! I had some thoughts on your last note about the sample rate.

    If you want to keep sampling at the higher rate and still display a stable value on the 7 seg, there are a couple of straightforward options:

    1. The simplest approach is to ignore the noisy LSBs and only use the upper N bits of the sample (in this case, determined empirically through testing). This is similar to an AGC in that it uses bit shifting to control dynamic range, but unlike an AGC, this is a fixed shift with no feedback based on signal level.

    2. Another approach is to increase the number of filter taps in your moving average (beyond the current 3-taps). Something like what is described here may be useful: https://zipcpu.com/dsp/2017/10/16/boxcar.html. Also, if you can get away with it, choose like 2,4,8,16… taps. It makes the divide easy.

    Both approaches reduce noise while maintaining the original sample rate.

Comments are closed.