简介:这是一份面向嵌入式开发者的ILI9341 TFT液晶屏驱动源码,配套芯片详解与应用说明,适合使用Arduino、Raspberry Pi等平台需要点亮屏幕、快速上手的开发者。资源包为单个C语言源文件(共1个文件),压缩体积仅2KB,轻量精简,便于直接嵌入项目进行二次开发与移植。已有233人学习下载。源码示例覆盖屏显初始化、清屏、绘制图形、显示文本等基础操作,并演示SPI与并行接口的底层时序控制,涵盖色深配置、伽马校正、对比度调节等关键寄存器设置,能帮助读者理解ILI9341的工作机制,快速实现高分辨率(800x480)、16.7M色真彩显示效果。无论是入门学习驱动编写,还是做小尺寸TFT显示项目,这份代码都能作为实用模板,省去从底层摸索的时间。
1. ILI9341这颗驱动芯片为什么值得再拆一遍
从网盘下载的ili9341.rar里通常就一个ili9341.c,很多人直接编进工程,屏幕却不亮或者花屏。问题不在代码本身,而在你对接的是哪套硬件接口、哪块面板。ILI9341是常见的240x320 TFT控制器,支持SPI、8/16位并口,内部有伽马校正和电源控制,但真正让工程师头疼的是初始化序列、坐标窗口设置和帧缓冲管理。下面以ili9341.c为线索,从接口选型、时序参数、寄存器操作讲到树莓派上的移植与帧率优化,既适合刚入门的嵌入式开发者,也能给正在做屏幕驱动移植的工程师提供排错思路。
2. ILI9341硬件接口与初始化时序解析
2.1 接口模式选型:SPI、8/16位并行的取舍
ILI9341的接口模式由IM0-IM3引脚的电平决定。实际上绝大多数模组已经把引脚电平固定死了,能选的只有SPI和并口。SPI模式只需要SCK、MOSI、CS、DC四根线,再加RES和背光,总共6根GPIO,适合树莓派、ESP32这类引脚不算富裕的平台。8位并行模式需要D0-D7八根数据线加上RD、WR、CS、RS等控制线,刷新率高,但在Linux板子上要处理电平转换和复用冲突,复杂度明显增加。
下面给出4线SPI模式的典型引脚映射,可以直接对照模组背面丝印:
| 功能 | 引脚名称 | 方向 | 说明 |
|---|---|---|---|
| 时钟 | SCL | 输入 | SPI时钟,初始建议10MHz |
| 数据 | SDA | 输入 | MOSI数据线 |
| 片选 | CS | 输入 | 低有效,必须拉低才能通信 |
| 数据/命令 | DC | 输入 | 低电平写命令,高电平写数据 |
| 复位 | RES | 输入 | 低电平复位 |
| 背光 | BLK | 输入 | 高电平点亮背光 |
选SPI还是并口,要看应用场景。如果只是显示静态界面或低速仪表,SPI足够。如果需要跑图形动画或视频播放,优先确认屏体是否支持8位并口。注意有些模组标注“SPI接口”但用的是3线SPI(没有DC线,命令和数据前面加一个控制字节),这跟常见的4线SPI完全不同。ili9341.c里如果只处理4线SPI,换到3线屏会全部花屏。
2.2 上电时序与复位电路
ILI9341复位时序要求非常明确:电源稳定后,RES拉低至少10us,再拉高并等待5ms。如果RES被拉低时间太短或者上电瞬间电源抖动,芯片可能进入未知状态,表现出来就是白屏或背光亮但无内容。
常见驱动初始化函数第一步通常是一个软复位,纯粹为了保险再拉一次RES引脚:
void ili9341_hw_reset(void) { gpio_set_output(PIN_RESET, 1); // 复位引脚先拉高 delay_ms(1); gpio_set_output(PIN_RESET, 0); // 拉低开始复位 delay_us(120); // 至少120us gpio_set_output(PIN_RESET, 1); // 释放复位 delay_ms(20); // 等待芯片完全苏醒 }这段代码里的PIN_RESET需要提前配置成推挽输出。delay_ms(20)是保守值,因为初始化序列中后续命令对时序要求不高,但刚唤醒时GRAM尚未完全稳定,偶尔会有显示偏移。如果在硬件上忘记接RES引脚,把这行函数删掉,驱动也会尝试通过命令0x01复位,但部分模组会失败,所以硬件复位仍然保留为好。
2.3 初始化序列:为什么先发一堆命令
ILI9341上电后默认处于Sleep In模式,只有按数据手册里的init flow发送固定命令,面板才能正常显示。ili9341.c里通常用一个const数组保存整个序列,每条命令格式是:命令字节,参数数量,参数列表。
static const uint8_t init_seq[] = { 0xC0, 2, 0x23, 0x0A, // 功率控制1:VRH和Divider 0xC1, 1, 0x10, // 功率控制2:VGH和VGL 0xC5, 2, 0x3E, 0x28, // VCOM控制 0x36, 1, 0x48, // 内存访问控制:横屏 0x3A, 1, 0x55, // 接口像素格式:RGB565 0xB1, 2, 0x00, 0x18, // 帧率控制 0x11, 0, // 退出睡眠 0x29, 0 // 打开显示 };这些寄存器是每个ILI9341驱动都会配置的核心项。0x36控制扫描方向,0x48对应MY=1、MX=0,很多模组竖屏是0x48,横屏要改成0x28或0xE8。0x3A设为0x55表示RGB565两字节一像素,设为0x66表示RGB666三字节一像素,后者颜色更准但刷屏数据量增加一半。0xB1的帧率参数决定面板自刷新频率,一般取0x18即约60Hz。
发送命令时,DC引脚要严格区分。下面是一个最简实现:
static void write_cmd(uint8_t cmd) { gpio_write(PIN_DC, 0); spi_transmit(&cmd, 1); } static void write_data(uint8_t data) { gpio_write(PIN_DC, 1); spi_transmit(&data, 1); }DC切换要在SPI发送前完成,否则第一个字节会被当错命令。如果使用硬件SPI,有些MCU的SPI外设会在传输期间和DC引脚做联动,但移植时保持这种简单方式最不容易出错。
2.4 白屏和花屏的常见排查路径
屏幕白屏但背光亮,大概率是复位或初始化序列没执行成功。先用逻辑分析仪抓CS、DC、SCK波形,确认MCU确实把0x01、0x11等命令发出来了。如果只有CS、SCK有波形而MOSI一直是低电平,检查MOSI是否接到屏的SDA引脚,部分模组把写指令和读数据分成两脚,接错就白屏。
花屏则多半是0x36参数和面板扫描方向不匹配,或者像素格式配置错误。比如把0x3A设成0x55,但LCD模组实际用18位,就会看到颜色不对和整体偏色。把0x36从0x48改成0x28后,屏幕内容会旋转180度,这也说明行列地址的基址设置生效了。
3. 拆解ili9341.c:驱动里的寄存器操作与绘制函数
3.1 命令/数据通道与字节序处理
ili9341.c底层的核心是write_cmd和write_data这两个函数,所有显示操作都建立在这两个函数之上。这里最容易被忽略的是字节序。ILI9341允许在0x3A中配置RGB565格式,但很多初始化代码里设置的是0x55,这表示一像素两字节,高位在前,即先发送高8位再发送低8位。
在写一个填充矩形函数前,先确认发送时的高低字节顺序。测试方法很简单:往背景填充红色(0xF800),如果变成蓝色(0x001F),说明字节序反了。
void ili9341_fill_screen(uint16_t color) { ili9341_set_window(0, 0, 239, 319); for (uint32_t i = 0; i < 240 * 320; i++) { ili9341_write_data(color >> 8); ili9341_write_data(color & 0xFF); } }这个函数里color >> 8是高字节,color & 0xFF是低字节。如果改用DMA连续发送,要注意缓冲区里的数据顺序也必须保持高字节在前,不能按内存地址从低到高直接搬,否则颜色全部错位。
3.2 核心绘制函数:窗口设置和像素填充
绘制图形的关键是set_window命令。它通过向0x2A写入列起始和结束地址、向0x2B写入行起始和结束地址,再发送0x2C命令。之后连续写入的像素数据会自动填充这个矩形窗口,每固定数量个像素后会自动回卷到下一行。
一个高效的填充矩形实现如下:
void ili9341_fill_rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { ili9341_set_window(x, y, x + w - 1, y + h - 1); uint16_t pixels = (uint16_t)w * h; uint8_t color_hi = color >> 8; uint8_t color_lo = color & 0xFF; while (pixels--) { ili9341_write_data(color_hi); ili9341_write_data(color_lo); } }这个实现简单但效率不高,每次write_data都是一次SPI事务。如果SPI驱动每次传输都被拆成两个write调用,速度会非常慢。更优做法是开一个局部缓冲:
#define BUFFER_LEN 512 void ili9341_fill_rect_fast(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { ili9341_set_window(x, y, x + w - 1, y + h - 1); uint8_t buf[BUFFER_LEN]; int idx = 0; uint16_t count = w * h; for (uint16_t i = 0; i < count; i++) { buf[idx++] = color >> 8; buf[idx++] = color & 0xFF; if (idx >= BUFFER_LEN) { spi_transmit(buf, idx); idx = 0; } } if (idx > 0) { spi_transmit(buf, idx); } }这里BUFFER_LEN取512,在嵌入式平台上属于可接受的栈占用。spi_transmit在树莓派上可以对应Linux的write(fd, buf, len),在STM32上可以对应HAL的HAL_SPI_Transmit。填充大面积色块时,缓冲区越大,SPI传输次数越少,速度越快。
3.3 文本和图元显示的内存占用分析
ILI9341没有内置字库,显示文本必须自己准备字模。常见做法是把ASCII字符的点阵存在一个二维数组里,每个字符占用5x7像素或8x16像素。下面的表格列出不同位深下,一块240x320屏幕的帧缓冲大小:
| 像素格式 | 每像素字节 | 帧缓冲大小 | 适用场景 |
|---|---|---|---|
| RGB565 | 2 | 150 KB | 大多数GUI |
| RGB888 | 3 | 225 KB | 高颜色精度 |
| 8位索引色 | 1 | 75 KB | 低内存MCU |
因为STM32F103这类MCU通常只有64KB RAM,放不下150KB的帧缓冲,所以驱动不会缓存整个画面,而是直接把像素写入GRAM。这样画点函数会频繁操作SPI,一条Line逐点画要几百次SPI交互,性能很低。推荐先画水平线或在行内连续写点:
void ili9341_draw_hline(uint16_t x0, uint16_t x1, uint16_t y, uint16_t color) { ili9341_set_window(x0, y, x1, y); for (uint16_t x = x0; x <= x1; x++) { ili9341_write_data(color >> 8); ili9341_write_data(color & 0xFF); } }3.4 文本绘制的条带化思路
ASCII字模通常每个字符5x7像素,绘制时逐行扫描。最简单的是逐像素调用draw_pixel,但这会让一个“A”字符产生35次SPI传输。条带化做法是把一行所有像素拼成一个颜色数组,然后通过set_window写给屏幕。
void ili9341_draw_char(uint16_t x, uint16_t y, char c, uint16_t fg, uint16_t bg) { const uint8_t *glyph = &font_5x7[(c - 0x20) * 5]; for (int row = 0; row < 7; row++) { ili9341_set_window(x, y + row, x + 4, y + row); for (int col = 0; col < 5; col++) { uint16_t color = (glyph[col] & (1 << row)) ? fg : bg; ili9341_write_data(color >> 8); ili9341_write_data(color & 0xFF); } } }注意这里取模方式是把每个字节的bit位映射到行,所以glyph[col]的bit0对应第一行。画字符时颜色、背景色直接由源数据决定,背景也一并填充,因此不需要额外处理背透问题。整个字符通过5次set_window完成,比逐点画快一个数量级。
4. 从源码到硬件:树莓派SPI与ILI9341移植实战
4.1 启用树莓派SPI设备和官方驱动
树莓派上点亮ILI9341最快的方式是使用内核的fbtft框架。在/boot/config.txt中启用SPI并挂载fbtft,重启后就会多出一个/dev/fb1设备。
# 启用SPI和fbtft驱动 dtoverlay=spi0-1cs dtoverlay=fbtft:custom,pins=25,spi0=1,reset=27,backlight=18这里的pins=25表示DC引脚接到GPIO25,spi0=1表示选择SPI0通道1(即CE1),reset=27和backlight=18分别接复位和背光。执行sudo reboot后,可以用ls /dev/fb*确认是否存在fb1。
如果系统自带的fbtft版本过旧,不支持上述overlay参数,可以改用主线内核里的ili9341设备树节点,需要手动写一个fbtft_device实例。这种情况下,dtoverlay的名称可能是ili9341,参数则根据树莓派内核文档调整。
4.2 自定义驱动:将ili9341.c适配到SPI设备节点
不使用fbtft时,可以在用户态直接操作/dev/spidev0.0,把ili9341.c里的spi_transmit函数替换成对文件描述符的读写。首先要配置SPI参数:
int fd = open("/dev/spidev0.0", O_RDWR); uint8_t spi_mode = SPI_MODE_0; uint8_t spi_bits = 8; uint32_t spi_speed = 20000000; ioctl(fd, SPI_IOC_WR_MODE, &spi_mode); ioctl(fd, SPI_IOC_WR_BITS_PER_WORD, &spi_bits); ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, &spi_speed);这组代码把SPI配置为模式0、8位字长、20MHz时钟。ILI9341的数据手册允许SPI时钟最高到10MHz或40MHz,具体看模组版本,建议先以10MHz测试,稳定后再升频。如果出现花屏,多半是SPI时序参数不匹配,可以改成SPI_MODE_0确认。
之后把整个ili9341.c编译进一个C程序,DC引脚的切换用libgpiod实现:
struct gpiod_line *dc_line; gpiod_line_set_value(dc_line, 0); // 命令 gpiod_line_set_value(dc_line, 1); // 数据注意DC引脚的操作不能和SPI传输并发执行,否则时序错乱。在单线程里保持“先切DC,再写SPI”的顺序即可。树莓派的GPIO操作本身有几十微秒开销,所以如果追求速度,建议把DC也交给SPI的硬件控制或使用DMA同步。这里不展开,但需要一个后备方案:初始化完成后将DC引脚固定为数据模式,尽量让SPI连续传输多个像素,减少DC切换次数。
4.3 树莓派GPIO复用冲突排查
树莓派的GPIO默认有多种复用功能,比如GPIO14和GPIO15默认给UART控制台,GPIO2和GPIO3默认给I2C。如果ili9341.c里定义的引脚碰巧落在这些复用上,会出现DC电平不受控、SPI偶尔闪断等问题。
排查流程如下:
- 用
raspi-gpio get查看分配给屏幕的GPIO是否都是普通输出。 - 遇到冲突时,在
/boot/config.txt添加dtoverlay=disable-i2c或dtoverlay=disable-uart。 - 禁用后重启,再次查看GPIO状态。
常用的冲突对照表:
| GPIO | 默认复用 | 冲突时现象 |
|---|---|---|
| GPIO0/1 | I2C EEPROM | DC引脚无法拉高 |
| GPIO2/3 | I2C | 初始化失败 |
| GPIO14/15 | UART TX/RX | 屏幕干扰串口日志 |
| GPIO18 | PCM | 声音输出异常 |
如果屏幕已经在用fbtft,加载模块时选一个没有默认复用的GPIO就行。比如GPIO25在多数树莓派上是普通IO,适合做DC引脚;GPIO27也常用于RES。
4.4 官方驱动和自写驱动的边界
树莓派fbtft内置的ILI9341驱动经过大量用户验证,初始化序列、TE信号、背光控制都已经完善,基本不需要改动。自定义ili9341.c适合学习或者用于非Linux平台。如果要在嵌入式Linux里跑图形界面,直接用fbtft更省事,它把屏幕注册成Linux frame buffer,上层Qt/GTK可以直接写/dev/fb1。
自写驱动的优势是能精确控制每一条命令,调试起来更直观。缺点是要自己处理DMA、TE同步、电源管理,投入时间多。建议先用fbtft把硬件确认好,再从它的初始化序列里抄一段做自研版本,这样能少踩很多坑。
5. 帧率验证、画面撕裂与常用优化手段
5.1 用时间戳衡量实际刷新率
验证驱动性能,不能只看屏幕亮了,要量一帧全屏填充需要多少毫秒。在树莓派用户态可以直接用time配合dd向/dev/fb1写数据:
time (dd if=/dev/zero of=/dev/fb1 bs=153600 count=1)这样写的是全零数据,如果是RGB565格式,填充的是黑色。如果fbtft开启了双缓冲,写入可能会被驱动缓存,测出的时间偏小,需要在代码里加msync或关闭fbdev的加速。更可靠的做法是在自定义驱动的fill_rect函数前后各取一次时间戳:
struct timespec t_start, t_end; clock_gettime(CLOCK_MONOTONIC, &t_start); ili9341_fill_screen(0x0000); clock_gettime(CLOCK_MONOTONIC, &t_end);以20MHz SPI计算,RGB565全屏写入150KB数据,理想时间约60ms。加上命令切换和GPIO置位,实测70-80ms是正常水平。如果明显高于80ms,优先检查SPI时钟是否真的跑在20MHz,可以通过SPI statistics节点确认实际传输速率。
5.2 撕裂与TE信号的利用
ILI9341支持TE(Tearing Effect)输出,用于同步MCU写入和面板扫描,避免一帧画面被拆成两半。接线是把TE引脚接到MCU的外部中断,在TE脉冲来临时启动DMA刷屏。
void te_interrupt_handler(void) { start_dma_transfer_next_frame(); }启用TE前需要先发命令0x35,ITE=1。部分型号还有0x44设置TE扫描行位置。不是所有模组都把TE引脚引出来,如果没有该引脚,就用延时机包或者直接接受轻微撕裂。对于只更新局部区域的应用,撕裂影响很小,可以不接TE。
5.3 脏矩形与局部刷新
最有效的提速方式是只在需要变化的区域调用ili9341_fill_rect。一个UI界面往往只有几个控件变化,例如温度数字从25变成26,只需刷新数字所在的那10x20像素区域,耗时仅0.2ms。
void gui_refresh() { rect_t dirty = gui_get_dirty_rect(); if (dirty.w || dirty.h) { ili9341_set_window(dirty.x, dirty.y, dirty.x + dirty.w - 1, dirty.y + dirty.h - 1); write_pixels_from_buffer(dirty.w, dirty.h); } }脏矩形算法需要在GUI层自行维护,驱动只提供窗口和批量写函数。注意set_window的坐标必须限制在0-239和0-319之间,否则GRAM写指针会回绕,导致后续数据写到屏幕另一侧,出现花屏。很多人在做滚屏时遇到过这种问题,根源就是窗口越界。
本文还有配套的精品资源,点击获取