C语言补充

编译期和运行期,未定义行为

C语言和C++中一些规范是针对编译期的,一些是针对运行期的。在学习的时候要关注哪些是编译期的规定,哪些是运行期的规定。
C语言和C++有大量的未定义行为,具体实现取决于编译器的设计。比如i=1;(++i)+(i++)的值就是未定义的。不同编译器结果不同。在一条语句中,对一个变量改变两次及以上的结果是未定义的。还有一个,int占多少字节也是标准中没有规定的,标准中只规定int至少是16位,int长度不能小于short。int的长度是编译器决定的。在32位、64位CPU中,编译器的int长度一般是32位。
不要把某一种编译器测试的结果就当成C和C++的标准。编译器是根据标准实现的。 # C/C++标准 C语言目前的标准有C89、C99、C11等。
C++目前的标准C++98、C++11、C++14、C++17、C++20等
每个标准最后两个数字代表发表的年号。
我们学的是C89和C++98
本章所用标准均为C99和C++14

C99新增特性

任意位置定义变量,不必必须在函数开始定义
inline关键字(与C++inline作用基本相同)
列表初始化结构体的时候指定属性
<stdint.h> <iso646.h>头文件
_Bool类型,增加<stdbool.h>头文件

C/C++编译过程

以下为编译过程中主要完成的工作。
无论是PC的程序,还是单片机的程序,在使用C/C++生成程序时经过编译,链接两个过程。
编译(compile):将C/C++源程序(后缀名为.c或.cpp等,还可以是汇编程序),生成二进制目标文件(后缀名为.o或.obj)。这一步由编译器完成。.o文件记录了文件中都定义了哪些函数,外部变量。
链接:将所有的.o文件合并成可执行文件。这一步由链接器完成。在这一步中,链接器在所有的.o文件中找到主函数,以及主函数所调用的函数。将它们放到一起,生成可执行文件。
注意:编译器和链接器是两个不同的程序。现在在IDE中一般把两步集成在一起。IAR中叫make,vs中叫生成xx项目,keil叫build。

理解编译和链接非常重要。对理解之后的定义声明,C和C++相互调用,报错时找到原因都有很大的作用。

分成编译、链接两步的好处
1. 在生成.o的过程中,如上,一个工程文件有多个.c .cpp源程序,可以让CPU多个核心同时编译a.c b.c c.pp文件,节省编译时间。(c++是编译最慢的语言) 2. 当其中一个源文件修改后,编译器只用重新编译该文件,不用编译其他文件。e.g.当a.c被修改后,编译器只用重新生成a.o文件,不用再生成c.o b.o文件,之后链接。 3. .o文件不仅可以由C、C++源程序生成,还可以由汇编、fortran等语言生成,方便不同语言之间的调用。

定义和声明

注意:在编译不同源文件的时候,是不会知道其他文件里有什么内容的。编译a.c的时候不知道b.c、c.cpp这些文件里定义了哪些函数。那么a.c里面的函数是怎么调用其他文件里面的函数呢?
这就需要在a.c文件中对外面的函数声明。
假设a.c文件里面有函数void fun_a(),调用了b.c文件里面的函数void fun_b(),则a.c文件中代码应该如下:

1
2
3
4
5
6
/*****a.c******/
void fun_b();//声明fun_b
void fun_a(){
// void fun_b();//这里也可以声明fun_b
fun_b();//调用fun_b
}

声明可以多次,(非内联)函数和变量定义只能有一次。这里void fun_b();就是对fun_b函数的声明,void fun_a(){}是对fun_a的定义,fun_b();是在fun_a中调用fun_b。
声明的作用是,告诉编译器,在其他地方有这样一个函数叫fun_b,它返回void,形参为void。在调用fun_b中,编译器在生成.o的时候就标记了这里调用了这样一个函数,名字是fun_b,返回void,形参为void。链接的时候链接器就在各个.o文件中找有没有对应的函数,之后生成正确的跳转地址的机器码来保证正确调用这个函数。如果我们在b.c中忘记定义fun_b,或者fun_b没有写成下面的样子,则会在链接的时候报错,注意,编译没错,错误在链接时出现。

1
2
3
4
/*****b.c******/
void fun_b(){
//do something
}

总之,定义就是告诉编译器这个东西是什么,怎么实现的,声明是告诉编译器有这么个东西,编译的时候不要感到奇怪,它在哪是链接器的事情。
多个文件使用同一个全局变量也是如此,关于变量的定义和声明以及C和C++源程序之间相互调用见后。

宏是一种预处理机制。在编译的第一步就是对整个源程序的宏进行处理。宏可以看作是一种功能不完整的生成代码的编程语言,由于编译是按照文件编译,宏的作用范围也是当前文件。
在学习本章时要时刻注意宏只是一种在文字上预处理,对源代码完成替换、粘贴、删除等工作。

#include

#include宏在预处理的时候是把#include语句用对应的文件内容替换。以Helloworld程序为例:

1
2
3
4
#include <stdio.h>
int main(){
printf("Hello,world!\n");
}
在编译的第一步,就是把stdio.h这个文件的全部内容复制到第一行。<stdio.h>文件太长了就不展示预处理后是什么样子了,请自己脑补。所以,如果你将来进的公司是按代码行数发工资,你就可以把整个<stdio.h>文件复制到你的代码中,效果和#include<stdio.h>一样。
#inlcude的作用,是可以把一些共用的代码(比如函数、变量的声明)放到一个头文件中,减少重复的输入。
例:
第一节的例子中我们在a.c中调用了b.c定义的函数fun_b,需要在a.c中声明void fun_b();如果我们需要调用b.c中定义的其他函数fun_b2、fun_b3,就要都声明一遍。如果除了a.c,d.c等其他文件都需要调用b.c的函数,每个文件都声明一次就很麻烦。我们可以新建一个b,h把声明的部分放到b.h(文件取名任意)中。b.h如下
1
2
3
4
/*****b.h******/
void fun_b();
void fun_b2();
void fun_b3();
注:
1. 理论上#include可以在任何位置,只要保证#include展开后代码没有错误即可。但一般习惯将#include放在文件头部 2. #include后面的文件名扩展名可以任意。#include "sb.c",#include "sb.txt"均可,无扩展名也可,C++的标准库头文件均无扩展名,如#include

#define

  1. 定义常量 例:#define PI 3.14
    在编译的前当前文件所有的PI就会被替换为3.14
  2. 定义宏函数 例 #define MAX(a,b) ((a)>(b)?(a):(b))
    若源程序中有 now_value = MAX(now_value, last_value); 宏处理后为 now_value = ((now_value)>( last_value)?( now_value):( last_value));
  3. 实现条件编译
    见下一节#if

#undef 可以取消之前的#define,如:

1
2
3
#define PI 3.14
//some code
undef PI
//之后PI 就认为没有定义,当然也可以重新定义
1
#define PI 3
## #if

作用,实现条件编译 #if 可以配合defined #elif #else || && !等宏和运算符使用 例

1
2
3
4
5
6
7
8
9
10
#define USE_1//定义USE_1
#if defined(USE_1)
//定义了USE_1,编译这一段
#elif defined(USE_2)
//定义了USE_2,编译这一段
#elif defined(PI) && (PI==3)
//如果之前有#define PI 3则编译这一段
#else
//上面三个条件都不满足编译这一段
#endif
除#if外,还有#ifdef(如果定义了xx)#ifndef(如果没定义 xx) #if #ifndef #ifdef需要和#endif配合
条件编译最常见的作用,防止头文件重复包含
我们知道#include的作用是把include的文件内容拷贝到当前位置,如果多个头文件同时包含了一个头文件,那么这个头文件会重复展开到当前源文件中。或者如果a.h中#include "b.h",b.h中有#include“a.h”,两个头文件相互展开,宏预处理的时候就会陷入死循环。为避免以上情况,一般采用条件编译作头文件保护。
如MSVC的<stdio.h>

1
2
3
4
#ifndef _INC_STDIO 
#define _INC_STDIO
...
#endif

如果同一个源文件第一次展开stdio.h,_INC_STDIO就会被定义,第二次展开时,由于_INC_STDIO已经被定义,就不会再把#ifndef _INC_STDIO后面的内容再次展开。

C语言内存模型

C语言数据的内存分为常量区,全局区(.bss和.data),堆区(heap),栈区(stack)

  1. 常量区
    常量区用于储存代码和常量等,如一个局部变量int a = 0x12345678;这个0x12345678在a生成之前就存在常量区。 常量一般保存在ROM中。
    注意a不是常量,a存放在栈中
  2. 全局区 在全局区的变量会在程序中一直占用内存。.bss段放程序中未初始化的或者初始化为0的变量;.data存放程序中已初始化的全局变量
  3. 堆区 在C语言中,用malloc分配的内存在堆上,堆区的内存可以用free在不需要的释放,不会一直占用内存。在单片机程序中,一般很少使用堆区。
  4. 栈区 栈是一个后进先出结构,在程序执行过程中是必须的。在函数调用过程中需要让栈增加,来保存函数的局部变量,形参。函数返回时栈减小,实现内存的回收。

变量生存期和作用域

生存期是针对程序运行时,作用域是针对编译时

作用域(对单一文件)

  1. 在"{}"以内的变量(局部变量)只能被"{}"以内的语句调用到,不能被"{}"以外的语句调用到
  2. 在所有"{}"以外的变量(全局变量)可以被所有语句调用到
  3. 两个不同作用域的变量名相同且都可以用到时,使用作用范围小的变量。

全局变量声明与定义

之前说过函数的声明,同样,全局变量的声明和定义与函数十分相似。在另一个文件中调用另一个文件的全局变量也需要声明,声明可以有多次,定义只能有一次。 声明比函数声明多extern关键字(函数声明默认有extern),而且只有全局变量才能被另一个文件调用。

1
2
3
4
5
6
7
/*****a.c******/
extern int var_b; //声明外部变量var_b
void fun_a(){
var_b = 1;
}
/*****b.c******/
int var_b;//定义全局变量var_b
extern声明变量和同声明函数一样,只是告诉编译器这里有一个全局变量,但是在哪里定义的不知道,编译器只能告诉链接器这个文件需要一个这样的变量,这个变量由链接器去到其他文件找。
static修饰的全局变量只能在当前文件起作用,static修饰的函数只能在当前文件起作用,编译的时候编译器不会把它们暴露出来,链接器看不见。不用static修饰的全局变量和函数在编译时编译器告诉链接器这个文件有哪些变量和函数是可以被其他文件调用的,链接器就知道这些变量和函数可能被用到。 ## 变量的生存期 生存期是针对程序运行时。
全局变量,也就是在所有"{}"以外定义的变量(无论有没有static修饰),注意是定义,声明是给编译器看的,全局变量和函数,不管声明了多少次,最后运行的时候只有定义的那一个。上例中var_b就是全局变量,在a.c中用extern声明的var_b和b.c中的var_b在链接时合成一个。
全局变量是存储在内存的全局区,从程序开始执行到结束,这个变量的位置一直在,正常情况下不会被其他变量占据。生存期为整个程序。 局部静态变量,在"{}"以内定义,并用static修饰的变量。虽然作用域只有当前"{}"内,但也是分配在全局区,生存期为整个程序。该变量只初始化一次。
局部变量,在"{}"以内定义,不用static修饰的变量。分配在栈区,在调用该函数时,栈增加,为该函数的所有局部变量提供内存空间,函数调用结束时,栈空间回收,局部变量消失。 下面一个例子说明不同的变量
1
2
3
4
5
6
7
8
9
/*****b.c******/
int var_b;//定义全局变量var_b
void fun_b(){
int var_b_temp = 2;//局部变量,当调用函数fun_b时其出生,fun_b返回时其死亡
static var_b_static = 3;
//静态局部变量,
//程序主函数开始前其被初始化为3,仅被初始化一次,
//仅在当前函数内可见,生存期为整个程序
}

static与extern在C语言中的作用

下面总结一些static与extern在C语言的作用,也是对上面的一个梳理 ### 对于全局变量和函数 使用static修饰的全局变量和函数,只对整个源文件可见,其他源文件不可见。例如,可以在a.c和b.c中都定义static void fun_ab(){}和static void var_ab;两个文件中同名的函数或变量在内存中是两个不同的东西,链接的时候不会将它们合在一起。
不使用static修饰的全局变量和函数,编译器会将它们暴露出来。如果在a.c中调用b.c定义的函数和变量。需要在a.c中用extern声明:

1
2
extern int var_b;
extern void fun_b();
函数声明时extern可省略。不要在变量声明时初始化。 ### 对于局部变量 局部变量的作用域为当前"{}"内
不用static修饰的局部变量分配在栈区,函数调用时出生,函数返回时死亡。不要返回一个这种变量的地址!
用static修饰的局部变量分配在全局。生存期为整个程序运行时,可以返回这种变量的地址。

C语言与C++函数相互调用

C++函数支持重载,编译器为了让链接器知道链接哪个函数,在生成函数名时把函数参数加入到函数名中。如:c.cpp中定义以下两个函数,

1
2
void fun_c(int var){} 
void fun_c(float var){}。
编译生成函数名时会生成类似 _fun_c_int,_fun_c_float这样的函数名。而C语言生成函数名时不包含参数信息。链接器在链接时不知道哪个.o是C语言源文件生成的,哪个是cpp生成的,互相调用时就出现调用的函数名和定义的函数名不一样的情况。
我们可以让编译器在cpp文件定义和调用函数时采用C语言的函数生成规则。方法是在定义或声明cpp函数前加 extern "C",例,在c.cpp中
1
extern "C" void fun_c1(){}
或者
1
extern "C" void fun_c1();
表示这个函数名的生成方式按照C语言的生成方式。如果向对一个文件中所有的函数用C语言的命名方式,可以
1
2
3
extern "C"{
//“{}”内放函数的声明或定义
}
大括号内的所有被声明和定义的函数都用C语言方式生成。 在C++代码中调用C语言程序也是用以上两种方法在调用之前的声明中加extern "C"
注: 1. extern "C"只改变函数名的生成方式,在extern "C"或extern "C"{}修饰的语句依然可以用C++的全部特性,除了函数重载。 2. extern "C"是C++特有的语句,在C语言源文件中无法通过编译。当一个头文件被C源程序和C++程序共同#include时,采用以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/********c.h**********/
#ifndef C_H
#define C_H//头文件保护

#ifdef __cplusplus
extern "C"{
#endif//#ifdef __cplusplus

//函数声明
void fun_c1();//不能重载

#ifdef __cplusplus
}//和extern "C"后面的"{"配对
#endif//#ifdef __cplusplus

#endif //#ifndef C_H

其中__cplusplus是一个宏,当源文件为C++时其被定义。当源文件是C++时,宏处理后保留extern "C",函数的命名按照C语言生成。源文件是C语言时,没有定义__cplusplus,宏处理后去掉extern "C",保证C语言正常编译。 # const 和 volatile const 在C语言中表示一个变量是只读的。可以提高代码的可读性。编译器有可能将const变量优化成常量。 volatile表示一个变量是易变的,被volatile修饰的关键字有可能被外部改变(如:IO口,DMA,其他线程),要求cpu每次使用该变量都要从内存中读取。
在cpu中,对于频繁使用的变量,编译器可能把它优化成寄存器变量。因为cpu都是用寄存器中的变量进行运算,如果需要运算内存的变量,需要读到寄存器中。C语言不能直接操作寄存器。
把变量放到寄存器中可以加快运行速度。但是如果这个变量的内存被外部改了,包括IO口的变化,串口寄存器接收到一个数据,全局变量被中断函数修改。cpu无法知道这个变量的内存已经被修改,还在使用存在寄存器的值。就会造成运行过程的错误。例如

1
2
3
4
5
6
#include <stdbool.h>
bool flag=true;
void f(){
while(flag){}
//do somthing
}
可能你的本意是flag在某个中断函数中变为false,这里等待flag为false。但是单独看这一个文件(别忘了编译是按文件进行的,编译器不知道其他文件有什么),flag一直是true编译器有可能把flag优化成一个寄存器变量或者常量。之后一直while(true){},//do something的部分永远不会执行到。解决的办法是定义flag的时候加上volatile。
1
volatile bool flag=true;
编译器读取flag的时候永远会在内存中取。
volatile适用于中断和主函数直间需要传值的全局变量。但是不可过度使用volatile,会阻止编译器优化,影响运行速度。

0%