大家好,我是惊觉。这期我们来聊聊头文件的使用。
提到头文件,大家肯定不陌生。在编写模块的时候,一般有一个.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
很多同学可能都知道,不应该在头文件中定义函数,应该声明函数。那么,为什么头文件里不能定义函数呢?为什么定义函数后,编译时会报错呢?
有些知识点,如果不仅知其然,还能知其所以然的话,运用起来会更加灵活自如。
这个问题涉及到编译和链接的细节,其实并不复杂,让笔者为大家一一剖析。
预编译
将项目源码转换为固件的过程有两大步骤:
- 编译:依次编译每一个源代码文件,包括C文件,C++文件,汇编文件(.s),生成对应的目标文件(.o)。
- 链接:将所有的目标文件链接成可执行文件。
之所以说是两大步骤,是因为每步里面有很多细分的子步骤。对编译而言,有一个预编译(预处理)的步骤。对于每一个.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之类的函数。
对外接口的声明及其所依赖的类型定义、变量声明,在这里,是不是都齐了呢。