1. 为什么一块0.96寸的OLED值得FPGA新手拿来练手如果你刚摸到FPGA开发板翻完语法书却不知道下一步该干嘛我建议你从驱动一块SSD1306 OLED屏开始。原因很直接它同时踩中了三个新手最需要练的东西——时序控制、状态机设计、串行协议实现。而且它便宜某宝上128x64分辨率的0.96寸模块十几块钱就能买到焊上四根线就能跑烧录失败也不会炸板子。SSD1306这颗驱动芯片在OLED模块里几乎是标配128x64分辨率、支持SPI和I2C两种接口。我们这里选SPI因为FPGA最擅长的就是并行逻辑生成串行时序用SPI驱动比I2C更直观不需要处理双向数据线也不用纠结上拉电阻。整个项目最终要实现的效果是FPGA上电后OLED屏幕点亮显示你写入的内容比如一行文字或者一个简单的图形。这篇文章面向的是刚学完Verilog基础语法、手里有一块FPGA开发板和一块SSD1306模块、但不知道如何把两者连起来的新手。我会从SSD1306的通信协议讲起一步步拆解SPI时序、初始化命令序列、显存写入逻辑最后给出完整的Verilog代码框架和上板调试经验。中间涉及到的每一个参数选择、每一个状态机跳转我都会解释为什么这么做而不是只丢一段代码让你抄。需要提前说明的是市面上常见的0.96寸OLED模块有7针和4针两种版本。7针模块同时引出SPI和I2C接口需要手动配置背面的电阻来选择模式4针模块只有VCC、GND、SCL、SDA四根线默认就是I2C模式。如果你买的是4针模块那它只能走I2C本文的SPI方案不适用。买之前一定看清楚建议直接选7针SPI/I2C兼容版灵活性更高。2. SSD1306的SPI通信协议拆解2.1 四线SPI的物理层与信号定义SSD1306支持四线SPI模式四根信号线分别是SCLK时钟、MOSI数据输入、CS片选、D/C数据/命令选择。注意这里没有MISO因为OLED屏只接受数据不需要回读所以是单向通信。这一点对FPGA很友好不用处理三态总线和方向控制。D/C引脚是SSD1306特有的它不属于标准SPI协议的一部分。标准SPI靠CS和时钟来区分传输但SSD1306需要额外一根线来告诉芯片“接下来收到的是命令还是显存数据”。D/C为低电平时MOSI上的数据被解释为命令D/C为高电平时被解释为写入显存的数据。这个设计简化了芯片内部逻辑但也意味着你在写Verilog状态机时必须严格管理D/C的切换时机。CS片选信号在SSD1306的SPI模式下每个字节传输前拉低、传输后拉高即可。有些实现会把CS一直拉低靠D/C来区分这样也能工作但不符合标准SPI的帧结构在高速传输时可能出问题。我建议老老实实每个字节都做一次CS拉低-传输-拉高的完整周期。2.2 SPI模式0的时序参数计算SSD1306的SPI接口支持模式0CPOL0CPHA0和模式3CPOL1CPHA1。我们选模式0因为这是最常用的配置时钟空闲时为低电平数据在时钟上升沿被采样。时序参数方面SSD1306的数据手册给出了明确要求SCLK最小周期为100ns对应10MHz高电平和低电平各至少50ns。这意味着SPI时钟频率不能超过10MHz。但实际调试时我建议从1MHz开始等屏幕稳定显示后再逐步提高。原因很简单新手布线往往比较随意杜邦线长了之后信号完整性会变差高频下容易出现数据错位。假设你的FPGA系统时钟是50MHz周期20ns。要生成1MHz的SPI时钟需要每25个系统时钟周期翻转一次SCLK。具体计算50MHz ÷ 1MHz 50半周期就是25个系统时钟。你可以用一个计数器从0数到24每到24时翻转SCLK同时更新MOSI上的数据。这里有个容易踩的坑数据必须在SCLK上升沿之前稳定。在模式0下MOSI应该在SCLK下降沿变化这样上升沿采样时数据已经稳定了至少半个周期。如果你在上升沿同时改变MOSI建立时间不够SSD1306可能采到错误的值。2.3 命令字节与数据字节的传输格式每次传输一个字节时SSD1306的接收顺序是高位在前MSB First。也就是说先发bit7最后发bit0。这一点和大多数SPI器件一致但如果你之前只玩过UART可能会习惯低位先发这里要特别注意。一个完整的字节传输流程是这样的CS拉低 → D/C设置为命令或数据 → 在SCLK的8个上升沿依次移出8个bit → CS拉高。如果你要连续传输多个字节可以在每个字节之间保持CS为低但D/C需要在字节边界切换。不过为了逻辑清晰我建议每个字节都做完整的CS周期虽然效率略低但状态机简单不容易出错。对于初始化命令序列通常需要连续发送二十多条命令。如果每条命令都单独做CS周期状态机会比较冗长。一个折中方案是把初始化命令序列存储在一个ROM里状态机自动逐条读取并发送D/C始终为低CS在整个序列期间保持低电平。这样只需要一个计数器来遍历ROM地址即可。3. 初始化序列那些数据手册没明说的细节3.1 上电时序与复位要求SSD1306对上电顺序有明确要求。VCC逻辑电源和VDD面板电源的上电时序会影响芯片内部复位电路的工作。对于常见的模块VCC和VDD通常连在一起由3.3V供电这种情况下上电顺序不是问题。但如果你用的是分离供电的裸屏需要确保VDD不早于VCC上电。复位方面SSD1306有一个RES#引脚低电平复位。模块上通常已经做了RC复位电路FPGA只需要在初始化前给一个至少3us的低电平脉冲即可。我一般会在状态机里加一个复位状态拉低RES#保持1ms然后拉高再等待1ms让芯片内部稳定之后才开始发送初始化命令。这个等待时间比手册要求的长但多等一会儿不会有副作用反而能避免一些莫名其妙的初始化失败。3.2 必须发送的初始化命令清单SSD1306的初始化命令有二十多条但并不是每条都必须。根据我的实际调试经验下面这些命令是必须发送的缺一不可命令作用常用值0xAE关闭显示初始化前先关显示0xD5设置时钟分频0x800xA8设置多路复用比0x3F64行0xD3设置显示偏移0x000x40设置起始行0x000x8D电荷泵设置0x14开启0x20内存寻址模式0x00水平寻址0xA1段重映射0xA1左右翻转0xC8扫描方向0xC8上下翻转0xDACOM引脚配置0x120x81对比度0xCF0xD9预充电周期0xF10xDBVCOMH电压0x400xA4全局显示开启0xA40xA6正常显示0xA60xAF开启显示最后发送其中0x8D命令的0x14参数是新手最容易漏掉的。这个命令控制电荷泵的开启如果不发屏幕可能完全不亮或者亮度极低。很多网上的例程省略了这条导致别人抄了之后点不亮还以为是接线问题。另外0x20命令设置寻址模式也很关键。SSD1306支持三种寻址模式页寻址、水平寻址、垂直寻址。我们选水平寻址0x00这样写入显存时列地址会自动递增跨页时行地址自动加1写满整个屏幕只需要连续写1024个字节不需要手动设置地址。3.3 初始化失败时的排查顺序如果你按照上面的命令序列发送后屏幕还是不亮按这个顺序排查量电压用万用表测模块VCC和GND之间是不是3.3V。有些FPGA开发板的3.3V引脚输出电流有限带不动OLED模块需要外接供电。查接线SCLK、MOSI、CS、D/C、RES五根信号线是否接对。特别是D/C接错了会把命令当数据写屏幕不会有任何反应。看时序用示波器或者逻辑分析仪抓SCLK和MOSI确认时钟频率没有超过10MHz数据在上升沿稳定。简化测试先只发0xAE、0x8D、0x14、0xAF这四条命令看屏幕是否出现微弱亮光。如果有亮光说明电荷泵工作了问题出在其他初始化命令上。我遇到过最常见的情况是RES引脚悬空。有些模块没有内部复位电路RES必须由FPGA驱动。如果你没接这根线芯片一直处于复位状态发什么命令都没用。4. 用Verilog搭建SPI发送状态机4.1 状态划分与时钟分频策略整个SPI发送模块可以划分为五个状态IDLE空闲、START启动、SEND_BYTE发送字节、WAIT等待、DONE完成。但实际实现时我更喜欢用两层状态机一层控制字节序列的发送流程另一层控制单个字节内部的bit移位。时钟分频方面用一个参数CLK_DIV来控制SPI速度。假设系统时钟50MHz目标SPI时钟1MHz则CLK_DIV 50。分频计数器从0数到CLK_DIV/2 - 1时翻转SCLK这样占空比正好50%。如果你需要更精确的占空比可以分别设置高电平和低电平的计数值但SSD1306对占空比不敏感50%足够了。parameter CLK_DIV 50; // 50MHz / 50 1MHz reg [5:0] div_cnt; reg sclk_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 0; sclk_r 0; end else if (div_cnt CLK_DIV/2 - 1) begin div_cnt 0; sclk_r ~sclk_r; end else begin div_cnt div_cnt 1; end end这段代码生成的是1MHz的SCLK。注意sclk_r在复位后为0符合模式0的空闲低电平要求。4.2 字节发送的移位逻辑与D/C控制发送一个字节时需要把8个bit依次移到MOSI上。用一个3位计数器bit_cnt从0数到7每个SCLK下降沿更新MOSI数据。为什么是下降沿因为模式0下上升沿采样下降沿变化数据能保证建立时间。reg [7:0] shift_reg; reg mosi_r; reg dc_r; reg cs_r; always (negedge sclk_r or negedge rst_n) begin if (!rst_n) begin mosi_r 0; bit_cnt 0; end else if (send_en) begin if (bit_cnt 8) begin mosi_r shift_reg[7 - bit_cnt]; bit_cnt bit_cnt 1; end end endD/C信号在字节发送前设置好整个字节期间保持不变。CS在字节发送前拉低发送完成后拉高。如果你要连续发送多个字节可以在字节之间保持CS低但D/C需要在字节边界切换。4.3 初始化ROM的组织方式把初始化命令序列存储在一个ROM里是最简洁的做法。用Verilog的case语句或者reg数组都可以实现。我倾向于用case语句因为综合工具对case的优化更好而且代码可读性高。reg [8:0] init_rom [0:25]; // 9位最高位是D/C低8位是数据 initial begin init_rom[0] 9h000; // 0xAE, D/C0 init_rom[1] 9h0D5; // 0xD5 init_rom[2] 9h080; // 0x80 // ... 其他命令 end这里用9位宽度的ROM最高位表示D/C低8位是命令或数据。这样状态机只需要读ROM、拆分D/C和数据、发送即可逻辑非常统一。5. 显存写入与屏幕显示控制5.1 水平寻址模式下的显存映射SSD1306的显存大小是128x64 bit也就是1024字节。在水平寻址模式下写入一个字节后列地址自动加1写到第128列时列地址归零、页地址加1。页地址从0到7对应屏幕的8个页每页8行像素。这意味着你只需要设置一次起始地址列0、页0然后连续写入1024个字节就能填满整个屏幕。每个字节的bit0对应页内最上面一行bit7对应最下面一行。这个映射关系在写显示内容时非常重要如果你搞反了显示的文字会上下颠倒。5.2 设置显示区域的命令序列在写入显存之前需要先设置显示区域。水平寻址模式下用这三条命令设置列地址范围0x21, 0x00, 0x7F列地址从0到1270x22, 0x00, 0x07页地址从0到7设置完这两条命令后后续写入的数据就会自动按水平寻址模式填充整个屏幕。如果你只想更新屏幕的一部分可以只设置对应的列和页范围这样能减少数据传输量。5.3 从显存数据到屏幕像素的对应关系假设你要在屏幕左上角显示一个8x8的字符这个字符的位图数据需要按列存储。SSD1306的显存是按页组织的每页8行一列对应一个字节。所以一个8x8字符在显存中占8个字节每个字节对应一列bit0到bit7对应页内的8行。如果你用取模软件生成字符数据注意选择“列行式”或“页寻址”模式不要选“行列式”。行列式生成的数据需要转置才能用新手很容易在这里搞错导致显示出来的字符是乱码。6. 上板调试从点不亮到稳定显示的完整过程6.1 第一次上电应该看到什么烧录成功后如果一切正常屏幕应该先全黑然后你写入的内容出现在左上角。如果你写的是全0xFF屏幕应该全亮写全0x00屏幕全黑。建议第一次测试时先写全0xFF这样能快速确认通信是否正常。如果屏幕完全不亮先别急着改代码。用逻辑分析仪抓一下SCLK、MOSI、CS、D/C四根线看看有没有波形。如果没有波形说明状态机没跑起来检查复位信号和时钟是否正常。如果有波形但屏幕不亮检查初始化命令序列是否完整特别是0x8D和0xAF这两条。6.2 显示闪烁或错位的常见原因屏幕显示闪烁通常是因为刷新率太低。如果你每次更新显示都重新发送全部1024字节在1MHz SPI时钟下需要大约10ms刷新率只有100Hz人眼能感觉到闪烁。解决办法是提高SPI时钟到8MHz或者只更新变化的部分。显示错位则多半是寻址模式设置错误。如果你用了页寻址模式但按水平寻址的方式写数据列地址不会自动递增数据会全部堆在第一列。检查0x20命令的参数是否正确设置为0x00。6.3 用滑动窗口滤波思路优化显示刷新这个技巧来自图像处理领域但用在OLED刷新上也很有效。如果你要显示动态内容比如一个移动的方块不需要每帧都重写整个屏幕。只需要记录方块上一帧的位置和当前帧的位置擦除旧位置、绘制新位置即可。这样每次刷新的数据量从1024字节降到几十字节刷新率能提高一个数量级。具体实现时在FPGA内部维护一个显存缓冲区1024字节的Block RAM每次更新时只修改缓冲区中变化的部分然后通过SPI把整个缓冲区发送出去。虽然发送量没减少但你可以用DMA思路让SPI发送和显存更新并行进行互不阻塞。7. 进阶方向从点亮屏幕到做出有意思的东西屏幕点亮只是第一步。接下来你可以尝试显示自定义图形、实现多级菜单、甚至做一个简单的示波器。如果你对图像处理感兴趣可以试试用FPGA做实时边缘检测把摄像头数据经过处理后显示在OLED上。虽然128x64分辨率不高但足够验证算法逻辑。另一个方向是优化SPI传输效率。目前每个字节都做完整的CS周期效率不高。可以改成连续传输模式CS在整个数据块传输期间保持低电平只在块结束时拉高。这样能减少CS翻转开销提高有效数据率。不过要注意D/C的切换时机确保命令和数据不会混淆。我在实际项目中发现SSD1306的对比度设置对显示效果影响很大。0xCF是一个比较均衡的值但在不同模块上可能需要微调。如果显示偏暗可以尝试0xFF如果偏亮有拖影降到0x9F试试。这个参数没有标准答案根据你的模块和观看环境调整即可。
