基于STM32的可见光通信系统设计与实现
简介本资源是一套基于STM32平台的可见光通信VLC完整嵌入式开发实践方案面向嵌入式开发者、物联网方向学生及光电通信初学者解决可见光调制解码、LED驱动控制与光信号收发系统集成等核心问题。压缩包含817个文件总计110.77MB涵盖156个C源文件含stm32f7xx_hal_tim.c等关键驱动、168个头文件h、156个编译中间文件o/d、154个Keil工程配置文件crf/uvprojx/uvoptx以及原理图sch、PCBbrd、Hex固件、调试脚本keilkill.bat等支撑从硬件接口配置、PWM编码实现、OOK调制解码到协议栈搭建的全流程开发。已有278人学习下载资源结构清晰包含F7系列完整Keil工程、HAL库适配代码、接收端电路设计参考及可直接烧录验证的二进制输出特别适合动手构建室内定位、低功耗IoT光通信原型的实践者快速上手与深度调试。1. 项目整体设计与思路拆解1.1 从“光的开关”到“信息的传输”很多人第一次听到“可见光通信”这个名词脑子里冒出来的第一个念头是“光还能传数据”第二个念头可能是“这和光纤通信有什么区别”。其实两者本质都是靠光来携带信息只不过光纤通信用的是不可见的红外激光在玻璃纤维里跑而可见光通信是直接利用我们日常照明用的LED灯通过让LED以极快的速度闪烁来传递二进制数据。这个闪烁速度远远超过人眼的感知范围所以在我们看来灯一直是亮的但接收端的光敏器件却能捕捉到这些明暗变化把它们还原成0和1。这个项目选择STM32作为主控芯片核心原因有三点。第一STM32的GPIO翻转速度足够快配合定时器能做到微秒级的精确延时这是可见光通信的基础。第二STM32的ADC配合DMA可以高速采样接收端的模拟信号不需要外加复杂的模拟解调电路。第三STM32的生态太成熟了标准库、HAL库的例程遍地都是无论你之前用的是哪个系列、哪个开发环境都能快速上手。我用的是STM32F103C8T6这块经典的“蓝 pill”板子72MHz主频足以应付几Kbps到几十Kbps的通信速率。整套系统的架构大概是这样的发送端由STM32控制一个LED的驱动电路把要发送的文本或传感器数据编码成脉冲序列通过LED的亮灭变化发出去接收端用光电二极管把光信号转成电信号经过运放整形之后送入STM32的ADC采样软件里再做解码还原出原始数据。这个设计最吸引我的地方在于它把课堂里学的通信原理真正落地了。你不再只是在示波器上观察正弦波而是亲眼看着一句话从一盏LED灯传到一米外的接收器上那种成就感是单纯跑代码给不了的。1.2 方案选型为什么不用蓝牙、Wi-Fi或红外做无线数据传输下意识的选择往往是蓝牙或Wi-Fi模块这也是大多数人第一反应。但可见光通信有它独特的价值它完全不需要射频天线和射频电路也就不存在电磁干扰和被探测到的风险。在一些对射频敏感的场景比如医院的手术室、飞机客舱、某些工业控制场所可见光通信反而是更合适的选择。和红外通信相比可见光通信的优势在于光源本身就能照明不需要专门的红外发射管而且光的覆盖范围可以通过灯具布局灵活控制光被挡住就断连这个特性天然具备一定的私密性。这里我也要说句实话这个项目并不会取代Wi-Fi它的意义更多在于让你理解通信系统的基本构成信源、编码、调制、信道、解调、解码。这些概念在射频通信里都是在黑盒子里完成的而在可见光通信里整个链路从发送到接收都是你自己写的代码任何一个环节出了毛病你都能直接定位这对嵌入式开发的功底提升非常大。1.3 通信速率的定位与预期管理不要把目标定太高。网上有些论文里做几十Mbps的可见光通信那是用了专用的LED驱动芯片、雪崩光电二极管、高速示波器采集卡、复杂的OFDM调制算法这些设备加起来可能比一辆二手奥拓还贵。我们要做的是一个“能跑起来、能看懂、能扩展”的演示级系统目标定在2Kbps到10Kbps就够了。换算一下10Kbps意味着每比特占100微秒这个时间尺度对STM32的GPIO翻转和ADC采样来说都绰绰有余对LED和光电二极管的带宽要求也不苛刻。普通LED的开关响应在纳秒级别根本不会成为瓶颈。真正限制速率的是接收端的模拟前端带宽和软件解码的容错能力这些我在后文会详细展开。2. 硬件选型与电路搭建要点2.1 发送端的LED驱动电路三极管开关就够用发送端的核心元器件只有一个LED和一个驱动三极管。STM32的GPIO输出能力只有几毫安直接推LED的话亮度不够通信距离会大打折扣。正确做法是用一个NPN三极管我用的是S8050做开关电路GPIO通过一个1K限流电阻接到三极管基极LED串一个限流电阻接在集电极和电源之间。这样GPIO只需要输出几毫安的基极电流就能控制集电极那边几百毫安的LED电流通断。LED我选的是白色高亮草帽灯发光角度大、响应速度快、价格便宜而且白色光的频谱覆盖范围宽接收端的硅光电二极管对它的响应度很好。限流电阻的计算公式是 R (VCC - V_LED) / I_LED假设VCC是5V白色LED正向压降约3V想让电流到100mA电阻就是(5-3)/0.1 20欧姆取一个常见的22欧姆。注意三极管饱和导通时的压降VCE(sat)大约是0.2V所以实际电流会比理论计算略小一点这个误差不影响使用。2.2 接收端的雪崩式难题光电二极管加跨阻放大接收端的核心是光信号变成电信号。最常用的器件是硅光电二极管工作在光伏模式时光照强度变化会引起反向电流变化。但问题来了这个电流非常微弱通常只有微安级别直接用STM32的ADC去采样根本采不到。解决办法是加一级跨阻放大器TIA把微弱的电流信号转换成电压信号。OPA340、LM358这些运放都能干这个活但LM358的带宽比较低高速信号会变形我实测下来推荐用OPA340或者更便宜的MCP6002单位增益带宽都在1MHz以上对几Kbps的信号完全够用。电路连接方式是光电二极管反接在运放的反相输入端和地之间运放的同相输入端接地反馈电阻从输出端接到反相输入端。反馈电阻的取值决定了增益我用的100K欧姆实测在1米距离、100mA LED电流的条件下接收端能输出约1V的峰峰值电压。如果距离拉远或者环境光干扰大可以换成200K或者470K欧姆。反馈电阻两端要并联一个小电容通常是10pF到22pF作用是抑制高频噪声和防止运放自激振荡。这个电容不加上去的话输出波形会有严重的振铃解码时很容易误判。2.3 环境光的处理别跟太阳硬刚第一次实验时我把系统放在窗边接收端波形乱七八糟完全没法解码。后来才意识到太阳光和室内照明灯的光强比LED信号大几个数量级光电二极管工作在很大的直流偏置点上信号完全被淹没了。解决办法有两层。第一层是光学层面的给接收端的电路加一个遮光罩用黑色热缩管把光电二极管套起来只在正前方留一个5毫米的小孔这样能滤掉大部分来自侧面和上方的环境光。第二层是电路层面的在跨阻放大器的输出端加一个高通滤波隔掉直流分量只保留交流信号。我用了一个1uF的电容串在运放输出和ADC输入之间配合一个10K的下拉电阻截止频率约16Hz人眼的灯光频闪100Hz都能被有效衰减。如果你想让系统在户外也能用那就要考虑在光电二极管前面加一个滤光片只允许和LED颜色匹配的光通过。比如用红色LED就配红色滤光片这样能把阳光中其他光谱成分挡掉不少。2.4 硬件清单与引脚分配如果你准备照着做我整理了一份硬件清单和引脚连接表省得你去翻数据手册。元器件型号/规格数量备注主控板STM32F103C8T6最小系统板1蓝Pill便宜量大LED白色高亮草帽LED1也可以串多个增强亮度NPN三极管S80501也可以用2N2222光电二极管蓝色滤光硅光电二极管1对可见光响应好运放OPA340或MCP60021TIA放大器电阻1K, 22Ω, 100KΩ, 10KΩ等若干按电路需求电容10pF, 1uF等若干反馈电容和高通面包板830孔1块原型验证够用USB转TTLCH3401接收端串口输出调试引脚分配我建议分成发送端和接收端两套板子来做调试的时候互不干扰。功能引脚说明发送端PA0LED驱动信号输出PWM或GPIO接收端PA1ADC输入采集运放输出串口PA9/PA10发送或接收数据打印按键PB0作为发送触发按键3. 软件核心编码、调制与解码的完整实现3.1 编码方案为什么用UART的帧格式做通信的第一步是确定数据的帧格式。最直接的办法是用STM32的UART外设把TXD引脚直接接到LED驱动电路上让UART自己产生起始位、数据位、停止位。接收端再用另一个UART的RXD引脚拾取信号理论上应该能解出来但实测效果很差原因在于UART对时序的容差要求比较高而可见光信道的信号沿不够陡峭导致采样点偏移。所以我改用软件模拟的方式自己定义了一个简单的帧格式8位数据1位起始位1位停止位无校验。这和UART的帧格式类似但我在起始位和停止位之间留了更宽的保护时间降低了解码难度。发送端的编码流程是把要发送的字符串转换成ASCII码每个字符8位按位发送。发送“1”对应LED点亮发送“0”对应LED熄灭。为了让接收端更容易检测到帧的起始我在每个字节前面发送一个较长的低电平作为前导码然后才是标准的起始位、数据位、停止位。3.2 定时器延时用DWT代替HAL_Delay很多新手写PWM都是用HAL_Delay死等但HAL_Delay的定时精度只有1毫秒而且会被中断打断对通信时序来说是致命的。我推荐的方案是使用DWTData Watchpoint and Trace模块这是ARM Cortex-M3内核自带的一个周期计数器精度是CPU主频的倒数72MHz下约13.9纳秒。DWT做延时有个好处是它不占用定时器资源而且延时精度是纳秒级的关键是不会被SysTick中断干扰。我在工程里封装了delay_us()和delay_ns()两个函数发送端每发送一个bit就用DWT延时一个固定的bit周期整个发送过程不依赖任何外设定时器。如果你用的是HAL库可以在SystemInit()之后直接打开DWT代码只有几行void DWT_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; }void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * 72; while (DWT-CYCCNT - start ticks); }延时函数的精度直接决定了通信速率的稳定性。比如你要实现10Kbps每个bit周期是100微秒延时误差不能超过±5%也就是±5微秒DWT的纳秒级精度完全满足要求。3.3 数据发送PWM与GPIO翻转的选择有两种驱动方式可以选。第一种是用定时器的PWM输出通过改变占空比来编码信号第二种是直接用GPIO翻转配DWT延时。两种我都试过PWM方式的优点是波形干净但缺点是修改占空比需要频繁操作定时器的比较寄存器在高位速率下容易产生时序抖动。GPIO翻转加DWT延时则更直接代码更简单也更容易控制发送的bit时序。我最后采用的是GPIO翻转方案。发送一个字节的函数大概长这样void send_byte(uint8_t data) { // 前导码拉低LED持续200us LED_OFF(); delay_us(200); // 起始位拉高LED持续100us LED_ON(); delay_us(BIT_PERIOD); // 数据位LSB first for (int i 0; i 8; i) { if (data 0x01) { LED_ON(); } else { LED_OFF(); } data 1; delay_us(BIT_PERIOD); } // 停止位拉低LED持续100us LED_OFF(); delay_us(BIT_PERIOD); }BIT_PERIOD取决于你目标的通信速率。我测试用的BIT_PERIOD是100微秒对应10Kbps这个速率在1米范围内能稳定解码。如果你想拉长距离就把BIT_PERIOD加大降到5Kbps换来的是一些抗干扰能力。3.4 接收端解码ADC采样的“过采样”策略接收端的核心在于如何准确判断每个bit是0还是1。方案有两种一种是用比较器电路把模拟信号转成数字方波再用STM32的输入捕获定时器去测量脉宽另一种是直接用ADC采样模拟信号在软件里设定阈值来判断。第一种方案电路更复杂需要额外加一颗比较器芯片第二种方案省电路但对ADC的采样率和DMA配置有要求。我采用了ADCDMA的方案思路是对每一个bit周期做多次采样然后取平均或取中位数作为判断依据这样能有效抵抗噪声尖峰干扰。比如BIT_PERIOD是100微秒采样周期设为10微秒一采样每个bit采样10次去掉一个最高值和一个最低值再把剩余8个值求平均。如果平均值大于阈值判定为1否则为0。ADC的配置要点是使用定时器触发ADC采样这样采样时刻是和bit边界严格对齐的。我用TIM2产生10微秒的更新事件触发ADC1的规则组采样采样结果通过DMA搬运到内存缓冲区里。DMA一次搬运200个点对应2000微秒也就是2毫秒的数据窗口这正好够接收一个20字节的完整数据包。这里有个容易踩的坑ADC的采样时间要设置合理太短会采不准太长会拖慢采样率。我设置的是ADC_SAMPLETIME_13CYCLES_5对应约0.46微秒的采样时间加上转换时间总共约1微秒比10微秒的采样周期快多了完全来得及。3.5 同步机制如何找到字节边界帧同步是软件解码里最容易翻车的地方。如果接收端不知道起始位在哪里那采出来的数据全是错位的。我的做法是利用前导码的特性发送前导码时LED保持熄灭接收端采样到的电平应该是一个持续的低电平。我在接收循环里持续监视ADC的采样值一旦检测到连续N个采样点都是低电平阈值以下就认为进入了前导码状态然后开始监测起始位的上升沿。起始位检测的代码逻辑是在前导码之后第一个采样值超过阈值的点就是起始位的开始从这个点开始往后推半个bit周期落到bit的中心位置再从这个中心位置开始每隔BIT_PERIOD采样一次连续采8个数据位最后再检查停止位是否为低电平。如果停止位不是低电平说明这一帧数据有误丢弃重收。实际调试时我发现接收端的采样时刻和发送端的发送时刻不可能完全同步总会有固定的相位偏移。所以我在每个bit的中心点采样而不是在bit的起点采样这样能把相位偏移的影响降到最低。这个思想和UART的过采样是完全一致的。3.6 数据包的组帧与CRC校验单个字节的收发能跑通之后接下来就要组数据包了。我自定义的帧格式分四部分帧头0xAA 0x55、长度1字节、数据N字节、CRC16校验2字节。帧头的作用是让接收端能够找到数据包的起始位置长度字段标记有效数据的字节数CRC16用来校验数据在传输过程中有没有发生位翻转。CRC16的实现不复杂标准的多项式是0x8005查表法最方便。网上有现成的查表法实现直接搬过来用就行注意高位在前还是低位在前要和接收端保持一致。我这里给出一个精简版的CRC16查表函数uint16_t crc16_update(uint16_t crc, uint8_t data) { uint8_t i; crc ^ data 8; for (i 0; i 8; i) { if (crc 0x8000) { crc (crc 1) ^ 0x1021; } else { crc crc 1; } } return crc; }数据包发送端通过串口助手或者OLED屏显示接收结果接收端在解析完一帧数据后通过串口打印到电脑上方便观察。3.7 环境光的周期性干扰软件滤波与动态阈值实验室里用的荧光灯和LED照明灯它们的频闪频率通常是100Hz左右经过整流桥后的脉动这在接收端会引入一个低频率的周期性干扰。如果通信速率太低比如1Kbps一个bit周期是1毫秒正好和100Hz的干扰周期重叠解码时很容易误判。解决这个问题的思路有两种。第一种是提高通信速率让光照频闪在一个bit周期内变化量非常小这个办法在速率高于5Kbps时就基本有效了。第二种是采用动态阈值不要用一个固定的比较阈值而是用一个缓慢跟踪平均值的滑动阈值。原理是当前ADC采样值减去滑动平均值得到的是交流分量再用这个交流分量做判决环境光的直流成分就被自动消除了。动态阈值的实现代码不复杂维护一个长度为32的环形缓冲区每采到一个新的ADC值就填入缓冲区并计算平均值判决阈值就是平均值加上一个固定偏移量。4. 调试过程与常见问题实录4.1 发送端波形正常、接收端全是噪声这是我遇到的第一个大问题。发送端的LED亮灭明显正常用手机慢动作拍摄能看到灯在闪但接收端ADC采到的数据完全看不出规律。排查过程是这样的先用万用表测量运放电源脚电压发现只有2.8V而运放要求最小3.3V。原来是面包板供电线接触不良导致运放工作在欠压状态输出摆幅被严重压缩。换掉供电跳线后波形立刻恢复正常。这个问题的教训是先量电源再查信号不要一上来就怀疑程序逻辑。电源不干净的话整个系统都不可能稳定工作。4.2 环境光变化导致的误码率飙升白天和晚上调试同样的代码晚上的误码率明显低白天经常出现解码错误。原因是白天环境光强光电二极管的暗电流和光电流都偏大使得运放输出的直流偏置点接近电源轨交流信号摆幅被压缩。解决办法是给接收端加一个自动增益控制最简单的做法是用电位器手动调节反馈电阻的阻值。我后来换成了数字电位器MCP4131通过SPI接口调整反馈电阻从10K到100K实现了软件层面的增益控制。实际效果很明显正午阳光直射时缩小增益光照平稳时放大增益误码率控制在极低水平。如果你不想加数字电位器还有个土办法是把接收端的遮光罩做长一点减小视场角。这个方法简单粗暴但效果还不错。4.3 HAL_Delay卡死之谜有朋友参考我的代码时反馈说发送数据的时候程序总是卡在延时函数里感觉像死循环。我一看他的代码发现他在HAL库工程里用了HAL_Delay而HAL_Delay依赖SysTick中断。如果之前初始化了某个外设时不小心关闭了SysTick或者SDK的启动代码默认的状态有差异HAL_Delay就会一直卡在while循环里等标志位。这就是为什么我更推荐用DWT做延时。DWT不依赖任何中断不会出现这种莫名其妙卡死的现象。如果你还是想用HAL_Delay千万记得确保SysTick中断是开启的并且在中断服务函数里调用HAL_IncTick()。4.4 接收端数据错位第一个字节对不上数据错位这个问题非常典型现象是整包数据里每一个字节的bit顺序都不对看起来像是所有bit都左移了一位或者右移了一位。原因分析后发现问题出在起始位检测的相位偏移上。我原先是检测到上升沿后立即开始采样第一个数据位但实际上上升沿之后数据线上还可能有一个短暂的拖尾导致第一个数据位的采样点偏早。解决办法是在检测到上升沿后延时半个bit周期跳到bit中心再开始采样每两个采样点之间刚好间隔一个bit周期。这个“半个bit周期”的补偿在实际调试中非常关键。4.5 通信距离上不去的瓶颈我的系统一开始只能稳定通信20厘米超过30厘米就丢包。排查下来发现瓶颈不在LED的亮度而在接收端的灵敏度。20厘米处光电二极管接收到的光功率大约是30微瓦跨阻放大器输出的信号峰峰值只有200毫伏ADC的量化噪声已经占了不小的比例。我做了三处改进通信距离立刻提升到1米以上。第一把反馈电阻从100K加大到470K增益提升到原来的5倍。第二在运放输出端到ADC输入之间加了一级RC低通滤波截止频率设为30KHz滤掉了大部分高频噪声。第三在光电二极管前面加了一个聚光透镜用放大镜老花镜片就能凑合聚焦后的光信号可以增强三到五倍。4.6 常见问题速查表现象可能原因解决办法接收端无信号运放电源异常先测电源确认运放工作电压波形噪声大缺少反馈电容或RC滤波在反馈电阻两端加10pF电容白天误码率高环境光直流偏置过大加遮光罩、加透镜、增大增益解码数据错位起始位采样点太早延时半个bit周期再采样程序卡死在delay用了HAL_Delay且SysTick异常改用DWT延时函数通信距离短接收端灵敏度不够加大反馈电阻、加聚光透镜帧头找不到前导码长度不够拉长前导码低电平时间5. 性能优化与后续扩展方向5.1 提高速率的策略从OOK到PPM基础版本用的是OOKOn-Off Keying调制也就是亮代表1、灭代表0一个bit周期内只有一个状态这个方案实现简单但频谱效率低。如果想让通信速率翻倍可以考虑PPMPulse Position Modulation调制把1毫秒的时间窗口分成4个时隙光脉冲出现在第一个时隙代表00、第二个时隙代表01、第三个时隙代表10、第四个时隙代表11。这种方式一个时隙窗口可以传递两个bit的4种状态同样时间内信息量翻倍。PPM的解码需要在接收端做时隙同步复杂度和OOK不在一个量级但对STM32来说仍然可以承受。我的经验是先用OOK跑通链路再挑战PPM这样思路更清晰。5.2 多路复用与全双工通信一个LED只能单向传输如果想做双向通信可以用两个LED两个光电二极管一发一收互不干扰。如果你想让一个LED同时传输多路信号可以用不同频率的副载波调制比如10KHz的副载波和20KHz的副载波分别携带两个声道的数据接收端用带通滤波器分离后再解调。这个方法在室内定位系统里比较常见。STM32F103的DSP性能有限2048点FFT在72MHz下大约需要几毫秒做实时解调会有些吃力。如果要做多路副载波建议直接用STM32F4系列或H7系列硬件FPU对FFT加速的效果非常明显。5.3 更远距离的探索光学天线与自适应增益如果需要几十米的通信距离LED要换成大功率COB光源光电二极管要换成雪崩光电二极管APD加前置放大器接收端还要加光学天线。一个简单的光学天线就是一个菲涅尔透镜能把大范围的光聚焦到APD的感光面上。我见过有人用老式CRT电视机屏幕前面的菲涅尔透镜做光学天线效果出奇地好。自适应增益控制在远距离通信里很重要。光强、距离、天气都会改变接收信号的幅度如果增益固定要么小信号被淹没在噪声里要么大信号把运放推向饱和。我上面提到的MCP4131数字电位器加上一个简单的自动增益控制算法就能实现从10K到470K的增益动态调整。5.4 把可见光通信和智能家居结合最后聊一个我觉得很有意思的扩展方向把可见光通信模块嵌入到智能台灯里。目前很多智能台灯用Wi-Fi或蓝牙做控制如果把通信链路换成可见光手机摄像头对准灯光就能传输配置数据不需要网络环境也不用额外配网。虽然传输数据量有限但足够传一些控制指令、设备ID、密钥初始化的信息。我在实际测试中就做过一个demo把一段文字编码后通过台灯的LED发出去手机摄像头以60fps拍摄灯光的明暗变化再用图像亮度序列解码。手机每秒最多采60帧对应比特率只有30bps左右但传一段几十字节的设备配置信息完全够用。这个方向还带来了一个附加价值——光通信用作一种低成本的近场通信手段可以用于室内设备认证和服务发现。要说得更直白一些可见光通信本质上是用光作为介质解决“最后一米”的通信问题把它和现有无线方案结合比单纯把一盏灯变成路由器更有工程实用价值。而在做的过程中你会对通信原理、嵌入式时序控制和硬件调试产生新的理解这比跑通一个demo本身更有意义。本文还有配套的精品资源点击获取