TCS230 颜色传感器:用 Arduino 辨色与分拣

Cartoon of a deep-blue TCS230 colour sensor board with a matte-black lens holder and four clear white LEDs shining a cone of light down onto bright red, green and blue plastic bricks

TCS230 从来不会告诉你颜色。它一次只报一个数字 —— 一个频率,来自你用两支引脚选中的那一片滤光片 —— 所以一次颜色读数其实是连着做的三次测量,最后这三个数加起来算什么,是你的 sketch 说了算,不是芯片。把 S0 设成 HIGH、S1 设成 LOW,用 pulseIn() 分别给三路计时,然后先拿一张白卡校准。

TCS230 颜色传感器实际测的到底是什么?

TCS230 里面只有一片 8×8 的光电二极管阵列,而这 64 颗并不一样:十六颗盖着红色滤光片,十六颗绿色,十六颗蓝色,还有十六颗完全不盖滤光片。任何时刻只有其中一组接到转换器上,选哪一组由 S2S3 决定。所以根本没有“读一次颜色”这种调用 —— 只有选红、测;选绿、测;选蓝、测。

OUT 脚上出来的也不是电压。转换器把选中那一组的光电流变成一个占空比 50% 的方波,它的频率正比于落在这组二极管上的光有多少,供电范围 2.7–5.5 V,见 TAOS TCS230 数据手册。亮就快,暗就慢。

S2 S3 接上的光电二极管组
LOW LOW
LOW HIGH
HIGH LOW Clear —— 不带滤光片
HIGH HIGH 绿

频率在离开芯片之前,还会先被 S0S1 分频一次,而这一档设成什么,直接决定 Arduino 能不能给这个方波计时。

S0 S1 输出分频 满量程频率 满量程时的半周期
LOW LOW 掉电
LOW HIGH 2% 10–12 kHz 42–50 µs
HIGH LOW 20% 100–120 kHz 4–5 µs
HIGH HIGH 100% 500–600 kHz 0.8–1 µs

Arduino 官方的 pulseIn() 文档写明它适用于 10 µs 以上的脉冲,而这个数字就替你把表里的那一行挑出来了。用 100% 的时候,一次偏亮的读数连 10 µs 都不到,浅色物体全部塌进同一个毫无意义的值里。用 2% 的时候没有哪个脉冲会太快,但一个深色物体会把单次测量拉到毫秒级,一次读数要做三回,延迟肉眼可见。20% 正好把普通室内条件下的读数落在几十微秒这一档;那一行里的 4–5 µs 是满量程,而模块自带那四颗 LED 照在日常物体上,永远到不了那个亮度。

Parts list

ItemPriceQty
Soldering Service for Circuit Boards & Electronics - 10 Pins Soldering ServiceSoldering Service for Circuit Boards & Electronics - 10 Pins Soldering ServiceSOLSE10RM1.40

TCS230 模块正好要焊 10 支排针。如果你不想自己动烙铁,加购 10 针焊接服务,我们帮你把排针装上。

TCS230 怎么接到 Arduino Uno 上?

TCS230 模块两排 5 脚的位置都是裸露的镀铜通孔,所以第一件事是焊接。从材料清单里那条 40 针 2.54 mm 公排针上掰下两段 5 针的,从镜头那一面插进去,让针脚从背面伸出来,这样杜邦胶壳就不会挡在光路上;技术细节可以看我们的排针焊接教程,家里没有烙铁的话,10 针焊接服务也能替你搞定。

两排针上的信号不一样,而且方向是反着走的,所以别按位置认,认丝印:一边是 GND OUT S2 S3 VCC,另一边是 VCC S1 S0 LED GND,各自的 1 脚焊盘是方形的。这两排落在一块 33.2 mm 见方板子的相对两条边上,间距大约 30 mm —— 比迷你面包板两块区之间 28 mm 的跨距还宽,所以这个模块没办法骑在面包板上。改用 Sensor Shield V5.0 传感器扩展板,把它压在 Uno 上:它把每一支数字引脚重新引成一组三针公排针,丝印是 G、V、S,另外还有一个四针的 IIC 排针给 OLED 用。两头都是公针,所以这十二条连线全部用母对母杜邦线,20 cm 的长度足够让传感器垂下来,指哪儿测哪儿。

标着 LED 的那支脚不是逻辑脚。它控制的是板上那四颗白色 LED,板子的说明书把它们加起来算作大约 25 mA,而板子本身也印证了这一点:每颗 LED 旁边那颗电阻标的是 331,也就是 330 Ω,白色 LED 压降约 3.1 V,所以每一路是 (5 − 3.1) / 330 = 5.8 mA,四路加起来 ≈ 23 mA。ATmega328P 数据手册给每支 I/O 脚定的 40 mA 是绝对最大值,而它只保证到 20 mA 为止的输出电平。所以把 LED 直接接 5V,让灯一直亮着 —— 反正颜色测量本来就要这样。

扩展板上那颗黄色的 SEL 跳线帽,就在蓝色接线端子旁边,它让数字排针的 V 脚由 Uno 自己的 5V 供电。出货时插在哪儿就留在哪儿 —— 拔掉它,D0–D13 那些排针的 V 脚就全部没电,而这个现象看起来非常像传感器坏了,而不像少了一个跳线帽。接线端子上什么都别接:SEL 插着的时候外接电源会直接并到 Uno 的 5V 电源轨上。

模块引脚(丝印) 接到扩展板的哪里 背后对应的 Uno 引脚
TCS230 OUT D8 那一组的 S D8
TCS230 S0 D4 那一组的 S D4
TCS230 S1 D5 那一组的 S D5
TCS230 S2 D6 那一组的 S D6
TCS230 S3 D7 那一组的 S D7
TCS230 VCC 任意一组的 V 5V
TCS230 GND 任意一组的 G GND
TCS230 LED 任意一组空着的 V 5V —— 绝对不要接数字脚
OLED GND IIC 排针的 GND
OLED VCC IIC 排针的 + 5V
OLED SCL IIC 排针的 SCL A5
OLED SDA IIC 排针的 SDA A4

OLED 那排针的顺序是 GND、VCC、SCL、SDA —— 地在最前,跟常见的 VCC 在前正好相反 —— 而扩展板 IIC 排针的顺序是 SCL、SDA、−、+。这四根要一根一根地接,不要当成一条平行排过去的排线。它的排针出货时已经焊好了;同一块屏也在我们的旋转编码器 OLED 菜单里出场。

卡通图:一块深蓝色的 TCS230 板子,中间是哑光黑色的镜头座和四颗透明 LED,一排孔还空着,另一排已经插上黑色公排针,正被一支发着橙光的烙铁头焊接
模块到手时是十个裸露的镀铜通孔。从那条 40 针排针上掰两段 5 针的焊上去,之后每一根杜邦线都是插上就走。

为什么红色物体反而给出最小的红色读数?

因为 TCS230 的 OUT 脚是被计时的,不是被读取的。pulseIn(OUT, LOW) 量的不是光 —— 它量的是那个方波 LOW 那一半有多长,单位微秒,也就是半个周期。周期是 1 / 频率,所以你拿回来的那个数字是 500000 / f:20 kHz 时是 25,5 kHz 时是 100。

把一块红色积木放到红色滤光片下面,它把红光反射得很强,于是频率升高,半周期缩短,数字反而变小。最匹配的那一路,是原始读数最小的那一路:TCS230 的原始读数量的其实是暗度

卡通对比图:深蓝色 TCS230 传感器对着白卡时,下方是一串挤得很密的深蓝色方波,被标出的低电平脉冲很短;对着黑卡时波形被拉长,被标出的低电平脉冲很长
白卡反射回来的光很多,所以波形很快,它 LOW 的那一半很短。黑卡几乎什么都反射不回来,于是同样一次测量得到的数字大得多。

TCS230 怎么校准,为什么说校准就是这个作品的全部?

TCS230 的原始读数会随距离、室内光线,以及物体本身有多亮而漂移,所以你要先把两端钉死。第一步先把高度固定下来,之后再也不要动 —— 离镜头 10–30 mm 适合大多数物体,近到那四颗 LED 的光锥能在目标上重叠。就在这个高度上,举一张白卡,记下那三个原始数字 —— 这是你会看到的最小值。然后换一个哑光黑的东西,再记一遍 —— 这是最大值。

接着 map(raw, white, black, 255, 0) 把原始读数换算成 0–255。注意输出范围是倒着写的:白对应的是的原始值,而它必须落到 255 上。整个反相关系,就是被这一个写反的参数掰回来的。

但这样还不够,而这一步恰恰是大多数教程跳过的。把物体挪远 5 mm,三个归一化后的值会一起变小,因为整体反射回来的光变少了。所以再把每个值除以三者之和:r = R / (R+G+B)。亮度被约掉,只剩下比例。一块红色积木近距离时三个 0–255 的值是 210/70/60,换成千分比是 617/205/176;把它挪远到读数变成 120/40/34,比例是 618/206/175 —— 数字差得很远,结论却完全一样,这就是分色机能够稳定复现的原因。整数除法会截尾,所以这三个数永远凑不满 1000。

镜头四周那四颗白色 LED 存在的理由也一样:传感器测的是反射光,光源一变,读数就跟着变。让它们一直亮着,并且把周围的缝隙遮起来挡掉室内光。在这一步上花几分钟,看看物体放到传感器下面时数字怎么动,是很值的:

卡通对比图:一块红色积木分别贴近和远离深蓝色 TCS230 传感器,下方是一高一矮两组红绿蓝柱状图,两组的比例完全相同
把物体挪远,三个读数会一起变小。把每个都除以三者之和,比例纹丝不动 —— 这就是分类器比的是比例、不是绝对值的原因。

TCS230 的 Arduino sketch 长什么样?

TCS230 的 sketch 就是三次读取、一次归一化、一次求比例,然后拿你自己的参考值做最近匹配。先从库管理器装上 Adafruit SSD1306Adafruit GFX Library。两个校准数组里的每一个数字都只是占位值 —— 先跑起来,用 9600 打开串口监视器看那一行 raw,把你自己的数字贴进去。

// TCS230 颜色传感器 + 0.96 寸 SSD1306 OLED,Arduino Uno。
// 接线:OUT -> D8,S0 -> D4,S1 -> D5,S2 -> D6,S3 -> D7,
//       VCC -> 5V,GND -> GND,LED -> 5V(绝对不要接数字脚:约 25 mA)。
//       OLED SCL -> A5,SDA -> A4,VCC -> 5V,GND -> GND。

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDR 0x3C   // 丝印写的是 0x78,那是 8 位形式,0x78 >> 1 = 0x3C

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

const uint8_t PIN_S0  = 4;
const uint8_t PIN_S1  = 5;
const uint8_t PIN_S2  = 6;
const uint8_t PIN_S3  = 7;
const uint8_t PIN_OUT = 8;

// ---- 第 1 步:你自己的校准值,单位是原始微秒数(R、G、B)-------------------
// 在你的工作距离上举一张白卡,看串口监视器上那一行 "raw",
// 把三个数字贴到这里。再拿一个哑光黑的东西重复一次。
const uint16_t WHITE_RAW[3] = {  26,  30,  25 };
const uint16_t BLACK_RAW[3] = { 240, 265, 230 };

// ---- 第 2 步:参考比例,千分比,用你自己的物体量出来 -----------------------
// 举着每个物体时把 ratio 那一行打印出来,然后把三元组贴到这里。
const int16_t REF[4][3] = {
  { 560, 235, 205 },   // 红
  { 235, 520, 245 },   // 绿
  { 205, 270, 525 },   // 蓝
  { 333, 333, 334 }    // 白 / 灰
};
const char *const NAME[4] = { "RED", "GREEN", "BLUE", "WHITE" };

// 一次测量。S2/S3 决定哪一组光电二极管接到转换器上;
// pulseIn 量的是占空比 50% 方波 LOW 的那一半,所以它返回的
// 是半个周期 —— 光越多,数字越小。
uint16_t readRaw(uint8_t s2, uint8_t s3) {
  digitalWrite(PIN_S2, s2);
  digitalWrite(PIN_S3, s3);
  delay(10);                                        // 等新滤光片稳定下来
  unsigned long us = pulseIn(PIN_OUT, LOW, 50000UL);
  if (us == 0 || us > 50000UL) return 50000;        // 超时:等同于全黑
  return (uint16_t)us;
}

// 把原始半周期换成 0-255 的“这个颜色回来了多少”。
// 输出范围是故意写反的:白色对应的是小的原始值,
// 而它必须落到 255 上。
uint8_t normalise(uint16_t raw, uint16_t white, uint16_t black) {
  long v = map((long)raw, (long)white, (long)black, 255L, 0L);
  return (uint8_t)constrain(v, 0L, 255L);
}

void setup() {
  pinMode(PIN_S0, OUTPUT);
  pinMode(PIN_S1, OUTPUT);
  pinMode(PIN_S2, OUTPUT);
  pinMode(PIN_S3, OUTPUT);
  pinMode(PIN_OUT, INPUT);

  digitalWrite(PIN_S0, HIGH);   // S0 HIGH、S1 LOW = 20% 输出分频,
  digitalWrite(PIN_S1, LOW);    // 满量程 100-120 kHz - pulseIn 计得动的范围

  Serial.begin(9600);
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    Serial.println(F("No SSD1306 at 0x3C - check SDA/SCL and 5V"));
    for (;;) { }
  }
  display.setTextColor(SSD1306_WHITE);
}

void loop() {
  uint16_t raw[3];
  raw[0] = readRaw(LOW,  LOW);    // 红色滤光片那一组
  raw[1] = readRaw(HIGH, HIGH);   // 绿色滤光片那一组
  raw[2] = readRaw(LOW,  HIGH);   // 蓝色滤光片那一组

  uint8_t rgb[3];
  for (uint8_t i = 0; i < 3; i++) {
    rgb[i] = normalise(raw[i], WHITE_RAW[i], BLACK_RAW[i]);
  }

  // 比的是比例,不是绝对值。除以总和就把“有多亮”丢掉了,
  // 只留下“是什么颜色”,而后者才是物体挪动之后还成立的东西。
  uint16_t sum = (uint16_t)rgb[0] + rgb[1] + rgb[2];
  if (sum == 0) sum = 1;
  int16_t ratio[3];
  for (uint8_t i = 0; i < 3; i++) {
    ratio[i] = (int16_t)((1000L * rgb[i]) / sum);
  }

  // 在比例空间里找最近的参考值:距离平方,最小的赢。
  uint8_t best = 0;
  int32_t bestDist = 2147483647L;
  for (uint8_t k = 0; k < 4; k++) {
    int32_t d = 0;
    for (uint8_t i = 0; i < 3; i++) {
      int32_t e = (int32_t)ratio[i] - REF[k][i];
      d += e * e;
    }
    if (d < bestDist) { bestDist = d; best = k; }
  }

  Serial.print(F("raw "));
  for (uint8_t i = 0; i < 3; i++) { Serial.print(raw[i]); Serial.print(' '); }
  Serial.print(F(" rgb "));
  for (uint8_t i = 0; i < 3; i++) { Serial.print(rgb[i]); Serial.print(' '); }
  Serial.print(F(" ratio "));
  for (uint8_t i = 0; i < 3; i++) { Serial.print(ratio[i]); Serial.print(' '); }
  Serial.print(F(" -> "));
  Serial.println(NAME[best]);

  display.clearDisplay();
  display.setTextSize(2);
  display.setCursor(0, 0);
  display.println(NAME[best]);
  display.setTextSize(1);
  display.setCursor(0, 24);
  display.print(F("R ")); display.print(rgb[0]);
  display.print(F("  G ")); display.print(rgb[1]);
  display.print(F("  B ")); display.println(rgb[2]);
  display.setCursor(0, 40);
  display.print(ratio[0]); display.print(F(" / "));
  display.print(ratio[1]); display.print(F(" / "));
  display.println(ratio[2]);
  display.setCursor(0, 54);
  display.print(F("fit ")); display.print(bestDist);
  display.display();

  delay(200);
}

它编译出来占 16,160 字节 flash、587 字节全局变量 —— 但 display.begin() 会在运行时再要走 1024 字节做帧缓冲,而 IDE 打印的那个数字从来不会把它算进去,所以真实的 SRAM 占用是 2048 里的大约 1611。屏幕上那个 fit 是获胜参考值的距离平方:小表示匹配得很有把握,大表示很勉强。校准时顺手做一个检查:如果白卡回来的原始值低于 10 左右,你已经贴到 pulseIn() 的下限了 —— 把传感器抬高一点,或者降到 2% 分频。那是 S0 LOW 加上 S1 HIGH —— 只把 S1 拉高而 S0 还是 HIGH,落到的是 100% 那一行,脉冲只会更短。

我们常见到的错误

红色物体给出最小的红色数字,于是看起来像接错了线。线没接错。pulseIn() 返回的是半个周期,光越多脉冲越短。任何比较之前,先做归一化。

手一从上面经过,读数就跳。室内光正连同模块自己的 LED 一起照在物体上。分类器的稳定程度不会超过它照明的稳定程度:把高度固定,把缝隙遮起来。

所有颜色都分成同一类,或者什么都读成黑色。通常是三件事之一:排针根本没焊,杜邦线只是虚搁在孔里;扩展板的 SEL 跳线帽被拔掉了,传感器的 V 脚没电;或者 LED 那支脚空着,根本没有东西在照亮物体。

一个没见过的物体,照样被很有把握地命名。最近匹配没有“拒绝”这个选项:不管你举什么上去,它都会返回你那四个参考值里最接近的一个,所以一块黄色积木也照样落进其中之一。记下你已知物体的 fit 大概在什么范围,比这更差的一律拒掉 —— 多一个 UNKNOWN 通道,好过分错一次。

在桌上好好的,装到机器上就不行。校准是跟距离绑死的。到成品实际使用的那个高度上,把白、黑和每一个参考比例统统重新量一遍。

常见问题

TCS230 和 TCS3200 是同一个东西吗?

对你的 sketch 来说,是。两者的编程模型和引脚顺序一样 —— S0/S1 分频、S2/S3 选滤光片、OUT 输出频率 —— 所以为其中任何一个写的代码,在这里都能原样跑。这块板子通常以 GY-31 的名字出现,背面丝印是 Color Sensor

TCS230 模块需要焊接吗?

需要 —— 它那两排 5 脚是镀铜通孔,要装 2.54 mm 公排针。材料清单里给了一条 40 针排针,掰两段 5 针的下来就行;或者加购 10 针焊接服务,我们帮你焊好。

LED 那支脚为什么必须接 5V,不能接数字脚?

它驱动的是四颗经 330 Ω 电阻限流的白色 LED,加起来大约 23–25 mA —— 超过了 ATmega328P 数据手册还保证输出电平的那 20 mA,也逼近它 40 mA 的绝对最大值。何况持续照明本来就更适合做测量。

为什么这个传感器要先看一张白卡才能用?

TCS230 测的是反射光,所以它的原始读数取决于距离和室内光线的程度,不比取决于颜色少。在你的工作距离上各取一次白读数和黑读数,就给了 map() 量程的两端。

它分得出浅蓝和深蓝吗?

只有在你把亮度留在算式里的时候才行。求比例这一步是故意把亮度丢掉的,所以同一色相的浅蓝和深蓝会落到同一组三元组上。把求比例之前那三个 0–255 值的总和跟每个参考值一起存下来,当作第二道判据。

颜色分类器是分拣机的眼睛,但不是分拣机的全部。我们的 28BYJ-48 步进电机教程讲的是那条机械臂,而循线小车展示的是另一种光学感测 —— 反射率,亮与暗之分。

最后更新:2026年8月。有问题?WhatsApp 联系我们

Leave a Reply

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