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