User Tools

Site Tools


tech:programming:c_language

理解C语言

编程模型

c语言是系统级编程语言,对于运行时支持的要求最小,本身不提供复杂的数据结构,任何扩展性的功能哪怕是基本的输入输出操作都需要依赖外部库。可以这样讲,C是高级语言中的低级语言,它提供的是某种对机器的最基本的抽象,因此,理解机器本身是理解C语言的核心。

下图给出了一个简单的机器抽象,以此来剖析C语言的编程模型。

首先是一个负责执行代码的中央处理器抽象。它需要完成所有运算,如“+”、“-”、“*”、“/”等算术运算及“&”、“|”、“^”等位逻辑运算;它需要实现各种程序跳转,如条件分支、switch和循环等;它还需要实现函数的调用和返回。

在中央处理器的抽象中存在一个寄存器组,所有运算的临时数据都保存在寄存器组中,寄存器组由若干固定长度的寄存器组成,这个长度又因机器的不同而不同,可能是16位、32位、亦或64位……C语言提供register关键字来修饰变量,以求将该变量存储在寄存器中,然而这种请求并不一定会被满足,因为寄存器的数量是非常有限的,通常也就在十几个左右。

其次是线性内存,这是一个以字节(8比特)为基本单位的线性序列,每个字节单元都有相应的编号,即“地址”,这个编号是线性增加的。除register修饰的变量之外(当然前提是这个修饰是成功的),所有其它变量都存放在线性内存中,变量占用的字节单元的数目并非固定,sizeof关键字可以给出这个值。不同性质的变量存放的区域也不相同:函数内部定义的局部变量存放在栈区,函数外部定义的全局变量和使用static关键字修饰的静态变量存放在静态区,运行期间分配获取的内存属于堆区,而代码部分如某个函数则存放在代码区。使用&关键字可以获取一个变量的地址,也即这个变量占用的第一个字节单元的地址。下面一个示例程序可以反映线性内存的不同区域:

#include <stdio.h>
#include <stdlib.h>
 
int static1;
 
void foo()
{
    return;
}
 
int main()
{
    static int static2 = 5;
    int local1;
    int local2;
    char* local3 = (char *)malloc(10);
    void (*fp)() = foo;
 
    printf("Stack Area:\t");
    printf("local1@%p, local2@%p, local3@%p\n", &local1, &local2, &local3);
    printf("Static Area:\t");
    printf("static1@%p, static2@%p\n", &static1, &static2);
    printf("Heap Area:\t");
    printf("Heap pointer@%p\n", local3);
    printf("Code Area:\t");
    printf("foo@%p\n", fp);
 
    return 0;
}

程序中定义了一个全局变量static1,一个静态变量static2,三个局部变量local1、local2和local3,其中local3是一个指向堆内存的指针,另外还定义了一个函数指针fp。程序在32位PC上运行的结果如下:

Stack Area:	local1@0xfff2bd20, local2@0xfff2bd24, local3@0xfff2bd28
Static Area:	static1@0x804a024, static2@0x804a020
Heap Area:	Heap pointer@0x9eb7008
Code Area:	foo@0x8048414

可以看出,局部变量存放在栈区,地址值很大;静态变量和函数分别存放于静态区和代码区,地址值最小,而且似乎这两个区域是邻近的;堆内存则位于堆区,地址值为0x09ebxxxx。值得注意的是,某些机器结构允许有多个线性内存空间,譬如代码区可能位另一个独立的线性内存空间中,在这种情况下,某个函数的地址可能与某个变量的地址相同,但这并不意味着它们占用相同的内存单元。

存放在线性内存的变量只在程序的一次运行中有效,假如某个C程序执行结束后再次执行,任何一种类型的变量都无法保存上次程序退出之前的值。

最后是文件系统。c语言中不提供文件系统的直接支持,所有文件相关的操作都需要使用标准输入输出库或其他第三方库来实现。在C语言中,文件是对数据输入输出的一个较高层次的抽象,所谓数据就是线性内存中的一段字节序列,文件也并非一定是某一非挥发性存储介质上的数据块,还可以是串口等输入输出设备,甚至是另一段内存——这完全由文件系统的实现来决定。

变量和类型

C语言是一种强类型语言,类型在代码的执行过程中起着关键性的作用,因此,程序员必须在定义或声明变量的时候显式指定类型,更需要注意代码执行过程中无处不在的显式或者隐式的类型转换。c语言本身仅支持与硬件相匹配的至简的类型,如int、char等,更高级的类型如字符串、队列、哈希表、集等,则需要借助结构体、数组和指针等扩展机制来实现。结构体和数组的基本功能是对多个类型进行组合,其中,数组是多个相同类型变量的组合,结构体是不同类型变量的组合;数组的成员是连续的,通过[]操作符和下标来访问,结构体的成员通过.或→操作符加成员名来访问。

C语言的内置基本类型是抽象的,如int、char——在不同的运行平台中,这些基本类型可能并不相同:16位的微控制系统中,int往往是两个字节,而对于32位的通用处理器,int通常是四个字节。

如前所述,任一个变量和函数在被使用之前都必须有一个显式的声明,声明的目的是揭示这些变量和函数的存在。一个变量的声明往往是这样的:

unsigned int counter;
void (*fp) (int arg);
char * str_list[];

这些声明企图说明的是counter是一个无符号的整型;fp是一个没有返回值但有一个整型参数的函数指针;str_list十一个char指针类型的数组。

函数的声明往往是这样的:

int foo();

foo是一个返回值为整型的函数,不需要传入参数。

由于许多函数和变量会被分散在多个源文件中的代码所使用,而在每个源文件的起始重复声明这些函数和变量并不像个好主意,因此C语言提供了include预编译关键字,允许在编译某个源文件之前将存放在其他文件的公共声明拷贝过来。存放公共声明的文件称为头文件,扩展名往往为“h”。

指针

指针是内存地址的抽象,通过*操作符访问指针可以得到对应地址上的变量的值。指针支持加、减和[]操作,操作的意义与指针的类型密切相关。

在某种意义上,一维数组变量和指针变量是一致的,但针对某些操作,它们的表现又不尽相同。

#include <stdio.h>
 
int main()
{
   int array[5] = {1, 2, 3, 4, 5};
   int *ptr = array;
 
   printf("%p\n", array);
   printf("%p\n", ptr);
   printf("%d\n", array[3]);
   printf("%d\n", ptr[3]);
   printf("%p\n", array+1);
   printf("%p\n", ptr+1);
   printf("%p\n", &array);
   printf("%p\n", &ptr);
   printf("%p\n", &array+1);
   printf("%p\n", &ptr+1);
   printf("%d\n", sizeof(array));
   printf("%d\n", sizeof(ptr));
 
   return 0;
}

以上代码在32位PC上的输出为:

0031F870
0031F870
4
4
0031F874
0031F874
0031F870
0031F864
0031F884
0031F868
20
4

可以看出,int数组array和int指针ptr对于操作+和[]的表现是相同的,都表征着一个整型指针类型的属性,但对于&操作和sizeof操作的表现却不一样,对array进行&操作前后的结果是相同的,对ptr进行&操作得到的却是另一个指向该指针的地址,继续深入又将发现&array和array虽然值相等,意义并不相同,实际上,&array形成了一个指向长度为5的数组的指针,因此&array+1的值在array的基础上足足增加了20,这是由于array还具备数组的属性。同理,sizeof(array)和sizeof(ptr)的结果也不相同。

再如:

#include <stdio.h>
 
static int array[4][4] =
{
    {11, 12, 13, 14},
    {21, 22, 23, 24},
    {31, 32, 33, 34},
    {41, 42, 43, 44}
};
 
int main(int argc, _TCHAR* argv[])
{
    int (*coeff[2])[4];
    int i;
 
    coeff[0] = array;
 
    for (i = 0; i < 4; i++)
        printf("%d ", coeff[0][0][i]);
 
    for (i = 0; i < 4; i++)
        printf("%d ", coeff[0][3][i]);
 
    return 0;
}

在以上代码中,coeff是一个长度为2的指针数组,指针指向的类型是长度为4的整型数组,coeff[i]和array其实是一样的,假如对coeff[0]进行++操作会使其值增长4*sizeof(int)。代码的输出结果如下:

11 12 13 14 41 42 43 44

函数

c程序由一系列的函数组成,在c语言中,没有专门的子程序语法,子程序由函数实现,求cosine值可以用一个float cos(float x)的函数来实现;而开启打印机同样可以用一个bool start_printer(char * printer_name)来实现。

值传递是c函数参数传递的唯一方式,函数执行时参数的值是函数调用者设定的参数值的一个拷贝,而实际上,如果定义参数的类型为指针,则可实现等同与引用传递的参数传递方式。

程序结构

c提供两种分支结构和三种循环结构,满足结构化编程的要求,最后为C++和JAVA所继承。

此页面内容陈旧,正在更新:!:

头文件的更新

如果一个模块的接口发生改变,要同时更新头文件和库文件。如果库文件已经更新而头文件依然采用旧的版本,会出现结构体成员变量访问越界的情况,导致程序的存储区损坏,程序运行出错。

头文件的作用

  • 提供库文件的说明,包括版本版权信息,预处理块,宏定义,结构体定义,函数声明等。(接口头文件)
  • 存放多个源文件固定使用的宏,全局变量,文件包含命令等。 (内部头文件)

全局变量

不同文件中定义同名全局变量不会产生编译错误(只有一个定义,其它认为是声明),但初始化只能有一次(认为是定义)。这造成不同模块中定义的同名全局变量会相互影响。为了避免这种影响,可以使用static关键字,将全局变量的可见范围限制到一个文件内。

变量的存储

堆变量存储在动态存储区(自由存储区),全局变量和static关键字修饰的局部变量存储在静态存储区,其它局部变量存储在栈区。(具体的物理存储情况依赖于编译器及程序的运行平台)

函数的返回值

函数不可以返回指向栈区的指针变量,因为该指针所指向的内存单元将在函数调用结束时被销毁。

有效性检查

需要对函数的输入参数(特别是指针)以及其他途径进入函数体的变量(如全局变量)进行有效性检查。使用指针之前需要检查是否为空,不用的指针(如初始化之前的指针或所指向的内存块被释放后的指针)置为空。

数组的长度

sizeof运算符对数组名操作可以得到数组的长度(以字节为单位),但是,数组作为函数参数时,该数组退化为同类型的指针,sizeof运算符就无法得到数组的长度了[1]。C语言对数组下标越界不做检查,由程序设计者自己保证程序的正确。

字符串的处理

  • 字符串常量存储在静态存储区中,以‘\0’结尾。
  • 字符串的处理通过字符数组来实现,字符数组可以使用普通数组的初始化方法进行初始化,也可以使用字符串常量进行初始化,在前一种情况下,为了便于相应的处理,一般要在数组中人为地加入字符‘\0’。
  char str[] = {‘s’, ‘t’, ‘r’, ‘i’, ‘n’, ‘g’, ‘\0}char str[] = “string”; // 定义一个字符数组str并使用字符串常量初始化
  char *str = “string”; // 定义一个指向字符的指针并赋为字符串常量的地址

数组名的意义

数组名可以作为指针使用,等于数组所在存储区的首地址,但是,与指针不同的是,数组名所对应的地址在数组的生命期内是不可以改变的。

数组元素的引用

通过数组名或指针变量都可以引用数组元素,引用的方法有两种,一种是使用‘*’运算符,另一种是使用‘[]’。

二维数组

二维数组的存储方式与一维数组相同,都是在内存空间中连续存放的。

        int a[3][4]; //  a[0][0]->a[0][1]->a[0][2]->a[0][3]->
                     //  a[1][0]->a[1][2]->a[1][3]->a[1][4]->
                     //  a[2][0]->a[2][1]->a[2][2]->a[2][3]

数组名a表示数组的首地址,a[1]则表示a[1][0]的地址(&a[1][0]),a+1也表示a[1][0]的地址(&a[1] [0]),a[1]+1表示a[1][1]的地址(&a[1][1]),而*a,*(a+1),*(*(a+1)+1)分别和 a[0],a[1],a[1][1]等价。同样,对于n维数组,a[i]表示a[i][0][0]…[0]的地址,a[i][j]表示a[i][j][0]…[0]的地址。

函数调用时的参数传递

函数调用过程中的参数传递顺序(实参压栈顺序)不是确定的,有的系统按自左至右的顺序,有的系统按自右至左的顺序(如MS C编译器就是按自右至左的顺序)。

检测函数调用过程中参数传递顺序的代码:

        void main() {
           int i = 2, p;
           p = f(i, ++i);
        }
        int f(int a, int b) {
           return a – b;
        }

数组名作函数参数

对于一维数组,C编译对作为参数的数组大小不作检查,只是将实参数组的首地址传给形参数组。实参数组和形参数组的大小可以一致也可以不一致(形参中不必给出数组的长度);对于二维数组,必须指定形参数组第二维的大小。

结构体

        struct demo
        {
        	int first;
        	unsigned char second;
        	short last;
        } ;
        static struct demo variable = {2, 3, 4};

对于上面定义的结构体,在32位环境中,sizeof(demo)、sizeof(variable)、sizeof(variable.first)、 sizeof(variable.second)、sizeof(variable.last)的运算结果分别为8、8、4、1、2。

指针变量的运算

  • +/-,++/–:依赖于指针的类型
  • ‘*’:间接寻址运算,引用指针所指向的变量的值
  • ‘[]’:变址寻址运算,引用以指针为首地址的存储区中某个地址单元的值

指向函数的指针变量

定义方式:返回类型 (*指针变量名)(形参列表); 对于指向函数的指针变量p,p+n,p++,p—等操作都是没有意义的。

指向函数的指针类型定义:typedef 返回类型 (*指针类型名)(形参列表);

返回指针值的函数

定义方式:类型标识符 *函数名(形参列表),注意不可返回无意义的指针。

使用断言

程序一般分为Debug版本和Release版本,Debug版本用于内部调试,Release版本发行给用户使用。断言 assert是仅在Debug版本起作用的宏,它用于检查“不应该”发生的情况。下面是一个assert宏的示意:

        #ifndef _DEBUG
        #  define assert(expr) do {} while (0)
        #else
        #  define assert(expr) \
                if(!(expr)) {					\
                printf( "Assertion failed! %s,%s,line=%d\n",	\
                #expr,__FILE__,__LINE__);		\
                }
        #endif

用指向指针的指针实现多维数组

以三维数组为例,实现一个三维数组需要定义一个指针int ***p;数组使用之前要申请内存空间(由GetMem3D函数实现),然后可以通过数组的引用方式引用某一个元素,如p[j][k];数组使用结束还需要释放内存空间(由FreeMem3D函数实现)。

        int ***GetMem3D(int n1, int n2, int n3) {
           int ***p;
           int i, j;
 
           p = (int ***) malloc(n1*sizeof(int**));
           for (i = 0; i < n1; i++) {
              p = (int **) malloc(n2*sizeof(int*));
              for (j = 0; j < n2; j++) {
                 p[j] = (int *) malloc(n3*sizeof(int));
              }
           }
        return p;
        }
 
        void FreeMem3D(int ***p, int n1, int n2) {
           int i, j;
 
           for (i = 0; i < n1; i++) {
              for (j = 0; j < n2; j++) {
                 free(p[j]);
              }
              free(p);
           }
        free(p);
        }

左移和右移运算符

        unsigned int a = 16;
        int b = 16;
        int c = -16;

对于左移运算,a«2和b«2的值均为64,c«2的值为-64,三个表达式的运算过程没有区别;对于右移运算,a»2和b»2的值为4,c»2的值为-4,第一个表达式进行逻辑右移运算,后两个表达式进行算术右移运算。

#将一个宏参数转换为字符串;

##连接两个红参数;

附录

附1,C语言发展史:

  • 1972 –1973 贝尔实验室的D.M.Ritchie在B语言的基础上设计出了C语言
  • 1973 K.Thompson和D.M.Ritchie合作把UNIX的90%以上采用C语言改写
  • 1977 出现了不依赖于具体机器的C语言编译文本《可移植C语言编译程序》
  • 1978 Brian W.Kernighan和Dennis M.Ritchie合著《The C Programming Language》
  • 1983 ANSI制定ANSI C
  • 1987 ANSI制定87 ANSI C
  • 1988 K&R修改其经典著作《The C Programming Language》

C语言是一种面向过程的结构化程序设计语言,结构化程序设计要求程序的模块化:一个程序由若干个模块组成,每个模块又由若干个子模块组成,整个程序具有一种层次结构。结构化程序设计的具体方法有两种,一种是自顶向下的设计方法,一种是自底向上的设计方法。

附2,常用C标准库函数(MS/ANSI/UNIX)[3]:

  • 类型转换函数<stdlib.h>
    • atoi 将一个字符串转换为整型
    • atol 将一个字符串转换为长整型
    • atof 将一个字符串转换为浮点型
  • 输入输出函数<stdio.h>
    • fopen 打开输入输出流
    • fclose 关闭输入输出流
    • feof 测试流中的文件结束标志
    • fscanf 从流中读格式化数据
    • fprintf 向流中写格式化数据
    • sscanf 从缓冲区中读格式化数据
    • sprintf 向缓冲区中写格式化数据
    • fread 从流中读数据
    • fwrite 向流中写数据 -
    • fgetc 从流中读字符
    • fputc 向流中写字符
    • ftell 获取当前文件指针位置
    • fseek 将文件指针移到指定位置
    • fflush 清空流缓冲区
    • ferror 读写错误判断
    • getc 从标准输入流读字符
    • putc 向标准输出流写字符
    • scanf 从标准输入流读格式化数据
    • printf 向标准输出流写格式化数据
  • 缓冲区操作函数<memory.h>
    • memcmp 比较两个缓冲区指定数目的字符
    • memcpy 将指定数目的字符从一个缓冲区拷贝到另一个缓冲区
    • memset 用指定的字符初始化缓冲区指定数目的字节
  • 字符串函数<string.h>
    • strcat 将一个字符串附加到另一个字符串上
    • strcmp 比较两个字符串
    • strcpy 将一个字符串复制到另一个字符串中
    • strlen 求字符串的长度
    • strchr 从字符串中找出指定字符
  • 内存分配函数<stdlib.h>
    • calloc 分配连续的内存空间并初始化为零
    • malloc 分配连续的内存空间
    • realloc 为一个已分配的内存区重新进行分配
    • free 释放一块已分配的内存空间

引用

  1. 林锐,高质量C++/C编程指南,p47,2001
  2. 谭浩强,C程序设计,清华大学版社,p195,1991
  3. Microsoft C使用者可参考MSDN或“Microsoft Visual C++运行库参考手册”,清华大学出版社,1994;其它C编译器使用者参考相应手册。
tech/programming/c_language.txt · Last modified: 2014/11/10 08:22 (external edit)