01signal.com

把普通耳机接到数字输出引脚上听音乐

本页面属于一个小项目系列,这些项目用于探索 Smart Zynq 开发板的各种功能。

这个项目也发布在 HelloFPGA 网站,推荐中文读者阅读。

简介

本教程介绍如何把普通耳机连接到 Smart Zynq 开发板并听音乐。这个项目的目的是演示 Xillybus 数据流的用途:向 FPGA 持续不断地发送数据。实现 PWM 调制器的 Verilog 代码也会在这里展示。

这里展示的代码并不是实现音频输出的典型范例。实现模拟输出的常用方法是一种更复杂的技术,称为 Sigma-Delta。这种技术确实可以在 FPGA 上实现,但其理论背景要难懂得多。

这个实现还有另一个缺点:它的采样率不精确(是 48828 Hz,而不是 48000 Hz)。这个问题很容易通过修改逻辑所用的时钟频率来修正。不过本示例着重于简单易懂,而不是精确性,所以这里就不介绍为此目的而操作时钟的话题了。

这个演示所需的设备如下:

准备 Vivado 工程

从演示包(demo bundle)的 zip 文件(boot partition kit)创建新的 Vivado 工程。用文本编辑器打开 verilog/src/xillydemo.v。删除代码中标有 “PART 2” 的部分,用下面的代码片段替换它:

   /*
    * PART 2
    * ======
    *
    * This code demonstrates a PWM-based audio output
    */

   reg [10:0]   pwm_level, threshold_left, threshold_right;
   reg 		pwm_left, pwm_right;
   reg 		fifo_out_valid;
   wire [31:0] 	fifo_out;
   wire 	fifo_empty;

   wire 	fifo_rd_en = !fifo_out_valid && !fifo_empty;
   wire 	next_word = (pwm_level == 11'h7ff);

   assign J6 = { pwm_right, pwm_left };

   always @(posedge bus_clk)
     begin
	pwm_level <= pwm_level + 1;

	if (next_word && fifo_out_valid)
	  begin
	     // The audio samples are signed integers. Change them to
	     // unsigned by adding 1024.
	     threshold_left <= fifo_out[15:5] + 1024;
	     threshold_right <= fifo_out[31:21] + 1024;
	  end
	else if (next_word) // FIFO's output not valid, keep silent
	  begin
	     threshold_left <= 0;
	     threshold_right <= 0;
	  end

	pwm_left <= (threshold_left > pwm_level);
	pwm_right <= (threshold_right > pwm_level);

	if (fifo_rd_en)
	  fifo_out_valid <= 1;
	else if (next_word)
	  fifo_out_valid <= 0;
     end

   // 32-bit FIFO for audio samples
   fifo_32x512 fifo_32
     (
      .clk(bus_clk),
      // Interface with Xillybus IP core
      .srst(!user_w_write_32_open),
      .din(user_w_write_32_data),
      .wr_en(user_w_write_32_wren),
      .full(user_w_write_32_full),

      // Interface with application logic
      .rd_en(fifo_rd_en),
      .dout(fifo_out),
      .empty(fifo_empty)
      );

   // Send the text "PWM" to reassure that the correct bitstream is used.
   assign user_r_read_32_eof = 0;
   assign user_r_read_32_empty = 0;
   assign user_r_read_32_data = 32'h0a_4d_57_50; // "PWM" + LF

或者,也可以从这里下载 xillydemo.v 文件。

用更新后的工程生成比特流(bitstream)文件,方法与为演示包生成比特流文件时相同。同时,把比特流文件复制到 TF 卡中(用这个工程生成的文件覆盖旧的 xillydemo.bit 文件)。

连接耳机

把一个 50Ω-200Ω 的电阻接到承载音频信号的 I/O 引脚:J6/1(左声道)或 J6/2(右声道)。要找到 J6,请在 Smart Zynq 开发板背面找到印有 “Bank 33 VCCIO Vadj” 的位置。靠近这个标记的那一排引脚就是我们要用的排针。因此,J6/1 就是最靠近 HDMI 连接器的那个引脚。

把电阻的另一端连接到耳机插头的尖端。可以用鳄鱼夹来完成这个连接。

把耳机插头的套筒部分连接到 Smart Zynq 的地。排针上的地位于 J6/35 或 J6/36。但建议不要使用这些引脚,因为它们靠近电源引脚。

也可以使用 J6/3 到 J6/34 范围内的任意引脚。FPGA 将这些引脚视为输出引脚,并把它们保持在逻辑 `0` 电平。因此,可以把这些引脚当作地来使用。

另外,也可以把鳄鱼夹夹在开发板某个连接器的外部金属部分来取得接地连接:以太网连接器、HDMI 连接器,或者某个 USB 连接器都可以。

启动开发板

照常给 Smart Zynq 上电(或者执行一次重启)。下一步是确认正确的比特流(bitstream)文件已加载到 FPGA(PL 部分)中。

在 shell 提示符下输入命令 “head /dev/xillybus_read_32”。这条命令会读取 /dev/xillybus_read_32 的前几行并打印结果:

# head /dev/xillybus_read_32
PWM
PWM
PWM
PWM
PWM
PWM
PWM
PWM
PWM
PWM

如果这条命令没有任何输出,或者输出与上面显示的不一致,就说明当前使用的是错误的比特流文件。

播放音频文件

把一个音频文件复制到 Xillinux 文件系统中。也就是说,Linux 系统内的命令应该能够访问到这个音频文件。

这个文件应该是 WAV 格式:未压缩的 PCM、双声道、s16le(WAV 文件几乎总是这种格式)。采样率应为 48000 Hz,不过 44100 Hz 也能相当好地工作。

可以从这个链接下载一个合适的音频文件。

把文件复制到 Linux 系统的方法有很多。例如,可以使用以太网从另一台电脑把文件复制到 Xillinux 的主目录,命令如下:

$ scp sample.wav root@192.168.1.10:~/

这条命令既可以用在 Microsoft Windows 的命令提示符中,也可以用在 Linux shell 中。把 IP 地址(本例中是 192.168.1.10)改成开发板的 IP 地址。

把文件复制到 Xillinux 还有其他方法。例如,使用 NFS 或 CIFS。

把文件复制到 Xillinux 文件系统之后,用下面的命令播放音频:

# cat sample.wav > /dev/xillybus_write_32

把 “sample.wav” 替换成你要播放的文件名。上面这条命令要求文件位于当前目录中。

这条命令会把文件播放到耳机上,直到新的 shell 提示符出现为止。你应该能听到其中一只耳朵有音乐(如果你把 J6/1 和 J6/2 分别接到耳机插头的不同部分,就能两只耳朵都听到)。

可以用 CTRL-C 在播放中途停止这条命令。

就这些。本页其余部分会解释其中的工作原理。

音频数据如何到达 FPGA

“cat” 命令把音频文件(sample.wav)的内容复制到一个名为 “xillybus_write_32” 的设备文件中。在 Linux 系统中,这是向硬件驱动发送数据的常用方法。在这个示例中,该驱动与 Xillybus 的 IP 核(IP core)对接。于是,数据被发送到 FPGA 内部逻辑中的 FIFO。

我们来看前面给出的 Verilog 代码中相关的部分:

   fifo_32x512 fifo_32
     (
      .clk(bus_clk),
      // Interface with Xillybus IP core
      .srst(!user_w_write_32_open),
      .din(user_w_write_32_data),
      .wr_en(user_w_write_32_wren),
      .full(user_w_write_32_full),

      // Interface with application logic
      .rd_en(fifo_rd_en),
      .dout(fifo_out),
      .empty(fifo_empty)
      );

这是一个标准 FIFO 的实例化(instantiation)。关于 FIFO 工作原理的一般性解释,请参考这个页面

这个 FIFO 有三个与向 FIFO 写入数据相关的端口:din、wr_en 和 full(满)。这三个端口都连接到 Xillybus IP 核。也就是说,这三个信号(user_w_write_32_data、user_w_write_32_wren 和 user_w_write_32_full)都连接到一个名为 xillybus 的模块。这样的安排使 Xillybus IP 核能把数据写入 FIFO。

Xillybus 利用这个安排来填充 FIFO——数据来自软件写入 /dev/xillybus_write_32 的内容。Xillybus 会持续尝试把尽可能多的数据写入 FIFO,但它永远不会造成溢出(overflow)——也就是说,它会遵守 FIFO 的 full(满)信号。

总之,整个过程是这样的:

Simplified data flow diagram for data playback with Xillybus

所有这些操作都是同时、持续地进行的。

关于 Xillybus 的更多信息,请参考这一系列页面,尤其是这一页

音频信号是如何产生的

到目前为止,上面解释的是数据如何到达 FPGA 内部的应用逻辑。下面我们来看数据是如何变成音频的。

首先,注意 Verilog 代码中的这一行:

   assign J6 = { pwm_right, pwm_left };

根据这行代码,两个音频输出分别是 pwm_right 和 pwm_left。这两个寄存器的赋值方式如下:

   always @(posedge bus_clk)
     begin
	pwm_level <= pwm_level + 1;

 [ ... ]
	pwm_left <= (threshold_left > pwm_level);
	pwm_right <= (threshold_right > pwm_level);
 [ ... ]
    end

注意,pwm_level 是一个简单的计数器。这个寄存器有 11 位,因此它从 0 计数到 2047,然后又从 0 重新开始。

当 threshold_left 大于 pwm_level 时,pwm_left 的值为 `1`。也就是说,threshold_left 与一个反复从 0 到 2047 遍历所有数值的计数器进行比较。threshold_left 的值越高,pwm_left 保持为 `1` 的时间就越长。这就是 PWM 的原理:脉冲的长度与我们要产生的模拟信号的值成正比。

pwm_right 与 threshold_right 之间的关系同理。

threshold_left 和 threshold_right 中存放的是来自 WAV 文件、经 Xillybus IP 核发送过来的数据。下面我们仔细看看这个过程是如何发生的。

先看 FIFO 实例化中与从 FIFO 读取相关的部分:

      // Interface with application logic
      .rd_en(fifo_rd_en),
      .dout(fifo_out),
      .empty(fifo_empty)

fifo_rd_en 的定义如下:

wire 	fifo_rd_en = !fifo_out_valid && !fifo_empty;

因此,当 FIFO 不 empty(空)且 fifo_out_valid 为低时,FIFO 的读使能就为高。那我们来看看 fifo_out_valid 的定义:

   always @(posedge bus_clk)
     begin
 [ ... ]
	if (fifo_rd_en)
	  fifo_out_valid <= 1;
	else if (next_word)
	  fifo_out_valid <= 0;
     end

fifo_out_valid 的含义是:当 FIFO 的输出有效时,这个寄存器为高。更准确地说,当 FIFO 的输出尚未被消费时,fifo_out_valid 为高。这就是为什么在 fifo_rd_en 为高一个时钟周期之后,这个寄存器才变为高。当 next_word 为高时,这个寄存器变为低。下面我们会看到,实现 PWM 的逻辑恰恰在 next_word 为高时消费 FIFO 的输出。

next_word 的定义如下:

   wire 	next_word = (pwm_level == 11'h7ff);

前面说过,pwm_level 是一个在 0 到 2047 之间遍历所有值的计数器。2047 的十六进制编码是 7ff。因此,next_word 恰好会在 pwm_level 即将回到零之前为高。

next_word 多长时间为高一次?bus_clk 的频率是 100 MHz。每 2048 个时钟周期,next_word 就为高一次。100 MHz ÷ 2048 ≈ 48828 Hz。所以 next_word 每秒大约为高 48828 次。

前面我提到,next_word 为高时就是 FIFO 输出被消费的时机。Verilog 代码中相关的部分如下:

   always @(posedge bus_clk)
     begin
 [ ... ]

	if (next_word && fifo_out_valid)
	  begin
	     // The audio samples are signed integers. Change them to
	     // unsigned by adding 1024.
	     threshold_left <= fifo_out[15:5] + 1024;
	     threshold_right <= fifo_out[31:21] + 1024;
	  end
	else if (next_word) // FIFO's output not valid, keep silent
	  begin
	     threshold_left <= 0;
	     threshold_right <= 0;
	  end
 [ ... ]
    end

首先可以看到,当 next_word 为高时,threshold_left 和 threshold_right 都会被赋予新值。如果 fifo_out_valid 为低,这两个寄存器的值就会变为零。当没有数据发送到 FIFO 因而 FIFO 变 empty(空)时,就会出现这种情况。

如果 fifo_out_valid 为高,就表示 FIFO 的 dout 端口上有一个音频采样值。这个值表示两个立体声声道的模拟信号。每一个这样的采样包含两个有符号数,以 16 位二进制补码形式表示。

属于左立体声声道的声音采样位于 fifo_out[15:0]。这是一个介于 -32768 和 32767 之间的有符号数。去掉最低的五个位之后,fifo_out[15:5] 的范围在 -1024 到 1023 之间。因此,表达式 `fifo_out[15:5] + 1024` 得到的是一个介于 0 到 2047 之间的无符号数。这个数值范围适合与 pwm_level 进行比较。

相应地,当 fifo_out[15:0] 等于 -32768 时,threshold_left 会被赋予零。条件 “threshold_left > pwm_level” 永远不会满足,所以 pwm_left 始终保持低电平。另一方面,当 fifo_out[15:0] 等于 32767 时,threshold_left 的值为 2047。于是,pwm_left 几乎一直为高。这就是 fifo_out[15:0] 控制每个脉冲中 pwm_left 高电平持续时间的方式。fifo_out[31:16] 以同样的方式控制 pwm_right。

把整个机制总结起来就是:每 2048 个时钟周期,next_word 就会为高一次。当 next_word 为高时,FIFO 的输出被取走并复制到 threshold_left 和 threshold_right。这会消费 FIFO 的输出,因此 fifo_out_valid 变为低。接下来,如果 FIFO 不 empty(空),fifo_rd_en 就会变为高,以便从 FIFO 读取一个新的音频采样。

前面提到,Xillybus IP 核会用 sample.wav 的内容来填充这个 FIFO。所以,音频采样从 sample.wav 的内容流向 threshold_left 和 threshold_right。如前所述,next_word 每秒大约为高 48828 次。这就是这个机制的采样率。

threshold_left 控制 pwm_left 为高的时间比例。threshold_right 与 pwm_right 之间的关系同理。最后,pwm_right 和 pwm_left 连接到名为 J6 的输出端口,因此它们就是出现在排针上的信号。

注意,当 next_word 为高时会发生两件事:一个音频采样被消费,同时 pwm_level 从零开始重新计数。因此,每个音频采样都会产生一个脉冲。

打印出 “PWM”

前面我建议你用命令 “head /dev/xillybus_read_32” 来确认 FPGA 加载了正确的比特流(bitstream)文件。预期结果是一连串的 “PWM”。这是由 Verilog 代码中的下面这部分实现的:

   // Send the text "PWM" to reassure that the correct bitstream is used.
   assign user_r_read_32_eof = 0;
   assign user_r_read_32_empty = 0;
   assign user_r_read_32_data = 32'h0a_4d_57_50; // "PWM" + LF

如果你看一下修改之前的 xillydemo.v,就会发现 user_r_read_32_rden、user_r_read_32_data 和 user_r_read_32_empty 原本连接到 FIFO。Xillybus IP 核利用这些信号从 FIFO 读取数据,并将这些数据作为一个数据流呈现,即 /dev/xillybus_read_32。

在修改 xillydemo.v 之前,这些信号连接到与 Xillybus IP 核写入所用的同一个 FIFO。结果就形成了一个回环(loopback):软件写入 /dev/xillybus_write_32 的数据,先由 Xillybus IP 核放入 FIFO,然后 Xillybus IP 核再从 FIFO 读出数据,并通过 /dev/xillybus_read_32 呈现。这个回环的用途,是作为学习 Xillybus 工作原理的起点。

修改 xillydemo.v 之后,这些信号与 FIFO 断开了连接。取而代之的是,user_r_read_32_data 始终等于 0x0a4d5750,而 user_r_read_32_empty 始终为零。此外,逻辑会忽略 user_r_read_32_rden。这相当于构造了一个永远不会 empty(空)的假想 FIFO。这个假想 FIFO 的输出始终是同一个值:0x0a4d5750。Xillybus IP 核表现得就像有一个始终被这个常数值填充的 FIFO。因此,从 /dev/xillybus_read_32 读取时,会反复收到 0x0a4d5750 这个词。当这个词被打印出来时,它被解释为四个字节:0x50、0x57、0x4d 和 0x0a。也就是说,分别是字符 P、W、M 和一个换行符(在 Linux 中用于标记一行结束)。

Verilog 代码与实际引脚的对应关系

上面的 Verilog 代码把 PWM 信号连接到 J6,但这些连接是如何到达排针的呢?答案可以在 xillydemo.xdc 中找到。这个文件是生成比特流(bitstream)的 Vivado 工程的一部分(位于 vivado-essentials 目录)。

xillydemo.xdc 包含多种使 FPGA 作为电子元器件正常工作所需的信息。除此之外,该文件还包含下面这些行:

[ ... ]

## J6 on board (BANK33 VADJ)
set_property PACKAGE_PIN U22  [get_ports {J6[0]}];   #J6/1  = IO_B33_LN2
set_property PACKAGE_PIN T22  [get_ports {J6[1]}];   #J6/2  = IO_B33_LP2
set_property PACKAGE_PIN W22  [get_ports {J6[2]}];   #J6/3  = IO_B33_LN3
set_property PACKAGE_PIN V22  [get_ports {J6[3]}];   #J6/4  = IO_B33_LP3
set_property PACKAGE_PIN Y21  [get_ports {J6[4]}];   #J6/5  = IO_B33_LN9
set_property PACKAGE_PIN Y20  [get_ports {J6[5]}];   #J6/6  = IO_B33_LP9
set_property PACKAGE_PIN AB22 [get_ports {J6[6]}];   #J6/7  = IO_B33_LN7
set_property PACKAGE_PIN AA22 [get_ports {J6[7]}];   #J6/8  = IO_B33_LP7

[ ... ]

第一行表示,信号 J6[0] 应连接到 U22,这是 FPGA 物理封装上的一个位置。根据 Smart Zynq 的原理图,这个 FPGA 引脚连接排针的第一个引脚。其他端口的位置也以同样方式定义。

前面我提到,J6/3 到 J6/34 范围内的任意引脚都可以当作地来用,因为这些输出引脚的值是 `0`。这是正确的,因为根据 xillydemo.v 开头的这一行,J6 由 34 个位组成:

  inout [33:0] J6,  //BANK33 VADJ

前面提到,J6 的赋值方式如下:

 assign J6 = { pwm_right, pwm_left };

这意味着 J6[0] 等于 pwm_left,J6[1] 等于 pwm_right。其余位呢?按照 Verilog 的语法,所有其他位都会被赋值为零。

直流偏置

排针连接的是 FPGA 的逻辑输出。当逻辑状态为 `1` 时,这些引脚中的每一个都具有大约 3.3V 的电压;当逻辑状态为 `0` 时,电压大约为 0V。

如果原始音频采样值为零,threshold_left 和 threshold_right 的值就会是 1024。也就是说,pwm_right 和 pwm_left 平均会有一半时间为高。因此,平均电压(直流分量)等于 3.3V ÷ 2 = 1.65V。所以,即使 WAV 文件中的音频采样具有理想的直流平衡,耳机上也会承受 1.65V 的直流分量。

因此,100Ω 电阻的用途不仅是降低音量,还要限制直流电流。不过,即使没有这个电阻,由于 FPGA 自身的限流能力以及耳机本身的电阻,电流通常也不会造成危害。这个电阻只是一种预防措施。

总结

这个项目展示了如何利用一个数字输出引脚来产生可以直接连接耳机的模拟音频信号。这个项目的重点是展示 Xillybus 数据流在把数据从软件发送到 FPGA 这一用途上的用法。同时,也给出了一种简单的 PWM 实现。

本页由机器从英文翻译而来。如有疑问,请参阅原文
Copyright © 2021-2026. All rights reserved. (dcc38493)