C语言中级内容总结

01

位操作C语言位操作详解(全网最全)_小熊coder的博客-CSDN博客_c语言位操作

总结就是:跟0与都变0;跟1与保持不变;跟0或保持不变;跟1或都变1;跟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或者清0或者取反,关键性的难点在于要事先构建一个特别的数,这个数和原来的值进行位与、位或、位异或等操作,即可达到我们对寄存器操作的要求。

使用移位获取特定位为1的二进制数
(1)最简单的就是用移位来获取一个特定位为1的二进制数。譬如我们需要一个bit3~bit7为1(隐含意思就是其他位全部为0)的二进制数,可以这样:(0x1f<<3)
(2)更难一点的要求:获取bit3~bit7为1,同时bit23~bit25为1,其余位为0的数:((0x1f<<3) | (7<<23))

再结合位取反获取特定位为0的二进制数
这次我们要获取bit4~bit10为0,其余位全部为1的数。怎么做?
利用上面讲的方法就可以:(0xf<<0)|(0x1fffff<<11)
但是问题是:连续为1的位数太多了,这个数字本身就很难构造,所以这种方法的优势损失掉了。
这种特定位(比较少)为0而其余位(大部分)为1的数,不适合用很多个连续1左移的方式来构造,适合左移加位取反的方式来构造。
思路是:先试图构造出这个数的位相反数,再取反得到这个数。(譬如本例中要构造的数bit4~bit10为0其余位为1,那我们就先构造一个bit4~bit10为1,其余位为0的数,然后对这个数按位取反即可)

总结:位与、位或结合特定二进制数即可完成寄存器位操作需求
(1)如果你要的这个数比较少位为1,大部分位为0,则可以通过连续很多个1左移n位得到。
(2)如果你想要的数是比较少位为0,大部分位为1,则可以通过先构建其位反数,然后再位取反来得到。
(3)如果你想要的数中连续1(连续0)的部分不止1个,那么可以通过多段分别构造,然后再彼此位与即可。这时候因为参与位或运算的各个数为1的位是不重复的,所以这时候的位或其实相当于几个数的叠加。

可以用宏定义来完成位运算,即事先设定好宏函数,来实现某个功能。

02

内存问题总结C语言的内存问题总结_路溪非溪的博客-CSDN博客

03

指针问题总结C语言的指针问题总结_路溪非溪的博客-CSDN博客

04

字符串

C语言没有原生字符串类型
(1)很多高级语言像java、C#等就有字符串类型,有个String来表示字符串,用法和int这些很像,可以String s1 = "linux";来定义字符串类型的变量。
(2)C语言没有String类型,C语言中的字符串是通过字符指针来间接实现的。

C语言使用指针来管理字符串
C语言中定义字符串方法:char *p = "linux";此时p就叫做字符串,但是实际上p只是一个字符指针(本质上就是一个指针变量,只是p指向了一个字符串的起始地址而已)。

C语言中字符串的本质:指针指向头、固定尾部的地址相连的一段内存
(1)字符串就是一串字符。字符反映在现实中就是文字、符号、数字等人用来表达的字符,反映在编程中字符就是字符类型的变量。C语言中使用ASCII编码对字符进行编程,编码后可以用char型变量来表示一个字符。字符串就是多个字符打包在一起共同组成的。
(2)字符串在内存中其实就是多个字节连续分布构成的(类似于数组,字符串和字符数组非常像)
(3)C语言中字符串有3个核心要点:第一是用一个指针指向字符串头;第二是固定尾部(字符串总是以'\0'来结尾);第三是组成字符串的各字符彼此地址相连。
(4)'\0'是一个ASCII字符,其实就是编码为0的那个字符(真正的0,和数字0是不同的,数字0有它自己的ASCII编码)。要注意区分'\0'和'0'和0.(0等于'\0','0'等于48)
(5)'\0'作为一个特殊的数字被字符串定义为(幸运的选为)结尾标志。产生的副作用就是:字符串中无法包含'\0'这个字符。(C语言中不可能存在一个包含'\0'字符的字符串),这种思路就叫“魔数”(魔数就是选出来的一个特殊的数字,这个数字表示一个特殊的含义,你的正式内容中不能包含这个魔数作为内容)。

注意:指向字符串的指针和字符串本身是分开的两个东西
char *p = "linux";在这段代码中,p本质上是一个字符指针,占4字节;"linux"分配在代码段,占6个字节;实际上总共耗费了10个字节,这10个字节中:4字节的指针p叫做字符串指针(用来指向字符串的,理解为字符串的引子,但是它本身不是字符串),5字节的用来存linux这5个字符的内存才是真正的字符串,最后一个用来存'\0'的内存是字符串结尾标志(本质上也不属于字符串)。

存储多个字符的2种方式:字符串和字符数组
我们有多个连续字符(典型就是linux这个字符串)需要存储,实际上有两种方式:第一种就是字符串;第二种是字符数组。

这两种方式有何区别?
字符数组初始化与sizeof、strlen
(1)sizeof是C语言的一个关键字,也是C语言的一个运算符(sizeof使用时是sizeof(类型或变量名),所以很多人误以为sizeof是函数,其实不是),sizeof运算符用来返回一个类型或者是变量所占用的内存字节数。为什么需要sizeof?主要原因一是int、double等原生类型占几个字节和平台有关;二是C语言中除了ADT之外还有UDT,这些用户自定义类型占几个字节无法一眼看出,所以用sizeof运算符来让编译器帮忙计算。
(2)strlen是一个C语言库函数,这个库函数的原型是:size_t strlen(const char *s);这个函数接收一个字符串的指针,返回这个字符串的长度(以字节为单位)。注意一点是:strlen返回的字符串长度是不包含字符串结尾的'\0'的。我们为什么需要strlen库函数?因为从字符串的定义(指针指向头、固定结尾、中间依次相连)可以看出无法直接得到字符串的长度,需要用strlen函数来计算得到字符串的长度。
(3)sizeof(数组名)得到的永远是数组的元素个数(也就是数组的大小),和数组中有无初始化,初始化多、少等是没有关系的;strlen是用来计算字符串的长度的,只能传递合法的字符串进去才有意义,如果随便传递一个字符指针,但是这个字符指针并不是字符串是没有意义的。
(4)当我们定义数组时如果没有明确给出数组大小,则必须同时给出初始化式,编译器会根据初始化式去自动计算数组的大小(数组定义时必须给出大小,要么直接给,要么给初始化式)

字符串初始化与sizeof、strlen
(1)char *p = "linux"; sizeof(p)得到的永远是4,因为这时候sizeof测的是字符指针p本身的长度,和字符串的长度是无关的。
(2)strlen刚好用来计算字符串的长度。

字符数组与字符串的本质差异(内存分配角度)
(1)字符数组char a[] = "linux";来说,定义了一个数组a,数组a占6字节,右值"linux"本身只存在于编译器中,编译器将它用来初始化字符数组a后丢弃掉(也就是说内存中是没有"linux"这个字符串的);这句就相当于是:char a[] = {'l', 'i', 'n', 'u', 'x', '\0'};
(2)字符串char *p = "linux";定义了一个字符指针p,p占4字节,分配在栈上;同时还定义了一个字符串"linux",分配在代码段;然后把代码段中的字符串(一共占6字节)的首地址(也就是'l'的地址)赋值给p。


总结对比:字符数组和字符串有本质差别。字符数组本身是数组,数组自身自带内存空间,可以用来存东西(所以数组类似于容器);而字符串本身是指针,本身永远只占4字节,而且这4个字节还不能用来存有效数据,所以只能把有效数据存到别的地方,然后把地址存在p中。
也就是说字符数组自己存那些字符;字符串一定需要额外的内存来存那些字符,字符串本身只存真正的那些字符所在的内存空间的首地址。

05

结构体

结构体定义时需要先定义结构体类型,然后再用类型来定义变量。

也可以在定义结构体类型的同时定义结构体变量。

从数组到结构体的进步之处
(1)结构体可以认为是从数组发展而来的。其实数组和结构体都算是数据结构的范畴了,数组就是最简单的数据结构、结构体比数组更复杂一些,链表、哈希表之类的比结构体又复杂一些;二叉树、图等又更复杂一些。
(2)数组有2个明显的缺陷:第一个是定义时必须明确给出大小,且这个大小在以后不能再更改;第二个是数组要求所有的元素的类型必须一致。更复杂的数据结构中就致力于解决数组的这两个缺陷。
(3)结构体是用来解决数组的第二个缺陷的,可以将结构体理解为一个其中元素类型可以不相同的数组。结构体完全可以取代数组,只是在数组可用的范围内数组比结构体更简单。

数组中元素的访问方式:表面上有2种方式(数组下标方式和指针方式);实质上都是指针方式访问。
结构体变量中的元素访问方式:只有一种,用.或者->的方式来访问。(.和->访问结构体元素其实质是一样的,只是C语言规定用结构体变量来访问元素用. 用结构体变量的指针来访问元素用->。实际上在高级语言中已经不区分了,都用.)
结构体的访问方式有点类似于数组下标的方式
结构体变量的点号或者->访问元素的实质是什么?其实本质上还是用指针来访问的。

结构体的对齐访问
举例说明什么是结构体对齐访问
(1)上节讲过结构体中元素的访问其实本质上还是用指针方式,结合这个元素在整个结构体中的偏移量和这个元素的类型来进行访问的。
(2)但是实际上结构体的元素的偏移量比我们上节讲的还要复杂,因为结构体要考虑元素的对齐访问,所以每个元素时间占的字节数和自己本身的类型所占的字节数不一定完全一样。(譬如char c实际占字节数可能是1,也可以是2,也可能是3,也可以能4····)
(3)一般来说,我们用.的方式来访问结构体元素时,我们是不用考虑结构体的元素对齐的。因为编译器会帮我们处理这个细节。但是因为C语言本身是很底层的语言,而且做嵌入式开发经常需要从内存角度,以指针方式来处理结构体及其中的元素,因此还是需要掌握结构体对齐规则。

结构体为何要对齐访问
(1)结构体中元素对齐访问主要原因是为了配合硬件,也就是说硬件本身有物理上的限制,如果对齐排布和访问会提高效率,否则会大大降低效率。
(2)内存本身是一个物理器件(DDR内存芯片,SoC上的DDR控制器),本身有一定的局限性:如果内存每次访问时按照4字节对齐访问,那么效率是最高的;如果你不对齐访问效率要低很多。
(3)还有很多别的因素和原因,导致我们需要对齐访问。譬如Cache的一些缓存特性,还有其他硬件(譬如MMU、LCD显示器)的一些内存依赖特性,所以会要求内存对齐访问。
(4)对比对齐访问和不对齐访问:对齐访问牺牲了内存空间,换取了速度性能;而非对齐访问牺牲了访问速度性能,换取了内存空间的完全利用。

结构体对齐的规则和运算
(1)编译器本身可以设置内存对齐的规则,有以下的规则需要记住:
第一个:32位编译器,一般编译器默认对齐方式是4字节对齐。

总结下:结构体对齐的分析要点和关键:
1、结构体对齐要考虑:结构体整体本身必须安置在4字节对齐处,结构体对齐后的大小必须4的倍数(编译器设置为4字节对齐时,如果编译器设置为8字节对齐,则这里的4是8)
2、结构体中每个元素本身都必须对齐存放,而每个元素本身都有自己的对齐规则。
3、编译器考虑结构体存放时,以满足以上2点要求的最少内存需要的排布来算。

gcc支持但不推荐的对齐指令:#pragma pack()   #pragma pack(n) (n=1/2/4/8)
(1)#pragma是用来指挥编译器,或者说设置编译器的对齐方式的。编译器的默认对齐方式是4,但是有时候我不希望对齐方式是4,而希望是别的(譬如希望1字节对齐,也可能希望是8,甚至可能希望128字节对齐)。
(2)常用的设置编译器编译器对齐命令有2种:第一种是#pragma pack(),这种就是设置编译器1字节对齐(有些人喜欢讲:设置编译器不对齐访问,还有些讲:取消编译器对齐访问);第二种是#pragma pack(4),这个括号中的数字就表示我们希望多少字节对齐。
(3)我们需要#prgama pack(n)开头,以#pragma pack()结尾,定义一个区间,这个区间内的对齐参数就是n。
(4)#prgma pack的方式在很多C环境下都是支持的,但是gcc虽然也可以不过不建议使用。

gcc推荐的对齐指令__attribute__((packed))     __attribute__((aligned(n)))

注意,是两个下划线,前后各有两个下划线。
(1)__attribute__((packed))使用时直接放在要进行内存对齐的类型定义的后面,然后它起作用的范围只有加了这个东西的这一个类型。packed的作用就是取消对齐访问。
(2)__attribute__((aligned(n)))使用时直接放在要进行内存对齐的类型定义的后面,然后它起作用的范围只有加了这个东西的这一个类型。它的作用是让整个结构体变量整体进行n字节对齐(注意是结构体变量整体n字节对齐,而不是结构体内各元素也要n字节对齐)

参考阅读blog:

http://www.cnblogs.com/dolphin0520/archive/2011/09/17/2179466.html
http://blog.csdn.net/sno_guo/article/details/8042332

offsetof和container_of

C语言:offsetof()的用法_普朗克三号粉丝的博客-CSDN博客_offsetof

container of()函数简介_叨陪鲤的博客-CSDN博客_container_of函数

offsetof宏与container_of宏
由结构体指针进而访问各元素的原理
通过结构体整体变量来访问其中各个元素,本质上是通过指针方式来访问的,形式上是通过.的方式来访问的(这时候其实是编译器帮我们自动计算了偏移量)。

offsetof宏:
(1)offsetof宏的作用是:用宏来计算结构体中某个元素和结构体首地址的偏移量(其实质是通过编译器来帮我们计算)。
(2)offsetof宏的原理:我们虚拟一个type类型结构体变量,然后用type.member的方式来访问那个member元素,继而得到member相对于整个变量首地址的偏移量。
(3)学习思路:第一步先学会用offsetof宏,第二步再去理解这个宏的实现原理。
(TYPE *)0        这是一个强制类型转换,把0地址强制类型转换成一个指针,这个指针指向一个TYPE类型的结构体变量。    (实际上这个结构体变量可能不存在,但是只要我不去解引用这个指针就不会出错)。
((TYPE *)0)->MEMBER    (TYPE *)0是一个TYPE类型结构体变量的指针,通过指针指针来访问这个结构体变量的member元素

&((TYPE *)0)->MEMBER  等效于&(((TYPE *)0)->MEMBER),意义就是得到member元素的地址。但是因为整个结构体变量的首地址是0。

container_of宏:
(1)作用:知道一个结构体中某个元素的指针,反推这个结构体变量的指针。有了container_of宏,我们可以从一个元素的指针得到整个结构体变量的指针,继而得到结构体中其他元素的指针。
(2)typeof关键字的作用是:typepef(a)时由变量a得到a的类型,typeof就是由变量名得到变量数据类型的。
(3)这个宏的工作原理:先用typeof得到member元素的类型定义成一个指针,然后用这个指针减去该元素相对于整个结构体变量的偏移量(偏移量用offsetof宏得到的),减去之后得到的就是整个结构体变量的首地址了,再把这个地址强制类型转换为type *即可。

学习指南和要求:
(1)最基本要求是:必须要会这两个宏的使用。就是说能知道这两个宏接收什么参数,返回什么值,会用这两个宏来写代码。看见代码中别人用这两个宏能理解什么意思。
(2)升级要求:能理解这两个宏的工作原理,能表述出来。(有些面试笔试题会这么要求)
(3)更高级要求:能自己写出这两个宏(不要着急,慢慢来)

06

共用体

(1)共用体union和结构体struct在类型定义、变量定义、使用方法上很相似。
(2)共用体和结构体的不同:结构体类似于一个包裹,结构体中的成员彼此是独立存在的,分布在内存的不同单元中,他们只是被打包成一个整体叫做结构体而已;共用体中的各个成员其实是一体的,彼此不独立,他们使用同一个内存单元。可以理解为:有时候是这个元素,有时候是那个元素。更准确的说法是同一个内存空间有多种解释方式。
(3)共用体union就是对同一块内存中存储的二进制的不同的理解方式。
(4)在有些书中把union翻译成联合(联合体),这个名字不好。现在翻译成共用体比较合适。
(5)union的sizeof测到的大小实际是union中各个元素里面占用内存最大的那个元素的大小。因为可以存的下这个就一定能够存的下其他的元素。
(6)union中的元素不存在内存对齐的问题,因为union中实际只有1个内存空间,都是从同一个地址开始的(开始地址就是整个union占有的内存空间的首地址),所以不涉及内存对齐。

共用体和结构体的相同和不同
(1)相同点就是操作语法几乎相同。
(2)不同点是本质上的不同。struct是多个独立元素(内存空间)打包在一起;union是一个元素(内存空间)的多种不同解析方式。

共用体的主要用途
(1)共用体就用在那种对同一个内存单元进行多种不同规则解析的这种情况下。
(2)C语言中其实是可以没有共用体的,用指针和强制类型转换可以替代共用体完成同样的功能,但是共用体的方式更简单、更便捷、更好理解。

大小端什么是大小端?如何确定大小端?_wwwlyj123321的博客-CSDN博客_大小端

什么是大小端模式
(1)大端模式(big endian)和小端模式(little endian)。最早是小说中出现的词,和计算机本来没关系的。
(2)后来计算机通信发展起来后,遇到一个问题就是:在串口等串行通信中,一次只能发送1个字节。这时候我要发送一个int类型的数就遇到一个问题。int类型有4个字节,我是按照:byte0 byte1 byte2 byte3这样的顺序发送,还是按照byte3 byte2 byte1 byte0这样的顺序发送。规则就是发送方和接收方必须按照同样的字节顺序来通信,否则就会出现错误。这就叫通信系统中的大小端模式。这是大小端这个词和计算机挂钩的最早问题。
(3)现在我们讲的这个大小端模式,更多是指计算机存储系统的大小端。在计算机内存/硬盘/Nnad中。因为存储系统是32位的,但是数据仍然是按照字节为单位的。于是乎一个32位的二进制在内存中存储时有2种分布方式:高字节对应高地址(小端模式)、高字节对应低地址(大端模式)
(4)大端模式和小端模式本身没有对错,没有优劣,理论上按照大端或小端都可以,但是要求必须存储时和读取时按照同样的大小端模式来进行,否则会出错。
(5)现实的情况就是:有些CPU公司用大端(譬如C51单片机);有些CPU用小端(譬如ARM)。(大部分是用小端模式,大端模式的不算多)。于是乎我们写代码时,当不知道当前环境是用大端模式还是小端模式时就需要用代码来检测当前系统的大小端。

用C语言写一个函数来测试当前机器的大小端模式。

用union来测试机器的大小端模式;

指针方式来测试机器的大小端。

通信系统中的大小端
(1)譬如要通过串口发送一个0x12345678给接收方,但是因为串口本身限制,只能以字节为单位来发送,所以需要发4次;接收方分4次接收,内容分别是:0x12、0x34、0x56、0x78.接收方接收到这4个字节之后需要去重组得到0x12345678(而不是得到0x78563412).
(2)所以在通信双方需要有一个默契,就是:先发/先接的是高位还是低位?这就是通信中的大小端问题。
(3)一般来说是:先发低字节叫小端;先发高字节就叫大端。(我不能确定)实际操作中,在通信协议里面会去定义大小端,明确告诉你先发的是低字节还是高字节。
(4)在通信协议中,大小端是非常重要的,大家使用别人定义的通信协议还是自己要去定义通信协议,一定都要注意标明通信协议中大小端的问题。

07

枚举

枚举是用来干嘛的?
(1)枚举在C语言中其实是一些符号常量集。直白点说:枚举定义了一些符号,这些符号的本质就是int类型的常量,每个符号和一个常量绑定。这个符号就表示一个自定义的一个识别码,编译器对枚举的认知就是符号常量所绑定的那个int类型的数字。
(2)枚举中的枚举值都是常量,怎么验证?
(3)枚举符号常量和其对应的常量数字相对来说,数字不重要,符号才重要。符号对应的数字只要彼此不相同即可,没有别的要求。所以一般情况下我们都不明确指定这个符号所对应的数字,而让编译器自动分配。(编译器自动分配的原则是:从0开始依次增加。如果用户自己定义了一个值,则从那个值开始往后依次增加)

C语言为何需要枚举
(1)C语言没有枚举是可以的。使用枚举其实就是对1、0这些数字进行符号化编码,这样的好处就是编程时可以不用看数字而直接看符号。符号的意义是显然的,一眼可以看出。而数字所代表的含义除非看文档或者注释。
(2)宏定义的目的和意义是:不用数字而用符号。从这里可以看出:宏定义和枚举有内在联系。宏定义和枚举经常用来解决类似的问题,他们俩基本相当可以互换,但是有一些细微差别。

宏定义和枚举的区别
(1)枚举是将多个有关联的符号封装在一个枚举中,而宏定义是完全散的。也就是说枚举其实是多选一。
(2)什么情况下用枚举?当我们要定义的常量是一个有限集合时(譬如一星期有7天,譬如一个月有31天,譬如一年有12个月····),最适合用枚举。(其实宏定义也行,但是枚举更好)
(3)不能用枚举的情况下(定义的常量符号之间无关联,或者无限的)用宏定义。
总结:宏定义先出现,用来解决符号常量的问题;后来人们发现有时候定义的符号常量彼此之间有关联(多选一的关系),用宏定义来做虽然可以但是不贴切,于是乎发明了枚举来解决这种情况。

08

预处理C语言的预处理详解_绘夜的博客-CSDN博客_c预处理

由源码到可执行程序的过程
(1)源码.c->(编译)->elf可执行程序
(2)源码.c->(编译)->目标文件.o->(链接)->elf可执行程序
(3)源码.c->(编译)->汇编文件.S->(汇编)->目标文件.o->(链接)->elf可执行程序
(4)源码.c->(预处理)->预处理过的.i源文件->(编译)->汇编文件.S->(汇编)->目标文件.o->(链接)->elf可执行程序

预处理用预处理器,编译用编译器,汇编用汇编器,链接用链接器,这几个工具再加上其他一些额外的会用到的可用工具,合起来叫编译工具链。gcc就是一个编译工具链。

预处理的意义
(1)编译器本身的主要目的是编译源代码,将C的源代码转化成.S的汇编代码。编译器聚焦核心功能后,就剥离出了一些非核心的功能到预处理器去了。
(2)预处理器帮编译器做一些编译前的杂事。

编程中常见的预处理
(1)#include(#include <>和#include ""的区别)
(2)注释
(3)#if  #elif  #endif  #ifdef
(4)宏定义

gcc中只预处理不编译的方法
(1)gcc编译时可以给一些参数来做一些设置,譬如gcc xx.c -o xx可以指定可执行程序的名称;譬如gcc xx.c -c -o xx.o可以指定只编译不连接,也可以生成.o的目标文件。
(2)gcc -E xx.c -o xx.i可以实现只预处理不编译。一般情况下没必要只预处理不编译,但有时候这种技巧可以用来帮助我们研究预处理过程,帮助debug程序。

总结:宏定义被预处理时的现象有:第一,宏定义语句本身不见了(可见编译器根本就不认识#define,编译器根本不知道还有个宏定义);第二,typedef重命名语言还在,说明它和宏定义是有本质区别的(说明typedef是由编译器来处理而不是预处理器处理的)

头文件包含
(1)#include <> 和 #include""的区别:<>专门用来包含系统提供的头文件(就是系统自带的,不是程序员自己写的),""用来包含自己写的头文件;更深层次来说:<>的话C语言编译器只会到系统指定目录(编译器中配置的或者操作系统配置的寻找目录,譬如在ubuntu中是/usr/include目录,编译器还允许用-I来附加指定其他的包含路径)去寻找这个头文件(隐含意思就是不会找当前目录下),如果找不到就会提示这个头文件不存在。
(2)""包含的头文件,编译器默认会先在当前目录下寻找相应的头文件,如果没找到然后再到系统指定目录去寻找,如果还没找到则提示文件不存在。
总结+注意:规则虽然允许用双引号来包含系统指定目录,但是一般的使用原则是:如果是系统指定的自带的用<>,如果是自己写的在当前目录下放着用"",如果是自己写的但是集中放在了一起专门存放头文件的目录下将来在编译器中用-I参数来寻找,这种情况下用<>。
(3)头文件包含的真实含义就是:在#include的那一行,将xx.h这个头文件的内容原地展开替换这一行#include语句。过程在预处理中进行。

注释
(1)注释是给人看的,不是给编译器看的。
(2)编译器既然不看注释,那么编译时最好没有注释的。实际上在预处理阶段,预处理器会拿掉程序中所有的注释语句,到了编译器编译阶段程序中其实已经没有注释了。

条件编译
(1)有时候我们希望程序有多种配置,我们在源代码编写时写好了各种配置的代码,然后给个配置开关,在源代码级别去修改配置开关来让程序编译出不同的效果。
(2)条件编译中用的两种条件判定方法分别是#ifdef 和 #if
区别:#ifdef XXX判定条件成立与否时主要是看XXX这个符号在本语句之前有没有被定义,只要定义了(我们可以直接#define XXX或者#define XXX 12或者#define XXX YYY)这个符号就是成立的。
的格式是:#if (条件表达式),它的判定标准是()中的表达式是否为true还是flase,跟C中的if语句有点像。


宏定义
宏定义的规则和使用解析
(1)宏定义的解析规则就是:在预处理阶段由预处理器进行替换,这个替换是原封不动的替换。
(2)宏定义替换会递归进行,直到替换出来的值本身不再是一个宏为止。
(3)一个正确的宏定义式子本身分为3部分:第一部分是#dedine ,第二部分是宏名 ,剩下的所有为第三部分。
(4)宏可以带参数,称为带参宏。带参宏的使用和带参函数非常像,但是使用上有一些差异。在定义带参宏时,每一个参数在宏体中引用时都必须加括号,最后整体再加括号,括号缺一不可。

宏定义示例1:MAX宏,求2个数中较大的一个
#define MAX(a, b) (((a)>(b)) ? (a) : (b))
关键:
第一点:要想到使用三目运算符来完成。
第二点:注意括号的使用

宏定义示例2:SEC_PER_YEAR,用宏定义表示一年中有多少秒
#define SEC_PER_YEAR    (365*24*60*60UL)
关键:
第一点:当一个数字直接出现在程序中时,它的是类型默认是int
第二点:一年有多少秒,这个数字刚好超过了int类型存储的范围

带参宏和带参函数的区别(宏定义的缺陷)
(1)宏定义是在预处理期间处理的,而函数是在编译期间处理的。这个区别带来的实质差异是:宏定义最终是在调用宏的地方把宏体原地展开,而函数是在调用函数处跳转到函数中去执行,执行完后再跳转回来。
注:宏定义和函数的最大差别就是:宏定义是原地展开,因此没有调用开销;而函数是跳转执行再返回,因此函数有比较大的调用开销。所以宏定义和函数相比,优势就是没有调用开销,没有传参开销,所以当函数体很短(尤其是只有一句话时)可以用宏定义来替代,这样效率高。
(2)带参宏和带参函数的一个重要差别就是:宏定义不会检查参数的类型,返回值也不会附带类型;而函数有明确的参数类型和返回值类型。当我们调用函数时编译器会帮我们做参数的静态类型检查,如果编译器发现我们实际传参和参数声明不同时会报警告或错误。
注:用函数的时候程序员不太用操心类型不匹配因为编译器会检查,如果不匹配编译器会叫;用宏的时候程序员必须很注意实际传参和宏所希望的参数类型一致,否则可能编译不报错但是运行有误。
总结:宏和函数各有千秋,各有优劣。总的来说,如果代码比较多用函数适合而且不影响效率;但是对于那些只有一两句话的函数开销就太大了,适合用带参宏。但是用带参宏又有缺点:不检查参数类型。

内联函数和inline关键字
(1)内联函数通过在函数定义前加inline关键字实现。
(2)内联函数本质上是函数,所以有函数的优点(内联函数是编译器负责处理的,编译器可以帮我们做参数的静态类型检查);但是他同时也有带参宏的优点(不用调用开销,而是原地展开)。所以几乎可以这样认为:内联函数就是带了参数静态类型检查的宏。
(3)当我们的函数内函数体很短(譬如只有一两句话)的时候,我们又希望利用编译器的参数类型检查来排错,我还希望没有调用开销时,最适合使用内联函数。

宏定义来实现条件编译(#define #undef #ifdef)
程序有DEBUG版本和RELEASE版本,区别就是编译时有无定义DEBUG宏。

09

函数

函数的本质
C语言为什么会有函数
(1)整个程序分成多个源文件,一个文件分成多个函数,一个函数分成多个语句,这就是整个程序的组织形式。这样组织的好处在于:分化问题、便于编写程序、便于分工。
(2)函数的出现是人(程序员和架构师)的需要,而不是机器(编译器、CPU)的需要。
(3)函数的目的就是实现模块化编程。说白了就是为了提供程序的可移植性。

函数书写的一般原则:
第一:遵循一定格式。函数的返回类型、函数名、参数列表等。
第二:一个函数只做一件事:函数不能太长也不宜太短,原则是一个函数只做一件事情。
第三:传参不宜过多:在ARM体系下,传参不宜超过4个。如果传参确实需要多则考虑结构体打包
第四:尽量少碰全局变量:函数最好用传参返回值来和外部交换数据,不要用全局变量。

函数是动词、变量是名词(面相对象中分别叫方法和成员变量)
(1)函数将来被编译成可执行代码段,变量(主要指全局变量)经过编译后变成数据或者在运行时变成数据。一个程序的运行需要代码和数据两方向的结合才能完成。
(2)代码和数据需要彼此配合,代码是为了加工数据,数据必须借助代码来起作用。拿现实中的工厂来比喻:数据是原材料,代码是加工流水线。名词性的数据必须经过动词性的加工才能变成最终我们需要的产出的数据。这个加工的过程就是程序的执行过程。

函数的实质是:数据处理器
(1)程序的主体是数据,也就是说程序运行的主要目标是生成目标数据,我们写代码也是为了目标数据。我们如何得到目标数据?必须2个因素:原材料+加工算法。原材料就是程序的输入数据,加工算法就是程序。
(2)程序的编写和运行就是为了把原数据加工成目标数据,所以程序的实质就是一个数据处理器。
(3)函数就是程序的一个缩影,函数的参数列表其实就是为了给函数输入原材料数据,函数的返回值和输出型参数就是为了向外部输出目标数据,函数的函数体里的那些代码就是加工算法。
(4)函数在静止没有执行(乖乖的躺在硬盘里)的时候就好象一台没有开动的机器,此时只占一些存储空间但是并不占用资源(CPU+内存);函数的每一次运行就好象机器的每一次开机运行,运行时需要耗费资源(CPU+内存),运行时可以对数据加工生成目标数据;函数运行完毕会释放占用的资源。
(5)整个程序的运行其实就是很多个函数相继运行的连续过程。

函数三要素:定义、声明、调用
(1)函数的定义就是函数体、函数声明是函数原型、函数调用就是使用函数
(2)函数定义是函数的根本,函数定义中的函数名表示了这个函数在内存中的首地址,所以可以用函数名来调用执行这个函数(实质是指针解引用访问);函数定义中的函数体是函数的执行关键,函数将来执行时主要就是执行函数体。所以一个函数没有定义就是无基之塔。
(3)函数声明的主要作用是告诉编译器函数的原型
(4)函数调用就是调用执行一个函数。

函数原型和作用
(1)函数原型就是函数的声明,说白了就是函数的函数名、返回值类型、参数列表。
(2)函数原型的主要作用就是给编译器提供原型,让编译器在编译程序时帮我们进行参数的静态类型检查
(3)必须明白:编译器在编译程序时是以单个源文件为单位的(所以一定要在哪里调用在哪里声明),而且编译器工作时已经经过预处理处理了,最最重要的是编译器编译文件时是按照文件中语句的先后顺序执行的。
(4)编译器从源文件的第一行开始编译,遇到函数声明时就会收到编译器的函数声明表中,然后继续向后。当遇到一个函数调用时,就在我的本文件的函数声明表中去查这个函数,看有没有原型相对应的一个函数(这个相对应的函数有且只能有一个)。如果没有或者只有部分匹配则会报错或报警告;如果发现多个则会报错或报警告(函数重复了,C语言中不允许2个函数原型完全一样,这个过程其实是在编译器遇到函数定义时完成的。所以函数可以重复声明但是不能重复定义)

递归函数

什么是递归函数
(1)递归函数就是函数中调用了自己本身这个函数的函数。
(2)递归函数和循环的区别。递归不等于循环
(3)递归函数解决问题的典型就是:求阶乘、求斐波那契数列

函数的递归调用原理
(1)实际上递归函数是在栈内存上递归执行的,每次递归执行一次就需要耗费一些栈内存。
(2)栈内存的大小是限制递归深度的重要因素。

使用递归函数的原则:收敛性、栈溢出
(1)收敛性就是说:递归函数必须有一个终止递归的条件。当每次这个函数被执行时,我们判断一个条件决定是否继续递归,这个条件最终必须能够被满足。如果没有递归终止条件或者这个条件永远不能被满足,则这个递归没有收敛性,这个递归最终要失败。
(2)因为递归是占用栈内存的,每次递归调用都会消耗一些栈内存。因此必须在栈内存耗尽之前递归收敛(终止),否则就会栈溢出。
(3)递归函数的使用是有一定风险的,必须把握好。

函数库
什么是函数库?
(1)函数库就是一些事先写好的函数的集合,给别人复用。
(2)函数是模块化的,因此可以被复用。我们写好了一个函数,可以被反复使用。也可以A写好了一个函数然后共享出来,当B有相同的需求时就不需自己写直接用A写好的这个函数即可。

函数库的由来
(1)最开始是没有函数库,每个人写程序都要从零开始自己写。时间长了慢慢的早期的程序员就积累下来了一些有用的函数。
(2)早期的程序员经常参加行业聚会,在聚会上大家互相交换各自的函数库。
(3)后来程序员中的一些大神就提出把大家各自的函数库收拢在一起,然后经过校准和整理,最后形成了一份标准化的函数库,就是现在的标准的函数库,譬如说glibc。

函数库的提供形式:动态链接库与静态链接库

动态链接库与静态链接库的区别_breakpoints_的博客-CSDN博客_动态链接库和静态链接库的区别
(1)早期的函数共享都是以源代码的形式进行的。这种方式共享是最彻底的(后来这种源码共享的方向就形成了我们现在的开源社区)。但是这种方式有它的缺点,缺点就是无法以商业化形式来发布函数库。
(2)商业公司需要将自己的有用的函数库共享给被人(当然是付费的),但是又不能给客户源代码。这时候的解决方案就是以库(主要有2种:静态库和动态库)的形式来提供。
(3)比较早出现的是静态链接库。静态库其实就是商业公司将自己的函数库源代码经过只编译不连接形成.o的目标文件,然后用ar工具将.o文件归档成.a的归档文件(.a的归档文件又叫静态链接库文件)。商业公司通过发布.a库文件和.h头文件来提供静态库给客户使用;客户拿到.a和.h文件后,通过.h头文件得知库中的库函数的原型,然后在自己的.c文件中直接调用这些库文件,在连接的时候链接器会去.a文件中拿出被调用的那个函数的编译后的.o二进制代码段链接进去形成最终的可执行程序。
(4)动态链接库比静态链接库出现的晚一些,效率更高一些,是改进型的。现在我们一般都是使用动态库。静态库在用户链接自己的可执行程序时就已经把调用的库中的函数的代码段链接进最终可执行程序中了,这样好处是可以执行,坏处是太占地方了。尤其是有多个应用程序都使用了这个库函数时,实际上在多个应用程序最后生成的可执行程序中都各自有一份这个库函数的代码段。当这些应用程序同时在内存中运行时,实际上在内存中有多个这个库函数的代码段,这完全重复了。而动态链接库本身不将库函数的代码段链接入可执行程序,只是做个标记。然后当应用程序在内存中执行时,运行时环境发现它调用了一个动态库中的库函数时,会去加载这个动态库到内存中,然后以后不管有多少个应用程序去调用这个库中的函数都会跳转到第一次加载的地方去执行(不会重复加载)。

函数库中库函数的使用
(1)gcc中编译链接程序默认是使用动态库的,要想静态链接需要显式用-static来强制静态链接。
(2)库函数的使用需要注意3点:第一,包含相应的头文件;第二,调用库函数时注意函数原型;第三,有些库函数链接时需要额外用-lxxx来指定链接;第四,如果是动态库,要注意-L指定动态库的地址。

字符串函数

C库中字符串处理函数包含在string.h中,这个文件在ubuntu系统中在/usr/include中

C语言常用库函数大全 - C语言网

数学库函数
math.h
(1)真正的数学运算的函数定义在:/usr/include/i386-linux-gnu/bits/mathcalls.h
(2)使用数学库函数的时候,只需要包含math.h即可。

……

注意区分编译时警告/错误,和链接时的错误:
编译时警告/错误:
math.c:9:13: warning: incompatible implicit declaration of built-in function ‘sqrt’ [enabled by default]double b = sqrt(a);
链接时错误:
math.c:(.text+0x1b): undefined reference to `sqrt'
collect2: error: ld returned 1 exit status

分析;这个链接错误的意思是:sqrt函数有声明(声明就在math.h中)有引用(在math.c)但是没有定义,链接器找不到函数体。sqrt本来是库函数,在编译器库中是有.a和.so链接库的(函数体在链接库中的)。
C链接器的工作特点:因为库函数有很多,链接器去库函数目录搜索的时间比较久。为了提升速度想了一个折中的方案:链接器只是默认的寻找几个最常用的库,如果是一些不常用的库中的函数被调用,需要程序员在链接时明确给出要扩展查找的库的名字。链接时可以用-lxxx来指示链接器去到libxxx.so中去查找这个函数。

链接时加-lm
(1)-lm就是告诉链接器到libm中去查找用到的函数。
(2)实战中发现在高版本的gcc中,经常会出现没加-lm也可以编译链接的。

自己制作静态链接库并使用
(1)第一步:自己制作静态链接库
首先使用gcc -c只编译不连接,生成.o文件;然后使用ar工具进行打包成.a归档文件
库名不能随便乱起,一般是lib+库名称,后缀名是.a表示是一个归档文件
注意:制作出来了静态库之后,发布时需要发布.a文件和.h文件。

(2)第二步:使用静态链接库
把.a和.h都放在我引用的文件夹下,然后在.c文件中包含库的.h,然后直接使用库函数。
第一次,编译方法:gcc test.c -o test
报错信息:test.c:(.text+0xa): undefined reference to `func1'
test.c:(.text+0x1e): undefined reference to `func2'
第二次,编译方法:gcc test.c -o test -laston
报错信息:/usr/bin/ld: cannot find -laston
collect2: error: ld returned 1 exit status
第三次,编译方法:gcc test.c -o test -laston -L.
无报错,生成test,执行正确。

(3)除了ar名另外,还有个nm命令也很有用,它可以用来查看一个.a文件中都有哪些符号

自己制作动态链接库并使用
(1)动态链接库的后缀名是.so(对应windows系统中的dll),静态库的扩展名是.a
(2)第一步:创建一个动态链接库。
gcc aston.c -o aston.o -c -fPIC
gcc -o libaston.so aston.o -shared 
-fPIC是位置无关码,-shared是按照共享库的方式来链接。
注意:做库的人给用库的人发布库时,发布libxxx.so和xxx.h即可。
(3)第二步:使用自己创建的共享库。
第一步,编译方法:gcc test.c -o test
报错信息:test.c:(.text+0xa): undefined reference to `func1'
test.c:(.text+0x1e): undefined reference to `func2'
collect2: error: ld returned 1 exit status

第二步,编译方法:gcc test.c -o test -laston
报错信息:/usr/bin/ld: cannot find -laston
collect2: error: ld returned 1 exit status

第三步,编译方法:gcc test.c -o test -laston -L.
编译成功

但是运行出错,报错信息:
error while loading shared libraries: libaston.so: cannot open shared object file: No such file or directory

错误原因:动态链接库运行时需要被加载(运行时环境在执行test程序的时候发现他动态链接了libaston.so,于是乎会去固定目录尝试加载libaston.so,如果加载失败则会打印以上错误信息。)

解决方法一:
将libaston.so放到固定目录下就可以了,这个固定目录一般是/usr/lib目录。
cp libaston.so /usr/lib即可

解决方法二:使用环境变量LD_LIBRARY_PATH。操作系统在加载固定目录/usr/lib之前,会先去LD_LIBRARY_PATH这个环境变量所指定的目录下去寻找,如果找到就不用去/usr/lib下面找了,如果没找到再去/usr/lib下面找。所以解决方案就是将libaston.so所在的目录导出到环境变量LD_LIBRARY_PATH中即可。
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:

/mnt/hgfs/Winshare/s5pv210/AdvancedC/4.6.PreprocessFunction

/4.6.12.sharedobject.c/sotest

在ubuntu中还有个解决方案三,用ldconfig

(4)ldd命令:作用是可以在一个使用了共享库的程序执行之前解析出这个程序使用了哪些共享库,并且查看这些共享库是否能被找到,能被解析(决定这个程序是否能正确执行)。

10

常见概念总结

存储类
(1)存储类就是存储类型,也就是描述C语言变量在何种地方存储。
(2)内存有多种管理方法:栈、堆、数据段、bss段、.text段······一个变量的存储类属性就是描述这个变量存储在何种内存段中。
(3)譬如:局部变量分配在栈上,所以它的存储类就是栈;显式初始化为非0的全局变量分配在数据段,显式初始化为0和没有显示初始化(默认为0)的全局变量分配在bss段。

作用域
(1)作用域是描述这个变量起作用的代码范围。
(2)基本来说,C语言变量的作用域规则是代码块作用域。意思就是这个变量起作用的范围是当前的代码块。代码块就是一对大括号{}括起来的范围,所以一个变量的作用域是:这个变量定义所在的{}范围内从这个变量定义开始往后的部分。(这就解释了为什么变量定义总是在一个函数的最前面)

生命周期
(1)声明周期是描述这个变量什么时候诞生(运行时分配内存空间给这个变量)及什么时候死亡(运行时收回这个内存空间,此后再不能访问这个内存地址,或者访问这个内存地址已经和这个变量无关了)的。
(2)变量和内存的关系,就和人(变量)去图书馆借书(内存)一样。变量的生命周期就好象我人借书的这段周期一样。
(3)研究变量的生命周期可以我们理解程序运行的一些现象、理解C语言的一些规则。

链接属性
(1)大家知道程序从源代码到最终可执行程序,经历的过程:编译、链接。
(2)编译阶段就是把源代码搞成.o目标文件,目标文件里面有很多符号和代码段、数据段、bss段等分段。符号就是编程中的变量名、函数名等。运行时变量名、函数名能够和相应的内存对应起来,靠符号来做链接的。
(3).o的目标文件链接生成最终可执行程序的时候,其实就是把符号和相对应的段给链接起来。
C语言中的符号有三种链接属性:外连接属性、内链接属性、无连接属性。

总结:以上4个概念,其实就是从4个不同角度来分析C语言的一些运行规则。综合这4种分析角度能够让程序员完全掌握C语言程序的运行规则和方法。

linux下C程序的内存映像

linux c三种内存,Linux下的c内存映像_周海棣的博客-CSDN博客

代码段、只读数据段
(1)对应着程序中的代码(函数),代码段在linux中又叫文本段(.text)
(2)只读数据段就是在程序运行期间只能读不能写的数据,const修饰的常量有可能是存在只读数据段的(但是不一定,const常量的实现方法在不同平台是不一样的)

数据段、bss段
(1)数据段存:1、显式初始化为非0的全局变量;2、显式初始化为非0的static局部变量
(2)bss段存:1、显式初始化为0或者未显式初始化的全局变量;2、显式初始化为0或未显式初始化的static局部变量。


C语言中什么样变量存在堆内存中?C语言不会自动向堆中存放东西,堆的操作是程序员自己手工操作的。程序员根据需求自己判断要不要使用堆内存,用的时候自己申请,自己使用,完了自己释放。

文件映射区
文件映射区就是进程打开了文件后,将这个文件的内容从硬盘读到进程的文件映射区,以后就直接在内存中操作这个文件,读写完了后在保存时再将内存中的文件写到硬盘中去。


(1)栈内存区,局部变量分配在栈上;函数调用传参过程也会用到栈

内核映射区
(1)内核映射区就是将操作系统内核程序映射到这个区域了。
(2)对于linux中的每一个进程来说,它都以为整个系统中只有它自己和内核而已。它认为内存地址0xC0000000以下都是它自己的活动空间,0xC0000000以上是OS内核的活动空间。
(3)每一个进程都活在自己独立的进程空间中,0-3G的空间每一个进程是不同的(因为用了虚拟地址技术),但是内核是唯一的。

OS下和裸机下C程序加载执行的差异
(1)C语言程序运行时环境有一定要求,意思是单独个人写的C语言程序没法直接在内存中运行,需要外部一定的协助,这段协助的代码叫加载运行代码(或者叫构建C运行时环境的代码,这一段代码在操作系统下是别人写好的,会自动添加到我们写的程序上,这段代码的主要作用是:给全局变量赋值、清bss段)。
(2)ARM裸机第十六部分,写shell时有一次定义了一个全局变量初始化为0但是实际不为0,后来在裸机的start.S中加了清bss段代码就变0了。这就说明在裸机程序中没人帮我们来做这一段加载运行时代码,要程序员自己做(start.S中的重定位和清bss段就是在做这个事);在操作系统中运行程序时程序员自己不用操心,会自动完成重定位和清bss,所以我们看到的现象:C语言中未初始化的全局变量默认为0·····。
(3)数据段的全局变量或静态局部变量都是有非0的初值的,这些初值在main函数运行之前就已经被初始化了,是重定位期间完成的初始化。

存储类相关的关键字
auto
(1)auto关键字在C语言中只有一个作用,那就是修饰局部变量。
(2)auto修饰局部变量,表示这个局部变量是自动局部变量,自动局部变量分配在栈上。(既然在栈上,说明它如果不初始化那么值就是随机的······)
(3)平时定义局部变量时就是定义的auto的,只是省略了auto关键字而已。可见,auto的局部变量其实就是默认定义的普通的局部变量。

static
(1)static关键字在C语言中有2种用法,而且这两种用法彼此没有任何关联、完全是独立的。其实当年本应该多发明一个关键字,但是C语言的作者觉得关键字太多不好,于是给static增加了一种用法,导致static一个关键字竟然有两种截然不同的含义。
(2)static的第一种用法是:用来修饰局部变量,形成静态局部变量。要搞清楚静态局部变量和非静态局部变量的区别。本质区别是存储类不同(存储类不同就衍生出很多不同):非静态局部变量分配在栈上,而静态局部变量分配在数据段/bss段上。
(3)static的第二种用法是:用来修饰全局变量,形成静态全局变量。要搞清楚静态全局变量和非静态全局变量的区别。区别是在链接属性上不同,讲到链接属性时详细讲。
分析:
1、静态局部变量在存储类方面和全局变量一样。
2、静态局部变量在生命周期方面和全局变量一样。
3、静态局部变量和全局变量的区别是:作用域、连接属性。静态局部变量作用域是代码块作用域(和普通局部变量是一样的)、链接属性是无连接;全局变量作用域是文件作用域(和函数是一样的)、链接属性方面是外连接。

register
(1)register关键字不常用,也只有一个作用,那就是:register修饰的变量。编译器会尽量将它分配在寄存器中。(平时分配的一般的变量都是在内存中的)。分配在寄存器中一样的用,但是读写效率会高很多。所以register修饰的变量用在那种变量被反复高频率的使用,通过改善这个变量的访问效率可以极大的提升程序运行效率时。所以register是一种极致提升程序运行效率的手段。
(2)uboot中用到了一个register类型的变量,gd这个变量是用来存uboot的全局变量(gd就是global data)。因为这个全局变量在整个uboot中到处都被访问,所以定义成register的。
(3)平时写代码要被定义成register这种情况很少,一般慎用。
(4)register编译器只能承诺尽量将register修饰的变量放在寄存器中,但是不保证一定放在寄存器中。主要原因是因为寄存器数量有限,不一定有空用。

extern
(1)extern主要用来声明全局变量,声明的目的主要是在a.c中定义全局变量而在b.c中使用该变量。
(2)C语言中程序的编译时以单个.c源文件为单位的,因此编译a.c时只考虑a.c中的内容(不会考了b.c的内容),这就导致a.c中使用了b.c中定义的变量时在编译时报错。解决方案是声明
(3)应该在a.c中使用g_b之前先声明g_b,声明就是告诉a.c我在别的文件中定义了g_b,并且它的原型和声明的一样,将来在链接的时候链接器会在别的.o文件中找到这个同名变量。声明一个全局变量就要用到extern关键字

volatile
(1)volatile的字面意思:可变的、易变的。C语言中volatile用来修饰一个变量,表示这个变量可以被编译器之外的东西改变。编译器之内的意思是变量的值的改变是代码的作用,编译器之外的改变就是这个改变不是代码造成的,或者不是当前代码造成的,编译器在编译当前代码时无法预知。譬如在中断处理程序isr中更改了这个变量的值,譬如多线程中在别的线程更改了这个变量的值,譬如硬件自动更改了这个变量的值(一般这个变量是一个寄存器的值)
(2)以上说的三种情况(中断isr中引用的变量,多线程中共用的变量,硬件会更改的变量)都是编译器在编译时无法预知的更改,此时应用使用volatile告诉编译器这个变量属于这种(可变的、易变的)情况。编译器在遇到volatile修饰的变量时就不会对改变量的访问进行优化,就不会出现错误。
(3)编译器的优化在一般情况下非常好,可以帮助提升程序效率。但是在特殊情况(volatile)下,变量会被编译器想象之外的力量所改变,此时如果编译器没有意识到而去优化则就会造成优化错误,优化错误就会带来执行时错误。而且这种错误很难被发现。
(4)volatile是程序员意识到需要volatile然后在定义变量时加上volatile,如果你遇到了应该加volatile的情况而没有加程序可能会被错误的优化。如果在不应该加volatile而加了的情况程序不会出错只是会降低效率。所以我们对于volatile的态度应该是:正确区分,该加的时候加不该加的时候不加,如果不能确定该不该加为了保险起见就加上。

restrict
(1)c99中才支持的,所以很多延续c89的编译器是不支持restrict关键字,gcc支持的。
(2)restrict也是和编译器行为特征有关的。
(3)restrict只用来修饰指针,不能修饰普通变量。
(4)http://blog.chinaunix.net/uid-22197900-id-359209.html
(5)memcpy和memmove的区别

typedef
(1)之前讲过了
(2)typedef在C语言关键字归类上属于存储类关键字,但是实际上和存储类没关系。

作用域详解
局部变量的代码块作用域
(1)代码块基本可以理解为一对大括号{}括起来的部分。
(2)代码块不等于函数,因为if  while for都有{}。所以代码块<=函数
(3)局部变量的作用域是代码块作用域,也就是说一个局部变量可以被访问和使用的范围仅限于定义这个局部变量的代码块中定义式之后的部分。

函数名和全局变量的文件作用域
(1)文件作用域的意思就是全局的访问权限,也就是说整个.c文件中都可以访问这些东西。这就是平时所说的局部和全局,全局就是文件作用域。
(2)详细准确的说:函数和全局变量的作用域是定义所在的整个.c文件之内定义式之后的部分。


总结:
(1)不管是局部变量、全局变量、函数,都要先定义才能使用
(2)严格来说我们上面的总结是错误的。准确的说:全局变量/函数的作用域都是自己所在的文件,但是定义式之前的部分因为缺少声明所以没法用,解决方案是:1、把它定义到前面去;2、定义到后面但是在前面加声明;局部变量因为没法声明,所以只能定义在前面去。
(3)在c89标准的编译器中(现在很多编译器还延续使用c89标准),所有的局部变量必须先定义在最前面,在变量定义之前不能有一句执行代码。在c99标准的编译器中(gcc兼容c99标准)可以允许在代码块内任意地方定义变量。但是允许定义的变量还是只能使用在定义了之后,定义之前还是不能用的。

同名变量的掩蔽规则
(1)问题:编程时,不可避免会出现同名变量。变量同名后不一定会出错。
(2)首先,如果两个同名变量作用域不同且没有交叠,这种情况下同名没有任何影响。
(3)其次,如果两个同名变量作用域有交叠,C语言规定在作用域交叠范围内,作用域小的一个变量会掩蔽掉作用域大的那个(县官不如现管)。

变量的生命周期
研究变量生命周期,有助于理解变量的行为特征。

栈变量的生命周期
(1)局部变量(栈变量)存储在栈上,生命周期是临时的。临时的意思就是说:代码执行过程中按照需要去创建、使用、消亡的。
(2)譬如一个函数内定义的局部变量,在这个函数每一次被调用时都会创建一次,然后使用,最后在函数返回的时候消亡。
(3)思考:一个函数内的局部变量为什么在函数外不能使用?
(4)思考:局部变量为什么分配在栈上?或者说局部变量为什么是临时生命周期?

堆变量的生命周期
(1)首先要明白:堆内存空间是客观存在的,是由操作系统维护的。我们程序只是去申请然后使用然后释放。
(2)我们只关心我们程序使用堆内存的这一段时间,因此堆变量也有了自己的生命周期,就是:从malloc申请时诞生,然后使用,直到free时消亡。
(3)所以堆内存在malloc之前和free之后不能再去访问,因此堆内存在实践编程时都是被反复的malloc和free的。

数据段、bss段变量的生命周期
(1)全局变量的生命周期是永久的。永久的意思就是在程序被执行时诞生,在程序终止时消亡。
(2)全局变量所占用的内存是不能被程序自己释放的,所以程序如果申请了过多的全局变量会导致这个程序一直占用大量内存。
(3)如果说堆内存是图书馆借的书,那么全局变量就是自己买的书。

代码段、只读段的生命周期
(1)其实就是程序执行的代码,其实就是函数,它的生命周期是永久的。不过一般代码的生命周期我们并不关注。
(2)有时候放在代码段的不只是代码,还有const类型的常量,还有字符串常量。(const类型的常量、字符串常量有时候放在rodata段,有时候放在代码段,取决于平台)

链接属性

C语言程序的组织架构:多个C文件+多个h文件
(1)庞大、完整的一个C语言程序(譬如linux内核、uboot)由多个c文件和多个h文件组成的。
(2)程序的生成过程就是:编译+链接。编译是为了将函数/变量等变成.o二进制的机器码格式,链接是为了将各个独立分开的二进制的函数链接起来形成一个整体的二进制可执行程序。

编译以文件为单位、链接以工程为单位
(1)编译器工作时是将所有源文件依次读进来,单个为单位进行编译的。
(2)链接的时候实际上是把第一步编译生成个单个的.o文件整体的输入,然后处理链接成一个可执行程序。

三种链接属性:外连接、内链接、无链接
(1)外连接的意思就是外部链接属性,也就是说这家伙可以在整个程序范围内(言下之意就是可以跨文件)进行链接,譬如普通的函数和全局变量属于外连接。
(2)内链接的意思就是(c文件内部)内部链接属性,也就是说这家伙可以在当前c文件内部范围内进行链接(言下之意就是不能在当前c文件外面的其他c文件中进行访问、链接)。static修饰的函数/全局变量属于内链接。
(3)无连接的意思就是这个符号本身不参与链接,它跟链接没关系。所有的局部变量(auto的、static的)都是无连接的

函数和全局变量的同名冲突
(1)因为函数和全局变量是外部链接属性,就是说每一个函数和全局变量将来在整个程序中所有的c文件都能被访问,因此在一个程序中的所有c文件中不能出现同名的函数/同名的全局变量。
(2)最简单的解决方案就是起名字不要重复,但是很难做到。主要原因是一个很大的工程中函数和全局变量名字太多了,而且一个大工程不是一个人完成的,是很多人协作完成,所以很难保证不会重名。解决方案呢?
(3)现代高级语言中完美解决这个问题的方法是命名空间namespace(其实就是给一个变量带上各个级别的前缀)。但是C语言不是这么解决的。
(4)C语言比较早碰到这个问题,当时还没发明namespace概念,当时C语言就发明了一种不是很完美但是凑活能用的解决方案,就是三种链接属性的方法。
(5)C语言的链接属性解决重名问题思路是这样的:我们将明显不会在其他c文件中引用(只在当前c文件中引用)的函数/全局变量,使用static修饰使其成为内链接属性,这样在将来连接时即使2个c文件中有重名的函数/全局变量,只要其中一个或2个为内链接属性就没事。
(6)这种解决方案在一定程度上解决了问题。但是没有从根本上解决问题,留下了很多麻烦。所以这个就导致了C语言写很大型的项目难度很大。

static的第二种用法:修饰全局变量和函数
(1)普通的(非静态)的函数/全局变量,默认的链接属性是外部的
(2)static(静态)的函数/全局变量,链接属性是内部链接。


最后的总结
(1)普通(自动)局部变量分配在栈上,作用域为代码块作用域,生命周期是临时,连接属性为无连接。定义时如果未显式初始化则其值随机,变量地址由运行时在栈上分配得到,多次执行时地址不一定相同,函数不能返回该类变量的地址(指针)作为返回值。
(2)静态局部变量分配在数据段/bss段(显式初始化为非0则在数据段,显式初始化为0或未显示初始化则在bss段),作用域为代码块作用域(人为规定的),生命周期为永久(天然的),链接属性为无连接(天然的)。定义时如果未显式初始化则其值为0(天然的),变量地址由运行时环境在加载程序时确定,整个程序运行过程中唯一不变;静态局部变量其实就是作用域为代码块作用域(同时链接属性为无连接)的全局变量。静态局部变量可以改为用全局变量实现(程序中尽量避免用全局变量,因为会破坏结构性)。
(3)静态全局变量/静态函数和普通全局变量/普通函数的唯一差别是:static使全局变量/函数的链接属性由外部链接(整个程序所有文件范围)转为内部链接(当前c文件内)。这是为了解决全局变量/函数的重名问题(C语言没有命名空间namespace的概念,因此在程序中文件变多之后全局变量/函数的重名问题非常严重,将不必要被其他文件引用的全局变量/函数声明为static可以很大程度上改善重名问题,但是仍未彻底解决)。
(4)写程序尽量避免使用全局变量,尤其是非static类型的全局变量。能确定不会被其他文件引用的全局变量一定要static修饰。
(5)注意区分全局变量的定义和声明。一般规律如下:如果定义的同时有初始化则一定会被认为是定义;如果只是定义而没有初始化则有可能被编译器认为是定义,也可能被认为是声明,要具体分析;如果使用extern则肯定会被认为是声明(实际上使用extern也可以有定义,实际上加extern就是明确声明这个变量为外部链接属性)。
(6)全局变量应该定义在c文件中并且在头文件中声明,而不要定义在头文件中(因为如果定义在头文件中,则该头文件被多个c文件包含时该全局变量会重复定义)。
(7)在b.c中引用a.c中定义的全局变量/函数有2种方法:一是在a.h中声明该函数/全局变量,然后在b.c中#include ;二是在b.c中使用extern显式声明要引用的函数/全局变量。其中第一种方法比较正式。
(8)存储类决定生命周期,作用域决定链接属性
(9)宏和inline函数的链接属性为无连接。

11

补充说明

操作系统究竟是个什么玩意?
像人类社会一样的计算机软件系统(有些人只埋头干活,有些人只做管理)
(1)人类社会最开始时人人都干活,这时候没有专业分工,所有人都直接做产生价值的工作。当时是合适的,因为当时生产力低下,人口稀少。这就像裸机程序一样(裸机程序的特点是:代码量小,功能简单、所有代码都和直接目的有关,没有服务性代码)。
(2)后来人口增加生产力提高,有一部分人脱离了直接产生价值的体力劳动专职指挥(诞生了阶级)。本质上来说是合理的,因为资源得到了更大限度的使用,优化了配置,提升了整体效率。程序也是一样,当计算机技术发展,计算机性能和资源大量增加,这时候写代码也要产生阶级也要进行分工,不然如果所有代码都去参加直接性的工作,则整体系统效率不高。(因为代码很难进行资源的优化配置)。
(3)解决方案就是操作系统。操作系统就是分出来的管理阶级,操作系统的代码本身并不直接产生价值,它的主要任务是管理所有资源,它主要为直接产生价值、直接劳动的那些程序(各种应用程序)提供服务。所以操作系统既是管理者也是服务者。
(4)裸机程序就好象小公司,操作系统下的程序就好象大型跨国公司;裸机程序就好象小国家,操作系统下程序就好象大国家;如果我们要做一个产品,软件系统到底应该是裸机还是基于操作系统呢?本质上取决于产品本身的复杂度。只有极简单的功能、使用极简单的CPU(譬如单片机)的产品才会选择用裸机开发;一般的复杂性产品都会选择基于操作系统来开发。

操作系统的调用通道:API函数
(1)操作系统负责管理和资源调配,应用程序负责具体的直接劳动,他们之间的接口就是API函数。当应用程序需要使用系统资源(譬如内存、譬如CPU、譬如硬件操作)时就通过API向操作系统发出申请,然后操作系统响应申请帮助应用程序执行功能。

C库函数和API的关系
(1)单纯的API只是提供了极简单没有任何封装的服务函数,这些函数应用程序是可用的,但是不太好用。应用程序为了好用,就对这个API进行了二次封装,把它变得好用一些,于是就成了C库函数。
(2)有时完成一个功能,有相应的库函数可以完成,也有API可以完成,用哪个都行。譬如读写文件,API的接口是open write read close;库函数的接口是fopen fwrite fread fclose。fopen本质上是使用open实现的,只是进行了封装。封装肯定有目的(添加缓冲机制)。
不同平台(windows、linux、裸机)下库函数的差异
(1)不同操作系统API是不同的,但是都能完成所有的任务,只是完成一个任务所调用的API不同。
(2)库函数在不同操作系统下也不同,但是相似性要更高一些。这是人为的,因为人下意识想要屏蔽不同操作系统的差异,因此在封装API成库函数的时候,尽量使用了同一套接口,所以封装出来的库函数挺像的。但是还是有差异,所以在一个操作系统上写的应用程序不可能直接在另一个操作系统上面编译运行。于是乎就有个可移植性出来了。
(3)跨操作系统可移植平台,譬如QT、譬如Java语言。

操作系统的重大意义:软件体系分工
(1)有了操作系统后,我们做一个产品可以首先分成2部分:一部分人负责做操作系统(开发驱动的);一部分人负责用操作系统实现具体功能(开发应用)。实际上上层应用层的功能进一步复杂化后又分了好多层。

main函数返回给谁?
函数为什么需要返回值
(1)函数在设计的时候设计了参数和返回值,参数是函数的输入,返回值是函数的输出。
(2)因为函数需要对外输出数据(实际上是函数运行的一些结果值)因此需要返回值
(3)形式上来说,函数被另一个函数所调用,返回值作为函数式的值返回给调用这个函数的地方
总结:函数的返回值就是给调用它的人返回一个值
main函数被谁调用
(1)main函数是特殊的,首先这个名字是特殊的。因为C语言规定了main函数是整个程序的入口。其他的函数只有直接或间接被main函数所调用才能被执行,如果没有被main直接/间接调用则这个函数在整个程序中无用。
(2)main函数从某种角度来讲代表了我当前这个程序,或者说代表了整个程序。main函数的开始意味着整个程序开始执行,main函数的结束返回意味着整个程序的结束。
(3)谁执行了这个程序,谁就调用了main。
(4)谁执行了程序?或者说程序有哪几种被调用执行的方法?

linux下一个新程序执行的本质
(1)表面来看,linux中在命令行中去./xx执行一个可执行程序
(2)我们还可以通过shell脚本来调用执行一个程序
(3)我们还可以在程序中去调用执行一个程序(fork exec)
总结:我们有多种方法都可以执行一个程序,但是本质上是相同的。linux中一个新程序的执行本质上是一个进程的创建、加载、运行、消亡。linux中执行一个程序其实就是创建一个新进程然后把这个程序丢进这个进程中去执行直到结束。新进程是被谁开启?在linux中进程都是被它的父进程fork出来的。
分析:命令行本身就是一个进程,在命令行底下去./xx执行一个程序,其实这个新程序是作为命令行进程的一个字进程去执行的。
总之一句话:一个程序被它的父进程所调用。
结论:main函数返回给调用这个函数的父进程。父进程要这个返回值干嘛?父进程调用子进程来执行一个任务,然后字进程执行完后通过main函数的返回值返回给父进程一个答复。这个答复一般是表示子进程的任务执行结果完成了还是错误了。(0表示执行成功,负数表示失败)

实践验证获取main的返回值
(1)用shell脚本执行程序可以获取程序的返回值并且打印出来
(2)linux shell中用$?这个符号来存储和表示上一个程序执行结果。

启示
(1)任何人任何事物都是有妈生的,不会无缘无故出现或消亡。
(2)看起来没用、改掉或去掉没错的,也不见得就真的没用没错。要大胆总结更要小心求证。

argc、argv与main函数的传参
谁给main函数传参
(1)调用main函数所在的程序的它的父进程给main函数传参,并且接收main的返回值。
为什么需要给main函数传参
(1)首先,main函数不传参是可以的,也就是说父进程调用子程序并且给子程序传参不是必须的。 int main(void)这种形式就表示我们认为不必要给main传参。
(2)有时候我们希望程序有一种灵活性,所以选择在执行程序时通过传参来控制程序中的运行,达到不需要重新编译程序就可以改变程序运行结果的效果。

表面上:给main传参是怎样实现的?
(1)给main传参通过argc和argv这两个C语言预订的参数来实现
(2)argc是int类型,表示运行程序的时候给main函数传递了几个参数;argv是一个字符串数组,这个数组用来存储多个字符串,每个字符串就是我们给main函数传的一个参数。argv[0]就是我们给main函数的第一个传参,argv[1]就是传给main的第二个参数····

本质上:给main传参是怎样实现的?
(1)上节课讲过,程序调用有各种方法但是本质上都是父进程fork一个子进程,然后字进程和一个程序绑定起来去执行(exec函数族),我们在exec的时候可以给他同时传参。
(2)程序调用时可以被传参(也就是main的传参)是操作系统层面的支持完成的。

给main传参要注意什么
(1)main函数传参都是通过字符串传进去的。
(2)程序被调用时传参,各个参数之间是通过空格来间隔的。
(3)在程序内部如果要使用argv,那么一定要先检验argc。

题目:写个计算器,然后运行时可以 ./calculator 3 + 5,程序执行返回8

void类型的本质
C语言属强类型语言
(1)编程语言分2种:强类型语言和弱类型语言。强类型语言中所有的变量都有自己固定的类型,这个类型有固定的内存占用,有固定的解析方法;弱类型语言中没有类型的概念,所有变量全都是一个类型(一般都是字符串的),程序在用的时候再根据需要来处理变量。
(2)C语言就是典型的强类型语言,C语言中所有的变量都有明确的类型。因为C语言中的一个变量都要对应内存中的一段内存,编译器需要这个变量的类型来确定这个变量占用内存的字节数和这一段内存的解析方法。

数据类型的本质含义
(1)数据类型的本质就决定变量的内存占用数,和内存的解析方法。
(2)所以得出结论:c语言中变量必须有确定的数据类型,如果一个变量没有确定的类型(就是所谓的无类型)会导致编译器无法给这个变量分配内存,也无法解析这个变量对应的内存。因此得出结论不可能有没有类型的变量。
(3)但是C语言中可以有没有类型的内存。在内存还没有和具体的变量相绑定之前,内存就可以没有类型。实际上纯粹的内存就是没有类型的,内存只是因为和具体的变量相关联后才有了确定的类型(其实内存自己本身是不知道的,而编译器知道,我们程序在使用这个内存时知道类型所以会按照类型的含义去进行内存的读和写)。

void类型的本质
(1)void类型的正确的含义是:不知道类型,不确定类型,还没确定类型。
(2)void a;定义了一个void类型的变量,含义就是说a是一个变量,而且a肯定有确定的类型,只是目前我还不知道a的类型,还不确定,所以标记为void。

为什么需要void类型
(1)什么情况下需要void类型?其实就是在描述一段还没有具体使用的内存时需要使用void类型。
(2)void的一个典型应用案例就是malloc的返回值。我们知道malloc函数向系统堆管理器申请一段内存给当前程序使用,malloc返回的是一个指针,这个指针指向申请的那段内存。malloc刚申请的这段内存尚未用来存储数据,malloc函数也无法预知这段内存将来被存放什么类型的数据,所以malloc无法返回具体类型的指针,解决方法就是返回一个void *类型,告诉外部我返回的是一段干净的内存空间,尚未确定类型。所以我们在malloc之后可以给这段内存读写任意类型的数据。
(3)void *类型的指针指向的内存是尚未确定类型的,因此我们后续可以使用强制类型转换强行将其转为各种类型。这就是void类型的最终归宿,就是被强制类型转换成一个具体类型。
(4)void类型使用时一般都是用void *,而不是仅仅使用void。

C语言中的NULL
NULL在C/C++中的标准定义
(1)NULL不是C语言关键字,本质上是一个宏定义
(2)NULL的标准定义:
#ifdef _cplusplus            // 条件编译
#define NULL 0
#else
#define NULL (void *)0        // 这里对应C语言的情况
#endif

解释:C++的编译环境中,编译器预先定义了一个宏_cplusplus,程序中可以用条件编译来判断当前的编译环境是C++的还是C的。
NULL的本质解析:NULL的本质是0,但是这个0不是当一个数字解析,而是当一个内存地址来解析的,这个0其实是0x00000000,代表内存的0地址。(void *)0这个整体表达式表示一个指针,这个指针变量本身占4字节,地址在哪里取决于指针变量本身,但是这个指针变量的值是0,也就是说这个指针变量指向0地址(实际是0地址开始的一段内存)。

从指针角度理解NULL的本质
(1)int *p;        // p是一个函数内的局部变量,则p的值是随机的,也就是说p是一个野指针。
(2)int *p = NULL;    // p是一个局部变量,分配在栈上的地址是由编译器决定的,我们不必关心,但是p的值是(void *)0,实际就是0,意思是指针p指向内存的0地址处。这时候p就不是野指针了。
(3)为什么要让一个野指针指向内存地址0处?主要是因为在大部分的CPU中,内存的0地址处都不是可以随便访问的(一般都是操作系统严密管控区域,所以应用程序不能随便访问)。所以野指针指向了这个区域可以保证野指针不会造成误伤。如果程序无意识的解引用指向0地址处的野指针则会触发段错误。这样就可以提示你帮助你找到程序中的错误。

为什么需要NULL
(1)第一个作用就是让野指针指向0地址处安全。
(2)第二个作用就是一个特殊标记。按照标准的指针使用步骤是:
int *p = NULL;        // 定义p时立即初始化为NULL
p = xx;
if (NULL != p)
{
    *p                 // 在确认p不等于NULL的情况下才去解引用p
}
p = NULL            // 用完之后p再次等于NULL

注意:一般比较一个指针和NULL是否相等不写成if (p == NULL),而写成if (NULL == p)。原因是第一种写法中如果不小心把==写成了=,则编译器不会报错,但是程序的意思完全不一样了;而第二种写法如果不小心把==写成了=则编译器会发现并报错。

注意不要混用NULL与'\0'
(1)'\0' 和 '0' 和 0  和 NULL几个区分开。
(2)'\0'是一个转义字符,他对应的ASCII编码值是0,本质就是0
(3)'0'是一个字符,他对应的ASCII编码值是48,本质是48
(4)0是一个数字,他就是0,本质就是0
(4)NULL是一个表达式,是强制类型转换为void *类型的0,本质是0.

总结:'\0'用法是C语言字符串的结尾标志,一般用来比较字符串中的字符以判断字符串有没有到头;'0'是字符0,对应0这个字符的ASCII编码,一般用来获取0的ASCII码值;0是数字,一般用来比较一个int类型的数字是否等于0;NULL是一个表达式,一般用来比较指针是否是一个野指针。

临时匿名变量

运算中的临时匿名变量
C语言和汇编的区别(汇编完全对应机器操作,C对应逻辑操作)
(1)C语言叫高级语言,汇编语言叫低级语言。
(2)低级语言的意思是汇编语言和机器操作相对应,汇编语言只是CPU的机器码的助记符,用汇编语言写程序必须拥有机器的思维。因为不同的CPU设计时指令集差异很大,因此用汇编编程的差异很大。
(3)高级语言(C语言)它对低级语言进行了封装(C语言的编译器来完成),给程序员提供了一个靠近人类思维的一些语法特征,人类不用过于考虑机器原理,而可以按照自己的逻辑原理来编程。譬如数组、结构体、指针····
(4)更高级的语言如java、C#等只是进一步强化了C语言提供的人性化的操作界面语法,在易用性上、安全性上进行了提升。

C语言的一些“小动作”
(1)高级语言中有一些元素是机器中没有的
(2)高级语言在运算中允许我们大跨度的运算。意思就是低级语言中需要好几步才能完成的一个运算,在高级语言中只要一步即可完成。譬如C语言中一个变量i要加1,在C中只需要i++即可,看起来只有一句代码。但实际上翻译到汇编阶段需要3步才能完成:第1步从内存中读取i到寄存器,第2步对寄存器中的i进行加1,第3步将加1后的i写回内存中的i。

使用临时变量来理解强制类型转换

使用临时变量来理解不同数据类型之间的运算

顺序结构

顺序结构
最浅显的顺序结构:三种结构之一
(1)代码执行的时候如果没有遇到判断跳转或者循环,默认是顺序执行的。执行完上一句则开始执行下一句。
(2)顺序结构说明CPU的工作状态,就是以时间轴来顺序执行所有的代码语句直到停机。

选择和循环结构内部的顺序结构
(1)譬如if(){}在{}内部是if的代码段,在代码段内部还是按照顺序结构来执行的。
(2)switch case内部也一样,也是按照顺序结构执行的。
(3)while for内部也是按照顺序结构来执行的。

编译过程中的顺序结构
(1)一个C程序有多个.c文件组成,编译的时候多个.c文件是独立分开编译的。每个c文件编译的时候,编译器是按照从前到后的顺序逐行进行编译的。
(2)编译器编程时的顺序编译会导致函数/变量必须先定义/声明才能调用,这也是C语言中函数/变量声明的来源。
(3)链接过程中呢?应该说链接过程链接器实际上是在链接脚本指导下完成的。所以链接时的.o文件的顺序是由链接脚本指定的。如果链接脚本中明确指定了顺序则会优先考虑这个规则按照这个指定的顺序排布,如果链接脚本中没有指定具体的顺序则链接器会自动的排布。

思考:为什么本质都是顺序结构?
(1)顺序结构本质上符号CPU的设计原理,CPU又是人设计的,所以CPU的设计符合人的思考原理

deburg宏

程序调试的debug宏
程序调试的常见方案:单步调试、裸机LED调试、打印信息、log文件
(1)利用调试器进行单步调试(譬如IDE中,Jlink)适用于新手,最大的好处就是直观,能够帮助找到问题。缺点是限制性大、速度慢。
(2)裸机使用LED、蜂鸣器等硬件调试,适合单片机裸机程序
(3)printf函数打印调试,比较常用,作为程序员必须学会使用打印信息调试。好处是具有普遍性,几乎在所有的情况下都能用。
(4)log文件(日志文件)是系统运行过程中在特定时候会打印一些调试信息,日志文件记录下来这些调试信息以供后续查找追查问题。适合于系统级或者大型程序的调试。

打印信息不能太多也不能太少
(1)调试信息太少会不够信息找到问题所在。
(2)调试信息太多会有大量的无用的信息淹没有用信息,导致有用信息无法看见,等于没有。

调试(DEBUG)版本和发行(RELEASE)版本的区别
(1)DEBUG版本就是包含了调试信息输出的版本,在程序测试过程中会发布debug版本,这种版本的程序运行时会打印出来调试信息/log文件,这些信息可以辅助测试人员判断程序的问题所在。DEBUG版本的坏处是输出调试信息占用了系统资源,拖慢了系统运行速度。因此DEBUG版本的性能低于RELEASE版本。
(2)RELEASE版本就是最终的发布版本,相较于DEBUG版本的功能代码是一样的,但是去掉了所有的调试信息。适合最终测试通过要发布的程序,因为去掉了调试信息所以程序的运行效率要更高。
(3)DEBUG和RELASE版本其实是一套源代码。源代码中是有很多的打印调试信息的语句的,如何来控制生成DEBUG和RELEEASE版本?靠条件编译,靠一个宏。

debug宏的实现原理
(1)DEBUG宏大概的原理是:
#ifdef DEBUG
#define dbg()        printf()
#else
#define dbg()    
#endif
(2)工作方式是:如果我们要输出DEBUG版本则在条件编译语句前加上#define DEBUG即可,这样程序中的调试语句dbg()就会被替换成printf从而输出;如果我们要输出RELEASE版本则去掉#define DEBUG,则dbg()就会被替换成空,则程序中所有的dbg()语句直接蒸发了,这样的程序编译时就会生成没有任何调试信息的代码。

debug宏的使用方法

分析几个DEBUG宏
(1)应用程序中DEBUG宏
#ifdef DEBUG
#define DBG(...) fprintf(stderr, " DBG(%s, %s(), %d): ", __FILE__, __FUNCTION__, __LINE__); fprintf(stderr, __VA_ARGS__)
#else
#define DBG(...)
#endif

注:__FILE__等是C语言中的预定义宏,就是说这个东西是个宏定义,但是是C语言自己定义的。这些宏具有特殊的含义,譬如__FILE__表示当前正在编译的c文件的文件名。

(2)内核中的DEBUG宏
#ifdef DEBUG_S3C_MEM
#define DEBUG(fmt, args...)     printk(fmt, ##args)
#else
#define DEBUG(fmt, args...)     do {} while (0)
#endif

12

状态机

什么是状态机
有限状态机
(1)常说的状态机是有限状态机FSM。FSM指的是有有限个状态(一般是一个状态变量的值),这个机器同时能够从外部接收信号和信息输入,机器在接收到外部输入的信号后会综合考虑当前自己的状态和用户输入的信息,然后机器做出动作:跳转到另一个状态。
(2)考虑状态机的关键点:当前状态、外部输入、下一个状态
两种状态机:Moore型和Mealy型
(1)Moore型状态机特点是:输出只与当前状态有关(与输入信号无关)。相对简单,考虑状态机的下一个状态时只需要考虑它的当前状态就行了。
(2)Mealy型状态机的特点是:输出不只和当前状态有关,还与输入信号有关。状态机接收到一个输入信号需要跳转到下一个状态时,状态机综合考虑2个条件(当前状态、输入值)后才决定跳转到哪个状态。
状态机的主要用途:电路设计、FPGA程序设计、软件设计
(1)电路设计中广泛使用了状态机思想
(2)FPGA程序设计
(3)软件设计(框架类型的设计,譬如操作系统的GUI系统、消息机制)

状态机解决了什么问题
(1)我们平时写程序都是顺序执行的,这种程序有个特点:程序的大体执行流程是既定的,程序的执行是遵照一定的大的方向有迹可寻的。
(2)但是偶尔会碰到这样的程序:外部不一定会按照既定流程来给程序输入信息,而程序还需要完全能够接收并响应外部的这些输入信号,还要能做出符合逻辑的输出。

C语言实现简单的状态机
4.9.16.1、题目:开锁状态机。功能描述:用户连续输入正确的密码则会开锁,如果密码输入过程错误则锁会退回到初始状态重新计入密码,即:用户只需要连续输入出正确的密码即可开锁(输入错误不用撤销、也不用删除)
4.9.16.2、题目分析

13

多线程

多线程简介
操作系统下的并行执行机制
(1)并行就是说多个任务同时被执行。并行分微观上的并行和宏观上的并行。
(2)宏观上的并行就是从长时间段(相对于人来说)来看,多个任务是同时进行的;微观上的并行就是真的在并行执行。
(3)操作系统要求实现宏观上的并行。宏观上的并行有2种情况:第一种是微观上的串行,第二种是微观上的并行。
(4)理论来说,单核CPU本身只有一个核心,同时只能执行一条指令,这种CPU只能实现宏观上的并行,微观上一定是串行的。微观上的并行要求多核心CPU。多核CPU中的多个核心可以同时微观上执行多个指令,因此可以达到微观上的并行,从而提升宏观上的并行度。

进程和线程的区别和联系
(1)进程和线程是操作系统的两种不同软件技术,目的是实现宏观上的并行(通俗一点就是让多个程序同时在一个机器上运行,达到宏观上看起来并行执行的效果)。
(2)进程和线程在实现并行效果的原理上不同。而且这个差异和操作系统有关。譬如windows中进程和线程差异比较大,在linux中进程和线程差异不大(linux中线程就是轻量级的进程)。
(3)不管是多进程还是多线程,最终目标都是实现并行执行。

多线程的优势
(1)前些年多进程多一些,近些年多线程开始用得多。
(2)现代操作系统设计时考虑到了多核心CPU的优化问题,保证了:多线程程序在运行的时候,操作系统会优先将多个线程放在多个核心中分别单独运行。所以说多核心CPU给多线程程序提供了完美的运行环境。所以在多核心CPU上使用多线程程序有极大的好处。

线程同步和锁
(1)多线程程序运行时要注意线程之间的同步。

详细情况敬请期待:《3.linux应用编程和网络编程》

14

链表

链表的引入
从数组的缺陷说起
(1)数组有2个缺陷,一个是数组中所有元素的类型必须一致;第二个是数组的元素个数必须事先制定并且一旦指定之后不能更改。
(2)如何解决数组的2个缺陷:数组的第一个缺陷靠结构体去解决。结构体允许其中的元素的类型不相同,因此解决了数组的第一个缺陷。所以说结构体是因为数组不能解决某些问题所以才发明的。
(3)如何解决数组的第二个缺陷?我们希望数组的大小能够实时扩展。譬如我刚开始定了一个元素个数是10,后来程序运行时觉得不够因此动态扩展为20.普通的数组显然不行,我们可以对数组进行封装以达到这种目的;我们还可以使用一个新的数据结构来解决,这个新的数据结构就是链表。
总结:几乎可以这样理解:链表就是一个元素个数可以实时变大/变小的数组。

大学为什么都有新校区?
(1)学校初建的时候(类似于变量定义并初始化时),这时候因为旁边都是荒地而没有建筑,因此学校的校园大小由自己定的;但是学校建立了之后旁边慢慢的也有了其他建筑(类似于这个变量分配了之后,内存的相邻区域又分配了其他变量与这个变量地址相连),这时候你的校园随着发展感觉不够用了想要扩展,却发现邻居已经住满了,校园的四周全部都是别人的建筑,这时候学校要扩展有2个办法:第一个是拆迁,第二个是搬迁,第三个是外部扩展。
(2)拆迁基本行不通,因为成本太高了。
(3)搬迁可以行的通。程序中解决数组大小扩展的一个思路就是整体搬迁。具体步骤是:先在另外的空白内存处建立一个大的数组,然后把原来的数组中的元素的值整个复制到新数组的头部,然后再释放掉原来数组的内存空间,并且把新的数组去替代原来的数组。这种可变数组在C语言中不支持,但是在更高级语言如C++、Java等里面是支持的。
(4)外部扩展的思路是最常见的,基本可以说是最合理的。它的一个思路就是化整为零,在原来的不动的前提下去外部扩展新的分基地。外部扩展在学校的例子中就是新校区;外部扩展在编程解决数组问题的点上就是链表。

链表是什么样的?
(1)顾名思义,链表就是用锁链连接起来的表。这里的表指的是一个一个的节点(一个节点就是一个校区),节点中有一些内存可以用来存储数据(所以叫表,表就是数据表);这里的锁链指的是链接各个表的方法,C语言中用来连接2个表(其实就是2块内存)的方法就是指针。
(2)链表是由若干个节点组成的(链表的各个节点结构是完全类似的),节点是由有效数据和指针组成的。有效数据区域用来存储信息完成任务的,指针区域用于指向链表的下一个节点从而构成链表。

时刻别忘了链表是用来干嘛的
(1)时刻谨记:链表就是用来解决数组的大小不能动态扩展的问题,所以链表其实就是当数组用的。直白点:链表能完成的任务用数组也能完成,数组能完成的任务用链表也能完成。但是灵活性不一样。
(2)简单说:链表就是用来存储数据的。链表用来存数据相对于数组来说优点就是灵活性,需要多少个动态分配多少个,不占用额外的内存。数组的优势是使用简单(简单粗暴)。

单链表的实现
单链表的节点构成
(1)链表是由节点组成的,节点中包含:有效数据和指针。
(2)定义的struct node只是一个结构体,本身并没有变量生成,也不占用内存。结构体定义相当于为链表节点定义了一个模板,但是还没有一个节点,将来在实际创建链表时需要一个节点时用这个模板来复制一个即可。

堆内存的申请和使用
(1)链表的内存要求比较灵活,不能用栈,也不能用data数据段。只能用堆内存。
(2)使用堆内存来创建一个链表节点的步骤:1、申请堆内存,大小为一个节点的大小(检查申请结果是否正确);2、清理申请到的堆内存;3、把申请到的堆内存当作一个新节点;4、填充你哦个新节点的有效数据和指针区域。

链表的头指针
(1)头指针并不是节点,而是一个普通指针,只占4字节。头指针的类型是struct node *类型的,所以它才能指向链表的节点。
(2)一个典型的链表的实现就是:头指针指向链表的第1个节点,然后第1个节点中的指针指向下一个节点,然后依次类推一直到最后一个节点。这样就构成了一个链。

实战:构建一个简单的单链表
(1)目标:构建一个链表,然后将一些数据(譬如1,2,3三个数字)存储在链表中

单链表的算法之插入节点
继续上节,访问链表中各个节点的数据
(1)只能用头指针,不能用各个节点自己的指针。因为在实际当中我们保存链表的时候是不会保存各个节点的指针的,只能通过头指针来访问链表节点。
(2)前一个节点内部的pNext指针能帮助我们找到下一个节点。
将创建节点的代码封装成一个函数
(1)封装时的关键点就是函数的接口(函数参数和返回值)的设计
从链表头部插入新节点
从链表尾部插入新节点
(1)尾部插入简单点,因为前面已经建立好的链表不用动。直接动最后一个就可以了。

单链表的算法之插入节点续
详解链表头部插入函数
什么是头节点
(1)问题:因为我们在insert_tail中直接默认了头指针指向的有一个节点,因此如果程序中直接定义了头指针后就直接insert_tail就会报段错误。我们不得不在定义头指针之后先create_node创建一个新节点给头指针初始化,否则不能避免这个错误;但是这样解决让程序看起来逻辑有点不太顺,因为看起来第一个节点和后面的节点的创建、添加方式有点不同。
(2)链表还有另外一种用法,就是把头指针指向的第一个节点作为头节点使用。头节点的特点是:第一,它紧跟在头指针后面。第二,头节点的数据部分是空的(有时候不是空的,而是存储整个链表的节点数),指针部分指向下一个节点,也就是第一个节点。
(3)这样看来,头节点确实和其他节点不同。我们在创建一个链表时添加节点的方法也不同。头节点在创建头指针时一并创建并且和头指针关联起来;后面的真正的存储数据的节点用节点添加的函数来完成,譬如insert_tail.
(4)链表有没有头节点是不同的。体现在链表的插入节点、删除节点、遍历节点、解析链表的各个算法函数都不同。所以如果一个链表设计的时候就有头节点那么后面的所有算法都应该这样来处理;如果设计时就没有头节点,那么后面的所有算法都应该按照没有头节点来做。实际编程中两种链表都有人用,所以大家在看别人写的代码时一定要注意看它有没有头节点。


从链表头部插入新节点
(1)注意写代码过程中的箭头符号,和说话过程中的指针指向。这是两码事,容易搞混。箭头符号实际上是用指针方式来访问结构体,所以箭头符号的实质是访问结构体中的成员。更清楚一点说程序中的箭头和链表的连接没有任何关系;链表中的节点通过指针指向来连接,编程中表现为一个赋值语句(用=来进行连接),实质是把后一个节点的首地址,赋值给前一个节点中的pNext元素做为值。
(2)链表可以从头部插入,也可以从尾部插入。也可以两头插入。头部插入和尾部插入对链表来说几乎没有差别。对链表本身无差别,但是有时候对业务逻辑有差别。


单链表的算法之遍历节点
什么是遍历
(1)遍历就是把单链表中的各个节点挨个拿出来,就叫遍历。
(2)遍历的要点:一是不能遗漏、二是不能重复、追求效率。
如何遍历单链表
(1)分析一个数据结构如何遍历,关键是分析这个数据结构本身的特点。然后根据本身特点来制定它的遍历算法。
(2)单链表的特点就是由很多个节点组成,头指针+头节点为整个链表的起始,最后一个节点的特征是它内部的pNext指针值为NULL。从起始到结尾中间由各个节点内部的pNext指针来挂接。由起始到结尾的路径有且只有一条。单链表的这些特点就决定了它的遍历算法。
(3)遍历方法:从头指针+头节点开始,顺着链表挂接指针依次访问链表的各个节点,取出这个节点的数据,然后再往下一个节点,直到最后一个节点,结束返回。

编程实战
(1)写一个链表遍历的函数,void bianli(struct node*pH);


单链表的算法之删除节点
为什么要删除节点
(1)一直在强调,链表到底用来干嘛的?
(2)有时候链表节点中的数据不想要了,因此要删掉这个节点。
删除节点的2个步骤
(1)第一步:找到要删除的节点;第二步:删除这个节点。
如何找到待删除的节点
(1)通过遍历来查找节点。从头指针+头节点开始,顺着链表依次将各个节点拿出来,按照一定的方法比对,找到我们要删除的那个节点。
如何删除一个节点
(1)待删除的节点不是尾节点的情况:首先把待删除的节点的前一个节点的pNext指针指向待删除的节点的后一个节点的首地址(这样就把这个节点从链表中摘出来了),然后再将这个摘出来的节点free掉接口。
(2)待删除的节点是尾节点的情况:首先把待删除的尾节点的前一个节点的pNext指针指向null(这时候就相当于原来尾节点前面的一个节点变成了新的尾节点),然后将摘出来的节点free掉。

注意堆内存的释放
(1)前面几节课我们写的代码最终都没有释放堆内存。当程序都结束了的情况下那些没有free的堆内存也被释放了。
(2)有时候我们的程序运行时间很久,这时候malloc的内存如果没有free会一直被占用直到你free释放它或者整个程序终止。

单链表的算法之逆序
什么是链表的逆序
(1)链表的逆序又叫反向,意思就是把链表中所有的有效节点在链表中的顺序给反过来。

单链表逆序算法分析
(1)当我们对一个数据结构进行一个操作时,我们就需要一套算法。这就是数据结构和算法的关系。
(2)我总结:算法有2个层次。第一个层次是数学和逻辑上的算法;第二次个层次是用编程语言来实现算法。
(3)从逻辑上来讲,链表的逆序有很多种方法。这些方法都能实现最终的需要,但是效率是不一样的。彼此的可扩展性、容错性等不同。
(4)思路:首先遍历原链表,然后将原链表中的头指针和头节点作为新链表的头指针和头节点,原链表中的有效节点挨个依次取出来,采用头插入的方法插入新链表中即可。
(5)链表逆序 = 遍历 + 头插入

编程实战


双链表的引入和基本实现
单链表的局限性
(1)单链表是对数组的一个扩展,解决了数组的大小比较死板不容易扩展的问题。使用堆内存来存储数据,将数据分散到各个节点之间,其各个节点在内存中可以不相连,节点之间通过指针进行单向链接。链表中的各个节点内存不相连,有利于利用碎片化的内存。
(2)单链表各个节点之间只由一个指针单向链接,这样实现有一些局限性。局限性主要体现在单链表只能经由指针单向移动(一旦指针移动过某个节点就无法再回来,如果要再次操作这个节点除非从头指针开始再次遍历一次),因此单链表的某些操作就比较麻烦(算法比较有局限)。回忆之前单链表的所有操作(插入、删除节点、 遍历、从单链表中取某个节点的数·····),因为单链表的单向移动性导致了不少麻烦。
总结:单链表的单向移动性导致我们在操作单链表时,当前节点只能向后移动不能向前移动,因此不自由,不利于解决更复杂的算法。

解决思路:有效数据+2个指针的节点(双链表)
(1)单链表的节点 = 有效数据 + 指针(指针指向后一个节点)
(2)双向链表的节点 = 有效数据 + 2个指针(一个指向后一个节点,另一个指向前一个节点)

双链表的封装和编程实现


双链表的算法之插入节点
尾部插入
头部插入


双链表的算法之遍历节点
(1)双链表是单链表的一个父集。双链表中如何完全无视pPrev指针,则双链表就变成了单链表。这就决定了双链表的正向遍历(后向遍历)和单链表是完全相同的。
(2)双链表中因为多了pPrev指针,因此双链表还可以前向遍历(从链表的尾节点向前面依次遍历直到头节点)。但是前向遍历的意义并不大,主要是因为很少有当前当了尾节点需要前向遍历的情况。
(3)总结:双链表是对单链表的一种有成本的扩展,但是这个扩展在有些时候意义不大,在另一些时候意义就比较大。因此在实践用途中要根据业务要求选择适合的链表。


双链表的算法之删除节点

你可能感兴趣的:(c语言,开发语言)