1. 项目缘起:从“点灯”到“显示”的跨越
如果你玩过Arduino或者STM32,第一个项目大概率是“点灯”——让一个LED闪烁。这确实是电子世界的“Hello World”,它验证了硬件连接、代码烧录和最基本的GPIO控制。但当你成功点亮第一个LED后,那种兴奋感很快就会消退,因为你会发现,除了亮和灭,你无法从这个小灯珠上获得任何更丰富的信息。它无法告诉你传感器读到了什么值,无法显示系统当前的状态,更无法进行人机交互。这时,你就会自然而然地想到下一个里程碑式的项目:驱动一块LCD屏幕。
“项目六 – LCD演示”这个标题,听起来简单,甚至有些平淡,但它恰恰是无数嵌入式爱好者、学生和创客从“硬件控制”迈向“信息呈现”的关键一步。LCD(液晶显示器)就像是为你的微控制器项目打开了一扇窗,让内部运行的数据、逻辑和状态能够直观地“说”给你听。无论是显示一句“Hello World”,还是实时绘制传感器波形,LCD都是连接数字世界与物理世界的桥梁。我最初接触LCD时,觉得它比LED复杂十倍不止,但真正上手后才发现,只要理清了通信协议、初始化流程和显示缓冲区的概念,它带来的成就感也是点灯的十倍。今天,我就结合Arduino和STM32这两个最流行的平台,来拆解LCD驱动的核心,特别是大家常踩的坑,比如STM32H750用DMA驱动SPI LCD时那些让人头疼的问题。
2. LCD驱动基础:协议、接口与初始化时序
在动手写代码之前,我们必须搞清楚LCD屏幕是怎么“听”我们的话的。这不像LED,给个高电平就亮。LCD内部是一个复杂的矩阵,我们需要告诉它点亮哪些像素、显示什么颜色。这个“告诉”的过程,就是通过通信协议完成的。
2.1 常见通信协议:SPI、I2C与8080/6800并行
对于小型LCD(比如常见的1.3寸、1.8寸、2.4寸屏),主流的通信方式有三种,它们各有优劣,选择哪种往往由屏幕本身的控制器和你的项目需求决定。
SPI(串行外设接口):这是目前最常见的选择,尤其是在Arduino生态中。它的优点是引脚占用少,通常只需要4根线(SCK时钟、MOSI主机输出、MISO主机输入、CS片选),如果不需要读回屏幕数据,MISO甚至可以省掉。通信速率快,适合刷新图片或简单动画。市面上绝大多数为Arduino设计的“Grove”接口LCD模块或插接式LCD扩展板,都采用SPI接口。它的工作模式是主从式,微控制器作为主机,发送时钟和数据给作为从机的LCD控制器。
I2C(Inter-Integrated Circuit):如果你觉得4根线还是太多,那么I2C是更好的选择,它只需要2根线(SDA数据、SCL时钟)。它通过地址来寻址总线上不同的设备,可以很方便地挂载多个传感器和屏幕。但代价是速度较慢,因为它是半双工通信,且协议开销比SPI大。通常只用于刷新率要求不高的字符显示,比如显示几行温湿度数据。很多OLED小屏喜欢用I2C。
8080/6800并行接口:这是一种古老的、但仍在一些中大尺寸屏或高性能场合使用的接口。它采用8位或16位的数据总线,配合读、写、使能等控制线,一次性传输一个字节或一个字的数据。优点是速度极快,适合刷屏或显示视频。缺点是极其占用IO口(动辄十几二十根线),布线复杂。STM32的FSMC(灵活静态存储器控制器)外设就是专门用来高效驱动这类并行屏的。
对于初学者和大多数创意项目,SPI接口的LCD是最佳起点。它平衡了速度、复杂度和资源占用。我们后续的讨论也将以SPI为主。
2.2 理解LCD控制器:并非直接控制像素
这里有一个关键概念:我们写的代码,并不是直接控制LCD面板上的每一个液晶分子。我们是在与一块集成在屏幕背后的LCD驱动控制器芯片通信。常见的控制器有ST7789、ILI9341、ILI9488等。我们的所有命令和数据,都是发给这个控制器的。
控制器内部有显存(GRAM)。你可以把它想象成一块画布。我们的任务就是通过SPI总线,把想要显示的图像数据“画”到这块GRAM里。控制器会自动地、持续地从GRAM中读取数据,转换成对应的电压,去驱动LCD面板上的像素点。所以,所谓的“刷新屏幕”,其实就是更新GRAM的内容。
2.3 核心操作:命令与数据
与LCD控制器的通信分为两种类型:发送命令和发送数据。这是所有LCD驱动的基石。
- 命令(Command / Register):用来设置控制器的工作模式。比如,设置屏幕的扫描方向(横屏还是竖屏)、颜色格式(RGB565还是RGB888)、进入或退出睡眠模式、开启或关闭显示等。每个命令都有一个唯一的命令码(Command Code)。
- 数据(Data):在发送某些命令后,需要跟随发送的参数或实际的像素数据。例如,发送设置“列地址”的命令后,需要跟两个字节的数据来指定起始列和结束列;发送“写GRAM”的命令后,后面跟着的所有数据都会被当作像素颜色值写入GRAM。
在SPI通信中,通常用一根额外的引脚(DC或A0)来区分当前发送的是命令还是数据。例如,拉低DC引脚表示接下来发送的是命令,拉高则表示发送的是数据。这是硬件上的约定,必须在代码中正确控制。
初始化序列:在LCD上电后,必须按照控制器数据手册(Datasheet)提供的一段固定的命令序列进行初始化。这个序列可能包含几十条命令,用于复位控制器、设置伽马校正、电源控制、像素格式等。这段代码通常又长又枯燥,但至关重要。幸运的是,流行的LCD库(如Arduino的TFT_eSPI、STM32的LTDC驱动库)都已经为我们写好了针对不同控制器的初始化代码。
3. 在Arduino平台上驱动LCD:从库函数到底层理解
Arduino的伟大之处在于其丰富的库生态,让驱动LCD变得异常简单。但作为学习者,我们不能只停留在调用库函数的层面。
3.1 常用库与硬件连接
最强大的库之一是TFT_eSPI。它支持海量的SPI和并行屏,驱动程序经过高度优化。使用步骤如下:
- 安装库:在Arduino IDE的库管理中搜索“TFT_eSPI”并安装。
- 配置驱动:这是最关键的一步。找到Arduino安装目录下的
libraries/TFT_eSPI/User_Setup.h文件。你需要根据自己屏幕的型号、控制器、分辨率、引脚连接来修改这个文件。
修改并保存后,库在编译时就会根据你的配置生成对应的底层驱动代码。// 例如,驱动一块ILI9341的2.4寸SPI屏,连接如下: // TFT_CS -> Arduino pin 10 // TFT_DC -> Arduino pin 9 // TFT_RST -> Arduino pin 8 // TFT_MOSI -> Arduino pin 11 (Arduino Uno的硬件SPI引脚) // TFT_SCLK -> Arduino pin 13 (Arduino Uno的硬件SPI引脚) // TFT_MISO -> Arduino pin 12 (可不用,如果不需要读屏) // TFT_LED -> 接一个PWM引脚控制背光 #define ILI9341_DRIVER // 告诉库你用的控制器 #define TFT_WIDTH 240 // 屏幕宽度 #define TFT_HEIGHT 320 // 屏幕高度 // 定义引脚 #define TFT_CS 10 #define TFT_DC 9 #define TFT_RST 8 // 使用硬件SPI #define USE_HSPI_PORT - 编写应用代码:现在你可以像使用高级图形API一样轻松绘图了。
#include <TFT_eSPI.h> TFT_eSPI tft = TFT_eSPI(); void setup() { tft.init(); tft.setRotation(1); // 设置屏幕方向 tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.drawString("Hello World!", 50, 100, 4); } void loop() { // 动态显示传感器数据 int sensorValue = analogRead(A0); tft.fillRect(50, 150, 100, 20, TFT_BLACK); // 清空之前的数据区域 tft.drawNumber(sensorValue, 50, 150, 2); delay(100); }
注意:
TFT_eSPI库默认使用SPI的transfer()函数进行通信。对于简单的字符和图形刷新,这完全足够。但当你需要快速刷新全屏图片或动画时,软件SPI(用digitalWrite模拟)会非常慢,务必使用硬件SPI引脚,并考虑启用库中的帧缓冲(Frame Buffer)功能,虽然会消耗更多内存,但可以避免刷新时的闪烁。
3.2 避开Arduino IDE的“慢”坑
很多新手会遇到编译慢、上传慢、库管理失败的问题。除了网络原因,一些设置可以改善:
- 不要安装在中文路径或系统盘:将Arduino IDE安装到
D:\Arduino这样的纯英文、无空格路径下。 - 使用国内镜像:在“文件”->“首选项”的“附加开发板管理器网址”中,添加国内的板子列表镜像,可以加速开发板包的下载。
- 管理库文件夹:手动下载的库(ZIP文件),可以通过“项目”->“加载库”->“添加.ZIP库”来安装,比IDE内下载更可靠。
- 关于版本:Arduino IDE 2.x 界面更现代,但1.8.x版本依然稳定且资源占用小。对于ESP32等板子,在2.0中安装支持包可能更快。根据你的电脑和网络情况选择即可。
4. 进军STM32:硬件SPI与DMA的威力
当你觉得Arduino的性能或灵活性不够时,STM32是自然的进阶选择。STM32的硬件SPI外设性能强大,如果再配上DMA(直接存储器访问),就能实现“解放CPU”的高效刷屏。
4.1 环境搭建:STM32在Arduino框架下
是的,STM32也可以用Arduino框架来开发,这降低了入门门槛。你需要:
- 在Arduino IDE中,通过“开发板管理器”安装“STM32 MCU based boards”支持包。
- 选择对应的开发板型号(如Nucleo-144, Generic STM32H7 series等)。
- 像在Arduino Uno上一样,使用
TFT_eSPI库。但需要根据STM32的引脚定义重新修改User_Setup.h。STM32的硬件SPI引脚是固定的(如SPI1的SCK=PA5, MOSI=PA7, MISO=PA6),你需要将TFT的引脚连接到这些硬件SPI引脚上,并在配置中启用正确的SPI端口(如#define USE_HSPI_PORT对于STM32可能不适用,需要查看库文档或示例)。
这种方式让你可以快速将Arduino项目迁移到性能更强的STM32上,但可能无法充分发挥STM32的全部硬件特性。
4.2 深入HAL库:配置硬件SPI
要追求极致性能和控制力,必须使用STM32CubeIDE和HAL库(或LL库)进行开发。以STM32H750驱动SPI LCD为例,步骤如下:
- CubeMX配置:
- 在
Pinout & Configuration标签页,启用一个SPI外设(如SPI1)为全双工主模式。 - 配置引脚:SCK, MOSI, MISO(可选),另外手动配置两个GPIO输出:一个作为
CS(片选),一个作为DC(命令/数据)。 - 配置SPI参数:时钟预分频器(决定SPI速度,如
PCLK/4)、数据大小(8位或16位)、时钟极性和相位(CPOL/CPHA,这必须与LCD控制器数据手册要求一致,通常为Mode 0或Mode 3)。 - 在
DMA Settings标签页,为SPI的TX(发送)流添加一个DMA通道(如DMA1 Stream3),方向为存储器到外设,数据宽度为字节或半字(与SPI数据大小匹配)。
- 在
- 生成代码:CubeMX会生成初始化代码。关键函数是
HAL_SPI_Transmit()(阻塞式)和HAL_SPI_Transmit_DMA()(非阻塞DMA式)。 - 编写驱动函数:你需要封装两个基础函数:
然后,用void LCD_WriteCommand(uint8_t cmd) { HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_RESET); // DC拉低,写命令 HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); // CS拉低,选中设备 HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); // CS拉高 } void LCD_WriteData(uint8_t data) { HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); // DC拉高,写数据 HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &data, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); }LCD_WriteCommand和LCD_WriteData组合,实现初始化序列和设置显示窗口等功能。
4.3 DMA驱动的陷阱与排错:以STM32H750为例
这是本文的重中之重,也是很多人在STM32H7系列上栽跟头的地方。你按照教程配置了DMA,代码编译通过,但屏幕就是一片漆黑或者花屏。问题可能出在以下几个层面:
4.3.1 内存一致性(Cache)问题STM32H750具有高速缓存(Cache)。当你使用DMA时,数据流是这样的:CPU准备显示数据,写入到SRAM中的数组(缓冲区)-> DMA引擎从SRAM中读取数据 -> 通过SPI发送给LCD。 问题在于:CPU写入的数据可能还停留在Cache里,并没有真正刷到SRAM中。而DMA是直接访问SRAM的,它读到的就是“过时的”或错误的数据,导致屏幕显示乱码。
解决方案:在启动DMA传输前,必须确保缓存数据写回到了内存。
- 对于存放发送数据的数组:使用
__attribute__((section(".dma_buffer")))将其定义到特定的、可缓存性配置的内存段(如DTCM或AXI SRAM),并在CubeMX的System Core->MPU(内存保护单元)中配置该内存区域为“Write-through”或“Non-cacheable”。 - 更简单的软件方法:在启动DMA传输前,调用
SCB_CleanDCache_by_Addr()函数,强制清理数据缓存中对应缓冲区地址范围的数据。这是最常用且有效的办法。uint16_t frame_buffer[320*240]; // 显示缓冲区 // ... 填充frame_buffer ... // 在调用HAL_SPI_Transmit_DMA前,清理缓存 SCB_CleanDCache_by_Addr((uint32_t*)frame_buffer, sizeof(frame_buffer)); HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)frame_buffer, sizeof(frame_buffer));
4.3.2 SPI时钟速度与DMA带宽不匹配STM32H750的SPI时钟可以设置得非常高(理论上可达系统时钟分频后的高速)。但DMA从内存搬运数据到SPI数据寄存器(DR)的速度是有限的。如果SPI时钟设得太快,DMA来不及供应数据,就会导致SPI underrun错误,发送的数据不完整。
解决方案:
- 适当降低SPI的时钟分频系数,不要一开始就追求极限速度。
- 优化DMA源数据地址的对齐和传输位宽。如果SPI数据宽度是16位,那么DMA的源数据地址最好是半字对齐(2字节对齐),传输位宽也设置为半字。
- 检查DMA通道的优先级,确保其有足够高的优先级不被其他中断长时间打断。
4.3.3 DMA传输完成中断(TC)与连续刷屏当你用DMA发送完一帧数据后,会进入DMA传输完成中断回调函数HAL_SPI_TxCpltCallback()。如果你想实现动画,就需要在这个回调函数里准备下一帧数据,并再次启动DMA传输。这里有个关键细节:在本次DMA传输真正、完全结束之前,不要修改发送缓冲区。否则DMA可能读到正在被修改的数据。
解决方案:使用双缓冲区(Double Buffering)。
- 准备两个缓冲区:
BufferA和BufferB。 - 当DMA正在从
BufferA发送数据时,CPU在BufferB中绘制下一帧。 - 在
HAL_SPI_TxCpltCallback()中断中,交换缓冲区指针,让DMA下一次从BufferB发送,而CPU去填充BufferA。 - 这样实现了绘制和传输的并行,避免了数据竞争,也能获得更流畅的动画效果。
4.3.4 硬件连接与电源别忽略了硬件!SPI时钟线(SCK)过长或靠近干扰源,会导致信号畸变。确保所有信号线尽可能短,并检查连接是否牢固。另外,LCD模块的背光需要足够的电流驱动,如果直接从MCU引脚取电,可能导致背光暗淡或MCU复位。最好使用三极管或MOS管来驱动背光。
排查DMA驱动问题时,一个有效的思路是:先让阻塞模式工作。即先用HAL_SPI_Transmit()函数实现最基本的显示(如清屏为某种颜色)。如果阻塞模式正常,说明硬件连接、SPI基本配置、初始化序列都是对的。然后再切换到DMA模式,并依次排查缓存、速度、缓冲区管理这些问题。
5. 进阶应用:从显示数据到绘制波形
当你能稳定驱动LCD后,就可以玩些更有趣的了。比如,实现一个简单的示波器或频谱显示。
核心思路:
- 数据采集:使用ADC以固定频率采样信号。
- 坐标映射:将ADC的采样值(如0-4095)映射到LCD屏幕的Y轴坐标(如0-239)。同时,X轴代表时间,每个采样点占据一个或几个像素的宽度。
- 绘制波形:最简单的方法是画线(Line)或画点(Pixel)。从第一个点开始,依次用线段连接相邻的采样点。为了消除闪烁,可以采用“先擦后画”的方式:在绘制新一帧波形前,先用背景色重绘上一帧波形所在的区域(即局部清屏),然后再绘制新波形。
- FFT频谱显示:如果你采集的是一段音频或振动信号,可以对采样数据进行快速傅里叶变换(FFT),得到其频率分量。然后将每个频率分量的幅度(强度)映射为LCD上的一列垂直柱状图的高度。这需要你集成一个FFT库(如ARM的CMSIS-DSP库),并对复数运算和窗函数有一定了解。
这个项目将综合运用ADC、定时器、DMA(用于ADC)、LCD驱动、甚至DSP知识,是一个非常好的综合性练习。它让你从“让屏幕显示东西”进化到“用屏幕分析东西”。
驱动LCD,尤其是用好DMA,是嵌入式开发中的一个经典课题。它涉及GPIO、SPI、DMA、中断、内存管理等多个核心概念。从Arduino的简单库函数调用,到STM32 HAL库的配置,再到H7系列上解决Cache一致性问题,这个过程本身就是一次扎实的学习之旅。当你第一次看到自己编写的代码在屏幕上流畅地画出波形时,那种感觉和第一次点亮LED是完全不同的——你创造了一个能与现实世界交互的窗口。希望这篇长文能帮你少走些弯路,更快地享受到这种创造的乐趣。