8:从USB摄像头把声音拿出来--ALSA大佬登场!

前言

前面的章节我们从认识摄像头开始,逐渐认识的YCbCr,并对其进行了H264的编码以及MP4封装。整个过程中,我们大致使用了V4L2和FFmpeg这两个重量级工具,就像我们前面章节所讲,V4L2只是给图像做服务的,并不参与音频。

在第3章中,我们说过V4L2这位大哥只管视频,不管音频。那音频谁来管?ALSA大佬管!

那么这一章,我们重点讨论一下ALSA,并且我们列一个目标:从USB摄像头获取音频流,并且编码成AAC!

一、ALSA介绍

1、ALSA是什么
  • 全称:​ Advanced Linux Sound Architecture (高级 Linux 声音架构)
  • 本质:​ Linux 内核的音频子系统和驱动框架。它提供了从底层硬件声卡驱动到上层用户空间应用程序接口(API)的一整套解决方案。
  • 目的:​ 管理和驱动计算机的声卡硬件,允许应用程序播放和录制声音。
  • 历史:​ 在 2.6 内核中正式取代了老旧的 OSS (Open Sound System)​,成为 Linux 默认的标准声音系统。
2、ALSA 的核心组成部分和功能
  1. 内核驱动:​

    • 这部分包含在内核源代码中 (/sound 目录)。
    • 直接与物理声卡硬件(集成、独立声卡、USB 声卡等)通信,处理中断、DMA、硬件寄存器读写等底层操作。
    • 为每种支持的声卡芯片或型号提供特定的驱动程序模块,等我们有能力后,也可以为一个音频芯片或驱动模块编写驱动程序,现在还是先用起来。
  2. 用户空间库 (libasound.so - ALSA library):​

    • 这是应用程序主要交互的接口。
    • 提供了一组丰富、统一的 API (称为 ALSA APIalsa-lib API),让应用程序开发者无需关心底层硬件的细节即可播放或录制音频。
    • 库负责将应用程序的请求(如"播放这个 PCM 数据流")传递给内核驱动,并处理缓冲区、格式转换、插件等高级功能。
    • 支持多种音频格式(采样率、位深、通道数)、参数设置(缓冲区大小、周期数)。
  3. 设备文件 (/dev/snd/ 目录下):​

    • 内核驱动为用户空间暴露的接口文件。虽然应用程序通常通过 libasound 访问音频功能,但理解这些设备文件有助于调试。
    • 主要设备:
      • /dev/snd/controlC#: 控制设备 (Control device),用于混音器控制(如 alsamixer / amixer 使用)。
      • /dev/snd/pcmC#D#: PCM 播放/录制设备 (Playback/Capture device)。C# 表示声卡号 (Card),D# 表示该声卡上的设备号 (Device)。我们编程程序的时候,会用到这个。

二、ALSA初体验

为了能够在程序中使用ALSA,需要安装ALSA开发库:

bash 复制代码
sudo apt-get install libasound2-dev

下面直接给出通过ALSA获取USB摄像头的PCM音频数据的代码,并根据这份代码进行讲解:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <alsa/asoundlib.h>

#define SAMPLE_RATE     22050   // 采样率
#define CHANNELS        1       // 单声道
#define PERIOD_SIZE     512     // 周期大小
#define PERIODS        4        // 缓冲区周期数
#define RECORD_SECONDS 5        // 录音时长(秒)

int main() {
    int rc;
    snd_pcm_t *capture_handle;
    snd_pcm_hw_params_t *hw_params;
    FILE *pcm_file;
    short *buffer;
    int dir = 0;

    // 1. 打开音频设备
    rc = snd_pcm_open(&capture_handle, "plughw:1,0", SND_PCM_STREAM_CAPTURE, 0);
    if (rc < 0) {
        fprintf(stderr, "无法打开音频设备: %s\n", snd_strerror(rc));
        return 1;
    }

    // 2. 分配硬件参数结构
    snd_pcm_hw_params_alloca(&hw_params);

    // 3. 初始化硬件参数
    rc = snd_pcm_hw_params_any(capture_handle, hw_params);
    if (rc < 0) {
        fprintf(stderr, "无法初始化硬件参数: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 4. 设置访问类型(交错模式)
    rc = snd_pcm_hw_params_set_access(capture_handle, hw_params, SND_PCM_ACCESS_RW_INTERLEAVED);
    if (rc < 0) {
        fprintf(stderr, "无法设置访问类型: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 5. 设置采样格式(16位小端)
    rc = snd_pcm_hw_params_set_format(capture_handle, hw_params, SND_PCM_FORMAT_S16_LE);
    if (rc < 0) {
        fprintf(stderr, "无法设置采样格式: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 6. 设置采样率
    unsigned int sample_rate = SAMPLE_RATE;
    rc = snd_pcm_hw_params_set_rate_near(capture_handle, hw_params, &sample_rate, &dir);
    if (rc < 0) {
        fprintf(stderr, "无法设置采样率: %s\n", snd_strerror(rc));
        goto cleanup;
    }
    printf("实际采样率: %u Hz\n", sample_rate);

    // 7. 设置声道数(单声道)
    rc = snd_pcm_hw_params_set_channels(capture_handle, hw_params, CHANNELS);
    if (rc < 0) {
        fprintf(stderr, "无法设置声道数: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 8. 设置周期大小
    snd_pcm_uframes_t period_size = PERIOD_SIZE;
    rc = snd_pcm_hw_params_set_period_size_near(capture_handle, hw_params, &period_size, &dir);
    if (rc < 0) {
        fprintf(stderr, "无法设置周期大小: %s\n", snd_strerror(rc));
        goto cleanup;
    }
    printf("实际周期大小: %lu 帧\n", period_size);

    // 9. 设置周期数(缓冲区大小 = 周期大小 * 周期数)
    unsigned int periods = PERIODS;
    rc = snd_pcm_hw_params_set_periods_near(capture_handle, hw_params, &periods, &dir);
    if (rc < 0) {
        fprintf(stderr, "无法设置周期数: %s\n", snd_strerror(rc));
        goto cleanup;
    }
    printf("实际周期数: %u\n", periods);

    // 10. 应用硬件参数
    rc = snd_pcm_hw_params(capture_handle, hw_params);
    if (rc < 0) {
        fprintf(stderr, "无法设置参数: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 11. 准备音频缓冲区
    buffer = malloc(period_size * sizeof(short));
    if (!buffer) {
        fprintf(stderr, "无法分配缓冲区\n");
        goto cleanup;
    }

    // 12. 打开输出文件
    pcm_file = fopen("test.pcm", "wb");
    if (!pcm_file) {
        fprintf(stderr, "无法创建输出文件\n");
        goto cleanup;
    }

    printf("开始录音...\n");

    // 13. 录音循环
    int frames = 0;
    const int total_frames = (sample_rate * RECORD_SECONDS) / period_size;

    while (frames < total_frames) {
        rc = snd_pcm_readi(capture_handle, buffer, period_size);
        if (rc == -EPIPE) {
            fprintf(stderr, "缓冲区溢出,正在恢复\n");
            snd_pcm_prepare(capture_handle);
            continue;
        } else if (rc < 0) {
            fprintf(stderr, "读取错误: %s\n", snd_strerror(rc));
            break;
        } else if (rc != period_size) {
            fprintf(stderr, "短帧读取,期望 %lu,实际 %d\n", period_size, rc);
        }

        // 写入PCM数据到文件
        fwrite(buffer, sizeof(short), rc, pcm_file);

        frames++;
        printf("\r已录制 %.1f 秒... ", (float)frames * period_size / sample_rate);
        fflush(stdout);
    }

    printf("\n录音完成!保存为 test.pcm\n");

cleanup:
    if (capture_handle) {
        snd_pcm_close(capture_handle);
    }
    if (buffer) {
        free(buffer);
    }
    if (pcm_file) {
        fclose(pcm_file);
    }

    return 0;
}

代码整体上还是比较简单的,逻辑也很清晰,只对新出现的部分做一些补充:

1、snd_pcm_xxx是ALSA库(alsa-lib)接口,编译的时候,需要链接 -lsound

2、snd_pcm_open的参数中,有一个"plughw:1,0",这里的plug指的是插件,比如应用程序想要获取44100采样率的数据,但是硬件只支持22050,那么plug就可以自动将音频数据从22050转成44100给到应用程序,主要是考虑到兼容性问题。但是在嵌入式中,音频硬件和驱动是固定的,不考虑兼容性,所以在打开音频设备时,使用"hw:1,0"即可。毕竟兼容性是要牺牲算力资源和内存资源的。

3、"hw:1,0"的命名规则如下:

card如果是0,代表是系统默认声卡。如果是1,一般是外接声卡,比如USB声卡。

设备号是从0开始的,我们的USB摄像头设备号只有一个,其他的不清楚。

所以"hw:1,0"表示的是:硬件访问方式,外置声卡,且声卡的设备号为0。

在Ubuntu中,在插入USB摄像头之前,/dev/snd里面的设备如下:

bash 复制代码
by-path  controlC0  midiC0D0  pcmC0D0c  pcmC0D0p  pcmC0D1p  seq  timer

插入USB摄像头后,/dev/snd里面的设备如下:

bash 复制代码
by-id  by-path  controlC0  controlC1  midiC0D0  pcmC0D0c  pcmC0D0p  pcmC0D1p  pcmC1D0c  seq  timer

可以看到多出了"controlC1"和"pcmC1D0c",后者就是声卡1,设备0。

4、周期是什么?

在代码中,音频的采样率(每秒钟采样多少个点)和单声道都好理解,但是PERIODS/PERIODS_SIZE是什么?

当ALSA从USB摄像头获取到音频数据后,会循环放在P个buffer中,这个P就是就是周期数,也就是PERIODS。每个音频帧的大小都是固定的,也就是PERRIODS_SIZE。其中音频帧又是交错格式(Interleaved)存储的。如果是立体声(左右双声道):L R L R... 我手里的摄像头是单声道的,就算是交错模式,存储方式也是单声道的:L L L L ...

在snd_pcm_readi读取音频数据时,有一个错误EPIPE处理。

播放音频的时候,EPIPE代表的是欠载,表示应用程序填充数据太慢,硬件已经消耗完所有的周期,需要加快数据填充用于播放。

录音的时候,EPIPE代表的是超限,意思是应用程序snd_pcm_readi读取的不及时,导致buffer中的数据溢出了,需要及时读取。

5、编译并运行

bash 复制代码
gcc uvc_voice_streaming.c -o uvc_voice_streaming -lasound -lavcodec -lavutil

运行后,就可以生成test.pcm,因为这个是pcm数据,没有头部结构,一般的播放器无法进行播放。笔者使用的是GoldWave,在打开的时候,参数选项要正确,否则无法正确播放录音。

三、使用FFmpeg进行AAC编码

FFmpeg在前面几章介绍过,虽然一个是视频,一个是音频,但是处理方式都差不多,这里就不再重复。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <alsa/asoundlib.h>
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <time.h>

#define SAMPLE_RATE     22050   // 采样率
#define CHANNELS        1       // 单声道
#define PERIOD_SIZE     512     // 周期大小
#define PERIODS        4        // 缓冲区周期数
#define RECORD_SECONDS 5        // 录音时长(秒)

// ADTS头部长度为7个字节
static uint8_t *adts_header = NULL;

// 生成ADTS头
static void add_adts_header(uint8_t *header, int packet_size, int sample_rate_index, int channels) {
    // Sync Point
    header[0] = 0xFF;
    header[1] = 0xF1;

    // Profile(2), Sampling Freq(4), Private(1), Channel Config(1)
    header[2] = ((2 - 1) << 6)  // AAC-LC = 2
              | (sample_rate_index << 2)
              | ((channels & 4) >> 2);

    // Channel Config(2), Original(1), Home(1), Copyright ID(1), Copyright Start(1), Frame Length(2)
    header[3] = ((channels & 3) << 6)
              | ((packet_size + 7) >> 11);

    // Frame Length(8)
    header[4] = ((packet_size + 7) >> 3) & 0xFF;

    // Frame Length(3), Buffer Fullness(5)
    header[5] = (((packet_size + 7) & 0x07) << 5)
              | 0x1F;

    // Buffer Fullness(6), Raw Data Blocks(2)
    header[6] = 0xFC;
}

// 获取采样率索引
static int get_sample_rate_index(int sample_rate) {
    int sample_rates[] = {96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000};
    for (int i = 0; i < 12; i++) {
        if (sample_rate == sample_rates[i]) {
            return i;
        }
    }
    return 7; // 默认使用22050Hz的索引
}

int main() {
    int rc;
    snd_pcm_t *capture_handle;
    snd_pcm_hw_params_t *hw_params;
    FILE *aac_file;
    short *buffer;
    int dir = 0;

    // FFmpeg变量
    AVCodec *codec = NULL;
    AVCodecContext *codec_ctx = NULL;
    AVFrame *frame = NULL;
    AVPacket *pkt = NULL;

    // 1. 打开音频设备
    rc = snd_pcm_open(&capture_handle, "plughw:1,0", SND_PCM_STREAM_CAPTURE, 0);
    if (rc < 0) {
        fprintf(stderr, "无法打开音频设备: %s\n", snd_strerror(rc));
        return 1;
    }

    // 2. 分配硬件参数结构
    snd_pcm_hw_params_alloca(&hw_params);

    // 3. 初始化硬件参数
    rc = snd_pcm_hw_params_any(capture_handle, hw_params);
    if (rc < 0) {
        fprintf(stderr, "无法初始化硬件参数: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 4. 设置访问类型(交错模式)
    rc = snd_pcm_hw_params_set_access(capture_handle, hw_params, SND_PCM_ACCESS_RW_INTERLEAVED);
    if (rc < 0) {
        fprintf(stderr, "无法设置访问类型: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 5. 设置采样格式(16位小端)
    rc = snd_pcm_hw_params_set_format(capture_handle, hw_params, SND_PCM_FORMAT_S16_LE);
    if (rc < 0) {
        fprintf(stderr, "无法设置采样格式: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 6. 设置采样率
    unsigned int sample_rate = SAMPLE_RATE;
    rc = snd_pcm_hw_params_set_rate_near(capture_handle, hw_params, &sample_rate, &dir);
    if (rc < 0) {
        fprintf(stderr, "无法设置采样率: %s\n", snd_strerror(rc));
        goto cleanup;
    }
    printf("实际采样率: %u Hz\n", sample_rate);

    // 7. 设置声道数(单声道)
    rc = snd_pcm_hw_params_set_channels(capture_handle, hw_params, CHANNELS);
    if (rc < 0) {
        fprintf(stderr, "无法设置声道数: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 8. 设置周期大小
    snd_pcm_uframes_t period_size = PERIOD_SIZE;
    rc = snd_pcm_hw_params_set_period_size_near(capture_handle, hw_params, &period_size, &dir);
    if (rc < 0) {
        fprintf(stderr, "无法设置周期大小: %s\n", snd_strerror(rc));
        goto cleanup;
    }
    printf("实际周期大小: %lu 帧\n", period_size);

    // 9. 设置周期数(缓冲区大小 = 周期大小 * 周期数)
    unsigned int periods = PERIODS;
    rc = snd_pcm_hw_params_set_periods_near(capture_handle, hw_params, &periods, &dir);
    if (rc < 0) {
        fprintf(stderr, "无法设置周期数: %s\n", snd_strerror(rc));
        goto cleanup;
    }
    printf("实际周期数: %u\n", periods);

    // 10. 应用硬件参数
    rc = snd_pcm_hw_params(capture_handle, hw_params);
    if (rc < 0) {
        fprintf(stderr, "无法设置参数: %s\n", snd_strerror(rc));
        goto cleanup;
    }

    // 初始化FFmpeg编码器
    codec = avcodec_find_encoder(AV_CODEC_ID_AAC);
    if (!codec) {
        fprintf(stderr, "找不到AAC编码器\n");
        goto cleanup;
    }

    codec_ctx = avcodec_alloc_context3(codec);
    if (!codec_ctx) {
        fprintf(stderr, "无法分配编码器上下文\n");
        goto cleanup;
    }

    // 设置AAC编码器参数
    codec_ctx->bit_rate = 64000;  // 64 kbps
    codec_ctx->sample_fmt = AV_SAMPLE_FMT_FLTP;  // AAC需要浮点平面格式
    codec_ctx->sample_rate = SAMPLE_RATE;
    codec_ctx->channel_layout = AV_CH_LAYOUT_MONO;  // 单声道
    codec_ctx->channels = CHANNELS;
    codec_ctx->profile = FF_PROFILE_AAC_LOW;  // AAC-LC

    rc = avcodec_open2(codec_ctx, codec, NULL);
    if (rc < 0) {
        char errbuf[AV_ERROR_MAX_STRING_SIZE];
        av_strerror(rc, errbuf, AV_ERROR_MAX_STRING_SIZE);
        fprintf(stderr, "无法打开编码器: %s\n", errbuf);
        goto cleanup;
    }

    // 分配音频帧
    frame = av_frame_alloc();
    if (!frame) {
        fprintf(stderr, "无法分配音频帧\n");
        goto cleanup;
    }

    frame->nb_samples = codec_ctx->frame_size;
    frame->format = codec_ctx->sample_fmt;
    frame->channel_layout = codec_ctx->channel_layout;
    frame->sample_rate = codec_ctx->sample_rate;

    rc = av_frame_get_buffer(frame, 0);
    if (rc < 0) {
        fprintf(stderr, "无法分配帧缓冲区\n");
        goto cleanup;
    }

    // 分配数据包
    pkt = av_packet_alloc();
    if (!pkt) {
        fprintf(stderr, "无法分配数据包\n");
        goto cleanup;
    }

    // 分配ADTS头部缓冲区
    adts_header = (uint8_t *)malloc(7);
    if (!adts_header) {
        fprintf(stderr, "无法分配ADTS头部缓冲区\n");
        goto cleanup;
    }

    // 准备音频缓冲区
    buffer = malloc(period_size * sizeof(short));
    if (!buffer) {
        fprintf(stderr, "无法分配缓冲区\n");
        goto cleanup;
    }

    // 打开输出文件
    aac_file = fopen("test.aac", "wb");
    if (!aac_file) {
        fprintf(stderr, "无法创建输出文件\n");
        goto cleanup;
    }

    printf("开始录音...\n");

    // 录音循环
    int frames = 0;
    const int total_frames = (SAMPLE_RATE * RECORD_SECONDS) / period_size;
    float *samples = (float *)frame->data[0];
    int samples_index = 0;

    while (frames < total_frames) {
        rc = snd_pcm_readi(capture_handle, buffer, period_size);
        if (rc == -EPIPE) {
            fprintf(stderr, "缓冲区溢出,正在恢复\n");
            snd_pcm_prepare(capture_handle);
            continue;
        } else if (rc < 0) {
            fprintf(stderr, "读取错误: %s\n", snd_strerror(rc));
            break;
        }

        // 将PCM数据转换为浮点格式并填充到frame
        for (int i = 0; i < rc; i++) {
            samples[samples_index++] = buffer[i] / 32768.0f;
            if (samples_index >= frame->nb_samples) {
                // 帧满了,进行编码
                rc = avcodec_send_frame(codec_ctx, frame);
                if (rc < 0) {
                    fprintf(stderr, "发送帧失败\n");
                    goto cleanup;
                }

                while (rc >= 0) {
                    rc = avcodec_receive_packet(codec_ctx, pkt);
                    if (rc == AVERROR(EAGAIN) || rc == AVERROR_EOF) {
                        break;
                    } else if (rc < 0) {
                        fprintf(stderr, "接收包失败\n");
                        goto cleanup;
                    }

                    // 添加ADTS头
                    add_adts_header(adts_header, pkt->size, 
                                  get_sample_rate_index(SAMPLE_RATE), CHANNELS);

                    // 写入ADTS头和AAC数据
                    fwrite(adts_header, 1, 7, aac_file);
                    fwrite(pkt->data, 1, pkt->size, aac_file);

                    av_packet_unref(pkt);
                }
                samples_index = 0;
            }
        }

        frames++;
        printf("\r已录制 %.1f 秒... ", (float)frames * period_size / SAMPLE_RATE);
        fflush(stdout);
    }

    // 刷新编码器
    avcodec_send_frame(codec_ctx, NULL);
    while (1) {
        rc = avcodec_receive_packet(codec_ctx, pkt);
        if (rc == AVERROR_EOF) {
            break;
        } else if (rc < 0) {
            fprintf(stderr, "刷新编码器失败\n");
            break;
        }

        // 添加ADTS头并写入最后的数据
        add_adts_header(adts_header, pkt->size, 
                       get_sample_rate_index(SAMPLE_RATE), CHANNELS);
        fwrite(adts_header, 1, 7, aac_file);
        fwrite(pkt->data, 1, pkt->size, aac_file);

        av_packet_unref(pkt);
    }

    printf("\n录音完成!保存为 test.aac\n");

cleanup:
    if (capture_handle) {
        snd_pcm_close(capture_handle);
    }
    if (buffer) {
        free(buffer);
    }
    if (aac_file) {
        fclose(aac_file);
    }
    if (codec_ctx) {
        avcodec_free_context(&codec_ctx);
    }
    if (frame) {
        av_frame_free(&frame);
    }
    if (pkt) {
        av_packet_free(&pkt);
    }
    if (adts_header) {
        free(adts_header);
    }

    return 0;
}

代码的逻辑关系如下:

该代码是在上一节代码基础上修改的。snd_pcm_readi之后,使用FFmpeg处理。这里只讲新的知识点。

1、重采样:

USB摄像头传过来的音频数据是交错模式(interleaved),即立体声的时候排列方式是:L R L R...

但是FFmpeg要求的是平面格式(Plannar),即每个声道单独存放:LLLL...RRR...

因为我们只有单通道,所以存储格式不需要更改,后面我们见到重采样就知道怎么回事的。代码里面只是对音频数据进行了浮点重采样,因为FFmpeg是要求浮点的。

2、AAC

AAC(Advanced Audio Coding)是现代音频压缩技术的巅峰之作,代表了心理声学模型应用的最高水平。作为MPEG-4标准的核心音频技术,AAC在效率、质量和灵活性方面都超越了前代MP3标准。笔者并没有对AAC做过多研究,有兴趣的道友可以稍微深入一下。

AAC常见容器格式

格式 特点 使用场景
​.aac 原始ADTS流 简单存储
​.m4a​ MP4容器 iTunes标准
.mp4​ 视频容器 视频伴音
.3gp​ 移动设备 手机录制
​.ts​ 传输流 数字电视

3、ADTS头结构

复制代码
// ADTS头示例
uint8_t adts[7] = {
    0xFF, // Sync byte 1
    0xF1, // Sync byte 2 + 保护位
    0x50, // 配置信息 (AAC-LC, 44.1kHz)
    0x80, // 声道配置 + 帧长度高位
    0x1F, // 帧长度中位
    0xFC, // 帧长度低位 + 缓冲区
    0x70  // 帧计数器
};

有了ADTS头,现代一般的播放器就能识别。

4、编译和运行

复制代码
gcc -o uvc_voice_streaming uvc_voice_streaming_aac+adts.c -lasound -lavcodec -lavutil -lm

运行后,可以得到test.aac文件,使用VLC或者其他播放器一般是可以播放的。

四、总结

本章节主要讨论了ALSA框架下的音频获取的过程,可以看到应用程序还是比较简单的,不需要对底层有过多的关注。

并使用FFmpeg对音频数据进行了AAC编码,有了之前的章节,我们对FFmpeg使用基本上已经得心应手了。

下一章将面临关键的挑战:如何精确同步来自V4L2的视频帧和来自ALSA的音频包的时间戳,并使用FFmpeg将它们无缝地封装进MP4文件,实现真正的音视频录制。

再之后我们就要进入真正运动相机硬件方面的探讨了。

相关推荐
勤匠17 分钟前
spring shell 基础使用
java·linux·spring
nightunderblackcat1 小时前
进阶向:Python图像处理,使用PIL库实现圆形裁剪
开发语言·图像处理·python
珹洺1 小时前
Linux操作系统从入门到实战(七)详细讲解编辑器Vim
linux·编辑器·vim
步、步、为营2 小时前
.NET + WPF框架开发聊天、网盘、信息发布、视频播放功能
.net·wpf·音视频
赵健zj2 小时前
鸿蒙Next开发,配置Navigation的Route
android·linux·ubuntu
Ruimin05192 小时前
LSV负载均衡
linux·运维·服务器·负载均衡·lvs
好奇的菜鸟2 小时前
Linux 系统下的 Sangfor VDI 客户端安装与登录完全攻略 (CentOS、Ubuntu、麒麟全线通用)
linux·ubuntu·centos
AuroraDPY2 小时前
Linux 环境变量
linux·运维·服务器
Ronin3053 小时前
【Linux系统】进程切换 | 进程调度——O(1)调度队列
linux·运维·服务器·ubuntu