Python与Arduino串口通信传输图片的联调实例
针对Arduino SRAM内存受限环境,通过Python图像预处理、RGB565格式转换、定长分包协议与逐行流式渲染,实现Python与Arduino串口通信传输图片的完整软硬件联调方案。
一、内存瓶颈与流式通信架构
很多初学者尝试在 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),上位机收到确认后再发送下一行。整个系统架构与数据流向如下图所示:
二、硬件接线与联调环境准备
本次联调实验使用的核心硬件为 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 | 背光使能 |
软件依赖方面:
- Python 环境:安装
pyserial(串口驱动)和Pillow(图像处理库)。命令行执行pip install pyserial pillow。 - Arduino 环境:在 Arduino IDE 的库管理器(Library Manager)中搜索并安装
Adafruit GFX Library与Adafruit ST7735 and ST7789 Library。
三、通信数据帧格式与握手协议设计
为了保证通信的绝对可靠,不发生数据错位与溢出,我们定义严格的数据帧结构。上位机发送单行像素采用定长数据包,总长度为 264 字节;下位机每成功解析一行并刷新到屏幕后,立即回发 1 字节的 ACK (0x06)。
四、联调步骤与时序流程
联调过程按照先连通硬件、后调试基础通信、再进行分包联调的顺序推进。整体时序分为五个确定阶段:
五、软硬件联调实现代码
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()
六、联调常见故障与排查矩阵
在实际联调过程中,软硬件环境的细微差异极易引起白屏、丢包或显示乱码。针对最常见的四大故障,我们总结了对应的根因与解决依据:
七、联调验证结果与工程总结
在 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 内存的入门级单片机上,也能稳定、优雅地完成高分辨率图像数据的可靠加载。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













