TCD1703C Linear CCD - Driving and reading
In the past years, I have published several projects that involve the infamous TCD1304 linear CCD. I made firmware for it for both the STM32F401CCU6 and the STM32F103C8T6 microcontrollers, and I also made a very fancy PC software that can receive and process the data coming from the microcontroller. I developed several breakout boards for it as well.
Now, it is time to step up the game and do the same on a better linear CCD which is the TCD1703. In this project, I will use the STM32F411CE6 microcontroller because it has all the features I need and it is a tiny bit faster than the F401, but the driving circuit will be different due to the principles of the CCD.
Introduction
So, this CCD is a bit better than the TCD1304. Instead of 3648 effective pixels, it has 7500, so a little more than double. Also, it has a much smaller pixel size as compared to the TCD1304. While the TCD1304 has 8 μm tall and 200 μm wide pixels, this one has 7 μm x 7 μm pixels. Probably worse for the light sensitivity due to the much smaller surface area per pixel, but better for the resolution. By experience, I can tell that the TCD1304 is super sensitive to light, so even with a significantly reduced pixel-surface area, I still expect a good enough sensitivity.
Since this CCD has 7500 7 μm tall pixels, the effective length of the sensitive area is 52.5 mm. This is much larger than the TCD1304’s 29.1 mm. It will come in handy in many applications.
Despite the many pins of the CCD, several pins receive the same signal or are connected to the same potential. For example, all the SS pins are connected to ground. Then, there are two CP pins: 3 and 20. They should be connected on the breadboard or PCB, and they should receive the CP pulses simultaneously. RS on pins 4 and 19 is the same. Then, several Ф pins should be connected together. For example, the Ф2B pins (5, 18) must be connected to Ф2,O2 (7) and Ф2,O1 (9) as well as Ф2,E1 (14) and Ф2,E2 (16). So these six pins are on the same line and they together receive the Ф2B pulses. Similarly, Ф1,O2 (6) and Ф1,O1 (10) as well as Ф1,E1 (13) and Ф1,E2 (17) should be connected. These four pins are on the same line and receive the Ф1,E pulses. So, the initial 14 pins are simplified to 4: RS, CP, Ф2x and Ф1x.
Since the STM32 uses 3.3 V signal levels and the CCD expects 5 V signals, I used a CD74HCT244E buffer to convert my signals. This is a fast enough logic buffer that can deal with the required fast signals. So, each signal from the STM32 is first connected to one of the input pins of the buffer, and then the buffer’s output is connected to the corresponding input of the linear CCD.
Since the output (OS1 and OS2) of the CCD ranges between 4-7 V, it cannot be directly read by the STM32’s ADC which can only accept voltages up to 3.3 V. My quick and dirty solution for now, so I can write this article and demonstrate the principles, is that I assembled a voltage divider and I connect its output to the corresponding ADC channel. This is absolutely not recommended by any means, but it works just good enough so I can test my code and present the functions. In the next TCD1703-related article, where I design a driver circuit and PCB for the linear CCD, I will include a proper op-amp-based signal conditioner.
Pin connections of the TCD1703 linear CCD. The color coded pins should be connected together.
Timing chart
The timing chart looks a bit intimidating at first glance, but after a little studying, it becomes manageable. Especially because the timings are quite forgiving. By experience, I can say that if we roughly follow the timing chart, we can squeeze out a reasonable signal from the CCD. The relative position of the signals matters more than their actual strict timing.
The CCD needs five driving signals, and it produces two outputs:
SH (Shift Pulse):
During the light integration phase, SH remains at a low level; when SH goes high, the charge accumulated during integration is transferred into the analogue shift register so that, under the drive of phase pulses, it can be read out.Ф1E, O (Phase Pulse 1):
This pulse serves as the driving signal for the analogue shift register. Each pulse shifts one bit of the stored charge out through the OS pin until all the bits have been transferred.Ф2E, O (Phase Pulse 2):
Because the TCD1703C is a dual-output CCD chip, it provides two-phase pulses—Ф1E, O and Ф2E, O—to drive the two channels separately. These two pulses are exactly 180° out of phase.Ф2B (Final Stage Pulse):
This pulse is identical in timing to Ф2E, O and serves as the final stage pulse.RS (Reset Pulse):
This pulse is used to clear any residual charge in the analogue shift register.CP (Clamp Pulse):
This pulse clamps the output signal to a zero level.OS1 and OS2
Signal outputs 1 and 2. The even (OS1) and odd (OS2) pixels, total 3822x2 pixels out of which 3750x2 are the real signal and the rest are dummy outputs.
What is perhaps important are the following things:
Everything must happen between two SH pulses. The first SH pulse starts the integration, and the second pulse finishes it (and starts the next). This kind of prepares things for us because then we know that between 2 SH pulses, we should have 3825 pulses of the other signals (see numbering over Ф1E, O). This already suggests to us the frequencies.
There are two simultaneous output signals (OS1 and OS2)! This needs a careful approach when it comes to sampling both of them.
The CCD expects 5 V driving signals, whereas the STM32 I use uses 3.3 V levels. I use a unidirectional level shifter to tackle this problem. Furthermore, the CCD also expects a 12 V power supply, and its output level needs an even more careful treatment because it is between 4-7 V.
Timing chart of the TCD1703 linear CCD
Timing requirements of the TCD1703 linear CCD
SH signal
As I wrote, the SH signal determines the integration time. More precisely, it is the time between two adjacent SH signals’ falling edges. According to the datasheet, between these two pulses, there are 3825 Ф1E, O pulses. Consequently, also this much Ф2E, O and Ф2B pulses but with the opposite polarity. There are one additional RS and CP pulses than Ф-pulses.
In my experimental firmware, I used TIM1/Channel 1 to produce the SH (PWM) signals. It is somewhat overkill because TIM1 is an advanced timer, and it can be used for many more things than just generating simple pulses, but initially, I wanted to use TIM1 as the master timer to trigger the rest of the clock sources, so I just stuck with it. I was able to reduce the integration time to 5 ms in my test code, which was low enough not to oversaturate the CCD in front of my computer. I first applied a PSC = 96-1 value, which reduced the 96 MHz clock to 1 MHz on TIM1. Then I set ARR = 5000-1, which creates a pulse every 5 ms (200 Hz). Finally, I set the CCR to 10-1 so the pulse became 10 us wide.
To repeat what we learned about timers: At each clock tick of the CPU, the timer counts up by 1. In the case of the F411 CPU @ 96 MHz, this is a count every 1/96 MHz, which is about 10.42 ns. Since I used a prescaler of 96, the timer only ticks at 1 MHz (CPU/PSC = 96/96 = 1). This means that TIM1 ticks every 1 microsecond. Since the ARR is set to 5000, TIM1 counts up to 5000 microseconds (5 ms) and then resets and restarts counting from zero again. The CCR is set to 10, so TIM1 keeps the output signal HIGH (CH polarity = HIGH) for 10 ticks, which is 10 us, then for the remaining 4990 us, the SH signal is low.
In addition to the timer settings, I enabled the update interrupt on this timer. Instead of chaining the timers together in a master-slave configuration to achieve the desired timing for driving the CCD, I use the interrupt of TIM1 to start the other timers and the ADC conversion. The reason why I use this approach is that this was the only way I could establish a stable enough data stream via USB while driving the CCD and continuously capturing all the data from it at a relatively high speed. Probably there are smarter ways, but this is my way.
CCD output (green) between two SH pulses (yellow). The CCD was covered with some random cables.
CP and RS signal
These signals are almost identical, so I discuss them together. They are identical in parameters, their only difference is their phase. CP starts 200 ns after RS.
So, the next timer is TIM2 (Channel 1), and this timer handles the RS signal. PSC = 0, so there is no clock division; TIM2 runs at 96 MHz. The counter period ARR = 125-1. 125 x 10.42 = 1302.5 ns or 1.3025 us. So every 1.3025 us, an RS pulse is generated. The pulse width, according to the datasheet, should be around 100 ns. Since 1 tick is 10.42 ns → 100/10.42 = 9.6. Therefore, I let the CCR be 9-1.
The CP timer is TIM3 (Channel 1), and it is configured exactly the same as TIM2. They will start with some delay between them to produce the timing required by the datasheet. Actually, the delay could be done with some external hardware as well, which would save us an extra timer. However, we have enough timers available, so I do not worry about it.
SH (yellow), RS (purple), CP (blue) and Ф (green) signals.
Ф signals
In total, there are three different Ф-signals; however, Ф2E, O, and Ф2B are identical, and Ф1E, O is just the inverted version of the previous two Ф-signals. One timer is enough, and it is TIM4. On channel 1, Ф1E, O is generated and on channel 2, Ф2E, O, and Ф2B. Their frequency is the same as that of CP and RS signals, so the ARR is set to 125-1. Their pulse width is a tiny bit wider, so CCR is set to 15-1. CCR = 15 means that the signal will be HIGH (CH Polarity = HIGH) for 15 ticks, which is about 156 ns. This is very important because, as we saw in the timing diagram, the OS1 and OS2 signals are controlled by the falling edge of Ф2B and the rising edge of RS. The width of the video signals (OS1,2) is therefore controlled by these two signals. Why this is important is that the microcontroller cannot read the ADC inputs in parallel. It has to read one channel first and then read the next one. Therefore, the OS1 and OS2 should be made as wide as possible, so that within the available time, both ADC channels can perform the reading. So, the Ф2B cannot have a too large duty cycle (too long HIGH period), otherwise it will shorten the width of the OS signals and the ADC would not have enough time to measure both channels (pixels).
Due to their opposite polarity, channel 1 is configured to have CH polarity = LOW, and channel 2 has CH polarity = HIGH. These settings ensure that they are the same signal but with the opposite polarity.
+ ADC trigger signal
This signal is also generated on TIM4 because it has (to have) the exact same frequency as the Ф-signals. It is located on channel 4 because this channel can also provide a trigger signal for the ADC. The difference is that this channel’s CH polarity is set to LOW, and the CCR value is set to 25-1. This CCR = 25-1 creates enough delay that the rising edge of the ADC trigger signal coincides with the beginning of the video signal therefore the ADC can capture the channels properly.
ADC trigger signal (blue) and one of the OS signals (green). The rising edge of the trigger starts the ADC conversion, and the “pit” on the OS signal represents a single pixel’s output signal. The width of these pits is controlled by the width of the Ф-signals, and it must be wide enough to fit 2 AD conversions!
ADC and DMA configuration
I use IN0 (PA0) and IN1 (PA1) as input channels of the ADC. The resolution is set to 12 bits. I enabled scan conversion mode, which allows the multichannel ADC reading; therefore, the ADC, within one cycle, will read both PA0 and PA1 channels. I disabled both discontinuous and continuous conversion modes as well as the “DMA continuous requests”. The EOC flag is generated at the end of single-channel conversion. When it comes to the conversion mode, the number of conversions is set to 2 since we have 2 channels. The external trigger source is Timer 4 Capture Compare 4 event, and the triggering happens on the rising edge. The two channels are ranked in ascending order, so channel 0 (PA0) has rank = 1 and channel 1 (PA1) has rank = 2. Don’t forget that the small “>” can be clicked in STM32CubeIDE next to the lines where the rank of the channel can be set!
The DMA is configured to be in circular mode. The DMA request is generated by ADC1, and DMA2 Stream 0 is selected as the stream. I set the priority to very high due to the clock speeds I used. The data width is chosen to be half word (which is 16 bits on a 32-bit chip like this STM32F3411 chip I use here).
USB communication
Under the Middleware configuration we should select “communication device class (virtual port com)” as the class for FS IP. Then, under the connectivity, USB_OTG_FS should be set to “device only”. These settings enable USB communication.
The whole process is handled in the HAL_ADC_ConvCpltCallback() function since this callback is executed when a full conversion is done, thus we have a whole CCD frame captured.
The usbSendBuffer[] has the size defined as “7644*2”, so when the memcpy is executed, from the [0]th item, I populate the the buffer with the actual pixels from the CCDDataBuffer. Then I just transfer this usbSendBuffer and set the usb_ready flag to 0 to indicate that the USB is busy. I implemented this to avoid any transfer issues. The usb_ready flag is handled in the usbd_cdc_if.c file inside the CDC_TransmitCplt_FS() function where it is set back to 1.
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (usb_ready) { uint16_t* pixels = (uint16_t*)CCDDataBuffer; memcpy(&usbSendBuffer[0], (uint8_t*)pixels, CCDSize * 2); if (CDC_Transmit_FS(usbSendBuffer, sizeof(usbSendBuffer)) == USBD_OK) { usb_ready = 0; } } }
Starting the acquisition
Starting the acquisition consists of the following steps:
ADC-DMA must be started
TIM1 (SH) should be started. This involves starting its interrupt (HAL_TIM_Base_Start_IT(&htim1); ) and PWM (HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); ). I also enable the IRQ in the code, just to be safe.
This, however, only lets the whole process run once, since we disable the DMA continuous requests. Therefore, at the end of (almost) every conversion, DMA must be restarted. I applied the following trick: I mentioned earlier that for TIM1, I enabled the interrupt. This will help me to restart the DMA when the SH pulse starts again. In the stm32f4xx_it.c file, inside the TIM1_UP_TIM10_IRQHandler() function, I added a few lines that periodically restart the DMA. I do not restart it for every SH pulse, because that would mean that I would send a full frame of data to the PC every 5 ms, which would most probably overwhelm my PC software. So, instead, I introduced a counter that is incremented when an SH pulse occurs, and only for every 20th pulse, I restart the DMA. This is still a high framerate, but it allows my PC software to handle the incoming data properly.
Sure, now we saw how to restart the DMA, but the rest of the clocks are not yet started. So, in the main.c file, I created the following code inside the TIM1’s PeriodElapsedCallback that you can see in the panel to the right.
When the SH period is over (and therefore a new one is about to start), this callback is executed. Inside it, we reset all the counters to zero, to make sure everything is aligned. Then, I introduce a custom delay of 200 ns. This delay simply utilises the DWT unit of the microcontroller and counts the number of ticks needed to reach the given delay. After the delay, I start the RS signal, then after another 100 ns delay, I start the CP signal. Finally, after another 200 ns delay, the Ф-signals are started together with the trigger signal for the ADC.
void TIM1_UP_TIM10_IRQHandler(void) { HAL_TIM_IRQHandler(&htim1); shCounter++; if (shCounter >= 40) { shCounter = 0; HAL_ADC_Stop_DMA(&hadc1); HAL_ADC_Start_DMA(&hadc1, (uint32_t*)CCDDataBuffer, CCDSize); } }
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { __HAL_TIM_SET_COUNTER(&htim2, 0); __HAL_TIM_SET_COUNTER(&htim3, 0); __HAL_TIM_SET_COUNTER(&htim4, 0); Custom_Delay_ns(200); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); Custom_Delay_ns(100); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); Custom_Delay_ns(200); HAL_TIM_PWM_Start(&htim4, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim4, TIM_CHANNEL_2); HAL_TIM_OC_Start(&htim4, TIM_CHANNEL_4); } }