关于 C 语言指针二三事


前言

学习 C 语言到现在已经一年了,自从部署好 DPDK 到现在也过去了半年,但是对 DPDK 的学习却半点都没有深入,虽然主观上时间、魄力、方法这三者没有到位,但从客观上讲,掌握 C 语言尤其是指针实属不易,不过这也足够吸引人。

大概半年前,学习了 UNIX 环境下的 C 编程,才窥见这门古老语言的魅力。此后与人谈及就说 C 语言由宏和结构体组成,通过指针相互操作。有一次了解到 Nginx 里四级指针满天飞,惊诧于战斗民族的剽悍之风。再者,封面的代码,又该如何解释?

(*(void (*)())0)()

既知指针是过不去的坎,最近发现几本 C 指针和 C 现代编程方法的书,就此又将学习 C 语言提上日程,本篇文章权当读书笔记以及感悟。

标准

C 语言标准有很多,Sourcetrail 居然给出了这么多版本:

#b# C 标准的版本

总的来说,C 语言经历了如下发展:

  1. K&R C:1972 年,Dennis Ritchie 改进了 B 语言,被称为 NB(New B),又称 K&R C

  2. ANSI C

    1. 1989 年,ANSI 推出 ANSI C 的 89 版标准
    2. 1990 年,ISO 推出 ISO C90

    实际上,ISO C90ANSI C89 是同一个规范,因为 ISO 采纳了 ANSI C。由于 ANSI 早于 ISO 推出,因此通常称这个版本为 ANSI C89

  3. C99:2000 年 3 月,ANSI 采纳了 ISO/IEC 9899:1999 标准,称为 C99

  4. C11:2011 年由 ISO/IEC 发布,C 语言标准的第三版,也被称为 C1X,指 ISO 标准 ISO/IEC 9899:2011

环境

本文基于 C99(C11 引入了很多特性,值得玩味),使用最通用的写法,测试环境如下:

  • 编译器:LLVM version 10.0.1 (clang-1001.0.46.4)
  • 平台:x86_64-apple-darwin18.7.0

初探指针

先来看一个简单的例子:

int main()
{
    int foo = 5;
    printf("foo:    %p\n", foo);
    printf("&foo:   %p\n", &foo);
    int * p;
    p = &foo;
    printf("p:      %p\n", p);
    printf("*p:     %p\n", *p);
    printf("&p:     %p\n", &p);
    return 0;
}

编译运行,如果不出意外的话,应该得到以下输出:

foo:    0x5
&foo:   0x7ffee8ba3988
p:      0x7ffee8ba3988
*p:     0x5
&p:     0x7ffee8ba3980

实际上编译得到两个 warning:应将打印 *p 的格式字符 %d 替换为 %d但是 who care? 此处为了统一,将 *p 也以地址格式输出。

例程做了几件事:

  1. 先定义一个变量 foo,初始化之后,很容易知道,值和地址分别为 50x...988
  2. 再定义一个指针 p,将 foo 的地址赋给它,此时:
    • pfoo 的地址
    • *pfoo 的值
    • &pp 的地址

为方便起见,在后续的例子中,省略了 main() 函数等代码,只在必要处给予说明。另由于现代操作系统地址随机化等内存保护措施,每次执行程序,变量的地址不一定相同,但绝对偏移是固定的,这点需要注意。

声明指针变量

声明时,使用 * 代表要声明的变量是指针:

int *p;

但我们有时候也看到:

int* p;
double * p;
int main(int argc, char **argv);

以上都是声明指针变量,int 是关键字,int* 显然不是(不会影响到对 int 的声明,但其实是声明 int 型的指针,后文会提到),在这种情况下,空格可有可无,实际上在 C 语言编译过程中,会忽略空格符、制表符和换行符。

所以只要逻辑没问题,下面代码可以如愿执行:

int
    bar
     =
     6
;
    printf
(
    "66%d!\n"   // 字符是一个整体,所以不能换行
      ,
    bar
)
;

但是这种代码风格太抽象了,如果不想去世的话,不建议这么写。相信连自己也会看不下去的。

好,既然 * 放在哪都没有区别,那为什么会有 int* pint *p 两种截然不同的形式存在呢?当我 int * p 不存在。

通常,C 语言的声明格式类似 类型 变量名,如 float pai 声明类型为 folat 的变量 pai,而像 double *pointer 则是声明类型为 double 的指针变量 pointer,注意并不是 *pointer,因此,写成 double* 将之作为类型名似乎更合理,但请注意:

int* foo, bar, p = 9;

其中只有 foo 是指针变量。可见,C 语言极其混乱灵活,很多现象不能一概而论

使用指针

实际上第一个例程已经使用过了指针,这里再来总结一下。

  • 标识符(即指针变量)的 值是地址
  • * 解引用,根据地址得到所代表的的值(字符或数值)
  • & 获得变量的地址,可以用来将其他对象的 地址值 赋给指针变量

指针变量存储的值是地址,所以以下代码,编译器不认为是定义指针,将产生 incompatible integer to pointer conversion initializing 'double *' with an expression of type 'double'warning

double * p = 3.14;

但把值换成字符串就又可以了,甚至还可以通过指针来访问,所以说 C 语言很多现象不能一概而论。

#include <stdio.h>
int func()
{
    char * p = "hahaha";
    printf("%s\n", p);
    return 0;
}

int main()
{
    char * q = "that's ok";
    printf("%s\n", q);
    printf("%c\n", *(q+2));
    func();
    return 0;
}

实际上字符串会作为只读变量单独存放,将以上代码反汇编后可以看到两处的字符串都作为常量存储在某一区域。

#b# 反汇编后的符号表

二探指针

数组

可能大多数 C 语言教材都会提到,一个数组 arr[9],在表达式中如果只给出数组名 arr,其实就代表 arr[0]

int arr[] = { 0, 1, 2, 3, 4 };
int *p;
p = arr;
printf("arr:        %p\n", arr);
printf("&arr[0]:    %p\n", &arr[0]);
printf("p:          %p\n", p);

printf("arr[0]:     %p\n", arr[0]);
printf("*p:         %p\n", *p);
printf("*arr:       %p\n", *arr);

printf("arr[1]:     %p\n", arr[1]);
printf("*(p+1)):    %p\n", *(p + 1));
printf("*(arr+1):   %p\n", *(arr + 1));

以上例程运行结果如下:

arr:        0x7ffee8461970
&arr[0]:    0x7ffee8461970
p:          0x7ffee8461970
arr[0]:     0x0
*p:         0x0
*arr:       0x0
arr[1]:     0x1
*(p+1):     0x1
*(arr+1):   0x1

可以得到以下几个结论:

  1. 作为表达式,数组名 arr 和指向该数组的指针变量 p 一样,其值都是地址
  2. 作为表达式,数组名 arr 的地址值与其第一个元素的地址值相同
  3. arr[i]*(arr + i) 等效

下标运算符 []

声明中的 [] 和表达式中的 [] 意义不同,就跟声明中的 * 和表达式中的 * 意义不同一样。

实际上,arr[i] 只是 *(arr+i) 的语法糖(syntax sugar)。

为什么说 [] 是语法糖呢?

引用轮子哥的 回答

  1. 如果去掉了一个功能,语言有些事情就做不了了,这就不是语法糖,而是基础功能
  2. 如果去掉了一个功能,语言做那些事情只是麻烦了一点点,这就是语言功能重复,或者只是提供了缩写功能
  3. 如果去掉了一个功能,语言做那些事情还是能做,但是实在是麻烦太多了,这就是语法糖了

以一个三维数组为例:

int d_3_arr[3][3][2] = {
    {
        {1, 2},
        {3, 4},
        {5, 6},
    },
    {
        {7, 8},
        {9, 10},
    },
    {
        {11, 12},
        {13, 14},
    }
};

要访问 13,使用 [] 的形式为 d_3_arr[2][1][0],使用指针的形式则为 *(*(*(d_3_arr + 2) + 1) + 0)

似乎跟做梦一样。使用指针运算的形式不仅需要开发者耗费精力去计算下标,还要注意书写,写错一个 () 或者 * 就会出问题。这两种写法孰优孰劣,不难辨知。

众所周知,C 语言没有多维数组,有也是靠一维数组模拟出来的。使用 for 循环遍历该三维数组,可以得到各元素的地址,示意如下:

#b# 多维数组内存分布图

可以清晰地看到,数组元素是线性分布的,数组初始化时指定了元素因此用 0 填补空缺,在我的环境下,int 型的数组每个元素占 4 字节。

数组的起始地址自然就是数组第一个元素的地址。当我们使用 d_3_arr + 1 得到的值就是红框部分数组的起始地址,以此类推。

大家可以计算一下 *(d_3_arr + 2) + 1 及其对应的值是多少。

现在来看一个骚操作:

  1. 众所周知,*(p+i)*(i+p) 显然是一样的
  2. p[i] 又是 *(p+i) 的语法糖
  3. 所以,p[i] 又可以写成 i[p]

接上面的例子,以下代码可验证成功:

printf("arr[1]:     %p\n", arr[1]);
printf("1[arr]:     %p\n", 1[arr]);

作为函数参数

我们知道,函数内不能修改实参的值,但是通过指针可以,函数不能传数组,但是传指针可以。

int func(int *arr);
int func(int arr[]);
int func(int arr[10]);

在声明函数形参时,数组或被自动解读成指针,即使指定了元素个数也会被忽略,所以以上写法最终都会被理解成第一种形式。

还有一个著名的例子便是 main() 函数,声明成如下形式时(而不是 int main(void);),可获得命令行参数。

int main(int argc, char * argv[]);
int main(int argc, char ** argv);
  • argc:参数个数
  • argv:包含参数字符串的二维数组

再看一个较复杂的例子:

void func(int a[][5]);  // 1
void func(int (*a)[5]); // 2

a 是个 int 型的二维数组,该数组的元素是一个 int 型的拥有 5 个元素的数组,而 a 这个数组的元素个数不需要明确,因为 a 最终会转成指针(如 2 所示)。

但有一点需要注意,声明的仅仅是指针,没有指定长度,所以在遍历数组时需要注意边界,或者在声明函数时就指定数组大小,就像 main() 函数中的 argc 一样。

字符串

C 语言中,定义 字符串 常量使用 char 数组

char str[] = "@_$tr1n9";

实际上,上述是一种省略形式,会被编译器特别解释为:

char str[] = { '@', '_', '$', 't', 'r', 'i', 'n', '9', '\0' };

此后,我们可以通过数组的方法(下标运算符或指针运算)访问、修改该数组的内容。

str[0] = 'a';
*(str+2) = 's';

此外,字符串也可以使用指针来定义,但无法修改内容,否则会报总线错误,因为使用这种方法定义,指针指向的是只读区域(见 使用指针 一节)的字符串起始位置。

char *string = "abc";
string[0] = 'd';    // bus error

通过以下语句,可验证字符串常量是数组:

printf("%d", sizeof("abcdefg"));    // 8

正因为如此,我们可以使用以下方式获得字符:

"0123456789"[2]

三探指针

指向函数的指针

先来看看函数名是个什么东西。

#include <stdio.h>
int func()
{
    printf("hh\n");
    return 0;
}

int main()
{
    printf("func:   %p\n", func);
    printf("&func:  %p\n", &func);
    return 0;
}

运行结果如下:

func:	0x10b56cf20
&func:	0x10b56cf20

正如数组名在表达式中可以被解读成指针一样,函数名也意味着指向函数的指针,其标识符就是初始地址。实际上各种类型都是如此————标识符是起始地址,只不过数组、结构体等有大小,而函数没有大小。

如果想在函数里调用另一个函数,这很简单,直接通过标识符调用即可。接上例main() 函数中使用如下语句都可成功调用 func() 函数:

func();
(*func)();

只要能获得 func 这个标识符或者说对应的地址即可。

但如果标识符不确定呢?即函数名未知,该如何调用?

参照函数传参,如果将被调函数的起始地址传入主调函数,即可调用之。函数在表达式中,应理解成指向函数的指针,比如在信号处理程序中:

signal(SIGSEGV, segv_handle);
signal(SIGSEGV, &segv_handle);

我们已经知道,以上两种方式都是传递函数的起始地址,然后按照之前提到的调用方式,所以都能成功。

似乎很合理,但是主调函数的声明尤其是参数表该怎么写?这就涉及到函数指针了。

来看这个例程:

#include <stdio.h>

void a(int (*callee)())
{
    printf("a: hhh\n");
    callee();
}

int b()
{
    printf("b: hhhh\n");
    return 0;
}

int main()
{
    a(b);
    return 0;
}

其中,int (*callee)() 是一个函数指针,其标识符(即形参)为 callee,就跟 int func(int *a) 这样的声明没什么两样,只不过声明函数需要带括号 ()

同样,类比普通变量的指针,函数指针也可以获得另一个函数的地址,从而代为调用。看以下例程的 *(func)() 函数:

#include <stdio.h>
void (*func)();

void a(int (*callee)())
{
    printf("a: hhh\n");
    callee();
}

int b()
{
    printf("b: hhhh\n");
    return 0;
}

int main()
{
    func = a;
    func(b);    // (*func)(b); 与之相同
    return 0;
}

现在,函数指针 func 可以绑定任何与之类型相同的函数,并且调用了。

上面这句话提到了一个词————类型,现在来看这个迟到的概念。

派生类型

C 语言类型有基本类型和派生类型之分。

double *p;
int (*func[9])(float);

参照《征服 C 指针》中“类型链的表示”,以上类型可图示如下:

#b# 类型链表示

链最后面的(即图中第一个)元素是基本类型,后面的则都是派生类型。

好,现在可以群魔乱舞了。如何解读以下语句(来自知乎专栏)?

int **p;
int *p[10];
int (*p)[10];
int *p(int);
int (*p)(int);
int (*p[10])(int);

首先要明确,不管类型链画成什么花,类型都是由基本类型和指针、数组、函数、结构体等派生类型组成的,而派生类型可以不断(递归或者重复)组合,就可以生成无限种派生类型。而每种类型都有类别,同样借用《征服 C 指针》中的概念,称这种类别为 类型分类

对于“类型链”表示里的两个例子,我们不加解释(解释方法见下文)地给出解读:

  1. p 是一个指向 double指针
  2. func 是一个拥有 9 个元素(并且元素为指向(参数为 float、返回值为 int 的函数)的指针)的 数组

可以看到,这样的解读类似下定义,其宾语就是 类型分类,同时也是链的第一个(即图中最后一个)元素。就像二维数组 arr[m][n] 一样,把数组抽象成子元素,层层嵌套。

解读 C 声明

所以,怎么来明确这个 类型分类 呢?或者说如何得到类型链的表示呢?

借用《征服 C 指针》的方法:

在这里,向读者介绍阅读 C 语言声明的方法:机械地向前读。

为了把问题变得简单,我们在这里不考虑 constvolatile。接下来遵循以下步骤来解释 C 的声明。

  1. 首先着眼于标识符(变量名或者函数名)。
  2. 从距离标识符最近的地方开始,依照优先顺序解释派生类型(指针、数组和函数)。优先顺序如下:
    1. 用于整理声明内容的括弧
    2. 用于表示数组的 [],用于表示函数的 ()
    3. 用于表示指针的 *
  3. 解释完成派生类型,使用 “of”、“to”、“returning” 将它们连接起来。
  4. 最后追加数据类型修饰符(在左边,intdouble 等)。
  5. 英语不好的人,可以倒序用日语(或者中文)解释。

个人认为最主要的就是第 1 步和第 2 步,可以明确 类型分类,至于其它,都是定语而已,可以慢慢往上添。

看下面例子,区别就在于有无括号 ()

int (*func_p)(double);  // 1
int *func_p(double);    // 2
  1. 首先着眼于标识符 func_p
    func_p is
  2. 对于 1,周围存在括号,所以 * 优先,func_p 是一个 指针
    func_p is pointer to
  3. 解释完 (*func_p),后面是表示函数的括号 () 以及参数 double
    func_p is pointer to function(double) returning
  4. 数据类型是 int,完工。
    func_p is pointer to function(double) returning int

而对于 2,不存在括号,所以按照优先级先解释表示函数的 (),所以 func_p 是一个函数,且参数为 double,返回值为 int *,即返回 int 型的指针变量。

所以按照上述方法,以下式子即可迎刃而解:

float *a[9];
float (*a)[9];

第一个,很简单,写成以下形式秒秒钟明白:

float * a[9];

a 是元素类型为 float 型指针的数组而已。

第二个,a 是个指针,指向一个数组(拥有 9float 型的元素)。

如果不出意外的话,群魔乱舞的例子解释完了(n 级指针无需多言,当然理解和使用是两种概念)。

函数声明

我们来看前面出现过的 signal() 函数的原型声明:

void (*signal(int sig, void (*func)(int)))(int);
  1. 首先看标识符,语句中有两个标识符:signalsigfunc,明显可以知道后两者是作为函数参数,所以主体是 signal
  2. 按照优先顺序,离标识符最近的是表示函数的括号 (),所以整个语句声明的是一个函数
    • 参数:除 int 型的 sig之外,还有
      void (*func)(int)
      分离之后就不难理解,func 是一个指针,指向一个函数(参数为 int、返回 void
    • 返回值:一个指针
      *signal(int sig, void (*func)(int));
  3. 现在得到了一个指针,用标识符 SIG 替代,则整条语句转换为:
    void (*SIG)(int);
    该指针指向一个函数(参数为 int、返回 void

类型链表示如下:

#b# 类型链表示

signal() 函数是用于注册信号处理的函数,按照如下方式调用后,返回之前注册的处理当前信号中断的函数(这个函数显然得用指针来表示)。

signal(SIGSEGV, segv_handle);
  • SIGSEGV:信号类型
  • segv_handle:信号处理程序,就是传入的函数(的地址)

signal() 函数及 segv_handle() 都有相同的结构:

void (*a)(int);

因此,借助 typedef,可以使声明更加清晰:

typedef void(*sig_t)(int) sig_t;
sig_t signal(int sig, sig_t func);

解读 C 声明 的引述中有这样一句话:

为了把问题变得简单,我们在这里不考虑 constvolatile

关于 volatile 修饰符后续再讨论。(因为我还没怎么接触到。) 现在来看 const 修饰符。

修饰符

C 语言修饰符(specifier)包括但不限以下几个:

  • inline
  • restrict
  • 类型修饰符
    • const
    • volatile
  • 存储类型修饰符
    • auto
    • static
    • register
    • extern
    • typedef

关于这几个修饰符后续再讨论,在这里是为了明确类型修饰符和存储类型修饰符可以一起使用。

const

const 可以将变量修饰为 只读,因此,常量就可以使用 const 来声明,const 也经常见于函数原型的参数列表。

strcpy() 函数就是一个范例:

char * strcpy(char *dst, const char *src);

此时,成为只读的不是 src,而是 src 所指向的对象

char *f_strcpy(char *dst, const char *src)
{
    src = NULL;     // success
    *src = "hhhhh"; // failed
}

const 修饰的是 *src 这一个整体,而不是 src 这个指针变量,即变量可以绑定其他地址。

如果要变量只读和都只读,可分别声明为如下形式(两处 const 都修饰了 src):

char *f_strcpy(char *dst, char * const src)
{
    src = NULL;     // failed
    *src = "hhhhh"; // success
}

char *f_strcpy(char *dst, const char * const src)
{
    src = NULL;     // falied
    *src = "hhhhh"; // failed
}

const 可以和 char 一起使用,排列组合一下,得到如下五种形式:

const char * p;
const * char p;
char * const p;
char const * p;
const char const * p;

定睛一看,const * char p 是个什么东西?去掉修饰符为 * char p;,根本不符合语法规范。其次,const char * pchar const * p 效果一样,所以只有以下三种形式:

const char * p;
char * const p;
const char const * p;

再谈函数调用

指向函数的指针 一节提到,使用以下方式都可以成功调用:

func();
(*func)();

实际上,函数调用运算符 () 的对象是函数指针,而函数在表达式中会自动转换成指向函数的指针。如果对指向函数的指针解引用 *,会暂时成为函数,但因为在表达式中,又会变为指向函数的指针,因此 * 运算符发挥不了作用,以下函数可正常调用:

(**********printf)("hhhhhh");

(*(void (*)())0)()

现在来解释封面的函数。某些运行于微处理器上的程序在启动计算机时,硬件将调用首地址为 0 的程序。为了模拟开机启动的情形,需要设计一个 C 语句,显式调用该程序,所以就有了这条语句。

(* (void (*)()) 0)()

假如有一个函数:

int (*func)(int);

可使用如下语句调用:

(*func)();

func 是指向函数的指针变量,即函数入口地址,那么这里入口地址是 0,但 0 不能作为函数指针,需要进行类型转换。

void (*)()

上式是一个指向返回值和参数都为空的函数的指针,可用来进行类型转换,转换之后如下:

(void (*)()) 0

然后进行函数调用,得到最终的语句。

后记

读到这里,或许可以发现,以上内容部分概念有些许重复,因为 C 语言的指针和数组是类似的,却又不完全相同,尽管我极力想把结构理清楚,但概念就是这么零碎的存在,所以按照话题来论述也无妨,融会贯通才是最终目标。实际上,《征服 C 指针》中也是分离各个话题的,造成很多概念提了又提的现象。 文章题目仿自鲁迅先生的《关于章太炎先生二三事》,当然文章内容与原著无关。本文以 C 指针为主要对象,但记录的不只是指针,(擒贼先擒王),而是围绕 C 语言一系列的坑与概念。

因为阅历和资历问题,我没法像《征服 C 指针》和《C 陷阱与缺陷》两位作者一样,援引经典文献,从历史根源出发讨论问题,因此我的论述从实验结果出发,我深知从现象看问题不可能全面,纰漏之处难免,还请各位不吝赐教。

其实后记的内容本该放在前言,但头重脚轻似乎不妥,所以放在这个不重要 (看都不看) 的角落。学习 C 语言是一辈子的事情,因此本文将持续更新,有必要时将会分解成系列文章。

参考资料

  1. 《征服 C 指针》
  2. 《C 陷阱与缺陷》
  3. 神一般的C语言指针, 你看懂了多少?
  4. C语言中-j—i怎样结合? - vczh的回答 - 知乎

文章作者: Palm Civet
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Palm Civet !
 上一篇
GitHub 与 SPA 部署 GitHub 与 SPA 部署
最近部署 SPA 时遇到了一些问题,即刷新页面会返回 404,设置服务器的 404 页面可以解决。基于静态页面托管的原理,可以将 SPA 发布在 GitHub Pages 上,并借助 Actions 实现持续部署(CD)。
2020-02-03
下一篇 
gmpy2 编译安装的坑 gmpy2 编译安装的坑
本文记录 Kali 下编译安装 gmpy2 的过程,需要在网上爬很多帖,但后来发现,可以直接使用 apt 就可以安装,所以踩坑记录变成了部署记录。
2019-11-02
  目录