Python与Arduino串口通信传输图片的联调实例

远山来客
远山来客
发布时间:2026-11-15 | 4人浏览过
原创

针对Arduino SRAM内存受限环境,通过Python图像预处理、RGB565格式转换、定长分包协议与逐行流式渲染,实现Python与Arduino串口通信传输图片的完整软硬件联调方案。

在嵌入式控制与物联网交互项目中,将上位机采集的图像传输到微控制器并在显示屏上实时呈现是一项经典需求。然而常见的 Arduino Uno 或 Nano 仅具备 2KB 的片上 SRAM,根本无法容纳一张哪怕仅有 128×128 分辨率的高清位图缓存(需要 32KB)。本文基于 Python 与 Arduino Uno、1.44 寸 ST7735 彩色液晶屏硬件组合,介绍一套“上位机预压缩切片、自定义帧流式传输、单片机逐行直接渲染、停等式确认”的完整通信联调方案,系统解决微控制器内存溢出与串口缓冲区丢包问题。

一、内存瓶颈与流式通信架构

很多初学者尝试在 Python 端读取 JPEG 或 PNG 图像后,试图直接一次性将几万字节的原始二进制流通过串口倒进 Arduino。这种做法在 Arduino Uno 等 AVR 架构单片机上必定引发死机或异常重启。原因在于:

  • SRAM 空间限制:ATmega328P 的 SRAM 总共只有 2048 字节。一张 128×128 分辨率、16 位色彩(RGB565)的图像,其体积为 128 × 128 × 2 = 32,768 字节,是单片机总内存的 16 倍。
  • 硬件串口缓冲区限制:Arduino 默认的串口接收环形缓冲区(Ring Buffer)仅为 64 字节。若上位机以 115200 bps 高速持续下发数据而单片机处理较慢,多出的字节会被硬件底层直接丢弃。

解决该物理限制的核心策略是“化整为零,逐行直写”:由 Python 负责所有繁重的图像处理(缩放、色彩空间转换、分行打包),并通过停等协议控制发送节奏;Arduino 每次仅接收一行像素数据(256 字节),在接收解包验证通过后,立刻将其推入 ST7735 屏幕显存中显示,随后回传确认标记(ACK),上位机收到确认后再发送下一行。整个系统架构与数据流向如下图所示:

Python与Arduino串口通信传输图片的联调实例
图 1:Python 上位机与 Arduino 逐行流式传输系统架构

二、硬件接线与联调环境准备

本次联调实验使用的核心硬件为 Arduino Uno R3(或 Nano)与 1.44 寸 ST7735 彩色 TFT 屏幕模块(分辨率 128×128 像素,SPI 接口)。

ST7735 模块通常采用标准 4 线 SPI 接口通信。由于 Arduino Uno 为 5V 逻辑电平,而多数 ST7735 模块引脚耐压为 3.3V(部分自带电平转换电阻或芯片),若模块未带电平转换器,建议在 SPI 信号线上串联 1kΩ 电阻。常规开发测试引脚连线如下表所示:

ST7735 屏幕引脚 Arduino Uno 引脚 引脚功能说明
VCC 5V 或 3.3V 供电电源(根据模块背部跳线决定)
GND GND 公用接地
CS (Chip Select) D10 SPI 片选信号(硬件 SS)
RESET D8 复位引脚
A0 / DC D9 命令/数据选择引脚
SDA / MOSI D11 SPI 数据输出(硬件 MOSI)
SCK / SCLK D13 SPI 时钟信号(硬件 SCK)
LED / BLK 3.3V 背光使能

软件依赖方面:

  1. Python 环境:安装 pyserial(串口驱动)和 Pillow(图像处理库)。命令行执行 pip install pyserial pillow。
  2. Arduino 环境:在 Arduino IDE 的库管理器(Library Manager)中搜索并安装 Adafruit GFX Library 与 Adafruit ST7735 and ST7789 Library。

三、通信数据帧格式与握手协议设计

为了保证通信的绝对可靠,不发生数据错位与溢出,我们定义严格的数据帧结构。上位机发送单行像素采用定长数据包,总长度为 264 字节;下位机每成功解析一行并刷新到屏幕后,立即回发 1 字节的 ACK (0x06)。

Python与Arduino串口通信传输图片的联调实例
图 2:串口通信帧结构与 RGB565 编码规范

四、联调步骤与时序流程

联调过程按照先连通硬件、后调试基础通信、再进行分包联调的顺序推进。整体时序分为五个确定阶段:

Python与Arduino串口通信传输图片的联调实例
图 3:联调全流程操作时序与阶段演进图

五、软硬件联调实现代码

1. Arduino 下位机代码

将以下代码烧录到 Arduino Uno 中。程序使用状态机解析每一行的数据帧,提取出 256 字节的 RGB565 像素流,通过 tft.setAddrWindow 定位当前扫描行,并调用 tft.pushColors 直推写入,最后回传 0x06:

#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <SPI.h>

// 引脚配置
#define TFT_CS   10
#define TFT_RST  8
#define TFT_DC   9

Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);

// 通信协议常量
const uint8_t HEADER_1 = 0xAA;
const uint8_t HEADER_2 = 0x55;
const uint8_t CMD_ROW  = 0x01;
const uint8_t ACK_BYTE = 0x06;
const uint8_t NAK_BYTE = 0x15;

const int IMG_WIDTH  = 128;
const int ROW_BYTES  = IMG_WIDTH * 2; // 256 字节

// 单行像素缓存(占用 256 字节 SRAM)
uint8_t rowBuffer[ROW_BYTES];

void setup() {
  Serial.begin(115200);
  
  // 初始化 ST7735 屏幕(常见 1.44 寸黑色标签模块)
  tft.initR(INITR_144GREENTAB);
  tft.setRotation(0);
  tft.fillScreen(ST77XX_BLACK);
  
  // 在屏幕顶端打印提示文字
  tft.setTextColor(ST77XX_WHITE);
  tft.setTextSize(1);
  tft.setCursor(10, 50);
  tft.println("Waiting for");
  tft.setCursor(10, 65);
  tft.println("Python stream...");
}

// 可靠读取指定字节数(带超时机制)
bool readBytesWithTimeout(uint8_t* dest, size_t len, unsigned long timeoutMs = 500) {
  size_t count = 0;
  unsigned long start = millis();
  while (count < len) {
    if (Serial.available() > 0) {
      dest[count++] = (uint8_t)Serial.read();
      start = millis(); // 收到新字节重置计时
    } else {
      if (millis() - start > timeoutMs) {
        return false;
      }
    }
  }
  return true;
}

void loop() {
  if (Serial.available() >= 2) {
    // 匹配帧头 0xAA 0x55
    if (Serial.read() == HEADER_1) {
      if (Serial.read() == HEADER_2) {
        uint8_t meta[4]; // [0]: CMD, [1]: row, [2]: lenHigh, [3]: lenLow
        if (!readBytesWithTimeout(meta, 4)) {
          Serial.write(NAK_BYTE);
          return;
        }

        uint8_t cmd = meta[0];
        uint8_t row = meta[1];
        uint16_t dataLen = ((uint16_t)meta[2] << 8) | meta[3];

        if (cmd == CMD_ROW && dataLen == ROW_BYTES && row < 128) {
          // 读取行像素载荷
          if (!readBytesWithTimeout(rowBuffer, ROW_BYTES)) {
            Serial.write(NAK_BYTE);
            return;
          }

          // 读取校验和与尾部
          uint8_t tail[3]; // [0]: XOR, [1]: \r, [2]: \n
          if (!readBytesWithTimeout(tail, 3)) {
            Serial.write(NAK_BYTE);
            return;
          }

          // 计算 XOR 校验
          uint8_t calcXor = cmd ^ row ^ meta[2] ^ meta[3];
          for (int i = 0; i < ROW_BYTES; i++) {
            calcXor ^= rowBuffer[i];
          }

          if (calcXor == tail[0]) {
            // 校验成功,推流写入 ST7735 显存指定行
            tft.setAddrWindow(0, row, IMG_WIDTH - 1, row);
            // rowBuffer 中是高位在前的数据,通过 pushColors 写入
            tft.pushColors((uint16_t*)rowBuffer, IMG_WIDTH, true);

            // 回发 ACK 确认包
            Serial.write(ACK_BYTE);
          } else {
            Serial.write(NAK_BYTE); // 校验失败
          }
        }
      }
    }
  }
}

2. Python 上位机脚本

Python 端负责将任意源图片(PNG、JPG 或 BMP)载入,保持宽高比或裁剪缩放为 128×128 像素,并逐像素转换为 16 位大端格式的 RGB565 字节,最后按照协议格式封包发出:

import time
import serial
from PIL import Image

PORT = "COM3"       # Windows 通常为 COMx,Linux/Mac 为 /dev/ttyUSB0 或 /dev/cu.usbserial-xxx
BAUD_RATE = 115200
IMG_PATH = "test.jpg"

HEADER = bytes([0xAA, 0x55])
CMD_ROW = 0x01
ACK = 0x06
NAK = 0x15

def convert_to_rgb565(img: Image.Image) -> bytes:
    """将 PIL 图像转换为 128x128 的 RGB565 字节数组 (总长 32768 字节)"""
    img = img.convert("RGB").resize((128, 128), Image.Resampling.LANCZOS)
    raw_bytes = bytearray()
    for y in range(128):
        for x in range(128):
            r, g, b = img.getpixel((x, y))
            # 提取 5-6-5 比特
            r5 = (r >> 3) & 0x1F
            g6 = (g >> 2) & 0x3F
            b5 = (b >> 3) & 0x1F
            rgb565 = (r5 << 11) | (g6 << 5) | b5
            # 大端模式打包为两个字节
            raw_bytes.append((rgb565 >> 8) & 0xFF)
            raw_bytes.append(rgb565 & 0xFF)
    return bytes(raw_bytes)

def send_image(ser: serial.Serial, img_data: bytes):
    print("[INFO] 开始逐行传输图像数据...")
    row_size = 128 * 2  # 256 字节

    for row in range(128):
        row_payload = img_data[row * row_size : (row + 1) * row_size]
        len_hi = (row_size >> 8) & 0xFF
        len_lo = row_size & 0xFF

        # 计算 XOR 校验
        chk = CMD_ROW ^ row ^ len_hi ^ len_lo
        for b in row_payload:
            chk ^= b

        # 组装完整帧:帧头(2) + CMD(1) + 行号(1) + 长度(2) + 载荷(256) + 校验(1) + 帧尾(2)
        packet = HEADER + bytes([CMD_ROW, row, len_hi, len_lo]) + row_payload + bytes([chk, 0x0D, 0x0A])

        retry_count = 0
        success = False

        while retry_count < 3 and not success:
            ser.write(packet)
            ser.flush()

            # 等待 Arduino 回应 ACK (超时 1 秒)
            resp = ser.read(1)
            if resp and resp[0] == ACK:
                success = True
                print(f"\r[进度] 行 {row + 1}/128 传输成功", end="", flush=True)
            else:
                retry_count += 1
                print(f"\n[警告] 行 {row} 接收异常 (返回: {resp}), 正在进行第 {retry_count} 次重试...")
                time.sleep(0.05)

        if not success:
            raise RuntimeError(f"[错误] 行 {row} 超过最大重试次数,传输中断!")

    print("\n[SUCCESS] 图像传输完成,屏幕显示已更新!")

def main():
    print(f"[INFO] 正在载入图像 {IMG_PATH} 并转换色彩...")
    img = Image.open(IMG_PATH)
    rgb565_data = convert_to_rgb565(img)

    print(f"[INFO] 正在打开串口 {PORT} @ {BAUD_RATE}...")
    ser = serial.Serial(PORT, BAUD_RATE, timeout=1.0)

    # 关键防重置处理:开启串口后等待 Arduino 初始化 Bootloader
    time.sleep(2.0)
    ser.reset_input_buffer()
    ser.reset_output_buffer()

    start_time = time.time()
    send_image(ser, rgb565_data)
    elapsed = time.time() - start_time
    print(f"[INFO] 净传输总耗时: {elapsed:.2f} 秒 (平均帧率: {1/elapsed:.2f} FPS)")

    ser.close()

if __name__ == "__main__":
    main()

六、联调常见故障与排查矩阵

在实际联调过程中,软硬件环境的细微差异极易引起白屏、丢包或显示乱码。针对最常见的四大故障,我们总结了对应的根因与解决依据:

Python与Arduino串口通信传输图片的联调实例
图 4:串口图片通信典型故障表现、诱因与排查对策矩阵

七、联调验证结果与工程总结

在 115200 bps 波特率下,理论每秒最大有效字节吞吐量约为 11.5 KB/s。一张 128×128 像素的 RGB565 图像(总长约 33.8 KB 包含协议开销)在停等式逐行应答机制下,实测平均耗时约 2.9 ~ 3.2 秒,平均传输帧率约为 0.33 FPS。

工程优化建议
  • 波特率提升:ATmega328P 在配合高质量 CH340 或 CP2102 芯片时,波特率可安全提升至 230400 bps 或 500000 bps,传输时间可压缩至 1 秒以内。
  • 简单行程编码(RLE):对于 UI 图标、矢量图或大面积纯色图像,可在 Python 端采用简单的 RLE 压缩编码,传输前缀长度指示,可降低 60% 以上的串口通信量。
  • ESP32 / RP2040 升级路径:若项目后续需要播放连续动态视频或 GIF,建议将主控升级至具备更高 SRAM 和 WiFi/USB-CDC 原生支持的 ESP32 或树莓派 Pico,届时可直接进行 DMA 显存双缓冲全速渲染。

通过本实例的联调过程可以看出,串口通信与微控制器的资源适配核心在于“协议分片”与“握手控制”。只要合理控制发送节奏与缓冲边界,即使在仅有 2KB 内存的入门级单片机上,也能稳定、优雅地完成高分辨率图像数据的可靠加载。

相关专题:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表