首页
社区
课程
招聘
[原创]《C陷阱与缺陷》个人学习笔记(持续更新)
2021-4-18 10:43 3912

[原创]《C陷阱与缺陷》个人学习笔记(持续更新)

2021-4-18 10:43
3912

入门逆向,学习一下《C陷阱与缺陷》,感觉受益匪浅收获颇丰,写出来与大家共同进步,不足之处望各位老师傅指正

 

个人感觉先看《C与指针》关于指针的两章再来看《C陷阱和缺陷》更好,《C与指针》笔记指路:C与指针笔记

《C陷阱和缺陷》第一章-词法“陷阱”

1.1=不同于==

1.C语言使用=作为赋值运算符,使用==作为比较运算符。这种使用上的便利性可能导致一个潜在的问题:比较运算符与赋值运算符使用错误。

1
2
3
4
5
6
7
8
9
//1
//这个程序本意为判断x与y是否相等,但是符号用错后,含义变成了将y的值赋给x,同时检查该值是否为0
if(x=y)
    break;
 
//2
//这个程序中,本意是跳过文件中的空格符,制表符和换行符。由于符号错误,且赋值运算符=的优先级低于逻辑运算符||,所以是将表达式' '||c=='\t'||c=='\n'的值赋给c,而' '的ASCII码不为0,所以这个表达式的值恒为1(或中只要有一个不为0,整个都为1),所以该循环会一直执行到文件结束。
while(c=' '||c=='\t'||c=='\n')
    c=getc(f);

2.某些编译器会在发现如e1=e2这种表达式出现在循环的条件判断部分时给出警告,那么在确实需要对变量进行赋值并检查其新值是否为0是,应采用下列方法:

1
2
3
4
5
6
7
//原来:
if(x=y)
    foo();
 
//改进:
if((x=y)!=0)
    foo();

1.2&和|不同于&&和||

C语言中&和|分别表示按位与和按位或,是按位运算符。而&&和||分别表示与和或,是逻辑运算符。

1.3词法分析中的“贪心法”

1.C语言中存在单字符与多字符,比如'/'和'*'分别有含义,他们在一起组成'/*'时又有与之前不同的含义。那么如何判断它的含义呢,C语言的解决办法是贪心法,即每一个符号应包含尽可能多的字符。具体实现为:从左到右一个字符一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符,判断已经读入的两个字符组成的字符串是否可能是一个符号的组成部分;如果可能再读入下一个字符,重复上述判断,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。

 

2.注意:除了字符串和字符常量,符号中间不能嵌有空白(空格符,制表符和换行符),如果/为判断下一个字符而读入的第一个字符,而之后紧接着*,那么无论上下文如何,这两个字符会被当做注释使用

1
2
3
4
5
6
7
8
//这个表达式本意为x除以p指向的值,再将其赋给y,但是连一起写就成了注释
y=x/*p;
 
*/
//改进
y=x/ *p;
//更好的方法
y=x/(*p);

3.例如在老版本的C语言中,允许使用=+和=-来代替现在的+=和-=,那么就会出现下列情况

1
2
3
4
5
6
//本意是将-1赋值给a
a=-1;
//结果理解为把a-1的值赋给a
a = a-1;
//应该写成
a = -1;

1.4整型常量

如果一个整型常量以0开头,那么该常量将被视为八进制,许多C编译器会把8和9也当做八进制来处理,注意有时在上下文中为了对其结构而在整型前面加个0会导致其被当做八进制。

1.5字符与字符串

1.单引号引起的一个字符实际上代表一个整数,整数值对应于该字符在编译器采用的字符集中的序列值。因此对应采用ASCII字符集的编译器而言,'a'的含义与0141(八进制)或者97(十进制)严格一致。

 

2.用双引号引起的字符,代表的是一个指向无名数组起始字符的指针,(详见《C与指针》第十三章笔记)该数组被双引号之间的字符以及一个额外的二进制为零的字符'\0'初始化。

1
2
3
4
printf("Hello World\n");
//等效于
char a[]={'H','e','l','l','o',' ','W','o','r','l','d','\n',0};
printf(a);

3."yes"的含义是依次包含'y','e','s'以及空字符'\0'的四个连续内存单元的首地址。'yes'的含义没有准确定义,但大多数编译器将其理解为一个整数值,由'y','e','s'所代表的整数值按照特定的编译器实现中定义的方式组合得到。

《C陷阱和缺陷》第二章-语法“陷阱”

2.1理解函数声明

1.构造表达式只有一个简单的规则:按照使用的方式来声明。

 

2.任何C变量的声明都由两部分组成:类型以及类似表达式的声明符。例如:int a;

 

3.一旦我们知道了如何声明一个给定类型的变量,那么该类型的类型转换符就很好得到了:只需要将声明中的变量名和声明末尾的分号去掉,再将剩余的部分用一个括号整个封装即可。 例如 int a; 那么强制类型转化为int就是(int),再看一个复杂点的例子float (*h) ( );表示h是一个指向返回值为浮点类型的函数的指针,所以该类型的强制转换符为:(float (*)( ))。

 

4.整个好玩的,来分析表达式(*(void(*)( ))0)( )。这真是一个让人头疼的表达式,不过我们一步一步来分析,首先,假定变量fp为一个指针函数,那么调用fp所指向的函数的方式为:(*fp)( );ANSI C标准使其能够简写为fp( );,此处在C与指针中有详细说明。第二步,我们想找到一个恰当的表达式来替换fp,于是我们想到了使用(*0)( );,但是这样写有一个问题,那就是*后必须接一个指针来做操作数,所以我们要对0进行强制类型转换,这样就引出了我们的第三步,声明一个返回值为void的类型的函数指针声明如下:void (*fp)( );,那么根据之前的知识,强制转换类型符应该是:(void (*)( )),将其放在0之前,得到(*(void(*)( ))0)( )。

2.2运算符的优先级问题

1.假设要判断两个变量之间二进制表示是否相同,可以使用

1
2
3
4
if(flags & FLAG)
//这种方法不够通俗易懂,改成下面这样是不是就通俗易懂了?
if(flags & FLAG != 0)
//这样看似通俗易懂,但是问题是,!=的优先级高于&,这导致我们不会得到想要的结果,所以应该给前面加个括号

2.又假设hi和low是两个整数,他们的值介于0到15之间,如果r是一个8位整数,且r的低四位和low各位上的数一样,而r的高四位与hi各位上的数一致,那么我们很可能会这么写:r=hi<<4 + low;但是由于加法运算的优先级高于移位运算,所以这样是错的,我们可以改良为:r=(hi<<4)+low;或者r=hi<<4|low;

 

 

3.优先级最高的其实并不是真正意义上的运算符。数组下标,函数调用操作符各结构成员选择操作符。他们都是自左向右结合, 所以a.b.c的含义是(a.b).c而非a.(b.c)。

 

4.单目运算符的优先级仅次于前述运算符。 在所有的真正意义上的运算符中,它们是优先级最高的。因为函数调用的优先级要高于单目运算符的优先级,所以如何p是一个函数指针,要调用p说指向的函数,必须写成:(*p)( ),类型转换也是单目运算符,它的优先级和其他单目运算符的优先级相同。单目运算符是自右向左结合,因此*p++会被编译器解释为*(p++)。

 

5.优先级比单目运算符低的,接下来就是双目运算符。在双目运算符中,算数运算符的优先级最高,移位运算符次之,关系运算符再次之,接着就是逻辑运算符,赋值运算符,条件运算符。

 

6.注意:!=和==的优先级低于其他的关系运算符。所有的按位运算符的优先级都高于顺序运算符。

2.3注意作为语句结束标志的分号

1.在if和while语句之后需要紧跟一条语句时,如果此时多了一个分号,那么原来紧跟在if和while子句之后的语句就是一条单独的语句,而与条件判断部分没有任何关系。

 

2.还有一个地方就是当一个声明的结尾紧跟着一个函数定义时,如果声明结尾的分号被省略,编译器可能会将声明的类型看作函数返回值的类型。

2.4switch语句

不要遗漏每个case后的break语句,否则它将会一直执行下去直到遇见break语句。如果是有意省略break语句,请写注释。

2.5函数调用

C语言要求:在函数调用时即时函数不带参数,也应包含参数列表,因此如果f是一个函数,f();是一个函数调用语句,而f;是一个什么也不做的语句,更精确的说,这个语句计算函数f的地址,却不调用该函数。

2.6“悬挂”else引发的问题

1
2
3
4
5
6
7
if(x==0)
    if(y==0)error();
else
{
    z=x+y;
    f(&z);
}

这段代码实际执行的意义与初始意义相去甚远,原因是C语言规定else始终与同一对括号内最近的未匹配的if结合。

1
2
3
4
5
6
7
8
9
if(x==0)
{
    if(y==0)error();
}
else
{
    z=x+y;
    f(&z);
}

这样即使else离第二个if更近,但是它被封装起来了,所以它会和第一个if结合,而非第二个。

《C陷阱和缺陷》第三章-“语义“陷阱”

3.1指针与数组

1.C语言中只有一维数组,而且数组的大小必须在编译时就作为一个常数确定下来。然而,C语言中数组的元素可以是任何类型的对象,当然也可以是另外一个数组。这样,要“仿真”一个多维数组就不是一件难事。

 

2.对于一个数组,我们能做的只有两件事:一是确定数组的大小,二是获取指向数组第一个元素(下标为0)的指针。其他有关数组的操作,本质上都是通过指针进行的指针运算。

 

3.例:int calendar[12][13];这个语句声明了calendar是一个数组,该数组拥有12个数组类型的元素,其中每一个元素都是一个拥有31个整型元素的数组(注意:不是一个拥有31个数组类型的元素的数组,其中每个元素又是一个拥有12个整型元素的数组),所以sizeof(calendar)的值是372(37x12)与sizeof(int)的乘积。

 

4.如果calendar不是用于sizeof的操作数,而是用于其他场合,那么calendar总是被转换成一个指向calendar数组的起始元素的指针。

 

5.如果一个指针指向的是数组中的一个元素,那么我们只要给这个指针加1,就能得到指向该数组中下一个元素的指针。同样的,如果我们给这个指针减1,那么我们就能得到该数组前一个元素的指针。

 

6.注意:给一个指针加上一个整数,与给该指针的二进制表示加上同样的整数两者的含义截然不同。 如果ip指向一个整数,那么ip+1指向的就是计算机内存的下一个整数,在大多数现代计算机中,他都不同于ip所指向地址的下一个内存位置。

 

7.如果两个指针指向的是同一个数组中的元素,我们可以将这两个指针相减,这样可以得到两者中间相隔几个数组元素。值得注意的是,如果p和q指向的不是同一个数组中的元素,即使他们所指向的地址在内存中正好间隔一个数组元素的整倍数,也无法保证其所得结果的正确性。

 

8.例如:a是一个数组,p是一个指针,那么p=a;就是将数组a中的第一个元素的地址赋给p,注意:这里不能写成p=&a;因为&a是一个指向数组的指针,而p是一个指向整型变量的指针,它们的类型不匹配。

 

 

9.*a=84;这个语句将数组a中下标为0的元素的值设置为84,同样道理,*(a+1);是数组a下标为1的元素的引用,总的来说,*(a+i)即为数组a中下标为i的元素的引用,这种方法也被简写为a[i]。实际上,由于a+i与i+a的含义相同,因此a[i]与i[a]也具有相同的含义 (非常不推荐第二种写法)。

 

10.例如下例:

1
2
3
int calendar[12][13];
int *p;
int i;

此例中,calendar[4]的含义是什么?calendar[4]是calendar数组的第五个元素,是calendar数组中12个有着31个整型元素的数组之一。因此,calendar[4]的行为也就表现为一个拥有31个整型元素的数组的行为。例如sizeof(calendar[4])的结果是31与sizeof(int)的乘积。

 

11.通过下标来指定数组中的元素,我们一般用如下方式

1
2
3
4
5
6
7
//基本操作
i=calendar[4][7];
//稍微进阶
i=*(calendar[4]+7);
//完全进阶
i=*(*(calendar+4)+7)
//虽然后面的看似很牛逼,但是在理解上远不如第一种简单,所以理解意思即可,平时没有必要就少写。

12.p=calendar;这个语句是非法的,因为calendar是一个二维数组,上下文使用时会将其转换为一个指向数组的指针;而p是一个指向整型变量的指针,两者类型不同,所以是非法的,但是我们可以声明一个指向数组的指针。例如:int (*ap)[31];这个语句的实际效果是,声明了*ap是一个拥有31个整型元素的数组,因此ap就是指向这类数组的指针。所以

1
2
3
int calendar[12][31];
int (*monthp)[31];
monthp=calendar;

这样,monthp将指向数组的calendar的第一个元素,也就是数组calendar的12个有着31个元素的数组类型元素之一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//数组清空常规写法
int month;
for(month=0;month<12;month++)
{
    int day;
    for(day=0;day<31;day++)
    {
        calendar[month][day]=0;
    }
}
 
//用指针的写法
int (*monthp)[31];
for(monthp=calendar;monthp<&calendar[12];monthp++)
{
    int *dayp;
    for(dayp=*monthp;dayp<&(*monthp)[31];dayp++)
    {
        *dayp=0;
    }
}

3.2非数组的指针

1.C语言中,字符串常量代表了一块包括字符串中所有字符以及一个空字符('\0')的内存区域的地址。

 

2.假定我们有两个这样的字符串s和t,我们希望将这两个字符串连接成单个字符串r。要做到这一点,我们可以借助常用的库函数,strcpy和strcat。

1
2
3
char *r;
strcpy(r,s);
strcat(r,t);

这样不行的原因在于不能确定r指向何处,不仅要让r指向一个地址,而且r所指向的地址处应该还有内存空间可供容纳字符串,这个内存空间应是以某种方式被分配了的。

1
2
3
4
char *r,*malloc();
r=malloc(strlen(s)+strlen(t));
strcpy(r,s);
strcat(r,t);

3.上面这个例子还是错的,第一malloc函数有可能无法提供请求的内存,此时它就会返回一个NULL指针。第二给r分配的内存使用完成后应该及时释放,这一点务必要记住。第三是调用malloc函数并未分配足够的内存。库函数strlen返回参数中字符串所包含的字符数目,而作为结束标志的空字符并未计算在内。因此,如果strlen(r)的值是n,那么字符串实际需要n+1个字符空间,所以我们必须要为r多分配一个字符空间。

1
2
3
4
5
6
7
8
9
10
char *r,*malloc();
r=malloc(strlen(s)+strlen(t)+1);
if(!r)
{
    complain();
    exit(1);
}
strcpy(r,s);
strcat(r,t);
free(r);

3.3作为参数的数组声明

在C语言中,我们没有办法可以将一个数组作为函数参数直接传递。如果我们使用数组名作为参数,那么数组名会立刻被转换为指向该数组第一个元素的指针。 即:

1
2
3
4
5
6
7
8
9
int strlen(char s[])
{
 
}
//等价于
int strlen(char *s)
{
 
}

但是需要注意的是,这两种方法虽然等价,但是表达的意思有所区别,平时使用时需要选择最合适含义的方式书写。

3.4避免“举隅法”

举隅法的含义是:以含义更宽泛的词语来代替含义相对较窄的词语,或者相反。C语言中的常见陷阱是:混淆指针与指针所指向的对象。

1
2
char *p,*q;
p="xyz";

有时我们认为p的值就是字符串"xyz",然而实际情况并非如此,p的值是一个指向由'x','y','z','\0'4个字符组成的数组的起始元素的指针。所以如果我们执行q=p,得

 

 

记住:复制指针并不同时复制指针所指向的数据。因此当我们执行语句:q[1]='Y';时,现在内存中存储的字符串成了'xYz',所以p指向的内存中存储的也是如此。

3.5空指针并非空字符串

C语言中将一个整数转换为一个指针,最后得到的结果取决于具体的C编译器实现。特殊情况就是常数0,编译器保证由0转换而来的指针不等于任何有效的指针。出于代码文档化的考虑,常数0这个值经常使用NULL来代替。当然无论是直接使用常数0还是使用符号NULL,效果都是相同的。需要记住的重要一点是,当常数0被转换为指针使用时,这个指针绝对不能被解除引用。换句话说,当我们将0赋值给一个指针变量时,绝对不能企图使用该指针所指向的内存中存储的内容。

3.6边界计算与不对称边界

1.在C语言中,一个拥有n个元素的数组中,存在下标为0的元素,却不存在下标为n的元素,它的元素下标范围为从0到n-1。

 

2.原则一:首先考虑最简单情况下的特例,然后将得到的结果外推;原则二:仔细计算边界,绝不掉以轻心。

 

3.在使用循环访问数组时,建议使用以下方法:用第一个入界点和第一个出界点来表示一个数值范围。具体而言,前面的例子我们不应说整数x满足边界条件为x>=16&&x<=37,而是写成x>=16&&x<38。注意此处的下界为“入界点”,即包括在取值范围中;而上界是“出界点”,即不包括在取值范围之中。

 

4.上述方法的好处有:1.取值范围的大小就是上界与下界之差。2.如果取值范围为空,那么上界等于下界。3.即时取值范围为空,上界也永远不可能小于下界。

3.7求值顺序

1.C语言中的某些运算符总是以一种已知的,规定的顺序来对其操作数进行求值,而另外一些则并非如此,例如:a < b && c < d。C语言的定义中说明a < b应当首先被求值,如果a确实小于b,此时必须进一步对c < d求值,但是如果a大于或等于b,则无需对c < d求值,表达式一定为假。

 

2.C语言中只有四个运算符(&&,||,?:和,)存在规定的求值顺序。运算符&&和运算符||首先对左侧操作数求值,只在需要时才对右侧操作数求值。运算符?:有三个操作数:在a?b:c中,操作数a首先被求值,根据a的值再求操作数b或者c的值。而逗号操作符,首先对左侧操作数求值,然后该值被“丢弃”,再对右侧操作数求值。

 

3.注意:分隔函数参数的逗号并非逗号运算符。例如,x和y在函数f(x,y)中的求值顺序是未定义的,而在函数g((x,y))中是确定的先x后y的顺序。在后一个例子中,函数g只有一个参数。这个参数的值是这样求得的,先对x求值,然后丢弃x的值,接着求y的值。

 

 

4.运算符&&和运算符||对于保证检查操作按照正确的顺序执行至关重要。

1
2
if(y!=0&&x/y>a)
    complain();

在上例中,必须保证仅当y非0时才能对x/y求值。

3.8运算符&&,||和!

1.按位运算符&,|和~,以及逻辑运算符&&,||和!。如果程序员用其中一类的某个运算符能替换掉另一类中对应的运算符,互换之后程序看起来还能正常工作,但是这实际上是巧合导致的。

 

2.按位运算符&,|和~对操作数的处理方式是将其视为一个二进制的位序列,分别对其每个位进行操作。逻辑运算符&&,||和!对操作数的处理方式是将其视作要么为真,要么为假。通常约定非零为真,零为假。这些运算符当结果为真时返回1,结果为假时返回0,它们只会返回0或1.而且运算符&&和运算符||在左侧操作数的值能够确定最终结果时根本不会对右侧操作数求值。

3.9整数溢出

C语言中存在两类整数算术运算,有符号运算与无符号运算。在无符号运算中,没有所谓的溢出:所有的无符号运算都是以2的n次方为模,这里的n是结果中的位数。如果算术运算符的一个操作数是有符号整数,另一个是无符号整数,那么有符号整数会被转换为无符号整数,溢出也不可能发生。但是当两个操作数都是有符号整数时,溢出就有可能发生,而且溢出的结果是未定义的。

3.10为函数main提供返回值

函数main与其他函数一样,如果并未显式声明返回类型,那么默认为整型。但是当程序中并未给出任何返回值时,一般不会造成什么危害。然而,在某些情形下函数main的返回值却并非无关紧要。大多数C语言实现都通过函数main的返回值来告知操作系统该函数的执行是成功还是失败。典型的处理方案是:返回值0代表程序执行成功,返回值非0代表程序执行失败。如果一个程序的函数main不返回任何值,那么有可能看上去执行失败。如果在使用一个软件管理系统,该系统关注程序被调用后执行是成功还是失败,那么很可能得到令人惊讶的结果。


[培训]《安卓高级研修班(网课)》月薪三万计划,掌握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法

最后于 2021-4-24 16:48 被PlumpBoy编辑 ,原因: 改进
收藏
点赞1
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回