Skip to main content
欢迎来到PAWPAW技术文档网站了解更多信息

为USB音频扩展数字信号处理

在本应用笔记中,我们将描述如何将DSP功能扩展到XMOS USB音频堆栈中。

USB音频是一款高度可配置的软件;在其最简单的形态中,它可能只是将单个ADC与USB音频接口连接起来;但它还可以处理多种I2S、TDM、DSD、S/PDIF、ADAT等接口。通常数据只是实时传输,但可能感兴趣的DSP可能包括:

  • 均衡器
  • 混音器
  • 动态范围压缩器
  • 音频效果器

本应用笔记讨论了USB音频堆栈提供的API,以便您可以在堆栈中包含DSP算法。

作为参考,我们提到了以下您可能想要使用的仓库:

USB音频简介

image-20231125103919778
图 1:USB音频结构

USB音频的基本结构如图1所示。左侧是USB接口连接到主机,这部分由XUD和XUA库处理。XUD 是xcore的底层USB库,XUA 是在xcore上实现的USB音频协议。右侧是一系列接口(ADC,DAC,S/PDIF,ADAT)。USB音频提供了从左到右(USB主机计算机到接口)的路径,这被称为输出路径;还有从右到左(接口到USB主机计算机)的路径,这被称为输入路径。这里所说的输入路径和输出路径是以主机(例如PC或手机)为中心的命名方式,我们之所以这样使用输入和输出,是因为它与USB标准的术语一致。

XU316设备有两个 Tile (tiles),在许多设计中,其中一个 Tile 将是空的。但这并不总是这样,可能会出现ADC/DAC I/O引脚位于另一个 Tile 上的情况。对于添加简单的DSP来说,这种微妙的差别并不重要。此外,用于USB堆栈的物理核心可能是 Tile 0或 Tile 1,这取决于设计。

USB音频提供的API

USB音频栈提供了一个您需要重写的函数,以便在系统中添加任何DSP功能:

extern void UserBufferManagement (
unsigned output_samples[NUM_OUTPUTS],
unsigned input_samples[NUM_INPUTS]
);

为了简洁,我们在这段代码中使用NUM_OUTPUTSNUM_INPUTS来分别指代输出音频通道数(NUM_USB_CHAN_OUT)和输入音频通道数(NUM_USB_CHAN_IN)。

UserBufferManagement函数基于USB音频栈的采样率(例如,48 kHz)被调用,两个数组之间包含了一个完整的多通道音频帧。第一个数组携带了所有将被发送到接口的数据,第二个数组携带了所有将被发送到USB主机的来自接口的数据。您可以选择拦截并重写这些数组中存储的样本。接口首先是所有I2S通道,然后是可选的S/PDIF,最后是可选的ADAT。

您可以重写的第二个函数是:

extern void UserBufferManagementInit(void);

该函数在第一次调用UserBufferManagement之前被调用一次。本文档中的代码不需要此函数,但其他代码可能需要它。

请注意,类型的值是无符号的32位数。这32位的使用取决于音频的数据类型,典型的值有16位PCM(前16位是有符号PCM值)、24位PCM(前24位是有符号PCM值)、32位PCM(前32位是有符号PCM值)或DSD(32位是PDM值,最低有效位表示最旧的1位值)。

在这个例子中,我们只修改输出路径 - 我们使用NUM_OUTPUTS=2NUM_INPUTS=4。我们可以通过一个cascaded_biquad来均衡输出信号。可以进一步对两个通道应用独立的双二阶滤波器来独立均衡立体声扬声器:

#define FILTERS 4
// b2/a0 b1/a0 b0/a0 -a1/a0 -a2/a0
int32_t filter_coeffs[FILTERS*5] = {
261565110, -521424736, 260038367, 521424736, -253168021,
255074543, -506484921, 252105451, 506484921, -238744538,
280274501, -523039333, 245645878, 523039333, -257484924,
291645146, -504140302, 223757950, 504140302, -246967640,
};
int32_t filter_states[NUM_INPUTS + NUM_OUTPUTS][FILTERS*4];
void UserBufferManagement (
unsigned output_samples[NUM_OUTPUTS],
unsigned input_samples[NUM_INPUTS]
) {
for (int i = 0; i < NUM_OUTPUTS; i++) {
output_samples[i] = dsp_filters_biquads((int32_t)output_samples[i], filter_coeffs, filter_states[i], FILTERS, 28);
}
}
void UserBufferManagementInit() {}

如果需要,可以结合input_samplesoutput_samples来混合来自接口或USB的数据到USB或接口中。

采样率取决于环境。USB应用通常有一个支持的采样率列表(也可能只有一个采样率),用户可以在主机上选择他们想要使用的采样率。在这里,为了简单起见,我们不讨论采样率变化;我们假设只有一个采样率。

可用的DSP函数

有几个库提供DSP以及常见的数学函数,它们在速度、精确度和易用性之间各有权衡。

  • lib_xcore_math:基于xcore.ai架构的高性能数学函数库。许多函数都经过优化,以利用向量单元和40位累加器。

  • lib_dsp:在CPU上执行的高分辨率数学函数,通常使用64位累加器。因为依赖CPU速度,并且没有专门的硬件加速,这些函数的速度不如lib_xcore_math快。

  • lib_audio_effects:音频效果器函数(基于lib_dsp)

在这份应用笔记中,我们以一个参数固定的级联的双二阶滤波器为例:

  • 第一级峰值滤波器:中心频率200 Hz,带宽为1个八度,增益 -20dB
  • 第二级峰值滤波器:中心频率400 Hz,带宽为1个八度,增益 +10dB
  • 第三级峰值滤波器:中心频率800 Hz,带宽为1个八度,增益 -20dB
  • 第四级峰值滤波器:中心频率1600 Hz,带宽为1个八度,增益 +10dB

这组滤波器不一定会在实际场景中应用,但它是比较容易听出效果的。

时序要求

XMOS USB音频堆栈旨在对单个样本进行操作,以最小化音频堆栈引入的延迟。UserBufferManagement() 函数从USB堆栈核心调用;它以系统的原生采样率(例如44.1 kHz)调用,因此它完成操作的时间不应超过一个样本周期。实际上,为了确保样本能够及时到达流水线的下一阶段,它拥有的时间比这还要短一些。

考虑到系统中单个线程的速度(例如600 / 8 = 75 MHz)和样本率(假设为44.1 KHz),我们可以计算在两个样本之间可用的发行指令发射槽(issue slot)数量:75,000,000 / 44,100 = 1,700个指令发射槽。这包括USB堆栈移动数据所需的时间。考虑到这一点,使用这种方法,DSP可用的指令发射槽不超过1,300个,这限制了能够使用的FIR滤波器系数或双二阶滤波器的数量。时间线如图2所示。

image-20231125104420951
图2:在线程内执行DSP的时间线

此外,随着样本率的提高,USB堆栈的开销保持不变,但样本之间的时间被压缩,进一步限制了DSP可用的指令周期数。

由于xcore是一个并发的多线程多核处理器,因此还有其他线程和核心可用于DSP。这取决于USB堆栈的具体配置(是否使用了特殊接口,如S/PDIF、ADAT、MIDI),但在仅使用I2S的简单情况下,USB音频大约使用了30%的计算能力,另外一个Tile完全空闲。

接下来我们将逐步研究以下内容:

  1. 研究如何使用其他Tile上的单个线程处理DSP
  2. 研究如何一般地并行化处理DSP
  3. 最后我们将探讨使用多个线程处理DSP

在另一个物理核上执行DSP

xcore架构提供了一个通信框架,可以在线程之间以及核之间高效地传输数据。通信是通过通道进行的。一个通道有两端,A和B,输出到A的数据必须从B输入,输出到B的数据必须从A输入。A和B可以在同一物理核的不同线程中,或者在同一芯片的不同核上,甚至在同一系统的不同芯片上;通信总是有效的,但是当物理距离增加时性能会降低。

通道就像是一个双向通信管道。它的缓冲容量非常小,因此通道的两端必须同意通信,否则一方将等待另一方。

lib_xcore提供了用于通信数据的数据类型和函数:

  • chanend_t c; 保存通道一端的引用的类型
  • chan ch; 保存具有两端的完整通道的类型
  • chan_out_word(c, x); 将一个字x通过通道端c输出的函数
  • x = chan_in_word(c); 从通道端c输入一个字x的函数
  • chan_out_buf_word(c, x, n); 将数组x中的n个字通过通道端c输出的函数
  • chan_in_buf_word(c, x, n); 从通道端c输入n个字到数组x的函数

我们也可以选择使用 XC 而非 C 语言和lib-xcore;结果行为是相同的。有等效的chanend_*函数,它们创建的是流通道而不是同步通道。在本应用说明中我们没有使用它们,但在需要额外性能和可预测性的情况下它们可能很有用。

在典型的代码中,将数字信号处理(DSP)任务卸载到另一个tile通常涉及到一个UserBufferManagement函数,该函数负责向DSP任务输出和输入样本;一个user_main.h函数,它声明了创建通道和启动DSP任务所需的额外代码;以及一个DSP任务,用于接收和传输数据。

UserBufferManagement代码如下:

#include "xcore/chanend.h"
#include "xcore/channel.h"
static chanend_t g_c;
void UserBufferManagement(
unsigned output_samples[NUM_OUTPUTS],
unsigned input_samples[NUM_INPUTS])
{
chan_out_buf_word(g_c, output_samples, NUM_OUTPUTS);
chan_out_buf_word(g_c, input_samples, NUM_INPUTS);
chan_in_buf_word(g_c, output_samples, NUM_OUTPUTS);
chan_in_buf_word(g_c, input_samples, NUM_INPUTS);
}
void UserBufferManagementSetChan(chanend_t c)
{
g_c = c;
}
void UserBufferManagementInit() {}

要包含在主程序中的代码如下:

#define USER_MAIN_DECLARATIONS \
chan c_data_transport; \
interface i2c_master_if i2c[1];
#define USER_MAIN_CORES \
on tile[1]: \
{ \
dsp_main(c_data_transport); \
} \
on tile[0]: \
{ \
ctrlPort(); \
i2c_master(i2c, 1, p_scl, p_sda, 100); \
} \
on tile[1]: \
{ \
UserBufferManagementSetChan(c_data_transport); \
unsafe \
{ \
i_i2c_client = i2c[0]; \
} \
}

最后,执行DSP的代码与缓冲管理函数相反:

#define FILTERS 4
// b2/a0 b1/a0 b0/a0 -a1/a0 -a2/a0
int32_t filter_coeffs [ FILTERS *5] = {
261565110 , -521424736 , 260038367 , 521424736 , -253168021 ,
255074543 , -506484921 , 252105451 , 506484921 , -238744538 ,
280274501 , -523039333 , 245645878 , 523039333 , -257484924 ,
291645146 , -504140302 , 223757950 , 504140302 , -246967640 ,
};
int32_t filter_states[NUM_INPUTS + NUM_OUTPUTS][FILTERS * 4];
void dsp_main(chanend_t c_data)
{
int for_usb[NUM_INPUTS + NUM_OUTPUTS];
int from_usb[NUM_INPUTS + NUM_OUTPUTS];
while (1)
{
chan_in_buf_word(c_data, &from_usb[0], NUM_OUTPUTS);
chan_in_buf_word(c_data, &from_usb[NUM_OUTPUTS], NUM_INPUTS);
chan_out_buf_word(c_data, &for_usb[0], NUM_OUTPUTS);
chan_out_buf_word(c_data, &for_usb[NUM_INPUTS], NUM_INPUTS);
for (int i = 0; i < 2; i++)
{
for_usb[i] = dsp_filters_biquads((int32_t)from_usb[i],
filter_coeffs,
filter_states[i],
FILTERS,
28);
}
}
}

下图显示了执行两个任务(USB任务调用UserBufferManagement和DSP任务dsp_main)的时间线。时间从上到下推移,我们展示了I2S到来的第5至第7帧数据周围的瞬间快照。小的深蓝色方框表示第5帧通过I2S到达的同时,处理第3帧的数据被发送出去。下方的浅蓝色方框是两个任务之间的通信;左边是UserBufferManagement(),右边是dsp_main()中while循环的前四行。

之后,USB任务有一段空闲时间(以应对更高的采样率和更多的通道),DSP任务开始DSP处理。当DSP正在处理第5帧数据时;第6帧数据在USB任务中到达,DSP任务必须在下一个通信阶段前完成。请注意,方框的大小并没有按比例绘制,否则有些方框会太小而看不清。

image-20231125104841922
图3:执行两个并发线程的时间线

值得注意的是,Buffer Manager空闲的灰色区域是其他线程可以利用的时间。这意味着,此时最多可以有五个DSP线程处于活动状态,占用处理器的全部带宽。在Buffer Manager工作的时期,DSP线程会运行得稍微慢一些;这可能几乎察觉不到,因为在这段时间内它们也需要一些下行时间。

在这个例子中,我们假设采样率为44,100 Hz。如果DSP线程太晚,那么所有的时间都会错乱;它必须准时完成,但它被允许刚好及时完成。请注意,DSP处理是与帧传输同步的,但相位是错开的。每个样本的处理都比到达的时间晚一点,导致整体延迟一个样本。

DSP的并行化

DSP的并行化涉及将单个较大的DSP处理拆分为多个任务。然后,我们可以将这些任务映射到多个线程上。区分这两个词的原因在于,任务是一个软件概念:一组执行有意义操作的指令,例如一个滤波器。如果我们有10个这样的任务,那么我们可以在线程1中执行其中的五个,在线程2中执行另外五个,这样我们就实现了2倍的并行性。

通常,任务之间是相互依赖的,当整体的DSP设计被详细规划出来时,这种依赖关系通过一个任务到另一个任务的箭头表示,代表数据从一个任务传输到下一个任务。当任务被映射到线程上时,必须遵守这些数据之间的依赖性。

由于DSP通常在已识别的数据集上有大量的计算集群,因此非常适合并行化。每个DSP问题都将被单独并行化,在本文档中,我们区分了可以构建其余部分的两种模型:

  • 数据并行化:同时处理多条数据流。在这种情况下,可以将左扬声器的DSP放在任务1中,将右扬声器的DSP放在任务2中。
  • 数据流水线:通过一系列顺序的处理步骤(或称为“任务”)来连续处理音频流数据。前一个任务的输出成为下一个任务的输入。

通常,这会产生两种设计。第一种设计是每个样本被送入一个任务,任务彼此独立地产生输出样本。第二种设计是样本通过一系列任务的顺序处理,最终产生输出样本。后一种架构与前一种设计相比有更高的固有延迟和略微复杂的设计。前一种设计非常简单,我们将首先讨论它。

使用数据并行化的DSP

数据并行化是前一个示例的简单扩展。我们不使用单个通道,而是使用多个通道将数据传输到DSP任务中。这就产生了下图4所示的时间线。与之前一样,我们使用通道在DSP任务之间进行通信,新的改动是我们必须创建这些DSP任务,并在它们之间创建通道。唯一的区别在于dsp_main函数。

image-20231125105022158
图 4:两个并发线程执行的时间线

UserBufferManagement代码如下:

static chanend_t g_c, g_c2;

void UserBufferManagement(
unsigned output_samples[NUM_OUTPUTS],
unsigned input_samples[NUM_INPUTS])
{
chan_out_buf_word(g_c, output_samples, NUM_OUTPUTS);
chan_out_buf_word(g_c, input_samples, NUM_INPUTS);
chan_in_buf_word(g_c, output_samples, NUM_OUTPUTS / 2);
chan_in_buf_word(g_c, input_samples, NUM_INPUTS / 2);
chan_out_buf_word(g_c2, output_samples, NUM_OUTPUTS);
chan_out_buf_word(g_c2, input_samples, NUM_INPUTS);
chan_in_buf_word(g_c2, output_samples + NUM_OUTPUTS / 2, NUM_OUTPUTS / 2);
chan_in_buf_word(g_c2, input_samples + NUM_INPUTS / 2, NUM_INPUTS / 2);
}

void UserBufferManagementSetChan(chanend_t c, chanend_t c2)
{
g_c = c;
g_c2 = c2;
}

void UserBufferManagementInit() {}

要包含在主程序中的代码如下:

#define USER_MAIN_DECLARATIONS \
chan c1, c2; \
interface i2c_master_if i2c[1];

#define USER_MAIN_CORES \
on tile[1]: \
{ \
dsp_main1(c1); \
} \
on tile[1]: \
{ \
dsp_main2(c2); \
} \
on tile[0]: \
{ \
ctrlPort(); \
i2c_master(i2c, 1, p_scl, p_sda, 100); \
} \
on tile[1]: \
{ \
UserBufferManagementSetChan(c1, c2); \
unsafe \
{ \
i_i2c_client = i2c[0]; \
} \
}

最后,执行DSP的代码与缓冲区管理功能相反:

#define FILTERS 4
// b2/a0 b1/a0 b0/a0 -a1/a0 -a2/a0
int32_t filter_coeffs[FILTERS*5] = {
261565110, -521424736, 260038367, 521424736, -253168021,
255074543, -506484921, 252105451, 506484921, -238744538,
280274501, -523039333, 245645878, 523039333, -257484924,
291645146, -504140302, 223757950, 504140302, -246967640,
};
int32_t filter_states[NUM_OUTPUTS / 2][FILTERS * 4];
int32_t filter_states2[NUM_OUTPUTS / 2][FILTERS * 4];

void dsp_main1(chanend_t c_data)
{
int for_usb[NUM_INPUTS / 2 + NUM_OUTPUTS / 2];
int from_usb[NUM_INPUTS + NUM_OUTPUTS];
while (1)
{
chan_in_buf_word(c_data, &from_usb[0], NUM_OUTPUTS);
chan_in_buf_word(c_data, &from_usb[NUM_OUTPUTS], NUM_INPUTS);
chan_out_buf_word(c_data, &for_usb[0], NUM_OUTPUTS / 2);
chan_out_buf_word(c_data, &for_usb[NUM_OUTPUTS / 2], NUM_INPUTS / 2);
for (int i = 0; i < NUM_OUTPUTS / 2; i++)
{
for_usb[i] = dsp_filters_biquads((int32_t)from_usb[i], filter_coeffs, filter_states[i], 4, 28);
}
}
}

dsp_main2 是完全相同的,只要它们有单独的状态来操作,就可以共享代码。使用此方法扩展到总计五个线程之后,xcore.ai 的管线将会被完全利用。你可以使用更多的线程(至高8个),但不会获得性能提升。这是因为尽管使用了更多的线程,但这些线程仍然使用了相同数量的指令发射周期。请参考架构与硬件指南

使用数据流水线的DSP

通过创建一个作为数据源和数据同步点的额外线程,我们可以构建一个自由组合的DSP处理流水线。这个线程的目的仅仅是执行这些任务。

这项任务之所以特殊,是因为它使数据路径形成了一个循环,从流水线中输出的数据必须在确定的时间点重新进入USB音频栈。我们正在构建的流水线如图5所示,它需要一些基础设置才能正常工作,但除此之外,代码本身相对简单易懂。

image-20231125105955115
图5:流水线示例

DSP 任务 1B

DSP 任务 1B 由 dsp_thread1b 实现,它从分配器中获取数据,并将数据输出给 DSP 任务 1A 和 1B:

#define FILTERS0 1
static __attribute__((aligned(8))) int32_t filter_coeffs0[FILTERS0 * 5] = {
261565110, -521424736, 260038367, 521424736, -253168021,
};
static __attribute__((aligned(8))) int32_t filter_states0[NUM_OUTPUTS][FILTERS0 * 4];

void dsp_thread0(chanend_t c_fromusb, chanend_t c_to1a, chanend_t c_to1b)
{
int from_usb[NUM_OUTPUTS];
int for_1[NUM_OUTPUTS];
while (1)
{
// Pick up my chunk of data to work on
chan_in_buf_word(c_fromusb, &from_usb[0], NUM_OUTPUTS);
for (int i = 0; i < NUM_OUTPUTS; i++)
{
for_1[i] = dsp_filters_biquads((int32_t)from_usb[i], filter_coeffs0, filter_states0[i], FILTERS0, 28);
}
// And forward answer to next stage
chan_out_buf_word(c_to1a, &for_1[0], NUM_OUTPUTS);
chan_out_buf_word(c_to1b, &for_1[0], NUM_OUTPUTS);
}
}

DSP任务 1A

DSP任务 1A 由 dsp_thread1a 实现,它从DSP任务 0 接收数据,并向DSP任务 2 输出数据:

#define FILTERS1a 2
static __attribute__((aligned(8))) int32_t filter_coeffs1a[FILTERS1a * 5] = {
261565110, -521424736, 260038367, 521424736, -253168021,
255074543, -506484921, 252105451, 506484921, -238744538,
};
static __attribute__((aligned(8))) int32_t filter_states1a[NUM_OUTPUTS / 2][FILTERS1a * 4];

void dsp_thread1a(chanend_t c_from0, chanend_t c_to2)
{
int from_0[NUM_OUTPUTS];
int for_2[NUM_OUTPUTS / 2];
while (1)
{
// Pick up my chunk of data to work on
chan_in_buf_word(c_from0, &from_0[0], NUM_OUTPUTS);
for (int i = 0; i < NUM_OUTPUTS / 2; i++)
{
for_2[i] = dsp_filters_biquads((int32_t)from_0[i], filter_coeffs1a, filter_states1a[i], FILTERS1a, 28);
}
// And forward answer to next stage
chan_out_buf_word(c_to2, &for_2[0], NUM_OUTPUTS / 2);
}
}

(再回到)DSP任务1B

同样地,DSP任务1B由dsp_thread1b实现,它从DSP任务0接收数据,并将数据输出到DSP任务2:

#define FILTERS1b 2
static __attribute__((aligned(8))) int32_t filter_coeffs1b[FILTERS1b * 5] = {
280274501, -523039333, 245645878, 523039333, -257484924,
291645146, -504140302, 223757950, 504140302, -246967640,
};
static __attribute__((aligned(8))) int32_t filter_states1b[NUM_OUTPUTS / 2][FILTERS1b * 4];

void dsp_thread1b(chanend_t c_from0, chanend_t c_to2)
{
int from_0[NUM_OUTPUTS];
int for_2[NUM_OUTPUTS / 2];
while (1)
{
// Pick up my chunk of data to work on
chan_in_buf_word(c_from0, &from_0[0], NUM_OUTPUTS);
for (int i = 0; i < NUM_OUTPUTS / 2; i++)
{
for_2[i] = dsp_filters_biquads((int32_t)from_0[i], filter_coeffs1b, filter_states1b[i], FILTERS1b, 28);
}
// And forward answer to next stage
chan_out_buf_word(c_to2, &for_2[0], NUM_OUTPUTS / 2);
}
}

DSP 任务 2

同样地,DSP 任务 2 由 dsp_thread2 实现,它从 DSP 任务 1A 和 1B 获取数据,并将数据输出到分发任务:

#define FILTERS2 1
static __attribute__((aligned(8))) int32_t filter_coeffs2[FILTERS2 * 5] = {
291645146, -504140302, 223757950, 504140302, -246967641,
};
static __attribute__((aligned(8))) int32_t filter_states2[NUM_OUTPUTS][FILTERS2 * 4];

void dsp_thread2(chanend_t c_from1a, chanend_t c_from1b, chanend_t c_todist)
{
int from_1a[NUM_OUTPUTS];
int from_1b[NUM_OUTPUTS];
int for_usb[NUM_OUTPUTS];
chan_out_buf_word(c_todist, &for_usb[0], NUM_OUTPUTS); // Sample -2
chan_out_buf_word(c_todist, &for_usb[0], NUM_OUTPUTS); // Sample -1
while (1)
{
// Pick up my chunk of data to work on
chan_in_buf_word(c_from1a, &from_1a[0], NUM_OUTPUTS / 2);
chan_in_buf_word(c_from1b, &from_1b[0], NUM_OUTPUTS / 2);
for_usb[0] = dsp_filters_biquads((int32_t)from_1a[0], filter_coeffs2, filter_states2[0], FILTERS2, 28);
for_usb[1] = dsp_filters_biquads((int32_t)from_1b[0], filter_coeffs2, filter_states2[1], FILTERS2, 28);
// And forward answer to the distributor for completion
chan_out_buf_word(c_todist, &for_usb[0], NUM_OUTPUTS);
}
}

分发器

分发器从USB堆栈获取数据,将其发送到DSP任务0,并从DSP任务2获取答案:

void dsp_data_distributor(chanend_t c_usb, chanend_t c_to0, chanend_t c_from2)
{
int for_usb[NUM_OUTPUTS + NUM_INPUTS];
int from_usb[NUM_OUTPUTS + NUM_INPUTS];
while (1)
{
// First deal with the USB side
chan_in_buf_word(c_usb, &from_usb[0], NUM_OUTPUTS);
chan_in_buf_word(c_usb, &from_usb[NUM_OUTPUTS], NUM_INPUTS);
chan_out_buf_word(c_usb, &for_usb[0], NUM_OUTPUTS);
chan_out_buf_word(c_usb, &for_usb[NUM_OUTPUTS], NUM_INPUTS);
// Now supply output data to DSP task 0
chan_out_buf_word(c_to0, &from_usb[0], NUM_OUTPUTS);
// Now pick up data from DSP task 2
chan_in_buf_word(c_from2, &for_usb[0], NUM_OUTPUTS);
}
}

启动线程

最后,我们需要编写代码来启动所有并行线程。这段代码启动五个任务,并使用六个通道将它们连接起来:

DECLARE_JOB(dsp_data_distributor, (chanend_t, chanend_t, chanend_t));
DECLARE_JOB(dsp_thread0, (chanend_t, chanend_t, chanend_t));
DECLARE_JOB(dsp_thread1a, (chanend_t, chanend_t));
DECLARE_JOB(dsp_thread1b, (chanend_t, chanend_t));
DECLARE_JOB(dsp_thread2, (chanend_t, chanend_t, chanend_t));

void dsp_main(chanend_t c_data)
{
channel_t c_dist_to_0 = chan_alloc();
channel_t c_0_to_1a = chan_alloc();
channel_t c_0_to_1b = chan_alloc();
channel_t c_1a_to_2 = chan_alloc();
channel_t c_1b_to_2 = chan_alloc();
channel_t c_2_to_dist = chan_alloc();

PAR_JOBS(
PJOB(dsp_data_distributor, (c_data, c_dist_to_0.end_a, c_2_to_dist.end_b)),
PJOB(dsp_thread0, (c_dist_to_0.end_b, c_0_to_1a.end_a, c_0_to_1b.end_a)),
PJOB(dsp_thread1a, (c_0_to_1a.end_b, c_1a_to_2.end_a)),
PJOB(dsp_thread1b, (c_0_to_1b.end_b, c_1b_to_2.end_a)),
PJOB(dsp_thread2, (c_1a_to_2.end_b, c_1b_to_2.end_b, c_2_to_dist.end_a)));
}
image-20231125110200324
图 6:流水线示例的时间线

为了展示这段代码的工作原理,我们在图6中展示了一个图表。请注意,分发任务大多数时间是空闲的;它仅在样本周期的开始和结束时消耗非常少的处理资源。这意味着其他五个线程可以被用来充分利用可用的DSP资源。

控制

为了控制您已经插入到代码中的DSP(例如,音量控制,均衡器设置),最简单的方法是将设置存储在内存中,并运行一个可以访问这些变量的异步线程。这个异步线程可以通过A/P控制(比如,通过I2C或SPI),或者它可以直接通过旋转编码器、按钮、滑条或触摸屏等接口控制。只要一方写入而另一方读取,这个过程就是线程安全的。

当你使用这种方法时需要小心,因为内存的更新与DSP是异步的:

  • 更新音量控制是完全安全的:它要么在这个样本中生效,要么在下一个样本中生效。
  • 更新FIR滤波器的滤波器系数也是安全的:最坏的情况是在一个样本中,它将使用一些旧的和一些新的参数。
  • 但是,在执行过程中更新IIR滤波器(一个二阶截面)的参数可能会产生问题。特别是,它可能使滤波器不稳定。这可以通过小步更新二阶截面来避免(这通常是个好主意,因为内部状态也需要稳定),或者可以使用同步线程来代替。

如果使用同步线程,其思想是线程不仅仅更新变量,而是请求DSP线程本身在对DSP安全的时候更新变量。这将需要两个线程之间的通道和一个协议,使得控制线程请求更新,并且在DSP任务准备就绪时给出答复,然后控制任务发布新的滤波器系数,DSP线程可以使用这些新系数。