驱动你自己的音频设备
我是一个奇怪的人,我希望我的电脑保持安静——这就是为什么我写了“可见铃声迷你指南”,我在其中建议进行扬声器切除手术。另一方面,我喜欢摆弄烙铁来制造不相干的东西。我构思过的最不相干的东西之一是回收电脑的扬声器,使其成为一个极低音量的音频设备。你可以想象,这个设备插入并行端口。
本文介绍了这种设备的驱动程序,展示了内核工作的有趣细节,并且篇幅仍然很短,几乎任何读者都能轻松阅读。硬件的快速描述是必要的,但您可以安全地跳过第一部分,直接跳到名为“写入数据”的部分。
此处描述的软件以及电路图均根据 GPL 发布,可从 ftp://ftp.systemy.it/pub/develop/ 上的 sad-1.0.tar.gz(独立音频设备)获取,这是我自己的 ftp 站点。
这项工作的一部分由意大利博尔扎诺(博岑)的巴士公司“SAD Trasporto Locale”(http://www.sad.it/)赞助。他们计划将我的硬件安装在他们的巴士上,并将公司名称更改为与我的软件包匹配(微笑)。(参见 Maurizio Cachia 的“Travelling Linux”,LJ,1997 年 6 月。)
我的设备插入并行端口,其原理图如图 1 所示;领带下的照片是唯一构建过的模型(意大利巴士将运行这种东西的不同版本,“bus for bus”--ftp://ftp.systemy.it/pub/develop/b4b-X.YY.tar.gz)。
我将基本想法归功于 pcsndrv 软件包的作者 Michael Beck;这个想法听起来像是“使用并行数据位来输出音频样本”。我自己的补充是“使用中断信号以正确的速度选通样本”。音频样本必须以 8KHz 的速率流动,任何不太古老的计算机都可以维持这样的中断速率:我几乎古老的开发机器运行的是 33 BogoMips 处理器,并且非常乐意播放并行音频。与 Michael 的软件包相比,基于中断的方法以更高的硬件复杂性换取更高的质量。
如图所示,该设备由一个简单的 D/A 转换器组成,该转换器由几个电阻器构建而成;然后将信号降低到 1.5V 峰峰值幅度,并通过低通滤波器馈送。我选择的滤波器是由方波驱动的开关电容器器件,方波频率是截止频率的十倍。6142 芯片是一款具有轨到轨输出的双运算放大器,是低功耗单电源设备的几种可能选择之一。
输出信号可以连接到小型扬声器,但只能在完全安静的环境中听到;其他环境需要某种形式的放大。我首选的放大器替代品是示波器,这是一种典型的通过观看来聆听的方法。
音频驱动程序的主要作用是通过音频设备推送数据。sad 驱动程序仅实现了 /dev/audio 风格:以 8KHz 速率流动的 8 位样本。写入 /dev/audio 的每个数据字节都应馈送到 8 位 A/D 转换器;每 125 微秒,必须用新的数据样本替换当前的数据样本。
定时问题应由驱动程序管理,而无需编写音频数据的程序进行干预。输出缓冲区是将定时问题与用户程序隔离的软件工具。
在 sad 中,输出缓冲区在加载时使用 get_free_pages 分配。此函数分配连续页面,它们的幂为 2;函数的 order 参数指定请求的页数,并用作 2 的幂。因此,order 为 1 表示两页,order 为 3 表示八页。输出缓冲区的分配顺序存储在宏 OBUFFER_ORDER 中,该宏在分布式源文件中为 0。这表示一页,在 x86 处理器上对应于 4KB,或相当于半秒的数据。
sad 的输出缓冲区是一个循环缓冲区;指针 ohead 和 otail 表示其起始点和结束点。内核使用无符号长整型值来表示物理地址,sad 中也使用了相同的约定
static unsigned long obuffer = 0; static unsigned long volatile ohead, otail;
请注意,ohead 和 otail 变量被声明为 volatile,以防止编译器将它们的值缓存在处理器寄存器中。这是一个重要的注意事项,因为这些变量将在中断时修改,与代码的其余部分异步。
稍后我们将看到 sad 也有一个输入缓冲区;整体缓冲区分配由以下几行组成,从 init_module 中执行
obuffer = __get_free_pages(GFP_KERNEL, OBUFFER_ORDER, 0 /* no dma */); ohead = otail = obuffer; ibuffer = __get_free_pages(GFP_KERNEL, IBUFFER_ORDER, 0 /* no dma */); ihead = itail = ibuffer; if (!ibuffer || !obuffer) { /* allocation failed */ cleanup_module(); /* use your own function */ return -ENOMEM; }
进程写入设备的任何数据都会放入循环缓冲区,只要它适合。当缓冲区已满时,写入进程将进入睡眠状态,等待释放一些空间。
由于数据样本平稳地流出,进程最终将被唤醒以完成其 write 系统调用。无论如何,一个好的驱动程序已准备好处理用户按下 ctrl-C 的情况,并且必须处理 SIGINT 和其他信号。
以下几行代码用于使当前进程进入睡眠状态并唤醒它,所有魔力都隐藏在 interruptible_sleep_on 中
while (OBUFFER_FREE < OBUFFER_THRESHOLD) { interruptible_sleep_on(&outq); if (current->signal & ~current->blocked) /* tell the fs layer to handle it */ /* a signal arrived */ return -ERESTARTSYS; /* else, loop */ } /* the following code writes to * the circular buffer */
什么是 OBUFFER_FREE 和 OBUFFER_THRESHOLD?它们是两个宏:前者访问 ohead 和 otail 以找出缓冲区中有多少可用空间;后者是一个简单的常量,预定义为 1024,一个伪随机数。此阈值的作用是通过避免过于频繁的睡眠->唤醒转换来保留系统资源。
如果阈值为 1,则只要缓冲区释放一个字节,进程就需要被唤醒,但它很快就会再次进入睡眠状态。因此,进程将始终处于运行状态,消耗处理能力并增加机器负载。1KB 的阈值确保进程进入睡眠状态时,它将至少睡眠十分之一秒,因为它在 1KB 的数据流经音频设备之前不会被唤醒。您可以重新编译 sad.c 并使用不同的阈值来查看较小的值如何使处理器保持忙碌。太大的值可能会导致音频跳跃,即声音断断续续。音频流变得跳跃,因为数据继续流动,而内核调度执行写入音频数据的进程。计算机负载越重,音频就越有可能跳跃;如果多个进程争用处理器,则播放音频的进程可能会唤醒得太晚,此时所有挂起的数据都已传输到音频设备。除了降低唤醒阈值外,您还可以通过增加缓冲区大小来解决此问题。
自然,write 设备方法只是故事的一半;另一半由中断处理程序执行。
在 sad 中,音频样本由硬件中断选通输出,硬件中断每 125 微秒向处理器报告一次。每个中断都由 ISR(中断服务例程,也称为“中断处理程序”)服务,ISR 用 C 语言编写。我不会在此处详细介绍注册中断处理程序的细节,因为它们已经在其他“内核角落”专栏中描述过。
每秒管理数千个中断对于处理器来说是一个不可忽略的负载(至少对于像我这样的慢速处理器而言),因此驱动程序仅在设备打开时启用中断报告,并在最后一个 close 时禁用它。
我想在这里展示的是数据如何流向 A/D 转换器。代码非常简单,并且 OBUFFER_THRESHOLD 常量再次出现,正如预期的那样
if (!OBUFFER_EMPTY) { /* send a sample */ OUTBYTE(*((u8 *)otail++)); if (otail == obuffer + OBUFFER_SIZE) otail = obuffer; /* wrap */ if (OBUFFER_FREE > OBUFFER_THRESHOLD) wake_up_interruptible(&outq); return; } wake_up_interruptible(&closeq);
像往常一样,每个代码片段都会引入新的问题;这次您可能想知道 OUTBYTE 和 closeq。后一个项目是下一节的主要主题,而 OUTBYTE 隐藏了将数据样本推送到 D/A 转换器的代码行。
该宏在 sad.c 中较早定义,如下所示
#define OUTBYTE(b) outb(convert(b), sad_base)
在这里,sad_base 是用于将数据发送到并行接口的处理器端口(通常为 0x378),而 convert 是一个简单的数学转换,它将以音频文件格式存储的数据字节转换为线性 0-255 值,更适合 D/A 转换器。
像 read 和 write 一样,close 系统调用是可以阻塞的调用之一。例如,当您完成软盘驱动器操作后,close 会阻塞,等待任何数据刷新到物理设备。可以通过运行以下命令来验证此行为
strace cp /boot/vmlinux /dev/fd0
音频设备在某种程度上类似于软盘驱动器:写入音频数据的程序在最后一次 write 系统调用后关闭文件。但是,这仅表示数据已传输到输出缓冲区,而不是 所有内容都已必然流向扬声器。当您想执行以下操作时,在关闭时阻塞的实现可能会有所帮助
cat file.au > /dev/sad && echo done另一方面,有时您会希望在进程关闭设备时停止播放声音。例如,如果您在键盘上弹钢琴,即使程序已将额外数据推送到输出缓冲区,声音也应在您抬起琴键时立即停止。
因此,sad 模块实现了两个设备入口点,一个在关闭时阻塞,另一个在关闭时不阻塞。次设备号 0 是阻塞设备,次设备号 1 是非阻塞设备。/dev 中的入口点由加载模块的脚本创建,该脚本包含在 sad 发行版中:/dev/sad 是在关闭时阻塞的设备,/dev/sadnb 是非阻塞设备。
虽然真正的设备驱动程序通常通过 ioctl 系统调用提供配置选项(例如,选择是否在关闭时阻塞),但我选择在 /dev 中提供不同的入口点,因为这样我可以使用普通的 shell 重定向来执行我的任务,而无需编写 C 代码来执行相关的 ioctl 调用。因此,sad.c 中的 close 方法如下所示
if (MINOR(inode->i_rdev)==0) /* wait */ interruptible_sleep_on(&closeq); else { unsigned long flags; /* drop data */ save_flags(flags); cli(); ohead=otail; restore_flags(flags); } MOD_DEC_USE_COUNT; if (!MOD_IN_USE) SAD_IRQOFF(); /* disable irq */ return;
实际上,关于 close 还有第三种可能性:只要有一些数据,即使在程序关闭音频设备后,也继续在后台播放。这种方法留给读者作为练习,因为我更喜欢有机会主动停止任何发出噪音的设备。
通常,设备可以读取也可以写入。读取 /dev/audio 通常会返回来自麦克风的数字化数据,但没有人要求我提供此功能,而且我对听到自己的声音也没有真正的兴趣。
当我构建物理设备的第一个 alpha 版本时,我发现需要定时中断速率,以确保它足够接近预期的 8KHz。(在 alpha 版本中,我使用可变电阻器来微调频率,并且我需要一种方法来检查其运行情况。)我想到的最简单的解决方案是使用主机计算机的时钟来测量时间间隔。
为此,我修改了中断处理程序,以便在读取设备时将时间戳写入输入缓冲区。输入缓冲区是一个循环缓冲区,就像上面描述的输出缓冲区一样。
前面来自 sad_interrupt 的摘录表明,在写入音频样本后,该函数返回给调用者。因此,任何额外的行都仅在没有音频数据时执行,因此 ISR 的其余部分专门用于收集定时信息。这显示了我如何实现“如果没有挂起的输出,则处理输入”,而不是更正确的“如果有人正在读取,则为其提供一些数据”。只要该设备不打算在生产环境中同时读取和写入,这是可以接受的。
static struct timeval tv, tv_last; unsigned long diff; do_gettimeofday(&tv); diff = (tv.tv_sec - tv_last.tv_sec) * 1000000 + (tv.tv_usec - tv_last.tv_usec); tv_last = tv; /* Write 16 bytes, assume bufsize * is a multiple of 16 */ ihead += sprintf((char *)ihead,"%15u\n", (int)diff); if (ihead == ibuffer + IBUFFER_SIZE) ihead = ibuffer; /* wrap */ wake_up_interruptible(&inq); /* anyone reading? */
打印两个样本之间的时间差比打印绝对时间有两个优点:数据对人类来说是直接有意义的,无需借助外部过滤器,并且输入缓冲区的任何溢出都不会对感知结果产生影响,除了丢失一些样本之外。
实际测试表明,报告的中断速率不像人们希望的那样稳定。某些系统活动需要您禁用中断报告,这会在 ISR 例程的执行中引入一些延迟。尽管如此,几微秒的振荡是完全可以接受的,并且在最终的音频中不会被感知到,反正音频也不是高保真的。
有趣的是,磁盘活动可能会在音频流中引入一些真正的失真,因为服务 IDE 中断可能需要长达两毫秒的时间(在我的系统上)。IDE 驱动程序在其自己的 ISR 活动时禁用中断报告,而巨大的延迟会导致来自并行端口的八个中断丢失,这反过来会导致音频数据流的明显失真。
如果您在磁盘活动期间从 sad 读取数据,您会看到较长的时间间隔;写入设备会产生非常糟糕的音频。解决此问题的简单方法是调用
/sbin/hdparm -u 1 /dev/hda
在播放任何音频之前。该命令告诉磁盘驱动器在服务其自己的中断时不要禁用报告中断。请参阅 hdparm 文档以进一步探究。
除了 open/close 和 read/write 对之外,设备驱动程序接口还提供了其他设备方法。虽然它们都不是设备操作的关键,但我通常会添加几行代码来实现 select 和 lseek。前者是那些多路复用多个输入/输出通道或使用非阻塞操作来读取和写入数据的程序所需要的。如果您运行真正的程序,它的作用非常必要,并且实现非常简单,我将在此处不再展示。另一方面,lseek 的实现由一行 return -ESPIPE; 组成,旨在告诉任何尝试 lseek 设备的程序,这是一个“管道”(向用户空间报告为“非法寻址”)。
我对计算机声音的反感使我成为该领域的新手,我真的不了解任何关于播放音频的程序,或者可以检索音频文件的站点。尽管 Linus Torvalds 提供了一个有趣的“我将 Linux 发音为 Linux”,但该文件不足以测试我的设备,我需要生成一些音频数据。结果是 sad 发行版包含一个播放正弦波的程序,一个播放方波的程序和一个不太好的钢琴实现。这些工具适用于您碰巧运行的任何 /dev/audio,并且玩起来可能很有趣,特别是如果您在 Linux 机器附近有示波器的话。
sad 程序的所有代码都可以通过匿名下载在文件 ftp.linuxjournal.com/pub/lj/listings/issue53/2997.tgz 中获得。
