公司动态
基于STM32的可见光通信:从硬件电路到曼彻斯特编码实现
简介本资源是一套基于STM32F7系列微控制器的可见光通信VLC完整嵌入式实现方案面向嵌入式开发初学者、光电通信方向学生及物联网系统开发者解决可见光调制解码、LED驱动控制与光信号接收处理等核心工程问题。压缩包共817个文件涵盖156个C源文件含stm32f7xx_hal_tim.c等关键驱动、168个头文件h、156个编译中间文件o及配套Keil工程文件uvprojx/uvoptx、PCB设计文件brd/sch、调试配置dbgconf和可执行镜像hex/axf整体达110.77MB结构完整覆盖硬件驱动、PWM编码、OOK调制、前端信号调理与基础协议栈构建。已有278人学习下载资源包含可直接编译运行的Keil工程、LED亮度动态控制脚本keilkill.bat、多层级调试支持STM32CubeMonitor兼容及典型室内定位与点对点通信场景适配代码助读者快速掌握VLC系统从原理到落地的全链路开发能力。 我最早接触“可见光通信”这四个字纯粹是因为一次被逼无奈实验室里几块STM32板子放在同一张桌上两个无线模块稍微靠近一点就开始互相丢包金属柜子一挡信号更是直接断连。当时正好翻到一篇关于Li-Fi的论文心里一动——既然LED灯可以高频翻转而STM32的定时器外设又快又稳那能不能干脆让光来“说话”于是就有了这个基于STM32的可见光通信项目简单说就是用LED灯把数据变成光脉冲发出去再用光电二极管把光脉冲收回来转成电信号STM32负责整条链路的编码、发送、接收和解码。如果你手头正好有一块STM32想从零体验一下光通信是怎么跑通的或者准备做室内定位、水下短距传输、电磁敏感环境下的数据交互这篇文章应该对你有用。我会把为什么选STM32、硬件电路怎么设计、为什么不能直接拿串口去推LED、软件怎么拆解成发射和接收两端以及我实际调试中踩过的一堆坑全部写清楚。有的地方我会直接给代码和参数有的地方我会说明设计思路尽量让基础差一点的朋友也能照着做出来。1. 我为什么决定用STM32做可见光通信1.1 从无线干扰困境到换一种传输思路可见光通信的核心并不神秘把需要发送的数据调制到LED的亮灭变化里光在空气中传播到接收端光电二极管把光信号还原成电流再经过放大和整形变成单片机认识的电平。整个过程和红外遥控有点像区别在于我们用的是可见光而且频率和协议完全由自己定。我当时的场景非常具体桌面上几台STM32采集节点要通过无线模块回传数据结果无线信道被隔壁工位的蓝牙音箱和USB 3.0设备干扰得乱七八糟。光通信的好处在这种环境里格外明显——光不会穿透墙壁也不会和周围的射频设备“抢厕所”链路之间天然隔离安全性也更好。再加上LED和光电二极管都很便宜用STM32的定时器和GPIO就能搞定基础收发不需要额外买射频前端或者外挂专用调制芯片。1.2 可见光通信能做什么不能做什么先把丑话说在前面可见光通信不是万能的它适合特定场景不适合跟Wi-Fi比赛跑。我自己做过简单测试在无遮挡、室内灯光不直射接收管的情况下用普通LED可以实现几十厘米到一两米的稳定传输数据速率从几kbps到几十kbps是这个方案最舒服的范围。再往上提高速率就要面临LED开关速度、光电二极管带宽、环境光噪声、PCB布局等一系列问题不是单片机换个时钟就能解决的。它适合的场景包括室内定位每个LED发不同的ID手机或者接收端根据光信号判断位置电磁敏感环境医院设备附近、变电站机房、实验室里对射频敏感的区域水下短距通信射频在水下衰减严重但蓝绿光穿透性相对好低成本短距点对点两台设备之间不想走线也不想用无线模块。它不适合的场景也很明显隔墙传输、超远距离、高带宽视频流、非视距复杂环境。这些短板决定了它更适合做“最后一米”的补充链路而不是替代主流无线通信。1.3 为什么是STM32而不是Arduino或专用芯片一开始我也考虑过用Arduino毕竟上手快。但实际算下来Arduino的GPIO翻转速度和定时器精度都不太够做低速的LED闪烁串口通信还行真要做曼彻斯特编码、定时采样、输入捕获解码就会频繁卡在时序上。专用VLC芯片确实存在比如一些公司做的Li-Fi收发模块性能好但是贵而且生态封闭想调参数非常不方便。对于学习还是做原型验证来说STM32的定位刚好定时器资源多基本定时器、通用定时器、高级定时器各有各的用途中断响应快NVIC可配置优先级收发关键路径可以做到微秒级响应GPIO翻转频率够用F103这种老平台都能轻松跑到几十MHz做几十kbps的调制绰绰有余生态成熟不管是标准库还是HAL库网上资料都很多出问题很快能找到答案。我之前用的是STM32F103C8T6也就是大家常说的“蓝板子”成本低、外设够用一块就能同时承担发射和接收的验证工作。2. 链路硬件设计发射端与接收端电路2.1 发射端电路三极管驱动LED方案与参数计算发射端的核心任务只有一个让LED根据GPIO输出的电平快速、干净地亮灭。很多人第一反应是把GPIO直接怼到LED上这种做法在电流只有几毫安、距离只有几厘米的时候勉强能亮但一旦想加大电流提高光功率GPIO就会被拖垮波形也一塌糊涂。我这里的做法是用一个NPN三极管当开关GPIO只负责给基极提供驱动电流LED的供电单独从5V或者更高电压的电源走。原理上可以这么理解GPIO输出高电平的时候基极有电流流过三极管导通LED那一路就有电流GPIO输出低电平三极管截止LED熄灭。关键是要把基极电阻和限流电阻算对否则不是驱动太弱就是三极管深度饱和导致关断速度变慢。我用的是2N2222或者S8050这类常见小功率三极管参数按极限Ic500mA左右来算实际工作电流控制在20mA到100mA之间余量很足。基极电阻的计算思路是这样的GPIO输出电压3.3V三极管be结压降约0.7V所以要保证基极电流比所需集电极电流除以放大倍数还要高。假设LED工作电流是50mA三极管hFE最低取100那基极电流至少要0.5mA。实际驱动时我会让基极电流再大一些取2~5mA这样三极管能更彻底地饱和压降更低。基极电阻 (3.3V - 0.7V) / 3mA ≈ 866Ω取常见值1kΩLED限流电阻 (5V - 管压降) / 50mALED的管压降根据颜色不同差别很大红光约1.8V到2.2V白光约3.0V到3.4V。如果用一颗白光LED限流电阻就是(5V - 3.2V)/50mA ≈ 36Ω取33Ω。有人会问既然这是通信用的LED是不是应该用激光二极管或者专用高速LED我们做的是基础验证普通LED完全够用只是一点要注意白色LED为了发出白光里面涂了荧光粉荧光的余晖效应会拖慢响应速度蓝色和红色LED反而更快。如果你追求稍高一点的速率优先选红光LED。2.2 接收端前端光电二极管、跨阻放大器与比较器接收端是整个项目里最容易翻车的地方。因为光信号经过空气衰减之后到达光电二极管的光强已经非常微弱了光电二极管产生的电流通常是微安甚至纳安级别如果不做放大和整形单片机根本没法直接判断0和1。我的第一版接收端电路用了最经典的“光电二极管加电阻加比较器”结构光电二极管工作在反偏模式光越强电流越大电流流过采样电阻转换成电压电压送到比较器或者运放与一个参考电压做比较比较器输出数字电平接到STM32的GPIO。这里有一个关键的取舍光电二极管有两种工作模式光伏模式不需要反偏暗电流小适合弱光检测但是响应速度慢光导模式需要加反偏电压响应速度快但是暗电流大。做通信时我选择光导模式因为我更关心的是信号边沿够不够快而不是静态电流有多小。光敏元件的选型上BPW34是很多人会用的光电二极管灵敏度高、封装成熟、响应速度在几十纳秒级别做几十kbps的通信绰绰有余。如果你不想自己焊跨阻放大电路也可以用带模拟输出的环境光传感器或者直接买那种带比较器输出的光电开关模块只是要注意这类模块的带宽通常不高需要实测确认。我实际电路里用的是运放加比较器的两级结构第一级是跨阻放大器把光电二极管的电流变成电压。跨阻放大器的反馈电阻决定了增益电阻越大输出电压越高但带宽也会下降。反馈电阻的选择是在灵敏度和带宽之间做权衡。第二级是比较器参考电压设在信号中点附近把模拟电压整形为高电平1和低电平0的方波再进GPIO。我选LM393比较器因为它是开漏输出需要外加上拉电阻。如果你只是想在桌面环境快速验证也可以跳过运放直接用“光电二极管一个10k到100k的采样电阻比较器”的简化结构。代价是死区大、边沿抖但能跑通基本流程后续再优化。2.3 供电和地线布局注意事项这一节是我吃了大亏才总结出来的。第一版电路我用面包板加杜邦线搭LED驱动和接收端的前置放大共用了同一路5V电源于是接收端的输出波形里全是尖刺看起来像数据其实全是干扰。后来用示波器一看才发现问题是LED瞬间通断时整个电源轨都在剧烈抖动接收端的小信号放大被这个噪声彻底淹没了。解决思路有三个都值得记下来分离模拟地和数字地接收端的光电二极管、运放、比较器这部分属于模拟电路LED驱动和单片机属于数字电路两者尽量使用独立的电源路径在电源入口处单点汇合增加去耦电容LED驱动部分的电源线上放一个100uF电解电容并一个0.1uF陶瓷电容接收端放大电路的电源和参考电压上也要加0.1uF电容每个芯片的电源脚旁边都放一个不要用长杜邦线布局高频回路LED驱动回路和接收端的输入回路都要尽量短最好直接画成PCB走线短才能减小寄生电感和天线效应。虽然这个项目原理上只是让LED亮灭但一旦进入模拟信号放大环节很多射频领域的基础法则就都适用了。你去查几乎所有“为什么我的可见光通信收不到”的问题一半以上都出在电源噪声和布局上。3. 让数据在光里“跑”起来编码与帧结构设计3.1 直接用串口电平驱动LED为什么不可行在做这个项目之前我最早想到的也是最简单的方案是把UART的TX引脚接一个三极管驱动LED接收端的光电二极管接一个比较器比较器输出直接接到另一个单片机的RX引脚。理论上这就是一个“无线串口”代码只需要用HAL_UART_Transmit和HAL_UART_Receive就行。跑起来才发现根本不行问题出在UART协议本身和光通信链路的适配上。UART发送一字节的时候空闲状态是高电平。如果直接把TX电平当成LED驱动信号那空闲状态下LED就是常亮的接收端光电二极管一直处于高光强状态。这看起来问题不大真正麻烦的是UART的起始位是一个下降沿数据位里面可能出现连续多个“0”或者连续多个“1”对应的LED就要保持同一个亮灭状态很久。接收端如果受到环境光抖动、电源波动的影响很容易把某一位误判成别的值而且UART本身没有任何机制能恢复位同步一旦某一位采错后面全乱。更麻烦的是LED的开关并不是瞬间完成的有上升时间和下降时间。波特率一高波形边沿占整个位周期的比例就变大误码率急剧上升。我的实际测试结果是用普通LED直接怼UART9600波特率都只能勉强工作距离稍微远一点就不行了。3.2 曼彻斯特编码的核心思想与时间参数选择于是我开始用曼彻斯特编码。这种编码方式在以太网里很常见核心思想是每个数据位都由两个半位组成数据“0”用“低电平到高电平”的翻转表示数据“1”用“高电平到低电平”的翻转表示。也就是说不管数据是什么每个位周期中间一定会发生一次翻转接收端可以根据翻转的边沿来提取时钟达到自同步的效果。用曼彻斯特编码有几个好处每个位周期都有边沿接收端可以持续校准时钟不会因为连续数据相同而丢失同步信号的平均电平基本恒定不会出现长时间常亮或者常灭导致的基线漂移接收端不需要知道绝对的起始时间只要能检测到边沿就能逐步恢复位流。当然代价也很明显一个数据位被拆成了两个物理半位实际物理翻转速率是数据速率的两倍。如果我想让LED的翻转频率控制在10kHz左右那数据传输速率就只有5kbps左右。这个折中对于低速控制应用完全能接受。我选的半位周期是100μs对应物理翻转频率10kHz数据速率5kbps。选择100μs是因为我的LED和光电二极管组合在10kHz左右时波形边沿依然比较干净环境光的主要干扰源是50Hz市电灯光闪烁10kHz距离它足够远可以通过简单的交流耦合滤除。3.3 帧结构设计前导码、数据区与校验有了编码方式还需要定义数据帧的格式否则接收端不知道自己收到的内容从哪开始、到哪结束。我的帧结构设计如下字段长度说明前导码8字节一串0x55 0xAA交替用于接收端时钟同步和相位对齐帧头2字节固定0xAA 0x55用于识别有效帧的起点长度1字节数据区长度最多255字节数据区N字节实际要传输的用户数据校验和1字节数据区所有字节累加和的低8位前导码是整个协议里最容易忽略又最关键的部分。接收端刚上电时不知道数据从哪里开始也不知道自己的采样点是否对准了位周期。前导码的0x55 0x55其实就是01010101的重复相当于让接收端先看到一段稳定的方波用它来锁定时钟相位。帧头告诉接收端下面开始是真正的数据不是前导码了。校验和用最简单的一次累加虽然防不了复杂的双bit错误但对付低速率光通信里最常见的单bit翻转已经够用。等后面链路稳定了再考虑换成CRC8。4. STM32固件实现发射端与接收端核心代码拆解4.1 发射端定时器中断加GPIO翻转的数据发送发射端的思路是先把一帧数据编码成一串物理电平序列然后放进一个数组由定时器中断每隔半位周期读取一个电平并输出到GPIO。核心代码如下#define HALF_BIT_US 100 #define TX_BUF_MAX 1024 __IO uint8_t tx_phys[TX_BUF_MAX]; __IO uint16_t tx_len 0; __IO uint16_t tx_idx 0; // 把一个逻辑位编码成两个物理半位 void vlc_encode_bit(uint8_t bit) { if (tx_len 2 TX_BUF_MAX) return; if (bit) { tx_phys[tx_len] 1; // 逻辑1前半段高 tx_phys[tx_len] 0; // 后半段低 } else { tx_phys[tx_len] 0; // 逻辑0前半段低 tx_phys[tx_len] 1; // 后半段高 } } // 把一个字节从高位到低位编码成逻辑位 void vlc_encode_byte(uint8_t data) { for (int i 7; i 0; i--) { vlc_encode_bit((data i) 0x01); } } // 组装一帧 void vlc_build_frame(uint8_t *payload, uint8_t len) { tx_len 0; // 前导码16个交替的1/0物理位 for (int i 0; i 8; i) vlc_encode_byte(0x55); // 帧头 vlc_encode_byte(0xAA); vlc_encode_byte(0x55); // 长度 vlc_encode_byte(len); // 数据 for (int i 0; i len; i) vlc_encode_byte(payload[i]); // 校验和 uint8_t sum 0; for (int i 0; i len; i) sum payload[i]; vlc_encode_byte(sum); }组装完成后启动定时器void vlc_start_tx(void) { tx_idx 0; HAL_GPIO_WritePin(LED_PORT, LED_PIN, tx_phys[0]); tx_idx 1; HAL_TIM_Base_Start_IT(htim6); }定时器中断服务函数里只有一个任务继续取下一个电平并输出直到发完整个数组void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { if (tx_idx tx_len) { HAL_GPIO_WritePin(LED_PORT, LED_PIN, tx_phys[tx_idx]); tx_idx; } else { HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_RESET); HAL_TIM_Base_Stop_IT(htim6); } } }注意定时器的配置要让中断周期正好等于半位周期100μs。在72MHz系统时钟下我可以让定时器时钟为1MHz设置自动重载值为99。htim6.Init.Prescaler 72 - 1; // 1MHz htim6.Init.Period 100 - 1; // 100us htim6.Init.CounterMode TIM_COUNTERMODE_UP;4.2 发射端的数据发送流程封装在主循环中发送一帧的流程就这么简单uint8_t data[] Hello VLC!; while (1) { vlc_build_frame(data, strlen(data)); vlc_start_tx(); HAL_Delay(200); // 留出完整发送时间 }我在这里故意用了一个比较土的HAL_Delay因为这不是生产环境只是基础原型等确认链路稳定后再改成“发送完成标志加帧间隔定时器”的方式。4.3 接收端定时器采样加半位对齐还原曼彻斯特数据接收端我采用了和发射端对称的方案同样用一个100μs的定时器中断每中断一次采样一次GPIO电平每两次采样合成一个逻辑位。这里有一个很关键的同步问题接收端怎么知道自己的采样点对齐到了位周期的正确位置我用前导码来解决。前导码是一串0x55曼彻斯特编码后物理电平就是01010101连续翻转。如果接收端采样点刚好对准了半位周期那每次采到的电平一定是交替的0,1,0,1……但如果采样点错位了半个周期采到的就会是1,0,1,0……这两种情况其实都算对齐。真正麻烦的是采样点和半位翻转边沿完全重合那采到的电平就会随机前一次后一次相同导致解码混乱。所以接收端的处理逻辑是在同步状态下判断连续两次采样的电平是否相同。如果连续多次相同说明采样点可能在翻转边沿附近这时就抛弃一个采样点重新对齐。如果连续两次采样始终不同说明已经稳定对齐到半位周期上可以进入数据接收状态。接收端的核心代码如下typedef enum { RX_SYNC 0, RX_SYNC_ALIGN, RX_DATA } RX_STATE; volatile RX_STATE rx_state RX_SYNC; vol p a hrefhttps://download.csdn.net/download/hakesashou/89411077 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p