
1. 为什么选择FPGA驱动OLED从点亮一块屏说起很多人第一次接触FPGA都是从点灯开始的。流水灯跑通了按键消抖做完了接下来呢我的建议是找一块OLED屏幕来驱动。原因很简单——点灯只能验证IO通不通而驱动一块128x64的OLED你会被迫把时钟分频、状态机设计、串行协议时序、显存管理、字模取模这一整套东西全部走一遍。这是一个麻雀虽小五脏俱全的项目做完之后你对FPGA开发的理解会上一个台阶。SSD1306这颗驱动芯片在0.96寸OLED模块上用得非常多128x64分辨率支持SPI和I2C两种接口。我选SPI来做因为SPI的时序更直观速率也够快FPGA最擅长的就是并行逻辑生成精确的串行时序。你不需要像单片机那样担心中断打断时序FPGA的硬件并行特性让SPI的每一位都可以精确到时钟周期。这篇文章面向的是刚学完Verilog基础语法、跑过几个简单例程、想找一个完整项目练手的FPGA新手。我会从SSD1306的通信协议讲起一步步搭建SPI发送模块、初始化序列、显存控制最后实现字符和简单图形的显示。代码基于Verilog-2001标准在Xilinx Artix-7和Intel Cyclone IV上都验证过通用性没问题。注意本文假设你已经会使用至少一种FPGA开发工具Vivado或Quartus知道怎么建工程、分配引脚、下载比特流。如果这些还不熟建议先花半天时间把工具链跑通再来看。2. SSD1306通信协议拆解别被数据手册吓到2.1 SPI四线模式到底怎么接线SSD1306支持SPI和I2CSPI模式下又分三线制和四线制。我强烈建议用四线SPI因为多一根D/C引脚数据/命令选择控制逻辑会清晰很多。四线SPI的引脚定义如下引脚名功能FPGA端连接SCLK串行时钟任意IO由FPGA输出SDIN串行数据任意IO由FPGA输出D/C数据/命令选择任意IO0命令1数据CS片选任意IO低电平有效RES复位任意IO低电平复位这里有个新手容易踩的坑SSD1306的SPI不是标准SPI它没有MISO因为OLED只接受数据不需要回读而且它的时钟极性是空闲低电平上升沿采样对应SPI Mode 0。你在写Verilog的时候数据要在SCLK上升沿之前就稳定好SSD1306在上升沿采样SDIN上的数据。2.2 命令和数据怎么区分SSD1306的通信格式很简洁每次传输先拉低CS然后根据D/C引脚的状态决定这次传的是命令还是数据接着在SCLK的驱动下逐位送出8个bit最后拉高CS。一个完整的字节传输时序是这样的CS拉低D/C设置为对应电平命令0数据1在SCLK低电平期间准备好数据位SCLK拉高SSD1306采样重复8次送完一个字节CS拉高这里的关键点是D/C信号要在CS有效期间保持稳定不能在传输中途切换。我见过有人把D/C和SDIN接在一起想省一根线结果屏幕完全不亮——因为SSD1306会在每个字节开始时锁存D/C的状态中途变化会导致命令和数据混淆。2.3 初始化序列必须按顺序来SSD1306上电后不会自动工作需要发送一长串初始化命令。这些命令的顺序不能乱因为有些命令之间存在依赖关系。我整理了一份最小可用的初始化序列// 初始化命令序列部分关键命令 // 0xAE: 关闭显示 // 0xD5, 0x80: 设置时钟分频 // 0xA8, 0x3F: 设置多路复用比1/64 // 0xD3, 0x00: 设置显示偏移 // 0x40: 设置起始行 // 0x8D, 0x14: 电荷泵使能必须否则屏幕不亮 // 0x20, 0x00: 内存寻址模式水平寻址 // 0xA1: 段重映射 // 0xC8: 扫描方向 // 0xDA, 0x12: COM引脚配置 // 0x81, 0xCF: 对比度 // 0xD9, 0xF1: 预充电周期 // 0xDB, 0x40: VCOMH电压 // 0xA4: 全局显示开启 // 0xA6: 正常显示非反色 // 0xAF: 开启显示其中0x8D, 0x14这条电荷泵命令绝对不能漏。很多新手照着网上的代码抄结果屏幕死活不亮十有八九就是漏了这条。SSD1306内部需要电荷泵产生驱动OLED所需的高电压不使能电荷泵屏幕就是一块黑玻璃。实操心得初始化序列建议用ROM或者case语句实现每个命令占一个地址。我习惯用一个计数器遍历命令表这样修改起来很方便不用改状态机逻辑。3. Verilog模块架构设计分而治之3.1 顶层模块怎么划分一个可维护的FPGA项目模块划分很重要。我的习惯是把OLED驱动拆成三个层次顶层模块oled_top例化所有子模块连接信号SPI发送模块spi_master负责将并行字节转为SPI时序OLED控制模块oled_ctrl负责初始化序列、显存写入、显示控制这样划分的好处是SPI模块可以复用到其他SPI设备上OLED控制模块专注于SSD1306的命令逻辑顶层模块只做连线。如果你以后要换用I2C接口的OLED只需要替换SPI模块控制逻辑基本不用动。3.2 时钟分频策略FPGA的系统时钟通常是50MHz或100MHz而SSD1306的SPI时钟最高可以到10MHz数据手册标称但实际用下来5MHz左右比较稳妥尤其是用杜邦线连接的时候。时钟太高会导致信号完整性变差屏幕出现花屏或随机点亮。我一般这样分频假设系统时钟50MHzSPI时钟目标5MHz分频系数就是10。用一个计数器从0数到9数到4的时候SCLK拉低数到9的时候SCLK拉高这样占空比就是50%。具体代码// 50MHz - 5MHz SPI时钟 reg [3:0] clk_cnt; reg spi_clk_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt 4d0; spi_clk_r 1b0; end else if (clk_cnt 4d9) begin clk_cnt 4d0; end else begin clk_cnt clk_cnt 1b1; end end always (posedge clk or negedge rst_n) begin if (!rst_n) spi_clk_r 1b0; else if (clk_cnt 4d4) spi_clk_r 1b0; else if (clk_cnt 4d9) spi_clk_r 1b1; end这里有个细节SCLK的生成最好用寄存器输出不要用组合逻辑。组合逻辑产生的时钟会有毛刺SSD1306对毛刺很敏感可能导致误采样。用寄存器打一拍信号干净很多。3.3 状态机设计思路SPI发送模块的核心是一个状态机我把它设计成四个状态IDLE空闲状态等待发送请求START拉低CS准备数据SEND逐位发送8个bitSTOP拉高CS产生完成脉冲状态转移的条件是发送使能信号和位计数器。这里的关键是位计数器和字节发送要同步每发送完一个bit位计数器加一加到7之后进入STOP状态。发送速率由分频后的时钟控制每个bit持续10个系统时钟周期5MHz SPI时钟。注意事项状态机的复位要同步复位异步复位释放也要同步。FPGA的亚稳态问题在跨时钟域的时候特别明显虽然这里SPI时钟是从系统时钟分频来的属于同源时钟但养成同步复位的好习惯没坏处。4. 显存管理与字模显示让屏幕显示内容4.1 SSD1306的显存结构SSD1306内部有一块128x64bit的GDDRAM显存对应屏幕上的128x64个像素。显存被分成8页Page0~Page7每页8行每行128列。也就是说每个字节控制一列中的8个像素LSB在上MSB在下。这个结构和常见的逐行扫描显示不一样新手容易搞混。举个例子你想在屏幕左上角显示一个8x8的字符需要往Page0的Column0写入一个字节这个字节的bit0对应屏幕第0行bit7对应第7行。如果你写反了字符会上下颠倒。设置显存写入位置的命令是0xB0 page设置页地址0~70x00 (col 0x0F)设置列地址低4位0x10 (col 4)设置列地址高4位这三条命令发完之后后续写入的数据就会自动填充到对应位置每写一个字节列地址自动加一。4.2 字模取模与存储要在OLED上显示字符需要先取模。常用的取模软件有PCtoLCD2002、字模提取软件等。取模方式选择阴码、逐列式、顺向高位在前这样出来的数据格式和SSD1306的显存结构匹配。以8x16的ASCII字符为例每个字符占16个字节8列x16行分两页存储。我通常把字模数据存在FPGA的Block RAM里用查找表的方式读取。Verilog中可以用case语句或者初始化ROM的方式// 8x16字模ROM示例以字符A为例 reg [7:0] font_rom [0:15]; initial begin font_rom[0] 8h00; font_rom[1] 8h00; font_rom[2] 8hF0; font_rom[3] 8h08; // ... 省略中间数据 font_rom[15] 8h00; end实际项目中我会把整个ASCII字库95个可打印字符都存进去总共95x161520字节用一块Block RAM完全放得下。读取的时候用字符的ASCII码减去0x20作为索引乘以16得到起始地址。4.3 显示刷新流程完整的显示刷新流程是这样的发送初始化命令序列设置页地址和列地址发送字模数据重复2-3直到所有内容写完发送0xAF开启显示这里有个优化技巧不要每次刷新都重新初始化。初始化只需要上电后做一次后续刷新只需要设置地址然后写数据。我见过有人在每次刷新时都发一遍初始化序列屏幕会闪烁得很厉害因为0xAE/0xAF反复开关显示。实操心得如果你要显示动态内容比如计数器、动画建议在FPGA内部维护一个显存缓冲区128x64bit 1024字节先在缓冲区里更新数据然后一次性刷到OLED。这样屏幕不会出现撕裂感。5. 常见问题排查屏幕不亮怎么办5.1 屏幕完全不亮这是最常见的问题排查顺序如下排查项可能原因解决方法电源3.3V供电不足用万用表测VCC和GND确保3.3V稳定复位RES引脚一直低电平检查复位电路上电后RES要拉高电荷泵漏发0x8D,0x14补上这条命令接线SCLK/SDIN接反对照模块丝印确认引脚CS片选一直高电平确保CS在传输期间拉低我遇到过最坑的一次是模块的VCC和GND在排针上标反了照着丝印接怎么都不亮后来用万用表一测才发现。所以拿到新模块第一件事是用万用表确认电源引脚别嫌麻烦。5.2 屏幕亮但显示乱码乱码通常和时序有关。先检查SPI时钟频率降到1MHz试试。如果降频后正常说明是信号完整性问题检查连线是否过长、是否有干扰。另外检查D/C信号的切换时机确保在CS拉低之前就设置好。还有一种可能是字模取模方式不对。如果你用的是逐行式取模但SSD1306需要逐列式显示出来的字符会是乱码或者镜像的。重新取模选择正确的模式。5.3 部分像素不亮或常亮这通常是显存数据的问题。检查写入的字节是否正确特别是页地址和列地址的计算。SSD1306的列地址是0~127页地址是0~7超出范围会导致数据写到错误的位置。如果是个别像素常亮可能是初始化时没有清空显存。上电后显存内容是随机的建议在初始化序列之后加一段清屏操作把所有显存写0。5.4 屏幕闪烁闪烁一般是刷新频率太低或者重复初始化导致的。如果你在always块里反复发送初始化命令屏幕会不停开关。正确的做法是用一个状态机控制初始化只执行一次之后进入正常显示模式。另外如果你用PWM调对比度频率太低也会闪烁。SSD1306的对比度是通过0x81命令设置的不需要PWM直接写一个固定值就行。6. 进阶玩法从静态显示到动画效果6.1 滑动窗口滤波在显示中的应用有朋友问过我怎么让OLED显示的数据更平滑。如果你在显示传感器数据比如温度、电压原始数据会有跳动直接显示数字会一直闪。这时候可以用滑动窗口滤波在FPGA里维护一个数据队列每次新数据进来去掉最旧的一个求平均值输出。Verilog实现滑动窗口滤波的核心是一个移位寄存器组reg [11:0] window [0:7]; // 8个12bit数据 reg [14:0] sum; integer i; always (posedge clk) begin // 移位 for (i 7; i 0; i i - 1) window[i] window[i-1]; window[0] new_data; // 求和组合逻辑 sum 0; for (i 0; i 8; i i 1) sum sum window[i]; // 平均值 sum / 8 filtered_data sum 3; end这样显示出来的数据会平滑很多视觉效果更好。6.2 简单动画的实现思路OLED做动画的原理很简单快速切换不同的画面。比如你要做一个方块的移动动画只需要在显存里改变方块的位置然后刷新屏幕。刷新率做到30fps以上人眼看起来就是连续的。具体实现时用一个计数器控制帧切换每帧更新方块坐标然后重新计算显存数据。注意不要每帧都全屏刷新只刷新方块移动涉及的区域这样可以提高刷新率。6.3 定点数显示FPGA里做浮点运算很耗资源通常用定点数代替。比如你要显示电压值可以先把电压放大100倍存成整数显示的时候在合适位置插入小数点。这样既避免了浮点运算显示效果也够用。定点数的格式一般是Q格式比如Q8.4表示8位整数4位小数。在Verilog里就是简单的移位和截断操作比浮点运算省资源得多。7. 工程组织与代码风格建议7.1 目录结构一个规范的FPGA工程目录应该这样组织oled_project/ ├── rtl/ # Verilog源码 │ ├── oled_top.v │ ├── spi_master.v │ └── oled_ctrl.v ├── sim/ # 仿真文件 │ └── tb_oled_top.v ├── constr/ # 约束文件 │ └── oled.xdc └── doc/ # 文档 └── ssd1306_init.md这样别人拿到你的工程能快速找到需要的文件自己也方便维护。7.2 命名规范Verilog的命名我建议遵循以下规则模块名用小写加下划线spi_master信号名用小写spi_clk、data_valid低电平有效的信号加_n后缀rst_n、cs_n参数用大写CLK_DIV、DATA_WIDTH这些规范不是强制的但团队协作时能减少很多沟通成本。7.3 仿真验证写FPGA代码一定要仿真。我习惯用Verilator或者Vivado自带的仿真器写一个简单的testbench给SPI模块发送几个字节看波形是否正确。仿真通过之后再上板能省很多调试时间。Testbench的关键是模拟SSD1306的行为至少要把CS、SCLK、SDIN的时序关系检查一遍。如果条件允许可以写一个简单的SPI从机模型验证数据是否正确接收。8. 踩坑记录与经验总结8.1 电平匹配问题有些FPGA开发板的IO电压是1.8V或2.5V而OLED模块需要3.3V。直接连接可能导致OLED不工作或者FPGA IO损坏。解决方法是加电平转换芯片或者确认FPGA的IO Bank电压是否支持3.3V。我用的Artix-7开发板IO电压是3.3V可以直接连。8.2 上电顺序OLED模块和FPGA的上电顺序也有讲究。如果OLED先上电FPGA还没配置好IO处于高阻态OLED可能会误接收一些随机信号。建议FPGA先配置好再给OLED上电或者在FPGA配置完成后通过软件控制OLED的电源。8.3 杜邦线的坑用杜邦线连接SPI信号线长超过10cm就容易出现信号完整性问题。如果屏幕出现随机噪点先换短一点的线试试。有条件的话用排线或者PCB稳定性会好很多。8.4 复位信号的亚稳态FPGA的复位信号如果来自按键会有抖动。直接用作复位可能导致状态机跑飞。建议加一个消抖模块或者用同步复位把复位信号打两拍再使用。// 复位信号同步化 reg rst_sync1, rst_sync2; always (posedge clk) begin rst_sync1 rst_n; rst_sync2 rst_sync1; end // 使用rst_sync2作为内部复位这个技巧在跨时钟域的时候特别重要能有效避免亚稳态传播。8.5 显存写入的边界检查SSD1306的显存是128x64如果你写入的数据超出了这个范围会回绕到开头。比如你从Column120开始写16个字节后8个字节会写到Column0~7。写代码的时候要注意边界或者确保你的数据长度不会超出。9. 从OLED驱动延伸出去的学习路径做完这个项目你其实已经掌握了不少FPGA开发的核心技能状态机设计、SPI协议实现、ROM/RAM使用、时序约束。接下来可以往这几个方向延伸第一个方向是图像处理。OLED显示的本质是操作显存你可以尝试在FPGA里实现简单的图像算法比如边缘检测、二值化然后把处理结果直接显示在OLED上。这样就把显示和图像处理结合起来了。第二个方向是多设备SPI总线。你可以在SPI总线上挂多个从设备比如OLED、Flash、传感器用一个SPI Master模块分时复用。这会涉及到片选管理和总线仲裁是很好的进阶练习。第三个方向是高速接口。SPI的速率有限如果你要驱动更高分辨率的屏幕可能需要用到MIPI或者RGB接口。这些接口的时序更复杂但原理是相通的有了SPI的基础学起来会快很多。第四个方向是软核处理器。你可以在FPGA里跑一个RISC-V或者MicroBlaze软核用C语言写OLED驱动Verilog只负责底层SPI时序。这样开发效率更高适合复杂的交互程序。我个人在实际操作中的体会是FPGA学习最有效的方式就是找一个完整的项目做透。OLED驱动这个项目不大不小刚好能覆盖大部分基础知识点而且效果直观——屏幕亮起来的那一刻成就感是很足的。代码我建议自己一行行敲不要直接复制敲的过程中你会思考每一行的作用这个思考过程比代码本身更有价值。