S3C2440裸机I2C控制器实战:协议解析与EEPROM读写 JZ2440裸板开发走到第十二课I2C这块硬骨头终于要啃了。前面串口、GPIO、定时器、中断多数外设一句话能讲清“怎么配就怎么用”但I2C不是。它只有SCL和SDA两根线却要承担一主多从、地址仲裁、应答重试这一整套会话规则在裸板上写I2C本质上是用寄存器去复现协议帧。本课围绕S3C2440内置的I2C控制器以板载EEPROM为实验对象把初始化、写字节、读字节、调试方法完整过一遍。适合已经会配置GPIO和串口、正要往总线型外设进阶的同学。这篇不会一上来贴代码。我先把协议层的几个关键动作讲透再对照控制器寄存器看它替你做了什么最后才是代码和坑。1. 为什么要单独给I2C开一课硬件简单协议不简单1.1 两根线背后的“会话规则”I2C只有两根线SCL时钟线和SDA数据线。硬件上比串口还省但软件上的规矩比串口多得多。串口是异步的双方各按自己的波特率采样错了顶多数据乱码I2C是同步的每一位数据都要跟随SCL的节拍主机不仅要产生时钟还要决定什么时候开始会话、什么时候结束会话、每个字节之后怎么确认。很多初学者拿到时序图第一反应是“就那么几个高低电平背下来照做就行”。真到写裸机驱动时会发现麻烦的不是单bit时序而是整个状态切换链路起始条件之后发地址要等应答应答之后发寄存器地址又要等应答再发数据还要等应答。任何一个环节没有按协议吃掉ACK整个传输就得停下来检查。这种“循环等事件、逐段推进”的写法和你之前写串口那种“往寄存器扔一个字节就完事”的体验完全不同。学裸板I2C还有一个现实理由JZ2440板载了EEPROM很多外部传感器、实时时钟、音频芯片也都是I2C接口。不管以后做Linux驱动还是RTOS驱动I2C子系统再复杂底层逻辑还是这一课讲的主发送、主接收状态机。1.2 实验对象板载EEPROM本课以板载EEPROM作为练手对象。这类芯片容量不大但引脚少、协议标准、应答行为非常典型。实验目标定得很具体初始化S3C2440的I2C控制器往EEPROM某一个存储单元写入一个字节再把这个字节读回来通过串口打印验证。这个闭环一旦跑通I2C主模式的基本功就算打牢了。后面接陀螺仪、温湿度传感器、OLED屏都是同一套动作换个从机地址、换段寄存器配置而已。2. 把I2C帧拆开起始、应答、地址与读写方向2.1 三个必须背下来的时序动作I2C总线上最核心的时序动作有三个。第一个是起始条件STARTSCL为高时SDA由高变低。这表示主机要开始一次传输所有从机都开始监听地址。第二个是停止条件STOPSCL为高时SDA由低变高。这表示传输结束总线释放回空闲状态。第三个是数据有效性规则SDA上的数据只能在SCL为低的时候变化SCL为高的时候SDA必须保持稳定接收方在这段时间内采样SDA的电平。第三个规则尤其重要它决定了所有数据帧的形态。用一个不太严谨但很好记的类比SCL高电平是“读数窗口”SDA是纸上的字只有窗口打开时对方才允许读内容。理解了这一点再看任何I2C数据波形就不会晕。2.2 7位地址是怎么变成0xA0和0xA1的I2C每个从机都有地址。S3C2440作为主机发送的第一个字节由7位从机地址和1位读写标志拼成高7位在前最低位是方向位0表示写、1表示读。以AT24C02为例它的7位地址由两部分决定器件类型标识符固定是1010后3位是A2、A1、A0三个引脚的电平。如果这三个引脚都接地7位地址就是0x50。然后做一次左移加上方向位写地址0x50 1 | 0 0xA0读地址0x50 1 | 1 0xA1所以代码里经常看到对EEPROM写0xA0、读0xA1就是从这里来的。不同开发板EEPROM的A2A1A0接法不一定一样实验前先翻一下原理图别一上来就抄网络上的地址。2.3 写操作和读操作的完整帧结构往EEPROM指定地址写一个字节总线上完整动作是这样的阶段总线内容说明1START主机发起会话20xA0从机写地址3ACKEEPROM应答4寄存器地址例如0x00要写入的存储单元5ACKEEPROM应答6数据字节要写入的内容7ACKEEPROM应答8STOP主机结束会话从EEPROM指定地址读一个字节就不是简单把方向位改成1就能完成的它需要先写后读总线动作是阶段总线内容说明1START主机发起会话20xA0先发写地址3ACKEEPROM应答4寄存器地址例如0x00告诉EEPROM要读哪里5ACKEEPROM应答6重复START不释放总线再发起一次起始70xA1切换成读地址8ACKEEPROM应答9数据字节EEPROM把存储单元内容送上SDA10NACK主机回非应答表示只要一个字节11STOP主机结束会话这里有个很关键的概念叫“重复起始”。主机在写完寄存器地址后不发送STOP直接让SDA在SCL为高时再拉低一次总线所有权仍然在自己手里从机也不会因为这次切换而复位指针。这样做的好处是读操作有原子性不会被其他主机插队。下一节写代码时还会遇到它。3. 硬件控制器帮我们干了什么S3C2440的I2C外设3.1 引脚复用GPE14和GPE15S3C2440的I2C引脚不是单独的它复用在一组GPIO上GPE14对应IICSCLGPE15对应IICSDA。在初始化I2C之前必须先修改GPECON寄存器把这两根引脚从普通GPIO切换成I2C功能。GPECON中每个引脚占2位GPE14对应bit[29:28]GPE15对应bit[31:30]I2C功能码是10。配置代码如下#include 2440addr.h void i2c_init(void) { // GPE14: IICSCL, GPE15: IICSDA, 功能码 10 GPECON ~((3 28) | (3 30)); GPECON | (2 28) | (2 30); // I2C控制器初始化 I2CCON 0xE0; // 1110 0000 IICLC 0x00; }这段代码先清除GPE14、GPE15的原有功能再置成I2C复用。I2CCON 0xE0的含义会在后面展开现在只需要知道ACK使能、时钟源选了PCLK/512、中断使能、挂起位清空。3.2 三个核心寄存器一个都不能少S3C2440的I2C控制器核心寄存器有四个最常用的是三个寄存器地址主要职责I2CCON0x54000000控制ACK使能、时钟源选择、中断使能、中断挂起位I2CSTAT0x54000004状态模式选择、启动/停止命令、传输状态码I2CDS0x5400000C数据发送和接收共用的移位寄存器IICLC0x54000010时钟分频对IICCLK再做一次分频I2CCON里最容易被忽略的是bit4——中断挂起位。每次I2C控制器完成一个动作比如地址发完收到ACK它会把bit4置1。无论你用中断还是轮询都必须检测到这个位然后手动清0控制器才会继续执行下一个总线动作。这个“做完一步必须清一次”的机制是裸机I2C驱动最容易漏的地方。I2CSTAT的bit[7:6]用来选择工作模式10是主接收11是主发送。bit5写1表示产生起始条件写0表示产生停止条件。bit[3:0]则是状态码比如0x18表示“SLAW已发送且收到ACK”0x28表示“数据字节已发送且收到ACK”。3.3 从PCLK到SCL的频率换算I2C的SCL频率不能随便给。EEPROM这类从机通常支持标准模式100kHz、快速模式400kHz把SCL跑到几MHz从机跟不上表现就是应答异常、数据错乱。S3C2440的时钟链路是PCLK先经过I2CCON的bit6选择一次分频得到IICCLK再经过IICLC寄存器做第二次分频得到最终SCL。常见PCLK是50MHz。如果I2CCON的bit6是0IICCLK PCLK/16 3.125MHz如果bit6是1IICCLK PCLK/512 97.6kHz。IICLC寄存器再做一次分频公式为SCL IICCLK / (IICLC[5:0] 1)一个很省事的配置是I2CCON的bit6置1IICLC保持0SCL直接就是97.6kHz落在标准模式100kHz附近EEPROM很舒服。如果一定要更精确地凑100kHz可以让IICCLKPCLK/163.125MHz设IICLC31得到SCL3.125MHz/3297.7kHz结果也差不多。提示不同修订版本的手册对分频公式描述可能略有差异写代码前一定找板子对应型号的芯片手册核对一遍别拿着记忆去填寄存器。4. 主发送模式实操把数据写进EEPROM4.1 初始化函数寄存器值怎么定初始化里I2CCON 0xE0是怎么定的拆开看0xE0 1110 0000 bit7 1 ACK使能主机在接收数据后会自动回ACK bit6 1 IICCLK PCLK/512SCL落在100kHz附近 bit5 1 中断使能后续轮询时可以看挂起位 bit4 0 挂起位清空保证初始状态干净初始化IICLC保持0不做额外分频。这样PCLK为50MHz时SCL就是97.6kHz左右。4.2 写字节的轮询流程主发送模式下写一个字节核心流程就是前文那个帧结构逐段走发地址、等ACK、发寄存器地址、等ACK、发数据、等ACK、发STOP。我用轮询方式实现故意不开中断方便观察每一步。int i2c_write_byte(unsigned char slave, unsigned char reg, unsigned char data) { unsigned int tmo; // 1. 器件写地址放进数据寄存器 I2CDS (slave 1) | 0; // 2. 主发送模式并产生起始条件 I2CSTAT 0xF0; // 3. 清挂起位总线开始执行第一步 I2CCON ~(1 4); // 4. 等待地址帧完成 tmo 0; while (!(I2CCON (1 4))) if (tmo 100000) return -1; if ((I2CSTAT 0xF8) ! 0x18) return -2; // SLAW, ACK // 5. 发寄存器地址 I2CDS reg; I2CCON ~(1 4); tmo 0; while (!(I2CCON (1 4))) if (tmo 100000) return -1; if ((I2CSTAT 0xF8) ! 0x28) return -2; // 数据已发, ACK // 6. 发数据字节 I2CDS data; I2CCON ~(1 4); tmo 0; while (!(I2CCON (1 4))) if (tmo 100000) return -1; if ((I2CSTAT 0xF8) ! 0x28) return -2; // 数据已发, ACK // 7. 发送停止条件 I2CSTAT 0xD0; I2CCON ~(1 4); return 0; }每一步之间的“清挂起位”是关键中的关键。你在串口发送时往数据寄存器写一个字节就完事但在I2C控制器里总线每个字节的推进都要你亲手“放行”。忘记清挂起位最典型的现象是代码卡在某个while里或者整个函数能退出但总线上根本没有完整传输。4.3 写周期为什么写完不能马上读EEPROM写完一个字节之后内部需要把缓冲区的数据刷进非易失存储阵列这段时间叫写周期典型值在5ms左右。写周期期间EEPROM不响应任何总线命令你去读它它不回ACK。最粗暴的做法是写完数据后延时10ms再继续。这个在裸机实验里够用但如果以后接实时性要求高的系统延时浪费太大。更好的做法是ACK轮询不停发送写地址直到EEPROM给出ACK表示内部写周期结束。int i2c_wait_write_complete(unsigned char slave) { unsigned int tmo; I2CDS (slave 1) | 0; I2CSTAT 0xF0; I2CCON ~(1 4); tmo 0; while (!(I2CCON (1 4))) if (tmo 100000) return -1; // 返回0表示收到ACK写周期结束返回-1表示还在忙 return ((I2CSTAT 0xF8) 0x18) ? 0 : -1; }调用时循环几次直到返回0就行while (i2c_wait_write_complete(0x50)) ;这个技巧在做系统移植时很常见建议直接用。5. 主接收模式实操把数据读回来5.1 指定地址读要用的“重复起始”读EEPROM不能像串口那样简单切个方向就收数据。I2C从机需要先知道你想读哪个存储单元所以必须先把寄存器地址写进去。问题在于你前面刚以“写”方向占住总线怎么切到“读”方向两块总线之间不能直接改方向否则从机不知道你要干什么。协议为此定义了重复起始条件主机在不释放总线的前提下重新拉一次起始然后发送读地址。S3C2440控制器同样支持这个动作只要在发送读地址前把I2CSTAT设为0xB0控制器就会自动在总线上产生重复起始。这里有个容易混淆的点上文中写操作是在主发送模式下完成的切换成主接收模式时模式位从11变成10起始/停止位又写了一次1控制器就会知道当前总线还是我占着的我要重新开始一次传输但方向变了。5.2 最后一个字节的NACK是关键主接收模式里确认应答位的行为和主发送模式不一样。主机作为接收方需要在每个字节后回ACK表示“继续发”但在最后一个字节前必须把ACK使能关闭让主机回NACK从机才会停止发送主机才有机会发STOP结束会话。如果读多个字节前N-1个字节要保持ACK使能第N个字节前关掉如果只读一个字节那么在SLAR发送完成、收到0x40状态码之后立刻关掉ACK使能。这个时序错一步EEPROM要么多发一个字节要么总线状态对不上。5.3 读函数代码与串口验证int i2c_read_byte(unsigned char slave, unsigned char reg, unsigned char *buf) { unsigned int tmo; // 1. 先写寄存器地址 I2CDS (slave 1) | 0; I2CSTAT 0xF0; I2CCON ~(1 4); tmo 0; while (!(I2CCON (1 4))) if (tmo 100000) return -1; if ((I2CSTAT 0xF8) ! 0x18) return -2; // 2. 发送寄存器地址 I2CDS reg; I2CCON ~(1 4); tmo 0; while (!(I2CCON (1 4))) if (tmo 100000) return -1; if ((I2CSTAT 0xF8) ! 0x28) return -2; // 3. 重复起始切到主接收模式 I2CDS (slave 1) | 1; I2CSTAT 0xB0; I2CCON ~(1 4); tmo 0; while (!(I2CCON (1 4))) if (tmo 100000) return -1; if ((I2CSTAT 0xF8) ! 0x40) return -2; // 4. 只读一个字节关掉ACK使能让从机知道不用再发 I2CCON ~(1 7); I2CCON ~(1 4); tmo 0; while (!(I2CCON (1 4))) if (tmo 100000) return -1; if ((I2CSTAT 0xF8) ! 0x58) return -2; // 5. 数据已经在I2CDS里 *buf I2CDS; // 6. 停止 I2CSTAT 0x90; I2CCON ~(1 4); return 0; }验证方法很简单写一个测试unsigned char val 0; i2c_write_byte(0x50, 0x00, 0x5A); delay_ms(10); if (i2c_read_byte(0x50, 0x00, val) 0 val 0x5A) printf(I2C read/write OK, val 0x%02X\n, val); else printf(I2C test failed\n);串口如果能打印出val 0x5A这一课的核心目标就完成了。6. 裸调I2C最容易卡住的五个场景6.1 首次探测就NACK先怀疑这四处地址发出去之后状态码不是0x18而是0x20意味着SLAW发出后收到了NACK也就是说总线上没有设备应答这个地址。这时候先别急着改代码按顺序排查检查项操作从机地址确认原理图上A2/A1/A0接法7位地址是否计算正确上拉电阻SCL、SDA必须有上拉常见4.7k测一下引脚电平接线SDA和SCL是否接反接触是否可靠总线冲突是否有其他设备占用了同一个地址时钟频率是否远高于从机额定SCL我见过最离谱的一次是SDA和SCL两根线在杜邦线上对调了波形逻辑完全看不出来但地址就是没人应答。先量通断再量电平。6.2 SDA被拉死时的总线恢复操作I2C总线上一个很经典的“死机”现象是SDA一直为低。原因是传输中途主机复位从机还在等待剩余的SCL脉冲完成那个没发完的字节。I2C协议本身没有超时机制从机不会自动放弃它就一直把SDA拉低。恢复办法是手动让SCL多跑几个周期把从机没等完的那些脉冲补上。可以先把GPE14、GPE15临时改成普通GPIO输出然后连续产生9个SCL脉冲最后在SCL为高时让SDA从低变高补一个STOP条件void i2c_bus_recover(void) { int i; // 临时配成GPIO输出 GPECON ~((3 28) | (3 30)); GPECON | (1 28) | (1 30); // SCL、SDA都置高 GPEDAT | (1 14); GPEDAT | (1 15); for (i 0; i 9; i) { GPEDAT ~(1 14); delay_us(5); GPEDAT | (1 14); delay_us(5); } // SCL为高时让SDA从低变高制造STOP GPEDAT ~(1 14); GPEDAT ~(1 15); GPEDAT | (1 14); delay_us(5); GPEDAT | (1 15); delay_us(5); // 恢复成I2C功能 GPECON ~((3 28) | (3 30)); GPECON | (2 28) | (2 30); }这个操作相当于“把从机卡住的会话强行走完”在很多场合下比断电重启更管用。6.3 读回0xFF或0x00多半不是控制器问题读回来的数据是0xFF说明SDA线上始终是高电平从机没有把数据拉下来。常见原因有三个EEPROM地址不对读到了没有连接的存储单元写周期没结束就发起读从机没有正常参与传输上拉电阻虚焊数据线电平异常。读回来是0x00则要怀疑写入的数据本身是否没写进去或者写地址时寄存器地址没对上比如往0x00写读的时候读成了0x01。这时候把逻辑分析仪挂上去看帧内容一眼就知道问题出在哪一段。6.4 用软件模拟I2C给硬件控制器“背锅”如果硬件控制器怎么调都不通我有个很有效的排查手段放弃I2C控制器直接用GPIO按I2C时序手动翻转做软件模拟I2C。它的好处是每一步时序都是你自己拉出来的你能精确知道某个状态为什么对不上。软件模拟只需要四件事起始条件、停止条件、发送一个字节、接收一个字节。起始让SDA先高后低停止让SDA先低后高发字节是从高位开始SCL每拉低一次改变一位每拉高一次对方采样一位。这个代码量不大却能让“到底是我理解错了协议还是控制器配置错了”这个问题瞬间有了答案。如果软件模拟I2C能正常读写EEPROM说明电路和从机都没问题问题一定在控制器配置如果软件模拟也不通优先检查上拉、接线、电平转换。这个“对照组”思路比对着寄存器和示波器瞎猜高效得多。6.5 轮询够用但不够优雅中断版怎么改本课提供的代码全部是轮询好处是逻辑直白一步一步看得见。但轮询版有个问题I2C每次传输都要等从机从机慢的时候CPU只能傻等这在裸机小实验里没关系以后放到带操作系统的环境里就是灾难。改成中断版的思路其实没变只是把每个“等待挂起位”的地方改成去中断服务函数里处理。维护一个简单的状态机中断里按当前状态推进下一步发地址、等ACK、发数据、发停止。代码量会多一部分但逻辑和轮询完全对应。我的建议是先跑通轮询版再用中断版重写一遍。两版都写过了你对I2C控制器的理解才真正牢固。最后说一点我自己的体会。I2C和串口最大的不同是它没有“物理层兜底”。串口错了最多乱码I2C一旦电气上不给力整个总线都僵住而且表现往往是代码逻辑完全正确但设备就是不应答。所以调I2C时手里有逻辑分析仪最好没有就用GPIO模拟法一点点对。先把电气关系理清再谈寄存器顺序反了会浪费很多时间。