公司动态
在reComputer J4012上驱动SPI显示屏:从硬件连接到Python驱动开发
1. 项目概述为什么要在边缘设备上折腾SPI屏最近在折腾一个边缘计算项目需要在一块没有HDMI接口的嵌入式设备上实时显示一些传感器数据和简单的状态图表。手头正好有一块 reComputer J4012 Classic这是一款基于 NVIDIA Jetson Orin NX 模组的紧凑型边缘AI设备性能强劲但接口“纯粹”——典型的工业嵌入式设计没有常规的显示器输出口。这恰恰是很多从桌面开发转向嵌入式或边缘部署的朋友会遇到的第一道坎我的图形界面怎么展示答案就在那些看似古老的接口上比如SPISerial Peripheral Interface。你可能在玩树莓派、Arduino时接触过它用来驱动一些小尺寸的LCD或OLED屏。在Jetson这样的高性能平台上使用SPI屏听起来有点“大材小用”但实际场景中非常实用功耗极低、接线简单、不占用宝贵的USB或PCIe资源非常适合作为设备的本地状态监视器、调试信息输出屏或者轻量级人机交互界面。reComputer J4012 Classic 搭载了 Jetson Orin NX 模组其40-pin的扩展接头兼容树莓派GPIO布局引出了SPI、I2C、UART等丰富的低速接口这为我们连接SPI显示屏提供了硬件基础。这次我就以驱动一块常见的ILI9341控制器、分辨率为240x320的2.4英寸TFT LCD屏为例把在 reComputer J4012 上点亮SPI屏的全过程、核心原理以及踩过的坑系统地梳理一遍。无论你是想给边缘AI盒子加个“眼睛”还是单纯学习在Linux下操作SPI设备这篇内容都能给你一份可复现的参考。2. 硬件准备与连接认识你的“信号线”在写第一行代码之前正确的硬件连接是成功的一半。SPI通信虽然线序标准但不同屏幕的引脚定义、电压要求可能有细微差别接错了轻则不工作重则烧毁设备。2.1 核心硬件清单你需要准备以下硬件reComputer J4012 Classic主角确保系统已正常启动。SPI TFT LCD显示屏我使用的是基于 ILI9341 驱动芯片的2.4寸屏。购买时务必确认驱动芯片型号ILI9341/ST7789/ILI9486等这决定了后续的软件配置。屏幕通常需要3.3V供电务必核对。杜邦线若干用于连接。建议使用母对母的杜邦线。(可选) 逻辑电平转换器如果屏幕是5V供电而Jetson的GPIO是3.3V电平则必须使用电平转换器如TXB0108保护Jetson的引脚。本例的屏幕是3.3V故可直连。2.2 引脚连接详解reComputer J4012 的40-pin GPIO接口引脚定义与树莓派40-pin兼容。我们需要使用其SPI0总线。以下是连接对照表以ILI9341屏幕常见引脚名为例reComputer J4012 (GPIO引脚号)引脚名称 (功能)SPI屏幕引脚作用Pin 13.3V PowerVCC提供3.3V电源Pin 6Ground (GND)GND共地Pin 19 (GPIO10)SPI0_MOSISDI (或 DIN)主设备输出从设备输入。Jetson发送显示数据到屏幕。Pin 21 (GPIO9)SPI0_MISOSDO (或 DOUT)主设备输入从设备输出。本例中屏幕通常不返回数据但建议连接。Pin 23 (GPIO11)SPI0_SCLKSCK串行时钟线由Jetson产生同步数据位传输。Pin 24 (GPIO8)SPI0_CE0_NCS (或 T_CS)片选信号0。低电平有效告诉屏幕“现在跟你通信”。Pin 26 (GPIO7)SPI0_CE1_N(备用CS)片选信号1。如果你有多个SPI设备可用此引脚。本例不用。Pin 18 (GPIO24)GPIODC (或 RS)数据/命令选择线。这是关键告诉屏幕当前发送的是命令低电平还是图像数据高电平。Pin 22 (GPIO25)GPIORST (或 RESET)复位线。低电平复位屏幕。通常初始化和异常时使用。Pin 1 或 Pin 173.3VLED (或 BLK)背光电源。接3.3V常亮也可通过一个GPIOPWM控制亮度。注意MOSI/MISO的名称是站在主设备Jetson角度定义的。对于屏幕从设备它的SDI应接主设备的MOSI它的SDO接主设备的MISO。有些屏幕标DIN/DOUT同理。连接实操要点断电操作连接所有线缆时务必确保 reComputer 处于完全关机断电状态。热插拔可能产生瞬时电流损坏GPIO引脚。DC和RST引脚它们不是SPI标准信号但却是控制屏幕所必需的。我们需要在软件中将其配置为普通的GPIO输出模式来控制它们。我选择了 GPIO24 和 GPIO25你也可以根据情况选择其他空闲的GPIO。背光直接接3.3V会让背光常亮。如果想控制亮度可以将其连接到一个支持PWM输出的GPIO引脚如GPIO18/PWM0然后在软件中启用PWM调节。3. 软件环境配置打通系统与硬件的通道硬件连接妥当后我们需要在 reComputer 的 Linux 系统通常是 Ubuntu/JetPack中进行一系列配置启用SPI驱动并准备好用户空间的访问权限。3.1 启用SPI内核模块与设备树覆盖Jetson 默认可能没有启用所有的SPI接口。我们需要确保SPI0已启用。检查当前SPI设备ls /dev/spi*如果能看到/dev/spidev0.0和/dev/spidev0.1对应CE0和CE1说明SPI0已启用。如果看不到或只有少数需要手动启用。通过设备树覆盖启用如果需要 Jetson 使用设备树Device Tree来描述硬件。我们可以通过修改引导参数来加载SPI的设备树覆盖。编辑引导配置文件sudo vim /boot/extlinux/extlinux.conf找到以APPEND ${cbootargs}开头的那一行在引号内的末尾添加确保在同一个引号内fec.macaddr00:04:4b:00:00:00 consolettyTCU0,115200 fbconmap:0 net.ifnames0 sdhci_tegra.en_boot_part_access1 **spitegra210-quad**关键是添加spitegra210-quad对于Orin NX这个覆盖名可能是tegra234-p3767-spi具体需查官方文档但通常默认已启用。更稳妥的方法是使用jetson-io工具进行图形化配置。保存后重启sudo reboot3.2 安装必要的软件库我们将使用 Python 进行开发因为它有丰富的库且易于调试。推荐使用spidev和Pillow用于图像处理。# 更新软件包列表 sudo apt update # 安装Python3开发工具和pip如果尚未安装 sudo apt install python3-dev python3-pip -y # 安装SPI的Python绑定库 sudo apt install python3-spidev -y # 或者使用pip安装可能版本更新 sudo pip3 install spidev # 安装图像处理库Pillow sudo pip3 install Pillow # 安装RPi.GPIO的兼容库用于控制DC、RST等GPIO引脚 # Jetson GPIO库通常已预装如果没有可以安装Jetson.GPIO sudo pip3 install Jetson.GPIO3.3 配置用户权限与SPI参数默认情况下/dev/spidev*设备文件属于root用户和spi组。为了让普通用户如nvidia能直接访问需要将用户加入spi组。# 将当前用户加入spi组 sudo usermod -a -G spi $USER # 注销并重新登录或重启使组权限生效登录后可以验证groups输出中应包含spi。SPI通信参数理解 SPI通信有四个关键参数在初始化spidev时需要设置最大时钟频率 (max_speed_hz)通信速度。ILI9341屏的典型值在10M到62.5M Hz之间。一开始可以设低一些如10_000_000稳定后再提高。模式 (mode)定义了时钟极性CPOL和相位CPHA。ILI9341通常使用Mode 0(CPOL0, CPHA0) 或Mode 3(CPOL1, CPHA1)。数据手册是关键但常用的是0b00即0。位顺序 (lsb_first)False 表示先传输最高有效位MSB first这是默认且常见的。字长 (bits_per_word)通常是8。4. 驱动代码解析与编写从字节到像素一切就绪现在进入核心环节编写驱动代码。我们将分步实现屏幕的初始化、基本绘图和显示图像。4.1 底层通信函数封装首先我们创建一个类ILI9341封装与SPI和GPIO的底层交互。import spidev import Jetson.GPIO as GPIO import time class ILI9341: def __init__(self, spi_bus0, spi_device0, dc_pin24, rst_pin25, max_speed_hz40000000): 初始化ILI9341屏幕驱动 :param spi_bus: SPI总线号reComputer上SPI0对应0 :param spi_device: SPI设备号CE0对应0CE1对应1 :param dc_pin: 数据/命令选择引脚 (BCM编号) :param rst_pin: 复位引脚 (BCM编号) :param max_speed_hz: SPI最大时钟频率 # SPI初始化 self.spi spidev.SpiDev() self.spi.open(spi_bus, spi_device) self.spi.max_speed_hz max_speed_hz self.spi.mode 0b00 # Mode 0 self.spi.lsbfirst False self.spi.bits_per_word 8 # GPIO初始化 self.dc_pin dc_pin self.rst_pin rst_pin GPIO.setmode(GPIO.BCM) # 使用BCM编号 GPIO.setup(self.dc_pin, GPIO.OUT) GPIO.setup(self.rst_pin, GPIO.OUT) # 硬件复位 self.reset() # 发送初始化序列 self.init_display() def reset(self): 硬件复位屏幕 GPIO.output(self.rst_pin, GPIO.HIGH) time.sleep(0.005) # 保持高电平至少5ms GPIO.output(self.rst_pin, GPIO.LOW) time.sleep(0.02) # 低电平至少20ms GPIO.output(self.rst_pin, GPIO.HIGH) time.sleep(0.15) # 等待屏幕稳定 def _write_command(self, cmd): 发送命令字节 GPIO.output(self.dc_pin, GPIO.LOW) # DC线拉低表示命令 self.spi.writebytes([cmd]) def _write_data(self, data): 发送数据字节或字节列表 GPIO.output(self.dc_pin, GPIO.HIGH) # DC线拉高表示数据 if isinstance(data, list): self.spi.writebytes(data) else: self.spi.writebytes([data]) def init_display(self): 发送ILI9341初始化命令序列 # 这是一个典型的初始化序列不同厂家屏幕可能微调请以屏幕资料为准 init_commands [ (0xEF, [0x03, 0x80, 0x02]), (0xCF, [0x00, 0xC1, 0x30]), (0xED, [0x64, 0x03, 0x12, 0x81]), (0xE8, [0x85, 0x00, 0x78]), (0xCB, [0x39, 0x2C, 0x00, 0x34, 0x02]), (0xF7, [0x20]), (0xEA, [0x00, 0x00]), # 电源控制 (0xC0, [0x23]), # Power control (0xC1, [0x10]), # Power control (0xC5, [0x3e, 0x28]), # VCOM control (0xC7, [0x86]), # VCOM control # 内存访问控制 (0x36, [0x48]), # Memory Access Control (MADCTL) - 设置方向0x48为横屏模式 # 像素格式 (0x3A, [0x55]), # Pixel Format Set - 16 bits/pixel (RGB565) # 帧率 (0xB1, [0x00, 0x18]), # Frame Rate Control # 显示功能控制 (0xB6, [0x08, 0x82, 0x27]), (0xF2, [0x00]), # 3Gamma Function Disable # 伽马校正 (0x26, [0x01]), # Gamma Set # 正负伽马校正 (0xE0, [0x0F, 0x31, 0x2B, 0x0C, 0x0E, 0x08, 0x4E, 0xF1, 0x37, 0x07, 0x10, 0x03, 0x0E, 0x09, 0x00]), (0xE1, [0x00, 0x0E, 0x14, 0x03, 0x11, 0x07, 0x31, 0xC1, 0x48, 0x08, 0x0F, 0x0C, 0x31, 0x36, 0x0F]), # 退出睡眠模式 (0x11, None), # Sleep Out ] for cmd, data in init_commands: self._write_command(cmd) if data is not None: self._write_data(data) if cmd 0x11: # Sleep Out命令后需要等待 time.sleep(0.12) # 打开显示 self._write_command(0x29) # Display ON time.sleep(0.05) def set_window(self, x0, y0, x1, y1): 设置要写入的显示区域窗口 # 发送列地址设置命令 self._write_command(0x2A) self._write_data([x0 8, x0 0xFF, x1 8, x1 0xFF]) # 发送行地址设置命令 self._write_command(0x2B) self._write_data([y0 8, y0 0xFF, y1 8, y1 0xFF]) # 发送内存写入命令 self._write_command(0x2C) def display_buffer(self, buffer): 将图像缓冲区数据显示到屏幕上。 :param buffer: 字节数组格式为RGB565长度应为 width * height * 2 # 设置全屏窗口 self.set_window(0, 0, self.width - 1, self.height - 1) # 一次性发送所有像素数据 GPIO.output(self.dc_pin, GPIO.HIGH) # 注意spidev的writebytes有长度限制可能需要分块发送 chunk_size 4096 # 分块大小避免缓冲区溢出 for i in range(0, len(buffer), chunk_size): self.spi.writebytes(buffer[i:ichunk_size]) def cleanup(self): 清理资源 self.spi.close() GPIO.cleanup([self.dc_pin, self.rst_pin])代码关键点解析复位序列严格的时序是屏幕正常工作的前提。先高后低再高并留有足够延时。命令/数据分离通过DC引脚的电平控制这是驱动SPI屏的核心机制。_write_command和_write_data是基础。初始化序列这一长串十六进制数字是屏幕驱动芯片的“启动密码”直接来自数据手册或厂家示例。它配置了电源、伽马、像素格式、扫描方向等。这里的序列是我屏幕可用的你的屏幕可能需要调整尤其是0x36(MADCTL) 命令它决定了显示方向。设置窗口set_window函数至关重要。它告诉屏幕接下来要往哪个矩形区域写入像素数据。全屏刷新就设置全屏窗口。数据发送display_buffer函数接收一个代表RGB565像素的字节数组并一次性写入。由于SPI缓冲区限制我们进行了分块发送。4.2 图像数据准备RGB565格式转换屏幕通常使用RGB565格式16位/像素即红色5位、绿色6位、蓝色5位。我们需要将常见的RGB888格式24位/像素图像转换为RGB565。from PIL import Image, ImageDraw, ImageFont class DisplayManager: def __init__(self, width240, height320): self.width width self.height height self.driver ILI9341() # 使用默认引脚 self.driver.width width self.driver.height height # 创建一个内存中的图像缓冲区PIL Image对象 self.image Image.new(RGB, (width, height), color(0, 0, 0)) self.draw ImageDraw.Draw(self.image) def rgb888_to_rgb565(self, r, g, b): 将8位的R,G,B值转换为16位的RGB565值 return ((r 0xF8) 8) | ((g 0xFC) 3) | (b 3) def image_to_buffer(self, pil_image): 将PIL Image对象转换为RGB565字节缓冲区。 如果图像尺寸不匹配会自动缩放。 if pil_image.size ! (self.width, self.height): pil_image pil_image.resize((self.width, self.height), Image.Resampling.LANCZOS) # 转换为RGB模式确保是RGB888 rgb_image pil_image.convert(RGB) pixels rgb_image.load() buffer bytearray(self.width * self.height * 2) # 每个像素2字节 idx 0 for y in range(self.height): for x in range(self.width): r, g, b pixels[x, y] color_565 self.rgb888_to_rgb565(r, g, b) # 高位字节在前 (Big Endian) buffer[idx] (color_565 8) 0xFF buffer[idx 1] color_565 0xFF idx 2 return buffer def draw_text(self, text, x10, y10, font_size20, color(255, 255, 255)): 在内部图像缓冲区上绘制文本 try: font ImageFont.truetype(/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf, font_size) except: font ImageFont.load_default() self.draw.text((x, y), text, fillcolor, fontfont) def draw_rectangle(self, xy, fillNone, outlineNone, width1): 绘制矩形 self.draw.rectangle(xy, fillfill, outlineoutline, widthwidth) def update_display(self): 将当前的图像缓冲区内容推送到屏幕 buffer self.image_to_buffer(self.image) self.driver.display_buffer(buffer) def clear(self, color(0, 0, 0)): 清屏为指定颜色 self.image Image.new(RGB, (self.width, self.height), colorcolor) self.draw ImageDraw.Draw(self.image) self.update_display() def cleanup(self): self.driver.cleanup()这个DisplayManager类提供了更高级的抽象。我们可以像使用一个简单的画布一样用draw_text、draw_rectangle等方法在内存中构图然后调用update_display一次性刷到屏幕上。image_to_buffer方法处理了繁琐的格式转换和内存布局。4.3 主程序与效果演示最后写一个简单的测试程序来验证一切是否正常工作。import time def main(): print(初始化显示管理器...) disp DisplayManager(width240, height320) try: # 1. 清屏为黑色 disp.clear((0, 0, 0)) time.sleep(1) # 2. 绘制一个红色矩形 print(绘制红色矩形...) disp.draw_rectangle([20, 20, 100, 100], fill(255, 0, 0)) disp.update_display() time.sleep(2) # 3. 绘制绿色矩形和文字 print(绘制绿色矩形和文字...) disp.draw_rectangle([120, 80, 220, 180], outline(0, 255, 0), width3) disp.draw_text(Hello reComputer!, 30, 130, font_size24, color(255, 255, 0)) disp.update_display() time.sleep(2) # 4. 加载并显示一张图片 print(加载并显示图片...) try: test_image Image.open(test_image.jpg) # 准备一张小图片放在同目录 # 将图片绘制到缓冲区 disp.image.paste(test_image, (50, 200)) disp.update_display() except FileNotFoundError: print(未找到test_image.jpg跳过图片显示。) disp.draw_text(No Image Found, 60, 220, color(200, 200, 200)) disp.update_display() time.sleep(3) # 5. 循环显示颜色和帧率测试 print(开始颜色循环测试...) colors [(255,0,0), (0,255,0), (0,0,255), (255,255,0), (255,0,255), (0,255,255)] frame_count 0 start_time time.time() for i in range(60): # 循环60次 disp.clear(colors[i % len(colors)]) frame_count 1 end_time time.time() fps frame_count / (end_time - start_time) print(f清屏帧率测试: {fps:.2f} FPS) # 显示结束信息 disp.clear((0, 0, 0)) disp.draw_text(Test Complete!, 40, 140, font_size26, color(0, 255, 255)) disp.update_display() time.sleep(2) except KeyboardInterrupt: print(\n用户中断。) finally: print(清理资源...) disp.cleanup() if __name__ __main__: main()运行这个脚本 (sudo python3 display_test.py因为涉及GPIO操作通常需要root权限)你应该能看到屏幕依次显示黑色背景、红色方块、绿色边框和文字、图片如果有最后进行颜色刷屏测试并显示完成信息。控制台会输出帧率这可以帮你评估SPI通信速度的瓶颈。5. 性能优化与高级应用探索基础显示完成后你可能会觉得刷新速度不够快或者想实现更复杂的UI。这里有几个优化和进阶方向。5.1 提升刷新率的技巧SPI屏的刷新率受限于总线速度、数据量和Python解释器开销。提高SPI时钟频率在ILI9341.__init__中逐步增加max_speed_hz比如从20M提高到40M、60M观察是否稳定。注意过高的频率可能导致信号失真屏幕花屏。使用DMA直接内存访问spidev库的普通writebytes会占用CPU。更高效的方式是使用Linux的SPI用户空间API配合DMA。这通常需要编写C扩展或使用像py-spidev底层优化更好的库或者直接使用ioctl系统调用。对于性能要求极高的场景这是必经之路。局部刷新如果只更新屏幕的一小部分如一个数字、一个图标务必使用set_window只设置需要更新的区域然后只发送该区域的像素数据可以极大减少数据量。双缓冲与脏矩形在内存中维护两个缓冲区Image对象。在一个后台缓冲区完成所有绘图操作然后一次性交换到前台并刷新。结合脏矩形算法只刷新发生变化的区域这是图形界面库的常见优化。使用NumPy加速像素操作image_to_buffer中的双重循环在Python中很慢。如果使用Pillow的tobytes()结合numpy进行向量化转换速度可以提升数十倍。import numpy as np def image_to_buffer_fast(self, pil_image): if pil_image.size ! (self.width, self.height): pil_image pil_image.resize((self.width, self.height), Image.Resampling.LANCZOS) # 转换为RGB模式然后转换为numpy数组 rgb_array np.array(pil_image.convert(RGB)) # 使用向量化操作计算RGB565 r, g, b rgb_array[..., 0], rgb_array[..., 1], rgb_array[..., 2] rgb565 ((r 0xF8) 8) | ((g 0xFC) 3) | (b 3) # 转换为字节并调整字节序 buffer rgb565.astype(u2).tobytes() # u2 表示大端16位无符号整数 return buffer这需要安装numpy(sudo pip3 install numpy)。实测对于240x320的全屏刷新速度可以从约100ms提升到10ms左右。5.2 集成到图形界面或Web服务一个静态的显示很快会变得无趣。在边缘计算场景中我们通常需要动态显示数据。创建简单的GUI应用可以使用tkinter、PyQt5或更轻量的guizero。在主循环中定期例如每秒从传感器或MQTT主题读取数据更新DisplayManager的内部图像然后调用update_display。注意要将耗时操作如图像转换放在子线程中避免阻塞UI。构建Web仪表盘使用Flask或FastAPI创建一个简单的Web服务器。定义一个API端点如/api/display来接收要显示的文本或图像数据。在 reComputer 上运行这个服务你就可以从同一网络下的任何设备手机、电脑通过浏览器发送指令控制屏幕显示内容。这对于远程监控和调试非常方便。与AI推理结果结合这是 reComputer 的强项。你可以使用jetson.inference库进行实时图像识别然后将识别结果如物体标签、置信度框直接绘制到DisplayManager的图像缓冲区并显示在SPI屏上形成一个本地的、低功耗的AI结果显示器。5.3 常见问题与排查技巧实录在调试过程中你几乎一定会遇到下面这些问题。这里是我的排查清单现象可能原因排查步骤屏幕全白或全黑无任何变化1. 电源或地线未接好。2. 复位时序不正确。3. SPI未正确启用或引脚映射错误。4. 初始化序列错误或与屏幕不匹配。1. 用万用表检查VCC和GND电压是否为稳定的3.3V。2. 用逻辑分析仪或示波器检查RST引脚波形是否符合时序图。3. 运行ls /dev/spi*确认设备存在。用sudo cat /sys/kernel/debug/gpio查看GPIO状态。4.重点检查初始化序列尤其是0x11(Sleep Out) 和0x29(Display ON) 命令是否发送。尝试使用屏幕供应商提供的确切序列。屏幕有显示但错乱、花屏、颜色不对1. SPI模式CPOL/CPHA设置错误。2. 像素格式RGB565 vs RGB666设置错误。3. 内存访问控制MADCTL, 0x36命令设置错误导致扫描方向不对。4. SPI时钟频率过高信号完整性差。1. 尝试将spi.mode改为0b11(Mode 3)。2. 确认0x3A命令发送的数据是0x55(16bit) 还是0x66(18bit)。3. 修改0x36命令的参数。常见值0x48(横屏)0x28(竖屏)0x88(横屏镜像)。4. 降低max_speed_hz到 10_000_000 或以下再试。检查接线是否过长是否有干扰。只能显示一部分或刷新区域不对set_window函数设置的区域坐标错误。确认x0, y0, x1, y1的计算和发送顺序。ILI9341的坐标通常是先列后行且每个坐标分高8位和低8位两次发送。确保你的屏幕分辨率是240x320而不是320x240。Python程序报权限错误用户不在spi和gpio组。运行groups确认。将用户加入组后需要重新登录而不仅仅是新开一个终端。最直接的方式sudo chmod 666 /dev/spidev0.0(临时)或使用sudo运行脚本。刷新速度非常慢1. Python循环转换效率低。2. SPI频率设置过低。3. 每次刷新都是全屏刷新。1. 采用5.1节提到的NumPy加速方法。2. 在稳定的前提下提高SPI频率。3. 实现局部刷新逻辑。背光不亮背光引脚LED未连接或接错。检查背光引脚是接在3.3V还是GPIO上。如果是GPIO需要在代码中将其设置为高电平输出。有些屏幕背光需要串联一个限流电阻。一个关键的实操心得准备一个逻辑分析仪即使是几十块的简易版会极大提升调试效率。你可以用它抓取SPI总线上的实际波形对照ILI9341的数据手册一眼就能看出命令、数据、时序是否正确比盲目猜测修改代码快得多。最后驱动SPI屏是一个软硬件紧密结合的过程耐心和细致的排查比复杂的代码更重要。一旦成功点亮这块小小的屏幕就能为你的 reComputer J4012 项目增添一个直观的交互窗口无论是用于显示系统状态、传感器数据流还是简单的AI推断结果都极具实用价值。