线阵 CCD 的使用方法(以 TCD1304 为例)

本文首先简单介绍 CCD 原理,然后介绍如何驱动典型结构的线阵 CCD,最后介绍一个经典线阵 CCD 型号 TCD1304 配合 STM32 微控制器构建 USB CCD 模块的最简单例子。本文的目的是,使化学或者物理背景的中学或本科学生快速了解什么是 CCD,以及在几分钟的时间内大致学会如何在他们的研究项目中使用低速线阵 CCD。

(我不想学习,我只想马上整一个插电脑上就能用的便宜传感器 直接看文末 PS2)

原理

线阵 CCD (linear CCD, 也翻译为线性 CCD) 实际上可以看成是一长排并列的 MOS 电容。MOS 电容可以有很多种结构,一种很常见的结构是在 p 型半导体上布置一层大约 1 微米厚的 n 型半导体,然后再向上布置一层大约 0.1 微米厚的二氧化硅,然后再往上布置一层金属或者什么东西当作电极。这个大约 1 微米厚的 n 型半导体层被称为 depletion zone(耗尽区),这个名称的来历如下:显然当电极上没有偏置电压时,n 区内的电子数量由平衡的费米能级决定。CCD 工作时,在电极上加一个偏置电压使得 n 区中的电子被充分耗尽,则 n 区内部靠近二氧化硅附近的位置就会产生一个电子势能极小,这个势能极小区域就可以容纳一些电子,这样就相当于形成了一个电容。

考虑只有一个像素的 CCD,当能量足够的光子入射到这个像素上时,光电效应导致该电容上产生一个电子空穴对,电子被保留在 MOS 结构的 depletion zone 中,而空穴进入p区,从而导致 depletion zone 中负电荷不断积累,且电荷数量和光子数量近似成正比。在这个像素上接一个将电荷数转化为电压的放大器(有很多种能做这件事的放大器,比如 MOSFET),就可以通过测量电压间接测出这个像素上的光照强度。

一般的 CCD 上远远不止有一个像素,比如典型的照相机 CCD 上一般有 107 ∼ 109 个像素,线阵 CCD 上一般有几千上万个像素。在这么多像素上都连接信号放大器是不现实的,一般的仪器也处理不了这么多路的模拟信号,因此多像素 CCD 的信号输出是序列式的,即在一段时间内按顺序依次输出每个像素上的电荷数。将电荷转化为电压的放大器实际上只连接在最后一个像素上,每测量完一次最后一个像素上的电荷数之后,只需要依次将每一个像素上的电荷转移到下一个像素上,重复这一过程就可以依次将每个像素的电荷数测出。这个转移过程在电路上也不复杂,我们前面提到,像素上的电容是由于偏置电压产生的,因此控制偏置电压就可以控制电容。为了将第 i 个像素上的电子转移到第 i + 1 个像素上,我们只需要先在第 i + 1 个像素的电极上施加正向偏压使得该像素能够容纳电子,这样第i 个像素上的一部分电子就会由于扩散效应等等原因转移到第 i + 1 个像素上;基本达到平衡之后,再将第i 个像素上的偏压撤销,从而剩下的电子也被迫转移到第 i + 1 个像素上。

上面所说的这种测量方法电路结构非常简单,但存在一个弊端,即在移动电荷的过程中依然还有光子在不断入射,假如第 i 个像素的电荷在某个时刻转移到了第 j 个像素上,但此时正好一个光子入射到第 j 个像素上,这就导致最后测出的电荷数并非原来的第 i 个像素上的电荷数,而是一个含时卷积,这个卷积造成的后果是不同像素间的电荷数之间产生了一些耦合,导致 CCD 的分辨率降低和各种其它副作用(有兴趣的读者可以查看一些讨论电子快门的文献)。好在上面所说的将电荷移动到下一个像素的过程速度非常快,其时间尺度相比曝光时间是近乎可以忽略的,从而保证了分辨率不过度降低。不过在这个过程中上一个像素的电子并不总是能全部转移到下一个像素,而是会流失一部分,每次转移成功的电荷数量除以原有的数量叫做电荷转移效率。现在的线阵 CCD 一般能达到 0.999999 的转移效率,因此也不用担心在转移几千次之后第一个像素上的电荷会被过多损耗。

总结上述原理,CCD 的基本工作流程是

  1. 主控制器要求进行一次新的测量时,首先对 CCD 器件进行供电,并输出一个合适的方波信号作为 CCD 主时钟

  2. 供电后,CCD 开始曝光,各个 MOS 电容中开始积累电荷

  3. 等待一段时间。这段时间被称为曝光时间或者积分时间,积分时间越长,MOS 电容中积累的电荷数越多,输出的信噪比越高。但若积分时间太长,将导致光强最强的区域附近的 MOS 电容饱和,电荷溢出至下一个 MOS 电容中,这种现象叫做过曝。

  4. 将主时钟信号耦合到 MOS 器件上,使每个像素上的电荷随时钟信号依次被转移到下一个像素,这叫做 clock out

  5. 开始测量放大器输出的电压。每完成一次电荷在像素间的转移(一般来说需要几个时钟周期),测量一次放大器输出的电压,第 i 次测得的电压大致就是原本第 i 个像素上的光强度(乘以一个系数)

  6. CCD 第一个像素上的电荷也已经转移到最后一个像素之后,本次测量结束,此时将 CCD 供电和时钟信号断开,等待下一次测量指令。如果需要进行连续测量,则无需断开,回到第2步即可。

驱动原理

从以上介绍的原理可以看到,不管是什么型号的 CCD,总是需要 2 种输入,1 种输出,至少 1 路供电和至少 1 个 GND(接地) 端:

  1. 供电: CCD 的供电要求可以查看该型号 CCD 的数据表,一般来说,MOS 能够耐受的电压范围很宽,比如说对于 TCD1304 (这是一个非常便宜的单列式线阵 CCD,只能测量光强),使用 3.0 ∼ 5.5 V 的供电都没有问题。对于比较复杂的 CCD,一般需要提供几个不同范围的直流偏压用在不同的引脚上,要求会高一些,比如对于 KLI-4104 (这是一个 4 列的 G R B L 型线阵 CCD,可以测量出颜色和亮度),就需要引出 11V, 15V, 0.7V 等几个不同的电压作为供电,并且不宜偏离超过 10% 以上(当然,超出范围只是元件不能正常工作,并不会烧毁,至少需要 17V 左右才能烧毁)

  2. GND: 接地端,没有什么可说的。

  3. 输出: 一般来说,线阵 CCD 有多少路像素就会有几个输出端。输出端需要连接一个足够快的 ADC 才不会漏采数据。事实上,线阵 CCD 的输出数据率很少会超出 MCU 内建 ADC 的采样能力,因此一般来说我们会使用 MCU 内建的 ADC,这样方便 ADC 采样周期和驱动电路的主时钟同步。值得注意的是有些线阵 CCD 的输出是倒置的,即对应 0 光强的输出电压高,而对于饱和光强的输出电压低。为了不浪费 ADC 的量程,一般还会在输出端到 ADC 之间接一个 inverting op amp,将 CCD 的输出范围调整到合适的区间并且将其倒置过来。

  4. 输入1: CCD 需要一个时钟信号输入才能工作,这个时钟信号用于之前在原理部分提到的电荷转移。因为电荷转移的速度很快,所以这个主时钟频率一般至少为 MHz 级别。CCD 一般对于时钟信号的频率精确度和电压精确度都不敏感,因此这个信号可以用一个普通的外部晶振产生,也可以用 MCU 的 GPIO 或者 PWM 输出直接产生,后者的信号质量一般差一些,但很方便并且也在 CCD 的容忍范围内。

  5. 输入2: 除了时钟信号外,CCD 一般还会有一个到数个脉冲输入端口,这些端口合在一起按照一定的时序输入脉冲信号,用于控制曝光时间的长度(或者说在什么时机将信号 clock out)

  6. 其它输入: 各个厂商还会给 CCD 加上一些额外的集成功能,比如电子快门等等,其时序一般也有要求,需要使用这些功能时,按照厂商所给的数据表设定时序即可。

例子

最后我们举一个最简单的例子。这个例子的目标是用一个非常低配置的微控制器驱动一个线阵CCD,并且将读出的数据通过 USB 接口传输到电脑上。如果读者手头上有一台电脑并且没有电子学方面的经验,我个人建议读者亲自动手制作一下才能更好地理解各个步骤,这个例子的制作花销大约是 20 – 40 元(RMB)。

为了简单起见,线阵 CCD 选择 TCD1304,因为它的引脚数很少并且时序简单。TCD1304 是东芝 (TOSHIBA) 出品的一款廉价 CCD,具有大概 4000 个不到的像素,16 bit 级的输出,工作主时钟频率 0.8 MHz – 4 MHz。TCD1304 有两种子型号,分别是 TCD1304AP 和 TCD1304DG,二者没有太大的区别,TCD1304DG 是更新一些的型号,引脚定义和 TCD1304AP 完全相同,在性能上没有改进,但它符合 RoHS 的环保标准。对我们来说哪个便宜买哪个就行了,现在的零售价在 10 – 30 元/个左右,批发一般 15 块钱一个。对我们的例子来说也可以用二手的,拆机货大概 5 – 10 块钱左右。

微控制器可以选择的型号很多,我们的需求是主频 48 MHz 以上(支持 USB 直连需要至少 48.00 MHz 的稳定时钟(漂移必须在 2500 ppm 以内),CCD 驱动需要大约 1 MHz 的时钟能力),至少有一个 12 bit 或 16 bit 的内建 ADC,至少 8 kB 的内存(每次读取的 TCD1304 输出的数据需要暂存在内存中等待 USB 发送,一共 4000 个像素就是 4 kB),便宜,最好支持定时器功能,我们至少需要3个定时器(timer)来处理 CCD 所需的时序。

一个最简单的选择是 STM32F1 系列的 STM32F103C8T6,因为这个控制器很便宜,并且在网上有很多现成的最小版出售,商家已经提前将外部的 HSE, LSE 时钟帮我们焊好,并且引出了 BOOT 选择跳线引脚、USB 引脚和 JTAG 调试引脚,可以节省我们很多焊接功夫。这种最小版在网上也被称为 “Blue Pill”,因为它的体积很小。这样的焊接好的板子在网上的售价大约是 8 – 10 块钱一个(一般包邮),会另外赠送一根 USB 线(如果商家不赠送,也可以到卖电子器件的网店花 1 块钱买一根短的,只需要买 1 块钱的就可以,网上有很多出售数据线的商家会把一根 USB 线卖到 10 块钱左右,但和 1 块钱的并没有区别)。

STM32F103C8T6 的主频是 72 MHz,有 4 个定时器,2 个 12 bit ADC,并且有完善的 USB 中间件库,内存大小我忘了,但反正是够了。它的缺点是 ADC 的速度比较慢,限制了 CCD 的工作频率,不过用在光谱仪、液位监测、光学测量等等方面已经非常足够了,如果需要更高的扫描频率,读者可以很容易地把这个例子中 STM32F1 系列的程序和电路迁移到 STM32F4 系列中。

为了完成这个实验,读者除了需要买一个 TCD1304,一个 STM32F103C8T6 最小版之外,还需要买一个 ST-LINK 烧录器(用于连接 JTAG 调试,网上大约 10 块钱包邮)和几根 2.54 mm 接头杜邦线 (公对公、公对母、母对母各买几根备用,零售差不多 1 毛钱 1 根)。如果读者还能整到一个示波器会更方便理解时序和调试电路,但没有也 OK。需要的物资总清单可以直接看文末 PS2。

时序控制

如图 1 所示是 TCD1304 的整体时序要求(摘自 TCD1304AP datasheet)。从图中可以看出,SH (shift gate) 脉冲用于控制积分时间,相邻 SH 脉冲下降沿之间的间隔就是积分时间。ICG (积分清除门, integration clear gate) 负脉冲则控制开始 clock out 模拟输出的时间,在 idle 状态下,ICG 为高电位,负脉冲发生时 ICG 电位被拉低。ϕM 为时钟输入信号,OS 为模拟输出端,ICG 负脉冲结束后,OS 开始依次输出各个像素上的值。比较 ϕM 的时序和 OS 的时序,可以看到每 4 个时钟周期 OS 输出一个像素上的值,即对于 TCD1304 来说完成一次像素间的电荷转移需要的时间可以看作 4 个时钟周期(注1)。

(注1: 这只是一种等效的说法,实际上器件内部的工作原理并非如此,因为 TCD1304 有两套 Integration Clear Gate – Shift Gate – Shift Register 结构,这两套结构交替工作输出。事实上,大多数线阵 CCD 为了提高输出频率,都具有多个 Shift Register 结构的设计,在这一点上理论与实际的差异可以用于解释在线阵 CCD 寿命快要结束时,往往得到的信号会出现奇怪的周期性(伪信号)的现象。比如说 TCD1304 有两套 Shift Register,因此在使用很长时间之后,两个 Shift Register 对应的模拟放大器的老化情况不一致,因此输出的信号中,每个偶数像素的信号比相邻的奇数像素的信号总是高一些,或者总是低一些)

Figure 1. Timing Chart of TCD1304

1 未给出具体的时序要求。具体的时序要求如图 2 所示 (也摘自 TCD1304AP datasheet)。从表中我们知道如下的要求:

  1. 在初始状态,ICG 引脚为高电位,SH 引脚为低电位,时钟信号正常工作

  2. CCD 的一个工作周期从 ICG 负脉冲的下降沿开始

  3. ICG 负脉冲下降沿后推迟 500 ns 为 SH 脉冲的上升沿

  4. SH 脉冲至少持续 1000 ns

  5. SH 脉冲结束后,ICG 负脉冲再推迟 5000 ns 后结束

  6. ICG 负脉冲结束时,时钟信号必须为高电位

Figure 2. Detailed Timing Requirements of TCD1304

STM32 系列微控制器的设置代码

对不熟悉嵌入式开发的读者的快速科普:微控制器(microcontroller, MCU)是一种便宜的低性能电脑,有很多种型号。最常见的 MCU 大约比小指甲盖略小一点。它能根据各种外部条件执行预先设定好的程序,比如根据温度传感器的数值控制加热功率等。MCU也可以用于进行一些简单的计算,因此也可以用来制作计算器、游戏机等。MCU中除了计算核心外,一般还会包括一些常用的设施,比如用于测量电压并转化成数值的 ADC,用于比较精确的计时的计时器(timer)等。和所有电脑一样,MCU 需要一个时钟才能工作,大部分 MCU 会内置一个微型时钟,但由于体积所限,内置的时钟一般不准确,因此 MCU 都可以接入外部的晶体振荡器作为时钟。晶体振荡器的固有频率信号经过锁相环(Phase Lock Loop, PLL)按一定倍数缩放后,提供给 MCU 中的各种设施使用,我们常说的时钟设置就是指选择合适的晶振、合适的时钟源以及合适的倍数。

为了产生上面所说的时序信号,需要用 MCU 内置的计时器产生输出。我们选择 8.000 MHz 的晶振作为 STM32F103C8T6 的高速外部时钟 (High Speed External Clock, HSE Clock),32.768 kHz 的晶振作为低速外部时钟 (Low Speed External Clock, LSE Clock)。8.000 MHz 信号经过 PLL Mux 倍频为 72 MHz 后作为主时钟。

这里对不熟悉 STM32 系列 MCU 的读者简单介绍一下该系列计时器的设定和命名。MCU 中的第 x 个计时器被称为 TIMx,其中有的是高级计时器,有的功能较少,所有计时器的默认模式为 Counting Up,每个时钟周期更新一次。有四个最关键的寄存器:Counter Register (CNT), Prescaler Register (PSC), AutoReload Register (ARR)和 Capture/Compare Register (CCR),高级计时器还有一个 Repetition Counter。当 TIMx 的 CNT 值溢出时一个计时周期结束,最大值减去 ARR 寄存器中的值被保存在 preload 寄存器中,该值将被作为计时器的初值,此时可以选择触发 NVIC 全局中断。在 Output Compare 模式中,CNT 值到达 CCR 时就可以执行一些行动。在 PWM 输出模式中,TIMx 值到达 CCR 时开始输出,到达 ARR 时停止输出。所有的计时器还允许配置为 One-Pulse Mode,此时计时器输出一次脉冲后将不会自动复位,而是等待外部事件触发,比如配置为 Slave Mode 之后监测某个引脚上的电位下降沿。

首先我们配置 CCD 所需的时钟信号,该信号由 MCU 上第二个计时器 TIM2 的第一个频道 CH1 输出到 PA0 引脚。我们首先设置 TIM2 时钟的 Clock Source 为 Internal Clock,Channel 1 的模式设置为 PWM Generation CH1。由于之前设置的工作主频是 72 MHz = 72000000 Hz,而 CCD 时钟需要的频率是 2 MHz = 2000000 Hz,因此,CCD 时钟的 ARR 值应设为 ARR = 72000000/2000000 − 1 = 36 − 1 = 35,时钟信号的 duty cycle 为 50%,故 CCR 的值应设为 CCR = 36/2 − 1 = 17

SH 信号由 TIM3 CH1 输出到 PA6 引脚,设置 TIM1 时钟的 Clock Source 为 Internal Clock,Channel 1 的模式设置为 PWM Generation CH1,同时,由于线阵 CCD 的典型的积分时间是在 ms 量级,简单地进行计算可以知道在 72 MHz 频率下,1 ms = 72000 个周期。而 STM32F103C8T6 的计时器均为 16 bit 计时器,其最大值为 216 − 1 = 65535,如果直接使用计时器的 PWM 输出,脉冲周期甚至不能达到 1 ms。因此,我们将这个计时器设置为 One-Pulse Mode。SH 脉冲的长度我们设置为 4 μs, 即 288 个周期,延迟 500 ns + 100 周期,为 36 + 100 = 136 个周期,故设置 CCR = 136, ARR = 288 + 136 = 424 即可

ICG 信号所需的负脉冲则由 TIM1 CH1 输出到 PA8 引脚,同样设置 TIM1 时钟的 Clock Source 为 Internal Clock,Channel 1 的模式设置为 PWM Generation CH1,同时设置 One-Pulse Mode。脉冲长度为 9 μs, 即 648 个周期,延迟 100 周期,故设置 CCR = 100, ARR = 648 + 100 = 748 即可。

上述的设置都可以通过简单的 C 代码完成,如 Listing [src:configure_timer.c] 所示,也可以通过 STM32CubeMX 这个软件进行生成,结果大同小异。

// SET UP TIM1 as PWM CH1, ONE PULSE

TIM_ClockConfigTypeDef sClockSourceConfig = {0};
TIM_MasterConfigTypeDef sMasterConfig = {0};
TIM_OC_InitTypeDef sConfigOC = {0};
TIM_BreakDeadTimeConfigTypeDef sBreakDeadTimeConfig = {0};

htim1.Instance = TIM1;
htim1.Init.Prescaler = 0;
htim1.Init.CounterMode = TIM_COUNTERMODE_UP;
htim1.Init.Period = 748;
htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim1.Init.RepetitionCounter = 0;
htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
if (HAL_TIM_Base_Init(&htim1) != HAL_OK)
{
Error_Handler();
}
sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
if (HAL_TIM_ConfigClockSource(&htim1, &sClockSourceConfig) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_PWM_Init(&htim1) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_OnePulse_Init(&htim1, TIM_OPMODE_SINGLE) != HAL_OK)
{
Error_Handler();
}
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim1, &sMasterConfig) != HAL_OK)
{
Error_Handler();
}
sConfigOC.OCMode = TIM_OCMODE_PWM2;
sConfigOC.Pulse = 100;
sConfigOC.OCPolarity = TIM_OCPOLARITY_LOW;
sConfigOC.OCNPolarity = TIM_OCNPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_ENABLE;
sConfigOC.OCIdleState = TIM_OCIDLESTATE_SET;
sConfigOC.OCNIdleState = TIM_OCNIDLESTATE_RESET;
if (HAL_TIM_PWM_ConfigChannel(&htim1, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
{
Error_Handler();
}
sBreakDeadTimeConfig.OffStateRunMode = TIM_OSSR_DISABLE;
sBreakDeadTimeConfig.OffStateIDLEMode = TIM_OSSI_DISABLE;
sBreakDeadTimeConfig.LockLevel = TIM_LOCKLEVEL_OFF;
sBreakDeadTimeConfig.DeadTime = 0;
sBreakDeadTimeConfig.BreakState = TIM_BREAK_DISABLE;
sBreakDeadTimeConfig.BreakPolarity = TIM_BREAKPOLARITY_HIGH;
sBreakDeadTimeConfig.AutomaticOutput = TIM_AUTOMATICOUTPUT_DISABLE;
if (HAL_TIMEx_ConfigBreakDeadTime(&htim1, &sBreakDeadTimeConfig) != HAL_OK)
{
Error_Handler();
}

HAL_TIM_MspPostInit(&htim1);

// SET UP TIM2 as PWM CH1

TIM_ClockConfigTypeDef sClockSourceConfig = {0};
TIM_MasterConfigTypeDef sMasterConfig = {0};
TIM_OC_InitTypeDef sConfigOC = {0};

htim2.Instance = TIM2;
htim2.Init.Prescaler = 0;
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 35;
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
if (HAL_TIM_Base_Init(&htim2) != HAL_OK)
{
Error_Handler();
}
sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
if (HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_PWM_Init(&htim2) != HAL_OK)
{
Error_Handler();
}
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig) != HAL_OK)
{
Error_Handler();
}
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 17;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
if (HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
{
Error_Handler();
}

//Start fM clock
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);

HAL_TIM_MspPostInit(&htim2);

// SET UP TIM3 as PWM CH1, ONE PULSE

TIM_ClockConfigTypeDef sClockSourceConfig = {0};
TIM_MasterConfigTypeDef sMasterConfig = {0};
TIM_OC_InitTypeDef sConfigOC = {0};

htim3.Instance = TIM3;
htim3.Init.Prescaler = 0;
htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
htim3.Init.Period = 424;
htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
if (HAL_TIM_Base_Init(&htim3) != HAL_OK)
{
Error_Handler();
}
sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_PWM_Init(&htim3) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_OnePulse_Init(&htim3, TIM_OPMODE_SINGLE) != HAL_OK)
{
Error_Handler();
}
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK)
{
Error_Handler();
}
sConfigOC.OCMode = TIM_OCMODE_PWM2;
sConfigOC.Pulse = 136;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_ENABLE;
if (HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
{
Error_Handler();
}

HAL_TIM_MspPostInit(&htim3);

在 One-Pulse Mode 下,计时器将不会自动复位。由于我们不需要外部触发计时器,因此无需将计时器设为主从模式。在所需的曝光时间达到后,为了产生 SH 和 ICG 脉冲,我们只需将对应的 TIMx_CR1.CEN 寄存器重设为 SET 即可,因此,主循环的基本结构就是

    while (1)
    {
        // this is equivalent to (&htim1)->Instance->CR1|=(TIM_CR1_CEN)
        __HAL_TIM_ENABLE(&htim1);
        __HAL_TIM_ENABLE(&htim3);
        HAL_Delay(40);
    }

当然,通过阅读 HAL 驱动的手册,我们知道在进入主循环前,配置为单脉冲模式的计时器还要通过特殊的指令才能启动:

    // this should go before the main loop
    HAL_TIM_OnePulse_Start(&htim1, TIM_CHANNEL_1);
    HAL_TIM_OnePulse_Start(&htim3, TIM_CHANNEL_1);

如果读者手上有示波器,此时将 STM32 板子的 JTAG 调试接口连接到 ST-LINK 上之后就可以连接电脑将上面的程序烧录到 MCU 中,接上示波器即可观察波形。当然,由于 MCU 的 CPU 指令集与我们的电脑并不一致,因此需要交叉编译才能得到正确的二进制文件,由于我们并不需要也没有操作系统,在 macbook 上这一步可以通过 arm-eabi-none-gcc 来做。

烧录完成后,将 CCD 和 STM32 板子连接在一起,其中,STM32 的 3V3 连接到 CCD 的 VDD 和 VAD,PA8 连接到 ICG,PA0 连接到 ϕM,PA6 连接到 SH。最后,再将 CCD 的 OS 连接到示波器,如图 3 所示,图中鳄鱼夹是示波器输入线,供电由一个 USB 充电器提供。(注意在烧录完成后,需要将 BOOT 选择的跳线帽重置为 (0, 0) 状态,图中 CCD 有黑色胶带粘贴的一侧是有缺口的一侧,注意不要将 CCD 的方向接反。)

Figure 3. Wiring of TCD1304 and STM32 board

此时,打开示波器的自动捕获,即可看到由于 CCD 积分溢出形成的信号,如图 4 所示。由于我们将 CCD 的左侧用黑色胶带封住,可以看到形成的信号也对应于胶带的位置,左侧为较弱的信号 (高电位),而右侧由于环境光照信号溢出达到饱和 (低电位)。

Figure 4. Signal overflow due to long exposure time

此时,将 CCD 放入无光照的环境中,取下黑胶带,随便拿一些光斑去照射 CCD 上的感光区,就可以在示波器上看到正常工作的信号了,如图 5 所示是一个被用几根导线随便挡了一下的光源,可以看到三根导线的影子区域光强比较低,反映为信号当中的几个高电位峰值。

Figure 5. Normal Output Signal of Linear CCD

为了让读者更好地理解我们之前对 timer 所做的设置和 TCD1304 的时序,我们将 ICG 和 SH 引脚的信号也一同引入示波器中,如图 6 所示,Channel 1 为 SH 脉冲,Channel 2 为 ICG 脉冲,Channel 3 则是 OS 的输出。可以看到使用计时器得到的时序还是比较准确的,基本都在容忍范围内。在 ICG 脉冲结束后几十微秒后,我们捕捉到了 OS 的输出信号。测量 OS 输出信号的总时长,刚好为 7.3 ms,我们的时钟频率为 2 MHz,因此,按照之前的计算,OS 的数据率为 0.5 MHz,即每 2 μs 输出一个像素上的值,TCD1304 共有 3648 个像素,总共就需要 7.296 ms 输出完毕,这与我们的实验非常吻合。(在图中我们可以看到脉冲的各沿有一些抖动,这是由于我们使用屏蔽很差的杜邦线作为信号连接线,线间发生电磁感应导致的,这会使我们的噪声非常大,因此,调试时使用的杜邦线不宜过长。这些噪音在最后将各个元件焊接到电路版上之后可以改善很多。)

Figure 6. Timing

至此,CCD 的驱动部分完成。接下来,我们要将 CCD 的 OS 输出引脚重新引回 MCU 的 ADC 输入引脚上,并编写程序将数据收集起来通过 USB 接口传输到电脑。这部分很简单,但现在该吃晚饭了,所以先写到这里,我吃个火锅回来就把它写完。。

ADC设置

前几天吃完火锅忘了还有这事,鸽了几天。。现在把剩下的部分写完。另外,刚刚才有人告诉我有老外已经写过如何用 stm32 系单片机驱动 tcd1304 的教程了,还专门整了一个博客,不过我看了一下,他写的不如我好。一是他根本没写原理;二是他的驱动电路用的是 TOSHIBA 在上个世纪推荐的古代电路(带一个 74hc04 和三极管,74hc04 还只用了一半的管脚,过于搞笑),麻烦并且没啥必要,对于新手来说如果板子 layout 做得不好反而引入更大的噪音;三是 STM32F103C8T6 明明有能力以 2 MHz 的速度驱动 CCD,他写的程序却只能做到 800 kHz,要达到 2 MHz 的速度还需要用更昂贵的 STM32F4 系芯片,成本整整上升了 10 倍,过于浪费资源;四是用单脉冲模式可以让 STM32F103C8T6 实现 1 ms 至任意长度的积分时间,他的设置却强行用 16 bit timer 的 PWM Generation 模式来做积分时间控制,这样虽然实现了硬件相位同步,但做出来的 CCD 几乎没有什么用,因为最长的积分时间也短得离谱,如果 MCU 上有 2 个 32 bit 以上的 timer 这样做倒是有点用处;最后,他虽然画了个电路板把 tcd1304 做成一块模块板方便接线,却没有顺手做 inverting op amp,也没有把电路板做成各种开发板 shield 的形式,那这电路板空间也过于浪费了,还不如直接把 tcd1304 插在面包板上来得快。。。stackoverflow 上还有人偷了他设计的电路板去做硕士论文,结果看错三极管发射极,画错线导致巨大的噪音,给我笑裂开。总之随便吐槽一下,大家别当真。。。

好,言归正传,上面说到我们已经成功用 stm32 单片机的 timer 生成了驱动 CCD 必要的时序,并且用示波器证实这个时序确实可以产生需要的信号,实际上到这里为止真正的驱动就已经结束了,剩下的部分并不是驱动,而是顺手把 MCU 多余的资源利用起来。。。所以顺手加上一个 USB 数据采集回传功能。

现在我们要把 CCD 的输出端 OS 连接回 MCU 的 ADC 上,这样我们就可以用 MCU 的 ADC 不断地读取 OS 端上的模拟信号了。这里需要注意的是,任何 ADC 都有一个速度上限,这个速度上限叫做 ADC 的采样能力或者最大采样率。要采样任何一个信号,该信号的最大频率不能超过 ADC 采样率的 0.5 倍,这叫做 Nyquist 采样定律。考虑线阵 CCD 输出的一种极端情况,比方说我们有一排光斑,刚好每隔一个像素照亮一个 CCD 上的像素,这时候 OS 端的输出就是一个反复高低振荡的信号,显然,线阵 CCD 输出的最大频率就对应于这种情况,此时信号的频率就是像素间电荷转移频率的一半(从亮 -> 暗 -> 回到亮是 OS 输出的一个周期,刚好对应两次电荷转移的周期),而之前我们分析过,TCD1304 的电荷转移频率(即 OS 端的数据率)又刚好是主时钟频率的 1/4。因此,TCD1304 OS 端信号的最大频率就是 CCD 主时钟频率的 1/8。为了正确采样这个信号,采样率至少要达到 CCD 主时钟频率的 1/4。TCD1304 支持的工作频率范围为 0.8 MHz – 4.0 MHz,因此对应的采样率为 0.2 MSample/s – 1 MSample/s。

说了那么多,那么 STM32F103C8T6 内建 ADC 的采样能力到达如何呢?这其实取决于时钟的配置。只有在 56 MHz 的 APB2 时钟频率下,STM32F103C8T6 内建 ADC 才能使用 14 MHz 时钟达到最快的 1μs 转换时间,然而,因为我们还想使用 USB 与主机交流,而 USB 需要的高速时钟为 48 MHz ± 2500 ppm,这就导致我们只能选择 48 MHz 或者 72 MHz 的主频,因此,我们只能退而求其次,使用 72MHz 的 APB2 时钟频率,将 ADC 降速至 12 MHz。此时,典型的转换时间大约是一点几个微秒。为了稳定起见,我们最好保证采样的周期大约保守取 2 μs,即 0.5 MSample/s。因此,ADC 的速度将 TCD1304 的速度限制在了 2 MHz 以内。这就是前文中说到的 STM32F103C8T6 这款 MCU 的缺陷,如果想要将速度提到更高,则只能更换更猛的 MCU 或者使用外置 ADC,但我们也说过了,这样做成本会提高,且对于大多数应用场合来说并没有什么特别的好处。TOSHIBA 推荐的 TCD1304 的典型工作频率也正是 2 MHz,刚好处于我们的极限之内。

我们要考虑的另一个问题是,在 0.5 MSample/s 的采样率下,ADC 临时寄存器的更新频率是每 144 个时钟周期就更新一次。对于这样高频的事件,如果使用中断模型,我们的 CPU 将完全忙于处理中断而几乎干不了别的事情,甚至如果我们的 CPU 上还有别的重度计算任务的话,还有可能来不及将 ADC 寄存器数据拷贝到内存中就被刷新了。因此,高频 ADC 必须配合 DMA (Direct Memory Access) 使用。DMA 相信大家做过高性能计算或者玩过显卡的都很熟悉,尤其是程序员,不过对于中学生可能会有一点陌生,如果有疑问可以去看看 CSAPP 这本书。后面我们就只简单介绍一下如何配置 stm32 上的 DMA。

现在我们来配置 ADC。首先,STM32 允许我们的 ADC 运行在不同的模式下,大概有 3 种普通的模式

  1. 软件唤醒一次 ADC,ADC 做一次采样就停,之后可以直接从 ADC 寄存器读数

  2. 软件唤醒 ADC 之后,ADC 不停做采样,每次做完都更新寄存器

  3. 软件唤醒 ADC 之后,ADC 待机,每次遇到外部信号触发就做一次采样,没有信号就不采样

CCD 的信号是每 4 个 CCD 时钟周期输出一个,因此我们 ADC 的采样节奏也要刚好一样,所以我们选择模式3,并且用剩下的最后一个计时器 TIM4 作为触发源。因此,我们首先将 TIM4 CH4 设置为 PWM Generation 模式,并设置 ARR = 144 – 1 = 143,至于脉冲时长可以随缘选择。然后将 ADC 设置为独立模式,并将 ExternalTrigConv 设置为 T4_CC4 (即 Timer 4,Capture Compare 4)。

最后,我们为 ADC 配置 DMA 模式。由于我们的 ADC 为 12 bit,它每次输出数据占用的宽度只需要 16 bit 即可,因此设置 DMA 的数据宽度为 half word。

以上的配置用代码可以表达为 Listing [src:adc_dma_configure.c] 的形式


// ADC1

ADC_ChannelConfTypeDef sConfig = {0};
hadc1.Instance = ADC1;
hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE;
hadc1.Init.ContinuousConvMode = DISABLE;
hadc1.Init.DiscontinuousConvMode = DISABLE;
hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T4_CC4;
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hadc1.Init.NbrOfConversion = 1;
if (HAL_ADC_Init(&hadc1) != HAL_OK) { Error_Handler(); }
sConfig.Channel = ADC_CHANNEL_3;
sConfig.Rank = ADC_REGULAR_RANK_1;
sConfig.SamplingTime = ADC_SAMPLETIME_1CYCLE_5;
if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) { Error_Handler(); }

// TIM4

TIM_ClockConfigTypeDef sClockSourceConfig = {0};
TIM_MasterConfigTypeDef sMasterConfig = {0};
TIM_OC_InitTypeDef sConfigOC = {0};
htim4.Instance = TIM4;
htim4.Init.Prescaler = 0;
htim4.Init.CounterMode = TIM_COUNTERMODE_UP;
htim4.Init.Period = 144-1;
htim4.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim4.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
if (HAL_TIM_Base_Init(&htim4) != HAL_OK) { Error_Handler(); }
sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
if (HAL_TIM_ConfigClockSource(&htim4, &sClockSourceConfig) != HAL_OK) { Error_Handler(); }
if (HAL_TIM_PWM_Init(&htim4) != HAL_OK) { Error_Handler(); }
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim4, &sMasterConfig) != HAL_OK) { Error_Handler(); }
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 36-1;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_ENABLE;
if (HAL_TIM_PWM_ConfigChannel(&htim4, &sConfigOC, TIM_CHANNEL_4) != HAL_OK) { Error_Handler(); }

HAL_TIM_MspPostInit(&htim4);

// DMA

__HAL_RCC_DMA1_CLK_ENABLE();
HAL_NVIC_SetPriority(DMA1_Channel1_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(DMA1_Channel1_IRQn);

为了使用 DMA,我们需要事先划定一片内存范围,因此在 main.c 的最开头,我们加上

    #define CCDSize 5000
    volatile uint16_t CCDDataBuffer[CCDSize];

然后我们回到 main 中,在进入循环前,我们首先校准 ADC ,启动 TIM4

    HAL_ADCEx_Calibration_Start(&hadc1);
    HAL_Delay(1000);

    HAL_TIM_PWM_Start(&htim4, TIM_CHANNEL_4);

之前我们在示波器上捕捉到的信号显示,在 SH 和 ICG 脉冲结束后几十微秒,OS 端才有信号输出。因此,在循环体的最开头,即发出 SH 和 ICG 脉冲之前,我们就开始启动 ADC 的采样,并等待 1 ms (这意味着大约会采集到 500 个无效信号值,但这样可以保证我们不采漏开头几个像素的值)

    HAL_ADC_Start_DMA(&hadc1, (uint32_t*) CCDDataBuffer, CCDSize);

USB 的设置

最后是最简单的 USB 设置,USB 通信利用 ST 提供的 USB Device 中间件是最为简单的,其详细的介绍可以查看 ST 提供的手册。使用这个中间件不需要做任何多余的事,将其包含的文件下载并放到我们写的程序旁边之后,在程序中引用头文件即可,由于我们将设备类型定义为 Communication Device Class,我们需要引用的头文件是

    #include "usb_device.h"
    #include "usbd_cdc_if.h"

引入 USB 库之后,我们在每次 ADC 采集的数据填满 DMA 时,将 DMA 中的全部数据通过 USB 发送出去即可,由于 DMA1 全局中断是默认打开的,我们无需做任何事情,只要定义该中断的回调函数即可,在我们的 main.c 源文件中加入如下的函数

    void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
    {
        CDC_Transmit_FS((uint8_t*) CCDDataBuffer, CCDSize * 2);
    }

即可。

测试

以上就是利用 STM32 MCU 驱动并处理 TCD1304 CCD 数据的全部流程了,将完成的程序编译好(对于不懂程序的同学,文末有一个编译好的固件,下载即可使用),并且烧录到 MCU 中,并且接好线(在前面图 3 的基础上,将 CCD 的 OS 线(即红色鳄鱼夹夹住的那根线) 接到最小版的 A3 上即可(我这里设置的 ADC 的输入端口是 PA3,也可以选择其它的端口,调整 ADC 设置代码中的 sConfig.Channel 即可。)),将 MCU 用 USB 线连接到电脑上。到互联网上下载一个 Serial Plotter 程序(比如 Arduino 的编辑器就带有这个功能,此外还有很多软件都可以做到,如果你对 python 比较熟悉,可以用 matplotlib),打开程序就可以看到 CCD 上的读数了。

如果你不喜欢安装额外的软件或者只是想非常快速地测试一下结果,也可以用如下这个非常简陋的 python 脚本,修改一下串口的地址即可使用,它比较卡。另外,我在 GitHub 上也发布过一个很好用的光谱 GUI 程序,见 这里,只需要稍微修改几个参数就可以使用了,它的速度很快,功能多一些,还支持保存、读取和粗略的数据处理。

    import serial
    import numpy as np
    import matplotlib.pyplot as plt

    ser = serial.Serial('/dev/cu.usbmodem8D88588656511')  # open serial port
    CCDSize = 5000
    s = [0] * CCDSize
    while True:
        for i in range(CCDSize):
            x = ser.read(2)
            s[i] = int.from_bytes(x,"little")
        print(s)
        plt.plot(s)
        plt.pause(0.5)
    plt.show()

如图 7 所示是我的测试结果,可以看到与示波器上看到的结果一致。注意到其中的噪声非常大,这并不是因为我们的程序或者设置有问题,而是一方面选择了很短的积分时间,另一方面使用比较长的杜邦线连线产生巨大的 EMI (尤其是时钟信号耦合到模拟线路上的干扰),导致了这些噪音,在将电路画成 PCB 之后会好很多。

从 USB 接口收到的 CCD 数据

如图 8 所示,利用 PCB 进行连线(这里加入了一个 inverting op amp 电路,将信号幅度放大 1.74 倍并倒置),可以看到噪音并未一同被放大,而是维持在了较低的水平,说明杜邦线的 EMI 干扰被基本排除了。

将杜邦线替换为 PCB 后,虽然仍然没有做数字模拟隔离,但噪音情况得到了一定的改善

为了确认电路没有问题,还可以做一个简单的噪声估计。首先简单取信号中一段,放大查看典型的噪声值。由于我懒得打开 matlab,就用眼睛估测一下。在电路中,我们仅仅采用了最低限度的噪声抑制技术,从图 9 中可以大致看到噪声水平大约是 3 – 10 个 ADC 输出级,对应 3.3V 供电的 12 bit ADC,这个等级约为 2 mV (min) – 8 mV (max)。从 TCD1304AP 和 TCD1304DG 的数据表中我们可以查到,CCD 的噪声等级(暗信号)为 2 mV,最大 5 mV,考虑放大倍数为 1.74,对应的噪声等级为 3.5 mV (typical) – 8.7 mV (max)。换言之,在最低限度的噪声抑制设计之后,我们已经完全达到了这个 CCD 极限的噪声水平。值得一提的是,数据表上给出的暗信号为 25°C 时测试的数据,和北京这边开着暖气的温度差不多,如果想要获得更低的噪声,我们可以将 CCD 放到干冰浴中,在较低的温度下,这个噪声将会显著地降低。不过很遗憾,由于我们所使用的 STM32F103C8T6 芯片以及 LDO, op amp 的限制,温度不能再继续降低,否则电路就不能正常工作了。

信号上的噪声

最后的一些建议

前面的几个部分就是驱动线阵 CCD 所需要的全部知识了。我将最后的一些零散的建议放在这里,如果你在调试的过程中遇到问题,可以参考这一部分的建议来排查。

  1. 由于我们直接用最小版给 TCD1304 供电,这个供电仅仅经过了最小程度的一个电容滤波,这样的电压源远远不够稳定,是不足以作为模拟电路的参考电压的。参考电压的波动是噪声的主要来源之一,不同生产商生产的最小版噪音水平差别很大,如果你的运气不太好,也许会买到噪音很烂的板子。为了解决这个问题,在 PCB 中引入一个基准电压源即可。

  2. 在上面的设计中,模拟信号的地平面与时钟信号、脉冲信号共享,这是噪声的主要来源之二。为了解决这个问题,我们推荐将地平面分为数字地和模拟地两部分,信号分别接地后通过星状拓扑连接。

  3. 当发现模拟电路振荡时,你可以检查运算放大器的参数是否正确,避免加入不必要的对齐电阻,因为这会引入新的极点。当怀疑某个电阻的阻值或者容性负载时,画 Bode 图或者根轨迹图来查看裕度是否足够。必要时,牺牲一些运算放大器的高频性能。

  4. 在低积分时间下,MCU 应当先进行内部平均,再将数据传输出去,否则 USB 的传输速度跟不上 ADC 采样速度,多次采样的结果被浪费。此时采用平均算法可以有效地降低热噪声(但很难降低先前的第二种 EMI 干扰,因为其具有非常特征的频率)。

  5. 如果你的 CCD 模块在 -100°C 工作不正常,不用慌,这很正常,因为大多数电子器件的极限范围就在这附近。如果你的 project 需要使用低温 CCD,你可以把 CCD 板独立出来,并通过射频线连接到主板上。

  6. 市面上出售的大多数 STM32F103C8T6 最小版存在一个小 bug,即 USB 2.0 接口的上拉电阻不对。按照 USB 2.0 全速设备的标准定义,D+ 应该由 1.5 kΩ 上拉至 3.3V,但大多数最小版生产商由于只会抄袭市面上的电路板,所以很多市售板都是由 4.7 kΩ 或者 10 kΩ 上拉至 5V。现在生产的电脑大部分能自动忽略这个错误,但有的老设备或嵌入式设备将不能正确识别到 USB 设备。如果发生这种情况,你需要将最小版上的上拉电阻 (一般是 R10, 0603) 替换成 1.5k 0603 1% 的电阻。

  7. CCD、op amp 都是静电敏感设备,千万不要用手去触摸 CCD 的针脚,CCD 不用时保存在防静电自封袋中。如果你的数据老是不对,或者在示波器上都没有任何信号,可以考虑是不是 CCD 已经被静电干烂了。

  8. 大部分 CCD 对红外线敏感,强烈的红外线会加速感光元件的老化,不要把 CCD 靠近火源或者马弗炉、感应炉,也不要将 CCD 放在红外聚焦透镜后面,更不能用 CCD 测量 CO2 激光的聚焦位置。

PS

PS1: 写这篇东西的原因

在制作各种光谱仪器的时候,经常需要使用线阵 CCD 作为终端的传感器。实际上,典型的现代光谱仪的结构一般就由狭缝、凹面全息光栅、线阵 CCD 构成(一些较老的光谱仪可能使用狭缝-凹面镜-平面光栅-凹面镜-线阵 CCD 的结构,使用平面光栅可以比使用凹面光栅节省一些成本)。一般来说光谱仪的结构会在仪器分析、普通物理这些课里介绍,不过令人吃惊的是在国内顶级高校的本科实验教学中,几乎没有涉及到如何使用 CCD 这样基础的内容(至少在北大没有),以至于老是有学弟学妹来问我一些搞笑的问题,所以姑且写一个简单的入门引导,可以当作给本科生做实验用的讲义的一部分。

PS2: 懒人教学

你需要买:

  1. STM32F103C8T6 最小版 * 1 (¥8)

  2. TCD1304DG * 1 (¥10)

  3. 长度 100 mm 的杜邦线(接头尺寸2.54 mm),母对母、公对公、公对母各10根备用 (¥3)

  4. ST-LINK 烧录器 * 1 (¥10)

  5. USB 线 * 1 (¥1)

连线:用杜邦线的两头分别插在两个元件的针脚上就可以把两个针脚连接起来。

  1. 最小版 GND CCD GND

  2. 最小版 3v3 CCD VDD

  3. 最小版 3v3 CCD VAD

  4. 最小版 A0 CCD ϕM

  5. 最小版 A6 CCD SH

  6. 最小版 A8 CCD ICG

  7. 最小版 A3 CCD OS

烧录:将 ST-LINK 连接到最小版的调试接口,在电脑上打开烧录软件 (mac 和 linux 上可以用 stm32flash),把下面这个附件写入最小版中

调试:拔下 ST-LINK,在最小版上插上 USB 线,USB 线另一端插电脑上,打开任意一款 Serial Plotter 软件即可看到数据

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.