取两个数较小值c语言_如何提升C语言安全性,达到RUST的安全性

fd4544407295e8952f130e8a1c83d624.png

可信C语言:让C语言达到和RUST一样的安全性

1. 所有权

所有运行的程序都必须管理其使用计算机内存的方式。一些语言中具有GC(Garbage Collection)机制,在程序运行时不断地寻找不再使用的内存(典型代表Java);在另一些语言中,程序员必须亲自分配和释放内存(典型代表C/C++),容易出错,不安全,经常被人诟病,一旦出现问题,调试工作巨大,让人沮丧;还有一些语言采用了ARC(Automatic Reference Counting)的机制(典型代表Object C和C++智能指针),据说ARC比传统GC机制性能好。Rust 则选择了另外一种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。在运行时,所有权系统的任何功能都不会减慢程序。Rust号称是安全语言,其核心功能就是“所有权(ownership)”。

C语言作为系统开发语言,不太可能全部迁移至C++或OC,在可以预期的时间迁移到Rust更加不可能,Rust特别有意设计的和C不一样的表达式语法,对广大系统软件工程师来说,学习成本就非常大,更别提实际迁移工作量了。

因此,吸收Rust的所有权思想并引入到C语言中提升C语言的安全性是一件非常有意义的事,在工具链上需要通过开发简单的插件来达到和RUST编译器一样的检查功能,我们姑且把这个插件叫做检查器。

我们也姑且把这种C语言称为TC(可信的C?随便取的,主要为了方便区分和后文描述)。

1.1. 栈(Stack)与堆(Heap)

在很多语言(比如Java和OC)中,你并不需要经常考虑到栈与堆。不过在像 C/Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的抉择。

栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反顺序取出值。这也被称作后进先出(last in, first out)。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做进栈(pushing onto the stack),而移出数据叫做出栈(popping off the stack)。

栈的操作是十分快速的,这主要是得益于它存取数据的方式:因为数据存取的位置总是在栈顶而不需要寻找一个位置存放或读取数据。另一个让操作栈快速的属性是,栈中的所有数据都必须占用已知且固定的大小。

在编译时大小未知或大小可能变化的数据,要改为存储在堆上。堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针(pointer)。这个过程称作在堆上分配内存(allocating on the heap),有时简称为 “分配”(allocating)。将数据推入栈中并不被认为是分配。因为指针的大小是已知并且固定的,你可以将指针存储在栈上,不过当需要实际数据时,必须访问指针。

想象一下去餐馆就座吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。

当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)如果超出允许的寄存器用量时会被压入栈中,函数的局部变量也会被压入栈中。当函数结束时,这些值被移出栈,这是由操作系统自动管理的,程序员并不需要关心。但操作系统对堆上数据并不会自动清理,因此,跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的存在就是为了管理堆数据,能够帮助解释为什么所有权要以这种方式工作。

1.2. 所有权规则

首先,让我们看一下所有权的规则。当我们通过举例说明时,请谨记这些规则:

1)TC中的每一个值都有一个被称为其所有者(owner)的变量。

2)值有且只有一个所有者。

3)当所有者(变量)离开作用域,这个值将被丢弃。

1.3. 变量作用域

变量的作用域(scope)是一个项(item)在程序中有效的范围。假设有这样一个变量:

int i = 0;

跟其他编程语言类似,C和TC一样,作用域由该变量最近的一组{}来圈定。

{

// i 在这里无效, 它尚未声明

int i = 0;// 从此处起,i 是有效的

// 使用 i

}// 此作用域已结束,i 不再有效

换句话说,这里有两个重要的时间点:

1) 当i进入作用域时,它就是有效的;

2) 一直持续到它离开作用域为止。

1.4. 复杂类型

前面介绍的基本数据类型是存储在栈上的并且当离开作用域时被移出栈,这是很容易理解的已有机制,不是所有权要解决的核心问题。现在我们需要寻找一个存储在堆上的复杂数据来探索TC是如何知道该在何时清理数据的。在C语言中,数据类型比较简单,所谓复杂类型就是指字符串、结构体和联合体。

1.5. 内存与分配

就字符串常量来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串并不占用栈或堆的空间。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变,用放在栈上的局部数组来存放也是不安全的,可能会导致栈空间的耗尽。

对于字符串类型,为了支持一个可变,可增长的文本片段,如在需要容纳一个不确定的外部输入时,一般需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

必须在运行时向操作系统请求堆内存。需要一个当我们处理完字符串时将内存返回给操作系统的方法。

C语言调用malloc库函数来申请堆空间,然后要识别出不再使用的内存并调用free库函数显式释放,这是程序员的责任,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,一样会引发异常。这些都可能会引起严重的安全问题。

TC采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面是作用域的例子,例子中计划使用一个堆空间来存放字符串,而不是一个基本变量:

{

// str 在这里无效, 它尚未声明

char *str = (char *)malloc(XXX);

// 如申请空间成功,从此处起,str是有效的

// 使用str

}// 此作用域已结束,str被检查器自动释放

这是一个将str需要的内存返回给操作系统的很自然的位置:当 str 离开作用域的时候。TC的检查器为我们调用一个特殊的函数,这个函数就是free,在结尾的 } 处自动添加free调用。如果程序员已经手工调用free函数,检查器会给出提示,不会再重复释放。不建议程序员手工调用free函数。

注意:在 C++ 中,这种 item 在生命周期结束时释放资源的模式有时被称作资源获取即初始化(Resource Acquisition Is Initialization (RAII))。如果你使用过 RAII 模式的话,应该对此做法并不陌生。

这个模式对编写TC代码的方式有着深远的影响。现在它看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。现在让我们探索一些这样的场景。

1.6. 所有权转移

先让我们看一个使用基本类型的例子:

int x = 5;

int y = x;

将变量 x 的整数值赋给 y,我们都很清楚这是在干什么:“将x初始化为 5,接着将 x 的值拷贝到 y”。现在有了两个变量,x 和 y,都等于 5。这也正是事实上发生了的,因为整数是有已知固定大小的简单值,所以这两个 5 被放入了栈中。

现在来看这个例子的字符串版本:

char *s1 = (char *)malloc(XXX);

char *s2 = s1;

这看起来与上面的代码非常类似,如果不了解C语言,我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个 s1 的拷贝,并将其地址赋值给 s2 。C程序员都知道,事实上并不是这样,而是s2和s1都指向了同一个堆空间。如下图所示:

bdef6a005ec5c52bc426205e56d32451.png

这种“一房多卖”的情况在C语言中是一件让人非常头痛的事,比如出现多次释放,或者释放后使用的问题。

TC语言并不会改成上述假想的那样,为S2重新申请一块空间,并将数据从S1指向的空间拷贝过来。程序员都知道内存的拷贝是一件很影响性能的事。TC语言为了确保内存安全,这种场景下,TC会认为 s1 不再有效,检查器会自动添加s1=NULL这样的语句。看看在 s2 被创建之后尝试使用 s1 会发生什么。这段代码不能运行:

char *s1 = (char *)malloc(XXX);

char *s2 = s1;

……

printf( "s1=%s!", s1 );

检查器会强烈提示错误,并拒绝进行下一步编译运行动作,否则你的程序可能会奔溃,因为 s1 已经是一个空指针。

如果你在其他语言中听说过术语浅拷贝(shallow copy)和深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 TC 同时使第一个变量无效了,这个操作被称为移动(move),而不是任何一种拷贝。上面的例子可以解读为 s1 被移动到了 s2 中。如下图所示:

50e03c0e05feff57dd99675524de4d13.png

这样就解决了我们前面讲的“一房多卖”的问题!因为只有 s2 是有效的,当s2离开作用域,检查器会添加自动释放的语句,完毕。

C程序员都知道如果需要复制一个副本,需要再次另外申请空间,并用memcpy此类的函数来进行数据复制。安全专家们都知道memcpy是一个非常危险的操作,但它的安全性并不是“所有权”要解决问题的范畴。

1.7. 所有权遗弃

转移接收了其它所有权,原先的所有权自动遗弃,毕竟我们是不允许脚踏两只船的。从空间被释放的时间点来看,这一做法和ARC机制非常类似。

char *s1 = (char *)malloc(XXX);

char *s2 = (char *)malloc(XXX);

s2 = s1;

//s2原有空间被遗弃,检查器会去释放它,s1的空间转给s2,s1置空

程序运行情况如下图所示:

dad0ebc7a5bd3984cba4e38aad22c41e.png

1.8. 永久所有权

前面已经讲到所有权是为了解决堆上分配问题而做的优化,但这并不意味着所有权概念仅用于堆上空间。比如位于栈上、位于代码段和数据段的基本数据类型(含数组),这些空间的所有权就是其变量,但和堆空间不同的是,这些变量对该空间拥有永久所有权,永久所有权不能转移给其它变量,也不能丢弃。

char *str = "Hello, TC!";// 错误语法,检查器会报错

char str[] = "Hello, TC!";// 正确语法,永久所有权

char str[XXX];// 正确语法,永久所有权

char *str1 = str;// 错误,永久所有权不能转移

C程序员可能会对最后一句提出严重的抗议,但这确实是TC语言所不同的地方,永久所有权不能被转移,但在后面章节可以看到,它可以被借用。因此,需要完成正常的功能是没有问题的,只是更安全而已。

1.9. 所有权与函数

将基本类型的形参传递给函数实参时,在语义上与给变量赋值相似,是一种值的传递。但对于复杂类型指针向函数传递时可能会产生所有权移动,就像赋值语句一样。下面例子使用注释展示变量何时进入和离开作用域:

void main( )

{

int i1 = 5;// i1 进入作用域

char *s1 = (char *)malloc(xxx);// s1 进入作用域

takes_ownership( i1, s1 );// i1 的值复制给函数里

// s1 的值移动到函数里 ...

// 所以到这里,s1不再有效

// 但可继续使用 i1

}// s1的空间已被移走,检查器会自动置空,程序员无需关心释放问题。

void takes_ownership( int i2, char *s2 )

{

// i2,s2 进入作用域

} //s2移出作用域,检查器自动释放s2空间。i2在栈上,无需任何操作。

当尝试在调用 takes_ownership 后使用 s1 时,检查器会强烈提示错误,并拒绝进行下一步编译运行动作,否则运行起来系统可能会奔溃。

1.10. 返回值与作用域

在某些场景下,调用一个函数对s1进行处理后,我还需要继续处理s1,但s1已经失去空间所有权,这怎么办呢?方法是让函数再将所有权返回。

void main()

{

char *s1 = (char *)malloc(xxx);// s1 进入作用域

……

s1 = takes_and_gives_back(s1);// s1 被移动到

// takes_and_gives_back 中,

// 它也将返回值移给 s1

……继续使用s1……

} // 这里, s1 移出作用域并被检查器释放

char * takes_and_gives_back( char *s2 )

{

// s2 进入作用域

……

return s2;// 返回所有权给调用者

}// s2所有权已经转移,检查器在这里不需要做特殊处理

每次都传进去再返回来就有点烦人,除此之外,我们也可能想返回函数体中产生的一些数据,就无法返回所有权了,C语言只允许返回一个值的。

我们不想这么啰嗦和形式化,那能否要函数使用一个值但不获取所有权呢?请参见下一章“所有权借用”。

1.11. 结构体的所有权

1.11.1. 所有权的包含关系

前面大部分内容都是以字符串为例介绍的,但在复杂数据类型中,还有结构体struct和联合体union两种,以struct为例来介绍其所有权问题。先看一个例子:

typedef struct staff

{

char name[10];

int age;

}STAFF_T;

我们定义了一个新的struct型的数据类型STAFF_T,然后实例化一个staff数据:

STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

staff_a拥有该示例的所有权,遵循上述所有权的相关规定,可以遗弃、转移和出借。这个例子比较简单,因为struct的成员全部是基本数据类型,如果struct含有复杂数据类型情况会如何呢?继续看复杂一点的例子,加入addr成员长短差异较大,程序员采用动态分配内存的方法:

typedef struct staff

{

char name[10];

int age;

char *addr;

}STAFF_T;

实例化(所有举例代码仅为说明问题,不考虑异常问题):

STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

staff_a->addr = (char *)malloc(100);

这个时候再内存中存在两块内存,示意图如下:

eae8038eaf42103385229474266b69d4.png

这个时候新的情况出现了,前面说第一块内存的所有权是staff_a的,那第二块内存的所有权属于谁呢?我们认为第二块内存的所有权属于staff_a的成员addr,并不属于staff_a,虽然他们存在紧密关系,这是特别要注意的地方。

当staff_a被释放或遗弃时,如果addr也有空间存在,会被一并自动释放,无需程序员手工处理。但能够释放的前提条件是其所有权(第二章介绍)没有被借用。

总之,理解了所有权的本质是空间被一个变量所关联,就不难理解结构体相关的所有权知识了。

1.11.2. 复杂数据成员所有权的转移

在结构体含有复杂数据类型成员时,所有权转移会比较复杂一点,主要是需要考虑其包含的下一级所有权是否存在。下面还是以STAFF_T结构体为例来解释。

typedef struct staff

{

char name[10];

int age;

char *addr;

}STAFF_T;

实例化staff_a:

STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

staff_a->addr = (char *)malloc(100);

定义一个staff_b,并将staff_a的所有权转移过来:

STAFF_T *staff_b = staff_a;

这种从根struct转移所有权的行为是一种整体转移。注意,在整体转移过程中,其复杂数据类型成员的所有权并没有转移,其所有权还在原先的地址上。下面示意图可以清晰看出,红圈所在的地址(所有权)并没有发生改变。

eb80de71ad994e1c976fa4fd17559391.png

除此之外,还可以部分转移,即只转移其复杂数据类型成员的所有权。实例化staff_a:

STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

staff_a->addr = (char *)malloc(100);

定义一个addr1,并将staff_a->addr的所有权转移过来:

char *addr1 = staff_a->addr;

如果有多层次的复杂数据的嵌套,原理也是一样。

2. 所有权借用

2.1 借用

前面代码中提到过这样一个问题:我们必须将s2返回给调用函数交还所有权,以便在调用函数能继续s1。下面演示如何定义并使用一个(新的)borrow函数,它可以使用s1的内容但不获取其所有权:

void main( )

{

int i1 = 5;// i1 进入作用域

char *s1 = (char *)malloc(xxx); // s1 进入作用域

borrow( i1, s1 );// i1 是值复制,不涉及所有权

// s1 的值借用到函数里 ...

……// s1继续有效,可继续使用

}// s1 离开作用域,检查器会自动释放,程序员无需处理。

void borrow( int i2, char const * volatiles2 )

{

// i2进入作用域

// s2仅仅是一个借用,在作用域范围内可以使用其内容值。

}

沿用C语言的volatile修饰符来表示一个借用(是否对编译器有其它影响?)。另外在C语言中,const *表示常量指针,指针指向内容为只读,在此表示为只读借用。用虚线表示借用关系,如下图所示:

3304a9ed608cef419fcd3c710444f63c.png

很显然,如果在borrow函数中,尝试给s2申请新的空间,或者尝试调用free函数释放s2,检查器都会强烈提示错误。当然,检查器自己也不会在s2的作用域结束时去释放s2。因为s2并没有内存空间的所有权。借用(borrowing),正如现实生活中,如果一个人拥有房产,你可以从他那里租借来住,但你并不拥有所有权。

void borrow( int i2, char const * volatile s2 )

{

// i2进入作用域

// s2仅仅是一个借用,在作用域范围内可以使用其内容值。

s2 = (char *)malloc(xxx); // 错误,借用不能申请自己的空间

free( s2 ); // 错误,借用没有自己的空间可释放

}// 作用域结束自动归还。

2.2. 归还

作用域结束,自动归还是很容易理解的事。除此之外,程序员还可以主动归还:

void borrow( int i2, char const * volatile s2 )

{

// i2进入作用域

s2 = NULL; // 主动归还

}

通过赋值到NULL是一种主动归还方式,可以成为显式归还。还有另外一种隐式主动归还:

void borrow( int i2, char const * volatile s2 )

{

// i2进入作用域

char *s4 = (char *)malloc(xxx);

s2 = s4; // s2向s4发起了借用,隐式的归还了原先的借用。

}

2.3. 可写借用

进一步,如果在borrow函数中,改变s2所指向的内容,检查器和编译器也会提示错误:

void borrow( int i2, char const * volatile s2 )

{

// i2进入作用域

// s2仅仅是一个借用,在作用域范围内可以使用其内容值。

s2[0] = 'a'; // 检查器提示错误,s2指向的内容不能改变。

}

C程序员很容易发现问题,是因为我们在借用时加了const修饰符,如果想修改s2指向空间的值。只要去掉这个const就可以了。我们把这种借用方式叫做可写借用。

void borrow( int i2, char * volatile s2 )

{

// i2进入作用域

// s2仅仅是一个借用,在作用域范围内可以使用其内容值。

s2[0] = 'a'; // 可写借用,写成功。

}

2.4. 可写借用限制

不过对于可写借用,有一个很大的限制:在特定作用域中的特定数据有且只有一个可写借用。这些代码会失败:

char *s1 = (char *)malloc(xxx);// s1 具有所有权

char * volatile s2 = s1;// 可写借用

char * volatile s3 = s1;// 再次可写借用,检查器报错

进一步讲,有了可写借用后,也不允许同时存在其它只读借用:

char *s1 = (char *)malloc(xxx); // s1 具有所有权

char const * volatile s2 = s1;// 只读借用,OK

char * volatile s3 = s1;// 可写借用,检查器报错

char const * volatile s4 = s1;// 再次只读借用,OK

不过,这个限制可考虑通过配置关闭,因为大部分语言中变量任何时候都是可变的。意思是说在检查器可以有选项可以选择:允许多次可写出借或者同时允许一个可写和多个只读出借。但这个限制的好处是让TC和Rust一样可以在编译前/时就避免数据竞争。数据竞争(data race)类似于竞态条件,它可由这三个行为造成:

两个或更多指针同时访问同一数据。至少有一个指针被用来写入数据。没有同步数据访问的机制。

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;TC和 Rust避免了这种情况的发生,因为它们甚至不会编译存在数据竞争的代码!

一如既往,可以使用大括号来创建一个新的作用域,以允许拥有多个可变借用,只要不是同时拥有:

char *s1 = (char *)malloc(xxx); // s1 具有所有权

{

char * volatile s2 = s1;// 可写借用

}

char * volatile s3 = s1;// 再次可写借用,因为s2已经失效

另外,需要特别注意的是,默认情况下,有了可写借用后,拥有所有权的变量自身也变得不可访问,既不能写也不能读。如果只是只读借用,原变量还可以读,只是不能写。

char *s1 = (char *)malloc(xxx);// s1 具有所有权

char * volatile s4 = s1;// 可写借用

s1[0] = 'a';// 检查器报错,s1不可写访问

printf( "s1=%s", s1 );// 检查器报错,s1不可读访问

这仅仅是一个为了说明问题的示例,如果实际情况中出现这种代码,也不用太担心,检查器会在s4离开作用域后恢复s1的可访问性,然后在s1离开作用域后释放空间。

总之,在默认限制规则下,我们也不能在拥有只读借用的同时拥有可写借用。只读借用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个只读借用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。

尽管这些限制有时使人沮丧,但请牢记这是TC检查器和Rust编译器在提前指出一个潜在的bug(在编译前/时而不是在运行时)并精准显示问题所在。这样你就不必去跟踪为何数据并不是你想象中的那样。

2.5. 悬垂指针(Dangling pointer)

在C这样具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,TC检查器和Rust中编译器确保永远也不会变成悬垂状态:情况一:对于拥有所有权的变量来说,检查器会严格按照作用域来确保数据绝对有效性;情况二:对于借用者来说,检查器会确保出借者不会在其归还数据之前离开作用域,不离开作用域,就不会被释放,也保证了数据的绝对有效。

情况二描述的这种机制实际上是一个我们还未介绍的新功能,叫:生命周期(lifetimes)。后面章节会详细介绍生命周期。现在,我们先来看一个例子,检查器如何检查这种错误:

char * volatile dangle( )

{

char *s1 = (char *)malloc(xxx);// s1 具有所有权

char * volatile s2 = s1;// s2 借用s1

return s2;

// s1离开作用域后释放,S2成为悬垂指针,检查报错并拒绝编译。

}

因为 s1 是在被调用函数内创建的,当该函数的代码执行完毕后,s1将被释放。我们尝试返回它的借用,指向一个无效的空间,这可不对!TC检查器不会允许我们这么做。当然,C语言是可以的,这些C语言的不安全性正是TC要优化的地方。

这里的解决方法是直接返回s1:

char * no_dangle( )

{

char *s1 = (char *)malloc(xxx);// s1 具有所有权

return s1;// s1 所有权返回,运行正常。

}

这样就没有任何错误了。所有权被移动出去,所以没有空间会被释放。

2.6. 借用的默认规则

1) 让我们概括一下之前对借用的讨论:

2) 同一时刻,可有0个或多个只读借用,但不能同时有任何可写借用;

3) 同一时刻,在没有只读借用时,允许有一个可写借用;

4) 在只读借用归还前不可写访问拥有所有权的源变量;

5) 在可写借用归还前不可写或读访问拥有所有权的源变量;

6) 借用在离开作用域后归还,也可以主动归还;

7) 在借用期内,所有者保证不会释放/转移这块内存。

2.7. 永久所有权的借用

对于具有永久产权的基本数据类型的借用,也遵循上述默认的借用规则。先看一下整形数据的例子:

int i = 0;

int j = i;// 值复制

int *k = &i; // 错误,尝试转移永久产权

int * volatile l = &i; // 正确,可写借用

int const* volatile l = &i; // 正确,不可写借用

下面,继续看一下永久所有权数组的例子

int i[10];

int *j = i;// 错误,尝试转移永久产权

int * volatile k = i;// 正确,可写借用

int const* volatile k = i; // 正确,不可写借用

在函数调用时也是一样的规则,但函数返回时有些不一样。前面在讲非永久所有权借用时也没有提及函数返回一个借用时的情况,因为这涉及到下面一个技术点。

2.8. 转借

Alice给Bob借了一笔钱,Bob转身把这笔钱又借给了Carol,这就产生了转借行为。把借来的东西再转借出去,这会产生让人非常头痛的三角债问题。按我们生活中的智慧,我们需要避免这种转借行为。但在TC和RUST语言中,必须允许转借行为存在。为了避免三角债务带来的烦恼,我们把转借在逻辑上可以理解是一种代理行为。因此,虽然Carol向Bob借用,但Carol的债主并不是Bob,还是Alice,是最终的所有权拥有者。这也很容易理解,因为Bob并不拥有所有权,所以他没有东西可供借出,他只能是一个代理人,帮助Alice做借出动作。

下面看一个例子:

char *Alice = (char *)malloc(xxx); // s1 具有所有权

char const *volatile Bob = Alice;// 只读借用,此时Alice无法写了

char const *volatile Carol = Bob;// 转借,相当于A同时出借给B和C

根据前面所述规则,一个变量可以同时有多个只读借用,所以上面操作没有问题。现在问题来了,如果Bob是一个可写借用呢?按照规则,如果存在可写借用,那有且只能有一个,其它不论是否可写,都不能再借出。但在这里的转借场景中,情况有所不同,即便Bob是可写借用,它仍然可以继续转借。但只能是可写转借,不能只读转借,看一个例子:

char *Alice = (char *)malloc(xxx); // s1 具有所有权

char * volatile Bob = Alice;// 可写借用,此时Alice无法读写

char const *volatile Carol = Bob;// 只读转借,错误操作

char * volatile Carol = Bob;// 可写转借,此时Bob也无法读写

可写借用来的东西为什么不能只读转借?这也很容易理解,如果允许只读转借,那Bob就变成了只读访问,这是没问题的,问题是Alice前面已经变成了完全不可访问,此处要不要变成只读访问呢?如果在继续转借下去,可能会引起连锁反应,所以,我们规定这种情况下只允许可写转借,只影响转借者本身的权限。

2.9. 转借的应用

转借在实际编码中是必须的,比如在前面提到的函数返回时。来看一个例子:

void main( )

{

char *s1 = (char *)malloc(xxx); // s1 进入作用域

char *volatile s3 = borrow( s1 ); // s1 的值借用到函数里 ...

……// 得到一个新借用者s3

}

char * volatile borrow( char * volatile s2 )

{

……;// s2 处理中

return s2+10;// 返回一个s2的一个偏移值,例如+10。

}

另外一个常用场景是函数的多层调用:

void borrow1( char * volatile s2 )

{

……;// s2 处理中

borrow2( s2 );// 继续转借给borrow2处理。

}

void borrow2( char * volatile s3 )

{

……;// s3 处理中

}

需要说明下,在第一个例子中返回了一个偏移值,我们对这种部分借用有一个专用名字叫“Slice借用”。

2.10. 结构体的借用

对于没有复杂数据类型成员的结构体,可以认为就是单块空间借用,规则完全如前所述。对于有复杂数据成员的结构体,借用就会复杂很多,特别是有多层复杂数据成员堆叠嵌套时。但还是前面的话,理解了所有权的本质是和一个变量相关联就不难理解结构体带来的复杂性了。下面还是以STAFF_T结构体为例来解释。

typedef struct staff

{

char name[10];

int age;

char *addr;

}STAFF_T;

2.10.1整体借用

实例化staff_a:

STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

staff_a->addr = (char *)malloc(100);

定义一个整体借用:

STAFF_T * volatile staff_b = staff_a;

此时,staff_a已经可写出借给了staff_b,按照出借规则,staff_a处于读写都不允许的状态,也不能够再出借给其它人。

这个时候新的问题来了,staff_b借用了staff_a,对staff_a的空间没有所有权,无法去释放,但这个借用是一个可写借用,表示对staff_a的成员内容有可写权限。也就是说,我们可以写成员staff_a->addr,这相当于我们神奇的获得了所有权。就是原本staff_a->addr所指向的空间的所有权转移到了staff_b->addr上,确确实实是转移行为,而不是借用关系。从下面示意图中可以看出,指向该空间的箭头是一个实箭头:

b45e903e9df810f3c554e4b6cda4dd20.png

这和我们的直觉可能有所不同,但这样的设计确实不会影响安全性,此时staff_a处于无法访问状态,自然无法对addr行驶所有权,检查器会自动完成检查确认工作。在实际编程中,这样的使用场景很多,比如处理一个链表上进进出出的节点,处理者只是借用了链表头,但它需要对下层空间拥有所有权才能进行链表操作。

仔细思考起来也不难理解,所有权本质上是一个变量对内存空间的指向。变量本身地址没变,那所有权就没变。

2.10.2Slice借用

结构体除了整体出借外,可以可以只借出部分成员。可以把一个结构体看做一棵树,叶子的借用就是slice借用。

实例化staff_a:

STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

staff_a->addr = (char *)malloc(100);

定义一个slice借用:

char * volatile addr1 = staff_a->addr;

此时,staff_a的一个成员的空间可写出借给了addr1,是一种slice借用,对这个叶子来说,往下又是一个整体,遵循整体借用规则。下图为一个示意图,r1被借用后,从r1指向内容r2往下又是一个整体,继续遵循整体借用规则。

5839814379c92e10c77474c2f9738067.png

现在问题来了,r1北向和东西向情况如何呢?

先看东西向兄弟成员,因为兄弟成员和r1没有任何包含关系,因此,r1的借用对兄弟成员的状态没有任何影响,兄弟成员可以继续借出(所有)、转移或释放(非永久所有权的兄弟成员)。

再看r1的北向,因为r1被其北向r0所包含,所以当r1被借出后、归还前,r0不允许被释放,如果r0还有北向,也以此类推,一直到结构体根部。这实际上是后面章节要讲的结构体的生命周期问题。但北向的借用和转移都不影响。

2.10.3出借期增益

实例化staff_a:

STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

定义一个整体借用:

STAFF_T * volatile staff_b = staff_a;

出借后,由借入方代理申请了新的空间:

staff_b->addr = (char *)malloc(100);

当staff_b归还给staff_a后,staff_a的成员就多了一块产权,这可以看做是staff_a在出借期间的增益。这并不难理解。

2.10.4借用者成员

结构体中含有借用者成员,修改一下STAFF_T的定义:

typedef struct staff

{

char name[10];

int age;

char * volatile addr;// 这是一个借用者

}STAFF_T;

实例化staff_a:

STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

定义一个整体借用:

STAFF_T * volatile staff_b = staff_a;

出借后,由借入方代理进行了成员的借用:

staff_b->addr = addr1;

因为,staff_b是一个可写借用,所以,上面代理为成员进行借用是允许的。反之,如果是只读借用,上面的操作将会被检查器报错。

在staff_b归还后,staff_a释放时,会自动归还借用来的成员空间。不过,如果不是十分必要这样做,建议staff_b在归还自身前先主动代理归还成员空间,这有利于简化生命周期管理。

2.11. 高维数据的借用

C语言最容易迷惑人的是高维指针,实际上前一节的结构体含有复杂数据类型成员时,也是一种高维指针。只是每一个维度都显式静态定义,更容易理解。在纯粹的高维指针中,中间维度是动态关联,中间关系就没有那么直观。先看一个例子:

char *s1 = (char *)malloc(xxx);

char **s2 = &s1;// 检查器报错,&s1是永久所有权,无法转移

char ** volatile s2 = &s1;//借用成功

需要注意的是,这个借用是一个高维借用,用示意图表示如下:

ea67bd10a9d6d1ced15f2113746afc0f.png

可见,s2借用的并不是s1所指向的空间,而是s1本身,即&s1指向的空间,只是没有显式定义&s1,实际地址中并不存在这个位置。根据前面所有权知识,我们知道&s1是一个永久所有权,所以,它只能被借用,但不能被转移。

另外,和结构体借用类似,s2借用到s1后,可以拥有s1指向空间的所有权,因为s1的位置没有发生任何改变,而且s2是一个可写借用,所以s2可以主动去释放、转移、遗弃s1所指向的空间,例如:

*s2 = NULL; // s1指向空间被遗弃

但在s2归还时,不会自动释放s1指向空间,因为s1还存在着。

3. 生命周期(lifetimes)

3.1 函数返回的生命周期问题

在前面章节中已经提到生命周期的概念,为了确保借用的有效性,出借方的生命周期一定要长于借用方,出借方不能在借用方归还前释放或转移,否则就会出现悬垂指针,造成诸如UAF之类的安全漏洞。

良好的编程风格有助于检查器来检查和推断生命周期,比如在函数调用过程中保持借用的单调性是非常提倡和实用的编程做法。所谓单调,就是变量只借给被调用函数,而不会反过来由被调用函数反借给调用函数。单调编程举例如下:

void fun_a( )

{

char *s1 = (char *)malloc(xxx);

fun_b( s1 );

}

void fun_b( char * volatile s2 )

{

fun_c( s2 )

}

void fun_c( char * volatile s3 )

{

……

}

当调用函数返回一个借用时,单调性就会被破坏,这对生命周期的自动化推断和检查会带来很大麻烦,我们不提倡这样的编程风格。在RUST语言中,采用手工注解的方法来解决。本来引入所有权问题,就是希望避免程序员对内存进行手工操作,对生命周期的手工注解显然破坏了设想。在TC中,我们尽量避免手工操作,改由检查器自动推断产生注解。看一个非单调借用的例子:

void fun_a( )

{

char *s1 = (char *)malloc(xxx);

char * volatile s2 = fun_b( s1 );

}

Char * volatile fun_b( char * volatile s3 )

{

return s3;

}

在这个例子中,fun_b函数返回s3的含义是将s3转借给s2(也可能是转借一个slice,因原理相同,所以在描述具体问题时不再区分是整体转借还是slice转借),s3自己的作用域结束归还,即函数返回后s1只存在一个s2的借用。按照借用规则,需要确保s1的生命周期比s2长。那么问题来了,在fun_a看来,如何知道s2是s1的一个借用呢?因为fun_a和fun_b无法确保总是在一个文件中,甚至fun_b是一个二进制库提供的,源代码都没有。这个时候,就需要用到注解了,在fub_b的头文件中声明中进行注解,语法如下:

Char * volatile @1fun_b( char * volatile @1s3 );

在函数名和s3前面都标注有一个“@”的修饰符,后面跟一个数字,代表组别。对于非TC语言(C/C++)开发、且没有源代码的库文件,需要手工添加注解,否则生命周期推测会失败,产生不可预料的严重后果。TC语言开发或者有源代码的其它语言(C/C++)开发的库,检查器会自动推断生产带注解的头文件函数声明。下面再看一个复杂点的例子:

void fun_a( )

{

char *s1 = (char *)malloc(xxx);

char *s2 = (char *)malloc(xxx);

char * volatile s5 = fun_b( s1,s2 );

}

Char * volatile fun_b( char * volatile s3,char * volatile s4 )

{

if( XXX )

return s3;

else

return s4;

}

在这个例子中,函数fun_b的返回值会受到运行时影响,无法在编译时/前由检查器确定。即s5最终是s1的借用还是s2的借用是没法在运行前确定的,不论是人工还是检查器都是无能为力的。这个时候,我们采用了RUST基本一样的简单粗暴的做法,如果不知道s3还是s4,那就要求s3和s4的出借方s1和s2的生命周期都要长于s5,注解方式如下:

Char *volatile @1fun_b( char *volatile @1s3,char *volatile @1s4 );

因为TC和C语言一样,只有一个返回值,所以,对于函数调用过程中生命周期的问题处理上比RUST要简单一些。

3.2. 结构体的生命周期问题

除了函数返回借用外,如果带有结构体,也可能返回一个借用。仍然采用前面的结构体来说明问题。

typedef struct staff

{

char name[10];

int age;

char * volatile addr;// 这是一个借用者

}STAFF_T;

然后,改造一下调用函数的参数:

void fun_a( )

{

char *s1 = (char *)malloc(xxx);

char *s2 = (char *)malloc(xxx);

STAFF_T staff_a;

fun_b( s1,s2 );

}

void fun_b( char * volatile s3,char * volatile s4,STAFF_T * volatile staff_b )

{

if( XXX )

staff_b->addr = s3;

else

staff_b->addr = s4;

}

同样的道理,我们需要在头文件声明中,为生命周期进行注解:

Char * volatile @1fun_b( char * volatile @1s3,char * volatile @1s4,STAFF_T * volatile @1staff_b );

需要注意的是,函数返回值只有一个,但结构体借用成员可以有多个,也可能有多个结构体借用参数,当多个“输出”受不同输入影响时,我们需要对注解进行分组:

Void fun_b( char * volatile @1s3,char * volatile @2s4,STAFF_T * volatile @1,2staff_b,STAFF_T volatile @2staff_c );

上面注解的含义很清楚,表示staff_b中可能有两个借用成员,分别受s3和s4影响,但staff_c只受s4影响。

当然,结构体带回和函数返回这两种“输出”也可能同时使用,注解原理是一样的,不再赘述。

除此之外,结构体生命周期还有前面章节中提到的当南向有成员借出后,北向禁止释放,否则就很会出现内存泄露的安全问题。检查器会确保不会出现这种问题。

3.3. 高维借用的生命周期问题

提升指针维度也能输出借用,这也是C语言中常用的方法。来看一个例子:

void fun_a( )

{

char *s1 = (char *)malloc(xxx);

char *s2 = (char *)malloc(xxx);

char *volatile s3 = NULL;// 定义一个借用,但尚未发生借用

fun_b( s1,s2,&s3 );

}

void fun_b( char * volatile s4,char * volatile s5,char ** volatile s6 )

{

if( XXX )

*s6 = s4;

else

*s6 = s5;

}

在这个例子中,s3是一个借用,但开始只有一个定义,并未进行实际借用动作,真正借用动作发生在被调用函数fun_b中。为了能在被调用函数中进行借用,需要将s3再提升一个维度,所以这里发生了第二个借用:

3dad9267a44d3bf883f088fcc7e8d2b7.png

根据前面章节介绍规则可知,&s3是一个永久所有权,因为它存在于栈中的某个地址上,只是没有显式定义变量名,但它仍然可以借出,借用方就是s6。通过s6这个借用,是可写借用,可以让*s6写为需要的值来完成s3的借用动作,比如借用s1:

2684879a32f19a3864c482f1376983c9.png

可见,通过高维借用,也能让函数调用产生借用“输出”,如果这个输出和输入的借用有关,那就需要进行生命周期的注解,如下:

void fun_b( char * volatile @1s4,char * volatile @1s5,char ** volatile @1s6 )

4. 并发多任务

RUST对并发做了很多底层定制,但最后还是用上了ARC机制,这需要编译器和运行时的支持。TC中并不希望做大改动,编译前检查和不用改动编译器以及libc库始终是要考虑的重要目标。我们倾向于认为多任务机制是系统本身的机制,而不是系统编程语言的机制。

4.1. 消息传递

消息传递是很多产品嵌入式开发的主要模式。原理上很清晰,通过消息传递和驱动,一个消息同时只有一个任务接收和处理(广播消息一般为只读借用,无需深入处理),可以将所有权随消息包一起流动。因此,在消息传递模式中,可以很容易的遵守所有权规则。消息本身的实现则由系统提供机制,比如管道,队列等机制。

4.2共享内存

除了消息传递外,在多任务并发环境中,总会遇到临界区问题。而任务并发完全由系统进行实时调度,在编译前无法确认各个任务的运行时间,更无法通过时分来给不同任务进行所有权转移。因此在多任务并发环境中,我们前面制定的编译前所有权规则会受到严重的挑战。当然,如果所有任务对临界区数据只读,那是没有问题的,因为可以同时只读借用给所有任务。但现实情况是大部分情况下,需要对临界区数据进行写操作。

为了应对多任务并发环境下的写操作,TC采用了和RUST不同的做法。TC利用系统已有的同步或互斥机制(后续简称为锁)来对借用进行增强。规则如下:

1) 将临界区数据只读借用给各个任务;

2) 增加注解,将临界区资源和系统锁进行绑定声明;

3) 在获得锁的作用域期间,允许对临界区资源写访问。

相应的绑定注解语法如下:

@BINDING 借用者 TO 上锁代码;

下面来看个例子:

pthread_mutex_t mutex; //定义一把互斥锁,注意:这把锁本身是一个临界区资源

char *share_date;//共享内存,临界资源

void* thread(void *id)

{

int num = *(int *)id;

@BINGING &mutex TO pthread_mutex_lock(&mutex)//绑定注解

@BINDING share_date TO pthread_mutex_lock(&mutex)//绑定注解

char const *volatile borrow_data = share_date; /* 只读借用,不要直接使用share_date,会降低安全性,在多个地方直接使用全局量,检查器会告警。此处可写借用也会报错,哪怕只有一个任务,因为注解已经声明这是一个临界区资源。*/

pthread_mutex_lock(&mutex)// 加锁

/* borrow_data此处自动转变为可写借用,可尽情进行处理了 */

/* 如果试图释放borrow_data,检查器报错,因为它只是个借用 */

pthread_mutex_unlock(&mutex); // 解锁

return NULL;

}

void main( )

{

int num_thread = 3;

share_date = (char *)malloc(100);

pthread_t *pt = (pthread_t *)malloc(sizeof(pthread_t) * num_thread);

int * id = (int *)malloc(sizeof(int) * num_thread);

if (pthread_mutex_init(&mutex, NULL) != 0)

{

goto end;

}

for (int i = 0; i < num_thread; i++)

{

id[i] = i;

if (pthread_create(&pt[i], NULL, thread, &id[i]) != 0)

{

goto end;

}

}

for (int i = 0; i < num_thread; i++)

{

pthread_join(pt[i], NULL);

}

pthread_mutex_destroy(&mutex);

end:

free(share_date);

// 只需要释放全局所有权,pt,id在作用域结束会自动释放

return;

}

总的来说,TC采用了比RUST更加简单、灵活和系统自适应的并发内存安全机制,达到了编译前检查,不改运行时的目标。当然,手工注解还是带来了一点麻烦,这可能是整个方案中唯一需要程序员手工完成的事,比RUST要少很多。

—— 尚未开发出检查器,所以未验证最终是否可行,欢迎大家批评指正

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值