除錯

本教學介紹四種除錯 OCaml 程式的技巧

在頂層追蹤函式呼叫

在頂層除錯程式最簡單的方法是追蹤函式呼叫,透過「追蹤」有問題的函式

# 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) 的快速教學。在開始之前,請注意

  • ocamldebugocamlc 位元組碼程式上執行(它不適用於原生碼可執行檔),並且
  • 它在 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,然後 rbbt 會為您提供回溯追蹤。

在除錯器中取得說明和資訊

要取得有關除錯器目前狀態的更多資訊,您可以直接在除錯器的頂層提示符中詢問;例如

(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-bESC-s 來回逐步執行。此外,您可以使用 CTRL-X space 設定斷點,等等...

列印未捕獲例外狀況的回溯追蹤

取得未捕獲例外狀況的回溯追蹤,對於了解問題發生的背景資訊很有幫助。然而,預設情況下,使用 ocamlcocamlopt 編譯的程式都不會列印它

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。接下來,它會產生兩個平行的 Domains t1t2,它們都會更新欄位 v.x

這是對應的 dune 檔案

(executable
 (name race)
 (modules race)
 (libraries unix))

如果我們在常規的 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 T4race.ml 的第 6 行進行寫入,並且
  • 第二個回溯追蹤報告在 thread T1race.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

仍然需要協助嗎?

協助改善我們的文件

所有 OCaml 文件都是開放原始碼。發現有錯誤或不清楚的地方嗎?提交一個 pull request。

OCaml

創新。社群。安全。