嵌入式上的C语言
这篇文章其实更适合在裸机开发操作寄存器之前来学习。
内存
为什么需要内存?
程序= 代码 + 数据
程序运行的目的要么重在数据结果(有返回值),要么重在过程(无返回值),要么即重于结果又重过程。
计算机程序的运行过程,其实就是程序中很多个函数相继运行的过程。程序是由很多个函数组成的,程序的本质就是函数,函数的本质就是加工数据的动作。
哈佛结构:将程序的代码和数据分开存放的一种结构,但是存放的位置可以是相同的也可以是不同的(ROM&RAM或RAM),总之只要是分成两个部分单独访问的结构都是哈佛结构。
哈佛结构的特点就是代码和数据单独存放,使之不会互相干扰,进而当程序出 BUG 时,最多只会修改数据的值(因为代码部分是只读的,不可改写),而不会修改程序的执行顺序。因此,这种结构大量应用在嵌入式编程 。
冯诺依曼结构:将代码和数据统一放在RAM(随机存取存储器)中,数据和代码之间一般是按照程序的执行顺序依次存储。
这样就会导致一个问题,如果程序出 BUG,由于程序没有对代码段的读写限定,因此,它将拥有和数据一样的读写操作权限。于是就会很容易的死机,一旦代码执行出现一点改变就会出现非常严重的错误。但是,冯诺依曼结构的好处是可以充分利用有限的内存空间,并使 CPU 对程序的执行十分的方便,不用来回跑。
程序运行为什么需要内存?
程序运行时要存放代码和数据,代码放在 DRAM 的只读权限代码段,数据放在 DRAM 的可读可写数据段,程序要跑,内存是必要条件 。
内存管理
从 OS 角度讲: OS 掌握所有的硬件内存,因为内存很大,所以 OS 把内存分成 1 个 1 个的页面(其实就是分块,一般是 4KB),然后以页面为单位来管理。页面内用更细小的方式来以字节为单位管理。 (只要调用系统的API就能管理内存)
在没有 OS 时,也就是裸机程序中程序需要直接操作内存,编程者需要自己计算内存的使用和安排。
C: C 语言编译器帮我们管理内存地址,我们都是通过编译器通过的变量名来访问内存的,
OS 下如果需要大块内存,可以通过 API(mallos、 free)来访问内存。 裸机程序中需要大块内存需要自己定义数组等来解决。
C++: C++对内存的使用进一步封装。我们可以用 new 来创建对象(其实就是为对象分配内存),然后使用完了用 delete 来删除对象(其实就是释放内存)。所以 C++比 C 更容易一些。但是 C++中的内存管理还是要靠程序员自己来做,例如需要使用 delete 删除对象释放内存,如果忘记,就会造成内存不能释放,就是所谓的内存泄露
从硬件角度:硬件的内存实现本身就是有宽度的,也就是内存条本身就有 8 位、 16 位等。需要注意的是,内存芯片之间可以并联,通过并联后 8 位内存芯片可以做出来 16 位、 32位的硬件内存。 从逻辑角度:内存位宽在逻辑上是任意的,甚至逻辑上内存的位宽可以是 24 位,但没必要。从逻辑角度,不管内存位宽多少,直接操作即可。但因为所有的逻辑操作都是要硬件实现,所以还是要尊重硬件内存位宽。
内存编制和寻址、内存对齐
在程序运行中,CPU实际只认识内存地址,而不关心这个地址所代表的空间在哪里、怎么分布的,因为硬件设计保证了这个地址就能找到这个格子,所以内存单元的两个概念:地址和空间是内存单元的两个概念。
内存编制是以字节为单位的。
数据类型是用来定义变量的,而这些变量需要在内存中存储和运算。所以数据类型必须和内存相匹配才能获得最好的性能。
内存的对齐访问不是逻辑问题, 是硬件问题。从硬件角度来说, 32 位的内存它 0、1、2、3 四个单元本身逻辑上就有相关性,这 4 个字节组合起来当做一个 int,硬件上就是合适的,效率就高。
C语言操作内存
变量名即对内存地址的封装。指针即保存这个地址的变量。函数名实质就是一段代码的首地址。
C 语言数据类型的本质含义:表示内存格子的个数(每个格子 1 个字节)和解析方法。
(1)决定内存格子的个数:如果给一个地址 0x30000000,那么这个地址即一个格子。如果int 定义它,这个地址就会扩展为 4 个格子。
(2)解析方法:(int) 0x30000000 含义就是从 0x30000000 开始的 4 个格子连起来共同存放的一个 int 型数据。(float) 0x30000000 含义就是从 0x30000000 开始的 4 个格子连起来共同存放的一个 float 型数据。
用指针来间接访问内存
C 语言中的指针,全名叫指针变量,指针变量其实很普通变量没有任何区别。譬如 int a 和int *p 其实没有任何区别, a 和 p 都代表一个内存地址(譬如是 0x20000000),但是这个内存地址(0x20000000)的长度和解析方法不同。 a 是 int 型所以 a 的长度是 4 字节,解析方法是按照 int 的规定来的(以 0x20000000 开头的连续的 4 个字节的空间中存了一个 int型的数); p 是 int *类型,所以长度是 4 字节,解析方法是 int *的规定来的(以 0x20000000开头的连续 4 字节的空间中存储了 1 个地址,这个地址所代表的内存单元中存放的是一个int 类型的数)。在 32 位系统中,指针变量永远占 4 个字节的内存空间。
用数组来管理内存
数组管理内存和变量其实没有本质区别,只是符号的解析方法不用。(普通变量、数组、指针变量其实都没有本质差别,都是对内存地址的解析,只是解析方法不一样)。
int a; //编译器分配 4 个字节长度给 a,并且把首地址和符号 a 绑定起来。
int b[10]; //编译器分配 40 个字节长度给 b,并且把首元素的首地址和符号 b绑定起来。
数组中第一个元素(b[0])就称为首元素;每一个元素都是类型都是 int,所以长度都是 4个字节,其中第一个字节的地址就称为首地址;首元素 b[0]的首地址就称为首元素首地址。
数组的缺陷:(1)数组中元素类型必须相同 (2)数组大小必须在定义时给出,而且一旦给出不能更改
内存管理之结构体
结构体和数组的本质差异还是在于怎么找变量地址的问题。
C 语言作为面向过程的语言,可以通过结构体内嵌指针实现面向对象的代码。
当然,面向对象的语言更为简单直观。
1 | struct s |
使用这样的结构体就可以实现面向对象。
内存管理之栈
栈是一种数据结构, C 语言中使用栈来存放局部变量。
栈管理内存的特点(小内存、自动化):
先进后出 FILO(First In Last Out) 栈
先进先出 FIFO(First In First Out) 队列
栈的特点是入口即出口,只有一个口,另一个口是堵死的。所以先进去的必须后出来队列的特点是入口和出口都有,必须从入口进,从出口出,所以先进去的必须先出来,否则就堵住后边的。
栈的应用举例:局部变量
C 语言中的局部变量是用栈来实现的。
我们在 C 语言中定义一个局部变量时(int a),编译器会在栈中分配一段空间(4 字节)给这个局部变量用(分配时栈顶指针会移动给出空间,给局部变量 a 用的意思就是,将这字节的栈内存的内存地址和我们定义的局部变量名 a 给关联起来),对应栈的操作是入栈。
注意:这里栈指针的移动和内存分配都是自动的。
然后等我们函数退出时,局部变量就会灭亡。对应栈的操作就是弹栈(出栈)。出栈时也是栈顶指针移动将栈空间中与 a 关联的那 4 个字节空间释放。这个动作也是自动的,不需要写代码干预。
栈的优点:入栈和出栈都由 C 语言自动完成。
分析一个细节: C 语言中,定义局部变量时如果未初始化,则值是随机的,为什么?
定义局部变量,其实就是在栈中通过移动栈指针来给程序提供一个内存空间和这个局部变量名绑定。因为这段内存空间在栈上,而栈内存是反复使用的(脏的,上次用完没清零的),所以说使用栈来实现的局部变量定义时如果不显式初始化,值就是脏的(也就是随机值)。
栈的约束:预定栈大小不灵活,怕溢出首先,栈是有大小的。所以栈内存大小不好设置。如果太小怕溢出,太大怕浪费内存。(这个缺点有点像数组)
其次,栈的溢出危害很大,一定要避免。所以我们在 C 语言中定义局部变量时不能定义太多或者太大(譬如不能定义局部变量时 int a[10000]; 使用递归来解决问题时一定要注意递归收敛)。
内存管理之堆
堆内存管理方式特点就是自由(随时释放申请;大小块随意)。堆内存是OS规划给堆管理器来管理的,之后来给使用的API来使用。
我们会在需要内存容量比较大,需要反复使用及释放时,会使用堆内存。很多数据结构(譬如链表)的实现都需要使用堆内存。
特点 1:容量不限(常规使用的需求容量都能满足)。
特点 2:申请及释放都需要手工进行,手工进行的含义就是需要写代码明确申请 malloc 和
释放 free。如果申请内存并使用后未释放,这段内存就丢失了(在堆管理器的记录中,这
段内存仍然属于你这个进程,但是进程自己又以为这段内存已经不用了,再用的时候又会
去申请新的内存块,这就叫吃内存。),称为内存泄露
C 语言操作堆内存的接口(malloc、 free)
堆内存释放时最简单,直接调用 free 释放即可。
1 | void free(void *ptr) |
堆内存申请时,有 3 个可选择的类似功能的函数: malloc、 calloc、 realloc
1 | void *malloc(size_t size); |
譬如要申请 10 个 int 元素的内存:
1 | malloc(40); |
堆内存申请时必须给定大小,然后一旦申请完成大小不能更改,如果要变更,只能通过realloc 接口
复杂数据结构
链表、哈希表(散列表)、二叉树、图等
链表是最重要的,链表在 Linux 内核中使用非常多,驱动、应用编写很多时候都需要使用链表。所以对链表必须掌握。掌握到: 会自己定义结构体来实现链表、会写链表的节点插入(前插、后插)、节点删除、节点查找、节点遍历等。
哈希表不是很常用,一般不需要自己写实现,而直接使用别人实现的哈希表表较多。对我们来说,最重要的是明白哈希表的原理、从而知道哈希表的特点,从而知道什么时候该使用哈希表,当看到别人用了哈希表的时候要明白别人为什么要用哈希表、合适不合适?有
没有更好的选择?
二叉树、图不用深究。
C语言位操作
位与&
(1)注意:位与符号是一个&,两个&&是逻辑与。
(2)真值表: 1&0=0 1&1=1 0&0=0 0&1=0
(3)从真值表可以看出:位与操作的特点是,只有 1 和 1 位于结果为 1,其余全是 0.
(4)位与和逻辑与的区别:位与时两个操作数是按照二进制位彼次对应位相与的,逻辑与是两个操作数作为整体来相与的。(举例: 0xAA&0xF0=0xA0, 0xAA && 0xF0=1)
位或|
(1)注意:位或符号是一个|,两个||是逻辑或。
(2)真值表: 1|0=1 1|1=1 0|0=0 0|1=1
(3)从真值表可以看出:位或操作的特点是:只有 2 个 0 相位或才能得到 0,只要有 1 个 1结果就一定是 1.
(4)位或和逻辑或的区别:位或时两个操作数是按照二进制位彼次对应位相与的,逻辑或是两个操作数作为整体来相或的。
位取反~
(1)注意: C 语言中位取反是~, C 语言中的逻辑取反是!
(2)按位取反是将操作数的二进制位逐个按位取反(1 变成 0, 0 变成 1);而逻辑取反是真(在 C 语言中只要不是 0 的任何数都是真)变成假(在 C 语言中只有 0 表示假)、假变成真。
任何非 0 的数被按逻辑取反再取反就会得到 1;
任何非 0 的数被按位取反再取反就会得到他自己;
位异或^
(1)位异或真值表: 1^1=0 0^0=0 1^0=1 0^1=1
(2)位异或的特点: 2 个数如果相等结果为 0,不等结果为 1。记忆方法:异或就是相异就或操作起来。
位与、位或、位异或的特点总结:
位与:(任何数,其实就是 1 或者 0)与 1 位与无变化,与 0 位与变成 0
位或:(任何数,其实就是 1 或者 0)与 1 位或变成 1,与 0 位或无变化
位异或:(任何数,其实就是 1 或者 0)与 1 位异或会取反,与 0 位异或无变化
左移位<< 与右移位>>
C 语言的移位要取决于数据类型。
对于无符号数,左移时右侧补 0(相当于逻辑移位)
对于无符号数,右移时左侧补 0(相当于逻辑移位)
对于有符号数,左移时右侧补 0(叫算术移位,相当于逻辑移位)
对于有符号数,右移时左侧补符号位(如果正数就补 0,负数就补 1,叫算术移位)
嵌入式中研究的移位,以及使用的移位都是无符号数
位与位或位异或在操作寄存器时的特殊作用
寄存器操作的要求(特定位改变而不影响其他位)
(1)ARM 是内存与 IO 统一编址的, ARM 中有很多内部外设, SoC 中 CPU 通过向这些内部外设的寄存器写入一些特定的值来操控这个内部外设,进而操控硬件动作。所以可以说:读写寄存器就是操控硬件。
(2)寄存器的特点是按位进行规划和使用。但是寄存器的读写却是整体 32 位一起进行的(也就是说你只想修改 bit5~ bit7 是不行的,必须整体 32bit 全部写入)
(3)寄存器操作要求就是:在设定特定位时不能影响其他位。
(4)如何做到?答案是:读-改-写三部曲。读改写的操作理念,就是:当我想改变一个寄存器中某些特定位时,我不会直接去给他写,我会先读出寄存器整体原来的值,然后在这个基础上修改我想要修改的特定位,再将修改后的值整体写入寄存器。这样达到的效果是:在不影响其他位原来值的情况下,我关心的位的值已经被修改了。
特定位清零用&
(1)回顾上节讲的位与操作的特点:(任何数,其实就是 1 或者 0)与 1 位与无变化,与 0位与变成 0
(2)如果希望将一个寄存器的某些特定位变成 0 而不影响其他位,可以构造一个合适的 1 和0 组成的数和这个寄存器原来的值进行位与操作,就可以将特定位清零。
(3)举例:假设原来 32 位寄存器中的值为: 0xAAAAAAAA,我们希望将 bit8~ bit15 清零而其他位不变,可以将这个数与 0xFFFF00FF 进行位与即可。
特定位置 1 用|
(1)回顾上节讲的位或操作的特点:任何数,其实就是 1 或者 0)与 1 位或变成 1,与 0 位
或无变化
(2)操作手法和刚才讲的位与是类似的。我们要构造这样一个数:要置 1 的特定位为 1,其
他位为 0,然后将这个数与原来的数进行位或即可。
特定位取反用^
(1)回顾上节讲的位异或操作的特点:(任何数,其实就是 1 或者 0)与 1 位异或会取反,与 0 位异或无变化
(2)操作手法和刚才讲的位与是类似的。我们要构造这样一个数:要取反的特定位为 1,其他位为 0,然后将这个数与原来的数进行位异或即可。
如何用位运算构建特定二进制数 (1少的用)
对寄存器特定位进行置 1 或者清 0 或者取反,关键性的难点在于要事先构建一个特别的数,这个数和原来的值进行位与、位或、位异或等操作,即可达到我们对寄存器操作的要求 。
使用移位获取特定位为 1 的二进制数
一个 1 0x1 两个 1 0x3 三个1 0x7 四个1 0xf 五个1 0x1f 六个1 0x3f 七个1 0x7f 八个1 0xff
(1)最简单的就是用移位来获取一个特定位为 1 的二进制数。譬如我们需要一个 bit3~ bit7为 1(隐含意思就是其他位全部为 0)的二进制数,可以这样: (0x1f<<3)
(2)更难一点的要求:获取 bit3~ bit7 为 1,同时 bit23~ bit25 为 1,其余位为 0 的数:((0x1f<<3) | (0x7<<23))
再结合位取反获取特定位为 0 的二进制数(1多的用)
(1)这次我们要获取 bit4~ bit10 为 0,其余位全部为 1 的数。怎么做?
(2)利用上面讲的方法就可以: (0xf<<0)|(0x1fffff<<11)
但是问题是:连续为 1 的位数太多了,这个数字本身就很难构造,所以这种方法的优势损失掉了。
(3)这种特定位(比较少)为 0 而其余位(大部分)为 1 的数,不适合用很多个连续 1 左移的方式来构造,适合左移加位取反的方式来构造。
(2)思路是:先试图构造出这个数的位相反数,再取反得到这个数。(譬如本例中要构造的数 bit4~ bit10 为 0 其余位为 1,那我们就先构造一个 bit4~ bit10 为 1,其余位为 0 的数,然后对这个数按位取反即可) ~(0x7f<<4)
位操作实战
(1)给定一个整型数 a,设置 a 的 bit3,保证其他位不变。
a |= (1<<3)
(2)给定一个整形数 a,设置 a 的 bit3bit7,保持其他位不变bit23,保持其他位不变。
a |= (0x1f<<3)
(3)给定一个整型数 a,清除 a 的 bit15,保证其他位不变。
a &= ~(1<<15)
(4)给定一个整形数 a,清除 a 的 bit15
a &= (0x1ff<<15)bit8。
(5)给定一个整形数 a,取出 a 的 bit3
a &= (0x3f<<3)
a >>= 3
(6)给一个寄存器的 bit7~ bit17 赋值 937
a &= ~(0x7ff<<7)
a |= (937<<7)
(7)给一个寄存器的 bit7~ bit17 中的值加 17
b = ((a & (0x7ff<<7))>>7 + 17)<<7
a &= ~(0x7ff<<7)
a |= b
(8)给一个寄存器的 bit7~ bit17 赋值 937,同时给 bit21~ bit25 赋值 17.
a &= ~((0x7ff<<7) | (0x1f<<21))
a |= ((937<<7) | (17<<21))
C语言指针
指针变量和普通变量的区别
首先必须非常明确:指针的实质就是个变量,它跟普通变量没有任何本质区别。指针完整的名字叫指针变量,简称指针。
指针的出现是为了实现间接访问,间接访问(CPU 的间接寻址)是 CPU 设计时决定的 .
指针使用三部曲:定义指针变量、关联指针变量、解引用
(1)当我们 int *p 定义一个指针变量 p 时,因为 p 是局部变量,所以也遵循 C 语言局部变量的一般规律(定义局部变量并且未初始化,则值是随机的),所以此时 p 变量中存储的是一个随机的数字。
(2)此时如果我们解引用 p,则相当于我们访问了这个随机数字为地址的内存空间。那这个空间到底能不能访问不知道(也许可以也许不行),所以如果直接定义指针变量未绑定有效地址就去解引用几乎必死无疑。
(3)定义一个指针变量,不经绑定有效地址就去解引用,就好象拿一个上了镗的枪在四面八方中随意开了一枪。
(4)指针绑定的意义就在于:让指针指向一个可以访问、应该访问的地方,指针的解引用是为了间接访问目标变量.
1 |
|
星号*
(1)C 语言中*****可以表示乘号,也可以表示指针符号。这两个用法是毫无关联的,只是恰好用了同一个符号而已。
(2)****在用于指针相关功能的是后续有 2 种用法:第一种是指针定义时, ***** 结合前面的类型用于表明要定义的指针的类型;第二种功能是指针解引用,解引用时p 表示 p 指向的变量本身。
3.2.2.取地址符&
取地址符使用时直接加在一个变量的前面,然后取地址符和变量加起来构成一个新的符号,这个符号表示这个变量的地址。
3.2.3.指针定义并未初始化、与指针定义然后赋值的区别
(1)指针定义时可以初始化,指针的初始化其实就是给指针变量初值(跟普通变量的初始化没有任何本质区别)。
(2)指针变量定义同时初始化的格式是: int a = 32; int *p = &a;
(3)不初始化时指针变量先定义再赋值: int a = 32; int *p; p = &a;
左值与右值
(1)放在赋值运算符左边的就叫左值,右边的就叫右值。所以赋值操作其实就是:左值 = 右
值;
(2)当一个变量做左值时,编译器认为这个变量符号的真实含义是这个变量所对应的那个内
存空间;当一个变量做右值时,编译器认为这个变量符号的真实含义是这个变量的值,也
就是这个变量所对应的内存空间中存储的那个数。
野指针问题
(1)野指针,就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
(2)野指针很可能触发运行时段错误(Sgmentation fault)
(3)因为指针变量在定义时如果未初始化,值也是随机的。指针变量的值其实就是别的变量(指针所指向的那个变量)的地址,所以意味着这个指针指向了一个地址是不确定的变量,这时候去解引用就是去访问这个地址不确定的变量,所以结果是不可知的。
(4)野指针因为指向地址是不可预知的。所以有 3 种情况:第一种是指向不可访问(操作系统不允许访问的敏感地址,譬如内核空间)的地址,结果是触发段错误,这种情况算好的了,因为编译器会报错;第二种是指向一个可用的、而且没有什么特别意义的空间(譬如我们曾经使用过但是已经不用的栈空间或堆空间),这时候程序运行不会出错,也不会对当前程序造成损害,这种情况下会掩盖你的程序错误,让你以为程序没问题,其实是有问题的;第三种情况就是指向了一个可用的空间,而且这个空间其实在程序中正在被使用(譬如说是程序的一个变量 x),那么野指针的解引用就会刚好修改这个变量 x 的值,导致这个变量莫名其妙的被改变,程序出现离奇的错误。一般最终都会导致程序崩溃,或者数据被损害。这种危害是最大的。
(5)指针变量如果是局部变量,则分配在栈上,本身遵从栈的规律(反复使用,使用完不擦出,所以是脏的,本次在栈上分配到的变量的默认值是上次这个栈空间被使用时余留下来的值),就决定了栈的使用多少会影响这个默认值。因此野指针的值是有一定规律不是完全随机的,但是这个值的规律对我们没意义。因为不管落在上面的野指针是 3 种情况的哪一种,都不是我们想看到的。
怎么避免野指针?
(1)野指针的错误来源就是指针定义了以后没有初始化,也没有赋值(总之就是指针没有明
确的指向一个可用的内存空间),然后去解引用。
(2)知道了野指针产生的原因,避免方法就出来了:在指针的解引用之前,一定确保指针指
向一个绝对可用的空间。
(3)常规的做法是:
第一点:定义指针时,同时初始化为 NULL
第二点:在指针使用之前,将其赋值绑定给一个可用地址空间
第三点:在指针解引用之前, 先去判断这个指针是不是 NULL
第四点:指针使用完之后,将其赋值为 NULL
1 |
|
NULL 到底是什么?
(1)NULL 在 C/C++中定义为:
#ifdefine _cplusplus //定义这个符号就表示当前是 C++环境
#define NULL 0 //在 C++中 NULL 就是 0
#else
#define NULL (void *)0 //在 C 中 NULL 是强制类型转换为 void *的 0
#endif
(2)在 C 语言中, int *p,你可以 p = (int *)0,但是不可以 p = 0,因为类型不同
(3)所以 NULL 的实质其实就是地址 0,然后我们给指针赋初值为 NULL,其实就是让指针指向 0 地址处。为什么指向 0 地址处?有 2 点原因:第一层原因是 0 地址处作为一个特殊地址(我们认为指针指向这里就表示指针没有被初始化,就表示野指针);第二层原因是这个 0 地址在一般的操作系统中都是不可被访问的,如果不按规矩(不检查是否等于 NULL就去解引用)写代码直接去解引用就会触发段错误,编译器会报错嘀。 (指针指向这个敏感地址没有问题,只要不解引用就 ok)
const 修饰指针的 4 种形式
(1)const 关键字,在 C 语言中用来修饰变量,表示这个变量是常量。
(2)const 修饰指针有 4 种形式,区分清楚这 4 种即可全部理解 const 和指针。
第一种: const int *p; //const 修饰 p 指向的变量20
第二种: int const *p; //const 修饰 p 指向的变量
第三种: int * const p; //const 修饰指针变量 p
第四种: const int * const p; //const 即修饰 p 指向的变量也修饰指针变量 p
(3)关于指针变量的理解,主要涉及到 2 个变量:第一个是指针变量 p 本身,第二个是 p 指向的那个变量(*p)。一个 const 关键字只能修饰一个变量,所以弄清楚这 4 个表达式的关键就是搞清楚 const 放在某个位置是修饰谁。
深入学习数组
从内存角度来理解数组
(1)从内存角度讲,数组变量就是一次分配多个变量,而且这多个变量在内存中的存储单元是依次相连接的。
(2)我们分开定义多个变量(譬如 int a, b, c, d)和和一次定义一个数组(int a[4])这两种定义方法相同点是都定义了 4 个 int 型变量,而且这 4 个变量都是独立的、单个使用的;不同点是单独定义时 a、 b、 c、 d 在内存中的地址不一定相连,但是定义成数组后,数组中的 4 个元素地址肯定是依次相连的。
(3)数组中多个变量虽然必须单独访问,但是因为他们的地址彼此相连,因此很适合用指针来操,因此数组和指针天生就有一种羁绊。
(1)这 4 个符号搞清楚了,数组相关的很多问题就都有答案了。理解这些符号的时候要和左值右值结合起来,也就是搞清楚每个符号分别做左值和右值时的不同含义。
(2)a 就是数组名。 a 做左值表示整个数组所有空间(10*4=40 字节), 但因为 C 语言规定数组操作时要独立单个操作,不能整体操作数组,所以 a 不能做左值; a 做右值表示数组首元素(数组的第 1 个元素,也就是 a[0])的首地址(首地址就是起始地址,就是 4 个字
节中最开始第一个字节的地址)。 a 做右值等于&a[0]。
(3)a[0]表示数组的首元素,也就是数组的第 1 个元素。 a[0]做左值时表示首元素对应的内存空间(连续 4 个字节); a[0]做右值时表示数组第 1 个元素的值(也就是数组第 1 个元素对应的内存空间中存储的那个数)
(4)&a 就是数组名 a 取地址,字面意思来看就应该是数组的地址。 &a 不能做左值(&a 实质是一个常量,不是变量,因此不能赋值,所以自然不能做左值。); &a 做右值时表示整个数组的首地址。
(5)&a[0]字面自已就是数组第 1 个元素的首地址(搞清楚[]和&的优先级, []的优先级要高于&,所以 a 先和[]结合再取地址)。 &a[0]不能做左值(&a[0]实质是一个常量,不是变量,因此不能赋值,所以自然不能做左值。); 做右值时表示数组首元素地址。 &a[0]做右值等于 a。
指针和数组的天生羁绊
以指针方式来访问数组元素
(1)数组元素使用时不能整体访问,只能单个访问。访问方式有 2 种:数组形式和指针形式。
(2)数组格式访问数组元素是:数组名[下标];
(3)指针方式访问数组元素是: *(指针+偏移量); *(数组名+偏移量);如果指针是数组首元素地址(a 或者&a[0]),那么偏移量就是下标;指针也可以不是首元素地址而是其他哪个元素的地址,这时候偏移量就要考虑叠加了。
(4)数组下标方式和指针方式均可以访问数组元素,两者的实质其实是一样的。在编译器内部都是用指针方式来访问数组元素的,数组下标方式只是编译器提供给编程者一种壳(语法糖)而已。所以用指针方式来访问数组才是本质的方法。
指针和数组类型的匹配问题
(1)int *p; int a[5]; p = a; //类型匹配, a 相当于&a[0]
(2)int *p; int a[5]; p = &a; //类型不匹配p 是 int *,是 int 指针类型,而&a 是整个数组的指针,也就是数组指针类型,所以不匹配
(3)&a 和 a、 &a[0]从数值来看是相等的,但是意义来看就不同了。从意义来看, a 和&a[0]是数组首元素地址,而&a 是整个数组的首地址;从类型来看, a 和&a[0]是元素的指针,也就是 int 类型的;而&a 是数组指针,是 int ()[5]类型。
1 |
|
指针与强制类型转换
变量的数据类型的含义
(1)所有的类型的数据存储在内存中,都是按照二进制格式存储的。所以内存中只知道 1 和0,不知道 int 的还是 float 的还是其他类型。
(2)int、 short、 char 等属于整型,他们的存储方式(数转换成二进制往内存中存放的方式)是相同的,只是内存格子大小不同(所以这几种整型就彼此叫二进制兼容格式);而 float、double 的存储方式彼此不同,和整型更不同。
(3)int a = 5; 时,编译器给 a 分配 4 字节空间,并且将 5 按照 int 类型的存储方式转换成二进制存到 a 所对应的内存空间中去(a 做左值的);我们 printf 去打印 a 的时候(a 此时做右值), printf 内部的 vsprintf 函数会按照格式化字符串(就是 printf 传参的第一个字
符串参数中的%d 之类的东西)所代表的类型去解析 a 所对应的内存空间,解析出的值用来输出。也就是说,存进去时是按照这个变量本身的数据类型来存储的(譬如本例中 a 为int 所以按照 int 格式来存储);但是取出来时是按照 printf 中%d 之类的格式化字符串的格
式来提取的。此时虽然 a 所代表的内存空间中的 1010 序列并没有变(内存是没被修改的)但是怎么理解(怎么把这些 1010 转成数字)就不一定了。譬如我们用%d 来解析,那么还是按照 int 格式解析则值自然还是 5;但是如果用%f 来解析,则 printf 就以为 a 对应的内存空间存储的是一个 float 类型的数,会按照 float 类型来解析,值自然是很奇怪的一个数字了。
分析几个题目:
- 按照 int 类型存却按照 float 类型取 一定会出错
- 按照 int 类型存却按照 char 类型取 有可能出错也有可能不出错
- 按照 short 类型存却按照 int 类型取 有可能出错也有可能不出错
- 按照 float 类型存却按照 double 取 一定会出错
指针数据类型转换实例分析 1(int * -> char *)
(1)int 和 char 类型都是整型的,类型兼容的。所以互转的时候有可能出错有可能对。
(2)int 和 char 的不同在于 char 只有 1 个字节而 int 有 4 个字节,所以 int 的范围比 char大。在 char 所表示的范围之内 int 和 char 是可以互转的不会出错;但是超过了 char 的范围后, char 转成 int 不会错。
指针、数组与 sizeof 运算符
sizeof 运算符
(1)sizeof 是 C 语言中的一个运算符(sizeof 不是函数,虽然用法很像函数), sizeof 的作用是用来返回()里边的变量或者数据类型占用的内存字节数。
(2)sizeof 存在的价值?主要是因为在不同平台下各种数据类型所占的字节数不尽相同(譬如 int 在 32 位系统中为 4 字节,在 16 位系统中为 2 字节…)。所以程序中需要使用 sizeof来判断当前变量/数据类型在当前环境下占几个字节。
1 | char str[] = "hello"; |
1 | char str[] = "hello"; |
(1)32 位系统中所有指针的长度都是 4,不管是什么类型的指针。
(2)strlen 是一个 C 库函数,用来返回一个字符串的长度(注意, 字符串的长度是不计算字符串末尾的’\0’的)。一定要注意 strlen 接收的参数必须是一个字符串(字符串的特征是以’\0’结尾)。
void fun(int b[100])
{
sizeof(b) // 4
}
(1)函数传参,形参可以用数组的
(2)函数形参是数组时,实际传递的不是整个数组,而是数组的首元素地址。也就是说函数
传参用数组来传,实际相当于传递的是指针(指针指向数组的首元素地址)。
通过 sizeof 获取数组元素个数的技巧
int a[56];
int b = sizeof(a) / sizeof(a[0]); // 整个数组字节数/数组中一个元素的字节数
printf(“b = %d.\n”, b); // 结果应该是数组的元素个数
3.8.8. #define 和 typedef 的区别
#define dpChar char *
typedef char *tpChar;
dpChar p1, p2; sizeof(p1) sizeof(p2)
tpChar p3, p4; sizeof(p3) sizeof(p4)
dpChar p1, p2; //展开: char *p1, p2; 相当于 char *p1, char p2;
tpChar p3, p4; // 等价于: char *p3, char *p4;
指针与函数传参
普通变量作为函数形参30
(1)函数传参时,普通变量作为参数时,形参和实参名字可以相同也可以不同,实际上都是
用实参来替代相对应的形参的。
(2)在子函数内部,形参的值等于实参。原因是函数调用时把实参的值赋值给了形参。
(3)这就是很多书上写的“传值调用”(相当于实参做右值,形参做左值)
1 | int a = 4; |
数组作为函数形参
(1)数组作为形参传参时,实际传递的不是整个数组,而是数组的首元素地址(也就是整个
数组的首地址。因为传参时是传值,所以首元素地址和数组的首地址这两个没区别)。所
以在子函数内部,传进来的数组名就等于是一个指向数组首元素的指针。所以 sizeof 得到
的是 4。
(2)在子函数内传参得到的数组首元素首地址,和外面得到的首元素首地址是相同的。很多
人把这种特性叫做“传址调用”(所谓的传址调用就是调用子函数时传了地址(也就是指
针),此时可以通过传进去的地址来访问实参。)
(3)数组作为函数参数时, []里的数组是可有可无的。为什么?因为数组名做形参传递的实
际只是个指针,根本没有数组长度这个信息。
1 | int a[5]; |
指针作为函数形参
和数组作为函数形参是一样的,这就好像指针方式访问数组元素和数组方式访问数组元素
的结果一样是一样的。
1 | int a[5]; |
结构体变量和结构体变量指针作为函数形参
(1)结构体变量作为函数形参的时候,实际上和普通变量(类似于 int 之类的)传参时的表现是一模一样的。所以说结构体变量其实也是普通变量而已。
(2)因为结构体一般都很大,所以如果直接用结构体变量进行传参,那么函数调用效率就会很低。(因为在函数传参时需要将实参赋值给形参,所以当传参的变量越大调用效率就会越低。)怎么解决?思路只有一个那就是不要传变量了,改传变量的指针(地址)进去。
(3)结构体因为自身太大,所以传参应该用结构体指针来传 .
传值调用与传址调用
(1)传值调用描述的是这样一种现象: x 和 y 作为实参,自己并没有真身进入 swap1 函数内部,而只是拷贝了一份自己的副本(副本具有和自己一样的值,但是是不同的变量)进子函数 swap1,然后我们在子函数 swap1 中交换的实际是副本而不是 x、 y 真身。所以在
swap1 内部确实是交换了,但是到外部的 x 和 y 根本没有受影响。
1 | int x = 3, y = 5; |
(2)在 swap2 中 x 和 y 真的被改变了(但是 x 和 y 真身还是没有进入 swap2 函数内,而是swap2 函数内部跑出来把外面的 x 和 y 真身给改了)。实际上实参 x 和 y 永远无法真身进入子函数内部(进去的只能是一份拷贝),但是在 swap2 我们把 x 和 y 的地址传进去给子
函数,于是在子函数内可以通过指针解引用方式从函数内部访问到外部的 x 和 y 真身,从而改变 x 和 y。
1 | int x = 3, y = 5; |
函数传参中使用 const 指针
(1)const 一般用在函数参数列表中,用法是 const int *p;(意义是指针变量 p 本身是可变的,而 p 所指向的变量是不可变的)
(2)const 用来修饰指针做函数传参,作用就在于声明在函数内部不会改变这个指针指向的内容,所以给该函数传一个不可改变的指针(char *p = “linux”;这种)不会触发错误,而一个未声明为 const 的指针的函数,你给他传一个不可更改的指针的时候就要小心了。
函数需要向外部返回多个值怎么办?
(1)一般来说,函数的输入部分就是函数参数,输出部分就是返回值。问题是函数的参数可以有很多个,而返回值只能有 1 个。这就造成我们无法一个函数返回多个值。
(2)实际编程中,一个函数需要返回多个值是非常普遍的,因此完全依赖于返回值是不靠谱的,通常的做法是用参数来做返回。(在典型 linux 风格函数中,返回值是不用来返回结果的,而是用来返回 0 或者负数来表示查询执行结果是对还是错,是成功还是失败,它是返回给调用它的进程的)。
(3)普遍做法,编程中函数的输入和输出都是靠函数参数的,返回值只是用来表示函数执行的结果是对还是错。如果这个参数是用来做输入的,就叫输入型参数;如果这个参数的目的是用来做输出的,就叫输出型参数。
(4)输出型参数就是用来让函数内部把数据输出到函数外部的
函数指针与 typedef
函数指针的实质(还是指针变量)
(1)函数指针的实质还是指针,还是指针变量。本身占 4 字节(在 32 位系统中,所有的指针都是 4 字节)
(2)函数指针、结构体指针、数组指针、普通指针之间并没有本质区别,区别在于指针指向的东西是个什么玩意儿。
(3)函数的实质是一段代码,这一段代码在内存中是连续分布的(一个函数的大括号括起来的所有语句将来编译出来生成的可执行程序是连续的),所以对于函数来说很关键的就是函数中的第一句代码的地址,这个地址就是所谓的函数地址,在 C 语言中用函数名这个符号来表示。
(4)结合函数的实质,函数指针其实就是一个普通变量,这个普通变量的类型是函数指针变量类型,它的值就是某个函数的地址(也就是它的函数名这个符号在编译器中对应的值)。
typedef 关键字的用法
(1)typedef 是 C 语言中的一个关键字,作用是用来定义(或者叫重命名类型)
(2)C 语言中的类型一共有 2 种:一种是编译器定义的原生类型(基础数据类型,如 int、double 之类的);第二种是用户自定义类型,不是语言自带的,是程序员自己定义的(譬如数组类型、结构体类型、函数类型……)。
(3)数组指针、指针数组、函数指针等都属于用户自定义类型。
(4)有时候自定义类型太长了,用起来不方便,所以用 typedef 给它重命名一个短点的名字。
(5)注意: typedef 是给类型重命名,也就是说 typedef 加工出来的都是类型,而不是变量。