除錯
本教學介紹四種除錯 OCaml 程式的技巧
- 追蹤函式呼叫,這在互動式頂層 (toplevel) 中有效。
- OCaml 除錯器,允許分析使用
ocamlc
編譯的程式。 - 如何在 OCaml 程式中取得未捕獲異常的回溯追蹤
- 使用 Thread Sanitizer 偵測 OCaml 5 程式中的資料競爭
在頂層追蹤函式呼叫
在頂層除錯程式最簡單的方法是追蹤函式呼叫,透過「追蹤」有問題的函式
# let rec fib x = if x <= 1 then 1 else fib (x - 1) + fib (x - 2);;
val fib : int -> int = <fun>
# #trace fib;;
fib is now traced.
# fib 3;;
fib <-- 3
fib <-- 1
fib --> 1
fib <-- 2
fib <-- 0
fib --> 1
fib <-- 1
fib --> 1
fib --> 2
fib --> 3
- : int = 3
# #untrace fib;;
fib is no longer traced.
多型函式
多型函式的一個困難之處在於,如果參數和/或結果是多型的,追蹤系統的輸出資訊不太豐富。考慮一個排序演算法(例如氣泡排序)
# let exchange i j v =
let aux = v.(i) in
v.(i) <- v.(j);
v.(j) <- aux;;
val exchange : int -> int -> 'a array -> unit = <fun>
# let one_pass_vect fin v =
for j = 1 to fin do
if v.(j - 1) > v.(j) then exchange (j - 1) j v
done;;
val one_pass_vect : int -> 'a array -> unit = <fun>
# let bubble_sort_vect v =
for i = Array.length v - 1 downto 0 do
one_pass_vect i v
done;;
val bubble_sort_vect : 'a array -> unit = <fun>
# let q = [|18; 3; 1|];;
val q : int array = [|18; 3; 1|]
# #trace one_pass_vect;;
one_pass_vect is now traced.
# bubble_sort_vect q;;
one_pass_vect <-- 2
one_pass_vect --> <fun>
one_pass_vect* <-- [|<poly>; <poly>; <poly>|]
one_pass_vect* --> ()
one_pass_vect <-- 1
one_pass_vect --> <fun>
one_pass_vect* <-- [|<poly>; <poly>; <poly>|]
one_pass_vect* --> ()
one_pass_vect <-- 0
one_pass_vect --> <fun>
one_pass_vect* <-- [|<poly>; <poly>; <poly>|]
one_pass_vect* --> ()
- : unit = ()
由於函式 one_pass_vect
是多型的,其向量參數會印成包含多型值的向量, [|<poly>; <poly>; <poly>|]
,因此我們無法正確追蹤計算。
解決這個問題的簡單方法是定義有問題函式的單型版本。這使用型別約束相當容易實現。一般來說,這允許正確理解多型函式定義中的錯誤。一旦修正了這個問題,您只需取消型別約束即可恢復函式的多型版本。
對於我們的排序常式,exchange
函式參數上的單一型別約束保證了單型型別,允許正確追蹤函式呼叫
# let exchange i j (v : int array) = (* notice the type constraint *)
let aux = v.(i) in
v.(i) <- v.(j);
v.(j) <- aux;;
val exchange : int -> int -> int array -> unit = <fun>
# let one_pass_vect fin v =
for j = 1 to fin do
if v.(j - 1) > v.(j) then exchange (j - 1) j v
done;;
val one_pass_vect : int -> int array -> unit = <fun>
# let bubble_sort_vect v =
for i = Array.length v - 1 downto 0 do
one_pass_vect i v
done;;
val bubble_sort_vect : int array -> unit = <fun>
# let q = [| 18; 3; 1 |];;
val q : int array = [|18; 3; 1|]
# #trace one_pass_vect;;
one_pass_vect is now traced.
# bubble_sort_vect q;;
one_pass_vect <-- 2
one_pass_vect --> <fun>
one_pass_vect* <-- [|18; 3; 1|]
one_pass_vect* --> ()
one_pass_vect <-- 1
one_pass_vect --> <fun>
one_pass_vect* <-- [|3; 1; 18|]
one_pass_vect* --> ()
one_pass_vect <-- 0
one_pass_vect --> <fun>
one_pass_vect* <-- [|1; 3; 18|]
one_pass_vect* --> ()
- : unit = ()
限制
為了追蹤程式中對資料結構和可變變數的賦值,追蹤工具的功能還不夠強大。您需要額外的機制來在任何地方停止程式並要求內部值:這是一個具有單步執行功能的符號除錯器。
單步執行函式程式的意義有點難以定義和理解。我們將使用執行時事件的概念,這些事件在參數傳遞給函式時、進入模式比對時或在模式比對中選擇子句時發生。計算進度由這些事件考慮,獨立於硬體上執行的指令。
儘管這很難實現,但在 Unix 下確實存在 OCaml 的此類除錯器:ocamldebug
。其用法將在下一節中說明。
事實上,對於複雜的程式,程式設計師很可能會使用顯式列印來尋找錯誤,因為這種方法可以減少追蹤資料:只會列印有用的資料,而且特殊用途的格式更適合獲取相關資訊,而不是由追蹤機制使用的通用美化列印器自動輸出的資訊。
OCaml 除錯器
我們現在提供 OCaml 除錯器 (ocamldebug
) 的快速教學。在開始之前,請注意
ocamldebug
在ocamlc
位元組碼程式上執行(它不適用於原生碼可執行檔),並且- 它在 OCaml 的原生 Windows 埠下無法運作(但在 Cygwin 埠下可以運作)。
啟動除錯器
考慮以下在檔案 uncaught.ml
中撰寫的明顯錯誤程式
(* file uncaught.ml *)
let l = ref []
let find_address name = List.assoc name !l
let add_address name address = l := (name, address) :: ! l
let () =
add_address "IRIA" "Rocquencourt";;
print_string (find_address "INRIA"); print_newline ();;
val l : (string * string) list ref = {contents = [("IRIA", "Rocquencourt")]}
val find_address : string -> string = <fun>
val add_address : string -> string -> unit = <fun>
Exception: Not_found.
在執行時,程式會引發未捕獲的例外 Not_found
。假設我們要找出這個例外是在哪裡以及為何被引發的,我們可以按如下步驟進行。首先,我們在除錯模式下編譯程式
ocamlc -g uncaught.ml
我們啟動除錯器
ocamldebug a.out
然後除錯器會回答一個橫幅和一個提示符
OCaml Debugger version 4.14.0
(ocd)
找出虛假例外的起因
輸入 r
(表示執行);您會得到
(ocd) r
Loading program... done.
Time : 27
Program end.
Uncaught exception: Not_found
(ocd)
不言自明,不是嗎?因此,您想要向後單步執行,以在引發例外之前設定程式計數器;因此輸入 b
作為後退單步執行,您會得到
(ocd) b
Time: 26 - pc: 0:29628 - module Stdlib__List
191 [] -> raise Not_found<|a|>
除錯器告訴您您在 Stdlib
的模組 List
中,在一個列表的模式比對中,該模式比對已選擇 []
的情況,並剛執行了 raise Not_found
,因為程式在該表達式之後立即停止(如 <|a|>
標記所示)。
但是,如您所知,您希望除錯器告訴您哪個程序呼叫了 List
中的程序,以及誰呼叫了呼叫 List
中程序的程序;因此,您想要執行堆疊的回溯追蹤
(ocd) bt
Backtrace:
#0 Stdlib__List list.ml:191:26
#1 Uncaught uncaught.ml:8:38
因此,最後呼叫的函式來自模組 List
的 191 行,第 26 個字元,即
let rec assoc x = function
| [] -> raise Not_found
^
| (a,b)::l -> if a = x then b else assoc x l
呼叫它的函式在模組 Uncaught
中,檔案 uncaught.ml
第 8 行,第 38 個字元
print_string (find_address "INRIA"); print_newline ();;
^
總而言之:如果您正在開發程式,您可以使用 -g
選項編譯它,以便在必要時準備除錯程式。因此,要找出虛假例外,您只需輸入 ocamldebug a.out
,然後 r
、b
和 bt
會為您提供回溯追蹤。
在除錯器中取得說明和資訊
要取得有關除錯器目前狀態的更多資訊,您可以直接在除錯器的頂層提示符中詢問;例如
(ocd) info breakpoints
No breakpoint.
(ocd) help break
break: Set breakpoint.
Syntax: break
break function-name
break @ [module] linenum
break @ [module] linenum columnnum
break @ [module] # characternum
break frag:pc
break pc
設定中斷點
讓我們設定一個斷點,並從頭重新執行整個程式 ((g)oto 0
然後 (r)un
)
(ocd) break @Uncaught 7
Breakpoint 1 at 0:42856: file uncaught.ml, line 7, characters 3-36
(ocd) g 0
Time : 0
Beginning of program.
(ocd) r
Time: 20 - pc: 0:42856 - module Uncaught
Breakpoint: 1
7 add_address "IRIA" "Rocquencourt"<|a|>;;
接著,我們可以逐步執行,找出在 find_address
中即將呼叫 List.assoc
前 (<|b|>
) 發生了什麼事
(ocd) s
Time: 21 - pc: 0:42756 - module Uncaught
3 let find_address name = <|b|>List.assoc name !l
(ocd) p name
name : string = "INRIA"
(ocd) p !l
$1 : (string * string) list = ["IRIA", "Rocquencourt"]
(ocd)
現在我們可以猜測為什麼 List.assoc
會找不到列表中的 "INRIA" 了...
在 Emacs 下使用除錯器
在 Emacs 下,您可以使用 ESC-x
ocamldebug a.out
呼叫除錯器。然後,Emacs 會直接將您帶到除錯器回報的檔案和字元,並且您可以使用 ESC-b
和 ESC-s
來回逐步執行。此外,您可以使用 CTRL-X space
設定斷點,等等...
列印未捕獲例外狀況的回溯追蹤
取得未捕獲例外狀況的回溯追蹤,對於了解問題發生的背景資訊很有幫助。然而,預設情況下,使用 ocamlc
和 ocamlopt
編譯的程式都不會列印它
ocamlc -g uncaught.ml
./a.out
Fatal error: exception Not_found
ocamlopt -g uncaught.ml
./a.out
Fatal error: exception Not_found
透過將環境變數 OCAMLRUNPARAM
設定為 b
(代表回溯追蹤) 來執行,我們可以得到更多有用的資訊
OCAMLRUNPARAM=b ./a.out
Fatal error: exception Not_found
Raised at Stdlib__List.assoc in file "list.ml", line 191, characters 10-25
Called from Uncaught.find_address in file "uncaught.ml" (inlined), line 3, characters 24-42
Called from Uncaught in file "uncaught.ml", line 8, characters 15-37
從這個回溯追蹤中應該可以清楚看出,當在第 8 行呼叫 find_address
時,我們從 Stdlib
中的 List.assoc
收到一個 Not_found
例外狀況。
環境變數 OCAMLRUNPARAM
在使用 dune
建置的程式時也有效
;; file dune
(executable
(name uncaught)
(modules uncaught)
)
OCAMLRUNPARAM=b dune exec ./uncaught.exe
Fatal error: exception Not_found
Raised at Stdlib__List.assoc in file "list.ml", line 191, characters 10-25
Called from Dune__exe__Uncaught.find_address in file "uncaught.ml" (inlined), line 3, characters 24-42
Called from Dune__exe__Uncaught in file "uncaught.ml", line 8, characters 15-37
使用 Thread Sanitizer 偵測資料競爭
隨著 OCaml 5 中引入多核心平行處理,隨之而來的是在相關的 Domain
之間引入資料競爭的風險。幸運的是,OCaml 的 Thread Sanitizer (TSan) 模式有助於捕捉並報告這些問題。
安裝 TSan 切換器
若要安裝 TSan 模式,請執行以下命令來建立專用的 TSan 切換器 (這裡我們建立一個 5.2.0 切換器)
opam switch create 5.2.0+tsan ocaml-variants.5.2.0+options ocaml-option-tsan
若要確認 TSan 切換器已正確安裝,請執行 opam switch show
並確認它印出 5.2.0+tsan
。
注意:自 OCaml 5.2.0 起,所有具有原生程式碼編譯器的架構都支援 TSan。
疑難排解
- 如果上述在安裝
conf-unwind
時失敗,並顯示No package 'libunwind' found
,請嘗試設定環境變數PKG_CONFIG_PATH
,使其指向libunwind.pc
的位置,例如,PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig
- 如果上述失敗並顯示類似
FATAL: ThreadSanitizer: unexpected memory mapping 0x61a1a94b2000-0x61a1a94ca000
的錯誤,這是已知舊版 TSan 的問題,可以透過執行sudo sysctl vm.mmap_rnd_bits=28
來減少 ASLR 熵來解決
偵測資料競爭
現在考慮以下寫在檔案 race.ml
中的 OCaml 程式
(* file race.ml *)
type t = { mutable x : int }
let v = { x = 0 }
let () =
let t1 = Domain.spawn (fun () -> v.x <- 10; Unix.sleep 1) in
let t2 = Domain.spawn (fun () -> v.x <- 11; Unix.sleep 1) in
Domain.join t1;
Domain.join t2;
Printf.printf "v.x is %i\n" v.x
它建立一個具有可變欄位 x
的記錄 v
,並初始化為 0
。接下來,它會產生兩個平行的 Domain
s t1
和 t2
,它們都會更新欄位 v.x
。
這是對應的 dune
檔案
namemoduleslibraries
如果我們在常規的 5.2.0
切換器下使用 dune
編譯並執行程式,程式看起來可以正常運作
$ opam exec -- dune build ./race.exe
$ opam exec -- dune exec ./race.exe
v.x is 11
但是,如果我們從新的 5.2.0+tsan
切換器使用 Dune 編譯並執行程式,TSan 會警告我們發生資料競爭
$ opam switch 5.2.0+tsan
$ opam exec -- dune build ./race.exe
$ opam exec -- dune exec ./race.exe
==================
WARNING: ThreadSanitizer: data race (pid=19414)
Write of size 8 at 0x7fb9d72fe498 by thread T4 (mutexes: write M87):
#0 camlDune__exe__Race__fun_560 /home/user/race/_build/default/race.ml:6 (race.exe+0x60c65)
#1 camlStdlib__Domain__body_696 /home/user/.opam/5.2.0+tsan/.opam-switch/build/ocaml-variants.5.2.0+tsan/stdlib/domain.ml:202 (race.exe+0x9c38c)
#2 caml_start_program <null> (race.exe+0x110117)
#3 caml_callback_exn runtime/callback.c:201 (race.exe+0xe00fe)
#4 domain_thread_func runtime/domain.c:1215 (race.exe+0xe3e83)
Previous write of size 8 at 0x7fb9d72fe498 by thread T1 (mutexes: write M83):
#0 camlDune__exe__Race__fun_556 /home/user/race/_build/default/race.ml:5 (race.exe+0x60c05)
#1 camlStdlib__Domain__body_696 /home/user/.opam/5.2.0+tsan/.opam-switch/build/ocaml-variants.5.2.0+tsan/stdlib/domain.ml:202 (race.exe+0x9c38c)
#2 caml_start_program <null> (race.exe+0x110117)
#3 caml_callback_exn runtime/callback.c:201 (race.exe+0xe00fe)
#4 domain_thread_func runtime/domain.c:1215 (race.exe+0xe3e83)
Mutex M87 (0x560c0b4fc438) created at:
#0 pthread_mutex_init ../../../../src/libsanitizer/tsan/tsan_interceptors_posix.cpp:1295 (libtsan.so.2+0x50468)
#1 caml_plat_mutex_init runtime/platform.c:57 (race.exe+0x1022f8)
[...]
SUMMARY: ThreadSanitizer: data race (/tmp/race/race.exe+0x4efb15) in camlRace__fun_560
==================
v.x is 11
ThreadSanitizer: reported 1 warnings
請注意,由於 TSan instrumentation 在單獨的切換器中運作,因此不需要變更我們的 dune
檔案。
TSan 報告警告說,兩個未協調的寫入同時發生,並且印出兩者的回溯追蹤
- 第一個回溯追蹤報告在
thread T4
的race.ml
的第 6 行進行寫入,並且 - 第二個回溯追蹤報告在
thread T1
的race.ml
的第 5 行進行先前的寫入
再次查看我們的程式,我們意識到這兩個寫入實際上並未協調。一種可能的修復方法是用 Atomic
取代我們的可變記錄欄位,以確保每個此類寫入都完全依序發生
(* file race.ml *)
let v = Atomic.make 0
let () =
let t1 = Domain.spawn (fun () -> Atomic.set v 10; Unix.sleep 1) in
let t2 = Domain.spawn (fun () -> Atomic.set v 11; Unix.sleep 1) in
Domain.join t1;
Domain.join t2;
Printf.printf "v is %i\n" (Atomic.get v)
如果我們使用此變更重新編譯並執行我們的程式,現在它會在沒有 TSan 警告的情況下完成
$ opam exec -- dune build ./race.exe
$ opam exec -- dune exec ./race.exe
v is 11
TSan instrumentation 受益於使用偵錯資訊編譯程式,這在 dune
下預設會發生。因此,若要在我們的 5.2.0+tsan
切換器下手動叫用 ocamlopt
編譯器,只需傳遞 -g
旗標即可
$ ocamlopt -g -o race.exe -I +unix unix.cmxa race.ml