C语言笔记
目录
语法要点
操作符号
运算符优先级
有些表达式的优先级和直觉相反,有些表达式看起来有二义从而难以确定应该怎么理解,出现二义时需要引入结合性来分析,下面列举几个建议添加括号说明的例子。
if (flags & NEED_READ != 0) // A read_data(); r = hi << 4 + low; // B while (c = getc(in) != EOF) // C putc(c, out); p = n * sizeof * q; // D apple = sizeof(int) * p; // E
- A
- 先执行逻辑比较再进行位运算
- B
- 先计算加法再计算移位
- C
- 先比较
getc()
和EOF
是否不相等,再赋值 - D
sizeof
优先级低于解引用,等价于n * sizeof(*q)
- E
sizeof
和int
先结合,如果写成sizeof(int)p
则编译不通过
括号是超越优先级的存在,必要的时候用括号增加易读性,但是切莫滥用括号。我精心编写了一组口诀来记住优先级,如下所示。
成员-一元-莫转换,四则-移位-比较看 位与-逻辑-问条件,赋值-逗号-后排见
关键字
- const
- 定义常量
- volatile
- 防止编译器优化省略
- register
- 建议编译器优化放到寄存器中,不在内存中故不能取地址
- static
- 用于限制作用域或存储位置
- extern
- 用于声明外部变量
如下代码如果不声明为volatile
会被编译器优化为一行代码:
XBYTE[2]=0x55; XBYTE[2]=0x56; XBYTE[2]=0x57; XBYTE[2]=0x58;
但是对于配置外部硬件而言,可能需要一个完整的操作顺序,这时就不能被优化。
一个const volatile
例子是只读状态寄存器,它不允许程序员改变,但可能受硬件环境影响。指针也可以是volatile
,如中断服务程序可以更改一个指针的指向。
用C++编译器编译C语言会修改函数名以支持重载,宏__cplusplus
是C++编译器内置的宏,用extern "C"
可以防止修改函数名导致符号找不到。通常都需要C文件在C++编译器和C编译器编译后得到一致的结果,所以这种写法在C代码中很常见。
#ifdef __cplusplus extern "C" { #endif ... #ifdef __cplusplus } #endif
内置类型
内置类型的最大值、最小值可以从头文件limits.h
中查询。
printf("%d", INT_MAX);
右移位的时候,如果是一个负数,那么高位可能是由0填充,也可能是由符号位填充,因此可移植的办法是使用无符号类型来右移位。此外移位的数目应该要保证大于等于0,并且严格小于对象的位长。对于负数,右移一位并不等于除以2,举例来说,-1 >> 1
一般不会是0,而-1 / 2
则为0。如果知道数值为非负,那么用移位来代替除法是没有问题的。
数组指针
数组
C99支持VLA(变长数组),但是不推荐使用,因为栈上分配数组容易导致崩溃。
指针
指针常量:指向固定
int *const cp = &x;
常量指针:只读指针
const int *pc = &x; int const *pc = &x;
双常量指针:指向固定的只读指针。
const int *const cpc = &x;
理解const
要记住它修饰的是其右边的东西,如右边是ptr
,那么就表示指向固定,如右边是*ptr
,那么就表示内容只读。
更复杂的形式:
const int **p; // A char *const *(*next)() // B
- A
- 右边是
**p
所以实际上是指内容只读 - B
- 函数指针,返回一个二级指针,第一级为常量指针
函数
参数从右往左入栈。
数组参数默认会退化为指针:
// 等价写法:int arr[] void arr1_args(int *arr, int len); // 等价写法:int (arr[])[LEN] -> int arr[][LEN] void arrp_args(int (*arr)[LEN]); // 等价写法:int arr[][COL] void arr2_args(int (*arr)[COL], int row);
int isvowel(c) char c; { return c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u'; }
在很多库函数中能看到这样的写法,这种写法主要是为了与老的编译器兼容,老的编译器不支持指定函数参数类型,所以在传递参数时会默认转换为int,这种写法和如下写法等价。
int isvowel(int i) { char c = i; return c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u'; }
如果编译器不优化,inline就是普通函数,更便于调试,调试好了之后采用优化重新编译,inline函数就像宏一样融入代码。在早期,inline还不是关键字,可以看到gcc库中有用__inline__
,就是处于兼容性考虑。
可变参数
#include <stdarg.h> #include <stdio.h> void test1(int count, ...) // number by count { va_list args; va_start(args, count); for (int i = 0; i < count; i++) { printf("%d\n", va_arg(args, int)); } va_end(args); } void test2(const char* s, ...) // end by NULL { const char* value = s; va_list args; va_start(args, s); while (value != NULL) { printf("%s\n", value); value = va_arg(args, char*); } va_end(args); } void test3(const char* format, ...) // parse by format { va_list args; va_start(args, format); vprintf(format, args); va_end(args); } int main(int argc, char* argv[]) { test1(3, 11, 22, 33); test2("hello", "world", NULL); test3("number %d\n", 1234); return 0; }
结构体
结构体长度
如果将尾部成员定义为0长数组,计算结构体大小时其对应成员长度按0计入。这种技巧的写法主要用在对性能和内存要求比较苛刻的情况。
class empty {}; struct zero_only {char str[0];}; struct zero_tail {int count; char str[0];}; printf("%d %d %d\n", sizeof(struct empty), sizeof(struct zero_only), sizeof(struct zero_tail));
1 0 4
用g++编译的结果如上所示,但这不是说答案就应该是这样的。标准C不允许0大小的对象,即0长数组、空结构、空共同体都是不允许的。所以这种写法没有可移植性。而C++标准则允许空类,空类具有长度是为了保证每个对象都有一个独立的地址。
在比较底层的代码中,经常可以看到在结构体定义中使用属性:
struct x86_hw_tss { u32 reserved1; u64 sp0; u64 sp1; ... } __attribute__((packed)) ____cacheline_aligned;
- packed
- 表示成员字段不用对齐
- ____cacheline_aligned
- 整个数据结构按照高速缓存行的大小对齐
默认的位段还是会对齐,仅在在能够容纳的地方优化存储,如果加上packed
属性就会取消一切对齐。不论那种方法都不允许对位段成员取地址。
// 1 + 2 + 8 typedef struct bsi {bool b; short s; int i;} __attribute__((__packed__)) BSI; typedef struct bis {bool b:6; int i:6; short s:1;} BIS; typedef struct bisp {bool b:6; int i:6; short s:1;} __attribute__((packed)) BISP; printf("%d %d %d\n", sizeof(BSI), sizeof(BIS), sizeof(BISP));
7 4 2
结构体组织
宏offsetof(class, member)
可用于计算成员偏移量,但是这个宏其实是有很多限制的,比方不能对结构体中的压缩位字段计算。
结构体的点操作符可以帮助我们访问结构的成员,反过来如果我们知道当前结构或数据类型属于某个结构体,也可以利用存储特性由成员获取父结构体。举一个例子,请看如下代码,问号处的代码应该怎么写才能返回信息所属的人?
struct person { char name[10]; int age; int id; struct infomation info; }; struct person *get_person(struct infomation *info) { // ? } int test_get_person(void) { struct person p; return &p == get_person(&p.info); }
在Linux内核中大量的运用到了get_person()这样的函数,或者说宏,比较典型易读的代码可以参考linux/list.h
文件。实现的关键思路就是在struct的表示中,成员地址相对于结构体的首地址偏移量是固定的。典型的宏定义如下。
/** * container_of - cast a member of a structure out to the containing structure * @ptr: the pointer to the member. * @type: the type of the container struct this is embedded in. * @member: the name of the member within the struct. * */ #define container_of(ptr, type, member) ({ \ const typeof(((type *)0)->member) *__mptr = (ptr); \ (type *)((char *)__mptr - offsetof(type, member));})
另一种更简洁的写法如下:
#define container_of(ptr, type, member) \ (type *)((char *)(ptr) - (char *) &((type *)0)->member)
有了这个宏,问号处的代码就很清晰了,直接写return container_of(info, struct person, info)
即可。
宏
#define max(a, b) ((a) > (b) ? (a) : (b)) int c = max(a++, ++b); // A #define TP struct table * TP a, b; // B #define a (x) sum(x) a(u); // C
- A
- 宏的定义本身没有问题,问题出在调用处执行了有副作用的代码
- B
- 解决方法是避免用宏来定义类型,改用
typedef
- C
- 展开得到
(x) sum(x)(u)
,故定义时要仔细
一个典型的assert宏定义为如下形式:
#define __assert_err(e, file, line) \ ((void)printf("%s:%u: assert '%s'\n", file, line, e), abort()) #define assert_err(e) \ ((void)((e) ? (void) 0 : __assert_err(#e, __FILE__, __LINE__)))
如果一个宏当中包含多个语句,应当使用do-while形式:
#define SET32(p_dst, src) \ do { \ (p_dst)[0] = (u8)(src); \ (p_dst)[1] = (u8)(((u32)(src)) >> 8); \ (p_dst)[2] = (u8)(((u32)(src)) >> 16); \ (p_dst)[3] = (u8)(((u32)(src)) >> 24); \ } while (0)
不能简单用大括号包围,因为遇到不带括号的if-else语句时会打断if-else从而不能通过编译。
可执行程序
位置无关代码的概念和动态库紧紧相连。静态库主要有两个问题,一是升级麻烦,二是常用函数大量复制让软件体积暴涨。优点是代码和数据在可执行文件有备份,符号地址固定。如果不将动态库编译成位置无关代码,就需要加载到指定地址,运行时就不得不修改页面将其安排到固定位置,造成效率下降。
注意事项
常量与变量
自动变量
#define BUFSIZE 1024 int main() { int c; char buf[BUFSIZE]; setbuf(stdout, buf); while((c = getchar()) != EOF) putchar(c); return 0; }
在main函数运行之后,将会刷新缓存,而此时作为自动变量的buf已经被释放了。可以通过将buf定义为静态变量避免这个问题。
进制误用
struct { int part_number; char *description; } part_table[] = { {027, "windows"}, {077, "linux"}, {123, "others"}, };
这里企图用0来对齐,但是编译器会将027/077视为八进制数据,这很可能不是程序编写者本身的意图。
越界与溢出
访问越界
int a[10]; for (int i = 0; i <= 10; i++) { a[i] = 0; printf("a[%d] = %d, ", i, a[i]); }
如果编译器按照内存地址递减的顺序给变量分配空间,那么a[10]
对应的实际就是变量i
,当循环到i=10
的时候会将i
复位为0,从而形成死循环。
运算溢出
if (a + b < 0) // A printf("out of range\n");
A处企图用两个整型相加的结果为负来判断是否溢出,但是这样的方法并不正确,例如某些机器上溢出会产生一个溢出状态,此时的结果就不为负数。比较简单的方式是通过a > INT_MAX - b
来判断,此外也可以将其转换为无符号整数来判断。
输入输出
char c; for (int i = 0; i < 5; i++) { scanf("%d", &c); printf("%d ", i); }
这部分代码的关键问题是c被声明为char类型,而在输入时又当作整型数,会导致变量c附近的内存被覆盖。
异常处理
错误编号
不要在正常返回的情况下检查errno,因为即便所调用的函数返回正确,也可能在函数中又调用了其他函数,而其他函数有可能会设置errno。
信号处理
信号可能出现在某些复杂的库函数中,如果signal处理函数中再调用这样的函数,结果可能导致不堪设想的后果。因此首先要避免在信号处理函数中调用复杂函数。
假设malloc执行过程被一个信号中断,此时malloc用于追踪可用内存的数据结构可能只有部分更新,如果在signal处理函数中再调用malloc,就可能让malloc完全崩溃。故切忌在信号处理函数中调用malloc。在信号处理函数中中使用longjmp也不安全,因为信号可能发生在malloc更新数据结构的过程中。信号处理函数能够做的安全的事情就是设置一个标志然后返回,期待主程序检查到这个标志。
对于算术运算,某些机器在信号处理函数返回时还会重新执行失败的操作,而我们又没有办法更新操作数,故此时唯一安全可移植的办法就是打印一条出错消息,然后用exit退出程序。
简单来说,大多情况信号处理函数正确做法是设置标志,由主程序检测标志,特殊情况,如算术出错信号,应直接退出程序。