php扩展开发-使用 C 语言编写高性能 PHP 扩展的底层编译与调用(zval 与内存管理)
以字符串加解密为例
前提
^ 是什么?——异或(XOR)
C 里的 ^ 不是乘方,是按位异或:两个二进制位不同则 1,相同则 0
1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0对一个字节操作时,就是 8 个 bit 逐位做这个运算。比如字符 'A' 和 'k':
'A' = 0x41 = 0 1 0 0 0 0 0 1
'k' = 0x6B = 0 1 1 0 1 0 1 1
───────────────────────────── XOR
结果 = 0 0 1 0 1 0 1 0 = 0x2A = '*'为什么 XOR 能加密?——一个魔法性质
XOR 有个黄金定律:同一个数异或两次,等于没动。
x ^ k ^ k = x密文 = 'A' ^ 'k' = 0x2A ← 加密
0x2A ^ 'k' = 'A' ← 再异或一次同样的 k,明文回来了!所以
加密:密文[i] = 明文[i] ^ 密钥[i]
解密:明文[i] = 密文[i] ^ 密钥[i] ← 一模一样的操作!先看纯C的例子
提示
这是docker中的Linux环境
test/xor_test.c
#include <stdio.h>
#include <string.h>
int main(void)
{
char data[] = "HELLO";
char key[] = "KEY";
size_t key_len = strlen(key);
char out[6];
// 加密
for (int i = 0; i < 5; i++)
{
out[i] = data[i] ^ key[i % key_len];
}
out[5] = '\0';
printf("密文(可能不可见): ");
for (int i = 0; i < 5; i++) printf("%02X ", (unsigned char)out[i]);
printf("\n");
/* 解密:同样的循环再来一遍 */
for (int i = 0; i < 5; i++) {
out[i] = out[i] ^ key[i % key_len];
}
printf("解密: %s\n", out);
return 0;
}注意
注意密文第二个字节是 00(NUL 字节)!
如果用 printf("%s", out) 打印密文,会碰到 NUL 就截断,屏幕上一片空白,看起来像加密失败了。
这就是为什么 PHP 字符串必须记长度(len)而不是靠 \0 结尾——二进制安全。下面的 yh_encrypt 返回值可能包含任意字节,扩展层必须正确处理长度。
之后执行
-o 指定输出文件名
gcc -o xor_test xor_test.c && ./xor_test或者
-O2(优化等级2)-Wall(显示所有警告)
gcc -O2 -Wall -o xor_test xor_test.c && ./xor_test如下图所见可以看到密文又原样显示出来了
还是以youhujun(demo)扩展为例
修改youhujun/youhujun.stub.php
<?php
/**
* @generate-class-entries
* @undocumentable
*/
function yh_p(mixed $param): void {}
function yh_encrypt(string $data, string $key): string {}
function yh_decrypt(string $data, string $key): string {}之后
php build/gen_stub.php youhujun.stub.php显示:# Saved youhujun_arginfo.h
警告
XOR 只是学习参考示例——相同明文+相同密钥必然得到相同密文,无完整性校验,真实代码加密必须用 AES + HMAC
再修改youhujun.c
/* youhujun extension for PHP */
#ifdef HAVE_CONFIG_H
# include "config.h"
#endif
#include "php.h"
#include "main/SAPI.h"
#include "Zend/zend_exceptions.h"
#include "ext/standard/info.h"
#include "php_youhujun.h"
#include "youhujun_arginfo.h"
/* For compatibility with older PHP versions */
#ifndef ZEND_PARSE_PARAMETERS_NONE
#define ZEND_PARSE_PARAMETERS_NONE() \
ZEND_PARSE_PARAMETERS_START(0, 0) \
ZEND_PARSE_PARAMETERS_END()
#endif
PHP_FUNCTION(yh_p)
{
zval *param;
ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_ZVAL(param)
ZEND_PARSE_PARAMETERS_END();
if (strcmp(sapi_module.name, "cli") != 0) {
php_printf("<pre>");
}
zend_print_zval_r(param, 0);
if (strcmp(sapi_module.name, "cli") != 0) {
php_printf("</pre>");
}
}
PHP_FUNCTION(yh_encrypt)
{
char *data, *key;
size_t data_len, key_len;
zend_string *result;
// 取两个字符串参数(Z_PARAM_STRING x2)
ZEND_PARSE_PARAMETERS_START(2, 2)
Z_PARAM_STRING(data, data_len)
Z_PARAM_STRING(key, key_len)
ZEND_PARSE_PARAMETERS_END();
if (key_len == 0) {
zend_throw_error(zend_ce_value_error, "Key cannot be empty");
return;
}
//分配内存
result = zend_string_alloc(data_len, 0);
for(size_t i = 0 ; i < data_len; i++)
{
ZSTR_VAL(result)[i] = data[i] ^ key[i % key_len];
}
RETURN_STR(result);
}
PHP_FUNCTION(yh_decrypt)
{
char *data, *key;
size_t data_len, key_len;
zend_string *result;
// 取两个字符串参数(Z_PARAM_STRING x2)
ZEND_PARSE_PARAMETERS_START(2, 2)
Z_PARAM_STRING(data, data_len)
Z_PARAM_STRING(key, key_len)
ZEND_PARSE_PARAMETERS_END();
if (key_len == 0) {
zend_throw_error(zend_ce_value_error, "Key cannot be empty");
return;
}
//分配内存
result = zend_string_alloc(data_len, 0);
for(size_t i = 0 ; i < data_len; i++)
{
ZSTR_VAL(result)[i] = data[i] ^ key[i % key_len];
}
RETURN_STR(result);
}
/* {{{ PHP_RINIT_FUNCTION */
PHP_RINIT_FUNCTION(youhujun)
{
#if defined(ZTS) && defined(COMPILE_DL_YOUHUJUN)
ZEND_TSRMLS_CACHE_UPDATE();
#endif
return SUCCESS;
}
/* }}} */
/* {{{ PHP_MINFO_FUNCTION */
PHP_MINFO_FUNCTION(youhujun)
{
php_info_print_table_start();
php_info_print_table_row(2, "youhujun support", "enabled");
php_info_print_table_end();
}
/* }}} */
/* {{{ youhujun_module_entry */
zend_module_entry youhujun_module_entry = {
STANDARD_MODULE_HEADER,
"youhujun", /* Extension name */
ext_functions, /* zend_function_entry */
NULL, /* PHP_MINIT - Module initialization */
NULL, /* PHP_MSHUTDOWN - Module shutdown */
PHP_RINIT(youhujun), /* PHP_RINIT - Request initialization */
NULL, /* PHP_RSHUTDOWN - Request shutdown */
PHP_MINFO(youhujun), /* PHP_MINFO - Module info */
PHP_YOUHUJUN_VERSION, /* Version */
STANDARD_MODULE_PROPERTIES
};
/* }}} */
#ifdef COMPILE_DL_YOUHUJUN
# ifdef ZTS
ZEND_TSRMLS_CACHE_DEFINE()
# endif
ZEND_GET_MODULE(youhujun)
#endif同一个函数实现,这就是 XOR 自逆性在工程上的体现
编译
之后
make确认扩展本身能不能加载
php -d extension=modules/youhujun.so -m | grep youhujun可以看到已经有了youhujun
测试
验证加密
php -d extension=modules/youhujun.so -r 'var_dump(bin2hex(yh_encrypt("游鹄君", "abcd")));'验证解密
php -d extension=modules/youhujun.so -r '
$s = "游鹄君";
$key = "abcd";
$e = yh_encrypt($s, $key);
var_dump(bin2hex($e));
var_dump(yh_decrypt($e, $key) === $s);
'
验证抛异常
php -d extension=modules/youhujun.so -r 'yh_encrypt("abc", "");'
关于"内存没释放"的疑惑
代码里没有 leak,原因是 Zend 内存的两层机制:
zend_string_alloc走 ZendMM,请求结束统一回收,忘了 RETURN 也不会永久泄漏(兜底)- 更关键的是
RETURN_STR(result)把所有权移交给了引擎,由引擎管引用计数,变量被回收时自动释放 - 这就是 C 扩展最容易踩坑的点:分配出来的东西,要么 RETURN 交给引擎,要么自己
zend_string_release(),必须二选一
报错处理 未定义标识符 "zend_ce_value_error"C/C++(20)
排错套路参考:C扩展开发通用排错套路-未定义标识符 xxx
查找zend_ce_value_error声明位置
grep -rn "zend_ce_value_error" /usr/local/php/include/php/ --include="*.h"/usr/local/php/include/php/Zend/zend_exceptions.h:35:extern ZEND_API ...
加上引用
#include "Zend/zend_exceptions.h"zend_throw_error 自己声明在 Zend/zend.h(php.h 已经包含),所以不用管。只有 zend_ce_value_error 这个类指针变量需要额外 include——因为类指针在 PHP 8之前不存在,是 8.0 新加的
拓展
Z_PARAM_类型 取参数
| 宏 | PHP接收类型 | 给到你的C变量 |
|---|---|---|
| Z_PARAM_ZVAL(p) | mixed(任意) | zval *p |
| Z_PARAM_STRING(s, l) | string | char *s + size_t l |
| Z_PARAM_LONG(n) | int | zend_long n |
| Z_PARAM_DOUBLE(d) | float | double d |
| Z_PARAM_BOOL(b) | bool | bool b |
| Z_PARAM_ARRAY(a) | array | zval *a(HashTable 要再取一层) |
| Z_PARAM_OBJECT(o) | object | zend_object *o |
进阶:Z_PARAM_OPTIONAL
char *key = "default_key";
size_t key_len = strlen(key); // 或用 sizeof("default_key") - 1
ZEND_PARSE_PARAMETERS_START(1, 2)
Z_PARAM_STRING(data, data_len)
Z_PARAM_OPTIONAL
Z_PARAM_STRING(key, key_len)
ZEND_PARSE_PARAMETERS_END();可选参数必须提前初始化默认值,宏不会帮你兜底
Zend API 全是这个套路,不止 Z_PARAM_ 一家,命名规律贯穿整个内核:
| 家族 | 作用 | 例子 |
|---|---|---|
| Z_PARAM_* | 读取函数入参(本次学习的) | Z_PARAM_LONG |
| Z_TYPE / Z_STRVAL / Z_LVAL | 拆解解析 zval 容器 | Z_LVAL_P(param) |
| RETVAL_* / RETURN_* | 设置返回值(RETURN直接返回;RETVAL设置之后还要return) | RETURN_STR、RETVAL_LONG |
| ZSTR_* | 操作 zend_string 字符串对象 | ZSTR_VAL、ZSTR_LEN |
