d系统编程内存安全
系统语言中的内存安全第3部分
本系列中的第一篇,演示如何使用新的DIP1000规则让切片和指针内存安全的引用栈.本系列的第二篇介绍了,ref存储类及DIP1000如何与(类,构和联等)聚集类型一起工作.
中文第1篇
中文第2篇
目前,该系列故意避开模板和自动功能.这使得前两篇文章更简单,因为不必处理推导函数属性,我叫"自动推导属性".
但是,在D代码中,自动函数和模板都很常见,因此如果不解释这些特征如何与语言更改配合使用,DIP1000系列就不完整.推导函数属性是避免所谓的"属性汤"的最重要工具,“属性汤”:函数用多个属性修饰,降低了可读性.
还将深入挖掘不安全代码.本系列的前两篇文章侧重于scope属性,但本篇更侧重于属性和内存安全.由于DIP1000最终是关于内存安全的,因此无法绕过.
避免重复属性
推导函数属性表明,语言会分析函数的主体,并在适用时自动添加@safe,pure,nothrow和@nogc属性.它还试添加域(scope)或中域(return scope)属性到参数,及添加return ref到的ref(不加,不会编译它)参数.
某些属性永远不会推导.如,编译器不会插入ref,lazy,out或@trusted属性,因为有时明确不需要它们.
有多种方法可打开推导函数属性.一是在函数签名中省略返回类型.注意,auto关键字不需要它.auto是未指定返回类型,存储类或属性时使用的占位符关键字.
如,不解析:
half(int x) { return x/2; }
所以用
auto half(int x) { return x/2; }
替代.但是也可这样:
@safe half(int x) { return x/2; }
就像使用了auto关键字一样,会推导出来其余的(pure,nothrow和@nogc)属性.
第二种启用推导属性方法是模板函数.用半示例,可如下完成:
int divide(int denominator)(int x) { return x/denominator; }
alias half = divide!2;
D规范没有说模板必须有参数.空参数列表可打开推导属性:
int half()(int x) { return x/2; }
甚至不需要调用点的模板实例化语法,就可调用此函数,如,不需要half!()(12),因为half(12)就可编译.
在另一个函数中存储函数也可启用推导属性.这些函数叫嵌套函数,这里.不仅,在另一个函数中直接嵌套的函数上启用推导,而且,在函数内的类型或模板中嵌套的大多数内容上也启用推导.例:
@safe void parentFun()
{
// 这是自动推导
int half(int x){ return x/2; }
class NestedType
{
// 这是自动推导
final int half1(int x) { return x/2; }
//这不是自动推导的;这是虚函数,在继承类中,编译器不知道是否有`不安全`覆盖
int half2(int x) { return x/2; }
}
int a = half(12); // 按@safe推导,工作.
auto cl = new NestedType;
int b = cl.half1(18); // 按@safe推导,工作.
int c = cl.half2(26); // 错误.
}
嵌套函数的缺点是,它们只能按词法顺序使用(调用点必须在函数声明下方),除非嵌套函数和调用都在父函数内的同一构,类,联或模板中,或依次都在父函数中.另一个缺点是不适合统一函数调用语法,ufcs.
最后,总是为函数字面(也叫λ函数)启用推导属性.
可这样定义减半函数:
enum half = (int x) => x/2;
并完全如常调用.但是,该语言不按函数对待它.而是按函数指针.表明,在全局域内,使用枚举(enum)或不变(immutable)而不是动很重要.否则,可从程序中其他位置更改λ,且纯函数无法访问它.
极少数时,该可变性可能是可取的,但一般,它是反模式(如一般的全局变量).
推导的局限
最少手动输入或最大属性膨胀都不是明智的目标.
自动推导的主要问题是代码中的细微更改,可能不受控制的打开和关闭推导属性.要了解何时重要,及会推导出什么.
编译器一般会尽量推导@safe,pure,nothrow和@nogc属性.如果函数可有这些,它几乎总是会有.规范说递归是异常:除非明确指定调用自身的函数不应是@safe,pure,nothrow.但是在测试中,我发现对递归函数,可推导这些属性.表明,人们正在努力使推导递归属性工作,并且它已部分工作.
在函数参数上推导scope和return不太可靠.一般,它会起作用,但编译器很快就会放弃.推导引擎越智能,编译所需时间越多,因此当前设计策略是仅在最简单时才推导这些属性.
编译器在哪推导?
D应养成此习惯:“如果错误地使函数,不安全,不纯,抛,垃集或逃逸,会怎样?如果答案是"立即发出编译器错误”,则自动推导没问题.另一方面,如果答案是"更新我正在维护的该库时,会破坏用户代码".此时,请手动加注解.
除了可能丧失作者准备应用的属性外,还有另一个风险:
@safe pure nothrow @nogc firstNewline(string from)
{
foreach(i; 0 .. from.length) switch(from[i])
{
case '\r':
if(from.length > i+1 && from[i+1] == '\n')
{
return "\r\n";
}
else return "\r";
case '\n': return "\n";
default: break;
}
return "";
}
你可能认为,由于是作者手动指定属性的,因此不会有问题.可惜,这是错误的.假设作者决定按所有返回值都是from参数的切片而不是串字面来重写函数:
@safe pure nothrow @nogc firstNewline(string from)
{
foreach(i; 0 .. from.length) switch(from[i])
{
case '\r':
if (from.length > i + 1 && from[i + 1] == '\n')
{
return from[i .. i + 2];
}
else return from[i .. i + 1];
case '\n': return from[i .. i + 1];
default: break;
}
return "";
}
奇怪!以前按域推导from参数,库用户依赖它,但现在按中域推导它,从而破坏了客户代码.
但,对内部函数,自动推导是节省手指及方便阅读的好方法.注意,只要在@safe函数或单元测试中,显式使用该函数,就可依赖@safe属性的自动推导.
在自动推导函数中,如果做了些潜在不安全事情,它会按@system而不是@trusted推导.从@safe函数调用@system函数会发出编译器错误,表明此时可安全依赖自动推导.
对内部函数,有时手动指定属性仍然有意义,因为违反这些属性时,手动属性会生成更好的错误消息.
模板呢
总是对模板函数启用自动推导.如果库接口需要公开一个怎么办?尽管很丑陋,可阻止推导:
private template FunContainer(T)
{
// 未自动推导
// (仅同名模板函数是)
@safe T fun(T arg){return arg + 3;}
}
//自动推导自身,但由于调用函数不是,只推导`@safe`
auto addThree(T)(T arg){return FunContainer!T.fun(arg);}
但是,模板一般根据编译时参数决定应有哪些属性.可用元编程按模板参数指定属性,但工作量太大,难以阅读,且很容易像依赖自动推导一样易出错.
更实用方法是,测试函数模板是否推导出期望属性.每次更改函数时,此不必也不应手动测试它.相反:
float multiplyResults(alias fun)(float[] arr)
if (is(typeof(fun(new float)) : float))
{
float result = 1.0f;
foreach (ref e; arr) result *= fun(&e);
return result;
}
@safe pure nothrow unittest
{
float fun(float* x){return *x+1;}
//用静态数组可确保按`域或中域`推导`arr`参数
float[5] elements = [1.0f, 2.0f, 3.0f, 4.0f, 5.0f];
//无需实际处理结果.想法是,既然可编译,就证明乘法结果是`@safe,pure,nothrow`,且参数是`域或中域.`
multiplyResults!fun(elements);
}
感谢D的编译时自省能力,还测试了不需要的属性:
@safe unittest
{
import std.traits : attr = FunctionAttribute,functionAttributes, isSafe;
float fun(float* x)
{
//使函数依赖抛和垃集
if (*x > 5) throw new Exception("");
static float* impureVar;
// 使函数不纯
auto result = impureVar? *impureVar: 5;
// 使参数非域
impureVar = x;
return result;
}
enum attrs = functionAttributes!(multiplyResults!fun);
assert(!(attrs & attr.nothrow_));
assert(!(attrs & attr.nogc));
//检查是否接受域参数.注意,此检查,对`@system`函数不管用.
assert(!isSafe!(
{
float[5] stackFloats;
multiplyResults!fun(stackFloats[]);
}));
//如果测试函数有属性错误,最好用阳性测试等类似方法确保上述测试会失败
assert(attrs & attr.safe);
assert(isSafe!(
{
float[] heapFloats;
multiplyResults!fun(heapFloats[]);
}));
}
在运行单元测试之前,如果编译时想要断定失败,则,在每个断定前添加static关键字就行.通过转换单元测试为普通函数,甚至可在非单元测试构建中显示这些编译器错误,如用
private @safe testAttrs()
//替换
@safe unittest
//.
实弹演习:@system
记住D是一种系统语言.在大多数D代码中,可很好地避免内存错误,但如果禁止D按与C或C++相同进入低级并绕过类型系统,那么就不是D了:指针位算术,直接读写硬件端口,在原始字节块析构等,D旨在完成所有这些工作.
不同于,C和C++中的,只要一个错误就可破坏类型系统,并任意位置都可能使未定义行为.D只有在不在@safe函数中,或使用(如-release或-check=assert=off等)危险的编译器开关时才会有危险(禁止断定失败是未定义行为(ub)),即使这样,语义也不太容易UB.如:
float cube(float arg)
{
float result;
result *= arg;
result *= arg;
return result;
}
这是语言无关函数,可用C,C++和D编译.有人准备计算arg的立方,但忘记用arg初化结果.在D中,尽管这是@system函数,但不会有危险.没有初化值表明,结果(result)默认初化为NaN(非数字),这使结果也是NaN,这是第一次使用此函数时的明显"错误"值.
但是,在C和C++中,不初化局部变量表明读取它是(无较小异常)未定义行为.函数甚至不处理指针,但根据标准,就像如下,调用该函数,
*(int*) rand() = 0XDEADBEEF;
一切都是由于很简单的错误.虽然许多启用了警告的编译器都会抓此警告,但并非所有编译器都会抓此警告,并且这些语言有大量类似示例,即使有警告也没用.
在D中,即使显式用如下
float result = void;
来空初化,也只表明未定义函数返回值,而不会调用函数.因此,即使用此初化器,也可用@safe注解函数.
尽管如此,对关心内存安全的人来说,假设D的@system代码足够安全到可为默认模式,也是不对的.两个示例会演示可能情况.
未定义行为可做什么
有人认为"未定义行为",仅表明"错误行为"或运行时崩溃.虽然一般是这样,但未定义行为比未抓的异常或无限循环危险得多.不同在,你根本无法保证未定义行为会怎样.听起来可能并不比无限循环更糟糕,但第一次进入时,就会发现意外的无限循环.
另一方面,具有未定义行为代码,测试时,可能会执行期望操作,但生产中执行完全不同操作.在生产环境中,即使,用与测试相同标志编译,行为也可能从A编译器版本更改为B编译器版本,或造成代码完全不相关的更改.示例:
//返回异常自身是否在数组中
bool replaceExceptions(Object[] arr, ref Exception e)
{
bool result;
foreach (ref o; arr)
{
if (&o is &e) result = true;
if (cast(Exception) o) o = e;
}
return result;
}
这里想法是,该函数用e替换数组中的所有异常.如果e自身在数组中,则返回真,否则返回假.事实上,测试证实它有效.如下使用该函数:
auto arr = [new Exception("a"), null, null, new Exception("c")];
auto result = replaceExceptions
(
cast(Object[]) arr,
arr[3]
);
转换不是问题吧?无论对象引用类型如何,它们总是具有相同大小,转换异常为Object父类型.它不像包含对象引用以外的内容数组.
可惜,D规范(这里)不是这样看待它的.相同内存位置中,有不同类型的两个类引用(或其它引用),然后赋值其中一个给另一个,这是未定义行为.这正是在
if (cast(Exception) o) o = e;
这里的本质.
如果数组确实包含e参数.由于仅在触发未定义行为时才能返回真,表明编译器可自由优化replaceExceptions,来总是返回假.这是休眠错误,测试中无法发现,但几年后,使用高级编译器的强大优化编译时,会完全搞砸应用.
要求转换来使用函数似乎是明显的警告信号,好的D不会忽视.我不太确定.即使在精细的高级代码中,转换也并不罕见.即使不同意,其他情况也可咬人.去年夏天,在D论坛上出现了该案例:
string foo(in string s)
{
return s;
}
void main()
{
import std.stdio;
string[] result;
foreach(c; "hello")
{
result ~= foo([c]);
}
writeln(result);
}
史蒂文遇见了该问题,他是位长期的D老兵,他自己不止一次讲过@safe和@system.这里和这里.能烧死他的东西都可烧死人.
一般,它就像人们想象的那样工作,并且根据规范很好.但是,如果使用-preview=in(将成为语言默认功能)的编译器开关,这里,并和DIP1000一起启用,则程序开始出现故障.in的旧语义与const相同,但新语义使其成为常域.
由于foo的参数是域(scope),在返回它,或返回其他东西前,编译器假定foo会复制[c].
因此在相同栈位置上,它为"hello"的每个字母分配[c].结果是程序打印["o","o","o","o","o"].至少对我,已很难理解该简单示例中了什么.在复杂代码基中找该错误,可能是一场噩梦.
这两个示例中的基本问题是相同的:未用@safe.如果用了,这两种未定义行为都会使编译错误(可replaceExceptions函数自身可加上@safe,但在使用点不能转换).到现在为止,应该很清楚应该谨慎使用@system代码.
何时前进
然而,迟早有一天,必须暂时降低护栏.下面是很好示例:
//未定义行为:传递非空针,给除了`"\0"`以外的独立符,或如`utf8Stringz`,在`指向符`位置或之后,给不带`\0`的数组,
extern(C) @system pure
bool phobosValidateUTF8(const char* utf8Stringz)
{
import std.string, std.utf;
try utf8Stringz.fromStringz.validate();
catch (UTFException) return false;
return true;
}
此函数,使用Phobos来验证UTF-8串,用另一种语言编写的代码.C是C,它喜欢用以零结尾的串,因此该函数按参数接受指针,而不是D数组.
因此该功能必然是不安全的.无法安全检查utf8Stringz是否指向null或有效的C串.如果指向的符不是"\0",表明必须读下个符,则该函数无法知道该符是否属于为串分配的内存.它只能相信调用代码是正确的.
尽管如此,此函数,正确使用了@system属性.
首先,主要从C或C++调用它.这些语言基本不保证安全.即使@safe函数,也只有在它仅取可在D的@安全代码中创建的那些参数时,才是安全的.
无论属性说什么,按参数传递
cast(const char*) 0xFE0DA1
给函数都是不安全的,且C或C++不验证传递的参数.
其次,该函数清楚地记录了会触发未定义行为的情况.但是,它没有提到,传递无效指针(如前的cast(const char*)0xFE0DA1)是UB,因为除非可另外显示,UB总是默认假定仅具有@system值.
第三,函数小且易于人工审核.函数都不应是不必要的大函数,但保持@system和@trusted函数小且易于审查比平时重要很多倍.
测试可调试@safe函数到相当好形式,但正如之前看到的,未定义行为可不受测试影响.分析代码是UB的唯一通用答案.
参数没有域属性是有原因的.可有它,则不会逃逸串指针.但是,好处不大.调用该函数的代码都必须是@system,@trusted或其他语言,表明总是可传递栈指针.如果错误重构此函数,域可能会提高D客户代码性能,以换取增加未定义行为的可能.
除非可证明该属性帮助解决性能问题,一般不需要该平衡.另一方面,该属性使读者更清楚,不应逃逸串.很难判断在此域是否是明智的.
进一步改进
不明显时,应该记录为什么@system函数是@system的.一般有更安全替代方案,示例函数可用D数组,或本系列上一篇文章中的CString构.为什么不采取替代方案?示例中,可写,对每个选项,ABI会有所不同,在C端,增加复杂性,且(C代码)总是不安全的.
除了可从@safe函数调用它们,@trusted函数类似@system函数,而不能调用@system函数.声明为@trusted时,表明与实际的@safe函数一样,作者已验证了,可用在安全代码中创建的参数使用该函数.需要像@system函数一样(或更)仔细审查它们.
此时,应记录(对其他开发人员,而不是用户),该所有时,该函数如何是安全的.或,如果该函数用起来并不完全安全,且该属性只是临时侵改,那么应该有很大的丑陋警告.
在较大的@safe函数中,定义一个小的@trusted函数来执行不安全的操作,从而不必禁止检查整个函数是很诱人的:
extern(C) @safe pure
bool phobosValidateUTF8(const char* utf8Stringz)
{
import std.string, std.utf;
try (() @trusted => utf8Stringz.fromStringz)().validate();//这里
catch (UTFException) return false;
return true;
}
但记住,要像显性@trusted函数一样记录和审查,父函数,因为封装的@trusted函数可让父函数执行任意操作.此外,由于按@safe标记该函数,乍一看,并不是需要特别注意的函数.因此,如果选择这样用@trusted,则需要可见的警告注解.
最重要的是,不要相信自己!就像任意非平凡大小的代码基都有错误一样,有时,超过10个@system函数,就会包含潜在的UB.
应积极使用D的其余增强功能(即断定,合约,不变量和检查边界),并在生产中保持启用.即使程序完全@safe,也建议这样.此外,对有大量不安全代码项目,应尽量使用如LLVM地址清理器和Valgrind等外部工具.
请注意,许多这些增强工具(包括语言和外部工具中的加固工具)的思想是,一旦检测到故障就崩溃.它减少了未定义行为,造成更严重损害的机会.
要求按随时接受崩溃设计程序.程序绝不能持有大量未保存数据,以至于崩溃时都丢失了.如果控制重要资源,必须可在重启用户或其他进程后,重新获得控制权,或必须有另一个备份程序.在系统编程中不能信任,不能承受崩溃检查的程序.
浙公网安备 33010602011771号