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.但是在测试中,我发现对递归函数,可推导这些属性.表明,人们正在努力使推导递归属性工作,并且它已部分工作.

函数参数上推导scopereturn不太可靠.一般,它会起作用,但编译器很快就会放弃.推导引擎越智能,编译所需时间越多,因此当前设计策略是仅在最简单时才推导这些属性.

编译器在哪推导?

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按与CC++相同进入低级并绕过类型系统,那么就不是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,这是第一次使用此函数时的明显"错误"值.

但是,在CC++中,不初化局部变量表明读取它是(无较小异常)未定义行为.函数甚至不处理指针,但根据标准,就像如下,调用该函数,

*(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串,用另一种语言编写的代码.CC,它喜欢用以零结尾的串,因此该函数按参数接受指针,而不是D数组.
因此该功能必然是不安全的.无法安全检查utf8Stringz是否指向null或有效的C串.如果指向的符不是"\0",表明必须读下个符,则该函数无法知道该符是否属于为串分配的内存.它只能相信调用代码是正确的.

尽管如此,此函数,正确使用了@system属性.
首先,主要从C或C++调用它.这些语言基本不保证安全.即使@safe函数,也只有在它仅取可在D的@安全代码中创建的那些参数时,才是安全的.
无论属性说什么,按参数传递

cast(const char*) 0xFE0DA1

函数都是不安全的,且CC++验证传递的参数.
其次,该函数清楚地记录了会触发未定义行为的情况.但是,它没有提到,传递无效指针(如前的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等外部工具.

请注意,许多这些增强工具(包括语言外部工具中的加固工具)的思想是,一旦检测到故障就崩溃.它减少未定义行为,造成更严重损害的机会.

要求按随时接受崩溃设计程序.程序绝不能持有大量未保存数据,以至于崩溃时都丢失了.如果控制重要资源,必须可在重启用户或其他进程后,重新获得控制权,或必须有另一个备份程序.在系统编程中不能信任,不能承受崩溃检查程序.

posted @   zjh6  阅读(39)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示