玩转树莓派屏幕之二:自定义屏幕显示

一、树莓派屏幕显示原理

屏幕驱动

安装执行MHS35-show完成之后,会加载屏幕驱动,使用命令

ll /dev/fb*

将会展示:

crw-rw---- 1 root video 29, 0 Oct  5 00:17 /dev/fb0
crw-rw---- 1 root video 29, 1 Oct  5 01:22 /dev/fb1

其中,fb0为默认HDMI输出,fb1为安装的屏幕,使用fbset命令查看缓存内容。

root@raspberrypi:/home/longtao/LCD-show/usr/fbcp-ili9341# fbset -fb /dev/fb0 

mode "480x320"
    geometry 480 320 480 320 16
    timings 0 0 0 0 0 0 0
    nonstd 1
    rgba 5/11,6/5,5/0,0/0
endmode

root@raspberrypi:/home/longtao/LCD-show/usr/fbcp-ili9341# 
root@raspberrypi:/home/longtao/LCD-show/usr/fbcp-ili9341# fbset -fb /dev/fb1

mode "480x320"
    geometry 480 320 480 320 16
    timings 0 0 0 0 0 0 0
    nonstd 1
    rgba 5/11,6/5,5/0,0/0
endmode

因为MHS35-show命令中会执行fbcp命令,将/dev/fb0(HDMI屏幕)中的内容映射到/dev/fb1(3.5寸屏幕),所以显示是一致内容。否则fb0将会显示以下内容
root@raspberrypi:# fbset -fb /dev/fb0
mode "640x480"
geometry 640 480 640 480 32
timings 0 0 0 0 0 0 0
rgba 8/16,8/8,8/0,8/24
endmode

MHS35-show 修改内容

1、/boot/firmware/config.txt文件:

在文件最后新增以下内容:

.....
[all]
hdmi_force_hotplug=1
---- 新增以下内容---
dtparam=i2c_arm=on
dtparam=spi=on
enable_uart=1
dtoverlay=mhs35:rotate=90
hdmi_group=2
hdmi_mode=1
hdmi_mode=87
hdmi_cvt 480 320 60 6 0 0 0
hdmi_drive=2

这些配置的作用为:

# 启用 I2C(用于传感器等)
dtparam=i2c_arm=on

# 启用 SPI(用于 MHS35 屏幕)
dtparam=spi=on

# 启用 UART 串口
enable_uart=1

# 加载 MHS35 屏幕驱动,横屏显示
dtoverlay=mhs35:rotate=90

# HDMI 设置:使用自定义分辨率
hdmi_group=2              # CEA 组(电视)
hdmi_mode=87              # 自定义模式

# hdmi_cvt <width> <height> <fps> <aspect> <margins> <interlace> <rb>
hdmi_cvt=480 320 60 6 0 0 0  # 480x320 @ 60Hz
hdmi_drive=2              # HDMI 模式(支持音频)

2、/etc/rc.local

新增==fbcp &==, 用于映射/dev/fb0 --> /dev/fb1

root@raspberrypi:~# cat /etc/rc.local 
#!/bin/sh -e
#
# rc.local
#
# This script is executed at the end of each multiuser runlevel.
# Make sure that the script will "exit 0" on success or any other
# value on error.
#
# In order to enable or disable this script just change the execution
# bits.
#
# By default this script does nothing.

# Print the IP address
_IP=$(hostname -I) || true
if [ "$_IP" ]; then
  printf "My IP address is %s\n" "$_IP"
fi

sleep 7
fbcp &

exit 0

3、 /boot/firmware/overlays/中

新增mhs35屏幕中的驱动

-rwxr-xr-x 1 root root 2.6K Oct  4 02:41 mhs35-overlay.dtb
-rwxr-xr-x 1 root root 2.6K Oct  4 02:41 mhs35.dtbo

二、 Linux LCD FrameBuff原理

Linux LCD Frambuffer 基础介绍和使用:https://blog.51cto.com/u_13064014/5079683
Linux应用开发【第一章】Framebuffer应用开发:https://zhuanlan.zhihu.com/p/443120506
Linux Framebuffer 技术:https://zhuanlan.zhihu.com/p/496623603

LCD显示原理:
image

具体细节可以看上面的介绍,其实简单来说,LCD显示的数据与内存中的数据有映射,通过该段这段内存中数据内容可以刷新屏幕。

Framebuffer 测试命令

为了方便测试 Framebuffer 可用,可以快速通过命令进行简单测试,如下所示:

  • 清屏命令

    dd if=/dev/zero of=/dev/fb0
    dd if=/dev/zero of=/dev/fb0 bs=1024 count=768
  • 截屏命令

    dd if=/dev/fb0 of=fbfile
    cp /dev/fb0 fbfile

    注意:这里的截屏其实就是拷贝 中的数据,所以只有当framebuffer中有数据存在时才能截屏成功

  • 将保存的信息显示传回framebuffer

    dd if=fbfile of=/dev/fb0
  • 往屏幕的左上角画一个白色的像素点

    echo -en '\xFF\xFF\xFF\x00' > /dev/fb0
  • 花屏指令

    cat /dev/urandom > /dev/fb0

三、显示代码

从上面已经知道,屏幕中显示的内容都是数据,可以通过C代码操作Framebuffer进行操作,有一定的上手难度。

其实换一个角度,屏幕中显示的内容都可以看成一帧图片,可以将显示的内容形成图片,然后将图片传入到/dev/fb0也是能够正常展示的。使用Python形成一帧图片还是比较简单的。

安装python3的依赖

sudo apt update
sudo apt install python3-pip fbi
pip3 install Pillow psutil --break-system-packages

--break-system-packages 表示破坏系统包,树莓派新版操作系统不运行直接pip安装包

编辑文件:

vim system_show.py
# system_monitor.py
from PIL import Image, ImageDraw, ImageFont
import os
import time
import psutil
import socket
import subprocess

# 屏幕尺寸
WIDTH, HEIGHT = 480, 320

def get_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except:
        return "N/A"

def get_cpu_usage():
    return psutil.cpu_percent(interval=0.5)

def get_memory_usage():
    mem = psutil.virtual_memory()
    return mem.percent

def get_load():
    load1, load5, load15 = os.getloadavg()
    return f"{load1:.2f}"

def is_service_running(service_name):
    try:
        result = subprocess.run(['systemctl', 'is-active', service_name], 
                                capture_output=True, text=True)
        return result.stdout.strip() == 'active'
    except:
        return False

def get_file_count(file_dir):
    try:
        result = subprocess.run(f"ls -al {file_dir} | wc -l", shell=True, capture_output=True, text=True, check=True)
        #print(result)
        return  str(int(result.stdout.strip()) - 1)
    except Exception as e:
        print(f"Error counting files in {file_dir}: {e}")
        return 'NA'

def is_docker_container_running(container_name):
    try:
        result = subprocess.run(['docker', 'inspect', '-f', '{{.State.Running}}', container_name],
                                capture_output=True, text=True)
        return 'true' in result.stdout.lower()
    except:
        return False

def is_mounted(path):
    return os.path.ismount(path)

def get_disk_usage(path):
    try:
        usage = psutil.disk_usage(path)
        percent = usage.percent
        total_gb = usage.total / (1024**3)
        used_gb = usage.used / (1024**3)
        return f"{percent:.1f}% ({used_gb:.1f}G/{total_gb:.1f}G)"
    except:
        return "N/A"

def show_text(text_lines, fontsize=20):
    img = Image.new('RGB', (WIDTH, HEIGHT), (0, 0, 0))
    draw = ImageDraw.Draw(img)

    try:
        font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", fontsize)
    except:
        font = ImageFont.load_default()

    y_start = 10
    line_height = fontsize + 6
    for i, line in enumerate(text_lines):
        draw.text((10, y_start + i * line_height), line, fill=(255, 255, 255), font=font)

    # 保存临时图片
    img.save("/tmp/system_monitor.png")

    # 输出到 framebuffer
    os.system("fbi -d /dev/fb1 -T 1 -noverbose -a /tmp/system_monitor.png > /dev/null 2>&1")

# === 主循环 ===
if __name__ == "__main__":
    while True:
        # 收集信息
        ip = get_ip()
        cpu = get_cpu_usage()
        mem = get_memory_usage()
        load = get_load()

        omv_ok = "✓" if is_service_running("openmediavault-engined") else "✗"
        openlist_ok = "✓" if is_docker_container_running("openlist") else "✗"
        rclone_task_ok = "✓" if any("rclone" in p.name().lower() or "rsync" in p.name().lower() 
                                    for p in psutil.process_iter(['name'])) else "✗"

        mount1 = "/srv/dev-disk-by-uuid-0987bf77-ebce-4022-afeb-fc9a56417e54"
        mount2 = "/mnt/aliyun"
        mount1_ok = "✓" if is_mounted(mount1) else "✗"
        mount2_ok = "✓" if is_mounted(mount2) else "✗"

        file_count1 = get_file_count("/srv/dev-disk-by-uuid-0987bf77-ebce-4022-afeb-fc9a56417e54/smb_xiaomi_vidoes/xxx")
        file_count2 = get_file_count("/mnt/aliyun/xxx/")

        usb_usage = get_disk_usage(mount1)

        # 构建显示文本
        lines = [
            "=== System Monitor ===",
            f"IP: {ip}",
            f"CPU: {cpu:.1f}%",
            f"Mem: {mem:.1f}%",
            f"Load: {load}",
            "",
            "=== Services ===",
            f"OMV: {omv_ok}",
            f"OpenList: {openlist_ok}",
            f"Rclone/Rsync: {rclone_task_ok}",
            "",
            "=== Mounts ===",
            f"Data Disk: {mount1_ok}     file_count:{file_count1}",
            f"AliyunFS: {mount2_ok}      file_count:{file_count2} ",
            "",
            "=== /srv/dev-disk-by-uuid-0987bf77-ebce-4022-afeb-fc9a56417e54 ===",
            f"Usage: {usb_usage}"
        ]

        # 显示
        show_text(lines, fontsize=12)

        # 每秒刷新一次
        time.sleep(5)

执行命令:

python3 system_show.py

展示效果

屏幕展示效果:
/tmp/system_monitor.png
system_monitor

真机显示效果(有点模糊):
image

参考文章