入门嵌入式这行,JZ2440这块板子几乎是绕不开的话题。我当年在它上面折腾裸板驱动的时候,最难啃的骨头之一就是I2C。这东西不像UART,串口线一接就能收发,也不像GPIO,拉高拉低就行。I2C一上来就是时序、应答、主从模式,一堆抽象概念。但等你在裸机上把I2C真正调通,再接EEPROM、温度传感器、RTC这些外设,你会发现这套总线协议是嵌入式世界里最通用的语言之一。
这篇文章我不会照着数据手册念,而是从我在JZ2440上从零写裸板I2C驱动的完整过程出发,把协议结构、芯片控制器寄存器、驱动代码、外设验证,以及踩坑排查的经验全部串起来。不管你是刚接触ARM裸板开发,还是已经调过Linux下的I2C驱动想回看底层原理,这篇都应该能给你一些参考。
1. I2C这两根线到底在传什么
1.1 开漏结构和上拉电阻决定了I2C的物理基石
I2C总线只有两根线,SCL时钟线和SDA数据线。很多初学者会问,一根数据线怎么能又发地址又发数据还能接收应答?答案在于它开漏(Open-Drain)的结构。所有挂在总线上的设备,SCL和SDA引脚都是开漏输出,不能主动输出高电平,只能把线拉低或者释放。线的高电平靠外部上拉电阻提供。
这带来一个很有意思的特性:总线空闲时,SCL和SDA都是高电平。任何一个设备只要把线拉低,总线就是低电平。这就是“线与”逻辑,也是I2C多主设备仲裁的基础。实际接线上,上拉电阻一般选4.7kΩ到10kΩ,接在3.3V电源上。如果漏接这个上拉,总线永远浮空,后续所有时序都是白搭。这是硬件上第一个大坑。
数据怎么在线上流动?规则是:SCL为高电平时,SDA必须保持稳定;SCL为低电平时,SDA才允许变化。每一bit数据都在SCL低电平期间准备好,高电平期间被接收方采样。所以SCL像节拍器,SDA像说话的人,只有节拍器没响的时候才能变调,节拍器一响就不能动,一动别人就会听错。
1.2 起始、停止条件与数据字节的格式
I2C通信最大的特点和坑都在时序的特殊位置上。本身协议约定,当SCL为高电平时,SDA电平不允许变化。但有两个人为例外:
- 起始条件(START):SCL为高电平时,SDA由高电平跳变为低电平。
- 停止条件(STOP):SCL为高电平时,SDA由低电平跳变为高电平。
这两条规定必须死死记住。SCL为高时,SDA下降沿是起始,SDA上升沿是停止。其他的SCL高电平时SDA变化都不合法。所有I2C设备内部的状态机都靠这两个特殊边沿来识别一次传输的开始和结束。
数据字节的格式则是MSB先行,也就是高位在前。每传输一个字节(8bit),后面必须跟一个应答位(ACK)。主机拉低SCL发出第9个时钟,从机在这期间把SDA拉低,表示“我收到并处理完了”。如果从机不拉低,保持SDA高电平,就是非应答(NACK),通常意味着地址错了、设备没准备好或数据出错。
1.3 地址、读位、写位:第一个字节到底在干什么
I2C总线上可以挂很多设备,主机通过从机地址来区分它们。每个从机地址是7位。传输时,这7位地址占一个字节的高7位,最低1位是读写标志位:0表示接下来主机要写数据,从机要收;1表示主机要读数据,从机要发。
例如常见EEPROM芯片AT24C02,它的地址格式是0b1010xxx,高4位固定为1010,低3位由芯片引脚A2、A1、A0的电平决定,开发板上这几个引脚通常接地,所以地址是0b1010000,加上写位就是0xA0,加上读位就是0xA1。
主机发送地址字节后,从机会回一个ACK。如果总线上没有这个地址的设备,或者设备不在线,主机收到的就是NACK。这一点在调试阶段最有用,直接判断硬件连接对不对。
2. S3C2440的I2C控制器:硬件状态机替你干了粗活
2.1 为什么要用控制器而不是GPIO模拟
JZ2440用的主控是S3C2440,内部有一个完整的I2C控制器。那为什么不直接用两个GPIO模拟I2C时序呢?GPIO模拟当然也能跑,很多单片机项目也是这么干的,但S3C2440的控制器一旦配置好,SCL的翻转、起始停止条件的产生、应答位的收发和检测都会由硬件状态机自动完成,CPU只需要在合适的时机读写数据寄存器、检查状态标志即可。
用GPIO模拟一次起始条件就要自己计算延时、手动翻转引脚,时序容易受中断影响,而且代码量更大。在裸机阶段学习I2C,用控制器可以让你把注意力放在抽象层次上,而不是每bit的翻转细节上。等你理解了控制器方式,再回头去模拟GPIO,会轻松很多。
2.2 四个关键寄存器先记牢
S3C2440的I2C控制器涉及四个寄存器,基地址是0x54000000:
| 寄存器 | 地址 | 作用 |
|---|---|---|
| IICCON | 0x54000000 | 控制寄存器,配置ACK使能、时钟源、中断、挂起标志和分频系数 |
| IICSTAT | 0x54000004 | 状态寄存器,配置主/从模式、触发START/STOP、指示总线忙/闲 |
| IICDS | 0x54000008 | 数据移位寄存器,发送时写数据,接收时读数据 |
| IICADD | 0x5400000C | 从机地址寄存器,本设备作为从机时使用,主机模式下基本不用管 |
这里有一个关键点,IICCON的bit4是中断挂起标志(Interrupt Pending)。当我们开启中断使能后,每一笔传输完成,这个位会被硬件置1。裸机轮询模式下的核心逻辑就是:不断读这个位,直到它为1,表示当前正在进行的传输事件已经完成,然后软件写0清除挂起,让总线继续走下一步。
这个位是I2C裸机驱动的心脏。理解了它,就理解了S3C2440上I2C控制的整个流程。
2.3 时钟分频:100kHz是怎么来的
I2C有两种时钟模式,标准模式100kHz,快速模式400kHz。S3C2440的I2C控制器时钟源来自PCLK(外设总线时钟)或EXTAL,通过IICCON的bit6选择。我们一般选PCLK,因为开发板上外设时钟通常配置在50MHz左右,直接用外部晶振反而麻烦。
时钟分频的计算,数据手册给出的模型是:内部IICCLK = PCLK / (分频值 + 1),外部经过一个额外分频网络才得到实际的SCL频率。实际裸机开发中,很少有人去精确推这个分频关系,常见做法是直接把IICCON的低4位设成0xF,再实测SCL波形,通常能得到100kHz到200kHz之间的频率,完全能满足EEPROM等大多数慢速外设的需求。
如果你要求严格遵循标准时序,我建议用逻辑分析仪抓SCL波形,然后调整低4位参数,实测为主。手册上的公式可以作为初值依据,但每个板的PCLK配置不同,依赖计算不如依赖实测。
3. 裸机驱动代码的骨架与核心流程
3.1 代码分层:别把所有东西堆在main里
裸机开发虽然不用操作系统,但代码组织仍然要清晰。我习惯把整个驱动拆成几个模块:寄存器地址定义放在头文件里,I2C初始化函数、发送函数、接收函数单独成一个.c文件,main文件里只做调用和测试验证。这样你后续换一颗芯片、换一套寄存器基地址时,只需要修改头文件。
寄存器地址定义用volatile指针,这一点极其重要。S3C2440是ARM920T内核,如果你不用volatile限定,编译器很可能优化掉你对寄存器地址的读写,导致驱动完全失效或者行为诡异。
3.2 初始化:三个寄存器就位,总线才能动起来
初始化要做的事很明确。第一,把GPE14和GPE15这两个引脚配置为第二功能,也就是IICSCL和IICSDA,而不是普通GPIO。JZ2440上这两个引脚默认可能被配置成GPIO输出模式,如果不改功能,就算寄存器配置全对,总线上也不会有波形。
第二,配置IICCON。使能ACK生成(bit7置1),时钟源选PCLK(bit6置0),使能中断(bit5置1,这样挂起标志才会工作),分频系数设个初值比如0xF。
第三,配置IICSTAT,把它设为待机状态,同时配置为主机模式。这个寄存器在后续每次发送和接收时还会反复修改。
初始化完成后,I2C控制器就处于就绪状态,等待主机发出START条件。
3.3 主发送流程:从START到STOP的每一步
一次完整的主机写入操作是这样的:
- 设置IICSTAT为主发送模式(0xD0),这一步会触发START条件,SCL和SDA随之出现起始波形。
- 把从机地址和写位组成的字节写入IICDS。
- 清除IICCON的挂起标志,硬件开始真正把地址字节移出到总线上。
- 等待挂起标志再次置1,说明地址已经发完,并且收到了从机的ACK。
- 清挂起标志,把要发送的第一个数据字节写入IICDS。
- 再等待挂起标志置1,确认这个字节也发出去了,而且从机回了ACK。
- 重复直到所有数据发完。
- 发送停止条件,S3C2440的做法是清除IICSTAT的bit5,硬件会自动产生STOP。
这里有个很容易犯的错,先清挂起再写数据,还是先写数据再清挂起?顺序必须是先写IICDS,后清挂起。因为清挂起这个动作实际上是通知硬件“你可以继续走状态机了”,硬件接着就会从IICDS取数据。如果你先清挂起再写数据,硬件可能已经取走了旧值或空数据,最终发出去的就是错的东西。
发送过程中,ACK的检查也很关键。地址发完后,如果硬件没有收到ACK,挂起标志照样会置1,但你不知道从机到底回应没有。S3C2440没有单独的ACK失败中断标志位,通常的做法是通过IICSTAT或者其他状态位判断,或者干脆依靠总线上从机地址是否正确而已。发数据阶段如果收到NACK,多半是从机没准备好或者地址理解有误,这时应该中止传输而不是继续发。
一个可靠的主发送函数骨架长这样:
void i2c_write_buf(u8 slave_addr, u8 *buf, u32 len) { int i; // 启动主发送, 产生START IICSTAT = 0xD0; // 写从机地址+写位 IICDS = (slave_addr << 1) | 0; // 清挂起, 让硬件发送地址 IICCON &= ~(1 << 4); // 等待地址发送完成 while (!(IICCON & (1 << 4))); // 检查收到NACK时做错误处理 if (i2c_check_nack()) { i2c_stop(); return; } // 写数据 for (i = 0; i < len; i++) { IICDS = buf[i]; IICCON &= ~(1 << 4); while (!(IICCON & (1 << 4))); if (i2c_check_nack()) { i2c_stop(); return; } } // 产生STOP IICSTAT &= ~(1 << 5); IICCON &= ~(1 << 4); }这段代码是逻辑骨架,实际使用时还要考虑信号量保护、超时机制。裸机单线程下可以先不考虑并发,但一定要有超时退出,否则从机不响应,程序会死等在一个while里,整个系统卡死。
3.4 主接收流程:最后一字节之前必须关掉应答
主机读数据的流程和写稍有不同。第一步仍然是设置主接收模式(写0x90到IICSTAT),产生START条件,然后写从机地址加读位到IICDS,清挂起,等待。地址被从机应答后,总线方向就反过来了,从机开始往SDA上发数据。
接收的循环逻辑是:
- 清挂起,等待数据从SDA被硬件接收。
- 挂起置1后,从IICDS读出这个字节。
- 如果还要继续收下一字节,保持IICCON的ACK使能(bit7为1),从机就不会知道自己该停了。
- 但如果这是最后一个字节,必须在读它之前把IICCON的ACK使能关掉。这样当主机读这一字节时,硬件会自动不回ACK,从机接收方看到NACK就知道该停止发送了。
这个“倒数第几个字节关ACK”的问题,是I2C新手最常见的困惑。回想I2C协议,从机每次发完一个字节后要等主机的应答,主机想继续读,回复ACK;主机不想再读了,回复NACK。所以必须在读最后一个字节之前提前关闭ACK使能,硬件才会在下一拍回复NACK。这个顺序不能错,一旦错了,从机会一直发,总线就会被拖住。
接收流程收尾时,最后还要产生STOP条件。同样是通过清IICSTAT的bit5来完成。
接收函数的骨架:
void i2c_read_buf(u8 slave_addr, u8 *buf, u32 len) { int i; // 主接收模式, 产生START IICSTAT = 0x90; // 写从机地址+读位 IICDS = (slave_addr << 1) | 1; IICCON &= ~(1 << 4); // 等待地址发送完成 while (!(IICCON & (1 << 4))); // 避免读到旧数据, 先清挂起再读第一字节 IICCON &= ~(1 << 4); for (i = 0; i < len; i++) { // 最后一个字节前关闭ACK if (i == len - 1) { IICCON &= ~(1 << 7); } while (!(IICCON & (1 << 4))); buf[i] = IICDS; // 如果还要继续读, 清挂起让总线继续 if (i < len - 1) { IICCON &= ~(1 << 4); } } // 产生STOP IICSTAT &= ~(1 << 5); IICCON &= ~(1 << 4); // 恢复ACK使能, 供下一次读操作使用 IICCON |= (1 << 7); }这段代码每一步都对应一个硬件状态机环节。裸机下调试时,建议在每一步等待前加一个计数器,超时打印错误标志。否则遇到从机死锁,你很难分清楚是卡在地址阶段还是数据阶段。
4. 挂一颗AT24C02,让数据真正读出来
4.1 认识测试对象
JZ2440开发板上通常板载一颗EEPROM芯片,常见型号是AT24C02,这也是很多人调I2C时的第一个实际外设。这颗芯片容量很小,只有2Kbit,也就是256字节。它通过I2C接口访问,支持字节写、页写、当前地址读、随机读和连续读。
我们实验的裸机板上,EEPROM的A2、A1、A0三个地址引脚一般直接接地,所以从机地址固定为0b1010000,加上写位就是0xA0,加上读位就是0xA1。如果你的板子上的EEPROM地址引脚有跳线或接到了GPIO,要按实际电平计算。
4.2 写一个字节到AT24C02要注意什么
向AT24C02写单个字节的流程是:
- 主机发出START。
- 发送0xA0(器件地址+写位),等待ACK。
- 发送要写入的字节地址(0~255),等待ACK。
- 发送要写入的数据,等待ACK。
- 主机发出STOP。
看起来和通用I2C写流程一样,但EEPROM有一个特殊之处:它写入后需要时间把数据固化到内部存储单元,这个时间叫内部写周期,一般要5ms。停止条件发出后,EEPROM内部在忙,这段时间内芯片不会响应任何新的I2C命令。如果主机紧接着去读,根本读不到刚写进去的数据。
所以写操作后必须加延时。裸机上最简单就是写个空循环延时函数,延时大于5ms。我实验的时候习惯延时10ms左右,确保芯片彻底忙完。这个延时是EEPROM实验里极易被忽略的一环。
AT24C02还支持页写,一次最多写入8或16字节,但要注意页边界不能跨越。如果写入的数据从第7字节开始连续写8个字节,跨过了一页的边界,EEPROM会丢掉页边界之后的部分,数据就错乱了。裸机测试里建议老老实实字节写,或者写之前检查地址是否在页边界内。
4.3 随机读:为什么先要“空写”一次地址
读单个字节的随机读流程比写更绕:
- 主机先发START。
- 发送0xA0(写地址),等待ACK。
- 发送要读的字节地址,等待ACK。
- 主机再发一次START(这就是重复起始条件,不是停止再启动)。
- 发送0xA1(读地址),等待ACK。
- 读取一个字节,主机回NACK。
- 发出STOP。
为什么读之前要先以写模式发一遍地址?因为EEPROM内部有一个8位的地址指针寄存器,随机读会把指针定位到指定地址,然后再切换成读模式,从地址指针指向的位置开始输出数据。这个地址指针就是EEPROM内部状态的一部分,必须先写地址来设置它。
很多人在这一步翻车,重复起始条件的实现非常容易搞错。在S3C2440裸机上,实现重复起始的方式是先把IICSTAT切到主发送模式,产生START,然后发送从机地址加读位。这和前面的发送流程在寄存器操作上没有本质区别,关键在于发送0xA1前不能发STOP,要保持总线的控制权。
我见过一个很典型的错误是,读流程中先发了STOP再重新起始,虽然结果也能读,但违反I2C规范,在一些严格遵循协议的从机上会出问题。正确的做法是使用重复起始条件。
4.4 完整验证实验:写字符串,读回来,打印出来
纸上谈兵再多,不如做一个能看结果的小实验。我的验证流程很简单:
- 初始化UART,波特率115200,用于打印调试信息。
- 初始化I2C控制器。
- 调用写函数,向AT24C02地址0x00写入一串字符,比如“JZ2440 I2C test”。
- 延时10ms。
- 调用读函数,从地址0x00连续读出16字节。
- 把读出的字节逐个用UART发送,打印到串口工具上。
如果写读一致,串口输出和原字符串完全一样,说明I2C链路、寄存器配置、时序都没有问题。如果读出来全是大大小小的乱码,就要检查是不是数据位顺序错了;如果读出来全是0xFF,说明根本没读到EEPROM的数据,可能是地址错误或者总线时序不通。
这个实验的价值在于,它验证的不只是I2C本身,还包括UART、内存缓冲区、循环逻辑等裸机基础能力。而且它给了你一个明确的“通过标准”,比单纯看寄存器状态要直观得多。调试过程中我把核心状态变量直接用UART打出来,卡在哪个环节一目了然。
5. 实测中一定会遇到的坑与排查思路
5.1 高频问题速查表
裸机调I2C,问题不外乎那几个,我整理了一个速查表,按现象查原因找解法:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 总线上完全无波形 | 引脚功能没配置为I2C | 检查GPE14/GPE15配置 |
| 总线上完全无波形 | 上拉电阻没接 | 确认SCL/SDA外部上拉到VCC |
| 全是0xFF读回 | EEPROM地址引脚配置错误 | 核对A0/A1/A2实际电平,计算器件地址 |
| 全是0xFF读回 | 读流程没做重复起始 | 确认随机读的完整时序 |
| 卡死在等待挂起标志循环 | 从机无ACK | 用示波器观察ACK位,确认从机在线 |
| 写后立即读失败 | EEPROM内部写周期未过 | 写STOP后延时5ms以上 |
| 读数据乱序或高位错 | 数据位顺序没按MSB先行 | 检查IICDS写入值是否字节顺序错乱 |
| 同一字节读两次 | 读最后一个字节前ACK没关 | 确认在读最后一字节前清IICCON bit7 |
这些坑每一个我都亲自踩过。尤其是读数据乱序那个,我当时误以为是影子寄存器的问题,查了半天才发现是写入IICDS的值本身就是反的,跟硬件没关系。
5.2 逻辑分析仪是裸板I2C排查的第一利器
裸机调试I2C,有逻辑分析仪和没逻辑分析仪完全是两个体验。示波器看单个波形,逻辑分析仪可以直接解码出起始、地址、ACK、数据、停止等协议层信息,一下子就能看出来是哪一步不对。
如果你手头有逻辑分析仪,接SCL和SDA两根线,软件里选I2C协议解析。触发条件设成起始条件,抓一轮完整传输。重点观察几个关键点:
- START条件是否出现在每次传输之前。
- 第一个字节是不是预期的从机地址加读写位。
- 第9个时钟上的SDA电平是低(ACK)还是高(NACK)。
- STOP之前的最后一拍ACK状态是否正确。
一次抓出完整波形,协议层问题基本当场暴露。如果没有逻辑分析仪,用示波器看SCL频率和SDA边沿变化也能判断个大概,但效率低不少。网上几十块钱的8通道逻辑分析仪在嵌入式圈子已经成为标配消耗品,强烈建议调I2C前先备一个。
5.3 其他容易被忽略的细节
第一,S3C2440的I2C控制器必须在使用前把IICCON的bit5中断使能打开。这一点很容易忽略。轮询模式下我们没有开总线的中断,也不写中断服务函数,但不代表控制器内部的中断逻辑可以关掉。如果bit5没置1,挂起标志位不会置位,你的等待循环就永远等不到。我第一次调的时候,查了半小时寄存器配置,最后发现只是少了这一个位。
第二,清挂起标志时要用读-修改-写还是直接赋值?我建议用“读回IICCON,修改bit4,再写回”的方式。但要注意,写回时原来的bit5中断使能位不能被覆盖掉。很多裸机代码图省事直接给IICCON赋值0xAF这种魔法数,一旦你后面改了时钟分频,就要跟着改这些魔法数,非常容易出错。更稳妥的做法是只改需要的位:
IICCON &= ~(1 << 4); // 清挂起第三,数据的接收顺序和发送顺序完全相反,不要以为同一个循环逻辑可以复用。写是从地址到数据一路按顺序发生,但读还要经过“写地址指针-重复起始-切换读方向”的过程。所以裸机驱动里写函数和读函数必须分开实现,不要试图合并成一个通用接口。
第四,延时函数在裸机上也是大事。如果你只是简单用for循环延时,编译优化级别不同,实际延时时间会差很多。调试I2C的时候建议把优化级别固定在一个值,或者用定时器延时。否则同一个代码在-O0调试正常,换成-O2就莫名失败,编译器优化把延时循环直接优化没了,这种情况是真的会发生。
写在最后的几点体会
我在JZ2440上把I2C调通之后,最大的感受是:I2C协议本身的复杂度其实被硬件控制器隐藏了大半。S3C2440的状态机已经把起始、停止、应答这些细节处理得七七八八,裸机驱动本质上只是在正确的时间点,往正确的寄存器里放正确的内容。真正难的不是代码本身,而是你要从数据手册和时序图里建立起一个完整的模型,知道每一步硬件在干什么,你写的代码又是在配合哪一步。
这样一套经验,不只是对S3C2440有用。后来我再在别的ARM芯片上写I2C驱动,最多花一个下午就能完成迁移。因为I2C的协议没有变,变的只是控制器的寄存器名字和位定义而已。你只要会读手册,知道“哪个寄存器是配置模式”“哪个位是挂起标志”“ACK使能在哪里关”,换成任何一颗芯片都是同样的套路。这就是裸机开发的价值,它逼着你去理解硬件真正的行为逻辑,而不是停留在调用接口的层面。