自学内容网 自学内容网

嵌入式C语言自我修养:C语言的模块化的编程思想

不同模块如何集成到系统中去?

模块的编译和链接

一个C语言项目划分成不同的模块,通常由多个文件来实现。在项目编译过程中,编译器是以C源文件为单位进行编译的,每一个C源文件都会被编译器翻译成对应的一个目标文件。链接器对每一个目标文件进行解析,将文件中的代码段、数据段分别组装,生成一个可执行的目标文件。

如果程序调用了库函数,则链接器也会找到对应的库文件,将程序中引用的库代码一同链接到可执行文件中。

在链接过程中,如果多个目标文件定义了重名的函数或全局变量,就会发生符号冲突,报重定义错误。这时候链接器就要对这些重复定义的符号做符号决议,决定哪些留下,哪些丢弃。

在一个多文件项目中,不允许有多个强符号。

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

若存在多个弱符号,则选择占用空间最大的那一个。

初始化的全局变量和函数是强符号,未初始化的全局变量。默认属于弱符号。

可以通过__attribute__属性声明显式更改符号的属性,将一个强符号显式转换为弱符号。

整个项目编译过程中,可以通过编译控制参数来控制编译流程:预处理、编译、汇编、链接,也可以指定多个文件的编译顺序。

通常使用自动化编译工具make来编译项目,make自动编译工具依赖项目的Makefile文件。

Makefile文件主要用来描述各个模块文件的依赖关系,要生成的可执行文件,需要编译哪些源文件。

make在编译项目时,会首先解析Makefile,分析需要编译哪些源文件,构建一个完整的依赖关系树,然后调用具体的命令一步步去生成各个目标文件和最终的可执行文件。

系统模块划分

根据系统的目标、实现的功能进行划分;当系统比较复杂时,对系统进行分层。

面向对象编程和系统的模块化设计侧重点不同。模块化设计的思想

内核是分而治之,重点在于抽象的对象之间的关联,而不是内容;面向对象编程思想主要是为了代码复用,重点在于内容实现。两者还有一个重要的区别是:两者不在同一个层面上。模块化设计是最高原则,先有系统定义,然后有模块和模块的实现,最后才有代码复用。一个系统不仅仅是模块的实现,还有各个模块之间的相互作用、相互关联,以及由它们构成的一个有机整体。面向对象编程,通过类的封装和继承实现了代码复用,减少了开发工作量,这是面向对象编程的长处。

模块的封装

在C语言中一个模块一般对应一个C文件和一个头文件。模块的实现在C源文件中,头文件主要用来存放函数声明,留出模块的API,供其他模块调用。

头文件深度剖析

编译器在编译各个C源文件的过程中,如果该C文件引用了其他文件中定义的函数或变量,编译器也不会报错,链接器在链接的时候会到这个文件里查找你引用的函数,如果没有找到才会报错。但是编译器为了检查你的函数调用格式是否存在语法错误,形参实参的类型是否一致,会要求程序员在引用其他文件的全局符号之前必须先声明,如变量的类型、函数的类型等,编译器会根据你声明的类型对编写的程序语句进行语法、语义上的检查。为了方便,将函数的声明直接放到头文件里,作为本模块封装的API,供其他模块使用。程序员在其他文件中如果想引用这些API函数,则直接#include这个头文件,然后直接调用。

变量的声明和定义的区别:是否分配内存是区分定义和声明的唯一标准。

一个变量的定义最终会生成与具体平台相关的内存分配汇编指令。

变量的声明则告诉编译器,该变量可能在其他文件中定义,编译时先不要报错,等链接的时候可以到指定的文件里去看看有没有,如果有就直接链接,如果没有则再报错也不迟。一个变量只能定义一次,即只能分配一次存储空间,但是可以多次声明。一般变量的定义要放到C文件中,不放到头文件中,因为头文件可能被多人使用,被多个文件包含,头文件经过预处理器多次展开之后也就变成了多次定义。

头文件重复包含

如果在一个项目中多次包含相同头文件,编译器也不会报错,因为预处理器在预处理阶段已经将头文件展开了:一个变量或函数可以有多次声明,这是编译器允许的。如果在头文件里定义了宏或一种新的数据类型,头文件再多次包含展开,编译器在编译时可能会报重定义错误。为了防止这种错误产生,可以在头文件中使用条件编译来预防头文件的多次包含。

隐式声明

(ANSI C标准支持,但C99/C11/C++标准已禁止)

如果一个C程序引用了在其他文件中定义的函数而没有在本文件中声明,编译器也不会报错,编译器会认为这个函数可能会在其他文件中定义,等链接的时候找不到其定义才会报错,而是会给一个警告信息并自动添加一个默认的函数声明。这个声明我们称为隐式声明。

函数的隐式声明可能与自定义函数冲突,如果我们引用库函数而没有包含对应的头文件,也有可能与库函数发生类型冲突。这些函数类型冲突虽然不影响程序的正常运行,但是会给程序带来很多无法预料的深层次bug,在不同的编译环境下,函数的运行结果甚至可能都不一样。为了编写高质量稳定运行的程序,我们要养成“先声明后使用”的良好编程习惯。

变量的声明与定义

extern关键字对外部文件的符号进行声明

extern int i;
extern int a[20],
extern struct student stu;
extern int function();
extern“c” int function();

extern声明的变量或函数在别的文件里定义,在本文件使用,告诉编译器不要报错。

//i.c
int i = 10;
int a[10]={1,2,3,4,5,6,7,8,9};
struct student
int age;
int num;
//main.c
#include <stdio.h>
extern int i;
extern int a[10];
struct student{
int age;
int num;
extern struct student stu;
extern int k;
int main(void)
printf("%s:i =%d\n",_func_,i);
for(int j=0;j<10;j++)
printf("a[%d]:%d\n",j,a[j]);
printf("stu.age =%d,num = %d\n",stu.age, stu.num);
printf("%s:k=%d\n",_func_,k);
return 0;
如何区分定义和声明

定义的本质就是为对象分配存储空间,声明则将一个标识符与某个C语言对象相关联(函数、变量等)。

声明函数原型为了提供给编译器做函数参数格式检查,声明一个变量,是为了告诉编译器,这个变量已经在别的文件里定义,我们想在本文件里使用它。在C语言中,我们可以声明各种各样的标识符:变量名、函数名、类型、类型标志、结构体、联合、枚举常量、语句标号、预处理器宏等。

(1)从extern的角度区别定义和声明:

如果省略了extern且具有初始化语句,则为定义语句。如int i=10。

如果使用了extern,无初始化语句,则为声明语句。如extern int i;。

如果省略了extern且无初始化语句,则为试探性定义。如int i;。

试探性定义(int i)。该变量可能在别的文件里有定义,所以先暂时定为声明。若别的文件里没有定义,则按照语法规则初始化该变量i,并将该语句定性为定义。一般这些变量会初始化为一些默认值:NULL、0、undefined values等。

(2)编译链接的角度去分析int i:

对于未初始化的全局变量,它是一个弱符号,先定性为声明。如果其他文件里存在同名的强符号,那么这个强符号就是定义,把这个弱符号看作声明没毛病;如果其他文件里没有强符号,那么只能将这个弱符号当作定义,为它分配存储空间,初始化为默认值。

前向引用和前向声明

无论声明什么类型的标识符,先声明后使用。

因为早期的编译器鉴于计算机内存资源限制,不可能同时编译多个文件,所以只能采取单独编译。编译器为了简化设计,采用了one-pass compiler设计,每个源文件只编译一次,这也决定了C语言“先声明后使用”的使用原则。

"先声明后使用"指一个标识符要在声明完成之后才能使用,在声明完成之前不能使用。

什么是声明完成呢?

一个变量的声明无非就是声明其类型,声明是给编译器看的,是为了应付编译器语法检查的。如果已经让编译器知道了这个标识符的类型,那么就认为声明完成了。

在C语言中,并不是所有的标识符都需要先声明后使用。

如果一个标识符在未声明完成之前就引用,被称为前向引用。

在C语言中,有3个可以前向引用的特例。

● 隐式声明

● 语句标号:跳转向后的标号时,不需要声明,可以直接使用。

● 不完全类型:在被定义完整之前用于某些特定用途。

语句标号:使用C语言的goto关键字可以往前跳,也可以往后跳,不需要对语句标号进行事先声明。

不完全类型是 指 那些尚未提供完整信息的类型。这种类型的对象或指针可以被声明和使用,但它们不能被定义(即分配存储空间。

(1)数组类型未知大小:

当数组声明时没有指定大小,例如 int a[]; 这样一个数组就是一个不完全类型。在这种情况下,编译器不知道数组的确切大小,因此无法为它分配具体的内存空间。但是你可以有一个指向该数组类型的指针,比如 int *p = a; 只要之后通过其他方式明确了数组的大小,就可以正常使用了。

(2)结构体或联合体类型未知内容:如果结构体或联合体只被命名而没有给出其成员的定义,那么这个结构体或联合体就是一个不完全类型。

定义与声明的一致性
//add.h
int add(int a, int b);
//add.c
#include"add.h"
int add(int a, int b)
return a+b;
//main.c
#include "add.h"
int main(void){
  int sum; 
  sum = add(1,2);
  return 0;
}

在模块里包含自己的头文件,可以使用头文件中定义的宏或数据类型,还有一个好处就是可以让编译器检查定义与声明的一致性。在模块的封装中,接口函数的声明和定义是在不同的文件里分别完成的,很多人在编程时可能比较粗心,一个函

数在声明和定义时的类型可能不一致,但是编译器又是以文件为单位进行编译的,无法检测到这个错误,那该怎么办?很简单,我们把一个函数的声明和定义放到一个文件中,编译器在编译时就会帮我们进行自检:检查一个函数的定义和声明是否一致,避免出现低级错误。

头文件路径

头文件两种包含方法

#include <stdio.h>
#include“module.h”

<>表示引用标准库的头文件

如果你使用的头文件是自定义的或项目中的头文件,一般使用双引号""包含。头文件路径一般分为绝对路径和相对路径:绝对路径以根目录“/”

编译器在编译过程中会按照这些路径信息到指定的位置查找头文件,然后通过预处理器做展开处理。在查找头文件的过程中,编译器会按照默认的搜索顺序到不同的路径下去搜索。

使用<>包含头文件:

GCC参数gcc-I指定的目录->环境变量CINCLUDEPATH指定的目录->GCC的内定目录。当不同目录下存在相同的头文件时,先搜到哪个就使用哪个,搜索到头文件后不再往下搜索。

在程序编译时,如果头文件没有放到官方路径下面,那么可以通过gcc-I来指定头文件路径,编译器在编译程序时,就会到用户指定的路径目录下面去搜索该头文件。如果你不想通过这种方式,也可以通过环境变量来添加头文件的搜索路径。

用双引号""来包含头文件路径:

编译器会首先在项目当前目录搜索需要的头文件,如果在当前项目目录下搜不到,则再到其他指定的路径下去搜索。

项目当前目录->通过GCC参数gcc-I指定的目录->环境变量CINCLUDEPATH指定的目录->GCC的内定目录.

常见的内定目录

/usr/include
/usr/local/include
/usr/include/i386-linux-gnu
/usr/lib/gcc/i686-linux-gnu/5/include
/usr/lib/gcc/i686-linux-gnu/5/include-fixed
/usr/lib/gcc-cross/arm-linux-gnueabi/5/include
头文件中的内联函数

使用inline关键字修饰的函数称为内联函数。内联函数也是函数,它和普通函数的不同之处在于,编译器在编译内联函数时,会根据需要在调用处直接展开,从而省去了函数调用开销。对于一些频繁调用而又短小精悍的函数,如果我们将其声明为内联函数,编译器编译时像宏一样展开,可以提升程序的运行效率。内联函数和宏相比,除了能像宏一样在调用处直接展开,在参数传递、参数检查、返回值等方面比宏更有优势。正是这种优势,内联函数在C语言中被广泛使用。但是函数虽然变成了内联函数,但是在编译的时候会不会展开还得由编译器决定。

内联函数一般定义在.C文件中,但是内联函数也可以定义在头文件中。一般来讲,变量和函数是不能在头文件中定义的,因为该头文件可能被多个C文件包含,当被预处理器展开后就变成了多次定义,很可能报重定义错误。当多个模块引用该头文件时,内联函数在编译时已经在多个调用处展开,不复存在了,不存在重定义问题。即使编译器没有对内联函数展开,也可以在内联函数前通过添加一个static关键字将该函数的作用域限制在本文件内,从而避免了重定义错误的发生。

static inline void func(int a, int b);
模块设计原则

高内聚低耦合是模块设计的基本原则。

高内聚:各个模块在实现各自功能的时候,要自己的事自己做,自己的功能自己实现,尽量不麻烦其他模块。一个模块要想实现高内聚,首先模块的功能要尽可能单一,一个功能由一个模块实现,这样才能体现模块的独立性,进而实现高内聚。要尽量调用本模块实现的函数,减少对外部函数的依赖,这样可以进一步提高模块的独立性,提高模块的内聚度。

与模块内聚对应的是模块耦合。

模块耦合指的是模块间的关联和依赖,包括调用关系、控制关系、数据传递等。模块间的关联越强,耦合度就越高,模块的独立性越差,内聚度也就随之越低。

不同模块之间有不同的耦合方式。

●非直接耦合:两个模块之间没有直接联系。

●数据耦合:通过参数来交换数据。

●标记耦合:通过参数传递记录信息。

●控制耦合:通过标志、开关、名字等,控制另一个模块。

●外部耦合:所有模块访问同一个全局变量。

我们在设计模块时,要尽量降低模块的耦合度。

低耦合有很多好处,如可以让系统的结构层次更加清晰,升级维护起来更加方便。在C语言程序中,我们可以通过下面的常用方法降低模块的耦合度。

● 接口设计:隐藏不必要的接口和内部数据类型,模块的API封装在头文件中,其余函数使用static修饰。

● 全局变量:尽量少使用,可改为通过API访问以减少外部耦合。

● 模块设计:尽可能独立存在,功能单一明确,接口少而简单。

● 模块依赖:模块之间最好全是单向调用,上下依赖,禁止相互调用。

被误解的关键字:goto

goto不是一无是处,其无条件跳转的特性有时候会大大简化程序的设计。如有多个出错出口的函数,我们可以使用goto将函数内的出错指定一个统一的出口,统一处理,反而会使函数的结构更加清晰。

int func(void){
  dosomething;
  if(expr1) goto err;
     do sth;
  if(expr2)
     goto err;
   return @;
err:
   return -1
}

通过代码复用,将一个函数多个出口归并为一个总出口,然后在总出口处对出错统一处理,释放malloc() 申请的动态内存、释放锁、文件句柄等资源。通过函数内部这种模块化的设计,既提高了效率,又不会破坏程序原来的结构。

在一个多重循环程序中,如果我们想从最内层的循环直接跳出,则需要多次使用break和return,层层退出才能达到预期目的,使用goto无条件跳转,简单粗暴,一步到位。

for(cxpr1){
for(expr2){
for(expr3){
for(expr4){
if(expr5)
   goto endloop;
}}}}
  return @;
endloop:
  return -1;

注意事项:goto在使用的过程中,也有一些需要注意的地方,如只能往下跳,不能往上跳。使用goto只能在同一函数内跳转,函数内goto标签的位置也有一定的讲究,goto标签一般在函数体内两段不同逻辑功能代码的交界处,用来区分函数内的模块化设计和逻辑关系。

模块间通信

系统内的各个模块可以通过各种耦合方式进行通信

全局变量

各个模块共享全局变量是各个模块之间进行数据通信最简单直接的方式。一个全局变量具有文件作用域,但是我们可以通过extern关键字将全局变量的作用域扩展到不同的文件中,然后各个模块可以通过全局变量进行通信了。

但是这样耦合度比较高,改进的方法:对全局变量的直接访问修改为通过函数接口间接访问。就像类的私有成员一样,该全局变量只能在一个模块中创建或直接修改,如果其他模块想要访问这个全局变量,则只能通过引出的函数读写接口进行访问。

//module.h
void val set(int value);
int val_get(void);
//module.c
int global val = 10;
void val set(int value){
  global_val = value;
}
int val_get(void){
  return global _val;
}

linux内核模块1:

#include <linux/init.h>
#include <linux/module.h>
MODULE_LICENSE("GPL");
int global_val = 10;
EXPORT_SYMBOL(global_val);
int get_global_val_value(void){
    return global_val;
}
void set_global_val_value(int a){
    global_val = a;
}
static int __init module1_init(void){
    printk(KERN_INFO "hello module1!\n");
    printk(KERN_INFO "module1:global_val = %d\n", global_val);
    return 0;
}
static void __exit module1_exit(void)
{
    printk(KERN_INFO "goodbye, module1!\n");
}
module_init(module1_init);
module_exit(module1_exit);

linux内核模块2:

#include <linux/init.h>
#include <linux/module.h>
#include <asm-generic/errno.h>
MODULE_LICENSE("GPL");
extern int global_val;
static int __init module2_init(void){
   printk(KERN_INFO "hello module2!\n");
   printk(KERN_INFO "module2:global_val = %d\n", global_val);
   return 0;
}
static void __exit module2_exit(void){
   printk(KERN_INFO "goodbye, module2!\n");
}
module_init(module2_init);
module_exit(module2_exit);

两个模块编译后insmod到内核中,模块2可以访问到模块1的全局变量。

通过共享全局变量进行模块间通信,实现最简单,也最容易理解,因此在各个项目中被广泛使用,包括在生产者-消费者模型中常用的共享缓冲区,其实也是基于这个思想设计的。

回调函数

一个系统的不同模块还可以通过数据耦合、标记耦合的方式进行通信,即通过函数调用过程中的参数传递、返回值来实现模块间通信。

// module.c
#include <stdio.h>
int send_data(char *buf, int len) {
    char data[100];
    int i;
    for(i = 0; i < len; i++)
        data[i] = buf[i];
    for(i = 0; i < len; i++)
        printf("received data[%d] = %d\n", i, data[i]);
    return len;
}
// main.c
#include <stdio.h>
#include "module.h"
int main(void) {
    char buffer[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
    int return_data;
    return_data = send_data(buffer, 10);
    printf("send data len:%d\n", return_data);
    return 0;
}

通过send_data()函数将main.c模块buffer 中 的 数 据 传 递 到 module.c 模 块 并 进 行 打 印 , 同 时 通 过send_data()函数的返回值将数据传递的长度信息从module.c模块反馈给main.c模块。

这种通信方式是单向调用的,无法实现双向通信。一个系统通过模块化设计,各个模块之间最理想的关系是一种上下依赖的关系,每一层的模块都是对下一层的封装,并留出API供上一层调用。每一层的模块只能主动调用下一层模块提供的API,然后自己封装成API供上一层的模块调用。但是当底层的模块想主动与上一层的模块进行通信时,该如何实现?

model.h

#ifndef RUNCALLBACK_H
#define RUNCALLBACK_H
// 声明回调函数类型
typedef void (*callback_func)(void);
// 声明运行回调函数的函数
void runcallback(callback_func fp);
#endif // RUNCALLBACK_H

model.c

#include <stdio.h>
#include "module.h"
// 定义运行回调函数的函数
void runcallback(callback_func fp){
    // 调用传入的回调函数
    fp();
}

main.c

#include <stdio.h>
#include "module.h"
// 定义两个回调函数
void func1(void)
{
    printf("func1...\n");
}
void func2(void)
{
    printf("func2...\n");
}
// 主函数
int main(void)
{
    // 运行回调函数
    runcallback(func1);
    runcallback(func2);
    return 0;
}

通过回调函数的设计,两个模块之间可以实现双向通信。模块之间通过函数调用或变量引用产生了耦合,也就有了依赖关系。因此为了 减少模块间的耦合性,我们可以在两个模块之间定义一个抽象接口。

device_manager.h


#ifndef STORAGE_DEVICE_H
#define STORAGE_DEVICE_H
typedef int (*read_fp)(void);
struct storage_device {
    char name[20];
    read_fp read;
};
extern int register_device(struct storage_device dev);
extern int read_device(const char* device_name);
#endif // STORAGE_DEVICE_H

device_manager.c

#include <stdio.h>
#include <string.h>
#include "device_manager.h"
struct storage_device device_list[100] = {0};
unsigned char num = 0;
int register_device(struct storage_device dev) {
    device_list[num++] = dev;
    return 0;
}
int read_device(const char* device_name) {
    int i;
    for (i = 0; i < 100; i++) {
        if (!strcmp(device_name, device_list[i].name)) {
            return device_list[i].read();
        }
    }
    printf("Error! Can't find device: %s\n", device_name);
    return -1;
}

main.c

#include <stdio.h>
#include "device_manager.h"
int sd_read(void) {
    printf("SD read data...\n");
    return 10;
}
int udisk_read(void) {
    printf("Udisk read data...\n");
    return 20;
}
int main(void) {
    struct storage_device sd = {"sdcard", sd_read};
    struct storage_device udisk = {"udisk", udisk_read};
    register_device(sd); // 高层模块函数注册,以便回调
    register_device(udisk);
    read_device("udisk"); // 实现回调, 控制反转
    read_device("sdcard");
    return 0;
}

device_manager.h 中,我们定义了一个抽象接口 read_fp,它是一个函数指针类型,指向一个没有参数并返回 int 类型的函数。这个接口允许任何设备实现自己的 read 函数,而主模块不需要知道具体的实现细节。

同一个头文件中,我们定义了一个 struct storage_device 结构体,它包含一个设备名称和一个指向 read_fp 类型函数的指针。这个结构体就是模块间通信的接口。

device_manager.c 中,实现了 register_deviceread_device 函数。register_device 函数允许设备注册自己的 read 函数,而 read_device 函数通过设备名称查找并调用相应的 read 函数

异步通信

函数调用,回调函数都是同步阻塞式调用。

CPU在访问一个资源时,如果资源没有准备好需要等待,CPU什 么也不干,原地打转干等就是同步通信,CPU去干其他事情,等资源准 备好了通知CPU,CPU再来访问就是异步通信。

常用的异步通信如下。

● 消息机制:具体实现与平台相关。

● 事件驱动机制:状态机、GUI、前端编程等。

● 中断。

● 异步回调。

在Linux操作系统中,各个模块间也会采用不同的异步通信方式进行通信:Linux内核模块之间可以使用notify机制进行通信;内核和用户之间可以通过AIO、netlink进行通信;用户模块之间异步通信的方

式更多,除了操作系统支持的管道、信号、信号量、消息队列,还可以使用socket、PIPE、FIFO等方式进行异步通信(?)


原文地址:https://blog.csdn.net/m0_52043808/article/details/142369909

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!