C语言进阶指南(2)丨数组和指针、打桩
三、指针和数组
尽管在某些上下文中数组和指针可相互替换,但在编译器看来二者完全不同,并且在运行时所表达的含义也不同。
当我们说对象或表达式有类型的时候,我们通常想的是定位器值的类型,也叫做左值。当左值有完全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]); }
(译者注:%i能识别输入的八进制和十六进制)
a是 int 型数组,pa 是指向 int 的指针,px 是个未完成的、指向数组的指针。a 赋值给 pa前,它的值被转为一个指向数组开头的指针。右值表达式 &a并非意味着指向 int,而是一个指针,指向 int 型数组因为当使用一元符号&时右值不被转换为指针。
表达式 a[1] 中下标的使用等价于 *(a+1),且服从如同 pa[1] 的指针算术规则。但二者有一个重要区别。对于 a 是数组的情况,a 变量的实际内存地址用于获取指向第一个元素的指针。当对于 pa 是指针的情况,pa 的实际值并不用于定位。编译器必须注意到 a 和 pa见的类型区别,因此声明外部变量时,指明正确的类型很重要。
int a[]; int *pa;
但在另外的编译单元使用下述声明是不正确的,将毁坏代码:
extern int *a; extern int pa[];
3.1 数组作为函数形数
某些类型数组变为指针的另一个场合在函数声明中。下述三个函数声明是等价的:
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 型数组。除了存储的内存位置不同外,数组 n 与 m 是相同的。从内存的角度讲,两个数组都如同括号内展示的内容那样,排布在连续的内存区域。访问到的和声明的完全一致。
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”))) 才将被导出。
持续更新中。
另外笔者是一个有着7年工作经验的架构师,对于c++,自己有做资料的整合,一个完整学习c语言c++的路线,学习资料和工具。可以进我的q群7418,18652领取,免费送给大家。希望你也能凭自己的努力,成为下一个优秀的程序员!另外博主的微信公众号是:c语言编程学习基地,欢迎关注!