Files
BlogPosts/Collection/YoudaoyunNotes/02C语言/14-预处理与文件组织.md

19 KiB
Raw Blame History

前导知识

GCC 编译一共分4个阶段预处理、编译、汇编、链接

gcc 【选项】要编译的文件【选项】【输出文件】

选项 说明
-E 控制GCC编译器仅对源码做预处理操作
-S 控制GCC编译器仅对指定文件处理之编译阶段
-c 控制GCC编译器仅对制定文件处理至汇编阶段并生成相应的目标文件
-o outfile 指定输出文件的文件名
  1. 预处理阶段

预处理阶段是编译的第一个阶段。在这个阶段GCC会扫描源代码并执行以下操作

  1. 删除注释

  2. 替换宏定义

  3. 处理条件编译指令

  4. 将头文件内容插入源代码中(展开头文件)

gcc -E hello.c -o hello.i
  1. 编译阶段

在预处理阶段之后GCC会将源代码翻译成汇编代码这个过程称为编译。编译器会检查源代码是否符合语法以及是否存在语义错误。

在编译阶段,编译器将源代码翻译成汇编代码,以便下一步的汇编阶段使用。

gcc -S hello.i -o hello.s
  1. 汇编阶段

汇编阶段是将汇编代码转换为机器代码的过程。在这个阶段,汇编器将汇编代码转换为机器指令,生成目标文件。

在汇编阶段,汇编器将汇编代码转换为机器指令,生成目标文件。

gcc -c hello.s -o hello.o
  1. 链接阶段

链接阶段是将所有目标文件合并成一个可执行文件的过程。在这个阶段,链接器将目标文件中未定义的符号与其他目标文件中定义的符号进行匹配,并生成一个可执行文件。

在链接阶段,链接器将目标文件合并成一个可执行文件。

gcc hello.o -o hello

预处理

在C语言程序源码中凡是以井号#开头的语句被称为预处理语句这些语句严格意义上并不属于C语言语法的范畴它们在编译的阶段统一由所谓预处理器cc1来处理。所谓预处理顾名思义指的是真正的C程序编译之前预先进行的一些处理步骤这些预处理指令包括

  1. 头文件:#include

  2. 定义宏:#define

  3. 取消宏:#undef

  4. 条件编译:#if、#ifdef、#ifndef、#else、#elif、#endif

  5. 显示错误:#error

  6. 修改当前文件名和行号:#line

  7. 向编译器传送特定指令:#progma

指令 描述 使用示例
#define 定义宏(符号常量或函数式宏) #define PI 3.14159
#include 包含头文件 #include <stdio.h>
#undef 取消已定义的宏 #undef PI
#ifdef 如果宏已定义则编译后续代码 #ifdef DEBUG
#ifndef 如果宏未定义则编译后续代码(常用于头文件保护) #ifndef HEADER_H
#if 条件编译可配合defined操作符使用 #if VERSION > 2
#else #if #ifdef WIN32
#elif 类似于else if #if defined(UNIX)
#endif 结束条件编译块 如上例所示
#error 产生编译错误并输出消息 #if !defined(C99)
#pragma 编译器特定指令(非标准,各编译器不同) #pragma once
  • 基本语法

  • 一个逻辑行只能出现一条预处理指令,多个物理行需要用反斜杠连接成一个逻辑行

  • 预处理是整个编译全过程的第一步:预处理 - 编译 - 汇编 - 链接

  • 可以通过如下编译选项来指定来限定编译器只进行预处理操作:

gcc example.c -o example.i -E

macro实际上就是一段特定的字串在源码中用以替换为指定的表达式。例如

#define PI 3.14

此处PI 就是宏宏一般习惯用大写字母表达以区分于变量和函数但这并不是语法规定只是一种习惯是一段特定的字串这个字串在源码中出现时将被替换为3.14。例如:

int main()
{
    printf("圆周率: %f\n", PI); 
    // 此语句将被替换为printf("圆周率: %f\n", 3.14);
}
  • 宏的作用:

  • 使得程序更具可读性:字串单词一般比纯数字更容易让人理解其含义。

  • 使得程序修改更易行:修改宏定义,即修改了所有该宏替换的表达式。

  • 提高程序的运行效率:程序的执行不再需要函数切换开销,而是就地展开。

无参宏

无参宏意味着使用宏的时候,无需指定任何参数,比如:

#define PI          3.14
#define SCREEN_SIZE 800*480*4 
int main()
{
    // 在代码中,可以随时使用以上无参宏,来替代其所代表的表达式:
    printf("圆周率: %f\n", PI); 
    mmap(NULL, SCREEN_SIZE, PROT_READ|PROT_WRITE, MAP_SHARED, ...);
}

注意到,上述代码中,除了有自定义的宏,还有系统预定义的宏:

// 自定义宏:
#define PI          3.14
#define SCREEN_SIZE 800*480*4 

// 系统预定义宏
#define NULL             ((void *)0)
#define PROT_READ	 0x1	 /* Page can be read.  */
#define PROT_WRITE	 0x2	 /* Page can be written.  */
#define MAP_SHARED	 0x01	 /* Share changes.  */

宏的最基本特征是进行直接文本替换,以上代码被替换之后的结果是:

int main()
{
    printf("圆周率: %f\n", 3.14); 
    mmap(((void *)0), 800*480*4, 0x1|0x2, 0x01, ...);
}

带参宏

带参宏意味着宏定义可以携带“参数”,从形式上看跟函数很像,例如:

#define MAX(a, b)   a>b ? a : b
#define MIN(a, b)   a<b ? a : b

以上的MAX(a,b) 和 MIN(a,b) 都是带参宏,不管是否带参,宏都遵循最初的规则,即宏是一段待替换的文本,例如在以下代码中,宏在预处理阶段都将被替换掉:

int main()
{
    int x = 100, y = 200;
    printf("最大值:%d\n", MAX(x, y));
    printf("最小值:%d\n", MIN(x, y));
    // 以上代码等价于:
    // printf("最大值:%d\n", x>y ? x : y);
    // printf("最小值:%d\n", x<y ? x : y);
}
  • 带参宏的特点:
  1. 直接文本替换,不做任何语法判断,更不做任何中间运算。

  2. 宏在编译的第一个阶段就被替换掉,运行中不存在宏。

  3. 宏将在所有出现它的地方展开,这一方面浪费了内存空间,另一方面有节约了切换时间。

带参宏的副作用

由于宏仅仅做文本替换,中间不涉及任何语法检查、类型匹配、数值运算,因此用起来相对函数要麻烦很多。例如:

#define MAX(a, b) a>b ? a : b

int main()
{
    int x = 100, y = 200;
    printf("最大值:%d\n", MAX(x, y==200?888:999));
}

直观上看,无论 y 的取值是多少,表达式 y==200?888:999 的值一定比 x 要大,但由于宏定义仅仅是文本替换,中间不涉及任何运算,因此等价于:

printf("最大值:%d\n", x>y==200?888:999 ? x : y==200?888:999);

可见,带参宏的参数不能像函数参数那样视为一个整体,整个宏定义也不能视为一个单一的数据,事实上,不管是宏参数还是宏本身,都应被视为一个字串,或者一个表达式,或者一段文本,因此最基本的原则是:

  • 将宏定义中所有能用括号括起来的部分,都括起来,比如:
#define MAX(a, b) ((a)>(b) ? (a) : (b))

宏定义中的符号粘贴

有些时候,宏参数中的符号并非用来传递数据,而是用来形成多种不同的字串,例如在某些系统函数中,系统本身规范了函数接口的部分标准,形如:

void __zinitcall_service_1(void)
{
    ...
}

void __zinitcall_service_2(void)
{
    ...
}

void __zinitcall_feature_1(void)
{
    ...
}

void __zinitcall_feature_2(void)
{
    ...
}

此时,若需要向用户提供一个方便整合字串的宏定义,可以这么写:

#define LAYER_INITCALL(layer, num)  __zinitcall_##layer##_##num

用户的调用如下:

LAYER_INITCALL(service, 1)();
LAYER_INITCALL(service, 2)();
LAYER_INITCALL(feature, 1)();
LAYER_INITCALL(feature, 2)();

注意:

在书写非字符串的字串时(如上述例子),使用两边双井号来

#define LAYER_INITCALL(num, layer)  __zinitcall_##layer##_##num##

但如果粘贴的字串并非出现在最末尾,则前后都必须加上双井号:

#define LAYER_INITCALL(num, layer)  __zinitcall_##layer##_##num##end

注意:

另外,如果字串本身拼接为字符串,那么只需要使用一个井号即可,比如:

#define domainName(a, b) "www." #a "." #b ".com"

int main()
{
    printf("%s\n", domainName(yueqian, lab));
}

执行打印如下:

gec@ubuntu:~$ ./a.out
www.yueqian.lab.com
gec@ubuntu:~$

无值宏定义

定义无参宏的时候,不一定需要带值,无值的宏定义经常在条件编译中作为判断条件出现,例如:

#define BIG_ENDIAN
#define __cplusplus
#include <stdio.h>

#define __DEFINE__H__ // 定义无值宏
#ifdef __DEFINE__H__  // 如果定义了宏__DEFINE__H__

// #ifndef __DEFINE__H__  // 如果未定义了宏__DEFINE__H__
// #define __DEFINE__H__  // 上下两个无值宏一般用于头文件中


#define PI    3.141526 // 无参宏

// 带参宏
#define MAX(a,b)  ((a)>(b)?(a):(b))  // 推荐
#define MIN(a,b)  (a<b?a:b)  // 不推荐

// 符号粘贴
#define FUNC(name, num)  func_##name##_##num    // ##表示粘贴字符。当粘贴符号是末尾是不需要结尾的##
#define FUN(name, num)  func_##name##_##num##_end //否则则不能省略结束##
#define domain(s1, s2)  "www."#s1"."#s2".com"  // 若需要粘贴的字符本身是字符串中的子串则只需#

#endif // 结束if条件

void func_abc_1(void)
{
    printf("%s is running\n", __FUNCTION__);
}
void func_abc_2(void)
{
    printf("%s is running\n", __FUNCTION__);
}
void func_xyz_1(void)
{
    printf("%s is running\n", __FUNCTION__);
}
void func_xyz_2(void)
{
    printf("%s is running\n", __FUNCTION__);
}


void func_abc_1_end(void)
{
    printf("%s is running\n", __FUNCTION__);
}
void func_abc_2_end(void)
{
    printf("%s is running\n", __FUNCTION__);
}
void func_xyz_1_end(void)
{
    printf("%s is running\n", __FUNCTION__);
}
void func_xyz_2_end(void)
{
    printf("%s is running\n", __FUNCTION__);
}


int main(int argc, char *argv[])
{
    // printf("圆周率:%.2f\n", PI);
    // int *p = NULL;
    
    // int x = 100, y = 200;
    // printf("最大值:%d\n", MAX(x, y==200?888:999));

    FUNC(abc,1)();
    FUNC(abc,2)();
    FUNC(xyz,1)();
    FUNC(xyz,2)();

    FUN(abc,1)();
    FUN(abc,2)();
    FUN(xyz,1)();
    FUN(xyz,2)();

    printf("%s\n", domain(gec,edu));
    return 0;
}

条件编译

  • 概念:有条件的编译,通过控制某些宏的值,来决定编译哪段代码。

  • 形式:

  • 形式1判断表达式 MACRO 是否为真,据此决定其所包含的代码段是否要编译

  • 注意:#if形式条件编译需要有值宏

#define A 0
#define B 1
#define C 2

#if A
    ... // 如果 MACRO 为真,那么该段代码将被编译,否则被丢弃
#endif
// 二路分支
#if A
    ... 
#elif B
    ...
#endif
// 多路分支
#if A
    ... 
#elif B
    ...
#elif C
    ...
#else
   ...
#endif
  • 形式:

  • 形式2判断宏 MACRO 是否已被定义,据此决定其所包含的代码段是否要编译

// 单独判断
#ifdef MACRO
    ...
#endif

// 二路分支
#ifdef MACRO
    ...
#else
   ...
#endif
  • 形式:

  • 形式3判断宏MACRO是否未被定义据此决定其所包含的代码段是否要编译

// 单独判断
#ifndef MACRO
    ...
#endif

// 二路分支
#ifndef MACRO
    ...
#else
   ...
#endif

  • 总结:

  • #ifdef#ifndef此种形式,判定的是宏是否已被定义,这不要求宏有值。

  • #if#elif 这些形式,判定的是宏的值是否为真,这要求宏必须有值。

  • #ifdef#ifndef#if结尾需要添加#endif表示结束条件,用以包裹代码块选择那一部分代码参与编译。

条件编译的使用场景

控制调试语句在程序中用条件编译将调试语句包裹起来通过gcc编译选项随意控制调试代码的启停状态。例如

gcc example.c -o example -DMACRO

以上语句中,-D意味着 DefineMACRO 是程序中用来控制调试语句的一个宏,如此一来就可以在完全不需要修改源代码的情况下,通过外部编译指令选项非常方便地控制调试信息的启停。

选择代码片段:在一些大型项目中(例如 Linux 内核),某个相同功能的模块往往有不同的实现,需要用户根据具体的情况来“配置”,这个所谓的配置的过程,就是对代码中不同的宏的选择的过程。

例如:

#define A 0  // 网卡1
#define B 1  // 网卡2  √
#define C 0  // 网卡3

// 多路分支
#if A
    ... 
#elif B
    ...
#elif C
    ...
#endif       
#include <stdio.h>

#define A 1   //声卡
#define B 0   //网卡
#define C 0   //串口

#if A
void fun_init_1(void)
{
    printf("%s is running...\n", __FUNCTION__);
}
#endif

#if B
void fun_init_2(void)
{
    printf("%s is running...\n", __FUNCTION__);
}
#endif

#if C
void fun_init_3(void)
{
    printf("%s is running...\n", __FUNCTION__);
}
#endif

void fun_destory_1(void)
{
    printf("%s is running...\n", __FUNCTION__);
}

void fun_destory_2(void)
{
    printf("%s is running...\n", __FUNCTION__);
}
void fun_destory_2_end(void)
{
    printf("%s is running...\n", __FUNCTION__);
}

int main()
{
#if A
    fun_init_1();
#endif

#if B
    fun_init_2();
#endif

#if C
    fun_init_3();
#endif

    return 0;
}
#include <stdio.h>

#define A 0   //声卡
#define B 0   //网卡
#define C 1   //串口

#if A
void fun_init_1(void)
{
    printf("%s is running...\n", __FUNCTION__);
}
#elif B
void fun_init_2(void)
{
    printf("%s is running...\n", __FUNCTION__);
}

#else
void fun_init_3(void)
{
    printf("%s is running...\n", __FUNCTION__);
}
#endif

void fun_destory_1(void)
{
    printf("%s is running...\n", __FUNCTION__);
}

void fun_destory_2(void)
{
    printf("%s is running...\n", __FUNCTION__);
}
void fun_destory_2_end(void)
{
    printf("%s is running...\n", __FUNCTION__);
}

int main()
{
#if A
    fun_init_1();
#elif B
    fun_init_2();
#else
    fun_init_3();
#endif

    return 0;
}

#include <stdio.h>

void fun_destory_1(void)
{
    printf("%s is running...\n", __FUNCTION__);
}

// #define DEBUG

#ifndef DEBUG
void fun_destory_2(void)
{
    printf("%s is running...\n", __FUNCTION__);
}
#endif


#ifdef  DEBUG   //gcc demo4.c -DDEBUG 编译时添加宏定义
void fun_destory_3_end(void)
{
    printf("%s is running...\n", __FUNCTION__);
}
#endif

int main(void)
{

    fun_destory_1();
#ifdef  DEBUG
    fun_destory_3_end();
#endif
#ifndef DEBUG
    fun_destory_2();
#endif
    return 0;
}

头文件

通常一个常规的C语言程序会

头文件的内容

  • 头文件中所存放的内容,就是各个源码文件的彼此可见的公共资源,包括:
  1. 全局变量的声明。

  2. 普通函数的声明。

  3. 静态函数的定义(内联函数)。

  4. 宏定义。

  5. 结构体、联合体的定义。

  6. 枚举常量列表的定义。

  7. 其他头文件。

  • 示例代码:
// head.h
extern int global; // 1全局变量的声明
extern void f1();  // 2普通函数的声明
static void f2()   // 3静态函数的定义
{
    ...
}
#define MAX(a, b) ((a)>(b)?(a):(b)) // 4宏定义
struct node    // 5结构体的定义
{
    ...
};
union attr    // 6联合体的定义
{
    ...
};
#include <unistd.h> // 7其他头文件
#include <string.h>
#include <stdint.h>
  • 特别说明:
  1. 全局变量、普通函数的定义一般出现在某个源文件(*.c )中,其他的源文件想要使用都需要进行声明(extern),因此一般放在头文件中更方便。

  2. 静态函数、宏定义、结构体、联合体的定义都只能在其所在的文件可见,因此如果多个源文件都需要使用的话,放到头文件中定义是最方便,也是最安全的选择。

头文件的使用

头文件编写好了之后,就可以被各个所需要的源码文件包含了,包含头文件的语句就是如下预处理指令:

// main.c
#include "head.h"  // 包含自定义的头文件
#include <stdio.h> // 包含系统预定义的文件

int main()
{
    ...
}

可以看到,在源码文件中包含指定的头文件有两种不同的形式:

  • 使用双引号:在指定位置 + 系统标准路径搜索 head.h

  • 使用尖括号:在系统标准路径搜索 stdio.h

头文件的格式

由于头文件包含指令 #include 的本质是复制粘贴,并且一个头文件中可以嵌套包含其他头文件,因此很容易出现一种情况是:头文件被重复包含。

  • 使用条件编译,解决头文件重复包含的问题,格式如下:
#ifndef _HEADNAME_H
#define _HEADNAME_H

...
... (头文件正文)
...

#endif

其中HEADNAME一般取头文件名称的大写

文件组织

一个简易示例

由于自定义的头文件一般放在源码文件的周围,因此需要在编译的时候通过特定的选项来指定位置,而系统头文件都统一放在标准路径下,一般无需指定位置。

假设在源码文件 main.c 中包含了两个头文件head.h 和 stdio.h ,由于他们一个是自定义头文件,一个是系统标准头文件,前者放在项目 pro/inc 路径下,后者存放于系统头文件标准路径下(一般位于 /usr/include因此对于这个程序的编译指令应写作

gec@ubuntu:~/pro$ gcc main.c -o main -I /home/gec/pro/inc

其中,/home/gec/pro/inc 是自定义头文件 head.h 所在的路径

  • 语法要点:

  • 预处理指令 #include 的本质是复制粘贴:将指定头文件的内容复制到源码文件中。

  • 系统标准头文件路径可以通过编译选项 -v 来获知,比如:

gec@ubuntu:~/pro$ gcc main.c -I /home/gec/pro/inc -v
... ...
#include "..." search starts here:
#include <...> search starts here:
    /usr/lib/gcc/x86_64-linux-gnu/7/include
    /usr/local/include
    /usr/lib/gcc/x86_64-linux-gnu/7/include-fixed
    /usr/include/x86_64-linux-gnu
    /usr/include
... ...

注意:*是通配符表示将src目录下的所有以.c结尾的文件都参与编译

表示告诉编译器头文件所在路径,编译会去指定的路径寻找头文件

应用场景(模块化编程)

练习

将图书管理系统的C语言文件使用