做嵌入式这几年,I2C从设备这种活儿我接过不少。最近一个项目里,需要把一颗瑞萨单片机做成挂在主控总线上面的I2C从设备模块,主控通过两根线就能读走单片机采集的传感器数据,也能给单片机下发控制指令,频率跑400kHz,要求长时间运行不能丢数据。看似不复杂,真正实现下来,里面还是有不少门道。
这篇文章说说我自己的完整实现路径。内容围绕瑞萨单片机硬件I2C从设备的协议理解、方案选型、e2 studio配置、回调设计、常见坑点展开。适合正在用瑞萨RA/RX/RL78系列做从设备模块、或者被软件模拟I2C从设备时序折腾到头大的朋友参考。不管你是刚接触单片机通信的新手,还是写过几个项目的老手,这里面的很多细节都能直接拿过去用。
1. 项目整体设计思路:把瑞萨单片机做成一个可寻址的I2C从设备模块
1.1 为什么优先使用硬件I2C从设备,而不是软件模拟
先说结论:只要芯片自带硬件I2C外设,从设备模式就一定要用硬件,不要图省事用GPIO模拟。
软件模拟I2C主设备我做过不少,确实灵活,随便找两个GPIO就能搭起来。但软件模拟I2C从设备完全是另一回事。从设备必须在主设备发出起始条件后,一个位一个位地及时响应,尤其在地址匹配阶段,一旦你响应慢了半个位周期,主机那边就判定为无应答,直接报“设备未找到”。我见过很多人在本地跑得通,一旦系统里开了中断、任务调度一复杂,从设备就会不定时丢地址、丢数据。问题的根源在于:软件模拟从设备是用中断里翻转GPIO的方式去逐位拼接协议的,任何高优先级中断都能打断这个节奏,一旦打断,位时序就废了。
硬件I2C外设则把这些脏活累活全部固化在硅片里。起始停止条件检测、地址匹配比较、时钟同步、仲裁、ACK/NACK的收发,全都由外设自动完成。你的软件只需要在处理“事件”的层面干活,从设备在收到完整一帧数据或者被主机请求发送一帧数据时,才会触发中断通知你。这个差别打个比方:软件模拟从设备等于你一边开车一边手动换挡,还要随时盯着路面;硬件外设等于自动挡加自适应巡航,你只管看导航就行。
在瑞萨MCU上,RA系列、RX系列、RL78系列都有成熟的硬件I2C外设,都支持从设备模式。我用的是RA系列,FSP图形化配置工具里选一个从设备驱动,生成代码直接用,省心很多。
1.2 方案选型:瑞萨产品线对比与项目边界确定
瑞萨单片机做I2C从设备,主要会碰到三条产品线:
- RA系列:Cortex-M内核,e2 studio搭配FSP驱动包,外设驱动和代码生成做得很完善,I2C从设备配置大概是所有系列里最省事的。适合新项目、对开发速度有要求的场景。
- RX系列:瑞萨自研CISC内核,工业控制领域用得多,性价比高,RIIC外设同样支持标准的从设备模式。老项目迁移或者对工业环境有要求时优先考虑。
- RL78系列:低功耗8/16位机,电池供电产品很常见。它的IICA/IIC0模块也能做从设备,但性能和寄存器操作相对繁琐,适合资源极小、功耗优先的场景。
我这次的项目选的是RA4M1,理由很简单:主控那边是3.3V电平,板上还有其他传感器和Nor Flash走I2C总线,总线压力不大,RA4M1的IIC外设带DTC,后面做大数据量传输还能用DMA搬数据,扩展性够。
选型定下来之后,功能上我先给这个从设备模块划了边界:
- 主设备可以读单片机的状态寄存器、读采集数据缓存、写控制寄存器;
- 从设备地址固定为7位地址0x32,支持400kbps快速模式;
- 每次事务最大数据长度不超过64字节,协议层做帧号校验,方便排查丢包。
这个边界很重要,不能一上来就想着做到万能。I2C本身带宽就有限,从设备响应还要实时,先把场景定死,后续的驱动、缓冲、状态机设计才有清晰的依据。
另外提醒一句:同一颗芯片上如果既要做从设备,又要做别的I2C主设备功能,除非特别确认总线时序,否则最好分开。两个功能共用一个外设在FSP里配置比较复杂,中断优先级也容易打架,不要自找麻烦。
1.3 从设备的“寄存器面板”模型:主控眼中的单片机
I2C从设备本质上就是让主控能够“隔着两根线操作你的单片机”。那主控凭什么知道这段地址该干什么、那段数据是什么含义?靠的就是你定义的寄存器映射。
我做项目习惯在动手写代码之前先画一张表格,相当于给主控开发者提供一份地址菜单。这个模块的映射表大致长这样:
| 寄存器地址 | 方向 | 长度 | 含义 |
|---|---|---|---|
| 0x00 | 只读 | 1字节 | 设备版本号,固定0x12 |
| 0x01 | 只读 | 1字节 | 状态寄存器,bit0就绪标志 |
| 0x10~0x1F | 只读 | 16字节 | 传感器数据缓存区 |
| 0x20 | 只写 | 1字节 | 控制命令寄存器 |
| 0x21~0x23 | 只写 | 3字节 | 参数配置区 |
主设备想要读取传感器数据,先发起起始条件,发送从设备地址加写位,然后发送寄存器地址0x10,再发起一个Restart,重新发送从设备地址加读位,这时候从设备就进入发送模式,把0x10开始的数据一个接一个吐给主设备,直到主设备发出NACK和停止条件。
这套“寄存器面板”的思路是I2C从设备通信的基石。主控不需要知道你的单片机内部状态机长什么样,它只需要知道地址、长度、方向就可以了。反过来,你的代码实现也只是围绕着这张表去处理读写请求,思路非常清晰。
2. I2C从设备协议与瑞萨IIC外设的核心机制
2.1 从设备视角重新理解I2C协议
大多数入门教程讲I2C都是从主设备出发怎么发起始、怎么发地址、怎么发数据。但做从设备的时候,你的视角要换过来:你不是发起者,你是被寻址、被操作的对象。
一个最简单的写操作流程是:主设备发出起始条件,发送7位地址加写位(bit0=0),如果从设备地址匹配且硬件就绪,从设备会自动回一个ACK。接着主设备每发送一个数据字节,从设备就要回一个ACK。全部数据发完后,主设备发送停止条件。
读操作流程稍有不同:主设备发送起始条件、从设备地址加读位(bit0=1),从设备地址匹配后ACK,紧接着从设备就要立刻往总线上发送数据。这里的关键点在于“从设备发数据”的速度不是自己决定的,而是由主设备的SCL时钟决定的。硬件I2C从设备在收到读请求之后,需要保证数据寄存器里已经准备好了要发的内容,否则SCL会被硬件拉低(时钟拉伸),等待软件填数据。
这里有个很多新手会迷糊的地方:主设备“读寄存器”实际上是一个复合操作。比如主设备想读0x10地址的数据,它必须先进行一次写操作,把0x10这个字节写到从设备里,再发一次Restart和读地址,才能真正读到数据。也就是说,从设备要能识别一段完整事务里,第一个数据字节是“寄存器地址”,后面跟的才是要写的内容。这个逻辑需要你在软件状态机里处理好,硬件只告诉你“收到了数据字节”,不会自动帮你区分是地址还是数据。
2.2 瑞萨IIC外设收到什么信号,会通知你什么
瑞萨的IIC外设做从设备时,会自动完成地址匹配检测和总线上基本的时序握手。FSP驱动会把底层事件翻译成几个关键回调事件,你只需要在回调里对这些事件做对应处理。
常见的事件大致包括:
- 地址匹配事件:外设在总线上检测到本机地址,并且方向为写或读。这个事件代表一次新事务的开始,你的状态机应该在这里初始化。
- 接收数据事件:主设备写了一个数据字节到从设备,外设完成接收,这个字节已经在数据寄存器里了,需要你及时读走。
- 发送请求事件:主设备要通过读操作从你这里取数据,外设向你要一个字节,你需要立刻把数据填进去。
- 传输完成事件:一次读写事务完整结束。
- 错误事件:总线错误、仲裁丢失、NACK异常等。
我在第一次用FSP生成从设备代码的时候,犯过一个错误:以为回调函数里把数据接收下来存到数组就完事了。实际上一旦通信速率快,主设备连续写几十个字节,回调里做数组操作虽然很快,但如果在回调里又调用了延时或者打印函数,就会拖累下一次数据传输,直接造成NACK或者字节错位。所以回调函数的基本原则是:只置标志位和搬运必要数据,绝不干耗时操作。
2.3 从设备寄存器映射的软件状态机设计
有了上面的协议基础,我推荐用状态机来管理从设备的读写流程。状态机应该至少包含这几个状态:
- IDLE:总线空闲,等待地址匹配;
- WRITE_ADDR:已经收到写地址,下一个收到的字节是寄存器地址;
- WRITE_DATA:正在接收写入寄存器数据;
- READ_DATA:正在向主设备发送读数据。
寄存器映射的写操作流程是这样的:收到写地址匹配后,状态进入WRITE_ADDR,收到第一个数据字节,存为寄存器偏移地址,状态转到WRITE_DATA。后续收到的字节按顺序写入当前偏移地址对应的寄存器数组,偏移地址递增。如果收到停止条件,状态回到IDLE。
读操作会复杂点,因为涉及Restart。主设备先写一个寄存器地址(此时从设备状态会经历WRITE_ADDR),紧接着主设备发送Restart和读地址。最关键的就是:地址匹配事件里要能区分当前是“写地址后第一次匹配”还是“读地址后的第二次匹配”。你需要在状态机里记录“上一次是否已经收到寄存器地址”。如果已经在WRITE_ADDR状态存了一个有效寄存器偏移,又收到读地址匹配,就切换成READ_DATA状态,接下来的发送请求事件都从当前寄存器偏移开始发数据。
这个状态机是所有I2C从设备代码的核心。状态设计对了,无论主设备是简单读一个字节,还是连续读一大块数据,都能稳定工作。
3. e2 studio实操:从零配置一个硬件I2C从设备
3.1 FSP工程配置的关键步骤
我用的是e2 studio加FSP,如果你用的是RX系列或RL78系列,流程类似,只是驱动名称和配置界面会有差异。具体步骤我按RA系列的流程写:
第一步,新建工程。选择芯片型号,比如R7FA4M1AB。系统时钟配置好,一般主时钟拉到48MHz或者芯片允许的最高频率,这个对I2C外设的波特率生成有影响,不要随便选太低的频率。
第二步,在FSP的Stacks配置界面添加IIC驱动,注意这里要选择从设备模式。RA系列的FSP里IIC外设支持主模式和从模式,如果你用的是SCI外设模拟的SI2C,也能做I2C从设备,但配置路径不同,千万别选错。
第三步,配置参数。从设备地址长度选7位,地址设为0x32。速率模式这边我选了快速模式支持400kbps,实际从设备模式能不能跑满这个速率,还取决于你外部上拉电阻和总线电容,后面会讲。中断优先级建议设置得高一点,从设备通信对及时性要求很高,优先级低了很容易被其他中断挤掉。
第四步,配置引脚。FSP会根据外设分配默认引脚,但你要在Pins页面确认一下实际使用的引脚是否支持IIC复用功能,并且和硬件设计一致。很多新手在这里栽过跟头:FSP分配了一个完全没引出来的引脚,结果焊好板子死活通信不上。
第五步,生成代码。FSP会生成包含IIC从设备驱动的HAL层代码,还有回调函数的空实现。
配置完这些,工程框架就搭起来了。
3.2 回调函数和状态机代码的落地实现
我习惯把回调函数写得非常薄,只负责设置事件标志。下面是一段示意代码,具体API以你手上的FSP版本为准,但逻辑是通用的:
/* 示意代码,事件宏名称以FSP实际生成为准 */ typedef struct { uint8_t reg_addr; /* 当前寄存器偏移 */ uint8_t rx_buffer[64]; /* 接收缓冲 */ uint8_t tx_buffer[64]; /* 发送缓冲 */ uint8_t rx_index; uint8_t tx_index; volatile uint8_t state; /* 状态机状态 */ volatile uint8_t event_flag; /* 事件标志 */ } i2c_slave_handle_t; static i2c_slave_handle_t g_i2c_slave; void i2c_slave_callback(i2c_slave_callback_args_t * p_args) { switch (p_args->event) { case I2C_SLAVE_EVENT_ADDRESS_SET: /* 地址匹配,准备开始新事务 */ g_i2c_slave.state = ST_WRITE_ADDR; g_i2c_slave.rx_index = 0; g_i2c_slave.tx_index = 0; g_i2c_slave.event_flag |= EVT_ADDR_SET; break; case I2C_SLAVE_EVENT_RX_DATA_REQUEST: /* 收到一个数据字节,读寄存器组 */ if (g_i2c_slave.rx_index < sizeof(g_i2c_slave.rx_buffer)) { g_i2c_slave.rx_buffer[g_i2c_slave.rx_index++] = p_args->data; } g_i2c_slave.event_flag |= EVT_RX_DATA; break; case I2C_SLAVE_EVENT_TX_DATA_REQUEST: /* 主机要求发送一个字节,从发送缓冲取数据 */ g_i2c_slave.tx_index++; if (g_i2c_slave.tx_index < sizeof(g_i2c_slave.tx_buffer)) { g_i2c_slave.tx_data = g_i2c_slave.tx_buffer[g_i2c_slave.tx_index]; } else { g_i2c_slave.tx_data = 0xFF; /* 超出长度填充FF */ } g_i2c_slave.event_flag |= EVT_TX_DATA; break; case I2C_SLAVE_EVENT_STOP: case I2C_SLAVE_EVENT_TX_COMPLETE: g_i2c_slave.state = ST_IDLE; g_i2c_slave.event_flag |= EVT_STOP; break; case I2C_SLAVE_EVENT_ERROR: g_i2c_slave.state = ST_IDLE; g_i2c_slave.event_flag |= EVT_ERROR; break; default: break; } }这里的核心逻辑是把“接收到数据字节”和“主机要发送数据”分离开。每次收到数据字节时,还需要根据当前状态机的状态决定到底是寄存器地址还是寄存器数据。这个判断放在主循环里做更稳妥,因为回调里只要把原始字节存下来就行,逻辑越少,回调响应越快。
3.3 主循环里的协议处理:数据搬运和寄存器更新
事件标志置位之后,主循环要快速响应处理。主循环里的核心函数包括:
- 处理接收完成事件,把rx_buffer里的第一个字节作为寄存器偏移,后续字节写入对应的寄存器数组;
- 当检测到READ_DATA状态时,提前把发送缓冲准备好,从当前寄存器偏移连续填充到tx_buffer,供回调里的发送请求事件使用;
- 主循环还要定期更新发送缓冲区里的传感器数据,确保主设备来读的时候拿到的不是旧数据。
比较重要的是,发送缓冲的更新时机要小心。如果主设备正在高速读取的时候,你突然在中间改了发送缓冲的内容,主机可能读到一个“撕裂”的数据包。我的做法是:传感器数据先写到临时变量,计算校验完成后,一次性memcpy到发送缓冲,中间不允许被打断。如果数据量不大,可以直接在进入READ_DATA状态前的一瞬间做更新,这样主机这次读到的就是完整的一份快照。
/* 主循环示例 */ while (1) { if (g_i2c_slave.event_flag & EVT_RX_DATA) { /* 从rx_buffer解析寄存器地址和数据 */ process_rx_data(); g_i2c_slave.event_flag &= ~EVT_RX_DATA; } if (g_i2c_slave.event_flag & EVT_TX_DATA) { /* 准备发送缓冲 */ prepare_tx_buffer(); g_i2c_slave.event_flag &= ~EVT_TX_DATA; } /* 定期更新传感器数据到发送缓冲 */ update_sensor_data(); /* 每1ms循环一次,确保及时响应 */ delay_1ms(); }注意,共享变量在回调函数和主循环之间传递,一定要用volatile修饰。这是个老生常谈但特别容易犯的错误。我之前就吃过亏,编译器优化后回调里改了一个标志位,主循环死活读不到更新后的值,排查了大半天才发现是缺少volatile。
3.4 错误恢复机制:不能让总线死锁拖垮整个系统
I2C总线最怕的就是SDA被拉低之后没人释放,造成总线死锁。从设备这边如果状态机卡死,或者寄存器没来得及读走数据,可能导致总线上出现异常电平。瑞萨硬件I2C外设自身有超时检测和错误标志,但你不能光靠它。
我建议在代码里加一个定时器,定期检查IIC外设的错误标志,一旦发现总线错误或者外设进入异常状态,就重新调用一次初始化函数,把外设恢复到空闲状态。不要小看这一步,实测下来,总线抖动、主机异常复位之类的场景下,有了这个外设重启机制,系统才能做到几天几夜稳定运行不卡死。
另外,从设备收到主设备发来的数据,如果发现长度超过寄存器数组上限,简单的做法是直接丢弃多余字节并置一个溢出标志。不要为了防止溢出而让缓冲区越界,这是嵌入式开发里最基础的纪律。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 主机一直找不到从设备地址 | 从设备地址配置错误、引脚复用错误、上拉电阻缺失 | 用逻辑分析仪看主机发出的地址,再核对FSP配置 |
| 地址能匹配,但主机读到全0xFF | 发送缓冲没有准备数据,寄存器偏移忘写 | 进READ_DATA状态前必须填充发送缓冲 |
| 通信偶发错位,数据整体偏移 | 状态机没有正确处理Restart | 重点检查“写寄存器地址后再读”的时序 |
| 运行一段时间后总线锁死 | 外设错误后没有恢复、时钟拉伸异常 | 加错误检测和IIC重新初始化逻辑 |
| 400kbps下波形上升沿很缓 | 上拉电阻太大,总线电容太大 | 减小上拉电阻,或降低通信速率 |
| 主机写数据偶尔丢最后一个字节 | 停止条件事件处理不及时 | 检查停止事件是否有有效中断优先级 |
这里我要特别强调一下最后一条。很多时候主设备连续写数据,最后一个字节发出后立即拉高SDA并发送停止条件,从设备的接收中断如果响应慢半拍,就可能丢失最后一个字节的读取。解决方法是保证I2C中断优先级足够高,并且在回调里只做最少的字节保存操作,千万不能放打印函数。
4.2 三个真实踩坑案例
第一个坑是上拉电阻引发的400k速率不稳。我一开始在从设备板子上用了一个10k欧姆上拉电阻,用逻辑分析仪看波形,数据线上升沿肉眼可见地变圆了。快速模式下允许的最大上升时间只有300纳秒左右,10k电阻配两三百皮法的总线电容,上升沿早已超标。后来我把上拉电阻换成1.5k,波形立刻干净了。经验公式上,Rmax大约等于tr除以0.8473再除以总线电容,按照300ns、200pF算下来是1.77k,所以快速模式常用1k到2.2k之间是合理的,具体看你板子上的电容和电平。
第二个坑是中断优先级太低导致丢字节。我在从设备回调里放了一个调试计数变量的自增,没在意优先级,结果系统里有一个1ms定时器中断优先级很高,主设备跑400k的时候,定时器中断频繁抢占I2C中断,导致从设备接收数据偶尔漏字节。后来把I2C中断优先级提到最高,问题彻底消失。这个教训告诉我,做I2C从设备,优先级分配要把响应时间放在第一位。
第三个坑是主机复位后的总线残留状态。某次测试,主设备意外复位,但复位瞬间SDA正好被拉低,导致从设备误以为总线上有数据传输,一直等待后续信号,最后总线卡死。我最后加了个监控任务,如果I2C一定时间没有事件也没有空闲状态,就强制重新初始化外设,顺利解决。这种问题在逻辑分析仪上很难复现,反而是靠“超时看门狗”式的思路来解决的。
4.3 调试工具和自测方法
我强烈建议准备一个逻辑分析仪,一百多块钱的就能用得很好。抓I2C波形时把采样率设高一点,解码器选I2C,就能直接看到起始条件、从设备地址、ACK/NACK、数据内容和停止条件。排查问题的时候,几乎所有的疑难杂症都能在这上面看出端倪。
没有逻辑分析仪的时候,可以用另外一块单片机当主设备,写一个简单的读写测试脚本,连续读写几百次,统计成功率和数据一致性。我早期没有逻辑分析仪,就是靠这招定位了很多问题。重点测试三类场景:
- 单字节读;
- 连续多字节读,长度超过一个寄存器区块;
- 连续多字节写,长度超过缓冲区。
最后在测试代码里加一个自检寄存器,主机一上来先读这个寄存器,里面固定放0xA5和0x5A两个字节,通过这个就能快速判断从设备基础通信是否正常。很多项目调试时通信“时好时坏”,其实就是遗漏了这种最简单的通路验证。
把这个从设备模块做稳定之后,后续还可以往SMBus、PMBus这种带命令规范的协议上扩展,或者用瑞萨IIC外设的DTC功能,把大数据量搬运交给DMA去处理,进一步释放CPU。我个人在实际操作中的体会是,硬件I2C从设备并不可怕,核心就是把协议状态机理清楚,把中断优先级的敏感性记在心里,把缓冲区竞争问题想明白,这三件事做到位,整条链路就会非常可靠。 最后再分享一个小技巧:在FSP配置里把从设备地址掩码暂时放宽,让外设能匹配到多个地址,调试阶段可以额外用一个地址专门做自检通道。等所有测试都通过了,再把地址掩码收紧,只保留实际产品要用的那一个。这个在量产前的调试阶段特别有用,帮你少折腾不少逻辑分析仪上的细节比对。