本教材現以連載的方式由網絡發布,并將于2014年由清華大學出版社出版最終完整版,版權歸作者和清華大學出版社所有。本著開源、分享的理念,本教材可以自由傳播及學習使用,但是務必請注明出處來自金沙灘工作室
前幾章我們學了一種通信協議叫做UART異步串口通信,這節課我們要來學習第二種常用的通信協議I2C。I2C總線是由PHILIPS公司開發的兩線式串行總線,多用于連接微處理器及其外圍設備。I2C總線的主要特點是接口方式簡單,兩條線可以掛多個參與通信的器件,即多機模式,而且任何一個器件都可以作為主機,當然同一時刻只能一個主機。
從原理上來講,UART屬于異步通信,比如電腦發送給單片機,電腦只負責把數據通過TXD發送出來即可,接收數據是單片機自己的事情。而I2C屬于同步通信,SCL時鐘線負責收發雙方的時鐘節拍,SDA數據線負責傳輸數據。I2C的發送方和接收方都以SCL這個時鐘節拍為基準進行數據的發送和接收。 從應用上來講,UART通信多用于板間通信,比如單片機和電腦,這個設備和另外一個設備之間的通信。而I2C多用于板內通信,比如單片機和我們本章要學的EEPROM之間的通信。 14.1 I2C時序初步認識
在硬件上,I2C總線是由時鐘總線SCL和數據總線SDA兩條線構成,連接到總線上的所有的器件的SCL都連到一起,所有的SDA都連到一起。I2C總線是開漏引腳并聯的結構,因此我們外部要添加上拉電阻。對于開漏電路外部加上拉電阻的話,那就組成了線“與”的關系。總線上線“與”的關系,那所有接入的器件保持高電平,這條線才是高電平。而任意一個器件輸出一個低電平,那這條線就會保持低電平,因此可以做到任何一個器件都可以拉低電平,也就是任何一個器件都可以作為主機,如圖14-1所示,我們添加了R63和R64兩個上拉電阻。 圖14-1 I2C總線的上拉電阻 雖然說任何一個設備都可以作為主機,但絕大多數情況下我們都是用微處理器,也就是我們的單片機來做主機,而總線上掛的多個器件,每一個都像電話機一樣有自己唯一的地址,在信息傳輸的過程中,通過這唯一的地址可以正常識別到屬于自己的信息,在我們的KST-51開發板上,就掛接了2個I2C設備,一個是24C02,一個是PCF8591。 我們在學習UART串行通信的時候,知道了我們的通信流程分為起始位、數據位、停止位這三部分,同理在I2C中也有起始信號、數據傳輸和停止信號,如圖14-2所示。
圖14-2 I2C時序流程圖 從圖上可以看出來,I2C和UART時序流程有相似性,也有一定的區別。UART每個字節中,都有一個起始位,8個數據位和1位停止位。而I2C分為起始信號,數據傳輸部分,最后是停止信號。其中數據傳輸部分,可以一次通信過程傳輸很多個字節,字節數是不受限制的,而每個字節的數據最后也跟了一位,這一位叫做應答位,通常用ACK表示,有點類似于UART的停止位。 下面我們一部分一部分的把I2C通信時序進行剖析。之前我們學過了UART,所以學習I2C的過程我盡量拿UART來作為對比,這樣有助于更好的理解。但是有一點大家要理解清楚,就是UART通信雖然我們用了TXD和RXD兩根線,但是實際一次通信,1條線就可以完成,2條線是把發送和接收分開而已,而I2C每次通信,不管是發送還是接收,必須2條線都參與工作才能完成,為了更方便的看出來每一位的傳輸流程,我們把圖14-2改進成圖14-3。
圖14-3 I2C通信流程解析 起始信號:UART通信是從一直持續的高電平出現一個低電平標志起始位;而I2C通信的起始信號的定義是SCL為高電平期間,SDA由高電平向低電平變化產生一個下降沿,表示起始信號,如圖14-3中的start部分所示。 數據傳輸:首先,UART是低位在前,高位在后;而I2C通信是高位在前,低位在后。第二,UART通信數據位是固定長度,波特率分之一,一位一位固定時間發送完畢就可以了。而I2C沒有固定波特率,但是有時序的要求,要求當SCL在低電平的時候,SDA允許變化,也就是說,發送方必須先保持SCL是低電平,才可以改變數據線SDA,輸出要發送的當前數據的一位;而當SCL在高電平的時候,SDA絕對不可以變化,因為這個時候,接收方要來讀取當前SDA的電平信號是0還是1,因此要保證SDA的穩定不變化,如圖14-3中的每一位數據的變化,都是在SCL的低電平位置。8為數據位后邊跟著的是一位響應位,響應位我們后邊還要具體介紹。 停止信號:UART通信的停止位是一位固定的高電平信號;而I2C通信停止信號的定義是SCL為高電平期間,SDA由低電平向高電平變化產生一個上升沿,表示結束信號,如圖14-3中的stop部分所示。 14.2 I2C尋址模式上一節介紹的是I2C每一位信號的時序流程,而I2C通信在字節級的傳輸中,也有固定的時序要求。I2C通信的起始信號(Start)后,首先要發送一個從機的地址,這個地址一共有7位,緊跟著的第8位是數據方向位(R/W),‘0’表示接下來要發送數據(寫),‘1’表示接下來是請求數據(讀)。 我們知道,打電話的時候,當撥通電話,接聽方撿起電話肯定要回一個“喂”,這就是告訴撥電話的人,這邊有人了。同理,這個第九位ACK實際上起到的就是這樣一個作用。當我們發送完了這7位地址和1位方向位,如果我們發送的這個地址確實存在,那么這個地址的器件應該回應一個ACK‘0’,如果不存在,就沒“人”回應ACK。 那我們寫一個簡單的程序,訪問一下我們板子上的EEPROM的地址,另外在寫一個不存在的地址,看看他們是否能回一個ACK,來了解和確認一下這個問題。 我們板子上的EEPROM器件型號是24C02,在24C02的數據手冊3.6部分說明了,24C02的7位地址中,其中高4位是固定的1010,而低3位的地址取決于我們電路的設計,由芯片上的A2、A1、A0這3個引腳的實際電平決定,來看一下我們的24C02的電路圖,如圖14-4所示。 圖14-4 24C02原理圖 從圖14-4可以看出來,我們的A2、A1、A0都是接的GND,也就是說都是0,因此我們的7位地址實際上是二進制的1010000,也就是0x50。我們用I2C的協議來尋址0x50,另外再尋址一個不存在的地址0x62,尋址完畢后,把返回的ACK顯示到我們的1602液晶上,大家對比一下。 /***********************lcd1602.c文件程序源代碼*************************/ #include <reg52.h>
#define LCD1602_DB P0
sbit LCD1602_RS = P1^0; sbit LCD1602_RW = P1^1; sbit LCD1602_E = P1^5;
void LcdWaitReady() //等待液晶準備好 { unsigned char sta;
LCD1602_DB = 0xFF; LCD1602_RS = 0; LCD1602_RW = 1; do { LCD1602_E = 1; sta = LCD1602_DB; //讀取狀態字 LCD1602_E = 0; } while (sta & 0x80); //bit7等于1表示液晶正忙,重復檢測直到其等于0為止 } void LcdWriteCmd(unsigned char cmd) //寫入命令函數 { LcdWaitReady(); LCD1602_RS = 0; LCD1602_RW = 0; LCD1602_DB = cmd; LCD1602_E = 1; LCD1602_E = 0; } void LcdWriteDat(unsigned char dat) //寫入數據函數 { LcdWaitReady(); LCD1602_RS = 1; LCD1602_RW = 0; LCD1602_DB = dat; LCD1602_E = 1; LCD1602_E = 0; } void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str) //顯示字符串,屏幕起始坐標(x,y),字符串指針str { unsigned char addr;
//由輸入的顯示坐標計算顯示RAM的地址 if (y == 0) addr = 0x00 + x; //第一行字符地址從0x00起始 else addr = 0x40 + x; //第二行字符地址從0x40起始
//由起始顯示RAM地址連續寫入字符串 LcdWriteCmd(addr | 0x80); //寫入起始地址 while (*str != '\0') //連續寫入字符串數據,直到檢測到結束符 { LcdWriteDat(*str); str++; } } void LcdInit() //液晶初始化函數 { LcdWriteCmd(0x38); //16*2顯示,5*7點陣,8位數據接口 LcdWriteCmd(0x0C); //顯示器開,光標關閉 LcdWriteCmd(0x06); //文字不動,地址自動+1 LcdWriteCmd(0x01); //清屏 } /*************************main.c文件程序源代碼**************************/ #include <reg52.h> #include <intrins.h>
#define I2CDelay() {_nop_();_nop_();_nop_();_nop_();}
sbit I2C_SCL = P3^7; sbit I2C_SDA = P3^6;
bit I2CAddressing(unsigned char addr); extern void LcdInit(); extern void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str);
void main () { bit ack; unsigned char str[10];
LcdInit(); //初始化液晶
ack = I2CAddressing(0x50); //查詢地址為0x50的器件 str[0] = '5'; //將地址和應答值轉換為字符串 str[1] = '0'; str[2] = ':'; str[3] = (unsigned char)ack + '0'; str[4] = '\0'; LcdShowStr(0, 0, str); //顯示到液晶上
ack = I2CAddressing(0x62); //查詢地址為0x62的器件 str[0] = '6'; //將地址和應答值轉換為字符串 str[1] = '2'; str[2] = ':'; str[3] = (unsigned char)ack + '0'; str[4] = '\0'; LcdShowStr(8, 0, str); //顯示到液晶上
while(1) {} }
void I2CStart() //產生總線起始信號 { I2C_SDA = 1; //首先確保SDA、SCL都是高電平 I2C_SCL = 1; I2CDelay(); I2C_SDA = 0; //先拉低SDA I2CDelay(); I2C_SCL = 0; //再拉低SCL } void I2CStop() //產生總線停止信號 { I2C_SCL = 0; //首先確保SDA、SCL都是低電平 I2C_SDA = 0; I2CDelay(); I2C_SCL = 1; //先拉高SCL I2CDelay(); I2C_SDA = 1; //再拉高SDA I2CDelay(); } bit I2CWrite(unsigned char dat) //I2C總線寫操作,待寫入字節dat,返回值為從機應答位的值 { bit ack; //用于暫存應答位的值 unsigned char mask; //用于探測字節內某一位值的掩碼變量
for (mask=0x80; mask!=0; mask>>=1) //從高位到低位依次進行 { if ((mask&dat) == 0) //該位的值輸出到SDA上 I2C_SDA = 0; else I2C_SDA = 1; I2CDelay(); I2C_SCL = 1; //拉高SCL I2CDelay(); I2C_SCL = 0; //再拉低SCL,完成一個位周期 } I2C_SDA = 1; //8位數據發送完后,主機釋放SDA,以檢測從機應答 I2CDelay(); I2C_SCL = 1; //拉高SCL I2CDelay(); ack = I2C_SDA; //讀取此時的SDA值,即為從機的應答值 I2C_SCL = 0; //再拉低SCL完成應答位,并保持住總線
return ack; //返回從機應答值 } bit I2CAddressing(unsigned char addr) //I2C尋址函數,即檢查地址為addr的器件是否存在,返回值為其應答值,即應答則表示存在,非應答則表示不存在 { bit ack;
I2CStart(); //產生起始位,即啟動一次總線操作 ack = I2CWrite(addr<<1); //器件地址需左移一位,因尋址命令的最低位為讀寫位,用于表示之后的操作是讀或寫 I2CStop(); //不需進行后續讀寫,而直接停止本次總線操作
return ack; } 我們把這個程序在KST-51開發板上運行完畢,會在液晶上邊顯示出來我們預想的結果,主機發送一個存在的從機地址,從機會回復一個應答位;主機如果發送一個不存在的從機地址,就沒有從機應答。 前邊我有提到過有一個利用庫函數_nop_()來進行精確延時,一個_nop_()的時間就是一個機器周期,這個庫函數是包含在了intrins.h這個庫文件中,我們如果要使用這個庫函數,只需要在程序最開始,和包含reg52.h一樣,include<intrins.h>之后,我們程序就可以直接使用這個庫函數了。 還有一點要提一下,I2C通信分為低速模式100kbit/s,快速模式400kbit/s和高速模式3.4Mbit/s。因為所有的I2C器件都支持低速,但卻未必支持另外兩種速度,所以作為通用的I2C程序我們選擇100k這個速率來實現,也就是說實際程序產生的時序必須小于等于100k的時序參數,很明顯也就是要求SCL的高低電平持續時間都不短于5us,因此我們在時序函數中通過插入I2CDelay()這個總線延時函數(它實際上就是4個NOP指令,用define在文件開頭做了定義),加上改變SCL值語句本身占用的至少一個周期,來達到這個速度限制。如果以后需要提高速度,那么只需要減小這里的總線延時時間即可。 此外我們要學習一個發送數據的技巧,就是I2C通信時如何將一個字節的數據發送出去。大家注意寫函數中,我用的那個for循環的技巧。for (mask=0x80; mask!=0; mask>>=1),由于I2C通信是從高位開始發送數據,所以我們先從最高位開始,0x80和dat進行按位與運算,從而得知dat第7位是0還是1,然后右移一位,也就是變成了用0x40和dat按位與運算,得到第6位是0還是1,一直到第0位結束,最終通過if語句,把dat的8位數據依次發送了出去。其他的邏輯大家對照前邊講到的理論知識,認真研究明白就可以了。 1.3 EEPROM的學習在實際的應用中,保存在單片機RAM中的數據,掉電后數據就丟失了,保存在單片機的FLASH中的數據,又不能隨意改變,也就是不能用它來記錄變化的數值。但是在某些場合,我們又確實需要記錄下某些數據,而它們還時常需要改變或更新,掉電之后數據還不能丟失,比如我們的家用電表度數,我們的電視機里邊的頻道記憶,一般都是使用EEPROM來保存數據,特點就是掉電后不丟失。我們板子上使用的這個器件是24C02,是一個容量大小是2Kbit位,也就是256個字節的EEPROM。一般情況下,EEPROM擁有30萬到100萬次的壽命,也就是它可以反復寫入30-100萬次,而讀取次數是無限的。 24C02是一個基于I2C通信協議的器件,因此從現在開始,我們的I2C和我們的EEPROM就要合體了。但是大家要分清楚,I2C是一個通信協議,它擁有嚴密的通信時序邏輯要求,而EEPROM是一個器件,只是這個器件采樣了I2C協議的接口與單片機相連而已,二者并沒有必然的聯系,EEPROM可以用其他接口,I2C也可以用在其它很多器件上。 14.3.1 EEPROM單字節讀寫操作時序1、EEPROM寫數據流程 第一步,首先是I2C的起始信號,接著跟上首字節,也就是我們前邊講的I2C的器件地 址(EERPOM),并且在讀寫方向上選擇“寫”操作。 第二步,發送數據的存儲地址。我們24C02一共256個字節的存儲空間,地址從0x00到0xFF,我們想把數據存儲在哪個位置,此刻寫的就是哪個地址。 第三步,發送要存儲的數據第一個字節,第二個字節......注意在寫數據的過程中,EEPROM每個字節都會回應一個“應答位0”,來告訴我們寫EEPROM數據成功,如果沒有回應答位,說明寫入不成功。 在寫數據的過程中,每成功寫入一個字節,EEPROM存儲空間的地址就會自動加1,當加到0xFF后,再寫一個字節,地址會溢出又變成了0x00。 2、EEPROM讀數據流程 第一步,首先是I2C的起始信號,接著跟上首字節,也就是我們前邊講的I2C的器件地 址(EERPOM),并且在讀寫方向上選擇“寫”操作。這個地方可能有同學會詫異,我們明明是讀數據為何方向也要選“寫”呢?剛才說過了,我們24C02一共有256個地址,我們選擇寫操作,是為了把所要讀的數據的存儲地址先寫進去,告訴EEPROM我們要讀取哪個地址的數據。這就如同我們打電話,先撥總機號碼(EEPROM器件地址),而后還要繼續撥分機號碼(數據地址),而撥分機號碼這個動作,主機仍然是發送方,方向依然是“寫”。 第二步,發送要讀取的數據的地址,注意是地址而非存在EEPROM中的數據,通知EEPROM我要哪個分機的信息。 第三步,重新發送I2C起始信號和器件地址,并且在方向位選擇“讀”操作。 這三步當中,每一個字節實際上都是在“寫”,所以每一個字節EEPROM都會回應一個“應答位0”。 第四步,讀取從器件發回的數據,讀一個字節,如果還想繼續讀下一個字節,就發送一個“應答位ACK(0)”,如果不想讀了,告訴EEPROM,我不想要數據了,別再發數據了,那就發送一個“非應答位NACK(1)”。 和寫操作規則一樣,我們每讀一個字節,地址會自動加1,那如果我們想繼續往下讀,給EEPROM一個ACK(0)低電平,那再繼續給SCL完整的時序,EEPROM會繼續往外送數據。如果我們不想讀了,要告訴EEPROM不要數據了,那我們直接給一個NAK(1)高電平即可。這個地方大家要從邏輯上理解透徹,不能簡單的靠死記硬背了,一定要理解明白。梳理一下幾個要點:A、在本例中單片機是主機,24C02是從機;B、無論是讀是寫,SCL始終都是由主機控制的;C、寫的時候應答信號由從機給出,表示從機是否正確接收了數據;D、讀的時候應答信號則由主機給出,表示是否繼續讀下去。 那我們下面寫一個程序,讀取EEPROM的0x02這個地址上的一個數據,不管這個數據之前是多少,我們都再將讀出來的數據加1,再寫到EEPROM的0x02這個地址上。此外我們將I2C的程序建立一個文件,寫一個I2C.c程序文件,形成我們又一個程序模塊。大家也可以看出來,我們連續的這幾個程序,lcd1602.c文件里的程序都是一樣的,今后我們大家寫1602顯示程序也可以直接拿過去用,大大提高了程序移植的方便性。 /*************************I2C.c文件程序源代碼***************************/ #include <reg52.h> #include <intrins.h>
#define I2CDelay() {_nop_();_nop_();_nop_();_nop_();}
sbit I2C_SCL = P3^7; sbit I2C_SDA = P3^6;
void I2CStart() //產生總線起始信號 { I2C_SDA = 1; //首先確保SDA、SCL都是高電平 I2C_SCL = 1; I2CDelay(); I2C_SDA = 0; //先拉低SDA I2CDelay(); I2C_SCL = 0; //再拉低SCL } void I2CStop() //產生總線停止信號 { I2C_SCL = 0; //首先確保SDA、SCL都是低電平 I2C_SDA = 0; I2CDelay(); I2C_SCL = 1; //先拉高SCL I2CDelay(); I2C_SDA = 1; //再拉高SDA I2CDelay(); } bit I2CWrite(unsigned char dat) //I2C總線寫操作,待寫入字節dat,返回值為應答狀態 { bit ack; //用于暫存應答位的值 unsigned char mask; //用于探測字節內某一位值的掩碼變量
for (mask=0x80; mask!=0; mask>>=1) //從高位到低位依次進行 { if ((mask&dat) == 0) //該位的值輸出到SDA上 I2C_SDA = 0; else I2C_SDA = 1; I2CDelay(); I2C_SCL = 1; //拉高SCL I2CDelay(); I2C_SCL = 0; //再拉低SCL,完成一個位周期 } I2C_SDA = 1; //8位數據發送完后,主機釋放SDA,以檢測從機應答 I2CDelay(); I2C_SCL = 1; //拉高SCL ack = I2C_SDA; //讀取此時的SDA值,即為從機的應答值 I2CDelay(); I2C_SCL = 0; //再拉低SCL完成應答位,并保持住總線
return (~ack); //應答值取反以符合通常的邏輯:0=不存在或忙或寫入失敗,1=存在且空閑或寫入成功 } unsigned char I2CReadNAK() //I2C總線讀操作,并發送非應答信號,返回值為讀到的字節 { unsigned char mask; unsigned char dat;
I2C_SDA = 1; //首先確保主機釋放SDA for (mask=0x80; mask!=0; mask>>=1) //從高位到低位依次進行 { I2CDelay(); I2C_SCL = 1; //拉高SCL if(I2C_SDA == 0) //讀取SDA的值 dat &= ~mask; //為0時,dat中對應位清零 else dat |= mask; //為1時,dat中對應位置1 I2CDelay(); I2C_SCL = 0; //再拉低SCL,以使從機發送出下一位 } I2C_SDA = 1; //8位數據發送完后,拉高SDA,發送非應答信號 I2CDelay(); I2C_SCL = 1; //拉高SCL I2CDelay(); I2C_SCL = 0; //再拉低SCL完成非應答位,并保持住總線
return dat; } unsigned char I2CReadACK() //I2C總線讀操作,并發送應答信號,返回值為讀到的字節 { unsigned char mask; unsigned char dat;
I2C_SDA = 1; //首先確保主機釋放SDA for (mask=0x80; mask!=0; mask>>=1) //從高位到低位依次進行 { I2CDelay(); I2C_SCL = 1; //拉高SCL if(I2C_SDA == 0) //讀取SDA的值 dat &= ~mask; //為0時,dat中對應位清零 else dat |= mask; //為1時,dat中對應位置1 I2CDelay(); I2C_SCL = 0; //再拉低SCL,以使從機發送出下一位 } I2C_SDA = 0; //8位數據發送完后,拉低SDA,發送應答信號 I2CDelay(); I2C_SCL = 1; //拉高SCL I2CDelay(); I2C_SCL = 0; //再拉低SCL完成應答位,并保持住總線
return dat; } /***********************lcd1602.c文件程序源代碼*************************/ #include <reg52.h>
#define LCD1602_DB P0
sbit LCD1602_RS = P1^0; sbit LCD1602_RW = P1^1; sbit LCD1602_E = P1^5;
void LcdWaitReady() //等待液晶準備好 { unsigned char sta;
LCD1602_DB = 0xFF; LCD1602_RS = 0; LCD1602_RW = 1; do { LCD1602_E = 1; sta = LCD1602_DB; //讀取狀態字 LCD1602_E = 0; } while (sta & 0x80); //bit7等于1表示液晶正忙,重復檢測直到其等于0為止 } void LcdWriteCmd(unsigned char cmd) //寫入命令函數 { LcdWaitReady(); LCD1602_RS = 0; LCD1602_RW = 0; LCD1602_DB = cmd; LCD1602_E = 1; LCD1602_E = 0; } void LcdWriteDat(unsigned char dat) //寫入數據函數 { LcdWaitReady(); LCD1602_RS = 1; LCD1602_RW = 0; LCD1602_DB = dat; LCD1602_E = 1; LCD1602_E = 0; } void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str) //顯示字符串,屏幕起始坐標(x,y),字符串指針str { unsigned char addr;
//由輸入的顯示坐標計算顯示RAM的地址 if (y == 0) addr = 0x00 + x; //第一行字符地址從0x00起始 else addr = 0x40 + x; //第二行字符地址從0x40起始
//由起始顯示RAM地址連續寫入字符串 LcdWriteCmd(addr | 0x80); //寫入起始地址 while (*str != '\0') //連續寫入字符串數據,直到檢測到結束符 { LcdWriteDat(*str); str++; } } void LcdInit() //液晶初始化函數 { LcdWriteCmd(0x38); //16*2顯示,5*7點陣,8位數據接口 LcdWriteCmd(0x0C); //顯示器開,光標關閉 LcdWriteCmd(0x06); //文字不動,地址自動+1 LcdWriteCmd(0x01); //清屏 } /************************main.c文件程序源代碼**************************/ #include <reg52.h>
extern void LcdInit(); extern void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str); extern void I2CStart(); extern void I2CStop(); extern unsigned char I2CReadACK(); extern unsigned char I2CReadNAK(); extern bit I2CWrite(unsigned char dat); unsigned char E2ReadByte(unsigned char addr); void E2WriteByte(unsigned char addr, unsigned char dat);
void main () { unsigned char dat; unsigned char str[10];
LcdInit(); //初始化液晶 dat = E2ReadByte(0x02); //讀取指定地址上的一個字節 str[0] = (dat/100) + '0'; //轉換為十進制字符串格式 str[1] = (dat/10%10) + '0'; str[2] = (dat%10) + '0'; str[3] = '\0'; LcdShowStr(0, 0, str); //顯示在液晶上 dat++; //將其數值+1 E2WriteByte(0x02, dat); //再寫回到對應的地址上
while(1) {} }
unsigned char E2ReadByte(unsigned char addr) //讀取EEPROM中的一個字節,字節地址addr { unsigned char dat;
I2CStart(); I2CWrite(0x50<<1); //尋址器件,后續為寫操作 I2CWrite(addr); //寫入存儲地址 I2CStart(); //發送重復啟動信號 I2CWrite((0x50<<1)|0x01); //尋址器件,后續為讀操作 dat = I2CReadNAK(); //讀取一個字節數據 I2CStop();
return dat; }
void E2WriteByte(unsigned char addr, unsigned char dat) //向EEPROM中寫入一個字節,字節地址addr { I2CStart(); I2CWrite(0x50<<1); //尋址器件,后續為寫操作 I2CWrite(addr); //寫入存儲地址 I2CWrite(dat); //寫入一個字節數據 I2CStop(); } /***********************************************************************/ 這個程序,以同學們現在的基礎,獨立分析應該不困難了,遇到哪個語句不懂可以及時問問別人或者搜索一下,把該解決的問題理解明白。大家把這個程序復制過去后,編譯一下會發現Keil軟件提示了一個警告:*** WARNING L16: UNCALLED SEGMENT, IGNORED FOR OVERLAY PROCESS,這個警告的意思是有我們代碼中存在沒有被調用過的變量或者函數。 大家仔細觀察一下,這個程序,我們讀取EEPROM的時候,只讀了一個字節我們就要告訴EEPROM不需要再讀數據了,因此我們讀完后直接回復一個“NAK”,因此我們只調用了I2CReadNAK()這個函數,而并沒有調用I2CReadACK()這個函數。我們今后很可能讀數據的時候要連續讀幾個字節,因此這個函數寫在了I2C.c文件中,作為I2C功能模塊的一部分是必要的,方便我們這個文件以后移植到其他程序中使用,因此這個警告在這里就不必管它了。 14.3.2 EEPROM多字節讀寫操作時序[size=14.0000pt]我們讀取EEPROM的時候很簡單,EEPROM根據我們所送的時序,直接就把數據送出來了,但是寫EEPROM卻沒有這么簡單。我們如果給EEPROM發送數據后,先保存在了EEPROM的緩存,EEPROM必須要把緩存中的數據搬移到“非易失”的區域,才能達到掉電不丟失的效果。而往非易失區域寫需要一定的時間,每種器件不完全一樣,ATMEL公司的24C02的這個寫入時間最高不超過5ms。在往非易失區域寫的過程,EEPROM是不會再響應我們的訪問的,不僅接收不到我們的數據,我們即使用I2C標準的尋址模式去尋址,EEPROM都不會應答,就如同這個總線上沒有這個器件一樣。數據寫入非易失區域完畢后,EEPROM再次恢復正常,可以正常讀寫了。 細心的同學,在看上一節程序的時候會發現,我們寫數據的那段代碼,實際上我們有去讀應答位ACK,但是讀到了應答位我們也沒有做任何處理。這是因為我們一次只寫一個字節的數據進去,等到下次重新上電再寫的時候,時間肯定遠遠超過了5ms,但是如果我們是連續寫入幾個字節的時候,我們就必須得考慮到應答位的問題了。寫入一個字節后,再寫入下一個字節之前,我們必須要等待EEPROM再次響應才可以,大家注意我的程序的寫法,可以學習一下。 之前我們知道編寫多.c文件移植的方便性了,本節程序和上一節的lcd1602.c文件和I2C.c文件完全是一樣的,因此這次我們只把main.c文件給大家發出來,幫大家分析明白。而同學們卻不能這樣,同學們是初學,很多知識和技巧需要多練才能鞏固下來,因此每個程序還是建議大家在你的Keil軟件上一個代碼一個代碼的敲出來。
/***********************lcd1602.c文件程序源代碼*************************/ 略 /************************I2C.c文件程序源代碼***************************/ 略 /************************main.c文件程序源代碼**************************/
#include <reg52.h>
extern void LcdInit(); extern void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str); extern void I2CStart(); extern void I2CStop(); extern unsigned char I2CReadACK(); extern unsigned char I2CReadNAK(); extern bit I2CWrite(unsigned char dat); void E2Read(unsigned char *buf, unsigned char addr, unsigned char len); void E2Write(unsigned char *buf, unsigned char addr, unsigned char len); void ArrayToHexStr(unsigned char *str, unsigned char *array, unsigned char len);
void main () { unsigned char i; unsigned char buf[5]; unsigned char str[20];
LcdInit(); //初始化液晶 E2Read(buf, 0x90, sizeof(buf)); //從E2中讀取一段數據 ArrayToHexStr(str, buf, sizeof(buf)); //轉換為十六進制字符串 LcdShowStr(0, 0, str); //顯示到液晶上 for (i=0; i<sizeof(buf); i++) //數據依次+1,+2,+3... { buf[ i] = buf[ i] + 1 + i; } E2Write(buf, 0x90, sizeof(buf)); //再寫回到E2中
while(1) {} }
void ArrayToHexStr(unsigned char *str, unsigned char *array, unsigned char len) //把一個字節數組轉換為十六進制字符串的格式 { unsigned char tmp;
while (len--) { tmp = *array >> 4; //先取高4位 if (tmp <= 9) //轉換為0-9或A-F *str = tmp + '0'; else *str = tmp - 10 + 'A'; str++; tmp = *array & 0x0F; //再取低4位 if (tmp <= 9) //轉換為0-9或A-F *str = tmp + '0'; else *str = tmp - 10 + 'A'; str++; *str = ' '; //轉換完一個字節添加一個空格 str++; array++; } } void E2Read(unsigned char *buf, unsigned char addr, unsigned char len) //E2讀取函數,數據接收指針buf,E2中的起始地址addr,讀取長度len { do { //用尋址操作查詢當前是否可進行讀寫操作 I2CStart(); if (I2CWrite(0x50<<1)) //器件應答則跳出循環,繼續執行,非應答則進行下一次查詢 break; I2CStop(); } while(1); I2CWrite(addr); //寫入起始地址 I2CStart(); //發送重復啟動信號 I2CWrite((0x50<<1)|0x01); //尋址器件,后續為讀操作 while (len > 1) //連續讀取len-1個字節 { *buf = I2CReadACK(); //最后字節之前為讀取操作+應答 buf++; len--; } *buf = I2CReadNAK(); //最后一個字節為讀取操作+非應答 I2CStop(); }
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len) //E2寫入函數,源數據指針buf,E2中的起始地址addr,寫入長度len { while (len--) { do { //用尋址操作查詢當前是否可進行讀寫操作,即等待上一次寫入操作完成 I2CStart(); if (I2CWrite(0x50<<1)) //器件應答則跳出循環,繼續執行,非應答則進行下一次查詢 break; I2CStop(); } while(1); I2CWrite(addr); //寫入起始地址 I2CWrite(*buf); //寫入一個字節數據 I2CStop(); //結束寫操作,以等待寫入完成 buf++; //數據指針遞增 addr++; //E2地址遞增 } }
函數ArrayToHexStr:這是一個把數組轉換成十六進制字符串的形式。由于我們從EEPROM讀出來的是正常的數據,而1602液晶接收的是ASCII碼字符,因此我們要通過液晶把數據顯示出來必須先通過一步轉換。算法倒是很簡單,就是把每一個字節的數據高4位和低4位分開,和9進行比較,如果小于等于9,則通過數字加’0’轉ASCII碼發送;如果大于9,則通過加’A’轉ASCII碼發送出去。 函數E2Read:我們在讀之前,要查詢一下當前是否可以進行讀寫操作,EEPROM正常響應才可以進行。進行后,最后一個字節之前的,全部給出ACK,而讀完了最后一個字節,我們要給出一個NAK。 函數E2Write:每次寫操作之前,我們都要進行查詢判斷當前EEPROM是否響應,正常響應后才可以寫數據。 14.3.3 EEPROM的頁寫入如果每個數據都連續寫入,像我們上節課那樣寫的時候,每次都先起始位,再訪問一下這個EEPROM的地址,看看是否響應,感覺上效率太低了。因此EEPROM的廠商就想了一個辦法,把EEPROM分頁管理。24c01、24c02這兩個型號是8個字節一個頁,而24c04、24c08、24c16是16個字節一頁。我們板子上的型號是24C02,一共是256個字節,8個字節一頁,那么就一共有32頁。 分配好頁之后,如果我們在同一個頁內連續寫入幾個字節后,最后再發送停止位的時序。EEPROM檢測到這個停止位后,統一把這一頁的數據寫到非易失區域,就不需要像上節課那樣寫一個字節檢測一次了,并且頁寫入的時間也不會超過5ms。如果我們寫入的數據跨頁了,那么寫完了一頁之后,我們要發送一個停止位,然后等待并且檢測EEPROM的空閑模式,一直等到把上一頁數據完全寫到非易失區域后,再進行下一頁的寫入,這樣就可以在一定程度上提高我們的寫入效率。
/***********************lcd1602.c文件程序源代碼*************************/ 略 /************************I2C.c文件程序源代碼***************************/ 略 /***********************eeprom.c文件程序源代碼*************************/
#include <reg52.h>
extern void I2CStart(); extern void I2CStop(); extern unsigned char I2CReadACK(); extern unsigned char I2CReadNAK(); extern bit I2CWrite(unsigned char dat);
void E2Read(unsigned char *buf, unsigned char addr, unsigned char len) //E2讀取函數,數據接收指針buf,E2中的起始地址addr,讀取長度len { do { //用尋址操作查詢當前是否可進行讀寫操作 I2CStart(); if (I2CWrite(0x50<<1)) //器件應答則跳出循環,繼續執行,非應答則進行下一次查詢 break; I2CStop(); } while(1); I2CWrite(addr); //寫入起始地址 I2CStart(); //發送重復啟動信號 I2CWrite((0x50<<1)|0x01); //尋址器件,后續為讀操作 while (len > 1) //連續讀取len-1個字節 { *buf = I2CReadACK(); //最后字節之前為讀取操作+應答 buf++; len--; } *buf = I2CReadNAK(); //最后一個字節為讀取操作+非應答 I2CStop(); }
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len) //E2寫入函數,源數據指針buf,E2中的起始地址addr,寫入長度len { while (len > 0) { //等待上次寫入操作完成 do { I2CStart(); if (I2CWrite(0x50<<1)) //器件應答則跳出循環,繼續執行,非應答則進行下一次查詢 break; I2CStop(); } while(1); //按頁寫模式連續寫入字節 I2CWrite(addr); //寫入起始地址 while (len > 0) { I2CWrite(*buf); //寫入一個字節數據 len--; //待寫入長度計數遞減 buf++; //數據指針遞增 addr++; //E2地址遞增 if ((addr&0x07) == 0) //檢查地址是否到達頁邊界,24C02每頁8字節,所以檢測低3位是否為零即可 break; //到達頁邊界時,跳出循環,結束本次寫操作 } I2CStop(); } } 這個eeprom.c文件中的程序,單獨做一個文件,用來管理eeprom的訪問。其中E2Read函數和上一節是一樣的,因為讀操作和是否同一頁無關。重點是E2Write函數,我們在寫入數據的時候,要計算下一個要寫的數據的地址是否是一個頁的起始地址,如果是的話,則必須跳出循環,等待EEPROM上一頁寫入到非易失區域后,再進行繼續寫入。 而寫了eeprom.c后,main.c文件里的程序就要變的簡單多了,大家可以自己看一下,不需要過多解釋了。 /************************main.c文件程序源代碼**************************/ #include <reg52.h>
extern void LcdInit(); extern void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str); extern void E2Read(unsigned char *buf, unsigned char addr, unsigned char len); extern void E2Write(unsigned char *buf, unsigned char addr, unsigned char len); void ArrayToHexStr(unsigned char *str, unsigned char *array, unsigned char len);
void main () { unsigned char i; unsigned char buf[5]; unsigned char str[20];
LcdInit(); //初始化液晶 E2Read(buf, 0x8E, sizeof(buf)); //從E2中讀取一段數據 ArrayToHexStr(str, buf, sizeof(buf)); //轉換為十六進制字符串 LcdShowStr(0, 0, str); //顯示到液晶上 for (i=0; i<sizeof(buf); i++) //數據依次+1,+2,+3... { buf[ i] = buf[ i] + 1 + i; } E2Write(buf, 0x8E, sizeof(buf)); //再寫回到E2中
while(1) {} }
void ArrayToHexStr(unsigned char *str, unsigned char *array, unsigned char len) //把一個字節數組轉換為十六進制字符串的格式 { unsigned char tmp;
while (len--) { tmp = *array >> 4; //先取高4位 if (tmp <= 9) //轉換為0-9或A-F *str = tmp + '0'; else *str = tmp - 10 + 'A'; str++; tmp = *array & 0x0F; //再取低4位 if (tmp <= 9) //轉換為0-9或A-F *str = tmp + '0'; else *str = tmp - 10 + 'A'; str++; *str = ' '; //轉換完一個字節添加一個空格 str++; array++; } } 多字節寫入和頁寫入程序都編寫出來了,而且頁寫入的程序我們還特地跨頁寫的數據,他們的寫入時間到底差別多大呢。我們用一些工具可以測量一下,比如示波器,邏輯分析儀等工具。我現在把兩次寫入時間用邏輯分析儀給抓了出來,并且用時間標簽T1和T2給標注了開始位置和結束位置,如圖14-5和圖14-6所示,右側顯示的|T1-T2|就是最終寫入5個字節所耗費的時間。多字節一個一個寫入,每次寫入后都需要再次通信檢測EEPROM是否在“忙”,因此耗費了大量的時間,同樣的寫入5個字節的數據,一個一個寫入用了8.4ms左右的時間,而使用頁寫入,只用了3.5ms左右的時間。 圖14-5 多字節寫入時間 圖14-6 跨頁寫入時間 14.4 I2C和EEPROM的綜合實驗學習[size=14.0000pt]電視頻道記憶功能,交通燈倒計時時間的設定,戶外LED廣告的記憶功能,都有可能有類似EEPROM這類存儲器件。這類器件的優勢是存儲的數據不僅可以改變,而且掉電后數據保存不丟失,因此大量應用在各種電子產品上。 我們這節課的例程,有點類似廣告屏。上電后,1602的第一行顯示EEPROM從0x20地址開始的16個字符,第二行顯示EERPOM從0x40開始的16個字符。我們可以通過UART串口通信來改變EEPROM內部的這個數據,并且同時改變了1602顯示的內容,下次上電的時候,直接會顯示我們更新過的內容。 這個程序所有的相關內容,我們之前都已經講過了。但是這個程序體現在了一個綜合程序應用能力上。這個程序用到了1602液晶、UART實用串口通信、EEPROM讀寫操作等多個功能的綜合應用。寫個點亮小燈好簡單,但是我們想學會真正的單片機,必須得學會這種綜合程序的應用,實現多個模塊同時參與工作,這個理念在我們的全板子測試視頻里已經有所體現。因此同學們,要認認真真的把工程建立起來,一行一行的把程序編寫起來,最終鞏固下來。
/***********************lcd1602.c文件程序源代碼*************************/ 略 /************************I2C.c文件程序源代碼***************************/ 略 /***********************eeprom.c文件程序源代碼*************************/ 略 /************************uart.c文件程序源代碼***************************/
#include <reg52.h>
bit flagOnceTxd = 0; //單次發送完成標志,即發送完一個字節 bit cmdArrived = 0; //命令到達標志,即接收到上位機下發的命令 unsigned char cntRxd = 0; unsigned char pdata bufRxd[40]; //串口接收緩沖區
extern void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str); extern void E2Write(unsigned char *buf, unsigned char addr, unsigned char len);
void ConfigUART(unsigned int baud) //串口配置函數,baud為波特率 { SCON = 0x50; //配置串口為模式1 TMOD &= 0x0F; //清零T1的控制位 TMOD |= 0x20; //配置T1為模式2 TH1 = 256 - (11059200/12/32) / baud; //計算T1重載值 TL1 = TH1; //初值等于重載值 ET1 = 0; //禁止T1中斷 ES = 1; //使能串口中斷 TR1 = 1; //啟動T1 } unsigned char UartRead(unsigned char *buf, unsigned char len) //串口數據讀取函數,數據接收指針buf,讀取數據長度len,返回值為實際讀取到的數據長度 { unsigned char i;
if (len > cntRxd) //讀取長度大于接收到的數據長度時, { len = cntRxd; //讀取長度設置為實際接收到的數據長度 } for (i=0; i<len; i++) //拷貝接收到的數據 { *buf = bufRxd[ i]; buf++; } cntRxd = 0; //清零接收計數器
return len; //返回實際讀取長度 } void UartWrite(unsigned char *buf, unsigned char len) //串口數據寫入函數,即串口發送函數,待發送數據指針buf,數據長度len { while (len--) { flagOnceTxd = 0; SBUF = *buf; buf++; while (!flagOnceTxd); } }
bit CmdCompare(unsigned char *buf, const unsigned char *cmd) //命令比較函數,緩沖區數據與指定命令比較,相同返回1,不同返回0 { while (*cmd != '\0') { if (*cmd != *buf) //遇到不相同字符時即刻返回0 { return 0; } else //當前字符相等時,指針遞增準備比較下一字符 { cmd++; buf++; } } return 1; //到命令字符串結束時字符都相等則返回1 } void TrimString16(unsigned char *out, unsigned char *in) //將一字符串整理成16字節的固定長度字符串,不足部分補空格 { unsigned char i = 0;
while (*in != '\0') //拷貝字符串直到輸入字符串結束 { *out = *in; out++; in++; i++; if (i >= 16) //當拷貝長度已達到16字節時,強制跳出循環 break; } for ( ; i<16; i++) //如不足16個字節則用空格補齊 { *out = ' '; out++; } *out = '\0'; //最后添加結束符 } void UartDriver() //串口驅動函數,檢測接收到的命令并執行相應動作 { unsigned char i; unsigned char len; unsigned char buf[30]; unsigned char str[17]; const unsigned char code cmd0[] = "showstr1 "; const unsigned char code cmd1[] = "showstr2 "; const unsigned char code *cmdList[] = {cmd0, cmd1};
if (cmdArrived) //有命令到達時,讀取處理該命令 { cmdArrived = 0; for (i=0; i<sizeof(buf); i++) //清零命令接收緩沖區 { buf[ i] = 0; } len = UartRead(buf, sizeof(buf)); //將接收到的命令讀取到緩沖區中 for (i=0; i<sizeof(cmdList)/sizeof(cmdList[0]); i++) //與所支持的命令列表逐一進行比較 { if (CmdCompare(buf, cmdList[ i]) == 1) //檢測到相符命令時退出循環,此時的i值就是該命令在列表中的下標值 { break; } } switch (i) //根據比較結果執行相應命令 { case 0: buf[len] = '\0'; //為接收到的字符串添加結束符 TrimString16(str, buf+sizeof(cmd0)-1); //整理成16字節的固定長度字符串,不足部分補空格 LcdShowStr(0, 0, str); //顯示字符串1 E2Write(str, 0x20, sizeof(str)); //保存字符串1,其E2起始地址為0x20 break; case 1: buf[len] = '\0'; TrimString16(str, buf+sizeof(cmd1)-1); LcdShowStr(0, 1, str); E2Write(str, 0x40, sizeof(str)); //保存字符串2,其E2起始地址為0x40 break; default: //i大于命令列表最大下標時,即表示沒有相符的命令,給上機發送“錯誤命令”的提示 UartWrite("bad command.\r\n", sizeof("bad command.\r\n")-1); return; } buf[len++] = '\r'; //有效命令被執行后,在原命令幀之后添加回車換行符后返回給上位機,表示已執行 buf[len++] = '\n'; UartWrite(buf, len); } }
void UartRxMonitor(unsigned char ms) //串口接收監控函數 { static unsigned char cntbkp = 0; static unsigned char idletmr = 0;
if (cntRxd > 0) //接收計數器大于零時,監控總線空閑時間 { if (cntbkp != cntRxd) //接收計數器改變,即剛接收到數據時,清零空閑計時 { cntbkp = cntRxd; idletmr = 0; } else { if (idletmr < 30) //接收計數器未改變,即總線空閑時,累積空閑時間 { idletmr += ms; if (idletmr >= 30) //空閑時間超過30ms即認為一幀命令接收完畢 { cmdArrived = 1; //設置命令到達標志 } } } } else { cntbkp = 0; } } void InterruptUART() interrupt 4 //UART中斷服務函數 { if (RI) //接收到字節 { RI = 0; //手動清零接收中斷標志位 if (cntRxd < sizeof(bufRxd)) //接收緩沖區尚未用完時, { bufRxd[cntRxd++] = SBUF; //保存接收字節,并遞增計數器 } } if (TI) //字節發送完畢 { TI = 0; //手動清零發送中斷標志位 flagOnceTxd = 1; //設置單次發送完成標志 } } /************************main.c文件程序源代碼**************************/
#include <reg52.h>
unsigned char T0RH = 0; //T0重載值的高字節 unsigned char T0RL = 0; //T0重載值的低字節
extern void LcdInit(); extern void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str); extern void E2Read(unsigned char *buf, unsigned char addr, unsigned char len); extern void ConfigUART(unsigned int baud); extern void UartRxMonitor(unsigned char ms); extern void UartDriver(); void ConfigTimer0(unsigned int ms); void InitShowStr();
void main () { EA = 1; //開總中斷 ConfigTimer0(1); //配置T0定時1ms ConfigUART(9600); //配置波特率為9600 LcdInit(); //初始化液晶 InitShowStr(); //初始顯示內容
while(1) { UartDriver(); } }
void InitShowStr() //處理液晶屏初始顯示內容 { unsigned char str[17];
str[16] = '\0'; //在最后添加字符串結束符,確保字符串可以結束 E2Read(str, 0x20, 16); //讀取第一行字符串,其E2起始地址為0x20 LcdShowStr(0, 0, str); //顯示到液晶屏 E2Read(str, 0x40, 16); //讀取第二行字符串,其E2起始地址為0x40 LcdShowStr(0, 1, str); //顯示到液晶屏 } void ConfigTimer0(unsigned int ms) //T0配置函數 { unsigned long tmp;
tmp = 11059200 / 12; //定時器計數頻率 tmp = (tmp * ms) / 1000; //計算所需的計數值 tmp = 65536 - tmp; //計算定時器重載值 tmp = tmp + 18; //修正中斷響應延時造成的誤差
T0RH = (unsigned char)(tmp >> 8); //定時器重載值拆分為高低字節 T0RL = (unsigned char)tmp; TMOD &= 0xF0; //清零T0的控制位 TMOD |= 0x01; //配置T0為模式1 TH0 = T0RH; //加載T0重載值 TL0 = T0RL; ET0 = 1; //使能T0中斷 TR0 = 1; //啟動T0 } void InterruptTimer0() interrupt 1 //T0中斷服務函數 { TH0 = T0RH; //定時器重新加載重載值 TL0 = T0RL; UartRxMonitor(1); //串口接收監控 } 我們在學習UART通信的時候,剛開始也是用的IO口去模擬UART通信過程,最終實現和電腦的通信。而后我們的STC89C52RC由于內部具備了UART硬件通信模塊,所以我們直接可以通過配置寄存器就可以很輕松的實現單片機的UART通信。同樣的道理,我們這個I2C通信,如果我們單片機內部有硬件模塊的話,單片機可以直接自動實現I2C通信了,就不需要我們再進行IO口模擬起始、模擬發送、模擬結束,配置好寄存器,單片機就會把這些工作全部做了。 不過我們的STC89C52RC單片機內部不具備I2C的硬件模塊,所以我們使用STC89C52RC單片機進行I2C通信必須用IO口來模擬。使用IO口模擬I2C,實際上更有利于我們徹底理解透徹I2C通信的實質。當然了,通過學習IO口模擬通信,今后我們如果遇到內部帶I2C模塊的單片機,也應該很輕松的搞定,使用內部的硬件模塊,可以提高程序的執行效率。 14.5 作業1、徹底理解I2C的通信時序,不僅僅是記住。 2、能夠獨立完成EEPROM任意地址的單字節讀寫、多字節的跨頁連續寫入讀出。 3、將前邊學的交通燈進行改進,使用EEPROM保存紅燈和綠燈倒計時的時間,并且可以通過UART改變紅燈和綠燈倒計時時間。 4、使用按鍵、1602液晶、EEPROM做一個簡單的密碼鎖程序。 |