9浏览
查看: 9|回复: 0

[ESP8266/ESP32] 基于FireBeetle 2 ESP32-C5 IIC屏幕电脑“副屏”

[复制链接]
本帖最后由 腿毛利小五郎 于 2025-12-6 11:27 编辑

基于FireBeetle 2 ESP32-C5 IIC屏幕电脑“副屏”
展示视频:


1. 项目背景与挑战
项目的目标很纯粹:想鼓捣一个电脑副屏玩一下,仅尝试。为了确保速率,手头上有一块IIC的屏幕,IIC速度限制,esp32C5支持wifi,保证成本另外一端就采用网络传输吧。
通过 TCP 协议将 PC 端画面实时传输到 ESP32 驱动的 SSD1306 OLED (128x64) 上。
这看似简单,但面临两个典型的不对称速率问题:
  • 生产者(网络层):ESP32 的 Wi-Fi 吞吐量极高,数据包到达往往是突发的。
  • 消费者(显示层):SSD1306 通常使用 I2C 接口,I2C速度受限。

如果采用简单的“接收 -> 显示”同步逻辑,网络抖动会导致卡顿,而 I2C 的低速刷新会阻塞网络接收,导致丢包。为了解决这种生产者-消费者速率不匹配问题,架构设计必须解耦。

2. 接线C5 设计十分用心,不仅自带拓展板,官方还为其定制了按引脚功能分类的彩色线材,大幅降低了接线难度,让操作更直观高效,这个细节真的值得点赞!
基于FireBeetle 2 ESP32-C5 IIC屏幕电脑“副屏”图2
3. 物理极限:I2C 总线的带宽分析
首先,我们需要正视硬件的物理上限。对于 SSD1306,我们使用 I2C 接口。
  • 标准速度:100 kHz (过于缓慢)
  • 快速模式:400 kHz (本项目采用)

让我们做一个理论计算:
OLED 分辨率为 128*64,即每帧需要传输 128*64/ 8 = 1024 Bytes 的数据。在 I2C 协议中,传输一个字节通常需要 9 个时钟周期(8位数据 + 1位 ACK)。
可以参考我之前的水帖文章最后对I2C的理解Esp32 C6 IIC使用u8g2成功点亮ssd1306 DF创客社区
那么一个比特的时间就是9/400000 差不多是22.5us。
传输一帧完整图像(纯数据,不计地址头和 Start/Stop 信号开销)所需时间:

1024*22.5us 差不多 23.04ms

理论最大帧率(FPS):

就是23.04的倒数,43.4帧

然而,现实很骨感。 加上 I2C 的地址开销、ESP32 的中断响应延迟、TCP 协议栈的处理时间,实际能稳定跑到 30 FPS 已是工程极限。I2C 的半双工、开漏输出特性决定了它不适合大数据量吞吐,因此,每一毫秒的 CPU 时间都不能被浪费在“等待总线”上。


4. 核心架构:三重缓冲 (Triple Buffering)
为了在 30 FPS 的物理极限下实现如丝般顺滑的体验,我放弃了传统的双缓冲(Double Buffering),转而采用了三重缓冲机制,很多项目里也都是用三缓冲。
在双缓冲模型中,我们有 BackBuffer(接收)和 FrontBuffer(显示)。
  • 当 I2C 正在忙碌地将 FrontBuffer 刷入屏幕时(耗时 ~30ms),网络层收到了新的一帧数据。
  • 此时 BackBuffer 尚未交换,网络线程不得不阻塞等待,或者直接丢弃数据。这会导致 CPU 空转,浪费了宝贵的网络处理能力。

我也引入了第三个缓冲区,系统结构变为:
  • Display Buffer (Front):当前 I2C 正在读取并发送给 OLED 的数据。
  • Receiving Buffer (Back):TCP 正在写入的网络数据。
  • Standby Buffer (Middle):一个空闲的中间态。

工作流程如下:
  • 网络线程:永远只向 Receiving 缓冲写入。写完后,它不需要等待 I2C 刷新完成,只需将 Receiving 与 Standby 指针交换。内存指针操作,是几乎零延迟的。
  • 显示线程(主循环):当 I2C 完成一帧刷新后,它检查是否有新数据。如果有,它将 Standby(存着最新完整帧)与 Display 指针交换。

  1. // 核心指针交换逻辑(原子操作思想)
  2. volatile uint8_t* buffers[3] = { buf0, buf1, buf2 };
  3. volatile int displayIdx = 0;
  4. volatile int receivingIdx = 1;
  5. volatile int standbyIdx = 2;
  6. void swapBuffersForDisplay() {
  7.   noInterrupts(); // 进入临界区,防止中断打断指针交换
  8.   int prevDisplay = displayIdx;
  9.   displayIdx = receivingIdx; // 将最新接收好的数据推向前台
  10.   receivingIdx = standbyIdx; // 拿空闲缓冲去接新数据
  11.   standbyIdx = prevDisplay;  // 旧的显示数据退居二线
  12.   interrupts();
  13. }
复制代码

5. 上位机上位机是在Windows下执行的python脚本,大致就是图像采集,数据流处理转换,封包,发送。逻辑还是很清晰的。
6. 总结
这个项目虽小,却是一个典型的软硬结合性能调优案例。
  • 硬件层面,我们将 I2C 时钟推至 400kHz,逼近 SSD1306 的物理极限。
  • 软件层面,通过三重缓冲打破了 I/O 速率瓶颈,实现了非阻塞的高效并发。基于FireBeetle 2 ESP32-C5 IIC屏幕电脑“副屏”图1


7. 源码
Arduino
  1. #include <WiFi.h>
  2. #include <Wire.h>
  3. #include <Adafruit_GFX.h>
  4. #include <Adafruit_SSD1306.h>
  5. // Wi-Fi
  6. const char* WIFI_SSID     = "";
  7. const char* WIFI_PASSWORD = "";
  8. // Server
  9. WiFiServer server(12345);
  10. WiFiClient client;
  11. // Display
  12. #define SCREEN_WIDTH 128
  13. #define SCREEN_HEIGHT 64
  14. #define OLED_RESET -1
  15. Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
  16. // Frame sizes and buffers
  17. const size_t FRAME_BYTES = (SCREEN_WIDTH * SCREEN_HEIGHT) / 8; // 1024
  18. uint8_t buf0[FRAME_BYTES];
  19. uint8_t buf1[FRAME_BYTES];
  20. uint8_t buf2[FRAME_BYTES];
  21. volatile uint8_t* buffers[3] = { buf0, buf1, buf2 };
  22. // Indices
  23. volatile int displayIdx = 0;
  24. volatile int receivingIdx = 1;
  25. volatile int standbyIdx = 2;
  26. // Receive state
  27. const uint16_t HEADER_MAGIC = 0xAA55;
  28. uint32_t recvSeq = 0;
  29. unsigned long lastRecvMillis = 0;
  30. const unsigned long RECV_TIMEOUT_MS = 2000; // 超时 -> 清屏或暂停显示
  31. // Utility for atomic swap of indices (disable interrupts around swap)
  32. void swapBuffersForDisplay() {
  33.   noInterrupts();
  34.   int prevDisplay = displayIdx;
  35.   displayIdx = receivingIdx;
  36.   receivingIdx = standbyIdx;
  37.   standbyIdx = prevDisplay;
  38.   interrupts();
  39. }
  40. void clearDisplayBuffer(uint8_t* b) {
  41.   memset(b, 0x00, FRAME_BYTES);
  42. }
  43. void showBuffer(uint8_t* b) {
  44.   // SSD1306 expects page addressing; Adafruit library has drawBitmap
  45.   display.clearDisplay();
  46.   display.drawBitmap(0, 0, b, SCREEN_WIDTH, SCREEN_HEIGHT, SSD1306_WHITE);
  47.   display.display();
  48. }
  49. bool readExact(WiFiClient& cli, uint8_t* dst, size_t len, unsigned long timeout_ms) {
  50.   size_t got = 0;
  51.   unsigned long start = millis();
  52.   while (got < len) {
  53.     if (cli.available()) {
  54.       int r = cli.read(dst + got, len - got);
  55.       if (r > 0) got += r;
  56.     } else {
  57.       if ((millis() - start) > timeout_ms) return false;
  58.       delay(1);
  59.     }
  60.   }
  61.   return true;
  62. }
  63. void setup() {
  64.   Serial.begin(115200);
  65.   delay(100);
  66.   // I2C 初始化
  67.   Wire.begin(9, 10); // SDA = 9, SCL = 10
  68.   Wire.setClock(400000); // 400kHz fast mode
  69.   // Display init
  70.   if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
  71.     Serial.println("SSD1306 allocation failed");
  72.     while (1);
  73.   }
  74.   display.clearDisplay();
  75.   display.display();
  76.   // Clear buffers
  77.   clearDisplayBuffer((uint8_t*)buffers[0]);
  78.   clearDisplayBuffer((uint8_t*)buffers[1]);
  79.   clearDisplayBuffer((uint8_t*)buffers[2]);
  80.   // Wi-Fi connect
  81.   Serial.printf("Connecting to %s\n", WIFI_SSID);
  82.   WiFi.mode(WIFI_STA);
  83.   WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  84.   int retry = 0;
  85.   while (WiFi.status() != WL_CONNECTED && retry < 40) {
  86.     delay(250);
  87.     Serial.print(".");
  88.     retry++;
  89.   }
  90.   if (WiFi.status() == WL_CONNECTED) {
  91.     Serial.println();
  92.     Serial.print("Connected. IP: ");
  93.     Serial.println(WiFi.localIP());
  94.   } else {
  95.     Serial.println();
  96.     Serial.println("WiFi connect failed. Starting AP as fallback.");
  97.     WiFi.softAP("ESP32_OLED_AP");
  98.     Serial.print("AP IP: ");
  99.     Serial.println(WiFi.softAPIP());
  100.   }
  101.   // Start server
  102.   server.begin();
  103.   Serial.printf("Server started on port %d\n", 12345);
  104.   lastRecvMillis = millis();
  105. }
  106. void loop() {
  107.   // Accept new client if none
  108.   if (!client || !client.connected()) {
  109.     if (server.hasClient()) {
  110.       if (client && client.connected()) {
  111.         WiFiClient other = server.available();
  112.         other.stop();
  113.       } else {
  114.         client = server.available();
  115.         Serial.println("Client connected");
  116.       }
  117.     }
  118.   }
  119.   // If client connected, try read frame header
  120.   if (client && client.connected()) {
  121.     // We try to read header: 2 bytes magic + 4 bytes seq + 2 bytes length = 8 bytes
  122.     if (client.available() >= 8) {
  123.       uint8_t header[8];
  124.       if (!readExact(client, header, 8, 1000)) {
  125.         Serial.println("Header read timeout");
  126.         client.stop();
  127.       } else {
  128.         uint16_t magic = (header[0] << 8) | header[1];
  129.         if (magic != HEADER_MAGIC) {
  130.           Serial.println("Bad magic, resyncing");
  131.           // try to resync by discarding one byte and continue
  132.           client.read();
  133.           return;
  134.         }
  135.         uint32_t seq = (uint32_t(header[2]) << 24) | (uint32_t(header[3]) << 16) | (uint32_t(header[4]) << 8) | uint32_t(header[5]);
  136.         uint16_t length = (uint16_t(header[6]) << 8) | uint16_t(header[7]);
  137.         if (length != FRAME_BYTES) {
  138.           Serial.printf("Unexpected frame length: %d (expected %d)\n", length, FRAME_BYTES);
  139.           // try to skip this amount (or drop connection)
  140.           if (length < 50000) {
  141.             uint8_t tmp[64];
  142.             size_t toskip = length;
  143.             while (toskip > 0 && client.connected()) {
  144.               size_t r = client.read(tmp, min((size_t)64, toskip));
  145.               if (r == 0) break;
  146.               toskip -= r;
  147.             }
  148.             return;
  149.           } else {
  150.             client.stop();
  151.             return;
  152.           }
  153.         }
  154.         // read payload into receiving buffer
  155.         uint8_t* recvBuf = (uint8_t*)buffers[receivingIdx];
  156.         bool ok = readExact(client, recvBuf, FRAME_BYTES, 2000);
  157.         if (!ok) {
  158.           Serial.println("Frame payload timeout, dropping client");
  159.           client.stop();
  160.         } else {
  161.           // frame fully received -> 更新 seq/time 并切换缓冲(无撕裂)
  162.           recvSeq = seq;
  163.           lastRecvMillis = millis();
  164.           swapBuffersForDisplay();
  165.         }
  166.       }
  167.     }
  168.   }
  169.   // Timeout handling: 如果长时间未接收,清屏一次并继续等待
  170.   if ((millis() - lastRecvMillis) > RECV_TIMEOUT_MS) {
  171.     static bool cleared = false;
  172.     if (!cleared) {
  173.       Serial.println("Receive timeout, clearing screen");
  174.       clearDisplayBuffer((uint8_t*)buffers[displayIdx]);
  175.       showBuffer((uint8_t*)buffers[displayIdx]);
  176.       cleared = true;
  177.     }
  178.   } else {
  179.     // 常规绘制当前 displayIdx 缓冲内容到 OLED(只在变化时或固定频率绘制)
  180.     static unsigned long lastDraw = 0;
  181.     unsigned long now = millis();
  182.     if (now - lastDraw >= 40) { // 每 ~25fps 更新一次屏幕显示
  183.       showBuffer((uint8_t*)buffers[displayIdx]);
  184.       lastDraw = now;
  185.     }
  186.   }
  187. }
复制代码


python:终端按下回车 切换显示样式
  1. #!/usr/bin/env python3
  2. # screen_stream_final.py
  3. # 依赖: pip install opencv-python numpy mss pyautogui
  4. import socket
  5. import threading
  6. import time
  7. import struct
  8. from collections import deque
  9. import numpy as np
  10. import cv2
  11. from mss import mss
  12. import pyautogui  # 用于获取鼠标位置
  13. # --- 用户配置 ---
  14. HOST = "10.168.1.122"  # <== 修改为你的 ESP32 IP
  15. PORT = 12345
  16. FPS = 30
  17. WIDTH, HEIGHT = 128, 64  # OLED 分辨率
  18. # 默认模式: 'FULL' (全屏) 或 'MOUSE' (跟随鼠标)
  19. # 你可以在运行时按 "Enter" 键切换
  20. DEFAULT_MODE = 'MOUSE'
  21. # --- 全局变量与锁 ---
  22. CURRENT_MODE = DEFAULT_MODE
  23. buffer_lock = threading.Lock()
  24. frame_queue = deque(maxlen=2)
  25. MAGIC = b"\xAA\x55"
  26. def get_mouse_region(monitor_info, target_w=128, target_h=64, zoom=2):
  27.     """
  28.     计算鼠标周围的截取区域。
  29.     zoom: 缩放倍数,2表示截取 256x128 的区域缩放到 128x64,这样更清晰。
  30.     """
  31.     mx, my = pyautogui.position()
  32.     crop_w = target_w * zoom
  33.     crop_h = target_h * zoom
  34.     # 确保不超出屏幕边界
  35.     # monitor_info 格式: {'left': 0, 'top': 0, 'width': 1920, 'height': 1080}
  36.     left = mx - crop_w // 2
  37.     top = my - crop_h // 2
  38.     # 边界限制
  39.     left = max(monitor_info["left"], min(left, monitor_info["left"] + monitor_info["width"] - crop_w))
  40.     top = max(monitor_info["top"], min(top, monitor_info["top"] + monitor_info["height"] - crop_h))
  41.     return {"top": int(top), "left": int(left), "width": crop_w, "height": crop_h}
  42. def capture_loop():
  43.     global CURRENT_MODE
  44.     with mss() as sct:
  45.         # 获取主屏幕信息
  46.         monitor = sct.monitors[1]
  47.         seq = 0
  48.         interval = 1.0 / FPS
  49.         print(f"[capture] Thread started. Current Mode: {CURRENT_MODE}")
  50.         while True:
  51.             t0 = time.time()
  52.             # --- 1. 根据模式截屏 ---
  53.             if CURRENT_MODE == 'MOUSE':
  54.                 # 鼠标跟随模式:截取鼠标周围区域 (2x 采样以获得高清晰度)
  55.                 region = get_mouse_region(monitor, WIDTH, HEIGHT, zoom=2)
  56.                 img_bgra = np.array(sct.grab(region))
  57.             else:
  58.                 # 全屏模式:直接截取整个屏幕
  59.                 img_bgra = np.array(sct.grab(monitor))
  60.             # --- 2. 图像处理 (OpenCV Pipeline) ---
  61.             # 转灰度
  62.             gray = cv2.cvtColor(img_bgra, cv2.COLOR_BGRA2GRAY)
  63.             # 缩放 (INTER_AREA 抗锯齿效果最佳)
  64.             # 如果是全屏模式,这里会将 1920x1080 -> 128x64,文字可能会很难看清,主要看大轮廓
  65.             resized = cv2.resize(gray, (WIDTH, HEIGHT), interpolation=cv2.INTER_AREA)
  66.             # 二值化 (Otsu 自动阈值)
  67.             # 你也可以改为固定阈值: cv2.threshold(resized, 128, 255, cv2.THRESH_BINARY)
  68.             _, thresh = cv2.threshold(resized, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
  69.             # --- 3. 极速打包 (Numpy Packbits) ---
  70.             # 将 0/255 图像转为 0/1 比特流
  71.             bits = (thresh > 127).astype(np.uint8)
  72.             # axis=1 表示横向打包,符合 SSD1306 从左到右的扫描顺序
  73.             packed_array = np.packbits(bits, axis=1)
  74.             payload = packed_array.tobytes()
  75.             # --- 4. 组装协议包 ---
  76.             # Header: Magic(2) + Seq(4) + Len(2)
  77.             header = MAGIC + struct.pack(">I", seq) + struct.pack(">H", len(payload))
  78.             packet = header + payload
  79.             with buffer_lock:
  80.                 frame_queue.append(packet)
  81.             seq = (seq + 1) & 0xFFFFFFFF
  82.             # 帧率控制
  83.             dt = time.time() - t0
  84.             sleep_t = interval - dt
  85.             if sleep_t > 0:
  86.                 time.sleep(sleep_t)
  87. def sender_loop():
  88.     """负责网络发送的线程,断线重连逻辑"""
  89.     while True:
  90.         try:
  91.             print(f"[sender] Connecting to {HOST}:{PORT}...")
  92.             with socket.create_connection((HOST, PORT), timeout=3) as s:
  93.                 s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  94.                 print("[sender] Connected! Sending stream...")
  95.                 while True:
  96.                     data = None
  97.                     with buffer_lock:
  98.                         if frame_queue:
  99.                             data = frame_queue.pop()  # 取最新帧
  100.                             frame_queue.clear()  # 丢弃旧帧
  101.                     if data:
  102.                         s.sendall(data)
  103.                     else:
  104.                         time.sleep(0.002)  # 让出 CPU
  105.         except (ConnectionRefusedError, TimeoutError, OSError) as e:
  106.             print(f"[sender] Disconnected: {e}. Retry in 2s...")
  107.             time.sleep(2)
  108.         except Exception as e:
  109.             print(f"[sender] Error: {e}")
  110.             time.sleep(2)
  111. def input_listener():
  112.     """控制台输入监听,用于运行时切换模式"""
  113.     global CURRENT_MODE
  114.     print("-" * 50)
  115.     print(f"当前模式: [{CURRENT_MODE}]")
  116.     print(">>> 按 [回车键] (Enter) 可在 FULL / MOUSE 模式间切换 <<<")
  117.     print("-" * 50)
  118.     while True:
  119.         # 阻塞等待用户按回车
  120.         input()
  121.         if CURRENT_MODE == 'FULL':
  122.             CURRENT_MODE = 'MOUSE'
  123.         else:
  124.             CURRENT_MODE = 'FULL'
  125.         print(f"\n[Switch] 模式已切换为: >>> {CURRENT_MODE} <<<")
  126. if __name__ == "__main__":
  127.     # 启动采集线程
  128.     t_cap = threading.Thread(target=capture_loop, daemon=True)
  129.     t_cap.start()
  130.     # 启动发送线程
  131.     t_send = threading.Thread(target=sender_loop, daemon=True)
  132.     t_send.start()
  133.     # 在主线程运行输入监听(或者也可以另开线程,这里直接利用主线程做交互)
  134.     try:
  135.         input_listener()
  136.     except KeyboardInterrupt:
  137.         print("\nStopping...")
复制代码






您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

为本项目制作心愿单
购买心愿单
心愿单 编辑
[[wsData.name]]

硬件清单

  • [[d.name]]
btnicon
我也要做!
点击进入购买页面
上海智位机器人股份有限公司 沪ICP备09038501号-4 备案 沪公网安备31011502402448

© 2013-2025 Comsenz Inc. Powered by Discuz! X3.4 Licensed

mail