我的外部記憶區

2008年5月9日星期五

Unix痛恨者手冊3:第一章:編程

別惹Unix,它弱不禁風,動不動就吐核(core dump) ——無名氏 如果你是通過在Unix上寫C代碼而學會的編程,那麼可能會覺得這一章有些彆扭。不幸的是,Unix如此廣泛地被應用到了科研教育領域,很少有學生能意識到Unix的許多設計並不是嚴瑾合理的。 例如,聽了我們關於有許多語言和環境比C/Unix要好的說法後,一個Unix愛好者是這麼為Unix和C辯護的: ---- 日期: 1991 Nov 9 發信人: tmb@ai.mit.edu (Thomas M. Breuel) Scheme, Smalltalk和Common Lisp這些語言確實提供了強大的編程環境。但是Unix內核,shell和C語言則針對的是更為廣泛的問題空間,而這些問題不是上面那些語言所擅長的(有的根本就無法處理)。 這些問題空間包括內存管理和局部性(locality)(在進程的產生和終止中實現),、持續性(persistency)(使用文件存儲數據結構),並行性(parallelism)(通過管道,進程和進程通訊機制來實現),保護和恢復(通過獨立的地址空間實現),以及可直觀讀取的數據表現方式(使用文本文件實現)。從實用的角度來看,Unix能很好地處理這些問題。 ---- Thomas Breuel誇獎Unix能夠解決複雜的計算機科學問題。幸運的是,這不是其他科學領域用來解決問題的方法。 ---- 日期: Tue, 12 Nov 91 11:36:04 -0500 發信人: markf@altdorf.ai.mit.edu 收信人: UNIX-HATERS 主題: Random Unix similes (隨機的Unix笑臉) 通過控制進程的產生與終止來進行內存管理,這就如同通過控制人的生死來對付疾病——這忽視了真正問題。 通過Unix文件獲得持續性就如同把你所有的衣服仍進衣櫃,幻想著能從裡面找到需要的衣服(不幸的是,我正是這麼去做的)。 通過管道,進程和進程通訊機制來實現並行化?Unix進程的代價是如此之高,以至於並行化得不償失。就像是鼓勵員工多生孩子,以解決公司人力資源短缺問題。 不錯,Unix當然可以處理文本。他還能處理文本。嗯,還有,我有沒有提到過Unix能夠很好地處理文本? ——Mark ---- ==蔚為壯觀的Unix編程環境== Unix狂熱分子們總在宣揚Unix的所謂「編程環境」。他們說Unix提供了豐富的工具,能夠使得編程工作更為容易。這是Kernighan和Mashey在《Unix編程環境》一文中的說法: Unix環境最能提高編程效率,這歸功於眾多的又小又有用的程序——工具,這些工具為日常的編程工作提供幫助。下面列舉的這些程序被認為是其中最為有用的。我們在下文中將以他們為例說明其他觀點。
 wc files —— 統計文件中的行數,字數和字符數。 pr files —— 打印文件,支持標題和多欄打印。 lpr files —— 打印文件 grep pattern files —— 找到符合某種模式的文件行。 
許多程序員的工作就是用它們和一些其他相關程序完成的。例如:
 wc *.c 
用於對所有C源代碼文件進行代碼量統計;
 grep goto *.c 
用於找到所有的goto語句。 這些就是「最為有用的」?!?! 有道理。這就是程序員的日常工作。事實上,今天我就用了不少時間來統計我的C代碼量,以至於沒有多少時間去做其他事情。等一下,我想我還得再數一遍。 同一期《IEEE計算機》上還有一篇文章,是Warren Teitelman和Larry Masinter寫的《Interlisp編程環境》.Interlisp是個極為複雜的編程環境。1981年Interlisp就有了Unix程序員到了1984還在夢想的工具。 Interlisp環境的設計者們使用的是完全不同的方法。他們決定開發一個複雜的工具,需要花不少時間來掌握,好處是一旦學會了,極大地提高編程效率。聽上去有些道理。 悲哀的是,今天很少有程序員能體會使用這類環境的感覺了。 ==在柏拉圖的洞穴裡編程== 我總有一種感覺,計算機語言設計和工具開發的目標應該是提高編程效率而不是降低。 comp.lang.c++上的一個貼子 ---- 計算機以外的其他產業早就體會到了自動化的意義。當人們走進快餐點,他們需要的是一致標準的東西,而不是什麼法國大菜。大規模地提供一致的一般食物,這比小批量的精耕細作要賺錢得多。 ---- netnews上一個技術人員的回復 ---- Unix不是世界上最好的軟件環境——它甚至不是一個好的環境。Unix編程工具又簡陋又難用;Unix調試器和PC上的沒法比;解析器(interpreters)仍然是富人的玩具;修改日誌(change log)和審記(audit trail)總是想起來才去做。可 Unix仍然被當成程序員的夢。也許它只能讓程序員夢到了效率的提高,而不是真的提高效率。 Unix程序員有點像數學家。你能從他們身上觀察到一個神秘現象,我們稱之為「空頭編程」(Programming by Implication)。一次我們和一個Unix程序員聊天,談到需要這樣一個 工具,能夠回答諸如「函數foo被誰調用過?」或者「那個函數改變過全局變量bar」之類的問題。他也認為這個工具會很有用,提議到,「你們可以自己寫一個。」 公平地說,他之所以只是說「你們可以自己寫一個」而不是真正寫一個,這是因為C語言的一些特性和Unix「編程環境」的強強聯手,使得寫這樣的程序難於上青天。 使用yacc進行解析(parsing with yacc) "Yacc"就是我用過yacc(1)之後想喊的。 ——匿名 ---- "YACC"是再一個編譯編譯器的編譯器(Yet Another Compiler Compiler)的意思。它接受與上下文無關(context-free)的語法,構造用於解析的下推自動機(pushdown automaton)。運行這個自動機,就得到了一個特定語言的 解 析器。這一理論是很成熟的,因為以前計算機科學的一個重要課題就是如何減少編寫編譯器的時間。 這個方法有個小問題:許多語言的語法不是與上下文無關的。這樣yacc的使用者不得不在每一個狀態轉換點上加上相關代碼,以處理和上下文有關的部分(類型檢查一般就是這麼處理的)。許多C編譯器使用的都是yacc生成的解析器;GCC 2.1的yacc語法 有1650行之多 (如果不用yacc,GCC應該能成為自由軟件基金會不錯的作品)。由yacc生成的代碼就更多了。 有些編程語言的語法比較容易解析。比如,Lisp能夠用一個遞歸下降解析器進行解析。「遞歸下降」是一個計算機術語,含義是「喝杯可樂的功夫就能實現」。作為試驗,我們寫了一個Lisp遞歸下降解析器,只用了250行C代碼。如果是用Lisp寫的,那麼一頁紙也用不了。 在上面提到的那個計算機科學原始時代,這本書的編輯還沒有生出來呢。計算機房是恐龍的天下,「真正的人」都在用儀表盤上的開關來編程。今天,社會學家和歷史工作者想破腦袋也無法理解為什麼理智的程序員卻設計、實現和傳播了如此難解析的語言。也許他們那時候極需一個困難的研究項目,設計一個難於解析的語言似乎是個不錯的課題。 一直想知道他們在那個時代吃的是什麼藥。 上面提到的那個工具類似於一個C編譯器的前端。C編譯器前端是個極其複雜的東西,這是C的複雜語法和yacc的使用造成的。沒有人真正動手去寫一個這樣的工具,這還有什麼奇怪的麼? 死硬的Unix分子會說你不需要這麼一個程序,因為有grep就足夠了。而且,你還能在shell管道中使用grep。有一天,我們想找出BSD內核源碼中所有使用min函數的地方。這是其中一個結果:
 % grep min netinet/ip_icmp.c icmplen = oiplen + min(8, oip->ip_len); * that not corrupted and of at least minimum length. * If the incoming packet was addressed directly to us, * to the incoming interface. * Retrieve any source routing from the incoming packet; % 
挺不錯的吧,grep找到了所有的min函數調用,而且還不止這些。 「不知道怎麼做愛。我撤。」("Don't know how to make love. Stop.") 理想的編程工具應該是這樣的,它能讓簡單的問題保持簡單,讓複雜的問題有解決的可能。不幸的是,許多Unix工具過分追求通用性,而忽視了簡潔。 Make就是這樣一個典型。從抽像意義而言,make的輸入是一個倚賴關係的描述。倚賴圖上的每個節點都對應這一組命令,當節點過期時(由它所倚賴的節點來決定),這些命令會被執行。節點和文件相關,文件的修改時間決定了節點是否過期。下面是一個簡單的倚賴關係圖,也就是Makefile:
 program: source1.o source2.o cc -o program source1.o source2.o  source1.o: source1.c cc -c source1.c  source2.o: source2.c cc -c source2.c 
這裡program, source1.o, source2.o, source1.c,source2.c就是關係圖上的節點。節點program倚賴於source1.o和source2.o。 如果source1.o或source2.o比program要新,make便會運行命令cc -o program source1.o source2.o重新生成program。當然,如果修改了source1.c,那麼source1.o和program都會過時,所以make會重新進行編譯和鏈接。 儘管make的模型很通用,可惜設計者從沒有考慮過簡單性。不過,許多Unix新手都能體會到make能多麼簡單地「鑽」(screw)了他們。 繼續我們上面的那個例子,假定有個程序員Dennis想調試source1.c,於是要編譯使用調試選項。他修改了一下Makefile:
 program: source1.o source2.o cc -o program source1.o source2.o  # I'm debugging source1.c source1.o: source1.c cc -c source1.c source2.o: source2.c cc -c source2.c 
"#"打頭的那行是註釋,會被make忽略。可憐的Dennis運行了一下make,這是它得到的:
 Make: Makefile: Must be a speparator on line 4. Stop 
make歇菜了。Dennis盯著Makefile看了有好幾分鐘,又看了幾小時,還是不明白哪兒出錯了。他覺得是註釋行的問題,可不是很肯定。 毛病出在當他加入註釋行時,他不小心在第二行開始的製表符(tab)前敲入了一個空格。製表符是Makefile語法的一個重要部分。所有的命令行(例子中cc開始的行)必須以製表符打頭。這就是Dennis的Makefile不工作的原因。 「那又怎樣?」你可能會說,「這有什麼不對的?」 它本身沒什麼不對。不過如果你想一下其他Unix編程工具的工作方式,就會覺得製表符語法就好像《地雷戰》裡的頭髮絲雷,看上去一馬平川,踩上去嗚呼哀哉。 你知道,製表符、空格符和換行符一般被統稱為「白字符」(whitespacecharacters)。「白字符」意味著「你可以放心大膽地忽略它」許多程序正是這麼做的,對空格和製表符一視同仁。就make孤芳自賞桀驁不馴鶴立雞群冰清玉潔眾人皆醉唯我獨醒。於是我們這位Dennis兄弟恐怕只能給自己腦袋來一槍,告別這悲慘的Unix世界。 可憐的Dennis最終也沒有找到自己那個Makefile的毛病,他現在落魄到只好去給一個中西部州立大學維護sendmail配置文件。默哀三分鐘。 ==頭文件== C語言有個東西叫頭文件,裡面是一些說明信息,在編譯時被源文件使用。和Unix上的其他玩意一樣,如果只有一個兩個,可以工作得很好,多了就沒戲了。 要知道你的源文件該使用那個頭文件,這可不是件容易事。頭文件是C預處理器(preprocessor)根據#include指令(directive)加載的。這個指令有兩個用法:
 #include  
 #include "header2.h" 
這兩種用法的區別和各個C預處理器的實現有關,也就是說,任何實現都可以大著膽子撒著歡兒由著性子亂來。 讓我們來看看Dennis的朋友Joey,Joey也是個Unix新手。Joey有個C程序foo.c,使用了foo.h中定義的一些數據結構,foo.c和foo.h放在了同一個目錄下。你可能已經知道"foo"是程序員常用的名字。Joey機器上的系統程序員也做了一個foo.h文件,並把它放到了缺省系統頭文件目錄/usr/include 倒霉蛋Joey編譯了foo.c,得到一堆語法錯誤。他迷惑不解,編譯器總在他定義的一些數據結構處報錯,可是這些數據結構在foo.h裡被定義的好好的呀。 你我估計能猜到Joey的問題在哪兒,他一定是這麼加載頭文件的:
 #include  
而不是寫成:
 #include "foo.h" 
可Joey不知道這個。也可能他確實是用的引號方式,只是他的編譯器的查找方式有些特別。不管怎樣,Joey是被幹掉了,很無辜地被干了。 維護很多頭文件是件挺頭疼的事,不幸的是,如果你寫個有用點兒的C程序,這是不可避免的。頭文件一般 於定義數據結構,一個頭文件往往倚賴於其他一?頭文件。去把那些頭文件 的倚賴關係整理一下,你這回可不愁沒事兒做了。 當然,編譯器會幫你的。如果你把倚賴關係搞錯了,編譯器會毫不留情地指出語法錯誤。記住,編譯器是個很忙很有身份的程序,它沒時間去區分未定義的數據結構和輸入錯誤的區別。事實上,即使你只是忘了敲個分號,C編譯器也會惱羞成怒,立馬撂挑子不幹了。 在編譯器社區,這一現象被稱為「錯誤雪崩」,或者按照編譯器自己的說法:「我完蛋了,起不來了。」 缺個分號會把解析器徹底搞暈,狂吐不止。這個解析器很可能是用yacc寫 成 的,yacc對語法正確的程序(很少見的一種情況)處理得很好,但要讓它生成健壯容錯自動恢復的解析器,這就有點兒勉為其難了。有經驗的C程序員都知道只有第一條解析錯誤才是有意義的。 ==工具程序和Man手冊== Unix工具是自成一體的;可以任意解釋命令行參數。這樣的自由有些煩人;別以為學會了一套命令行規則就一勞永逸了,你必須去讀每個命令的Man手冊,才能知道如何去使用。 知道有那麼多清楚明白的Man手冊供你參考,你一定很開心吧。 看一下下面這個例子。「摘要」一欄總結得挺不錯的,是不是?
 LS(1) Unix程序員手冊 LS(1)  名稱 ls - 列出目錄內容  摘要 ls [ -acdfgilqrstu1ACLFR ] 名稱 ...  描述 對於每個目錄參數,ls列舉那個目錄的內容;對於每個文件參數, ls 給出文件名以及要求的其他信息。缺省情況下,輸出將按照字 母順序排列。如果沒有參數,則列舉當前目錄的內容。如果有不只 一個參數,這些參數首先會被適當排序,但是文件參數總是會被排 在目錄參數前面。  ls有很多選項:  [ ... ]  BUGS 文件名中的換行符和製表符會被可打印字符  輸出設備會被假設有80列寬 
輸出會根據輸出設備的不同而不同,比如"ls -s"的結果和"ls -s| lpr"的結果不一樣。這是不正確的,然而如果不這麼做,一些倚賴這個功能的舊有shell腳本就會完蛋。 如果你想玩個遊戲,不妨讀一下每個Man手冊的BUGS部分,然後想像一下每個bug是如何造成的。看一下這個shell的man手冊:
 SH(1) Unix程序員手冊 SH(1)  名稱 sh, for, case, if, while, :, ., break, continue, cd, eval, exec, exit, export, login, read, readonly, set, shift, times, trap, umask, wait - 命令語言  摘要 ls [ -ceiknrstuvx ] [參數] ...  描述 Sh是一個命令程序語言,它執行來自終端或文件的命令。下面是各 個選項的說明。  [ ... ]  BUGS  如果把使用<<提供的標準的輸入提供給使用&運行起來的非同步的進程, shell會搞不清楚輸入文檔的名字。會生成一個垃圾文件/tmp/sh*, shell會抱怨找不到使用另外一個名字的文檔。 
我們用了好幾分鐘也沒搞明白這個bug究竟是他媽什麼意思。一個Unix專家看過之後說:「我邊看邊撓腦袋,有寫這段BUGS的功夫,估計足夠這傢伙改掉這個吊玩意了。」 不幸的是,修改bug幾乎是不可能的,因為它會隨著每個新發佈的操作系統而捲土重來。在80年代早期,在這些bug還沒有被Unix信徒奉為神聖以前,一個BBN的程序員真的修改了伯克利make的這個製表符bug。這不是很難,也就是幾行代碼的事兒。 和所有責任感的公民一樣,BBN的駭客們把補丁發給了伯克利,希望能把它加入主Unix代碼中。一年過後,伯克利發佈了新版本的Unix,make的這個bug還是存在。BBN的駭客第二次做了修改,又把補丁交給了伯克利。 ....然而伯克利的第三次發佈還是老樣子,BBN的程序員徹底失望了。他們沒有再提交補丁,而是把他們所有的Makefile中空格打頭的行替換成了製表符。畢竟BBN僱傭他們是來寫新程序的,而不是反覆修改同一個bug。 (據說,Stu Felman(make的作者)一開始就查覺到了這個問題,他沒有修改,因為那時已經有10個用戶開始用了。) 源碼就是文檔。哇~~ 牛逼!
 如果我寫著不容易,那麼你理解起來就不應該容易。    —— 一個Unix程序員 
我們在《文檔》一章裡提到Unix程序員認為操作系統的源代碼是最好的文檔。一個著名的Unix歷史學家曾經指出:「畢竟,操作系統自己也是靠讀源代碼來知道下一步該幹嘛的。」 可是通過閱讀源代碼來理解Unix,這就如同開著Ken Thompson的老爺車(對,就是閃著大紅問號的那輛)周遊世界。 Unix內核源碼(更準確的說,是ftp.uu.net上發佈的伯克利網絡磁帶2版的代碼)幾乎沒有註釋,充斥這大"段"沒有空行的代碼,goto隨處可見,絞盡腦汁給妄圖讀懂它的人製造麻煩。有個駭客感歎到:「閱讀Unix代碼就好像走在伸手不見五指的巷子裡。我總是停下來摸摸口袋,腦子裡迴響著一個聲音『老天,我就要遭劫了。』」 當然,內核代碼有它自己的警報系統。四處散佈著這樣的小小註釋:
 /* XXX */ 
意思是有什麼東西不太對勁兒。你應該知道哪兒出事兒了。 ==這絕不可能是bug,我的Makefile需要它!== BBN的程序員應該算是另類。大部分Unix程序員是不去修改bug的:他們沒有源代碼。即使修改了也於事無補。這就是為什麼Unix程序員遇到bug的第一個反應不是修了它,而是繞過它。 於是我們看到了悲慘的一幕:為什麼不一勞永逸地解決問題,而是一錯再錯?也許早期的Unix程序員是尼采「永恆輪迴」思想的信徒。 對於調試方法,存在著兩個截然不同的派別:一個是「外科手術派」,包括流行於早期ITS和Lisp系統,程序運行過程中始終有調試器參與,如果程序崩潰了,調試器(也就是所謂外科大夫)會對問題進行診斷醫治。 Unix是屬於更古老的「屍體解剖派」。Unix下如果一個程序崩潰了,會遺留下一個core文件,從各個方面看這都和屍體沒什麼兩樣。Unix調試器然後會找出死因。有趣的是,Unix程序常常和人一樣,死於本可治療的疾病、事故以及疏忽。 ==對付Core== 如果你的程序吐核(core)了,你首先要做的是找到它。這不該太困難,因為core文件總是很大——4, 8, 甚至12兆。 core文件之所以這麼大,是因為它包括了所有用來調試的信息:堆棧,數據,代碼指針等等,無所不包,除了程序的動態狀態。如果你在調試一個網絡程序,在你的程序吐核的時候,已經為時太晚了;程序的網絡連接已經沒有了,更致命的一擊是,所有打開的文件現在都被關上了。 不幸的是,在Unix上只能如此。 例如,不能把調試器作為命令解析器,或者在內核發生異常時把控制交給調試器。如果想讓調試器在程序崩潰時進行接管,那你只能在調試器裡面運行所有程序(是的,有的Unix版本讓你用調試器接管一個運行中的進程,但是你手邊必須有一個還有符號的程序文件)。如果你想調試中斷代碼,你的調試器必須截獲每個中斷,然後把合適的中斷返回給程序。你能想像emacs裡每敲一鍵都發生3個進程切換(context switch)的感覺麼? 顯然,例程調試(routine debugging)思想和Unix哲學是格格不入的。 ---- 日期: Wed, 2 Jan 91 07:42:04 PST 發信人: Michael Tiemann 收信人: UNIX-HATERS 主題: Debuggers (調試器) 想過Unix調試器為什麼這麼蹩腳麼?這是因為如果它想提供什麼功能, 那一定會跟來一堆bug,如果有bug,它一定會吐核(dump core),如果它吐核, 靠,你用來調試的那個core文件就會被覆蓋。如 果能讓程序來控制如何吐核,何時吐核,以及吐在哪裡,那就太好了。 ---- ==bug骨灰盒== 和其他操作系統不同,Unix把bug供奉為標準操作。之所以那麼多Unix bugs得不到修正,這裡有個不可告人的原因——如果修正了,那麼已有的一些程序就會死逼了。然而,荒唐的是,Unix程序員在增加新功能時卻從來不去考慮向下兼容。 考慮到這些,Michael Tiemann給出了Unix調試器覆蓋core文件的10個理由:
 日期: Thu, 17 Jan 91 10:28:11 PST 發信人: Michael Tiemann  收信人: UNIX-HATERS 主題: Unix Debuggers (Unix調試器)  David Letterman (美國著名晚間脫口秀主持人)的10個最佳理由是:  10. 這會破壞已有代碼。 9. 這需要修改文檔。 8. 太難實現了。 7. 這怎麼是調試器的活兒?為什麼不寫個「工具」做它? 6. 如果調試器吐了核,你應該丟開你自己的程序,開始調試調試器。 5. 太難理解了。 4. 哪兒有餅乾? 3. 為什麼非得現在做? 2. Unix也不是神仙。 1. 哪兒有問題? 
Unix程序員總是打著「這會破壞已有代碼」的幌子,不願意修正bug。可這裡面還有內幕,修正bug不但會破壞已有代碼,還必須修改簡單完美的Unix接口,而這正是Unix教眾們的命根子。至於這個接口是否工作,這並不重要。Unix教眾們不去提出更好的接口,也不去修正bug,而是齊聲高唱「Unix接口好簡潔,好簡潔。Unix接口就是美,就是美!Unix無罪!Unix有理!」。 不幸的是,繞過bug是個很惡劣的行為,它使得錯誤成為了操作系統規範的一部分。你越是等,就越難以修正,因為越來越多的程序會盡力繞過bug,以至於沒有了bug反而活不了了。同理,修改操作系統接口帶來的影響更大,因為更多的程序必須根據這個正確的新接口進行修改。(這解釋了為什麼ls有那麼多的選項來完成幾乎一樣的工作)。 如果你把一隻青蛙仍到開水裡,它會馬上跳出來。它知道開水很燙。可是,如果你把青蛙放到冷水裡,再慢慢地加熱,青蛙感覺不到什麼,直到最後被燙死。 Unix接口已經開鍋了。以前,輸入/輸出的全部接口只包括open, close, read和write。網絡支持給Unix添了一大把柴禾。現在,至少有五種方法向一個文件句柄輸入數據:write, writev, send, sendto和sendmsg。每個都在 內核中有不同的實現,這意味著有五倍的可能出現bug,有五種不同的性能結果需要考慮。讀文件也一樣(read, recv, recvfrom和recvmsg)。等死吧,青蛙們。 ==文件名擴展== Unix「所有程序自成一體」的規定有一個例外。Unix程序經常要處理一個或多個文件。Unix shells提供了命名一組文件的方法,shell會把這組文件展開,做為一個文件列表 傳遞給各個命令。 例如,假設你的目錄下有文件A, B和C。如果象刪除所有這些文件,你可以運行 "rm *"。shell會把"*"擴展成為"A B C",並把他們做為rm的參數傳遞給它。這個方法有不少問題,這在上一章已 經提到過了。不過,你應該知道讓shell來擴展文件名不是偶然的:而是 精心設計的結果。在Kernighan和Mashey發表的《Unix編程環境》一文中(IEEE計算機雜誌,1981年四月),他們指出:「把這個作為shell的一個機制,這避免了各個程序的重複勞動,而且保證了為所有程序提供一致的輸入。」 (Unix的一個理想是 讓任何人能夠運行任何shell。現在你沒法運行任何shell;你的shell必須提供文件名擴展)。 別忙。標準輸入/輸出庫(Unix所謂的stdio)不就能「為所有程序提供一致的輸入」麼?提供一個用於擴展文件名的庫函數不就成了?這些傢伙沒有聽說過鏈接庫麼?那些關於性能的說法也是無稽之談,因為他們無法提供任何的性能數據,他們甚至沒有說明「性能指標」是什麼。指的是開發一個小程序會快一些?還是指能高性能地把一個新手的所有文件一掃而光? 大多數情況下,讓shell進行文件名擴展也無所謂,因為這和程序自己擴展的結果沒什麼不同。可是,和Unix上的許多玩意一樣,它早晚會咬你一口,而且不輕。 假設你是個Unix新手,目錄下有兩個文件A.m和B.m。你習慣了MS-DOS,想把它們的名字換成A.c和B.c。嗯~~ 沒找到rename命令,不過mv命令似乎差不多。於是你執行mv *.m *.c。 shell將這個命令擴展為 mv A.m B.m,你辛辛苦苦寫了幾小時的B.m就這麼被幹掉了。 再好好思考一下上面這個問題,你就會發現理論上你完全不可能提供一個和MS-DOS "rename"一樣的功能。對於軟件工具,就扯這麼多吧。 ==健壯性,或者說「所有輸入行必須小於80個字符」== 1990年11月份的《ACM通訊》上登了Miller Fredriksen等人寫的一篇精采文章,題目是《Unix工具的穩定性的經驗性研究》。他們使用一些隨機數據作為Unix工具的輸入,發現有24-33%(不同的Unix發佈結果有所不同)的工具崩潰了。有時候甚至整個系統都完蛋了。 文章是以一個笑話開頭的。其中一位作者曾使用一個極差的電話連接工作,發現許多工具都垮掉了。於是他決定針對這一現象進行更系統的調查研究。 許多bug都可以歸因於C語言的陳規陋習。事實上,Unix的許多內在腦損傷都是C語言造成的。Unix的核心以及所有的工具程序都是用C語言寫的。著名語言學家Benjamin Whorf說過:語言決定思想。Unix有深深的C烙印。C 語言使得程序員根本無法想像能寫出健壯的程序。 C語言是極小的。它被設計成能在各種硬件上快速地進行編譯,所以它有著和硬件類似的結構。 Unix誕生之初,使用高級語言編寫操作系統是個革命性的想法。現在則應該考慮使用一種有錯誤檢查的語言了。 C是個最為底層的語言,誕生於硬件更為底層的時代。PDP-11沒有的,C語言也不會有。過去幾十年的編程語言研究表明,語言中加入錯誤處理,自動內存管理和抽像數據類型等功能,會使得開發出的程序更為健壯可靠。你在C裡面找不到這些東西。C語言太流行了,沒人去考慮給它增加諸如數據標記或硬件垃圾回收支持等功能。即使硬件提供了垃圾回收功能,也只是多費了一些硅片罷了,因為許多C語言編寫的程序根本無法使用它。 回想一下,C是無法處理整數溢出的。解決方法是使用超過問題需要的整數大小,希望這個大小在你有生之年足夠用。 C也沒有真正意義上的數組,它有個像是數組的東西,實際不過是一個指向一塊內存的指針。數組定位表達式(array[index])不過是表達式(*(array+index))的簡寫版。所以你甚至可以說index[array],這和表達式(*(array+index))是一個意思。聰明吧?在字符處理時經常能見到這個用法。數組變量和指針變量經常可以互換。 舉個例子,假設你有:
 char *str = "bugy"; 
於是下面的這些語句都是一樣的:
 0[str] == 'b' *(str+1) == 'u' *(2+str) == 'g' str[3] == 'y' 
==C語言夠偉大的吧?== 這個做法的問題是C根本不做任何自動數組邊界檢查。為什麼該C去做呢?數組在C裡只是個指針而已,你可以把指針指向內存的任何地方,是不是?不過,一般你不想在內存裡亂寫亂畫,特別在是一些關鍵的地方,比如程序的堆棧。 這把我們引到了Miller的文章裡提到的一類bug。許多程序是在讀取輸入到堆棧上的一塊字符緩衝區時崩潰的。許多C程序是這麼做的;下面的C程序把一行輸入讀到堆棧上的一個數組裡,然後調用do_it函數進行處理。
 a_function() { char c, buff[80]; int i = 0;  while ((c = getchar()) != '\n') buff[i++] = c; buff[i] = '\000'; do_it(buff); } 
這類代碼把Unix搞得臭不可聞。知道為什麼緩衝區被定為80個字符麼?這是因為許多Unix文件每行最多有80個字符。知道為什麼沒有邊界檢查,也沒有文件尾檢查麼?這是因為這個程序員喜歡把c = getchar()這樣的賦值語句嵌入 到while循環中。信不信,有些人還推崇C的這種縮簡寫法,管他媽什麼可讀性可維護性。最後,調用do_it(),數組搖身一變成了指針,作為第一個參數傳了進去。 ==作為練習:如果在一行當中到達了文件尾,這個程序的結果是什麼?== 當Unix用戶查覺到這個內置的限制後,他們想到的不是去修正這個bug,而是想方設法躲過它。比如,Unix的磁帶備份工具(tape archiver)tar不能處理超過100個字符的路徑名(包括目錄)。解決方 法是:不要備份目錄到磁帶,或者使用dump。更好的辦法是:不要建立太深的目錄,這樣文件的絕對路徑就不會超過100個字符。 2038年1月18日上午10點14分07秒,Unix馬虎編程將在這一刻上演精采的一幕,那時Unix的32位timeval將耗盡... 再回到我們前面那個例子,假設輸入行有85個字符。這個函數毫無問題地接受了這個輸入,可問題是最後那5個字符會被放到哪裡呢?答案是它們會佔據任何排放在數組後面的5個字節。之前那裡放著的是什麼呢? c和i這兩個變量可能會被分配在字符數組之後,所以有可能會被85字符長的輸入衝垮。如果輸入了850個字符呢?則可能會毀掉堆棧上的重要的C運行環境系統信息,比如返回地址等。毀掉這些信息的最好結果是程序可能崩潰。 我們說「可能崩潰」是因為程序的編寫者從沒想到過你竟能毀掉堆棧。想像一下我們的這個程序讀入了很長的一行,約有2,000個字符,這行字符被用來覆蓋堆棧上的返回地址以及其他環境信息,它將調用2,000個字符裡埋藏的一段代碼。這段代碼可能會做一些很有用的事情,比如執行(exec)出一個shell,運行一些命令。 Robert T. Morris的著名Unix蠕蟲病就是使用了這個機制(以及其他一些技巧)黑進Unix主機的。我不知道其他人為什麼還會這麼做,真的不知道,嘻嘻。 ---- 日期: Thu, 2 May 91 18:16:44 PDT 發信人: Jim McDonald 收信人: UNIX-HATERS 主題: how many fingers on your hands? (你共有幾根手指?) 下面是給我的上司的一個報告: 一個用來更新Make文件的程序使用了一個指針,對它的訪問毀掉了一個存放倚賴關係的數組,這個倚賴關係被用來生成Makefile。直接後果是生成的錯誤Makefile不能用於編譯任何東西,沒有生成所需的對象文件(.o),所以編譯最終失敗了。一天的工作就這麼付之東流了,只是因為一個傻瓜認為10個頭文件足夠所有人使用了,然後對它進行了極其危險的優化以在1毫秒內生成所有的Make文件! 網絡化的壞處是,你沒法再闖進某人的辦公室裡把他的心給挖出來。 (關於堆棧溢出攻擊,可參考經典論文 http://www.phrack.org/phrack/49/P49-14 Smashing The Stack For Fun And Profit --me) ---- ==異常處理== 編寫健壯程序的最大挑戰是如何正確處理錯誤和其他異常。不幸的是,C幾乎沒有為此提供什麼幫助。今天在學校裡學會編程的人裡很少有誰知道異常是什麼。 異常是函數無法正常運行時所產生的一個狀態。異常經常發生在請求系統服務時,比如分配內存,打開文件等。由於C沒有提供異常處理支持,程序員必須自己在服務請求時加入異常處理代碼。 例如,下面是所有C語言課本中推薦的使用malloc()分配內存的方法:
 struct bpt *another_function() { struct bpt *result;  result = malloc(sizeof(struct bpt)); if (result == 0) { fprintf(stderr, "error: malloc: ???\n");  /* recover gracefully from the error */ [...] return 0; } /* Do something interesting */ [...] return result; } 
another_function函數分配了一個類型為bpt的結構,返回了一個指向這一結構的指針。這段代碼說明了如何分配內存給這個結構。因為C沒有顯式的異常處理支持,C程序員必須自己去做這件事(就是粗體的那些代碼)。 當然你可以不這麼幹。許多C程序員認為這是小事一樁,從來不做異常處理。他們的程序往往是這樣的:
 struct bpt *another_function() { struct bpt *result = malloc(sizeof(struct bpt));  /* Do something interesting */ [...] return result; } 
多麼簡單,多麼乾淨,大多數系統服務請求都會成功的,是不是?這樣的程序在大多數場合運行良好,直到它們被應用到複雜特殊的地方,往往就會神秘地失效。 Lisp的實現總是包括一個異常處理系統。異常條件包括OUT-OF-MEMORY這樣的名稱,程序員可以為特定的異常提供異常處理函數。這些處理函數在異常發生時被自動調用——程序員不需要介入,也不需要做特殊的檢查。適當地使用,可以讓程序更為健壯。 CLU這樣的編程語言也有內置的異常處理。每個函數定義都有一系列可以發出的異常條件。對異常的顯式支持可以幫助編譯器檢查那些未被處理的異常。CLU程序總是十分健壯,因為編譯器逼著CLU程序員去考慮異常處理問題。C程序是個什麼樣子呢:
 日期: 16 dec 88 16:12:13 GMT 主題: Re: GNU Emacs 發信人: debra@alice.UUCP  <448@myab.se> lars@myab.se (Lars Pensy)>寫到: ... 所有的程序都應該檢查系統調用(如write)的返回結果,這非常重要。 
同意,可不幸的是很少有程序在進行讀(read)寫(write)時這麼做。 Unix工具程序一般會檢查open系統調用的返回值,假設所有隨後的read,write和close總會成功。 原因很明顯:程序員很懶,不做錯誤處理程序會顯得更小更快。(這樣你的系統會有更優異的性能表現)。 這封信的作者繼續指出,由於大部分系統工具不對write()等系統調用的返回值進行檢查,系統管理員就必須保證文件系統時時刻刻都有足夠的空間。正是如此:許多Unix程序假設它們可以寫任何成功打開的文件,想寫多少就寫多少。 讀到這裡你可能會皺眉頭,"嗯~~」一下。最為可怕的是,就在《Unix工具的穩定性的經驗性研究》這篇文章的前幾頁,登載了一份報告,說明休斯頓外層空間中心的飛船控制實時數據採集系統是如何轉型為Unix系統的。"嗯~~」 ==捕捉bug是社會所不能接收的== 不去檢查和報告bug,這會使製造商生產的系統顯得似乎更為健壯和強大。更重要的是,如果Unix系統報告每一個錯誤,那麼就根本不會有人去用它!這是活生生的現實。 ---- 日期: Thu, 11 Jan 90 09:07:05 PST 發信人: Daniel Weise 收信人: UNIX-HATERS 主題: Now, isn't that clear? (現在明白了麼?) 惠普做了一些工作,這樣我們的惠普Unix系統能夠報告一些可能會影響它的網絡錯誤。這些惠普系統和SUN, MIPS, DEC工作站共享一個網絡。我們經常會發現其他機器所引發的問題, 可 是當我們通知給那些機器的主人時(因為這些系統不報告錯誤,他們不知道自己的機器有一半時間是用在重發數據包上了),他們往往反稱是我們這裡的問題,因為只有我們這裡報出了錯誤。 ---- 「兩國相爭,不斬來使」,不過在Unix世界裡,你最好別當信使。 ==修不了?重啟!== 如果某個關鍵軟件不能適當處理錯誤的數據和操作條件,那麼系統管理員該如何是好呢?嗯~~,如果它能在一段時間裡正常工作,你就能通過不斷重啟它來湊合著運行。這個法子不是很可靠,也不具有擴展性,不過足夠讓Unix苟 硬寫 一陣子了。 下面就是這麼一個例子,說明如何在named程序不穩定的情況下提供郵件服務: ---- 日期: 14 May 91 05:43:35 GMT 發信人: tytso@athena.mit.edu (Theodore Ts'o) (著名的Ted Ts'o? --me) 主題: Re: DNS performance metering: a wish list for bind 4.8.4 (DNS性能測試:bind 4.8.4的期待功能表) 收信人: comp.protocols.tcp-ip.domains 我們現在是這麼解決這個問題的:我寫了一個叫"ninit"的程序以非精靈(deamon)模式(nofork)運行named,然後等待它退出。當named退出時,ninit重新啟動一個新的named。另外,每隔五分鐘,ninit會醒來一次發給named一個SIGIOT信號,named接到這個信號後會包一些狀態信息寫入/usr/tmp/named.stats文件中。每隔60秒鐘,ninit會用本地named進行一次域名解析。如果短時間內沒有得到結果,它會殺掉named,重新啟動一個新的。 我們在MIT的名稱服務器上和我們的郵件網關(mailhub)上運行了這個程序。我們發現它很有用,能夠捕捉named的神秘死亡或僵死。這在我們的郵件網關上更是不可缺少,因為即使域名解析中斷一小會兒,我們的郵件隊列也會給撐炸了。 當然,這類辦法會引發這樣的問題:如果ninit有bug,那麼該怎麼辦呢?難道也要寫一個程序不斷重啟ninit麼?如果寫了,你又如何保證那個正常工作呢? 對於軟件錯誤的這種態度並不少見。下面這個man手冊最近出現在我桌上。我們還不能肯定這是不是個玩笑。BUGS部分很是發人深省,因為那裡列舉的bug是Unix程序員總也無法從代碼中剔除的: NANNY(8) Unix程序員手冊 NANNY(8) 名稱 nanny - 奶媽,運行所有服務的服務 摘要 /etc/nanny [switch [argument]] [...switch [argument]] 描述 許多系統都為用戶提供各種服務(server)功能。不幸的是,這些服務經常不明不白地罷工,造成用戶無法獲得所需要的服務。Nanny(奶媽)的作用就是照看(babysit)好這些服務,避免關鍵服務的失效,而不需要系統管理員的隨時監視。 另外,許多服務使用日誌文件作為輸出。這些數據常會很討厭地充滿磁盤。可是,這些數據又是重要的跟蹤記錄,應該盡量保存。Nanny會定期把日誌數據重定向到新文件。這樣,日誌數據被化整為零,舊的日誌文件就能被任意轉移走,而不對服務構成影響。(現在這成了logrotate的任務 --me) 最後,nanny還提供了一些控制功能,使得系統管理員能夠對nanny以及它所照看的服務進行運行時操作。 選項 ... BUGS 有個服務在nanny中做分離fork(detaching fork)。nanny會錯誤地認為這個服務死掉了,不斷重啟它。 到目前為止,nanny還不能容忍配置文件的錯誤,如果配置文件的路徑不對或者內容有錯誤,nanny必死無疑。 不是所有的選項都被實現了。 Nanny倚賴系統提供的網絡功能進行進程間通訊。如果網絡代碼有錯誤,nanny將無法處理這些錯誤,可能僵死或是死循環。 對不穩定軟件經常重啟,這已經成了MIT雅典娜計劃(Project Athena)的日常工作,現在他們每星期天的凌晨4點都會重啟AFS(Andrew File System, 一種網絡文件系統)服務器。但願沒有人週末熬夜趕 寫下週一要交的作業... ---- 怎麼樣,Unix編程很有趣吧?驚險,刺激,痛並快樂!該回家了,休息一下,大麻沒勁了,有海洛英;C用膩了,我們還有C++!放心,離死不遠了。

沒有留言: