Solaris 动态跟踪指南

第 7 章 结构和联合

相关变量的集合可以组合成称为结构联合的复合数据对象。通过为这些对象创建新的类型定义,可在 D 中对其进行定义。您可以将新类型用于任何 D 变量,包括关联的数组值。本章介绍了用于创建和处理这些复合类型及与其交互的 D 运算符的语法和语义,并使用多个演示 DTrace fbtpid 提供器用法的示例程序,对结构和联合的语法进行说明。

结构

D 关键字 structstructure 的缩写,用于引入由一组其他类型组成的新类型。新的结构类型可用作 D 变量和数组的类型,从而使您可以采用单一名称定义相关变量组。D 结构与 C 和 C++ 中对应的构造相同。如果您采用 Java 编程语言编写程序,可将 D 结构视为只包含数据成员但不包含方法的类。

我们假定您希望在 D 中创建一个较为复杂的系统调用跟踪程序,用于记录与 shell 执行的每个 read(2)write(2) 系统调用有关的多种因素,如已用时间、调用数以及作为参数传递的最大字节计数。您可以编写一条 D 子句,在三个单独的关联数组中记录这些属性,如下例所示:

syscall::read:entry, syscall::write:entry
/pid == 12345/
{
	ts[probefunc] = timestamp;
	calls[probefunc]++;
	maxbytes[probefunc] = arg2 > maxbytes[probefunc] ?
	    arg2 : maxbytes[probefunc];
}

但是,该子句效率很低,因为 DTrace 必须创建三个单独的关联数组,并存储与每个数组的 probefunc 对应的同一元组值的各个副本。相反,可以使用结构来节省空间并使程序易于阅读和维护。首先,在程序源文件顶部声明一个新的结构类型:

struct callinfo {
	uint64_t ts;      /* timestamp of last syscall entry */
	uint64_t elapsed; /* total elapsed time in nanoseconds */
	uint64_t calls;   /* number of calls made */
	size_t maxbytes;  /* maximum byte count argument */
};

struct 关键字后跟一个可选标识符,用于引用现在称为 struct callinfo 的新类型。然后,将结构成员括在一组大括号 { } 中,并使用分号 (; ) 结束整个声明。定义每个结构成员使用的语法与 D 变量声明相同,即首先列出成员类型,然后是命名成员的标识符,以及另一个分号 (;)。

结构声明本身仅定义新类型,而不会创建任何变量或在 DTrace 中分配存储空间。完成声明之后,即可在整个 D 程序的其余部分将 struct callinfo 用作类型,并且每个类型为 struct callinfo 的变量都将存储结构模板描述的四个变量副本。这些成员将根据成员列表按顺序分配内存,并在成员间引入数据对象对齐所必需的填充空间。

您可以通过 "." 运算符编写以下格式的表达式,使用成员标识符名称来访问各成员值:

variable-name.member-name

以下示例为使用新结构类型的改进程序。请转到编辑器并键入以下 D 程序,然后将其保存在名为 rwinfo.d 的文件中:


示例 7–1 rwinfo.d:收集 read(2) 和 write(2) 统计信息

struct callinfo {
	uint64_t ts;      /* timestamp of last syscall entry */
	uint64_t elapsed; /* total elapsed time in nanoseconds */
	uint64_t calls;   /* number of calls made */
	size_t maxbytes;  /* maximum byte count argument */
};

struct callinfo i[string];	/* declare i as an associative array */

syscall::read:entry, syscall::write:entry
/pid == $1/
{
	i[probefunc].ts = timestamp;
	i[probefunc].calls++;
	i[probefunc].maxbytes = arg2 > i[probefunc].maxbytes ?
		arg2 : i[probefunc].maxbytes;
}

syscall::read:return, syscall::write:return
/i[probefunc].ts != 0 && pid == $1/
{
	i[probefunc].elapsed += timestamp - i[probefunc].ts;
}

END
{
	printf("        calls  max bytes  elapsed nsecs\n");
	printf("------  -----  ---------  -------------\n");
	printf("  read  %5d  %9d  %d\n",
	    i["read"].calls, i["read"].maxbytes, i["read"].elapsed);
	printf(" write  %5d  %9d  %d\n",
	    i["write"].calls, i["write"].maxbytes, i["write"].elapsed);
}

键入该程序后,请运行 dtrace -q -s rwinfo.d,指定其中一个 shell 进程。然后在 shell 中键入数条命令,并在完成 shell 命令输入后,在 dtrace 终端按 Ctrl-C 组合键,以触发 END 探测器并列显结果:


# dtrace -q -s rwinfo.d `pgrep -n ksh`
^C
        calls  max bytes  elapsed nsecs
------  -----  ---------  -------------
  read     36       1024  3588283144
 write     35         59  14945541
#

结构指针

在 C 和 D 中,通过指针引用结构很常见。您可以使用运算符 -> 来通过指针访问结构成员。如果 struct s 包含成员 m,并且您具有指向名为 sp 的结构的指针(即 spstruct s * 类型的变量),则可以使用 * 运算符,首先取消对 sp 指针的引用,以便访问该成员:

struct s *sp;

(*sp).m

或者,可将 -> 运算符用作该表示法的简写。如果 sp 为指向结构的指针,则以下两个 D 代码段的含义完全相同。

(*sp).m				sp->m

DTrace 提供了多个属于结构指针的内置变量,包括 curpsinfocurlwpsinfo。这些指针分别引用结构 psinfolwpsinfo,并且其内容提供了有关当前进程及轻量进程 (lightweight process, LWP)(与触发当前探测器的线程关联)状态的信息快照。Solaris LWP 是用户线程的内核表示,Solaris 线程和 POSIX 线程接口是基于该进程建立的。为方便起见,DTrace 采用与 /proc 文件系统文件 /proc/pid /psinfo/proc/pid/lwps/ lwpid/lwpsinfo 相同的格式导出此信息。/proc 结构可供观察工具和调试工具(如 ps(1)pgrep(1)truss(1))使用,系统头文件 <sys/procfs.h> 中定义了该结构,proc(4) 手册页中也有其说明。下面列出了几个使用 curpsinfo 的示例表达式,及其类型和含义:

curpsinfo->pr_pid

pid_t

当前进程 ID 

curpsinfo->pr_fname

char []

可执行文件名 

curpsinfo->pr_psargs

char []

初始命令行参数 

您应在随后通过检查 <sys/procfs.h> 头文件以及 proc(4) 中的相应说明,查看完整的结构定义。下例将通过匹配命令行参数,使用 pr_psargs 成员来确定所关注的进程。

在 C 程序中,经常使用结构来创建复杂的数据结构,因此,描述和引用来自 D 的结构的能力也可提供强大的功能,以便观察 Solaris 操作系统内核及其系统接口的内部工作方式。除使用前述的 curpsinfo 结构外,下例还通过观察 ksyms(7D) 驱动程序和 read(2) 请求之间的关系,检查某些内核结构。该驱动程序使用两个称作 uio(9S)iovec(9S) 的常见结构,来响应从字符设备文件 /dev/ksyms 读取的请求。

uio(9S) 手册页介绍了可通过名称 struct uio 或类型别名 uio_t 访问的 uio 结构。该结构用于说明与在内核和用户进程之间复制数据有关的 I/O 请求。uio 又包含由一个或多个 iovec(9S) 结构组成的数组,如果使用 readv(2)writev(2) 系统调用请求多个块,则每个结构都说明一部分请求的 I/O。在 struct uio 上运行的内核设备驱动程序接口 (device driver interface, DDI) 例程中有一个是函数 uiomove(9F),该函数是内核驱动程序用于响应用户进程 read(2) 请求并将数据复制回用户进程的函数系列之一。

ksyms 驱动程序用于管理名为 /dev/ksyms 的字符设备文件,该文件似乎为包含内核符号表相关信息的 ELF 文件,但实际上是驱动程序使用当前装入内核的模块集创建的一种假象。该驱动程序使用 uiomove(9F) 例程来响应 read(2) 请求。下例说明,/dev/ksymsread(2) 的参数和调用将调用通过驱动程序与 uiomove(9F) 匹配,以将结果复制到 read(2) 中指定位置的用户地址空间。

我们可以使用 strings(1) 实用程序和 -a 选项,强制从 /dev/ksyms 进行串读取。请尝试在 shell 中运行 strings -a /dev/ksyms,然后查看生成的输出结果。在编辑器中,键入示例脚本的第一条子句,然后将其保存在名为 ksyms.d 的文件中:

syscall::read:entry
/curpsinfo->pr_psargs == "strings -a /dev/ksyms"/
{
	printf("read %u bytes to user address %x\n", arg2, arg1);
}

第一条子句使用表达式 curpsinfo->pr_psargs 来访问和匹配 strings(1) 命令的命令行参数,以便脚本在跟踪参数之前选择正确的 read(2) 请求。请注意,通过使用运算符 ==(左侧参数为 char 类型的数组,右侧参数为字符串),D 编译器将指示左侧参数应提升为字符串,并执行字符串比较。在某个 shell 中键入并执行命令 dtrace -q -s ksyms.d,然后在另 一个 shell 中键入命令 strings -a /dev/ksyms。执行 strings(1) 时,将会显示与以下示例类似的 DTrace 输出:


# dtrace -q -s ksyms.d
read 8192 bytes to user address 80639fc
read 8192 bytes to user address 80639fc
read 8192 bytes to user address 80639fc
read 8192 bytes to user address 80639fc
...
^C
#

此示例可使用常用的 D 编程方法进行扩展,以便跟踪该初始 read(2) 请求的线程深入内核。进入 syscall::read:entry 中的内核后,接下来的脚本将设置线程局部标志变量,用于指示此线程为关注线程,并在 syscall::read:return 中清除该标志。设置该标志后,可在其他探测器上将其用作谓词,以检测 uiomove(9F) 等内核函数。DTrace 函数边界跟踪 (function boundary tracing,fbt) 提供器发布了用于进入和返回该内核中定义的函数(包括 DDI 中的那些函数)的探测器。请键入下列使用 fbt 提供器检测 uiomove(9F) 的源代码,然后再次将其保存到文件 ksyms.d 中:


示例 7–2 ksyms.d:跟踪 read(2) 和 uiomove(9F) 关系

/*
 * When our strings(1) invocation starts a read(2), set a watched flag on
 * the current thread.  When the read(2) finishes, clear the watched flag.
 */
syscall::read:entry
/curpsinfo->pr_psargs == "strings -a /dev/ksyms"/
{
	printf("read %u bytes to user address %x\n", arg2, arg1);
	self->watched = 1;
}

syscall::read:return
/self->watched/
{
	self->watched = 0;
}

/*
 * Instrument uiomove(9F).  The prototype for this function is as follows:
 * int uiomove(caddr_t addr, size_t nbytes, enum uio_rw rwflag, uio_t *uio);
 */
fbt::uiomove:entry
/self->watched/
{
	this->iov = args[3]->uio_iov;

	printf("uiomove %u bytes to %p in pid %d\n",
	    this->iov->iov_len, this->iov->iov_base, pid);
}

该示例的最后一条子句使用线程本地变量 self->watched,来确定所关注的内核线程进入 DDI 例程 uiomove(9F) 的时间。在此之后,脚本使用内置的 args 数组访问 uiomove() 的第四个参数 (args[3]),该参数是一个指向代表该请求的 struct uio 的指针。D 编译器会自动将 args 数组的每个成员,与受检测的内核例程的 C 函数原型所对应的类型进行关联。uio_iov 成员包含一个指向该请求的 struct iovec 的指针。为供子句中的子句局部变量 this->iov 使用,保存了该指针副本。在最后一条语句中,脚本取消了对 this->iov 的引用,以访问 iovec 成员 iov_leniov_base,二者分别表示 uiomove(9F) 的长度(以字节为单位)以及目标基本地址。这些值应与驱动程序中发出的 read(2) 系统调用的输入参数匹配。请转到您的 shell 并运行 dtrace -q -s ksyms.d,然后在另一个 shell 中再次输入命令 strings -a /dev/ksyms。显示的输出将与以下示例类似:


# dtrace -q -s ksyms.d
read 8192 bytes at user address 80639fc
uiomove 8192 bytes to 80639fc in pid 101038
read 8192 bytes at user address 80639fc
uiomove 8192 bytes to 80639fc in pid 101038
read 8192 bytes at user address 80639fc
uiomove 8192 bytes to 80639fc in pid 101038
read 8192 bytes at user address 80639fc
uiomove 8192 bytes to 80639fc in pid 101038
...
^C
#

在您的输出中,地址和进程 ID 将有所不同,但您应看到 read(2) 的输入参数与通过 ksyms 驱动程序传递至 uiomove(9F) 的参数匹配。

联合

联合是 ANSI-C 和 D 支持的另一种复合类型,它与结构密切相关。联合是一种复合类型,其中,定义了一组不同类型的成员,并且所有成员对象都占用同一存储区域。因此,根据联合的指定方式,联合是一个变体类型的对象,在任何给定时间内仅有一个成员有效。通常,使用其他变量或状态部分来指示当前处于有效状态的联合成员。联合的大小为其最大成员的大小,用于联合的内存对齐为联合成员所需的最大对齐值。

Solaris kstat 框架定义了一个包含联合的结构,用于在以下示例中说明和观察 C 和 D 联合。kstat 框架用于导出一组表示内核统计信息(如内存使用情况和 I/O 吞吐量)的指定计数器。使用该框架可以实现 mpstat(1M)iostat(1M) 等实用程序。此框架使用 struct kstat_named 来描述指定的计数器及其值,定义如下:

struct kstat_named {
	char name[KSTAT_STRLEN]; /* name of counter */
	uchar_t data_type;	/* data type */
	union {
		char c[16];
		int32_t i32;
		uint32_t ui32;
		long l;
		ulong_t ul;
		...
	} value;	/* value of counter */
};	

为了直观起见,缩短了检查声明。完整的结构定义可在 <sys/kstat.h> 头文件中找到,kstat_named(9S) 中对其进行了说明。以上声明在 ANSI-C 和 D 中均有效,该声明定义了一个结构,其中包含一个作为其成员之一的联合值,该值具有各种类型的成员,具体取决于计数器的类型。请注意,由于该联合本身是在另一种类型 struct kstat_named 中声明的,因此省略了该联合类型的正式名称。此声明样式称作匿名联合。名为 value 的成员具有上述声明描述的联合类型,但是此联合类型本身没有名称,因为无需在任何其他位置使用该类型。为结构成员 data_type 分配的值指示对类型为 struct kstat_named 的所有对象都有效的联合成员。对于 data_type 的值,定义了一组 C 预处理器标记。例如,标记 KSTAT_DATA_CHAR 等于零,指示成员 value.c 为该值的当前存储位置。

示例 7–3 演示了如何通过跟踪用户进程访问 kstat_named.value 联合。通过 kstat_data_lookup(3KSTAT) 函数,可从用户进程对 kstat 计数器进行采样,该函数将返回指向 struct kstat_named 的指针。为获取最新的计数器采样值,mpstat(1M) 实用程序将在执行时重复调用该函数。请转到您的 shell 并尝试运行 mpstat 1,然后观察输出。几秒钟之后,在 shell 中按 Ctrl-C 组合键以中止 mpstat。为观察计数器采样情况,我们要启用一个在 mpstat 命令每次调用 libkstat 中的 kstat_data_lookup(3KSTAT) 函数时都会触发的探测器。为此,我们将使用一个新的 DTrace 提供器:pidpid 提供器允许您在用户进程的 C 符号位置(如函数入口点)动态创建探测器。您可以通过编写以下格式的探测器说明,要求 pid 提供器在用户函数进入和返回位置创建探测器:

pidprocess-ID:object-name:function-name:entry
pidprocess-ID:object-name:function-name:return

例如,如果要在进程 ID 12345 中创建在进入 kstat_data_lookup(3KSTAT) 时触发的探测器,可编写以下探测器说明:

pid12345:libkstat:kstat_data_lookup:entry

pid 提供器将在指定用户进程与探测器说明对应的程序位置插入动态检测过程。该探测器实现强制到达受检测的程序位置的每个用户线程陷入操作系统内核并进入 DTrace,同时触发对应的探测器。因此,尽管实现位置与用户进程关联,但指定的 DTrace 谓词和操作仍在操作系统内核环境中执行。有关 pid 提供器的详细信息,请参见第 30 章

您可以在程序中插入称为宏变量的标识符,在编译程序时将计算该标识符,并将其替换为其他 dtrace 命令行参数,从而无需在每次要将程序应用于不同进程时,都对 D 程序源代码进行编辑。宏变量使用美元符号 $ 并后跟标识符或数字进行指定。如果您执行命令 dtrace -s script foo bar baz,D 编译器将自动定义宏变量 $1$2$3,分别作为标记 foobarbaz。您可以在 D 程序表达式或探测器说明中使用宏变量。例如,以下探测器说明检测指定为 dtrace 附加参数的任何进程 ID:

pid$1:libkstat:kstat_data_lookup:entry
{
	self->ksname = arg1;
}

pid$1:libkstat:kstat_data_lookup:return
/self->ksname != NULL && arg1 != NULL/
{
	this->ksp = (kstat_named_t *)copyin(arg1, sizeof (kstat_named_t));
	printf("%s has ui64 value %u\n", copyinstr(self->ksname),
	    this->ksp->value.ui64);
}

pid$1:libkstat:kstat_data_lookup:return
/self->ksname != NULL && arg1 == NULL/
{
	self->ksname = NULL;
}

有关宏变量和可重用脚本的详细信息,请参见第 15 章。至此,我们已了解如何使用进程 ID 检测用户进程,现在让我们返回到采样联合。请转到编辑器并键入完整示例的源代码,然后将其保存在名为 kstat.d 的文件中:


示例 7–3 kstat.d:跟踪对 kstat_data_lookup(3KSTAT) 的调用

pid$1:libkstat:kstat_data_lookup:entry
{
	self->ksname = arg1;
}

pid$1:libkstat:kstat_data_lookup:return
/self->ksname != NULL && arg1 != NULL/
{
	this->ksp = (kstat_named_t *) copyin(arg1, sizeof (kstat_named_t));
	printf("%s has ui64 value %u\n",
	    copyinstr(self->ksname), this->ksp->value.ui64);
}

pid$1:libkstat:kstat_data_lookup:return
/self->ksname != NULL && arg1 == NULL/
{
	self->ksname = NULL;
}

现在,转到其中一个 shell 并执行命令 mpstat 1,以启动在进行统计信息采样并每秒报告一次的模式下运行的 mpstat(1M)。开始运行 mpstat 后,在其他 shell 中执行命令 dtrace -q -s kstat.d `pgrep mpstat。您将会看到与正在访问的统计信息对应的输出。按 Ctrl-C 组合键中止 dtrace,然后返回到 shell 提示符。


# dtrace -q -s kstat.d `pgrep mpstat`
cpu_ticks_idle has ui64 value 41154176
cpu_ticks_user has ui64 value 1137
cpu_ticks_kernel has ui64 value 12310
cpu_ticks_wait has ui64 value 903
hat_fault has ui64 value 0
as_fault has ui64 value 48053
maj_fault has ui64 value 1144
xcalls has ui64 value 123832170
intr has ui64 value 165264090
intrthread has ui64 value 124094974
pswitch has ui64 value 840625
inv_swtch has ui64 value 1484
cpumigrate has ui64 value 36284
mutex_adenters has ui64 value 35574
rw_rdfails has ui64 value 2
rw_wrfails has ui64 value 2
...
^C
#

如果您在每个终端窗口中捕获输出,并通过统计信息从前一次迭代所报告的值中减去每个值,则应可将 dtrace 输出与 mpstat 输出关联。该示例程序在进入查找函数时记录计数器名称指针,然后在从 kstat_data_lookup(3KSTAT) 返回时执行大多数跟踪操作。如果 arg1()(返回值)不为 NULL(),D 内置函数 copyinstrcopyin 则会将函数结果从用户进程复制到 DTrace 中。完成 kstat 数据复制后,该示例将报告联合的 ui64 计数器值。此简化示例假定 mpstat 对使用 value.ui64 成员的计数器进行采样。作为一种练习,请尝试记录 kstat.d,以使用多个谓词并列显与 data_type 成员对应的联合成员。此外,还可以尝试创建用于计算连续数据值之差,以及实际生成与 mpstat 类似的输出的 kstat.d 版本。

成员大小和偏移

通过 sizeof 运算符,可以确定包括结构或联合在内的任何 D 类型或表达式的大小(以字节为单位)。sizeof 运算符可应用于表达式或由括号括住的类型名称,如以下两个示例所示:

sizeof expression				sizeof (type-name)

例如,如果将表达式 sizeof (uint64_t)sizeof (callinfo.ts) 插入以上示例程序的源代码中,二者都将返回值 8。sizeof 运算符的正式返回类型为类型别名 size_t,后者被定义为一个大小与当前数据模型中的指针相同的无符号整数,用于表示字节计数。如果将 sizeof 运算符应用于表达式,D 编译器将会验证该表达式,但是,生成的对象大小在编译时进行计算,并且不会生成表达式代码。您可以在需要整型常数的任何位置使用 sizeof

您可以使用配套运算符 offsetof,来确定结构或联合成员相对于与结构或联合类型的任何变量关联的存储区起始位置的偏移量(以字节为单位)。offsetof 运算符用于以下格式的运算符:

offsetof (type-name, member-name)

其中,type-name 是任何结构或联合类型的名称或类型别名,member-name 是命名该结构或联合的成员的标识符。offsetofsizeof 类似,也返回 size_t,并且可在 D 程序中可使用整型常数的任何位置使用。

位字段

D 还允许定义任意位数的整型结构和联合成员(称作位字段)。通过指定带符号整数或无符号整数的基本类型、成员名以及指示要为字段分配的位数的后缀,可以声明位字段,如以下示例所示:

struct s {
	int a : 1;
	int b : 3;
	int c : 12;
};

位字段宽度是一个整型常数,通过结尾冒号与成员名分隔。位字段宽度必须为正数,并且其位数不得超过对应的整数基本类型的宽度。在 D 中,无法声明 64 位以上的位字段。D 位字段与对应的 ANSI-C 功能兼容,并提供对相应 ANSI-C 功能的访问。位字段通常用于非常需要内存存储空间,或结构布局必须与硬件寄存器布局匹配的情况。

位字段是一种编译器构造,该构造可自动设置整数布局和一组掩码,以提取成员值。您只需自己定义掩码并使用 & 运算符,即可获取同样的结果。C 编译器和 D 编译器将尽可能对位进行有效打包,但是,由于它们会按照所需顺序或方式随意打包,因此,无法保证位字段在不同编译器或体系结构间生成相同的位布局。如果您需要稳定的位布局,则应使用 & 运算符,亲自构造位掩码并提取值。

与任何其他结构或结构成员一样,只需指定位字段成员名并结合 "." 或 -> 运算符,便可访问位字段成员。位字段将自动提升为下一个最大的整数类型,以供所有表达式使用。由于位字段存储不能在字节边界上对齐,或大小不能为字节约数,因此,无法将 sizeofoffsetof 运算符应用于位字段成员。另外,D 编译器还禁止您使用 & 运算符获取位字段成员的地址。