程序设计基石与实践系列之类型提升、内存分配,数组转指针、打桩和矢量变换

英文出处:Peter Fačka: Guide to Advanced Programming in C

C语言可用于系统编程、嵌入式系统中,同时也是其他应用程序可能的实现工具之一。 当你对计算机编程怀有强烈兴趣的时候,却对C语言不感冒,这种可能性不大。想全方位地理解C语言是一件极具挑战性的事。

Peter Fačka 在2014年1月份写下了这篇长文,内容包括:类型提升、内存分配,数组转指针、显式内联、打桩(interpositioning)和矢量变换。

整型溢出和类型提升

多数C程序员以为,整型间的基本操作都是安全的。事实上,整型间基本操作也容易出现问题,例如下面的代码:

int main(int argc, char** argv) {
    long i = -1;

    if (i < sizeof(i)) {
         printf("OK\n");
    }
    else {
         printf("error\n");
    }

    return 0;
}
上述代码中,变量  被转换为无符号整型。这样一来,它的值不再是-1,而是  size_t 的最大值。变量 i的类型之所以被转换,是因为  sizeof 操作符的返回类型是无符号的。具体参见C99/C11标准之常用算术转换一章:

“If the operand that has unsigned integer type has rank greater or equal to the rank of the type of the other operand, then the operand with signed integer type is converted to the type of the operand with unsigned integer type.”

若无符号整型类型的操作数的转换优先级不低于另一操作数,则有符号数转为无符号数的类型。

C标准中,size_t 被定义为不低于16位的无符号整型。通常 size_t 完全对应于 long。这样一来,int 和 size_t 的大小至少相等,可基于上述准则,强转为无符号整型。(译者注:本人印象深刻的相关问题是“if(-1U > 0L)”在32、64位机器上的判断结果分别是什么,为什么;除long long外,long 类型在涉及兼容性的产品代码中应被禁用)

这个故事给了我们一个关于整型大小可移植性的观念。C标准并未定义shortintlonglong long 的确切大小及其无符号形式。标准仅限定了它们的最小长度。以x86_64架构为例,long 在Linux环境中是64比特,但在64位Windows系统中是32比特。为了使代码更具移植性,常见的方法是使用C99的 stdint.h 文件中定义的、指定长度的特殊类型,包括 uint16_tint32_t 等。此文件定义了三种整型类型:

  • 有确切长度的:uint8_t uint16_t,int32_t等
  • 有长度最小值的最短类型:uint_least8_t,uint_least16_t,int_least32_t等
  • 执行效率最高的有长度最小值的类型:uint_fast8_t,uint_fast16_t,int_fast32_t等

但不幸的是,仅依靠 stdint.h 并不能根除类型转换的困扰。C标准中“整型提升规则”中写道:

若int的表达范围足以覆盖所有的基础类型,此值将被转换为int;否则将转为unsigned int。这就叫做整型提升。整型提升过程中,所有其他的类型保持不变。

下述代码在32位平台中将返回65536,在16位平台上返回0:

uint32_t sum()
{
    uint16_t a = 65535;
    uint16_t b = 1;
    return a+b;
}
无论C语言实现中,是否把未修饰的char看做有符号的,整型提升都连同符号一起把值保留下来。

如何实现char类型通常取决于硬件体系或操作系统,常由其平台的ABI(应用程序二进制接口)指定。如果你愿意自己尝试的话,char会被转为signed char,下述代码将打印出-128和-127,而不是128和129。x86架构中可用GCC的-funsigned-char参数切换到强制无符号提升。

char c = 128;
char d = 129;
printf("%d,%d\n",c,d);

内存申请和管理

malloc, calloc, realloc, free

使用malloc分配指定字节大小的、未初始化的内存对象。若入参值为0,其行为取决于操作系统实现,或者说,这是C和POSIX标准均未定义的行为。

若请求的空间大小为0,则结果视具体实现而定:返回值可以是空指针或特殊指针。

malloc(0) 通常返回有效的特殊指针。或者返回的值可成为 free 函数的参数,且函数不会错误退出。例如 free 函数对NULL指针不做任何操作。

因此,若空间大小参数是某个表达式的结果的话,要确保测试过整型溢出的情况。

size_t computed_size;

if (elem_size && num > SIZE_MAX / elem_size) {
    errno = ENOMEM;
    err(1, "overflow");
}

computed_size = elem_size*num;
一般说来,要分配一个元素大小相同的序列,可考虑使用  calloc 而非用表达式计算大小。同时  calloc 将把分配的内存初始化为0。像往常一样使用  free 释放分配的内存。

realloc 将改变已分配内存对象的大小。此函数返回一个指针,指针可能指向新的内存起始位置,内存大小取决于入参中请求的空间大小,内容不变。若新的空间更大,额外的空间未被初始化。若 realloc 入参中,指向旧对象的指针为NULL,并且大小非0,此行为等价于 malloc。若新的大小为0,且提供的指针非空,此时 realloc 的行为依赖于操作系统。

多数实现将尝试释放对象内存,返回NULL或与malloc(0)相同的返回值。例如在Windows中,此操作会释放内存并返回NULL。OpenBSD也会释放内存,但返回的指针指向的空间大小为0。

realloc 失败时会返回NULL,也因此断开与旧的内存对象的关联。所以不但要检查空间大小参数是否存在整型溢出,还要正确处理 realloc 失败时的对象大小。

#include <stdio.h>
#include <stdint.h>
#include <malloc.h>
#include <errno.h>

#define VECTOR_OK            0
#define VECTOR_NULL_ERROR    1
#define VECTOR_SIZE_ERROR    2
#define VECTOR_ALLOC_ERROR   3

struct vector {
    int *data;
    size_t size;
};

int create_vector(struct vector *vc, size_t num) {

    if (vc == NULL) {
        return VECTOR_NULL_ERROR;
    }

    vc->data = 0;
    vc->size = 0;

    /* check for integer and SIZE_MAX overflow */
    if (num == 0 || SIZE_MAX / num < sizeof(int)) {
        errno = ENOMEM;
        return VECTOR_SIZE_ERROR;
    }

    vc->data = calloc(num, sizeof(int));

    /* calloc faild */
    if (vc->data == NULL) {
        return VECTOR_ALLOC_ERROR;
    }

    vc->size = num * sizeof(int);
    return VECTOR_OK;
}

int grow_vector(struct vector *vc) {

    void *newptr = 0;
    size_t newsize;

    if (vc == NULL) {
        return VECTOR_NULL_ERROR;
    }


    /* check for integer and SIZE_MAX overflow */
    if (vc->size == 0 || SIZE_MAX / 2 < vc->size) {
        errno = ENOMEM;
        return VECTOR_SIZE_ERROR;
    }

    newsize = vc->size * 2;

    newptr = realloc(vc->data, newsize);

    /* realloc faild; vector stays intact size was not changed */
    if (newptr == NULL) {
        return VECTOR_ALLOC_ERROR;
    }

    /* upon success; update new address and size */
    vc->data = newptr;
    vc->size = newsize;
    return VECTOR_OK;
}

避免致命错误

一般避免动态内存分配问题的方法无非是尽可能把代码写得谨慎、有防御性。本文列举了一些常见问题和少量避免这些问题的方法。

1) 重复释放内存

调用 free 可能导致此问题,此时入参指针可能为NULL(依照《C++ Primer Plus》,free(0)不会出现问题。译者注)、未使用 malloc 类函数分配的指针,或已经调用过 free realloc(realloc参数中大小填0,可释放内存。译者注)的指针。考虑下列几点可让代码更健壮:

  • 指针初始化为NULL,以防不能立即传给它有效值的情况
  • GCC和Clang都有-Wuninitialized参数来警告未初始化的变量
  • 静态和动态分配的内存不要用同一个变量
  • 调用 free 后要把指针设回为NULL,这样一来即便无意中重复释放也不会导致错误
  • 测试或调试时使用assert之类的断言(如C11中静态断言,译者注)
char *ptr = NULL;

/* ... */

void nullfree(void **pptr) {
    void *ptr = *pptr;
    assert(ptr != NULL)
    free(ptr);
    *pptr = NULL;
}

2) 访问未初始化的内存或空指针 代码中的检查规则应只用于NULL或有效的指针。对于去除指针和分配的动态内存间联系的函数或代码块,可在开头检查空指针。

3) 越界访问内存

(PS:你能说出strcpy / strncpy / strlcpy的区别么,能的话这节就不必看)

访问内存对象边界之外的地方并不一定导致程序崩溃。程序可能使用损坏了的数据继续运行,其行为可能很危险,也可能是故意而为之,利用此越界操作来改变程序的行为,以此获取其他受限的数据,甚至注入可执行代码。 老套地人工检查数组和动态分配内存的边界是避免此类问题的主要方法。内存对象边界的相关信息必须人工跟踪。数组的大小可由sizeof操作符指出,但数组被转换为指针后,函数调用sizeof仅返回指针大小(视机器位数而定,译者注),而非原来的数组大小。

C11标准中边界检查接口Annex K定义了一些新的库函数集合,这些函数可用于替换标准库(如字符串和I/O操作)常见部分,它们更安全、更易于使用。例如[the slibc library][slibc]都是上述函数的开源实现,但接口不被广泛采用。基于BSD(或基于Mac OS X)的系统提供了strlcpystrlcat 函数来完成更好的字符串操作。其他系统可通过libbsd库调用它们。

许多操作系统提供了通过内存区域间接控制受保护内存的接口,以防止意外读/写操作,入Posxi mprotect。类似的间接访问的保护机制常用于所有的内存页。

避免内存泄露

内存泄露,常由于程序中未释放不再使用的动态分配的内存导致。因此,真正理解所需要的分配的内存对象的范围大小是很有必要的。更重要的是,要明白何时调用 free。但当程序复杂度增加时,要确定 free 的调用时机将变得更加困难。早期设计决策时,规划内存很重要。

以下是处理内存泄露的技能表:

1) 启动时分配 想让内存管理保持简单,一个方法是在启动时在堆中分配所有所需的内存。程序结束时,释放内存的重任就交给了操作系统。这种方法在许多场景中的效果令人满意,特别是当程序在一个批量操作中完成对输入的处理的情况。

2) 变长数组 如果你需要有着变长大小的临时存储,并且其生命周期在变量内部时,可考虑VLA(Variable Length Array,变长数组)。但这有个限制:每个函数的空间不能超过数百字节。因为C99指出边长数组能自动存储,它们像其他自动变量一样受限于同一作用域。即便标准未明确规定,VLA的实现都是把内存数据放到栈中。VLA的最大长度为SIZE_MAX字节。考虑到目标平台的栈大小,我们必须更加谨慎小心,以保证程序不会面临栈溢出、下个内存段的数据损坏的尴尬局面。

3) 自己编写引用计数  这个技术的想法是对某个内存对象的每次引用、去引用计数。赋值时,计数器会增加;去引用时,计数器减少。当引用计数变为0时,这意味着此内存对象不再被使用,可以释放。因为C不提供自动析构(事实上,GCC和Clang都支持cleanup语言扩展), 也不是重写赋值运算符,引用计数由调用retain/release的函数手动完成。更好的方式,是把它作为程序的可变部分,能通过这部分获取和释放一个内存对象的拥有权。但是,使用这种方法需要很多(编程)规范来防止忘记调用release(停止内存泄露)或不必要地调用释放函数(这将导致内存释放地过早)。若内存对象的生命期需要外部事件指出,或应用程序的数据结构隐含了某个内存对象的持有权的处理,无论何种情况,都容易导致问题。下述代码块含有简化了的内存管理引用计数。

#include <stdlib.h>
#include <stdint.h>

#define MAX_REF_OBJ 100
#define RC_ERROR -1

struct mem_obj_t{
    void *ptr;
    uint16_t count;
};

static struct mem_obj_t references[MAX_REF_OBJ];
static uint16_t reference_count = 0;

/* create memory object and return handle */
uint16_t create(size_t size){

    if (reference_count >= MAX_REF_OBJ)
        return RC_ERROR;

    if (size){
        void *ptr = calloc(1, size);

        if (ptr != NULL){
            references[reference_count].ptr = ptr;
            references[reference_count].count = 0;
            return reference_count++;
        }
    }

    return RC_ERROR;
}

/* get memory object and increment reference counter */
void* retain(uint16_t handle){

    if(handle < reference_count && handle >= 0){
        references[handle].count++;
        return references[handle].ptr;
    } else {
        return NULL;
    }
}

/* decrement reference counter */
void release(uint16_t handle){
    printf("release\n");

    if(handle < reference_count && handle >= 0){
        struct mem_obj_t *object = &references[handle];

        if (object->count <= 1){
            printf("released\n");
            free(object->ptr);
            reference_count--;
        } else {
            printf("decremented\n");
            object->count--;
        }
    }
}
如果你关心编译器的兼容性,可用  cleanup 属性在C中模拟自动析构。

void cleanup_release(void** pmem) {
    int i;
    for(i = 0; i < reference_count; i++) {
        if(references[i].ptr == *pmem)
           release(i);
    }
}

void usage() {
    int16_t ref = create(64);

    void *mem = retain(ref);
    __attribute__((cleanup(cleanup_release), mem));

    /* ... */
}
上述方案的另一缺陷是提供对象地址让  cleanup_release 释放,而非引用计数值。这样一来, cleanup_release 必须在  references 数组中做开销大的查找操作。一种解决办法是,改变填充的接口为返回一个指向  struct mem_obj_t 的指针。另一种办法是使用下面的宏集合,这些宏能够创建保存引用计数值的变量并追加  clean 属性。

/* helper macros */
#define __COMB(X,Y) X##Y
#define COMB(X,Y) __COMB(X,Y)
#define __CLEANUP_RELEASE __attribute__((cleanup(cleanup_release)))

#define retain_auto(REF) retain(REF); int16_t __CLEANUP_RELEASE COMB(__ref,__LINE__) = REF

void cleanup_release(int16_t* phd) {
    release(*phd);
}

void usage() {
    int16_t ref = create(64);

    void *mem = retain_auto(ref);
    /* ... */
}
(译者注:##符号源自C99,用于连接两个变量的名称,一般用在宏里。如int a##b就会定义一个叫做ab的变量;__LINE__指代码行号,类似的还有__FUNCTION__或__func__和__FILE__,可用于打印调试信息;__attribute__符号来自gcc,主要用于指导编译器优化,也提供了一些如构造、析构、字节对齐等功能)

4) 内存池 若一个程序经过数阶段才能彻底执行,每阶段的开头都分配有内存池,需要分配内存时,就使用内存池的一部分。内存池的选择,要考虑分配的内存对象的生命周期,以及对象在程序中所属的阶段。每个阶段一旦结束,整个内存池就要立即释放。这种方法在记录型运行程序中特别有用,例如守护进程,它可能随着时间减少内存分段。下述代码是个内存池内存管理的仿真:

#include <stdlib.h>
#include <stdint.h>

struct pool_t{
    void *ptr;
    size_t size;
    size_t used;
};

/* create memory pool*/
struct pool_t* create_pool(size_t size) {
    struct pool_t* pool = calloc(1, sizeof(struct pool_t));

    if(pool == NULL)
        return NULL;

    if (size) {
        void *mem = calloc(1, size);

        if (mem != NULL) {
            pool->ptr = mem;
            pool->size = size;
            pool->used = 0;
            return pool;
        }
    }
    return NULL;
}

/* allocate memory from memory pool */
void* pool_alloc(struct pool_t* pool, size_t size) {

    if(pool == NULL)
        return NULL;

    size_t avail_size = pool->size - pool->used;

    if (size && size <= avail_size){
        void *mem = pool->ptr + pool->used;
        pool->used += size;
        return mem;
    }

    return NULL;
}

/* release memory for whole pool */
void delete_pool(struct pool_t* pool) {
    if (pool != NULL) {
        free(pool->ptr);
        free(pool);
    }
}

内存池的实现涉及非常艰难的任务。可能一些现有的库能很好地满足你的需求:

5) 数据结构

把数据存到正确的数据结构里,能解决很多内存管理问题。而数据结构的选择,大多取决于算法,这些算法访问数据、把数据保存到例如链表、哈希表或树中。按算法选择数据结构有额外的好处,例如能够遍历数据结构一次就能释放数据。因为标准库并未提供对数据结构的支持,这里列出几个支持数据结构的库:

6) 标记并清除垃圾收集器

处理内存问题的另一种方式,就是利用自动垃圾收集器的优势,自此从自己清除内存中解放出来。于引用计数中内存不再需要时清除机制相反,垃圾收集器在发生指定事件是被调用,如内存分配错误,或分配后超过了确切的阀值。标记清除算法是实现垃圾收集器的一种方式。此算法先为每个引用到分配内存的对象遍历堆,标记这些仍然可用的内存对象,然后清除未标记的内存对象。

可能C中最有名的类似垃圾收集器的实现是Boehm-Demers-Weiser conservative garbage collector 。使用垃圾收集器的瑕疵可能是性能问题,或向程序引入非确定性的延缓。另一问题是,这可能导致库函数使用 malloc,这些库函数申请的内存不受垃圾处理器监管,必须手动释放。

虽然实时环境无法接受不可预料的卡顿,仍有许多环境从中获取的好处远超过不足。从性能的角度看,甚至有性能提升。一些项目使用含有Mono项目GNU Objective C运行环境或Irssi IRC客户端的Boehm垃圾收集器。

指针和数组

尽管在某些上下文中数组和指针可相互替换,但在编译器看来二者完全不同,并且在运行时所表达的含义也不同。

当我们说对象或表达式有类型的时候,我们通常想的是定位器值的类型,也叫做左值。当左值有完全non-const类型时,此类型不是数组类型(因为数组本质是内存的一部分,是个只读常量,译者注),我们称此左值为可修改左值,并且此变量是个值,当表达式放到赋值运算符左边的时候,它被赋值。若表达式在赋值运算符的右边,此变量不必被修改,变量成为了修改左值的的内容。若表达式有数组类型,则此表达式的值是个指向数组第一个元素的指针。

上文描述了大多数场景下数组如何转为指针。在两种情形下,数组的值类型不被转换:当用在一元运算符 &(取地址)或 sizeof 时。参见C99/C11标准 6.3.2.1小节:

(Except when it is the operand of the sizeof operator or the unary & operator, or is a string literal used to initialize an array, an expression that has type “array of type” is converted to an expression with type “pointer to type” that points to the initial element of the array object and is not an lvalue.)

除非它是sizeof或一元运算符&的操作数,再或者它是用于初始化数组的字符文本,否则有着“类型数组”类型的表达式被转换为“指向类型”类型的指针,此指针指向数组对象的首个元素且指针不是左值。

由于数组没有可修改的左值,并且在绝大多数情况下,数组类型的表达式的值被转为指针,因此不可能用赋值运算符给数组变量赋值(即int a[10]; a = 1;是错的,译者注)。下面是一个小示例:

short a[] = {1,2,3};
short *pa;
short (*px)[];

void init(){
    pa = a;
    px = &a;

    printf("a:%p; pa:%p; px:%p\n", a, pa, px);

    printf("a[1]:%i; pa[1]:%i (*px)[1]:%i\n", a[1], pa[1], (*px)[1]);
}
(ps:%i能识别输入的八进制和十六进制)

a 是 int 型数组,pa 是指向 int 的指针,px 是个未完成的、指向数组的指针。a 赋值给 pa 前,它的值被转为一个指向数组开头的指针。右值表达式 &a 并非意味着指向 int,而是一个指针,指向 int 型数组因为当使用一元符号&时右值不被转换为指针。

表达式 a[1] 中下标的使用等价于 *(a+1),且服从如同 pa[1] 的指针算术规则。但二者有一个重要区别。对于 a 是数组的情况,a 变量的实际内存地址用于获取指向第一个元素的指针。当对于 pa 是指针的情况,pa 的实际值并不用于定位。编译器必须注意到 apa见的类型区别,因此声明外部变量时,指明正确的类型很重要。

int a[];
int *pa;
但在另外的编译单元使用下述声明是不正确的,将毁坏代码:

extern int *a;
extern int pa[];

数组作为函数形数

某些类型数组变为指针的另一个场合在函数声明中。下述三个函数声明是等价的:

void sum(int data[10]) {}

void sum(int data[]) {}

void sum(int *data) {}
编译器应报告函数 sum 重定义相关错误,因为在编译器看来上述三个例子中的参数都是 int 型的。.

多维数组是有点棘手的话题。首先,虽然用了“多维”这个词,C并不完全支持多维数组。数组的数组可能是更准确的描述。

typedef int[4] vector;
vector m[2] = {{1,2,3,4}, {4,5,6,7}};
int n[2][4] = {{1,2,3,4}, {4,5,6,7}};
变量 m 是长度为2的 vector 类型, vector 是长为4的 int 型数组。除了存储的内存位置不同外,数组 nm 是相同的。从内存的角度讲,两个数组都如同括号内展示的内容那样,排布在连续的内存区域。访问到的和声明的完全一致。

int *p = n[1];
int y = p[2];
通过使用下标符号 n[1],我们获取到了每个元素大小为4字节的整型数组。因为我们要定位数组的第二个元素, 其位置在多维数组中是数组开始偏移四倍的整型大小。我们知道,在这个表达式中整型数组被转为指向 int 的指针,然后存为 p。然后 p[2] 将访问之前表达式产生的数组中的第三个元素。上面代码中的 y 等价于下面代码中的 z:

int z = *(*(n+1)+2);
也等价于我们初学C时写的表达式:

int x = n[1][2];
当把上文中的二维数组作为参数传输时,第一“维”数组会转为指针,指向再次阵列的数组的第一个元素。因此不需要指明第一维。剩余的维度需要明确指出其长度。否则下标将不能正确工作。当我们能够随心所欲地使用下述表格中的任一形式来定义函数接受数组时,我们总是被强制显式地定义最里面的(即维度最低的)数组的维度。

void sum(int data[2][4]) {}
void sum(int data[][4]) {}
void sum(int (*data)[4]) {}
为绕过这一限制,可以转换数组为指针,然后计算所需元素的偏移。

void list(int *arr, int max_i, int max_j){
    int i,j;

    for(i=0; i<max_i; i++){

        for(j=0; j<max_j; j++){
            int x = arr[max_i*i+j];
            printf("%i, ", x);
        }

        printf("\n");
    }
}
另一种方法是main函数用以传输参数列表的方式。main函数接收二级指针而非二维数组。这种方法的缺陷是,必须建立不同的数据,或者转换为二级指针的形式。不过,好在它运行我们像以前一样使用下标符号,因为我们现在有了每个子数组的首地址。

int main(int argc, char **argv){
    int arr1[4] = {1,2,3,4};
    int arr2[4] = {5,6,7,8};

    int *arr[] = {arr1, arr2};

    list(arr, 2, 4);
}

void list(int **arr, int max_i, int max_j){
    int i,j;

    for(i=0; i<max_i; i++){

        for(j=0; j<max_j; j++){
            int x = arr[i][j];
            printf("%i, ", x);
        }

        printf("\n");
    }
}
用字符串类型的话,初始化部分变得相当简单,因为它允许直接初始化指向字符串的指针。

const char *strings[] = {
    "one",
    "two",
    "three"
};
但这有个陷阱,字符串实例被转换成指针,用 sizeof 操作符时会返回指针大小,而不是整个字符串文本所占空间。另一个重要区别是,若直接用指针修改字符串内容,则此行为是未定义的。

假设你能使用变长数组,那就有了第三种传多维数组给函数的方法。使用前面定义的变量来指定最里面数组的维度,变量 arr 变为一个指针,指向未完成的int数组。

void list(int max_i, int max_j, int arr[][max_j]){
    /* ... */
    int x = arr[1][3];
}
此方法对更高维度的数组仍然有效,因为第一维总是被转换为指向数组的指针。类似的规则同样作用于函数指示器。若函数指示器不是 sizeof 或一元操作符 & 的参数,它的值是一个指向函数的指针。这就是我们传回调函数时不需要 & 操作符的原因。

static void catch_int(int no) {
    /* ... */
};

int main(){
    signal(SIGINT, catch_int);

    /* ... */
}

打桩(Interpositioning)

打桩是一种用定制的函数替换链接库函数且不需重新编译的技术。甚至可用此技术替换系统调用(更确切地说,库函数包装系统调用)。可能的应用是沙盒、调试或性能优化库。为演示过程,此处给出一个简单库,以记录GNU/Linux中 malloc 调用次数。

/* _GNU_SOURCE is needed for RTLD_NEXT, GCC will not define it by default */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <stdint.h>
#include <inttypes.h>

static uint32_t malloc_count = 0;
static uint64_t total = 0;

void summary(){
    fprintf(stderr, "malloc called: %u times\n", count);
    fprintf(stderr, "total allocated memory: %" PRIu64 " bytes\n", total);
}

void *malloc(size_t size){
    static void* (*real_malloc)(size_t) = NULL;
    void *ptr = 0;

    if(real_malloc == NULL){
        real_malloc = dlsym(RTLD_NEXT, "malloc");
        atexit(summary);
    }

    count++;
    total += size;
    return real_malloc(size);
}
打桩要在链接libc.so之前加载此库,这样我们的 malloc 实现就会在二进制文件执行时被链接。可通过设置 LD_PRELOAD 环境变量为我们想让链接器优先链接的全路径。这也能确保其他动态链接库的调用最终使用我们的 malloc 实现。因为我们的目标只是记录调用次数,不是真正地实现内存分配,所以我们仍需要调用“真正”的 malloc 。通过传递 RTLD_NEXT 伪处理程序到 dlsym,我们获得了指向下一个已加载的链接库中 malloc 事件的指针。第一次 malloc 调用 libc 的 malloc,当程序终止时,会调用由 atexit 注册的获取和 summary 函数。看GNU/Linxu中打桩行为(真的184次调用!):

$ gcc -shared -ldl -fPIC malloc_counter.c -o /tmp/libmcnt.so
$ export LD_PRELOAD="/tmp/libstr.so"
$ ps
  PID TTY          TIME CMD
 2758 pts/2    00:00:00 bash
 4371 pts/2    00:00:00 ps
malloc called: 184 times
total allocated memory: 302599 bytes

4.1 符号可见性

默认情况下,所有的非静态函数可被导出,所有可能仅定义有着与其他动态链接库函数甚至模板文件相同特征标的函数,就可能在无意中插入其它名称空间。为防止意外打桩、污染导出的函数名称空间,有效的做法是把每个函数声明为静态的,此函数在目标文件之外不能被使用。

在共享库中,另一种控制导出的共享目标的方式是用编译器扩展。GCC 4.x和Clang都支持 visibility 属性和 -fvisibility 编译命令来对每个目标文件设置全局规则。其中 default 意味着不修改可见性,hidden 对可见性的影响与 static 限定符相同。此符号不会被放入动态符号表,其他共享目标或可执行文件看不到此符号。

#if __GNUC__ >= 4 || __clang__
  #define EXPORT_SYMBOL __attribute__ ((visibility ("default")))
  #define LOCAL_SYMBOL  __attribute__ ((visibility ("hidden")))
#else
  #define EXPORT_SYMBOL
  #define LOCAL_SYMBOL
#endif
全局可见性由编译器参数指定,可通过设置 visibility 属性被本地覆盖。实际上,全局策略设置为 hidden,则所有符号会被默认为本地的,只有修饰  __attribute__ ((visibility (“default”))) 才将被导出。

五、显式内联

(想让)函数代码被直接集成到调用函数中,而非产生独立的函数目标和单个调用,可显式地使用 inline 限定符来指示编译器这么做。根据 section 6.7.4 of C standard inline 限定符仅建议编译器使得”调用要尽可能快”,并且“此建议是否有效由具体实现定义”

要用内联函数优点的最简单方法是把函数定义为 static ,然后将定义放入头文件。

/* middle.h */
static inline int middle(int a, int b){
    return (b-a)/2;
}
独立的函数对象仍然可能被导出,但在翻译单元的外部它是不可见的。这种头文件被包含在多个翻译单元中,编译器可能为每个单元发射函数的多份拷贝。因此,有可能两个变量指向相同的函数名,指针的值可能不相等。

另一种方法是,既提供外部可连接的版本,也提供内联版本,两个版本功能相同,让编译器决定使用哪个。这实际上是内嵌限定符的定义:

If all of the file scope declarations for a function in a translation unit include the inline function specifier without extern, then the definition in that translation unit is an inline definition. An inline definition does not provide an external definition for the function, and does not forbid an external definition in another translation unit. An inline definition provides an alternative to an external definition, which a translator may use to implement any call to the function in the same translation unit. It is unspecified whether a call to the function uses the inline definition or the external definition.

在一个翻译单元中,若某个函数在所有的文件范围内都包含不带extern的内联函数限定符,则此翻译单元中此函数定义是内联定义。内联定义不为函数提供外部的定义,也不禁止其他翻译单元的外部定义。内联定义为外部定义提供一个可选项,在同一翻译单元内翻译器可用它实现对函数的任意调用。调用函数时,使用内联定义或外联定义是不确定的。

(译者注:即gcc中的 extern inline,优先使用内联版本,允许外部版本的存在)

对于函数的两个版本,我们可以把下面的定义放在头文件中:

/* middle.h */
inline int middle(int a, int b){
    return (b-a)/2;
}
然后在具体的源文件中,用extern限定符发射翻译单元中外部可链接的版本:

#include "middle.h"
extern int middle(int a, int b);
GCC编译器的实现不同于上述译码方式。若函数由 inline 声明,GCC总是发射外部可链接的目标代码,并且程序中只存在一个这样的定义。若函数被声明为export inline的,GCC将永不为此函数发射外部可链接的目标代码。自GCC 4.3版本起,可使用-STD= c99的选项使能为内联定义使能C99规则。若C99的规则被启用,则定义GNUC_STDC_INLINE。之前描述的 static 使用方法不受GCC对内联函数解释的影响。如果你需要同时使用内联和外部可链接功能的函数,可考虑以下解决方案:

/* global.h */
#ifndef INLINE
# if __GNUC__ && !__GNUC_STDC_INLINE__
#  define INLINE extern inline
# else
#  define INLINE inline
# endif
#endif
头文件中有函数定义:

/* middle.h  */
#include "global.h"
INLINE int middle(int a, int b) {
  return (b-a)/2;
}
在某个具体实现的源文件中:

#define INLINE
#include "middle.h
若要对函数强制执行内联,GCC和Clang编译器都可用  always_inline 属性达成此目的。下面的例子中,独立的函数对象从未被发射。

/* cdefs.h */
# define __always_inline   inline __attribute__((always_inline))

/* middle.h */
#include <cdefs.h>
static __always_inline int middle(int a, int b) {
  return (b-a)/2;
}
一旦编译器内联失败,编译将因错误而终止。例如   Linux kernel 就使用这种方法。可在 cdefs.h 中上述代码中使用的  __always_inline 。

六、矢量扩展

许多微处理器(特别是x86架构的)提供单指令多数据(SIMD)指令集来使能矢量操作。例如下面的代码:

#include <stdint.h>
#include <string.h>
#define SIZE 8
int16_t a[SIZE], b[SIZE];

void addtwo(){
    int16_t i = 0;

    while (i < SIZE) {
        a[i] = b[i] + 2;
        i++;
    }
}

int main(){
    addtwo();
    return a[0];
}
addtwo 中的循环迭代 8 次,每次往数组 b 上加 2,数组 b 每个元素是 16 位的有符号整型。函数 addtwo 将被编译成下面的汇编代码:

$ gcc -O2 auto.c -S -o auto_no.asm

addtwo:
.LFB22:
        .cfi_startproc
        movl    $0, %eax
.L2:
        movzwl  b(%rax), %edx
        addl    $2, %edx
        movw    %dx, a(%rax)
        addq    $2, %rax
        cmpq    $16, %rax
        jne     .L2
        rep
        ret
        .cfi_endproc
起初,0 写入到 eax 寄存器。标签 L2 标着循环的开始。b 的首个元素由  movzwl 指令被装入的32位寄存器 edx 前16位。 edx寄存器的其余部分填 0。然后 addl 指令往 edx 寄存器中 a 的第一个元素的值加 2 并将结果存在 dx 寄存器中。累加结果从 dx(edx 寄存器的低16位)复制到 a 的第一个元素。最后,显然存放了步长为 2 (占2个字节 – 16位)的数组的  rax 寄存器与数组的总大小(以字节为单位)进行比较。如果 rax 不等于16,执行跳到 L2 ,否则会继续执行,函数返回。

SSE2 指令集提供了能够一次性给 8 个 16 位整型做加法的指令 paddw。实际上,最现代化的编译器都能够自动使用如 paddw 之类的矢量指令优化代码。Clang 默认启用自动向量化。 GCC的编译器中可用 -ftree-vectorize 或 -O3 开关启用它。这样一来,向量指令优化后的 addtwo 函数汇编代码将会大有不同:

$ gcc -O2 -msse -msse2 -ftree-vectorize -ftree-vectorizer-verbose=5 auto.c -S -o auto.asm

addtwo:
.LFB22:
        .cfi_startproc
        movdqa  .LC0(%rip), %xmm0
        paddw   b(%rip), %xmm0
        movdqa  %xmm0, a(%rip)
        ret
        .cfi_endproc

;...

.LC0:
        .value  2
        .value  2
        .value  2
        .value  2
        .value  2
        .value  2
        .value  2
        .value  2
最显着的区别在于循环处理消失了。首先,8 个 16 位值为 2 整数被标记为 LC0,由 movdqa 加载到 xmm0 寄存器。然后paddw 把 b 的每个 16 位的元素分别加到 xmm0 中的多个数值 2上。结果写回到 a,函数可以返回。指令 movqda 只能用在由16个字节对齐的内存对象上。这表明编译器能够对齐两个数组的内存地址以提高效率。

数组的大小不必一定只是 8 个元素,但它必须以 16 字节对齐(需要的话,填充),因此也可以用 128 位向量。用内联函数也可能是一个好主意,特别是当数组作为参数传递的时候。因为数组被转换为指针,指针地址需要16字节对齐。如果函数是内联的,编译器也许能减少额外的对齐开销。

#include <stdint.h>

void __always_inline addtwo(int16_t* a, int16_t *b, int16_t size){
    int16_t i;

    for (i = 0; i < size; i++) {
        a[i] = b[i] + 2;
    }
}

int main(){
    const int16_t size = 1024;
    int16_t a[size], b[size];

    addtwo(a, b, size);
    return a[0];
}
循环迭代 1024 次,每次把两个长度为 16 比特的有符号整型相加。使用矢量操作的话,上例中的循环总数可减少到 128。但这也可能自动完成,在GCC环境中,可用 vector_size 定义矢量数据类型,用这些数据和属性显式指导编译器使用矢量扩展操作。此处列举出 emmintrin.h 定义的采用 SSE 指令集的多种矢量数据类型。

* SSE2 */
typedef double __v2df __attribute__ ((__vector_size__ (16)));
typedef long long __v2di __attribute__ ((__vector_size__ (16)));
typedef int __v4si __attribute__ ((__vector_size__ (16)));
typedef short __v8hi __attribute__ ((__vector_size__ (16)));
typedef char __v16qi __attribute__ ((__vector_size__ (16)));
这是用 __v8hi 类型优化之前的示例代码后的样子:

#include <stdint.h>
#include <string.h>
#include <emmintrin.h>

static void __always_inline _addtwo(__v8hi *a, __v8hi *b, const int16_t sz){
    __v8hi c = {2,2,2,2,2,2,2,2};

    int16_t i;
    for (i = 0; i < sz; i++) {
        a[i] = b[i] + c;
    }
}

static void __always_inline addtwo(int16_t *a, int16_t *b, const int16_t sz){
    _addtwo((__v8hi *) a, (__v8hi *) b, sz/8);
}

int main(){
    const int16_t size = 1024;
    int16_t a[size], b[size];
    /* ... */

    addtwo(a, b, size);
    return a[0];
}
关键是把数据转到合适的类型(此例中为 __v8hi),然后由此调整其他的代码。优化的效果主要看操作类型和处理数据量的大小,可能不同情况的结果差异很大。下表是上例中 addtwo 函数被循环调用 1 亿次的执行时间:

Compiler 	                 Time
gcc 4.5.4 O2 	                1m 5.3s
gcc 4.5.4 O2 auto vectorized 	12.7s
gcc 4.5.4 O2 manual 	        8.9s
gcc 4.7.3 O2 auto vectorized 	25.s
gcc 4.7.3 O2 manual 	        8.9s
clang 3.3 O3 auto vectorized 	8.1s
clang 3.3 O3 manual 	       9.5s

Clang 编译器自动矢量化得更快,可能是因为用以测试的外部循环被优化的更好。慢一点的 GCC 4.7.3在内存对齐(见下文)方面效率稍低。

int32_t i;
for(i=0; i < 100000000; i++){
    addtwo(a, b, size);
}

6.1 使用内建函数( Intrinsic Function)

GCC 和 Clang 编译器也提供了内建函数,用来显式地调用汇编指令。

确切的内建函数跟编译器联系很大。x86 平台下,GCC 和 Clang 编译器都提供了带有定义的头文件,通过 x86intrin.h 匹配 Intel 编译器的内建函数(即 GCC 和 Clang 用 Intel 提供的头文件,调用 Intel 的内建函数。译者注)。下表是含特殊指令集的头文件:

  • MMX: mmintrin.h
  • SSE: xmmintrin.h
  • SSE2: emmintrin.h
  • SSE3: mm3dnow.h
  • 3dnow: tmmintrin.h
  • AVX: immintrin.h

使用内建函数后,前面的例子可以改为:

#include <stdint.h>
#include <string.h>
#include <emmintrin.h>

static void __always_inline addtwo(int16_t *a, int16_t *b, int16_t size){

    int16_t i;
    __m128i c = _mm_set1_epi16(2);

    for (i = 0; i < size; i+=8) {
        __m128i bb = _mm_loadu_si128(b+i);  // movqdu b+i -> xmm0
        __m128i r = _mm_add_epi16(bb, c);   // paddw c + xmm0 -> xmm0
        _mm_storeu_si128(a+i, r);           // movqdu xmm0 -> a+i
    }
}

int main(){
    const int16_t size = 1024;
    int16_t a[size], b[size];
    /* ... */

    addtwo(a, b, size);
    return a[0];
}
当编译器产生次优的代码,或因代码中的 if 条件矢量类型不可能表达需要的操作时时,可能需要这种编写代码的方法。

6.2 内存对齐

注意到上个例子用了与 movqdu 而非 movqda (上面的例子里仅用 SIMD 产生的汇编指令使用的是 movqda。译者注)同义的 _mm_loadu_si128。这因为不确定 ab 是否已按 16 字节对齐。使用的指令是期望内存对象对齐的,但使用的内存对象是未对齐的,这样肯定会导致运行错误或数据毁坏。为了让内存对象对齐,可在定义时用 aligned 属性指导编译器对齐内存对象。某些情况下,可考虑把关键数据按 64 字节对齐,因为 x86 L1 缓存也是这个大小,这样能提高缓存使用率。

#include <stdint.h>
#include <string.h>
#include <emmintrin.h>

static void __always_inline addtwo(int16_t *a, int16_t *b, int16_t size){

    int16_t i;
    __m128i c = _mm_set1_epi16(2) __attribute__((aligned(16)));

    for (i = 0; i < size; i+=8) {
        __m128i bb = _mm_load_si128(b+i);  // movqda b+i -> xmm0
        __m128i r = _mm_add_epi16(bb, c);   // paddw c + xmm0 -> xmm0
        _mm_store_si128(a+i, r);           // movqda xmm0 -> a+i
    }
}

int main(){
    const int16_t size = 1024;
    int16_t a[size], b[size] __attribute__((aligned(16)));
    /* ... */

    addtwo(a, b, size);
    return a[0];
}
考虑到程序运行速度,使用自动变量好过静态或全局变量,情况允许的话还应避免动态内存分配。当动态内存分配无法避免时,Posix 标准 和 Windows 分别提供了 posix_memalign_aligned_malloc 函数返回对齐的内存。

高效使用矢量扩展喊代码优化需要深入理解目标架构工作原理和能加速代码运行的汇编指令。这两个主题相关的信息源有  Agner`s CPU blog 和它的装订版 Optimization manuals

 

七、逸闻轶事

本文最后一节讨论 C 编程语言里一些有趣的地方:

array[i] == i[array];
因为下标操作符等价于*(array + i),因此 array 和 i 是可交换的,二者等价。

$ gcc -dM -E - < /dev/null | grep -e linux -e unix
#define unix 1
#define linux 1
默认情况下,GCC 把 linuxunix 都定义为 1,所以一旦把其中一个用作函数名,代码就会编不过。

int x = 'FOO!';
short y = 'BO';
没错,字符表达式可扩展到任意整型大小。

x = i+++k;
x = i++ +k;
后缀自增符在加号之前被词法分析扫描到。

(即示例中两句等价,不同于 x = i +  (++k) 。译者注)

x = i+++++k; //error
x = i++ ++ +k; //error

y = i++ + ++k; //ok
词法分析查找可被处理的最长的非空格字符序列(C标准6.4节)。第一行将被解析成第二行的样子,它们俩都会产生关于缺少左值的错误,缺失的左值本应该被第二个自增符处理。

致谢

若有需要增改之处,欢迎留言到 原文链接 。

参考文献


关于程序设计基石与实践更多讨论与交流,敬请关注本博客和新浪微博songzi_tea.

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值