
简介本资源是一套基于STM32平台实现可见光通信VLC的完整嵌入式开发工程面向嵌入式开发者、物联网方向学生及光通信初学者解决可见光调制解码、LED驱动控制、光电信号处理与轻量级协议栈构建等核心实践问题。压缩包含817个文件涵盖156个C源码.c、168个头文件.h、156个编译中间文件.o及大量工程配置文件.uvprojx/.uvoptx、硬件设计文件.brd/.sch、调试输出.axf/.hex和HAL库驱动代码总大小110.77MB结构完整支持Keil MDK直接编译调试。已有278人学习下载资源包含STM32F7系列主控的TIM-PWM精确调光实现、OOK/FSK编码逻辑、光电接收前端信号调理代码、以及配套的keilkill.bat等实用工具脚本目录组织清晰便于分模块理解VLC物理层与链路层协同机制。 很多人第一次听说“可见光通信”的时候第一反应都是光也能传数据是不是拿个手电筒晃一晃就算传数据了其实还真差不多是这个意思只不过要把“晃”的频率提高、把编码规则定义好才能让对端STM32稳定收到信息。我自己这个项目就是用STM32加一颗LED把数据发出去再用光敏器件把光信号收回来解调实现了一块板子到另一块板子的无线传输。整个过程不涉及Wi-Fi、不涉及蓝牙就是纯粹的光。这篇内容我会从链路搭建、硬件选型、STM32端的编码与解码、以及实测中踩过的坑一路讲到可以复现的程度适合正在学STM32、想做点不一样的小项目或者对Li-Fi这类技术感兴趣的朋友参考。1. 项目整体思路用一盏LED把数据“照”出去1.1 可见光通信链路是怎么搭起来的可见光通信的链路其实和红外遥控非常相似核心就是把二进制数据变成光的亮灭变化然后用接收端把光的强弱变化变回电信号最后解码出原始数据。只不过红外遥控用的是人眼看不见的红外光这里直接用可见光LED看得见、也更好调试。一条完整的单向链路包括四块发射端STM32负责把数据编码成脉冲信号。驱动电路用三极管或MOS管控制LED快速开关。光通道LED发出的光经过空气传播到达接收端。接收端光敏二极管或光敏电阻把光信号转成电信号再通过比较器整形送进STM32解码。这里最容易忽略的一点是LED的亮灭频率远高于人眼能感知的范围之后人眼看到的就是“常亮”但接收端却能够准确识别出光强的变化。这和手机屏幕的PWM调光原理其实是同类思路。理解了这一点整个项目的基本逻辑就清楚了关键在于“快”和“稳”快是指LED的开关速度要跟得上波特率稳是指接收端能在环境光干扰下识别出信号光。1.2 调制方式怎么选OOK、PWM编码和曼彻斯特我一开始做这个项目的时候第一版直接用的最简单的OOK调制也就是On-Off Keying发“1”的时候LED亮发“0”的时候LED灭。这种方式的优点是代码简单GPIO拉高拉低就行缺点也很明显同步性差接收端不知道每个bit从哪里开始而且连续发多个0的时候LED一直灭电路状态很容易受环境光漂移影响。所以第二版改成了PWM编码也叫脉宽编码。这种方式的思路是用脉冲宽度来表示数据比如定义1个时间单位宽度的脉冲代表“0”2个时间单位宽度的脉冲代表“1”。接收端只需要测量每个脉冲的宽度就能解出数据。这样做的好处是接收端可以自动同步但坏处是数据速率受最长脉宽限制而且对定时精度要求比较高。最终我采用的是曼彻斯特编码这种编码在局域网和RFID里非常常见。规则很简单每个bit的中间一定会发生一次跳变上升沿表示“0”下降沿表示“1”。由于每个bit都有跳变接收端可以提取时钟同步信号也不存在长串0或长串1导致的直流漂移问题。三种方式对比下来我的建议是调制方式实现难度抗干扰能力同步方式适用场景OOK最简单弱需额外帧头玩具Demo、学习验证PWM脉宽中等中自带脉宽同步低速稳定传输曼彻斯特稍复杂强每bit自同步实际项目推荐1.3 为什么选STM32而不是Arduino或者纯逻辑电路选择STM32的原因其实很实在。第一是STM32的定时器资源非常丰富可以同时承担PWM输出、输入捕获、编码器模式等多项任务。做可见光通信发射端需要精确的PWM信号接收端需要精确测量脉冲宽度这两件事用STM32的定时器硬件就能完成不需要靠CPU死等。第二是STM32的中断系统足够灵活。接收光信号时外部中断配合输入捕获可以在微秒级别响应脉冲跳变这让高速解码成为可能。如果用Arduino虽然也能做但它的定时器只有一个16位通道数量也少灵活性和精度都差一些。第三是后续扩展空间大。整个项目调试过程中需要串口打印数据、ADC采集环境光强度、甚至挂个OLED显示波形参数STM32的资源完全够用不会出现刚做一半发现外设不够的局面。这个项目我用的是STM32F103C8T6也就是大家常说的“蓝丸”核心板价格便宜、资料多适合起步。1.4 频率与波特率的匹配估算很多新手做这类项目最大的问题是想到什么频率就用什么频率完全不计算。结果要么LED的开关速度跟不上要么接收端根本采不到信号。这里我算一个具体的例子。假设我准备用9600bps的波特率传数据每个bit的时间是1 / 9600 约等于 104微秒如果用曼彻斯特编码每个bit中间要跳变一次那接收端面对的最高信号频率是9600乘以2 等于 19200Hz也就是19.2kHz这个频率对LED来说完全没问题普通照明级LED的开关响应在几十kHz甚至MHz级别都能跟上。真正的问题在接收端如果选光敏电阻做接收普通光敏电阻的响应时间在10毫秒到几十毫秒级别换算成带宽只有100Hz左右根本解不出19.2kHz的信号。所以要么把波特率降到几百bps要么把接收端换成响应速度更快的光敏二极管。这个计算过程是可见光通信项目里最关键的“为什么”之后再选择器件和波特率心里就有底了。2. 硬件电路设计发射端与接收端到底怎么接2.1 发射端一颗三极管搞定LED开关发射端电路不复杂但有几个细节会影响可靠性。STM32的GPIO引脚输出高电平是3.3V而LED一般需要20mA左右的驱动电流直接接GPIO虽然也能亮但驱动能力不够而且开关速度上不去。所以需要加一级三极管或者MOS管放大。我用的方案是S8050三极管NPN型便宜而且饱和压降低。电路接法是这样的STM32的PB0引脚通过一个1k电阻接S8050的基极。S8050发射极接地。集电极接LED的负极。LED正极接一个限流电阻限流电阻再接到5V电源。基极串联的1k电阻是为了限制基极电流防止IO口过流。限流电阻的阻值需要计算。假设LED正向压降约3.2V白光LED典型值电源5V目标电流20mA那么(5V - 3.2V) / 0.02A 90Ω实际取100Ω电流约18mA足够亮也不至于超功耗。需要注意的是如果用STM32的3.3V供电而不是5V限流电阻要用更小的值否则驱动电流不够LED亮度低传输距离会明显缩短。如果想让开关速度更快可以把三极管换成AO3400这类N沟道MOS管栅极直接接STM32引脚也能驱动开关损耗更小适合后面想把波特率往上推的场景。2.2 接收端光敏器件选型与信号整形接收端是整个项目中最容易出问题的地方。刚开始我用的是光敏电阻加分压电路直接接到STM32的ADC引脚希望通过判断ADC值的大小来识别0和1。试下来发现一个严重问题光敏电阻响应太慢波形上升沿和下降沿都是斜坡到了几十bps以上就完全没法识别了。后来我换成了“光敏二极管加比较器”的方案。具体接法光敏二极管反向接入电路也就是阴极接5V阳极通过一个电阻接地。光线越强光敏二极管反向漏电流越大电阻上的压降就越大。把这个压降信号接到LM393比较器的同相输入端。比较器的反相输入端接一个电位器用来调节参考阈值。比较器输出端接STM32的EXTI引脚。LM393是开漏输出所以输出端必须接一个上拉电阻到3.3V否则输出电平飘忽不定。这个上拉电阻我选的10kΩ兼顾速度和功耗。比较器的作用是把模拟信号变成干净的数字方波光敏二极管感受到的光强超过阈值时输出高电平低于阈值时输出低电平。这样STM32的引脚就不会收到一堆中间状态的模拟电压而是直接得到0和1的序列。如果不想自己搭运放电路也可以直接买一些光敏传感器模块比如用LM393比较器加光敏电阻的模块原理一样但响应速度依然受光敏电阻限制。追求速度的话建议找基于光敏二极管的模块或者按上面的电路自己焊。2.3 硬件避坑环境光、供电与布局这部分是我实际调试过程中最头疼的。第一次在白天窗边测试接收端完全乱码原因是自然光里的红外线和可见光成分在光敏二极管上产生了很强的直流偏置把信号淹没了。解决办法有三个机械遮光给接收端套一个黑色热缩管或者3D打印的遮光罩只留一个朝向发射端的小孔把环境光挡掉大部分。软件阈值自适应后面会详细讲用ADC实时采集背景光强度动态调整判断阈值。交流耦合在比较器前端加一个高通滤波器把缓慢变化的直流光和低频环境光滤掉只保留高频信号光。这是比较电子的做法效果最好。供电方面要注意LED瞬间开关会在电源线上产生电流尖峰如果STM32和LED共用同一个电源可能会引起复位或者ADC采样抖动。我在做的时候是LED单独用5V供电STM32通过AMS1117降压到3.3V两者共地但不共用电源轨实测稳定很多。还有一种做法是给LED驱动加一个大电容做储能让开关瞬间的电流冲击从电容里走不在电源线上产生大压降。布局上尽量让LED驱动电路远离接收端的模拟电路因为它们之间会产生空间耦合。信号灯和接收管之间如果靠得太近接收端会直接收到发射端的电路辐射干扰而不是经过光通道的信号。我一开始把两块电路板正面贴正面测试结果怎么调都不对拉开距离到10厘米以上就正常了。2.4 材料清单为了让你少走弯路我列一个完整清单全部可以在常用元器件店买到器件型号/规格数量用途STM32开发板STM32F103C8T6核心板2块发射端和接收端各一块LED白光高亮LED1个光信号发射三极管S80501个LED开关驱动光敏二极管PIN光敏二极管1个光信号接收比较器LM3931个信号整形电阻100Ω、1kΩ、10kΩ若干限流、上拉、分压电位器10kΩ1个比较器阈值调节遮光管黑色热缩管1段遮挡环境光面包板或洞洞板若干电路搭建3. STM32侧软件实现编码、发射与接收3.1 发射端代码思路PWM与定时器配合发射端的核心任务是把要发送的数据按照曼彻斯特编码规则变成引脚上的高低电平变化。为了不占用CPU太多时间我用的是定时器PWM输出配合DMA或者中断来更新占空比。先说思路。假设我配置定时器3的PWM输出频率为波特率的两倍也就是19.2kHz对于一个9600bps的曼彻斯特编码信号每个数据bit在时间轴上占据两个PWM周期。比如发送“0”电平在一个bit时间内从高变低也就是先输出一个周期的PWM高电平再输出一个周期的PWM低电平发送“1”则反过来先低后高。利用HAL库配置PWM输出的代码大致是这样// 定时器3 PWM输出初始化频率19.2kHz // 这里使用STM32CubeMX生成基础配置 TIM_OC_InitTypeDef sConfigOC {0}; sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 50; sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1);然后写一个发送函数根据当前要发送的0/1序列动态修改比较寄存器的值。如果在中断里修改CCR值需要小心处理时序防止在PWM计数过程中修改造成毛刺。一个比较实用的做法是使用DMA发送整个波形表把一帧数据的电平序列预先算好然后通过DMA不断向CCR写入新值CPU只负责填充缓冲区和启动传输。我实际使用的是定时器更新中断在中断回调里判断当前发送到第几个bit然后根据曼彻斯特编码规则置高或者置低对应的定时器通道。这样代码直白也方便后面调整编码格式。3.2 接收端代码思路外部中断加输入捕获测脉宽接收端的第一步是用外部中断检测比较器输出的跳变沿。每个上升沿或者下降沿代表曼彻斯特编码中bit中间的那个跳变。我用的方案是定时器输入捕获这样能把跳变发生时的计数器值记录下来自动计算脉宽不用CPU去反复读计数器。具体做法是把接收引脚映射到定时器2的通道1使能输入捕获和上升沿/下降沿中断。在中断回调中读取当前捕获值和上一次捕获值差值就是两个沿之间的时间也就是半个bit或者一个bit的时长。根据这个时长判断当前电平持续了多长时间从而还原出0和1。这里有一个关键点需要在代码中处理如果电平持续时间太长超过了定时器溢出周期会导致捕获计数归零差值为负数或者异常小。解决办法是把定时器配置为1MHz计数频率定时器溢出时间设置为10ms以上这对应非常低的波特率余量。如果用TIM216位定时器在1MHz下最多计65535微秒也就是约65ms足够覆盖慢速信号了。如果波特率很高、两个沿之间时间很短则不需要担心溢出问题。使用HAL库时输入捕获的启动方式如下HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1);然后在回调函数里取计数差值void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { uint16_t current HAL_TIM_ReadCapturedValue(htim2, TIM_CHANNEL_1); uint16_t diff current - last_capture; last_capture current; // 根据diff解析数据 } }需要注意中断回调里尽量不要做复杂的解码运算否则会影响下一次捕获。我的做法是在中断里只把diff存到一个环形缓冲区主循环里再取出解析。3.3 数据帧协议与编解码通信协议是保证数据可靠传输的核心。我定义了一个简单帧格式前导码0xAA 0xAA 0xAA用于让接收端锁定时钟和同步。帧起始标志0x7E。数据长度1字节表示后续数据字节数。数据载荷若干字节。校验CRC8或者简单的异或校验。发射端发送时先把帧拼接好再进行曼彻斯特编码。接收端解码时先从前导码中恢复时钟然后检测0x7E定位帧头再按照固定长度读数据最后做校验。编码函数不用太复杂核心逻辑可以写成void manchester_send_byte(uint8_t byte) { for (int i 0; i 8; i) { if (byte 0x80) { // 发送“1”先低后高 set_gpio_low(); delay_half_bit(); set_gpio_high(); delay_half_bit(); } else { // 发送“0”先高后低 set_gpio_high(); delay_half_bit(); set_gpio_low(); delay_half_bit(); } byte 1; } }这里 delay_half_bit 可以用定时器延时或者DWT延时。如果用HAL_Delay精度到毫秒级对9600bps来说误差太大必须用微秒级的延时。STM32F103可以用DWT - CYCCNT寄存器实现精确延时也可以在CubeMX里配置一个基础定时器做us级延时。解码端的核心是状态机。每收到一个跳变根据当前状态判断是数据还是干扰然后按bit拼接字节。这个状态机需要处理三种情况正常曼彻斯特跳变、噪声引起的毛刺、帧头检测。我建议大家在编码和解码的时候都加上调试输出通过串口把接收到的脉宽时间打出来这样调试比瞎猜效率高很多。3.4 代码工程结构建议工程结构虽然不直接影响功能但直接影响你后期改代码的心情。我的建议是拆成四个模块bsp_led.cLED驱动相关的GPIO和PWM初始化。bsp_photo.c光敏二极管接收、比较器输入捕获初始化。manchester.c曼彻斯特编码和解码状态机。app_main.c业务逻辑比如读取传感器数据、组帧、解析帧、串口打印。如果你用的是HAL库建议直接从STM32CubeMX生成工程把时钟、串口、定时器、GPIO都配好再往工程里加自己的模块。如果用标准库模板工程要自己搭耗时会多一些但代码逻辑更直观。两种方式我都试过个人建议新手直接用CubeMX加HAL库把更多精力放在协议调试上。4. 实测记录与问题排查4.1 上电测试流程和观测点焊接好电路之后不要急着接光电通信先分模块测试。我的测试流程是这样的第一步用杜邦线把STM32的PB0手动拉高拉低观察LED是否正常亮灭。第二步用手机摄像头对准LED发送一个慢速的方波信号通过手机屏幕看到LED有肉眼不可见的闪烁说明发射通路正常。第三步把LED用手电筒或者手机闪光灯对准接收端用万用表量比较器输出引脚看是否能从低电平跳变到高电平。第四步把发射端STM32和接收端STM32连接好让接收端执行最原始的“收到高电平就点亮板载LED”的测试代码确认整个链路是通的。第五步再开始跑曼彻斯特编码解码代码用串口把接收到的数据打出来。前四步如果哪一步不正常就不要往下走。很多问题最后查出来都是硬件接触不良或者电源没接好白白浪费了很多时间。4.2 排查实录串口乱码、误码距离上不去我在调试过程中遇到最典型的问题有两个。第一个问题是串口打印出来的接收数据一直是乱码偶尔正确一个字节。用示波器看接收端波形发现波形的边沿不是干净的方波而是带有明显的振铃和缓坡。因为LM393比较器本身没有迟滞功能信号在阈值附近来回抖动导致一个跳变被误识别成多个跳变。解决办法是在LM393的输出端和同相输入端之间加一个几百kΩ的正反馈电阻形成迟滞比较器让上下阈值分开这样信号一旦翻转就不会在边缘反复抖动。迟滞电阻我用的220kΩ实测效果很好。第二个问题是传输距离一直上不去超过10厘米就开始丢包。排查下来发现LED使用的是普通5mm白光LED发光角度太大光能分散得厉害。我换成了聚光型LED并且在接收端前面加了一个凸透镜聚焦把距离提升到了50厘米以上。如果想再远可以换更大功率的LED比如1W的仿流明灯珠配合恒流驱动。但要注意功率上去后LED发热增加PWM频率太高可能影响寿命。4.3 参数调优阈值自适应与滤波环境光变化是可见光通信最麻烦的干扰源。白天和晚上、开灯和关灯接收端的背景光强完全不一样固定阈值只能在一个环境下工作。我后来做了一个自适应阈值方案每隔一段时间在接收端空闲时用ADC采样光敏二极管输出的平均电平把这个值作为比较器的参考基准动态调整判断阈值。STM32的ADC加上DMA扫描可以很轻松完成多通道采样而且不占CPU时间感兴趣的话可以参考“STM32 ADC多通道扫描循环采样DMA”的配置方式。软件上还可以加一个简单的数字滤波比如连续三次采样一致才认为电平有效滤掉窄脉冲毛刺。曼彻斯特编码本身对毛刺有一定容忍度因为合法的跳变间隔都是规律的如果收到的脉宽明显异常就直接丢弃。4.4 常见问题速查表现象常见原因解决办法接收端完全无反应比较器供电或上拉电阻缺失检查LM393输出上拉到3.3V串口输出乱码比较器无迟滞边缘抖动增加220kΩ正反馈电阻距离只有几厘米LED发光角太大换聚光LED或加透镜白天阳光下无法工作背景光太强直流偏置饱和加遮光罩或做交流耦合高频时误码率高光敏电阻响应慢换光敏二极管接收接收端偶尔复位LED电流尖峰干扰电源LED与STM32分开供电帧头能抓到但数据错波特率偏差或时钟未同步检查编码函数延时精度改用输入捕获测脉宽5. 还可以往哪些方向扩展5.1 从单向到双向通信我目前做的是单向链路发射端只管发接收端只管收。实际使用中双向通信更有意思。比如两个STM32节点各带一个LED和光敏接收管采用半双工模式定义主从关系主节点发送数据后切换到接收模式等待从节点应答。这和RS485通信的逻辑非常像只是传输介质从线缆换成了光。我建议先把单工链路调稳再考虑双向否则收发切换的时序会让调试难度翻倍。5.2 结合传感器做室内光通信节点可见光通信很适合和传感器结合做成“有光就有数据”的智能化场景。比如做一个基于STM32的智能台灯LED既负责照明又周期性向外发送温湿度数据放在桌上的接收节点收到数据后显示在OLED屏幕上。开灯的同时就完成了数据分发这种体验比单独拉一根串口线有意思得多。想做得更复杂的可以引入FreeRTOS接收解码任务、传感器采集任务、串口打印任务分时运行系统扩展性会好很多。加上Wi-Fi模块之后还能把光通信收到的数据转发到本地网络形成“最后几米的光接入”方案。5.3 需要再深挖的方向如果想把可见光通信做成一个真正有说服力的作品有几个方向值得深挖一个是OFDM调制把多个频率的光信号叠加在一起传输频谱效率成倍提升但F103的算力可能不太够需要换更高主频的芯片另一个是纠错编码比如汉明码或者CRC重传机制能在丢包率较高的可见光链路上显著提高可靠性还有一个是室内定位利用多个LED的位置信息和接收光强通过三角定位估算接收端的位置这一块在学术和工程上都很热。给第一次做这个项目的人的三点建议做可见光通信和我之前做普通串口通信、I2C传感器最大的不同在于它没有一个稳定的有线通道信号在空气里传播时会被各种因素干扰。这也正是它有意思的地方。根据我自己的实操经验第一次做的话先不要追求高速率把波特率设在1200bps到2400bps之间用最简单的曼彻斯特编码跑通链路再一步步优化。等你看到接收端串口里正常打印出“Hello Li-Fi”的一瞬间会觉得之前熬夜调阈值都是值得的。不要怕把电路改来改去这个项目里示波器看到的每一种古怪波形都是对通信原理的一次直观理解。本文还有配套的精品资源点击获取