前置知识:了解 CrossPoint Reader 的硬件组成(第 2 章),已搭建好开发环境(第 3 章)。 本章目标:理解嵌入式开发中三种最核心的硬件通信方式,理解 HAL 层设计思想,看懂设备自动识别的实现。

在嵌入式世界里,CPU 和外部设备(墨水屏、SD 卡、传感器……)之间需要”对话”。这种对话不是通过网线或蓝牙,而是通过 PCB 上的铜线——也就是引脚之间的物理连接。 本章介绍三种最常见的”对话方式”:GPIO(直接控制引脚电平)、SPI(高速串行总线)、I2C(低速多设备总线)。CrossPoint Reader 同时使用了这三种方式。

4.1 GPIO 基础

什么是 GPIO

GPIO 的全称是 General-Purpose Input/Output,翻译过来就是”通用输入输出引脚”。 ESP32-C3 有大约 22 个 GPIO 引脚。每个引脚本质上就是芯片上的一根”触手”,可以:
  • 输出:CPU 命令它输出高电平(3.3V)或低电平(0V)
  • 输入:CPU 读取它当前是高电平还是低电平
类比:GPIO 就像一个个”开关”。设为输出时,CPU 可以拨动开关;设为输入时,CPU 可以查看开关被外部拨到了哪一侧。

数字输出:控制 LED 和 MOSFET

最简单的 GPIO 用法是数字输出——让引脚输出高或低电平。
// 点亮一颗 LED
pinMode(LED_PIN, OUTPUT);       // 把引脚配置为"输出"模式
digitalWrite(LED_PIN, HIGH);    // 输出高电平 → LED 亮
digitalWrite(LED_PIN, LOW);     // 输出低电平 → LED 灭
在 CrossPoint Reader 中,GPIO 输出用来控制 MOSFET(一种电子开关)。比如,通过一个 GPIO 控制 MOSFET 来给墨水屏供电或断电——这比直接从 GPIO 引脚供电更安全,因为 GPIO 能提供的电流非常小(只有几毫安),而 MOSFET 可以”转接”更大的电流。
类比:GPIO 引脚就像一根手指,它能拨动一个继电器开关(MOSFET),而继电器开关控制着大功率电路。你的手指力气小,但可以拨动一个开关来控制整间房的灯。

数字输入:读按键

// 读取按键状态
pinMode(BUTTON_PIN, INPUT_PULLUP);     // 配置为输入,并启用内部上拉电阻
int state = digitalRead(BUTTON_PIN);   // 读取引脚电平
if (state == LOW) {
    // 按键被按下了(按下时引脚被拉到低电平)
}
这里的 INPUT_PULLUP 值得解释一下。如果一个输入引脚什么都不接,它的电平是不确定的(可能随机跳变,术语叫”浮空”)。上拉电阻的作用是:在没有按键按下时,把引脚”拉”到高电平,给它一个确定的默认状态。按键按下后,引脚被接到地(GND),电平变低,CPU 就知道”按键按下了”。

模拟输入:ADC 读电池电压

GPIO 不仅可以读”高/低”两种状态,有些引脚还可以读取模拟电压值——这就是 ADC(Analog-to-Digital Converter,模数转换器)
// 读取电池电压(简化示例)
int rawValue = analogRead(BATTERY_ADC_PIN);  // 返回 0~4095 的整数
float voltage = rawValue * 3.3 / 4095.0;    // 转换为实际电压值
ESP32-C3 的 ADC 是 12 位的,所以返回值范围是 0~4095(2^12 = 4096 个级别)。0 代表 0V,4095 代表 3.3V(参考电压)。 CrossPoint Reader 的 X4 版本就是通过 ADC 来检测电池电压的。不过 ADC 测量本身有一定误差,所以实际代码中通常会做多次采样取平均值。

中断:让硬件主动通知 CPU

前面的 digitalRead() 是一种轮询方式——CPU 不断地去查看按键状态。这就像你每隔几秒看一次手机有没有新消息,很浪费精力。 更高效的方式是中断:告诉硬件”当按键按下时,主动通知我”。
// 注册一个中断处理函数
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), onButtonPress, FALLING);

// 中断处理函数(ISR: Interrupt Service Routine)
void IRAM_ATTR onButtonPress() {
    // 按键按下时,硬件自动调用这个函数
    // 注意:ISR 里只能做非常简单的操作,比如设个标志位
    buttonPressed = true;
}
类比:轮询就像每隔五分钟去信箱看看有没有信;中断就像装了一个门铃——有信来了,邮递员按门铃通知你。
IRAM_ATTR 是一个特殊标记,告诉编译器”把这个函数放到 IRAM(内部 RAM)里”。因为中断发生时 Flash 可能正在被使用(比如正在读取数据),如果中断函数也存在 Flash 里就会冲突。放到 RAM 里就没这个问题了。 CrossPoint Reader 的按键检测就使用了中断方式,这样设备在深度睡眠时可以被按键唤醒——CPU 休眠时没法轮询,但中断可以把它”叫醒”。

4.2 SPI 总线

什么是 SPI

SPI 的全称是 Serial Peripheral Interface(串行外设接口)。它是一种高速的通信方式,适合传输大量数据——比如把一整屏画面数据发送给墨水屏,或者从 SD 卡读取一本电子书。 SPI 使用 4 根线:
线名全称方向作用
SCLKSerial Clock主机 → 从机时钟信号,“节拍器”
MOSIMaster Out, Slave In主机 → 从机主机发送数据给从机
MISOMaster In, Slave Out从机 → 主机从机发送数据给主机
CSChip Select主机 → 从机片选,选择跟哪个从机通信
类比:SPI 就像一套对讲机系统。SCLK 是统一的节拍(“一、二、三,发送!”),MOSI 是主机的麦克风,MISO 是主机的耳机,CS 是频道选择——你要先选好频道(拉低某个设备的 CS),对方才会听你说话。

为什么墨水屏和 SD 卡可以共享 SPI

CrossPoint Reader 上,墨水屏和 SD 卡共用同一组 SPI 总线(共享 SCLK、MOSI、MISO 三根线),只是各有各的 CS 引脚。
                ESP32-C3(主机)
                ┌──────────┐
                │     SCLK ├───────────┬──────────────┐
                │     MOSI ├──────┬────│──────────────┤
                │     MISO ├──────│────┤──────────────┤
                │          │      │    │              │
                │   CS_EPD ├──────│────┤              │
                │   CS_SD  ├──────│────│──────┐       │
                └──────────┘      │    │      │       │
                                  │    │      │       │
                            ┌─────┴────┴─┐ ┌──┴──────┴─┐
                            │  墨水屏     │ │  SD 卡     │
                            │  (EPD)     │ │            │
                            └────────────┘ └────────────┘
通信过程:
  1. 要和墨水屏通信:拉低 CS_EPD(选中墨水屏),保持 CS_SD 为高(SD 卡”睡觉”),然后通过 MOSI/MISO 交换数据。
  2. 要和 SD 卡通信:拉低 CS_SD(选中 SD 卡),保持 CS_EPD 为高(墨水屏”睡觉”),然后通过 MOSI/MISO 交换数据。
  3. 同一时刻只能和一个设备通信——CS 保证了”对话”的排他性。
类比:一条电话线上接了两部电话(墨水屏和 SD 卡)。你拨某个设备的”分机号”(拉低它的 CS),只有被叫的那部电话会响,另一部不理你。

SPI 的速度优势

SPI 通常可以跑到几十 MHz 的时钟频率。CrossPoint Reader 的墨水屏 SPI 时钟设为 20MHz 左右——这意味着每秒可以传输约 20Mbit 的数据。对于把一整帧画面(约 200KB)推送到墨水屏来说,绰绰有余。

4.3 I2C 总线

什么是 I2C

I2C 的全称是 Inter-Integrated Circuit(集成电路间通信),读作”I-squared-C”或”I-two-C”。它是一种低速但灵活的通信方式,只需要 2 根线
线名全称作用
SDASerial Data数据线(双向)
SCLSerial Clock时钟线
类比:如果 SPI 是”对讲机”(快但需要多根线),I2C 就像挂号信系统——只有一条邮路(SDA),但每封信上都写着收件人的地址。虽然慢一些,但一条线就能寄给很多人。

地址寻址:每个设备有唯一地址

I2C 总线上可以挂多个设备,每个设备有一个 7 位地址(0x00 ~ 0x7F)。主机(ESP32-C3)发送数据时,先发送目标地址,只有地址匹配的设备才会响应。 CrossPoint Reader(X3 版本)的 I2C 总线上挂了 3 个芯片:
                ESP32-C3(主机)
                ┌──────────┐
                │      SDA ├───────┬────────────┬────────────┐
                │      SCL ├───┬───│────────┬───│────────┬───│
                └──────────┘   │   │        │   │        │   │
                               │   │        │   │        │   │
                          ┌────┴───┴──┐ ┌───┴───┴──┐ ┌───┴───┴──┐
                          │ BQ27220   │ │ DS3231   │ │ QMI8658  │
                          │ 电量计    │ │ 实时时钟 │ │ 陀螺仪   │
                          │ 0x55     │ │ 0x68     │ │ 0x6B     │
                          └──────────┘ └──────────┘ └──────────┘
  • BQ27220(地址 0x55):电量计芯片,精确追踪电池的充电/放电状态
  • DS3231(地址 0x68):实时时钟(RTC),即使设备关机也能保持走时
  • QMI8658(地址 0x6B):六轴运动传感器(陀螺仪 + 加速度计),可以检测设备的姿态
通信示例:
#include <Wire.h>    // Arduino 的 I2C 库

Wire.begin(SDA_PIN, SCL_PIN);     // 初始化 I2C 总线
Wire.beginTransmission(0x55);     // "嗨,地址 0x55 的设备,听到请回答"
Wire.write(0x06);                 // 发送命令:"请告诉我电池电压"
Wire.endTransmission();           // 发送完毕
Wire.requestFrom(0x55, 2);       // "请发 2 个字节给我"
uint16_t voltage = Wire.read() | (Wire.read() << 8);  // 读取返回数据

SPI vs I2C 速查对比

特性SPII2C
线数4 根(SCLK+MOSI+MISO+CS)2 根(SDA+SCL)
速度快(通常 1~80 MHz)慢(通常 100~400 KHz)
设备数量每加一个设备多一根 CS 线靠地址区分,理论上 127 个
典型用途屏幕、SD 卡、Flash传感器、时钟、电量计
选谁需要传大量数据时数据量小但设备多时
总结:数据量大、速度要求高的设备(墨水屏、SD 卡)走 SPI;数据量小但设备多的(传感器、时钟)走 I2C。CrossPoint Reader 两种都用了,各司其职。

4.4 HAL 层设计思想

为什么要封装硬件细节

假设你要在屏幕上显示一行文字。最底层的操作是:
  1. 拉低墨水屏的 CS 引脚
  2. 通过 SPI 发送”设置显示窗口”命令
  3. 计算每个像素的位置
  4. 把像素数据一个字节一个字节地通过 SPI 发出去
  5. 发送”刷新屏幕”命令
  6. 拉高 CS 引脚
如果每次显示文字都要写这些代码,那整个项目会充满重复的硬件操作细节,难以阅读和维护。 HAL(Hardware Abstraction Layer,硬件抽象层) 的思想就是:把硬件细节藏起来,向上层提供简洁的接口。
// 没有 HAL 的写法:上层代码满是硬件细节
digitalWrite(CS_EPD, LOW);
SPI.transfer(0x24);          // 发送命令字节
for (int i = 0; i < bufferSize; i++) {
    SPI.transfer(frameBuffer[i]);
}
SPI.transfer(0x20);          // 刷新命令
digitalWrite(CS_EPD, HIGH);

// 有 HAL 的写法:上层代码清晰简洁
display.clearScreen();
display.drawText(10, 20, "Hello, CrossPoint!");
display.refresh();
类比:你开车时只需要踩油门、刹车、转方向盘。你不需要知道发动机的活塞怎么运动、刹车片怎么摩擦、转向柱怎么传动——这些细节被”封装”在了汽车的操控接口里。HAL 就是嵌入式开发中的”汽车操控接口”。

CrossPoint Reader 的 HAL 模块

CrossPoint Reader 将硬件操作封装为四个 HAL 模块:
┌─────────────────────────────────────────────────┐
│               应用层(阅读器功能)                 │
│         Activity、页面渲染、文件解析 ...           │
├────────────┬──────────┬────────────┬─────────────┤
│ HalDisplay │ HalGPIO  │HalPower   │ HalStorage  │
│ 墨水屏驱动 │ 引脚管理 │Manager    │ SD 卡/Flash │
│            │ 按键中断 │电池电源   │ 文件操作     │
├────────────┴──────────┴────────────┴─────────────┤
│          硬件(SPI、I2C、GPIO 引脚)              │
└─────────────────────────────────────────────────┘
  • HalDisplay:封装墨水屏的 SPI 通信。上层只需调用 clearScreen()drawPixel() 等函数,不需要知道底层的 SPI 命令序列。
  • HalGPIO:管理所有 GPIO 引脚的配置和中断注册。上层只需调用 isButtonPressed() 即可,不需要关心上拉电阻和中断配置。
  • HalPowerManager:封装电池电量检测(通过 I2C 读取 BQ27220 或通过 ADC 直接读取)和电源控制(MOSFET 开关)。
  • HalStorage:封装 SD 卡的 SPI 通信和文件系统操作。上层只需调用 readFile() / writeFile()
HAL 层还有一个重要好处:硬件更换时,只需要修改 HAL 层,上层代码不用动。 CrossPoint Reader 有 X3 和 X4 两个硬件版本,I2C 总线上的芯片不同、引脚分配也不同,但得益于 HAL 层的隔离,上层的阅读器功能代码完全一样

4.5 设备自动识别(X3 vs X4)

CrossPoint Reader 有两个硬件版本:
  • X3:I2C 总线上挂了 BQ27220(电量计)、DS3231(时钟)、QMI8658(陀螺仪)
  • X4:没有这些 I2C 芯片,用 ADC 读电池电压,用 NTP 网络对时
固件是同一套代码,设备开机后需要自动识别”我是 X3 还是 X4”。怎么做到的呢?

核心思路:I2C 探测

原理很简单——向 I2C 总线上的已知地址发送信号,看有没有设备应答。有应答说明芯片存在(X3),没有应答说明芯片不在(X4)。 这就像在一栋公寓楼里挨个敲门:如果 0x55 号门有人应答、0x68 号门有人应答、0x6B 号门有人应答——那这栋楼就是 X3 版本。如果敲了半天没人应,那就是 X4。

完整代码解析

// lib/hal/HalGPIO.cpp

// 单次探测:依次检查三个芯片是否存在
X3ProbeResult runX3ProbePass() {
    X3ProbeResult result;

    // 初始化 I2C 总线
    Wire.begin(X3_I2C_SDA, X3_I2C_SCL, X3_I2C_FREQ);
    Wire.setTimeOut(6);   // 超时时间 6ms,没有回应就放弃

    // 逐个探测
    result.bq27220 = probeBQ27220Signature();  // 敲 0x55 号门(电量计)
    result.ds3231  = probeDS3231Signature();   // 敲 0x68 号门(时钟)
    result.qmi8658 = probeQMI8658Signature();  // 敲 0x6B 号门(陀螺仪)

    Wire.end();   // 释放 I2C 总线
    return result;
}
每个 probeXXXSignature() 函数不只是简单地检查”有没有设备应答”,还会读取芯片的特征寄存器来验证身份——就像不只是看有没有人开门,还要核对身份证。

为什么要探测两轮

HalGPIO::DeviceType detectDeviceTypeWithFingerprint() {
    // 第一步:检查缓存(之前识别过的结果)
    const NvsDeviceValue cachedValue = readNvsDeviceValue(
        NVS_KEY_DEV_CACHED, NvsDeviceValue::Unknown);
    if (cachedValue == NvsDeviceValue::X3 || cachedValue == NvsDeviceValue::X4) {
        return nvsToDeviceType(cachedValue);  // 缓存命中,直接返回
    }

    // 第二步:两轮探测
    const X3ProbeResult pass1 = runX3ProbePass();  // 第一轮
    delay(2);                                       // 等 2 毫秒
    const X3ProbeResult pass2 = runX3ProbePass();  // 第二轮

    // 两轮都至少检测到 2 个芯片,才认定为 X3
    const bool x3Confirmed = (pass1.score() >= 2) && (pass2.score() >= 2);

    // 第三步:结果写入 NVS 缓存,下次开机直接使用
    // ...
}
为什么要两轮? 因为 I2C 通信可能受到电气噪声的干扰——偶尔一次探测可能会出现误判(明明有芯片却没检测到,或者明明没有芯片却误报存在)。连续探测两轮,两轮结果一致才算数,大大提高了可靠性。 这在嵌入式开发中是一种常见的策略:对硬件的判断不要只依赖一次测量,多测几次取共识。 为什么要缓存? 识别结果写入 NVS(Flash 上的非易失存储),下次开机直接读取缓存,跳过探测过程。这样做的好处有两个:
  1. 加快启动速度——探测 I2C 设备需要时间
  2. 避免极端情况下的误判——只在首次开机(或 NVS 被清除时)才做探测

score() 的含义

X3ProbeResult 有一个 score() 方法,返回”成功探测到的芯片数量”。阈值设为 2 表示:三个芯片中至少检测到两个就认定为 X3。之所以不要求全部三个都在,是为了容错——万一某个芯片偶尔接触不良,不至于把 X3 误判为 X4。

本章要点

  1. GPIO 是最基本的硬件通信方式。数字输出控制 LED/MOSFET,数字输入读取按键,模拟输入(ADC)读取电压,中断实现硬件事件的即时通知。
  2. SPI 是高速串行总线(4 根线),适合大数据量传输。墨水屏和 SD 卡共享 SPI 总线,通过各自的 CS 片选线区分”跟谁说话”。
  3. I2C 是低速双线总线,通过地址寻址,适合挂载多个低速传感器。CrossPoint Reader X3 的 I2C 总线上挂了电量计(0x55)、时钟(0x68)、陀螺仪(0x6B)。
  4. HAL 层将硬件操作细节封装为简洁的接口,让上层代码不依赖具体硬件。这使得同一套应用代码可以在不同硬件版本(X3/X4)上运行。
  5. 设备自动识别通过 I2C 探测实现:向已知地址发信号,有设备应答就是 X3,否则是 X4。两轮探测保证可靠性,结果缓存到 NVS 加快后续启动。
  6. 嵌入式开发的一条重要原则:不要依赖单次测量,多测几次取共识——硬件世界充满噪声和不确定性。