1.2.1 一切皆bit
将8 bit分为一组,我们定义了字节(Byte)。
1956年6月,使用了Byte这个术语,用来表示数字信息的基本单元。
最早的字节并非8 bit。《计算机程序设计的艺术》一书中的MIX机器采用6bit作为1Byte。8 bit的Byte约定,和IBM System/360大型机的研发有关,由其领导者布鲁克斯(Frederick P.Brooks,美国,1931-2022)推行开来。
对于单字节中的位,从左到右,我们依次编号为bit7~bit0
字节是计算机世界中计量大小的基本单位,例如:1KB=1024Byte,1MB=1024KB1GB=1024 MB。这里1024=210。为了方便表示二进制,人们将其4位一组,使用0~F这16个字符进行十六进制编码
字节序
例如,0x12345678,在内存中的存储可能是0x12、0x34、0x56、0x78(内存的高位地址存的是高位)
也可能是0x78、0x56、0x34、0x12。前者被称为大端法(Big-Endian),后者被称为小端法(Little-Endian)。
比如,在计算机系统的本地内存中,Intel与ARM架构常使用小端法表示法,也叫作本机序(Host 0rder)。而在网络传输上,人们习惯于使用大端法,即网络序(Net 0rder)。又如,BMP文件使用的是小端法,而MP4文件使用的是大端法。
位序
但是我们在读取位的时候,会存在两个方向:从bit0读到bit7,或从bit7读到bit0。
例如,对于0x39=0b 00111001,从不同的方向读取,就会得到00111001或者10011100两种结果。
后面我们会看到,位序对于理解图像格式和压缩算法都是非常重要的。例如,对于PNG、GIF图像格式,位序是从bit0开始的。但是对于JPEG图像格式,位序则是从bit7开始的。
比特流与比特率
对于计算机中连续存储的字节单元,我们可以将其看作一个比特流
为了衡量信息的传递速度,我们定义比特率为每秒钟传送的比特,单位为bps(bit persecond)。比特率也称为码率。
bps中的b是小写的。对于字节,用大写的B表示Byte,1Bps=8 bps。
在音视频通话时,若带宽小于300 kbps,则我们称之为低带宽。
低带宽下RTC优化标准为:纯音频最低支持60kbps,音视频最低支持100kbps。
Base64表示法
十六进制通过将4 bit分为一组,得到了0x00~0x0F这16种不同的值
在早期,由于历史原因,电子邮件只允许传输英文字符数据[10]。当传输非字符数据时,网Gateway)会将字节中8位的最高位置为0。
为了能将二进制值用英文字符表示出来,人们设计了Base64编码。
Base64编码将6 bit分为一组,并用26-64个不同的字符来表示。
由于字节是8 bit一组的,当字节数不是3的倍数时,比特数不能被6整除。
Base64编码使用等号字符=在最后做附加(padding),最终可能是3种情况:没有=,1个或者2个=,表示此Base64串末尾有多少个附加字节。
1.2.2 字符管理类
最简单的Buffer类,通常结构如下:
#pragma once
#include "DTypes.h"
typedef struct tagBuffer {
DByte* pBuf; // 指向二进制数据的开始位置
DUInt32 nSize; // 二进制数据的字节
};
其中,pBuf指向二进制数据的开始地址,nSize为二进制数据的字节数,如图1-6所示。在开源web服务器Nginx中,字符串就是这样定义的。
我们可以用下面的代码进行一下测试:
#include "Buffer.h"
#include <stdio.h>
int main() {
tagBuffer buffer;
//分配多少个字节
int byteSize = 128;
buffer.pBuf = (DByte*)malloc(sizeof(DByte) * byteSize);
buffer.nSize = byteSize;
//使用buffer
if (buffer.pBuf) {
//do something with buffer.pBuf
for (int i = 0; i < 10; i++) {
buffer.pBuf[i] = i;
}
//show buffer contents
for (int i = 0; i < 10; i++) {
printf("%d ", buffer.pBuf[i]);
}
//free buffer
free(buffer.pBuf);
buffer.pBuf = NULL;
buffer.nSize = 0;
}
return 0;
}
然而上述定义存在很多问题,比如,如何控制内存分配与释放,如何让多个线程安全地共享,如何在函数与线程间高效地传递,减少复制次数。
Buffer作为传输通信中最常用的对象,我们需要为其实现更多的易用特性
极小栈消耗。
内存自动回收。
跨线程安全传递。
·写时复制(Copy-on-Write)
·十六进制表示。
·Base64转换。
简单定义
优化定义
本书提供的Buffer实现,采用内存布局。对比简单定义,新的内存布局有如下特点。
(1)栈区只使用了一个指向堆区的指针,这使得占用栈空间的代价极小。
(2)栈指针指向堆区Buffer的起始地址,这使得可以随时访问Buffer的内容。
(3)在堆区Buffer前面,还有两个成员:nRefCount用来维护Buffer的引用计数;nSize用来表示Buffer的大小。
struct DBufferData {
DInt32 nRefCount;
DInt32 nSize;
DByte* pBuf;
};
typedef struct StackBuffer {
DBufferData* pData;
};
#include "Buffer.h"
#include <stdio.h>
#include <iostream>
using namespace std;
int main() {
DBufferData data;
data.pBuf = new DByte[10];
data.nSize = 10;
data.nRefCount = 0; // 初始为0
StackBuffer stackbuffer;
if (data.pBuf != NULL) {
//我们让stackbuffer指向它
stackbuffer.pData = &data;
//让data的引用计数加1
data.nRefCount++;
}
cout << "stackbuffer.pData->nRefCount = " << stackbuffer.pData->nRefCount << endl;
stackbuffer.pData->nRefCount--; // 引用计数减1
stackbuffer.pData = NULL;
if (data.nRefCount == 0) {
delete[] data.pBuf; // 引用计数为0,释放内存
data.pBuf = NULL;
data.nSize = 0;
}
return 0;
}
引用计数
引用计数可以有效地记录对象自身被线程持有的情况:
(1)当Buffer初始化时,其引用计数为1。
(2)当需要读取访问或复制构造Buffer时,无须复制Buffer的内容。我们仅需新增一个指向堆区的栈指针,并且让Buffer的引用计数加1。这里的指针,可能来自同一个线程的栈,也可能来自不同线程的栈。
(3)当不再需要访问Buffer时,将其引用计数减1。
(4)当引用计数减到0时,对象不再被任何线程持有,可安全地销毁[12]堆区Buffer。
(5)当需要修改Buffer时,根据引用计数的情况,决定是否需要写时复制
由于涉及多个线程之间的共享操作,引用计数必须用原子变量来实现。
在Common文件夹下,有一个DAtomic.h文件,提供了对C++11<atomic>的类型封装。
我们先来进行原子操作的定义。
#pragma once
#ifndef DATOMIC_H
#define DATOMIC_H
#include <atomic>
template<class T>
class DAtomic {
public:
// 构造函数初始化原子变量
DAtomic(const T& initialValue = T()) : value(initialValue) {}
// 原子加载
T load() const {
return value.load();
}
// 原子存储
void store(T newValue) {
value.store(newValue);
}
// 原子增加
T fetch_add(T addValue) {
return value.fetch_add(addValue);
}
// 原子减少
T fetch_sub(T subValue) {
return value.fetch_sub(subValue);
}
private:
std::atomic<T> value;
};
#endif // DATOMIC_H
测试atomic
#include <iostream>
#include "DAtomic.h"
using namespace std;
int main() {
DAtomic<int> counter;
int currentValue = counter.load();
cout << "Initial value: " << currentValue << endl;
counter.store(10);
cout << "New value: " << counter.load() << endl;
int newValue = counter.fetch_add(5);
cout << "New value after fetch_add(5): " << newValue << endl;
newValue = counter.fetch_sub(3);
cout << "New value after fetch_sub(3): " << newValue << endl;
cout << "Final value: " << counter.load() << endl;
return 0;
}
写时复制
当引用计数为1时,我们可以正常地随意读写Buffer内的所有数据。
但当引用计数大于1时,如要修改Buffer的内容,我们就需要对其数据进行复制。这样各个线程仍可以像自己独占Buffer一样,访问各自的Buffer数据,使读写互不影响。
写时复制(Copy-On-Write,简称COW)是一种优化技术,用于减少内存使用和提高性能。其核心思想是,当多个用户共享同一份数据时,只要这些用户只读取而不修改数据,那么它们可以共享同一份物理副本。只有当某个用户尝试修改数据时,才会创建数据的独立副本供该用户使用。
-
初始状态:
- 当创建一个新的
Buffer
对象时,它的引用计数为1,表示只有一个所有者。 - 当通过复制构造函数或赋值操作符创建新的
Buffer
对象时,引用计数会增加,因为多个对象共享同一份数据。
- 当创建一个新的
-
读取操作:
- 读取操作不会改变数据,因此不需要创建新的副本。所有共享同一份数据的对象都可以安全地读取数据。
-
写入操作:
- 当某个
Buffer
对象尝试修改数据时,首先检查引用计数。 - 如果引用计数大于1,说明有其他对象也在共享这份数据。此时,需要创建一个新的副本,以便修改操作不会影响其他共享者。
- 创建新的副本后,修改新的副本,然后更新当前对象的内部状态,使其指向新的副本。
- 当某个
//buffer 原子操作
class DBufferAtomic {
public:
DBufferAtomic(size_t size) : buffer(size) , m_nRefCount(1) {}
DBufferAtomic(DBufferAtomic& other) : buffer(other.buffer){
other.m_nRefCount.fetch_add(1);
m_nRefCount.store(other.m_nRefCount.load());
}
DBufferAtomic& operator=(DBufferAtomic& other) {
if (this!= &other) {
other.m_nRefCount.fetch_add(1);
m_nRefCount.store(other.m_nRefCount.load());
buffer = other.buffer;
}
return *this;
}
const std::vector<char>& getData() const {
return buffer;
}
void modifyData(size_t index, char value) {
if (m_nRefCount.load() > 1) {
//复制一份数据
std::vector<char> temp(buffer);
temp[index] = value;
buffer = temp;
(*this).buffer = std::move(temp);
}
else {
buffer[index] = value;
}
}
private:
DAtomic<int> m_nRefCount;
std::vector<char> buffer;
};