【嵌入式 C 学习】Day25:I²C 基础通信、设备地址与 ACK/NACK

一、前言

前两天学习了 SPI:

Day23:SPI 单字节全双工通信
Day24:SPI 寄存器读写协议

今天开始学习另一种常见的板级通信协议:I²C。

I²C 常用于连接:

温湿度传感器
EEPROM
RTC 实时时钟
OLED 显示屏
电流电压检测芯片
加速度计
陀螺仪
GPIO 扩展芯片

本次使用 C 语言模拟一个地址为 0x48 的 I²C 从机,实现:

START 和 STOP
7 位设备地址
读写地址字节
ACK 和 NACK
单字节写入
单字节读取
错误地址处理
错误通信顺序检查
通信次数统计

二、I²C 的两根信号线

I²C 主要使用两根线:

SCL:串行时钟线
SDA:串行数据线

SCL 通常由主机产生,用于提供通信时钟。

SDA 是双向数据线,可以完成:

主机向从机发送数据
从机向主机发送数据

由于发送和接收共用 SDA,I²C 通常属于半双工通信。


三、为什么 I²C 需要上拉电阻

I²C 的 SCL 和 SDA 通常采用开漏输出。

开漏输出只能:

主动把线路拉低
释放线路

不能像普通推挽 GPIO 一样主动输出高电平。

因此需要外部上拉电阻:

              VCC
               |
            上拉电阻
               |
主机 SDA ------+------ 从机 SDA

当所有设备都释放 SDA 时,上拉电阻使线路变为高电平。

当任意一个设备拉低 SDA 时,总线变为低电平。

所以:

输出 0:主动拉低
输出 1:释放总线

这种多个设备共用线路的逻辑称为:

线与
Wired-AND

四、I²C 总线空闲状态

总线空闲时:

SCL = HIGH
SDA = HIGH

此时没有设备拉低线路。

在本次软件模拟中,使用:

bus.started = 0U;

表示总线处于空闲状态。


五、START 起始信号

START 的定义是:

SCL 保持高电平时
SDA 从高电平变为低电平

简化时序:

SCL:──────── HIGH ────────

SDA:──── HIGH ──┐
                 └── LOW ──
                 ↑
               START

START 表示一段新的 I²C 事务开始。

程序接口:

int i2c_start(I2cBus *bus);

成功后:

started = 1
addressed = 0
start_count + 1

六、STOP 停止信号

STOP 的定义是:

SCL 保持高电平时
SDA 从低电平变为高电平

简化时序:

SCL:──────── HIGH ────────

SDA:──── LOW ───┐
                 └── HIGH ─
                 ↑
                STOP

STOP 表示当前事务结束,总线恢复为空闲状态。

程序接口:

int i2c_stop(I2cBus *bus);

成功后:

started = 0
addressed = 0
stop_count + 1

七、I²C 如何选择从机

SPI 通常使用不同的 CS 引脚选择从机:

CS_FLASH
CS_SENSOR
CS_DISPLAY

I²C 则通过设备地址选择从机。

例如:

温度传感器:0x48
EEPROM:0x50
RTC:0x68

多个设备可以共用同一组:

SCL
SDA

主机发送地址后,只有地址匹配的从机返回 ACK。

核心区别:

SPI 使用 CS 选择设备
I²C 使用设备地址选择设备

八、7 位设备地址

本次模拟设备地址:

#define I2C_DEVICE_DEFAULT_ADDRESS 0x48U

0x48 是一个 7 位设备地址。

7 位地址的数值范围为:

0x00~0x7F

需要注意,真实 I²C 规范中有部分保留地址,因此实际使用时还需要查看芯片数据手册。


九、地址字节的组成

I²C 设备地址是 7 位,但总线上发送的是 8 位地址字节。

结构:

bit7~bit1:7 位设备地址
bit0:读写方向位

即:

地址字节 = 7 位地址左移 1 位 | 读写方向

程序使用:

typedef enum
{
    I2C_DIRECTION_WRITE = 0,
    I2C_DIRECTION_READ = 1
} I2cDirection;

十、地址 0x48 的写地址字节

设备地址:

0x48

左移一位:

0x48 << 1 = 0x90

写方向为 0:

0x90 | 0 = 0x90

因此:

0x90

表示:

访问设备 0x48,并进行写操作

十一、地址 0x48 的读地址字节

设备地址左移:

0x48 << 1 = 0x90

读方向为 1:

0x90 | 1 = 0x91

因此:

0x91

表示:

访问设备 0x48,并进行读操作

需要区分:

0x48:7 位设备地址
0x90:写地址字节
0x91:读地址字节

程序中通常保存 0x48,发送时再生成 0x900x91


十二、ACK 应答

ACK 全称:

Acknowledge

I²C 每发送完 8 位后,第 9 个时钟用于应答。

ACK 时,接收方拉低 SDA:

SDA = 0

表示:

当前字节已经正确接收
可以继续通信

程序中定义:

typedef enum
{
    I2C_RESPONSE_ACK = 0,
    I2C_RESPONSE_NACK = 1
} I2cResponse;

十三、NACK 非应答

NACK 时,接收方不拉低 SDA:

SDA = 1

NACK 可能表示:

没有设备响应该地址
从机拒绝接收数据
主机不再继续读取
通信过程发生异常

因此 NACK 不一定表示错误,需要结合出现的位置判断。


十四、单字节写入流程

本次向设备写入:

0xA5

完整流程:

START
    ↓
发送地址 0x48 + WRITE
    ↓
从机 ACK
    ↓
发送数据 0xA5
    ↓
从机 ACK
    ↓
STOP

总线上的字节:

地址字节:0x90
数据字节:0xA5

程序调用:

i2c_start(&bus);

i2c_send_address(
    &bus,
    &device,
    0x48U,
    I2C_DIRECTION_WRITE,
    &response);

i2c_write_byte(
    &bus,
    &device,
    0xA5U,
    &response);

i2c_stop(&bus);

写入后:

stored_data = 0xA5

十五、单字节读取流程

本次从机准备返回:

0x5A

完整流程:

START
    ↓
发送地址 0x48 + READ
    ↓
从机 ACK
    ↓
从机发送 0x5A
    ↓
主机发送 NACK
    ↓
STOP

总线上的地址字节:

0x91

程序调用:

i2c_start(&bus);

i2c_send_address(
    &bus,
    &device,
    0x48U,
    I2C_DIRECTION_READ,
    &response);

i2c_read_byte(
    &bus,
    &device,
    &read_data,
    I2C_RESPONSE_NACK);

i2c_stop(&bus);

十六、为什么单字节读取后发送 NACK

读取数据时:

从机是发送方
主机是接收方

如果主机还要继续读取,主机发送 ACK。

如果当前已经是最后一个字节,主机发送 NACK。

所以单字节读取时:

读取 0x5A
主机发送 NACK
STOP

这里的 NACK 表示:

数据已经收到
不再继续读取

属于正常协议行为。


十七、I²C 总线结构体

本次定义:

typedef struct
{
    uint32_t clock_hz;

    uint8_t started;
    uint8_t addressed;
    uint8_t current_address;

    I2cDirection direction;

    uint32_t start_count;
    uint32_t stop_count;
    uint32_t ack_count;
    uint32_t nack_count;
    uint32_t byte_count;
} I2cBus;

其中:

clock_hz:I²C 时钟频率
started:是否已经产生 START
addressed:是否有从机响应地址
current_address:当前 7 位地址
direction:当前读写方向
start_count:START 次数
stop_count:STOP 次数
ack_count:ACK 次数
nack_count:NACK 次数
byte_count:8 位字段数量

十八、模拟从机结构体

定义:

typedef struct
{
    uint8_t address;
    uint8_t stored_data;
    uint8_t read_data;
} I2cDevice;

含义:

address:设备的 7 位地址
stored_data:保存主机写入的数据
read_data:从机准备返回的数据

初始化后:

address = 0x48
stored_data = 0x00
read_data = 0x5A

十九、函数返回值和 NACK 的区别

i2c_send_address() 同时具有:

函数返回值
response 参数

函数返回值表示:

参数是否正确
是否已经 START
方向是否合法
地址是否合法

response 表示协议层应答:

ACK
NACK

例如访问错误地址时:

地址字节正常发送
从机没有响应
response = NACK
函数仍返回成功

因此:

协议 NACK 不等于 C 函数执行失败

二十、错误地址测试

模拟从机地址:

0x48

主机故意访问:

0x50

写地址字节:

0x50 << 1 = 0xA0

因为没有地址为 0x50 的设备,所以得到 NACK:

Address 0x50 -> NACK

地址阶段收到 NACK 后,再继续写数据会被驱动拒绝。

设备原来的数据仍然保持:

0xA5

二十一、错误通信顺序测试

程序测试了以下错误:

没有 START 就发送地址
没有 START 就发送 STOP
事务中再次 START
没有发送地址就写数据
READ 方向调用写函数
WRITE 方向调用读函数

这些调用都被正确拒绝。

Day25 暂时不支持 Repeated START,所以下列代码第二次调用失败:

i2c_start(&bus);
i2c_start(&bus);

Day26 将专门学习 Repeated START。


二十二、实际运行结果

执行:

cd /root/Embedded_14Days/day25

make clean
make
make run

运行结果:

===== Day25 I2C Basic Communication Test =====
[I2C CONFIG] Clock = 100000 Hz
[DEVICE CONFIG] 7-bit address = 0x48
[DEVICE CONFIG] Read data = 0x5A

----- Test 1: Address Byte Build -----
[ADDRESS] 7-bit device address = 0x48
[ADDRESS] Write address byte   = 0x90
[ADDRESS] Read address byte    = 0x91
[CHECK] Address bytes are correct
[CHECK] Write byte bit0 = 0
[CHECK] Read byte bit0 = 1

----- Test 2: Single Byte Write -----
[I2C] START
[I2C] Address byte 0x90 -> ACK
[I2C] Write data 0xA5 -> ACK
[I2C] STOP
[DEVICE] Stored data = 0xA5
[CHECK] Single byte write verified

----- Test 3: Single Byte Read -----
[I2C] START
[I2C] Address byte 0x91 -> ACK
[I2C] Read data 0x5A
[I2C] Master response -> NACK
[I2C] STOP
[CHECK] Single byte read verified

----- Test 4: Wrong Device Address -----
[I2C] START
[I2C] Address 0x50 (byte 0xA0) -> NACK
[EXPECTED] Wrong address was not acknowledged
[EXPECTED ERROR] Data write rejected after address NACK
[I2C] STOP
[CHECK] Stored data unchanged: 0xA5

----- Test 5: Invalid Sequences -----
[EXPECTED ERROR] Address rejected before START
[EXPECTED ERROR] STOP rejected before START
[I2C TEST] First START succeeded
[EXPECTED ERROR] Repeated START rejected
[EXPECTED ERROR] Write rejected before address
[EXPECTED ERROR] Write rejected in READ direction
[EXPECTED ERROR] Read rejected in WRITE direction
[CHECK] Invalid sequence test completed

----- Test 6: Invalid Parameters -----
[EXPECTED ERROR] NULL bus rejected
[EXPECTED ERROR] Zero clock rejected
[EXPECTED ERROR] NULL device rejected
[EXPECTED ERROR] Invalid direction rejected
[EXPECTED ERROR] NULL address output rejected

----- Final I2C Status -----
[FINAL] START count = 3
[FINAL] STOP count  = 3
[FINAL] ACK count   = 3
[FINAL] NACK count  = 2
[FINAL] Byte count  = 5
[FINAL] Bus started = NO
[FINAL] Addressed   = NO
[FINAL] Stored data = 0xA5
[FINAL CHECK] All I2C tests passed

===== I2C Test Finished =====

编译过程中没有出现:

warning
error

二十三、最终计数分析

单字节写入

START:1
STOP:1
地址字节:1
数据字节:1
ACK:2

单字节读取

START:1
STOP:1
地址字节:1
数据字节:1
ACK:1
NACK:1

错误地址

START:1
STOP:1
地址字节:1
NACK:1

最终:

START = 3
STOP  = 3
ACK   = 3
NACK  = 2
BYTE  = 5

ACK 和 NACK 使用第 9 个时钟周期,不属于新的 8 位数据字节,因此不计入 byte_count


二十四、SPI 与 I²C 对比

项目 SPI I²C
信号线 SCK、MOSI、MISO、CS SCL、SDA
设备选择 CS 设备地址
数据方向 MOSI/MISO 分开 SDA 双向
通信方式 通常全双工 通常半双工
应答机制 由设备协议决定 ACK/NACK
上拉电阻 通常不需要 通常需要
多设备连接 每个从机通常需要 CS 多设备共用两根线
通信速度 通常较高 通常较低

最重要的是:

SPI 使用 CS 选设备
I²C 使用地址选设备

二十五、今日收获

通过 Day25 学习,我掌握了:

  1. I²C 的基本特点;
  2. SCL 和 SDA 的作用;
  3. 开漏输出;
  4. 上拉电阻;
  5. 总线空闲状态;
  6. START 和 STOP;
  7. 7 位设备地址;
  8. 地址字节组成;
  9. 读写方向位;
  10. 0x480x900x91 的区别;
  11. ACK 和 NACK;
  12. 地址阶段应答;
  13. 写数据阶段应答;
  14. 单字节读取结束 NACK;
  15. 单字节写入流程;
  16. 单字节读取流程;
  17. 错误地址处理;
  18. 通信顺序检查;
  19. 函数失败和协议 NACK 的区别;
  20. I²C 状态与通信次数统计。

二十六、下一步计划

Day26 将继续学习:

I²C 寄存器读写协议
Repeated START
连续多字节读取

典型 I²C 寄存器读取流程:

START
设备地址 + WRITE
寄存器地址
Repeated START
设备地址 + READ
读取寄存器数据
NACK
STOP

重点理解:

为什么读取 I²C 寄存器时
通常需要先写寄存器地址
再切换到读取方向

duhong2026

【嵌入式 C 学习】Day26:I²C 寄存器读写、Repeated START 与连续多字节读取

一、前言

Day25 学习了 I²C 基础通信,包括:

SCL 和 SDA
START 与 STOP
7 位设备地址
读写方向位
ACK 与 NACK
单字节写入
单字节读取

但真实的 I²C 传感器通常不仅包含一个数据字节,而是包含多个内部寄存器,例如:

设备 ID
状态寄存器
温度数据
配置寄存器
故障标志
中断状态

主机不仅要知道设备地址,还要告诉设备:

我要访问哪个寄存器

因此,Day26 使用 C 语言模拟了一个带寄存器的 I²C 温度传感器,实现:

设备寄存器表
寄存器指针
Repeated START
单寄存器读取
配置寄存器写入
连续多字节读取
寄存器指针自动递增
温度高低字节组合
只读寄存器保护
非法寄存器地址保护
总线忙状态保护
事务失败恢复

二、为什么直接发送读地址还不够

假设 I²C 设备地址是:

0x48

主机发送:

START
地址 0x48 + READ

只能告诉设备:

我要从地址为 0x48 的设备读取数据

但设备内部有多个寄存器:

0x00:设备 ID
0x01:状态
0x02:温度高字节
0x03:温度低字节
0x04:配置

从机无法仅根据读地址判断主机想读取哪个寄存器。

因此,读取内部寄存器时通常要先发送寄存器地址。


三、典型 I²C 寄存器读取流程

读取设备内部寄存器的典型流程是:

START
    ↓
设备地址 + WRITE
    ↓
从机 ACK
    ↓
发送寄存器地址
    ↓
从机 ACK
    ↓
Repeated START
    ↓
设备地址 + READ
    ↓
从机 ACK
    ↓
读取寄存器数据
    ↓
主机 NACK
    ↓
STOP

例如读取设备 ID 寄存器 0x00

START
TX 0x90
RX ACK

TX 0x00
RX ACK

Repeated START
TX 0x91
RX ACK

RX 0x42
TX NACK

STOP

其中:

0x90:地址 0x48 + WRITE
0x00:设备 ID 寄存器地址
0x91:地址 0x48 + READ
0x42:设备 ID 数据

四、为什么先使用 WRITE 方向

读取寄存器之前,主机需要先把寄存器地址发送给从机。

例如:

0x02

表示主机准备读取:

TEMP_HIGH

虽然最终目标是读取数据,但发送寄存器地址这一步仍然是:

主机向从机发送数据

因此必须先使用:

设备地址 + WRITE

五、什么是寄存器指针

设备内部定义:

uint8_t register_pointer;
uint8_t register_pointer_valid;

register_pointer 表示当前准备访问的寄存器位置。

例如主机发送:

0x02

设备保存:

device->register_pointer = 0x02U;
device->register_pointer_valid = 1U;

之后进入 READ 方向时,从机从:

device->registers[
    device->register_pointer]

返回数据。

此时读取结果是:

registers[0x02] = 0x09

六、为什么需要 register_pointer_valid

设备刚完成初始化时,主机还没有指定寄存器地址。

此时:

register_pointer_valid = 0U;

表示:

当前寄存器指针不能用于读取

主机发送合法寄存器地址后:

register_pointer_valid = 1U;

这样可以阻止错误流程:

没有设置寄存器地址
直接从设备读取数据

否则设备无法判断应该返回哪个寄存器。


七、什么是 Repeated START

Repeated START 中文称为:

重复起始信号

普通 START 发生在总线空闲时:

started = 0

Repeated START 发生在事务尚未通过 STOP 结束时:

started = 1

Day26 新增接口:

int i2c_repeated_start(
    I2cBus *bus);

调用成功后:

started 保持为 1
addressed 清零
current_address 清零
等待重新发送设备地址
repeated_start_count 加 1

八、普通 START 与 Repeated START 的区别

普通 START:

总线原来处于空闲状态
开始一个全新的事务

Repeated START:

当前事务尚未结束
主机不发送 STOP
直接重新产生 START

调用条件分别是:

i2c_start(&bus);

要求:

started = 0

而:

i2c_repeated_start(&bus);

要求:

started = 1

九、为什么 Repeated START 后要重新发送地址

在寄存器读取第一阶段:

START
设备地址 + WRITE
寄存器地址

此时总线方向为:

WRITE

接下来主机准备读取数据,因此需要切换为:

READ

Repeated START 后必须重新发送:

设备地址 + READ

因此代码会清除旧状态:

bus->addressed = 0U;
bus->current_address = 0U;

然后重新调用:

i2c_send_address(
    bus,
    device,
    address,
    I2C_DIRECTION_READ,
    &response);

十、为什么不用 STOP 再 START

也可以设计为:

START
设备地址 + WRITE
寄存器地址
STOP

START
设备地址 + READ
数据
STOP

但更常见的是:

START
设备地址 + WRITE
寄存器地址
Repeated START
设备地址 + READ
数据
STOP

Repeated START 的优势:

整个寄存器读取保持为一个连续事务
中间不会释放总线
其他主机不能插入通信
寄存器指针状态不容易被破坏
符合许多设备数据手册规定的时序

十一、本次模拟的设备寄存器

Day26 模拟一个地址为 0x48 的温度传感器。

地址 寄存器 初始值 权限
0x00 DEVICE_ID 0x42 只读
0x01 STATUS 0x01 只读
0x02 TEMP_HIGH 0x09 只读
0x03 TEMP_LOW 0xE6 只读
0x04 CONFIG 0x00 可读写

代码定义:

#define I2C_DEVICE_REG_ID           0x00U
#define I2C_DEVICE_REG_STATUS       0x01U
#define I2C_DEVICE_REG_TEMP_HIGH    0x02U
#define I2C_DEVICE_REG_TEMP_LOW     0x03U
#define I2C_DEVICE_REG_CONFIG       0x04U

十二、设备 ID 读取

设备 ID:

#define I2C_DEVICE_EXPECTED_ID 0x42U

读取流程:

START
0x90
ACK
0x00
ACK
Repeated START
0x91
ACK
0x42
NACK
STOP

程序调用:

i2c_register_device_read_register(
    &bus,
    &device,
    I2C_DEVICE_REG_ID,
    &device_id);

运行结果:

[DEVICE] ID register = 0x42
[CHECK] Device ID matched

设备 ID 常用于验证:

设备地址是否正确
通信是否正常
芯片型号是否匹配
驱动是否访问了正确设备

十三、状态寄存器读取

状态寄存器:

0x01

本次定义 bit0 为温度数据就绪标志:

#define I2C_DEVICE_STATUS_READY 0x01U

检查代码:

if((status &
    I2C_DEVICE_STATUS_READY) != 0U)
{
    /* 温度数据已经准备完成 */
}

运行结果:

[DEVICE] Status register = 0x01
[CHECK] Temperature data is ready

十四、配置寄存器写入流程

CONFIG 寄存器地址:

0x04

写入数据:

0xA5

完整流程:

START
    ↓
设备地址 + WRITE
    ↓
ACK
    ↓
寄存器地址 0x04
    ↓
ACK
    ↓
配置数据 0xA5
    ↓
ACK
    ↓
STOP

总线上的三个字节:

0x90
0x04
0xA5

程序调用:

i2c_register_device_write_register(
    &bus,
    &device,
    I2C_DEVICE_REG_CONFIG,
    0xA5U);

运行结果:

[CONFIG] Before write = 0x00
[CONFIG] Write value  = 0xA5
[CONFIG] After write  = 0xA5
[CHECK] Configuration write verified

十五、为什么写事务完成后才更新寄存器

程序不是在收到数据 0xA5 后立即修改寄存器,而是在完整事务成功后才执行:

device->registers[
    register_address] =
    register_value;

需要保证以下步骤全部成功:

START 成功
设备地址发送成功
地址 ACK
寄存器地址发送成功
寄存器地址 ACK
数据发送成功
数据 ACK
STOP 成功

这样可以避免:

I²C 传输失败
但软件中的配置寄存器已经被错误修改

十六、连续多字节读取

温度原始值占两个寄存器:

0x02:TEMP_HIGH
0x03:TEMP_LOW

连续读取过程:

START
    ↓
地址 + WRITE
    ↓
寄存器地址 0x02
    ↓
Repeated START
    ↓
地址 + READ
    ↓
读取高字节 0x09
    ↓
主机 ACK
    ↓
读取低字节 0xE6
    ↓
主机 NACK
    ↓
STOP

程序调用:

i2c_register_device_read_temperature(
    &bus,
    &device,
    &temperature_raw,
    &temperature_c);

十七、中间字节为什么发送 ACK

读取第一个温度字节:

TEMP_HIGH = 0x09

由于后面还需要继续读取:

TEMP_LOW

所以主机发送:

ACK

表示:

这个字节已经收到
请继续发送下一个字节

十八、最后一个字节为什么发送 NACK

读取第二个字节:

TEMP_LOW = 0xE6

它是最后一个字节,所以主机发送:

NACK

表示:

当前字节已经收到
本次读取结束
不要再继续发送

之后主机发送 STOP。

因此:

中间字节后发送 ACK
最后字节后发送 NACK

十九、连续读取判断代码

核心逻辑:

if((index + 1U) < length)
{
    master_response =
        I2C_RESPONSE_ACK;
}
else
{
    master_response =
        I2C_RESPONSE_NACK;
}

假设:

length = 2

第一次循环:

index = 0
index + 1 = 1
1 < 2
发送 ACK

第二次循环:

index = 1
index + 1 = 2
2 < 2 不成立
发送 NACK

二十、寄存器指针自动递增

读取第一个字节前:

register_pointer = 0x02

设备返回:

registers[0x02] = 0x09

之后执行:

device->register_pointer++;

此时:

register_pointer = 0x03

下一次读取返回:

registers[0x03] = 0xE6

这种机制称为:

Auto Increment
寄存器地址自动递增

真实设备是否支持自动递增,需要查看芯片数据手册。


二十一、温度高低字节拆分

默认温度原始值:

#define I2C_DEVICE_DEFAULT_TEMP_RAW 2534U

十六进制:

2534 = 0x09E6

高字节提取:

(uint8_t)(
    (temperature_raw >> 8) &
    0x00FFU);

结果:

0x09

低字节提取:

(uint8_t)(
    temperature_raw &
    0x00FFU);

结果:

0xE6

二十二、温度高低字节组合

读取结果:

high_byte = 0x09
low_byte  = 0xE6

组合代码:

temperature_raw =
    (uint16_t)(
        ((uint16_t)high_byte << 8) |
        (uint16_t)low_byte);

计算:

0x09 << 8 = 0x0900
0x0900 | 0x00E6 = 0x09E6

得到:

temperature_raw = 2534

二十三、温度换算

本次规定:

一个原始单位 = 0.01℃

代码:

temperature_c =
    (float)temperature_raw /
    100.0f;

计算:

2534 ÷ 100.0 = 25.34℃

运行结果:

[TEMPERATURE] High byte = 0x09
[TEMPERATURE] Low byte  = 0xE6
[TEMPERATURE] Raw value = 2534
[TEMPERATURE] Celsius   = 25.34 C

二十四、I²C 设备结构体设计

Day26 定义:

typedef struct
{
    I2cDevice bus_device;

    uint8_t registers[
        I2C_DEVICE_REGISTER_COUNT];

    uint8_t register_pointer;
    uint8_t register_pointer_valid;

    uint32_t register_read_count;
    uint32_t register_write_count;
} I2cRegisterDevice;

各成员作用:

bus_device:
复用基础 I²C 地址和单字节通信功能

registers:
模拟设备内部寄存器

register_pointer:
当前寄存器位置

register_pointer_valid:
寄存器指针是否有效

register_read_count:
成功读取的寄存器数量

register_write_count:
成功写入的寄存器数量

二十五、为什么复用 Day25 的 I2cDevice

Day25 已经定义:

typedef struct
{
    uint8_t address;
    uint8_t stored_data;
    uint8_t read_data;
} I2cDevice;

它已经能够完成:

设备地址匹配
地址 ACK/NACK
单字节发送
单字节接收

Day26 通过:

I2cDevice bus_device;

复用这些能力,再增加寄存器功能。

这样不需要把底层地址和 ACK/NACK 逻辑重新写一遍。


二十六、单寄存器读取复用连续读取接口

单寄存器读取函数:

int i2c_register_device_read_register(
    I2cBus *bus,
    I2cRegisterDevice *device,
    uint8_t register_address,
    uint8_t *register_value);

内部直接调用:

return i2c_register_device_read_registers(
    bus,
    device,
    register_address,
    register_value,
    1U);

也就是:

单寄存器读取
等于
连续读取一个字节

这种写法减少了重复代码。


二十七、连续读取范围检查

当前合法寄存器范围:

0x00~0x04

例如从 0x03 开始读取 3 个字节:

0x03
0x04
0x05

其中 0x05 越界。

程序计算剩余寄存器数量:

remaining_registers =
    I2C_DEVICE_REGISTER_COUNT -
    start_register;

然后判断:

if(length > remaining_registers)
{
    return 0;
}

非法操作在产生 START 前被拒绝,因此不会改变通信计数。


二十八、只读寄存器保护

当前只有:

CONFIG,地址 0x04

允许写入。

权限检查:

int i2c_device_register_is_writable(
    uint8_t register_address)
{
    if(register_address ==
       I2C_DEVICE_REG_CONFIG)
    {
        return 1;
    }

    return 0;
}

以下寄存器不能写:

DEVICE_ID
STATUS
TEMP_HIGH
TEMP_LOW

程序尝试向 DEVICE_ID 写入 0x99 时,操作被拒绝。

设备 ID 仍然保持:

0x42

二十九、非法操作保护

本次测试了:

访问非法寄存器 0x20
连续读取越界
写入只读 DEVICE_ID
读取长度为 0
总线忙时开始寄存器读取

运行结果:

[EXPECTED ERROR] Invalid register 0x20 rejected
[EXPECTED ERROR] Out-of-range continuous read rejected
[EXPECTED ERROR] Read-only device ID write rejected
[EXPECTED ERROR] Zero-length read rejected
[EXPECTED ERROR] Register read rejected while bus busy

三十、失败操作为什么不改变计数

参数、地址和权限都在 START 前检查。

因此失败操作不会:

产生 START
产生 Repeated START
产生 STOP
增加 ACK 或 NACK
增加字节计数
增加寄存器读写计数
修改设备寄存器

运行结果:

[CHECK] All counters unchanged

三十一、总线忙状态保护

完整设备事务要求总线起始状态为空闲:

if(bus->started != 0U)
{
    return 0;
}

测试中先手动调用:

i2c_start(&test_bus);

模拟总线正在被其他操作占用。

此时设备读取被拒绝:

[EXPECTED ERROR] Register read rejected while bus busy

最后调用 STOP 恢复总线空闲。


三十二、失败事务恢复

设备层定义内部函数:

static void
i2c_register_device_abort_transaction(
    I2cBus *bus,
    I2cRegisterDevice *device);

当事务执行到一半失败时:

将寄存器指针设为无效
如果事务已经开始,则尝试发送 STOP

目的:

避免总线一直处于忙状态
避免后续事务全部失败
避免继续使用错误寄存器指针

三十三、为什么辅助函数使用 static

定义:

static void
i2c_register_device_abort_transaction(...);

static 表示该函数只能在:

i2c_device.c

内部使用。

它属于设备驱动内部实现,不需要暴露给:

main.c
其他模块

三十四、软件分层

Day26 将 I²C 工程分为三层:

main.c
应用测试层
        ↓
i2c_device.c
设备寄存器协议层
        ↓
i2c.c
I²C 总线层

main.c

负责:

初始化总线和设备
执行设备测试
打印结果
检查最终统计

i2c_device.c

负责:

设备寄存器表
寄存器地址
寄存器指针
寄存器读写权限
Repeated START 读取流程
连续多字节读取
温度处理

i2c.c

负责:

START
Repeated START
STOP
设备地址发送
读写方向
ACK/NACK
单字节收发
总线状态和统计

三十五、实际运行结果

执行:

cd /root/Embedded_14Days/day26

make clean
make
make run

运行结果:

===== Day26 I2C Register Protocol Test =====
[I2C CONFIG] Clock = 100000 Hz
[DEVICE CONFIG] 7-bit address = 0x48
[DEVICE CONFIG] Register count = 5
[DEVICE CONFIG] Expected ID = 0x42

----- Test 1: Repeated START State -----
[EXPECTED ERROR] Repeated START rejected on idle bus
[I2C] Normal START generated
[I2C] Repeated START generated
[STATE] Bus started = YES
[STATE] Addressed   = NO
[CHECK] Repeated START state is correct
[I2C] STOP generated
[CHECK] Temporary bus returned to idle

----- Test 2: Device Information -----
[DEVICE] ID register     = 0x42
[CHECK] Device ID matched
[DEVICE] Status register = 0x01
[CHECK] Temperature data is ready
[COUNT] Register reads = 2
[COUNT] Repeated START = 2

----- Test 3: Continuous Temperature Read -----
[TEMPERATURE] High byte = 0x09
[TEMPERATURE] Low byte  = 0xE6
[TEMPERATURE] Raw value = 2534
[TEMPERATURE] Celsius   = 25.34 C
[CHECK] Temperature value matched
[COUNT] Register reads = 4
[COUNT] ACK count      = 10
[COUNT] NACK count     = 3

----- Test 4: Configuration Register -----
[CONFIG] Before write = 0x00
[CONFIG] Write value  = 0xA5
[CONFIG] After write  = 0xA5
[CHECK] Configuration write verified
[COUNT] Register reads  = 6
[COUNT] Register writes = 1

----- Test 5: Invalid Register Operations -----
[EXPECTED ERROR] Invalid register 0x20 rejected
[EXPECTED ERROR] Out-of-range continuous read rejected
[EXPECTED ERROR] Read-only device ID write rejected
[EXPECTED ERROR] Zero-length read rejected
[CHECK] Device ID remains 0x42
[CHECK] All counters unchanged

----- Test 6: Busy Bus Protection -----
[I2C] Temporary bus marked busy
[EXPECTED ERROR] Register read rejected while bus busy
[I2C] Temporary bus released
[CHECK] Busy bus protection verified

----- Final I2C Register Status -----
[FINAL] START count          = 6
[FINAL] Repeated START count = 5
[FINAL] STOP count           = 6
[FINAL] ACK count            = 19
[FINAL] NACK count           = 5
[FINAL] Byte count           = 24
[FINAL] Register reads       = 6
[FINAL] Register writes      = 1
[FINAL] Device selected      = NO
[FINAL] Bus started          = NO
[FINAL] Config register      = 0xA5
[FINAL CHECK] All I2C register tests passed

===== I2C Register Test Finished =====

编译过程中没有出现:

warning
error

三十六、通信计数分析

主总线共执行:

5 次寄存器读取事务
1 次寄存器写入事务

所以:

普通 START = 6
STOP = 6

五次读取都需要 Repeated START:

Repeated START = 5

配置寄存器写入不需要切换方向,因此不使用 Repeated START。


三十七、为什么 Byte count 是 24

四次单寄存器读取,每次包含:

写地址字节
寄存器地址
读地址字节
寄存器数据

所以:

4 × 4 = 16

一次温度连续读取包含:

写地址字节
寄存器地址
读地址字节
温度高字节
温度低字节

所以:

5

一次配置寄存器写入包含:

写地址字节
寄存器地址
配置数据

所以:

3

总计:

16 + 5 + 3 = 24

三十八、为什么 ACK count 是 19

四次单寄存器读取,每次有:

写地址 ACK
寄存器地址 ACK
读地址 ACK

所以:

4 × 3 = 12

一次温度连续读取:

写地址 ACK
寄存器地址 ACK
读地址 ACK
高字节后的主机 ACK

所以:

4

一次配置写入:

写地址 ACK
寄存器地址 ACK
配置数据 ACK

所以:

3

总计:

12 + 4 + 3 = 19

三十九、为什么 NACK count 是 5

共有五次读取事务:

读取 DEVICE_ID
读取 STATUS
读取温度
读取写入前 CONFIG
读取写入后 CONFIG

每次读取最后一个字节后,主机都发送 NACK。

所以:

NACK count = 5

这些 NACK 都表示正常结束读取。


四十、Day25 与 Day26 的区别

Day25 重点:

设备地址
单字节写入
单字节读取
ACK 和 NACK

基本流程:

START
Address
Data
STOP

Day26 重点:

内部寄存器
寄存器指针
Repeated START
连续读取
寄存器权限

典型读取流程:

START
Address + WRITE
Register Address
Repeated START
Address + READ
Data
STOP

四十一、SPI 与 I²C 寄存器读取对比

SPI 寄存器读取:

CS LOW
发送读命令和寄存器地址
发送 Dummy Byte
读取寄存器数据
CS HIGH

I²C 寄存器读取:

START
设备地址 + WRITE
寄存器地址
Repeated START
设备地址 + READ
读取寄存器数据
STOP

SPI 通过:

CS

选择设备。

I²C 通过:

设备地址

选择设备。


四十二、今日收获

通过 Day26 学习,我掌握了:

  1. I²C 设备内部寄存器;
  2. 寄存器地址;
  3. 寄存器指针;
  4. 寄存器指针有效状态;
  5. 普通 START 和 Repeated START;
  6. Repeated START 的使用条件;
  7. Repeated START 后重新发送设备地址;
  8. 先写寄存器地址再读取数据;
  9. 单寄存器读取;
  10. 配置寄存器写入;
  11. 连续多字节读取;
  12. 中间字节 ACK;
  13. 最后字节 NACK;
  14. 寄存器地址自动递增;
  15. 温度高低字节拆分;
  16. 温度高低字节组合;
  17. 温度值换算;
  18. 只读与可写寄存器;
  19. 非法寄存器保护;
  20. 连续读取越界保护;
  21. 零长度读取保护;
  22. 总线忙状态保护;
  23. 失败事务恢复;
  24. 完整事务成功后再修改寄存器;
  25. 应用层、设备层和总线层分层;
  26. 通信事务数与寄存器读取数的区别;
  27. START、Repeated START、STOP、ACK、NACK 和字节计数。

四十三、下一步计划

下一天将学习:

中断基础与事件驱动

计划内容:

轮询和中断的区别
中断请求
中断使能
中断标志
中断服务函数 ISR
volatile
共享变量
中断计数
按键中断模拟
UART 接收中断模拟
主循环事件处理
中断中只做必要工作

重点理解:

中断负责快速响应和记录事件
主循环负责处理耗时任务

duhong2026

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐