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

数据处理和内存安全

本章介绍了xC中的数据处理和内存安全性。在xC中,编译器通过静态检查和运行时检查来确保内存访问的安全性,并引入了额外的数据处理特性,如引用和可空类型,这些特性提供了更灵活和安全的数据处理方式。

内存安全方面,xC要求任务之间不共享内存,并对指针进行别名和所有权的跟踪。

xC的内存安全机制包括静态检查和运行时检查,以避免无效的内存访问和并行访问冲突。这些机制有助于消除程序中的内存错误和难以追踪的错误,提高代码的可靠性和稳定性。

额外的数据处理特性

引用

xC语言和 C++ 一样,提供了引用作为间接引用某些数据的方法。例如,以下声明创建了一个引用x,指向整数i

int i = 5;
int &x = i;

读取和写入引用与读取和写入原始变量相同:

printf("The value of x is %d\n", x);
x = 7;
printf("x has been updated to %d\n", x);
printf("i has also been updated to %d\n", i);

引用还可以指向数组或结构的元素:

int a[5] = {1, 2, 3, 4, 5};
int &y = a[0];
printf("y has value %d\n", y);

函数参数也可以是引用。例如,以下函数接受一个引用并更新其所引用的值:

void f(int &x) {
x = x + 1;
}

可以将该函数调用时要引用的值作为参数传递:

void pass_by_reference_example() {
int i = 5;
printf("Value of i is %d\n", i);
f(i);
printf("Value of i is %d\n", i);
}

引用可以作为接口函数参数在任务之间传递。例如,以下接口中的函数update可以更改作为参数提供的变量:

interface if1 {
void update(int &x);
}

可以这样调用:

void task(client interface if1 i) {
...
i.update(y); // 这可能会改变y的值
}

与在接口调用中传递数组一样,即使通信的任务位于不同的tile上,更新引用也是有效的。

可空类型

xC中的资源(如接口、通道、端口和时钟)必须始终具有有效的值。可空限定符允许这些类型成为特殊值null,表示没有值。这类似于某些编程语言中的可选类型。

可空限定符是一个问号?。因此,以下声明是一个可空的端口:

port ?p;

给定一个可空类型的变量,程序可以使用isnull内置函数来检查它是否为null,例如:

if (!isnull(p)) {
// 我们知道p不为null,因此可以在这里使用它
...
}

这个功能对于可选的函数参数非常有用,例如:

// 接受一个端口和可选的第二个端口的函数
void f(port p, port ?q);

引用也可以声明为可空。由于可空限定符应用于引用,因此它需要出现在引用符号的右侧,例如:

// 接受可选整数'y'进行更新的函数
void f(int x, int &?y);

最后,数组也可以声明为可空。在这种情况下,声明需要明确指定参数是对数组的引用,例如:

// 接受可选整数数组'a'的函数
void f(int (&?a)[5]);

可变长度数组

在xC中,需要将数组声明为固定大小。然而,基于某个参数的局部数组可以声明为可变大小,只要该参数同时被标记为staticconst即可。当调用带有静态参数的函数时,实参必须满足以下两种情况之一:

void f(static const int n) {
printf("Array length = %d\n", n);
int arr[n];
for (int i = 0; i < n; i++) {
arr[i] = i;
for (int j = 0; j < i; j++) {
arr[i] += arr[j];
}
}
printf("--------\n");
for (int i = 0; i < n; i++) {
printf("Element %d of arr is %d\n", i, arr[i]);
}
printf("--------\n\n");
}

在调用带有静态参数的函数时,实参必须满足以下两种情况之一:

  • 是常量表达式
  • 是调用函数的静态常量参数

例如:

void g(static const int n) {
// 静态参数可以使用常量表达式作为参数调用
f(2);
// 或者传递静态常量参数
f(n);
}

这些限制意味着尽管局部数组具有可变大小,编译器仍然可以静态跟踪堆栈使用情况。

多返回值

在xC中,函数可以返回多个值。例如,以下函数返回两个值:

{int, int} swap(int a, int b) {
return {b, a};
}

在调用函数时,可以一次性分配多个值:

int x = 5, y = 7;
{x, y} = swap(x, y);

内存安全

在C语言和xC语言中,您有多种访问内存的方式(见图15)。

image-20230708173333374

图15:在代码中使用不同的方式访问内存

在C语言中,内存分配可以通过变量声明或malloc函数实现。程序只允许访问这些已分配的内存区域,如果试图访问超出分配区域的部分,则会导致无效行为,并使得结果未定义。

而在xC语言中,还引入了并行任务的概念,同时也增加了一项额外的限制:禁止任何任务访问其他任务所拥有的内存。拥有内存的任务对其有写入权限。在程序运行过程中的特定节点上,内存所有权可以在不同任务之间转移。

如果一个任务访问了它不应该访问的内存,那么这是一个程序错误,并且可能导致破坏性的、难以追踪的错误。xC通过添加检查来帮助尽早捕获无效的内存访问(例如,在编译时或程序执行的早期阶段)以消除这些错误。例如,图16中的所有错误都将被检测到。

image-20230708173447904
图16:常见的无效内存操作

为了能够进行所有这些检查并使用C风格的指针,xC需要在指针类型上附加额外的注释。这些限制有助于确保内存安全性 - 参见指针

运行时异常

xC编译器会尝试在编译时检查内存错误,并在编译过程中报告错误。例如,给定以下代码:

void f() {
int a[5];
a[7] = 10;
}

编译器将无法编译该代码,并报告以下错误:

bad.xc:  In function 'f':
bad.xc:4: error: index of array exceeds its upper bound

然而,有时候在编译阶段我们无法确定是否会发生内存错误,例如:

void f(int a[]) {
a[7] = 10;
}

在此类情况下,编译器会插入一个运行时检查以确保内存操作的安全性。如果操作不安全,将会抛出一个异常。这会导致程序在错误发生的地方停止运行,避免因内存损坏在后续某一不易追踪的环节中引起难以预测的错误。如果已经连接了调试器,那么它将会报告发生了何种异常以及异常发生的位置(前提是程序已开启调试信息编译功能)。

当应用程序发布正式版本时,可以为程序设置一个通用的异常处理器,例如,在遇到故障时重启设备。当然,实际上在这个阶段,程序应该不会发生任何错误。

边界检查

编译器会追踪所有数组和指针(不包含不安全指针)的边界。一旦试图访问超出这些边界,将会引起编译错误或运行时异常。

如果编译器无法在编译时确定操作是否安全,它将会插入运行时检查。然而,这些检查过程需要消耗执行时间。对于数组,在很多情况下,通过以下语法告知编译器数组边界与另一个变量有关,即可避免这些检查操作:

// 函数接受大小为n的数组
void f(int a[n], unsigned n) {
for (int i = 0; i < n; i++)
a[i] = i;
}

在这种情况下,代码不需要进行边界检查,因为编译器可以推断出,所有访问都在数组的边界内(由变量n给出)。

当调用函数时,编译器仍然需要检查边界是否正确。例如,以下代码将引发错误:

void f(int a[n], unsigned n);
void g() {
int a[5];
f(a, 8); // 错误 - 边界不匹配
}

并行使用冲突检测

在编译过程中,XTC工具会进行并行使用冲突的检查,即确保没有任务访问其他任务所拥有的内存。该检测方式可以通过跟踪到哪些任务对某个变量进行写入操作来判断内存所有权。举例来说,如果您试图编译以下程序:

#include <stdio.h>

int g = 7;

void task1() {
g = 7; //task1对int g进行了赋值
}

void task2() {
printf("%d ", g);
}

int main() {
par {
task1();
task2();
}
return 0;
}

那么编译器将返回以下错误:

par.xc:10: error: use of `g' violates parallel usage rules
par.xc:7: error: previously used here
par.xc:5: error: previously used here

如果数据只被读取,两个任务可以访问它。因此,以下代码是有效的:

#include <stdio.h>

const int g = 7;

void task1() {
printf("%d ", g + 2);
}

void task2() {
printf("%d ", g);
}

int main() {
par {
task1();
task2();
}
return 0;
}

指针

指针是一种非常强大的编程工具,但同时也很容易导致无效的内存访问操作。通过边界检查,可以避免因指针运算而导致的无效访问。然而,指针仍可能指向已被释放的内存,而且无效的并行使用也可能通过指针间接发生。xC语言能够检测到这些无效操作,并在编译或运行时抛出错误。

为了实现这一目标,每个指针都需要分配一个类型。存在四种指针类型:受限(restricted)、别名(aliasing)、可移动(movable)和不安全(unsafe)。可以使用以下语法来声明指针类型:

pointer-type *pointer-kind pointer-variable

例如,以下声明是一个名为p的可移动指向整数的指针:

int * movable p;

如果在声明中没有描述指针类型,则会假定默认类型。默认类型取决于声明是全局变量、参数还是局部变量。下表显示了默认类型:

声明位置默认类型
全局变量受限(Restricted)
参数受限(Restricted)
局部变量别名(Aliasing)
函数返回值无默认值(No default value) - 必须显式声明

别名

为了追踪哪些指针可能指向已经释放的内存,或者可能引发无效的并行内存访问,编译器需要具备追踪指针别名情况的能力。所谓“别名”,是指当两个程序元素同时引用同一块内存区域的现象。下图(图17)展示了一个别名情况的例子。

image-20230708181315148

图17:三个程序元素引用同一内存区域

受限指针

在C语言和xC语言中,都存在受限指针的概念。一个受限指针无法进行别名处理,也就是说,唯一可以访问该内存地址的方式便是通过这个指针。在C语言中,编译器会假设通过受限指针的访问不会发生别名,但并不会进行任何检查来确保这种情况。而在xC语言中,额外的检查机制会确保非别名限制得以实现。

对于xC语言中的受限指针,首先进行的检查是:当给定一个指向对象的受限指针时,程序不能通过原始变量来访问内存:

int i = 5;
int *restrict p = &i;
printf("%d ", i); // 这是一个错误

函数参数默认为受限制指针,因此以下代码也是无效的:

int i = 5;
// 函数参数默认为受限制指针
void f(int *p) {
i = 7; // 这是一个错误,由于下面的调用导致的
}
void g() {
f(&i);
}

xC对受限制指针的第二个检查是,指针不能重新分配或复制:

int i = 5, j = 7;
int *restrict p = &i;
int *restrict q;
p = &j; // 无效 - 不能将受限制指针重新分配
q = p; // 无效 - 不能复制受限制指针

这些检查确保受限制指针始终指向被跟踪的非别名位置。它也不能指向已释放的内存。

由于指针函数参数默认为受限制类型,编译器还会检查在函数调用点是否创建了别名:

// 函数接受两个受限制指针
void f(int *p, int *q);
void g() {
int i;
f(&i, &i); // 这是无效的,因为参数存在别名
}

由于受限制指针不能被复制,函数返回类型不能是受限制类型。

允许别名的指针

受限指针在使用上是相当有限的。往往,允许指针进行别名处理会更加方便。别名指针类型就允许这种操作,但其使用规则与受限指针不同。

局部指针默认为别名指针,所以以下代码是合法的:

int f() {
int i = 5, j = 7;
int *p = &i; // 这是一个别名指针
int *q = &j; // 这也是一个别名指针
p = q; // 别名指针可以重新分配和复制
return i + *p + *q;
}

为了追踪别名指针所创建的别名,需遵守以下限制:

  • 不能在并行任务(par)中将别名指针传递给不同的任务
  • 不能间接访问别名指针(例如,一个指向别名指针的指针)

如果函数接受可能产生别名的指针参数,或者返回可能产生别名的指针,这个信息需要被明确地写入函数的类型定义中。例如,下面这个函数的返回值可能与其参数产生别名关系:

char *alias strchr(const char *alias haystack, int needle);

全局指针可以在程序的任何位置进行访问,因此无法轻易追踪其别名情况。因此,在xC中,全局指针不能设置为别名指针。它们默认为受限指针,但也可以被标记为不安全或可移动指针。

函数参数

当将指针传递给函数时,有一些特别的规则允许在各类指针类型之间进行转换。首先,受限指针能被传递给需要接受别名指针参数的函数:

void f(int *alias x);
void g(int *y) {
f(x); // y是受限制指针,但可以作为别名传递
}

实际上,在函数内部,受限指针会被当作别名指针来处理:

void g(int *y) { // 'y'是受限制指针
// 在函数内部,'y'表现得像别名指针
int *p = y;
y = p;
...
}

如果别名指针并未与其他参数产生别名,那么它可以被传递给需要受限指针参数的函数:

void f(int *x, int *y) { *x; }
int g() {
int i = 5, j = 8;
int *p = &i;
int *q = &j;
f(p, q); // 只要p不与q发生别名关系,就是有效的
}

但这只在别名指针的来源局限于函数内部时才有效。如果别名指针被赋予一个来自传入参数或全局变量的值,那么对被调用的函数会施加额外的限制——它不能访问任何全局变量(因为这些全局变量可能与正在传递的指针产生别名)。例如,下面的代码是无效的:

int i = 5;
int f(int *q) {
return *q + i; // 编译器假设'q'不与'i'发生别名关系
}
void g() {
int *p = &i;
f(p); // 无效,因为f访问了一个全局变量而'p'具有非局部作用域
}

此代码将在编译时失败,并显示错误:

p.xc:10: error: passing non-local alias to function `f' which accesses a global variable

转移所有权(可移动指针)

在程序的不同部分之间转移指针的所有权非常有用。例如:

  • 将所有权转移到全局变量,以便稍后使用
  • 在并行运行的任务之间转移所有权

在这些情况下,为了避免产生竞态条件和悬空指针,指针仍需保证没有别名,但受限指针不能被重新赋值或复制。可移动指针提供了一种解决方案。这类指针可以进行转移,但只能以保持非别名属性且永不指向已释放内存的方式进行。

可以使用movable类型修饰符来声明一个可移动指针:

int i = 5;
int *movable p = &i;

与受限制指针一样,在这种情况下程序不能使用i,因为那会破坏指针的非别名属性。

可移动指针值可以使用move运算符进行转移:

int *movable q;
q = move(p);

move运算符将源指针设置为null。这确保每次只有一个变量拥有内存位置的所有权。

在将可移动指针传递给函数或从函数返回可移动指针时,也必须使用move运算符:

int *movable global_p;
void f(int *movable p) { global_p = move(p); }
int *movable g(void) {
return move(global_p); // 在此处需要使用move运算符
}
void h(void) {
int i = 5;
int *movable p = &i;
f(move(p)); // 在此处需要使用move运算符
p = g();
}

可移动指针不能指向已经释放的内存。为了确保这一点,需要遵守以下限制:

  • 当可移动指针超出其作用域时,必须仍然指向它初始化时的相同内存区域。

为了实现这个目标,会插入运行时检查(因此,当指针超出作用域时可能会抛出异常)。例如,下面的代码是无效的:

int *movable global_p;
void f() {
int i = 5;
int *p = &i;
global_p = move(p);
} // <-- 在此处发生异常,因为'p'不指向初始化时的区域

这样可以避免global_p指向已释放的内存。

在并行任务间传递指针

指针可以作为接口函数参数进行传递,例如:

interface if1 {
void f(int * alias p);
};

在事务执行期间,任务共享指针,例如:

void f(server interface if1 i) {
select {
case i.f(int * alias p):
printf("%d", *(p + 2));
break;
}
}

当声明包含带有指针参数的函数的接口时,无法跨 tiles 使用它(因为 tiles 具有独立的内存空间)。

受限和别名指针只能在事务执行期间使用。例如,以下代码是无效的:

void f(server interface if1 i) {
int * alias q;
select {
case i.f(int * alias p):
q = p; // 无效,不能将别名移动到更大的范围
break;
}
printf("%d", *q);
}

要在事务的范围之外传输指针,应使用可移动指针,例如:

interface if2 {
void f(int * movable p);
};

void f(server interface if2 i) {
int * movable q;
select {
case i.f(int * movable p):
q = move(p); // ok,所有权被转移
break;
}
printf("%d", *q);
}

这样,一个任务必须在明确定义的点上放弃内存区域的所有权,以便其他任务使用它(因此不会发生意外的竞态条件)。

不安全指针

提供了不安全指针类型,用于与 C 的兼容性,并实现动态的、具有别名的数据结构(例如链表)。这不是默认的指针类型,程序员有责任确保这些类型的内存安全性。

除非在不安全区域访问,否则不安全指针是不透明的。函数可以被标记为不安全,以显示其主体是不安全区域:

unsafe void f(int * unsafe x) {
// 我们可以在这里解引用 x,
// 但要小心 - 它可能指向垃圾
printintln(*x);
}

不安全函数只能从不安全区域调用。你可以通过将复合语句标记为不安全来创建本地不安全区域:

void g(int * unsafe p) {
int i = 99;
unsafe {
p = &i;
f(p);
}
// 不能从这里解引用 p 或调用 f
}

这些区域让程序员能够明确区分:

  • 程序中自动保证安全的部分
  • 需要程序员手动保证安全的部分。

在不安全区域内,不安全指针可以显式地被转换成安全指针 - 这就需要程序员做出承诺,即从那个时间点开始,这个指针可以被视为是安全的。

从一个任务写入并在另一个任务中读取不安全指针,这种行为是未定义的。