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 自己写;
- 多一个要单独学的设备。 它有自己的指令和烧录方式,“我们学过串口”只能让对接变简单,不能把学习成本变成零。
它换来的东西也很实在:改界面不用重烧单片机。 想换个措辞、挪一下按钮,在电脑上改完下载到屏里就行,单片机那边一行代码都不用动。
实验三:用串口屏显示一行字,再接收一次按钮事件。
- 用厂商的界面编辑器放一个文本框和一个按钮,下载到屏里;
- 单片机上电后发一条指令,把文本框改成
Hello world; - 按下屏幕上的按钮,在串口上观察屏主动上报的那一条消息;
- 手边没有串口屏的话,这一节可以换成模拟实验:用串口助手往板子发一条“上报格式”的消息,看程序能不能认出是哪个控件——接线可以没有,解析逻辑照样能验证。
什么时候用哪种屏
| 你想要的 | 顺手的选择 | 理由 |
|---|---|---|
| 几行字、几个数字,随时看得到 | 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。”
“……就这?”
“还、还有按钮有没有被按下……”