
简介面向嵌入式 Linux 开发人员与运维工程师这份资源以常用小尺寸 8080 并行接口液晶屏为切入点完整展示了在 Linux 系统中为其编写驱动程序的全过程。内容围绕驱动初始化、数据传输、命令发送、设备注册与释放等核心任务展开并详细说明了输入输出端口读写、内存映射机制、设备树配置以及中断处理等关键技术细节适合正在学习内核驱动开发或需要将液晶屏快速接入实际项目的开发者。压缩包体积仅 15KB共包含 9 个文件以 C 源码、头文件和构建脚本为主辅以 Markdown 说明文档、许可证及 Git 忽略文件整体结构紧凑便于通读。配套说明文档提供了中英文编译加载说明可帮助读者快速理解代码组织、编译流程及驱动加载方法。目前已有 351 人学习/下载读者通过研读源码和文档可以系统掌握 Linux 设备驱动模型、内核模块注册、字符设备创建、硬件寄存器访问以及 LCD 控制命令发送等实用技能为后续自行移植或开发类似显示驱动打下扎实基础。1. 8080并行接口液晶屏的Linux驱动一份代码包背后的完整落地路径小尺寸液晶屏里8080并行接口其实比 SPI 更“反直觉”——它占了 8 根数据线加好几根控制线走线成本高但很多嵌入式产品还是选它。原因很简单SPI 一位一位挪数据写一帧 320×240 的 RGB565 画面要挪十几万次8080 并口一个 WR 脉冲就能锁存 8 bit 像素数据刷新同样大小的显存CPU 占用和等待时间都低一个量级。尤其在做仪表、HMI 面板这类需要局部频繁刷新文字和数字的场景8080 接口的实时性优势很明显。这份名为 lcd8080-master 的源码包解决的正是“在一个跑 Linux 的板子上怎么让 8080 接口的小屏真正亮起来”这件事。它不像用户态绘图库那样只负责往缓冲区里画点东西而是把驱动拆成了 io 访问层、ILI9341 控制逻辑层、字符设备接口层三个文件从最底层的 GPIO 拉电平到初始化命令序列再到/dev节点一条链路完整覆盖。适合两种人一是嵌入式 Linux 开发中要用 8080 小屏、却不想从 datasheet 逐条啃时序的工程师二是想学内核驱动、但觉得常见按键驱动和 LED 驱动太简单、想找一个带真实硬件时序的练手项目的进阶学习者。接下来我会按代码包的真实结构把这套驱动从原理到排错一层层拆开。2. 8080接口原理与内核模块骨架为什么不能靠用户态直接操作硬件2.1 8080接口的信号线与读写时序8080 接口是从 Intel 8080 系列处理器总线时序演化来的名字里带着“8080”核心就是一组并行数据总线加读写控制信号。包里的 lcd8080.h 注释里标注得很清楚这种接口至少需要这几类信号表格8080 接口最小信号集信号方向作用D0-D7双向8 位并行数据总线传输命令字和像素数据CS输入片选低电平有效选中 LCD 控制器RS或称 A0输入寄存器选择低电平写命令高电平写数据WR输入写选通下降沿/上升沿锁存数据总线上的值RD输入读选通配合 RS 读取 LCD 状态或显存RESET输入硬件复位低电平有效GND / VCC电源逻辑电源和地部分屏还带背光控制写一个字节的标准时序是先把 CS 拉低再按是命令还是数据去设置 RS然后把 8 bit 数据放到 D0-D7 上最后给一个 WR 脉冲。控制器在 WR 的上升沿把总线数据锁存进内部寄存器。读时序则反过来RS 和 RD 配合数据由 LCD 控制器驱动到总线上CPU 在 RD 有效期间采样。这里有个关键点8080 是并行总线所有数据线必须在 WR 有效沿之前稳定这就是所谓的建立时间setup time而数据在有效沿之后还要保持一段时间叫保持时间hold time。后面排错时你会发现大部分花屏问题都出在这个时序参数上。2.2 为什么驱动必须进内核I/O 访问权限、中断上下文和设备模型会有人问我都跑 Linux 了能不能直接在用户态用 mmap 映射 GPIO 寄存器或者干脆操作/dev/mem短时间调试可以这么干但产品代码不能这么写。内核态驱动要承担三件用户态做不好的事第一硬件访问权限和资源管理。GPIO 控制器、LCD 控制器的寄存器地址属于系统资源用户态程序直接访问会绕过内核的资源仲裁。比如同一个 GPIO 控制器一个驱动在申请时已经通过gpio_request()占用了某个引脚你的用户态程序再拿同一个引脚就会冲突。内核驱动可以用request_mem_region()、gpio_request()这类接口正规登记资源系统里所有驱动能互相感知谁占用了什么。第二中断上下文。虽然 8080 接口本身通常不需要中断但驱动在probe()阶段注册设备、在数据写入时可能被调度器打断如果时序里要求两个脉冲间隔不超过某个值用户态进程随时可能被换出 CPU时序就崩了。内核驱动可以通过spin_lock_irqsave()配合忙等待来保证一段代码不被抢占。第三设备模型。Linux 的设备树Device Tree机制会把硬件的引脚配置、地址信息从板级代码里拆出来驱动通过compatible字符串和设备树节点匹配。这个匹配过程只有内核驱动能参与用户态程序拿不到这些信息。I/O 访问函数也有平台差异。x86 上常见的outb()/inb()在 ARM 上并不适用ARM 平台通常用ioremap()把物理地址映射到内核虚拟地址空间再用iowrite8()和ioread8()读写。而 GPIO 模拟 8080 时序时更常用gpiod_set_value()这类通用 GPIO 接口好处是驱动代码和具体平台解耦——你在这块板子上用 GPIO 12 做 WR换块板子改设备树就行C 代码不用动。2.3 驱动骨架与设备树匹配从 module_init 到 probe 的注册链路打开 lcd8080-core.c第一眼看到的就是驱动的注册骨架。它走的是一条标准的内核模块路径module_init注册驱动驱动匹配到设备树节点后触发probeprobe里完成硬件初始化和字符设备注册。这里我把典型实现抽出来注释对应到包内的代码结构#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/of_gpio.h #include linux/cdev.h #include linux/device.h #include lcd8080.h static int lcd8080_probe(struct platform_device *pdev) { struct lcd8080_dev *dev; int ret; dev devm_kzalloc(pdev-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; /* 从设备树节点读取 GPIO 编号 */ dev-gpio_wr of_get_named_gpio(pdev-dev.of_node, wr-gpios, 0); dev-gpio_rs of_get_named_gpio(pdev-dev.of_node, rs-gpios, 0); dev-gpio_reset of_get_named_gpio(pdev-dev.of_node, reset-gpios, 0); /* 核心初始化申请引脚、拉复位、发初始化命令序列 */ ret lcd8080_hw_init(dev); if (ret 0) return ret; /* 注册字符设备用户空间通过 /dev/lcd8080 访问 */ ret lcd8080_cdev_register(dev); if (ret 0) return ret; platform_set_drvdata(pdev, dev); return 0; } static int lcd8080_remove(struct platform_device *pdev) { struct lcd8080_dev *dev platform_get_drvdata(pdev); lcd8080_cdev_unregister(dev); lcd8080_hw_exit(dev); return 0; } static const struct of_device_id lcd8080_of_match[] { { .compatible lcd8080,ili9341 }, { /* sentinel */ } }; static struct platform_driver lcd8080_driver { .probe lcd8080_probe, .remove lcd8080_remove, .driver { .name lcd8080, .of_match_table lcd8080_of_match, }, }; module_platform_driver(lcd8080_driver); MODULE_LICENSE(GPL);这段代码有三个地方需要注意。第一devm_kzalloc()是设备资源管理接口probe 失败或 remove 时内核会自动释放内存省掉手动kfree()的麻烦第二of_get_named_gpio()从设备树里取 GPIO 编号包的作者把引脚信息放在设备树而不是写死在内核代码里这是嵌入式 Linux 驱动开发的常见做法第三compatible lcd8080,ili9341是驱动的身份标识设备树节点里只有写了同样字符串这个 probe 才会被调用。配套的设备树节点一般长这样gpio1 { lcd8080_demo { compatible lcd8080,ili9341; wr-gpios gpio1 12 GPIO_ACTIVE_LOW; rs-gpios gpio1 13 GPIO_ACTIVE_LOW; reset-gpios gpio1 14 GPIO_ACTIVE_LOW; status okay; }; };status okay这个属性很容易被漏掉默认节点可能继承父节点的 disabled 状态导致 probe 根本不执行这个问题我在第 4 章会专门展开。3. 驱动源码逐层拆解io 读写原语、ILI9341 初始化序列与字符设备接口代码包的文件分工很清晰lcd8080-io.c 管最底层的电平操作lcd8080-ili9341.c 管屏幕初始化命令lcd8080-core.c 管字符设备和用户空间接口lcd8080.h 是三个文件共享的结构体和宏定义。这种分层对调试特别友好——屏幕不亮时你可以逐层确认是引脚电平没对、初始化序列没生效还是用户空间根本没拿到设备节点。3.1 lcd8080-io.c底层读写原语的两种实现io 层是整个驱动的地基。这一层要保证“往引脚上写 0 或 1”这个动作是确定性的、可重复的。驱动里典型的实现是用 GPIO 模拟时序因为小尺寸 8080 屏的时钟频率通常只有几兆赫兹GPIO 翻转速度完全够用。核心函数是这样的#include linux/gpio/consumer.h #include lcd8080.h static void lcd8080_set_cs(struct lcd8080_dev *dev, int val) { gpiod_set_value(dev-gpio_cs, val); } static void lcd8080_set_rs(struct lcd8080_dev *dev, int val) { gpiod_set_value(dev-gpio_rs, val); } static void lcd8080_write_byte(struct lcd8080_dev *dev, u8 data) { int i; /* 数据线 D0-D7 分别为独立 GPIO按位输出 */ for (i 0; i 8; i) { gpiod_set_value(dev-gpio_data[i], (data i) 0x01); } /* WR 拉低数据建立后再拉高形成写脉冲 */ gpiod_set_value(dev-gpio_wr, 0); ndelay(dev-timing.t_write_pulse); gpiod_set_value(dev-gpio_wr, 1); ndelay(dev-timing.t_hold); }参数说明gpio_data[i]是 8 根数据线对应的 GPIO consumer 对象数组初始化时从设备树的>#include lcd8080.h static const struct lcd8080_init_seq lcd8080_init_sequence[] { { 0x01, 0, NULL }, /* Software Reset */ { 0x11, 0, NULL }, /* Sleep Out */ { 0x36, 1, (u8[]){ 0x48 } }, /* Memory Access Control: 扫描方向 */ { 0x3A, 1, (u8[]){ 0x55 } }, /* Pixel Format: 16bit RGB565 */ { 0xB6, 3, (u8[]){ 0x00, 0x22, 0x3B } }, /* Display Function Control */ { 0x20, 0, NULL }, /* Display Inversion Off非必须 */ { 0x29, 0, NULL }, /* Display On */ }; int lcd8080_init_ili9341(struct lcd8080_dev *dev) { int i; const struct lcd8080_init_seq *seq; dev_info(dev-dev, ILI9341 init start\n); for (i 0; i ARRAY_SIZE(lcd8080_init_sequence); i) { seq lcd8080_init_sequence[i]; /* 写命令字节 */ lcd8080_write_command(dev, seq-cmd); /* 带参数的命令紧接着写参数 */ if (seq-len 0) { lcd8080_write_data(dev, seq-data[0]); if (seq-len 1) lcd8080_write_data(dev, seq-data[1]); } } dev_info(dev-dev, ILI9341 init done\n); return 0; }这段序列看着简单但有三个容易踩的坑。第一命令和参数是隔着 RS 状态区分的lcd8080_write_command()内部会把 RS 置 0lcd8080_write_data()会把 RS 置 1漏掉这个区分屏幕会完全无响应第二0x3A设成0x55表示 16 位 RGB565如果和你后面显存里存放的像素格式不一致颜色就会错乱第三0x36的0x48控制了扫描方向和 RGB/BGR 顺序同一个屏横屏竖屏用不同的值这个字节通常要靠实际点亮后肉眼确认。3.3 lcd8080-core.c字符设备、ioctl 与用户空间显示接口当底层 io 和控制器初始化就绪剩下的就是怎么把能力开放给用户空间。这段代码注册了一个字符设备用户程序通过open(/dev/lcd8080)拿到文件描述符然后用ioctl()下发控制命令、用write()传像素数据#include linux/cdev.h #include linux/uaccess.h #include lcd8080.h #define LCD8080_IOC_MAGIC L #define LCD8080_IOCTL_CLEAR _IO(LCD8080_IOC_MAGIC, 0x01) #define LCD8080_IOCTL_SET_BACKLIGHT _IOW(LCD8080_IOC_MAGIC, 0x02, int) static long lcd8080_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct lcd8080_dev *dev filp-private_data; int brightness; switch (cmd) { case LCD8080_IOCTL_CLEAR: memset(dev-fb, 0, dev-fb_size); lcd8080_fb_flush(dev); break; case LCD8080_IOCTL_SET_BACKLIGHT: if (copy_from_user(brightness, (void __user *)arg, sizeof(brightness))) return -EFAULT; lcd8080_set_backlight(dev, brightness); break; default: return -EINVAL; } return 0; } static const struct file_operations lcd8080_fops { .owner THIS_MODULE, .open lcd8080_open, .release lcd8080_release, .write lcd8080_write, .unlocked_ioctl lcd8080_ioctl, };ioctl 命令号的定义规格是固定的_IO表示无参数命令_IOW表示用户空间向内核传参数_IOC_MAGIC是一个自定义魔数避免和其他驱动冲突。copy_from_user()不能省内核空间不能直接解引用用户态指针否则会触发缺页异常。lcd8080_write()的实现就是接收用户传来的像素数据按当前光标位置写进显存再把整帧或局部脏矩形刷到屏幕上。3.4 Makefile 与模块加载验证insmod 之后怎么确认驱动真的在跑源码包里的 Makefile 是标准的外部模块编译写法目标模块由三个 .o 文件链接而成obj-m lcd8080.o lcd8080-objs : lcd8080-core.o lcd8080-io.o lcd8080-ili9341.o KERN_DIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERN_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERN_DIR) M$(PWD) cleanobj-m指定要生成的内核模块名lcd8080-objs列出模块包含的所有目标文件顺序无所谓链接器会自动解析符号。KERN_DIR指向当前内核版本的构建树如果交叉编译这里要改成你的交叉编译器对应的内核源码目录。编译并加载验证的完整流程make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- sudo insmod lcd8080.ko dmesg | tail -20 ls -l /dev/lcd8080insmod返回成功只是第一步必须用dmesg看内核日志里有没有 probe 成功的输出。正常会看到类似lcd8080: ILI9341 init done和lcd8080: cdev registered的记录。如果/dev/lcd8080节点不存在先查是不是设备树节点没匹配上或者内核的devtmpfs没自动创建设备节点——后者要在驱动里调用device_create()注册到lcd8080_class类下。4. 移植避坑时序、GPIO 编号与设备树配置中的五个真实问题这块我直接说血泪经验。驱动代码能编译过、加载成功不代表屏幕能亮。实战里我在这类小屏上踩过的坑按概率排序就下面五个。4.1 WR 脉宽不足导致花屏现象屏幕能初始化能显示内容但画面有随机横纹、字符边缘模糊刷新时局部闪烁。原因lcd8080_write_byte()里 WR 低电平保持时间太短。GPIO 操作在高速内核下极快ndelay(10)可能实际只有几纳秒而 ILI9341 要求 WR 低电平脉宽最小 15ns不同型号略有差异数据建立时间也不够控制器采样到的是不稳定数据。解决把时序参数从模块参数里提出来insmod lcd8080.ko wr_pulse_ns50 hold_ns30先按 datasheet 里的典型值放大 2 到 3 倍验证。如果屏幕正常了再逐步缩减找到稳定的最小值。另外注意别在循环里用udelay(1)它不适合纳秒级时序用ndelay()或者一个空循环cpu_relax()。4.2 GPIO 编号错一位整屏偏移还是全黑现象屏幕点亮后画面像是被“平移”了一列或者 RGB 通道两两互换更严重的直接全黑。有时候同一份代码在一批板子上正常另一批板子全是雪花点。原因设备树里 GPIO 引脚编号和实际 PCB 走线对不上。最常见的是数据线 D0-D7 里某两根接反了8051 时代的老工程师一般都会对着原理图检查新手容易想当然按顺序接。GPIO 编号错位不会出现在编译期也不会出现在 insmod 阶段必须点亮后肉眼判断。解决看 PCB 原理图逐个对照数据线。这里有个技巧写一个测试驱动先把 D0-D7 全部置 0再单独拉高某一根用示波器量屏端的引脚电平确认编号映射。没有示波器就在屏幕上写一组已知颜色图案比如按 RGB 分量分别置位看颜色是否对得上。4.3 设备树节点没生效probe 函数不执行时先查 status 和 compatible现象insmod lcd8080.ko成功dmesg里没有任何 probe 相关的打印/dev/lcd8080也没创建。检查模块加载日志platform_driver_register返回 0但就是没人调 probe。原因两种可能。第一设备树节点status是disabled或者没写默认继承父节点状态第二compatible字符串对不上驱动里写的是lcd8080,ili9341设备树里写成了lcd8080-ili9341一个字符的差异都匹配不上。解决先用ls /proc/device-tree/找到你的节点路径直接查看compatible和status属性内容。更直接的做法是在 probe 入口加一行dev_info()如果不打印基本就是匹配问题。4.4 缓冲区分配不当导致内存碎片现象驱动长时间运行后kmalloc()分配显存缓冲区偶尔返回 NULL屏幕刷新中断但系统整体内存还有很多空闲。原因显存缓冲区如果一次性kmalloc()几 MB在内存碎片化的系统上很容易失败。常见做法是用devm_kzalloc()分配结构体但显存这种大块缓冲区应该用devm_kmalloc()配合GFP_DMA标志或者在probe阶段尽早分配。解决检查缓冲区分配是否用了GFP_KERNEL且没有加__GFP_REPEAT。对于 320×240 的 RGB565 缓冲96KB 不算大但如果是 800×480 就是 768KB建议用devm_kmalloc_array()加上错误回退分配失败时返回-ENOMEM并且打印告警而不是直接空指针崩溃。4.5 初始化期间 printk 刷屏拖慢时序现象屏幕偶尔初始化失败重启后正常用dmesg看初始化命令的中段夹杂了很多其他驱动的日志。原因lcd8080_init_ili9341()里每条命令之间用了dev_info()在串口 console 开启且波特率不高的情况下一次dev_info()阻塞的时间可能达到毫秒级直接拉大了命令间隔。虽然大部分控制器对命令间隔不敏感但 ILI9341 的 Software Reset 到 Sleep Out 之间的延时是有要求的被打印拖慢后时序抖动变大。解决初始化阶段只保留入口和出口两条日志中间的每条命令调试都用dev_dbg()正常运行时开启 DEBUG 也不至于刷爆日志。如果确实需要保留完整日志改成在初始化完成后一次性dev_info()打印整个命令序列的执行结果。提示遇到屏幕行为随机、时好时坏的问题优先怀疑时序不稳其次怀疑电源波动。给 LCD 的 VCC 引脚就近加 10uF 去耦电容能解决很多看起来像驱动 bug 的间歇性花屏。5. 进阶验证用逻辑分析仪校准时序再让驱动节点长成 framebuffer5.1 用户空间画点与清屏ioctl write 的实际调用驱动写完第一件事不是接屏看效果而是先用一个最小用户态程序验证链路。下面这段 C 代码打开设备节点、清屏、在屏幕中央画一个白色像素#include stdio.h #include fcntl.h #include sys/ioctl.h #include stdint.h #define LCD8080_IOCTL_CLEAR _IO(L, 0x01) int main(void) { int fd open(/dev/lcd8080, O_RDWR); if (fd 0) { perror(open); return 1; } ioctl(fd, LCD8080_IOCTL_CLEAR); /* 假设显存 240x320 RGB565中央像素偏移为 (120 160*240) * 2 */ uint16_t white 0xFFFF; lseek(fd, (120 160 * 240) * 2, SEEK_SET); write(fd, white, sizeof(white)); close(fd); return 0; }这份代码能跑通说明从 GPIO 到控制器到设备节点的整条链路是通的。如果 lseek 写入的位置不对画面显示不出来的像素位置对不上那就要检查驱动里lcd8080_write()是否处理了文件偏移。5.2 用逻辑分析仪校准时序一套可以量化的验证方法我换了三块不同的 8080 屏之后养成了个习惯新屏第一次点亮一定用逻辑分析仪同时抓 CS、RS、WR 和 D0-D7至少抓一个完整的命令写周期。看三个指标WR 低电平脉宽、数据在 WR 上升沿前建立的时间、RS 在有效沿前的稳定时间。逻辑分析仪采样率建议 100MHz 以上普通 24MHz 的便宜分析仪抓 8 路信号会失真。对比 datasheet 里的 AC 特性表把驱动里的timing参数设到实测值的 1.5 倍以上留足余量。5.3 从字符设备到 framebuffer下一个阶段的路字符设备接口适合点对点控制但要在屏幕上跑 Qt、跑 LVGL 这类图形库它们默认走/dev/fb0字符设备就接不上了。升级思路是把驱动注册成struct fb_info实现fb_fillrect()、fb_imageblit()、fb_ioctl()这几个回调把原本的 ioctl 清屏映射到fb_fillrect把 write 映射到fb_ioctl的FBIO_WAITFORVSYNC刷帧。这样应用层代码不用改图形库直接就能用。源码包里现在的字符设备实现正好是 framebuffer 移植的一个中间态——io 层和 ILI9341 层不用动换掉 core 层的设备注册逻辑就行。那次移植花了我一个通宵最后发现坑不在 fb_ops 回调而在fb_var_screeninfo里的xres、yres、bits_per_pixel必须和0x3A设置的像素格式完全一致。从那以后我每次拿到一块新屏都会先用字符设备驱动画满屏纯色毯子再用逻辑分析仪量一遍时序最后才往 framebuffer 上叠图形库。这套流程虽然慢但每一步出了问题都能定位到具体层级不会让你面对一块黑屏无从下手。希望帮到你。本文还有配套的精品资源点击获取