目录
📌 前言
本篇文章为《基于Lattice XO2-4000HC FPGA核心板及电子森林综合训练底板的ADC数字电压表及OLED显示设计(Verilog)》一文的延伸,针对该项目中的SSD1306 OLED显示屏驱动的指令及工作(扫描)方式进行较为详细的说明,并结合原文中oled_driver_adc
驱动模块的部分代码进行详细分析。
👉 注意事项及源代码参见原文。
🎁 oled_driver_adc驱动顶层模块代码
为便于后续对照代码进行说明,在此先贴出源代码,子模块的代码在原文中给出,简要而言为两个只读ROM,分别读取命令编码及字符编码。
module oled_driver_adc #(
parameter CMD_WIDTH = 8, // LCD命令宽度
parameter CMD_DEPTH = 5'd25, // LCD初始化的命令的数量
parameter CHAR_WIDTH = 40, // 一个文字的数据宽度
parameter CHAR_DEPTH = 7'd123 // 文字库数量
)(
input sys_clk,
input rst_n,
input [7:0] oled_display_digital, // 两位ADC数据(一位小数)
output reg oled_csn, //OLCD液晶屏使能
output reg oled_rst, //OLCD液晶屏复位
output reg oled_dcn, //OLCD数据指令控制
output reg oled_clk, //OLCD时钟信号
output reg oled_data //OLCD数据信号
);
localparam IDLE = 3'b0, MAIN = 3'b1, INIT = 3'b10;
localparam SCAN = 3'b11, WRITE = 3'b100, DELAY = 3'b101;
localparam HIGH = 1'b1, LOW = 1'b0;
localparam DATA = 1'b1, CMD = 1'b0;
wire [CMD_WIDTH-1:0] cmd_out; // cmd_RAM输出的8位命令
wire [CHAR_WIDTH-1:0] char_out; // data_RAM输出的40位文字
reg [7:0] wr_reg;
reg [7:0] ypage, xpage_high, xpage_low;
reg [(8*21-1):0] char; // 字符串
reg [4:0] char_num; // 文字个数 最多16
reg [4:0] cmd_addr;
reg [7:0] char_addr;
reg [2:0] cnt_main;
reg [2:0] cnt_init;
reg [3:0] cnt_scan;
reg [4:0] cnt_write;
reg [14:0]num_delay, cnt_delay;
reg [2:0] state, state_last;
oled_cmd_RAM #(
.RAM_WIDTH(CMD_WIDTH),
.RAM_DEPTH(CMD_DEPTH),
.ADDR_WIDTH(5)
) CMD_RAM(
.clk(sys_clk),
.rst_n(rst_n),
.re(oled_dcn),
.addr(cmd_addr),
.data(cmd_out)
);
oled_char_RAM #(
.RAM_WIDTH(CHAR_WIDTH),
.RAM_DEPTH(CHAR_DEPTH),
.ADDR_WIDTH(8)
) CHAR_RAM(
.clk(sys_clk),
.rst_n(rst_n),
.re(oled_dcn),
.addr(char_addr),
.data(char_out)
);
always @(posedge sys_clk or negedge rst_n) begin
if(!rst_n) begin
cnt_main <= 1'b0;
cnt_init <= 1'b0;
cnt_scan <= 1'b0;
cnt_write <= 1'b0;
wr_reg <= 1'b0;
ypage <= 1'b0;
xpage_high <= 1'b0;
xpage_low <= 1'b0;
char <= 1'b0;
char_num <= 1'b0;
cmd_addr <= 1'b0;
char_addr <= 1'b0;
num_delay <= 15'd5;
cnt_delay <= 1'b0;
oled_csn <= HIGH;
oled_rst <= HIGH;
oled_dcn <= CMD;
oled_clk <= HIGH;
oled_data <= LOW;
state <= IDLE;
state_last <= IDLE;
end
else begin
case(state)
IDLE: begin
cnt_main <= 1'b0;
cnt_init <= 1'b0;
cnt_scan <= 1'b0;
cnt_write <= 1'b0;
wr_reg <= 1'b0;
ypage <= 1'b0;
xpage_high <= 1'b0;
xpage_low <= 1'b0;
char <= 1'b0;
char_num <= 1'b0;
cmd_addr <= 1'b0;
char_addr <= 1'b0;
num_delay <= 15'd5;
cnt_delay <= 1'b0;
oled_csn <= HIGH;
oled_rst <= HIGH;
oled_dcn <= CMD;
oled_clk <= HIGH;
oled_data <= LOW;
state <= MAIN;
state_last <= MAIN;
end
MAIN: begin
if(cnt_main >= 3'd6)
cnt_main <= 3'd5;
else
cnt_main <= cnt_main + 1'b1;
case(cnt_main) //MAIN状态
3'd0: begin state <= INIT; end
3'd1: begin ypage <= 8'hb0; xpage_high <= 8'h10; xpage_low <= 8'h00; char_num <= 5'd16; char <= "ADC DATA DISPLAY";state <= SCAN; end
3'd2: begin ypage <= 8'hb1; xpage_high <= 8'h10; xpage_low <= 8'h00; char_num <= 5'd16; char <= "VOLTAGE: . V ";state <= SCAN; end
3'd3: begin ypage <= 8'hb2; xpage_high <= 8'h10; xpage_low <= 8'h00; char_num <= 5'd16; char <= "AUTHOR: ZIRU PAN";state <= SCAN; end
3'd4: begin ypage <= 8'hb3; xpage_high <= 8'h10; xpage_low <= 8'h00; char_num <= 5'd16; char <= " ";state <= SCAN; end
3'd5: begin ypage <= 8'hb1; xpage_high <= 8'h15; xpage_low <= 8'h00; char_num <= 5'd1 ; char <= oled_display_digital[7:4]; state <= SCAN; end
3'd6: begin ypage <= 8'hb1; xpage_high <= 8'h16; xpage_low <= 8'h00; char_num <= 5'd1 ; char <= oled_display_digital[3:0]; state <= SCAN; end
default: state <= IDLE;
endcase
end
INIT: begin //初始化状态
case(cnt_init)
5'd0: begin
oled_rst <= LOW;
cnt_init <= cnt_init + 1'b1;
end //复位有效
5'd1: begin
num_delay <= 15'd25000;
state <= DELAY;
state_last <= INIT;
cnt_init <= cnt_init + 1'b1;
end //延时大于3us
5'd2: begin
oled_rst <= HIGH;
cnt_init <= cnt_init + 1'b1;
end //复位恢复
5'd3: begin
num_delay <= 15'd25000;
state <= DELAY;
state_last <= INIT;
cnt_init <= cnt_init + 1'b1;
end //延时大于220us
5'd4: begin
if(cmd_addr >= CMD_DEPTH) begin
cmd_addr <= 1'b0;
cnt_init <= cnt_init + 1'b1;
end
else begin
cmd_addr <= cmd_addr + 1'b1;
num_delay <= 15'd5;
oled_dcn <= CMD;
wr_reg <= cmd_out;
state <= WRITE;
state_last <= INIT;
end
end
5'd5: begin
cnt_init <= 1'b0;
state <= MAIN;
end
default: state <= IDLE;
endcase
end
SCAN: begin
if(cnt_scan == 4'd12) begin
if(char_num)
cnt_scan <= 4'd3;
else
cnt_scan <= cnt_scan + 1'b1;
end
else if(cnt_scan == 4'd13)
cnt_scan <= 4'd0;
else
cnt_scan <= cnt_scan + 1'b1;
case(cnt_scan)
4'd0: begin oled_dcn <= CMD; wr_reg <= ypage; state <= WRITE; state_last <= SCAN; end //定位列页地址
4'd1: begin oled_dcn <= CMD; wr_reg <= xpage_low; state <= WRITE; state_last <= SCAN; end //定位行地址低位
4'd2: begin oled_dcn <= CMD; wr_reg <= xpage_high; state <= WRITE; state_last <= SCAN; end //定位行地址高位
4'd3: begin char_num <= char_num - 1'b1; end
4'd4: begin char_addr <= char[(char_num*8)+:8]; end
4'd5: begin oled_dcn <= DATA; wr_reg <= 8'h00; state <= WRITE; state_last <= SCAN; end //5*8点阵变成8*8
4'd6: begin oled_dcn <= DATA; wr_reg <= 8'h00; state <= WRITE; state_last <= SCAN; end //5*8点阵变成8*8
4'd7: begin oled_dcn <= DATA; wr_reg <= 8'h00; state <= WRITE; state_last <= SCAN; end //5*8点阵变成8*8
4'd8: begin oled_dcn <= DATA; wr_reg <= char_out[39:32]; state <= WRITE; state_last <= SCAN; end
4'd9: begin oled_dcn <= DATA; wr_reg <= char_out[31:24]; state <= WRITE; state_last <= SCAN; end
4'd10:begin oled_dcn <= DATA; wr_reg <= char_out[23:16]; state <= WRITE; state_last <= SCAN; end
4'd11:begin oled_dcn <= DATA; wr_reg <= char_out[15:8] ; state <= WRITE; state_last <= SCAN; end
4'd12:begin oled_dcn <= DATA; wr_reg <= char_out[7:0] ; state <= WRITE; state_last <= SCAN; end
4'd13:begin state <= MAIN; end
default: state <= IDLE;
endcase
end
WRITE: begin //WRITE状态,将数据按照SPI时序发送给屏幕
if(cnt_write >= 5'd17)
cnt_write <= 5'd0;
else
cnt_write <= cnt_write + 1'b1;
case(cnt_write)
5'd0: begin oled_csn <= LOW; end //9位数据最高位为命令数据控制位
5'd1: begin oled_clk <= LOW; oled_data <= wr_reg[7]; end //先发高位数据
5'd2: begin oled_clk <= HIGH; end
5'd3: begin oled_clk <= LOW; oled_data <= wr_reg[6]; end
5'd4: begin oled_clk <= HIGH; end
5'd5: begin oled_clk <= LOW; oled_data <= wr_reg[5]; end
5'd6: begin oled_clk <= HIGH; end
5'd7: begin oled_clk <= LOW; oled_data <= wr_reg[4]; end
5'd8: begin oled_clk <= HIGH; end
5'd9: begin oled_clk <= LOW; oled_data <= wr_reg[3]; end
5'd10:begin oled_clk <= HIGH; end
5'd11:begin oled_clk <= LOW; oled_data <= wr_reg[2]; end
5'd12:begin oled_clk <= HIGH; end
5'd13:begin oled_clk <= LOW; oled_data <= wr_reg[1]; end
5'd14:begin oled_clk <= HIGH; end
5'd15:begin oled_clk <= LOW; oled_data <= wr_reg[0]; end //后发低位数据
5'd16:begin oled_clk <= HIGH; end
5'd17:begin oled_csn <= HIGH; state <= DELAY; end
default: state <= IDLE;
endcase
end
DELAY: begin
if(cnt_delay >= num_delay) begin
cnt_delay <= 15'd0;
state <= state_last;
end
else
cnt_delay <= cnt_delay + 1'b1;
end
default: state <= IDLE;
endcase
end
end
endmodule
☔️ OLED驱动原理简述
📎 结构及引脚分配
该部分参看SSD1306 OLED驱动芯片 详细介绍、百度云:SSD1306 英文手册(提取码csdn,由于CSDN存在相同资源但是不免费)及百度文库:SSD1306 中文手册,在此不做详述。
🎲 MCU总线接口:4线SPI
关于SSD1306的总线接口,在上述参考链接中均有说明,在此对原文FPGA核心板中所使用的4线SPI接口做详细说明。
FPGA GPIO与OLED(其实是与SSD1306,该驱动集成在显示屏背部)结构连接电路图如下图所示,包括VCC、串行时钟(SCLK)、串行数据(SDIN)、数据/命令控制(D/C)、片选(CS#)及一复位(RES)。
在SCLK每个上升沿,SDIN上的数据按MSB~LSB顺序移位到一个8位移位寄存器中。每八个时钟对D/C进行一次采样,移位寄存器中的8位数据根据D/C的采样结果决定写入到图形显示数据RAM(GDDRAM)或命令寄存器。其写时序如下图所示:
根据上述4线SPI接口的工作原理,即:片选有效后,命令解码模块根据D/C#确定输入数据是被解释为数据还是命令。D/C#引脚为HIGH,SDIN输入的D[7:0]则被解释为写入图形显示数据RAM (Graphic Display Data RAM, GDDRAM)的显示数据。如果是LOW,则D[7:0]被解释为命令,然后被解码并写入相应的命令寄存器。
代码中,在INIT
状态下5’d4处cmd_addr <= cmd_addr + 1'b1; oled_dcn <= CMD; wr_reg <= cmd_out;
即指定D/C#为低电平,而后输入的cmd_out
则被解释为命令编码,并存入相应寄存器中。
SCAN
状态中,oled_dcn <= DATA
则指定D/C#为高电平,而后输入wr_reg
的数据为字符编码的一个八位,连续输入5次,则代表一个字母的全部显示编码,这杯解释为图形显示数据,则存入GDDRAM中。
🔬 OLED驱动模块工作分析(结合代码)
🎨 图形显示数据RAM(GDDRAM)
GDDRAM是一个位映射静态RAM,保存要显示的位模式。内存大小为128×64位,分成8页,从PAGE0到PAGE7,用于黑白128×64点阵显示,如下图所示。
注:重映射在后文说明。
当一个数据字节被写入GDDRAM时,当前列同页的所有行图像数据被填充(即列地址指针指向的整个列(8位)被填充)。数据位D0被写入顶部行。数据位D7写入底部一行。
寻址模式
在SSD1306中有3种不同的内存寻址模式:页寻址模式、水平寻址模式和垂直寻址模式。设置寻址模式需要两个字节的命令:
- 20H,00H:水平寻址模式;
- 20H,01H,垂直寻址模式;
- 20H,02H,页寻址模式(复位);
- 20H,03H,无效。
页寻址模式
页面寻址模式(A[1:0]-10xb)页寻址模式。当GDDRAM被读写后,列地址指针自动增加1。如果列地址指针到达列结束地址,列地址指针将重置为列起始地址,页地址指针不变。用户必须设置新的页面和列地址,以便访问下一页RAM内容。页寻址模式的页和列地址的移动顺序如下图所示。
在正常GDDRAM读写和页寻址模式下,需要定义启动RAM访问指针的位置:
- 通过命令B0H~B7H设置目标显示位置的页面起始地址;
- 通过命令00H~0FH设置指针的较低列地址,高4位恒定为00H,低4位为要设置的起始列地址的低4位;
- 通过命令10H~1FH设置指针的较高列地址,高4位恒定为01H,低4位为要设置的起始列地址的高4位;
- *例如:设定B0H、00H、15H,则指针在PAGE1,COL80(50H = 80D)的COM0处。
例如,页面地址设置为B2H,较低列地址为03H,较高列地址为10H。这意味着起始列是PAGE2的SEG3。内存访问指针的位置如下图所示,输入数据字节将被写入RAM第3列的位置。
代码中,MIAN
状态下设置了要显示的字符串,其中ypage
指定了页,xpage_low
与xpage_high
指定了低与高列地址。由于该OLED屏幕为128×32,因此分为4页(page)和SEG0~SEG127,则ypage = 8'hb0~8'hb3
。
由于每个字符为5×8,保持恰当的字间距使之扩充为8×8,因此一行可以显示16个字符,对于代码中需要不断动态刷新的一行字符串 “VOLTAGE:空空空.空V空空”,两个不断更新的数字需要在第三和第四个“空”字处,因此单独设置这两个字符的访问指针为:
ypage <= 8'hb1; xpage_high <= 8'h15; xpage_low <= 8'h00; // 50H = 80(COL)
ypage <= 8'hb1; xpage_high <= 8'h16; xpage_low <= 8'h00; // 60H = 96(COL)
对于满满一行字符串,则设置高低列地址均为00H,则指针在该页COL0处。
水平模式寻址
在水平寻址模式下,当GDDRAM被读写后,列地址指针自动增加1。如果列地址指针到达列结束地址。列地址指针被重置为列起始地址,页地址指针增加1。
水平寻址模式下,页面和列地址的移动顺序如下图所示。当列地址指针和页地址指针都到达结束地址时,指针被重置为列起始地址和页起始地址(图中虚线)。
垂直模式寻址
在垂直寻址模式下,当GDDRAM被读写后,页面地址指针自动增加1。如果页面地址指针到达页面结束地址,页面地址指针被重置为页面开始地址,列地址指针加1。
垂直寻址方式下,页面和列地址的移动顺序如下图所示。当列地址指针和页地址指针都到达结束地址时,指针被重置为列起始地址和页起始地址(图中虚线)。
重映射及多路复用
- A0/A1,设置段映射:
- A0H:列地址0映射到SEG0(复位状态);
- A1H:列地址127映射到SEG0,即原本:SEG0对应COL0→SEG127对应COL127,现在映射为:SEG0对应COL127→SEG127对应COL0。
-
A8 + A[5:0] (00H~3FH),设置多路复用率(MUX Ratio)为N+1:A[5:0]从0到14的取值都是无效的。代码中,命令RAM中
8'ha8
后接8'h1F
,则指定复用率(MUX Ratio) = 1FH + 1 = 20H,则复用率为20H。 -
C0/C8,设置COM输出扫描方向:
- C0:正常模式(复位状态),从COM0→COM[N-1];
- C1:重映射模式,扫描从COM[N-1]→COM0,其中N为复用率。上述复用率设置为20H,代码中指定为C0,则从COM0→COM31进行扫描。
- DA + A[5:4] (02H/12H/22H/32H):设置COM引脚的硬件配置(以复用率为64为例):
-
A[5:4] = 00H:顺序COM引脚配置,禁止COM左右重映射,当扫描方向为COM0→COM63,COM的配置如下图;当扫描方向为COM63→COM0,为第二幅图。
-
A[5:4] = 10H:顺序COM引脚配置,允许COM左右重映射,当扫描方向为COM0→COM63,COM配置如下图,当扫描方向为COM63→COM0,为第二幅图。
-
A[5:4] = 01H:备用COM引脚配置(奇偶间隔),禁止COM左右重映射,当扫描方向为COM0→COM63,COM配置如下图,当扫描方向为COM63→COM0,为第二幅图。
-
A[5:4] = 11H:备用COM引脚配置(奇偶间隔),允许COM左右重映射,当扫描方向为COM0→COM63,COM配置如下图,当扫描方向为COM63→COM0,为第二幅图。
代码中,命令RAM中8'hda
后接8'h02
,则指定A[5:4] = 00H,顺序配置COM引脚,禁止引脚左右重映射,扫描方向为COM31→COM0。
🍥 代码中使用的工作方式
OLED屏幕上显示出字符的方式不唯一,这与OLED屏幕加载的命令、输入字符编码的顺序等均有关系。
⚠️ 注意:经作者实验,OLED屏幕正面看右上角可能为每一页的COL0、ROW0处;从上至下依次为PAGE3/2/1/0。
单个字符编码
首先考虑代码中,oled_char_RAM
中储存的字符编码是怎么一回事。例如:
Mem[ 50] = {8'h42, 8'h61, 8'h51, 8'h49, 8'h46}; // 50 2
在无段映射(SEG与COL一致,SEG0)、扫描方向为正向(COM0→COM7),写入GDDRAM时先写入字符编码高位,WRITE
状态下先写入一个字节编码的高位时表示字符“2”的字符编码为如下图所示:
为正常显示,需要改变扫描方向和SEG重映射:
字符串写入顺序
字符串由多个字符组成,因此相较于单个字符存在字符扫描顺序问题。该问题取决于页选址模式小节中GDDRAM访问指针的位置。
在无段映射(SEG与COL一致,A0H)、扫描方向为正向(COM0→COM7,C0H),写入GDDRAM时先写入字符编码高位,正序读入字符串时字符串“27”显示的效果为:
指针从右上方红色箭头处开始朝下扫描。
而原文所提供的代码其字符串扫描顺序如下图所示,具体方式说明见后:
指针从左下方红色箭头处开始朝上扫描。
在oled_cmd_RAM
中设置了段重映射,因此SEG0对应COL127→SEG127对应COL0;设置反向扫描,因此每一页(page)从下往上填充一个八位编码。
在读入显示数据(D/C = 1)时,先需要在D/C = 0下输入3个字节的命令以决定RAM访问指针起始位置:ypage
(设置页起始地址)、xpage_low
与xpage_high
(共同设置COL地址)。
在SCAN
状态下,记录字符个数的计数器char_num
是倒序的,由于char_addr <= char[(char_num*8)+:8]
,字符串从最左侧(高位)开始取字符编码(示例中先扫描“2”)。程序中,由于字符编码为5×8,需要扩充至8×8才可在每个字符间保持恰当的间距,因此先输入3列8'h00
(空白),而后再读入字符编码。读入字符编码时,程序中先读入字符“2”的高位(8’h42)→低位(8’h46),并且写入COM中的一个八位编码(8’h42)顺序为:01000010。
多行字符串写入顺序
多行字符串写入需要增加PAGE的控制。具体体现在代码中,由于是页寻址模式,扫描每一行字符串需要设置不同的ypage
、xpage_low
、xpage_high
。