JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

strace、ltrace 與良好的日誌

gdb、Valgrind 與各種消毒器,看的都是你的程式碼對記憶體做了什麼。但有些臭蟲根本不在你的程式碼裡——它們藏在你的程式與核心之間的對話裡。這一篇給你兩支聽診器去聽那段對話:strace 與 ltrace;接著教你最謙卑、也最耐用的除錯工具:一種就算在凌晨三點你也還讀得懂的日誌。

另一種臭蟲,另一種工具

在整個章節裡,你打造了一套向看的工具。gdb 把你的程式凍住,讓你讀它的暫存器與呼叫堆疊回溯核心傾印讓你在當機之後、對著一具屍體做同樣的事;Valgrind 與各種消毒器盯著每一次載入與儲存,去逮到釋放後使用緩衝區溢位。這些回答的都是同一個問題:我的程式碼對記憶體做了什麼? 但有一整類臭蟲,完全活在這個問題之外。你的程式開錯了檔案、卡在一個永遠不回應的通訊端上、用一個莫名其妙的狀態碼結束,或者在你機器上跑得好好的、到了伺服器上就死了。記憶體沒問題。臭蟲藏在你的程式與外部世界進行的那場對話裡。

回想你兩個章節前學過的那條邊界。一個系統呼叫不是一般的函式呼叫:它從你的使用者空間程式,跨進核心——那是唯一被允許碰硬體、檔案系統或網路的程式碼。你的程式讀的每一個檔案、它從通訊端送出的每一個位元組、它生出的每一個行程——全都是靠你的程式透過 open()、read()、write()、connect() 這類呼叫去請求核心而發生的。那串請求就是那場對話。而正因為它是一條鋒利、定義清楚的邊界,我們才能站在它上面,把每一件跨過去的事都記下來。做這件事的工具就是 strace

strace:把每個系統呼叫都記下來

把你的程式放在 strace 底下跑,它就會替程式發出的每一個系統呼叫印出一行:呼叫的名字、它的引數(被解碼成可讀的形式——旗標展開成文字、字串用引號顯示),以及它的回傳值。你不必重新編譯、不需要原始碼,甚至不需要除錯資訊。strace 的運作方式,是借用核心自己的追蹤機制(ptrace() 設施),在每一條核心邊界上攔截你的行程。代價是真實存在的——每個被追蹤的呼叫都會被停下來、報告出來,所以一個程式在 strace 底下可能慢上許多倍——但若只是要看清楚發生了什麼,沒有比這更直接的辦法。

$ strace -f -e trace=openat,read,write ./myprog
openat(AT_FDCWD, "config.txt", O_RDONLY)  = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/config.txt", O_RDONLY) = 3
read(3, "port=8080\nhost=localhost\n", 4096) = 26
read(3, "", 4096)                         = 0
write(1, "listening on 8080\n", 18)       = 18
+++ exited with 0 +++
一段迷你的 strace 紀錄。第一個 openat() 回傳 -1,附帶錯誤 ENOENT——程式在工作目錄裡找 config.txt、沒找到,於是退而求其次去找 /etc,這次成功了,拿回檔案描述符 3。對 fd 3 的 read() 回傳 26 個位元組,接著回傳 0(檔案結尾)。對 fd 1(標準輸出)的 write() 送出 18 個位元組。-f 會在 fork() 之後跟進子行程;-e trace=... 把輸出過濾到只剩你在意的那些呼叫。

再讀一次那段紀錄,因為它展現了 strace 的超能力。這裡的臭蟲,從程式內部是看不見的——程式確實開了一個設定檔、也確實讀了它,所以它從不當機。但 strace 揭露了:第一個 openat() 失敗了,錯誤是 ENOENT,而程式悄悄退回去用 /etc 裡的另一個檔案。如果你的程式讀到的是錯誤的設定,這一行就告訴你為什麼。strace 正是對付「在我機器上明明可以」的工具——那個它找不到的檔案、那個被拒絕的權限、那個從意料之外的路徑載入的函式庫、那個它收到卻忽略的 errno。你不再是在猜測環境;你是在親眼看著程式去碰它。

ltrace:邊界的另一側

strace 看的是系統呼叫——你的程式跟核心對話。但你的程式所做的事,有很大一部分根本不會抵達核心。當你呼叫 malloc()、strlen() 或 printf() 時,你呼叫的是某個共享函式庫(C 函式庫,libc)裡的函式,而那些工作大多完全發生在使用者空間。malloc() 通常只是從函式庫早已擁有的記憶體裡切出一塊;只有偶爾、當它需要從核心拿更多記憶體時,才會下降到一個真正的系統呼叫(像 mmap() 或 brk())。strace 看不到那個 malloc()——只看得到它底下那個罕見的系統呼叫。要看清楚那些函式庫呼叫,你需要它的姊妹工具 ltrace

ltrace 會替你的程式每一次呼叫某個共享函式庫印出一行,同樣把引數與回傳值都解碼。這讓上一個章節的那個區分變得具體而可見:它正是函式庫呼叫與系統呼叫之間的差別。strace 給你看核心邊界;ltrace 給你看函式庫邊界。用這兩種方式各追蹤同一個程式一次、再把輸出並排放在一起,是最能讓你感受到使用者空間在哪裡結束、核心在哪裡開始的方法之一——ltrace 顯示十次 malloc() 呼叫,strace 則顯示它們底下那唯一的一次 mmap()。

認真對待的 printf 除錯

有一種除錯工具比其他所有工具都老,它不需要任何特別的安裝、在哪裡都能跑:印出一行,說明你在哪裡、你看到了什麼printf 除錯 在那些以為 gdb 已讓它過時的人之間名聲不好,而那個名聲是錯的。要檢視單一一個被凍住的瞬間,除錯器無人能敵;但要看清楚一段隨時間展開的序列,日誌無人能敵——尤其是對付一個在除錯器底下就消失的臭蟲,或一個在你無法接上去的伺服器上、一萬次執行才出現一次的臭蟲。這兩種工具回答的是不同的問題,認真的程式設計師會把兩把都磨利。

但要認真對待它,因為天真的列印會以兩種出名的方式對你說謊。第一是緩衝:printf() 寫到標準輸出,而標準輸出在連到終端機時是行緩衝、但被重導到檔案或管線時卻是全緩衝。所以如果你的程式當掉了,最後幾行 printf()——往往是最重要的、就在當機前的那幾行——可能還坐在緩衝區裡,永遠進不了那個檔案。你看到的輸出停在真正的失敗之前,把你支使到錯誤的地方去找。除錯用的行請印到標準錯誤(它是無緩衝的),或在每一行之後呼叫 fflush(stdout),或把那個串流設成無緩衝。第二是交錯:兩個執行緒同時列印,會把彼此的輸出在一行中間拼接成一團亂碼,所以只要牽涉到並行,就永遠在每一行標上它的執行緒或行程編號。

良好的日誌:從零散的列印,到一份你信得過的紀錄

你隨手加上、又刪掉的零散 printf() 呼叫,對一場五分鐘的搜捕來說沒問題。但同樣的本能,加上紀律,就成了日誌——一份你的程式在執行時持續發出的、永久而有結構的紀錄,是當一個失敗發生在昨天、而你又重現不出來時,你拿來讀的東西。一個隨手的列印,跟一行好的日誌之間的差別,在於你放進它的內容。一行好的日誌帶著時間戳記(讓你能替事件排序、量出間隔)、一個嚴重程度(讓你之後能只看警告與錯誤)、一個穩定的位置或元件名稱(讓你能用 grep 找某一個子系統),還有——這是大家會跳過的部分——真正要緊的那些,而不只是「到這裡了」。「打不開設定檔」是一句哀嘆;「open('/etc/app.conf') 失敗:errno=2 (ENOENT)」才是一個診斷。

嚴重程度,是讓日誌維持有用、而非把人淹沒的那個旋鈕。常見的階梯是 DEBUG(如消防水管般的細節,正式環境關掉)、INFO(正常的里程碑)、WARNING(有點怪但已處理)、ERROR(某個操作失敗了),以及 FATAL(程式無法繼續)。紀律在於以恰當的層級去記錄,並讓一個執行時設定來決定要顯示多少:搜捕時開啟喋喋不休的 DEBUG,一切健康時則安靜在 WARNING 以上。這直接接回了講錯誤的那個章節——你每一處檢查回傳值的地方,無論是 open()、read() 還是 malloc(),都是一個天然的位置,可以在處理之前、把那個失敗連同它的 errno一起記下來,把你的錯誤檢查變成一條可追溯的紙本軌跡。

退一步,看看整個章節是怎麼拼在一起的。gdb核心傾印凍住一個瞬間、讓你檢視它;Valgrind 與各種消毒器盯著記憶體、找你看不見的臭蟲;strace 與 ltrace 站在核心與函式庫的邊界上、把那場對話謄寫下來;而日誌記錄的,是其他工具給不了的那個隨時間展開的故事。沒有任何單一工具是答案——真正的功夫,是知道你手上是哪一個問題。是現在的狀態是什麼?(除錯器)、是什麼碰了這塊記憶體?(消毒器)、它請系統做了什麼?(strace),還是是哪一串序列把事情帶到這裡的?(日誌)。把這個選擇練到精熟,你就不再憑空猜測——你開始,正如這個章節所承諾的,去親眼看見