调试

本教程介绍四种调试 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|>

调试器告诉您您在 StdlibList 模块中,在一个对列表的模式匹配中,该模式匹配已经选择了 [] 案例,并且刚刚执行了 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|>;;

然后,我们可以单步执行并找到在 List.assoc 即将在 find_address 中被调用之前(<|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 时,从 StdlibList.assoc 中接收了一个 Not_found 异常。

当处理使用 dune 生成的程序时,环境变量 OCAMLRUNPARAM 也适用。

;; 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

使用线程安全分析器检测数据竞争

随着 OCaml 5 中引入多核并行性,随之而来的是在涉及的 Domain 之间引入数据竞争的风险。幸运的是,OCaml 的线程安全分析器 (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。接下来,它生成两个并行 Domaint1t2,它们都更新了字段 v.x

这是一个对应的 dune 文件

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

如果我们使用 dune 在一个普通的 5.2.0 切换下编译并运行程序,该程序似乎可以正常工作。

$ 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 检测是在一个单独的切换下进行的,因此我们的 dune 文件不需要任何更改。

TSan 报告警告了两个在并行发生的未协调写入之间的数据竞争,并打印了这两个的回溯。

  • 第一个回溯报告了在 race.ml 的第 6 行,thread T4 中的写入。
  • 第二个回溯报告了之前在 race.ml 的第 5 行,thread T1 中的写入。

再次查看我们的程序,我们意识到这两个写入实际上并没有协调。一个可能的解决方法是用一个 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 检测从使用调试信息编译程序中获益,调试信息默认情况下在 dune 中生成。因此,要在我们的 5.2.0+tsan 切换下手动调用 ocamlopt 编译器,只需传递 -g 标志即可。

$ ocamlopt -g -o race.exe -I +unix unix.cmxa race.ml

帮助改进我们的文档

所有 OCaml 文档都是开源的。发现错误或不清楚的地方吗?提交一个 pull request。

OCaml

创新。社区。安全。