Rust是如何避开C开发中的隐形坑?

C常见的代码安全风险

软件开发的Bug定位在软件开发中一直是一个热门的话题,在日常开发中绝大部分的Bug也由内存问题负责,其他则因为操作溢出、逻辑错误、时序等问题引发。据微软的安全中心部门的统计报告显示70%的内存Bug,谷歌安全也在博客中显示在安卓bug中由90%的因内存问题触发。在日常的嵌入式开发中,内存问题尤为关注,一旦出现内存bug,可能导致嵌入式系统轻易崩溃,也因此嵌入式C工程师在写完代码后需要花几倍于写代码的时间去调试定位Bug。

那么在处理这些常见的潜在风险时,Rust与C分别是如何处理的呢?

C/Rust 代码处理对比

操作溢出

对于基础的数据类型来说,每个类型都有有效范围,如果代码中的操作对数据的操作无意超过了该数据的范围,则可能会引起一些隐藏的风险。对于C/C++来说,没有大部分的操作中,没有强制检查数据操作结果是否溢出,从而将溢出的风险可能转移到后续的其他关联的逻辑。


for (int8_t i = 0; i < 256; i++) {     
    // 进入了死循环 
}

// 移位溢出 
uint16_t Number = 0;                         
for (size_t i = 0; i < 32; i++) { 
    Number <<= 1
}

int8_t ch = 128;         
if (ch > 0) {    
    // 可能不能进入 
}

uint8_t x = 255;     
// 溢出 
x += 1

Rust则在数据溢出方面有着严格的限制,通常大部分的溢出能能在编译阶段直接报错,运行时候的溢出逻辑通则panic终止(Debug模式下),有效保证立即发现风险点,能精确准确到问题的根源。

let val: i8 = 128;     //编译报错

let mut idx: i8 = 0  
loop {  
    idx += 1;
    if idx >=  128 {   // 编译报错 
        break;    
    }
}

let mut val: u32 = 0xffff_fffe;           
let mut inc: u32 = fun();   
val += inc;           // 运行时候如果溢出则 panic终止    

数组写溢出

数组写溢出的问题在C项目可能经常有发生,通常索引的值并不那么明显,在数组修改时可能操作会正常返回,但一旦写到未知的区域,则可能引起连锁的安全事故,且不易定位。通常需要专业的工具如ASan等去实时监控写数组溢出异常,非常耗费内存资源。在嵌入式资源有限的硬件中,通常无法部署。

char buf[] = "hello world";     
char buf2[20];   
int idx = fun();            
buf[idx] = 'H'// 索引超限,但运行可能修改位置位置的内容程序可继续运行         

Rust则利用天生支持索引检测,且运行高效,不会耗费过多的资源。在常量索引中通常在编译时候就能检测出并报错,运行时则会检测索引有效值,非法则panic停止运行,严谨的检查将每个细微的问题准确暴露出来。当然也容许在生产环境中关闭panic避免影响业务。

let mut buf: [u810] = [010];   
buf[12] = 1;                    // 编译报错   
buf[fun()];                     // 运行可能panic到此行,程序终止
let mut buf:[u810] = [010]; // 使用迭代器遍历数组,高效简洁且安全
for v  in &mut buf {       
    *v = 1;         
}

数组访问溢出

数据访问超限通常在C开发中也存在,虽然可能没有写溢出严重性,但也可能给生产带来潜在的Bug风险,需要大量的时间去分析定位。

char buf[] = {'1''2''3''0'};   //没有结束字符'\0'   
size_t len = strlen(buf);            // 返回错误的长度       
char ch = buf[5];                    // 返回非法值     

Rust在数组的访问也检查严格,通常使用迭代器进行读或写操作,代码既简洁易懂,且运行效率与手写循环无差别,这也是Rust常用和推荐的遍历方法。

let mut buf: [u810] = [010];    
let v = buf[12]                 // 编译报错   
let v = buf[fun()];             // 运行可能panic到此行,程序终止




let mut buf:[u810] = [010]; // 使用迭代器遍历数组,高效简洁且安全
for v  in &mut buf {  
    println("{}", v);         

指针对齐错误

在ARM处理器中,通常对于数据的取值需要严格的地址对齐,否则会引起总线中断死机。在C开发中经常会遇到一些日志打印问题,为了查看一些地址的值,可能误触发死机。

char buf[] = "1234";
printf ("%d", (int)*(buf + 0));  // 地址可能未对齐,可能进入总线错误中断

struct test_t {  
    uint16_t a;    
    uint8_t b;  
    uint32_t c;
}

struct test_t t;                   
uint32_t b = (uint32_t)(uint32_t *)(&t.b); 

Rust有这很多高级语言的特性,如打印函数通常在编译时候会自动获知数据的类型,无需手动指定打印的类型,既节省了编码量又安全。在数据地址分配方面会自动优化地址,满足安全运行逻辑。

let c = "1234";   
println("{}", c);

struct Test {       //编译时通常顺序会重新优化,节省空间或注重安全
    v16: u16;     
    v8: u8;
    v32: u32;
}

内存申请和释放问题

嵌入式C中通常避免少使用内存分配的接口,主要原因是因为容易引起内存问题,但在一些特殊业务中又不得不大量使用,因此在一些大型工程中,定位内存泄露、内存释放两次、操作已释放的内存、释放非法内存等问题非常困难,通常需要经验丰富的嵌入式工程师才能解决,为避免这种问题,通常不得不采取静态代码分析如cppcheck``splint,sanitize等工具去扫描代码,但是一些内存问题即使通过静态代码分析工具也很难检测出,只能在运行时候出问题才能排查。

uint8_t *ptr = malloc(1024);  
free(ptr);  
free(ptr);                  // 释放两次           
*(ptr + 1)     = 1;         // 操作已经释放的问题            

uint8_t *buf = malloc(1024); //不释放    
buf[1024] = 1;              // 操作非法内存   

uint8_t *vptr = 232323;  
free(vptr)                  // 非法释放内存                            

Rust则在在内存安全方面有着无与伦比的优势,利用变量的生命周期原理自动释放空间,高效利用内存。这一点在有限的嵌入式资源中尤为重要。让嵌入式开发如Go、Java等高级语言一样简单,无需过多关注内存的申请和释放问题,只专注于业务功能逻辑,Rust编译器会保证你的代码不会在内存上翻车。

let buf:[u8;10];              
printfln!("{}", buf);   //编译报错,使用未初始化的内存                 

{
    let mut vec = Vec::new(); 
}
vec.push(1);             // 编译报错,vec已经释放,不给你再操作


学习C很简单,成为大师很难

学习Rust很难,成为大师很简单


请使用浏览器的分享功能分享到微信等