简介基于AS7341光谱传感器的STM32显示程序面向嵌入式开发与颜色识别场景解决多通道光谱强度采集、RGB565色彩转换及TFT屏实时显示问题。压缩包共222个文件、约5.75MB包含C源码与头文件、编译中间文件、可执行文件以及Keil工程配置等其中.uvprojx可直接导入工程调试.hex与.axf可供烧录参考结构完整。程序通过标准库模拟IIC协议与AS7341通信读取8个通道的光强值在1.44寸TFT屏幕上直观显示并提供光谱值转RGB565函数将数据映射为人眼可感知的真实色彩。涉及传感器驱动、协议模拟、图形显示等嵌入式关键技术既适合STM32初学者入门也可供颜色识别、环境光检测或植物生长监测项目开发者参考。目前已有769人学习下载内含完整工程文件与调试配置可辅助快速理解光谱分析整体流程并在此基础上扩展更多实用功能。1. 一块芯片把“颜色不对劲”变成一串可归档的数据AS7341 光谱强度显示程序能做什么产线上有一台配色设备批次样品之间的色差长期靠老师傅肉眼抽检直到去年一次自然光下的出货翻车整个工程部才统一态度颜色识别不能靠感觉。换用 AS7341 这颗多通道光谱传感器之后STM32 端按 8 个可见光波段读出强度计数再把 F1~F8 的比例算成一张柱状图显示在 OLED 屏上所有判断都有据可查。这份源码的核心价值是把一颗原本要啃手册的传感器封装成了三段清晰的逻辑I2C 通信、光谱强度换算、颜色分类。适合手里有 STM32 开发板和 AS7341 模块的人复现也适合想用光谱数据替代传统颜色传感器的硬件工程师做二次开发。阅读前你不需要懂光谱学只需要会编译 STM32 工程、会用串口助手看输出。2. 硬件链路与初始化顺序AS7341 和 STM32 的 I2C 接口怎么搭2.1 传感器通道与硬件最小系统AS7341 的核心是一组多波段光电二极管内部大致分为可见光通道 F1~F8、一个 Clear 通道和一个 NIR 通道。和常见的三通道颜色传感器相比它最大的优势是波段分得更细能捕捉到同色异谱的差异也就是两个物体在标准光源下肉眼同色、实际反射光谱完全不同的情况。这一点在后端做染色一致性判断时非常关键因为普通 RGB 传感器会被这类情况骗过去。硬件连线其实很常规传感器模块大多通过 7 位 I2C 地址挂在 MCU 的 I2C 总线上。以我常用的某国产 STM32F103 核心板为例AS7341 模块直接插到核心板的 I2C1 引脚上OLED 屏则挂在另一条 I2C 总线避免两个设备互相干扰。模块引脚STM32 对应AS7341 VIN3.3V3.3VAS7341 GNDGNDGNDAS7341 SDA串行数据PB7 / I2C1_SDAAS7341 SCL串行时钟PB6 / I2C1_SCLAS7341 INT中断输出PB0可配置为下降沿触发OLED SDA串行数据PB11 / I2C2_SDAOLED SCL串行时钟PB10 / I2C2_SCL这里有个容易疏忽的地方AS7341 模块的 VDDIO 和 I2C 上拉电平要一致。如果模块板子上已经带了上拉电阻且默认按 3.3V 设计直接接 3.3V 供电就正常如果上拉电阻是焊到 5V 的就必须改跳线或换模块否则 STM32 的 GPIO 会吃进超过手册允许的电压。我通常在原理图上把 VDDIO 也画出来不让它悬空。2.2 初始化顺序和 I2C 地址确认AS7341 的 I2C 地址不是固定唯一的常见有 0x38 和 0x39 两档由模块上的 ADDR 引脚电平决定。初始化第一步不是写寄存器而是先扫一遍总线确认设备在哪个地址上。这个习惯救过我很多次因为某些模块默认是 0x39而淘宝买的另一批模块却是 0x38直接写死地址会导致第一次通电就卡在读 STATUS 寄存器。初始化顺序常见做法是先复位再配置测量模式最后开启测量。AS7341 内部控制寄存器比较多但真正涉及的流程可以压缩成下面几步void as7341_init(void) { // 1. 等待设备稳定 HAL_Delay(10); // 2. 软复位把寄存器恢复到默认值 as7341_write_reg(0x80, 0x10); // 常见做法主控制寄存器里的软复位位 HAL_Delay(10); as7341_write_reg(0x80, 0x00); // 3. 配置积分时间和增益 // 积分时间寄存器控制 ADC 采集窗口增益寄存器控制模拟放大倍数 as7341_write_reg(0xA9, 0x04); // 配置寄存器设为 100 个时钟周期左右 as7341_write_reg(0x81, 0x00); // 增益档位0 表示一倍增益 // 4. 开启光谱测量通道 as7341_write_reg(0xA8, 0x01); // 使能测量状态 as7341_write_reg(0xAF, 0x01); // 写入测量命令启动一次采集 }这段代码里0x80 主控制寄存器负责软复位和电源模式0xA9 配置寄存器控制积分时间0xAF 是测量命令寄存器。不同批次的数据手册对寄存器功能命名略有差异所以我在工程里特意把每个地址都做了宏定义换芯片批次时只需要改头文件。积分时间从 100 到 1000 个时钟周期都可以用环境光较弱时加大积分时间比盲目提高增益更不容易出现噪声。初始化完成后并不急着读数据而是先读一次 STATUS 寄存器确认测量完成标志位拉起来了再进主循环。这里要强调一个细节AS7341 的寄存器读取支持连续地址自动递增但写入多字节时很多驱动库并不会自动处理。我封装驱动时只做了单字节写把所有配置都拆成单条 write虽然慢几十微秒但排除了 I2C 自动递增地址在不同固件版本上的兼容性问题。后面接在线调试器看波形也能少很多干扰。3. 核心代码拆解从原始 ADC 值算出各波段光谱强度3.1 寄存器读写封装和命令流程整份工程里最值得读的就是as7341.c这个文件它把 I2C 底层通信、寄存器读写、测量触发封装成了三个很薄的接口。先看最底层的读函数我习惯用 HAL 库的HAL_I2C_Mem_Read它能避免手动拼接写地址和读地址的麻烦。HAL_StatusTypeDef as7341_read_reg(uint8_t reg_addr, uint8_t *buf, uint8_t len) { // 指向 AS7341 的 I2C 句柄模块挂在 I2C1 上 return HAL_I2C_Mem_Read(hi2c1, AS7341_I2C_ADDR, reg_addr, I2C_MEMADD_SIZE_8BIT, buf, len, 100); }HAL_I2C_Mem_Read的参数分别是 I2C 句柄、7 位设备地址、寄存器地址、地址宽度、接收缓冲区、长度和超时时间。注意 AS7341 的寄存器地址宽度是 8 位不能用I2C_MEMADD_SIZE_16BIT否则设备会收到两个字节的地址命令导致无响应。这种底层函数写完以后就不要到处散落 HAL 调用后续所有代码统一走as7341_read_reg和as7341_write_reg。真正开始测量时流程是清空上一次数据发测量命令然后轮询状态寄存器。这里轮询方式比中断方式更省调试时间因为第一次跑通时中断引脚配置错了不会导致系统卡死最多是读数多等几十毫秒。bool as7341_measure_and_read(uint16_t *channel_values, uint8_t num_channels) { // 1. 触发一次测量 as7341_write_reg(0xAF, 0x01); // 2. 等待测量完成超时保护设为 200ms uint32_t tick HAL_GetTick(); while ((as7341_read_status() 0x01) 0) { if (HAL_GetTick() - tick 200) { return false; // 超时返回失败上位机可以立刻发现 } } // 3. 读取各通道数据每个通道占两个字节 for (uint8_t i 0; i num_channels; i) { uint8_t raw[2]; as7341_read_reg(AS7341_CH_BASE i * 2, raw, 2); channel_values[i] (uint16_t)((raw[1] 8) | raw[0]); } return true; }这段代码把读数据这件事抽象成了“触发、等待、读取”三步。判断测量完成的状态位是STATUS寄存器的 bit0低电平表示正在测量拉高表示数据就绪。超时保护是必须加的因为 I2C 线如果被外部干扰拉死while 循环就是死循环加一个HAL_GetTick()超时能保证系统不会失控。3.2 从原始计数到光谱强度量纲和比例关系很多第一次接触传感器的人拿到channel_values就直接拿去画柱状图但原始值之间不具备可比性不同通道的 ADC 满量程、暗电流和光学响应都不一样。AS7341 的原始值其实是一个无量纲计数正确的做法是把暗电流减掉再除以积分时间做归一化。我在工程里做了一组原始值到“相对光谱强度”的换算代码不长但很关键typedef struct { uint16_t raw_value; float intensity; } spectrum_channel_t; float as7341_calc_intensity(uint16_t raw, uint16_t dark, uint32_t integration_time_us) { // 暗电流是盖上遮光罩后测得的基底噪声通常几十到几百计数 int32_t valid (int32_t)raw - (int32_t)dark; if (valid 0) { valid 0; // 防止暗电流比信号还大出现负强度 } // 强度 有效计数 / 积分时间得到每秒计数便于不同曝光时间之间对比 return (float)valid * 1000000.0f / (float)integration_time_us; }参数里dark是每块板子实测出来的暗电流不能直接抄别人的数值。我一般把 AS7341 放到不透光的黑盒子里连续采 50 次取平均值写进一个dark_calib[8]数组里烧到 Flash。积分时间单位是微秒代码里乘 1000000 是为了把结果转成“每秒有效计数”。这样即使你换了光源亮度、改了积分时间输出的intensity数组也能和历史数据做趋势对比。真正用于颜色识别时比强度绝对值更重要的是八个通道之间的比例。两个强度值都涨一倍说明是光源整体变亮并不代表颜色变化。我在代码里加了一个归一化函数把F1~F8归一化到总和为 1void as7341_normalize(float *intensity, float *ratio, uint8_t len) { float sum 0.0f; for (uint8_t i 0; i len; i) { sum intensity[i]; } if (sum 0.001f) { return; // 光线太暗保持上一帧数据避免显示满屏零柱 } for (uint8_t i 0; i len; i) { ratio[i] intensity[i] / sum; } }归一化后的数组每一项的含义是“这个波段占整个可见光响应的百分比”。做颜色分类时比较的就是这个比例数组不是原始计数。这个设计是整个工程里我觉得做得比较聪明的地方它把环境光强弱的影响和颜色本身的差异剥离开了后续不管是识别红绿蓝还是做染色一致性判定都基于同一套比例数据不用再单独校准光照。4. 显示与颜色识别OLED 柱状图和阈值判别的落地实现4.1 OLED 上绘制八个通道的光谱柱状图拿到归一化比例之后显示层就有事可做了。OLED 屏用 I2C2 独立挂在第二条总线上驱动函数只需要一个短板接口设置像素点坐标然后填充色块。代码里绘制柱状图的逻辑非常朴素用每个通道的强度比例映射到屏幕高度。void oled_draw_spectrum(float *ratio, uint8_t channel_count) { // OLED 分辨率为 128x64横坐标按通道数量均分 uint8_t bar_width 128 / channel_count; for (uint8_t i 0; i channel_count; i) { uint8_t bar_height (uint8_t)(ratio[i] * 55.0f); // 留出底部文字区 uint8_t x_start i * bar_width; uint8_t y_start 64 - bar_height; for (uint8_t x x_start 2; x x_start bar_width - 2; x) { for (uint8_t y y_start; y 64 - 1; y) { oled_draw_pixel(x, y, 1); } } // 在柱子下方标注通道编号 oled_show_char(x_start 2, 56, 0 i); } }这里ratio[i]是上一章归一化出来的浮点数范围在 0 到 1 之间乘 55 是为了防止比例接近 1 时柱子画出屏幕。画像素采用双层循环而不是用现成的画矩形库一方面是因为 OLED 驱动为节省 Flash 没有做图形扩展另一方面是这种写法在任何屏幕驱动上都能平移过去。第 0 到第 7 个通道分别对应 F1 到 F8OLED 底部的数字能让观察者一眼区分波段顺序。实际运行时如果发现柱子全部顶到屏幕顶部先怀疑归一化数组是不是变成了单通道比例再怀疑是不是暗电流没减掉导致有效计数虚高。我第一次跑这块屏幕时看到八根柱子齐刷刷顶满排查了半天最后发现是驱动里把ratio[i]的精度用uint8_t传入了函数浮点数全被取整成 0 或 1。从那以后凡是跨度超过一帧的数据我都强制用浮点指针传。4.2 基于归一化向量的颜色分类阈值显示只是给人看的自动识别颜色才是产线要的功能。颜色分类这一层我选的是最简单的方案预先采集标准色卡把每个颜色对应的归一化数组存成模板然后把当前样本和每个模板做欧氏距离比较距离最短的就认为属于这个颜色。#define COLOR_TEMPLATE_COUNT 6 float color_distance(const float *a, const float *b, uint8_t len) { float sum 0.0f; for (uint8_t i 0; i len; i) { float diff a[i] - b[i]; sum diff * diff; } return sum; // 不开根号单调性不变省一点耗时 } uint8_t color_match(const float *sample) { // 模板表由色卡实测得到存在 Flash 里 extern const float color_templates[COLOR_TEMPLATE_COUNT][8]; float best_dist 999999.0f; uint8_t best_index 0; for (uint8_t i 0; i COLOR_TEMPLATE_COUNT; i) { float dist color_distance(sample, color_templates[i], 8); if (dist best_dist) { best_dist dist; best_index i; } } // 如果距离超过阈值认为是不认识的未知色 if (best_dist 0.05f) { return COLOR_UNKNOWN; } return best_index; }代码里的COLOR_UNKNOWN是一个特殊返回码用于处理样品颜色不在模板库里的情况。阈值 0.05 是我根据实际测试调出来的经验值样本与模板的欧氏距离低于 0.05 基本稳定识别高于 0.08 就容易把相似颜色混在一起。你也可以把模板从 6 个扩展到 20 个这个距离算法不需要改变。需要注意八个通道的权重目前是相同的如果某个通道对你要区分的颜色没有区分度可以在计算距离前乘一个权重数组避免噪声通道干扰判断。工程里还保留了一部分串口日志的输出在调试模式下每读一次数据就打印一次当前识别结果printf([SPECTRUM] F1%d F8%d MATCH%d DIST%.5f\r\n, sample_raw[0], sample_raw[7], match_index, best_dist);这行日志是我排查问题时最常用的抓手。硬件端改一次光源或换一块板子是否影响识别结果看DIST的变化趋势比看图像直观得多。如果DIST整体上涨说明你的模板库是在旧光源下标定的需要重新采一次色卡。5. 避坑记录AS7341 调试中遇到的五个典型问题5.1 问题一I2C 总线挂死程序阻塞在 HAL 库等待标志位现象代码跑在第一个HAL_I2C_Mem_Read就再也返回不了调试器暂停后看到程序停在HAL_I2C_Mem_Read内部的一个 while 循环上。原因这一般是 SDA 线被某个设备拉低或者 AS7341 模块没有正常上电总线处于异常状态。另一个常见原因是模块上的 I2C 地址引脚悬空地址在 0x38 和 0x39 之间漂移MCU 发出的设备地址没有器件应答。解决先把中断里的 HAL 超时参数从 100 改成 500再把手上的逻辑分析仪夹到 SDA 和 SCL 上看确认是否真的出现了 ACK。如果波形上没有 ACK就用 I2C 扫描程序遍历地址 0x00 到 0x7F把能应答的设备打出来。地址扫描完还没发现设备检查 ADDR 引脚是否被拉到了确定的电平我甚至遇到过模块批次默认电平不同的问题。5.2 问题二所有通道读回来的原始值全是 0x0000现象测量命令发了状态寄存器也显示完成但八个通道读出来是清一色的零。原因积分时间配置太短或者增益配置为 0光线又弱ADC 积出来的电荷量不足以形成有效信号。更常见的是初始化顺序错了先开启了测量再配增益导致增益配置没有真正写入。解决把积分时间寄存器调到最长档增益调到八倍先确认能读到一个非零值然后逐个降低积分时间和增益找到适合当前光照的中间值。改完配置后一定要重新触发一次测量不能指望上一次的配置自动流到本次。另外检查暗电流数组如果暗电流数值被错误地设置得比原始值还大减出来也会变成零。5.3 问题三同一块色卡每次读数偏差超过 20%现象传感器固定不动连续读 20 次F2 通道的原始值忽高忽低波动范围很大。原因环境光本身有频闪日光灯和劣质 LED 灯的 100Hz 频闪会让积分窗口采到不同亮度的光。另一种可能是积分时间过短恰好只采到了光源波动周期的一小段。解决把积分时间调长到 20ms 以上让一个积分窗口覆盖多个光源波动周期取平均值的效果就出来了。如果调长积分时间会让系统响应变慢就改成连续采 4 次取平均。注意这个平均操作要发生在原始值阶段发生减暗电流之前不要对已经过阈值判定的结果再平均。5.4 问题四OLED 柱状图和串口打印的识别结果对不上现象屏幕上八根柱子看起来像一个颜色但串口打印的结果却显示是另一个颜色。原因OLED 绘制和串口打印用了两套不同时刻的数据。绘制函数把ratio数组画在了屏幕上而打印函数用的是下一次测量得到的sample_raw两组数据相差一个测量周期如果物体在移动就会不一致。解决在主循环里只调用一次测量函数把测量结果存到同一个全局结构体里显示和打印都从这个结构体取数。我把这个结构体命名为spectrum_data_snapshot主循环里任何函数都只读这份快照不各自触发测量。这样调整以后屏幕和串口输出的一致性问题就彻底消失了。5.5 问题五换了一块开发板颜色识别阈值全部失效现象同一份源码烧到另一块 STM32 板上色卡识别率明显下降之前调好的阈值不再适用。原因每块板子的供电电压不同AS7341 的参考电压和 ADC 满量程随之漂移另外两块板子的 I2C 上拉阻值不同波形边沿斜率不一样也影响了采样抖动。这是硬件差异不是代码逻辑问题。解决换板子之后重新走一遍标定流程采集色卡模板和暗电流。把标定系数做成可配置项存到 Flash 的某个偏移区不同板子写上各自的标定值源码里保留默认值即可。从那以后我每次换板子的第一件事就是跑一次标定例程确认F1~F8的暗电流都在正面附近再继续往下做功能。6. 二次开发把光谱数据送进上位机替换掉难维护的阈值表6.1 串口输出和上位机收数据当前工程把颜色匹配逻辑放在 MCU 里模板表写死在 Flash 里。这个做法在数量少时没问题但如果要加色号就得重新烧固件让使用体验打折扣。我提供的改造思路是把 MCU 降级成纯采集器只负责按时输出 JSON 格式的光谱数据模板匹配交给上位机。printf({\ch\:[%.3f,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f],\time\:%lu}\r\n, ratio[0], ratio[1], ratio[2], ratio[3], ratio[4], ratio[5], ratio[6], ratio[7], HAL_GetTick());每次测量完就打印一行上位机在串口端解析 JSON。这样改动有两个好处一是增加模板不需要动 MCU二是上位机可以直接录一整段光谱数据回放排查问题不再需要手持板子贴近样品。波特率建议设 9216008 字节浮点数一行内容115200 也够但调试时会不断被打断。6.2 验证一把用脚本批量比对模板改动完以后我用上位机脚本做了 50 次重复测量对比新旧两套分类结果。脚本把每次测量的距离矩阵打出来看到所有正确识别样本的距离都小于错误样本的最小距离这条改线才算验收。从那以后我每次动标定相关的代码都强制重新走一遍这组流程先测暗电流再采模板然后连续测 20 次看漂移最后跑一遍距离矩阵。这套流程看起来笨但确实是踩了上面五个坑之后总结出来的护身符。希望帮到你。本文还有配套的精品资源点击获取