1. 从零开始:认识你的NT35310显示屏

如果你手头有一块NT35310驱动的LCD屏,正准备把它点亮,画上第一个像素,那你来对地方了。我玩过不少这类屏幕,从早期的单色屏到现在的彩色TFT,NT35310算是资源受限的嵌入式项目里一个挺经典的选择。它分辨率是480x320,能显示26万色,自带一个345600字节的显存,最关键的是,它支持多种接口,其中就包括我们嵌入式开发者最常用、也最“底层”的8080并行接口。这意味着,哪怕你的主控芯片没有专门的LCD控制器,也能通过几根普通的GPIO口,用软件“模拟”出通信时序,把它驱动起来。这个过程,就是从硬件连接到软件模拟,再到最终画出图形的一整套“手艺活”。

刚开始接触时,你可能会被数据手册里那一堆时序图和控制指令吓到。别担心,这就像学开车,先搞清楚油门、刹车和方向盘(也就是那几根关键的控制线),剩下的就是熟练工了。这块屏幕的核心秘密,都藏在它的GRAM(图形存储器)里。我们写的每一个指令,最终都是为了告诉屏幕:数据往GRAM的哪个位置存,以及怎么从GRAM里读出来显示。所以,驱动开发本质上就是和这块GRAM进行正确“对话”的过程。

那么,谁适合看这篇实战指南呢?如果你正在用STM32、GD32、ESP32这类微控制器做项目,需要驱动一块NT35310屏幕来显示UI、波形或者图片,但发现现有的库不好用、太臃肿,或者想彻底搞懂底层原理自己从头写一个轻量高效的驱动,那这篇文章就是为你准备的。我会带你走一遍我踩过的坑,分享最稳的实现方法,从最基础的引脚连接、时序模拟,一直讲到如何写出一个高效的画点函数,并以此为基础构建你的图形世界。

2. 硬件基石:理解并模拟8080时序

驱动NT35310,第一步不是写代码,而是看懂硬件怎么连,以及通信的“语言规则”——也就是时序。NT35310支持8080并行接口,这是一种在单片机领域非常常见的通信方式,你可以把它想象成一套简单的“读写”信号协议。

2.1 关键引脚与连接

我们需要关注以下几组核心引脚:

  • 数据线 D[0:15]:一共16根,用于传输命令或像素颜色数据。这是你和屏幕“说话”的内容通道。
  • 控制线:
    • CS (Chip Select):片选信号。只有当它被拉低时,屏幕才会“竖起耳朵”听你说话。就像打电话,得先拨通号码。
    • RS (Register Select):寄存器/数据选择线。这是最关键的一根线,它决定了你通过数据线发送的是“命令”还是“数据”。通常,RS=0表示发送的是指令(比如设置坐标、旋转屏幕),RS=1表示发送的是真正的像素颜色值。
    • WR (Write):写使能线。当你要向屏幕“写入”数据或命令时,需要将这个引脚拉低一个脉冲。相当于说“我现在要发东西了,你准备好接收”。
    • RD (Read):读使能线。当你要从屏幕“读取”数据(比如读ID、读像素颜色)时,需要将这个引脚拉低。不过在实际显示应用中,读操作相对较少。
    • RESET:复位引脚。拉低一段时间可以强制屏幕恢复到初始状态,通常上电后需要操作一次。
    • 背光控制:这个引脚控制屏幕背光的亮灭,一般用PWM来控制亮度。

在实际连接时,将MCU的16个GPIO口(如果只用8位模式,就是8个)连接到屏幕的D0-D15,再把CS、RS、WR、RD分别接到MCU的其他GPIO上。硬件连接就这么简单直接。

2.2 软件模拟8080写时序

很多低成本MCU没有硬件FSMC(灵活的静态存储器控制器),我们就需要用GPIO口和延时来模拟出8080的时序。我画过很多次时序图,总结下来,写一个命令或数据的流程就像一套固定的“广播体操”:

  1. 准备阶段:先设置RS引脚的电平。如果要写命令(比如0x36),就把RS拉低;如果要写数据(比如颜色值0xF800),就把RS拉高。然后把16位数据放到数据线D0-D15上。
  2. 片选有效:将CS引脚拉低,告诉屏幕:“注意,我要开始跟你通信了!”
  3. 发出写脉冲:将WR引脚从高电平拉低,保持一小段时间(这个时间需要参考数据手册,通常是几十纳秒),然后再拉高。这个从低到高的“上升沿”,就是屏幕锁存当前数据线上数据的时刻。
  4. 结束通信:将CS拉高,一次写操作完成。

用代码来体现,就是一个高度优化的函数。这里我分享一个经过实测非常稳定的写法,它避免了使用浮空循环,直接操作寄存器来保证速度:

// 假设已经用宏定义了各个控制引脚对应的GPIO端口和引脚号
#define LCD_CS_PORT  GPIOA
#define LCD_CS_PIN   GPIO_PIN_4
#define LCD_RS_PORT  GPIOA
#define LCD_RS_PIN   GPIO_PIN_5
#define LCD_WR_PORT  GPIOA
#define LCD_WR_PIN   GPIO_PIN_6
// 数据端口假设是GPIOC的0-15脚
#define LCD_DATA_PORT GPIOC

// 快速设置引脚高低电平的宏(以STM32 HAL库为例)
#define LCD_CS_LOW()   HAL_GPIO_WritePin(LCD_CS_PORT, LCD_CS_PIN, GPIO_PIN_RESET)
#define LCD_CS_HIGH()  HAL_GPIO_WritePin(LCD_CS_PORT, LCD_CS_PIN, GPIO_PIN_SET)
// 其他WR、RS类似...

// 写16位数据(命令或数据)的核心函数
void NT35310_Write_16bit(uint16_t data) {
    // 1. 将16位数据输出到数据端口
    // 注意:这里需要根据你的硬件连接,将数据映射到正确的物理引脚上。
    // 假设是连续16个GPIO,可以整体写入端口输出寄存器以提升速度。
    LCD_DATA_PORT->ODR = (LCD_DATA_PORT->ODR & 0xFFFF0000) | data; // 示例,需适配

    // 2. 拉低WR,产生写脉冲
    LCD_WR_LOW();
    // 这里需要一个极短的延时,对于几十MHz的MCU,几个NOP指令就够了
    __NOP(); __NOP(); __NOP(); __NOP();

    // 3. 拉高WR,在上升沿锁存数据
    LCD_WR_HIGH();
}

而封装好的写命令和写数据函数,区别仅在于RS引脚的状态:

void NT35310_Write_Cmd(uint16_t cmd) {
    LCD_RS_LOW(); // 写命令时,RS置低
    NT35310_Write_16bit(cmd);
}

void NT35310_Write_Data(uint16_t data) {
    LCD_RS_HIGH(); // 写数据时,RS置高
    NT35310_Write_16bit(data);
}

注意:模拟时序的关键是延时时间要满足数据手册的要求。太快了屏幕可能来不及反应,太慢了又会影响刷屏速度。通常用__NOP()或简单的循环来调整。如果MCU支持,使用硬件定时器产生精确延时是更专业的方法。

2.3 硬件加速方案:FSMC

如果你的主控是STM32F1/F4系列,并且屏幕接口接在了支持FSMC的引脚上,那么恭喜你,你可以获得“飞一般”的体验。FSMC是STM32内置的一个外设,它可以把一片外部存储器(或LCD这类设备)映射到MCU的内存地址空间。你只需要配置好FSMC的时序参数,之后向某个特定的内存地址写数据,硬件就会自动帮你生成符合8080时序的波形,速度远超软件模拟,刷全屏图片会流畅得多。设置FSMC涉及一堆寄存器,主要是配置地址建立时间、数据建立时间、总线宽度等。一旦配置成功,你的写操作就会简化为 *(volatile uint16_t *)0x60000000 = data; 这样的形式,非常简洁高效。不过,这篇实战我们先聚焦于更通用的软件模拟方法,掌握了原理,再用FSMC就是水到渠成。

3. 核心对话:NT35310指令系统详解

硬件通了,接下来就要学习屏幕能听懂哪些“单词”和“句子”,也就是它的指令集。NT35310的指令都是16位的,但绝大多数指令(除了后面要说的写GRAM指令0x2C)只使用低8位,高8位忽略。发送指令的流程永远是:先发命令码,再发参数(如果有的话)。

3.1 身份识别:读取ID指令(0xD3)

拿到一块屏幕,第一件事往往是确认它是不是NT35310,或者具体是哪一款。发送命令0xD3,然后连续读取4个字节(32位)的数据,其中通常包含制造商ID、驱动器版本等信息。这个ID可以用来在代码中做屏幕类型检测,适配不同的初始化序列。实现读时序和写时序类似,但需要把数据线设置为输入模式,并操作RD引脚。读取ID的代码可以帮助你验证硬件连接和基本通信是否正常。

3.2 显示方向控制:存储访问控制指令(0x36)

这是最有意思也最实用的指令之一。指令0x36后面跟的一个参数,决定了屏幕GRAM的扫描方向,直接影响了显示内容的朝向。参数中的MY、MX、MV三个位是控制关键。

MYMXMV扫描方向(GRAM指针增长方向)显示效果(默认竖屏为基准)
000从左到右,从上到下正常显示(0度旋转)
100从左到右,从下到上上下翻转(180度旋转?不,是Y轴镜像)
010从右到左,从上到下左右翻转(X轴镜像)
110从右到左,从下到上180度旋转
001从上到下,从左到右顺时针90度旋转
011从上到下,从右到左顺时针90度旋转 + 左右镜像
101从下到上,从左到右逆时针90度旋转
111从下到上,从右到左逆时针90度旋转 + 左右镜像

怎么用呢?比如你的屏幕是竖着放的,但希望显示横屏的内容,你就可以设置MY=0, MX=0, MV=1,实现顺时针90度旋转。这里有个大坑:旋转后,屏幕的X和Y方向也交换了!原本480x320(宽x高),旋转90度后,逻辑上变成了320x480。你的画点函数坐标映射必须跟着变,否则画出来的位置全是错的。我当初就在这里调试了半天。

3.3 设定绘画区域:列与行地址设置指令(0x2A, 0x2B)

在屏幕上画图,你得告诉画笔从哪里开始,到哪里结束。0x2A指令就是设置X坐标(列地址)范围,0x2B是设置Y坐标(行地址)范围。每个指令后面需要跟4个8位参数,分别是起始坐标的高8位、低8位,和结束坐标的高8位、低8位。

例如,你想设置从点(10, 20)开始绘制,那么:

  1. 发送命令 0x2A
  2. 发送数据 0x00 (10的高8位,其实就是0)
  3. 发送数据 0x0A (10的低8位)
  4. 发送数据 0x01 (假设结束x=480,高8位是0x01)
  5. 发送数据 0xE0 (480的低8位是0xE0)
  6. 发送命令 0x2B
  7. 发送数据 0x00 (20的高8位)
  8. 发送数据 0x14 (20的低8位)
  9. 发送数据 0x01 (假设结束y=320,高8位是0x01)
  10. 发送数据 0x40 (320的低8位是0x40)

提示:如果你只是画一个点或者连续填充一个矩形区域,设置起始和结束坐标是必要的。但如果你打算用“画点”函数逐个像素绘制,通常只需要在画点函数内部设置起始坐标(因为结束坐标就是起始坐标本身),这样可以节省一次指令传输,稍微提升一点速度。

3.4 开始绘画:写GRAM指令(0x2C)

这是最常用的指令,没有之一。在你用0x2A和0x2B设定好一块矩形区域后,发送0x2C命令,接下来你发送的所有16位数据,都会被当作RGB565格式的颜色值,依次填充到设定的区域里。而且,GRAM的地址会根据你之前通过0x36指令设置的扫描方向自动递增。比如默认从左到右扫描,填完第一行第一个点,地址会自动指向第一行第二个点,直到碰到你设定的X结束坐标,然后Y坐标加一,跳转到下一行开始。这个特性是连续刷屏或填充矩形的性能关键。你可以一次性发送成千上万个颜色数据,而无需反复发送坐标指令,效率极高。

3.5 初始化流程:让屏幕准备好

屏幕刚上电时,内部状态是未知的,需要一套规定的“唤醒”序列。NT35310的数据手册会提供一个推荐的初始化命令序列。这个序列通常包括:软件复位、设置像素格式、设置扫描方向、打开显示等。你需要严格按照手册里的顺序和延时,一条条命令发下去。我通常会把整个初始化序列做成一个数组,在系统启动后调用。一个典型的初始化开头是这样的:

void NT35310_Init(void) {
    // 1. 硬件复位(如果接了RESET引脚)
    LCD_RST_LOW();
    Delay_ms(100);
    LCD_RST_HIGH();
    Delay_ms(100);

    // 2. 发送软件复位命令(可选,看手册)
    NT35310_Write_Cmd(0x01);
    Delay_ms(120); // 复位需要较长延时

    // 3. 发送初始化序列
    NT35310_Write_Cmd(0xCF);
    NT35310_Write_Data(0x00);
    NT35310_Write_Data(0xC1);
    NT35310_Write_Data(0X30);
    // ... 后续几十条命令
    // ...

    // 4. 设置显示方向
    NT35310_Write_Cmd(0x36);
    NT35310_Write_Data(0x48); // 例如,设置MY=0, MX=1, MV=0,根据你的需要

    // 5. 打开显示
    NT35310_Write_Cmd(0x29);
    Delay_ms(100);

    // 6. 清屏为白色或黑色
    LCD_Clear(WHITE);
}

注意:不同厂家、不同批次的NT35310屏幕,初始化序列可能略有差异。如果发现初始化后屏幕花屏、颜色不对或者不亮,第一件事就是检查初始化序列,或者尝试从供应商那里获取准确的序列。

4. 实战绘制:从画点到图形界面

一切准备就绪,终于到了最激动人心的环节:让屏幕亮起来,并画出东西。所有复杂的图形,无论是直线、圆、图片还是UI,其基础都是一个最核心的函数——画点函数。

4.1 构建高效的画点函数

画一个点,需要三步:设置这个点的坐标,发送写GRAM指令,发送这个点的颜色值。代码如下:

// 假设屏幕宽高已定义
#define LCD_WIDTH  480
#define LCD_HEIGHT 320

void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) {
    // 1. 边界检查,防止写入越界
    if (x >= LCD_WIDTH || y >= LCD_HEIGHT) {
        return;
    }

    // 2. 设置坐标:只设置起始坐标(因为只画一个点,起始=结束)
    NT35310_Write_Cmd(0x2A);
    NT35310_Write_Data(x >> 8);
    NT35310_Write_Data(x & 0xFF);
    // 注意:这里没有发送结束坐标参数。对于单点,很多驱动实现只发起始坐标。
    // 更严谨的做法是发四个参数,但只发两个(起始坐标)通常也能工作,因为屏幕会默认结束坐标等于起始坐标。

    NT35310_Write_Cmd(0x2B);
    NT35310_Write_Data(y >> 8);
    NT35310_Write_Data(y & 0xFF);

    // 3. 发送写GRAM命令和颜色数据
    NT35310_Write_Cmd(0x2C);
    NT35310_Write_Data(color);
}

这个函数非常清晰,但效率上有优化空间。每次画点都要发送两次设置坐标的命令(共4-6次数据写入),开销很大。一个重要的优化技巧是“窗口”模式:如果你要连续画多个相邻的点(比如画线、填充矩形),可以先设置一个窗口区域(用0x2A和0x2B设置起始和结束坐标),然后只发一次0x2C命令,接着连续发送所有点的颜色数据。GRAM地址会自动递增,无需为每个点重复设置坐标。

4.2 从点到线,从线到面

有了LCD_DrawPoint,你就拥有了创造一切的画笔。下面是一些基础图形算法的简单实现思路:

  • 画线(Bresenham算法):这是计算机图形学中最经典的画线算法,只用整数加减和比较,速度极快。你可以实现水平线、垂直线和任意斜率的直线。
  • 画矩形:调用四次画线函数,或者更高效地,用“窗口”填充的方式:设置矩形区域为窗口,然后连续发送矩形面积个数的同一种颜色数据。
  • 画圆:同样可以用Bresenham画圆算法。
  • 填充:填充矩形就是上述的窗口连续写。填充任意闭合区域可以用扫描线种子填充算法,不过在单片机上更常用的是先画轮廓再近似填充。

这里给一个优化后的矩形填充函数示例,它利用了连续写的特性:

void LCD_Fill(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) {
    uint32_t total_pixels = (uint32_t)(x2 - x1 + 1) * (y2 - y1 + 1);
    uint32_t i;

    // 设置填充窗口
    NT35310_Write_Cmd(0x2A);
    NT35310_Write_Data(x1 >> 8);
    NT35310_Write_Data(x1 & 0xFF);
    NT35310_Write_Data(x2 >> 8);
    NT35310_Write_Data(x2 & 0xFF);

    NT35310_Write_Cmd(0x2B);
    NT35310_Write_Data(y1 >> 8);
    NT35310_Write_Data(y1 & 0xFF);
    NT35310_Write_Data(y2 >> 8);
    NT35310_Write_Data(y2 & 0xFF);

    // 发送写命令,然后连续写入颜色数据
    NT35310_Write_Cmd(0x2C);
    for(i = 0; i < total_pixels; i++) {
        NT35310_Write_Data(color);
    }
}

4.3 显示图片与字体

显示一张图片,本质上是将图片的像素颜色数组,按照正确的顺序,搬运到屏幕的GRAM中。你需要先将图片(比如BMP、JPEG)在电脑上转换成RGB565格式的数组。这个数组可以存放在MCU的内部Flash(如果不大)、外部SPI Flash、或者SD卡中。显示时,设置好图片在屏幕上显示的起始坐标和大小(宽度和高度),然后以这个区域为窗口,将整个像素数组通过0x2C指令连续发送出去。如果图片太大,内存放不下,就需要分段读取和显示。

显示字符或汉字,则是使用“字模”。每个字符对应一个位图(点阵),显示时,遍历这个位图,遇到1就画前景色,遇到0就画背景色(或者不画)。通常我们会把常用字符的字模做成一个数组库,比如12x12、16x16、24x24点阵的字库。

4.4 性能优化与调试技巧

在资源受限的MCU上驱动LCD,性能是个永恒的话题。除了上面提到的使用“窗口”连续写,还有以下优化点:

  1. 减少函数调用开销:将最底层的NT35310_Write_Data和NT35310_Write_Cmd函数用宏或内联函数实现。
  2. 使用DMA:如果MCU支持,并且你使用FSMC或SPI等硬件接口,配置DMA来搬运颜色数据到LCD,可以极大解放CPU,实现后台刷屏。
  3. 局部刷新:只更新屏幕上变化的部分区域,而不是整个屏幕。这对做UI动画或仪表盘非常有用。
  4. 双缓冲:在内存中开辟一块和屏幕一样大的缓冲区(对于480x320 RGB565,需要300KB!这对很多MCU不现实),所有绘图操作先在内存缓冲区中进行,完成一帧后,再一次性DMA搬运到屏幕。这可以消除闪烁,但极其消耗内存。

调试时,最常用的工具就是逻辑分析仪或者示波器。抓取CS、WR、RS和数据线的波形,对照8080时序图,可以精准定位是命令发错了,还是时序不对。另外,准备一个简单的“测试色块”函数,依次填充红、绿、蓝、白、黑等纯色,可以快速检查颜色通道和连接是否正确。遇到花屏,首先检查初始化序列、扫描方向设置以及电源是否稳定。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐