How to write a display driver

So you've decided to bring up a new board. Before you dive into coding, ensure that you have everything you need by answering these questions:

  • How will I understand how the device and its registers work?

    • This is usually called a "theory of operation". Manufacturers often provide datasheets with register definitions, but those references may not explain how the device is used in practice.
  • Is there an existing driver for a similar board?

    • Where practical, reuse code for similar boards by refactoring that code and amending the bind rules for the driver.
  • Does the device have a fixed display?

    • Some display controllers and panels (output screens) are tightly coupled. If this is the case for a new board, you'll need to add support for the GPIO, I2C, and other controls as part of the display driver.

Prerequisites

This guide assumes that you are familiar with driver development for one or more operating systems. It also assumes that you are familiar with the Fuchsia DDK-TL.

Programming Languages

New drivers must be written in C++. Rust support is planned, but is still highly experimental.

If an appropriately licensed driver already exists and is written in C, it may be acceptable to port it to Fuchsia rather than implementing a new version in C++. Please contact graphics-dev@fuchsia.dev before making this decision.

Getting Started

For platforms without ACPI or a PCI bus, Modifying board drivers is the first step. This guide assumes that the board driver is ready and that the display driver is codenamed fancy. All code for the new driver will live in src/graphics/display/drivers/fancy-display/.

To begin, create:

  • A minimal implementation of DisplayEngine
  • A set of bind rules
  • A build recipe for the DisplayEngine and the bind rules

Add the driver to the build

  1. Create the build recipe in a file named BUILD.gn
# Copyright 2021 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import("//build/bind/bind.gni")
import("//build/drivers.gni")

driver_bind_rules("fancy-display-bind") {
  rules = "meta/fancy-display.bind"
  bind_output = "fancy-display.bindbc"
  tests = "meta/bind_tests.json"
  deps = [
    "//src/devices/bind/board_maker_company.platform",
  ]
}

# Factored out so that it can be used in tests.
source_set("common") {
  public_deps = [
    ":fancy-display-bind",
  ]
  sources = [
    "fancy-display.cc",
  ]
}

fuchsia_cc_driver("fancy-display") {
  sources = []
  deps = [
    ":common",
    "//src/devices/lib/driver",
  ]
}
  1. Add //src/graphics/display/drivers/fancy-display as a dependency for the board(s) that you are using as test products. For example, if your device is part of a Khadas VIM3 board, modify //boards/vim3.gni by adding your driver to the _common_bootfs_deps list.

Choose devices to drive

Now that you have a build recipe, you can move on to creating the bind rules, which the driver manager uses to decide whether a driver can be used with a device.

  1. In src/graphics/display/drivers/fancy-display, create meta/fancy-display.bind:
// Copyright 2021 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

using fuchsia.pci;

fuchsia.BIND_PROTOCOL == fuchsia.pci.BIND_PROTOCOL.DEVICE;
fuchsia.BIND_PCI_VID == fuchsia.pci.BIND_PCI_VID.PLANK_HW_INC;
accept fuchsia.BIND_PCI_DID {
  // Fancy
  0x0100,
  // Fancy+ series
  0x0120,
  0x0121,
}

For PC devices, the intel-display bind rules are a good example. For fixed-hardware SoCs, see the Amlogic display rules.

Minimal driver

Finally, add a bare bones driver that simply constructs a new object every time it successfully binds to a device. Later, you can use the datasheet to get the device to actually do something.

In src/graphics/display/drivers/fancy-display, create fancy-display.cc:

#include <ddktl/device.h>
#include <fuchsia/hardware/display/controller/cpp/banjo.h>

namespace fancy_display {

class Device;
using DeviceType = ddk::Device<Device>

// A Device exposes a single display controller for use by the display
// coordinator driver in src/graphics/display/drivers/coordinator.
//
// This object is constructed once for each device that matches this
// driver's bind rules.
class Device : public DeviceType {
 public:
  explicit Device(zx_device_t* parent) : DeviceType(parent) {}

  // If Bind() returns an error, the driver won't claim the device.
  zx_status_t Bind() { return ZX_OK };

  // Functionality needed by the common display driver core.
  void DisplayEngineRegisterDisplayEngineListener(
      const display_engine_listener_protocol* interface) {}

  zx_status_t DisplayEngineImportBufferCollection(
      uint64_t collection_id, zx::channel collection_token) {
    return ZX_ERR_NOT_SUPPORTED;
  }

  zx_status_t DisplayEngineReleaseBufferCollection(
      uint64_t collection_id) {
    return ZX_ERR_NOT_SUPPORTED;
  }

  zx_status_t DisplayEngineImportImage(const image_metadata_t* image_metadata,
                                               uint64_t collection_id, uint32_t index,
                                               uint64_t* out_image_handle) {
    return ZX_ERR_NOT_SUPPORTED;
  }

  void DisplayEngineReleaseImage(image_t* image) {}

  config_check_result_t DisplayEngineCheckConfiguration(
      const display_config_t** display_configs, size_t display_count,
      client_composition_opcode_t* out_client_composition_opcodes_list, size_t client_composition_opcodes_count,
      size_t* out_client_composition_opcodes_actual);

  void DisplayEngineApplyConfiguration(
      const display_config_t** display_config, size_t display_count) {}

  zx_status_t DisplayEngineSetBufferCollectionConstraints(
      const image_buffer_usage_t* usage, uint64_t collection_id) {
    return ZX_ERR_NOT_SUPPORTED;
  }

};

}  // namespace fancy_display

// Main bind function called from dev manager.
zx_status_t fancy_display_bind(void* ctx, zx_device_t* parent) {
    fbl::AllocChecker alloc_checker;
    auto dev = fbl::make_unique_checked<fancy_display::Device>(
        &alloc_checker, parent);
    if (!alloc_checker.check()) {
        return ZX_ERR_NO_MEMORY;
    }
    auto status = dev->Bind();
    if (status == ZX_OK) {
      // The driver/device manager now owns this memory.
      [[maybe_unused]] auto ptr = dev.release();
    }
    return status;
}

// zx_driver_ops_t is the ABI between driver modules and the device manager.
// This lambda is used so that drivers can be rebuilt without compiler
// warnings if/when new fields are added to the struct.
static zx_driver_ops_t fancy_display_ops = [](){
    zx_driver_ops_t ops;
    ops.version = DRIVER_OPS_VERSION;
    ops.bind = fancy_display_bind;
    return ops;
}();

// ZIRCON_DRIVER marks the compiled driver as compatible with the zircon
// 0.1 driver ABI.
ZIRCON_DRIVER(fancy_display, fancy_display_ops, "zircon", "0.1");

Display drivers are required to implement the DisplayEngine protocol, which exposes hardware layers and implements vsync notifications. A display-coordinator driver multiplexes between all the device-specific drivers on the system and the display driver stack clients, which are the system compositor and Virtcon.

Implementation tips

The driver decides when and how a configuration passed to ApplyConfiguration takes effect. In order to avoid tearing, drivers should apply new settings just after vsync.

Most devices generate interrupts for vsync events. The easiest way to ensure timely vsync notifications is to spawn a separate thread just for servicing that interrupt. Even if no images are displayed, your driver must call OnDisplayVsync for every vsync.

Controllers with bootloader support

If the display is active on boot, e.g. a panel is turned on and an image is displayed, then you can get basic functionality in your driver quickly. Read bootloader logs and/or source to find:

  • The physical address of the framebuffer
  • The registers used to program that address
  • The pixel dimensions of the image, e.g. 800x600
  • The pixel format of the image, e.g. RGB888, NV12, or BGRA8888

Then:

  1. Modify the driver to report a display with the format constraints.
  2. Record the physical address of any imported image in image->handle.
  3. When ApplyConfig is called, re-program the registers.

If you do not yet know how to observe vsyncs, you can fake it with a thread that calls OnDisplayVsync at 60Hz.

Controllers that boot "dark"

There is no one right way to bring up a display controller that lacks even a basic bootloader driver. In most cases, your roadmap will be:

  1. Power up the device.
  2. Initialize clocks.
  3. Discover attached displays.
  4. Program PHYs for a compatible mode.
  5. Program layouts (framebuffer addrs, etc.) on vsync to avoid tearing.
  6. Integrate with Sysmem.