C语言语法拾遗
专门总结了一些C语言C99/C11之后的新语法或冷门语法
预处理和宏——灵魂
预处理器和宏可以说是面向对象语言独有的东西,这些特性发生在编译这个过程之前,使得C语言的编译过程变得“可控”,甚至可以说C语言编译本身就是一个开发者可编程的过程——或许这样说比较抽象,举个例子:java的宏并不对一般开发者开放,一般只有OpenJDK的开发者才会面对java宏和相关预编译指令;但是C语言的宏直接出现在hello world程序中:“#include”指令本身就意味着对链接器进行调用——这些宏严格来说并不属于C语言的语法学习范畴,但是如果想靠C语言造轮子,这就是无法避开的
C预处理器就是C语言的灵魂,上能干涉程序实现,下能检查编译原理
预处理指令的特殊用法
预处理器对井号标记
#
有三种不同的用法:标记一个指令
这是最常用的方法,#之前的空白会被忽略,因此各种头文件的格式总是防止重复包含-引用-宏定义-变量定义-函数定义
输入的字符串化
使用#可以将一个变量转义为字符串,并且如果旁边有其它字符串相邻,会将他们合并在一起
这就是很多c程序处理字符串IO的方法
1
上面的代码会将输入的变量cmd转换成字符串,输出变量名并输出对应的值
把符号连接起来
使用两个##就可以将不是字符串的东西拼接在一起
比如
1
2
3
4name = LL;
name##_list
//等效于
LL_list往往使用这种编程方法实现C语言的键值对(字典)轮子
避免头文件包含
这个用法很重要,懂得都懂。不懂的话自己写两个一模一样的.h文件碰几次报错就懂了
使用方法有两种:
1
2
3
4
5
6
/* 这里是头文件内容 */或者
1
这行语句只要加在文件开头即可通知编译器不进行二次包含,它实际上依赖于编译器,但每个主流的编译器都支持该指令
static和extern保护
在.c库文件内的所有函数前使用static,并在.h文件中进行声明,可以对函数和变量进行一定的保护
在含有很多全局变量,会被多处包含的.h头文件中使用extern声明全局变量可以防止多次重复编译;但是要注意:只在其中一个包含了该头文件的.c文件中进行变量定义
typedef
使用typedef可以提高代码可读性、化简声明复杂度
还可以把一个结构体封装成一个“类”或封装出一个“方法”
这里要强调的是:typedef本身是一个C语言指令,并不是宏——它在编译阶段才会执行,并为某个数据类型声明一个别名,并且你也可以继续使用这个数据类型原来的名字。typedef并不会在编译阶段就得到执行,虽然现代的C编译器会对其做出优化,但是它的运行还可能会占据微不足道的一段程序运行时间——特别是在某些优化不好的冷门嵌入式设备编译器中,这也有可能导致一些莫名其妙的底层bug
可变参数宏
宏用于执行文本替换,但其思路和函数并不相同,并且它最大的特点就是:在预处理阶段完成替换,且遵循相对严格的替换原则
因此如果不仔细地写宏很容易造成错误!
宏一般能分成两类,一类是表达式展开宏:可以对这类宏进行求值,或宏干脆就是个数值,如下所示
1 |
另一类是指令展开宏:一条甚至一系列指令,有可用的未知量,如下所示
1 |
为了编写鲁棒性更高(人话:更不容易出bug、易于移植)的宏,应遵循以下三条规则
多用括号:把所有容易出bug的东西都括起来,防止重复错误和过度替换错误
代码块两端加入花括号
示例如下
1
2
3
4
5
6
7
8
9
10
//上面这个例子容易出错,应该如下修改
//还有另外的方法,可以相当程度上保证代码块的安全
//但是这种方法并不是万能的,要注意灵活变通!避免重复作用:使用注释等方法提醒用户不要做出越界的使用方法以免过度替换,并使用较少数量的参数,尽量防止参数过多导致bug
现代编译器中往往都会带有宏替换指示功能,Vim、Emacs甚至提供了一整套插件用于纠错,应该合理应用这些插件
这里要介绍的是一个特殊的宏:可变参数宏
1 | __VA_ARGS__ |
它的展开是给定元素的集合
可以使用这个宏来实现宏输入任意多的参数
著名的printf
函数使用了可变参数表,但是可变参数表并不是万能的,它无法使用在宏中,因此一般使用可变参数宏来实现类似的功能
1 | int printf (const char *__format, ...) |
1 |
|
其中省略号表示可变的参数表,使用__VA_ARGS__
就可以把参数传递给宏
特别地,C++并不支持这一手段
使用该手段可以构造出某些面向对象语言的遍历语句
1 |
|
指针与数组——C语言的底层
内存与变量
C语言提供了三种内存分配方式:
自动
一般的变量都是自动类型变量,显式或隐式使用auto标注地变量都使用自动内存分配
在变量作用域中分配得内存,离开作用域后变量对应的内存区域被删除
静态
文件作用域内或函数中使用static声明的变量使用静态分配方式
静态程序在整个生命周期内一直存在
特别地,如果忘记对一个静态变量进行初始化,它会默认初始化为0或NULL
手动
使用free或malloc等C库函数进行手动分配内存
如果手动分配内存出问题,很可能导致段错误
C程序的底层结构
C程序经过编译后会形成如下几个结构(注意这几个结构都是C生成目标文件的一部分)进行保存:
堆栈段
用于存储程序中的局部变量名,因此占据空间一般比较小(毕竟只是存名字)
BSS段
用于存储程序中的全局变量和静态变量,包括变量名和变量初值
代码段
用于存储程序中的指令,所有C语句都会被编译成汇编指令再进行汇编得到二进制格式的指令,用于驱动CPU运行(突然想到一个特殊的看待文件的视角:操作系统就是CPU的驱动程序,指令被封装在可执行文件里,操作系统负责驱动CPU执行这些文件描述的指令;对于裸机编程并不需要将指令封装成文件,而是根据CPU的架构分装指令和数据(哈佛架构)或将指令和数据送到CPU之内后再进行区分并执行(冯诺依曼架构),也就是说此时CPU并不需要一个特别的驱动程序)
程序被加载进入内存后则会映射出一个类似的空间,任何函数都会在内存中占据空间中的一部分,称为函数帧,函数帧会独立使用上面的结构保存与这个函数有关的所有信息。
比如下面这个程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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函数对指针指向的内容进行分配内存
这就要谈到指针和数组的不同:指针指向的是需要手工分配的内存区域;数组名则指向已经在数组初始化阶段完成自动分配的内存区域。初始化一个数组的实际过程如下:
- 在栈上分配出一个空间,这个空间就等于数组的大小
- 将数组名初始化为指针
- 将该指针指向新分配的地址头部
状态机和静态变量
看如下的经典的递归计算斐波那契数列函数
1 | int fibonacci(void) |
它可以被用静态变量的方法替代
1 | int fibonacci(void) |
这就将一个递归函数转化成了一个状态机
在C语言中实现状态机的关键就在于静态变量,它可以让一个函数内部的参数保持存在,从而达到多次调用、多次计数的效果
甚至在多线程程序中也可以使用_Thread_local
关键字来实现单线程的静态变量
指针定向运算
声明一个数组实际上就是将指针进行了重定向的运算
1 | int buf[4]; |
因此可以使用类似的方法实现数据“重定向”
1 | bit[0]=*(a); |
使用该方式可以提高代码可读性
同时也可以使用这种方法提高算法效率
1 | char* list[] = { |
使用上述方法可以对字符串数组进行快速解析
也可以化简多维数组,这个应该算是老生常谈——数组的数组就是指向指针的指针
回调函数
回调函数指被传递给另一个函数来进行内部使用的函数
一般使用函数指针来实现
1 |
|
函数名本身被视作一个指针,它指向函数程序的首地址,因此可以被当作一般的函数进行传递
下面就是指一个无输入,输出int的函数callback
1 | int (*callback)(void) |
对应的也可以创造出各种复杂的回调函数,回调函数本质上只会被输入和输出的数据类型所限定,其名字并没有决定性意义
1 | struct ReturnClass (*MyLocalFunction)(struct PassClass, void* parameter, uint8_t nums) |
习惯上将回调函数的名字称为回调函数句柄(Handle)
void指针
void指针可以指向任何东西,而使用void指针指向一个结构体可以让大型程序的编写中的传参和调用更加容易,这也是C面向对象的一个基础
下面的函数是FreeRTOS中的任务函数(线程)的原型
1 | typedef void (*TaskFunction_t)( void * ); |
它输入一个参数,并没有返回值。其中的输入参数可以是任何数据类型,这正是void*
的妙用:将任意类型适配到当前函数或数据
使用void指针还可以写出完备的高可移植性数据结构,并且它也是实现C泛型的基础
变量和数据类型——骨干
C变量与数据类型完全基于面向过程思想,也正因此C语言可以通过指针对内存进行操作,或是基于顺序存储的数组、结构体、指针等构建出顺序表、链表等数据结构,甚至构建出面向对象的“类”。因为C变量面向过程的性质,导致它们在使用中存在很多误区或不被注意的问题,这里简单梳理一下C变量的常见问题和基于基本数据类型实现的数据结构
类型转换
类型转换常常会导致一些隐蔽的错误,尤其是在缺少编译器自动纠错辅助的情况下(某些逆大天的嵌入式编程IDE就是这样),下面列举一些常常会导致出错的问题和对应的解决方案
两个整数相除总是返回整数
可以使用“加0”的方法
1
2
34/3 == 2;
4/(3+0.0) == 1.3333;
4/3. == 1.3333;或直接显式进行类型转换
1
4/(double)3 == 1.3333;
数组的索引必须是整数
1
2
3int a[4];
a[3.3]; //错误
a[(int)3.3] == a[3]; //避免错误
复合常量
C99标准引入了符合常量
1 | double double_value = 3.7; |
这就是一个典型例子,复合常量就是包含了同类型已赋值变量的常量,它会自动分配内存,常用来绕过临时变量
指定初始化器
指定初始化器是C99引入的新特性,可以像以下方式初始化一个结构体
1 | struct _gpio |
相比于
1 | MyGPIO = {OUTPUT, 5, PullUp, GPIO_Pin_HIGH, GPIO_Speed_100MHz}; |
这种方法可以有效减少劳动量——因为大多数IDE都集成了这种初始化器的代码提示功能,可以只打出一个.
,再从待选列表中选出要赋值的量
C面向对象
在说明C面向对象编程方法之前需要强调几点:
- typedef是面向对象编程中用于减少代码书写量的重要工具
- C使用结构体和回调函数来实现多种功能
- 不要害怕阅读很长的数据类型
- 在使用面向对象编写C之前,应该想想你的需求能否用面向过程的方式解决,再想想使用面向对象后获得的开发思路、可移植性提升比起效率损失而言是否值得,如果感觉有些问题,尽早放弃使用面向对象编写C程序的想法
C语言的一般库格式如下:
- 一组数据结构,用于代表库所针对领域的关键概念,并对库针对的问题进行代码结构上的描述
- 一组函数,用于处理数据结构
这也就是经典的数据结构+算法
但是面向对象的语言则不这样处理,它们通常:
- 定义一个或多个类,用于描述问题本身
- 定义这些类的方法,用于处理问题并建立问题之间的联系
同时OOP语言(比如C++)还会进行以下扩展来方便用户进行各种处理:
- 继承:用于扩展已有的类结构
- 虚函数:规定了一个类中所有对象都默认,但对不同对象的实例都有所限制的行为
- 私有和公有:用于划分类与方法要处理的范围
- 运算符重载:让一个运算符能够处理不同但有所类似的数据类型/对象
- 引用计数:用于自动化地分配和回收内存空间
下面将从几个不同的方面阐述C语言实现面向对象编程机制的方法
C实现的类
先从计算机的底层讲起吧——说起来,计算机的底层是哪里?汇编?CPU?逻辑门?晶体管?答案是数学!
图灵机和lambda代数是等价的两种描述计算机原理的模型
图灵机描述了一个可以在纸带上到处移动并修改其中值的读写头模型;lambda代数则描述了一个使用描述来处理参数列表的表达式
这两者分别就是面向过程和面向对象思想的数学原理
c语言使用下面的结构体来描述一个人的信息
1 | struct person |
并使用下面的函数来输出一个人的名字
1 | char* output_name(struct person) |
这些信息被放在内存中,按顺序保存,当函数执行到的时候,CPU寻址到对应的位置,从对应的位置读取数据并输出
而面向对象语言中,使用类似字典(键值对)的方式保存人的数据
1 | person = {"name":10, "sex":"?", "age":18, "height":1.7} |
更进一步,将其封装成一个类
1 | class Person: |
调用时只需要按照初始化一个对象-对象.方法
就可以对数据进行处理
1 | Person a_person |
C++、Java这些OOP语言都可以快速扩展现有类型,但是处理速度一般没有C快;同样Python更加直接的扩展命名列表思路只需要向其添加成员,就可以扩展当前数据类型,然而很难得到注册功能来检查代码正确性——有得必有失。然而在很多情况下需要我们实现既快速又便于扩展的代码,尤其是在嵌入式设备上,这时候就需要使用到C面向对象编程思想了
面向对象基于类;类是结构体的延伸;C面向对象基于结构体
最简单的,使用结构体就可以实现基于C的字典(基于键值对)
1 | struct _key_value{ |
然而附加问题出现了:
这个字典基于C指针实现;C指针需要使用malloc、free来管理内存;字典管理内存会具有很大不便
对于一般的应用实现来说,开发者手动分配内存并将其封装在大的函数里就足够了,但是总有一些特殊的时候(比如操作系统编写)用户会需要使用到大量的字典操作,因此就应该创造“虚函数”用来管理内存
为了安全起见,也应该设置找不到字典的标志来防止溢出/过放问题
最后应该实现添加和遍历字典的方法
1 | extern void* dictionary_not_found; |
类似这样就可以编写一个C实现的“宏”及其实现操作的“方法”了。
但是这种实现方式仅仅是对C结构体的进一步封装,就连C Primer Plus都把这种操作摆在书里(就在最后一章)
最关键的,如何实现继承?
老版本的C使用非常复杂的结构传递机制实现,而C11给出了更简单的答案:匿名结构体成员
C11允许在结构中包含匿名的成员,gcc和clang通过*-fms-extensions的命令行选项来使用强模式:新结构声明中的某处包含另一个结构;如果不使用这个选项,编译器会自动使用弱模式,它不允许开发者使用匿名的结构标识符来引用之前定义过的结构,二是要求结构必须在本地定义。这段话很明显不是地球人能说的,所以*直接看例子
1 | /* 强模式 */ |
弱模式的处理方式不太好易用,所以一般都会使用强模式实现继承
但是一般在处理老旧代码的时候需要整体搬运结构体,这种情况下为了避免“牵一发而动全身”的情况,应保证使用弱模式——因为远古编译器并不支持强模式
对于一个典型的C对象句柄,更适合使用指针而不是基本的结构体。
使用指针能带来以下好处:
- 指针本身的内存空间会被自动管理,它指向的对象可以使用统一的malloc函数来动态管理内存或使用静态数据变量在程序执行之初进行内存初始化
- 使用
=
即可完成指针指向对象的复制 - 操作指针比直接操作完整的结构体更节省时间
- 表、栈、FIFO、树、图等数据结构都可以通过指针快速构建和管理
- 永远使用结构指针就不会对使用结构参数-指针参数二者之间造成困扰
- 使用专用的free函数和malloc函数操作指针可以更安全地处理对象内存
对于越庞大的系统,越适合使用指针实现,特别是编译器、操作系统等不得不用C语言完成的东西
C实现的方法
C面向对象中,使用回调函数实现方法,回调函数则基于函数指针
下面是一个包含了方法的C类
1 | struct _myclass |
需要注意:在本博文的代码中采用类Qt的方法命名法(或者说比较通用的C++方法命名法),即小写动词+驼峰类名
的形式
这种方法的特色就是“简明易懂”,便于写出自解释的代码;不过相较于类名+动词
的命名法,它更难以进行检索,哪种方法好就见仁见智吧
calculateMyClass
就是一个典型的函数指针,初始化时可以通过以下代码:
1 | double cal(int const *in) |
下面的代码演示了之前所说键值对类对应的方法
1 | struct _key_value{ |
能够注意到:new_key_val并没有作为一个方法实现,因为它用于创建一个键值对对象,需要被用户直接调用,这就涉及到了私有变量和公有变量——这些按下不表,急不可耐的读者可以直接跳到下一节
众所周知,C++有个this,Python有个self,这两个变量都可以很方便地定义出函数的默认值;然而C并不提供这些,如果你见到了C22或者C114514里提供了这些东西,请大声呵斥(笑)
不过C提供了更加高级的实现——预编译器
使用预编译器可以像以下代码一样写出代替this的功能
1 |
这段代码实现了打印一个默认值(in本身)的效果
private和public
从作用域说起,C语言中只有三条关于变量作用域的准则:
变量在未被声明前不在作用域内
如果变量在一对花括号内定义,那么结束花括号(
}
之后),变量就会处于作用域之外有一个特例:for循环和函数可以有变量定义在开始的花括号前的一对括弧内——在一对括弧内定义的变量,其作用域等同于在花括号内定义
如果一个变量不在任何花括号内,它的作用域就会从它的声明持续到文件结尾
确实,C语言的作用域限制少得可怜——友元、类作用域、原型作用域、动态作用域、扩展作用域、命名空间……全都没有;或许可以强行称使用malloc的作用域为动态作用域
特别需要说明一下#include包含文件的变量作用域——它们也遵循以上三条规则,在包含头文件时,就相当于将整个头文件复制粘贴到了 源文件里
在OOP中,私有数据表示不应被外界直接调用的数据,函数可以通过直接在.c文件内声明、定义,而不是挂在头文件中实现私有——上面所说的私有方法也类似,直接把函数指针封在结构体中就可以实现私有方法;如果想要更严格的限制,还可以在函数前使用static关键字;全局变量同理。不过有得必有失,局部变量想要实现私有的话,就只能在命名上加上_private
后缀并祈求你的用户不会瞎用这个变量吧
有限的运算符重载
虽然说很难用,但C11标准确实提供了一套使用宏来处理运算符重载的机制
个人不是很推荐对C语言进行这样的修改——很明显,这完全不是C所擅长的领域!到目前为止也很少有代码使用C11的**_Generic**,就连Linux内核中也没有出现多少这样的语句
这个预编译语句用法比较复杂,但是GLibC中的很多运算符(复数乘法、矩阵乘法)都通过这个语句进行重载,详细内容可以查看官方文档和库的使用说明,这里处于个人能力所限和篇幅所限不再介绍
C面向对象举例
这里引用RT-Thread实时操作系统的源码作为例子,这是一个使用了典型的C面向对象编写的大型程序
下面摘录的是其内核实现
1 | /* |
一些经常被遗忘但在嵌入式编程中仍有作用的关键字
volatile
一个定义为volatile的变量是说这变量可能随时会被改变,这样编译器就不会去假设这个变量的值——优化器在用到这个变量时必须每次都会重新读取这个变量的值,而不是使用保存在寄存器里的备份
这个关键字常被用于下面的场合:
并行设备的硬件寄存器
说人话就是MCU的外设控制寄存器地址需要用volatile指明
一个中断服务子程序中会访问到的非自动变量
这个就很明显了,用于指示中断的全局变量
多线程应用中被几个任务共享的变量
这种情况一般会在SMP设备或多核的高性能嵌入式设备中出现,多核执行任务中一定将全局变量设为volatile,否则可能导致跑飞。如果有双核并行化需求且对效率没有极致的需求,尽量使用RTOS甚至嵌入式Linux,并在分配任务的时候尽量使用RTOS自带的信号量或消息队列可以减少出现问题的可能性
事实上volatile应该解释为“直接存取原始内存地址”,正因如此,volatile是可以和const一起使用的,这表示对于某个只读变量始终直接存取原始内存地址
下面给一个网上随处可见的例子:
1
2
3
4
5//下面的函数有什么错误?
int square(volatile int *ptr)
{
return *ptr * *ptr;
}这段代码的目的是用来返回指针ptr指向值的平方,但由于ptr指向一个volatile型参数,这个参数的值随时可能变化,编译器可能会将其优化为以下代码
1
2
3
4
5
6
7int square(volatile int *ptr)
{
int a,b;
a = *ptr;
b = *ptr;
return a * b;
}实际上编译器生成的是汇编指令,a、b并不影响存取变量,但是会消耗额外的内存空间、让CPU执行不必要的取址甚至分支跳转指令,并且如果外界因素导致ptr指向的变量变化,就会实实在在地影响函数的返回值——会从理论上的a*a变成a*b
正确的代码应该像下面这样
1
2
3
4
5
6int square(volatile int *ptr)
{
int a;
a = *ptr;
return a * a;
}使用一个普通变量a来暂存ptr指向的值,虽然这样看似浪费了内存,但是和上面那段代码的内存占用实际上是一样的,并且能够避免出错
extern
在c语言中最不被重视但确实是最重要的关键字
用于在头文件中声明已在对应.c库文件中定义过的变量
也常用于在多任务文件中定义main文件中的全局变量
没有这个关键字,编译器必报错,一改就是半天
static
static在不同作用域中有不同的含义
全局变量static
用于指示这个全局变量只在单文件中起作用,可以用于在.c文件中声明常量来提高文件的可移植性
局部变量static
用于指示静态局部变量,这个学过C语言的人应该都比较熟悉了
最常见的用法是在MCU的按键扫描函数中使用,如下所示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21unsigned char KEY_Scan(unsigned char mode)
{
static u8 key_up=1;
if(mode)
key_up=1;
if(key_up && ( KEY0==1 || WK_UP==1 ))
{
delay_ms(10); //软件消抖
key_up=0;
if(KEY0==1)
return KEY0_PRES;
else if(WK_UP==1)
return WKUP_PRES;
}
else if(KEY0==0 && WK_UP==0)
{
key_up=1;
}
return 0;
}函数static
用于声明某个函数是本文件内有效的函数
同样用于在.c文件中声明常量来提高文件的可移植性
一般来说会在.h文件中再写一条extern static标明的函数
static函数在内存中只有一份,普通函数会在每次调用中生成一份拷贝
register
这个关键字确实很少使用,但是一旦用上就十有八九是需要硬优化算法的地方,如果实在需要使用这种方法优化,可以使用内联汇编来进行替代,不仅可以稳定”寄存器命中“,还可以更好地强调代码的执行速度优先
库函数与轮子——顶层
C库函数可以说是编写C程序的重中之重,从基本的stdio到高级的glibc,这些库中的函数都由大师编写,高效、简洁,而一般的开发者至少要学会使用这些库函数和基于C实现的轮子,它们除了在C程序中发挥作用,还能借助辅助工具和其他语言实现共同编译运行以提升其他语言代码的效率
使用C库函数进行字符串处理
将字符串转换为数字
最基本的方法是使用
atoi
和atof
使用方法如下
1
2
3
4
5char a = "42";
int x = atoi(a);
char million[] = "1e6";
double m = atof(million);更安全一点的库函数如下所示
1
2int a = strtod(char string[],char *p);
//里面的指针p会被指向第一个不能被解析成数字的字母asprintf函数
该函数需要使用支持GNU或BSD标准库的系统,但也可以使用vsnprintf函数快速实现asprintf函数
该函数可以让字符串处理更方便
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39/* 一般的字符串处理方法 */
void get_strings(char const *in)
{
char* cmd;
int len = strlen("strings ") + strlen(in) + 1;
cmd = malloc(len);
snprintf(cmd, len, "strings %s", in);
if(system(cmd))
{
fprintf(stderr, "something went wrong running %s.\n", cmd);
}
free(cmd);
}
/* 使用asprintf的字符串处理方法 */
void get_strings(char const *in)
{
char* cmd;
asprintf(&cmd, "strings %s", in);
if(system(cmd))
{
fprintf(stderr, "something went wrong running %s.\n", cmd);
}
free(cmd);
}
int main(int argc, char **argv)
{
get_strings(argv[0]);
return 0;
}asprintf和sprintf很相似,但是它需要传入的是字符串在内存中的位置而不是字符串本身,因为这个函数会为字符串分配新的空间
可以通过运行两次vsnprintf来实现asprintf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int asprintf(char **str, char *format, ...) __attribute__ ((format(prntf,2,3)));
int asprintf(char **str, char *format, ...)
{
va_list argp;
va_start(argp, format);
char one_char[1];
int len = vanprintf(one_char, 1, format, argp);
if (len < 1)
{
fprintf(stderr, "An encoding error occurred. Setting the input pointer to NULL.\r\n");
*str = NULL;
return len;
}
va_end(argp);
*str = malloc(len + 1);
if (!str)
{
fprintf(stderr, "Couldn't allocate %i bytes.\r\n", len + 1);
return -1;
}
va_start(argp, format);
vsnprintf(*str, len + 1, format, argp);
va_end(argp);
return len
}该函数具有防止数据越界、自动分配内存、自动控制内存大小的安全特性,同时可以使用该函数来实现连接字符串的功能
1
asprintf(&q, "%s and another clause %s", q, add);
这个特性可以用来实现数据库查询的底层
字符串解析
一般的字符串解析需要根据分隔符,配合正则表达式来抽取出子字符串。但是简单情况下只要使用c库函数strtok就可以完成使用分隔符划分字符串的任务:它会对输入的字符串进行迭代,直到遇到第一个分隔符,然后用一个
'\0'
来覆盖它,并返回一个指向这个子字符串头部的指针;当再次调用时,它会检索到下一个标记的尾部,并以合法的字符串形式返回这个标记。可以使用strto_s
版本来实现多线程保护和提高安全性,它支持一个额外的参数:提供输入字符串的长度,并在后续的调用过程中不断缩短,表示每次调用时剩余字符串的长度,示例代码如下:1
2
3
4
5
6
7
8
9
size_t len = strlen(instring);
//第一次使用
txt = strtok_s(instring, &len, delimiter, &scratch);
//第二次使用
txt = strtok_s(NULL, &len, delimiter, &scratch);特别注意:如果有连续的两个或更多分隔符被当作单个分隔符,那么空白标记会被忽略
Unicode解析
ASCII已经不适合这个版本了,大家都在用Unicode:为每个用于人类通信的字符设置一个单独的十六进制数值,一般是从0x0000到0xffff之间。Unicode具有以下几个流行的编码格式,他们之间的区别子啊与设置几个字节作为分析单位
- UTF-32:指定4字节(32位二进制位)作为基本单位,每个字符都可以用1个单位进行编码,但需要使用很多空白填充
- UTF-16:使用2字节(16位二进制位)作为基本单位,有些字符需要使用2个单位来表示,但大多数字符都能用1个单位表示
- UTF-8:使用1字节(8位二进制位)作为基本单位,许多字符需要使用多个单位来表示
这三种方式中的字符序列不一定相关,特定的用于解析Unicode字符的c函数也应运而生
超过73%的网站使用了UTF-8,Mac和Linux操作系统在默认情况下使用UTF-8表示任何文本。作为程序开发者,需要进行以下操作:
- 确定宿主系统的编码方式
- 按照合适的编码存储文本
- 认识到一个字符并不占据固定数量的字节,防止以基地址+偏移量表示的代码不会产生编码点的碎片
- 用便利的工具函数完成任何类型的文本理解
UTF-8的内部编码对于C语言来说可以轻松处理,但是具有一些需要注意的隐患,因为:
- 单位是8个二进制位,即一个char型,因此可以把一个UTF-8字符串写成char*字符串,与ASCII类似
- 前128个UTF-8和ASCII完全匹配;非ASCII的Unicode字符则无法和ASCII匹配
- U+0000是一个合法的编码点,可以写成
'\0'
;但是这也导致把UTF-16或UTF-32赋值给char*变量时很可能出现一个充满NULL字节的字符串
大多数POSIX和c-string库标准函数都可以对UTF-8编码生效
GNU还提供了一个可移植的libiconv函数库,指定了一个命令行的iconv程序,用于c函数上传至shell
对于UTF-16字符,C标准可以使用wchar_t来进行处理(windows将wchar_t置为16位),C11还提供了char16_t和char32_t的类型,分别对应16位和32位字符,但是目前还不太常用
多线程与原子操作
现在是2201年了,不会有人还在用单核的PC吧——就连MCU都在搭载双核乃至四核处理器,C语言也与时俱进,利用POSIX和现有的C库实现多线程-原子操作!
借助POSIX或者OpenMP编译器命令,可以很轻松的将命令转到多线程执行
1 |
使用上面一行语句即可将单线程程序变成多线程的。OpenMP会自动计算系统可用线程数,并将工作拆分
如果你在嵌入式设备上移植了RTOS,应该会很熟悉借助互斥量、消息队列实现的线程(任务)间同步与消息传输,不过要在PC上使用C进行多线程编译,还应该调整编译器指令
1 | CFLAGS = -g -Wall -O3 -fopenmp -pthread #同时使用fopenmp pthread atomic支持 |
不过虽然多线程能让任务效率提高,但是也可能会导致代码出现某些玄学bug
多线程Bug比单线程Bug更难处理
这时候就需要利用经验Debug了,加油吧
OpenMP和POSIX还有其他的一些指令,可以提供更完善的多线程支持,可以查看官方文档来了解它们,这里不再介绍
原子操作,即不能被中断的操作——这是嵌入式编程的专有术语?不,多线程编程中的原子操作也指不能被线程切换打断的操作,使用以下命令高速OpenMP原子操作
1 |
可以使用高频的原子操作在某种程度上替代互斥操作;然而请不要一直使用它——否则这和单线程编程又有什么区别呢
使用POSIX的pthread机制可以更标准地对Linux程序进行移植/多线程修改
如果你读过C Primer Plus,那么里面简要介绍的_Atomic
关键字应该会让你懵一会,这个东西其实也是C11标准为了更好兼容多线程编程而创建的关键字,可以用于保护变量在多线程执行中不被额外的线程改变,各种各样的变量都可以修改成原子的——从int到struct
SQLite
SQL即结构化查询语言,这是一个大体上人类可阅读的与数据库交互的语言。一个SQL数据库可以抽取一个数据集合的子集,也可以合并多个数据集合。C程序可以使用SQLite提供的接口来实现数据库搭建和使用,而这个东西本体仅包括一个C文件和一个头文件。
这个库使用了多种宏和C预编译指令来实现SQL的操作,并将API封装到了 统一的接口上
相关内容可以参考SQLite简介
cJSON
c语言中,没有直接的字典、字符串数组等数据结构,所以要借助结构体处理json。cJSON就是一个为json数据解析而生的高效率c库
类似的,也存在用于XML(扩展标记语言)、HTML(超文本标记语言)等数据解析的C库libxml和cURL
cJSON使用称为cJSON的数据结构负责JSON数据的编解码
1 | typedef struct cJSON { |
使用顺序如下:
包含头文件
1
2
3
4
5
6
//上面是必须的几个头文件JSON解析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16cJSON *root_json = cJSON_Parse(data); //将字符串解析成json结构体
if (NULL == root_json)
{
printf("error:%s\n", cJSON_GetErrorPtr());
cJSON_Delete(root_json);
return;
}
//"name":"EVDI"
cJSON *name_json = cJSON_GetObjectItem(root_json, "name");
if (name_json != NULL)
{
char *name = cJSON_Print(name_json); //将JSON结构体打印到字符串中
printf("name:%s\n", name);
free(name); //自行处理内存
}获取JSON数据
1
2
3cJSON *data_json = cJSON_GetObjectItem(root_json, "data"); //获取data键对应的值
int id = cJSON_GetObjectItem(data_json, "id")->valueint;
printf("id:%d\n", id); //输出输出JSON数据
1
2
3char *username = cJSON_Print(cJSON_GetObjectItem(data_json, "username"));
printf("username:%s\n", username);
free(username);创建JSON并添加值
1
2
3cJSON *root_json = cJSON_CreateObject(); //创建一个JSON串
cJSON_AddItemToObject(data_json, "id", cJSON_CreateNumber(1)); //数字值
cJSON_AddItemToObject(data_json, "userpass", cJSON_CreateString("123456")); //字符串值