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