Linux Console Graphics with, DRM, GBM, EGL, GLES, logind, libinput, etc. in CL

I want glop2 to support running directly on Linux console without X or Wayland or whatever, and happened to want to run some code on an arm board with flaky X drivers, so spent some time trying to figure out how to get that running. This post is my notes on what I've figure out so far, for when I get around to actually implementing glop2.

Device permissions

The image I had on the arm board is Ubuntu, using systemd, so I use logind to manage devices instead of opening them directly. This simplifies some aspects of permissions, since if you are running on a particular seat, you can automatically access any devices associated with that seat. In other ways, it is more complicated, since if you connect over ssh you don't have a seat and can't access anything.

On other systems, would probably want to add the user to whatever groups have access to the /dev/dri/card*/ and /dev/input/event* devices, and open them manually.

Since I was too lazy to go find a keyboard to plug into the device to control a seat, and also because I wanted to boot directly into the program when it was done, I ended up configuring the getty@tt1 service to login automatically (and tell systemd not to start X as well).

$ sudo systemctl disable lightdm
$ sudo systemctl edit getty@tty1

then add

[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin username --noclear %I

and tell ~/.profile to automatically run a lisp image running a swank swank server when run on tty1. Also send all output to a file since we can't see console while running graphics, and can't scroll up in console output without a keyboard.

tty=`ps -p $$ -o tty=`
if [ "$tty" = tty1 ]; then
    ~/bin/foo > ~/debug-log 2>&1
fi

Then it boots I can just restart the getty to restart the image when I get things in a bad state, or want to see if my program works correctly when started directly.

$ sudo systemctl restart getty@tty1

Getting access to the seat

Once the system is set up, the next step is talking to logind to get access to the seat, activate it, etc. For that, we need to use DBUS. I used the dbus library. We'll also need a few other libraries, so load all of those as well (or add them to dependencies in an .asd file and load that).

(ql:quickload '(:alexandria :cffi :osicat :dbus :cl-drm :cl-gbm :cl-egl :cl-libinput :cl-opengl/es2))
;; Might need to manually download cl-libinput from
;;  https://github.com/malcolmstill/cl-libinput if it isn't in QL yet.

;; Parts of this might also need my forks from https://github.com/3b/
;; if I haven't gotten them cleaned up enuogh to submit upstream yet, or
;; from upstream sources if I have but they haven't hit QL yet.

;; Substitute normal :cl-opengl if desired if it is available on your
;; system.

For real use, would want to store a single dbus connection, and reuse the objects, but for interactive examples, lets define some helper macros.


(defvar *bus*)

(defmacro with-dbus (() &body body)
  `(dbus:with-open-bus (*bus* (dbus:system-server-addresses))
     ,@body))

(defmacro with-object ((object call path destination)
                                    &body forms)
  `(let ((,object (dbus:make-object-from-introspection
                   (dbus:bus-connection *bus*) ,path ,destination)))
     (flet ((,call (interface-name method-name &rest args)
              (apply #'dbus:object-invoke ,object
                     interface-name method-name args)))
       ,@forms)))

Now we can make some calls and see if things are working:

(with-dbus ()
  (with-object (o call "/org/freedesktop/login1"
                  "org.freedesktop.login1")
    (call "org.freedesktop.login1.Manager"
          "ListSessions")))

;; Here I have 2 SSH sessions open, with seat = "", and one console
;; session, "seat0"

;; session id, uid, username, seat name, session name
(("4" 1001 "3b" "" "/org/freedesktop/login1/session/_34")
 ("14" 1001 "3b" "seat0" "/org/freedesktop/login1/session/_314")
 ("3" 1001 "3b" "" "/org/freedesktop/login1/session/_33"))


(with-dbus ()
  (with-object (o call "/org/freedesktop/login1"
                  "org.freedesktop.login1")
    (call "org.freedesktop.login1.Manager"
          "ListSeats")))

;; There is one seat
(("seat0" "/org/freedesktop/login1/seat/seat0"))


;; but what we really want to know is which one we are currently using:

(with-dbus ()
  (with-introspected-object (o call "/org/freedesktop/login1"
                               "org.freedesktop.login1")
    (call "org.freedesktop.login1.Manager"
       "GetSessionByPID" (nix:getpid))))

;; ->
"/org/freedesktop/login1/session/_314"

See https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html for the full list of APIs available from logind.

Most of the rest will be using the Manager or Session interfaces, so lets wrap that up into another macro.


(defmacro with-dbus* ((login lcall lprop session scall sprop)
                      &body body)
  (alexandria:with-gensyms (session-name)
    `(dbus:with-open-bus (*bus* (dbus:system-server-addresses))
       (let ((,login (dbus:make-object-from-introspection
                      (dbus:bus-connection *bus*)
                      "/org/freedesktop/login1"
                      "org.freedesktop.login1")))
         (flet ((,lcall (interface-name method-name &rest args)
                  (apply #'dbus:object-invoke ,login
                         interface-name method-name args))
                (,lprop (property)
                  (dbus:get-property *bus*
                                     "org.freedesktop.login1"
                                     "/org/freedesktop/login1"
                                     "org.freedesktop.login1.Manager"
                                     property)))
           (declare (ignorable #',lcall #',lprop))
           (let* ((,session-name (,lcall "org.freedesktop.login1.Manager"
                                         "GetSessionByPID" (nix:getpid)))
                  (,session (dbus:make-object-from-introspection
                             (dbus:bus-connection *bus*)
                             ,session-name
                             "org.freedesktop.login1")))
             (flet ((,scall (method-name &rest args)
                      (apply #'dbus:object-invoke ,session
                             "org.freedesktop.login1.Session"
                             method-name args))
                    (,sprop (property)
                      (dbus:get-property *bus*
                                         "org.freedesktop.login1"
                                         ,session-name
                                         "org.freedesktop.login1.Session"
                                         property)))
               (declare (ignorable #',scall #',sprop))
               ,@body)))))))

Add one more helper to translate paths to the major + minor device numbers logind wants, and we are ready to start doing graphics.


(defun path-device (path)
  ;; return device major/minor for a given path
  (let ((rdev (nix:stat-rdev (nix:stat path))))
    (assert (not (zerop rdev)))
    (list (ldb (byte 8 8) rdev) (ldb (byte 8 0) rdev))))

(defmacro with-card ((fd) &body body)
  ;; not bothering with gensyms here since we might want access to the
  ;; various bindings created here, and real code would probably be
  ;; factored out differently instead of using a macro like this
  `(with-dbus* (l lc lp s sc sp)
     ;; first we need to take control of the session
     (sc "TakeControl" nil)

     ;; then open the DRI device. Currently I just hard-code
     ;; "/dev/dri/card0", not sure what the "correct" way to find it is
     ;; yet. Possibly looking for "/run/udev/tags/<seat>/+drm*" and
     ;; parsing the name? (where <seat> is "seat" for seat0, otherwise
     ;; name of seat?) Maybe something with libudev?
     (unwind-protect
          (multiple-value-bind (,fd paused)
              (apply #'sc "TakeDevice"
                     (path-device "/dev/dri/card0"))
            (when ,fd
              (unwind-protect
                   (progn
                     ;; if the session is paused, we can try activating it
                     (when paused
                       (sc "Activate"))
                     ,@body))

              ;; Releasing the session below implicitly releases
              ;; devices, but we can do it manually too
              (apply #'sc "ReleaseDevice"
                     (path-device "/dev/dri/card0"))
              ;; but apparently we still need to close the FD
              ;; ourselves (don't see it mentioned in logind docs,
              ;; aside from needing to close old fd after Resume
              ;; events, but FDs stay open without it)
              (nix:close fd)))
       ;; when we are done, we need to release the session
       (sc "ReleaseControl"))))

(with-card (fd)
  (format t "got FD ~s for DRM device~%" fd))

For real code we should also watch for DBUS events indicating devices have been Paused/Resumed, screen has been Locked/Unlocked, changes in session Active or Idle state, or if the system is shutting down or sleeping.

Adding graphics

Once we have a DRM fd open, we can use it to switch to graphics mode and set up OpenGL ES (or normal GL if you have it).

We can query some capabilities with drm:get-cap:


(cffi:defcenum (drm-caps :uint64)
  (:dumb-buffer #x1)
  (:vblank-high-crtc #x2)
  (:dumb-preferred-depth #x3)
  (:dumb-prefer-shadow #x4)
  (:prime #x5)
  (:timestamp-monotonic #x6)
  (:async-page-flip #x7)

;;;; on my machine:
;;caps:
;;  :DUMB-BUFFER = 1
;;  :VBLANK-HIGH-CRTC = 1
;;  :DUMB-PREFERRED-DEPTH = 0
;;  :DUMB-PREFER-SHADOW = 0
;;  :PRIME = 3
;;  :TIMESTAMP-MONOTONIC = 1
;;  :ASYNC-PAGE-FLIP = 0
;;  :CURSOR-WIDTH = 64
;;  :CURSOR-HEIGHT = 64
;;  :ADDFB2-MODIFIERS = 0
;;  :PAGE-FLIP-TARGET = 0
;;

In particular the :async-page-flip = 0 here means we need to use event-based page flipping rather than asynchronous.

Now we set up the various graphics libraries (drm, gbm, egl), handle page flipping, and then we can do some simple drawing.


;;; for double-buffered display wwith vsync, we need to set up some
;;; handlers for drm events

(cffi:defcallback vblank-handler :void ((fd :int)
                                        (seq :unsigned-int)
                                        (sec :unsigned-int)
                                        (usec :unsigned-int)
                                        (user-data (:pointer :void)))
  (declare (ignorable fd seq sec usec user-data))
  ;; vblank event seems not be sent by default, not sure how to enable
  ;; them
  )

(defparameter *flipping* nil)
(cffi:defcallback page-flip-handler :void ((fd :int)
                                           (seq :unsigned-int)
                                           (sec :unsigned-int)
                                           (usec :unsigned-int)
                                           (user-data (:pointer :void)))
  (declare (ignorable fd seq sec usec user-data))
  (setf *flipping* nil))

(defun make-drm-event-context ()
  (let ((p (cffi:foreign-alloc '(:struct drm:event-context))))
    (cffi:with-foreign-slots ((drm:version
                               drm:vblank-handler
                               drm:page-flip-handler)
                              p (:struct drm:event-context))
      (setf drm:version 2)
      (setf drm:vblank-handler (cffi:callback vblank-handler))
      (setf drm:page-flip-handler (cffi:callback page-flip-handler)))
    p))




(with-card (fd)
  ;; drm:find-display-configuration picks a connector and mode for us,
  ;; which is good enough for my current needs. If you want to support
  ;; multiple monitors or change modes you might need to do it
  ;; yourself.
  (let* ((display-config (drm:find-display-configuration fd))
         ;; some slots from the display-config we will need later
         (mode-info (drm:mode-info display-config))
         (crtc (drm:crtc display-config))
         ;; To do any output, we need buffers in which to store it,
         ;; and from which the card can display them.  libgbm (Generic
         ;; Buffer Management) is responsible for managing those
         ;; buffers, so we need to initialize it as well.
         (gbm (gbm:create-device fd))
         ;; get an EGL display from the GBM device
         ;; (get-platform-display is in egl 1.5, but my board only has
         ;; 1.4. Should query versions and check extensions before
         ;; doing this...)
         (display (egl:get-platform-display-ext :platform-gbm-khr
                                                gbm
                                                (cffi:null-pointer))))

    ;; initialize EGL on the display (returns major / minor version
    ;; numbers as 2 values)
    (format t "egl init ~s~%" (multiple-value-list (egl:initialize display)))
    ;; tell EGL which API we want to use, :opengl-api for desktop GL
    (egl:bind-api :opengl-es-api)
    ;; and verify we got it
    (format t "egl api ~s~%" (egl:query-api))

    ;; print out some information on available APIs, driver being
    ;; used, and available extensions
    (loop for i in '(:client-apis :vendor :version :extensions)
          do (format t "~s: ~s~%"
                     i (egl:query-string display i)))

    ;; set up a gles context
    (let* ((configs (egl::choose-config* display
                                         :buffer-size 32
                                         :depth-size :dont-care
                                         :stencil-size :dont-care
                                         :surface-type :window-bit
                                         :renderable-type :opengl-es2-bit
                                         :none))
           ;; we'll just use the first one here
           (config (car configs))
           ;; create a context, ask for GLES2
           (context (egl:create-context display
                                        config
                                        (cffi:null-pointer)
                                        :context-major-version 2
                                        :none))
           ;; get dimensions of the mode
           (width (drm:mode-width mode-info))
           (height (drm:mode-height mode-info))
           ;; create a GBM surface we can draw into
           (gbm-surface (gbm:surface-create gbm
                                            width height
                                            875713112 ;; xrgb8888
                                            5)) ;; scanout | rendering
           ;; create an egl surface from the gbm surface
           (egl-surface (egl:create-window-surface display config
                                                   gbm-surface :none))
           ;; configuration for drm event handler
           (event-context (make-drm-event-context))
           ;; page flipping code is a bit verbose, so we'll store some
           ;; state in a list to pass to it. Should all be in proper
           ;; classes or whatever for real code
           (flip-state (list nil nil ;; some state variables
                             event-context
                             fd display egl-surface gbm-surface
                             crtc width height)))
      ;; see what configs are available
      (loop for i from 0
            for c in configs
            do (format t "~%egl config ~s:~% ~s~%" i
                       (egl:get-config-attribs display c)))

      ;; print some info about the EGL surface we created
      (format t "~%~%EGL surface properties:~%")
      (loop for i in '(:width :height
                       :horizontal-resolution :vertical-resolution
                       :render-buffer :swap-behavior)
            do (format t "  ~s = ~s~%" i
                       (egl:query-surface display egl-surface i)))

      ;; make the egl context current
      (unless (zerop (egl:make-current display
                                       ;; we will read from and draw
                                       ;; to same surface
                                       egl-surface egl-surface
                                       context)))
      ;; reset the page flip flag
      (setf *flipping* nil)

      ;; now we should be able to start drawing. Since gles2 requires
      ;; VBOs and shaders, we'll just clear the screen to random
      ;; colors for a few second...
      (loop repeat 3000
            do (gl:clear-color (random 1.0) (random 1.0) (random 1.0) 1)
               (gl:clear :color-buffer)
               (swap-buffers flip-state))


      ;; when we are done, clean everything up (should probably use a
      ;; few unwind-protects here)
      (when crtc
        ;; restore the original crtc settings
        (cffi:with-foreign-object (connector-id :uint32)
          (setf (cffi:mem-ref connector-id :uint32)
                (drm::connector-id display-config))
          (cffi:with-foreign-slots ((drm:crtc-id drm:buffer-id drm:x drm:y)
                                    crtc
                                    (:struct drm:mode-crtc))
            (drm:mode-set-crtc fd
                               drm:crtc-id drm:buffer-id drm:x drm:y
                               connector-id 1
                               (cffi:foreign-slot-pointer
                                crtc '(:struct drm:mode-crtc) 'drm:mode))
            (drm:mode-free-crtc crtc))))
      ;; clean up buffers used for page flipping
      (when (car flip-state)
        (drm:mode-remove-framebuffer fd (first flip-state))
        (gbm:surface-release-buffer gbm-surface (second flip-state)))
      ;; destroy surfaces
      (egl:destroy-surface display egl-surface)
      (gbm:surface-destroy gbm-surface)
      ;; destroy context
      (egl:destroy-context display context)
      ;; close display
      (egl:terminate display)
      ;; close gbm device
      (gbm:device-destroy gbm)
      ;; free event-context
      (cffi:foreign-free event-context))))


(defun swap-buffers (flip-state)
  (destructuring-bind (old-fb old-bo
                       event-context
                       fd display egl-surface gbm-surface
                       crtc width height) flip-state
    ;; if we have scheduled a page flip, wait for the completion
    ;; event. Real code should use select/poll/epoll/etc to wait for
    ;; the FD to be readable instead of busy-waiting like this.
    (loop do (drm:handle-event fd event-context)
          while *flipping*
          do (sleep 0.001))
    ;; first swap the EGL buffers
    (egl:swap-buffers display egl-surface)
    ;; call surface-lock-front-buffer once per egl:swap-buffers to get
    ;; a gbm `bo` (buffer object) for the front buffer
    (let* ((bo (gbm:surface-lock-front-buffer gbm-surface))
           ;; get some slots from the BO we will be using later
           (handle (gbm:bo-get-handle bo))
           (stride (gbm:bo-get-stride bo)))

      (cffi:with-foreign-objects ((fb :uint32))
        ;; create a framebuffer using the bo associated with HANDLE for
        ;; storage
        (drm:mode-add-framebuffer fd width height
                                  24 32 stride handle fb)
        ;; set flag indicating we scheduled a page flip
        (setf *flipping* t)
        ;; schedule a page flip for next vblank to display the new
        ;; framebuffer, and request and event when the flip completes

        ;; if we wanted double-buffer without vsync, we could use
        ;; drm:mode-set-crtc instead to swap immediately. Probably can
        ;; also configure it to draw directly to a single framebuffer
        ;; for single-buffered display.
        (drm:mode-page-flip fd (drm:crtc-crtc-id crtc)
                            (cffi:mem-aref fb :uint32)
                            1 ;; 1 = event, 2 = async
                            (cffi:null-pointer))
        ;; after the first frame, we need to clean up the previous
        ;; frame
        (when old-bo
          (drm:mode-remove-framebuffer fd old-fb)
          (gbm:surface-release-buffer gbm-surface old-bo))

        ;; write state back into input list for next call
        (setf (first flip-state) (cffi:mem-ref fb :uint32)
              (second flip-state) bo)))))

Similar to the logind events, we should also be watching for lost contexts, etc in the graphics code.