Android高阶操作系统

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

2020-02-16  本文已影响0人  wipping的技术小栈

一、前言

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

二、堆栈优化

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

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

2.1 堆优化

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

2.1.1 malloc

使用 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都是无效的,它们都是关于空闲链表的指针,那么这些指针的空间全部被认为是空闲空间,所以直接复用这些成员的内存。

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

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

2.1.2 mallopt

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

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

param 参数意义如下:

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 堆优化总结

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

2.2 栈优化

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

2.2.1 栈分配内存

虽然栈的使用我们一般不需要去操心,但在某些情况下我们可以使用函数 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 种角度来查看:

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

链接视图与执行视图比较

3.1.1 ELF文件头分析

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

3.1.2 ELF文件节区信息

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

节区信息

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

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

3.1.3 ELF文件段信息

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

段信息
其输出细节可以查看《linux应用程序——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 数据段说明

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

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

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

我们先看看 初始化为 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大小 的数组,他们的区别如下:

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 数据段优化

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

五、代码段

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

5.1 删除冗余代码

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

5.2 使用 Thumb指令

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

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

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

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

当然了,在一些极端场合,可能我们不得不同时使用 ARM指令集Thumb指令集,编译器是支持同时使用 2 种指令集的。但是有些情况是只有在 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

上一篇 下一篇

猜你喜欢

热点阅读