OLED为什么能显示字:软件I2C换成硬件I2C
经典三问
Q: OLED 显示一个字,中间经过了什么?
A: 一个坐标先被算成 SSD1306 的 page 和 column,变成命令与显存数据,再作为字节送到从机地址上。
Q: 同一块屏为什么有两种送法?
A: 要送的字节是同一批,差别只在最底下那一段:一种由 CPU 一位一位拨出来(软件 I2C),一种交给 STM32 自带的 I2C 外设(硬件 I2C)。
Q: 换实现之后,哪些代码变、哪些不变?
A: 变的只有“把字节送出去”那一个函数和它的初始化;SSD1306 的命令、page/column、字模和应用层一行都不用动。
这次不从波形开始。
把屏幕擦成全黑,只在正中央点亮一个像素:
OLED_DrawPixel(64u, 31u, 1u);
烧录、上电。黑色屏幕中间冒出一个很小的白点,像有人拿针尖在玻璃后面敲了一下。这个点没有“像素线”直接连到 STM32,代码里也没写“把第 64 列灯打开”。它从一个坐标,变成了一串命令、地址和数据,最后才落到面板上的一个发光单元。
下面沿着这一个点走三步:先用软件 I2C 把它送出去(前面一直在用的那套写法),再把最底层换成硬件 I2C 送同一个点,最后把两种送法并排放在一起看。上层一个字都不改——这正好能验证一件事:协议与实现是两回事。
这里用的是一份刻意重写的极简教学驱动:没有整屏显存缓冲,每次显示调用都立即走完一次完整的 I2C 小事务,方便把每一层都摊开看。接口名也做了简化——显示无符号数在这里叫
OLED_ShowUnsigned(),显示一个字符叫OLED_ShowChar(),画点叫OLED_DrawPixel(x, y, on)(三个参数,最后一个是“点亮还是熄灭”),清屏叫OLED_Clear()。等做菜单和动态图形、需要整屏刷新时,会换回带显存缓冲的那套驱动,接口名也回到更常见的写法。这份驱动基于江协科技公开的 OLED-V2.0 程序重写,原始代码版权归原作者所有。
先把四根线插对
桌上放一块常见的 0.96 寸四针 OLED:
OLED VCC -> 3.3V 或模块手册指定的电压
OLED GND -> STM32 GND
OLED SCL -> STM32 PB8
OLED SDA -> STM32 PB9
如果黑屏,用万用表量一下 VCC 和 GND。之前最浪费时间的一次,是我盯着 OLED_ShowUnsigned() 改了半小时,最后才发现模块根本供电不足,类似的还有两边没有共地。
开漏输出、上拉电阻、为什么空闲时两根线是高电平——这些道理上一回讲完了,这里只重复一句:软件 I2C 时引脚配成开漏,靠模块上的上拉把线拉回高;硬件 I2C 时引脚配成复用开漏,交出去的只是“谁在拨这根线”,开漏这件事没变。
一个点是算出来的
OLED_DrawPixel(64, 31, 1) 里的 64 是横向列,31 是纵向行。SSD1306 并不单独接收一个“像素编号”,它按 8 行一组组织显存。所以这个点其实属于 page = 31 / 8 = 3、这一列的第 31 % 8 = 7 位,真正写下去的是一个只有最高位为 1 的字节:
1000 0000 -> page 3, column 64 的第 7 个像素
负责这件事的函数没有把计算藏起来:
void OLED_DrawPixel(uint8_t x, uint8_t y, uint8_t on)
{
uint8_t page;
uint8_t bit_mask;
if ((x >= 128u) || (y >= 64u))
{
return;
}
page = (uint8_t)(y / 8u);
bit_mask = (uint8_t)(1u << (y % 8u));
OLED_SetCursor(page, x);
OLED_WriteData((on != 0u) ? bit_mask : 0x00u);
}
先定位,再写一个数据字节。这里没有整屏缓存,也没有“刷新函数”替你补第二步,所以每调用一次,屏幕就立刻收到一次完整的小事务。
这个最小函数有一个必须知道的限制:它直接写整整一个字节,点亮一个像素时会把同一列、同一 page 的其他 7 个像素清掉。屏幕刚清过的时候现象很干净;以后要在已有图形上改一个像素,就得先维护显存,不能继续靠它硬写。
把 1u << (y % 8u) 改成固定的 0x01u,再把 y 从 31 改成 24,白点会落到同一个 page 的顶部(y = 24 还是 page 3,位号是 24 % 8 = 0,0x01 点的是这一页的第 0 行,也就是最上面那一行)。代码没坏,显存的位布局正在给你上课。
想看“一组 bit 排成轮廓”,把那一行换成:
OLED_ShowChar(0u, 0u, 'A');
屏幕上出现的字母,其实只是 6 个连续的列字节。
上层那一半:控制字节和位置
SSD1306 收到地址之后,还要知道后面的字节是命令还是显存数据。所以每一轮事务都先发一个控制字节:
static uint8_t OLED_WriteCommand(uint8_t command)
{
return OLED_Write(0x00u, command); /* 0x00:后面这个字节是命令 */
}
static uint8_t OLED_WriteData(uint8_t data)
{
return OLED_Write(0x40u, data); /* 0x40:后面这个字节是显存数据 */
}
写像素之前要先指定位置。128x64 的显存不是一张字符纸,而是 8 个 page,每个 page 高 8 个像素、横向 128 列:
static void OLED_SetCursor(uint8_t page, uint8_t column)
{
OLED_WriteCommand((uint8_t)(0xB0u | (page & 0x07u)));
OLED_WriteCommand((uint8_t)(0x10u | ((column >> 4) & 0x0Fu)));
OLED_WriteCommand((uint8_t)(column & 0x0Fu));
}
0xB0 | page 选纵向的第几个 page;列地址被拆成高 4 位和低 4 位两条命令,这是 SSD1306 自己的规定。
这一整段与“怎么送”完全无关。 不管底下是软件 I2C 还是硬件 I2C,命令是这三条、控制字节是这两个、page 和 column 是这么算的——它们说的是“送什么”,不是“怎么送”。
字模和这几个上层函数也一并放在这里——OLED_ShowChar()、OLED_ShowUnsigned()、OLED_Clear() 都在这一层,它们只认 OLED_SetCursor() 和 OLED_WriteData():
/* 6x8 字模:每个字符 6 列,每列一个字节,最低位是最上面那一行。
这里只摘了用到的几个字符,完整的 ASCII 表按同样格式 6 字节一组往后排。 */
static const uint8_t OLED_Font6x8[][6] =
{
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, /* ' ' */
{ 0x3E, 0x51, 0x49, 0x45, 0x3E, 0x00 }, /* '0' */
{ 0x00, 0x42, 0x7F, 0x40, 0x00, 0x00 }, /* '1' */
{ 0x42, 0x61, 0x51, 0x49, 0x46, 0x00 }, /* '2' */
{ 0x21, 0x41, 0x45, 0x4B, 0x31, 0x00 }, /* '3' */
{ 0x18, 0x14, 0x12, 0x7F, 0x10, 0x00 }, /* '4' */
{ 0x27, 0x45, 0x45, 0x45, 0x39, 0x00 }, /* '5' */
{ 0x3C, 0x4A, 0x49, 0x49, 0x30, 0x00 }, /* '6' */
{ 0x01, 0x71, 0x09, 0x05, 0x03, 0x00 }, /* '7' */
{ 0x36, 0x49, 0x49, 0x49, 0x36, 0x00 }, /* '8' */
{ 0x06, 0x49, 0x49, 0x29, 0x1E, 0x00 }, /* '9' */
{ 0x00, 0x7C, 0x12, 0x11, 0x12, 0x7C } /* 'A' */
};
/* 字符在表里的下标,没收录的字符当空格 */
static uint8_t OLED_FontIndex(char c)
{
if (c == ' ')
{
return 0U;
}
if ((c >= '0') && (c <= '9'))
{
return (uint8_t)(1U + (uint8_t)(c - '0'));
}
if (c == 'A')
{
return 11U;
}
return 0U;
}
static void OLED_ShowChar(uint8_t x, uint8_t y, char c)
{
const uint8_t *glyph = OLED_Font6x8[OLED_FontIndex(c)];
uint8_t i;
for (i = 0U; i < 6U; i++)
{
OLED_SetCursor((uint8_t)(y / 8U), (uint8_t)(x + i));
OLED_WriteData(glyph[i]);
}
}
static void OLED_ShowUnsigned(uint8_t x, uint8_t y, uint32_t value, uint8_t digits)
{
uint32_t divisor = 1U;
uint8_t i;
if (digits == 0U)
{
return;
}
for (i = 1U; i < digits; i++)
{
divisor *= 10U;
}
for (i = 0U; i < digits; i++)
{
OLED_ShowChar((uint8_t)(x + i * 6U), y,
(char)('0' + (value / divisor) % 10U));
divisor /= 10U;
}
}
static void OLED_Clear(void)
{
uint8_t page;
uint8_t column;
for (page = 0U; page < 8U; page++)
{
for (column = 0U; column < 128U; column++)
{
OLED_SetCursor(page, column);
OLED_WriteData(0x00U);
}
}
}
接缝就在一个函数上
把这两半分开看,接缝只有一行:
static uint8_t OLED_Write(uint8_t control, uint8_t data);
它的任务是:把“控制字节 + 一个数据字节”送到 7 位地址 0x3C 上去,成功返回 1。上面所有 OLED 函数都只跟它打交道;而它自己有两个版本,一个用软件 I2C,一个用硬件 I2C。
OLED_DrawPixel / OLED_ShowChar / OLED_ShowUnsigned ... ← 上层:决定送什么
|
OLED_Write(control, data) ← 接缝
|
软件 I2C(CPU 拨) 或者 硬件 I2C(外设拨) ← 下层:决定怎么送
两边不能同时接:同一组 PB8/PB9 只能有一个主人。选了硬件 I2C,就不要再去初始化软件 I2C 的那套 GPIO 配置。
第一版:让 CPU 自己拨
软件版直接沿用上一回那份最小软件 I2C。它的三个基本动作——SoftI2C_Start()、SoftI2C_SendByte()、SoftI2C_Stop()——每一拍都由程序亲手拨出来,所以:
#define OLED_I2C_ADDRESS 0x3CU /* 7 位地址,线上会左移一位变成 0x78 */
static uint8_t OLED_Write(uint8_t control, uint8_t data)
{
SoftI2C_Start();
if (SoftI2C_SendByte((uint8_t)(OLED_I2C_ADDRESS << 1)) == 0U)
{
SoftI2C_Stop();
return 0U;
}
if (SoftI2C_SendByte(control) == 0U)
{
SoftI2C_Stop();
return 0U;
}
if (SoftI2C_SendByte(data) == 0U)
{
SoftI2C_Stop();
return 0U;
}
SoftI2C_Stop();
return 1U;
}
每个 SoftI2C_SendByte() 的返回值就是那第 9 拍读到的应答:1 表示对方把线拉低了(ACK),0 表示没人理。所以这个函数不光负责送,还顺手把“送没送到”变成了一次可以判断的结果——上层保存它,串口就能打印出 oled ack failed 这类提示,而不是让屏幕安静地黑着。
跑一遍,白点出现。这一版的脾气是:每一次像素、每一个字节都是一段完整的小事务(起始、地址、控制字节、数据、停止),所以它很慢,但慢得随时可以打断点、随时能数拍子。
第二版:把拨线这件事交给外设
硬件版的思路是:引脚不再由我们的代码拨,而是交给 I2C1 这个外设——它自己产生时钟、自己发起始和停止、自己收发应答。代码里剩下的只是“发什么”和“等它做完”。
引脚要重映射。I2C1 默认在 PB6/PB7,重映射之后才落到我们一直用的 PB8/PB9:
#define OLED_I2C_ADDRESS 0x3CU
#define OLED_I2C_SPEED 400000U /* SSD1306 这一档支持 400kHz */
#define OLED_I2C_TIMEOUT 10000U /* 兜底:别永远等下去 */
static void OLED_I2cInit(void)
{
GPIO_InitTypeDef gpio;
I2C_InitTypeDef i2c;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);
GPIO_PinRemapConfig(GPIO_Remap_I2C1, ENABLE); /* I2C1 挪到 PB8/PB9 */
gpio.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9; /* PB8=SCL, PB9=SDA */
gpio.GPIO_Mode = GPIO_Mode_AF_OD; /* 复用开漏:交给 I2C1,仍然只能拉低 */
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &gpio);
i2c.I2C_Mode = I2C_Mode_I2C;
i2c.I2C_ClockSpeed = OLED_I2C_SPEED;
i2c.I2C_DutyCycle = I2C_DutyCycle_2;
i2c.I2C_OwnAddress1 = 0x00U; /* 只有从机模式才用得到,主机填 0 */
i2c.I2C_Ack = I2C_Ack_Enable;
i2c.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
I2C_Init(I2C1, &i2c);
I2C_Cmd(I2C1, ENABLE);
}
和软件版对照着看,有三处要停一下:
- 时钟开了两个:
GPIOB和AFIO在 APB2 上,I2C1在 APB1 上——外设挂在不同总线上,开时钟的函数就不是同一个; AFIO是干什么的:重映射属于“引脚复用”的一部分,不开它的时钟,GPIO_PinRemapConfig()不会起作用;- 引脚模式是
AF_OD而不是前面的Out_OD:两者都是开漏,区别是“谁来控制这个引脚”——AF_OD把它交给外设。
硬件外设不会自己等你,所以每走一步都要确认“上一个动作完成了没有”。等事件是这样的:
static uint8_t OLED_WaitEvent(uint32_t event)
{
uint32_t timeout = OLED_I2C_TIMEOUT;
while (I2C_CheckEvent(I2C1, event) != SUCCESS)
{
timeout--;
if (timeout == 0U)
{
return 0U; /* 等太久:不等了,报错给上层 */
}
}
return 1U;
}
I2C_CheckEvent() 一边看状态寄存器,一边把事件和期望值比对;它读 SR1 再读 SR2,所以顺带把 ADDR 这类标志也清掉了——它不是纯粹的“查询”,而是“确认并推进”。这也是硬件 I2C 编程最需要习惯的一点:你不是在拨线,而是在按顺序和一台机器对话。
OLED_I2C_TIMEOUT 这个数字不需要精确:它只是“别永远等下去”的兜底。真要计较,它对应的是循环次数而不是时间——一台 72MHz 的芯片上,一轮(一次 I2C_CheckEvent() 调用,几十个时钟周期)是几百纳秒的量级,10000 次超时对应的是几毫秒。
有了这两个工具,OLED_Write() 的硬件版就是把动作按顺序排开:
static uint8_t OLED_Write(uint8_t control, uint8_t data)
{
I2C_GenerateSTART(I2C1, ENABLE); /* 起始条件 */
if (OLED_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT) == 0U)
{
return 0U;
}
I2C_Send7bitAddress(I2C1, (uint8_t)(OLED_I2C_ADDRESS << 1),
I2C_Direction_Transmitter); /* 地址 + 写方向 */
if (OLED_WaitEvent(I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED) == 0U)
{
return 0U;
}
I2C_SendData(I2C1, control); /* 控制字节 */
if (OLED_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTING) == 0U)
{
return 0U;
}
I2C_SendData(I2C1, data); /* 显存数据 */
if (OLED_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED) == 0U)
{
return 0U;
}
I2C_GenerateSTOP(I2C1, ENABLE); /* 停止条件 */
return 1U;
}
I2C_Send7bitAddress() 要的是已经左移过的 8 位地址:0x3C << 1 = 0x78,方向位由它的最后一个参数填。这一点和上一回讲地址时的结论完全对得上——线上跑的从来不是那 7 位本身。
跑一遍,白点还是那个白点。上层一个字都没改。
两种送法摆在一起
| 软件 I2C | 硬件 I2C | |
|---|---|---|
| 谁产生时钟 | CPU,一位一位拨 | I2C1 外设 |
| 引脚模式 | GPIO_Mode_Out_OD |
GPIO_Mode_AF_OD |
| 引脚 | PB8/PB9 直接配 |
PB8/PB9,需要重映射 |
| 代码形态 | 时序都写在函数里,一眼看得见每一拍 | 按事件顺序发指令,等待由 I2C_CheckEvent() 完成 |
| 速率 | 由代码和主频决定(还随编译优化变化) | 由 I2C_ClockSpeed 设定,外设自己保证 |
| 占不占外设 | 不占外设,只占两个引脚和 CPU 时间 | 占用 I2C1 这个外设 |
| 出问题看什么 | 波形、每一拍、ACK 返回值 | 等到了哪个事件、BUSY 标志、超时 |
| 适合什么 | 想看清协议、设备少、速率要求低 | 长期使用、要稳定的速率、要省 CPU 时间 |
还有一张更有用的对照表——同一件事,两种写法各是哪一句:
| 事务里的动作 | 软件版 | 硬件版 |
|---|---|---|
| 发起始条件 | SoftI2C_Start() |
I2C_GenerateSTART() + 等 MASTER_MODE_SELECT |
| 送地址和方向 | SoftI2C_SendByte(0x78) |
I2C_Send7bitAddress() + 等 MASTER_TRANSMITTER_MODE_SELECTED |
| 看应答 | SoftI2C_SendByte() 的返回值 |
地址事件等不到就是没有应答 |
| 送一个字节 | SoftI2C_SendByte(control) |
I2C_SendData() + 等 MASTER_BYTE_TRANSMITTING |
| 确保最后一位发完 | 循环跑完就算发完 | 等 MASTER_BYTE_TRANSMITTED(比上一个事件更严) |
| 发停止条件 | SoftI2C_Stop() |
I2C_GenerateSTOP() |
看得出来,硬件版的每一句都在“下达一个指令然后等回执”,而软件版的每一句都在“亲手把一个电平摆上去”。协议没变,动作的粒度变了。
两个实验
实验一:两种实现,同一个现象
先用软件版:OLED_I2cInit() 那一段不接进来,OLED_Write() 用软件版,上电应该出现正中央那个白点。
再换成硬件版:把软件 I2C 的引脚初始化去掉,接上 OLED_I2cInit(),OLED_Write() 换成硬件版,重新烧录。
现象应该一模一样:白点在同一个位置。如果硬件版不亮而软件版亮,按顺序查三件事——AFIO 时钟开了没有、GPIO_Remap_I2C1 配了没有、引脚模式是不是 AF_OD。
实验二:量一次刷屏要多久
两种实现各跑一遍同样的动作:清屏,然后显示两行数字。
用那条 1ms 时间轴测时间,结果存进一个 uint32_t 变量 oled_refresh_ms:
uint32_t start = tick_ms;
OLED_Clear();
OLED_ShowUnsigned(0u, 0u, 1234u, 4u);
OLED_ShowUnsigned(0u, 16u, 5678u, 4u);
oled_refresh_ms = tick_ms - start;
把 oled_refresh_ms 显示在屏幕上,或者从串口打出来。硬件版会明显更快,原因有两层:一是时钟由外设产生、跑到设定好的 400kHz,二是省掉了每一次电平变化时那一串函数调用。
数字具体是多少,取决于 SoftI2C_Delay() 里的循环次数和你的主频——这正是前面说过的那件事:软件 I2C 的速率由代码决定,硬件 I2C 的速率由配置决定。 想让它更快,软件版要改循环次数,硬件版改一个 I2C_ClockSpeed。
硬件 I2C 的坑
BUSY 可能是忙的。 上电、复位、或者上一次传输中途断电之后,总线可能停在“忙”状态。动手之前先确认它空闲:
static uint8_t OLED_I2cReady(void)
{
uint32_t timeout = OLED_I2C_TIMEOUT;
while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY) != RESET)
{
timeout--;
if (timeout == 0U)
{
return 0U;
}
}
return 1U;
}
卡住了要能自救。 如果从机在某一拍把线拉住不放(主机中途复位、接线松动都可能造成),硬件外设会一直等,程序看起来就“死”在那一句上。等超时之后,做一次停止加外设复位,让状态机回到起点:
static void OLED_I2cRecover(void)
{
I2C_GenerateSTOP(I2C1, ENABLE);
I2C_SoftwareResetCmd(I2C1, ENABLE);
I2C_SoftwareResetCmd(I2C1, DISABLE);
OLED_I2cInit(); /* 复位会把配置清回默认,得重新配一次 */
}
事件顺序不能乱。 先发起始、再等 MASTER_MODE_SELECT,先送地址、再等 MASTER_TRANSMITTER_MODE_SELECTED——少等一步、或者提前把数据塞进 DR,状态机就会走到你没预料的地方。软件 I2C 里没有这类问题,因为顺序是你自己一行一行写出来的。
中断里别用它。 硬件版的每一次等待都是阻塞的,把它塞进中断服务函数里,就等于让中断去等一堆微秒级的事件——这正好是前面讲 DMA 时那条结论的另一个例子:中断里只做最必要的搬运,等待留给主循环。
读事务比写事务多两步。 这次只写不读,所以没遇到;等以后读陀螺仪的寄存器时会看到:收到最后一个字节之前要先关掉应答、并提前申请停止条件,读完再恢复。这不是本节的内容,但看到那段代码时不要以为写错了。
黑屏和乱码,按现场一层层查
供电和共地
只接 VCC 不接 GND,两根线没有共同参考,屏幕当然可能全黑。先量电压,再确认 STM32、OLED、电源共地。这个问题别靠改代码解决。
地址错误
把 OLED_I2C_ADDRESS 临时改成 0x3D,地址扫描会变空,串口提示 ACK 失败。波形仍然完整,只是第 9 个时钟没有低电平回应——总线在工作,设备没有接话。
控制字节错误
地址有应答,屏幕却整屏乱码。把 OLED_WriteData() 里的 0x40 改成 0x00,画面立刻不对。这里说明总线通了,设备语义没通。
page/column 错位
画面不是全黑,而是字符跑到顶部、右侧或者重叠在一起。这时候抓波形没有意义,直接检查 OLED_SetCursor() 发出的三条命令和调用参数。
换成硬件版之后才出现的故障
按这个顺序查:AFIO 时钟有没有开、重映射有没有配、引脚模式是 AF_OD 还是被写成了 Out_OD(写成 Out_OD 就是外设没接管,波形会怪)、I2C_Cmd() 有没有使能、BUSY 是不是一直挂着。
清屏为什么会拖很久
上面那版 OLED_Clear() 是逐字节发送的:每个字节都重新起始、发地址、发控制字节、发数据、停止。这样会产生很多段短事务,屏幕刷新自然显得慢。
这不是 SSD1306 要求的唯一写法。更高效的版本会一次起始之后连续发送大量数据,最后才停止;再往后还可以做显存缓冲,只在画面改变时整屏更新。
先用软件版把每一层都看清楚,再用硬件版把速度提上来,最后才考虑缓冲——顺序反过来做,出了问题就不知道是哪一层的事了。
应当看到的现象
程序上电后,屏幕正中央出现一个像素;把那一行换成 OLED_ShowChar(0u, 0u, 'A'),出现一个字母;换成 OLED_ShowUnsigned(0u, 0u, 1234u, 4u),出现四位数字。
把两种实现各跑一遍,现象完全相同。把地址改错,扫描为空、ACK 失败;把控制字节改错,地址仍应答但画面乱码;把 page 改错,字符移位。
把这些现象记在同一张纸上:
没有供电/共地 -> 可能完全没有可靠波形
地址错误 -> 波形完整但第 9 位 NACK
控制字节错误 -> 地址 ACK,画面语义错误
page/column 错误 -> 画面存在但位置错误
换成硬件版才出问题 -> 先看 AFIO/重映射/引脚模式/外设使能
以后 OLED 再黑屏,别从 OLED_ShowString() 开始乱改。先问电有没有到、线有没有共地、地址有没有 ACK、控制字节对不对、SSD1306 当前的 page 和 column 在哪里——你是在沿着一条真实的数据链路往下查,而不是对着一块沉默的屏幕碰运气。
最后橘猫说
从头到尾只换了 OLED_Write() 这一个函数,以及为它准备的初始化。上面那些 OLED_DrawPixel()、OLED_ShowChar()、OLED_ShowUnsigned() 一行都没动,SSD1306 的命令、page/column 的算法一行也没动。
原因就是开头那句:协议与实现是两件事。 I2C 这份约定没变——两根线、起始、7 位地址加方向、每字节一个应答、停止——变的只是“谁来执行它”。软件版让每一拍都留在代码里,硬件版把每一拍交给外设,两者在线上做的是同一件事。
先把约定看清楚,再决定用什么去实现它。同一条总线以后再挂上第二个设备(比如一颗陀螺仪),它们仍然共用这两个引脚、共用起始与停止,只靠从机地址区分彼此——换成硬件 I2C 之后,这个能力一点没少,反而更稳。
原文地址: https://www.cveoy.top/t/topic/qHUC 著作权归作者所有。请勿转载和采集!