屏幕不止一种:OLED、LCD1602字符屏、TFT/IPS图形屏和串口屏怎么用,这与TTY和PTY又有什么关系

0 阅读26分钟

OLED 用了很久了。它便宜、省引脚、够小,两行字加几个数字就能把程序的状态摆在眼前。

但有些需求它接不住:要显示图像、要画一条曲线、要一块比 128×64 更大的画面、要一个能按的按钮。这时候面前会冒出三个词:字符屏、TFT(或者 IPS)、串口屏。

下面把它们各来一遍。三类的做法不一样,但都落在我们已经会的东西上——GPIO、把线一位一位拨出去的写法、串口收发,以及“照模块附带的资料接线、用厂商给的驱动、只改应用层”这套分工。

(下面每次说到“模块附带的资料”,指的就是买模块时那份压缩包:里面的例程、初始化表、取模软件。它们的作者是模块厂或屏厂,不是我们;这里讲的正是怎么和它们打交道。)

三类屏,三个层次的问题

字符屏(LCD1602 这类)图形屏(TFT/IPS)串口屏
屏里有什么一颗字符控制器一颗显示控制器 + 显存一颗小单片机 + 界面
你能控制到哪一层只能决定“第几行第几个字符是什么”每一个像素的颜色控件的内容与事件
怎么接并行数据线 + 几根控制线SPI(或并口,看芯片)串口,两根信号线
谁在干活单片机写字符单片机算像素、送数据屏自己排版,单片机发指令
典型代价只能显字符,位置固定引脚多、要字库、刷新慢贵、带宽低、指令是厂商定的

把这三列放在一起看,会冒出一个很实用的判断:你越是想让屏“好看”,就越要把工作从单片机挪到屏上。 OLED 和 TFT 是“在单片机上画画”,串口屏是“让屏自己去画”。这里没有谁高谁一等,区别只是复杂度放在单片机还是屏自己身上。

字符屏:LCD1602

它显示的不是像素,是字符

LCD1602 的屏幕上印着两行、每行 16 个格子。你送进去的不是“某一行某一列的像素”,而是一个字符的编码:送 'A' 的 ASCII 码,它自己查内部字符库,把那个字形显示出来。

好处是不用字库、不用算坐标、常常还吃得下 5V;限制是——你只能显示它字符库里的东西,位置也固定在 16×2 个格子里。

它的接口是一组并行线:RS(这一字节是命令还是数据)、RW(读还是写)、E(使能,靠一个电平脉冲把数据读进去),加上数据线 D0~D7。

接线:两种省法

模块附带的例程通常这样接:RS=PC13、RW=PC14、E=PC15,数据线 D0~D7 接 PA0~PA7,八位模式。

这个接法能跑,但有两处可以省:

  • 数据线只用 4 根。 LCD1602 支持 4 位模式:先送高 4 位,再送低 4 位,一个字节分两次进去。八位模式占 8 个引脚,四位模式占 4 个;代码多几行,引脚少一半;
  • RW 可以直接接地。 它唯一的用途是让单片机读“忙标志”,好省掉固定延时。只写不读的话,把 RW 接 GND,用固定延时替代读忙标志,又省一根线。

下面按省引脚的版本来:RS=PC13、E=PC14、D4~D7=PA4~PA7,RW 接地。注意这只是一组示例引脚。真要把屏装上车,先按资源表把这几个引脚重新分一次——引脚不够时怎么取舍,面包板接线那篇已经讲过。

驱动:一个字节拆成两次送

#define LCD_RS_PORT   GPIOC
#define LCD_RS_PIN    GPIO_Pin_13
#define LCD_E_PORT    GPIOC
#define LCD_E_PIN     GPIO_Pin_14

/* 送出 4 位:E 上那个脉冲,就是控制器“读走这一口”的时刻 */
static void LCD_WriteNibble(uint8_t nibble)
{
    GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)((nibble & 0x01U) != 0U));
    GPIO_WriteBit(GPIOA, GPIO_Pin_5, (BitAction)((nibble & 0x02U) != 0U));
    GPIO_WriteBit(GPIOA, GPIO_Pin_6, (BitAction)((nibble & 0x04U) != 0U));
    GPIO_WriteBit(GPIOA, GPIO_Pin_7, (BitAction)((nibble & 0x08U) != 0U));

    GPIO_SetBits(LCD_E_PORT, LCD_E_PIN);
    Delay_us(1U);
    GPIO_ResetBits(LCD_E_PORT, LCD_E_PIN);
    Delay_us(1U);
}

/* is_data = 1 写数据(要显示的字符),0 写命令(配置) */
static void LCD_WriteByte(uint8_t value, uint8_t is_data)
{
    if (is_data != 0U)
    {
        GPIO_SetBits(LCD_RS_PORT, LCD_RS_PIN);
    }
    else
    {
        GPIO_ResetBits(LCD_RS_PORT, LCD_RS_PIN);
    }

    LCD_WriteNibble((uint8_t)(value >> 4));         /* 先高四位 */
    LCD_WriteNibble((uint8_t)(value & 0x0FU));      /* 再低四位 */
}

Delay_us() 就是前面用 SysTick 做的那份精确延时。这里的等待正好是它擅长的场合——“等一段时间”,而不是“把节奏放慢”:LCD1602 在清屏、复位这些动作之后确实需要一段时间才肯理你,等得准一点是必要的。(软件 I2C 那种位延时是另一回事)

初始化要按控制器的规矩来。4 位模式的进门方式很特别:先用八位模式的复位序列敲三下,再切到四位模式:

void LCD1602_Init(void)
{
    GPIO_InitTypeDef gpio;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOC, ENABLE);

    gpio.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7;
    gpio.GPIO_Mode = GPIO_Mode_Out_PP;
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &gpio);

    gpio.GPIO_Speed = GPIO_Speed_2MHz;  /* PC13/PC14 在备份域,手册只允许 2MHz */
    gpio.GPIO_Pin = LCD_RS_PIN | LCD_E_PIN;
    GPIO_Init(GPIOC, &gpio);

    Delay_ms(20U);                  /* 上电后先等它内部复位 */
    Delay_ms(20U);

    LCD_WriteNibble(0x03U);         /* 复位序列:三次 0x03 */
    Delay_ms(5U);
    LCD_WriteNibble(0x03U);
    Delay_us(200U);
    LCD_WriteNibble(0x03U);
    Delay_us(200U);

    LCD_WriteNibble(0x02U);         /* 从这一下开始,进入 4 位模式 */
    Delay_us(200U);

    LCD_WriteByte(0x28U, 0U);       /* 4 位、两行、5x8 点阵 */
    LCD_WriteByte(0x0CU, 0U);       /* 开显示、不显示光标、不闪烁 */
    LCD_WriteByte(0x06U, 0U);       /* 每写一个字符,地址自动加一 */
    LCD_WriteByte(0x01U, 0U);       /* 清屏 */
    Delay_ms(2U);
}

0x28、0x0C、0x06、0x01 这几条是这类控制器几十年没变的常用指令,意思分别是:屏幕多大、要不要光标、写完一格往哪走、清空。

显示字符时,先告诉它“从哪一格开始”,再送字符:

void LCD1602_SetCursor(uint8_t row, uint8_t column)
{
    uint8_t offset = (row == 0U) ? 0x00U : 0x40U;

    LCD_WriteByte((uint8_t)(0x80U | (offset + column)), 0U);
}

void LCD1602_ShowString(uint8_t row, uint8_t column, const char *text)
{
    LCD1602_SetCursor(row, column);

    while (*text != '\0')
    {
        LCD_WriteByte((uint8_t)(*text), 1U);
        text++;
    }
}

void LCD1602_Clear(void)
{
    LCD_WriteByte(0x01U, 0U);
    Delay_ms(2U);
}

第二行的起点为什么是 0x40,而不是“第一行 16 个字符之后”?这是控制器内部地址表的排法:两行各占一段连续地址,第二行从第 64 个开始。所以第二行第 0 格对应的命令是 0x80 | 0x40 = 0xC0。

实验一:两行各显示一句话,再把第二行第 0 格单独改写一次。

LCD1602_Init();
LCD1602_ShowString(0U, 0U, "LCD1602 ready");
LCD1602_ShowString(1U, 0U, "2nd row = 0xC0");
LCD1602_SetCursor(1U, 0U);
LCD_WriteByte((uint8_t)'X', 1U);

最后那个 X 会盖掉第二行的第一个字符,而不是跑到别处——这就是地址在起作用。 如果它出现在奇怪的位置,先怀疑 row、column 算错了,而不是屏坏了。

图形屏:TFT 和 IPS

它和 OLED 的差别在哪

同样是“一个像素一个像素”,TFT 和那块 0.96 寸 OLED 的差别有三处:

  • 彩色。 常见编码是 RGB565:一个像素 16 位,红 5 位、绿 6 位、蓝 5 位。0xF800 是纯红、0x07E0 是纯绿、0x001F 是纯蓝;
  • 多了一颗控制器。 屏上那块玻璃本身不记住画面,要靠控制器里的显存把画面存住。单片机得按控制器的指令设窗口、写显存;
  • 字库要自己带。 OLED 那种 6×8、8×16 的小字库在彩屏上太小,中文更是必须自己取模。

为什么这一版是软件 SPI

STM32F103C8T6 这颗芯片没有 FSMC——数据手册里写得很清楚:FSMC 只在 100 脚和 144 脚的封装上才有,而我们用的是 48 脚。没有 FSMC,就没有那条“把并口屏当内存写”的快路。

所以在这一档芯片上带小屏,主流做法就是 SPI:慢一点,但引脚少、接线简单、任何一颗 STM32 都有。

模块例程里那块 1.8 寸小屏走的就是软件 SPI:SCL、SDA(MOSI)、RST、DC、CS、BLK 六根线,全是普通 GPIO,靠程序一位一位拨。下面这段按同样的思路写,引脚仍以示例形式标出:

#define LCD_SCL_PORT   GPIOA
#define LCD_SCL_PIN    GPIO_Pin_0
#define LCD_SDA_PORT   GPIOA
#define LCD_SDA_PIN    GPIO_Pin_1
#define LCD_RST_PORT   GPIOA
#define LCD_RST_PIN    GPIO_Pin_2
#define LCD_DC_PORT    GPIOA
#define LCD_DC_PIN     GPIO_Pin_3
#define LCD_CS_PORT    GPIOA
#define LCD_CS_PIN     GPIO_Pin_4
#define LCD_BLK_PORT   GPIOA
#define LCD_BLK_PIN    GPIO_Pin_5

六根线各管一件事,要先分清:

线作用
SCL时钟,由我们产生
SDA数据,一位一位送
CS片选:拉低表示“现在跟你说话”
DC数据还是命令:0 是命令,1 是数据
RST复位,低电平有效;上电后要先给它一个脉冲
BLK背光,直接拉高最省事,也可以接 PWM 调亮度

最小驱动

软件 SPI 的本质还是“拨线”,和软件 I2C 一个道理,只是协议不同:这里没有起始和应答,数据在时钟的上升沿被对方读走。

static void LCD_WR_Byte(uint8_t data)
{
    uint8_t bit;

    for (bit = 0U; bit < 8U; bit++)                 /* 高位先行 */
    {
        GPIO_ResetBits(LCD_SCL_PORT, LCD_SCL_PIN);  /* 时钟先拉低 */

        if ((data & 0x80U) != 0U)
        {
            GPIO_SetBits(LCD_SDA_PORT, LCD_SDA_PIN);
        }
        else
        {
            GPIO_ResetBits(LCD_SDA_PORT, LCD_SDA_PIN);
        }

        GPIO_SetBits(LCD_SCL_PORT, LCD_SCL_PIN);    /* 上升沿:控制器读走这一位 */
        data = (uint8_t)(data << 1);
    }

    GPIO_ResetBits(LCD_SCL_PORT, LCD_SCL_PIN);
}

这类控制器对时序很宽容,几十兆主频下的循环本身已经够慢,所以循环里不需要额外延时。真遇到花屏再考虑加一点点,但先别急着怪速度。

片选和“命令还是数据”,用两个函数分开:

static void LCD_WriteCommand(uint8_t command)
{
    GPIO_ResetBits(LCD_DC_PORT, LCD_DC_PIN);    /* DC = 0:命令 */
    GPIO_ResetBits(LCD_CS_PORT, LCD_CS_PIN);
    LCD_WR_Byte(command);
    GPIO_SetBits(LCD_CS_PORT, LCD_CS_PIN);
}

static void LCD_WriteData8(uint8_t data)
{
    GPIO_SetBits(LCD_DC_PORT, LCD_DC_PIN);      /* DC = 1:数据 */
    GPIO_ResetBits(LCD_CS_PORT, LCD_CS_PIN);
    LCD_WR_Byte(data);
    GPIO_SetBits(LCD_CS_PORT, LCD_CS_PIN);
}

static void LCD_WriteData16(uint16_t data)
{
    LCD_WriteData8((uint8_t)(data >> 8));       /* 16 位颜色:先高字节 */
    LCD_WriteData8((uint8_t)(data & 0x00FFU));
}

画东西之前,先让控制器知道“画在哪一块”:

static void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1)
{
    LCD_WriteCommand(0x2AU);                    /* 列地址范围 */
    LCD_WriteData16(x0);
    LCD_WriteData16(x1);

    LCD_WriteCommand(0x2BU);                    /* 行地址范围 */
    LCD_WriteData16(y0);
    LCD_WriteData16(y1);

    LCD_WriteCommand(0x2CU);                    /* 接下来发的都是显存数据 */
}

void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color)
{
    uint32_t count = (uint32_t)(x1 - x0 + 1U) * (uint32_t)(y1 - y0 + 1U);

    LCD_SetWindow(x0, y0, x1, y1);

    while (count > 0U)
    {
        LCD_WriteData16(color);
        count--;
    }
}

void LCD_DrawPixel(uint16_t x, uint16_t y, uint16_t color)
{
    LCD_Fill(x, y, x, y, color);
}

0x2A、0x2B、0x2C 这一组是这类控制器的通用做法:先把要写的矩形框出来,后面连续送的颜色就自动一格一格填进去,不用每画一个像素都重新报一次坐标。于是“填一块纯色”和“画一个像素”其实是同一个函数,只有窗口大小不同。

初始化表为什么别自己写

上面这些是“怎么用”,还有一大块是“怎么让它醒过来”:复位之后,控制器需要几十到上百条配置命令——扫描方向、颜色顺序、时序参数、电压设置……这些值来自控制器手册和屏厂的实际调试,没有推出来的捷径,照模块资料给的那张表抄最省事。

static void LCD_LoadInitTable(void)
{
    /* 这里放模块附带资料里那张初始化表:
       每一条都是 LCD_WriteCommand(...) 加若干 LCD_WriteData8(...) */
}

void LCD_Init(void)
{
    GPIO_InitTypeDef gpio;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    gpio.GPIO_Pin = LCD_SCL_PIN | LCD_SDA_PIN | LCD_RST_PIN |
                    LCD_DC_PIN | LCD_CS_PIN | LCD_BLK_PIN;
    gpio.GPIO_Mode = GPIO_Mode_Out_PP;          /* 软件 SPI:普通推挽输出 */
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &gpio);

    GPIO_SetBits(LCD_BLK_PORT, LCD_BLK_PIN);    /* 打开背光 */
    GPIO_SetBits(LCD_CS_PORT, LCD_CS_PIN);

    GPIO_ResetBits(LCD_RST_PORT, LCD_RST_PIN);  /* 复位脉冲 */
    Delay_ms(20U);
    GPIO_SetBits(LCD_RST_PORT, LCD_RST_PIN);
    Delay_ms(120U);

    LCD_LoadInitTable();
}

抄完那张表,通常还要核对三处才能和自己的屏对上:

  • 分辨率:1.8 寸常见 128×160,2.4/2.8 寸常见 240×320;
  • 方向:同一个屏横过来竖过去,靠一条“扫描方向”命令决定;改成横屏之后,x 和 y 的取值范围跟着换;
  • 颜色顺序:如果红色显示成蓝色,多半是颜色顺序那一位设反了,不是接线问题。

字库和取模

彩色屏上显示汉字,靠的是字模数组:把每个汉字的点阵按某种格式导出成一串字节,写进程序,显示时按点阵逐位画点。

模块附带的资料里一般还有一个取模软件,导出时有三件事必须和驱动对上:取模方向、字节顺序、点阵大小。这三样只要有一个不一致,显示出来就是一堆碎点或者镜像的字。这和前面 OLED 遇到的是同一件事——数据没坏,是两边对“这些位怎么排”的理解不一样。

实验二:先在屏幕上填两块颜色,再显示一个汉字。

LCD_Init();

LCD_Fill(0U, 0U, 127U, 79U, 0xF800U);           /* 上半屏填红 */
LCD_Fill(0U, 80U, 127U, 159U, 0x001FU);         /* 下半屏填蓝 */

/* 16×16 的点阵:数组在下面给出,函数内部按位决定前景还是背景 */
LCD_ShowChinese(56U, 72U, chinese_font_16x16, 0xFFFFU, 0xF800U);

LCD_ShowChinese() 内部就是把窗口设成 16×16,再按点阵数组逐位决定这一格画前景色还是背景色——它和 LCD_Fill() 用的是同一套窗口机制,只是颜色不再统一。

/* 16×16 点阵:外框加一条对角线,只为把机制跑起来。
   换成真正的字,用取模软件按 16×16、行优先、高位在左导出 32 字节替换即可 */
static const uint8_t chinese_font_16x16[32] = {
    0xFF, 0xFF, 0x80, 0x01, 0x80, 0x01, 0x88, 0x01,
    0x84, 0x01, 0x82, 0x01, 0x81, 0x01, 0x80, 0x81,
    0x80, 0x41, 0x80, 0x21, 0x80, 0x11, 0x80, 0x09,
    0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0xFF, 0xFF,
};

void LCD_ShowChinese(uint16_t x, uint16_t y, const uint8_t *font,
                     uint16_t fg, uint16_t bg)
{
    uint8_t row;
    uint8_t col;

    LCD_SetWindow(x, y, (uint16_t)(x + 15U), (uint16_t)(y + 15U));

    for (row = 0U; row < 16U; row++)
    {
        uint16_t bits = ((uint16_t)font[row * 2U] << 8) | font[row * 2U + 1U];

        for (col = 0U; col < 16U; col++)
        {
            LCD_WriteData16((bits & (uint16_t)(0x8000U >> col)) ? fg : bg);
        }
    }
}

每个像素都要显式写一个颜色,包括“不亮”的那些。 16×16 就是 256 次 LCD_WriteData16(),这也是汉字比 ASCII 费时间的原因。

串口屏:把界面搬到电脑上

它的本质是另一台小电脑

串口屏里装着一颗自己的单片机和一小块存储。你在电脑上用厂商的界面编辑器拖控件——文本框、按钮、进度条、曲线——排版、选字体、配色全在电脑上做完,然后下载到屏里。

单片机这边只剩两件事:

  • 发指令:告诉屏“把某个控件的内容改成什么”“跳到第几页”;
  • 收事件:屏幕上某个按钮被按下时,屏会主动从串口上报一条消息。

它对接的东西我们全都学过。 发指令就是串口发字符串;收上报就是串口收字节,和讲命令协议那一回处理命令是同一个套路——把上报当成“另一种命令”来解析。

一块串口屏接上 PA9/PA10,和我们接 CH340、接蓝牙模块一模一样:它只是又一个“挂在串口上的设备”。

/* 让屏上某个文本控件显示一行字(具体写法以厂商的指令手册为准) */
Uart_SendString("set text: Hello world\r\n");

Uart_SendString() 就是串口那几篇里的发送函数,一个字节一个字节往外送。这一行到底该写什么,取决于厂商给的指令表——不同厂家的写法完全不同,这件事只能照手册来,不能照抄别人的板子。

反过来,屏上报的事件也进同一条串口:

/* 主循环里沿用讲命令协议那一回的收字节思路 */
while (Uart_ReadByte(&received_byte))
{
    SerialScreen_Service(received_byte);
}

SerialScreen_Service() 是给串口屏上报写的解析函数:按厂商的格式判断“这是哪个控件、发生了什么事”,再交给业务层。它和命令帧的解析是同一件事——先划边界,再看内容,最后交给业务。

#define SCREEN_FRAME_LEN  5U    /* 帧头两字节 + 控件编号 + 事件类型 + 校验和 */

static uint8_t screen_frame[SCREEN_FRAME_LEN];
static uint8_t screen_count = 0U;

void SerialScreen_Service(uint8_t byte)
{
    if ((screen_count == 0U) && (byte != 0x5AU))
    {
        return;                             /* 还没进帧,非帧头就丢 */
    }

    screen_frame[screen_count++] = byte;

    if (screen_count < SCREEN_FRAME_LEN)
    {
        return;
    }

    screen_count = 0U;

    if (screen_frame[1] != 0xA5U)
    {
        return;                             /* 第二个字节对不上,整帧作废 */
    }

    if ((uint8_t)(screen_frame[2] + screen_frame[3]) != screen_frame[4])
    {
        return;                             /* 校验不过,整帧作废 */
    }

    /* 到这里是一帧完整上报:控件编号在 [2],事件在 [3] */
    if ((screen_frame[2] == 1U) && (screen_frame[3] == 1U))
    {
        /* 1 号控件的“按下”,业务层的动作写在这里 */
    }
}

上面这套只是最低限度能跑的骨架:帧头、长度、校验和都按一个假设的厂商格式来,真正接一块屏时,把 SCREEN_FRAME_LEN、帧头字节和校验方式换成手册里写的那一套,其余不用动。

它的代价

  • 带宽有限。 115200 下每秒只能送一万多个字节,刷一张全屏彩色图会很慢,画曲线也要控制点数;
  • 钱。 同样尺寸的屏,串口屏通常比裸屏贵一截,因为屏上多了一颗单片机和一套固件;
  • 受厂商限制。 控件怎么摆、字体能不能换、动画能做成什么样,取决于它给你什么;想更自由,就只能换裸 TFT 自己写;
  • 多一个要单独学的设备。 它有自己的指令和烧录方式,“我们学过串口”只能让对接变简单,不能把学习成本变成零。

它换来的东西也很实在:改界面不用重烧单片机。 想换个措辞、挪一下按钮,在电脑上改完下载到屏里就行,单片机那边一行代码都不用动。

实验三:用串口屏显示一行字,再接收一次按钮事件。

  1. 用厂商的界面编辑器放一个文本框和一个按钮,下载到屏里;
  2. 单片机上电后发一条指令,把文本框改成 Hello world;
  3. 按下屏幕上的按钮,在串口上观察屏主动上报的那一条消息;
  4. 手边没有串口屏的话,这一节可以换成模拟实验:用串口助手往板子发一条“上报格式”的消息,看程序能不能认出是哪个控件——接线可以没有,解析逻辑照样能验证。

什么时候用哪种屏

你想要的顺手的选择理由
几行字、几个数字,随时看得到0.96 寸 OLED(已经会了)引脚少、便宜、代码里什么都能自己控
中文、图形、曲线、彩色SPI 的 TFT/IPS像素级可控;代价是引脚、字库和速度
漂亮的界面、要能按按钮、不想在单片机里画图串口屏排版和触摸都交给屏;代价是钱、带宽和厂商限制
只要两行字符,成本压到最低LCD1602便宜、简单、常常能直接吃 5V
屏幕只是给现场看几眼,不用交互OLED 或字符屏越简单,越不容易在比赛现场掉链子

选屏这件事很容易被“越大越好、越彩色越好”带走。真正该问的还是那句话:这个画面要给谁看、在现场起什么作用? 如果它只是让你在赛道上确认“程序跑到哪一步了”,两行字符加一个数字就够了;如果它要摆在别人面前看,那才轮到 TFT 和串口屏。

三类屏的共性

把这三节收成一句话:不管哪种屏,你做的事都是三步——接上、初始化、然后往里写东西。

差别只在“往里写”这一步的层次:

  • LCD1602 写的是字符,位置是格子;
  • TFT 写的是像素,位置是坐标,内容是颜色;
  • 串口屏写的是控件,位置由屏里的界面工程决定。

三者还有一个共同的坑:初始化表或者指令格式对不上,画面就是黑的或者乱的,而且不会报错。 所以遇到问题先分层查——供电和接线、复位、初始化、最后的写入格式,一层层往下走,别一上来就怀疑自己的业务代码。

再往外一层:电脑把串口当成什么

前面三类屏都在讲一件事:谁负责把字节变成看得见的东西。 字符屏里是它自己的字符库,TFT 里是我们一个字模一个字模画上去,串口屏里是屏上那颗单片机的界面工程。把这条轴往外再推一步,会落到一台电脑身上——它接上 CH340 之后,也把那一串字节变成了看得见的字符,而且是这套东西里资格最老的一层:TTY,终端。

在单片机这边,printf() 只是把字符一个字节一个字节交给 USART 的寄存器,剩下没有别人。到了电脑这边,中间多了一整套东西:内核里有一个驱动负责这一路的字节,用户程序拿到的名字是 /dev/ttyUSB0 这样的一个文件。

这就是 TTY:内核里那层替你和一台终端打交道的软件。 名字是从电传打字机留下来的,机器早换了好几代,名字没换。它管三件事——把字节送出去、把字节收进来、以及在两者中间按一套约定动一动手脚。这三件事在两边是一体的,前面那三类屏只做前两件加一点排版,所以这里是最完整的一档。

那个文件真的是个文件

Linux 上把 USB 转串口插上去,系统里会多出 /dev/ttyUSB0。它是设备文件:用起来像文件,内容不是数据,是“在这台设备上做一次读写”的入口。 echo 往里写、cat 往外读、screen 和 minicom 打开它、Python 里的串口库打开它——全都是同一件事:打开那一个文件。

命名也不是随便起的。USB 转串口(CH340、CP2102、FT232 这些)拿到的是 ttyUSB0 这样的名字;板子上本来就有的那些串口是 ttyS0、ttyAMA0 这样。Windows 上同一个东西叫 COM3。 比如 ch341-uart converter now attached to ttyUSB0,说的就是内核在这一刻把“一个 USB 设备”变成了“一个终端节点”,这个节点要有名字,才能被程序打开。

中间那道会动手脚的层

TTY 和普通文件还有一处不一样,也正是后面所有麻烦的来源:你往里写的字节,不一定原样出去;从那头读到的字节,也不一定是线上原样的。

中间那道层按一套约定做事:波特率和数据位这些参数;收到换行时要不要顺手加一个回车(为了让老式的终端显示正常,这个“好意”留到了今天);要不要把你发出去的字符回显一遍(这样在终端里敲键盘才看得见自己敲了什么);一行回车才算一行;Ctrl+C 这类按键要不要当成信号;还有一些字节被留下来当流控用。这套约定有个名字叫 termios,在 Linux 上打印当前值的命令是 stty:

stty -F /dev/ttyUSB0 -a

Linux 上用 stty -F、macOS 上用 stty -f 指定设备,不同系统参数写法不完全一样。

对着一块板子用串口工具时,这套约定默认是按“一个人坐在终端前敲键盘”配的。串口屏、TFT 上那些给人读的日志顺着它走没问题,甚至更省事。可换成 VOFA+ 的 JustFloat 那种连续二进制浮点、或者 51、52 里那种自定义的二进制帧时,麻烦就来了:

  • 内容、长度、校验和这些位置都可能出现正好是行结束符的字节,约定就顺手给它添一个字符——线上的字节和板子发出来的字节已经不是一个东西了;
  • 某个字节正好落在“流控”那一类,它可能根本没往线上走;
  • 每一段都可能触发一次回显。

同一个 /dev/ttyUSB0,一边是文本日志看着一切正常,一边是二进制帧对不上,原因就在这道层上。 所以程序自己打开串口时,这套约定必须先关掉——原样收、原样发、不加回车、不回显、不解释任何字节。Python 的 pyserial 这类串口库,打开端口时默认就走这一档;换成自己读写文件、或者在终端里用 cat 去看那口数据,落在的就是默认那一套。

PTY:没有硬件的那一对

TTY 不止一种形态,还有一对不接任何硬件的:PTY,伪终端。它总是成对出现——一头叫主端,另一头叫从端。从端长得就和 /dev/ttyUSB0 一样,程序打开它、按 termios 配置它,什么感觉都没有;真正在另一头做事的,是另一个程序。

终端模拟器就是这么干活的:

  • 你敲的键,从窗口交给主端;
  • 从内核的角度看,这些字节就是从一台“终端”进来的,和插着一块板子的时候走的是同一条路;
  • 从端 / 主端 / 那个负责做事的程序,连起来就是“终端”这两个字的现代形态。

只要某个程序需要一个 TTY 才能跑,而现场根本没有硬件,就在软件里造一对出来——不需要一根线、也不需要一块芯片。

SSH 是同一个机制最划算的一种用法:远端那一头,登录会话拿到的也是一对伪终端;而你手边这台电脑,只是把中间一段链路的字节原样转过去。所以代码里那些“没有终端就跑不好”的毛病,从这里看全都说得通——程序一看 stdout 不是 TTY,就把输出改成整块缓冲,日志一下子变得看不见。

这么一圈绕回来,三类屏可以并排排进同一条轴里。字从哪来、谁在排版,还有一道界:中间有没有一层会替你动字节。

LCD1602 / TFT串口屏电脑上的 TTY
字从哪来屏控制器里的字符库 / 我们送去的点阵屏上那颗单片机的界面工程内核里的 TTY 层
谁在排版我们的驱动屏自己终端软件
谁在替你动字节没人,送什么写什么屏的指令集termios 那一套约定

左边那两栏,板子送出去什么,屏幕上就是什么;最右边多了中间那道层,是这套东西里唯一会替你改字节的地方。

最后橘猫说

三类屏各解决一个层次:LCD1602 只给字符,位置固定在 16×2 格子里,省引脚可以上 4 位模式、把 RW 接地;TFT/IPS 给的是像素,一个像素 16 位 RGB565,靠 0x2A/0x2B 设窗口、0x2C 往里写;串口屏把界面做成一台挂在串口上的小电脑,单片机只发指令、收事件。三者的姿势一样:接上、初始化、往里写,出问题也按同一个顺序查——供电接线、复位、初始化、写入格式。选屏别被“大”和“彩色”带走,先问这个画面给谁看。

最后蜘蛛说

“你要串口屏干嘛?”
“显示 Hello World。”
“……就这?”
“还、还有按钮有没有被按下……”