标签 调试 下的文章

有时你需要知道的最重要的信息是什么,你当前的程序状态是如何到达那里的。有一个 backtrace 命令,它给你提供了程序当前的函数调用链。这篇文章将向你展示如何在 x86\_64 上实现堆栈展开以生成这样的回溯。

系列索引

这些链接将会随着其他帖子的发布而上线。

  1. 准备环境
  2. 断点
  3. 寄存器和内存
  4. ELF 和 DWARF
  5. 源码和信号
  6. 源码级逐步执行
  7. 源码级断点
  8. 堆栈展开
  9. 读取变量
  10. 之后步骤

用下面的程序作为例子:

void a() {
    //stopped here
}

void b() {
     a();
}

void c() {
     a();
}

int main() {
    b();
    c();
}

如果调试器停在 //stopped here' 这行,那么有两种方法可以达到:main->b->amain->c->a`。如果我们用 LLDB 设置一个断点,继续执行并请求一个回溯,那么我们将得到以下内容:

* frame #0: 0x00000000004004da a.out`a() + 4 at bt.cpp:3
  frame #1: 0x00000000004004e6 a.out`b() + 9 at bt.cpp:6
  frame #2: 0x00000000004004fe a.out`main + 9 at bt.cpp:14
  frame #3: 0x00007ffff7a2e830 libc.so.6`__libc_start_main + 240 at libc-start.c:291
  frame #4: 0x0000000000400409 a.out`_start + 41

这说明我们目前在函数 a 中,a 从函数 b 中跳转,bmain 中跳转等等。最后两个帧是编译器如何引导 main 函数的。

现在的问题是我们如何在 x86\_64 上实现。最稳健的方法是解析 ELF 文件的 .eh_frame 部分,并解决如何从那里展开堆栈,但这会很痛苦。你可以使用 libunwind 或类似的来做,但这很无聊。相反,我们假设编译器以某种方式设置了堆栈,我们将手动遍历它。为了做到这一点,我们首先需要了解堆栈的布局。

            High
        |   ...   |
        +---------+
     +24|  Arg 1  |
        +---------+
     +16|  Arg 2  |
        +---------+
     + 8| Return  |
        +---------+
EBP+--> |Saved EBP|
        +---------+
     - 8|  Var 1  |
        +---------+
ESP+--> |  Var 2  |
        +---------+
        |   ...   |
            Low

如你所见,最后一个堆栈帧的帧指针存储在当前堆栈帧的开始处,创建一个链接的指针列表。堆栈依据这个链表解开。我们可以通过查找 DWARF 信息中的返回地址来找出列表中下一帧的函数。一些编译器将忽略跟踪 EBP 的帧基址,因为这可以表示为 ESP 的偏移量,并可以释放一个额外的寄存器。即使启用了优化,传递 -fno-omit-frame-pointer 到 GCC 或 Clang 会强制它遵循我们依赖的约定。

我们将在 print_backtrace 函数中完成所有的工作:

void debugger::print_backtrace() {

首先要决定的是使用什么格式打印出帧信息。我用了一个 lambda 来推出这个方法:

    auto output_frame = [frame_number = 0] (auto&& func) mutable {
        std::cout << "frame #" << frame_number++ << ": 0x" << dwarf::at_low_pc(func)
                  << ' ' << dwarf::at_name(func) << std::endl;
    };

打印输出的第一帧是当前正在执行的帧。我们可以通过查找 DWARF 中的当前程序计数器来获取此帧的信息:

    auto current_func = get_function_from_pc(get_pc());
    output_frame(current_func);

接下来我们需要获取当前函数的帧指针和返回地址。帧指针存储在 rbp 寄存器中,返回地址是从帧指针堆栈起的 8 字节。

    auto frame_pointer = get_register_value(m_pid, reg::rbp);
    auto return_address = read_memory(frame_pointer+8);

现在我们拥有了展开堆栈所需的所有信息。我只需要继续展开,直到调试器命中 main,但是当帧指针为 0x0 时,你也可以选择停止,这些是你在调用 main 函数之前调用的函数。我们将从每帧抓取帧指针和返回地址,并打印出信息。

    while (dwarf::at_name(current_func) != "main") {
        current_func = get_function_from_pc(return_address);
        output_frame(current_func);
        frame_pointer = read_memory(frame_pointer);
        return_address = read_memory(frame_pointer+8);
    }
}

就是这样!以下是整个函数:

void debugger::print_backtrace() {
    auto output_frame = [frame_number = 0] (auto&& func) mutable {
        std::cout << "frame #" << frame_number++ << ": 0x" << dwarf::at_low_pc(func)
                  << ' ' << dwarf::at_name(func) << std::endl;
    };

    auto current_func = get_function_from_pc(get_pc());
    output_frame(current_func);

    auto frame_pointer = get_register_value(m_pid, reg::rbp);
    auto return_address = read_memory(frame_pointer+8);

    while (dwarf::at_name(current_func) != "main") {
        current_func = get_function_from_pc(return_address);
        output_frame(current_func);
        frame_pointer = read_memory(frame_pointer);
        return_address = read_memory(frame_pointer+8);
    }
}

添加命令

当然,我们必须向用户公开这个命令。

    else if(is_prefix(command, "backtrace")) {
        print_backtrace();
    }

测试

测试此功能的一个方法是通过编写一个测试程序与一堆互相调用的小函数。设置几个断点,跳到代码附近,并确保你的回溯是准确的。

我们已经从一个只能产生并附加到其他程序的程序走了很长的路。本系列的倒数第二篇文章将通过支持读写变量来完成调试器的实现。在此之前,你可以在这里找到这个帖子的代码。


via: https://blog.tartanllama.xyz/c++/2017/06/24/writing-a-linux-debugger-unwinding/

作者:Simon Brand 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

目录

  • break -- 在指定的行或函数处设置断点,缩写为 b
  • info breakpoints -- 打印未删除的所有断点,观察点和捕获点的列表,缩写为 i b
  • disable -- 禁用断点,缩写为 dis
  • enable -- 启用断点
  • clear -- 清除指定行或函数处的断点
  • delete -- 删除断点,缩写为 d
  • tbreak -- 设置临时断点,参数同 break,但在程序第一次停住后会被自动删除
  • watch -- 为表达式(或变量)设置观察点,当表达式(或变量)的值有变化时,暂停程序执行
  • step -- 单步跟踪,如果有函数调用,会进入该函数,缩写为 s
  • reverse-step -- 反向单步跟踪,如果有函数调用,会进入该函数
  • next -- 单步跟踪,如果有函数调用,不会进入该函数,缩写为 n
  • reverse-next -- 反向单步跟踪,如果有函数调用,不会进入该函数
  • return -- 使选定的栈帧返回到其调用者
  • finish -- 执行直到选择的栈帧返回,缩写为 fin
  • until -- 执行直到达到当前栈帧中当前行后的某一行(用于跳过循环、递归函数调用),缩写为 u
  • continue -- 恢复程序执行,缩写为 c
  • print -- 打印表达式 EXP 的值,缩写为 p
  • x -- 查看内存
  • display -- 每次程序停止时打印表达式 EXP 的值(自动显示)
  • info display -- 打印早先设置为自动显示的表达式列表
  • disable display -- 禁用自动显示
  • enable display -- 启用自动显示
  • undisplay -- 删除自动显示项
  • help -- 打印命令列表(带参数时查找命令的帮助),缩写为 h
  • attach -- 挂接到已在运行的进程来调试
  • run -- 启动被调试的程序,缩写为 r
  • backtrace -- 查看程序调用栈的信息,缩写为 bt
  • ptype -- 打印类型 TYPE 的定义

break

使用 break 命令(缩写 b)来设置断点。

用法:

  • break 当不带参数时,在所选栈帧中执行的下一条指令处设置断点。
  • break <function-name> 在函数体入口处打断点,在 C++ 中可以使用 class::functionfunction(type, ...) 格式来指定函数名。
  • break <line-number> 在当前源码文件指定行的开始处打断点。
  • break -N break +N 在当前源码行前面或后面的 N 行开始处打断点,N 为正整数。
  • break <filename:linenum> 在源码文件 filenamelinenum 行处打断点。
  • break <filename:function> 在源码文件 filenamefunction 函数入口处打断点。
  • break <address> 在程序指令的地址处打断点。
  • break ... if <cond> 设置条件断点,... 代表上述参数之一(或无参数),cond 为条件表达式,仅在 cond 值非零时暂停程序执行。

详见官方文档

info breakpoints

查看断点,观察点和捕获点的列表。

用法:

  • info breakpoints [list...]
  • info break [list...]
  • list... 用来指定若干个断点的编号(可省略),可以是 21-32 5 等。

disable

禁用一些断点。参数是用空格分隔的断点编号。要禁用所有断点,不加参数。

禁用的断点不会被忘记,但直到重新启用才有效。

用法:

  • disable [breakpoints] [list...]
  • breakpointsdisable 的子命令(可省略),list...info breakpoints 中的描述。

详见官方文档

enable

启用一些断点。给出断点编号(以空格分隔)作为参数。没有参数时,所有断点被启用。

用法:

  • enable [breakpoints] [list...] 启用指定的断点(或所有定义的断点)。
  • enable [breakpoints] once list... 临时启用指定的断点。GDB 在停止您的程序后立即禁用这些断点。
  • enable [breakpoints] delete list... 使指定的断点启用一次,然后删除。一旦您的程序停止,GDB 就会删除这些断点。等效于用 tbreak 设置的断点。

breakpointsdisable 中的描述。

详见官方文档

clear

在指定行或函数处清除断点。参数可以是行号,函数名称或 * 跟一个地址。

用法:

  • clear 当不带参数时,清除所选栈帧在执行的源码行中的所有断点。
  • clear <function>, clear <filename:function> 删除在命名函数的入口处设置的任何断点。
  • clear <linenum>, clear <filename:linenum> 删除在指定的文件指定的行号的代码中设置的任何断点。
  • clear <address> 清除指定程序指令的地址处的断点。

详见官方文档

delete

删除一些断点或自动显示表达式。参数是用空格分隔的断点编号。要删除所有断点,不加参数。

用法: delete [breakpoints] [list...]

详见官方文档

tbreak

设置临时断点。参数形式同 break 一样。

除了断点是临时的之外,其他同 break 一样,所以在命中时会被删除。

详见官方文档

watch

为表达式设置观察点。

用法: watch [-l|-location] <expr> 每当一个表达式的值改变时,观察点就会暂停程序执行。

如果给出了 -l 或者 -location,则它会对 expr 求值并观察它所指向的内存。例如,watch *(int *)0x12345678 将在指定的地址处观察一个 4 字节的区域(假设 int 占用 4 个字节)。

详见官方文档

step

单步执行程序,直到到达不同的源码行。

用法: step [N] 参数 N 表示执行 N 次(或由于另一个原因直到程序停止)。

警告:如果当控制在没有调试信息的情况下编译的函数中使用 step 命令,则执行将继续进行,直到控制到达具有调试信息的函数。 同样,它不会进入没有调试信息编译的函数。

要执行没有调试信息的函数,请使用 stepi 命令,详见后文。

详见官方文档

reverse-step

反向单步执行程序,直到到达另一个源码行的开头。

用法: reverse-step [N] 参数 N 表示执行 N 次(或由于另一个原因直到程序停止)。

详见官方文档

next

单步执行程序,执行完子程序调用。

用法: next [N]

step 不同,如果当前的源代码行调用子程序,则此命令不会进入子程序,而是将其视为单个源代码行,继续执行。

详见官方文档

reverse-next

反向步进程序,执行完子程序调用。

用法: reverse-next [N]

如果要执行的源代码行调用子程序,则此命令不会进入子程序,调用被视为一个指令。

参数 N 表示执行 N 次(或由于另一个原因直到程序停止)。

详见官方文档

return

您可以使用 return 命令取消函数调用的执行。如果你给出一个表达式参数,它的值被用作函数的返回值。

用法: return <expression>expression 的值作为函数的返回值并使函数直接返回。

详见官方文档

finish

执行直到选定的栈帧返回。

用法: finish 返回后,返回的值将被打印并放入到值历史记录中。

详见官方文档

until

执行直到程序到达当前栈帧中当前行之后(与 break 命令相同的参数)的源码行。此命令用于通过一个多次的循环,以避免单步执行。

用法:until <location>u <location> 继续运行程序,直到达到指定的位置,或者当前栈帧返回。

详见官方文档

continue

在信号或断点之后,继续运行被调试的程序。

用法: continue [N] 如果从断点开始,可以使用数字 N 作为参数,这意味着将该断点的忽略计数设置为 N - 1(以便断点在第 N 次到达之前不会中断)。如果启用了非停止模式(使用 show non-stop 查看),则仅继续当前线程,否则程序中的所有线程都将继续。

详见官方文档

print

求值并打印表达式 EXP 的值。可访问的变量是所选栈帧的词法环境,以及范围为全局或整个文件的所有变量。

用法:

  • print [expr]print /f [expr] expr 是一个(在源代码语言中的)表达式。

默认情况下,expr 的值以适合其数据类型的格式打印;您可以通过指定 /f 来选择不同的格式,其中 f 是一个指定格式的字母;详见输出格式

如果省略 expr,GDB 再次显示最后一个值。

要以每行一个成员带缩进的格式打印结构体变量请使用命令 set print pretty on,取消则使用命令 set print pretty off

可使用命令 show print 查看所有打印的设置。

详见官方文档

x

检查内存。

用法: x/nfu <addr>x <addr> nfu 都是可选参数,用于指定要显示的内存以及如何格式化。addr 是要开始显示内存的地址的表达式。

n 重复次数(默认值是 1),指定要显示多少个单位(由 u 指定)的内存值。

f 显示格式(初始默认值是 x),显示格式是 print('x','d','u','o','t','a','c','f','s') 使用的格式之一,再加 i(机器指令)。

u 单位大小,b 表示单字节,h 表示双字节,w 表示四字节,g 表示八字节。

例如:

x/3uh 0x54320 表示从地址 0x54320 开始以无符号十进制整数的格式,双字节为单位来显示 3 个内存值。

x/16xb 0x7f95b7d18870 表示从地址 0x7f95b7d18870 开始以十六进制整数的格式,单字节为单位显示 16 个内存值。

详见官方文档

display

每次程序暂停时,打印表达式 EXP 的值。

用法: display <expr>, display/fmt <expr>display/fmt <addr> fmt 用于指定显示格式。像 print 命令里的 /f 一样。

对于格式 is,或者包括单位大小或单位数量,将表达式 addr 添加为每次程序停止时要检查的内存地址。

详见官方文档

info display

打印自动显示的表达式列表,每个表达式都带有项目编号,但不显示其值。

包括被禁用的表达式和不能立即显示的表达式(当前不可用的自动变量)。

undisplay

取消某些表达式在程序暂停时的自动显示。参数是表达式的编号(使用 info display 查询编号)。不带参数表示取消所有自动显示表达式。

delete display 具有与此命令相同的效果。

disable display

禁用某些表达式在程序暂停时的自动显示。禁用的显示项目不会被自动打印,但不会被忘记。 它可能稍后再次被启用。

参数是表达式的编号(使用 info display 查询编号)。不带参数表示禁用所有自动显示表达式。

enable display

启用某些表达式在程序暂停时的自动显示。

参数是重新显示的表达式的编号(使用 info display 查询编号)。不带参数表示启用所有自动显示表达式。

help

打印命令列表。

您可以使用不带参数的 help(缩写为 h)来显示命令的类别名的简短列表。

使用 help <class> 您可以获取该类中的各个命令的列表。使用 help <command> 显示如何使用该命令。

详见官方文档

attach

挂接到 GDB 之外的进程或文件。该命令可以将进程 ID 或设备文件作为参数。

对于进程 ID,您必须具有向进程发送信号的权限,并且必须具有与调试器相同的有效的 uid。

用法: attach <process-id> GDB 在安排调试指定的进程之后做的第一件事是暂停该进程。

无论是通过 attach 命令挂接的进程还是通过 run 命令启动的进程,您都可以使用的 GDB 命令来检查和修改挂接的进程。

详见官方文档

run

启动被调试的程序。

可以直接指定参数,也可以用 set args 设置(启动所需的)参数。

例如: run arg1 arg2 ... 等效于

set args arg1 arg2 ...
run

还允许使用 ><>> 进行输入和输出重定向。

详见官方文档

backtrace

打印整体栈帧信息。

  • bt 打印整体栈帧信息,每个栈帧一行。
  • bt n 类似于上,但只打印最内层的 n 个栈帧。
  • bt -n 类似于上,但只打印最外层的 n 个栈帧。
  • bt full n 类似于 bt n,还打印局部变量的值。

whereinfo stack(缩写 info s) 是 backtrace 的别名。调用栈信息类似如下:

(gdb) where
#0  vconn_stream_run (vconn=0x99e5e38) at lib/vconn-stream.c:232
#1  0x080ed68a in vconn_run (vconn=0x99e5e38) at lib/vconn.c:276
#2  0x080dc6c8 in rconn_run (rc=0x99dbbe0) at lib/rconn.c:513
#3  0x08077b83 in ofconn_run (ofconn=0x99e8070, handle_openflow=0x805e274 <handle_openflow>) at ofproto/connmgr.c:1234
#4  0x08075f92 in connmgr_run (mgr=0x99dc878, handle_openflow=0x805e274 <handle_openflow>) at ofproto/connmgr.c:286
#5  0x08057d58 in ofproto_run (p=0x99d9ba0) at ofproto/ofproto.c:1159
#6  0x0804f96b in bridge_run () at vswitchd/bridge.c:2248
#7  0x08054168 in main (argc=4, argv=0xbf8333e4) at vswitchd/ovs-vswitchd.c:125

详见官方文档

ptype

打印类型 TYPE 的定义。

用法: ptype[/FLAGS] TYPE-NAME | EXPRESSION

参数可以是由 typedef 定义的类型名, 或者 struct STRUCT-TAG 或者 class CLASS-NAME 或者 union UNION-TAG 或者 enum ENUM-TAG

根据所选的栈帧的词法上下文来查找该名字。

类似的命令是 whatis,区别在于 whatis 不展开由 typedef 定义的数据类型,而 ptype 会展开,举例如下:

/* 类型声明与变量定义 */
typedef double real_t;
struct complex {
    real_t real;
    double imag;
};
typedef struct complex complex_t;

complex_t var;
real_t *real_pointer_var;

这两个命令给出了如下输出:

(gdb) whatis var
type = complex_t
(gdb) ptype var
type = struct complex {
    real_t real;
    double imag;
}
(gdb) whatis complex_t
type = struct complex
(gdb) whatis struct complex
type = struct complex
(gdb) ptype struct complex
type = struct complex {
    real_t real;
    double imag;
}
(gdb) whatis real_pointer_var
type = real_t *
(gdb) ptype real_pointer_var
type = double *

详见官方文档


参考资料


译者:robot527 校对:mudongliangwxy

上一篇博文中我们给调试器添加了一个简单的地址断点。这次,我们将添加读写寄存器和内存的功能,这将使我们能够使用我们的程序计数器、观察状态和改变程序的行为。

系列文章索引

随着后面文章的发布,这些链接会逐渐生效。

  1. 准备环境
  2. 断点
  3. 寄存器和内存
  4. Elves 和 dwarves
  5. 源码和信号
  6. 源码级逐步执行
  7. 源码级断点
  8. 调用栈展开
  9. 读取变量
  10. 下一步

注册我们的寄存器

在我们真正读取任何寄存器之前,我们需要告诉调试器一些关于我们的目标平台的信息,这里是 x8664 平台。除了多组通用和专用目的寄存器,x8664 还提供浮点和向量寄存器。为了简化,我将跳过后两种寄存器,但是你如果喜欢的话也可以选择支持它们。x86\_64 也允许你像访问 32、16 或者 8 位寄存器那样访问一些 64 位寄存器,但我只会介绍 64 位寄存器。由于这些简化,对于每个寄存器我们只需要它的名称、它的 DWARF 寄存器编号以及 ptrace 返回结构体中的存储地址。我使用范围枚举引用这些寄存器,然后我列出了一个全局寄存器描述符数组,其中元素顺序和 ptrace 中寄存器结构体相同。

enum class reg {
    rax, rbx, rcx, rdx,
    rdi, rsi, rbp, rsp,
    r8,  r9,  r10, r11,
    r12, r13, r14, r15,
    rip, rflags,    cs,
    orig_rax, fs_base,
    gs_base,
    fs, gs, ss, ds, es
};

constexpr std::size_t n_registers = 27;

struct reg_descriptor {
    reg r;
    int dwarf_r;
    std::string name;
};

const std::array<reg_descriptor, n_registers> g_register_descriptors {{
    { reg::r15, 15, "r15" },
    { reg::r14, 14, "r14" },
    { reg::r13, 13, "r13" },
    { reg::r12, 12, "r12" },
    { reg::rbp, 6, "rbp" },
    { reg::rbx, 3, "rbx" },
    { reg::r11, 11, "r11" },
    { reg::r10, 10, "r10" },
    { reg::r9, 9, "r9" },
    { reg::r8, 8, "r8" },
    { reg::rax, 0, "rax" },
    { reg::rcx, 2, "rcx" },
    { reg::rdx, 1, "rdx" },
    { reg::rsi, 4, "rsi" },
    { reg::rdi, 5, "rdi" },
    { reg::orig_rax, -1, "orig_rax" },
    { reg::rip, -1, "rip" },
    { reg::cs, 51, "cs" },
    { reg::rflags, 49, "eflags" },
    { reg::rsp, 7, "rsp" },
    { reg::ss, 52, "ss" },
    { reg::fs_base, 58, "fs_base" },
    { reg::gs_base, 59, "gs_base" },
    { reg::ds, 53, "ds" },
    { reg::es, 50, "es" },
    { reg::fs, 54, "fs" },
    { reg::gs, 55, "gs" },
}};

如果你想自己看看的话,你通常可以在 /usr/include/sys/user.h 找到寄存器数据结构,另外 DWARF 寄存器编号取自 System V x86\_64 ABI

现在我们可以编写一堆函数来和寄存器交互。我们希望可以读取寄存器、写入数据、根据 DWARF 寄存器编号获取值,以及通过名称查找寄存器,反之类似。让我们先从实现 get_register_value 开始:

uint64_t get_register_value(pid_t pid, reg r) {
    user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, nullptr, &regs);
    //...
}

ptrace 使得我们可以轻易获得我们想要的数据。我们只需要构造一个 user_regs_struct 实例并把它和 PTRACE_GETREGS 请求传递给 ptrace

现在根据要请求的寄存器,我们要读取 regs。我们可以写一个很大的 switch 语句,但由于我们 g_register_descriptors 表的布局顺序和 user_regs_struct 相同,我们只需要搜索寄存器描述符的索引,然后作为 uint64_t 数组访问 user_regs_struct 就行。(你也可以重新排序 reg 枚举变量,然后使用索引把它们转换为底层类型,但第一次我就使用这种方式编写,它能正常工作,我也就懒得改它了。)

        auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                               [r](auto&& rd) { return rd.r == r; });

        return *(reinterpret_cast<uint64_t*>(&regs) + (it - begin(g_register_descriptors)));

uint64_t 的转换是安全的,因为 user_regs_struct 是一个标准布局类型,但我认为指针算术技术上是 未定义的行为 undefined behavior 。当前没有编译器会对此产生警告,我也懒得修改,但是如果你想保持最严格的正确性,那就写一个大的 switch 语句。

set_register_value 非常类似,我们只是写入该位置并在最后写回寄存器:

void set_register_value(pid_t pid, reg r, uint64_t value) {
    user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, nullptr, &regs);
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [r](auto&& rd) { return rd.r == r; });

    *(reinterpret_cast<uint64_t*>(&regs) + (it - begin(g_register_descriptors))) = value;
    ptrace(PTRACE_SETREGS, pid, nullptr, &regs);
}

下一步是通过 DWARF 寄存器编号查找。这次我会真正检查一个错误条件以防我们得到一些奇怪的 DWARF 信息。

uint64_t get_register_value_from_dwarf_register (pid_t pid, unsigned regnum) {
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [regnum](auto&& rd) { return rd.dwarf_r == regnum; });
    if (it == end(g_register_descriptors)) {
        throw std::out_of_range{"Unknown dwarf register"};
    }

    return get_register_value(pid, it->r);
}

就快完成啦,现在我们已经有了寄存器名称查找:

std::string get_register_name(reg r) {
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [r](auto&& rd) { return rd.r == r; });
    return it->name;
}

reg get_register_from_name(const std::string& name) {
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [name](auto&& rd) { return rd.name == name; });
    return it->r;
}

最后我们会添加一个简单的帮助函数用于导出所有寄存器的内容:

void debugger::dump_registers() {
    for (const auto& rd : g_register_descriptors) {
        std::cout << rd.name << " 0x"
                  << std::setfill('0') << std::setw(16) << std::hex << get_register_value(m_pid, rd.r) << std::endl;
    }
}

正如你看到的,iostreams 有非常精确的接口用于美观地输出十六进制数据(啊哈哈哈哈哈哈)。如果你喜欢你也可以通过 I/O 操纵器来摆脱这种混乱。

这些已经足够支持我们在调试器接下来的部分轻松地处理寄存器,所以我们现在可以把这些添加到我们的用户界面。

显示我们的寄存器

这里我们要做的就是给 handle_command 函数添加一个命令。通过下面的代码,用户可以输入 register read raxregister write rax 0x42 以及类似的语句。

    else if (is_prefix(command, "register")) {
        if (is_prefix(args[1], "dump")) {
            dump_registers();
        }
        else if (is_prefix(args[1], "read")) {
            std::cout << get_register_value(m_pid, get_register_from_name(args[2])) << std::endl;
        }
        else if (is_prefix(args[1], "write")) {
            std::string val {args[3], 2}; //assume 0xVAL
            set_register_value(m_pid, get_register_from_name(args[2]), std::stol(val, 0, 16));
        }
    }

接下来做什么?

设置断点的时候我们已经读取和写入内存,因此我们只需要添加一些函数用于隐藏 ptrace 调用。

uint64_t debugger::read_memory(uint64_t address) {
    return ptrace(PTRACE_PEEKDATA, m_pid, address, nullptr);
}

void debugger::write_memory(uint64_t address, uint64_t value) {
    ptrace(PTRACE_POKEDATA, m_pid, address, value);
}

你可能想要添加支持一次读取或者写入多个字节,你可以在每次希望读取另一个字节时通过递增地址来实现。如果你需要的话,你也可以使用 process_vm_readvprocess_vm_writev/proc/<pid>/mem 代替 ptrace

现在我们会给我们的用户界面添加命令:

    else if(is_prefix(command, "memory")) {
        std::string addr {args[2], 2}; //assume 0xADDRESS

        if (is_prefix(args[1], "read")) {
            std::cout << std::hex << read_memory(std::stol(addr, 0, 16)) << std::endl;
        }
        if (is_prefix(args[1], "write")) {
            std::string val {args[3], 2}; //assume 0xVAL
            write_memory(std::stol(addr, 0, 16), std::stol(val, 0, 16));
        }
    }

continue_execution 打补丁

在我们测试我们的更改之前,我们现在可以实现一个更健全的 continue_execution 版本。由于我们可以获取程序计数器,我们可以检查我们的断点映射来判断我们是否处于一个断点。如果是的话,我们可以停用断点并在继续之前跳过它。

为了清晰和简洁起见,首先我们要添加一些帮助函数:

uint64_t debugger::get_pc() {
    return get_register_value(m_pid, reg::rip);
}

void debugger::set_pc(uint64_t pc) {
    set_register_value(m_pid, reg::rip, pc);
}

然后我们可以编写函数来跳过断点:

void debugger::step_over_breakpoint() {
    // - 1 because execution will go past the breakpoint
    auto possible_breakpoint_location = get_pc() - 1;

    if (m_breakpoints.count(possible_breakpoint_location)) {
        auto& bp = m_breakpoints[possible_breakpoint_location];

        if (bp.is_enabled()) {
            auto previous_instruction_address = possible_breakpoint_location;
            set_pc(previous_instruction_address);

            bp.disable();
            ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr);
            wait_for_signal();
            bp.enable();
        }
    }
}

首先我们检查当前程序计算器的值是否设置了一个断点。如果有,首先我们把执行返回到断点之前,停用它,跳过原来的指令,再重新启用断点。

wait_for_signal 封装了我们常用的 waitpid 模式:

void debugger::wait_for_signal() {
    int wait_status;
    auto options = 0;
    waitpid(m_pid, &wait_status, options);
}

最后我们像下面这样重写 continue_execution

void debugger::continue_execution() {
    step_over_breakpoint();
    ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);
    wait_for_signal();
}

测试效果

现在我们可以读取和修改寄存器了,我们可以对我们的 hello world 程序做一些有意思的更改。类似第一次测试,再次尝试在 call 指令处设置断点然后从那里继续执行。你可以看到输出了 Hello world。现在是有趣的部分,在输出调用后设一个断点、继续、将 call 参数设置代码的地址写入程序计数器(rip)并继续。由于程序计数器操纵,你应该再次看到输出了 Hello world。为了以防你不确定在哪里设置断点,下面是我上一篇博文中的 objdump 输出:

0000000000400936 <main>:
  400936:   55                      push   rbp
  400937:   48 89 e5                mov    rbp,rsp
  40093a:   be 35 0a 40 00          mov    esi,0x400a35
  40093f:   bf 60 10 60 00          mov    edi,0x601060
  400944:   e8 d7 fe ff ff          call   400820 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
  400949:   b8 00 00 00 00          mov    eax,0x0
  40094e:   5d                      pop    rbp
  40094f:   c3                      ret

你要将程序计数器移回 0x40093a 以便正确设置 esiedi 寄存器。

在下一篇博客中,我们会第一次接触到 DWARF 信息并给我们的调试器添加一系列逐步调试的功能。之后,我们会有一个功能工具,它能逐步执行代码、在想要的地方设置断点、修改数据以及其它。一如以往,如果你有任何问题请留下你的评论!

你可以在这里找到这篇博文的代码。


via: https://blog.tartanllama.xyz/c++/2017/03/31/writing-a-linux-debugger-registers/

作者:TartanLlama 译者:ictlyh 校对:jasminepeng

本文由 LCTT 原创编译,Linux中国 荣誉推出

Shell 脚本编程 是你在 Linux 下学习或练习编程的最简单的方式。尤其对 系统管理员要处理着自动化任务,且要开发新的简单的实用程序或工具等(这里只是仅举几例)更是必备技能。

本文中,我们将分享 10 个写出高效可靠的 bash 脚本的实用技巧,它们包括:

1、 脚本中多写注释

这是不仅可应用于 shell 脚本程序中,也可用在其他所有类型的编程中的一种推荐做法。在脚本中作注释能帮你或别人翻阅你的脚本时了解脚本的不同部分所做的工作。

对于刚入门的人来说,注释用 # 号来定义。

# TecMint 是浏览各类 Linux 文章的最佳站点

2、 当运行失败时使脚本退出

有时即使某些命令运行失败,bash 可能继续去执行脚本,这样就影响到脚本的其余部分(会最终导致逻辑错误)。用下面的行的方式在遇到命令失败时来退出脚本执行:

# 如果命令运行失败让脚本退出执行
set -o errexit 
# 或
set -e

3、 当 Bash 用未声明变量时使脚本退出

Bash 也可能会使用能导致起逻辑错误的未声明的变量。因此用下面行的方式去通知 bash 当它尝试去用一个未声明变量时就退出脚本执行:

# 若有用未设置的变量即让脚本退出执行
set -o nounset
# 或
set -u

4、 使用双引号来引用变量

当引用时(使用一个变量的值)用双引号有助于防止由于空格导致单词分割开和由于识别和扩展了通配符而导致的不必要匹配。

看看下面的例子:

#!/bin/bash
# 若命令失败让脚本退出
set -o errexit 
# 若未设置的变量被使用让脚本退出
set -o nounset
echo "Names without double quotes" 
echo

names="Tecmint FOSSMint Linusay"

for name in $names; do
  echo "$name"
done

echo
echo "Names with double quotes" 
echo

for name in "$names"; do
  echo "$name"
done

exit 0

保存文件并退出,接着如下运行一下:

$ ./names.sh

Use Double Quotes in Scripts

在脚本中用双引号

5、 在脚本中使用函数

除了非常小的脚本(只有几行代码),总是记得用函数来使代码模块化且使得脚本更可读和可重用。

写函数的语法如下所示:

function check_root(){
  command1; 
  command2;
}
# 或
check_root(){
  command1; 
  command2;
}

写成单行代码时,每个命令后要用终止符号:

check_root(){ command1; command2; }

6、 字符串比较时用 = 而不是 ==

注意 === 的同义词,因此仅用个单 = 来做字符串比较,例如:

value1=”tecmint.com”
value2=”fossmint.com”
if [ "$value1" = "$value2" ]

7、 用 $(command) 而不是老旧的 command 来做代换

命令代换 是用这个命令的输出结果取代命令本身。用 $(command) 而不是引号 command 来做命令代换。

这种做法也是 shellcheck tool (可针对 shell 脚本显示警告和建议)所建议的。例如:

user=`echo “$UID”`
user=$(echo “$UID”)

8、 用 readonly 来声明静态变量

静态变量不会改变;它的值一旦在脚本中定义后不能被修改:

readonly passwd_file=”/etc/passwd”
readonly group_file=”/etc/group”

9、 环境变量用大写字母命名,而自定义变量用小写

所有的 bash 环境变量用大写字母去命名,因此用小写字母来命名你的自定义变量以避免变量名冲突:

# 定义自定义变量用小写,而环境变量用大写
nikto_file=”$HOME/Downloads/nikto-master/program/nikto.pl”
perl “$nikto_file” -h  “$1”

10、 总是对长脚本进行调试

如果你在写有数千行代码的 bash 脚本,排错可能变成噩梦。为了在脚本执行前易于修正一些错误,要进行一些调试。通过阅读下面给出的指南来掌握此技巧:

  1. 如何在 Linux 中启用 Shell 脚本调试模式
  2. 如何在 Shell 脚本中执行语法检查调试模式
  3. 如何在 Shell 脚本中跟踪调试命令的执行

本文到这就结束了,你是否有一些其他更好的 bash 脚本编程经验想要分享?若是的话,在下面评论框分享出来吧。


作者简介:

Aaron Kili 是一个 Linux 和 F.O.S.S(Free and Open-Source Software,自由及开放源代码软件)爱好者,未来的 Linux 系统管理员、Web 开发人员,目前是 TecMint 的内容创作者,他喜欢用电脑工作,且崇尚分享知识。


via: https://www.tecmint.com/useful-tips-for-writing-bash-scripts-in-linux/

作者:Aaron Kili 译者:ch-cn 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

这是调试器工作原理系列文章的第一篇,我不确定这个系列会有多少篇文章,会涉及多少话题,但我仍会从这篇基础开始。

这一篇会讲什么

我将为大家展示 Linux 中调试器的主要构成模块 - ptrace 系统调用。这篇文章所有代码都是基于 32 位 Ubuntu 操作系统。值得注意的是,尽管这些代码是平台相关的,将它们移植到其它平台应该并不困难。

缘由

为了理解我们要做什么,让我们先考虑下调试器为了完成调试都需要什么资源。调试器可以开始一个进程并调试这个进程,又或者将自己同某个已经存在的进程关联起来。调试器能够单步执行代码,设定断点并且将程序执行到断点,检查变量的值并追踪堆栈。许多调试器有着更高级的特性,例如在调试器的地址空间内执行表达式或者调用函数,甚至可以在进程执行过程中改变代码并观察效果。

尽管现代的调试器都十分的复杂(我没有检查,但我确信 gdb 的代码行数至少有六位数),但它们的工作的原理却是十分的简单。调试器的基础是操作系统与编译器 / 链接器提供的一些基础服务,其余的部分只是简单的编程而已。

Linux 的调试 - ptrace

Linux 调试器中的瑞士军刀便是 ptrace 系统调用(使用 man 2 ptrace 命令可以了解更多)。这是一种复杂却强大的工具,可以允许一个进程控制另外一个进程并从 内部替换 Peek and poke 被控制进程的内核镜像的值(Peek and poke 在系统编程中是很知名的叫法,指的是直接读写内存内容)。

接下来会深入分析。

执行进程的代码

我将编写一个示例,实现一个在“跟踪”模式下运行的进程。在这个模式下,我们将单步执行进程的代码,就像机器码(汇编代码)被 CPU 执行时一样。我将分段展示、讲解示例代码,在文章的末尾也有完整 c 文件的下载链接,你可以编译、执行或者随心所欲的更改。

更进一步的计划是实现一段代码,这段代码可以创建可执行用户自定义命令的子进程,同时父进程可以跟踪子进程。首先是主函数:

int main(int argc, char** argv)
{
    pid_t child_pid;

    if (argc < 2) {
        fprintf(stderr, "Expected a program name as argument\n");
        return -1;
    }

    child_pid = fork();
    if (child_pid == 0)
        run_target(argv[1]);
    else if (child_pid > 0)
        run_debugger(child_pid);
    else {
        perror("fork");
        return -1;
    }

    return 0;
}

看起来相当的简单:我们用 fork 创建了一个新的子进程(这篇文章假定读者有一定的 Unix/Linux 编程经验。我假定你知道或至少了解 fork、exec 族函数与 Unix 信号)。if 语句的分支执行子进程(这里称之为 “target”),else if 的分支执行父进程(这里称之为 “debugger”)。

下面是 target 进程的代码:

void run_target(const char* programname)
{
    procmsg("target started. will run '%s'\n", programname);

    /* Allow tracing of this process */
    if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
        perror("ptrace");
        return;
    }

    /* Replace this process's image with the given program */
    execl(programname, programname, 0);
}

这段代码中最值得注意的是 ptrace 调用。在 sys/ptrace.h 中,ptrace 是如下定义的:

long ptrace(enum __ptrace_request request, pid_t pid,
                 void *addr, void *data);

第一个参数是 _request_,这是许多预定义的 PTRACE_* 常量中的一个。第二个参数为请求分配进程 ID。第三个与第四个参数是地址与数据指针,用于操作内存。上面代码段中的 ptrace 调用发起了 PTRACE_TRACEME 请求,这意味着该子进程请求系统内核让其父进程跟踪自己。帮助页面上对于 request 的描述很清楚:

意味着该进程被其父进程跟踪。任何传递给该进程的信号(除了 SIGKILL)都将通过 wait() 方法阻塞该进程并通知其父进程。此外,该进程的之后所有调用 exec() 动作都将导致 SIGTRAP 信号发送到此进程上,使得父进程在新的程序执行前得到取得控制权的机会。如果一个进程并不需要它的的父进程跟踪它,那么这个进程不应该发送这个请求。(pid、addr 与 data 暂且不提)

我高亮了这个例子中我们需要注意的部分。在 ptrace 调用后,run_target 接下来要做的就是通过 execl 传参并调用。如同高亮部分所说明,这将导致系统内核在 execl 创建进程前暂时停止,并向父进程发送信号。

是时候看看父进程做什么了。

void run_debugger(pid_t child_pid)
{
    int wait_status;
    unsigned icounter = 0;
    procmsg("debugger started\n");

    /* Wait for child to stop on its first instruction */
    wait(&wait_status);

    while (WIFSTOPPED(wait_status)) {
        icounter++;
        /* Make the child execute another instruction */
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
            perror("ptrace");
            return;
        }

        /* Wait for child to stop on its next instruction */
        wait(&wait_status);
    }

    procmsg("the child executed %u instructions\n", icounter);
}

如前文所述,一旦子进程调用了 exec,子进程会停止并被发送 SIGTRAP 信号。父进程会等待该过程的发生并在第一个 wait() 处等待。一旦上述事件发生了,wait() 便会返回,由于子进程停止了父进程便会收到信号(如果子进程由于信号的发送停止了,WIFSTOPPED 就会返回 true)。

父进程接下来的动作就是整篇文章最需要关注的部分了。父进程会将 PTRACE_SINGLESTEP 与子进程 ID 作为参数调用 ptrace 方法。这就会告诉操作系统,“请恢复子进程,但在它执行下一条指令前阻塞”。周而复始地,父进程等待子进程阻塞,循环继续。当 wait() 中传出的信号不再是子进程的停止信号时,循环终止。在跟踪器(父进程)运行期间,这将会是被跟踪进程(子进程)传递给跟踪器的终止信号(如果子进程终止 WIFEXITED 将返回 true)。

icounter 存储了子进程执行指令的次数。这么看来我们小小的例子也完成了些有用的事情 - 在命令行中指定程序,它将执行该程序并记录它从开始到结束所需要的 cpu 指令数量。接下来就让我们这么做吧。

测试

我编译了下面这个简单的程序并利用跟踪器运行它:

#include <stdio.h>

int main()
{
    printf("Hello, world!\n");
    return 0;
}

令我惊讶的是,跟踪器花了相当长的时间,并报告整个执行过程共有超过 100,000 条指令执行。仅仅是一条输出语句?什么造成了这种情况?答案很有趣(至少你同我一样痴迷与机器/汇编语言)。Linux 的 gcc 默认会动态的将程序与 c 的运行时库动态地链接。这就意味着任何程序运行前的第一件事是需要动态库加载器去查找程序运行所需要的共享库。这些代码的数量很大 - 别忘了我们的跟踪器要跟踪每一条指令,不仅仅是主函数的,而是“整个进程中的指令”。

所以当我将测试程序使用静态编译时(通过比较,可执行文件会多出 500 KB 左右的大小,这部分是 C 运行时库的静态链接),跟踪器提示只有大概 7000 条指令被执行。这个数目仍然不小,但是考虑到在主函数执行前 libc 的初始化以及主函数执行后的清除代码,这个数目已经是相当不错了。此外,printf 也是一个复杂的函数。

仍然不满意的话,我需要的是“可以测试”的东西 - 例如可以完整记录每一个指令运行的程序执行过程。这当然可以通过汇编代码完成。所以我找到了这个版本的 “Hello, world!” 并编译了它。

section    .text
    ; The _start symbol must be declared for the linker (ld)
    global _start

_start:

    ; Prepare arguments for the sys_write system call:
    ;   - eax: system call number (sys_write)
    ;   - ebx: file descriptor (stdout)
    ;   - ecx: pointer to string
    ;   - edx: string length
    mov    edx, len
    mov    ecx, msg
    mov    ebx, 1
    mov    eax, 4

    ; Execute the sys_write system call
    int    0x80

    ; Execute sys_exit
    mov    eax, 1
    int    0x80

section   .data
msg db    'Hello, world!', 0xa
len equ    $ - msg

当然,现在跟踪器提示 7 条指令被执行了,这样一来很容易区分它们。

深入指令流

上面那个汇编语言编写的程序使得我可以向你介绍 ptrace 的另外一个强大的用途 - 详细显示被跟踪进程的状态。下面是 run_debugger 函数的另一个版本:

void run_debugger(pid_t child_pid)
{
    int wait_status;
    unsigned icounter = 0;
    procmsg("debugger started\n");

    /* Wait for child to stop on its first instruction */
    wait(&wait_status);

    while (WIFSTOPPED(wait_status)) {
        icounter++;
        struct user_regs_struct regs;
        ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
        unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0);

        procmsg("icounter = %u.  EIP = 0x%08x.  instr = 0x%08x\n",
                    icounter, regs.eip, instr);

        /* Make the child execute another instruction */
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
            perror("ptrace");
            return;
        }

        /* Wait for child to stop on its next instruction */
        wait(&wait_status);
    }

    procmsg("the child executed %u instructions\n", icounter);
}

不同仅仅存在于 while 循环的开始几行。这个版本里增加了两个新的 ptrace 调用。第一条将进程的寄存器值读取进了一个结构体中。 sys/user.h 定义有 user_regs_struct。如果你查看头文件,头部的注释这么写到:

/* 这个文件只为了 GDB 而创建
   不用详细的阅读.如果你不知道你在干嘛,
   不要在除了 GDB 以外的任何地方使用此文件  */

不知道你做何感想,但这让我觉得我们找对地方了。回到例子中,一旦我们在 regs 变量中取得了寄存器的值,我们就可以通过将 PTRACE_PEEKTEXT 作为参数、 regs.eip(x86 上的扩展指令指针)作为地址,调用 ptrace ,读取当前进程的当前指令(警告:如同我上面所说,文章很大程度上是平台相关的。我简化了一些设定 - 例如,x86 指令集不需要调整到 4 字节,我的32位 Ubuntu unsigned int 是 4 字节。事实上,许多平台都不需要。从内存中读取指令需要预先安装完整的反汇编器。我们这里没有,但实际的调试器是有的)。下面是新跟踪器所展示出的调试效果:

$ simple_tracer traced_helloworld
[5700] debugger started
[5701] target started. will run 'traced_helloworld'
[5700] icounter = 1\.  EIP = 0x08048080\.  instr = 0x00000eba
[5700] icounter = 2\.  EIP = 0x08048085\.  instr = 0x0490a0b9
[5700] icounter = 3\.  EIP = 0x0804808a.  instr = 0x000001bb
[5700] icounter = 4\.  EIP = 0x0804808f.  instr = 0x000004b8
[5700] icounter = 5\.  EIP = 0x08048094\.  instr = 0x01b880cd
Hello, world!
[5700] icounter = 6\.  EIP = 0x08048096\.  instr = 0x000001b8
[5700] icounter = 7\.  EIP = 0x0804809b.  instr = 0x000080cd
[5700] the child executed 7 instructions

现在,除了 icounter,我们也可以观察到指令指针与它每一步所指向的指令。怎么来判断这个结果对不对呢?使用 objdump -d 处理可执行文件:

$ objdump -d traced_helloworld

traced_helloworld:     file format elf32-i386

Disassembly of section .text:

08048080 <.text>:
 8048080:     ba 0e 00 00 00          mov    $0xe,%edx
 8048085:     b9 a0 90 04 08          mov    $0x80490a0,%ecx
 804808a:     bb 01 00 00 00          mov    $0x1,%ebx
 804808f:     b8 04 00 00 00          mov    $0x4,%eax
 8048094:     cd 80                   int    $0x80
 8048096:     b8 01 00 00 00          mov    $0x1,%eax
 804809b:     cd 80                   int    $0x80

这个结果和我们跟踪器的结果就很容易比较了。

 将跟踪器关联到正在运行的进程

如你所知,调试器也能关联到已经运行的进程。现在你应该不会惊讶,ptrace 通过以 PTRACE_ATTACH 为参数调用也可以完成这个过程。这里我不会展示示例代码,通过上文的示例代码应该很容易实现这个过程。出于学习目的,这里使用的方法更简便(因为我们在子进程刚开始就可以让它停止)。

代码

上文中的简单的跟踪器(更高级的,可以打印指令的版本)的完整c源代码可以在这里找到。它是通过 4.4 版本的 gcc 以 -Wall -pedantic --std=c99 编译的。

结论与计划

诚然,这篇文章并没有涉及很多内容 - 我们距离亲手完成一个实际的调试器还有很长的路要走。但我希望这篇文章至少可以使得调试这件事少一些神秘感。ptrace 是功能多样的系统调用,我们目前只展示了其中的一小部分。

单步调试代码很有用,但也只是在一定程度上有用。上面我通过 C 的 “Hello World!” 做了示例。为了执行主函数,可能需要上万行代码来初始化 C 的运行环境。这并不是很方便。最理想的是在 main 函数入口处放置断点并从断点处开始分步执行。为此,在这个系列的下一篇,我打算展示怎么实现断点。

参考

撰写此文时参考了如下文章:


via: http://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1

作者:Eli Bendersky 译者:YYforymj 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

shell 脚本调试系列 中,本文将解释第三种 shell 脚本调试模式,即 shell 跟踪,并查看一些示例来演示它如何工作以及如何使用它。

本系列的前面部分清晰地阐明了另外两种 shell 脚本调试模式:详细模式和语法检查模式,并用易于理解的例子展示了如何在这些模式下启用 shell 脚本调试。

  1. 如何在 Linux 中启用 Shell 脚本的调试模式
  2. 如何在 Shell 脚本中执行语法检查调试模式

shell 跟踪简单的来说就是跟踪 shell 脚本中的命令的执行。要打开 shell 跟踪,请使用 -x 调试选项。

这会让 shell 在终端上显示所有执行的命令及其参数。

我们将使用下面的 sys_info.sh shell 脚本,它会简要地打印出你的系统日期和时间、登录的用户数和系统的运行时间。不过,脚本中包含我们需要查找和更正的语法错误。

#!/bin/bash
# script to print brief system info
ROOT_ID="0"
DATE=`date`
NO_USERS=`who | wc -l`
UPTIME=`uptime`
check_root(){
  if [ "$UID" -ne "$ROOT_ID" ]; then
    echo "You are not allowed to execute this program!"
    exit 1;    
}
print_sys_info(){
  echo "System Time    : $DATE"
  echo "Number of users: $NO_USERS"
  echo "System Uptime  : $UPTIME
}
check_root
print_sys_info
exit 0

保存文件并执行脚本。脚本只能用 root 用户运行,因此如下使用 sudo 命令运行:

$ chmod +x sys_info.sh
$ sudo bash -x sys_info.sh

Shell Tracing - Show Error in Script

shell 跟踪 - 显示脚本中的错误

从上面的输出我们可以观察到,首先执行命令,然后其输出做为一个变量的值。

例如,先执行 date,其输出做为变量 DATE 的值。

我们可以执行语法检查来只显示其中的语法错误,如下所示:

$ sudo bash -n sys_info.sh 

Syntax Checking in Script

脚本中语法检查

如果我们审视这个 shell 脚本,我们就会发现 if 语句缺少了封闭条件的 fi 关键字。因此,让我们加上它,新的脚本应该看起来像这样:

#!/bin/bash
#script to print brief system info
ROOT_ID="0"
DATE=`date`
NO_USERS=`who | wc -l`
UPTIME=`uptime`
check_root(){
  if [ "$UID" -ne "$ROOT_ID" ]; then
    echo "You are not allowed to execute this program!"
    exit 1;
  fi    
}
print_sys_info(){
  echo "System Time    : $DATE" 
  echo "Number of users: $NO_USERS"
  echo "System Uptime  : $UPTIME
}
check_root
print_sys_info
exit 0

再次保存文件并以 root 执行,同时做语法检查:

$ sudo bash -n sys_info.sh

Perform Syntax Check in Shell Scripts

在 shell 脚本中执行语法检查

上面的语法检查操作的结果仍然显示在脚本的第 21 行还有一个错误。所以,我们仍然要纠正一些语法。

再一次分析脚本,会发现第 21 行的错误是由于在 print_sys_info 函数内最后一个 echo 命令中没有闭合双引号 "

我们将在 echo 命令中添加闭合双引号并保存文件。修改过的脚本如下:

#!/bin/bash
#script to print brief system info
ROOT_ID="0"
DATE=`date`
NO_USERS=`who | wc -l`
UPTIME=`uptime`
check_root(){
  if [ "$UID" -ne "$ROOT_ID" ]; then
    echo "You are not allowed to execute this program!"
    exit 1;
  fi
}
print_sys_info(){
  echo "System Time    : $DATE"
  echo "Number of users: $NO_USERS"
  echo "System Uptime  : $UPTIME"
}
check_root
print_sys_info
exit 0

现在再一次检查语法。

$ sudo bash -n sys_info.sh

上面的命令不会产生任何输出,因为我们的脚本语法上正确。我们也可以再次跟踪脚本执行,它应该工作得很好:

$ sudo bash -x sys_info.sh

Trace Shell Script Execution

跟踪 shell 脚本执行

现在运行脚本。

$ sudo ./sys_info.sh

Shell Script to Show Date, Time and Uptime

用 shell 脚本显示日期、时间和运行时间

shell 跟踪执行的重要性

shell 脚本跟踪可以帮助我们识别语法错误,更重要的是识别逻辑错误。例如,在 sys_info.sh shell 脚本中的 check_root 函数,它用于确定用户是否为 root,因为脚本只允许由超级用户执行。

check_root(){
  if [ "$UID" -ne "$ROOT_ID" ]; then
    echo "You are not allowed to execute this program!"
    exit 1;
  fi
}

这里的魔法是由 if 语句表达式 ["$ UID" -ne "$ ROOT_ID"] 控制的,一旦我们不使用合适的数字运算符(示例中为 -ne,这意味着不相等),我们最终可能会出一个逻辑错误。

假设我们使用 -eq (意思是等于),这将允许任何系统用户以及 root 用户运行脚本,因此是一个逻辑错误。

check_root(){
  if [ "$UID" -eq "$ROOT_ID" ]; then
    echo "You are not allowed to execute this program!"
    exit 1;
  fi
}

注意:我们在本系列开头介绍过,set 这个 shell 内置命令可以在 shell 脚本的特定部分激活调试。

因此,下面的行将帮助我们通过跟踪脚本的执行在其中找到这个逻辑错误:

具有逻辑错误的脚本:

#!/bin/bash
#script to print brief system info
ROOT_ID="0"
DATE=`date`
NO_USERS=`who | wc -l`
UPTIME=`uptime`
check_root(){
  if [ "$UID" -eq "$ROOT_ID" ]; then
    echo "You are not allowed to execute this program!"
    exit 1;
  fi
}
print_sys_info(){
  echo "System Time    : $DATE"
  echo "Number of users: $NO_USERS"
  echo "System Uptime  : $UPTIME"
}
#turning on and off debugging of check_root function
set -x ; check_root;  set +x ;
print_sys_info
exit 0

保存文件并调用脚本,在输出中,我们可以看到一个普通系统用户可以在未 sudo 的情况下运行脚本。 这是因为 USER_ID 的值为 100,不等于为 0 的 root 的 ROOT_ID

$ ./sys_info.sh

Run Shell Script Without Sudo

未 sudo 的情况下运行 shell 脚本

那么,现在我们已经完成了 shell 脚本调试系列,可以在下面的反馈栏里给我们关于本篇或者本系列提出问题或反馈。


作者简介:

Aaron Kili 是 Linux 和 F.O.S.S 爱好者,将来的 Linux SysAdmin、web 开 发人员,目前是 TecMint 的内容创作者,他喜欢用电脑工作,并坚信分享知识。


via: http://www.tecmint.com/trace-shell-script-execution-in-linux/

作者:Aaron Kili 译者:geekpi 校对:jasminepeng

本文由 LCTT 原创编译,Linux中国 荣誉推出