Linux设备驱动程序(四)——调试技术2

简介: Linux设备驱动程序(四)——调试技术2

三、通过查询调试

大量使用 printk 仍然会显著降低系统性能,然而,因处理调试信息而使系统性能减慢是我们所不希望的。这个问题可以通过在 /etc/syslogd.conf 中日志文件的名字前面加一个减号前缀来解决。修改配置文件带来的问题在于,在完成调试之后这些改动将依旧保留;如果不愿作这种持久性修改的话,另一个选择是运行一个非 klogd 程序(如前面介绍的 cat /proc/kmsg),但这样并不能为通常的系统操作提供一个合适的环境。

多数情况中,获取相关信息的最好方法是在需要的时候才去查询系统信息,而不是持续不断地产生数据。实际上,每个Unix 系统都提供了很多工具用于获取系统信息,如ps、netstat、vmstat、等等。

驱动程序开发人员可以用如下方法对系统进行查询:在proc 文件系统中创建文件、使用驱动程序的 ioctl 方法,以及通过 sysfs 导出属性等。

1、使用 /proc 文件系统

/proc 文件系统是一种特殊的、由软件创建的文件系统,内核使用它向外界导出信息,/proc 下面的每个文件都绑定于一个内核函数,用户读取其中的文件时,该函数动态地生成文件的“内容”我们已经见到过这类文件的一些输出情况,例如,/proc/modules 列出的是当前载入模块的列表

在 Linux 系统中对 /proc 的使用很频繁。现代 Linux 发行版中的很多工具都是通过 /proc 来获取它们需要的信息,例如 ps、top 和 uptime。有些设备驱动程序也通过 iproc 导出信息,而我们自己的驱动程序当然也可以这么做。因为 /proc 文件系统是动态的,所以驱动程序模块可以在任何时候添加或删除其中的入口项

①、在/proc中实现文件

所有使用 /proc 的模块必须包含 <linux/proc_fs.h>,并通过这个头文件来定义正确的函数。

在某个进程读取 /proc 文件时,内核会分配一个内存页 (即PAGE_SIZE字节的内存块),驱动程序可以将数据通过这个内存页返回到用户空间。该缓冲区会传人我们定义的函数,而该函数称为 read_proc方法:

int (*read_proc)(char *page, char **start, off_t offset, int count, int *eof, void *data);
  • page:指向用来写入数据的缓冲区
  • start:返回实际的数据写到内存页的哪个位置
  • offset 、count:与read方法相同
  • eof:指向一个整型数,当没有数据可返回时,驱动程序必须设置这个参数
  • data:提供给驱动程序的专用数据指针,可用于内部记录

在我们的 read_proc 方法被调用时,start 的初始值为 NULL。如果保留 *start 为空,内核将假定数据保存在内存页偏移量0的地方;也就是说,内核将对 read_proc 作如下简单假定:该函数将虚拟文件的整个内容放到了内存页,并同时忽略 offset 参数。相反,如果我们将start 设置为非空值,内核将认为由 *start 指向的数据是 offset 指定的偏移量处的数据,可直接返回给用户。

关于 /proc 文件还有另一个主要问题,这也是 start 意图解决的一个问题。有时,在连续的 read 调用之间,内核数据结构的 ASCII 表述会发生变化,以至于读取进程发现前后两次调用所获得的数据不一致

注意,还有一个更好的方法可实现 /proc 文件,该方法称为 seg_file,我们稍后将讲述这个方法。现在我们来看一个例子,下面是scull设备 read_proc 函数的简单实现:

int scull_read_procmem(char *buf, char **start, off_t offset, int count, int *eof, void *data)
{
  int i, j, len = 0;
  int limit = count - 80; /* Don't print more than this */
  for (i = 0; i < scull_nr_devs && len <= limit; i++) {
    struct scull_dev *d = &scull_devices[i];
    struct scull_qset *qs = d->data;
    if (down_interruptible(&d->sem))
      return -ERESTARTSYS;
    len += sprintf(buf+len,"\nDevice %i: qset %i, q %i, sz %li\n", i, d->qset, d->quantum, d->size);
    for (; qs && len <= limit; qs = qs->next) { /* scan the list */
      len += sprintf(buf + len, " item at %p, qset at %p\n", qs, qs->data);
      if (qs->data && !qs->next) /* dump only the last item */
        for (j = 0; j < d->qset; j++) {
          if (qs->data[j])
            len += sprintf(buf + len, " % 4i: %8p\n", j, qs->data[j]);
        }
    }
    up(&scull_devices[i].sem);
  }
  *eof = 1;
  return len;
}

②、创建自己的 /proc 文件

一旦定义好了一个 read_proc 函数,就需要把它与一个 /proc 入口项连接起来。这通过调用 create_proc_read_entry 实现:

struct proc_dir_entry *create_proc_read_entry(const char *name, mode_t mode, struct proc_dir_entry *base, read_proc_t *read_proc, void *data);
  • name:要创建的文件名称
  • mode:该文件的保护掩码(可传0表示系统默认值)
  • base:该文件所在的目录(如果base为NULL,则该文件将创建在/proc的根目录)
  • read_proc:实现该文件的read_proc 函数
  • data:内核会忽略data参数,但是会将该参数传递给 read_proc

下面是 scull 调用该函数创建 /proc 文件的代码:

create_proc_read_entry("scullmem", 0 /* default mode */, NULL /* parent dir */, scull_read_procmem, NULL /* client data */);

上述代码在 /proc 目录下创建了一个称为 scullmem 的文件并默认具有全局可读权限设置。

当然,在卸载模块时,/proc 中的入口项也应被删除。remove_proc_entry 就是用来撤销 create_proc_read_entry 所做工作的函数:

remove_proc_entry("scullmem", NULL /* parent dir */);

在使用 /proc 文件时,读者必须谨记这种实现的几个不足之处,因此我们不鼓励使用/proc文件。

  • 最重要的问题和 /proc 项的删除有关。删除调用可能在文件正在被使用时发生,因为 /proc 入口项不存在关联的所有者,因此对这些文件的使用并不会作用到模块的引用计数上在移除模块时,执行sleep 100 < /proc/myfile 命令就可以触发这个问题。
  • 另外一个问题是关于使用同一名字注册两个人口项。内核信任驱动程序,因此不会检查某个名称是否已经被注册,因此如果不小心,将可能导致两个或多个入口项具有相同的名字

③、seq_file 接口

为了让内核开发工作更加容易,通过对 /proc 代码的整理而增加了 seq_file 接口。这一接口为大的内核虚拟文件提供了一组简单的函数。seq_file 接口假定我们正在创建的虚拟文件要顺序遍历一个项目序列,而这些项目正是必须要返回给用户空间的。为使用 seq_file,我们必须创建一个简单的“选代器(iterator)”对象,该对象用来表示项目序列中的位置,每前进一步,该对象输出序列中的一个项目下面我们将使用这个方法针对 scull 驱动程序创建一个 /proc 文件。

显然,第一步是包含<linux/seg_file.h>头文件,然后必须建立四个迭代器对象,分别为 start、next、stop 和 show。

start 方法始终会首先调用,该函数的原型如下:

void *start(struct seq_file *sfile, loff_t *pos);
  • sfile:大多数情况下忽略
  • pos:读取的位置,因为 seq_file 的实现通常都要遍历一个项目序列,因此位置通常被解释为指向序列中下一个项目的游标(cursor)。scull 驱动程序将每个设备当作序列中的一个项目,这样,传入的 pos 就可以简单作为scull_devices 数组的索引。

于是,scull的start方法可如下编写:

static void *scull_seq_start(struct seq_file *s, loff_t *pos)
{
if (*pos >= scull_nr_devs)
  return NULL; /* No more to read */
return scull_devices + *pos;
}

如果返回值非 NULL,则迭代器的实现可将其作为私有值使用。

next 函数应将送代器移动到下一个位置,并在序列中没有其他项目时返回 NULL。该方法的原型是:

void *next(struct seq_file *sfile, void *v, loff_t *pos);
  • v:先前对 start 或者 next 的调用所返回的选代器
  • pos :文件的当前位置

next 方法应增加 pos 指向的值,这依赖于迭代器的工作方式,在某些情况下,我们也许要让 pos 的增加值大于1。scull 的 next 方法如下实现:

static void *scull_seq_next(struct seq_file *s, void *v, loff_t *pos)
{
  (*pos)++;
  if (*pos >= scull_nr_devs)
    return NULL;
  return scull_devices + *pos;
}

当内核使用迭代器之后,会调用 stop 方法通知我们进行清除工作:

void stop(struct seq_file *sfile, void *v);

scull 的实现不需要完成请除工作,因此它的 stop 方法为空。

值得注意的是,在设计上,seq_file 的代码不会在 start 和 stop 的调用之间执行其他的非原子操作。我们可以确信,start 被调用之后马上就会有对 stop 的调用。因此,在 start 方法中获取信号量或者自旋锁是安全的。只要其他 seq_file 方法是原子的,则整个调用过程也是原子的

在上述调用之间,内核会调用 show 方法来将实际的数据输出到用户空间。该方法的原型如下:

int show(struct seq_file *sfile, void *v);

该方法应该为迭代器所指向的项目建立输出。但是,它不能使用 printk 函数,而要使用针对 seq_file 输出的一组特殊函数:

int seq_printf(struct seq_file *sfile, const char *fmt, ...);

这是 seq_file 实现的 printf 等价函数;它需要通常的格式字符串以及额外的值参数。同时,我们还要将 show 函数传人的 seq_file 结构传递给这个函数如果 seq_printf 返回了一个非零值,则意味着缓冲区已满,而输出被丢弃大部分实现都会忽略这个返回值。

int seq_putc(struct seq_file *sfile, char c);
int seq_puts(struct seq_file *sfile, const char *s);

这两个函数是用户空间常用的 putc 和 puts 函数的等价函数。

int seq_escape(struct seq_file *m, const char *s, const char *esc);

这个函数等价于 seq_puts,只是若 s 中的某个字符也存在于 esc 中,则该字符会以八进制形式打印。传递给 esc 参数的常见值是"\t\n\ ",它可以避免要输出的空白字符弄乱屏幕或者迷惑 shel1脚本。

int seq_path(struct seq_file *sfile, struct vfsmount *m, struct dentry *dentry, char *esc);

这个函数可用于输出与某个目录项关联的文件名。对设备驱动程序来讲,它没有多少价值,这里包含该函数只是出于完整性考虑。

在我们的例子中,scull 中使用的 show 方法代码如下所示:

static int scull_seq_show(struct seq_file *s, void *v)
{
  struct scull_dev *dev = (struct scull_dev *) v;
  struct scull_qset *d;
  int i;
  if (down_interruptible (&dev->sem))
    return -ERESTARTSYS;
  seq_printf(s, "\nDevice %i: qset %i, q %i, sz %li\n", (int)(dev - scull_devices), dev->qset,
  dev->quantum, dev->size);
  for (d = dev->data; d; d = d->next) { /* scan the list */
    seq_printf(s, " item at %p, qset at %p\n", d, d->data);
    if (d->data && !d->next) /* dump only the last item */
    for (i = 0; i < dev->qset; i++) {
      if (d->data[i])
      seq_printf(s, " % 4i: %8p\n",
      i, d->data[i]);
    }
  }
  up(&dev->sem);
  return 0;
}

这里,我们最终解释了自己的“迭代器”值,它实际就是一个指向 scull_dev 结构的指针。

现在,我们定义了完整的迭代器操作函数,scull 必须将这些函数打包并和 /proc 中的某个文件连接起来。首先要填充一个 seq_operations 结构:

static struct seq_operations scull_seq_ops = {
  .start = scull_seq_start,
  .next = scull_seq_next,
  .stop = scull_seq_stop,
  .show = scull_seq_show
};

有了这个结构,我们必须创建一个内核能够理解的文件实现。在使用 seq_file 时,我们不使用先前描述过的 read_proc 方法而最好在略低的层次上连接到 /proc。也就是说我们将创建一个 file_operations 结构(即用于字符驱动程序的相同结构),这个结构将实现内核在该 /proc 文件上进行读取和定位时所需的所有操作。幸运的是,这一过程非常直接。首先创建一个 open 方法,该方法将文件连接到 seq_file操作:

static int scull_proc_open(struct inode *inode, struct file *file)
{
  return seq_open(file, &scull_seq_ops);
}

对 seq_open 的调用将 file 结构和我们上面定义的顺序操作连接在一起。open是唯一一个必须由我们自己实现的文件操作,因此,我们的 file_operations 结构可如下定义。

static struct file_operations scull_proc_ops = {
  .owner = THIS_MODULE,
  .open = scull_proc_open,
  .read = seq_read,
  .llseek = seq_lseek,
  .release = seq_release
};

这里,我们指定了我们自己的 open 方法,但对其他的 file_operations 成员,我们使用了已经定义好的seq_read、seq lseek 和 seq_release 方法。

最后,我们建立实际的 /proc 文件:

entry = create_proc_entry("scullseq", 0, NULL);
if (entry)
  entry->proc_fops = &scull_proc_ops;

这次,我们没有使用 create_proc_read_entry 函数,而是使用了低层的 create_proc_entry,它的原型定义如下

struct proc_dir_entry *create_proc_entry(const char *name,mode_t mode,struct proc_dir_entry *parent);

该函数的参数和 create_procread entry 等价,分别是文件的名称(name)访问保护掩码(mode)以及父(parent) 目录。

利用上面的代码,scull 就在 /proc 中拥有了一个和先前版本类似的文件。但显然,这个文件要更加灵活一些,不管输出有多大,它都能够正确处理文件定位,并且相关代码更加容易阅读和维护。如果读者的 /proc 文件包含有大量的输出行,则我们建议使用seq_file接口来实现该文件。

2、ioctl 方法

iocil 是作用于文件描述符之上的一个系统调用。ioctl 接收一个“命令”号以及另一个(可选的)参数,命令号用以标识将要执行的命令,而可选参数通常是个指针。作为替代 /proc 文件系统的方法,我们可以专为调试设计若干 ioctl 命令。这些命令从驱动程序复制相关的数据到用户空间,然后可在用户空间中检验这些数据。

四、通过监视调试

有许多方法可用来监视用户空间程序的工作情况,比如用调试器一步步跟踪它的函数插人打印语句,或者在 strace 状态下运行程序等等。在检查内核代码时,后面这种技术最值得关注。

strace 命令是一个功能非常强大的工具,它可以显示由用户空间程序所发出的所有系统调用。它不仅可以显示调用,而且还能显示调用参数以及用符号形式表示的返回值。当系统调用失败时,错误的符号值(如ENOMEM)和对应的字符串(如“Out of memory内存溢出”) 都能被显示出来。strace 有许多命令行选项,其中最为有用的是下面几个:

  • -t,该选项用来显示调用发生的时间;
  • -T,显示调用所花费的时间;
  • -e,限定被跟踪的语用类型;
  • -0,将输出重定向到一个文件中

默认情况下,strace 将跟踪信息打印到 stderr 上。

下面给出了 strace ls /dev > /dev/scull0 命令的最后几行输出信息:

open("/dev", O_RDONLY|O_NONBLOCK|O_LARGEFILE|O_DIRECTORY) = 3
fstat64(3, {st_mode=S_IFDIR|0755, st_size=24576, ...}) = 0
fcntl64(3, F_SETFD, FD_CLOEXEC) = 0
getdents64(3, /* 141 entries */, 4096) = 4088
[...]
getdents64(3, /* 0 entries */, 4096) = 0
close(3) = 0
[...]
fstat64(1, {st_mode=S_IFCHR|0664, st_rdev=makedev(254, 0), ...}) = 0
write(1, "MAKEDEV\nadmmidi0\nadmmidi1\nadmmid"..., 4096) = 4000
write(1, "b\nptywc\nptywd\nptywe\nptywf\nptyx0\n"..., 96) = 96
write(1, "b\nptyxc\nptyxd\nptyxe\nptyxf\nptyy0\n"..., 4096) = 3904
write(1, "s17\nvcs18\nvcs19\nvcs2\nvcs20\nvcs21"..., 192) = 192
write(1, "\nvcs47\nvcs48\nvcs49\nvcs5\nvcs50\nvc"..., 673) = 673
close(1) = 0
exit_group(0) = ?

很明显,当 ls 完成对目标目录的检索后,在首次对 write 的调用中,它试图写入 4KB 数据。很奇怪的是(对于ls来说),实际只写人了 4000 个字节,接着它重试这一操作。然而,我们知道 scull 的 write 实现每次最多只写入一个量子(scull 中设置的量子大小为4000个字节),所以我们所预期的就是上述的部分写人。经过几个步骤之后,每件工作都顺利通过,程序正常退出。

下面是另一个例子,让我们来对 scull 设备进行读操作(使用 wc 命令)

[...]
open("/dev/scull0", O_RDONLY|O_LARGEFILE) = 3
fstat64(3, {st_mode=S_IFCHR|0664, st_rdev=makedev(254, 0), ...}) = 0
read(3, "MAKEDEV\nadmmidi0\nadmmidi1\nadmmid"..., 16384) = 4000
read(3, "b\nptywc\nptywd\nptywe\nptywf\nptyx0\n"..., 16384) = 4000
read(3, "s17\nvcs18\nvcs19\nvcs2\nvcs20\nvcs21"..., 16384) = 865
read(3, "", 16384) = 0
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0
write(1, "8865 /dev/scull0\n", 17) = 17
close(3) = 0
exit_group(0) = ?

正如我们所料,read每次只能读取 4000 个字节,但数据总量与前面例子中写入的总量是相同的。

strace 对于查找系统调用运行时的细微错误最为有用。通常应用程序或演示程序中的 perror 调用信息在用于调试时还不够详细,而 strace 能够确切查明系统调用的哪个参数引发了错误,这一点对调试是大有帮助的

目录
相关文章
|
5天前
|
Linux
Linux(5)WIFI/BT调试笔记
Linux(5)WIFI/BT调试笔记
22 0
|
1月前
|
Linux 数据安全/隐私保护 虚拟化
Linux技术基础(1)——操作系统的安装
本文是龙蜥操作系统(Anolis OS) 8.4 的安装指南,用户可以从[龙蜥社区下载页面](https://openanolis.cn/download)获取ISO镜像。安装方法包括物理机的光驱和USB闪存方式,以及虚拟机中的VMware Workstation Pro设置。安装过程涉及选择语言、配置安装目标、选择软件集合和内核,设置Root密码及创建新用户。安装完成后,可通过文本模式或图形化界面验证系统版本,如Anolis OS 8.4,标志着安装成功。
|
1月前
|
Shell Linux C语言
【Shell 命令集合 设备管理 】Linux 创建设备文件 MAKEDEV命令 使用指南
【Shell 命令集合 设备管理 】Linux 创建设备文件 MAKEDEV命令 使用指南
35 0
|
1月前
|
Linux 编译器 程序员
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
69 0
|
1天前
|
Cloud Native Linux 开发者
【Docker】Docker:解析容器化技术的利器与在Linux中的关键作用
【Docker】Docker:解析容器化技术的利器与在Linux中的关键作用
|
5天前
|
Linux Android开发
Linux(6)CH9434 SPI调试笔记
Linux(6)CH9434 SPI调试笔记
13 0
|
19天前
|
网络协议 Linux SDN
虚拟网络设备与Linux网络协议栈
在现代计算环境中,虚拟网络设备在实现灵活的网络配置和隔离方面发挥了至关重要的作用🔧,特别是在容器化和虚拟化技术广泛应用的今天🌐。而Linux网络协议栈则是操作系统处理网络通信的核心💻,它支持广泛的协议和网络服务🌍,确保数据正确地在网络中传输。本文将深入分析虚拟网络设备与Linux网络协议栈的关联,揭示它们如何共同工作以支持复杂的网络需求。
|
20天前
|
存储 缓存 固态存储
Linux设备全览:从字符到块,揭秘每种设备的秘密
在Linux的世界里,设备是构成系统的基础,它们使得计算机能够与外界互动。Linux设备可以大致分为几种类型,每种类型都有其独特的特性和用途。🌌让我们一起探索这些设备类型及其特性。
|
26天前
|
安全 Linux
嵌入式Linux系统关闭串口调试信息的输出
嵌入式Linux系统关闭串口调试信息的输出
19 1
|
1月前
|
弹性计算 Linux Shell
Linux技术基础(2)——文本处理
文本处理实验:探索[Vim](https://developer.aliyun.com/adc/scenario/aced2264751f4866a8340de4cf9db0fa)的命令、输入和底线模式,学习文本编辑快捷操作,如光标移动、删除、复制和粘贴。了解如何使用底线命令模式进行文件保存、退出及搜索替换。同时,掌握`cat`、`more`、`less`、`head`、`tail`等文本查看命令,以及`stat`、`wc`、`file`、`diff`等文件处理命令。利用`grep`、`sed`、`awk`和`cut`进行文本搜索、替换和分析。