别再纠结指针了!!!

李肖遥

共 2410字,需浏览 5分钟

 ·

2020-09-21 10:02



来源:裸机思维

作者:GorgonMeducer


【在前面的话】


不得不说,看了太多的人在各种地方讨论指针……越发看下去,越发觉得简单的事情被搞那么复杂,真是够了,求求你们,放开那个变量,让我来!


【万能转换公式】


1、从变量的三要素开始谈起

  为了把复杂的事情说简单,我们抛开指针先从变量谈起。(好吧,不知道这个笑话是不是够冷)一个变量(Variable),或者顺便兼容下面向对象(OO)的概念,我们统一称为对象(Object),除了保存于其中的内容以外,只有三个要素:


  1. 由一定宽度无符号整数(Unsigned Integer)所表示的地址“数值”(Address Value)

  2. 对象的大小(Size)和对齐

  3. 可对该对象适用的“方法”(Method)和“运算”(Operation)


其中,我们习惯于把后两者合并在一起称之为,变量的"类型"。


> 地址数值(Address Value)

  地址的数值是一个无符号整数,其位宽由CPU的地址总线宽度所决定。话虽如此,其实主要还是编译器在权衡了“用户编写代码的便利性”以及“生成机器码的效率”后为我们提供的解决方案:例如,针对8位机,编译器普遍以等效为uint16_t的整数来保存地址信息;针对16位机和32位机,编译器则普遍选择uint32_t的整数来保存地址信息;针对64位机,编译器则可能会提供两种指针类型,分别用来对应uint32_t的4G地址空间和由uint64_t所代表的恐怖地址空间……


  提问,8086有20根地址线,请问用哪种整型来表示其地址呢?(uint16_t、uint32_t还是uint20_t)——由于uint20_t并不存在,也并不适合CPU进行地址运算,所以统一用uint32_t来表示最为方便。


  总而言之,地址的数值是一个无符号整数。知道这个有什么用呢?我们待一会再说。这里我们需要强调一句废话:地址的数值既然是整数,那么它就可以用另外的变量(类型合适的整形变量或者指针变量)进行保存——任何指针变量,其本质,首先是一个无符号整形变量。任何指针常量,其本质首先是一个无符号整数


请一定要记住(重要的事情说三遍):

变量的三要素中,仅有地址值有可能会占用物理存储空间。

变量的三要素中,仅有地址值有可能会占用物理存储空间。

变量的三要素中,仅有地址值有可能会占用物理存储空间。



> 大小(Size)和对齐

  如果仅从变量的大小来看整个计算机世界,就好像一副彩色图片被二值化了,到处是Memory Block,他们的尺寸通常是1个字节、2个字节、4个字节、8个字节、16个字节或者由他们组合而成的长度各异Block。这些Block通常被编译器在代码生成的时候对其到地址的宽度上,比如地址宽度是32bit的,就对齐到4字节,地址宽度是16bit的,就对齐到2字节……


  如果你习惯于使用汇编语言来进行开发,你一定能体会我所描述的这种感觉。这些你统统都可以忘记但有一点绝对要记住(重要的事情说三遍):


变量的三要素中,大小值从不会额外占用物理存储空间。

变量的三要素中,大小值从不额外占用物理存储空间。

变量的三要素中,大小值从不额外占用物理存储空间。


注意:地址的大小信息描述的是这个变量占用几个字节,这里说大小信息并不占用物理存储器空间,并不是说,变量中保存的内容不占用存储器空间。请注意区别。


  C语言中,可以用sizeof( )来获取一个变量的大小。前面我们说过,指针首先是一个整形变量,那么容易知道:

uint8_t *pchObj;uint16_t *phwObj;uint32_t *pwObj;

sizeof(pchObj)sizeof(phwObj)sizeof(pwObj)以及sizeof任意其它指针的结果都是一样的,都是当前系统保存地址数值的整形变量的宽度。对32位机来说,这个数值就是4——因为,sizeof( ) 求的是括号内变量的宽度,而指针变量首先是一个整形变量!同一CPU中同一寻址能力的指针,其宽度是一样一样一样的!


一个类型的大小信息除了描述一个变量所占用的存储器尺寸以外,还隐含了该变量的对齐信息。从结论来说,32位处理器架构下:
  • 对普通的变量类型来说,编译器“倾向于”将小于等于64Bit的数据类型自动对齐到与其大小相同的整数倍上;比如2字节大小的变量会被对齐到2的整数倍地址上,4字节大小的变量会被对齐到4的整数倍地址上,以此类推。


  • 对结构体和共用体来说,它会以所有成员中最大的那个对齐作为自己的对齐值。比如,下面的结构体就是对齐到4的整倍数,因为结构体内最大的对齐类型来自于一个指针(pTarget),而指针在32位系统下是4字节,因此整个结构体的对齐就是4:

struct example_t {    uint8_t chID;             //!< 对齐到1字节    uint16_t hwCMDList[4];    //!< 对齐到2字节    void *pTarget;            //!< 对齐到4字节};                            //!< 整个结构体对齐到4字节


> 适用的方法(Method)和运算(Operation)

  对面向对象中的对象来说,方法就是该对象类中描述的各种成员函数(Method);

  对数据结构中的各类抽象数据类型(ADT,Abstract Data Type)来说,就是各类针对该数据类型的操作函数,比如链表的添加(Add)、插入(Insert)、删除(Delete)、和查找(Search)操作;比如队列对象的入队(enqueue)、出队(Dequeue)函数;比如栈对象的入栈(PUSH)、出栈(POP)等等……

  对普通数值类的变量来说,就是所适用的各类运算,比如针对 int的四则运算(+、-、*、/、>、<、==、!=...)。你不能对float型的数据进行移位操作,为什么呢?因为不同的类型拥有不同的适用方法和运算。

  也许你已经猜到了,类型所适用的方法和运算也不会占用物理存储空间。由于变量的“大小信息”和“适用的方法和运算信息”统称为“类型(Type)信息”,我们可以简化为:

变量的三要素中,类型信息从不额外占用物理存储空间。

变量的三要素中,类型信息从不额外占用物理存储空间。

变量的三要素中,类型信息从不额外占用物理存储空间。


2、化繁为简的威力

  前面说了那么多,实际上可以简化为下面的等式:


  Variable = Address Value + Type Info

  变量 = 地址数值 + 类型信息


  其中,地址数值的保存、表达和运算是(有可能)实实在在需要占用物理存储器空间的(RAM和ROM);而类型信息则是编译器专用的——仅仅在编译时刻会用到,用来为编译器语法检测和生成代码提供信息的——话句话说,你只需要知道,类型信息是一个逻辑上的信息,是虚的,在最终生成的程序中并不占用任何存储器空间。你也可以理解为,类型信息最终以程序行为的方式体现在代码中,而并不占用任何额外的数据存储器空间


  既然知道了变量的本质,我们就可以随心所欲了,比如,我们可以随意创建一个全局变量:

#define s_wMyVariable    (* (( uint32_t *) 0x12345678))

  s_wMyVariable是一个 uint32_t类型的全局变量,它的地址是0x12345678。它和我们通过普通方式生成的全局变量使用起来没有任何区别——当然,它是个黑户,简单说就是它所占用的空间是非法的,无证的,在编译器的户口本看来,这块空地上什么都没有,因此它仍然会将0x12345678开始的4个字节用作其它目的。

  一方面,是不是突然觉得手上拥有了神一般的权利?其实,这种方法非常常用,MCU的寄存器就是这么定义的,例如:

#define CONTROL      (*(volatile uint32_t *) CONTROL_BASE_ADDR)

我们可以将上述定义全局变量的方法提炼成所谓的全局变量公式:

#define <全局变量的名称>  (*(<全局变量的类型> *)<全局变量的地址>)


甚至,我们干脆定义一个宏来替我们批量生产全局变量:

#define __VAR(__TYPE, __ADDR)     (*(__TYPE *) (__ADDR))

使用起来也很方便,例如:

__VAR(  float, 0x20004000  ) = 3.1415926;

  总结来说:只要给我一个整数,我就可以把它变成任何类型的全局变量!你可以的!我看好你哦。


3、万能类型转换

  只要你牢记了那句话:给我一个整数,我就能翘起地球,那么我们就可以用它玩出更好玩的东西。


首先,整数从何而来呢?除了前面的直接使用常数以外,当然还可以从整形变量中来,例如,前面的例子可以简单的改写成:

uint32_t wTemp = 0x20004000;__VAR(  float, wTemp  ) = 3.1415926;

毫无压力!整数还可以从指针中来,例如:

//!我们定义一个全局变量 wDemo,其地址是0x20004000#define wDemo    (*(uint32_t *) 0x20004000   )uint32_t *pwSrc = &wDemo;                //!< 获取wDemo的地址//!< 获取指针保存的地址数值,并用普通整形变量保存下来uint32_t wTemp = (uint32_t) pwSrc;    __VAR(  float, wTemp  ) = 3.1415926;

是不是觉得wTemp有点多余?因此我们可以直接写成:

//!我们定义一个全局变量 wDemo,其地址是0x20004000#define wDemo    (*(uint32_t *) 0x20004000   )uint32_t *pwSrc = &wDemo;                //!< 获取wDemo的地址__VAR(  float, (uint32_t) pwSrc  ) = 3.1415926;

是不是pwSrc也多余了?好,我们继续来:

//!我们定义一个全局变量 wDemo,其地址是0x20004000#define wDemo    (*(uint32_t *) 0x20004000   )__VAR(  float, (uint32_t)  &wDemo  ) = 3.1415926;

当然,如果这个时候你说直接填0x20004000不就行了,要么你已经懂了,要么你还糊涂着,仔细想想:


  • 如果wDemo是任意由编译器生成的对象(变量),意味着什么呢?(前面说过,作为全局变量,我们土法制造的和compiler原装的用起来没有任何区别)

  • 如果我们有任意的指针,我们需要对指针指向的类型进行转换(转换后才好操作),应该怎么办?


接下来,我们很容易根据前面的讨论,得出第二个万能公式,可以将任意变量(或地址)转换成我们想要的类型:

#define CONVERT(  __ADDR,   __TYPE )            \    __VAR(  (__TYPE),  (uint32_t) (__ADDR)  )

例如,我们可以直接将字节数组中某一段内容截取出来,当做某种类型的变量来访问:

//! 某数据帧解析函数void command_handler(  uint8_t *pchStream, uint16_t hwLength  ){    // offset 0, uint16_t      uint16_t hwID = CONVERT( pchStream, uint16_t);      // offset 4, float    float fReceivedValue = CONVERT( &pchStream[ 4 ], float ) ;    ...}

4、请忘记指针

  如果你是一个指针苦手,那么请忘记之前所学的一切。记住一句话:指针只是一个用法怪异的整形变量,专门用来保存变量的地址数值指针的类型都是用来欺骗编译器的,我是聪明的人类,我操纵类型,我不是愚蠢的编译器。


  推论:因为指针变量的本质是整形变量,所以指向指针的指针,只不过是一个指向普通整形变量的普通指针,因此指向指针的指针并不存在——世界上只存在普通指针——世界上只存在用法怪异的整形变量,专门用来保存目标变量的地址数值。


  推论:世界上并不存在指向指针的指针的指针的指针……


  给我一个整数,我自己造自己的变量。

  指针的数值运算太坑?转换成整数,加减乘除,随便整。


5、小结

地址:所谓地址就是一个整形的数值(常数)。地址不包含任何类型信息

指针:指针分为指针常量和指针变量,单独说指针的时候,通常指指针常量。其中:

指针常量 = 地址数值(常数)+ 类型信息

指针变量 = 整形变量 + 类型信息


变量 = (* 指针)

指针 = &变量


类型信息可以通过强制类型转换来实现,也就是大家熟悉的  () 用法。地址数值的改变,则统一转化为普通整数以后再说。


指针常量 = 整数常量 + 类型信息      

也就是:

指针常量 = (<类型信息> *)整数 常量


反过来也成立:


整数常数 = 指针常量 - 类型信息

也就是:

整数常数 = (unsigned int)指针常量


同理,可以获得整形变量和指针之间的转换关系,这里就不一一列举了。


怎么样,事情是不是变得简单了?哪有什么指针,哪有那么多麻烦事情?统统都是整数。下回我们将一起来捅一个马蜂窝。哈哈哈哈哈


【后记】



说在后面的话:

  其实,每次看到一群人热热闹闹的谈论指针,我心里真实的想法是:这么简单的事情被你们搞这么复杂——把复杂的事情变简单,把简单的事情做可靠才是使用C语言进行工程设计的关键。指针不是炫技,请各位老司机们安全驾驶。




【说在前面的话】


如果说指针在一些人心中是导致代码“极其不稳定的奇技淫巧”,那么“函数指针”则是导致代码跑飞和艰涩难懂的罪魁祸首。然而,函数指针的定义和使用其实非可以非常简单——请暂时忘记原本你从课本上所学的知识,让我们来看一种函数指针的正确打开方式。


【函数指针】

假设有一个目标函数,其函数原型是这样的:
extern bool serial_out(uint8_t chByte);

那么如何定义指向该函数原型的函数指针呢?


步骤1:用typedef定义一个函数原型类型:

typedef bool serial_out_t(uint8_t chByte);

或者省略形参的变量名:

typedef bool serial_out_t(uint8_t);


步骤2:使用新类型按照普通指针的使用方法来使用。


  • 使用新的类型来定义指向该类型的指针——函数指针

serial_out_t *fnPutChar = NULL;...fnPutChar = &serial_out;

如果用传统的方法,上面的代码等效为:

bool (*)(uint8_t) fnPutChar = NULL;...fnPutChar = serial_out;


  • 使用函数指针的来访问函数

...if (NULL != fnPutChar) {    //! 调用函数指针所指向的函数    bResult = (*fnPutChar)('H');     //!< 这里的"*"可以省略,但最好保留哦}...


需要特别注意:

  • 我们并不是通过typedef来直接定义指针类型,而是定义一个专门针对目标函数原型的新类型——这样在定义函数指针变量时就和普通变量类型一样需要使用“*”——任何时候都知道这是一个指针,不会迷惑。

  • 虽然这里"&"在C语言语法上是可以省略的,但是为了简化规则(简化需要记忆的特殊情况),这里我们要遵守普通指针的使用规则——取地址的时候要使用取地址运算符“&”,访问指针所指向空间的时候,“*”也不能省略。


使用这种方法定义和使用函数指针好处非常明显:
  • 极大的提高了代码的可读性——与函数指针有关的代码,任何时候一眼看就知道是一个指针;

  • 极大的降低了函数指针的使用难度——通过typedef定义一个针对函数原型的类型,将函数指针的使用变得跟普通指针一摸一样,从而省去了额外的记忆负担;

  • 允许轻松套娃


关于最后一点,我们不妨做一个极端一点的例子:

假设有一个函数,其输入参数是一个函数指针,其返回函数也是一个函数指针:

typedef struct task_cb_t task_cb_t;
typedef const charget_err_string_t(task_cb_t *ptTask);typedef void on_task_cpl_evt_t(task_cb_t *ptTask);
extern get_err_code_t *run_task( task_cb_t *ptTask,  on_task_cpl_evt_t *fnTaskCPLEvtHandler);

为了让这个例子显得更为合理,我假想了一个调度器,而run_task就是这个调度器执行用户任务的函数。分析上面的代码容易清晰的获得以下信息:

  • task_cb_t 是用户任务的控制块,具体内容未知,但我们可以用它来声明指针变量;

  • 函数指针(get_err_code_t *)指向的函数可以返回指定任务的错误代码;

  • 函数指针(on_task_cpl_evt_t *)所指向的函数是一个事件处理程序;

  • 函数 run_task会执行指定的任务,“可能”会在任务执行完成的时候通过函数指针 fnTaskCPLEvtHandler调用一个用户指定的事件处理程序;

  • 函数run_task在执行指定任务的时候,如果发生了错误,“可能”会返回一个非NULL的函数指针,类型是:(get_err_code_t *),用户可以通过这个函数指针获取任务ptTask专属的错误信息(字符串);


怎么样,是不是看起来一切都简单自然?那你考虑过,如果要做一个指向run_task的函数指针应该是什么样么?套娃开始:

typedef get_err_code_t *run_task_t(task_cb_t *, on_task_cpl_evt *);

【注意】run_task_t 前面的“*”是 (get_err_code_t *)的一部分。


我们可以用新类型run_task_t定义一个函数指针:

static run_task_t *s_fnDispatcher = NULL;
最后,作为一个挑战,我很怀疑有没有人能不借助typedef的方法,重新写出函数指针 s_fnDispatcher 的定义?欢迎在评论区留言,写下你的答案。


【后记】


借助typedef,函数指针的使用可以极大的简化。与传统方式不同的是,这里typedef定义的不是函数指针本身,而是一个“函数原型的类型”——借助这一小技巧,我们成功的贯彻了“复杂的事情变简单、简单的事情变可靠”的原则。

推荐阅读:


嵌入式编程专辑
Linux 学习专辑
C/C++编程专辑
Qt进阶学习专辑

长按前往图中包含的公众号关注

浏览 43
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报