零基础学C语言——预编译

发布时间:2023年12月27日

这是一个C语言系列文章,如果是初学者的话,建议先行阅读之前的文章。笔者也会按照章节顺序发布。

一个C语言源码要想生成可执行程序,需要经过四个阶段——预编译、编译、汇编、连接。在一些文档中会将汇编归入编译阶段。还有些文档中会将连接写成链接。

这四个阶段大至做的工作分别是:

  • 预编译:对源文件进行预处理,主要是完成预编译指令的处理。预处理包含:
    • 展开头文件
    • 宏替换
    • 去掉注释
    • 条件编译
  • 编译:这个阶段主要是将预处理后的代码转换为汇编代码
  • 汇编:调用汇编器将编译阶段生成的汇编代码转换为二进制机器码
  • 连接:将汇编阶段生成的各个源文件对应的二进制机器码文件合并生成可执行程序

本篇不深入讨论编译、汇编、连接的内容,仅针对预编译部分进行说明。

除代码注释外,其余预编译指令均以井号(#)开头。

注释

在之前的文章中,笔者一直在使用注释,细心的读者可能会注意到代码区域内的/**/和//。

/*
  这是一个
  多行注释
*/

//这是一个行注释

在C中有上述两种注释方式。我们这个系列的第一篇环境搭建文章中提到过,我们的操作系统环境是Linux或者苹果的OSX(也就是darwin),这二者都属于类UNIX系统。还有一些其他的类UNIX系统,例如AIX,Solaris等,这些系统上的C编译器可能不支持行注释。

头文件引入

在之前文章的很多代码中,都会看到#include …字样。

include就是一个预编译指令,这个指令用于将其后文件名指定的文件中的内容引入当前源文件中include指令所在位置。例如:

/*a.h*/
int a;
/*a.c*/
#include "a.h"

int main(void)
{
  a = 10;
  return 0;
}

这样的写法等价于:

/*a.c*/
int a;

int main(void)
{
  a = 10;
  return 0;
}

C语言程序中,我们通常会按照功能将代码划分到不同源文件中。一个功能模块中可能会有其他功能模块需要用到的函数,如果每个用到这些函数的源文件都要写一份函数、结构、类型的声明,那么代码中将会有大量这样的重复声明,且一旦函数、结构、类型定义有改动,那么所有引用到的文件都要跟着修改。

头文件就是用来解决这个麻烦的,我们会将这些结构、类型、函数声明全部放入头文件中,然后在需要用到这些声明的源文件中用include指令将文件引入,这样源文件就非常整洁清晰了。


细心的读者可能会发现,我们的代码中存在了两种include写法:

#include <...>
#include "..."

这两者的差别在于:

“”的写法会优先查找当前目录下是否存在该头文件,如果不存在,则去标准库头文件所在目录中查找;而<>则是在标准库头文件所在目录下查找指定的头文件。这里额外提一句,我们使用的printf就是声明在标准库头文件stdio.h中的。

宏定义

在数学运算中,如果我有个标号PI,且它等价于3.14,那么我在写公式时可以写成:

PI * 2 * 2

在实际运算时,会将PI替换成3.14:

3.14 * 2 * 2

此时,PI就相当于C语言中的一个宏。

这个例子看似与常量很相似,但是常量是在编译阶段被处理的,而宏则是在预编译阶段被处理的,且宏的用法更加复杂。

定义一个宏,用到了define指令,我们看一个例子:

#define PI 3.14
float foo(float r)
{
  return r * r * PI;
}

这段代码在预编译阶段(编译阶段之前),会被处理成:

float foo(float r)
{
  return r * r * 3.14;
}

当然,这个处理结果并未生成文件,因此无法直观看到。

宏定义的一般形式

#define 新写法 等价的写法

再举一个例子:

#define add(a, b) a+b
//我们定义了一个宏 add(a, b) 它等价于 a + b
//例如:
add(1, 2) //等价于1 + 2
add(3.1, 1.7) //等价于3.1 + 1.7
add(1+1, 3+2) //等价于1+1 + 3+2

这里宏配合()时有些类似一个函数,此时括号内用逗号分隔的每一个片段都是一个参数,本例中a和b就是参数。

可以看到,这相当于将a和b原样不动地完全替换到a+b中。

那么这样的宏会有什么坑呢?例如:

add(1, 2) * add(3, 4)

我原本期望是3 * 7,但是原样不动替换的含义则是 1 + 2 * 3 + 4,最终等于11。

解决方案很简单:

#define add(a, b) (a+b)
add(1, 2) * add(3, 4) //将被展开成 (1+2) * (3+4) = 21

有时,宏定义中等价的写法部分内容比较长,因此可能会想换一行写。如果直接换行,编译器是无法正常处理的。因此需要再换行前加入一个延续运算符(\),这个运算符仅用于预编译阶段,所以不在我们的运算符文章中给出。例如:

#define add4(a, b) \
    add(a, b)+add(a, b)+add(a, b)+add(a, b)

再换行前加入一个反斜杠,这样下一行也将被作为宏定义的一部分继续处理。并且从这个例子中可以看到,宏是可以嵌套使用的,这里的add就是上面定义的add宏。

有时,我们可能在一些情况下要删除掉我们定义的宏,这时就要使用undef指令了,例如:

#undef add4(a, b)
//删除掉add4(a, b)这个宏
#undef PI
//删除掉PI

预定义宏

前一小节说明了如何定义宏,但有些宏,编译器已经预先定义好了。

  • __DATE__:当前日期,一个以 “MMM DD YYYY” 格式表示的字符数组
  • __TIME__:当前时间,一个以 “HH:MM:SS” 格式表示的字符数组
  • __FILE__:当前文件名,一个字符数组(即所谓的字符串)
  • __LINE__:当前行号,一个十进制整数
  • __STDC__:当编译器以 ANSI 标准编译时,其值为1
#include <stdio.h>

int main(void)
{
  printf("date:[%s]\ntime:[%s]\nfile:[%s]\nline:[%d]\nstdc:[%d]\n", __DATE__, __TIME__, __FILE__, __LINE__, __STDC__);
  return 0;
}

运行结果如下:

date:[Feb  6 2020]
time:[14:59:19]
file:[a.c]
line:[5]
stdc:[1]

条件编译

既然可以定义宏,也可以删除宏,那么当我们在写程序时就有可能需要判断一个宏是否存在,如果存在则启用某段代码,如果不存在则启用另一段代码。这样的处理就称作条件编译

我们将使用如下指令来完成条件编译:

  • ifdef:如果宏存在,则真值判断为真,否则为假
  • ifndef:如果宏不存在则为真,否则为假
  • if:如果给定条件为真,则对真值区代码进行编译,if还支持逻辑的与(&&)或(||)非(!)。
  • else:配合if使用,如果if条件失败,则将else区域代码进行编译
  • elif:配合if使用,如果满足给定条件,则对本条件所对应的真值区代码进行编译
  • endif:表示if区域或者ifdef、ifndef区域结束
  • defined()运算符:判断一个宏是否被定义,定义了为真,否则为假

下面简单看一个综合使用的例子:

#include <stdio.h>

#define A 1
#define B 2

int main(void)
{
  #ifdef A
    printf("A is defined.\n");
  #endif
  #ifndef C
    printf("C is not defined.\n");
  #endif
  
  #if defined(C)
    printf("C is defined.\n");
  #elif defined(B) && !defined(C)
    printf("B is defined, C is not defined.\n");
  #else
    printf("Shouldn't be here.\n");
  #endif
    return 0;
}

这个例子经过条件编译处理后的结果大致如下:

#include <stdio.h>

int main(void)
{
    printf("A is defined.\n");
    printf("C is not defined.\n");  
    printf("B is defined, C is not defined.\n");
    return 0;
}

因此,输出结果为:

A is defined.
C is not defined.
B is defined, C is not defined.

宏中字符串常量

我们来看这样一个需求,我对一周7天做了宏定义,如下:

#define Mon 0
#define Tue 1
#define Wed 2
#define Thu 3
#define Fri 4
#define Sat 5
#define Sun 6

此时,我期望输出结果如下:

Mon:0
Tue:1
Wed:2
Thu:3
Fri:4
Sat:5
Sun:6

我希望在不额外手工定义任何变量或常量的情况下满足我的输出期望。

下面我们来看一个解决方法:

#define print_week_day(day) printf("%s:%d\n", #day, day)

此时,我们在main中使用宏:

int main(void)
{
  print_week_day(Mon);
  print_week_day(Tue);
  print_week_day(Wed);
  print_week_day(Thu);
  print_week_day(Fri);
  print_week_day(Sat);
  print_week_day(Sun);
  return 0;
}

这里,我们在宏print_week_day中,第一个day的位置前加了#,这个符号会将day的部分作为一个字符数组常量,而不是替换成day所指代的宏的值。

自定义错误信息

有时,我们虽然可以利用条件编译判断一个宏是否存在,但是如果宏不存在时我们希望编译器能抛出一段我们自定义的错误提示来终结掉编译。这里就要用到error指令了。

error指令的一般形式

#error 一段自定义文字

例如:

int main(void)
{
  #ifndef TEST
    #error TEST not existent
  #endif
    return 0;
}

TEST本就不存在,因此编译这段代码时,编译器会报错,error后的内容将作为报错信息给出:

a.c:4:6: error: TEST not existent
    #error TEST not existent
     ^

粘贴运算符

我们来看这样一个需求:我有若干源文件,我想在每个源文件中定义一组全局变量(注意不是静态全局变量,因此名字不可重复),且每一个文件内的全局变量的类型与其他文件的都一样只是名字不同。

一般的解决方案是,在每一个源文件中直接拷贝,如果全局变量的个数少也没什么问题,但是如果全局变量有几十个,且如果需要修改某一个全局变量的类型是,其余文件的对应变量类型也要跟着修改,那似乎会很“爽”。

有一种简单的解决方案,这里用到了粘贴运算符(##):

/*common.h*/
#define group_var(prefix); \
  int prefix##_a; \
  float prefix##_b; \
  char prefix##_c;

这里,##会将prefix指代的内容与其右侧的_a拼接在一起。此时,在源文件中我们可以写:

/*a.c*/
int main(void)
{
    group_var(a);
    return 0;
}

可以看到,group_varprefixa,则我们就有了全局变量a_aa_ba_c。如果此时有另一个源文件b.c,我们也可以直接写group_var(b),就定义了b_ab_bb_c

pragma

这是一个特殊的预编译指令,用于指示编译器完成一些特定的动作。

pragma指令的一般形式

#pragma 指示字 参数

其中,指示字包含:

  • message:自定义编译信息
  • once:保证头文件只被编译一次
  • pack:用于指定内存对齐

不同C编译器所支持的指示字有所不同,因此pragma指令的部分指示字是不可移植的。

下面给出上述三个指示字的用例:

message:
#pragma message "This is a message"
int main(void)
{
  return 0;
}

编译时会输出:

a.c:1:9: warning: This is a message [-W#pragma-messages]
#pragma message "This is a message"
        ^

注意,这段信息不是报错,而是以警告的形式给出的。

once:
/* a.h */
#pragma once
#include "a.h"
int a;
/* a.c */
#include "a.h"
int main(void)
{
  return 0;
}

可以看到,a.h中又include了自己,因此如果不加pragma once编译器会给出如下报错:

./a.h:1:10: error: #include nested too deeply
#include "a.h"
         ^

加了之后,因为只会被处理一次,所以顺利完成编译。

pack:

这个指示字一般常用于结构体中,在结构体一文中捎带提起过,结构体存在对齐规则。默认情况下,编译32位程序,编译器会让结构体中成员向4字节对齐,64位程序则向8字节对齐。但是这个对齐是可以通过pragma pack进行修改的。我们看一个例子,这个例子是在64位操作系统下生成的64位程序:

#include <stdio.h>

struct test {
  char a;
  long b;
  int  c;
};

int main(void)
{
  printf("%lu\n", sizeof(struct test));
  return 0;
}

这个程序的输出是24。这是因为a只有1字节,而b有8字节,因此中间额外填充了7字节,来凑8字节对齐。此时a、b都已向8字节对齐,共16字节。c为4字节,其后也没有小于等于4字节的成员,因此需要补充4字节,凑8字节对齐。

如果不希望结构体做对齐(也就是1字节对齐)呢?看下例:

#include <stdio.h>

#pragma pack(1)
struct test {
  char a;
  long b;
  int  c;
};
#pragma pack()

int main(void)
{
  printf("%lu\n", sizeof(struct test));
  return 0;
}

此时,输出的结果为13,即1字节a+8字节b+4字节c。

pack后括号内的数就是对齐的字节数,如果括号内不写入任何数值,则表示恢复默认。



喜欢的小伙伴可以关注码哥,也可以给码哥留言评论,如有建议或者意见也欢迎私信码哥,我会第一时间回复。
感谢阅读!
文章来源:https://blog.csdn.net/weixin_40960130/article/details/135218544
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。