文章目录
- 1.1 指针的基本概念
- 1.1.1 指针是一个程序实体所占用内存空间的首地址
- 1.1.2 指针变量
- 1.1.3 指针变量必须赋值之后才能使用
- 1.1.4 取址运算符与取值运算符
- 1.2 函数指针
- 1.3 函数指针作为函数参数及回调函数
这篇文章是我阅读《嵌入式实时操作系统μCOS-II原理及应用》后的读书笔记,记录目的是为了个人后续回顾复习使用。
《嵌入式实时操作系统μC/OS-Ⅱ原理及应用》这本书的前言部分有提到:
C 指针看起来像是一个复习的内容,其实是要重点强调 C 指针中的函数指针,因为这种数据类型在操作系统软件中使用的频率太高了,而高校的 C 语言教学又大多不把它当作重点,所以致使相当一部分高校学生甚至不知道函数指针为何物。除了 C 指针之外,C 语言中的关键字
typedef
及其常用方法也是由于上述原因而被初学者忽视,从而造成了学习上的困难,因此在第 2 章也增加了这方面的内容。当然,因为本书的宗旨不是介绍 C 语言,所以仅依靠本书的寥寥数语并不能真正使读者完全掌握函数指针,但起码能使读者知道基础的欠缺之所在,从而主动去查找和阅读文献。
因此,接下来我们需要复习扩充 C 语言中的指针这部分的知识内容,为接下来的实时操作系统的学习打基础。
指针可以直接对内存地址进行读写,例如我们学习 STM32 时,通过操作 RCC_APB2ENR
、GPIOA_CRL
、GPIOA_ODR
依次将 GPIOA
端口时钟打开、配置 GPIOA1
引脚模式为推挽输出、GPIOA1
引脚输出高、低电平,从而让连接在 GPIOA1
引脚上的 LED
闪烁。如下方的示例程序所示:
void delay(unsigned long d);
void delay(unsigned long d)
{
while (d--);
}
int main()
{
unsigned int * pReg;
/* 使能 GPIOA 时钟 */
pReg = (unsigned int *)(0x40021000 + 0x18);//RCC_APB2ENR
*pReg |= (0x1<<2);
/* 设置 GPIOA1 为输出引脚 */
pReg = (unsigned int *)(0x40010800 + 0x00);//GPIOA_CRL
*pReg &= (unsigned int)~(0xF<<(4*1));
*pReg |= (0x3<<(4*1));
pReg = (unsigned int *)(0x40010800 + 0x0C);//GPIOA_ODR
while (1)
{
/* 设置 GPIOA 输出 1 */
*pReg |= (0x1<<1);
delay(1000000);
/* 设置 GPIOA 输出 0 */
*pReg &= (unsigned int)~(1<<1);
delay(1000000);
}
}
上述程序编译后得到 hex 文件加载到 Proteus 的 STM32 仿真模型中,仿真效果如下面的 GIF 动图所示:
因此,指针使得 C 程序具有较强的硬件访问能力。而操作系统又是位于系统硬件和应用程序中间的一层软件系统(层级关系如下图所示),它恰恰需要直接操作系统硬件,这也是大多数操作系统都用 C 语言来编写的原因。
1.1 指针的基本概念
1.1.1 指针是一个程序实体所占用内存空间的首地址
为了对数据、函数、数组等程序实体进行识别,为它们定义相应的名称是通常的做法,例如常量名、变量名、函数名、数组名等。但有时人们对程序实体占用内存空间的情况更感兴趣,例如希望知道它们的首地址,因为通过地址来操作这些程序实体更加方便和直接,特别是在需要访问连续存放的程序实体时(数组、结构体等,后文将介绍到)。
由于一个程序实体一定占用一个连续内存空间,于是 C 语言规定,程序实体所占用内存空间的第一个单元地址,就叫做这个程序实体的指针,因为它起到了指示一个程序实体位置的作用。
由于 C 语言的基础知识需要掌握的透彻些,才能理解上面指针就是地址这个说法。接下来就先来回顾复习一下 C 语言基础。
下方内容翻译自 Embeetle 的 Embedded C Tutorial,在翻译的过程中,我把自己上机实验的结果,以截图的方式插入到相应的位置。
1.1.1.1 变量
我们可以将变量想象成一个可以容纳数据的容器:
int my_variable;
my_variable = 5;
变量可以被定义为:变量就是一个名字,它代表了用来存储数据的一个或多个内存位置。
1.1.1.1.1 RAM 内存
要理解变量的真正含义,我们必须对 MCU 中的 RAM 内存有一些了解。
1.1.1.1.1.1 基础知识
8 位 MCU
RAM 内存不仅仅是一系列位的序列,它们被分组成一个个 8 位位宽的字节,每个字节都有一个唯一的地址。下图展示了 8 位 MCU 的 RAM 内存:
对于 8 位 MCU ,字节是 CPU 可以在单个操作中读取或写入的最小“数据单元”(也称为“内存字”)。
16 位 MCU
对于 16 位 MCU ,最小的“数据单元”(内存字)宽度为 16 位:
从技术上讲,为每个内存字分配唯一的地址是可行的。然而,更常见的是用唯一的地址寻址每个字节,这被称为字节寻址。
换句话说:理论上,我们可以使用一个地址指向一个 16 位内存字中的单个字节,字节寻址系统允许这样做。然而,在实践中,我们却很少会这样做。原因是访问 16 位内存字中的单个字节速度较慢,因为 CPU 必须读取整个内存字,使用位操作改变单个字节,然后将其写回。
因此,内存字的地址递增 2。
32 位 MCU
同理可得,在 32 位 MCU 中,地址递增 4:
1.1.1.1.1.2 RAM 中的变量
变量 my_int
是一个两字节的内存段,起始地址为 0x0018
。变量 my_char
是一个单字节的内存段,起始地址为 0x0014
。变量 my_float
是一个四字节的内存段,起始地址为 0x0010
。
它们在 16 位 RAM 内存中的分布如下图所示:
然后是在 32 位 RAM 中的分布情况:
注意看,变量 my_int
发生了一些有趣的变化:在 8 位 RAM 内存中是两个字节,在 16 位 RAM 内存中仍然是两个字节,但在 32 位 RAM 中却变成了四个字节!不管 CPU 的位数是多少,大多数变量都有一个明确定义的大小。整型类型是这个规则的一个例外,稍后我们再详细介绍它。
让我们总结一下:
变量 | 8 位 RAM | 16 位 RAM | 32 位 RAM |
---|---|---|---|
my_int | 两个字节 | 两个字节(一个16位内存字) | 四个字节(一个32位内存字) |
my_char | 一个字节 | 一个字节(一个内存字的一半) | 一个字节(一个内存字的四分之一) |
my_float | 四个字节 | 四个字节(两个内存字) | 四个字节(一个内存字) |
测试程序如下:
//==========================================
#include <stdio.h>
//==========================================
//定义了函数 print_size
void print_size(void)
{
printf("size of int:\t%d\n", sizeof(int));
printf("size of char:\t%d\n", sizeof(char));
printf("size of float:\t%d\n", sizeof(float));
}
//====================主函数======================
int main(void)
{
print_size(); //调用了函数 print_size
return 0;
}
16 位平台的运行结果如下:
64 位平台(Ubuntu 18.04)的运行结果如下:
1.1.1.1.1.3 地址分配
综上所述,我们知道变量基本上是 RAM 内存的一段,具有明确定义的大小、地址和名称(也称为标识符,例如 foo
)。我们在代码中给变量起了名字,但是谁给变量分配地址的呢?
编译器是时候出场了。一旦我们定义了一个变量,编译器就会将该变量分配到内存中的特定位置。实际上,这个过程比较复杂,编译器和链接器一同工作来确定变量最终在内存中的位置。
如果我们向变量写入内容,实际上是将内容写入到该内存位置。如果我们读取变量,实际上是读取对应的内存位置。
1.1.1.1.2 数据类型
我们前面在观察一个整数、一个字符和一个浮点变量分布在内存中的情况时,已经捎带提过数据类型这个问题。
1.1.1.1.2.1 基础知识
除了名称,变量还有一个数据类型:
数据类型决定两件事:变量的大小(在内存中占用多少空间)以及其值的解释方式。这里的“它的值的解释方式”是什么意思?让我们考虑一个例子。在 16 位 RAM 内存中,两个内存字被填充了如下位:
如果将这些内存单元分配给数据类型为 float
的变量,那么这 32 位数据会被 CPU 解释为一个浮点数,其值为
6.69487
×
1
0
−
33
6.69487\times10^{-33}
6.69487×10−33。
然而,如果相同的内存单元分配给 long int
数据类型(在一个 16 位 CPU 上,长整型数据类型通常是一个 32 位的有符号整数),其解释出来的就是数值
168496141
168496141
168496141。
测试程序如下(64 位平台下 signed int
和 float
的数据类型都占据 4 个字节):
#include <stdio.h>
int main() {
unsigned int value = 0x0a0b0c0d; // 4个字节中的内容是 0x0a0b0c0d 的变量
float float_value = *(float *)&value; // 将内存单元内容以浮点数方式解释
// 以浮点数形式输出
printf("浮点数值: %.10e\n", float_value);
// 以整型形式输出内存单元内容
printf("整型值: %u\n", value);
return 0;
}
上述示例程序运行结果如下:
换句话说,两个变量可以存储完全相同的位,但如果它们的数据类型不同,它们表示的内容就完全不同!
数据类型确定了:
- 变量的大小(在 RAM 中占用多少字节)。
- 其中的位是如何被解释的。
1.1.1.1.2.2 基本数据类型
C 语言只有四种基本数据类型:
char
单个字符,8 位int
整数,16 位float
单精度浮点数,32 位double
双精度浮点数,64 位
注意,int
的大小不是固定的!它会因编译器而异。对于 16 位 MCU ,通常为 16 位。对于 32 位 MCU ,它就是 32 位的了。
通过使用数据类型限定符如 unsigned
、signed
、short
和 long
,我们可以创建一些变体:
unsigned char
无符号的单个字符,8位signed char
有符号的单个字符,8位unsigned short int
无符号整数 16位signed short int
有符号整数 16位unsigned int
无符号整数 16位signed int
有符号整数 16位unsigned long int
无符号整数 32位signed long int
有符号整数 32位
由于即使我们使用数据类型限定符,整数的大小还是会严重依赖于编译器,所以建议使用头文件 <stdint.h>
中定义的以下类型:
-
uintn_t /intn_t 无符号/有符号确切宽度整数,保证精确拥有 n 位。 n 位
-
uint_leastn_t/int_leastn_t 无符号/有符号最小宽度整数,保证是至少具有 n 位的可用最小类型。 >= n 位
-
uint_fastn_t/int_fastn_t 无符号/有符号最快宽度整数,保证是至少具有 n 位的可用最快类型。 >= n 位
“最快”并不总是“最小”。一个16位 MCU 处理16位整数会比处理8位整数更快! -
uintmax_t/intmax_t 无符号/有符号最大宽度整数,保证是可用的最大整数类型。
-
uintptr_t/intptr_t 无符号/有符号整数,保证能够容纳一个指针。
指针将在稍后解释。现在可以忽略它们。
如果你等不及的话,这里有一个来自 StackOverflow 的关于intptr_t
和void*
之间区别的牛逼解释:一个
void*
应该指向某个对象。尽管现代实用性很高,一个指针不是一个内存地址。好吧,它通常/可能/总是(!) 持有一个,但它不是一个数字。它是一个指针。它指向一个东西。
而一个intptr_t
则不是。它是一个整数值,可以安全地转换成一个指针,或者可以从一个指针安全地转换得到,所以你可以用它来处理老掉牙的API。
这就是为什么你可以在一个intptr_t
上做更多数字和位操作,而在一个void*
上不能。
1.1.1.1.3 变量的作用范围和存活时间
变量存放在哪里?它们的生命周期有多长?我们已经弄清楚变量存放在 RAM 内存中。然而,RAM 内存有几个部分。事实证明,变量最终存放在的部分决定了它们的生命周期。首先,我们先快速看一下 MCU 中的 RAM 内存。
1.1.1.1.3.1 RAM 内存
MCU 通常会将大块的 RAM 分配给所谓的“外设”。
MCU 中的外设是实际执行某些操作的“对象”(电路)。例如一个外设可以是:
- 模拟输入或输出引脚。
- 数字输入或输出引脚。
- UART连接。
要与这些外设进行交互,我们应该与它们的 SFR(Special Function Registers,特殊功能寄存器)交互。写这些 SFR 将使外设执行某些操作。读SFR我们将得到它们状态的反馈。
SFR 与 RAM 内存的某些部分进行了内存映射。对于作为程序员的我们来说,这看起来就像是与 RAM 内存进行简单的交互!内存映射可以在数据手册中找到。
RAM 内存的其余部分称为通用 RAM,可以用于其他任何目的。
我们现在先忽略 RAM 中的这部分。让我们来看看 RAM 中的通用区域。下图显示了 32 位 RAM 内存中的通用区域。
这是 32 位 MCU 中的 RAM 内存。每个内存单元宽度为 32 位(4 字节)。由于内存默认为按字节寻址,每个连续的内存单元地址递增 4。
对于这个特定的 MCU ,RAM 从地址 0x2000_0000
开始,到 0x2004_FFFC
结束(最后一个 32 位内存单元的地址为 0x2004_FFFC
。最后一个字节的地址为 0x2004_FFFF
),总共 320KB:
如图中所示,RAM 内存被分成三部分:
- 数据区(data)
- 未初始化数据区(bss)
- 栈(stack)
前两个部分用于长期存在的具有固定地址的变量:
data 区用于在启动时具有非零值的变量,当然,刚启动时,RAM 内存中的所有位都是零(或者只是随机位)。因此,需要一个启动脚本将这些初始值从 Flash 程序存储器加载到 RAM 内存中。这样的启动脚本可以用 C 编写,但通常用汇编语言编写。
而 bss 区用于初始化为零的变量。
栈通常从 RAM 的顶部开始,并向下增长。这样的堆栈被称为降序堆栈。相反,从 RAM 底部开始的堆栈称为升序堆栈。每次调用新函数时,会将一帧数据压入到栈上(使其稍微增长)。这个“数据帧”称为栈帧,它属于被调用函数,并在函数运行期间存在。在这个栈帧中,我们能够找到:
- 函数的返回地址——函数完成后接续执行的位置在哪里?
- CPU 寄存器的保存副本,这些寄存器可能会被函数修改。通过这些副本,在完成函数后可以恢复 CPU 寄存器。
- 参数——传递给函数的任何内容。
- 局部变量——我们在函数体内声明和定义的所有变量。
换句话说,栈帧包含函数运行所需的所有局部、短期数据。完成后,栈帧从栈中弹出,使其再次缩小。
栈可以增长直到达到 data 区和 bss 区。继续增长会导致栈溢出错误!
1.1.1.1.3.2 变量存放在哪里?
我们仍然需要搞懂这个问题:变量位于哪里?上面的 RAM 内存小节中已经给出了一些提示:
-
长期存在且具有固定地址的变量被分配在 data 和 bss 内存段中。
-
仅在函数调用期间存在的局部变量被分配在属于被调用函数的堆栈帧中。例如:
- 函数参数。
- 函数体中声明和定义的局部变量。
RAM 中的堆栈部分。它们在其函数执行完毕后从堆栈中弹出(并被销毁)。
现在我们清楚的是:如果我们知道了变量存在的位置,我们也就知道它将存活多久。
1.1.1.1.3.3 变量的作用范围有多广?
另一个重要问题是:变量的作用范围有多广?换句话说,它的作用域是什么?仅仅通过查看存储位置是无法回答这个问题的。可以肯定的是,存储位置可以提供一些线索,但并不总是能给出确定的答案。
存活在堆栈上的变量始终只有有限的作用域。这类变量只能从它们所属的函数内部访问。存活在 data 段或 bss 段的变量可以具有全局作用域(在任何地方都可以访问)、文件作用域(只能在编译单元[编译单元是一个.c文件以及它所包含的所有内容(通常是一个或多个头文件)]内访问)甚至是局部作用域(只能在单个函数内部访问)。下面的表格显示了如何区分它们。
1.1.1.1.3.4 变量小结
以下表格提供了我们可以定义的所有类型变量的完整概览:
变量 | 定义 | 存储位置 | 生命周期 | 作用域 |
---|---|---|---|---|
全局变量 | 在任何函数之外定义,但通常定义在文件的顶部:int v; | data 或 bss 段中的固定地址 | 程序的整个执行期间 | 整个代码库。全局变量可以在其他源文件中访问,前提是你在那里放置了一个该变量的声明(不是定义!)。通常,这是通过包含一个头文件来完成的,在头文件中,该变量被使用 extern 关键字声明 |
仅限文件的全局变量 | 在任何函数之外定义,但通常定义在文件的顶部:static int v; | data 或 bss 段中的固定地址 | 程序的整个执行期间 | 限定于文件内 |
局部静态变量 | 在任何函数内定义:int foo(void) { static int v; ... } | data 或 bss 段中的固定地址 | 程序的整个执行期间 | 仅限于函数内。被调函数被调用后其值不变,直到主调函数调用另一个被调函数! |
自动变量 | 在函数体中定义:int foo(void) { auto int v; ... } 通常不需要 auto 关键字。函数内定义的变量默认为自动变量,除非编译器配置了选项使它们默认为静态变量。这种情况非常少见,所以我们可能永远不会遇到默认为静态变量的情况。 | 栈中的地址。更具体地说,自动变量存储在属于其定义函数的栈帧中。这个栈帧不过是栈上的一块数据,用于保存函数正常运行所需的所有临时内容(返回地址、参数值、自动变量等)。当函数执行完成时,相应的栈帧会从栈中弹出并销毁。 | 函数处于被调用运行期间 | 仅限于该函数 |
自动参数 | 在函数头部定义:int foo(auto int x;) { ... } 通常不需要使用 auto 关键字。函数参数默认为自动,除非编译器已激活选项使它们默认为静态。这非常不常见,所以你可能永远不会遇到。 | 栈中的地址。更具体地说,参数存储在属于其定义函数的栈帧中。这个栈帧不过是栈上的一块数据,用于保存函数正常运行所需的所有临时内容(返回地址、参数值、自动变量等)。当函数执行完成时,相应的栈帧会从栈中弹出并销毁。 | 直到函数运行结束 | 仅限于该函数 |
寄存器变量 | 在函数体中定义:int foo(void) { register int v; ... } | 存储在 CPU 寄存器。该关键字是对编译器的提示,表明该变量可以放入 CPU 寄存器中。是否这样做取决于编译器。在 PC 上,register 关键字在 RAM 是一个单独的芯片时很有用。在 MCU 上,RAM(通常)与 CPU 在同一封装中,使其速度非常快。因此,定义一个 register 变量通常是没有意义的。 | 直到函数运行结束 | 仅限于该函数 |
1.1.1.1.3.5 名称冲突
如果两个变量或参数具有相同的名称会发生什么?这种情况何时被允许,何时会引起“名称冲突”?要回答这个问题,首先必须对“变量作用域”概念有一个很好的理解:
作用域是程序中变量有效的区域。在该区域之外,变量无法被访问。
上一小节中的表格清楚地显示了不同变量类型的作用域。当两个变量(或参数)处于彼此作用域之外时,它们可以拥有相同的名称而不会出现任何问题:
void foo(int n)
{
y += n;
}
void bar(int n)
{
z += n;
}
foo()
中的参数 n
与 bar()
中的参数 n
完全没有关系。
测试程序如下:
#include <stdio.h>
int y = 1, z = 2;
void foo(int n)
{
y += n;
printf("y = %d\n", y);
}
void bar(int n)
{
z += n;
printf("z = %d\n", z);
}
int main(void)
{
foo(1);
bar(2);
}
上例测试程序编译链接后的运行结果如下:
对于在函数体内声明的局部变量也是如此。在下面的示例中,foo()
中的 x
与 bar()
中的局部变量 x
完全无关:
void foo(void)
{
int x = 5;
y += x;
}
void bar(void)
{
int x = 7;
z += x;
}
测试程序如下:
#include <stdio.h>
int y = 1, z = 2;
void foo(void)
{
int x = 5;
y += x;
printf("y = %d\n", y);
}
void bar(void)
{
int x = 7;
z += x;
printf("z = %d\n", z);
}
int main(void)
{
foo();
bar();
}
运行结果:
但是,如果有一个与之相同名称的全局变量会发生什么呢?看看下面这段代码:
// 声明和定义全局变量 n
int n;
void foo(int n)
{
// 局部变量 n 隐藏了全局变量 n。这使得从这里无法访问全局变量 n。
y += n;
}
void bar(int x)
{
// 这里没有声明局部变量 n,所以我们可以访问全局变量 n。
z = n + x;
}
正如你所看到的,局部变量(或参数)优先于全局变量。全局变量被隐藏,因此完全无法访问。
测试程序:
#include <stdio.h>
// 声明和定义全局变量 n
int n;
void foo(int n)
{
// 局部变量 n 隐藏了全局变量 n。这使得从这里无法访问全局变量 n。
printf("n in foo: %d\n", n);
}
void bar(int x)
{
// 为了防止编译器报告警告信息
x = x;
// 这里没有声明局部变量 n,所以我们可以访问全局变量 n。
printf("n in bar: %d\n", n);
}
int main(void)
{
n = 3;
foo(6);
bar(6);
}
运行结果:
1.1.1.1.4 声明和定义
- 声明:告诉编译器一个具有特定名称、类型和大小的变量存在。编译器随后知道足够的信息来与变量交互。然而,这一阶段不会进行内存分配。
- 定义:编译器在 RAM 内存中寻找一些空闲空间,并为变量分配一个或多个字节。变量现在有一个地址了。事实上,这一阶段变量具有一个相对地址,绝对地址在链接阶段解析,但现在我们可以忘记这些。
- 初始化:我们给变量赋一个值。这一步并不是严格必需的。如果我们不初始化变量,它将包含一个随机值。
虽然我们对初始化相当了解,但对大多数程序员来说,声明和定义之间的区别有点模糊,这是因为它们通常在同一行上实现!让我们考虑几个例子。
1.1.1.1.4.1 在同一行上声明和定义
这行代码既声明又定义了变量 x
:
float x; // <- 变量 x 在这里声明并定义
它实际上是在说:“创建一个名为 x
的 float
类型变量。在 RAM 内存中从地址 0x0010
开始预留四个字节。” 这里的 0x0010
只是一个示例,同时它是一个相对地址。如前所述,在链接阶段会解析绝对地址。
你也可以在同一行上进行声明、定义和初始化:
float x = 42; // <- 变量 x 在这里声明、定义并初始化
如果你真的想要花哨一点,你还可以一次为多个变量这样做:
float x = 42, y = 38;
测试程序:
#include <stdio.h>
int main(void)
{
char a; // <- 变量 a 在这里声明并定义
a = 97; // <- 变量 a 在这里被赋值
printf("char variable a = %c\n", a);
int b = 233; // <- 变量 b 在这里声明、定义并初始化
printf("integer variable b = %d\n", b);
float x = 42, y = 38; // <- 变量 x、y 在这里声明、定义并初始化
printf("float variable x = %.f, y = %.f\n", x, y);
return 0;
}
运行结果:
1.1.1.1.4.2 分行编写声明和定义
在某些情况下,先声明变量,然后再定义它是有用的。
extern
关键字
extern float x; // <- 变量 x 在这里声明
...
float x; // <- 变量 x 在这里定义
extern
关键字告诉编译器变量 x
是"在外部定义"的——就像"在别处定义"一样。因此,带有 extern
关键字的行仅声明变量。
一个纯粹的声明(没有定义)就好像告诉编译器:“oi!这里有一个名为 x
的 float
类型变量,但它是在别的地方定义的,所以现在不要为它预留任何内存。”
变量声明 vs 变量定义
理解变量声明和变量定义之间的区别非常重要:
声明一个变量意味着你告诉编译器它的名称、类型和大小。一般来说就是告诉编译器存在这个变量。编译器之后才知道足够的信息来与它交互。
而定义,则意味着除了声明所做的一切之外,还在 RAM 内存中为变量分配了一个或多个字节。我们可以认为:
定义 = 声明 + 预留空间
在 C 中,当你声明变量时,也同时定义了它,除非你使用 extern
关键字,否则两者同时发生。
现在,将声明和定义分开的用意是什么?这在有多个文件的情况下我们才能够体会到。想象一下,我们在文件 foo.c
中创建了变量 v
,而我们也想在 bar.c
中使用它。惯性思维下我们是这样做的:
我们希望 foo.c
和 bar.c
中的变量 v
是相同的程序实体(这是因为它具有全局作用域,稍后详细讨论这一点)。因此,这两个文件中的语句 v = 5
和 v = 7
将写入相同的内存位置。
然而,我们下意识地在两个文件中都定义了这个变量。还记得吗?定义变量意味着为其分配内存。因此,我们要求编译器为此变量预留内存……两次!如果编译器这样做,变量 v
将有两个内存位置,那将是一团乱麻。幸好,编译器不允许我们这样做。它会报告“multiply defined symbol(多次定义的符号)”错误。
那么,我们如何在多个文件中使用相同的变量呢?解决方案很简单,确保它只被定义一次!然后你可以在许多其他文件中声明它,随便你声明多少次——都没问题:
我们之前说过,声明对于编译器与变量进行交互已经足够了。这正是我们在文件 bar.c
中所做的。我们只在那里声明了变量,然后与之进行交互。
1.1.1.1.5 头文件
通常的做法是在一个单独的头文件中声明变量。让我们来看看为什么要如此大费周章。
假设我们在文件 foo.c
中定义了变量 v1
、v2
和 v3
。我们希望在其他几个文件中使用它们,所以我们在所有这些文件中都声明它们:
现在,这就产生了很多的重复代码。用一个单一的语句来替换声明块岂不更好?这就是头文件的用处。我们可以把所有的声明放在一个头文件中,并在所有其他文件中包含该文件:
编译器预 CPU 会把包含语句以文本形式替换为头文件的内容。我们将头文件命名为 foo.h
仅仅是因为它包含了在 foo.c
中定义(或者你可以说‘创建’)的变量的声明。
让 foo.c
成为一个源文件,那么相应的头文件 foo.h
应该包含你想在其他文件中使用的所有变量的声明,这些变量是在 foo.c
中创建(定义)的。
简而言之:我们在头文件中放入了在 foo.c
中那些想暴露给其他文件的程序实体的声明。
测试程序:
bar.h
#ifndef _BAR_H_
#define _BAR_H_
extern int flob(void);
#endif
bar.c
#include "foo.h"
int flob(void)
{
v1 = v2 + v3;
return v1;
}
baz.h
#ifndef _BAZ_H_
#define _BAZ_H_
extern int flub(void);
#endif
baz.c
#include "foo.h"
int flub(void)
{
v1 = v2 - v3;
return v1;
}
foo.h
#ifndef _FOO_H_
#define _FOO_H_
extern int v1;
extern int v2;
extern int v3;
extern int foo(void);
#endif
foo.c
int v1;
int v2;
int v3;
int foo(void)
{
v1 = 5;
v2 = 3;
v3 = 8;
return v1;
}
test.c
#include <stdio.h>
#include "bar.h"
#include "baz.h"
int main(void)
{
printf("value of v1 in foo is: %d\n", foo());
printf("value of v1 in flob is: %d\n", flob());
printf("value of v1 in flub is: %d\n", flub());
return 0;
}
运行结果:
1.1.1.2 字面值和常量
字面值和常量常常被混淆或误用,我们来搞清楚它们之间的区别。
1.1.1.2.1 字面值
观察下面这个简单的 C 程序:
unsigned int a;
unsigned int b;
unsigned int c;
int main(void)
{
a = 5; // 5是一个字面值
c = a + b;
}
字面值是“硬编码”的值。它们可以是数字、字符或字符串。它们可以用多种格式表示:十进制、十六进制、二进制、字符等等。
字面值是一个值,比如一个数字、字符或字符串,可以赋给一个变量或常量。它也可以直接作为函数参数或表达式中的操作数使用。
有四种基本类型的字面值:整型、浮点型、字符和字符串字面值。
1.1.1.2.1.1 整型字面值
整数可以用十进制、十六进制或二进制表示:
- 十进制整数
一个十进制整数不能以0
开头,除了0
本身。它不能包含小数点,因为它是一个整数(显然)。有效的十进制整数有:
0 5 127 -1021 65535
无效的十进制整数有:
32,767 25.0 1 024 0552
- 十六进制整数
十六进制整数必须以0x
或0X
开头。它可以包含数字0-9
和A-F
或小写字母a-f
。有效的十六进制整数有:
0x 0x1 0x0A2B 0xBEEF
无效的十六进制整数有:
0x5.3 0EA12 0xEG 53h
- 二进制整数
二进制整数必须以0b
或0B
开头。它只能包含数字0
和1
。有效的二进制整数有:
0b 0b1 0b01010001100001111
无效的二进制整数有:
0b1.0 01100 0b12 10b
1.1.1.2.1.2 浮点型字面值
浮点型字面值与十进制整数字面值非常相似,但允许有小数点。使用 e
表示法来指定指数。
这样的表示法如 2.56e-5
代表着:
2.56
×
1
0
−
5
2.56\times10^{-5}
2.56×10−5
有效的浮点型字面值有:
2.56e-5 10.4378 48e8 0.5 10f
无效的浮点型字面值有:
0x5Ae-2 02.41 F2.33
1.1.1.2.1.3 字符字面值
字符字面值用单引号 '
表示。它可以包含任何单个可打印字符,以及使用转义序列(如 '\n'
表示换行字符)的任何单个不可打印字符。
反斜杠加上其后的字符被视为一个单一字符,并且有一个单一的 ASCII 值。
一般来说,反斜杠表示其后的字符是特殊的。它可以表示一个不可打印的字符,例如 '\0'
代表 NUL
,或者表示一个字符串字面值中不能单独使用的字符,比如双引号或反斜杠本身,在字符串中分别表示为 "\""
和 "\\"
。
有效的字符有:
'a' 'T' '\n' '5' '@' ' '(空格)
无效的字符有:
'me' '23' '''(应使用'\'',必须转义引号)
1.1.1.2.1.4 字符串字面值
字符串字面值在双引号 "
内指定。它可以包含任何可打印或不可打印字符(使用转义序列)。字符串字面值总是以空字符 '\0'
结尾。有效的字符串有:
"Embeetle" "Hi\n" "info@embeetle.com" "He said, \"Hi\""
无效的字符串有:
"He said, "Hi""
字符串是数组的特例。如果声明时没有指定维度,空字符 '\0'
会自动添加到末尾:
char color[] = "RED";
// 存储为:
color[0] = 'R'
color[1] = 'E'
color[2] = 'D'
color[3] = '\0'
下图中黄色高亮部分展示了字符串使用字符数组进行存储,以及单个字符的布局情况:
但是,如果你用(错误的)维度声明它,也可能搞砸:
上图中,编译器报错提示我们:初始化字符串’char [3]'过长 [fpermissive]。这是因为我们试图将一个超过数组大小的字符串赋值给一个固定大小的字符数组,结束符 \0
没有位置预留给它存放。
char color[3] = "RED";
// 存储为:
color[0] = 'R'
color[1] = 'E'
color[2] = 'D'
这不是一个完整的字符串,因为末尾没有以空字符 '\0'
终结!
image-20240425154749223_88f2396e77fae.png#pic_center
1.1.1.2.2 字面值限定符
与变量一样,字面值可以被限定为强制编译器将它们视为特定的数据类型。限定符是通过在字面值后面添加后缀来指定的:
U
或u
表示无符号:30U
L
(首选)或l
表示长整型:25L
F
或f
表示浮点数:10f
或10.25f
U
和 L
后缀可以组合使用,从而得到无符号长整型字面值:0xF5UL
,但是 U
必须始终放在 L
的前面。
没有后缀的整数被假定为有符号短整型。你要小心这种未经限定的字面值:
image-20240425161414606_d7d93944e8450.png#pic_center
换句话说,像 10
这样的数字将被解释为一个有符号短整型数。在一些算术表达式中,可能需要强制编译器将其视为浮点值。考虑这个例子:
int a = 10;
int b = 4;
float c;
c = a / b; // c 现在的值是 2.000000 !
输出结果如下图所示:
其中一个操作数必须是浮点类型,这样结果也会是浮点类型:
int a = 10;
float b = 4.0f;
float c;
c = a / b; // c 现在的值是 2.500000
这时输出结果正确,如下图所示:
1.1.1.2.3 符号常量
符号常量是在整个程序中用来表示某个固定值的“标签”或“名称”。例如,你可以将 PI
定义为一个符号常量,用来表示值 3.14159
。
在 C 中有两种定义常量的方法:文本替换标签和常量变量。
1.1.1.2.3.1 文本替换标签
可以这样创建一个文本替换标签:
#define PI 3.14159
编译器预处理器将会把任何代码处(除了字符串中的情况)出现的 PI
替换为 3.14159
。预处理器不关心变量类型、数字、字符或与 C 语法相关的任何其他内容。它只执行文本替换。
注意,#define
指令不用分号结尾,除非你希望它成为文本替换的一部分。
1.1.1.2.3.2 常量变量
可以这样创建一个常量变量:
const float PI = 3.14159;
常量变量这个术语听起来像是一个完全矛盾的描述,但这是准确的描述。在前一小节中我们讨论了变量的概念:它包括一个名称和数据类型,在定义时会分配一个(一个或多个)内存单元。所有这些在这里仍然适用,但有一个重要的区别。当常量变量被定义时,它的值永远不会改变:
常量变量是一个永远不会改变其值的变量。
因此,当你像上面那样定义一个常量变量 PI
时,链接器将分配 4 字节的 RAM 内存来存储表示数 3.14159
的值。在 MCU 上,资源是有限的。在绝大多数情况下,最好使用 #define
来定义常量!
1.1.1.3 运算符
1.1.1.3.1 算术运算符
一个算术表达式包含一个或多个操作数(例如:变量如 x
和 y
),这些操作数与运算符(例如 +、- 等)组合在一起产生一个结果。在 C 语言中有 9 个算术运算符:
运算符 | 操作 | 示例 | 结果 |
---|---|---|---|
* | 乘法 | x * y | x 和 y 的乘积 |
/ | 除法 | x / y | x 除以 y 的商 |
% | 取模 | x % y | x 除以 y 的余数 |
+ | 加法 | x + y | x 和 y 的和 |
- | 减法 | x - y | x 和 y 的差 |
+ (一元) | 正数 | +x | x 的值 |
- (一元) | 负数 | -x | x 的负值 |
++ (一元) | 自增 | x++ | 使用 x,然后将 x 加 1 |
++ (一元) | 自增 | ++x | 先将 x 加 1,然后使用 x |
-- (一元) | 自减 | x-- | 使用 x,然后将 x 减 1 |
-- (一元) | 自减 | --x | 先将 x 减 1,然后使用 x |
在使用除法 /
运算符时要非常小心。如果你将一个整数除以一个整数,结果也将是一个整数!任何小数部分都将丢失:
int a = 10;
int b = 4;
float c;
c = a / b; // c 现在的值为 2.000000!
两个操作数中至少一个必须是浮点类型,这样结果也将是浮点类型:
int a = 10;
float b = 4.0f;
float c;
c = a / b; // c 现在的值为 2.500000
C 临时将一个操作数的类型提升为另一个操作数的较大类型。这是一个“隐式类型转换”的特殊情况:
20240425173253_SQN0cUNWrF2_dfa40fd52cc15.png#pic_center
提升层次结构如下(来自 Microchip 教程的图表):
测试程序如下:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int x = 10;
int y = 4;
int resInt;
float resFloat;
// 乘法
resInt = x * y;
printf("x * y = %d * %d = %d\n", x, y, resInt);
// 除法(整数)
resInt = x / y;
printf("x / y = %d / %d = %d\n", x, y, resInt);
// 除法(浮点数)
resFloat = (float)x / y;
printf("x / y = %d / %d = %f\n", x, y, resFloat);
// 取模
resInt = x % y;
printf("x %% y = %d %% %d = %d\n", x, y, resInt);
// 加法
resInt = x + y;
printf("x + y = %d + %d = %d\n", x, y, resInt);
// 减法
resInt = x - y;
printf("x - y = %d - %d = %d\n", x, y, resInt);
// 正数
resInt = +x;
printf("+x = +%d = %d\n", x, resInt);
// 负数
resInt = -x;
printf("-x = -%d = %d\n", x, resInt);
// 自增(后置)
resInt = x++;
printf("x++ (后置): result = %d, x = %d\n", resInt, x);
// 重置 x
x = 10;
// 自增(前置)
resInt = ++x;
printf("++x (前置): result = %d, x = %d\n", resInt, x);
// 重置 x
x = 10;
// 自减(后置)
resInt = x--;
printf("x-- (后置): result = %d, x = %d\n", resInt, x);
// 重置 x
x = 10;
// 自减(前置)
resInt = --x;
printf("--x (前置): result = %d, x = %d\n", resInt, x);
exit(0);
}
运行结果如下:
1.1.1.3.2 赋值运算符
赋值运算符 =
用于赋值语句:
赋值语句是一条将值赋给一个变量的语句。
有两种类型的赋值语句:简单和复合。
1.1.1.3.2.1 简单赋值
简单赋值可以是这样的:
int a, b, c
a = 5;
b = 5 + 3;
c = a + b;
我们可以轻易的计算右边的表达式,并将其赋给左边的变量。
1.1.1.3.2.2 复合赋值
在复合赋值中,变量出现在表达式的两边:
int a, b, c
x = x + 5;
// 这个操作可以理解为:
// x 的新值将被设置为
// 等于 x 的当前值加上 5
这个操作可以简写为:
int a, b, c
x += 5;
在 C 中存在以下简写的复合赋值运算符:
运算符 | 示例 | 结果 |
---|---|---|
+= | x += y | x = x + y |
-= | x -= y | x = x - y |
*= | x *= y | x = x * y |
/= | x /= y | x = x / y |
%= | x %= y | x = x % y |
&= | x &= y | x = x & y |
^= | x ^= y | x = x ^ y |
` | =` | `x |
<<= | x <<= y | x = x << y |
>>= | x >>= y | x = x >> y |
1.1.1.3.3 关系运算符
使用关系运算符,我们可以比较两个值,看看所做的比较是否为真或为假。如果条件为真,则表达式返回一个非零值(通常为 1);如果条件为假,则表达式返回 0。
运算符 | 操作 | 示例 | 结果 |
---|---|---|---|
< | 小于 | x < y | 如果 x 小于 y,则为 1,否则为 0 |
<= | 小于或等于 | x <= y | 如果 x 小于或等于 y,则为 1,否则为 0 |
> | 大于 | x > y | 如果 x 大于 y,则为 1,否则为 0 |
>= | 大于或等于 | x >= y | 如果 x 大于或等于 y,则为 1,否则为 0 |
== | 等于 | x == y | 如果 x 等于 y,则为 1,否则为 0 |
!= | 不等于 | x != y | 如果 x 不等于 y,则为 1,否则为 0 |
请注意:不要混淆 =
和 ==
!!!
赋值运算符 =
将右侧的表达式赋给左侧的变量。‘等于’ ==
运算符比较两侧。所以这样是正确的:
// 正确
if (x == 5)
{
// 做某事
}
但是这样是错误的:
// 错误!
if (x = 5)
{
// 做某事
}
括号中的表达式 (x = 5)
将始终为真,因为 x
被赋予了值 5
(一个非零值),而不是与 5
进行比较。如果你掉入了这个陷阱,那么将很难找到错误,因为编译器不会将其标记为错误。
在条件表达式中,任何非零值都被解释为 TRUE
,值 0 总是 FALSE
。
1.1.1.3.4 逻辑运算符
C 语言有三个逻辑运算符:逻辑与 &&
、逻辑或 ||
和逻辑非 !
:
运算符 | 操作 | 示例 | 结果 |
---|---|---|---|
&& | 逻辑与 | x && y | 如果 x != 0 并且 y != 0 ,则为 1,否则为 0 |
` | ` | 逻辑或 | |
! | 逻辑非 | !x | 如果 x == 0 ,则为 1,否则为 0 |
要小心所谓的短路!在 C 中,当检验逻辑操作时,如果已知结果,则不会评估所有子表达式。例如:
if ((a == 3) && (b == 5))
{
// 做某事
}
如果 (a == 3)
结果为 FALSE,则下一个子表达式 (b == 5)
将不会被检验。当然,这没什么大不了的。但是如果你在那里放置了赋值表达式:
if ((a == 3) && (b = c + 5))
{
// 做某事
}
这在 C 中是完全合法的(但是这是个坏习惯)。第二个子表达式 (b = c + 5)
只有在第一个表达式 (a == 3)
为 TRUE 时才会被检验。因为如果第一个表达式为 FALSE,C 将会短路,不再检验第二个子表达式,因为已经没有必要再去检验整个表达式的结果了。然而,这意味着变量 b
可能不会被赋予值 c + 5
,导致你代码中的意外后果!
在这种逻辑运算中使用函数调用时也存在类似的问题,这是一个非常常见的做法。第二个函数可能会被检验,也可能不会。记住这一点!
1.1.1.3.5 位运算符
位运算符在位级别上执行布尔代数运算。以位与 &
运算符为例:
这将得到:
C 语言有以下位运算符:
运算符 | 操作 | 示例 | 结果(对于每个位位置) |
---|---|---|---|
& | 位与 | x & y | 如果 x 和 y 中都是 1,则为 1;如果 x 或 y 中至少有一个为 0,则为 0 |
` | ` | 位或 | `x |
^ | 位异或 | x ^ y | 如果 x 和 y 中的位不同,则为 1;如果 x 和 y 中的位相同,则为 0 |
~ | 位非(取反) | ~x | 0 变为 1,1 变为 0 |
要小心,不要混淆逻辑运算符和它们相对应的位运算符!
不要将逻辑与运算符 &&
与按位与运算符 &
混淆。逻辑或运算符 ||
与按位或 |
运算符也是如此。
逻辑运算符在整个数值上执行布尔运算——如果为零则被视为 FALSE,否则为 TRUE。
位运算符在操作数的相应位上执行布尔运算。
1.1.1.3.6 移位运算符
移位运算符将操作数中的位左移或右移指定数量的位:
运算符 | 操作 | 操作 | 结果 |
---|---|---|---|
<< | 左移 | x << y | 将 x 向左移动 y 位 |
>> | 右移 | x >> y | 将 x 向右移动 y 位 |
1.1.1.3.6.1 左移
在左移中,左侧移出的位被丢弃,右侧始终被 0 填充。下面是一个无符号数的示例:
以及类似的,有符号数的示例:
1.1.1.3.6.2 右移
在右移中,右侧移出的位也被丢弃,但左侧不总是填充为零!如果操作数是无符号的,则空位将填充为零:
如果操作数是有符号的,则左侧的空位将用移位前最高有效位的值填充。这称为 ‘符号扩展’:
1.1.1.3.6.3 用于 2 的幂算术的移位运算
左移操作实际上是 2 的幂的乘法:
右移操作是相反的:
让我们考虑上面给出的示例:
-
250 * 2^2 = 1000
这与结果 232 不同。这个差异可以解释为左移操作中丢失了左边的两位。换句话说:数字1000
太大了,无法放入一个 8 位变量中!如果我们在一个 16 位变量上执行这个操作,那么它将有效。 -
-6 * 2^2 = -24
结果是正确的。 -
250 / 2^2 = 62.5
结果 62 很接近。我们正在使用整数,所以你应该接受四舍五入误差。这是完整的操作:250 / 2 = 125 余 0 125 / 2 = 62 余 1
-
-6 / 4 = -1.5
结果 -2 也是接近的:-6 / 2 = -3 余 0 -3 / 2 = -1 余 1
左移和右移比实际的乘法或除法要快得多。无论何时都要使用它们!
1.1.1.3.7 内存地址运算符
以下运算符在这里显示以供完整性。我们稍后会讨论指针。
运算符 | 操作 | 示例 | 结果 |
---|---|---|---|
& | 地址 | &x | x 的指针 |
* | 间接引用 | *p | p 指向的对象或函数 |
[] | 下标 | x[n] | 数组 x 的第 n 个元素 |
. | 结构/联合成员 | x.y | 结构或联合 x 中名为 y 的成员 |
-> | 结构/联合成员的引用 | p->y | p 指向的结构或联合中名为 y 的成员 |
1.1.1.3.8 类型转换运算符
之前我们将一个字面值转换为 float
类型,方法是输入:4.0f
要将变量转换为特定类型,你需要使用转换运算符:(float)x
如果你忘记了,会发生以下情况:
int x = 10;
float y;
y = x / 4; // y = 2.000000
如果你正确进行了转换,你会得到:
int x = 10;
float y;
y = (float)x / 4; // y = 2.500000
类型转换将变量 x
在运算期间临时转换为 float
类型。这会强制操作使用 float
作为其类型,从而得到正确的结果。
1.1.1.4 分支选择
一个分支选择结构由一个或多个待评估的条件以及一个或多个如果条件为真则执行的语句组成。另外,它可以有在条件被确定为假时执行的其他语句。一个典型的分支选择结构如下所示:
在 C 中,任何非零且非 NULL 的值被认为是 TRUE。如果该值为零或 NULL,则被认为是 FALSE。
NULL 指针(空指针)通常定义为:#define NULL 0
或:#define NULL (void *)0
C 程序员务必要理解:在指针语境中,NULL 和 0 是可以互换的,并且未经强制类型转换的 0 是完全可接受的。任何使用NULL(而不是 0)的情况都应该被视为一个温和的提醒,即涉及到一个指针;但程序员不应依赖它(无论是为了我们自己的理解还是编译器的)来区分指针 0 和整数 0。
只有在指针语境中,NULL 和 0 才等价。NULL 不应在需要另一种类型的 0 时使用。
C 语言没有布尔数据类型,这意味着布尔表达式返回的是整数:
- 如果表达式评估为 FALSE,则返回 0
- 如果表达式评估为 TRUE,则返回非零值(通常返回 1,但不保证)
1.1.1.4.1 If 语句
if 语句由布尔表达式后跟一个或多个语句组成:
该表达式 expression
被评估为 TRUE(!= 0) 或 FALSE(== 0) 。如果为 TRUE,则执行语句 statement
。否则,将执行 if
语句后的代码:
if (x)
{
printf("foo!");
}
if (x == 1)
{
printf("bar!");
}
在上面的代码块里,如果 x
中是任何的非零值,则第一个 if
语句打印 “foo!
”;如果 x
正好等于 1
,则第二个 if
语句打印 “bar!
”。
运行结果:
你可以随意地嵌套 if
语句:
if (x)
{
printf("foo!");
if (x == 1)
{
printf("bar!");
}
}
运行结果:
你也可以在最后放置一个 else
语句:
if (x)
{
printf("foo!\n");
}
else
{
printf("x 一定是 0!\n");
}
最后但同样重要的——我们可以像下面这样链接 if-else-if
语句:
if (x == 1)
{
printf("x 一定是 1!");
}
else if (x == 2)
{
printf("x 一定是 2!");
}
else if (x == 3)
{
printf("x 一定是 3!");
}
else
{
printf("x 一定是其他值!");
}
1.1.1.4.2 Switch 语句
当我们需要一系列 if-else-if 语句时,switch
语句是处理代码的一种更优雅的方式。唯一的缺点是条件表达式必须都评估为整数类型的结果(int
或 char
),而 if
语句可以在其条件表达式中使用任何数据类型。
switch (x)
{
case 1:
printf("x 等于 1");
break;
case 2:
printf("x 等于 2");
break;
case 3:
printf("x 等于 3");
break;
default:
printf("x 等于其他值");
break;
}
需要注意的是,每个 case
语句块都由一条 break;
语句结束,这消除了 fall through。没有 break 语句,后续 case
的所有语句都将被执行,如果 x == 1
,则会得到此输出:
x 等于 1
x 等于 2
x 等于 3
x 等于其他值
break;
语句确保仅执行特定 case
下的语句。
1.1.1.4.3 循环
循环语句允许多次执行一个语句或一组语句:
让我们考虑 C 语言中的三种循环类型:while
循环、for
循环和 do ... while
循环。
1.1.1.4.3.1 For 循环
使用 for
循环,你可以有效地编写需要执行特定次数的循环:
如上图所示,for
循环由四个部分组成:
-
initialization(初始化)
初始化语句仅在开始时运行一次。通常在这儿初始化一个循环计数器变量。
在上面的例子中,计数器变量是在for
循环之前声明的——在for
循环上一行。在现代编译器中,我们可以在for
循环中同时进行声明和初始化:for (int i = 0; i < 4; i++) {...}
-
test condition(测试条件)
在初始化语句之后,循环开始。首先评估测试条件,如果为 TRUE,则可以运行循环体。 -
update statement(更新语句)
循环体运行后,更新语句被执行一次。在这儿通常会递增计数器变量。然后评估测试条件,以查看是否应该再次运行循环体。 -
loop body(循环体)
循环体包含测试条件成立时应执行的所有语句。
下图显示了 for
循环的执行过程:
下面的 GIF 动图演示了 for
循环的动态过程:
只要测试条件为 TRUE,就会按照 测试条件 --> 执行循环体 --> 更新计数器
继续循环。然而,测试条件并不是退出 for
循环的唯一方式!你可以在循环体中的任何地方放置一个 break;
语句以跳出循环,退出是立即的,主体中的任何其他代码将被跳过。
1.1.1.4.3.2 While 循环
while
循环比 for
循环简单。它没有初始化,也没有更新语句,你应该自己做这些事情:
int i = 0;
while (i < 4)
{
printf("第 %d 次遍历循环体\n", i);
i++;
}
你会注意到动态过程更简单:
就像 for
循环一样,如果测试条件在初始化后立即失败,则 while
循环的循环体可能根本不会被执行。
与 for
循环一样,你可以使用 break;
语句退出 while
循环。
1.1.1.4.3.3 Do … while 循环
与 for
和 while
循环不同,do ... while
循环保证至少执行其主体一次。这是因为它在循环底部评估测试条件。
以下是 do ... while
循环的示例:
int i = 0;
do
{
printf("第 %d 次遍历循环体\n", i);
i++;
} while (i < 4)
这就是为什么在选择 while
或 do ... while
时应该始终小心的原因:
1.1.1.4.3.4 Break 和 Continue 语句
break;
语句允许你立即退出任何循环,跳过循环体中的任何剩余代码。不会发生新的迭代。
continue;
语句有不同的效果。它导致程序跳回到循环顶部(测试条件),而不完成当前迭代。下一次迭代可能会发生也可能不会(取决于测试条件):
int i = 0;
while (i < 4)
{
i++;
if (i == 2) continue; // 跳回
printf("第 %d 次遍历循环体\n", i);
}
// 预期输出:
// Loop iteration 1
// Loop iteration 3
// Loop iteration 4
测试结果:
可以看到,i
等于 2 时,不会执行下一行的打印语句!
1.1.1.5 函数
1.1.1.5.1 简介
函数是一个自包含的程序段,一组由 {..}
包围的语句。在其他编程语言中,函数通常被称为过程或子程序。
在 C 语言中,函数概念上类似于数学课中的代数函数:
这是你在 C 语言中编写函数的方式:
例如:
/**
* 函数返回输入的 x 和 y 中的最大值。
*/
int maximum(int x, int y)
{
int z;
if(x < y)
{
z = y;
}
else
{
z = x;
}
return z;
}
1.1.1.5.1.1 函数头
函数的第一行被称为函数头。它包含了关于如何与函数接口的所有信息——函数的输入和输出。它列出了函数接受的所有参数及其数据类型,还定义了函数返回的数据类型。
1.1.1.5.1.2 返回语句
虽然函数只能通过 return
语句返回一个值,但你可以有多个条件执行的 return
语句:
/**
* 函数返回输入的 x 和 y 中的最大值。
*/
int maximum(int x, int y)
{
if(x < y)
{
return y;
}
else
{
return x;
}
}
如果 return
语句不返回任何内容(return
后紧跟分号 ;
)或者根本没有 return
语句,则返回类型为 void
。
1.1.1.5.1.3 参数
函数的参数声明方式与普通变量一样,但在函数括号内用逗号分隔的列表中声明。
在函数头中声明的参数名称是局部变量,意味着它们在函数外部无效。
1.1.1.5.2 函数声明
就像变量一样,函数在使用之前必须声明。函数声明只是告知编译器以下信息:
- 函数名
- 参数及其类型
- 返回类型
有了这些信息,编译器就知道如何与函数交互。实际的函数定义(即函数体)可以稍后再写。
1.1.1.5.2.1 使用函数原型声明
可以通过提供函数原型来声明函数,这基本上就是函数头的一个副本:
int maximum(int x, int y);
在函数声明中,你可以省略参数名称:
int maximum(int, int);
在以下示例中,函数 maximum()
在文件顶部声明,而在底部定义:
int a = 5, b = 10, c;
// 在此声明函数 maximum()
int maximum(int x, int y);
/**
* 主函数,程序从这里开始。
*/
int main(void x)
{
c = maximum(a, b);
printf("最大值是 %d\n", c);
}
// 在此定义函数 maximum():
int maximum(int x, int y)
{
if(x < y)
{
return y;
}
else
{
return x;
}
}
运行结果如下图所示:
1.1.1.5.2.2 在同一行声明和定义
如果你没有在文件顶部编写函数原型(函数原型不一定要放在文件的顶部,但通常这是它的位置。或者放在头文件中——稍后会详细说明),函数将在定义的同时被声明,两者将同时发生。但要小心,你有可能导致函数未及时声明的问题:
int a = 5, b = 10, c;
/**
* 主函数,程序从这里开始。
*/
int main(void x)
{
// 错误:函数 maximum() 此时尚未声明!
c = maximum(a, b);
printf("最大值是 %d\n", c);
}
// 在此声明和定义函数 maximum():
int maximum(int x, int y)
{
if(x < y)
{
return y;
}
else
{
return x;
}
}
为了避免这种“未定义函数错误”,你必须将函数原型放在文件顶部(如前文所示)或将函数定义放在其使用之前:
int a = 5, b = 10, c;
// 在此声明和定义函数 maximum():
int maximum(int x, int y)
{
if(x < y)
{
return y;
}
else
{
return x;
}
}
/**
* 主函数,程序从这里开始。
*/
int main(void x)
{
c = maximum(a, b);
printf("最大值是 %d\n", c);
}
1.1.1.5.2.3 函数声明与定义的区别
理解函数声明与函数定义的区别非常重要:
- 函数声明告知编译器特定函数的存在以及其名称、返回类型和参数类型。这样,编译器就能与函数交互。
- 函数定义则指定函数的具体实现。它提供了函数的主体及其在调用时执行的所有语句。
函数声明必须在使用前出现,定义可以稍后再出现,可能在代码文件的底部,甚至在另一个文件中。
如果没有提供显式声明(例如,文件顶部没有函数原型),那么函数定义同时起到声明和定义的双重作用。
函数可以多次声明。通常,如果一个函数在多个文件中使用,每个文件都必须有该函数的声明,以便编译器知道它的存在及其交互方式。
函数只能定义一次。如果你在两个文件(如 foo.c 和 bar.c)中定义一个函数,编译器将无法确定使用哪个定义。
1.1.1.5.2.4 extern 关键字
还记得变量声明与定义章节中的 extern
关键字吗?这个关键字用于在声明变量时不在同一行定义它。没有这个关键字,两者总是在同一行发生!
函数不需要使用 extern
关键字,函数声明很容易识别:它只是以分号 ;
结尾的函数头——没有主体,函数定义总是有主体。就这么简单。
但是,你可以在函数声明中添加 extern
关键字,使其更明确:
// 函数声明
extern int foobar(int);
1.1.1.5.3 在头文件中声明函数
将变量和函数声明在单独的头文件中是一种常见做法。编译器预处理器将包含语句(include
)文本上替换为头文件的内容:
如你所见,函数 foobar()
在 foo.h
中声明,因此它可以在任何包含此头文件的 c 文件中使用。只有在 foo.c
中,函数才会被定义。
1.1.1.5.4 函数作用域
与变量类似,函数也有作用域。作用域是函数可以被调用的区域。
默认情况下,函数是全局的。这意味着它们在代码库中是全局可用的。如果你想在另一个文件中使用函数,当然必须在那里声明它,如前几章所述。
你可以将函数限定为定义它的文件内。为此,请使用 static
关键字:
// 定义局部函数
static int foobar(int n)
{
return n * 5;
}
以这种方式定义(和声明)的函数只能在文件内部访问。
1.1.1.5.5 值传递
在编程语言中,函数可以通过两种方式调用:
-
值传递:
传递给函数的变量被复制到调用栈。
调用栈是RAM内存的一部分,用于存储关于程序中被调用函数的临时信息:- 函数参数。
- 返回地址:执行完成后应该返回到哪里?
- 在函数体内部定义的临时、局部变量。
因此,对函数参数的更改不会影响函数外部的任何变量。
-
引用传递:
传递给函数的变量的地址被复制到调用栈。对参数的更改会更改你传递的原始变量!
值传递系统是 C 语言中调用函数的默认方式。
我们可以选择性地传递变量的地址(指针——稍后会讲到),但这时我们得知道自己在干什么。我们将在讨论指针时再次谈论这个话题。
以下示例显示了值传递系统的运作方式:
int a, b, c;
// 函数声明
int foo(int, int);
int main(void)
{
a = 5;
b = 10;
printf("调用 foo() 之前=> a = %d, b = %d\n", a, b);
c = foo(a, b);
printf("调用 foo() 之后=> a = %d, b = %d\n", a, b);
}
// 函数定义
int foo(int x, int y)
{
x = x + (++y);
return x;
}
这段代码将打印:
调用 foo() 之前=> a = 5, b = 10
调用 foo() 之后=> a = 5, b = 10
如你所见,函数 foo()
没有更改原始变量 a
和 b
!
运行结果如下图所示:
1.1.1.6 数组
数组是一个可以存储多个相同类型数据的变量:
数组是可以存储多个相同类型数据的变量。这些单独的数据,称为元素,被顺序存储,并且通过数组索引(有时称为下标)唯一标识,索引是从 0 开始的。
这是一个数组在内存中的存储方式(示例:使用 16 位宽的 RAM 存储一个 16 位整数数组):
在数组基础知识章节,我们将学习如何声明、定义和初始化数组。
在字符串章节,我们将了解到字符串是一种特殊类型的数组(字符数组)。
1.1.1.6.1 数组基础知识
数组是可以存储多个相同类型数据的变量。这些单独的数据,称为元素,被顺序存储,并且通过数组索引(有时称为下标)唯一标识,索引是从 0 开始的。
这是一个数组在内存中的存储方式(示例:使用 16 位宽的 RAM 存储一个 16 位整数数组):
1.1.1.6.1.1 声明和定义
数组可以包含任意数量的元素,但在定义时必须指定其大小(元素数量)。在声明时不应指定大小(除非声明和定义在同一行)。让我们声明和定义一个大小为 3 的数组 x
:
// 声明数组 x
extern int x[];
// 定义数组 x
int x[3];
大小必须是一个已知于编译时期的常量整数。
1.1.1.6.1.2 初始化
一旦声明和定义,你可能会尝试这样初始化数组:
// [错误] 初始化数组 x
x = {5, 7, 9};
不幸的是,一旦数组被声明和定义,就不能作为一个整体修改,只能逐个元素修改:
// [正确] 初始化数组 x
x[1] = 5;
x[2] = 7;
x[3] = 9;
然而,在定义数组的同时,你可以修改整个数组:
// 声明数组 x
extern int x[];
// 定义并初始化数组 x
int x[3] = {5, 7, 9};
为了进一步简化符号,可以省略声明步骤,然后定义将同时完成两项任务。所以你可以在一行上完成所有操作:
// 声明、定义并初始化数组 x
int x[3] = {5, 7, 9};
1.1.1.6.1.3 数组大小
我们之前说过,数组大小……
- 可以在声明中给出
但是这样做是非常不推荐的,因为你必须将其与定义中给出的大小保持同步。 - 必须在定义中给出
- 必须是一个字面值或编译时已知的常量。
// 声明数组 x
extern int x[];
// 定义并初始化数组 x
int x[] = {5, 7, 9}; // 编译器知道大小是 3
注意,这段代码中没有给出大小 3。然而,数组 x
已经正确地声明、定义和初始化!编译器从初始化步骤知道它的大小,这只因为初始化和定义发生在同一行。
1.1.1.6.1.4 使用
数组像变量一样访问,但带有索引:
// 使用数组
array_name[index]
索引可以是变量或常量。注意,C 不会检查边界!
1.1.1.6.1.5 多维数组
你可以这样创建二维数组:
// 二维数组声明
extern type array_name[][];
// 二维数组定义
type array_name[size_1, size_2];
多维数组可以有任意数量的维度:
// 10x10 数组,用于存储 100 个整数
int a[10][10];
// 10x10x10 数组,用于存储 1000 个整数
int b[10][10][10];
这是初始化多维数组的方法:
// 2x2x2 数组,用于存储 8 个整数
char b[2][2][2] = {{{0, 1}, {2, 3}}, {{4, 5}, {6, 7}}};
1.1.1.6.2 字符串
字符串基本上是字符数组:
字符串是 char
类型的数组,其最后一个元素是 ASCII 值为 0 的空字符 \0
。
字符串必须作为字符数组进行操作——逐个元素处理。
1.1.1.6.2.1 声明和定义
一个字符串可以像任何其他数组一样声明和定义:
// 声明 my_str
extern char my_str[];
// 定义 my_str
char my_str[7];
1.1.1.6.2.2 初始化
声明和定义后,你可能会尝试这样初始化字符串:
// [错误] 初始化 my_str
my_str = {'B', 'E', 'E', 'T', 'L', 'E', '\0'};
编译器不接受这种方式。记住,数组一旦声明和定义后,不能整体修改。必须逐个元素进行:
// [正确] 初始化 my_str
my_str[0] = 'B';
my_str[1] = 'E';
my_str[2] = 'E';
my_str[3] = 'T';
my_str[4] = 'L';
my_str[5] = 'E';
my_str[6] = '\0';
唯一可以整体修改数组的时刻是在数组定义的表达式中。因此对于字符串,我们可以这样做:
// 声明 my_str
extern char my_str[];
// 定义并初始化 my_str
char my_str[7] = {'B', 'E', 'E', 'T', 'L', 'E', '\0'};
为了进一步简化符号,我们可以省略声明步骤。定义将同时完成两个目的,所以你可以在一行中完成所有操作:
// 声明、定义并初始化 my_str
char my_str[7] = {'B', 'E', 'E', 'T', 'L', 'E', '\0'};
如你所见,我们使用列表符号来初始化 my_str
(就像你对普通数组所做的那样)。然而,它也可以用字符串字面值完成:
// 声明、定义并初始化 my_str
char my_str[7] = "BEETLE";
不需要 \0
终止字符。该字符隐含在字符串字面值中,因此会自动追加。
在 16 位系统上,字符串将存储在 RAM 中:
1.1.1.6.2.3 字符串大小
记住我们在上一章节所说的:如果在定义数组的同一行同时初始化它,可以省略大小——编译器会为我们计算:
// 声明、定义并初始化 my_str
char my_str[] = {'B', 'E', 'E', 'T', 'L', 'E', '\0'}; // 编译器知道大小是 7
如果使用字符串字面值进行初始化,这同样有效:
// 声明、定义并初始化 my_str
char my_str[] = "BEETLE"; // 编译器知道大小是 7
编译器知道字符串终止字符 \0
必须考虑在内。因此,它将 BEETLE
这样的字符串计为 7 个字符!
1.1.1.6.2.4 使用
记住数组元素的访问方式:
// 使用数组
array_name[index];
字符串也是如此:可以使用这种符号访问它们的各个字符元素。
- 字符串比较
字符串不能像普通变量那样比较。不能像比较两个整数那样做 if (str1 == str2)
。要比较两个字符串,必须逐个字符进行比较。
标准 C 库提供了函数 strcmp()
来比较两个字符串。当它们匹配时返回 0
(FALSE)——因此其逻辑必须反转!
- 写字符串
我们知道一旦声明和定义字符串,不能再整体修改它,唯一的机会是在定义时同一行进行,之后尝试修改只会导致错误:
$ gcc test.c -Wall && a.exe
test.c: In function 'main':
test.c:14:12: error: assignment to expression with array type
14 | my_str = "foo";
| ^
所以必须逐个元素进行。然而,对于大字符串这样做可能非常繁琐。因此,使用标准 C 库中的 strcpy()
函数(这个库函数也是逐个元素地进行操作):
// C 代码测试文件
// ================
#include <stdio.h>
#include <string.h>
// 声明 my_str
extern char my_str[];
// 定义 my_str
char my_str[4];
int main()
{
strcpy(my_str, "foo");
printf("my_str = %s\n", my_str);
}
如果事先知道你会将 "foo"
写入变量 my_str
,这一切都很好。然后你知道 my_str
应该是 4 个字符的大小。但如果你不知道将哪个字符串写入变量呢?只需将 my_str
数组设得足够长(例如 50 个字符)。然后你可以将任何符合此大小的字符串字面值写入其中。由于终止字符 \0
的存在,处理字符串的函数知道它们不必遍历所有 50 个字符。它们在终止字符处停止,其余的是垃圾。
1.1.1.7 指针
还记得变量章节中的以下图示吗:
变量 foo
包含值 42
。在某些情况下,我们希望处理变量的内存地址而不是它的值:
图中的变量 p
保存了变量 foo
的地址。换句话说,变量 p
指向变量 foo
。
指针是一个变量,它保存另一个变量或函数的地址。它允许你间接访问变量或函数。
有两种类型的指针:
1.1.1.7.1 数据指针
在下面这个图中,指针 p
保存了变量 x
的地址:
在使用指针时,可以对指针变量本身或对解引用的指针进行操作:
-
读/写指针变量本身:
如果你读/写指针变量本身,你是在操作存储在p
中的地址。这样的操作不会触及x
的值!当然,如果你修改了存储在p
中的地址,它将不再指向x
。例如:p += 5; // 修改指针变量中保存的地址
在这个例子中,指针
p
将不再指向变量x
,而是指向 RAM 中某个其他的内存单元。这是因为这里修改了存储在p
中的地址。变量x
未被触及! -
读/写解引用的指针:
对解引用的指针变量进行读/写操作等同于对指向的变量进行相同的操作。例如:*p += 5; // 等同于 x += 5;
在这个例子中,操作实际上是在变量
x
上进行的,x
是p
指向的变量。
1.1.1.7.1.1 声明和定义
- 如何声明和定义
以下演示的是如何声明和定义一个指向 type
类型变量的指针:
// 声明指针
extern type *ptr_name;
// 定义指针
type *ptr_name;
如果你不使用 extern
关键字显式声明指针,那么定义将同时起到‘声明’和‘定义’两种目的。 点击这里回顾一下有关‘声明’和‘定义’之间的区别。
当然,如果不使用 extern
关键字,两者可以在同一行发生:
// 声明并定义指针
type *ptr_name;
- 这意味着什么
以下是一个声明和定义的示例:
// 声明并定义指针
int *p;
如何解释这样的指针声明?大多数 C 程序员对这一点有一个错误的映像,这种映像通常有效,但并非总是如此。我们希望你完全正确理解:
大多数人认为的 | 真正发生的情况 |
---|---|
你说: 我声明 p 是一个指向整数的指针。编译器说: 好的。 | 你说: 我声明 *p (解引用的p )是一个整数。注意,这里你并没有解引用 p 。你只是说,如果它被解引用,它将是一个整数。然后编译器说: 是的,所以如果 *p 是一个整数,那么这意味着 p 必须指向一个整数。 |
在声明中,* 运算符和指针变量的类型形成一个整体。因此,将它们合并在一起是有意义的:int* p; | 在声明中,* 运算符和标识符 p 形成一个整体。因此,将它们合并在一起是有意义的: int *p; |
你现在可能在想:
是的,但这样处理是有意义的。在处理指针时,脑海中有错误的映像可能暂时有效,但你最终会撞到南墙(遇到问题)!
1.1.1.7.1.2 初始化
要初始化一个指针,我们需要将它设置为其他变量的地址。这可以用 &
(取地址)运算符完成。让我们声明、定义和初始化一个整数指针作为示例:
// 声明指针
extern int *p;
// 定义指针
int *p;
// 初始化指针
p = &x;
为什么最后一行没有 *
?那是因为我们在设置指针变量本身的值。换句话说,p
本身必须存储 x
的地址。
1.1.1.7.1.3 使用指针
一旦声明、定义和初始化了指针,它可以像指向的变量一样使用。为此,需要在指针前加上 *
(解引用)运算符。注意,这个运算符在声明和/或定义期间的效果与现在不同。它的含义取决于上下文:
*(解引用)运算符的效果取决于上下文:
-
指针声明和/或定义:
* 运算符在声明和/或定义期间不会进行解引用。 相反,它只是表示如果你解引用变量 p, 那么你将访问给定类型的值:另一种说法是,* 运算符使编译器得出结论, 被声明和/或定义的变量是一个指针变量而不是普通变量。
- 其他上下文:
在所有其他上下文中,* 运算符将执行解引用。 这就像你在处理变量 x 本身一样。 所有读/写操作都在指针指向的内存单元上进行,而不是指针变量本身所在的内存单元上进行。
因此,当我们看到 *
运算符时,总是需要注意上下文。让我们考虑一个示例:
// 声明并定义一个整数变量
int x;
// 声明并定义一个整数指针
int *p; // '*' 表示:'这是一个指针变量,不是普通变量'
// 初始化指针,
// 将 x 的地址赋值给它
p = &x;
// 将变量 x 的值设置为 5
*p = 5; // '*' 表示:'解引用指针 p'
在最后一行,表达式 *p = 5;
的效果与写 x = 5;
完全相同。
多亏了 *
(解引用)运算符,你可以访问指针所持有地址的数据。由于 p
被赋值为 x
的地址,因此在任何地方使用 *p
,效果都等同于直接使用 x
。
下面漫画阐明了 *
操作符的作用:
1.1.1.7.1.4 在同一行声明、定义和初始化
我们知道 *
运算符的效果取决于上下文。然而,如果上下文是混合的呢?例如,让我们在一行中声明、定义和初始化一个指针:
// 声明、定义并初始化一个指针
int *p = &x;
现在,这个上下文中的 *
运算符做了什么?请看以下图示:
如你所见,*
位于左侧,指针变量被声明和定义的位置。这意味着它没有启用解引用指针功能,它仅表示被声明和定义的变量是一个指针,而不是普通变量。
这行代码可以分为:
// 声明并定义一个指针
int *p;
// 初始化指针
p = &x;
甚至可以进一步分为:
// 声明一个指针
extern int *p;
// 定义指针
int *p;
// 初始化指针
p = &x;
1.1.1.7.1.5 指针示例
让我们考虑以下指针示例:
int x, y;
int *p;
x = 0xDEAD;
y = 0xBEEF;
p = &x;
*p = 0x0100;
p = &y;
*p = 0x0200;
前两行声明并定义了变量 x
、y
和指针变量 p
。它们都在 RAM 内存中被分配了位置。
接下来两行将值 0xDEAD
和 0xBEEF
分配给变量 x
和 y
。
指针变量 p
现在被填充为变量 x
的地址。注意这里没有解引用操作符 *
,这是因为我们在将数据写入指针变量本身。从此刻开始,它指向 x
。
这里使用 *
运算符来“解引用”指针变量。这意味着表达式: *p = 0x0100;
等价于: x = 0x0100;
指针变量 p
现在被填充为变量 y
的地址。注意这里没有解引用操作符 *
,这是因为我们在将数据写入指针变量本身。从此刻开始,它指向 y
。
这里使用 *
运算符来“解引用”指针变量。这意味着表达式: *p = 0x0200;
等价于: y = 0x0200;
1.1.1.7.1.6 指针算术运算
我们知道当这样做时会发生什么:
*p += 3;
指向的值将增加 3
。指针变量 p
本身存储的地址保持不变:
现在让我们这样做:
p += 3;
由于没有 *
解引用运算符,操作是在变量 p
本身上进行的:
p
中存储的地址增加,使其不再指向 x
。现在我们可以问自己:
- 地址增加了多少?
- 地址增加后
p
指向什么?
- 地址增加了多少?
也许你预想的是地址增加 3
,比如:
0x001C + 3 = 0x001F
在这儿其实不然!编译器知道 p
是一个’指向整数的指针’,在 16 位处理器上,整数用两个字节(16位)表示。因此,编译器应用了乘数因子 2
:
0x001C + 3*2 = 0x0022
一般来说,当向指针加(或减)时,规则是:
增加和减少指针将按它们指向类型的大小(字节数)的倍数修改值。
如果 p
指向的是浮点数而不是整数,它将以 4 的倍数修改(一个浮点数是 4 字节)。如果它指向的是双精度浮点数,它将以 8 的倍数修改,M3
- 地址增加后
p
指向什么?
由于前面讨论的乘数因子,指针增加一使其指向内存中同类型的下一个值(下一个整数,下一个浮点数,下一个双精度浮点数等)。
如果你在那个内存单元中放了完全不同的东西会发生什么?也许它包含两个字符:
好吧,你可能在那里放了两个字符,但最终,一切都存储为位。指针 p
在那个地址所看到的只是……比特位!指针 p
是一个’指向整数的指针’,所以它将把那里两个字节解释为一个整数。让我们看看它’看到’了什么:
字符’A’在二进制中是 0100_0001
。字符’B’是 0100_0010
。它们一起编码为 0100_0010_0100_0001
。指针 p
将这个 16 位序列解释为整数 16961
。
- 在数组中移动
如果你仔细观察,你应该已经意识到指针算术运算是在数组中移动的完美机制。数组是一组元素(相同类型)存储在 RAM 中的连续内存单元中。由于在指针的算术操作上应用了乘数因子,在数组元素之间跳跃就是基本操作了。
我们将在下一章进一步研究这个问题。
- 跳来跳去时要小心!
增加和减少指针地址可以非常有用——但也很危险!如果指针指向数组中的某个元素,我们必须保持呆在数组的范围内。如果我们越出数组界限,可能会引起各种莫名其妙的BUG。
请像 Bobby 一样,在解引用指针之前,知道你指向了什么。如果你指向数组中的一个元素——请保持在数组的界限内!
1.1.1.7.2 指向数组的指针
还记得数组在内存中的存储方式(示例:使用 16 位宽的 RAM 存储一个 16 位整数数组)吗:
可以使用普通数组语法移动数组元素:只需增加索引。然而,也可以用指针来完成。数组元素占据连续的内存位置,因此,如果你知道第一个元素的地址,你可以通过增加地址轻松地在数组中移动。
对于数组,有两种指针:
-
指向数组第一个元素的指针
-
指向数组“对象”本身的指针
警告:
Microchip的教程 没有区分这两种指向数组的指针。
我们将对比讨论这两种指针,以展示它们的相似性和差异。
1.1.1.7.2.1 声明和定义
让我们声明和定义一个数组,以及两个指向它的指针:
// 声明、定义并初始化整数数组
int x[3] = {5, 7, 9};
// 声明并定义指向第一个元素的指针
extern int *p; // 声明
int *p; // 定义
// 声明并定义指向数组“对象”的指针
extern int (*q)[]; // 声明
int (*q)[3]; // 定义
请注意,声明和定义分别在不同的行上,它们也可以在同一行上。如果声明时未使用 extern
关键字显式进行,那么定义将具有’声明’和’定义’(指针)变量的双重目的。
p
和 q
的声明是不同的,因为:
- 指针
p
将指向数组的第一个元素。 - 指针
q
将指向数组“对象”本身。
让我们来比较一下这两个声明。
- 指针
p
的声明
指针 p
的声明很简单:
extern int *p;
我声明
*p
(解引用的p
)是一个整数。
我们知道编译器的反应:
是的,所以如果
*p
是一个整数,那么这意味着p
必须指向一个整数。
确实,p
是一个指向整数的指针,它恰好是数组 x[]
中的第一个整数。然而,p
对这一点并不感兴趣,它指向一个整数——仅此而已。
- 指针
q
的声明
指针 q
的声明有点复杂:
extern int (*q)[];
我声明
(*q)[]
(解引用的q
,解引用后再索引)是一个整数。
我们知道编译器的反应:
我靠!
等等,再试一次:
所以,如果我先解引用
q
然后索引它,我将得到一个整数结果。换句话说,*q
(解引用的q
)必须是一个整数数组。所以q
必须是一个指向整数数组的指针。
如你所见,编译器得出结论:
(*q)[]
(解引用的q
,解引用后再索引)必须是一个整数。这是我们声明的内容。*q
(解引用的q
)必须是一个整数数组。q
必须是一个指向整数数组的指针。
请注意,声明中没有给出数组的大小。你可以在那里给出,但最好在定义中提供(如果我们在定义和声明中都指定了数组大小,当它们在代码更改后变得不同时,将有风险导致不一致,最好避免这种重复):
int (*q)[3];
不过,最终提供数组的大小是很重要的。否则,在尝试增加或减少指针时会遇到这个错误:
test.c: In function 'main':
test.c:30:6: error: increment of pointer to an incomplete type 'int[]'
30 | q++;
| ^~
编译器不知道如何增加 q
的地址,因为 q
的类型不完整。它是一个指向整数数组的指针,但数组中有多少个元素?必须知道大小才能使类型完整。
1.1.1.7.2.2 初始化
- 初始化指针
p
和q
现在我们已经声明(并定义)了指针 p
和 q
,是时候初始化它们了:
// 初始化指针 p
p = &x[0];
// 初始化指针 q
q = &x;
如你所见,指针 p
得到第一个元素的地址,而指针 q
得到整个数组对象的地址。事实上,这些地址完全相同:
等一下——所以它们都指向相同的地址?是的,它们是!你可以通过一个简短的 C 程序自行测试:
运行以下文件:
// C 程序测试文件
// ================
#include <stdio.h>
// 声明、定义并且初始化整数数组
int x[3] = {5, 7, 9};
// 声明、定义指向第一个元素的指针
extern int *p; // 声明
int *p; // 定义
// 声明、定义指向数组“对象”的指针
extern int (*q)[]; // 声明
int (*q)[3]; // 定义
int main()
{
// 初始化指针 p
p = &x[0];
// 初始化指针 q
q = &x;
// 以十六进制形式打印保存在指针变量 p、q 中的地址
printf("保存在变量 p 中的地址:%x\n", p);
printf("保存在变量 q 中的地址:%x\n", q);
}
在编译和执行这段代码时,我得到以下输出:
这是否意味着指针 p
和 q
是等价的?不,尽管它们存储相同的地址,但它们的类型不同。指针 p
是一个“指向整数的指针”,而 q
是一个“指向大小为 3 的整数数组的指针”。
不要那么快!当你计划对指针进行算术运算时,这种差异非常重要。我们将在第3小节指针运算中进一步讨论这一点。
- 数组地址符号
你应该注意到,分配给指针 p
和 q
的数组地址写法不同。实际上,有三种表示数组 x[]
的地址的符号:
&x[0]
&x
x
它们都返回相同的地址。然而——与Microchip教程中声称的不同——使用哪种符号确实很重要!
当你处理指向数组第一个元素的指针时——例如上例中的指针 p
——你应该使用以下符号之一:
p = &x[0];
p = x;
如果你处理的是指向整个数组“对象”的指针——例如上例中的指针 q
——你应该只能使用以下符号:
q = &x;
让我们看看如果你混用符号会发生什么:
// C 语言测试文件
// ================
#include <stdio.h>
// 声明、定义并初始化整数数组
int x[3] = {5, 7, 9};
// 声明并定义指向第一个元素的指针
extern int *p; // 声明
int *p; // 定义
// 声明并定义指向数组“对象”的指针
extern int (*q)[]; // 声明
int (*q)[3]; // 定义
int main()
{
// 指针 p 应该使用以下任一种方式初始化:
// p = &x[0];
// p = x;
// 使用错误的方式初始化它:
p = &x;
// 指针 q 应该只使用这种方式初始化:
// q = &x;
// 使用错误的方式初始化它:
q = &x[0];
// 以十六进制格式打印存储在指针 p 和 q 中的地址
printf("存储在 p 中的地址: %x\n", p);
printf("存储在 q 中的地址: %x\n", q);
}
在我的计算机上,得到如下输出:
$ gcc test.c -Wall && a.exe
test.c: 在函数 'main' 中:
test.c:23:7: 警告:从不兼容的指针类型 'int (*)[3]' 向 'int *' 赋值 [-Wincompatible-pointer-types]
23 | p = &x;
| ^
test.c:29:7: 警告:从不兼容的指针类型 'int *' 向 'int (*)[3]' 赋值 [-Wincompatible-pointer-types]
29 | q = &x[0];
| ^
存储在 p 中的地址: ec088010
存储在 q 中的地址: ec088010
尽管程序不会崩溃,但编译器确实会抱怨不兼容的指针类型!
1.1.1.7.2.3 指针运算
我们在前面已经提到过指针运算的话题,记住:
增加和减少指针将按它们指向类型的大小(字节数)的倍数修改值。
让我们现在将这些知识应用到指针 p
和 q
上。记住它们都从相同的地址开始,但指针 p
是“指向整数的指针”,而 q
是“指向大小为 3 的整数数组的指针”。让我们看看当我们增加它们时会发生什么:
指针 p
是“指向整数的指针”,一个整数是两个字节长(假设我们使用的是默认整数大小为2字节的16位处理器),因此自加以两个为步长进行。这就是为什么地址从 0x0012
变为 0x0014
。实际上,这意味着每次自加都使指针指向内存中的下一个整数(假设它们都连续存储在内存中)。
指针 q
是“指向大小为 3 的整数数组的指针”。换句话说,指针 q
指向整个数组对象。一个包含 3 个整数的完整数组的大小是 6 个字节(假设我们使用的是默认整数大小为2字节的16位处理器)。这就是为什么地址从 0x0012
变为 0x0018
。实际上,这意味着每次自加都使指针指向内存中的下一个“3 个整数的数组”(假设有多个这样的“3 个整数的数组”连续存储在内存中)。
让我们用一个简短的 C 代码示例来证明这一点:
编译并运行以下文件:
// C 代码测试文件
// ================
#include <stdio.h>
// 声明、定义并初始化整数数组
int x[3] = {5, 7, 9};
// 声明并定义指向第一个元素的指针
extern int *p; // 声明
int *p; // 定义
// 声明并定义指向数组“对象”的指针
extern int (*q)[]; // 声明
int (*q)[3]; // 定义
int main()
{
// 初始化指针 p
p = &x[0];
// 初始化指针 q
q = &x;
// 打印存储在指针 p 和 q 中的地址,
// 以十六进制格式显示
printf("自加前\n");
printf("存储在 p 中的地址: %x\n", p);
printf("存储在 q 中的地址: %x\n", q);
// 自加指针
p++;
q++;
// 再次打印指针地址
printf("\n自加后\n");
printf("存储在 p 中的地址: %x\n", p);
printf("存储在 q 中的地址: %x\n", q);
}
编译并执行这段代码时,我得到了以下输出:
$ cd C:/Users/kristof/work
$ gcc test.c -Wall && a.exe
自加前
存储在 p 中的地址: 321a8010
存储在 q 中的地址: 321a8010
自加后
存储在 p 中的地址: 321a8014
存储在 q 中的地址: 321a801c
在解释这些结果之前,你必须记住这是在你的本地计算机上运行的——不是在16位微控制器上!在我的计算机上,默认的整数大小为 32 位(4 字节)。这就是为什么指针 p
从地址 0x321A_8010
跳到 0x321A_8014
。
指针 q
从地址 0x321A_8010
递增到 0x321A_801C
。这是一个 12 字节的增长,正好是三个 4 字节整数数组所占据的字节数。
1.1.1.7.2.4 数组索引
假设你有一个数组 x[]
,你想要访问第 n
个元素。你可以这样做——显然——如下所示:
x[n]
运算符 []
被称为下标运算符。它实际上做了什么?
- 下标运算符
[]
的含义
编译器内部是这样解释下标运算符的:
这种带偏移量的指针形式可以理解为:
取数组
x[]
第一个元素的地址(切记这可以写成&x[0]
或者仅仅是x
),增加 n 个元素的偏移量(切记,一个元素偏移量n
乘以一个因子来计算实际的字节偏移量,这个乘法因子是指针类型中的字节数),然后解引用它。
这意味着你正在解引用数组中第 n 个元素所在的地址。
C99 标准定义下标运算符 []
为:
下标运算符 [] 的定义是: E1[E2] 等同于 (*((E1)+(E2)))。
- 使用下标运算符
[]
的奇技淫巧
观察这个小技巧:
x[n] == *(x + n) == *(n + x) == n[x]
例如,你可以将 x[3]
写成 3[x]
!
没错,但我们不鼓励你这样索引数组。只需记住这个技巧,这是面试中的一个常见问题。
1.1.1.7.2.5 自加语法规则
在自加指针时要小心。你是在自加地址,还是值?许多初学者认为 *p++
会增加 p
指向的数据,而实际上它对数据没有影响,而是增加了指针变量本身(指向的地址)。
- 后自加语法规则
在后自加(或自减)中,++
(或 --
)符号放在末尾。下表显示了如果你想自加指针本身或它指向的数据时,括号应放在哪里:
语法 | 操作 | 示例 |
---|---|---|
*p++ 或 *(p++) 两者等价 | 后自加指针 | z = *(p++); 等价于: z = *p; p = p + 1; |
(*p)++ | 后自加指针指向的数据 | z = (*p)++; 等价于: z = *p; *p = *p + 1; |
要对指针指向的数据进行操作,必须将解引用运算符 *
和指针 p
一起用括号括起来,基本上表示这两个符号是不可分割的,必须首先一起考虑。因此,如果 p
指向 x
,(*p)++
等同于 x++
。自加将对指向的数据进行,而指针本身保持不变。
- 前自加语法规则
进行前自加操作时,它的工作方式与后自加类似,但现在自加操作在使用指针之前进行。除此之外,其他规则相同。
语法 | 操作 | 示例 |
---|---|---|
++*p 或 *(++p) (两者等价) | 前自加指针 | z = *(++p); 等价于: p = p + 1; z = *p; |
++(*p) | 前自加指针指向的数据 | z = ++(*p); 等价于: *p = *p + 1; z = *p; |
- 通用规则
作为后加加和前加加的通用规则:
如果解引用运算符 * 在括号外(或根本没有使用括号),指针本身将增加(地址)。如果 * 在括号内,指向的值将增加。
1.1.1.7.3 字符串指针
指向字符串的指针是指向数组的指针的一种特殊情况。记住,字符串是字符数组。
1.1.1.7.3.1 回顾:字符数组
记住我们到目前为止表示字符串的方式——作为字符数组:
字符串是 char 类型的数组,其最后一个元素是 ASCII 值为 0 的空字符 '\0'
例如:
char my_str[] = "BEETLE";
// 存储为:
my_str[0] = 'B'
my_str[1] = 'E'
my_str[2] = 'E'
my_str[3] = 'T'
my_str[4] = 'L'
my_str[5] = 'E'
my_str[6] = '\0'
在 16 位系统上,字符串将如下图所示存储在 RAM 中:
字符串字面值 "BEETLE"
直接放入给定的字符数组中。尽管在字符串字面值中未明确提及,但终止字符 '\0'
是其一部分。
点击这里跳转到相关章节。
1.1.1.7.3.2 指向字符串的指针
一旦声明和定义了字符串(字符数组),你就可以声明和定义指向它的指针。
- 指向第一个元素的指针
让我们声明并定义指针 my_str_ptr
,并让它指向字符串的第一个字符:
char my_str[] = "BEETLE";
char *my_str_ptr = my_str;
在 16 位处理器上,这将得到以下结果:
还记得前面的内容吗:
当你处理指向数组第一个元素的指针时——例如上例中的指针
p
——你应该使用以下符号之一:p = &x[0]; p = x;
如果你处理的是指向整个数组“对象”的指针——例如上例中的指针
q
——你应该只能使用以下符号:q = &x;
因为我们写:
char *my_str_ptr = my_str;
或等效地:
char *my_str_ptr = &my_str[0];
我们知道指针 my_str_ptr
只是指向字符数组的第一个元素。任何处理 my_str_ptr
的函数都可以通过增加指针地址逐个访问这些字符。一旦遇到终止字符,该函数就知道已经到达字符串的末尾。
每当提到指向字符串的指针时,这通常就是指:指向字符串第一个元素的指针。
- 指向整个字符串的指针
指向第一个字符的指针的对立面是指向整个字符串的指针。尽管很少使用,但为了完整性,我们将在这里讨论:
char my_str[] = "BEETLE";
char (*my_whole_str_ptr)[7] = &my_str;
my_whole_str_ptr
指向 7 字节字符数组这一个整体。这对指针运算有深远影响。如果你自加my_whole_str_ptr
,它将增加 7 个字节!
- 示例
让我们考虑一个字符串、指向字符串的指针和指向整个字符串的指针的示例:
编译并运行以下文件:
// C 代码测试文件
// ==============
#include <stdio.h>
#include <string.h>
// 声明、定义并初始化 my_str
char my_str[] = "BEETLE";
// 声明、定义并初始化指向 my_str 的指针
char *my_str_ptr = my_str;
// 声明、定义并初始化指向整个 my_str 的指针
char (*my_whole_str_ptr)[7] = &my_str;
int main()
{
// 打印字符串
printf("my_str = %s\n", my_str);
printf("my_str_ptr = %s\n", my_str_ptr);
printf("my_whole_str_ptr = %s\n", my_whole_str_ptr);
}
在我的电脑上,这会产生以下输出:
$ gcc test.c -Wall && a.exe
test.c: In function 'main':
test.c:18:33: warning: format '%s' expects argument of type 'char *', but argument 2 has type 'char (*)[7]' [-Wformat=]
18 | printf("my_whole_str_ptr = %s\n", my_whole_str_ptr);
| ~^ ~~~~~~~~~~~~~~~~
| | |
| | char (*)[7]
| char *
my_str = BEETLE
my_str_ptr = BEETLE
my_whole_str_ptr = BEETLE
虽然程序不会崩溃,但编译器确实会抱怨不兼容的指针类型!如你所见,字符数组 my_str
和指向其第一个元素的指针 my_str_ptr
都可以作为 printf()
函数的参数被接受(准确的说,作为填入 %s
占位符的参数)。然而,my_whole_str_ptr
则不能。这对于大多数处理字符串的函数都是如此。它们接受以下类型:
- 字符数组
- 指向字符数组第一个元素的指针
- 字符串字面值
但它们不接受指向整个字符数组的指针。
1.1.1.7.3.3 指向字符串字面值的指针
指向字符串字面值的指针是指向字符数组的指针的一种特殊情况。所以如果你这样写:
char *my_str_ptr = "BEETLE";
真正发生的是:
- 字符串字面值在 FLASH 存储器中创建(更准确地说,应该是“在只读内存中”,通常这是微控制器的 FLASH 内存)。
- 在 RAM 中创建一个“指向字符”的指针变量。
- 指针被赋值为字符串字面值中第一个字符的地址。
结果如下:
暂停一下,你看到了吗?my_str_ptr
的类型是什么?没错,它是一个“指向字符的指针”,它指向字符’B’。这样是正确的吗?
不完全是。字符’B’(以及字符串中的所有其他字符)存储在只读内存中。因此,它们不是 char
类型,而是 const char
类型!因此,现代 C/C++ 编译器会给你以下警告:
test.c:10:20: warning: ISO C++ forbids converting a string constant to 'char*' [-Wwrite-strings]
10 | char *my_str_ptr = "BEETLE";
| ^~~~~~~~
因此,你应该这样写:
const char *my_str_ptr = "BEETLE";
现在 my_str_ptr
指向的是常量字符串的第一个字符!
字符串字面值并不生而平等
你注意到字符串字面值可以根据上下文完全不同地处理吗?例如:
char my_str[] = "BEETLE";
在这里,字符串字面值 "BEETLE" 直接存储在字符数组 my_str[]中,这可能在 RAM 内存中的某个地方。
在这个例子中:
const char *my_str_ptr = "BEETLE";
字符串字面值存储在 FLASH 存储器(只读)中,指针只保存第一个字符的地址。
1.1.1.7.3.4 比较字符串
我们以前讨论过比较字符串的话题(见数组章节末尾)。在那里,所有字符串都表示为字符数组。我们解释说,像 if(str1 == str2)
这样的比较是行不通的。要比较两个字符串,你需要做以下之一:
- 逐字符比较字符串。
- 使用标准 C 库提供的
strcmp()
函数。当它们匹配时返回0
(FALSE)——有点反直觉,所以要小心!
这两种方法在比较字符串时仍然是唯一有效的方法,即使是那些由指针表示的字符串。
你如何恰当地使用 strcmp()
函数?让我们检查它的函数原型:
int strcmp(const char *s1, const char *s2);
暂时忽略 const
关键字。这个函数的形参是要比较的字符串的地址。让我们分析一个例子:
编译并运行以下文件:
// C 语言测试文件
//=====================
#include <stdio.h>
#include <string.h>
// 声明并定义字符串数组
char mystr[] = "BEETLE";
// 声明、定义和初始化指针
char *mystr_ptr_1 = &mystr[0]; // 指向数组 'mystr' 中的第一个字符
char *mystr_ptr_2 = mystr; // 等效的表示法
char *mystr_ptr_3 = "BEETLE"; // 指向字符串字面值
char *mystr_ptr_4 = "FOO"; // 指向字符串字面值
int main()
{
// 以十六进制格式打印指针本身的位置
printf("address mystr_ptr_1: %x\n", &mystr_ptr_1);
printf("address mystr_ptr_2: %x\n", &mystr_ptr_2);
printf("address mystr_ptr_3: %x\n", &mystr_ptr_3);
printf("address mystr_ptr_4: %x\n", &mystr_ptr_4);
// 以十六进制格式打印存储在所有指针中的地址
printf("\n");
printf("address stored in mystr_ptr_1: %x\n", mystr_ptr_1);
printf("address stored in mystr_ptr_2: %x\n", mystr_ptr_2);
printf("address stored in mystr_ptr_3: %x\n", mystr_ptr_3);
printf("address stored in mystr_ptr_4: %x\n", mystr_ptr_4);
// 比较字符串
printf("\n");
printf("strcmp(mystr, mystr_ptr_1) = %d\n", strcmp(mystr, mystr_ptr_1));
printf("strcmp(mystr_ptr_1, mystr_ptr_2) = %d\n", strcmp(mystr_ptr_1, mystr_ptr_2));
printf("strcmp(mystr_ptr_1, mystr_ptr_3) = %d\n", strcmp(mystr_ptr_1, mystr_ptr_3));
printf("strcmp(mystr_ptr_1, mystr_ptr_4) = %d\n", strcmp(mystr_ptr_1, mystr_ptr_4));
printf("strcmp(mystr_ptr_1, \"BEETLE\") = %d\n", strcmp(mystr_ptr_1, "BEETLE"));
}
在我的电脑上,打印结果如下:
$ cd C:/Users/kristof/work
$ gcc test.c -Wall && a.exe
address mystr_ptr_1: d2f18018
address mystr_ptr_2: d2f18020
address mystr_ptr_3: d2f18028
address mystr_ptr_4: d2f18030
address stored in mystr_ptr_1: d2f18010
address stored in mystr_ptr_2: d2f18010
address stored in mystr_ptr_3: d2f19000
address stored in mystr_ptr_4: d2f19007
strcmp(mystr, mystr_ptr_1) = 0
strcmp(mystr_ptr_1, mystr_ptr_2) = 0
strcmp(mystr_ptr_1, mystr_ptr_3) = 0
strcmp(mystr_ptr_1, mystr_ptr_4) = -1
strcmp(mystr_ptr_1, "BEETLE") = 0
我们来分析一下这里发生了什么:
首先,我们声明、定义并初始化了以下实体:
- mystr
字符数组mystr
在内存中占用 7 个字节——其中 6 个用于字符本身,另一个用于空字符终止符。 - mystr_ptr_1
指针mystr_ptr_1
指向字符数组mystr
的第一个元素。指针本身占用两个字节。 - mystr_ptr_2
指针mystr_ptr_2
完全等同于前一个。这是因为这些表示法是等效的:&mystr[0] == mystr
- mystr_ptr_3
指针mystr_ptr_3
被分配了字符串字面值 “BEETLE” 的地址。注意!这个字符串字面值存储在程序存储器中的某个地方。它与存储在mystr
中的字符串是不同的实体,具有自己的地址。 - mystr_ptr_4
最后,我们创建了指针mystr_ptr_4
并为其分配了另一个字符串字面值 “FOO” 的地址。这个字符串也存储在程序存储器中,紧接在 “BEETLE” 字符串之后。
在比较字符串时,我们总是将字符串的地址作为参数传递给函数 strcmp()
。如果两个字符串相等(基于字符逐个比较的方式,而不是基于字符串存储的位置),函数返回零。可以通过多种方式将字符串地址传递给这个函数:
-
指针
mystr_ptr_1
和mystr_ptr_2
包含字符串的地址。因为我们要传递这些地址给函数,指针不应被解引用!// 比较 'mystr_ptr_1' 和 'mystr_ptr_2' 所指向的字符串 strcmp(mystr_ptr_1, mystr_ptr_2)
-
现在直接传递字符数组
mystr
:// 比较存储在数组 'mystr' 中的字符串与指针 'mystr_ptr_1' 所指向的字符串 strcmp(mystr, mystr_ptr_1)
这里的第一个参数是字符数组
mystr
。记住,仅仅写数组名相当于传递其第一个元素的地址。所以我们可以这样重写:// 比较存储在数组 'mystr' 中的字符串与指针 'mystr_ptr_1' 所指向的字符串 strcmp(&mystr[0], mystr_ptr_1)
-
你也可以直接将字符串字面值传递给函数:
// 比较 'mystr_ptr_1' 所指向的字符串与字符串字面值 strcmp(mystr_ptr_1, "BEETLE")
这可能并不明显,但传递一个字符串字面值会先将字符串存储在程序存储器中,然后传递其第一个字符的地址。
1.1.1.7.4 指针数组
指针数组是一个普通的数组变量,其元素恰好都是指针。它与指向数组的指针非常不同:
上面的图片基于以下代码:
// 声明、定义并初始化三个整数变量
int a = 10;
int b = 13;
int c = 18;
// 声明并定义一个“指针数组”
int *y[3];
int main()
{
// 初始化数组中的每个元素
y[0] = &a;
y[1] = &b;
y[2] = &c;
}
数组 y[]
本身就像任何其他数组一样,y[]
的元素只是恰好是指向整数的指针。
1.1.1.7.4.1 声明和定义
让我们关注声明和定义数组 y[]
的那一行:
// 声明并定义一个“指针数组”
int *y[3];
这可以分为两行:
// 声明并定义一个“指针数组”
extern int *y[]; // 声明
int *y[3]; // 定义
你注意到这样的“指针数组”的声明与“指向整个数组的指针”的声明非常相似吗?让我们比较两者:
指向整个数组的指针 | 指针数组 |
---|---|
extern int (*q)[]; | extern int *y[]; 等效于: extern int *(y[]); 因为 [] 的优先级高于 * |
你说: > 我声明 (*q)[] (解引用的 q ,解引用后索引)是一个整数。编译器说: > 所以,如果我先解引用 q 然后索引它,我将得到一个整数结果。换句话说,*q (解引用的 q )必须是一个整数数组。所以 q 必须是一个指向整数数组的指针。 | 你说: > 我声明实体 *(y[]) (索引 y ,索引后解引用)是一个整数。编译器说: > 所以,如果我先索引 y 然后解引用它,我将得到一个整数结果。换句话说,y[] (索引 y )必须是一个指向整数的指针。所以 y 必须是这样的指针数组。 |
如你所见,编译器得出结论: - (*q)[] (解引用的 q ,解引用后索引)必须是一个整数。这是你声明的内容。- *q (解引用的 q )必须是一个整数数组。- q 必须是一个指向整数数组的指针。 | 如你所见,编译器得出结论: - *(y[]) (索引 y ,索引后解引用)必须是一个整数。这是你声明的内容。- y[] (索引 y )必须是一个指向整数的指针。- y 必须是一个这样的指针数组。 |
定义告诉我们数组的大小:int (*q)[3]; | 定义告诉我们数组的大小:int *(y[3]); |
最终结论:q 是一个指向包含3个整数的数组的指针。 | 最终结论:y 是一个包含 3 个指向整数的指针的数组。 |
1.1.1.7.4.2 初始化
指针数组可以这样初始化:
// 初始化数组中的每个元素
y[0] = &a;
y[1] = &b;
y[2] = &c;
初始化也可以在声明和定义数组的同一行中进行:
// 声明、定义并初始化一个“指针数组”
int *y[3] = {&a, &b, &c};
1.1.1.7.5 指针参数
通常在 C 中,函数参数是按值传递的。这意味着如果你将一个变量作为参数传递给函数,函数将创建该值的副本并进行操作,而不会改变原始变量。
在许多情况下,希望函数修改传递给它的实际变量,这个概念称为引用传递。要在 C 中实现这一点,你需要传递一个地址:
在 C 中实现引用传递:
要让函数修改变量 x,你必须传递该变量的地址——可以直接使用 & 运算符或通过传递一个指向 x 的指针。
基本上,你是在告诉函数你希望它修改的变量的地址。
1.1.1.7.5.1 按引用传递参数
有两种方法可以按引用传递参数,如下面的示例所示:
// C代码测试文件
// ================
#include <stdio.h>
// 声明、定义并初始化全局变量 x
int x = 2;
// 声明、定义并初始化指针 p
int *p = &x;
void square(int *n)
{
*n *= *n;
}
int main(void)
{
// 传递变量 x 的地址,因此地址将
// 分配给指针参数:n = &x
square(&x);
printf("x 的值:%d\n", x);
// 将指针传递给函数,因此持有的地址
// 由指针分配给指针参数:n = p
square(p);
printf("x 的值:%d\n", x);
}
在第一种方法中,变量 x
的地址通过 &
运算符获取:
square(&x);
基本上,square
函数的参数 n
是这样分配的:
n = &x
在第二种方法中,变量 x
的地址通过指针 p
获取:
square(p);
基本上,square
函数的参数 n
是这样分配的:
n = p
在这两种方法中,参数 n
将存储变量 x
的地址。换句话说,n
将指向该变量。
这个测试程序的输出是:
$ gcc test.c -Wall && a.exe
x 的值:4
x 的值:16
1.1.1.7.5.2 星号 *
和参数
记住星号 *
运算符的作用取决于上下文。在指针变量的声明和/或定义中使用时,它只是告诉编译器:“oi~,这是一个指针!”。在其他上下文中使用时,它实际上是解引用指针,使你真正处理的是指向的原始变量。
让我们将这些知识应用到上一个章节的 square()
函数中:
在函数头中的括号内,参数 n
正在被声明和定义,每次调用函数时都会发生这种情况:
- 声明:
参数n
被声明,以便可以在函数体中使用。 - 定义:
参数n
也被定义,因为它在栈上获得了一个位置。
参数“活着”直到函数不再活跃。然后栈上为参数分配的内存单元会被释放。
由于参数在此处被声明和定义,星号运算符 *
仅表示参数是一个指针,它没有被解引用!
在函数体内,参数 n
正在被使用。在该上下文中,*
运算符解引用指针参数。换句话说,就像在使用原始变量(例如全局变量 x
)一样。
1.1.1.7.6 函数指针
指针还可以用于指向函数。这样的函数指针保存函数的地址——通常是 Flash 存储器中的地址。
1.1.1.7.6.1 声明和定义
以下是声明和定义函数指针的方法:
// 声明并定义函数指针 fp
extern int (*fp)(int x); // 声明
int (*fp)(int x); // 定义
这是你声明的:
我声明
(*fp)(int x)
是一个整数实体。
然后编译器展开这个声明:
所以如果
(*fp)(int x)
是一个整数,那么:
(*fp)
是一个接受 int 参数并返回 int 结果的函数。fp
是一个指向这样的函数的指针。
换句话说:
当然,声明和定义可以在一行上进行:
// 声明并定义函数指针 fp
int (*fp)(int x);
在声明和/或定义中,你不必提到参数名称。只需参数类型即可:
int (*fp)(int);
1.1.1.7.6.2 初始化
可以这样初始化函数指针:
// 让 fp 指向函数 foo()
fp = &foo;
在这里省略地址 &
运算符没有任何影响。
你可以在声明和定义的同一行上初始化函数指针:
// 声明并定义函数指针 fp
int (*fp)(int) = &foo;
一旦初始化,fp
指向 Flash 存储器中的函数代码:
1.1.1.7.6.3 使用
要使用函数指针 fp
,首先解引用它,然后传递参数:
int result;
result = (*fp)(10);
让我们考虑以下示例:
编译并运行以下文件:
// C 代码测试文件
// ==============
#include <stdio.h>
int foo(int);
int (*fp)(int);
int foo(int x)
{
x += 5;
return x;
}
int main(void)
{
fp = &foo;
printf("(*fp)(10) = %d\n", (*fp)(10));
}
编译并执行此代码时,我得到以下输出:
$ gcc test.c -Wall && a.exe
(*fp)(10) = 15
上方内容翻译自 Embeetle 的 Embedded C Tutorial
1.1.2 指针变量
指针也是数据,可以使用一个变量来保存,这种专用于保存指针的变量叫做指针变量。但需注意,指针变量不仅保存了程序实体所占用内存空间的首地址,而且还隐式地保存了该实体所占空间的大小及布局等信息。通常,在不会引起歧义的前提下,指针变量也常常简称为指针。定义一个指针变量的语法格式如下:
数据类型 * 变量名;
例如语句:
int * p1;
就定义了一个指针变量,其名称为 p1
,它可以存放一个大小正好可以容纳一个 int
类型数据的内存空间的首地址。
定义指针变量的方法与定义普通变量的方法相似,只不过在数据类型和变量名之间加了一个符号 *
,该符号表示其后的变量名不是普通变量名,而是一个指针变量名;符号 *
前面的数据类型,则说明了从指针变量中地址开始的连续内存空间的大小,即它的大小等于这个数据类型的字节数(单元数)。
指针变量的定义格式很有意思,因为符号 *
处于数据类型与变量名之间,所以既可以将这个符号与数据类型结合起来看成是一种新的数据类型,也可以把它与变量名结合起来看成是变量名。以上面的定义 int * p1;
为例,如果把符号 *
看成属于数据类型这一边,即看成如下形式:
int* p1;
那么 int*
就是一种新的数据类型——整型指针类型,后面的标识符 p1
则是这个类型的一个变量,即 p1
是一个指针变量;如果把符号 *
看成属于变量名那一边,即看成如下形式:
int *p1;
则 *p1
就是一个整型变量,只不过这个整型变量标识由符号 *
和 p1
组成,而其中的 p1
则是这个整型变量的指针。
1.1.3 指针变量必须赋值之后才能使用
指针定义了之后,总是很快就会被赋值,因为指针变量必须赋值之后才有实际意义。指针变量的值只能为指针(首地址)而不能是其他数据,否则将会引起错误。例如,如果已经某个整型数据的指针(首地址)为 0x44600000
,那么就可以编写如下的代码来保存它:
int *p2; //定义指针变量
p2 = (int *)0x44600000 //将指针赋予指针变量 p2
或
int *p2 = (int *)0x44600000 //将指针赋予指针变量 p2 并定义指针变量 p2
在上面的代码中,在地址值 0x44600000
前必须使用强制类型转换 (int *)
,以便通知编译器,0x44600000
是一个整型数据所占内存空间的首地址,是一个 int
类型指针,代表了一个整型数据所占用的内存空间。如果没有这个类型转换,那么编译器就会报错。
1.1.4 取址运算符与取值运算符
其实,在实际工程中,除了在编写操作系统、驱动程序和嵌入式系统等底层软件时能事先知道某些程序实体的指针(首地址)之外,大多数时候不可能事先就知道一个程序实体的首地址,知道的只是各种程序实体的名称,为此 C 语言提供了“取址”运算符 &
,以使用户可以获得一个程序实体的指针。即当 &
作用在一个变量名上时,其结果就是该变量的指针,例如如下两段代码:
int a; //定义了一个整型 a
int *p; //定义了一个整型指针变量 p
p = &a; //使用运算符 `&` 将整型变量 a 的指针赋予指针变量 p
和
int a; //定义了一个整型 a
int *p = &a; //定义了一个整型指针变量 p,并且使用运算符 `&` 将整型变量 a 的指针赋予指针变量 p
与取址运算符 &
相对应,C 语言还提供了“取值”运算符 *
,如果把该运算符作用于指针变量前面,那么运算的结果将是该指针指向的变量的值。其实这并不奇怪,因为前面已经说过,在定义了一个指针变量 p1
之后,*p1
代表的是这个指针所指向的变量。例如如下代码:
#include <stdio.h>
int main(void)
{
int *p1;
int x = 6;
p1 = &x;
printf("\t*p1 = %d\n", *p1); //输出 *p1 的值 6
printf("\tp = %p\n", p1); //输出 p 的值(x 所占内存空间的首地址)
}
程序的运行结果将是在显示器上显示变量 a
中的数据“6”和其首地址。
1.2 函数指针
函数指针是最重要的 C 指针之一,它可以指向一个函数。
一个函数就是一段代码,C 编译器会为这段代码分配一段连续内存空间,同时把首地址作为常量值赋予以函数名定义的常量。这就是说,函数名就是该函数的指针。
函数指针可以保存于一个指针变量,并在程序中通过这个函数指针变量调用这个函数。指针变量也称为“指向函数的指针变量”或函数指针变量。在不会产生歧义的情况下,函数指针变量也叫做函数指针。
与定义一个变量的指针变量方法相似,在函数定义格式中,如果在函数名的前面作用了符号 *
,那么这里的函数名就变成了函数指针变量,即定义一个函数指针变量的格式如下:
返回值类型 (*变量名)(参数1类型,参数2类型,…);
其中:返回值类型 (*)(参数1类型,参数2类型,…);
为函数指针变量的类型。
例如,一个具有整型返回值且具有两个参数(一个类型为 int
,另一个类型为 float
)的函数指针变量类型的写法如下:
int (*)(int,float);
下面便是把 pf
定义为上述类型的指针变量的代码:
int (*pf)(int, float);
在这里需要提醒的是,上述定义千万不能写成如下的样子:
int *pf(int, float);
因为这个写法是定义了一个具有 int*
类型返回值的函数 pf
,而不是一个函数指针变量 pf
。
下面给出了一个函数指针的用法示例:
#include <stdio.h>
//定义了函数 func_1
int func_1(char para1, int para2)
{
para1 = para1; //为了防止编译器出现未使用变量警告信息
para2 = para2; //为了防止编译器出现未使用变量警告信息
printf("%s\n","call func_1");
return 0;
}
//定义了函数 func_2
int func_2(char para1, int para2)
{
para1 = para1; //为了防止编译器出现未使用变量警告信息
para2 = para2; //为了防止编译器出现未使用变量警告信息
printf("%s\n","call func_2");
return 0;
}
//定义了函数指针 pf
int (*pf)(char para1, int para2);
//======================主函数======================
int main()
{
pf = func_1; //函数指针指向了 func_1
pf('a', 1); //调用了函数 func_1
pf = func_2; //函数指针指向了 func_2
pf('b', 2); //调用了函数 func_2
return 0;
}
通过上面示例代码可知,不仅可以通过函数指针变量来间接调用函数,而且同一个函数指针变量还可以随时通过改变与之关联的函数名来改变它所指向的函数,因而使得同一个指针变量可以调用不同的函数,只要它们的返回值类型和参数类型相同。其中,函数指针的第二个特性特别受欢迎,因为对于那些需要不断升级且函数名还需要经常改变的函数,使用函数指针变量来调用是再合适不过了。例如,某系统有一个缴费函数 fei_x()
,因其函数名含有版本号 x
,故为了适应函数名的变化,那么在程序中使用指向这个函数的函数指针变量来进行调用就是一种合理的做法。
1.3 函数指针作为函数参数及回调函数
在 C 语言中,任何合法类型数据都可以作为函数的参数,故函数指针也不例外。例如如下函数定义:
int func(int (*pF)(char para1, int para2))
{
para1 = para1; //为了防止编译器出现未使用变量警告信息
para2 = para2; //为了防止编译器出现未使用变量警告信息
pF('a', 6);
return 0;
}
函数的参数 pF
就是一个函数指针,其类型为 int (*)(char, int)
。即具有如下返回值及参数类型的函数的函数名(函数指针)均可以作为上述 func()
函数的实参,例如 int user_l(char, int)
、int user_2(char, int)
、int user3(char, int)
等等。
程序示例如下:
//==========================================
#include <stdio.h>
//==========================================
//定义了函数 user_1
int user_1(char para1, int para2)
{
para1 = para1; //为了防止编译器出现未使用变量警告信息
para2 = para2; //为了防止编译器出现未使用变量警告信息
printf("%s\n", "user_1");
return 0;
}
//==========================================
//定义了函数 user_2
int user_2(char para1, int para2)
{
para1 = para1; //为了防止编译器出现未使用变量警告信息
para2 = para2; //为了防止编译器出现未使用变量警告信息
printf("%s\n", "user_2");
return 0;
}
//==========================================
//定义了函数 user_3
int user_3(char para1, int para2)
{
para1 = para1; //为了防止编译器出现未使用变量警告信息
para2 = para2; //为了防止编译器出现未使用变量警告信息
printf("%s\n", "user_3");
return 0;
}
//==========================================
//定义了函数指针变量 pF
int (*pF)(char para1, int para2);
//==========================================
//以函数指针为参数的函数
void system_func(int (*pF)(char para1, int para2))
{
printf("calling the function ");
pF('a', 1);
printf("the system function's own functionality.\n\n");
}
//====================主函数======================
int main(void)
{
system_func(user_1); //调用了函数 user_1
system_func(user_2); //调用了函数 user_2
system_func(user_3); //调用了函数 user_3
return 0;
}
将函数指针作为函数参数,可以将一个函数传递到另一个函数内来调用。这种用法在操作系统这类系统软件中应用得相当频繁,因为操作系统的某些函数的功能需要用户配合才能实现,常需要调用用户提供的函数。目前,大致有两种方法来实现用户函数的调用:一种方法就是像上例中的 system_func()
那样以函数指针来传递被调用的用户函数;另一种方法就是在系统函数中设置所谓的“钩子函数”,即在系统函数中需要调用用户功能的地方调用一个空函数,然后由用户去实现这个空函数的功能。在第一种方法中,由于是系统函数调用用户函数,与常用的用户程序调用系统函数的调用方向不同,故人们将这种调用叫做“回调”,而被系统调用的这个函数就叫做“回调函数”。