目录
- 0. 前言
- 1. 开发环境
- 1.1 IDE
- 1.2 代码文本编辑器
- 1.3 编译器
- 1.3.1 GCC
- 1.4 调试器
- 2. C语言
- 2.1 位域
- 2.2 指示器
- 2.2.1 数组指示器
- 2.2.2 结构体指示器
- 2.2.3 结构体 + 数组
- 2.3 变长数组
- 2.4 预处理指令
- 2.4.1 `#`运算符
- 2.4.2 `##`运算符
- 2.4.3 可变参数宏
- 2.5 泛型选择
- 2.6 内建函数
- 2.7 其他特性
0. 前言
写这篇文章起因是因为我在实际工作中遇到了很多有关C语言开发的基础知识,而这些基础在学校里却几乎不会提及。下面我将从一个嵌入式开发者的角度分享一些比较实用的基础。
1. 开发环境
1.1 IDE
根据我自己的经历,现在学校里大部分编程都会从c语言入门,用来教学以及实践的软件一般是VC++6.0。
因为VC++6.0安装方便,轻量化,上手简单等优势,非常适合初学者入门,它其实是一个很古早的IDE(Integrated Development Environment),即集成开发环境。
IDE可以提供代码的文本编辑、分析、编译以及调试功能,但也因为其集成度比较高,它的目标群体也会更加狭窄,比如Pycharm,IntelliJ idea,Clion等JetBrains出的IDE系列,就是分别针对于Python,JAVA以及C/C++的开发环境,也有像Visual Stidio之类的多平台重型IDE,不过微软开发的Visual Stidio更多注重于Windows桌面环境的开发。
同理也有针对嵌入式开发环境的IDE,像很多同学可能都比较熟悉的Keil,还有IAR Embedded Workbench。这两家因为支持的芯片范围大,使用频率高等原因,深受大量嵌入式开发人员的喜爱,但是相信大家对其复古的界面和有限的自定义选项也是印象深刻。因此有实力的芯片厂商常常会基于开源的Eclipse自定义自己的IDE,以定制更加符合自身芯片特性的开发环境,比如ST公司的STM32CubeIDE,TI公司的的CCS,乐鑫的ESP IDE等,或者也有以Eclipse插件形式存在的对芯片的编译支持。
总体来说,也正是因为这些IDE高度定制化后的局限性,IDE更适合需求简单,或者刚入门的开发者。但是为满足各行各业开发者的需求,如果我们将IDE的代码编辑、分析、编译以及调试功能都剥离开来,每一个功能其实都有多款专用的软件可以支持,从而方便各行各业开发者根据自身实际需求定制开发环境。
1.2 代码文本编辑器
代码编辑的本质是文本编辑,也就是用windows自带的文本编辑器都能写代码,但是不会有程序员喜欢用它去写代码,因为它没有辅助程序员写代码的功能,写起来事倍功半。针对代码编辑功能,最受欢迎的两款轻量级文本编辑器是sublime和VS Code,当然它们也不是完全局限于文本编辑功能,受益于其丰富的插件系统,它们也能实现基础的代码分析功能。当然也还有大佬喜欢用Vim作为代码编辑器,它的优势是可以在没有GUI用户界面的Linux上为开发者提供代码编辑功能。
1.3 编译器
编译器的功能主要是将人能够阅读的文本代码翻译成能在目标平台上运行的机器码。众所周知,代码有很多语言,像Python,C/C++,Rust,JAVA,C#,Objective-C,Swift等等,目标平台架构也有很多,比如x86架构(常见于桌面级的CPU,比如Intel和AMD的CPU),各种各样的ARM架构(常见于移动平台,系统级SoC以及嵌入式系统),开源的RISCV架构等等。代码语言以及架构的排列组合就需要各种编译器。当然每款编译器也可能支持多种语言以及多个平台。
以C语言为例,常见的编译器有GCC,Clang,LLVM等,每种编译器支持的C语言标准可能不同,比如某款编译器可能不支持一些C语言的新特性,这就需要在编程的时候注意我们所使用的编译器所支持的语法。对于嵌入式开发而言,由于目标平台纷杂繁复,各家厂商一般还是需要在已有的开源编译器的基础上,定制一些自己的功能,以适用自身的芯片。比如乐鑫在gcc的基础上,定制了针对xtensa以及riscv的编译器。
1.3.1 GCC
GCC是一款使用非常频繁且非常强大的编译器,它也不仅支持编译C语言,也可以编译如C++、 Objective-C、Fortran、Java、Ada和Go等语言。如何编译并不是本文重点,但是我想介绍一下它识别额外加在代码中的属性声明功能,如果你在C语言中有见到过__attribute__(())
这样的语句,那其实是在通过属性声明为编译器指定编译需求。这里简单介绍几个比较常用的属性声明:
__attribute__((deprecated("This function is deprecated")))
可以用于在编译时提示该函数已被弃用;__attribute__((aligned(4)))
用于指定一块内存的对其字节;__attribute__((packed))
可以让GCC采用紧凑的方式为目标变量分配内存,尤其是用于结构体或联合体;__attribute__((always_inline))
要求编译器必须内联该内联函数;__attribute__((weak))
声明符号为弱符号,可被强符号覆盖;__attribute__((optimize("-O3")))
用于指定编译的优化等级;__attribute__((unused))
用来表明该变量或函数未使用,可以去掉编译时usued的warning;__attribute__((alias("xxx")))
用来表示同名符号;__attribute__((section()))
用来指定该变量或函数编译后存放位置(如:ram, flash等)
其他更多的编译选项可以参考GCC官网的手册
1.4 调试器
在完成代码撰写后,除了一些编译错误能够在编译阶段直接发现,程序实际运行时我们常常会遇到各种各样的bug,我们用来调试的方法除了大名鼎鼎的print大法,在有条件的情况下使用调试器能够为我们debug提供更高的效率。C语言调试主要还是GDB,而嵌入式环境下则还需要通过片上调试器来使GDB能够进行片上调试,其中OpenOCD是一款常见的开源片上调试器,通过OpenOCD以及各种linker连接嵌入式芯片后,即可使用GDB对其进行调试。
2. C语言
在学校里学的C语言为了方便初学者入门,一般都会选择最经典的C89或C99标准为基础进行教学,但是经过这些年的发展,C语言的标准已经经历了好几个版本,包括C1X(C11,C18等),C20等,正如上文提到,一些最新的特性不一定所有编译器都支持,因此使用新特性时也要结合编译器使用。
下面将介绍一些C语言比较实用或者有意思的特性。
2.1 位域
我们有时候会在结构体或联合体的字段后面看到冒号接一个数字,这个就是位域:
struct test_s{unsigned int a : 1;unsigned int b : 2;unsigned int c : 1;unsigned int reserve : 28;
} test;
注意冒号后面的值可不是变量初始化的值,它表示了这个字段所占的位宽,比如a
占了一个比特,b
占了两个比特。
位域的好处一是可以节省结构体整体所占空间,二是可以通过位域的方式为某一个或某几个比特取一个字段名,三是可以直接通过字段名取某一比特的值,如通过test.c
就可以方便的获取或设置第四位的值。
结构体中的位域常常被用于映射寄存器的特定比特。
2.2 指示器
指示器用于数组或结构体初始化时为指定字段赋值。
2.2.1 数组指示器
指示器可以从任意位置开始赋值,也可以在后面覆盖前面的值,然后再从该位置开始赋值
int a[] = {[3] = 1, 5, 6, [8] = 7, [4] = 2, [9 ... 15] = 1};
2.2.2 结构体指示器
结构体指示器用的比较多,可以通过字段赋值
struct test_s test = {.a = 1,.c = 1,.b = 2,
};
2.2.3 结构体 + 数组
struct test_s test[3] = {[0 ... 2] = {.a = 1,.c = 1,.b = 2,}
};
2.3 变长数组
C99 开始,在栈上的数组支持变长,但是不能在声明时被初始化,可以用于保存名字之类的字符串
#include <stdio.h>
#include <stdint.h>
#include <string.h>/* 使用变长数组参数时,长度参数需要在变长数组的前面 */
void my_strcat(size_t len, const char name1[len], const char name2[len]) {/* 确定最长长度,避免strlen取值溢出 */int len1 = strlen(name1) < len ? strlen(name1) : len;int len2 = strlen(name2) < len ? strlen(name2) : len;/* 声明变长数组,+1用于保存结束符`\0` */char cat_name[len1 + len2 + 1];/* 因不能初始化赋值,需要手动清零,保证最后一个字节是`\0` */memset(cat_name, 0, len1 + len2 + 1);/* 复制字符串到变长数组,限制复制长度避免溢出 */strncpy(cat_name, name1, len1);strncpy(cat_name + len1, name2, len2);/* 打印拼接的字符串 */printf("%s\n", cat_name);
}int main(void)
{char name1[] = "Hello ";char name2[] = "World!";func(7, name1, name2);return 0;
}
2.4 预处理指令
预处理指令也是在学校中很少提起的内容,编译器在第一阶段就会对预处理指令进行展开等操作。预处理包括条件编译#if #else #endif
,编译编译指示#pragma
,宏定义#define
等,下面主要介绍一些宏的实用操作。
2.4.1 #
运算符
在宏定义中,#
表示将目标符号转为字符串,常用于打印一些日志信息,如:
#define GET_STR(symbol) #symbolvoid func(void *handle)
{if (!handle) {/* 若handle为NULL时,会打印 'handle is NULL' */printf("%s is NULL\n", GET_STR(handle));}
}
2.4.2 ##
运算符
宏定义中,##
则为符号的连接,通过符号连接,可以得到一个新的符号,一般用来连接得到一些函数、变量、字符串等等,用法多种多样。比如拼接函数的:
#define FUNC_N(n) func_##nvoid func_1(void)
{printf("func_1\n");
}void func_2(void)
{printf("func_1\n");
}int main(void)
{FUNC_N(1);FUNC_N(2);/* 注意,宏处理是在预编译阶段,因此拼接的符号不能为变量,如下写法则会因为找不到func_i而报错 */// for (int i = 1; i < 3; i++) {// FUNC_N(i);// }
}
2.4.3 可变参数宏
有时候我们需要对例如printf
之类的可变参数函数定义一个宏,那我们就需要用到可变参数宏(C99),它用了一个预定义宏__VA_ARGS__
指代了可变参数的部份
#define TEST(condition, ...) (\(condition) ? \printf("Passed test %s\n", #condition) : \printf(__VA_ARGS__) \
)
2.5 泛型选择
泛型选择是C1X中的特性,它可以提供类似函数重载的功能,即用同一个函数名对应多个函数,但是泛型选择只能对一个表达式的类型进行选择,类似于switch功能。
#define TEST_TYPE(x) _Generic(x, \int: test_int, \float: test_float, \default: test_default)(x)
void test_int(int x)
{printf("test_int: %d\n", x);
}void test_float(float x)
{printf("test_float: %.1f\n", x);
}void test_default(void *x)
{printf("test_defualt: %p\n", x);
}int main(void)
{int a = 1;float b = 2.0;char c = 'n';TEST_TYPE(a); // 打印 test_int: 1TEST_TYPE(b); // 打印 test_float: 2.0TEST_TYPE(&c); // 打印 test_defualt: + 变量c的地址
}
泛型选择也不仅限于函数重载,由于它更加类似于用来判断表达式类型的switch语句,因此在类型: 表达式
中,类型可以是typdef的类型,而不局限于基本类型,表达式可以是字符串、一个语句甚至一个语句块,由此增加了泛型选择使用的灵活性。
2.6 内建函数
内建函数是编译器内部实现的函数,可以像关键字一样直接调用,一般以__builtin
开头,内建函数可以帮助我们快速实现一些简单的功能,常见的内建函数有
__builtin_return_address(LEVEL)
用于获取某一层的返回地址,0获取当前函数返回地址,1为上一级函数返回地址,以此类推.__builtin_frame_address(LEVEL)
用于获取函数调用的栈帧地址,一般用于调试时查看函数调用栈的情况。__builtin_constant_p(n)
判断是否为常数__builtin_clz(n)
返回二进制的n中,从最高位向右计数,直到第一位不是0为止,返回计数的0的个数,注意,如果n是0时,行为未定义__builtin_ctz(n)
返回二进制的n中,从最低位向左计数,直到第一位不是0为止,返回计数的0的个数,注意,如果n是0时,行为未定义__builtin_ffs(n)
返回二进制的n中,最低非零位下标,从1开始计数,n为0时,返回值为0__bulitin_popcount(n)
返回二进制的n中,1的个数__builtin_parity(n)
返回二进制的n中,1个数的奇偶,奇数返回1,偶数返回0
2.7 其他特性
其他还有很多实用的新特性,比如复数运算、原子操作、多线程等等,本文不作展开(因为懒),不过推荐大家一本C语言特性写得非常详细的教材,《C语言程序设计——现代方法》(注意不要买成习题解答)。
另外,如果想要了解一些嵌入式开发中可能会用到的一些规范以及技巧,推荐一下《嵌入式C语言自我修养——从芯片、编译器到操作系统》,这本书很适合对嵌入式开发有一个快速且全面的了解,里面也提到了很多很实用的技巧,看完受益匪浅。