您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center   Code  
会员   
   
 
     
   
 
 订阅
读懂Python解释器必须要会的C语言知识
 
作者: kobe_OKOK_

   次浏览      
2023-9-8
 
编辑推荐:
本文介绍了Python解释器、C语法语法及C语言可执行文件的内存模型,希望对您的学习有所帮助 。
本文来自于CSDN,由火龙果软件Alice编辑、推荐。

前言

曾几何时,对Python的源码开始好奇,随着对Python的依赖越来越深,特别想知道guido是怎么在短时间内设计出了这么牛逼的语言。

工作中,不只听到一个人在吐槽Python"速度慢",但是你真的知道Python慢的原因吗?

很多人都说Python是一门解释型语言,是一门脚本语言,不需要编译就直接执行,真的是这样吗?

还有人在不使用Python多线程的情况下依然吐槽GIL。。。。

这种类似的疑问还有很多,只有揭开CPython解释器的神秘面纱才可能知道这些问题的答案,如果你想用好Python,成为Python的高级玩家,那么CPython是你跨不过去的一道门槛。

Python和CPython

Python

python是一门语言

CPython

使用C语言实现的Python解释器,解释器分为两部分,编译器和虚拟机,用Python语言编写完源代码之后,会使用编译器先把python语言编译成字节码,也就是通常我们看到的.pyc或者.pyd文件,虚拟机执行编译好的字节码。

环境介绍

[root@localhost c_source]# uname -a 
Linux localhost.localdomain 3.10.0-1160.71.1.el7.x86_64 #1 SMP
Tue Jun 28 15:37:28 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
[root@localhost c_source]# gcc -v Using built-in specs. COLLECT_GCC=gcc COLLECT_LTO_WRAPPER=/opt/rh/devtoolset-9/root/
usr/libexec/gcc/x86_64-redhat-linux/9/lto-wrapper Target: x86_64-redhat-linux Configured with: ../configure --enable-bootstrap --
enable-languages=c,c++,fortran,lto --prefix=/opt
/rh/devtoolset-9/root/usr --mandir=/opt/rh/devtoolset-9
/root/usr/share/man --infodir=/opt/rh/
devtoolset-9/root/usr/share/info --with-bugurl=
http://bugzilla.redhat.com/bugzilla --enable-shared --
enable-threads=posix --enable-checking=release
--enable-multilib --with-system-zlib --enable-__cxa_atexit
--disable-libunwind-exceptions --enable-gnu-unique-object
--enable-linker-build-id --with-gcc-major-version-only
--with-linker-hash-style=gnu --with-default-
libstdcxx-abi=gcc4-compatible --enable-plugin --enable-initfini-array
--with-isl=/builddir/build/BUILD/gcc-9.3.1-20200408/
obj-x86_64-redhat-linux/isl-install --disable-libmpx
--enable-gnu-indirect-function --with-tune=
generic --with-arch_32=x86-64 --build=x86_64-redhat-linux Thread model: posix gcc version 9.3.1 20200408 (Red Hat 9.3.1-2) (GCC)

 

 

1 简述Python解释器

Python是一门”解释型语言“,python解释器常用的有三种,分别是C语言实现的CPython(官方版本),Java语言实现的Jython,Python语言自己实现的解释器。我们只关注官方版本:用C语言实现的解释器。

CPython 分为两部分,分别是编译器和虚拟机,只是Python将其归结到一条指令下,当你在命令行敲下Python xxx.py 的时候,CPython解释器会执行两个步骤,先把xxx.py编译成字节码,然后虚拟机在逐条执行字节码。

编译器类似于Javac命令,将xxx.java 编译成xxx.class文件

虚拟机类似于Java命令,将java xxx 会执行编译好的文件

CPython解释器完全用C语言实现的,大部分都是用的C语言的基础语法。下面逐个说明一下用法。

2 C语法语法

2.1 结构体

结构体是C语言中最重要的数据类型,可以让用户自定义数据类型。

定义结构体

语法

struct(关键字) 结构体名 {结构体内容}; 

struct Student {
	char* name; // 姓名
	int age;    // 年龄
	}

初始化结构体

#include <stdio.h>
#include <stdlib.h>


struct Student {
    char *name;
    int age;
}stu1, stu2 ;


int main() {
   stu1.name = "张三";
   stu1.age = 11;
   stu2.name = "李四";
   stu2.age = 12;
   printf("学生1的名字是:%s,年龄是:%d\n", stu1.name, stu1.age);
   printf("学生2的名字是:%s,年龄是:%d\n",stu2.name,stu2.age);
   return 0;
}

/*
[root@localhost c_source]# gcc main.c -o a.out && ./a.out 
学生1的名字是:张三,年龄是:11
学生2的名字是:李四,年龄是:12

*/

 

2.2 typedef

typedef 关键字用于给数据类型定义别名,常用于给结构体定义别名;

基础应用

#include <stdio.h>
#include <stdlib.h>


int main(){

 typedef int integer;  // 将int别名设置成integer
 typedef int* pinteger;  // 将int的指针设置pinteger 
 integer a = 10;
 printf("a=%d,sizeof(a)=%d\n", a, sizeof(a));
 integer b = 11;
 pinteger pb  = &b;
 printf("b=%d,&b=%p,pb=%p,*pd=%d\n", b,&b,pb,*pb);
 return 0;
}

从运行结果看,可以发现integer类型变量与int类型变量相同。

常规应用

typedef 最常用的就是定义结构体的别名

#include <stdio.h>
#include <stdlib.h>


struct Student {
 char* name;
 int age;
};


int main(){
 typedef struct Student stu; // 为struct Student 设置一个别名为stu 
 typedef struct Student* pstu;  // 为struct Student* 设置一个别名为pstu 
 stu stu1;
 stu1.name = "张三";
 stu1.age = 11;
 pstu stu2 = (pstu)malloc(sizeof(stu));  // 在堆中申请一块内存
 stu2->name = "李四";
 stu2->age = 12;
 printf("stu1的名字是:%s,年龄是:%d\n",stu1.name,stu1.age);
 printf("stu2的名字是:%s, 年龄是:%d\n",stu2->name, stu2->age);
 printf("stu2的地址是: %p\n",stu2);
 if(stu2 != NULL){
  free(stu2);  // 用完之后要释放掉
 }
return 0;
}

2.3 宏定义

宏定义用于定义常量和表达式

宏定义一个常量,代码中的符号都会被替换成100

#include <stdio.h>
#include <stdlib.h>
#define MAX 100


int main(){
 int sum;
 for(int i=0;i<=MAX;i++){
  sum += i;
 }
 printf("sum=%d\n",sum);
 return 0;
}

预编译

gcc -E t4.c -o t4.i

# 6 "t4.c"
int main(){
 int sum;
 for(int i=0;i<=100;i++){ // 预编译结束后完成了宏变量的替换
  sum += i;
 }
 printf("sum=%d\n",sum);
 return 0;
}

 

定义表达式宏

#include <stdio.h>
#include <stdlib.h>
#define ADD(x,y) ((x)+(y))

int main(){
 int a = 1;
 int b = 2;
 int add = ADD(a,b); 
 printf("add=%d\n", add);
 return 0;
}

 

预编译gcc -E t5.c -o t5.i

# 5 "t5.c"
int main(){
 int a = 1;
 int b = 2;
 int add = ((a)+(b)); //ADD(a,b) 被替换了一下
 printf("add=%d\n", add);
 return 0;
}

 

加括号的原因是为了在替换的过程中避免因为运算符的原因导致结果与实际不符。

#define MUL(x,y) ((x)*(y))

如果使用上面的宏定义
在代码中这样使用: 
MUL(1+2,3+4)
如果加了括号,完成预编译,替换宏之后 ((1+2*3+4)) 
如果不加括号,完成预编译,替换宏之后 (1+2*3+4) 
两个结果是完成不一样的。

 

在python的源码中大量使用了宏定义来提高代码的效率

python 3.8.0 中

object.h

#define _PyObject_HEAD_EXTRA            \
    struct _object *_ob_next;           \
    struct _object *_ob_prev;

typedef struct _object {
    _PyObject_HEAD_EXTRA  
    /* 这是一个宏,预编译结束后会被替换成struct _object *_ob_next 和 
    struct _object *_ob_prev  */
    Py_ssize_t ob_refcnt;  // 引用计数
    struct _typeobject *ob_type; // 对象类型
} PyObject;

2.4 预编译指令

语法

#if
#ifdef
#ifndef
#else
#elif
#endif
#define
#undef
#defined

 

2.4.1 C语言源码到可执行文件的过程

组成程序的每个源文件通过编译过程分别转换成目标代码。

每个目标文件由链接器捆绑在一起,形成一个单一而完整的可执行程序。

链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且他可以搜索程序员个人的程序库,将其需要的函数也链接到程序。

预编译 main.c -----> main.i

文本操作

#include 头文件的包含

注释删除:使用空格替换注释

#define 替换,所以宏无法进行调试。

编译 main.i ----> main.s

把c语言代码翻译成汇编代码

语法分析

词法分析

语义分析

符号汇总

汇编 main.s ----> main.o

把汇编代码转换成二进制代码(指令)。

形成符号表。(符号+地址)

链接 main.o ----> main.exe

可执行文件

2.4.2 C语言预定义符号

__FILE__      //进行编译的源文件
__LINE__     //文件当前的行号
__DATE__    //文件被编译的日期
__TIME__    //文件被编译的时间
__STDC__    //如果编译器遵循ANSI C,其值为1,否则未定义

 

# 4 "t6.c"

int main(){
 printf("__FILE__:%s\n","t6.c");
 printf("__LINE__:%s\n",6);
 printf("__DATE__:%s\n","Nov  7 2022");
 printf("__TIME__:%s\n","14:21:47");
 printf("__STDC__:%s\n",1);
 return 0;
}

 

2.4.3 宏定义的高级玩法

#define NAME "kobe" // 添加宏
#undef NAME // 移除宏

 

# 的作用

使用#,把一个宏参数变成对应的字符串。

#define PRINT(X) printf("the value of "#X" is %d\n", X)
int main()
{
	int a = 10;
	int b = 20;
	PRINT(a);
	PRINT(b);
	return 0;
}

/*
the value of a is 10
the value of b is 20
*/

 

##的作用

## 可以把位于他两边的符号合成一个符号,允许宏定义从分离的文本片段创建创建标识符。

#define CAT(X,Y) X##Y
int main()
{
	int class84 = 2021;
	printf("%d\n", CAT(class, 84)); // 2021
}

 

宏与函数的区别

宏和函数的对比

对于上述的宏,也可以用函数实现其功能。

使用宏的优点:

1.用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作需要的时间更多,所以宏比函数在程序的规模和速度方面更胜一筹。

2.函数的参数必须声明为特定的类型,所以函数只能在类型合适的表达式上使用。反之,这个宏可以用于整型、长整型、浮点数等等,宏是类型无关的。

使用宏的缺点:

1.每次调用宏,一份宏定义的代码插入程序中,除非宏比较短,否则可能会大幅度增加代码的长度。

2.宏无法调试。在预编译(预处理)阶段,已经把 # define 给替换了,已经不再是宏了。

3.宏由于类型无关,也就不够严谨。

4.宏可能会带来运算符优先级的问题,更容易导致程序出错。

2.4.4 基础预编译指令#if

#if 表达式
 printf("编译\n");
#endif 

 

#if 表达式
 printf("编译\n");
#else
 printf("未编译\n"); 
#endif 

案例

#include <stdio.h>
#include <stdlib.h>


int main(){
 #if 1
  printf("我被编译了\n");
 #endif
 return 0;
}

案例

#include <stdio.h>
#include <stdlib.h>
#define DEBUG 1 

int main(){
 #ifdef DEBUG 
  printf("我被编译了!\n");
 #endif 
 return 0; 
}

2.4.5 判断是否定义宏指令#ifdef

#define DEBUF 
#ifdef DEBUG
 printf("debug\n"); 
#endif

 

2.4.6 避免多次包含

a.h

#ifdef __A_H__
#define __A_H__ 

int add(int x, int y){
 return x+y;
}

#endif

 

b.h

#ifndef __B_H__
#define __B_H__ 

#include "a.h"

int add(int x, int y){
 return (x+y) *2;
}

#endif

 

t9.c

#include <stdio.h>
#include <stdlib.h>

/*
先导入b.h 
b.h又导入了a.h 
后面是add函数
*/
#include "b.h" 
/*
这里实际上不会执行了
因为已经在b.h中加载一次了,因为使用了#ifdef #define #endif 语句,所有不会重复加载
*/
#include "a.h"


int main(){
 int a = 1;
 int b = 2;
 int result = add(a, b);
 printf("result=%d\n", result);
 return 0;
}

 

先看这个输出结果

显然用的是b.h里面的add函数,分析一下原因。

t9.c中:

先导入b.h

b.h又导入了a.h

后面是add函数,所以a.h中的函数被b.h的覆盖掉了

如果不使用条件编译#ifdef ,程序会因为重复变量报错的。

注意:使用include导入的时候要注意:内置标准库使用#include <stdio.h>

如果是自己定义的头文件,使用#include “test.h” 使用""编译器会在当前目录中查找

2.5 assert断言

巧用断言是C高级程序员必备的技能,用好断言可以让C程序更加简洁。

不使用断言的程序

#include <stdio.h>
#include <stdlib.h>

float division(float a, float b){
 if(b==0){
  printf("除数不能为0\n");
  exit(-1);
 }  // if代码块做判断
 return a/b;
}


int main(){
 float a = 1.0;
 float b = 0;
 float res = division(a, b);
 return 0;
}

使用断言的程序

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>


float division(float a, float b){
 assert(b != 0);
 return a/b;
}


int main(){
 float a = 1.0;
 float b = 0;
 float res = division(a, b);
 return 0;
}

断言b不等于0失败了!!!

2.6 goto 语法

无条件跳转

语法

goto label; 

label: 
 expressions; 

 

注意goto语句不能跨函数使用

goto语句最好的使用场景是跳出多层循环(3层以上的循环)

如果不是非必要的,尽量少使用。

3 C语言可执行文件的内存模型

3.1 代码区text

主要存放编译好的代码和常量,这部分都是不可以修改的。

#include <stdio.h>


void main(){
 char *s = "hello world\n";
 printf("%p------> %s\n", s, s);
 return;
}

#include <stdio.h>


void main(){
 char *s = "hello world...\n";
 printf("%p------> %s\n", s, s);
 return;
}

从结果可以看出来,给字符串增加了3个字符,代码区就增加了三个字符,很好理解,与预期结果相同。

尝试修改一下常量的值

#include <stdio.h>


void main(){
 char *s = "hello world...\n";
 printf("%p------> %s\n", s, s);
 printf("%c\n",*s);
 *s = 'H';
 return;
}
~  

因为常量不能修改,所以报错。

3.2 初始化数据区

初始化数据区(data)负责存储已初始化的全局变量和静态变量,它也是和可执行文件具有相同的生命周期,在程序执行结束之前我们都可以使用它。

上面的例子我们定义的字符串在代码区,那么如果我想修改字符串应该怎么办呢?

我们可以把字符串设置成静态变量。

#include <stdio.h>


void main(){
 char *s = "hello world...";
 printf("代码区%p------> %s\n", s, s);
 char s1[] = "hello world...";
 printf("%p------>%s\n", s1, s1);
 s1[0] = 'H';
 printf("%p------>%s\n",s1, s1);
 return;
}

3.3 未初始化数据区

最后是未初始化数据区 bss,它是负责存储未初始化全局变量、未初始化静态变量。

#include <stdio.h>
#include <stdlib.h>

int main(){
 static int a,b,c,d;
 a = 1;
 c = 2;
 printf("a=%p---%d\n",&a, a);
 printf("b=%p---%d\n",&b, b);
 printf("c=%p---%d\n",&c, c);
 printf("d=%p---%d\n",&d, d);
 return 0;
}

 

#include <stdio.h>
#include <stdlib.h>

int main(){
 static int a,b,c,d,e;
 a = 1;
 c = 2;
 printf("a=%p---%d\n",&a, a);
 printf("b=%p---%d\n",&b, b);
 printf("c=%p---%d\n",&c, c);
 printf("d=%p---%d\n",&d, d);
 printf("e=%p---%d\n",&e, e);
 return 0;
}

3.4 堆区

栈的效率虽然很高,不用我们维护,但它的局限性也显而易见,就是它要求变量的大小必须明确、固定。

而当我们需要动态大小的内存时,只能使用堆,比如我们要实现可变长度的数组,那么必须分配在堆上,否则无法扩容。而堆上分配内存时,一般都会预留一些空间,这是最佳实践。

在堆上分配内存除了可以让大小动态化,还可以让生命周期动态化。我们说过,函数调用结束之后,那么函数对应的栈帧会被回收,同时相关变量对应的内存也会被回收。所以栈上内存的生命周期是不受开发者控制的,并且局限在当前调用栈。

而堆则不同,堆上分配出来的每一块内存都需要显式地释放,这就使得堆内存有更加灵活的生命周期,可以在不同的调用栈之间共享数据。因为数据只要我们不回收,那么就始终就驻留在堆上,并且何时回收也是由我们来决定的。

因此当内存动态可变的时候,我们会在堆上分配。当然啦,堆内存是负责具体存储数据的,然后还要在栈上分配一个指针,它引用堆区的内存。

3.5 栈区

栈是程序运行的基础,每当一个函数被调用时,一块连续的内存就会在栈顶被分配出来,供函数执行使用,这块内存被称为栈帧(stack frame),或者简称为帧。

这里我们就又得出了一个结论:在编译时,如果局部变量的大小不确定、或者大小可以改变,那么它的值就无法安全地放在栈上,应该要放在堆上。也就是在堆上为变量分配内存,并且还要在栈上分配一个指针,引用堆上的内存。

因此变量的值究竟分配在栈上还是分配是在堆上,结论如下:

1)如果一个函数返回了局部变量的指针,那么要在堆上为其分配内存;

2)如果在编译时,局部变量的大小不确定、或者大小可以改变,那么它的值就无法安全地放在栈上,所以此时也要在堆上为其分配内存;

3)如果变量的值过大,那么优先在堆上为其分配内存;

   
次浏览       
相关文章

编译原理--C语言C文件和头文件的关系
用 C 语言开发一门编程语言 — 抽象语法树
C语言 | 嵌入式C语言编程规范
详解C语言数组越界及其避免方法
 
相关文档

C语言-指针讲解
详解C语言中的回调函数
ARM下C语言编程解析
设计模式的C语言实现
相关课程

C++高级编程
C++并行编程与操作
C++ 11,14,17,20新特性
C/C++开发基础

最新活动计划
QT应用开发 11-21[线上]
C++高级编程 11-27[北京]
LLM大模型应用与项目构建 12-26[特惠]
UML和EA进行系统分析设计 12-20[线上]
数据建模方法与工具 12-3[北京]
SysML建模专家 1-16[北京]
 
 
最新文章
编译原理--C语言C文件和头文件的关系
用 C 语言开发一门编程语言 — 抽象语法树
C语言 | 嵌入式C语言编程规范
详解C语言数组越界及其避免方法
最新课程
C++高级编程
C++并行编程与操作
C++ 11,14,17,20新特性
C/C++开发基础
成功案例
某航天科工单位 C++新特性与开发进阶
北京 C#高级开发技术
四方电气集团 嵌入式高级C语言编程
北大方正 C语言单元测试实践