C语言语法拾遗
专门总结了一些C语言C99/C11后的新语法或不受欢迎的语法
预处理和宏-灵魂
预处理器和宏可以说是面向对象语言独有的东西,,使C语言的编译过程可控,甚至可以说C语言编译本身就是一个开发者可编程的过程——也许更抽象,例如:java宏不对普通开发者开放,一般只有OpenJDK只有开发者才能面对java宏和相关预编译指令;但是C语言的宏直接出现在hello world##include指令本身意味着调用链接器——严格来说,这些宏不属于C语言的语法学习范畴,但如果你想用C语言建造轮子,这是不可避免的
C预处理器是C语言的灵魂,可以实现干扰程序,可以检查编译原理
预处理指令的特殊用法
-
预处理器标记井号
#
有三种不同的用法:-
标记指令
这是最常用的方法,#之前的空白会被忽略,所以各种头文件的格式总是防止重复包含-引用-宏定义-变量定义-函数定义
-
字符串输入
这是很多c程序处理字符串IO的方法
#define Pevel(cmd) printf(#cmd ":%g\n",cmd);
上述代码将输入变量cmd转换为字符串,输出变量名并输出相应的值
-
连接符号
用两个##把不是字符串的东西拼接在一起
比如
name = LL; name##_list //等效于 LL_list
C语言的键值对(字典)轮子通常采用这种编程方法
-
-
避免包含头文件
这个用法很重要,懂得都懂。不懂的话自己写两个一模一样的.h文件碰几次报错就明白了。
有两种使用方法:
#ifndef __THIS_DOCUMENT #define __THIS_DOCUMENT /* 这是头文件的内容 */ #endif
或者
#pragma once
只要在文件开头添加此行语句,就可以通知编译器不包含二次。它实际上依赖于编译器,但每个主流编译器都支持该指令
-
static和extern保护
在.c在使用库文件中的所有函数之前static,并在.h声明文件可以保护函数和变量
它含有大量的全局变量,将包含在许多地方.h使用头文件extern声明全局变量可以防止多次重复编译;但需要注意的是,只有其中一个包含了该文件.c变量定义在文件中
typedef
使用typedef可以提高代码的可读性,简化声明的复杂性
也可以将结构封装成类或方法
这里要强调的是:C语言指令本身,——它将在编译阶段执行,并为数据类型声明一个别名,您可以继续使用数据类型的原始名称。typedef它不会在编译阶段实施。虽然现代C编译器将优化它,但它的运行也可能占据一个微不足道的程序运行时间——特别是在一些不受欢迎的嵌入式设备编译器中,这也可能导致一些无法解释的底部bug
可变参数宏
宏用于执行文本替换,但其想法和函数不同,其最大的特点是在预处理阶段完成替换,并遵循相对严格的替换原则
所以不小心写宏很容易犯错!
宏一般可以分为两类,一类是:这种宏可以求值,或者宏只是一个值,如下所示
#define PI 3.14159265 #define T 2-1 #define one_to_ten 1/10
另一类是:有可用的未知量,如下所示
#define max m>n?m:n #define u(x) x>0?x:0 #define t=t+1
为了编写鲁棒性更高(人话:更不容易出bug、易于移植)的宏,应遵循以下三条规则
-
:把所有容易出bug的东西都括起来,防止重复错误和过度替换错误
-
示例如下
#define doubleincrement(a, b) \ (a)++; \ (b)++; //上面这个例子容易出错,应该如下修改 #define doubleincrement(a, b) \ { (a)++; \ (b)++;} //还有另外的方法,可以相当程度上保证代码块的安全 #define doubleincrement(a,b) do{ (a)++;(b)++;}while(0) //但是这种方法并不是万能的,要注意灵活变通!
-
:使用注释等方法提醒用户不要做出越界的使用方法以免过度替换,并使用较少数量的参数,尽量防止参数过多导致bug
现代编译器中往往都会带有宏替换指示功能,Vim、Emacs甚至提供了一整套插件用于纠错,应该合理应用这些插件
这里要介绍的是一个特殊的宏:
__VA_ARGS__
它的展开是给定元素的集合
可以使用这个宏来实现
著名的printf
函数使用了可变参数表,但是可变参数表并不是万能的,它无法使用在宏中,因此一般使用可变参数宏来实现类似的功能
int printf (const char *__format, ...)
{
int __retval;
__builtin_va_list __local_argv; __builtin_va_start( __local_argv, __format );
__retval = __mingw_vfprintf( stdout, __format, __local_argv );
__builtin_va_end( __local_argv );
return __retval;
}
#define DEBUG(...) printf(__VA_ARGS__)
DEBUG("%d", a);
//展开成
printf("%d", a);
其中省略号表示可变的参数表,使用__VA_ARGS__
就可以把参数传递给宏
特别地,C++并不支持这一手段
使用该手段可以构造出某些面向对象语言的遍历语句
#define foreach(__c_object, ...) \ for(char** __c_object = (char* []) {
__VA_ARGS__, NULL}; *__c_object; __c_object++)
//使用例
int main(void)
{
char** str = "hello";
foreach(i, "test", str, "over")
{
printf("%s\n", i);
}
}
//该函数用于遍历并输出test、hello、over三个字符串,就像是python的for一样!
指针与数组——C语言的底层
内存与变量
C语言提供了三种内存分配方式:
-
一般的变量都是自动类型变量,显式或隐式使用auto标注地变量都使用自动内存分配
在变量作用域中分配得内存,离开作用域后变量对应的内存区域被删除
-
文件作用域内或函数中使用static声明的变量使用静态分配方式
静态程序在整个生命周期内一直存在
特别地,如果忘记对一个静态变量进行初始化,它会默认初始化为0或NULL
-
使用free或malloc等C库函数进行手动分配内存
如果手动分配内存出问题,很可能导致
C程序的底层结构
C程序经过编译后会形成如下几个结构(注意这几个结构都是C生成目标文件的一部分)进行保存:
-
堆栈段
用于存储程序中的局部变量,因此占据空间一般比较小(毕竟只是存名字)
-
BSS段
用于存储程序中的全局变量和静态变量,包括变量名和变量初值
-
代码段
用于存储程序中的指令,所有C语句都会被编译成汇编指令再进行汇编得到二进制格式的指令,用于驱动CPU运行(突然想到一个特殊的看待文件的视角:操作系统就是CPU的驱动程序,指令被封装在可执行文件里,操作系统负责驱动CPU执行这些文件描述的指令;对于裸机编程并不需要将指令封装成文件,而是根据CPU的架构分装指令和数据(哈佛架构)或将指令和数据送到CPU之内后再进行区分并执行(冯诺依曼架构),也就是说此时CPU并不需要一个特别的驱动程序)
程序被加载进入内存后则会映射出一个类似的空间,任何函数都会在内存中占据空间中的一部分,称为函数帧,函数帧会独立使用上面的结构保存与这个函数有关的所有信息。
比如下面这个程序
#include <stdio.h> static int r=114; int q=514; void foo(void); int main(void) { int a=0; double b=0; for(int i=0;i<r;i++) { foo(); } } void foo(void) { int k=1919; k++; printf("hello!\n"); }
会在运行时被分成两个函数帧——main和foo进行保存
其中变量q和r会被作为全局变量保存在BSS段,a、b会被保存在main函数对应的堆栈段,i会被保存在for循环专属的堆栈段或程序堆栈段(根据编译器实现而不同),k会被保存在foo函数对应的堆栈段,两个函数中涉及到的操作指令都会保存在代码段
从main跳转到foo的步骤如下:
- 保护现场,将main函数中属于堆栈段的变量(当前保存在寄存器)都压入main函数栈
- 在执行for循环时根据条件/分支跳转指令确定跳转到foo,PS指向foo所在的代码段地址
- 将foo中的变量k的值从foo函数栈中弹出,并加载到寄存器
- 执行foo中的指令,执行完毕后执行保护现场操作
- 执行恢复现场,继续执行main函数中的指令
在操作系统进行函数跳转时一般会采用分支跳转指令。更底层的实现可以参考计算机组成原理相关教程
要注意:堆栈并不是堆+栈,堆栈就是堆栈
堆栈是内存中一块专门的区域,特点是先入后出
长度限制比一般内存小得多,专门用于保存自动变量,也用于临时保存寄存器中的值(保护现场)
堆栈段的内存分配一般由硬件/编译器/操作系统内存分配算法等底层处理系统实现
通过手动方式分配的内存都会保存在堆空间,堆的实现根据操作系统或内存分配算法有所不同
堆是内存分配算法在内存中创建的内存池状数据结构
一般来说堆的大小就是可用内存的剩余大小
C语言中的数据内存分配
C语言中的数据在进行内存分配时往往会遵循以下原则:
- 在函数外部声明或在函数内部使用static关键字声明一个变量,这个变量就是静态变量
- 在函数内部使用auto或无额外的关键字声明一个变量,这个变量就是动态变量
- 声明指针也遵循以上两种原则
在声明指针时虽然也遵守基本原则——指针会被保存为“指针变量”(一般的实现中,指针和long long或double具有相同的大小,8字节),但是它指向的东西可以是自动、静态、手动三种类型中的任意一种。这就是为什么需要使用malloc函数对指针指向的内容进行分配内存
这就要谈到指针和数组的不同:指针指向的是需要手工分配的内存区域;数组名则指向已经在数组初始化阶段完成自动分配的内存区域。初始化一个数组的实际过程如下:
- 在栈上分配出一个空间,这个空间就等于数组的大小
- 将数组名初始化为指针
- 将该指针指向新分配的地址头部
状态机和静态变量
看如下的经典的递归计算斐波那契数列函数
int fibonacci(void)
{
if(n<=0)
{
return -1; //错误输入
}
else if(n == 1 || n == 2)
{
return 1;
}
else
{
int result = Fibonacci(n - 2) + Fibonacci(n - 1);
}
}
它可以被用静态变量的方法替代
int fibonacci(void)
{
static int a1 = 0;
static int a2 = 1;
int out = a1 + a2;
a1 = a2;
a2 = out;
return out;
}
这就将一个递归函数转化成了一个
在C语言中实现状态机的关键就在于静态变量,它可以让一个函数内部的参数保持存在,从而达到多次调用、多次计数的效果
甚至在多线程程序中也可以使用_Thread_local
关键字来实现单线程的静态变量
指针定向运算
声明一个数组实际上就是将指针进行了重定向的运算
int buf[4];
buf[0]=3;
buf[2]=8;
//可以等价于
int *buf = (int*)malloc(4 * sizeof(int));
*(buf+0)=3;
*(buf+2)=8;
因此可以使用类似的方法实现数据“重定向”
bit[0]=*(a);
bit[2]=*(a+2);
bit[3]=0x08;
bit[4]=*(b);
bit[6]=*(b+2);
bit[8]=*(b+4);
使用该方式可以提高代码可读性
同时也可以使用这种方法提高算法效率
char* list[] = {
"first",
"second",
"third",
NULL
}
for(char** p = list; *p != NULL; p++)
{
printf("%s\n",p[0]);
}
使用上述方法可以对字符串数组进行快速解析
也可以化简多维数组,这个应该算是老生常谈——
回调函数
一般使用函数指针来实现
#include <stdio.h>
int callback1(void)
{
printf("callback 1\n");
return 0;
}
int callback2(void)
{
printf("callback 2\n");
return 0;
}
int callback3(void)
{
printf("callback 3\n");
return 0;
}
int Handle(int (*callback)())
{
printf("ENTERING HANDLE FUNC\n");
callback(); //在函数内部执行另一个函数
printf("LEAVING HANDLE FUNC\n");
}
int main(void)
{
printf("MAINI\n");
Handle(callback1); //传递回调函数
Handle(callback2);
Handle(callback3);
printf("MAINL\n");
return 0;
}
函数名本身被视作一个指针,它指向函数程序的首地址,因此可以被当作一般的函数进行传递
下面就是指一个无输入,输出int的函数callback
int (*callback)(void)
对应的也可以创造出各种复杂的回调函数,回调函数本质上只会被输入和输出的数据类型所限定,其名字并没有决定性意义
struct ReturnClass (*MyLocalFunction)(struct PassClass, void* parameter, uint8_t nums)
OS_ReturnState (*TaskFunctionHandle)(void* parameter)
习惯上将回调函数的名字称为回调函数(Handle)
void指针
void指针可以指向任何东西,而使用void指针指向一个结构体可以让大型程序的编写中的传参和调用更加容易,这也是C面向对象的一个基础
下面的函数是FreeRTOS中的任务函数(线程)的原型
typedef void (*TaskFunction_t)( void * );
它输入一个参数,并没有返回值。其中的输入参数可以是任何数据类型,这正是void*
的妙用:将任意类型适配到当前函数或数据
使用void指针还可以写出完备的高可移植性数据结构,并且它也是实现C泛型的基础
变量和数据类型——骨干
类型转换
类型转换常常会导致一些隐蔽的错误,尤其是在缺少编译器自动纠错辅助的情况下(某些逆大天的嵌入式编程IDE就是这样),下面列举一些常常会导致出错的问题和对应的解决方案
-
两个整数相除总是返回整数
可以使用“加0”的方法
4/3 == 2;4/(3+0.0) == 1.3333;4/3. == 1.3333;
或直接显式进行类型转换
4/(double)3 == 1.3333;
-
数组的索引必须是整数
int a[4];a[3.3]; //错误a[(int)3.3] == a[3]; //避免错误
复合常量
C99标准引入了符合常量
double double_value = 3.7;(double[]) {
20.38, double_value, 9.6}
这就是一个典型例子,复合常量就是包含了同类型已赋值变量的常量,它会自动分配内存,
指定初始化器
指定初始化器是C99引入的新特性,可以像以下方式初始化一个结构体
struct _gpio{
volatile uint8_t direction; volatile uint8_t pin; volatile uint8_t special; volatile uint8_t value; volatile uint8_t speed;}typedef struct _gpio GPIO_InitStruct;void main(void){
GPIO_InitStruct MyGPIO; MyGPIO = {
.direction = OUTPUT; .pin = 5; .special = PullUp; .value = GPIO_Pin_HIGH; .speed = GPIO_Speed_100MHz; }}
相比于
MyGPIO = {
OUTPUT, 5, PullUp, GPIO_Pin_HIGH, GPIO_Speed_100MHz};//或MyGPIO.direction = OUTPUT;......MyGPIO.speed = GPIO_Speed_100MHz;
这种方法可以有效减少劳动量——因为大多数IDE都集成了这种初始化器的代码提示功能,可以只打出一个.
,再从待选列表中选出要赋值的量
C面向对象
在说明C面向对象编程方法之前需要强调几点:
- typedef是面向对象编程中用于减少代码书写量的重要工具
- C使用结构体和回调函数来实现多种功能
- 不要害怕阅读很长的数据类型
C语言的一般库格式如下:
- 一组数据结构,用于代表库所针对领域的关键概念,并对库针对的问题进行代码结构上的描述
- 一组函数,用于处理数据结构
这也就是经典的数据结构+算法
但是面向对象的语言则不这样处理,它们通常:
- 定义一个或多个类,用于描述问题本身
- 定义这些类的方法,用于处理问题并建立问题之间的联系
同时OOP语言(比如C++)还会进行以下扩展来方便用户进行各种处理:
- 继承:用于扩展已有的类结构
- 虚函数:规定了一个类中所有对象都默认,但对不同对象的实例都有所限制的行为
- 私有和公有:用于划分类与方法要处理的范围
- 运算符重载:让一个运算符能够处理不同但有所类似的数据类型/对象
- 引用计数:用于自动化地分配和回收内存空间
下面将从几个不同的方面阐述C语言实现面向对象编程机制的方法
C实现的类
先从计算机的底层讲起吧——说起来,计算机的底层是哪里?汇编?CPU?逻辑门?晶体管?答案是数学!
图灵机和lambda代数是等价的两种描述计算机原理的模型
图灵机描述了一个可以在纸带上到处移动并修改其中值的读写头模型;lambda代数则描述了一个使用描述来处理参数列表的表达式
这两者分别就是面向过程和面向对象思想的数学原理
c语言使用下面的结构体来描述一个人的信息
struct person
{
char* name;
bool sex;
unsigned int age;
unsigned double height;
}
并使用下面的函数来输出一个人的名字
char* output_name(struct person)
{
return person.name;
}
这些信息被放在内存中,按顺序保存,当函数执行到的时候,CPU寻址到对应的位置,从对应的位置读取数据并输出
而面向对象语言中,使用类似字典(键值对)的方式保存人的数据
person = {
"name":10, "sex":"?", "age":18, "height":1.7}
更进一步,将其封装成一个
class Person:
"""描述人属性的类"""
person_number = 0
def __init__(self, name, sex, age, height):
self.name = name
self.sex = sex
self.age = age
self.height = height
person_number += 1
def displayName(self):
print(self.name)
调用时只需要按照初始化一个对象-对象.方法
就可以对数据进行处理
Person a_person
a_person.displayName()
C++、Java这些OOP语言都可以快速扩展现有类型,但是处理速度一般没有C快;同样Python更加直接的扩展命名列表思路只需要向其添加成员,就可以扩展当前数据类型,然而很难得到注册功能来检查代码正确性——有得必有失。然而在很多情况下需要我们实现既快速又便于扩展的代码,尤其是在嵌入式设备上,这时候就需要使用到编程思想了
面向对象基于类;类是结构体的延伸;C面向对象基于结构体
最简单的,使用结构体就可以实现基于C的字典(基于键值对)
struct _key_value{
char* key; void* value;}typedef struct _key_value key_value; struct _dictionary{
key_value **pairs; int length;}typedef struct _dictionary dictionary;typedef dictionary* Dictionary;
然而附加问题出现了:
这个字典基于C指针实现;C指针需要使用malloc、free来管理内存;字典管理内存会具有很大不便
对于一般的应用实现来说,开发者手动分配内存并将其封装在大的函数里就足够了,但是总有一些特殊的时候(比如操作系统编写)用户会需要使用到大量的字典操作,因此就应该创造“虚函数”用来管理内存
为了安全起见,也应该设置找不到字典的标志来防止溢出/过放问题
最后应该实现添加和遍历字典的方法
extern void* dictionary_not_found;
/* 新建键值对 */
key_value* new_key_val(char* key, void* value)
{
key_value* out = (key_value*)malloc(sizeof(key_value));
*out = (key_val){
.key = key, .value = value};
return out;
}
/* 复制键值对 */
key_value* copy_key_val(key_value const* in)
{
key_value* out = (key_value*)malloc(sizeof(key_value));
*out = *in;
return out;
}
/* 删除键值对 */
void free_key_val(key_value* in)
{
free(in);
}
/* 判断当前键值对的键值是否和给出的键值对应 */
int match_key_val(key_value const* in, char const* key)
{
return !(strcasecmp(in->key, key));