一、C语言#pragma once的用法

1 #pragma once有什么作用?

为了避免同一个头文件被包含(include)多次,C/C++中有两种宏实现方式:

一种是#ifndef方式;

另一种是#pragma once方式。

在能够支持这两种方式的编译器上,二者并没有太大的区别。但两者仍然有一些细微的区别。

2 两者的使用方式有何区别   

示例代码如下:

//方式一:
#ifndef  __SOMEFILE_H__
#define   __SOMEFILE_H__
 ... ... // 声明、定义语句
#endif

//方式二:
#pragma once
 ... ... // 声明、定义语句

3 两者各有何特点   

(1)#ifndef

#ifndef的方式受C/C++语言标准支持。它不仅可以保证同一个文件不会被包含多次,也能保证内容完全相同的两个文件(或者代码片段)不会被不小心同时包含。

当然,缺点就是如果不同头文件中的宏名不小心“撞车”,可能就会导致你看到头文件明明存在,但编译器却硬说找不到声明的状况——这种情况有时非常让人郁闷。

由于编译器每次都需要打开头文件才能判定是否有重复定义,因此在编译大型项目时,ifndef会使得编译时间相对较长,因此一些编译器逐渐开始支持#pragma once的方式。

(2)#pragma once

#pragma once 一般由编译器提供保证:同一个文件不会被包含多次。注意这里所说的“同一个文件”是指物理上的一个文件,而不是指内容相同的两个文件。

你无法对一个头文件中的一段代码作pragma once声明,而只能针对文件。

其好处是,你不必再担心宏名冲突了,当然也就不会出现宏名冲突引发的奇怪问题。大型项目的编译速度也因此提高了一些。

对应的缺点就是如果某个头文件有多份拷贝,本方法不能保证他们不被重复包含。当然,相比宏名冲突引发的“找不到声明”的问题,这种重复包含很容易被发现并修正。

另外,这种方式不支持跨平台!

4 两者之间有什么联系?

#pragma once 方式产生于#ifndef之后,因此很多人可能甚至没有听说过。目前看来#ifndef更受到推崇。

因为#ifndef受C/C++语言标准的支持,不受编译器的任何限制;

而#pragma once方式却不受一些较老版本的编译器支持,一些支持了的编译器又打算去掉它,所以它的兼容性可能不够好。

一般而言,当程序员听到这样的话,都会选择#ifndef方式,为了努力使得自己的代码“存活”时间更久,通常宁愿降低一些编译性能,这是程序员的个性,当然这是题外话啦。

还看到一种用法是把两者放在一起的:

#pragma once
#ifndef __SOMEFILE_H__
#define __SOMEFILE_H__

... ... // 声明、定义语句

#endif

总结:

看起来似乎是想兼有两者的优点。

不过只要使用了#ifndef就会有宏名冲突的危险,也无法避免不支持#pragma once的编译器报错,所以混用两种方法似乎不能带来更多的好处,倒是会让一些不熟悉的人感到困惑。

选择哪种方式,应该在了解两种方式的情况下,视具体情况而定。

只要有一个合理的约定来避开缺点,我认为哪种方式都是可以接受的。

而这个已经不是标准或者编译器的责任了,应当由程序员自己或者小范围内的开发规范来搞定。

为了避免同一个文件被include多次:

  • #ifndef方式 
  • #pragma once方式

在能够支持这两种方式的编译器上,二者并没有太大的区别,但是两者仍然还是有一些细微的区别。

方式一:

#ifndef __SOMEFILE_H__
#define __SOMEFILE_H__
... ... // 一些声明语句
#endif

方式二:

#pragma once
... ... // 一些声明语句

#ifndef的方式依赖于宏名字不能冲突,这不光可以保证同一个文件不会被包含多次,也能保证内容完全相同的两个文件不会被不小心同时包含。

当然,缺点就是如果不同头文件的宏名不小心“撞车”,可能就会导致头文件明明存在,编译器却硬说找不到声明的状况。

#pragma once则由编译器提供保证:同一个文件不会被包含多次。

注意这里所说的“同一个文件”是指物理上的一个文件,而不是指内容相同的两个文件。带来的好处是,你不必再费劲想个宏名了,当然也就不会出现宏名碰撞引发的奇怪问题。

对应的缺点就是如果某个头文件有多份拷贝,本方法不能保证他们不被重复包含。

当然,相比宏名碰撞引发的“找不到声明”的问题,重复包含更容易被发现并修正。

方式一 由语言支持所以移植性好,

方式二 可以避免名字冲突。

二、C语言Makefile:从基础到高级应用

Makefile是一种编译控制文件,广泛用于项目的自动化构建。它定义了一系列的规则来指导构建的过程。

通过Makefile,开发者可以轻松管理大型项目的编译链接、清理等任务。

本文将从Makefile的基础用法讲起,逐步深入到更高级的应用,为你呈现一个全面而详细的Makefile使用手册。

Makefile的基本结构

一个最简单的Makefile包含规则,规则由目标(target)、依赖(dependencies)和命令(commands)三部分组成:

target: dependencies
    commands

命令前的Tab键是必须的。下面是一个简单的示例:

hello: hello.c
    gcc -o hello hello.c

变量的使用

在Makefile中声明变量可以使得我们的代码更加简洁。

CC=gcc
CFLAGS=-std=c99
LDFLAGS=
OBJ=main.o utils.o

app: $(OBJ)
    $(CC) -o app $(OBJ) $(LDFLAGS)

main.o: main.c
    $(CC) $(CFLAGS) -c main.c

utils.o: utils.c utils.h
    $(CC) $(CFLAGS) -c utils.c

通用规则和模式匹配

模式规则可以减少我们重复相同命令的工作量。

%.o: %.c
    $(CC) $(CFLAGS) -c $<

$< 是自动变量之一,代表依赖列表中的第一项。

自动化变量

Makefile提供了一系列自动化变量,它们在规则的命令中非常有用:

  • $@ 表示规则中的目标文件名;
  • $^ 表示所有的依赖文件列表;
  • $< 表示第一个依赖文件;
  • $? 表示所有比目标新的依赖文件列表。

函数的使用

Makefile中内置了许多函数,用以执行字符串操作、文件操作等。

例如,获取源文件列表:

SRC=$(wildcard *.c)
OBJ=$(patsubst %.c,%.o,$(SRC))

控制Make的行为

  • make -B 强制重新编译所有目标;
  • make -n 显示将要执行的命令而不实际执行;
  • make -f <file> 指定使用其他名称的Makefile文件;
  • make -j 允许并行执行(多核编译)。     

高级用法 - 条件判断

Makefile也支持条件判断,这在不同环境需要执行不同命令时非常有用。

ifeq ($(OS),Windows_NT)
    RM=del /Q
else
    RM=rm -f
endif

clean:
    $(RM) *.o

使用变量和文件包含来组织Makefile

对于大型项目,组织多个Makefile是一种好方法。

# 在子Makefile中
include config.mk

自定义函数

通过定义可以重用的函数,你可以使你的Makefile变得更加强大和灵活。

define run-cc
$(CC) $(CFLAGS) -o $@ $^
endef

app: $(OBJ)
    $(call run-cc)

处理多目标

定义一个规则来批量处理多个文件。

FILES := file1 file2 file3

all: $(FILES)

$(FILES):
    touch $@

伪目标的使用

伪目标不代表实际的文件,它只是一个动作的名称。

.PHONY: clean

clean:
    rm -f *.o app

调试Makefile

你可以使用make --debug或添加注释来帮助调试Makefile。

app: main.o utils.o
    # 这是一个链接的命令
    $(CC) -o app main.o utils.o

结语

Makefile是构建自动化的强大工具,既可以简化小型项目的构建流程,也能够灵活管理大型应用程序的复杂构建系统。

通过本文的详细论述和丰富示例,您应该能够基本掌握Makefile的各项技能,并在实际项目中加以应用。

希望以上内容对你深入理解和使用Makefile有所帮助。记住,“实践出真知”——编写你自己的Makefile并尝试使用这些特性是最好的学习方式。

三、嵌入式工具代码合集

嵌入式开发中常用的C语言工具代码确实很重要。以下是一些利剑级别的C语言工具代码示例,以及它们的简要讲解。

1 循环队列(Circular Buffer)

typedef struct {
    int buffer[SIZE];
    int head;
    int tail;
    int count;
} CircularBuffer;

void push(CircularBuffer *cb, int data) {
    if (cb->count < SIZE) {
        cb->buffer[cb->head] = data;
        cb->head = (cb->head + 1) % SIZE;
        cb->count++;
    }
}

int pop(CircularBuffer *cb) {
    if (cb->count > 0) {
        int data = cb->buffer[cb->tail];
        cb->tail = (cb->tail + 1) % SIZE;
        cb->count--;
        return data;
    }
    return -1; // Buffer is empty
}

循环队列是一种高效的数据结构,适用于缓冲区和数据流应用,例如串口通信接收缓冲。

2 断言(Assertion)

#define assert(expression) ((void)0)
#ifndef NDEBUG
#undef assert
#define assert(expression) ((expression) ? (void)0 : assert_failed(__FILE__, __LINE__))
#endif

void assert_failed(const char *file, int line) {
    printf("Assertion failed at %s:%d\n", file, line);
    // Additional error handling or logging can be added here
}

断言用于在程序中检查特定条件是否满足,如果条件为假,会触发断言失败,并输出相关信息

3 位域反转(Bit Reversal)

unsigned int reverse_bits(unsigned int num) {
    unsigned int numOfBits = sizeof(num) * 8;
    unsigned int reverseNum = 0;

    for (unsigned int i = 0; i < numOfBits; i++) {
        if (num & (1 << i)) {
            reverseNum |= (1 << ((numOfBits - 1) - i));
        }
    }
    return reverseNum;
}

该函数将给定的无符号整数的位进行反转,可以用于某些嵌入式系统中的位级操作需求。

4 固定点数运算(Fixed-Poin Arithmetic)

typedef int16_t fixed_t;

#define FIXED_SHIFT 8
#define FLOAT_TO_FIXED(f) ((fixed_t)((f) * (1 << FIXED_SHIFT)))
#define FIXED_TO_FLOAT(f) ((float)(f) / (1 << FIXED_SHIFT))

fixed_t fixed_multiply(fixed_t a, fixed_t b) {
    return (fixed_t)(((int32_t)a * (int32_t)b) >> FIXED_SHIFT);
}

在某些嵌入式系统中,浮点运算会较慢或不被支持。因此,使用固定点数运算可以提供一种有效的浮点数近似解决方案。

5 字节序转换(Endianness Conversion)

uint16_t swap_bytes(uint16_t value) { return (value >> 8) | (value << 8); }


用于在大端(Big-Endian)和小端(Little-Endian)字节序之间进行转换的函数。


6 位掩码(Bit Masks)

#define BIT_MASK(bit) (1 << (bit))

用于创建一个只有指定位被置位的位掩码,可用于位操作。

7 计数器计数(Timer Counting)

#include <avr/io.h>

void setup_timer() {
    // Configure timer settings
}

uint16_t read_timer() {
    return TCNT1;
}

在AVR嵌入式系统中,使用计时器(Timer)来实现时间测量和定时任务。

8 二进制查找(Binary Search)

int binary_search(int arr[], int size, int target) {
    int left = 0, right = size - 1;

    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) {
            return mid;
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return -1; // Not found
}

用于在已排序的数组中执行二进制查找的函数。

9 位集合(Bitset)

#include <stdint.h>

typedef struct {
    uint32_t bits;
} Bitset;

void set_bit(Bitset *bitset, int bit) {
    bitset->bits |= (1U << bit);
}

int get_bit(Bitset *bitset, int bit) {
    return (bitset->bits >> bit) & 1U;
}

实现简单的位集合数据结构,用于管理一组位的状态。

这些代码示例代表了嵌入式开发中常用的一些利剑级别的C语言工具代码。它们在嵌入式系统开发中具有广泛的应用,有助于优化性能、节省资源并提高代码的可维护性。

四、STM32单片机的堆栈

  学习STM32单片机的时候,总是能遇到“堆栈”这个概念。分享本文,希望对你理解堆栈有帮助。

    对于了解一点汇编编程的人,就可以知道,堆栈是内存中一段连续的存储区域,用来保存一些临时数据。堆栈操作由PUSH、POP两条指令来完成。而程序内存可以分为几个区:

  • 栈区(stack)
  • 堆区(Heap)
  • 全局区(static)
  • 文字常亮区程序代码区

    程序编译之后,全局变量,静态变量已经分配好内存空间,在函数运行时,程序需要为局部变量分配栈空间,当中断来时,也需要将函数指针入栈,保护现场,以便于中断处理完之后再回到之前执行的函数。
    栈是从高到低分配,堆是从低到高分配。

普通单片机与STM32单片机中堆栈的区别
    普通单片机启动时,不需要用bootloader将代码从ROM搬移到RAM。

   这里我们可以先看看单片机程序执行的过程,单片机执行分三个步骤:

  • 取指令
  • 分析指令
  • 执行指令

    根据PC的值从程序存储器读出指令,送到指令寄存器。然后分析执行执行。这样单片机就从内部程序存储器去代码指令,从RAM存取相关数据。

    RAM取数的速度是远高于ROM的,但是普通单片机因为本身运行频率不高,所以从ROM取指令慢并不影响。

    而STM32的CPU运行的频率高,远大于从ROM读写的速度。所以需要用bootloader将代码从ROM搬移到RAM。

    使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。

    其实堆栈就是单片机中的一些存储单元,这些存储单元被指定保存一些特殊信息,比如地址(保护断点)和数据(保护现场)。

    如果非要给他加几个特点的话那就是:

  • 这些存储单元中的内容都是程序执行过程中被中断打断时,事故现场的一些相关参数。如果不保存这些参数,单片机执行完中断函数后就无法回到主程序继续执行了。
  • 这些存储单元的地址被记在了一个叫做堆栈指针(SP)的地方。

结合STM32的开发讲述堆栈

    从上面的描述可以看得出来,在代码中是如何占用堆和栈的。可能很多人还是无法理解,这里再结合STM32的开发过程中与堆栈相关的内容来进行讲述。

    如何设置STM32的堆栈大小?

    在基于MDK的启动文件开始,有一段汇编代码是分配堆栈大小的。

c语言-嵌入式专辑10~_编译器

    这里重点知道堆栈数值大小就行。还有一段AREA(区域),表示分配一段堆栈数据段。数值大小可以自己修改,也可以使用STM32CubeMX数值大小配置,如下图所示。

c语言-嵌入式专辑10~_嵌入式硬件_02

 STM32F1默认设置值0x400,也就是1K大小。

Stack_Size EQU 0x400

    函数体内局部变量:

void Fun(void){ char i; int Tmp[256]; //...}

    局部变量总共占用了256*4 + 1字节的栈空间。所以,在函数内有较多局部变量时,就需要注意是否超过我们配置的堆栈大小。

    函数参数:

void HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *GPIO_Init)

    这里要强调一点:传递指针只占4字节,如果传递的是结构体,就会占用结构大小空间。提示:在函数嵌套,递归时,系统仍会占用栈空间。

    堆(Heap)的默认设置0x200(512)字节。

Heap_Size EQU 0x200

    大部分人应该很少使用malloc来分配堆空间。虽然堆上的数据只要程序员不释放空间就可以一直访问,但是,如果忘记了释放堆内存,那么将会造成内存泄漏,甚至致命的潜在错误。

MDK中RAM占用大小分析

    经常在线调试的人,可能会分析一些底层的内容。这里结合MDK-ARM来分析一下RAM占用大小的问题。在MDK编译之后,会有一段RAM大小信息:

c语言-嵌入式专辑10~_嵌入式硬件_03

    这里4+6=1640,转换成16进制就是0x668,在进行在调试时,会出现:

c语言-嵌入式专辑10~_嵌入式硬件_04

  这个MSP就是主堆栈指针,一般我们复位之后指向的位置,复位指向的其实是栈顶:

c语言-嵌入式专辑10~_编译器_05

    而MSP指向地址0x20000668是0x20000000偏移0x668而得来。具体哪些地方占用了RAM,可以参看map文件中【Image Symbol Table】处的内容:

c语言-嵌入式专辑10~_头文件_06

五、嵌入式代码头文件的规则

1、头文件作用

C语言里,每个源文件是一个模块,头文件为使用该模块的用户提供接口。接口指一个功能模块暴露给其他模块用以访问具体功能的方法。使用源文件实现模块的功能,使用头文件暴露单元的接口。用户只需包含相应的头文件就可使用该头文件中暴露的接口。

通过头文件包含的方法将程序中的各功能模块联系起来有利于模块化程序设计:

1)通过头文件调用库功能。在很多场合,源代码不便(或不准)向用户公布,只要向用户提供头文件和二进制库即可。用户只需按照头文件中的接口声明来调用库功能,而不必关心接口如何实现。编译器会从库中提取相应的代码。

2)头文件能加强类型安全检查。若某个接口的实现或使用方式与头文件中的声明不一致,编译器就会指出错误。这一简单的规则能大大减轻程序员调试、改错的负担。

在预处理阶段,编译器将源文件包含的头文件内容复制到包含语句(#include)处。在源文件编译时,连同被包含进来的头文件内容一起编译,生成目标文件(.obj)。如果所包含的头文件非常庞大,则会严重降低编译速度(使用GCC的-E选项可获得并查看最终预处理完的文件)。因此,在源文件中应仅包含必需的头文件,且尽量不要在头文件中包含其它头文件。

2、 头文件组织原则

源文件中实现变量、函数的定义,并指定链接范围。头文件中书写外部需要使用的全局变量、函数声明及数据类型和宏的定义。

建议组织头文件内容时遵循以下原则:

1)头文件划分原则:类型定义、宏定义尽量与函数声明相分离,分别位于不同的头文件中。内部函数声明头文件与外部函数声明头文件相分离,内部类型定义头文件与外部类型定义头文件相分离。

注意,类型和宏定义有时无法分拆为不同文件,比如结构体内数组成员的元素个数用常量宏表示时。因此仅分离类型宏定义与函数声明,且分别置于* .th和*.fh文件(并非强制要求)。

2)头文件的语义层次化原则:头文件需要有语义层次。不同语义层次的类型定义不要放在一个头文件中,不同层次的函数声明不要放在一个头文件中。

3)头文件的语义相关性原则:同一头文件中出现的类型定义、函数声明应该是语义相关的、有内部逻辑关系的,避免将无关的定义和声明放在一个头文件中。

4)头文件名应尽量与实现功能的源文件相同,即module.c和module.h。但源文件不一定要包含其同名的头文件。

5)头文件中不应包含本地数据,以降低模块间耦合度。

即只有源文件自己使用的类型、宏定义和变量、函数声明,不应出现在头文件里。作用域限于单文件的私有变量和函数应声明为static,以防止外部调用。将私有类型置于源文件中,会提高聚合度,并减少不必要的格式外漏。

6)头文件内不允许定义变量和函数,只能有宏、类型(typedef/struct/union/enum等)及变量和函数的声明。特殊情况下可extern基本类型的全局变量,源文件通过包含该头文件访问全局变量。但头文件内不应extern自定义类型(如结构体)的全局变量,否则将迫使本不需要访问该变量的源文件包含自定义类型所在头文件[1]。

7)说明性头文件不需要有对应的源文件。此类头文件内大多包含大量概念性宏定义或枚举类型定义,不包含任何其他类型定义和变量或函数声明。此类头文件也不应包含任何其他头文件。

8)使用#pragma once或header guard(亦称include guard或macro guard)避免头文件重复包含。#pragma once是一种非标准但已被现代编译器广泛支持的技巧,它明确告知预处理器“不要重复包含当前头文件”。而header guard则通过预处理命令模拟类似行为:

#ifndef  _PRJ_DIR_FILE_H  //必须确保header guard宏名永不重名
#define  _PRJ_DIR_FILE_H

//<头文件内容>

#endif

使用#pragma once相比header guard具有两个优点:

  • 更快。编译器不会第二次读取标记#pragma once的文件,但却会读若干遍使用header guard 的文件(寻找#endif);
  • 更简单。不再需要为每个文件的header guard取名,避免宏名重名引发的“找不到声明”问题。
    缺点则是:
  • #pragma once保证物理上的同一个文件不会被包含多次,无法对头文件中的一段代码作#pragma once声明。若某个头文件具有多份拷贝(内容相同的多个文件),pragma不能保证它们不被重复包含。当然,这种重复包含很容易被发现并修正。
    9) C++中要引用C函数时,函数所在头文件内应包含extern "C"。
//.h文件头部
#ifdef  __cplusplus
extern "C" {
#endif

//<函数声明>

//.h文件尾部
#ifdef  __cplusplus
}
#endif

 被extern "C"修饰的变量和函数将按照C语言方式编译和连接,否则编译器将无法找到C函数定义,从而导致链接失败。

10)头文件内要有面向用户的充足注释,从应用角度描述接口暴露的内容。

3、 头文件包含原则

在实际编程中,常常因头文件包含不当而引发编译时报告符号未定义的错误或重复定义的警告。要消除符号未定义的编译错误,只需在引用符号(变量、函数、数据类型及宏等)前确保它已被声明或定义[4]。要消除重复定义的警告,则需合理设计头文件包含顺序和层次。

建议包含头文件时遵循以下原则:

1)源文件内的头文件包含顺序应从最特殊到一般,如:

#include "通用头文件"  //内部可能定义本模块数据类型别名
#include "源文件同名头文件"
#include "本模块其他头文件"
#include "自定义工具头文件"
#include "第三方头文件"
#include "平台相关头文件"
#include "C++库头文件"
#include "C库头文件"

优点是每个头文件必须include需要的关联头文件,否则会报错。同时,源文件同名头文件置于包含列表前端便于检查该头文件是否自完备,以及类型或函数声明是否与标准库冲突。

2)减少头文件的嵌套和交叉引用,头文件仅包含其真正需要显式包含的头文件。

例如,头文件A中出现的类型定义在头文件B中,则头文件A应包含头文件B,除此以外的其他头文件不允许包含。

头文件的嵌套和交叉引用会使程序组织结构和文件组织变得混乱,同时造成潜在的错误。大型工程中,原有头文件可能会被多个其他(源或头)文件包含,在原有头文件中添加新的头文件往往牵一发而动全身。若头文件中类型定义需要其他头文件时,可将其提出来单独形成一个全局头文件。

3)头文件应包含哪些头文件仅取决于自身,而非包含该头文件的源文件。

例如,编译源文件时需要用到头文件B,且源文件已包含头文件A,而索性将头文件B包含在头文件A中,这是错误的做法。

4)尽量保证用户使用此头文件时,无需手动包含其他前提头文件,即此头文件内已包含前提头文件。

例如,面积相关操作的头文件Area.h内已包含关于点操作的头文件Point.h,则用户包含Area.h后无需再手动包含Point.h。这样用户就不必了解头文件的内在依赖关系。

5)头文件应是自完备的,即在任一源文件中包含任一头文件而不会产生编译错误。

6)源文件中包含的头文件尽量不要有顺序依赖。

7)尽量在源文件中包含头文件,而非在头文件中。且源文件仅包含所需的头文件。

8)头文件中若能前置声明(亦称前向声明[5]),就不要包含另一头文件。仅当前置声明不能满足或过于麻烦时才使用include,如此可减少依赖性方面的问题。示例如下:

struct T_MeInfoMap;  //前置声明
struct T_OmciMsg;    //前置声明

typedef FUNC_STATUS (*OmciChkFunc)(struct T_MeInfoMap *ptMeInfo, struct T_OmciMsg *ptMsg, struct T_OmciMsg *ptAckMsg);


//OMCI实体信息
typedef struct{
    INT16U wMeClass;               //实体类别
    OMCI_ATTR_INFO *pMeAttrInfo;   //实体所定义的属性信息指针
    INT8U  ucAttrNum;              //实体所定义的属性数目
    INT16U wTotalAttrLen;          //实体所有属性所占的总字节数,初始化为0,动态计算
    INT8U  *pszDbName;             //实体存库时的数据表名称,建议不要超过DB_NAME_LEN(32)
    INT16U wMaxRecNum;             //实体存库时支持的最大记录数目
    OmciChkFunc fnCheck;           //Omci校验函数指针
    BOOL   bDbCreated;             //实体数据表是否已创建
}OMCI_ME_INFO_MAP;

如上,在OmciChkFunc函数的实现源文件内包含T_MeInfoMap和T_OmciMsg所在头文件即可。

另举一例如下:

typedef TBL_SET_MODE (*OperTypeFunc)(INT8U *pTblEntry);

typedef INT8U (*CmpRecFunc)(VOID *pvCmpData, VOID *pvRecData); //为避免头文件交叉引用,与CompareRecFunc异名同构

//表属性信息
typedef struct{
    INT16U wMaxEntryNum;         //表属性最大表项数目(实体记录数目wMaxRecNum * wMaxEntryNum <= MAX_RECORD_NUM)
    OperTypeFunc fnGetOperType;  //操作类型函数指针。根据表项数据或外界需求(只读表)解析当前表项操作类型
    TBL_KEY_INFO tCmpKeyInfo;    //检索表属性子表记录时的匹配关键字信息(TBL_KEY_INFO)
    CmpRecFunc   fnCmpAddKey;    //增加表项时需要检测的关键字匹配函数指针
    CmpRecFunc   fnCmpDelKey;    //删除表项时需要检测的关键字匹配函数指针
    INT16U wTblEntrySize;        //表属性表项字节数,由外部动态赋值
}TBL_ATTR_INFO;

如上,CompareRecFunc函数原型由其他头文件提供,此处为避免头文件交叉引用定义其异名同构原型CmpRecFunc。

在不会引起歧义的前提下,头文件内尽可能使用VOID指针代替非基本类型的值变量或指针,以避免再包含类型定义所在的头文件。但这将影响代码可读性并降低程序执行效率,应权衡利弊。

9)避免包含重量级的平台头文件,如windows.h或d3d9.h等。若仅使用该头文件少量函数,可extern函数到源文件内。如下:

/****************************************************************************************
                       外部函数声明 (当外部接口未提供头文件或头文件过于复杂时) 
 ****************************************************************************************/
//因声明所在头文件引用混乱,此处仅extern函数声明。
extern INT32S DBShmCliInit(VOID); //#include "db_shm_mgr.h"
extern INT32S cmLockInit(VOID);   //#include "common_cmapi.h"

若还使用该头文件某些类型和宏定义,可创建适配性源文件。在该源文件内包含平台头文件,封装新的接口并将其声明在同名头文件内,其他源文件将通过适配头文件间接访问平台接口。如下:

/*****************************************************************************************
* 文件名称:Omci_Send_Msg.c
* 内容摘要:OMCI消息转发接口
* 其它说明: 该头文件封装SEND接口,以避免其他源文件包含支撑api和pid公共头文件导致引用混乱。
 *****************************************************************************************/

#include "Omci_Common.h"
#include "Omci_Send_Msg.h"
#include "oss_api.h"

/**********************************************************************************************
                                         函数实现区
**********************************************************************************************/

//向自身进程发送异步消息
INT32U OmciAsynSendSelf(INT16U wEvent, VOID *pvMsg, INT16U wMsgLen)
{
    PID dwSelfPid = 0;
    SELF(&dwSelfPid);
    return ASEND(wEvent, pvMsg, wMsgLen, dwSelfPid);
}

10)对于函数库(包括标准库和自定义的公共宏及接口)的头文件,可将其加入到一个通用头文件中。需要控制该头文件的体积(主要是该头文件所包含的所有头文件内容大小),并确保所有源文件首先包含该通用头文件。示例如下:

#ifndef  _OMCI_COMMON_H
#define  _OMCI_COMMON_H

/*******************************************************************************************
* 说明:
* 本文件仅应包含与具体通信协议无关的通用数据类型及宏定义。
* 为简化头文件包含且不失可移植性,本文件内可包含少量C库通用头文件。
* 因本文件内定义基本数据类型别名,故.c文件中应将本头文件置于包含列表顶端,
* 否则编译时可能产生类型未定义错误。
*******************************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <limits.h>

#include "Omci_Byte.h"

//<Other Contents...>

注意,示例头文件内包含C库文件虽能简化包含,但却与规则1冲突。也可另外增加包含库文件列表的通用头文件。

11)若不确定类型、宏定义或函数声明所在头文件具体路径,可在源文件中再次定义或声明,编译器会以redefined警告或conflicting错误给出类型、宏定义或函数声明所在头文件路径。

4、代码文件组织原则

建议C语言项目中代码文件组织遵循以下原则:

1)使用层次化和模块化的软件开发模型。每个模块只能使用所在层和下一层模块提供的接口。

2)每个模块的文件(可能多个)保存在一个独立文件夹中。

模块文件较多时可采用子目录的方式,物理上隔离不同层次的文件。子目录下源文件和头文件应分开存放,如分别置入include和source目录。

3)用于模块裁减的条件编译宏保存在一个独立文件中,便于软件裁减。

4)硬件相关代码和操作系统相关代码与工程代码相对独立保存,以便于软件移植。

5)按相同功能或相关性组织源文件和头文件。同一文件内的聚合度要高,不同文件中的耦合度要低。

在对既有工程做单元测试时,耦合度低的文件布局非常便于搭建环境。

6)声明和定义分开,使用头文件暴露模块需要提供给外部的类型、宏、变量和函数。尽量做到模块对外部透明,用户在使用模块功能时无需了解具体的实现。

7)作为对外接口的头文件一经发布,应保持稳定。修改时一定要慎重。

8)文件夹和文件命名要能够反映出模块的功能。

9)正式版本和测试版本使用统一文件,使用宏控制是否产生测试输出。

10)必要的注释不可缺少。

5、 注解

【注1】全局变量的使用原则

1)若全局变量仅在单个源文件中访问,则可将该变量改为该文件内的静态全局变量;

2)若全局变量仅由单个函数访问,则可将该变量改为该函数内的静态局部变量;

3)尽量不要使用extern声明全局变量,最好提供函数访问这些变量。直接暴露全局变量是不安全的,外部用户未必完全理解这些变量的含义。

4)设计和调用访问动态全局变量、静态全局变量、静态局部变量的函数时,需要考虑重入问题。

【注2】#pragma once的可移植性

#ifndef由C/C++语言标准支持,不受编译器任何限制;而#pragma once仅由编译器提供保证,存在可移植性等问题。某些gcc编译器版本(如3.2.3)会报告“warning: #pragma once is obsolete”的警告,而其他较老版本的编译器可能会报错。但随着gcc 3.4的发布,#pragma once中的一些问题(主要与符号链接和硬链接有关)得以解决,#pragma once命令也标记为“未废弃”。

还有种写法同时使用#pragma once和header guard编写“可移植性”代码,以利用编译器可能支持的#pragma once优化。如下:

#pragma once
#ifndef    _PRJ_DIR_FILE_H
#define   _PRJ_DIR_FILE_H

//<头文件内容>

#endif

该法似乎兼有两者的优点。但既然使用#ifndef就有宏名重名的风险,也无法避免不支持#pragma once的编译器告警或报错,故混用两种方法似乎不能带来更多的好处,反倒让不熟悉的人感到困惑。

注意,如果使用header guard,理论上可在代码任何地方判断当前是否已经包含某个头文件。但应避免通过该判断来改变后续代码的逻辑走向!这种做法将使程序依赖于头文件的包含顺序,极不可取。若需要实现“若当前包含HeaderA.h,才加入StructB结构”,可对StructB结构创建HeaderB.h头文件,在HeaderA.h中包含HeaderB.h。

【注3】extern "C"

C++语言在编译时为实现函数重载,会结合函数名、参数数目及类型信息而生成一个中间函数名。例如,C++中函数void foo(int x, float y)编译后在符号库中生成的名字为_ foo_int_float(不同编译器可能生成不同函数名,但均采用相同机制,生成的新名字称为”mangled name”);而该函数被C编译器编译后在符号库中的名字为_foo。

C语言中不支持extern "C"声明,在.c文件中包含extern "C"时会出现编译语法错误。

当然编译器也可以为其他语言提供链接说明。例如:extern "FORTRAN"、extern "Ada"等。

【注4】声明(declaration)与定义(definition)

全局变量或函数可(在多个编译单元中)有多处声明,但只允许定义一次。全局变量定义时分配空间并赋初始值(如果有);函数定义时提供函数体内容。

#pragma once
#ifndef    _PRJ_DIR_FILE_H
#define   _PRJ_DIR_FILE_H

//<头文件内容>

#endif

在多个源文件中共享变量或函数时,需确保定义和声明的一致性。通常在某个相关的源文件中定义,然后在头文件中进行外部声明。需要使用时包含相应的头文件即可。定义变量的源文件也应包含该头文件,以便编译器检查定义和声明的一致性。

该规则可提供高度的可移植性:它与ANSI/ISO C标准一致,同时也兼顾大多数ANSI前的编译器和链接器。(Unix编译器和链接器常使用允许多重定义的“通用模式”,只要保证最多对一处定义进行初始化即可。该方式被ANSI C标准称为一种“通用扩展”)。某些很老的系统可能要求显式初始化以区别定义和外部声明。

通用扩展在《深入理解计算机系统》中解释为:多重定义的符号只允许最多一个强符号。函数和定义时已初始化的全局变量是强符号;未初始化的全局变量是弱符号。Unix链接器使用以下规则来处理多重定义的符号:

规则一:不允许有多个强符号。在被多个源文件包含的头文件内定义的全局变量会被定义多次(预处理阶段会将头文件内容展开在源文件中),若在定义时显式地赋值(初始化),则会违反此规则。

规则二:若存在一个强符号和多个弱符号,则选择强符号。

规则三:若存在多个弱符号,则从这些弱符号中任选一个。

当不同文件内定义同名(即便类型和含义不同)的全局变量时,该变量共享同一块内存(地址相同)。若变量定义时均初始化,则会产生重定义(multiple definition)的链接错误;若某处变量定义时未初始化,则无链接错误,仅在因类型不同而大小不同时可能产生符号大小变化(size of symbol `XXX' changed)的编译警告。在最坏情况下,编译链接正常,但不同文件对同名全局变量读写时相互影响,引发非常诡异的问题。这种风险在使用无法接触源码的第三方库时尤为突出。

因此,应尽量避免使用全局变量。若确有必要,应采用静态全局变量(无强弱之分,且不会和其他全局符号产生冲突),并封装访问函数供外部文件调用。

【注5】前向声明(forward declaration)

结构体类型S在声明之后定义之前是一个不完全类型(incomplete type),即已知S是一个类型,但不知道包含哪些成员。不完全类型只能用于定义指向该类型的指针,或声明使用该类型作为形参指针类型或返回指针类型的函数。指针类型对编译器而言大小固定(如32位机上为四字节),不会出现编译错误。

假设先后定义两个结构A和B,且两个结构需要互相引用。在定义A时B还没有定义,则要引用B就需要前向声明结构B(struct B;)。示例如下:

typedef BOOL (*func)(const DefStruct *ptStrt);
 
typedef struct DefStruct_t
{
    int i;
    func f;
}DefStruct;

如上在DefStruct中使用回调函数func声明,这样交叉引用必然编译报错。进行前向声明即可:

typedef struct DefStruct_t DefStruct;
typedef BOOL (*func)(const DefStruct *ptStrt);

struct DefStruct_t
{
    int i;
    func f;
};

注意,在前向声明和具体定义之间涉及标识符(变量、结构、函数等)实现细节的使用都是非法的。若函数被前向声明但未被调用,则编译和运行正常;若前向声明函数被调用但未被定义,则编译正常但链接报错(undefined reference)。将具体定义放在源文件中可部分避免该问题。