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 之后,这个能力一点没少,反而更稳。

posted @ 2026-11-08 14:08  Zw-awa  阅读(7)  评论(0)    收藏  举报