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

使用C和lib_xcore对XCore tile进行编程

XCore编译器( xcc )支持使用GNU C或C++针对XCore编程。 C平台库lib_xcore提供对XCore特定硬件功能的访问。在使用XCC C编译器时,lib_xcore可作为系统头文件的集合使用;所有头文件的名称都以 xcore/ 开头。

并行执行

以下代码展示了一个使用lib_xcore的多核“hello world”应用程序。与在C中编程类似的是,程序有一个 main 函数作为其入口点 - main 在单个逻辑核上启动,必须使用lib_xcore来启动其他逻辑核上的任务。

main.c
#include <stdio.h>
#include <xcore/parallel.h>

DECLARE_JOB(say_hello, (int));
void say_hello(int my_id)
{
printf("Hello world from %d!\n", my_id);
}

int main(void)
{
PAR_JOBS(
PJOB(say_hello, (0)),
PJOB(say_hello, (1)));
}

PAR_JOBS

#include <stdio.h>
#include <xcore/parallel.h>

DECLARE_JOB(say_hello, (int));
void say_hello(int my_id)

PAR_JOBS 来自 xcore/parallel.h ,它是一个 lib_xcore 库的构造宏,用于在不同的核心上调用两个或更多的函数。每次调用 PJOB 宏都代表一个任务 - 第一个参数是要调用的函数,第二个参数是传递给该函数的参数。注意,被PAR_JOBS宏调用的函数必须使用DECLARE_JOB宏在与PAR_JOBS相同的翻译单元中声明。DECLARE_JOB宏的参数是函数的名称和代表参数签名的一组类型名称。被PAR_JOBS调用的函数的返回类型必须为 void

  PAR_JOBS(
PJOB(say_hello, (0)),
PJOB(say_hello, (1)));

当执行PAR_JOBS时,每个PJOB都会在当前 tile 的不同逻辑核上并行执行。在此之后有一个隐式的“join”,这意味着程序在PAR_JOBS宏之后的执行都会暂停,直到所有启动的任务都返回。因为每个PJOB任务都运行在自己的逻辑核上,所以在执行PAR_JOBS宏时,必须有足够多的空闲逻辑核来运行所有任务。可以运行的任务数量不能超过单个tile上的逻辑核总数。如果某些tile正在运行其他任务,那么可以运行的任务数量会更少。如果没有足够的内核,PAR_JOBS宏将在运行时导致异常。

计算堆栈大小

在前面的示例中,多个任务是由一个线程启动的,然而没有必要指定这些线程的堆栈应位于何处。这是因为PAR_JOBS宏为每个启动的线程分配一个堆栈,作为启动线程堆栈的“子堆栈”。分配的堆栈大小由XMOS工具计算,XMOS工具还确保为调用线程分配的堆栈,足以满足其启动的所有线程和被调用函数。

可以使用编译器的 -report 选项来查看已分配给堆栈的 RAM 量。

xcc -target=XCORE-AI-EXPLORER hello_world.c -report
Constraint check for tile[0]:
Memory available: 524288, used: 22408 . OKAY
(Stack: 1620, Code: 19528, Data: 1260)
Constraints checks PASSED.
提示

引用lib_xcore的头文件时,-report选项仅打印内存使用信息。

为了计算每个线程所需的堆栈大小,编译器为每个函数添加了特殊的符号来描述其堆栈需求。这些符号在XCore ABI文档中有详细说明。XTC工具并不总能确定一个函数的堆栈需求,例如当它通过指针调用一个函数或者当它是递归函数时。如果编译器无法推导出一个函数的堆栈大小需求,则需要用户手动提供最坏情况下的需求。这可以通过手动定义符号或者注释代码来实现。

硬件定时器

定时器是一个简单的资源,可以被读取以获取时间戳。定时器可以配置触发时间,这会导致读取操作阻塞,直到达到该时间戳。这使得定时器适用于测量时间以及延迟触发。我们可以使用lib_xcore的xcore/hwtimer.h访问定时器,它提供了许多与定时器资源交互的函数。另外类似于其他和资源相关的函数,lib_xcore的定时器使用资源handle工作。因为所有的定时器都在同一个资源池种,XCore会跟踪可用的定时器并根据需要分配handle。hwtimer_alloc从池中分配一个定时器并返回其handle。由于可用定时器数量有限,分配可能失败,在这种情况下,它将返回0。

使用通道通信

XCore的chanend资源允许任务之间进行通信。它可以通过通信网络发送数据,允许在跨tile、不同逻辑核甚至跨封装上运行的任务进行同步通信,这是它的优点。chanend资源允许任务之间高效地通信和同步。

通道

lib_xcore库在xcore/channel.h头文件中提供Channel类型,用于在同一tile的两个任务之间创建通信通道。以下代码示例展示了一个程序,其中sends_first任务向receives_first任务发送1个字的数据,后者以1个字节响应。在这个程序中,调用channel_alloc来建立一个通道,两个任务可以使用该通道进行通信。需要注意的是,返回的channel_t类型仅是一个包含通道两端的结构体。通道是在启动任务之前创建的,每个任务接收通道的一端。

#include <stdio.h>
#include <xcore/channel.h>
#include <xcore/parallel.h>

DECLARE_JOB(sends_first, (chanend_t));
void sends_first(chanend_t c)
{
chan_out_word(c, 0x12345678);
printf("Received byte: '%c'\n", chan_in_byte(c));
}

DECLARE_JOB(receives_first, (chanend_t));
void receives_first(chanend_t c)
{
printf("Received word: 0x%lx\n", chan_in_word(c));
chan_out_byte(c, 'b');
}

int main(void)
{
channel_t chan = chan_alloc();

PAR_JOBS(
PJOB(sends_first, (chan.end_a)),
PJOB(receives_first, (chan.end_b)));

chan_free(chan);
}

这种方法的优势在于两个任务是解耦的——只要它们实现相同的应用层协议,任何一个任务都不需要知晓它正在与什么通信,或者通道的另一端在网络中的何处执行。

在任务内部,chan_ 函数用于发送和接收数据。这些函数会同步任务,因为chan_out_word会阻塞,直至另一个任务中的chan_in_word被调用,反之亦然。其中最重要的是,对每次“发送”函数的调用,都需要调用正确的对应的“接收”函数,否则任务通常会死锁或触发硬件异常。

流式通道

常规通道在每次发送/接收时都会实施握手过程。这有利于同步参与通道的线程,并防止因发送的数据量超过接收端预期而导致任务无法继续。尽管在大多数情况下握手过程是必要的,但是它确实会带来运行时开销,包括进行实际握手所需时间以及发送任务常常被同步阻塞。

为解决此问题,我们提供了流式通道。xcore/channel.h 中的每个chan_函数都对应于xcore/channel_streaming.h中的s_chan_函数。这些函数的功能与非流式对应函数相同,唯一的区别是不执行握手过程。每个chanend都有至少能容纳一个字的数据缓冲区。向流式通道发送数据只会在输出数据的缓冲区空间不足时被阻塞。

路径容量

XCore封装内部以及封装之间的通信结构具有有限的容量,这意味着在任何给定时间使用的通道端之间路由的数量是有限的。如果网络中没有可用容量,任务试图发送数据时,该任务将开始排队直到有通道可用。常规通道在每次通信时都会建立和移除网络中的路由,这是握手过程的副作用。这意味着路由仅在很短的时间内开放,所有任务都有机会利用网络。然而,由于流式通道不执行握手,它们的路由在分配之后到释放之前的整个期间会保持开启。如果保留了太多开放的流式通道,这可能会使其他任务无法访问网络(包括使用非流式通道的任务)。因此,建议尽可能减少流式通道分配的时间。

基础I/O端口

端口是允许XCore封装与外部引脚进行交互的资源。每个tile上都具有不同宽度的各种端口;这些端口(Port)通常会共享引脚(pin),而端口实际映射到引脚的方式因封装体而异。端口极其灵活,可用作软件任务来实现输入/输出外设。

与池化资源不同,端口不是由XCore分配的(因为映射到所需引脚的端口总是需要的)。相反,端口的句柄可从platform.h获取,格式为XS1_PORT_<W><I>,其中*<W>为端口位宽,<I>*为区分同一位宽端口的单个字母。例如,XS1_PORT_16A是第一个16位端口。由于端口不是由池管理的,使用前必须明确启用,不再需要时必须禁用。

与计时器相似的是,端口具有可配置的触发器,设置后在读取端口时,调用方(例如:尝试读取端口的任务或函数)将被阻塞,直到触发条件得到满足。例如,可以配置端口,使尝试读取其输入信号的调用方在读取到指定值前变为阻塞。默认情况下,该触发器持续生效,因此当满足触发条件时,尝试读取输入信号的调用方为非阻塞;但一旦触发条件变为false,调用方将再次变为阻塞。用于与端口交互的函数(包括输入、输出和配置触发器)可从xcore/port.h中获取。

下述程序在按键被按下时点亮LED。每次读取端口时,其触发条件将更新为在其值不等于当前值时触发——这意味着端口仅在其引脚上的数值变化时读取一次。

#include <platform.h>
#include <xcore/port.h>

int main(void)
{
port_t button_port = XS1_PORT_4D,
led_port = XS1_PORT_4C;

port_enable(button_port);
port_enable(led_port);

while (1)
{
unsigned long button_state = port_in(button_port);
port_set_trigger_in_not_equal(button_port, button_state);
port_out(led_port, ~button_state & 0x1);
}

port_disable(led_port);
port_disable(button_port);
}

事件处理

在XCore架构中,事件是一个重要的概念,因为它们允许资源表明自己已经准备好,可以进行输入操作了。Lib_xore提供了一个_select_构造,用于等待事件的发生并在事件发生时运行处理代码。

#include <platform.h>
#include <xcore/port.h>
#include <xcore/select.h>

int main(void)
{
port_t button_port = XS1_PORT_4D,
led_port = XS1_PORT_4C;

port_enable(button_port);
port_enable(led_port);

SELECT_RES(
CASE_THEN(button_port, on_button_change))
{
on_button_change: {
unsigned long button_state = port_in(button_port);
port_set_trigger_in_not_equal(button_port, button_state);
port_out(led_port, ~button_state & 0x1);
continue;
}
}

port_disable(led_port);
port_disable(button_port);
}

上述应用程序展示了来自xcore/select.h的lib_xcore SELECT_RES方法,在这个例子中,它处理单一事件。这等效于基础端口I/O中的例子,不同之处在于port_in不会阻塞,因为它仅在端口的触发条件被满足时执行(相反,线程将在SELECT_RES开始时阻塞)。

在此例子中,和此文档中之前的例子一样,任务会因等待一个资源而阻塞。理论上来说,这通常是一个良好的设计,也是编程模型中普遍推荐的模型。然而,这通常不是有效利用可用逻辑核的方法——大多数任务在大多数时间内都会处于等待状态。任务也可能需要能够接受来自多个资源的输入(例如,一个“收集器”任务可以监听多个通道)。

出于这些原因,XCore 允许任务配置多个资源来生成事件,然后等待其中任何一个事件的发生。lib_xcore select 构造支持此操作——使其类似于 Unix select。理论上来说,XCore 会复用任务感兴趣的事件,而 select 会解复用它们。

../../../_images/multiplexing.png

以下代码实现了一个定时器,它允许LED在按住按钮时闪烁。如果在处理另一个事件时发生新的事件,则后续事件会保留在其各自的资源上,并会在正在运行的处理程序继续执行时(continue)进行处理。

#include <platform.h>
#include <xcore/hwtimer.h>
#include <xcore/port.h>
#include <xcore/select.h>

int main(void)
{
port_t button_port = XS1_PORT_4D,
led_port = XS1_PORT_4C;

hwtimer_t timer = hwtimer_alloc();
port_enable(button_port);
port_enable(led_port);

int led_state = 0;
unsigned long button_state = port_in(button_port);
port_set_trigger_in_not_equal(button_port, button_state);


SELECT_RES(
CASE_THEN(button_port, on_button_change),
CASE_THEN(timer, on_timer))
{
on_button_change:
button_state = port_in(button_port);
port_set_trigger_value(button_port, button_state);
continue;

on_timer:
hwtimer_set_trigger_time(timer, hwtimer_get_time(timer) + 20000000);
if (~button_state & 0x1) {
led_state = !led_state;
port_out(led_port, led_state);
}
continue;
}

port_disable(led_port);
port_disable(button_port);
hwtimer_free(timer);
}

SELECT_RES宏接受一个或多个参数,这些参数必须是来自同一头文件的“情况指示符”的展开。最常见的情况指示符是CASE_THEN。它接受两个参数:第一个是等待事件的资源,第二个是事件发生时在SELECT块中跳转到的标签。在进入SELECT_RES时,任务进入等待状态,当指定的资源之一上有事件可用时,控制权转移到作为处理程序指定的标签。每个处理程序必须以breakcontinue结束;break使控制跳转到SELECT块后的代码,而continue返回等待状态以处理另一个事件。

在前面的例子中,每次处理定时器事件时,定时器的下次触发时间都会被调整到未来某个时间,这使得定时器能够定期且重复地触发。在定时器事件的处理程序中,仅当按住按钮时才切换LED的状态。这种结构意味着任务的大部分时间都在等待两种可能事件中的任何一种。然而,即使没有按住按钮,定时器事件也会发生——所以尽管它不会产生任何效果,处理程序也仍在运行。select 构造允许有条件地屏蔽某些并不总是需要关注的事件——这被称为“门卫表达式”。在下面的代码中,门卫表达式会屏蔽on_timer事件,直到满足其条件。每次SELECT进入等待状态时,都会重新评估门卫表达式;如果事件在门卫表达式“屏蔽”时发生,一旦其条件被重新评估为true,它将立即被处理。

#include <platform.h>
#include <xcore/hwtimer.h>
#include <xcore/port.h>
#include <xcore/select.h>

int main(void)
{
port_t button_port = XS1_PORT_4D,
led_port = XS1_PORT_4C;

hwtimer_t timer = hwtimer_alloc();
port_enable(button_port);
port_enable(led_port);

int led_state = 0;
unsigned long button_state = port_in(button_port);
port_set_trigger_in_not_equal(button_port, button_state);


SELECT_RES(
CASE_THEN(button_port, on_button_change),
CASE_GUARD_THEN(timer, ~button_state & 0x1, on_timer))
{
on_button_change:
button_state = port_in(button_port);
port_set_trigger_value(button_port, button_state);
continue;

on_timer:
hwtimer_set_trigger_time(timer, hwtimer_get_time(timer) + 20000000);
led_state = !led_state;
port_out(led_port, led_state);
continue;
}

port_disable(led_port);
port_disable(button_port);
hwtimer_free(timer);
}

高级I/O端口

端口具有极高的灵活性,通常“软外设”的实现可以将大量工作委托给端口;这可以显著提高响应速度。特别是端口可以连接到可配置的时钟块资源,常用于驱动端口输入输出的时序。记录端口和时钟块的全部功能和接口超出了本文档的范围。有关这些资源的更多信息,请参阅lib_xcore端口和时钟API,以及XCore指令集文档。

附录:堆栈大小计算指南

如本文档前面所示,XMOS工具能够计算许多C/C++函数的堆栈大小要求。

一般来说,当某个函数的堆栈大小已经确定,并且它只调用已知堆栈大小的函数时,该函数的堆栈大小要求是可以计算出来的。

函数指针组

当通过函数指针调用函数时,无法自动确定被调用函数的精确堆栈大小需求,因为无法确知给定指针可能指向的所有函数。因此,XMOS工具允许将间接调用站点注释为_函数指针组_。函数也可以注释为其所属的一个或多个组。当堆栈大小计算器遇到通过注释的函数指针进行的间接调用时,它会假设所调用的函数与指针组中堆栈大小需求最大的函数具有相同的堆栈大小需求。

下列代码段定义了一个函数指针fp,它属于名为my_functions的组:

__attribute__(( fptrgroup("my_functions") )) void(*fp)(void);

当一个函数通过这样的指针进行调用时,该函数的堆栈大小将包括调用my_functions组中任何函数所需的足够空间。函数指针组在将函数显式添加之前为空,所以在所有可能的被调用函数都使用正确的组进行注释之前,这样的调用是危险的(可能导致堆栈溢出)。在以下代码段中,func1被注释为my_functions组的成员:

_attribute__(( fptrgroup("my_functions") ))
void func1(void)
{
}

显式地设置堆栈大小

在某些情况下,有必要显式设置分配给函数的堆栈大小。这可能是因为函数没有固定的要求(例如:使用可变长度数组),或者当大量使用间接调用使得注释不可行时,这种方式可能是可取的。

在这些情况下,可以指定分配给某个特定函数调用的字数;以下代码段是汇编指令,它将名为task_main的函数的堆栈需求设置为1024个字:

.globl task_main.nstackwords
.set task_main.nstackwords,1024

请注意,这仅仅定义了一个符号(其名称基于函数名称),该符号将由编译器为可计算函数定义。

此代码可以作为独立对象汇编和链接,也可以在编译C/C++代码时作为附加输入文件传递给xcc

有限的一组二元运算符可用于表达堆栈大小需求;这常常对于基于一个函数调用的其他函数来设置该函数自身的堆栈大小需求时非常有用。可用的运算符有:

+*$M - 计算其两个操作数中的较大值(用于查找一系列函数中最耗费的函数) • $A - 计算其左操作数上舍入至右操作数的下一个倍数(用于向上舍入至堆栈对齐时的需求)

括号()也可以使用。

以下指令将task_main的堆栈需求设置为1024个字加上my_function0my_function1需求中的较大值,全部舍入至双字对齐:

.globl task_main.nstackwords
.set task_main.nstackwords, (1024 + (my_function0.nstackwords $M my_function1.nstackwords )) $A 2

以这种方式设置堆栈大小时,编译器仍会为通过指针进行的未注释调用发出警告;这可以使用-Wno-xcore-fptrgroup选项来关闭警告。

手动设置堆栈需求时应当小心——函数的分配必须足以满足其“自身”的使用,以及它调用的所有函数和使用PAR_JOBS启动的所有线程。