如果你和一個優秀的程序員共事,你會發現他對他使用的工具非常熟悉,就像一個畫家了解他的畫具一樣。----比爾.蓋茨
1 不能簡單的認為是個工具
嵌入式程序開發跟硬件密切相關,需要使用C語言來讀寫底層寄存器、存取數據、控制硬件等,C語言和硬件之間由編譯器來聯系,一些C標準不支持的硬件特性操作,由編譯器提供。
匯編可以很輕易的讀寫指定RAM地址、可以將代碼段放入指定的Flash地址、可以精確的設置變量在RAM中分布等等,所有這些操作,在深入了解編譯器后,也可以使用C語言實現。
C語言標準并非完美,有著數目繁多的未定義行為,這些未定義行為完全由編譯器自主決定,了解你所用的編譯器對這些未定義行為的處理,是必要的。
嵌入式編譯器對調試做了優化,會提供一些工具,可以分析代碼性能,查看外設組件等,了解編譯器的這些特性有助于提高在線調試的效率。
此外,堆棧操作、代碼優化、數據類型的范圍等等,都是要深入了解編譯器的理由。
如果之前你認為編譯器只是個工具,能夠編譯就好。那么,是時候改變這種思想了。
2 不能依賴編譯器的語義檢查
編譯器的語義檢查很弱小,甚至還會“掩蓋”錯誤。現代的編譯器設計是件浩瀚的工程,為了讓編譯器設計簡單一些,目前幾乎所有編譯器的語義檢查都比較弱小。為了獲得更快的執行效率,C語言被設計的足夠靈活且幾乎不進行任何運行時檢查,比如數組越界、指針是否合法、運算結果是否溢出等等。這就造成了很多編譯正確但執行奇怪的程序。
C語言足夠靈活,對于一個數組test[30],它允許使用像test[-1]這樣的形式來快速獲取數組首元素所在地址前面的數據;允許將一個常數強制轉換為函數指針,使用代碼(((void()())0))()來調用位于0地址的函數。C語言給了程序員足夠的自由,但也由程序員承擔濫用自由帶來的責任。
2.1莫名的死機
下面的兩個例子都是死循環,如果在不常用分支中出現類似代碼,將會造成看似莫名其妙的死機或者重啟。
unsigned?char?i;????//例程1? for(i=0;i<256;i++) { //其它代碼 ?}
unsigned char i; //例程2 for(i=10;i>=0;i--) { //其它代碼 }
對于無符號char類型,表示的范圍為0~255,所以無符號char類型變量i永遠小于256(第一個for循環無限執行),永遠大于等于0(第二個for循環無限執行)。需要說明的是,賦值代碼i=256是被C語言允許的,即使這個初值已經超出了變量i可以表示的范圍。C語言會千方百計的為程序員創造出錯的機會,可見一斑。
2.2不起眼的改變
假如你在if語句后誤加了一個分號,可能會完全改變了程序邏輯。編譯器也會很配合的幫忙掩蓋,甚至連警告都不提示。代碼如下:
if(a>b); //這里誤加了一個分號 a=b; //這句代碼一直被執行
不但如此,編譯器還會忽略掉多余的空格符和換行符,就像下面的代碼也不會給出足夠提示:
這段代碼的本意是n<3時程序直接返回,由于程序員的失誤,return少了一個結束分號。編譯器將它翻譯成返回表達式logrec.data=x[0]的結果,return后面即使是一個表達式也是C語言允許的。這樣當n>=3時,表達式logrec.data=x[0];就不會被執行,給程序埋下了隱患。
2.3 難查的數組越界
上文曾提到數組常常是引起程序不穩定的重要因素,程序員往往不經意間就會寫數組越界。
一位同事的代碼在硬件上運行,一段時間后就會發現LCD顯示屏上的一個數字不正常的被改變。經過一段時間的調試,問題被定位到下面的一段代碼中:
int SensorData[30]; //其他代碼 for(i=30;i>0;i--) { SensorData[i]=…; //其他代碼 }
這里聲明了擁有30個元素的數組,不幸的是for循環代碼中誤用了本不存在的數組元素SensorData[30],但C語言卻默許這么使用,并欣然的按照代碼改變了數組元素SensorData[30]所在位置的值, SensorData[30]所在的位置原本是一個LCD顯示變量,這正是顯示屏上的那個值不正常被改變的原因。真慶幸這么輕而易舉的發現了這個Bug。
其實很多編譯器會對上述代碼產生一個警告:賦值超出數組界限。但并非所有程序員都對編譯器警告保持足夠敏感,況且,編譯器也并不能檢查出數組越界的所有情況。比如下面的例子:
你在模塊A中定義數組:
int SensorData[30];
在模塊B中引用該數組,但由于你引用代碼并不規范,這里沒有顯示聲明數組大小,但編譯器也允許這么做:
extern int SensorData[];
這次,編譯器不會給出警告信息,因為編譯器壓根就不知道數組的元素個數。所以,當一個數組聲明為具有外部鏈接,它的大小應該顯式聲明。
再舉一個編譯器檢查不出數組越界的例子。函數func()的形參是一個數組形式,函數代碼簡化如下所示:
這個給SensorData[30]賦初值的語句,編譯器也是不給任何警告的。實際上,編譯器是將數組名Sensor隱含的轉化為指向數組第一個元素的指針,函數體是使用指針的形式來訪問數組的,它當然也不會知道數組元素的個數了。造成這種局面的原因之一是C編譯器的作者們認為指針代替數組可以提高程序效率,而且,可以簡化編譯器的復雜度。
指針和數組是容易給程序造成混亂的,我們有必要仔細的區分它們的不同。其實換一個角度想想,它們也是容易區分的:可以將數組名等同于指針的情況有且只有一處,就是上面例子提到的數組作為函數形參時。其它時候,數組名是數組名,指針是指針。
下面的例子編譯器同樣檢查不出數組越界。
我們常常用數組來緩存通訊中的一幀數據。在通訊中斷中將接收的數據保存到數組中,直到一幀數據完全接收后再進行處理。即使定義的數組長度足夠長,接收數據的過程中也可能發生數組越界,特別是干擾嚴重時。
這是由于外界的干擾破壞了數據幀的某些位,對一幀的數據長度判斷錯誤,接收的數據超出數組范圍,多余的數據改寫與數組相鄰的變量,造成系統崩潰。由于中斷事件的異步性,這類數組越界編譯器無法檢查到。
如果局部數組越界,可能引發ARM架構硬件異常。
同事的一個設備用于接收無線傳感器的數據,一次軟件升級后,發現接收設備工作一段時間后會死機。調試表明ARM7處理器發生了硬件異常,異常處理代碼是一段死循環(死機的直接原因)。接收設備有一個硬件模塊用于接收無線傳感器的整包數據并存在自己的緩沖區中,當硬件模塊接收數據完成后,使用外部中斷通知設備取數據,外部中斷服務程序精簡后如下所示:?
__irq ExintHandler(void) { unsignedchar DataBuf[50]; GetData(DataBug); //從硬件緩沖區取一幀數據 //其他代碼 }
由于存在多個無線傳感器近乎同時發送數據的可能加之GetData()函數保護力度不夠,數組DataBuf在取數據過程中發生越界。由于數組DataBuf為局部變量,被分配在堆棧中,同在此堆棧中的還有中斷發生時的運行環境以及中斷返回地址。溢出的數據將這些數據破壞掉,中斷返回時PC指針可能變成一個不合法值,硬件異常由此產生。
如果我們精心設計溢出部分的數據,化數據為指令,就可以利用數組越界來修改PC指針的值,使之指向我們希望執行的代碼。
1988年,第一個網絡蠕蟲在一天之內感染了2000到6000臺計算機,這個蠕蟲程序利用的正是一個標準輸入庫函數的數組越界Bug。起因是一個標準輸入輸出庫函數gets(),原來設計為從數據流中獲取一段文本,遺憾的是,gets()函數沒有規定輸入文本的長度。
gets()函數內部定義了一個500字節的數組,攻擊者發送了大于500字節的數據,利用溢出的數據修改了堆棧中的PC指針,從而獲取了系統權限。目前,雖然有更好的庫函數來代替gets函數,但gets函數仍然存在著。
2.4神奇的volatile
做嵌入式設備開發,如果不對volatile修飾符具有足夠了解,實在是說不過去。volatile是C語言32個關鍵字中的一個,屬于類型限定符,常用的const關鍵字也屬于類型限定符。
volatile限定符用來告訴編譯器,該對象的值無任何持久性,不要對它進行任何優化;它迫使編譯器每次需要該對象數據內容時都必須讀該對象,而不是只讀一次數據并將它放在寄存器中以便后續訪問之用(這樣的優化可以提高系統速度)。
這個特性在嵌入式應用中很有用,比如你的IO口的數據不知道什么時候就會改變,這就要求編譯器每次都必須真正的讀取該IO端口。這里使用了詞語“真正的讀”,是因為由于編譯器的優化,你的邏輯反應到代碼上是對的,但是代碼經過編譯器翻譯后,有可能與你的邏輯不符。
你的代碼邏輯可能是每次都會讀取IO端口數據,但實際上編譯器將代碼翻譯成匯編時,可能只是讀一次IO端口數據并保存到寄存器中,接下來的多次讀IO口都是使用寄存器中的值來進行處理。因為讀寫寄存器是最快的,這樣可以優化程序效率。與之類似的,中斷里的變量、多線程中的共享變量等都存在這樣的問題。
不使用volatile,可能造成運行邏輯錯誤,但是不必要的使用volatile會造成代碼效率低下(編譯器不優化volatile限定的變量),因此清楚的知道何處該使用volatile限定符,是一個嵌入式程序員的必修內容。
一個程序模塊通常由兩個文件組成,源文件和頭文件。如果你在源文件定義變量:
unsigned int test;
并在頭文件中聲明該變量:
extern unsigned long test;
編譯器會提示一個語法錯誤:變量’ test’聲明類型不一致。但如果你在源文件定義變量:
volatile unsigned int test;
在頭文件中這樣聲明變量:
extern unsigned int test; /*缺少volatile限定符*/
編譯器卻不會給出錯誤信息(有些編譯器僅給出一條警告)。當你在另外一個模塊(該模塊包含聲明變量test的頭文件)使用變量test時,它已經不再具有volatile限定,這樣很可能造成一些重大錯誤。比如下面的例子,注意該例子是為了說明volatile限定符而專門構造出的,因為現實中的volatile使用Bug大都隱含,并且難以理解。
在模塊A的源文件中,定義變量:
volatile unsigned int TimerCount=0;
該變量用來在一個定時器中斷服務程序中進行軟件計時:
TimerCount++;
在模塊A的頭文件中,聲明變量:
extern unsigned int TimerCount; //這里漏掉了類型限定符volatile
在模塊B中,要使用TimerCount變量進行精確的軟件延時:
#include “…A.h” //首先包含模塊A的頭文件 //其他代碼 TimerCount=0; ?while(TimerCount<=TIMER_VALUE);???//延時一段時間(感謝網友chhfish指這里的邏輯錯誤)?? //其他代碼
實際上,這是一個死循環。由于模塊A頭文件中聲明變量TimerCount時漏掉了volatile限定符,在模塊B中,變量TimerCount是被當作unsigned int類型變量。由于寄存器速度遠快于RAM,編譯器在使用非volatile限定變量時是先將變量從RAM中拷貝到寄存器中,如果同一個代碼塊再次用到該變量,就不再從RAM中拷貝數據而是直接使用之前寄存器備份值。
代碼while(TimerCount<=TIMER_VALUE)中,變量TimerCount僅第一次執行時被使用,之后都是使用的寄存器備份值,而這個寄存器值一直為0,所以程序無限循環。下面的流程圖說明了程序使用限定符volatile和不使用volatile的執行過程。
為了更容易的理解編譯器如何處理volatile限定符,這里給出未使用volatile限定符和使用volatile限定符程序的反匯編代碼:
沒有使用關鍵字volatile,在keil MDK V4.54下編譯,默認優化級別,如下所示(注意最后兩行):
122: unIdleCount=0; 123: 0x00002E10 E59F11D4 LDR R1,[PC,#0x01D4] 0x00002E14 E3A05000 MOV R5,#key1(0x00000000) 0x00002E18 E1A00005 MOV R0,R5 0x00002E1C E5815000 STR R5,[R1] 124: while(unIdleCount!=200); //延時2S鐘 125: 0x00002E20 E35000C8 CMP R0,#0x000000C8 0x00002E24 1AFFFFFD BNE 0x00002E20
使用關鍵字volatile,在keil MDK V4.54下編譯,默認優化級別,如下所示(注意最后三行):
122: unIdleCount=0; 123: 0x00002E10 E59F01D4 LDR R0,[PC,#0x01D4] 0x00002E14 E3A05000 MOV R5,#key1(0x00000000) 0x00002E18 E5805000 STR R5,[R0] 124: while(unIdleCount!=200); //延時2S鐘 125: 0x00002E1C E5901000 LDR R1,[R0] 0x00002E20 E35100C8 CMP R1,#0x000000C8 0x00002E24 1AFFFFFC BNE 0x00002E1C
可以看到,如果沒有使用volatile關鍵字,程序一直比較R0內數據與0xC8是否相等,但R0中的數據是0,所以程序會一直在這里循環比較(死循環);再看使用了volatile關鍵字的反匯編代碼,程序會先從變量中讀出數據放到R1寄存器中,然后再讓R1內數據與0xC8相比較,這才是我們C代碼的正確邏輯!
2.5局部變量
ARM架構下的編譯器會頻繁的使用堆棧,堆棧用于存儲函數的返回值、AAPCS規定的必須保護的寄存器以及局部變量,包括局部數組、結構體、聯合體和C++的類。默認情況下,堆棧的位置、初始值都是由編譯器設置,因此需要對編譯器的堆棧有一定了解。
從堆棧中分配的局部變量的初值是不確定的,因此需要運行時顯式初始化該變量。一旦離開局部變量的作用域,這個變量立即被釋放,其它代碼也就可以使用它,因此堆棧中的一個內存位置可能對應整個程序的多個變量。
局部變量必須顯式初始化,除非你確定知道你要做什么。下面的代碼得到的溫度值跟預期會有很大差別,因為在使用局部變量sum時,并不能保證它的初值為0。編譯器會在第一次運行時清零堆棧區域,這加重了此類Bug的隱蔽性。
由于一旦程序離開局部變量的作用域即被釋放,所以下面代碼返回指向局部變量的指針是沒有實際意義的,該指針指向的區域可能會被其它程序使用,其值會被改變。
char * GetData(void) { char buffer[100]; //局部數組 … return buffer; }
2.6使用外部工具
由于編譯器的語義檢查比較弱,我們可以使用第三方代碼分析工具,使用這些工具來發現潛在的問題,這里介紹其中比較著名的是PC-Lint。
PC-Lint由Gimpel Software公司開發,可以檢查C代碼的語法和語義并給出潛在的BUG報告。PC-Lint可以顯著降低調試時間。
目前公司ARM7和Cortex-M3內核多是使用Keil MDK編譯器來開發程序,通過簡單配置,PC-Lint可以被集成到MDK上,以便更方便的檢查代碼。MDK已經提供了PC-Lint的配置模板,所以整個配置過程十分簡單,Keil MDK開發套件并不包含PC-Lint程序,在此之前,需要預先安裝可用的PC-Lint程序,配置過程如下:
點擊菜單Tools---Set-up PC-Lint…
PC-Lint Include Folders:該列表路徑下的文件才會被PC-Lint檢查,此外,這些路徑下的文件內使用#include包含的文件也會被檢查;
Lint Executable:指定PC-Lint程序的路徑
Configuration File:指定配置文件的路徑,該配置文件由MDK編譯器提供。
菜單Tools---Lint 文件路徑.c/.h
檢查當前文件。
菜單Tools---Lint All C-Source Files
檢查所有C源文件。
PC-Lint的輸出信息顯示在MDK編譯器的Build Output窗口中,雙擊其中的一條信息可以跳轉到源文件所在位置。
編譯器語義檢查的弱小在很大程度上助長了不可靠代碼的廣泛存在。隨著時代的進步,現在越來越多的編譯器開發商意識到了語義檢查的重要性,編譯器的語義檢查也越來越強大,比如公司使用的Keil MDK編譯器,雖然它的編輯器依然不盡人意,但在其V4.47及以上版本中增加了動態語法檢查并加強了語義檢查,可以友好的提示更多警告信息。建議經常關注編譯器官方網站并將編譯器升級到V4.47或以上版本,升級的另一個好處是這些版本的編輯器增加了標識符自動補全功能,可以大大節省編碼的時間。
3 你覺得有意義的代碼未必正確
C語言標準特別的規定某些行為是未定義的,編寫未定義行為的代碼,其輸出結果由編譯器決定!C標準委員會定義未定義行為的原因如下:
簡化標準,并給予實現一定的靈活性,比如不捕捉那些難以診斷的程序錯誤;
編譯器開發商可以通過未定義行為對語言進行擴展
C語言的未定義行為,使得C極度高效靈活并且給編譯器實現帶來了方便,但這并不利于優質嵌入式C程序的編寫。因為許多 C 語言中看起來有意義的東西都是未定義的,并且這也容易使你的代碼埋下隱患,并且不利于跨編譯器移植。Java程序會極力避免未定義行為,并用一系列手段進行運行時檢查,使用Java可以相對容易的寫出安全代碼,但體積龐大效率低下。作為嵌入式程序員,我們需要了解這些未定義行為,利用C語言的靈活性,寫出比Java更安全、效率更高的代碼來。
3.1常見的未定義行為
自增自減在表達式中連續出現并作用于同一變量或者自增自減在表達式中出現一次,但作用的變量多次出現
自增(++)和自減(--)這一動作發生在表達式的哪個時刻是由編譯器決定的,比如:
r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];
不同的編譯器可能有著不同的匯編代碼,可能是先執行i++再進行乘法和加法運行,也可能是先進行加法和乘法運算,再執行i++,因為這句代碼在一個表達式中出現了連續的自增并作用于同一變量。更加隱蔽的是自增自減在表達式中出現一次,但作用的變量多次出現,比如:
a[i] = i++; /* 未定義行為 */
先執行i++再賦值,還是先賦值再執行i++是由編譯器決定的,而兩種不同的執行順序的結果差別是巨大的。
函數實參被求值的順序
函數如果有多個實參,這些實參的求值順序是由編譯器決定的,比如:
printf("%d %d ", ++n, power(2, n)); /* 未定義行為 */
是先執行++n還是先執行power(2,n)是由編譯器決定的。
有符號整數溢出
有符號整數溢出是未定義的行為,編譯器決定有符號整數溢出按照哪種方式取值。比如下面代碼:
int value1,value2,sum //其它操作 sum=value1+value; /*sum可能發生溢出*/
有符號數右移、移位的數量是負值或者大于操作數的位數
除數為零
malloc()、calloc()或realloc()分配零字節內存
3.2如何避免C語言未定義行為
代碼中引入未定義行為會為代碼埋下隱患,防止代碼中出現未定義行為是困難的,我們總能不經意間就會在代碼中引入未定義行為。但是還是有一些方法可以降低這種事件,總結如下:
了解C語言未定義行為
標準C99附錄J.2“未定義行為”列舉了C99中的顯式未定義行為,通過查看該文檔,了解那些行為是未定義的,并在編碼中時刻保持警惕;
尋求工具幫助
編譯器警告信息以及PC-Lint等靜態檢查工具能夠發現很多未定義行為并警告,要時刻關注這些工具反饋的信息;
總結并使用一些編碼標準
1)避免構造復雜的自增或者自減表達式,實際上,應該避免構造所有復雜表達式;
比如a[i] = i++;語句可以改為a[i] = i; i++;這兩句代碼。
2)只對無符號操作數使用位操作;
必要的運行時檢查
檢查是否溢出、除數是否為零,申請的內存數量是否為零等等,比如上面的有符號整數溢出例子,可以按照如下方式編寫,以消除未定義特性:
int value1,value2,sum; //其它代碼 if((value1>0 && value2>0 && value1>(INT_MAX-value2))|| (value1<0 && value2<0 && value1<(INT_MIN-value2))) { //處理錯誤 } else { sum=value1+value2; }
上面的代碼是通用的,不依賴于任何CPU架構,但是代碼效率很低。如果是有符號數使用補碼的CPU架構(目前常見CPU絕大多數都是使用補碼),還可以用下面的代碼來做溢出檢查:
int value1, value2, sum; unsigned int usum = (unsigned int)value1 + value2; if((usum ^ value1) & (usum ^ value2) & INT_MIN) { /*處理溢出情況*/ } else { sum = value1 + value2; }
使用的原理解釋一下,因為在加法運算中,操作數value1和value2只有符號相同時,才可能發生溢出,所以我們先將這兩個數轉換為無符號類型,兩個數的和保存在變量usum中。如果發生溢出,則value1、value2和usum的最高位(符號位)一定不同,表達式(usum ^ value1) & (usum ^ value2) 的最高位一定為1,這個表達式位與(&)上INT_MIN是為了將最高位之外的其它位設置為0。
了解你所用的編譯器對未定義行為的處理策略
很多引入了未定義行為的程序也能運行良好,這要歸功于編譯器處理未定義行為的策略。不是你的代碼寫的正確,而是恰好編譯器處理策略跟你需要的邏輯相同。了解編譯器的未定義行為處理策略,可以讓你更清楚的認識到那些引入了未定義行為程序能夠運行良好是多么幸運的事,不然多換幾個編譯器試試!
以Keil MDK為例,列舉常用的處理策略如下:
1) 有符號量的右移是算術移位,即移位時要保證符號位不改變。
2)對于int類的值:超過31位的左移結果為零;無符號值或正的有符號值超過31位的右移結果為零。負的有符號值移位結果為-1。
3)整型數除以零返回零
4 了解你的編譯器
在嵌入式開發過程中,我們需要經常和編譯器打交道,只有深入了解編譯器,才能用好它,編寫更高效代碼,更靈活的操作硬件,實現一些高級功能。下面以公司最常用的Keil MDK為例,來描述一下編譯器的細節。
4.1編譯器的一些小知識
默認情況下,char類型的數據項是無符號的,所以它的取值范圍是0~255;
在所有的內部和外部標識符中,大寫和小寫字符不同;
通常局部變量保存在寄存器中,但當局部變量太多放到棧里的時候,它們總是字對齊的。
壓縮類型的自然對齊方式為1。使用關鍵字__packed來壓縮特定結構,將所有有效類型的對齊邊界設置為1;
整數以二進制補碼形式表示;浮點量按IEEE格式存儲;
整數除法的余數的符號于被除數相同,由ISO C90標準得出;
如果整型值被截斷為短的有符號整型,則通過放棄適當數目的最高有效位來得到結果。如果原始數是太大的正或負數,對于新的類型,無法保證結果的符號將于原始數相同。
整型數超界不引發異常;像unsigned char test; test=1000;這類是不會報錯的;
在嚴格C中,枚舉值必須被表示為整型。例如,必須在?2147483648 到+2147483647的范圍內。但MDK自動使用對象包含enum范圍的最小整型來實現(比如char類型),除非使用編譯器命令??enum_is_int 來強制將enum的基礎類型設為至少和整型一樣寬。超出范圍的枚舉值默認僅產生警告:#66:enumeration value is out of "int" range;
對于結構體填充,根據定義結構的方式,keil MDK編譯器用以下方式的一種來填充結構:
I> 定義為static或者extern的結構用零填充;
II> 棧或堆上的結構,例如,用malloc()或者auto定義的結構,使用先前存儲在那些存儲器位置的任何內容進行填充。不能使用memcmp()來比較以這種方式定義的填充結構!
編譯器不對聲明為volatile類型的數據進行優化;
__nop():延時一個指令周期,編譯器絕不會優化它。如果硬件支持NOP指令,則該句被替換為NOP指令,如果硬件不支持NOP指令,編譯器將它替換為一個等效于NOP的指令,具體指令由編譯器自己決定;
__align(n):指示編譯器在n 字節邊界上對齊變量。對于局部變量,n的值為1、2、4、8;
attribute((at(address))):可以使用此變量屬性指定變量的絕對地址;
__inline:提示編譯器在合理的情況下內聯編譯C或C++ 函數;
4.2初始化的全局變量和靜態變量的初始值被放到了哪里?
我們程序中的一些全局變量和靜態變量在定義時進行了初始化,經過編譯器編譯后,這些初始值被存放在了代碼的哪里?我們舉個例子說明:
?unsigned?int?g_unRunFlag=0xA5;
?static?unsigned?int?s_unCountFlag=0x5A;
我曾做過一個項目,項目中的一個設備需要在線編程,也就是通過協議,將上位機發給設備的數據通過在應用編程(IAP)技術寫入到設備的內部Flash中。我將內部Flash做了劃分,一小部分運行程序,大部分用來存儲上位機發來的數據。隨著程序量的增加,在一次更新程序后發現,在線編程之后,設備運行正常,但是重啟設備后,運行出現了故障!經過一系列排查,發現故障的原因是一個全局變量的初值被改變了。
這是件很不可思議的事情,你在定義這個變量的時候指定了初始值,當你在第一次使用這個變量時卻發現這個初值已經被改掉了!這中間沒有對這個變量做任何賦值操作,其它變量也沒有任何溢出,并且多次在線調試表明,進入main函數的時候,該變量的初值已經被改為一個恒定值。
要想知道為什么全局變量的初值被改變,就要了解這些初值編譯后被放到了二進制文件的哪里。在此之前,需要先了解一點鏈接原理。
ARM映象文件各組成部分在存儲系統中的地址有兩種:一種是映象文件位于存儲器時(通俗的說就是存儲在Flash中的二進制代碼)的地址,稱為加載地址;一種是映象文件運行時(通俗的說就是給板子上電,開始運行Flash中的程序了)的地址,稱為運行時地址。
賦初值的全局變量和靜態變量在程序還沒運行的時候,初值是被放在Flash中的,這個時候他們的地址稱為加載地址,當程序運行后,這些初值會從Flash中拷貝到RAM中,這時候就是運行時地址了。
原來,對于在程序中賦初值的全局變量和靜態變量,程序編譯后,MDK將這些初值放到Flash中,位于緊靠在可執行代碼的后面。在程序進入main函數前,會運行一段庫代碼,將這部分數據拷貝至相應RAM位置。
由于我的設備程序量不斷增加,超過了為設備程序預留的Flash空間,在線編程時,將一部分存儲全局變量和靜態變量初值的Flash給重新編程了。在重啟設備前,初值已經被拷貝到RAM中,所以這個時候程序運行是正常的,但重新上電后,這部分初值實際上是在線編程的數據,自然與初值不同了。
4.3在C代碼中使用的變量,編譯器將他們分配到RAM的哪里?
我們會在代碼中使用各種變量,比如全局變量、靜態變量、局部變量,并且這些變量時由編譯器統一管理的,有時候我們需要知道變量用掉了多少RAM,以及這些變量在RAM中的具體位置。
這是一個經常會遇到的事情,舉一個例子,程序中的一個變量在運行時總是不正常的被改變,那么有理由懷疑它臨近的變量或數組溢出了,溢出的數據更改了這個變量值。要排查掉這個可能性,就必須知道該變量被分配到RAM的哪里、這個位置附近是什么變量,以便針對性的做跟蹤。
其實MDK編譯器的輸出文件中有一個“工程名.map”文件,里面記錄了代碼、變量、堆棧的存儲位置,通過這個文件,可以查看使用的變量被分配到RAM的哪個位置。要生成這個文件,需要在Options for Targer窗口,Listing標簽欄下,勾選Linker Listing前的復選框,如下圖所示。
4.4默認情況下,棧被分配到RAM的哪個地方?
MDK中,我們只需要在配置文件中定義堆棧大小,編譯器會自動在RAM的空閑區域選擇一塊合適的地方來分配給我們定義的堆棧,這個地方位于RAM的那個地方呢?
通過查看MAP文件,原來MDK將堆棧放到程序使用到的RAM空間的后面,比如你的RAM空間從0x4000 0000開始,你的程序用掉了0x200字節RAM,那么堆棧空間就從0x4000 0200處開始。
使用了多少堆棧,是否溢出?
4.5 有多少RAM會被初始化?
在進入main()函數之前,MDK會把未初始化的RAM給清零的,我們的RAM可能很大,只使用了其中一小部分,MDK會不會把所有RAM都初始化呢?
答案是否定的,MDK只是把你的程序用到的RAM以及堆棧RAM給初始化,其它RAM的內容是不管的。如果你要使用絕對地址訪問MDK未初始化的RAM,那就要小心翼翼的了,因為這些RAM上電時的內容很可能是隨機的,每次上電都不同。
4.6 MDK編譯器如何設置非零初始化變量?
對于控制類產品,當系統復位后(非上電復位),可能要求保持住復位前RAM中的數據,用來快速恢復現場,或者不至于因瞬間復位而重啟現場設備。而keil mdk在默認情況下,任何形式的復位都會將RAM區的非初始化變量數據清零。
MDK編譯程序生成的可執行文件中,每個輸出段都最多有三個屬性:RO屬性、RW屬性和ZI屬性。對于一個全局變量或靜態變量,用const修飾符修飾的變量最可能放在RO屬性區,初始化的變量會放在RW屬性區,那么剩下的變量就要放到ZI屬性區了。
默認情況下,ZI屬性區的數據在每次復位后,程序執行main函數內的代碼之前,由編譯器“自作主張”的初始化為零。所以我們要在C代碼中設置一些變量在復位后不被零初始化,那一定不能任由編譯器“胡作非為”,我們要用一些規則,約束一下編譯器。
分散加載文件對于連接器來說至關重要,在分散加載文件中,使用UNINIT來修飾一個執行節,可以避免編譯器對該區節的ZI數據進行零初始化。這是要解決非零初始化變量的關鍵。
因此我們可以定義一個UNINIT修飾的數據節,然后將希望非零初始化的變量放入這個區域中。于是,就有了第一種方法:
修改分散加載文件,增加一個名為MYRAM的執行節,該執行節起始地址為0x1000A000,長度為0x2000字節(8KB),由UNINIT修飾:
LR_IROM1 0x00000000 0x00080000 { ; load region size_region ER_IROM1 0x00000000 0x00080000 { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x10000000 0x0000A000 { ; RW data .ANY (+RW +ZI) } MYRAM 0x1000A000 UNINIT 0x00002000 { .ANY (NO_INIT) } }
那么,如果在程序中有一個數組,你不想讓它復位后零初始化,就可以這樣來定義變量:
unsigned char plc_eu_backup[32] __attribute__((at(0x1000A000)));
變量屬性修飾符__attribute__((at(adde)))用來將變量強制定位到adde所在地址處。由于地址0x1000A000開始的8KB區域ZI變量不會被零初始化,所以位于這一區域的數組plc_eu_backup也就不會被零初始化了。
這種方法的缺點是顯而易見的:要程序員手動分配變量的地址。如果非零初始化數據比較多,這將是件難以想象的大工程(以后的維護、增加、修改代碼等等)。所以要找到一種辦法,讓編譯器去自動分配這一區域的變量。
分散加載文件同方法1,如果還是定義一個數組,可以用下面方法:
unsigned char plc_eu_backup[32] __attribute__((section("NO_INIT"),zero_init));
變量屬性修飾符__attribute__((section(“name”),zero_init))用于將變量強制定義到name屬性數據節中,zero_init表示將未初始化的變量放到ZI數據節中。因為“NO_INIT”這顯性命名的自定義節,具有UNINIT屬性。
將一個模塊內的非初始化變量都非零初始化
假如該模塊名字為test.c,修改分散加載文件如下所示:
LR_IROM1 0x00000000 0x00080000 { ; load region size_region ER_IROM1 0x00000000 0x00080000 { ; load address = execution address *.o (RESET, +First) ????*(InRoot$$Sections) } RW_IRAM1 0x10000000 0x0000A000 { ; RW data .ANY (+RW +ZI) } RW_IRAM2 0x1000A000 UNINIT 0x00002000 { test.o (+ZI) } }
在該模塊定義時變量時使用如下方法:
這里,變量屬性修飾符__attribute__((zero_init))用于將未初始化的變量放到ZI數據節中變量,其實MDK默認情況下,未初始化的變量就是放在ZI數據區的。
編輯:黃飛
評論
查看更多