美文网首页Android高阶操作系统
《嵌入式Linux内存与性能详解》笔记2——进程内存优化

《嵌入式Linux内存与性能详解》笔记2——进程内存优化

作者: wipping的技术小栈 | 来源:发表于2020-02-16 17:39 被阅读0次

    一、前言

    我们上文《linux应用程序——内存测量》说了如何测量分析系统内存和进程内存的使用情况。当我们大概知道进程的使用情况后,我们可以针对性地做一些优化,那么本文将简单地说几种内存优化的方法。

    二、堆栈优化

    在讲解内存优化前,这里简单地说明一下一个程序的组成

    • 栈区(stack):由编译器自动分配释放,存放函数的参数值,局部变量的值等。
    • 堆区(heap):一般由程序员分配释放,若程序员不释放,程序结束时可能由 OS 回收。
    • 数据段:初始化的全局变量和静态变量在一块区域,程序结束后由系统释放
    • BSS段:未初始化的全局变量 和未初始化的静态变量所在区域。程序结束后由系统释放。
    • 代码段:存放程序的二进制代码和文字常量

    其中我们讲解 的优化方法,其余的更加深入我们找机会再做讲述

    2.1 堆优化

    提起堆,熟悉的读者应该就会想起 malloc 或者 new 。开辟内存是我们常见的使用手法,但使用得不好容易造成内存泄露和内存空洞,导致系统无法正常回收内存,造成的结果就是系统内存不足,严重的导致程序崩溃退出。

    2.1.1 malloc

    • 当程序 malloc 申请内存时:
      1. 如果堆的地址空间还在 1G 以下,进程会通过系统调用 brk,来让 Linux 内核扩展堆顶的内存空间(堆是向上增长)。
      2. 如果堆的地址空间还在 1G 以上,那么会使用 mmap 映射空闲的物理页内存来获取内存
    • 当使用 free 释放内存时,进程又会通过系统调用 brkummap,来让内核缩减堆的内存空间或去映射
    • 用户态 使用 malloc 申请的内存是以 字节 为单位,而在内核中内存的管理是以 页(4K) 为单位
    • malloc 开辟的内存都是 8字节对齐
    • 太多的 brk系统调用,会使进程的速度变得很慢。
    • malloc 最小的分配长度为 16字节,如果分配 1、2个字节会造成浪费

    使用 malloc 时并不会直接向内核请求内存,而是先通过 glibc 的堆管理,再获得内存。而 glibc 的堆管理使用使用一种结构体。 该结构体来定义 malloc 分配或释放的内存块,如下所示:

    struct malloc_chunk {
        INTERNAL_SIZE_T      prev_size;  /* Size of previous chunk (if free).  */
        INTERNAL_SIZE_T      size;       /* Size in bytes, including overhead. */
        struct malloc_chunk* fd;         /* double links -- used only if free. */
        struct malloc_chunk* bk;
        /* Only used for large blocks: pointer to next larger size.  */
        struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
        struct malloc_chunk* bk_nextsize;
    }
    

    我们以一段小程序为例子来讲解:

    #include <stdlib.h>
    #include <stdio.h> 
    int main() 
    {  
        char* p;
        p = malloc(20);
        strcpy(p, "Hello,world!");
        printf("%s\n", p); 
        printf("%#x\n", *(p-4)); 
        free(p); 
        return 0; 
    }
    
    运行结果

    (p-4) 这个地址,其实记录着 malloc空间 的大小,我们看以下这段内存的分布图,如下:

    内存分布图

    会发现在数据前面还有 2 个 4字节 的空间,在将其与我们前面的结构体对比就会发现前 2 个成员其实是一样的,而后面的 4 个指针成员被复用为内存空间了,为什么呢?
    因为如果当前 chunk 是使用中的,那么 fdbkfd_nextsizebk_nextsize都是无效的,它们都是关于空闲链表的指针,那么这些指针的空间全部被认为是空闲空间,所以直接复用这些成员的内存。

    注意:这里面有两个标志位:

    • P=1:表示上一块正在被使用,这时 prev_size 通常为 0
    • P=0:表示上一块空闲,这时 prev_size 通常为上一块的大小。
    • M=1:表示该内存块通过 mmap 来分配。在分配 大块内存 时,会采用 mmap 的 方式,那么在释放时会由 munmap_chunk 去释放;否则,释放时由 chunk_free 完成。
    • M=0:则表示该内存块不采用 mmap 方式分配。

    我们再看看程序的计算结果。0x19 去掉 3 个比标志位就是 0x18 等于十进制的 24,我们分配了 20 字节,其 8 字节对齐的结果就是 24

    2.1.2 mallopt

    glibc 为我们提供了了 堆管理 ,同时也保留了一些策略设置的接口,让我们自行定义策略。我们可以通过使用 mallopt 来定义完成策略的设置,其原型如下:

    /*
        * param:想要设置的属性项
        * value:设置值,单位为字节
    */
    int mallopt(int param, int value);
    

    param 参数意义如下:

    • M_MXFAST:定义 fastbins小块内存 阀值,小于该阀值的小块空闲内存在释放后将不会去尝试合并,并且会在进程中继续使用(可以提高内存分配的速度)。其 缺省值 为 64,设置为 0 则禁止掉 fastbinsfastbins 生效时,对于一些小块的内存在释放后不去尝试合并以节省 CPU 和内存,因为 glibc 合并内存块需要去跟踪每个内存块。跟踪内存是需要代价的,如果关闭 fastbins 可以则会为每个内存块付出代价,无论内存块的大小。所以设置 M_MXFAST ,需要适度,太大太下都有可能产生不良影响

    • M_MMAP_THRESHOLDlibc大块内存的阀值。申请大于该阀值的内存,内存管理器将使用 mmap 系统调用申请内存;如果申请小于该阀值的内存,内存管理使用 brk系统调 用来扩展堆顶指针。 该阀值缺省值为 128kB

      int main()
      {
          char *p=malloc(1024*521); 
          pid_t pid=getpid();   
          printf("pid = %d\n", pid);  
          pause(); 
          return 0; 
      }
      

      如上所示的代码,会发现进程的 maps 多了一个段,该段的地址不在 ,这就是使用 mmap 开辟 的内存空间,如下所示:

      image.png
    • M_MMAP_MAX:该进程中多使用 mmap 分配地址段的数量。

    • M_TRIM_THRESHOLD:堆顶内存回收阀值,当堆顶连续空闲内存数量大于该阀值时,libc 的内存管理其将调用系统调用 brk,来调整堆顶地址,释放内存。该值缺省为 128k

      int main()
      {
        char *p[11];     
        int i;     
        /* 开辟 11 片内存 */
        for(i = 0; i < 11; i++)     
        {         
            p[i]=(char*)malloc(1024*2);         
            strcpy(p[i], "123");     
        }     
         /* 释放 11 片内存 */
        for(i = 0; i < 11; i++) 
        {         
            free(p[i]);     
        }   
      
        pid_t pid=getpid();     
        printf("pid = %d\n", pid);  
        pause(); 
      
        return 0; 
      }
      

      如上所示的代码,在运行后我们查看 smaps 属性,如下:

      smaps属性
      可见,我们的内存并没有被系统回收,而是一直还存在内存中,如果我们在前面加上下面的代码:
        /* 当堆顶空闲内存大于 1Kb 时回收 */
        mallopt(M_TRIM_THRESHOLD, 1024);
        /* 一定要加入打印才能生效,不然无法生效,笔者也不清楚为何,求大神告知 */
        printf("finished M_TRIM_THRESHOLD\n");     
        /* 设置堆顶无空闲内存块 */
        mallopt(M_TOP_PAD, 0);
      

      运行后查看 smaps 属性如下所示,可见内存已经被回收了

      image.png
    • M_TOP_PAD:该参数决定了当 libc 内存管理器调用 brk 释放内存时,堆顶还需要保留的空闲内存数量。该值缺省为 0

    2.1.2 内存空洞

    内存泄露 是程序常见的一种漏洞,但这种漏洞我们往往能够通过跟踪或者其他手段找出来,但 内存空洞 比较不容易发现,我们看看下面这段代码:

    int main()
    {
        mallopt(M_TRIM_THRESHOLD, 1024);
        printf("finished M_TRIM_THRESHOLD\n");     
        mallopt(M_TOP_PAD, 0);
    
        char *p[11];     
        int i;     
        /* 开辟 11 片内存 */
        for(i = 0; i < 11; i++)     
        {         
            p[i]=(char*)malloc(1024*2);         
            strcpy(p[i], "123");     
        }     
         /* 只释放10片内存 */
        for(i = 0; i < 10; i++) 
        {         
            free(p[i]);     
        }   
    
        pid_t pid=getpid();     
        printf("pid = %d\n", pid);  
        pause(); 
    
        return 0; 
    }
    

    我们通过设置 堆顶空闲内存块大小1KB堆定空闲内存0 的方法去掉因为策略造成影响,与上面不同的是我们开辟 11 块内存,但只释放了 10 块,我们看看运行后的 smaps 的情况:

    smaps
    发现我们释放堆顶下面的 10 块内存并没有被系统回收,与前面的相比,仅仅只是少释放了一块内存。这样的现象就是 内存空洞,也就是 只要堆顶部还有内存在使用,堆顶下方不管释放了多少内存都不会被释放。

    假设我们把开辟的内存放大一点,如下所示:

    int main()
    {
        mallopt(M_TRIM_THRESHOLD, 1024);
        printf("finished M_TRIM_THRESHOLD\n");     
        mallopt(M_TOP_PAD, 0);
    
        char *p[11];     
        int i;     
        for(i = 0; i < 5; i++)     
        {         
            p[i]=(char*)malloc(1024*512);         
            strcpy(p[i], "123");     
        }     
     
        for(i = 0; i < 4; i++) 
        {         
            free(p[i]);     
        }   
    
        pid_t pid=getpid();     
        printf("pid = %d\n", pid);  
        pause(); 
    
        return 0; 
    }
    

    上面的代码使用了 512KB 大小的内存块,那么我们看看运行后的 smaps

    smaps
    因为我们使用了大内存块,所以系统是通过 mmap 来开辟内存的,所以 堆段 的使用情况没有变,而下面新增了一个内存块,可见该段实际使用的物理内存只有 4K,以为我们为其赋值了字符串,所以内核为该段开辟了一个内存页,而其他内存已经被释放掉,这样就没有造成 内存空洞但因为使用了更多的系统调用,其性能会有所下降。

    《嵌入式Linux内存与性能详解》建议:在申请分配内存时,本着就近原则 就可以了,需要的时候才分配内存,不需要了立刻释放。不必去严格的追求申请和释放的顺序。

    2.1.3 内存跟踪

    这里简单的介绍一种排查 内存泄露 的方法,使用 mtrace 来进行内存跟踪。使用步骤如下:

    1. 引入头文件 #include <mcheck.h>
    2. 在需要跟踪的程序中需要包含头文件,而且在 main函数开始 调用函数 mtrace。这样进程后面一切分配和释放内存的操作都可以由 mtrace 来跟踪和分析。
    3. 定义一个 环境变量,用来指示一个文件。该文件用来输出 log 信息。如下的例子:
      export MALLOC_TRACE=mtrace.log
    4. 正常运行程序,此时程序中的关于内存分配和释放的操作都可以记录下来。

    代码如下:

    #include <stdlib.h>
    #include <stdio.h>
    #include <unistd.h> 
    #include <string.h> 
    #include <malloc.h>
    #include <mcheck.h> 
    int main()
    {
        mtrace(); 
    
        mallopt(M_TRIM_THRESHOLD, 1024);
        printf("finished M_TRIM_THRESHOLD\n");     
        mallopt(M_TOP_PAD, 0);
    
        char *p[11];     
        int i;     
        for(i = 0; i < 5; i++)     
        {         
            p[i]=(char*)malloc(1024*512);         
            strcpy(p[i], "123");     
        }     
     
        for(i = 0; i < 4; i++) 
        {         
            free(p[i]);     
        }   
    
        pid_t pid=getpid();     
        printf("pid = %d\n", pid);  
        muntrace(); 
        pause(); 
    
        return 0; 
    }
    
    mtrace.log
    我们开辟了 5 块内存,却释放了 4 块,所以图中一共有 5 个 +号 和 4 个 -号

    2.1.4 堆优化总结

    • 堆内存的小单位为 16Byte,所以尽量减少小块内存的申请,避免内存浪费。
    • 调整 M_MMAP_THRESHOLD,降低 mmap 的门槛,会降低内存空洞的风险,但也会增加系统调用,降低性能。
    • 调整 M_TRIM_THRESHOLD,减少堆顶连续内存门槛,释放更多的堆顶内存。

    以上是从书中获取到的经验,但无论如何还是需要结合实际的工程需求来做优化,希望可以帮到各位读者

    2.2 栈优化

    进程的 是由程序自动来维护的,不需要手动申请和释放。一般情况下,栈是一段线性分布的内存,不会出现碎片问题。它是给函数存放跳转时的环境的。

    2.2.1 栈分配内存

    虽然栈的使用我们一般不需要去操心,但在某些情况下我们可以使用函数 alloc 来获取栈上的内存。同理,这块内存是不需要我们释放的。

    • 使用 alloc 分配的内存跟通过定义变量获取的内存有什么不同呢?

    我们看看下面 2 段程序及其结果:

    int main()
    {
        int n = 0; 
        char* p = NULL; 
        for(int i = 0; i < 1024; i++)
        {
            p = (char*)alloca(1024*5); 
        }
        pid_t pid = getpid();
        printf("pid:%d\n",pid);
        pause();
        return 0;
    }
    
    不赋值.png
    #include <stdlib.h>
    #include <stdio.h>
    #include <unistd.h> 
    #include <string.h> 
    #include <malloc.h>
    #include <mcheck.h> 
    #include <string.h>
    
    int main()
    {
        int n = 0; 
        char* p = NULL; 
        for(int i = 0; i < 1024; i++)
        {
            p = (char*)alloca(1024*5); 
            memcpy(p, "123", 4);
        }
        pid_t pid = getpid();
        printf("pid:%d\n",pid);
        pause();
        return 0;
    }
    
    复制.png

    可以看到,复制前后我们使用的物理内存完全不一样,也就是说使用 alloc 开辟的内存未必就是物理内存,只有在复制后产生 缺页异常 后才能获取内存

    再看看 通过变量获取内存,同理还是看看 2 段程序:

    int main()
    {
        int n = 0; 
        char p[1204*1024*5]; 
        pid_t pid = getpid();
        printf("pid:%d\n",pid);
        pause();
        return 0;
    }
    
    非初始化赋值
    #include <stdlib.h>
    #include <stdio.h>
    #include <unistd.h> 
    #include <string.h> 
    #include <malloc.h>
    #include <mcheck.h> 
    #include <string.h>
    
    int main()
    {
        int n = 0; 
        char p[1204*1024*5] = {0};
        pid_t pid = getpid();
        printf("pid:%d\n",pid);
        pause();
        return 0;
    }
    
    初始化赋值

    可见,通过定义变量获取的内存也是需要对其进行赋值才会有物理内存产生。

    可见也就是说栈的申请不会通过系统调用来获取物理内存。而是随着压栈的操作,栈顶指针访问不存在的物理内存后发生 缺页异常 从而获取内存。因为没有通过系统调用,所以这样的内存获取也比较快捷和方便

    综上所述,所以按照笔者的理解 两者应该是没有区别

    2.2.2 栈释放内存

    那么我们在栈上面申请的物理内存是如何释放的,我们通过一个代码片段看一看:

    int num=10000;   
    int func() 
    { 
        num--; 
        if(num == 0) 
        {     
            return 0; 
        } 
        func(); 
    }
    int main()
    {
        func();
        pid_t pid = getpid();
        printf("pid:%d\n",pid);
        pause();
        return 0;
    }
    
    递归栈使用情况

    我们发现:在函数退出后,栈依旧没被释放掉,还是被进程抢占着。这个发现似乎有点惊讶,我们以为的是在函数退出后,物理内存会被释放掉返回给系统。这意味着 栈内存的使用只会增加不会减少

    毕竟在栈的内存并不通过系统调用,有触发申请跟回收的事件。但栈只是通过 缺页异常 这种硬件异常来获取内存,且我们并没有合适的时间来让栈进行释放。但是这样做的好处就是函数使用栈的时候不用频繁使用内存,对于使用频繁的函数来说这样的效率会更加高。

    2.2.3 栈内存总结

    • 尽量避免在使用频率低的栈空间申请大量内存
    • 尽量避免使用递归函数

    最后附上一张 函数栈帧结构图

    函数栈帧结构图

    三、ELF文件瘦身

    3.1 ELF文件介绍

    ELF文件 是 linux 下的 可执行文件格式,包括 可定位文件(.o)静态库(.a)共享库(.so)核心转储文件(core dump)等。
    那么在查看 ELF文件 时我们有 2 种角度来查看:

    • 链接视图:将 ELF文件 中所需要保存的信息按照信息的类型、格式的不同,分别保存在文件中不同的 节区(section) 。为了访问这些 section,在 ELF文件 中又包含了一个 section 位置的索引,我们称这个索引为 节区头部表(section headers)
    • 执行视图:按照运行时的需要,执行视图把 section 按照规则分类,并且划分为不同的 段(segment)。为了说明 segmentsection 的关系,在 ELF 文件中又引入了 程序头部表(program headers),又称为 段表

    我们可以看看 sectionsegment 之间的关系,如下图所示:

    链接视图与执行视图比较

    3.1.1 ELF文件头分析

    我们使用 readelf -h 来查看 ELF文件头
    ELF文件头 各个字段的含义可以查看文章 《linux应用程序——ELF查看工具》。我们主要简单看一下需要注意的地方

    • Magic:该字段的值为 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
      1. 7f 45 4c 46 表明该文件是 ELF文件
      2. 01 表明该文件运行在32位的操作系统上
      3. 01表明数据采用小端排序,高位在前
    • Size of this headerELF文件头 大小,值为 52
    • Start of program headers:注意这个字段的值为 52,即 程序头部表(program header)ELF文件 中的位置位 52,而 ELF文件头 大小的值也是为 52,说明 程序头部表 在文件中紧挨着 ELF文件头

    3.1.2 ELF文件节区信息

    使用 readelf -S 查看文件的节区信息,如下图所示

    节区信息

    其输出细节可以查看《linux应用程序——ELF查看工具》,这里我们看常用的节区:

    • .hash:符号哈希表
    • .dynsym:动态链接符号表
    • .dynstr:动态链接字符串表
    • .rel.dyn:节区中包含了重定位信息
    • .rel.plt:节区中包含了重定位信息
    • .init:此节区包含了可执行指令,是进程初始化代码的一部分。当程序开始执行时,系统要在开始 调用主程序入口之前(通常指 C 语言的 main 函数)执行这些代码
    • .plt:此节区包含过程链接表(procedure linkage table)
    • .text:此节区包含程序的可执行指令。
    • .fini:此节区包含了可执行的指令,是进程终止代码的一部分。程序正常退出时,系统将安排执行 这里的代码。
    • .rodata:这些节区包含只读数据,这些数据通常参与进程映像的只读代码段。
    • .init_array:进程初始化的所运行的函数指针数组
    • .fini_array:进程退出时的所运行的函数指针数组
    • .dynamic:此节区包含动态链接信息。
    • .got:此节区包含全局偏移表,其与 plt 一起协作完成符号的动态查找
    • .data:节区包含初始化了的数据,将出现在程序的内存映像中。
    • .bss:包含将出现在程序的内存映像中的为初始化数据。根据定义,当程序开始执行,系统将把这 些数据初始化为 0。此节区不占用文件空间。
    • .comment:符号调试信息
    • .debug_aranges:符号调试信息
    • .debug_pubnames:符号调试信息
    • .debug_info:符号调试信息
    • .debug_abbrev:符号调试信息
    • .debug_line:符号调试信息
    • .debug_frame:符号调试信息
    • .debug_str:符号调试信息
    • .note.gnu.arm.ide:符号调试信息
    • .debug_ranges:符号调试信息
    • .shstrtab:节区包含 节区名称
    • .symtab:节区包含一个 符号表
    • .strtab:节区包含一个 字符串表

    Flags属性AAX 的节,在 ELF文件 的分布是连续的,中间没有穿插 AW不需要内存 的节。同样地,所有属性为 AW 的节都顺序的排列在一起。这是为进程运行时划分为 只读代码段可读写数据段 奠定基础。

    3.1.3 ELF文件段信息

    使用 readelf -l 查看文件的段信息,如下图所示

    段信息
    其输出细节可以查看《linux应用程序——ELF查看工具》,这里我们来分析其中常用的段:
    • PHDR:其偏移值为 0x34,十进制为 52,正好是 程序头部表ELF文件 中的位置
    • INTERP:该段给出了运行该文件所需要的解释器,如图所示,该程序使用的解释器为 ld-linux-armhf.so.3。对 ELF文件 来讲,loader的作用是加载 ELF文件 到内存后做 符号解析,直到把程序指针返还给进程
    • LOAD:段的内容将会被加载到内存中,可以根据权限来判断是代码段还是数据段。权限为 只读可执行 ,该段对应于 代码段,权限为 可读可写 的,对应于我们常说的 数据段
    • DYNAMIC:该段给出了此 ELF文件 所需要的 动态链接信息

    再看看下面的 节与段的映射关系,可以看到我们一共有 9 个段,而下面的映射也将一些节区分别映射到了这 9 个段中,有些节区没有映射进来是因为不需要参与到程序的运行。同时可以看到,在 代码段 中的各个 节区 在文件中是 顺序排列 的; 数据段 也是一样。 这是因为在程序运行前期,loader 会将 ELF文件代码段 和 *数据段 使用 mmap 将其映射到内存中,这就要求各段所包含的 节区 在文件中必须是连续的。

    3.1.4 ELF文件动态链接信息

    使用 readelf -d 查看文件的动态信息,如下图所示:

    动态链接信息
    其中 Type 属性为 NEEDEN 的代表程序依赖该 动态库,这样 loader 就知道加载哪些动态库到内存中来支持我们的程序。

    3.1.5 ELF文件瘦身

    在知晓了 ELF文件 的一些构成后,我们可以使用 strip 工具来删除 ELF文件 一些没有用的节区。
    比如先使用 strip 工具直接对程序进行删减:

    strip
    我们还可以加入选项 --remove-section 来指定要删除的节区,比如 comment节区 我们不需要,我们可以使用 strip --remove-section=.comment,如下所示:
    删除指定段

    注意:图中使用 交叉编译链 是因为 strips 无法识别 ARM 平台的程序。

    四、数据段及代码段优化

    4.1 数据段说明

    在我们的程序中,与 数据段 相关的节区有:

    • .init_array
    • .fini_array
    • .dynamic
    • .got
    • .data
    • .bss

    各个节区的作用我们之前已经有简单说过了,下面我们重点关注 .data.bss 段。他们 2 者的作用如下:

    • .bss:主要用来保存 未初始化初始化为 0 的全局变量和静态变量。
    • .data: 主要用来保存初 始化不为 0 的全局变量或静态变量。

    他们之间显著的区别就是变量是否 初始化不为 0

    • 因为初始化不为 0 ,所以程序需要记录他们的初始值。这样就需要在程序中开辟空间来记录他们的值
    • 初始化为 0 ,则将这些变量所在的段映射到一个全 0 的页面即可,所以 .bss段 不占空间

    我们先看看 初始化为 0 的实例代码,如下所示的:

    int bss_array[1024 * 1024] = {0}; 
    int main(int argc, char* argv[]) 
    {
        pause(); 
        return 0; 
    }
    
    bss段大小 maps readelf

    初始化不为 0 的实例代码:

    int data_array[1024 * 1024] = {1}; 
    int main(int argc, char* argv[]) 
    {
        pause(); 
        return 0; 
    }
    
    data段大小 maps readelf

    两段代码都在程序中开辟了 4M大小 的数组,他们的区别如下:

    • 初始化为0
      1. maps 属性中,堆区 的范围是经过拓展的,是大于 4M的
      2. readelf 读取出来的都是数据段大小不符合 4M大小 的要求
    • 初始化不为0
      1. maps 属性中,堆区 的范围并没有经过拓展。
      2. maps 属性中的 数据段 大小符合 4M 的要求
      3. readelf 读取出来的都是数据段大小符合 4M大小 的要求

    Loader 在处理数据段时,其首先根据 FileSiz 的大小来创建 数据段初始化为 0 进程的数据段只有 4k,而 初始化不为 0进程的数据段就有 4M 多。 初始化为 0 进程为了容纳 bss段4M大小 的数据,loader 在进程的 堆段 中申请出足够的内存来容纳它。所以我们发现在 初始化为 0 进程中,虽然我们没有申请内存,但却有了一个 4M 的堆段。 初始化不为 0进程则是数据段就直接开辟为 4M大小 以供程序使用

    某种程度上来说,使用 bss段 自动使用了 malloc 来帮我们自动开辟内存了,所以在我们访问此段时是不会产生 缺页异常 的。而使用 data段 则会触发并分配物理内存

    以上是只有 bss段data段 的情况,如果同时有 bss段data段,代码如下:

    #include <stdlib.h>
    #include <stdio.h>
    #include <unistd.h> 
    #include <string.h> 
    #include <malloc.h>
    #include <mcheck.h> 
    #include <string.h>
     
    int data_array[1024*4 - 2] = {1}; 
    int bss_array[4] = {0};
    int main(int argc, char* argv[]) 
    {
        pid_t pid = getpid();
        printf("pid:%d\n",pid);   
        printf("data_array = %p, bss_array = %p\n", data_array, bss_array);
        pause(); 
        return 0; 
    }
    
    运行结果 maps

    我们发现 2 个不同段的数组,其地址居然都在数据段。在这种情况下, loader数据段 中的 .data 节进行填充后,使用 .bss 节数据对 剩余的字节进行填充,并将这些剩余的字节全部填充为 0,这同时会造成对后一个页面的 写操作,从而产生 dirty page

    4.2 数据段优化

    关于 数据段 的优化,并不是针对进程本身,而是针对 动态库。如果我们在编写动态库时能尽量优化 数据段 ,那么可以节省比较多的内存空间。这里有一些方法步骤可供参考:

    • 尽可能的减少全局变量和静态变量。 我们可以使用“nm”来列出所有在.data 和.bss 节的变量
    • 只读的全局变量,加上 const,从而使其转移到 代码段。因为代码段的共享特性,可以节省内存。
    • 对频繁引用的字符串可以将其定义在 代码段
      *不要在 头文件 定义变量(但可以声明,比如使用 extern 声明模块内部的变量),除了能够有效减少编译错误外还能减少因为多个重复引用而浪费内存。

    五、代码段

    代码段在内存优化方面作用并不大,因为代码段是整个进程共享的,而且在内存不足的时候会回收。这里简要地说几点重要的。

    5.1 删除冗余代码

    我们尽可能的删除不必要的代码及变量,因为冗余代码有可能会导致物理内存的使用增加。比如我们定义一变量,却不使用它。在程序运行的时候,因为这种变量的存在,可能会导致 缺页异常 发生的概率增加,因此进程的运行效率会下降。下面 2 个编译选项有助于显示此类冗余代码:

    • -Wunused:检查无用代码
    • --Wunreachable-code :检查从未使用的代码

    5.2 使用 Thumb指令

    我们知道 Thumb指令 是一种指令高级密集的指令集,它与 ARM指令集 之间的关系大致如下:

    • 在功能相同的情况下,Thumb指令集ARM指令集 占用的内存空间小,因为它是一种 16位 的指令集。
    • 所有的 ARM指令 多有对应的 Thumb指令,两者在一定的情况下可以互相调用。
    • 大多数 Thumb指令 是无条件执行的,而大多数 ARM指令 是有条件执行的

    高密度就以为在同等功能的情况下,Thumb指令集 要使用更多的指令来完成功能,从而有可能导致运行的时间比较长,有说法 Thumb指令集ARM指令集 之间的效率关系如下:

    • Thumb 代码所需的存储空间约为 ARM 代码的 60%-70%
    • Thumb 代码使用的指令数比 ARM 代码多约 30%-40%
    • 若使用 32位 的存储器,ARM 代码比 Thumb 代码 快约 40%
    • 若使用 16位 的存储器,Thumb 代码比 ARM 代码快约 40%-50%
    • ARM 代码相比较,使用 Thumb 代码,存储器的 功耗 会降低约 30%

    综上所述,如果系统的 性能 有较高要求,应应该使用 32位存储系统ARM指令集。如果系统的 成本及功耗有较高要求,则应使用 16位存储系统Thumb指令集

    我们可以添加编译选项 -mthumb ,让编译器使用 Thumb指令 来编译程序。

    当然了,在一些极端场合,可能我们不得不同时使用 ARM指令集Thumb指令集,编译器是支持同时使用 2 种指令集的。但是有些情况是只有在 ARM状态下才能执行

    • 使用或者禁止异常中断 只能在 ARM状态 下完成
    • ARM处理器 总是从 ARM状态 开始执行。按照笔者理解 main函数 所在的文件需要用 ARM指令集编译

    下面是笔者的例子,与 《嵌入式Linux内存与性能详解》 描述不符,但作为笔记还是记下来。

    /* thumb.c */
    #include <stdio.h>
    void func_thumb()
    {
        printf("I'm thunmb\n");
    }
    
    /* arm.c */
    #include <stdio.h>
    extern void func_thumb();
    void func_arm()
    {
        func_thumb();
        printf("I'm arm\n");
    }
    
    int main()
    {
        func_arm();
        
    }
    

    第一次分别使用下面的指令进行编译:

    arm-linux-gnueabihf-gcc -mthumb -o thumb.o -c thumb.c
    arm-linux-gnueabihf-gcc -o arm.o -c arm.c
    arm-linux-gnueabihf-gcc -o arm_and_thumb arm.o thumb.o

    然后在使用 objdump 反编译 arm_and_thumb,返现他们之间的调用并没有 Thumb状态 和 **ARM状态之间切换,其汇编如下图所示:

    反汇编
    可以看到在各个函数之间的跳转并没使用 bx 或者 blx 这样的会更改状态的指令
    注:关于 bx 或者 blx 请各位读者自行查阅资料

    在笔者经过一番查找后发现有一个编译选项 -mthumb-interwork,其意义是 生成的目标文件,允许在ARM和Thumb之间交叉调用
    第一次分别使用下面的指令进行编译:

    arm-linux-gnueabihf-gcc -mthumb-interwork -mthumb -o thumb.o -c thumb.c
    arm-linux-gnueabihf-gcc -mthumb-interwork -o arm.o -c arm.c
    arm-linux-gnueabihf-gcc -mthumb-interwork -o arm_and_thumb arm.o thumb.o

    反编译之后情况依旧相同,与书中描述不符。这里笔者猜想可能是编译器不支持该选项,如果有读者可以解答该问题,还请不吝赐教。

    六、参考链接

    《嵌入式Linux内存与性能详解》
    malloc_chunk边界标记法和空间复用https://blog.csdn.net/sim120/article/details/39373229
    对于GNU编译器中-mthumb-interwork和-mthumb的理解https://blog.csdn.net/moqingxinai2008/article/details/53909051

    相关文章

      网友评论

        本文标题:《嵌入式Linux内存与性能详解》笔记2——进程内存优化

        本文链接:https://www.haomeiwen.com/subject/dtspfhtx.html