蔡凯迪 或许这就是迪哥吧 2023-10-20 22:50 发表于浙江

单片机串口调试屏幕

本文介绍一个Micropython单片机的开发技巧, 在串口被占用的情况下通过外接屏幕进行调试。

目前比较流行的树莓派pico类型的单片机, 可以通过Thonny等程序方便的进行micropython的开发调试, 程序的输出可以直接显示到交互式窗口上。但是涉及到串口通信的程序就会非常麻烦, 因为Thonny中的交互式命令本身是占用了串口的, 而串口是不可以被两个程序一起调用的。这就导致了用到串口的程序无法直接看到输出和debug信息。

我这里有两种解决思路。首先,micropython的输出是可以直接读的。在单片机和电脑正确的建立了串口连接之后, 在单片机上print()的信息可以在电脑上readline()读出来。

# 单片机micropython
import sys, select
poll = select.poll()
poll.register(sys.stdin, select.POLLIN)
print("hello")

# PC端
import serial
s = serial.Serial("COM4", baudrate=115200, timeout=1)
s.readline()  # "hello"

本文主要介绍另一种方法, 即在单片机上接一个屏幕用于显示输出。这种方法有更高的可玩性, 而且可以极大地减少调试过程中的痛苦, 并且可以封装在最后的成品中, 作为一个又好看又有用的输出显示设备。

1. 硬件

forum.jpg

淘宝买的这种屏幕,二十几块一个。只需要四根线连接单片机,GND和VCC是接地和3.3V供电没什么好说的,SLC和SDA接哪个需要查一下单片机的引脚定义。屏幕用的是I2C总线,一般只有两组或是四组引脚是支持的。比如树莓派pico,引脚如表所示。

forum.jpg

2. 亮起来

接好线之后第二步是让屏幕亮起来。这里需要一个驱动sh1106.py, 网上可以找到(https://github.com/scy/SH1106/blob/master/sh1106.py)。这个驱动提供了一些基础的显示功能, 把它复制到单片机上。然后:

from machine import I2C
from sh1106 import SH1106_I2C

i2c = I2C(0)  # 使用默认的SLC和SDA引脚,具体是哪两个要查
display = SH1106_I2C(128, 64, i2c)  # 分辨率128*64
display.text("hello", 10, 10)  # 在(10, 10)位置显示文字

能亮之后就比较简单了, 以前print()出来的debug信息都改成在屏幕上显示就好, 执行了一些关键步骤的时候也可以在屏幕上显示提示信息。

需要注意的是,屏幕显示东西是需要耗时的, 可能会高达20-40 ms。所以不要串行的放到时间敏感的代码中间。

3. 一点封装经验

能用和好用之间隔着巨大的鸿沟, 我做这个也不敢说好用, 但是分享一下封装思路, 希望能多少帮到一些不是程序员出身的朋友们。

3.1 屏幕类

首先是屏幕这个类的封装。举例来说, 每次显示完内容,如果不重新fill(0)的话, 原来亮起来的像素是不会熄灭的, 相当于字在叠加显示。而在主程序中, 我们不希望每次都考虑屏幕是如何显示内容的, 只要把内容给到屏幕就好。因此我们至少需要做这样的封装:

class Display:
    def __init__(self, width: int = 128, height: int = 64):
        self._width = width
        self._height = height
        self.fps = fps
        i2c = I2C(0)
        self.display = SH1106_I2C(self._width, self._height, i2c)
        self.display.fill(0)

    def text(self, txt: str):
        self.display.fill(0)
        self.display.text(txt, 4, 4)

这个简单的text()函数带来了一个显而易见的问题:字都在一行,而且位置是写死的。我们想显示一行内容的时候是居中的, 字很长的时候会自动换行并且在纵向保持居中。

在说自动换行之前,有必要先说一下标题。在有限的屏幕空间内显示信息, 有一个标题栏是十分直观的, 因为信息总体上是可以分成几类的, 比如debug信息、状态信息、串口信息等等。有一个标题栏有助于快速直观地知道显示的是什么内容。

class Display:

    # ...

    def draw_border(self, linewidth: int = 1):
        """画出边框"""
        self.display.rect(0, 0, self._width, self._height, linewidth)

    def draw_header(
        self,
        title: str,
        linewidth: int = 1,
        with_border: bool = True,
        refresh: bool = False,
    ):
        """画标题栏,容量为15个字符"""
        text_position = int(self._height * 0.06)
        self.display.text(title, text_position, text_position)
        line_height = int(self._height * 0.22)
        self.display.hline(0, line_height, self._width, linewidth)
        if with_border:
            self.draw_border()
        if refresh:
            self.display.show()

3.2 自动换行

自动换行其实是一个很好实现的功能, 甚至可以当成一个简单的程序员面试题。首先判断一下显示不下的情况(> 60字), 然后每15个字符切成一行, 设计一个公式映射行数、字符数和起始位置的关系, 显示就可以了。当然,有些情况下我们希望能在字符串中加入\n手动换行, 这种情况也单独判断一下。

    def draw_message(self, title: str, message: str):
        if len(message) > 60:
            return self.draw_message(title, "message too long")
        self.display.fill(0)
        self.draw_header(title)

        if "\n" in message:
            formated_message = message.split("\n")
            rows = len(formated_message)
        else:
            rows = math.ceil(len(message) / 15)  # 一行最多15个字母
            formated_message = [message[15 * i : 15 * (i + 1)] for i in range(rows)]

        y_position = [int(70 * (r + 1) / (rows + 1)) for r in range(rows)]
        x_position = [int((15 - len(m)) / 2) * 8 + 3 for m in formated_message]
        for i, m in enumerate(formated_message):
            self.display.text(m, x_position, y_position)
        self.show()

作为面试题的扩展, 大家还可以想一下如何不在单词中间换行, 而是在完整的单词之后换行, 或是自动添加连字符。

3.3 例子:显示PWM状态

我们举个例子再展示一下面向对象编程的优点。比方说这个单片机是用来做PWM输出的, 我们想通过屏幕实时显示PWM的状态, 那么我们就可以在屏幕类里再封装这样一个函数, 直接接受一个PWM类,把它显示出来。

    def draw_pwm(self, pwm, title: str = "PWM"):
        duty = pwm.duty_u16()
        freq = pwm.freq()
        self.display.fill(0)

        self.draw_header(title)

        self.display.text("Duty", 4, 18)
        self.display.text(f"{duty / 65535 * 100:<5.2f}%", 32, 29)
        self.display.text("Pulse frequency", 4, 40)
        self.display.text(f"{freq:<7.2f} Hz", 32, 51)
        self.show()

这样对于主程序来说, 想要显示一个PWM信号的状态, 只要把PWM传给屏幕的这个函数就行了, 不需要考虑怎么获取PWM的占空比和频率, 也不需要考虑如何显示的细节。

3.4 耗时

经过我测试这个屏幕每次显示内容会消耗几十毫秒的时间, 这在一些应用场景下是不可接受的。

我们当然可以给屏幕一个fps限制, 比如每秒钟只刷新一次, 来减少对时间的占用。但更优雅的办法是多线程。

可以通过一个全局变量monitor_cmd保存需要显示的内容和是否显示完成的信息, 新建一个线程一直监听这个全局变量, 如果发现有没显示的信息, 就把它显示出来,并标记为已经显示完成。

import time, _thread, _display

# 信息的形式
class MCmd:
    def __init__(self, rendered: bool, title: str, message: str, time: int) -> None:
        self.rendered = rendered
        self.title = title
        self.message = message
        self.time = time

# 监听线程
def monitor(o: _display.Display, time_interval):
    global monitor_cmd
    while True:
        if not monitor_cmd.rendered:
            o.draw_message(monitor_cmd.title, monitor_cmd.message)
            monitor_cmd.rendered = True
            time.sleep_ms(monitor_cmd.time + 1)
        time.sleep_ms(time_interval)

# 主函数
if __name__ == "__main__":
    # 初始化设备
    oled = _display.Display()
    # 初始化全局变量
    monitor_cmd = MCmd(
        rendered=False,
        title="init",
        message="init success, starting main loop...",
        time=1000,
    )
    # 启动监听线程
    _thread.start_new_thread(monitor, (oled, 24))

这样,在程序中有想要显示的内容, 只需要修改一下全局变量monitor_cmd就可以了。

总结

如今esp32水平的芯片性能已经非常强了, 但是可惜micropython的支持还不够完善, 能做的事远不如用c或者c++那么多。但对于许多许多工程控制问题,micropython是足够的, 并且可以节省大量开发时间。希望相关的底层代码、软硬件社区越来越好, 我写这一点点东西, 也是希望能贡献一点微小的力量, 愿用python在单片机上写机器学习的时代早日到来!