你真的了解头文件吗

大家好,我是惊觉。这期我们来聊聊头文件的使用。

提到头文件,大家肯定不陌生。在编写模块的时候,一般有一个.c就会有一个.h,如下图:
在这里插入图片描述
在.c中需要使用其他模块的变量和函数时,需要先引用头文件。比如:

#include "app.h"
#include "common.h"
#include "drv_uart.h"
#include "test.h"
#include "examples.h"
#include "stack_monitor.h"

那么大家真的完全了解头文件的用法和规则吗?请看下题。

哪儿出错了

calc.h

#ifndef __CALC_H__
#define __CALC_H__

int calc_add(int a, int b)
{
    return a + b;
}

#endif

user1.c

#include "calc.h"

void user1_test_add(void)
{
    int a = 1;
    int b = 2;
    int sum = calc_add(a, b);
    LOG_I("%d + %d = %d", a, b, sum);
}

user2.c

#include "calc.h"

void user2_test_add(void)
{
    int a = 3;
    int b = 4;
    int sum = calc_add(a, b);
    LOG_I("%d + %d = %d", a, b, sum);
}

上述代码想实现的内容非常简单。calc.h定义了一个加法函数,被user1.c和user2.c使用。编译user1.c和user2.c的时候没有问题,最终链接时会报错:
在这里插入图片描述
信息比较长,我只截取了一部分。这种报错会有如下关键字:

  • multiple definition of `calc_add’
  • first defined here

很多同学可能都知道,不应该在头文件中定义函数,应该声明函数。那么,为什么头文件里不能定义函数呢?为什么定义函数后,编译时会报错呢?

有些知识点,如果不仅知其然,还能知其所以然的话,运用起来会更加灵活自如

这个问题涉及到编译和链接的细节,其实并不复杂,让笔者为大家一一剖析。

预编译

将项目源码转换为固件的过程有两大步骤:

  1. 编译:依次编译每一个源代码文件,包括C文件,C++文件,汇编文件(.s),生成对应的目标文件(.o)。
  2. 链接:将所有的目标文件链接成可执行文件。

之所以说是两大步骤,是因为每步里面有很多细分的子步骤。对编译而言,有一个预编译(预处理)的步骤。对于每一个.c,编译器会对其进行预处理,生成一个临时的.c,再对这个临时文件进行编译。这就好比食客在饭店写好菜单后,服务员在菜单上添加额外的标记再送给厨师。

以下语句都是在预编译时起作用:

  • #ifndef CALC_H
  • #define CALC_H
  • #include “calc.h”

大家都知道#include是引用头文件。那编译器到底是怎么引用头文件的呢?其实非常简单,让我们以user1.c为例。为方便阅读,再写一遍:

user1.c

#include "calc.h"

void user1_test_add(void)
{
    int a = 1;
    int b = 2;
    int sum = calc_add(a, b);
    LOG_I("%d + %d = %d", a, b, sum);
}

预编译user1.c时,读取到了include "calc.h"语句后,会将calc.h中的内容搬运过来,放在临时文件之中。当然啦,user1.c中定义的函数和变量也会放进临时文件。说白了,预处理的过程就是将.c文件及其引用的.h文件中的内容拼成一个临时的.c文件。当然啦,不光是内容的拼接,还有宏替换。

预处理后的user1.c

int calc_add(int a, int b)
{
    return a + b;
}

void user1_test_add(void)
{
    int a = 1;
    int b = 2;
    int sum = calc_add(a, b);
    logger_output(3, "user1", 21, "%d + %d = %d", a, b, sum);
}

预处理后的user2.c

int calc_add(int a, int b)
{
    return a + b;
}

void user2_test_add(void)
{
    int a = 3;
    int b = 4;
    int sum = calc_add(a, b);
    logger_output(3, "user2", 21, "%d + %d = %d", a, b, sum);
}

现在问题很明显了。user1.c和user2.c在预处理后都定义了calc_add函数,这在链接步骤时自然会冲突啦。

验证

怎么验证预编译是不是如笔者所说的这样呢?其实可以手工体验一把的。可以在命令行中单独预编译某个源文件,只要给gcc加一个-E参数即可。

gcc -E user1.c > user1.i

如果你是在嵌入式IDE中进行实验的话,可以把相关文件的编译指令复制出来,去掉-o参数和-d参数,加上-E参数即可。

比如把这行 user1.c的命令完整的复制出来。
在这里插入图片描述
删除红框的内容。
在这里插入图片描述
加上-E参数,为了方便,将输出重定向到user1.i。
在这里插入图片描述
在命令行中运行:
在这里插入图片描述

生成的文件比笔者刚才展示的要复杂一些,不过,calc_add函数赫然在列:
在这里插入图片描述

正确的做法

正确的做法是:在头文件中进行函数声明,在源文件中进行函数定义。

calc.h

#ifndef __CALC_H__
#define __CALC_H__

int calc_add(int a, int b);

#endif

calc.c

#include "calc.h"

int calc_add(int a, int b)
{
    return a + b;
}

再看一个错误

现在看看如下头文件会不会导致错误呢?

calc.h

#ifndef __CALC_H__
#define __CALC_H__

int calc_offset = 0;

int calc_add(int a, int b);

#endif

calc_offset同样会导致冲突。因为这里还是属于定义,而不是声明。

声明变量的方法如下:

extern int calc_offset;

int calc_offset;

声明变量时,可以省略extern,但是不能给变量赋值,赋值了就是定义。

应该这样用头文件

笔者认为,我们应该像制定菜单一样来书写头文件。饭店在制作菜单时,往往只会写上菜名,而不会暴露过多的细节,如某道菜里面有哪些佐料,是如何烹饪的。头文件也是如此,仅给出对外接口的声明及其所依赖的类型定义、变量声明

最简单的头文件

sys.h声明了时间相关的接口,属于最简单的头文件类型。由于声明中用到了uint32_t,所以引用了定义该类型的stdint.h。

#ifndef SYS_H_
#define SYS_H_

#include <stdint.h>

void sys_init(void);
void sys_delay(uint32_t ms);
void sys_udelay(uint32_t us);
uint32_t sys_get_time(void);

#endif /* SYS_H_ */

起聚合作用的头文件

对于经常要引用的头文件,可以把它们集中放在某一个头文件之中,以方便引用。

common.h

#ifndef COMMON_H_
#define COMMON_H_

#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include <stdlib.h>

#include "sys.h"

#endif /* COMMON_H_ */

更复杂的情况

最后,我们看一个比较复杂的头文件。

drv_uart.h

#ifndef DRV_UART_H_
#define DRV_UART_H_

#include "common.h"
#include "stm32f4xx.h"

#define UART_RX_BUFSIZE	 128

typedef struct uart uart_t;

typedef void (*uart_rx_cb_t)(uart_t *uart, uint8_t *buf, uint32_t len);

struct uart
{
    UART_HandleTypeDef *handle;

    uint8_t rx_buf[UART_RX_BUFSIZE];
    uint32_t rx_index;
    uart_rx_cb_t rx_cb;
};

extern uart_t uart1;
extern uart_t uart2;

void drv_uart_init(void);
void drv_uart_set_callback(uart_t *uart, uart_rx_cb_t rx_cb);
void drv_uart_send_byte(uart_t *uart, uint8_t data);
void drv_uart_send(uart_t *uart, void *buf, int len);
void drv_uart_send_str(uart_t *uart, const char *str);

#endif /* DRV_UART_H_ */

这其中有:

  • 宏定义,如UART_RX_BUFSIZE
  • 结构体定义,如struct uart
  • 函数指针定义,uart_rx_cb_t
  • 变量声明,uart1
  • 函数声明

定义结构体struct uart是因为各函数中的入参用到了这个类型。声明和uart1和uart2就是可操作的串口实例,需要传递给drv_uart_send之类的函数。

对外接口的声明及其所依赖的类型定义、变量声明,在这里,是不是都齐了呢。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值