编程语言-C的指针问题

为什么说指针是C语言的精髓? 指针有什么用 举例说明

C语言的指针和语言历史背景相关,这个得从在上个世纪60年代说起……

一位年轻小伙小丹(Dennis MacAlistair Ritchie),需要编写一个操作系统,但缺少合适的语言工具。那时B语言精炼且接近硬件,但过于简单且数据无类型。用汇编写引导程序合适,大型操作系统效率有点低。年轻人想法就是多,没有工具就自制工具上,于是小丹同学就顺手设计了C语言。

作为一门语言工具的目的很纯粹,为操作系统而生[旺柴]

  • 开发效率高的高级语言;
  • 能够直接操作硬件;
  • 编译后的代码执行效率高;

C作为高级语言,与同时期的高级语言比肯定不能弱,毕竟那时没有Java、C++、PHP、Go、Python、C#等高级语言,而且这一点和题目没什么关系[旺柴]


正式回到主题

1. 操作硬件

这个主要是应用在与硬件非常近的场景,主要用于读写特定寄存器,比如:嵌入式开发、驱动软件开发、操作系统开发等。

这里先以以简单的stm32f103为例。GPIOB8管脚有连接一个LED灯,此时若想点亮这个灯就需要控制管脚输出高电平:

1
2
3
4
5
6
7
8
9
#define GPIOB_BSRR *((volatile unsigned int *)(0x40010C00 + 0x10))

int main(void)
{
...
//点亮LED
GPIOB_BSRR = 1 << 8;
...
}

注意看,宏GPIOB_BSRR定义为*((volatile unsigned int *)0x40010C10),这里已经应用了指针的知识,这个地址就是GPIOB口的BSRR寄存器。至于为什么是这个地址和写入值的含义与处理器相关,具体可以查看STM32F103芯片手册。换一种写法就是

1
2
3
4
5
6
7
8
int main(void)
{
volatile unsigned int *pGPIO_BSRR = (volatile unsigned int *)0x40010C10;
...
//点亮LED
*pGPIO_BSRR = 0x100000000;
...
}

指针是一种变量,pGPIO_BSRR中存储的值是0x40010C10,则点亮LED语句就是向地址0x40010C10写入一个整数值0x100000000若不用指针用普通变量能表达向特定寄存器赋值的语义吗?显然是不能的

毕竟声明一个整数变量,它地址是0x40010C10概率还没我中大奖的概率大。每个特定处理器寄存器的地址是固定的,与内存地址范围没有交叠,所以编译器也不会给普通变量分配这样的地址。

部分同学可能不熟悉嵌入式,再找找上古Linux 0.11中有这么一个函数con_init,读取显示参数(0x90006地址存储显示参数在启动时获取的存储的)

1
2
3
4
5
6
7
8
#define ORIG_VIDEO_COLS (((*(unsigned short *)0x90006) & 0xff00) >> 8)

void con_init(void)
{
...
video_num_columns = ORIG_VIDEO_COLS;
...
}

换种写法就是

1
2
3
4
5
6
7
8
void con_init(void)
{
...
unsigned short *pORIG_VIDEO = (unsigned short *)0x90006;
unsigned short ORIG_VIDEO_COLS = ((*pORIG_VIDEO)>>8) & 0xff;
video_num_columns = ORIG_VIDEO_COLS;
...
}

这段就是将参数从内存地址0x90006中取出来,参数高字节就是显示列数。与嵌入式不同的是,这个地址就是普通内存地址,还是有运气遇到这个地址的[旺柴]

2. 执行效率高

其他老师不太清楚,我们像上课那会儿,老师怎么介绍指针的?

1
2
3
4
int a = 5;
int *pa = &a;
*pa = 6;
printf("a=%d\n", *pa);

本人:「老师,为啥不直接使用变量a

老师:「这里讲解指针pa的用途」

[旺柴]

其实,指针很多时候是为提高执行效率,避免大块数据拷贝。比如,在收到一个1.5K的网络二进制包,需要调用解析函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define PACKAGE_SIZE 1500

typedef struct net_raw_s
{
int data[PACKAGE_SIZE];
...
} net_raw_t;

int parse(const net_raw_t raw)
{
// 具体解析数据流程,保密
...
}

int main()
{
net_raw_t recv_net_data;
...
// 接收到网络数据准备解析
int ret = parse(recv_net_data);
...
}

每次调用parse函数都会拷贝net_raw_t类型数据,这个数据包有1.5K大小耗时非常多的。从执行效率考虑,此处非常适合使用指针方式,虽然要麻烦一点点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define PACKAGE_SIZE 1500

typedef struct net_raw_s
{
int data[PACKAGE_SIZE];
...
} net_raw_t;

int parse(net_raw_t *const raw)
{
// 具体解析数据流程,保密
...
}

int main()
{
net_raw_t recv_net_data;
...
// 接收到网络数据准备解析
int ret = parse(&recv_net_data);
...
}

本质上说,函数参数永远都是值传递,没有所谓的地址传递。函数参数raw指针变量只是赋值为recv_net_data变量的地址,没有所谓的地址传递。而指针的优势是,不管什么类型的指针,它自身都只占用4字节内存(32位),所以赋值效率高。若将函数parse修改为传递1.5K个指针,相信它的执行效率比最初版本parse还要慢。


还有一个抽象性应该很多高级语言都有,只是C语言需要利用指针来完成。

3. 抽象性

主要涉及函数指针,在模块设计时用处比较大,就是模块对外提供抽象接口。可以降低模块的耦合性,提升功能内聚性,提高协同开发效率,整体降低工程成本。这个特性在Linux内核中应用也非常广泛,下面主要以字符驱动为例进行说明。没接触过Linux字符驱动的建议先参考这篇文章

Linux设备驱动之字符设备驱动(超级详细~)

Linux字符设备驱动的通用操作

  • 初始化
  • 打开设备
  • 读数据
  • 写数据
  • 关闭设备
  • 等等操作

所有设备都按下面这个模板实现接口逻辑,就形成各种字符设备驱动。下面这个是并口打印机的驱动(源文件在linux-2.6.12/drivers/char/lp.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct file_operations {
...
int (*open) (struct inode *, struct file *);
...
};

static struct file_operations lp_fops = {
.owner = THIS_MODULE,
.write = lp_write,
.ioctl = lp_ioctl,
.open = lp_open,
.release = lp_release,
.read = lp_read,
};

static int lp_open(struct inode * inode, struct file * file)
{
...
}

在加载了这个驱动后,应用程序只需要调用openreadclose等接口就能操作这个字符设备。当然应用程序需要通过打印机打印信息时,需要先打开打印机设备,就要调用内核open函数。此时调用的open不是结构体struct file_operations中的函数指针,而是内核接口封装过的,但最终是调用lp_open,这样设备就初始化好就能继续操作。

在打开设备过程中,内核调用它们的都是结构体struct file_operations中的open函数指针。此时内核只关注要打开设备,而不关注设备如何打开,这些其实就是抽象。不管是打印机设备还是串口设备,只要需要打开动作,内核就负责找到设备的结构体struct file_operations中的open函数指针调用即可。

若没有这个抽象性,那编写内核时就要直接调用驱动接口。则一个开发人员既要掌握内核驱动管理逻辑细节,又要掌握设备驱动逻辑细节,最后软件维护成本必然很高。


上面说的这些都是了解的C语言指针的用处,只想说它太重要了,这也是这门上古语言能流传至今的秘宝。

以上都是个人对指针的理解,若有纰漏望轻喷[旺柴]